From 872fd7d5ffbf931aa86d10ea6d220d7a0c8087cd Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 2 Sep 2026 20:39:51 +0800 Subject: [PATCH] webapi: resource-timing Adds resource timing, e.g. `performance.getEntriesByType("resource")`. The `resource-timing` WPT category is currently at 4.6%, and this is a first step at improving it. It also hopefully fixes https://github.com/lightpanda-io/browser/issues/3359 This is more complicated than I thought because there's a "Timing-Allow-Origin" header that a server can include which hides some of the data if the request doesn't come from the listed origin. And that, of course, interacts with redirects. (The DOMException change is seemingly random, but it came up in one of the WPT cases I was looking at). --- src/browser/Frame.zig | 26 +- src/browser/tests/domexception.html | 14 + .../performance_observer.html | 2 +- .../tests/performance_resource_timing.html | 270 ++++++++ .../performance_resource_timing_buffer.html | 38 ++ src/browser/webapi/DOMException.zig | 11 +- src/browser/webapi/Performance.zig | 600 +++++++++++++++++- src/browser/webapi/PerformanceObserver.zig | 17 +- .../webapi/SharedWorkerGlobalScope.zig | 2 +- src/browser/webapi/Worker.zig | 2 +- src/browser/webapi/WorkerGlobalScope.zig | 5 +- src/network/HttpClient.zig | 254 +++++++- src/network/http.zig | 42 ++ src/server/cdp/domains/fetch.zig | 2 +- src/server/cdp/domains/network.zig | 2 +- src/sys/libcurl.zig | 20 + src/testing.zig | 27 + 17 files changed, 1281 insertions(+), 53 deletions(-) create mode 100644 src/browser/tests/performance_resource_timing.html create mode 100644 src/browser/tests/performance_resource_timing_buffer.html diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index fa026d3e3..47396a5a5 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -410,17 +410,6 @@ pub fn init(self: *Frame, frame_id: u32, page: *Page, opts: InitOpts) !void { ._http_owner = undefined, }; self._queued_events = &self._queued_events_1; - self._http_owner = .{ - .blob_urls = &page.blob_urls, - .origin = &self.origin, - .url = &self.url, - .parent = if (parent) |p| &p._http_owner else null, - .frame_id = frame_id, - .document_frame_id = frame_id, - .loader_id = self._loader_id, - .cookie_jar = &session.cookie_jar, - .notification = session.notification, - }; var screen: *Screen = undefined; var visual_viewport: *VisualViewport = undefined; @@ -442,7 +431,7 @@ pub fn init(self: *Frame, frame_id: u32, page: *Page, opts: InitOpts) !void { ._proto = undefined, ._document = self.document, ._location = undefined, - ._performance = .init(), + ._performance = .init(factory, arena), ._screen = screen, ._visual_viewport = visual_viewport, ._cross_origin_wrapper = undefined, @@ -458,6 +447,19 @@ pub fn init(self: *Frame, frame_id: u32, page: *Page, opts: InitOpts) !void { } self.window._cross_origin_wrapper = .{ .window = self.window }; + self._http_owner = .{ + .blob_urls = &page.blob_urls, + .origin = &self.origin, + .url = &self.url, + .parent = if (parent) |p| &p._http_owner else null, + .frame_id = frame_id, + .document_frame_id = frame_id, + .loader_id = self._loader_id, + .cookie_jar = &session.cookie_jar, + .notification = session.notification, + .performance = &self.window._performance, + }; + self._style_manager = try StyleManager.init(self); errdefer self._style_manager.deinit(); diff --git a/src/browser/tests/domexception.html b/src/browser/tests/domexception.html index 05bdc837a..8068d6737 100644 --- a/src/browser/tests/domexception.html +++ b/src/browser/tests/domexception.html @@ -121,6 +121,20 @@ OK + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/performance_resource_timing_buffer.html b/src/browser/tests/performance_resource_timing_buffer.html new file mode 100644 index 000000000..0a9d24f83 --- /dev/null +++ b/src/browser/tests/performance_resource_timing_buffer.html @@ -0,0 +1,38 @@ + + + + + diff --git a/src/browser/webapi/DOMException.zig b/src/browser/webapi/DOMException.zig index 8dd7850dd..9eea8c224 100644 --- a/src/browser/webapi/DOMException.zig +++ b/src/browser/webapi/DOMException.zig @@ -34,7 +34,6 @@ pub fn init(message: ?[]const u8, name: ?[]const u8) DOMException { ._custom_message = message, }; } - pub fn fromError(err: anyerror) ?DOMException { return switch (err) { error.SyntaxError => .{ ._code = .syntax_error }, @@ -249,7 +248,15 @@ pub const JsApi = struct { pub var class_id: bridge.ClassId = undefined; }; - pub const constructor = bridge.constructor(DOMException.init, .{}); + pub const constructor = bridge.constructor(struct { + fn constructor(m_: ?[]const u8, n_: ?[]const u8, exec: *const js.Execution) !DOMException { + return init( + if (m_) |m| try exec.dupeString(m) else null, + if (n_) |n| try exec.dupeString(n) else null, + ); + } + }.constructor, .{}); + pub const code = bridge.accessor(DOMException.getCode, null, .{}); pub const name = bridge.accessor(DOMException.getName, null, .{}); pub const message = bridge.accessor(DOMException.getMessage, null, .{}); diff --git a/src/browser/webapi/Performance.zig b/src/browser/webapi/Performance.zig index 946f8f5b2..692d69438 100644 --- a/src/browser/webapi/Performance.zig +++ b/src/browser/webapi/Performance.zig @@ -20,6 +20,7 @@ const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../js/js.zig"); +const Factory = @import("../Factory.zig"); const EventCounts = @import("EventCounts.zig"); const PerformanceObserver = @import("PerformanceObserver.zig"); @@ -27,17 +28,32 @@ const PerformanceObserver = @import("PerformanceObserver.zig"); const Execution = js.Execution; const Allocator = std.mem.Allocator; +// https://w3c.github.io/resource-timing/#dfn-resource-timing-buffer-size-limit +const DEFAULT_RESOURCE_BUFFER_SIZE = 250; + pub fn registerTypes() []const type { - return &.{ Performance, Entry, Mark, Measure, PerformanceTiming, PerformanceNavigation }; + return &.{ Performance, Entry, Mark, Measure, ResourceTiming, PerformanceTiming, PerformanceNavigation }; } const Performance = @This(); _time_origin: u64, +_arena: Allocator, +_factory: *Factory, +// Marks and measures. Kept in startTime order (see insertOrdered), as the +// getters must return them. _entries: std.ArrayList(*Entry) = .empty, +// resources has its own cap, so it's split from entries (it's also potentially +// polled more) +_resources: std.ArrayList(*Entry) = .empty, _timing: PerformanceTiming = .{}, _navigation: PerformanceNavigation = .{}, _event_counts: EventCounts = .{}, +_resource_buffer_size: u32 = DEFAULT_RESOURCE_BUFFER_SIZE, + +// Captured when the first observer registers. Entries created outside of +// JS (resource timing, from the HTTP client) need it to notify observers. +_exec: ?*const Execution = null, // PerformanceObserver infrastructure. Lives here (rather than on the owning // Frame/WorkerGlobalScope) so that both contexts get observers for free. @@ -54,8 +70,10 @@ pub fn highResTimestamp() u64 { return rounded; } -pub fn init() Performance { +pub fn init(factory: *Factory, arena: Allocator) Performance { return .{ + ._arena = arena, + ._factory = factory, ._time_origin = highResTimestamp(), }; } @@ -93,7 +111,7 @@ pub fn mark( const opts = _options orelse Mark.Options{}; const start_time = opts.startTime orelse self.now(); const m = try Mark.init(name, opts.detail, start_time, exec); - try self._entries.append(exec.arena, m._proto); + try self.insertOrdered(&self._entries, m._proto); try self.notifyObservers(m._proto, exec); return m; } @@ -144,7 +162,7 @@ pub fn measure( options.duration, exec, ); - try self._entries.append(exec.arena, m._proto); + try self.insertOrdered(&self._entries, m._proto); try self.notifyObservers(m._proto, exec); return m; }, @@ -168,14 +186,14 @@ pub fn measure( null, exec, ); - try self._entries.append(exec.arena, m._proto); + try self.insertOrdered(&self._entries, m._proto); try self.notifyObservers(m._proto, exec); return m; }, }; const m = try Measure.init(name, null, 0.0, self.now(), null, exec); - try self._entries.append(exec.arena, m._proto); + try self.insertOrdered(&self._entries, m._proto); try self.notifyObservers(m._proto, exec); return m; } @@ -205,27 +223,238 @@ pub fn clearMeasures(self: *Performance, measure_name: ?[]const u8) void { } pub fn setResourceTimingBufferSize(self: *Performance, max_size: u32) void { - _ = self; - _ = max_size; + self._resource_buffer_size = max_size; } -pub fn getEntries(self: *const Performance) []*Entry { - return self._entries.items; +pub fn clearResourceTimings(self: *Performance) void { + self._resources.clearRetainingCapacity(); +} + +// All times are microseconds +pub const ResourceInfo = struct { + name: []const u8, + initiator: []const u8, // static string + protocol: []const u8, // static string + start: u64, + redirect_start: u64, // 0 when the fetch wasn't redirected. + redirect_end: u64, + fetch_start: u64, + dns_start: u64, + dns_end: u64, + connect_start: u64, + connect_end: u64, + secure_start: u64, // 0 unless the final hop was https. + request_start: u64, + response_start: u64, + response_end: u64, + transfer_size: u64, + encoded_body_size: u64, + decoded_body_size: u64, + status: u16, + content_type: []const u8, // unsafe to reference past the function call + content_encoding: []const u8, // unsafe to reference past the function call + from_cache: bool, + // Every response in the chain was same-origin or carried a matching + // Timing-Allow-Origin. + timing_allow: bool, +}; + +pub fn addResource(self: *Performance, info: ResourceInfo) !void { + if (self._resources.items.len >= self._resource_buffer_size) { + // TODO: Perfomance should be an EventTarget and it should fire + // a resourcetimingbufferfull event + return; + } + + const arena = self._arena; + const allow = info.timing_allow; + const redirected = allow and info.redirect_start != 0; // redirects only show with a TAO + + const start_time = self.relative(info.start); + const response_end = self.relative(info.response_end); + + const rt = try self._factory.chained(.{ + Entry{ + ._start_time = start_time, + ._duration = @max(response_end - start_time, 0), + ._name = try arena.dupe(u8, info.name), + ._type = undefined, + }, + ResourceTiming{ + ._proto = undefined, + ._initiator_type = info.initiator, + ._next_hop_protocol = if (allow) info.protocol else "", + ._delivery_type = if (allow and info.from_cache) "cache" else "", + ._redirect_start = if (redirected) start_time else 0, + ._redirect_end = if (redirected) self.relative(info.redirect_end) else 0, + ._fetch_start = if (redirected) self.relative(info.fetch_start) else start_time, + ._domain_lookup_start = self.gated(allow, info.dns_start), + ._domain_lookup_end = self.gated(allow, info.dns_end), + ._connect_start = self.gated(allow, info.connect_start), + ._connect_end = self.gated(allow, info.connect_end), + ._secure_connection_start = self.gated(allow, info.secure_start), + ._request_start = self.gated(allow, info.request_start), + ._response_start = self.gated(allow, info.response_start), + ._response_end = response_end, + ._transfer_size = if (allow) info.transfer_size else 0, + ._encoded_body_size = if (allow) info.encoded_body_size else 0, + ._decoded_body_size = if (allow) info.decoded_body_size else 0, + ._response_status = if (allow) info.status else 0, + ._content_type = if (allow) minimizeMimeType(info.content_type) else "", + ._content_encoding = if (allow) contentEncoding(info.content_encoding) else "", + }, + }); + rt._proto._type = .{ .resource = rt }; + try self.insertOrdered(&self._resources, rt._proto); + + if (self._exec) |exec| { + try self.notifyObservers(rt._proto, exec); + } +} + +fn gated(self: *const Performance, allow: bool, micros: u64) f64 { + return if (allow) self.relative(micros) else 0; +} + +// https://mimesniff.spec.whatwg.org/#minimize-a-supported-mime-type +fn minimizeMimeType(header: []const u8) []const u8 { + var buf: [255]u8 = undefined; + const raw = std.mem.trim(u8, header[0 .. std.mem.indexOfScalar(u8, header, ';') orelse header.len], " \t"); + if (raw.len > buf.len) { + return ""; + } + const essence = std.ascii.lowerString(&buf, raw); + if (javascript_mime_types.has(essence)) { + return "text/javascript"; + } + if (std.mem.eql(u8, essence, "application/json") or std.mem.eql(u8, essence, "text/json") or std.mem.endsWith(u8, essence, "+json")) { + return "application/json"; + } + if (std.mem.eql(u8, essence, "image/svg+xml")) { + return "image/svg+xml"; + } + if (std.mem.eql(u8, essence, "application/xml") or std.mem.eql(u8, essence, "text/xml") or std.mem.endsWith(u8, essence, "+xml")) { + return "application/xml"; + } + if (supported_mime_types.getIndex(essence)) |i| { + return supported_mime_types.keys()[i]; + } + return ""; +} + +// https://mimesniff.spec.whatwg.org/#javascript-mime-type +const javascript_mime_types = std.StaticStringMap(void).initComptime(.{ + .{ "application/ecmascript", {} }, + .{ "application/javascript", {} }, + .{ "application/x-ecmascript", {} }, + .{ "application/x-javascript", {} }, + .{ "text/ecmascript", {} }, + .{ "text/javascript", {} }, + .{ "text/javascript1.0", {} }, + .{ "text/javascript1.1", {} }, + .{ "text/javascript1.2", {} }, + .{ "text/javascript1.3", {} }, + .{ "text/javascript1.4", {} }, + .{ "text/javascript1.5", {} }, + .{ "text/jscript", {} }, + .{ "text/livescript", {} }, + .{ "text/x-ecmascript", {} }, + .{ "text/x-javascript", {} }, +}); + +// What a browser renders or decodes, beyond the JS/JSON/XML families +// handled above. +const supported_mime_types = std.StaticStringMap(void).initComptime(.{ + .{ "text/html", {} }, + .{ "text/plain", {} }, + .{ "text/css", {} }, + .{ "text/csv", {} }, + .{ "text/vtt", {} }, + .{ "application/pdf", {} }, + .{ "application/octet-stream", {} }, + .{ "image/png", {} }, + .{ "image/apng", {} }, + .{ "image/jpeg", {} }, + .{ "image/jpg", {} }, + .{ "image/pjpeg", {} }, + .{ "image/gif", {} }, + .{ "image/webp", {} }, + .{ "image/avif", {} }, + .{ "image/bmp", {} }, + .{ "image/x-icon", {} }, + .{ "image/vnd.microsoft.icon", {} }, + .{ "font/woff", {} }, + .{ "font/woff2", {} }, + .{ "font/ttf", {} }, + .{ "font/otf", {} }, + .{ "audio/mpeg", {} }, + .{ "audio/mp4", {} }, + .{ "audio/ogg", {} }, + .{ "audio/wav", {} }, + .{ "audio/webm", {} }, + .{ "video/mp4", {} }, + .{ "video/webm", {} }, + .{ "video/ogg", {} }, +}); + +// https://w3c.github.io/resource-timing/#dom-performanceresourcetiming-contentencoding +// A single registered coding is named; anything else is "@unknown", more +// than one is "multiple", none is "". +fn contentEncoding(header: []const u8) []const u8 { + if (header.len == 0) { + return ""; + } + if (std.mem.indexOfScalar(u8, header, ',') != null) { + return "multiple"; + } + var buf: [8]u8 = undefined; + const value = std.mem.trim(u8, header, " \t"); + if (value.len > buf.len) { + return "@unknown"; + } + return content_encodings.get(std.ascii.lowerString(&buf, value)) orelse "@unknown"; +} + +const content_encodings = std.StaticStringMap([]const u8).initComptime(.{ + .{ "br", "br" }, + .{ "dcb", "dcb" }, + .{ "dcz", "dcz" }, + .{ "deflate", "deflate" }, + .{ "gzip", "gzip" }, + .{ "zstd", "zstd" }, +}); + +pub fn getEntries(self: *const Performance, exec: *const Execution) ![]const *Entry { + return mergeOrdered(exec.local_arena, self._entries.items, self._resources.items); } pub fn getEntriesByType(self: *const Performance, entry_type: []const u8, exec: *const Execution) ![]const *Entry { - return filterEntriesByType(exec.local_arena, self._entries.items, entry_type); + const kind = Entry.Type.Enum.parse(entry_type) orelse return &.{}; + if (kind == .resource) { + return self._resources.items; + } + return filterEntriesByType(exec.local_arena, self._entries.items, kind); } pub fn getEntriesByName(self: *const Performance, name: []const u8, entry_type: ?[]const u8, exec: *const Execution) ![]const *Entry { - return filterEntriesByName(exec.local_arena, self._entries.items, name, entry_type); + const arena = exec.local_arena; + if (entry_type) |t| { + const kind = Entry.Type.Enum.parse(t) orelse return &.{}; + const list = if (kind == .resource) self._resources.items else self._entries.items; + return filterEntriesByName(arena, list, name, kind); + } + return mergeOrdered( + arena, + try filterEntriesByName(arena, self._entries.items, name, null), + try filterEntriesByName(arena, self._resources.items, name, null), + ); } // Also used by PerformanceObserver -pub fn filterEntriesByType(arena: Allocator, list: []*Entry, entry_type: []const u8) ![]const *Entry { +pub fn filterEntriesByType(arena: Allocator, list: []const *Entry, kind: Entry.Type.Enum) ![]const *Entry { var result: std.ArrayList(*Entry) = .empty; for (list) |entry| { - if (std.mem.eql(u8, entry.getEntryType(), entry_type)) { + if (entry._type == kind) { try result.append(arena, entry); } } @@ -233,18 +462,16 @@ pub fn filterEntriesByType(arena: Allocator, list: []*Entry, entry_type: []const } // Also used by PerformanceObserver -pub fn filterEntriesByName(arena: Allocator, list: []*Entry, name: []const u8, entry_type: ?[]const u8) ![]const *Entry { +pub fn filterEntriesByName(arena: Allocator, list: []const *Entry, name: []const u8, kind: ?Entry.Type.Enum) ![]const *Entry { var result: std.ArrayList(*Entry) = .empty; - for (list) |entry| { if (!std.mem.eql(u8, entry._name, name)) { continue; } - if (entry_type == null or std.mem.eql(u8, entry.getEntryType(), entry_type.?)) { + if (kind == null or entry._type == kind.?) { try result.append(arena, entry); } } - return result.items; } @@ -293,12 +520,13 @@ fn getMarkTime(self: *const Performance, mark_name: []const u8) !f64 { } pub fn registerObserver(self: *Performance, observer: *PerformanceObserver, exec: *const Execution) !void { + self._exec = exec; for (self._observers.items) |o| { if (o == observer) { return; } } - return self._observers.append(exec.arena, observer); + return self._observers.append(self._arena, observer); } pub fn unregisterObserver(self: *Performance, observer: *PerformanceObserver) void { @@ -317,6 +545,9 @@ pub fn unregisterObserver(self: *Performance, observer: *PerformanceObserver) vo /// delivery. Does NOT fire the callbacks synchronously — that happens later /// via the scheduled task. pub fn notifyObservers(self: *Performance, entry: *Entry, exec: *const Execution) !void { + if (self._observers.items.len == 0) { + return; + } for (self._observers.items) |observer| { if (observer.interested(entry)) { observer._entries.append(exec.arena, entry) catch |err| { @@ -372,6 +603,52 @@ pub fn scheduleDelivery(self: *Performance, exec: *const Execution) !void { ); } +// Entries nearly always arrive in startTime order, so the slot is at (or near) +// the end. +fn insertOrdered(self: *Performance, list: *std.ArrayList(*Entry), entry: *Entry) !void { + var i = list.items.len; + while (i > 0 and list.items[i - 1]._start_time > entry._start_time) : (i -= 1) {} + try list.insert(self._arena, i, entry); +} + +// Merge two startTime-ordered lists into one. +fn mergeOrdered(arena: Allocator, a: []const *Entry, b: []const *Entry) ![]const *Entry { + if (a.len == 0) { + return b; + } + if (b.len == 0) { + return a; + } + + var i: usize = 0; + var j: usize = 0; + var k: usize = 0; + const out = try arena.alloc(*Entry, a.len + b.len); + while (i < a.len and j < b.len) : (k += 1) { + if (b[j]._start_time < a[i]._start_time) { + out[k] = b[j]; + j += 1; + } else { + out[k] = a[i]; + i += 1; + } + } + @memcpy(out[k .. k + a.len - i], a[i..]); + k += a.len - i; + @memcpy(out[k..], b[j..]); + return out; +} + +// Milliseconds since the time origin, rounded like highResTimestamp +fn relative(self: *const Performance, micros: u64) f64 { + if (micros == 0) { + return 0; + } + const elapsed = micros -| self._time_origin; + const rounded = @divTrunc(elapsed + 2, 5) * 5; + return @as(f64, @floatFromInt(rounded)) / 1000.0; +} + pub const JsApi = struct { pub const bridge = js.Bridge(Performance); @@ -386,7 +663,8 @@ pub const JsApi = struct { pub const measure = bridge.function(Performance.measure, .{}); pub const clearMarks = bridge.function(Performance.clearMarks, .{}); pub const clearMeasures = bridge.function(Performance.clearMeasures, .{}); - pub const setResourceTimingBufferSize = bridge.function(Performance.setResourceTimingBufferSize, .{ .noop = true }); + pub const setResourceTimingBufferSize = bridge.function(Performance.setResourceTimingBufferSize, .{}); + pub const clearResourceTimings = bridge.function(Performance.clearResourceTimings, .{}); pub const getEntries = bridge.function(Performance.getEntries, .{}); pub const getEntriesByType = bridge.function(Performance.getEntriesByType, .{}); pub const getEntriesByName = bridge.function(Performance.getEntriesByName, .{}); @@ -413,7 +691,7 @@ pub const Entry = struct { measure: *Measure, navigation, paint, - resource, + resource: *ResourceTiming, taskattribution, @"visibility-state", mark: *Mark, @@ -435,6 +713,12 @@ pub const Entry = struct { mark = 14, // If we ever have types more than 16, we have to update entry // table of PerformanceObserver too. + + // The JS entryType string, e.g. "resource"; null for a type we + // don't know (the getters then return nothing). + pub fn parse(entry_type: []const u8) ?Enum { + return std.meta.stringToEnum(Enum, entry_type); + } }; }; @@ -456,6 +740,20 @@ pub const Entry = struct { return self._start_time; } + pub fn toJSON(self: *const Entry) struct { + name: []const u8, + entryType: []const u8, + startTime: f64, + duration: f64, + } { + return .{ + .name = self._name, + .entryType = self.getEntryType(), + .startTime = self._start_time, + .duration = self._duration, + }; + } + pub const JsApi = struct { pub const bridge = js.Bridge(Entry); @@ -468,6 +766,7 @@ pub const Entry = struct { pub const duration = bridge.accessor(Entry.getDuration, null, .{}); pub const entryType = bridge.accessor(Entry.getEntryType, null, .{}); pub const startTime = bridge.accessor(Entry.getStartTime, null, .{}); + pub const toJSON = bridge.function(Entry.toJSON, .{}); }; }; @@ -583,6 +882,224 @@ pub const Measure = struct { }; }; +/// PerformanceResourceTiming — one per fetch the HTTP client completed for +/// this document (or worker). +pub const ResourceTiming = struct { + pub const Proto = Entry; + + _proto: *Entry, + _initiator_type: []const u8, + _next_hop_protocol: []const u8, + _delivery_type: []const u8, + _redirect_start: f64, + _redirect_end: f64, + _fetch_start: f64, + _domain_lookup_start: f64, + _domain_lookup_end: f64, + _connect_start: f64, + _connect_end: f64, + _secure_connection_start: f64, + _request_start: f64, + _response_start: f64, + _response_end: f64, + _transfer_size: u64, + _encoded_body_size: u64, + _decoded_body_size: u64, + _response_status: u16, + _content_type: []const u8, + _content_encoding: []const u8, + + pub fn getInitiatorType(self: *const ResourceTiming) []const u8 { + return self._initiator_type; + } + + pub fn getNextHopProtocol(self: *const ResourceTiming) []const u8 { + return self._next_hop_protocol; + } + + pub fn getDeliveryType(self: *const ResourceTiming) []const u8 { + return self._delivery_type; + } + + pub fn getWorkerStart(_: *const ResourceTiming) f64 { + // @ServiceWorker + return 0; + } + + pub fn getRedirectStart(self: *const ResourceTiming) f64 { + return self._redirect_start; + } + + pub fn getRedirectEnd(self: *const ResourceTiming) f64 { + return self._redirect_end; + } + + pub fn getFetchStart(self: *const ResourceTiming) f64 { + return self._fetch_start; + } + + pub fn getDomainLookupStart(self: *const ResourceTiming) f64 { + return self._domain_lookup_start; + } + + pub fn getDomainLookupEnd(self: *const ResourceTiming) f64 { + return self._domain_lookup_end; + } + + pub fn getConnectStart(self: *const ResourceTiming) f64 { + return self._connect_start; + } + + pub fn getConnectEnd(self: *const ResourceTiming) f64 { + return self._connect_end; + } + + pub fn getSecureConnectionStart(self: *const ResourceTiming) f64 { + return self._secure_connection_start; + } + + pub fn getRequestStart(self: *const ResourceTiming) f64 { + return self._request_start; + } + + pub fn getResponseStart(self: *const ResourceTiming) f64 { + return self._response_start; + } + + pub fn getResponseEnd(self: *const ResourceTiming) f64 { + return self._response_end; + } + + pub fn getFirstInterimResponseStart(_: *const ResourceTiming) f64 { + // 1xx are consumed by libcurl + return 0; + } + + pub fn getFinalResponseHeadersStart(self: *const ResourceTiming) f64 { + return self._response_start; + } + + pub fn getTransferSize(self: *const ResourceTiming) u64 { + return self._transfer_size; + } + + pub fn getEncodedBodySize(self: *const ResourceTiming) u64 { + return self._encoded_body_size; + } + + pub fn getDecodedBodySize(self: *const ResourceTiming) u64 { + return self._decoded_body_size; + } + + pub fn getResponseStatus(self: *const ResourceTiming) u16 { + return self._response_status; + } + + pub fn getContentType(self: *const ResourceTiming) []const u8 { + return self._content_type; + } + + pub fn getContentEncoding(self: *const ResourceTiming) []const u8 { + return self._content_encoding; + } + + pub fn toJSON(self: *const ResourceTiming) struct { + name: []const u8, + entryType: []const u8, + startTime: f64, + duration: f64, + initiatorType: []const u8, + nextHopProtocol: []const u8, + deliveryType: []const u8, + workerStart: f64, + redirectStart: f64, + redirectEnd: f64, + fetchStart: f64, + domainLookupStart: f64, + domainLookupEnd: f64, + connectStart: f64, + connectEnd: f64, + secureConnectionStart: f64, + requestStart: f64, + responseStart: f64, + firstInterimResponseStart: f64, + finalResponseHeadersStart: f64, + responseEnd: f64, + transferSize: u64, + encodedBodySize: u64, + decodedBodySize: u64, + responseStatus: u16, + contentType: []const u8, + contentEncoding: []const u8, + } { + const entry = self._proto; + return .{ + .name = entry._name, + .entryType = entry.getEntryType(), + .startTime = entry._start_time, + .duration = entry._duration, + .initiatorType = self._initiator_type, + .nextHopProtocol = self._next_hop_protocol, + .deliveryType = self._delivery_type, + .workerStart = 0, + .redirectStart = self._redirect_start, + .redirectEnd = self._redirect_end, + .fetchStart = self._fetch_start, + .domainLookupStart = self._domain_lookup_start, + .domainLookupEnd = self._domain_lookup_end, + .connectStart = self._connect_start, + .connectEnd = self._connect_end, + .secureConnectionStart = self._secure_connection_start, + .requestStart = self._request_start, + .responseStart = self._response_start, + .firstInterimResponseStart = 0, + .finalResponseHeadersStart = self._response_start, + .responseEnd = self._response_end, + .transferSize = self._transfer_size, + .encodedBodySize = self._encoded_body_size, + .decodedBodySize = self._decoded_body_size, + .responseStatus = self._response_status, + .contentType = self._content_type, + .contentEncoding = self._content_encoding, + }; + } + + pub const JsApi = struct { + pub const bridge = js.Bridge(ResourceTiming); + + pub const Meta = struct { + pub const name = "PerformanceResourceTiming"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const initiatorType = bridge.accessor(ResourceTiming.getInitiatorType, null, .{}); + pub const nextHopProtocol = bridge.accessor(ResourceTiming.getNextHopProtocol, null, .{}); + pub const deliveryType = bridge.accessor(ResourceTiming.getDeliveryType, null, .{}); + pub const workerStart = bridge.accessor(ResourceTiming.getWorkerStart, null, .{}); + pub const redirectStart = bridge.accessor(ResourceTiming.getRedirectStart, null, .{}); + pub const redirectEnd = bridge.accessor(ResourceTiming.getRedirectEnd, null, .{}); + pub const fetchStart = bridge.accessor(ResourceTiming.getFetchStart, null, .{}); + pub const domainLookupStart = bridge.accessor(ResourceTiming.getDomainLookupStart, null, .{}); + pub const domainLookupEnd = bridge.accessor(ResourceTiming.getDomainLookupEnd, null, .{}); + pub const connectStart = bridge.accessor(ResourceTiming.getConnectStart, null, .{}); + pub const connectEnd = bridge.accessor(ResourceTiming.getConnectEnd, null, .{}); + pub const secureConnectionStart = bridge.accessor(ResourceTiming.getSecureConnectionStart, null, .{}); + pub const requestStart = bridge.accessor(ResourceTiming.getRequestStart, null, .{}); + pub const responseStart = bridge.accessor(ResourceTiming.getResponseStart, null, .{}); + pub const firstInterimResponseStart = bridge.accessor(ResourceTiming.getFirstInterimResponseStart, null, .{}); + pub const finalResponseHeadersStart = bridge.accessor(ResourceTiming.getFinalResponseHeadersStart, null, .{}); + pub const responseEnd = bridge.accessor(ResourceTiming.getResponseEnd, null, .{}); + pub const transferSize = bridge.accessor(ResourceTiming.getTransferSize, null, .{}); + pub const encodedBodySize = bridge.accessor(ResourceTiming.getEncodedBodySize, null, .{}); + pub const decodedBodySize = bridge.accessor(ResourceTiming.getDecodedBodySize, null, .{}); + pub const responseStatus = bridge.accessor(ResourceTiming.getResponseStatus, null, .{}); + pub const contentType = bridge.accessor(ResourceTiming.getContentType, null, .{}); + pub const contentEncoding = bridge.accessor(ResourceTiming.getContentEncoding, null, .{}); + pub const toJSON = bridge.function(ResourceTiming.toJSON, .{}); + }; +}; + /// PerformanceTiming — Navigation Timing Level 1 (legacy, but widely used). /// https://developer.mozilla.org/en-US/docs/Web/API/PerformanceTiming /// All properties return 0 as stub values; the object must not be undefined @@ -651,3 +1168,44 @@ const testing = @import("../../testing.zig"); test "WebApi: Performance" { try testing.htmlRunner("performance.html", .{}); } + +test "WebApi: Performance.resource_timing" { + try testing.htmlRunner("performance_resource_timing.html", .{}); + try testing.htmlRunner("performance_resource_timing_buffer.html", .{}); +} + +test "Performance: minimizeMimeType" { + // The WPT table: /mimesniff/mime-types/resources/mime-types-minimized.json + const cases = [_][2][]const u8{ + .{ "application/ecmascript", "text/javascript" }, + .{ "text/javascript1.5", "text/javascript" }, + .{ "text/jscript", "text/javascript" }, + .{ "text/json", "application/json" }, + .{ "application/ld+json", "application/json" }, + .{ "image/svg+xml", "image/svg+xml" }, + .{ " Text/HTML; charset=utf-8", "text/html" }, + .{ "APPLICATION/X-JAVASCRIPT;x=1", "text/javascript" }, + .{ "text/xml", "application/xml" }, + .{ "application/xhtml+xml", "application/xml" }, + .{ "image/png", "image/png" }, + .{ "text/html", "text/html" }, + .{ "font/woff2", "font/woff2" }, + .{ "image/jpe", "" }, + .{ "application/png", "" }, + .{ "random/png", "" }, + .{ "", "" }, + }; + for (cases) |case| { + try testing.expectEqual(case[1], minimizeMimeType(case[0])); + } +} + +test "Performance: contentEncoding" { + try testing.expectEqual("", contentEncoding("")); + try testing.expectEqual("gzip", contentEncoding("gzip")); + try testing.expectEqual("br", contentEncoding(" BR ")); + try testing.expectEqual("zstd", contentEncoding("zstd")); + try testing.expectEqual("@unknown", contentEncoding("identity")); + try testing.expectEqual("@unknown", contentEncoding("unrecognizedname")); + try testing.expectEqual("multiple", contentEncoding("gzip, deflate,Apple")); +} diff --git a/src/browser/webapi/PerformanceObserver.zig b/src/browser/webapi/PerformanceObserver.zig index 627cad849..b3e64db1c 100644 --- a/src/browser/webapi/PerformanceObserver.zig +++ b/src/browser/webapi/PerformanceObserver.zig @@ -73,7 +73,6 @@ const ObserveOptions = struct { type: ?[]const u8 = null, }; -/// TODO: Support `buffered` option. pub fn observe(self: *PerformanceObserver, maybe_options: ?ObserveOptions, exec: *const Execution) !void { const options: ObserveOptions = maybe_options orelse .{}; // Update threshold. @@ -127,9 +126,11 @@ pub fn observe(self: *PerformanceObserver, maybe_options: ?ObserveOptions, exec: // Per spec, buffered is only valid with the type option, not entryTypes. // Delivery is async via a queued task, not synchronous. if (options.buffered and options.type != null and !self.hasRecords()) { - for (self._performance._entries.items) |entry| { - if (self.interested(entry)) { - try self._entries.append(self._arena, entry); + for ([_][]const *Performance.Entry{ self._performance._entries.items, self._performance._resources.items }) |list| { + for (list) |entry| { + if (self.interested(entry)) { + try self._entries.append(self._arena, entry); + } } } if (self.hasRecords()) { @@ -157,7 +158,7 @@ pub fn takeRecords(self: *PerformanceObserver) ![]*Performance.Entry { } pub fn getSupportedEntryTypes() []const []const u8 { - return &.{ "mark", "measure" }; + return &.{ "mark", "measure", "resource" }; } /// Returns true if observer interested with given entry. @@ -216,11 +217,13 @@ pub const EntryList = struct { } pub fn getEntriesByType(self: *const EntryList, entry_type: []const u8, exec: *Execution) ![]const *Performance.Entry { - return Performance.filterEntriesByType(exec.local_arena, self._entries, entry_type); + const kind = Performance.Entry.Type.Enum.parse(entry_type) orelse return &.{}; + return Performance.filterEntriesByType(exec.local_arena, self._entries, kind); } pub fn getEntriesByName(self: *const EntryList, name: []const u8, entry_type: ?[]const u8, exec: *Execution) ![]const *Performance.Entry { - return Performance.filterEntriesByName(exec.local_arena, self._entries, name, entry_type); + const kind = if (entry_type) |t| (Performance.Entry.Type.Enum.parse(t) orelse return &.{}) else null; + return Performance.filterEntriesByName(exec.local_arena, self._entries, name, kind); } pub const JsApi = struct { diff --git a/src/browser/webapi/SharedWorkerGlobalScope.zig b/src/browser/webapi/SharedWorkerGlobalScope.zig index 1a643c20e..84b5b9039 100644 --- a/src/browser/webapi/SharedWorkerGlobalScope.zig +++ b/src/browser/webapi/SharedWorkerGlobalScope.zig @@ -104,7 +104,7 @@ pub fn init(frame: *Frame, url: [:0]const u8, name: []const u8, worker_type: Wor .ctx = self, .method = .GET, .url = owned_url, - .resource_type = .script, + .resource_type = .worker, .header_callback = httpHeaderCallback, .data_callback = httpDataCallback, .done_callback = httpDoneCallback, diff --git a/src/browser/webapi/Worker.zig b/src/browser/webapi/Worker.zig index da857186c..7b6751250 100644 --- a/src/browser/webapi/Worker.zig +++ b/src/browser/webapi/Worker.zig @@ -105,7 +105,7 @@ pub fn init(url: []const u8, options: ?WorkerOptions, frame: *Frame) !*Worker { .url = resolved_url, .frame_id = self._frame_id, .loader_id = self._loader_id, - .resource_type = .script, + .resource_type = if (self._type == .module) .script else .worker, .header_callback = httpHeaderCallback, .data_callback = httpDataCallback, .done_callback = httpDoneCallback, diff --git a/src/browser/webapi/WorkerGlobalScope.zig b/src/browser/webapi/WorkerGlobalScope.zig index dde06509d..9db3df817 100644 --- a/src/browser/webapi/WorkerGlobalScope.zig +++ b/src/browser/webapi/WorkerGlobalScope.zig @@ -166,7 +166,7 @@ pub fn init( ._event_manager = .init(arena), ._script_manager = undefined, ._location = .{ ._url = url }, - ._performance = .init(), + ._performance = .init(factory, arena), ._http_owner = undefined, }, leaf_value, @@ -184,6 +184,7 @@ pub fn init( .loader_id = loader_id, .cookie_jar = &session.cookie_jar, .notification = session.notification, + .performance = &self._performance, }; self._script_manager = ScriptManagerBase.init( @@ -427,7 +428,7 @@ fn importScript(self: *WorkerGlobalScope, arena: Allocator, url: [:0]const u8) ! const transfer = http_client.newRequest(.{ .url = resolved_url, .method = .GET, - .resource_type = .script, + .resource_type = .worker, .shutdown_callback = HttpClient.noopShutdown, // syncRequest installs its own }, &self._http_owner) catch |err| { log.warn(.http, "importScript", .{ .url = resolved_url, .err = err }); diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index 0d55601e6..f9e4cf996 100644 --- a/src/network/HttpClient.zig +++ b/src/network/HttpClient.zig @@ -29,7 +29,7 @@ const URL = @import("../browser/URL.zig"); const referrer = @import("../browser/referrer.zig"); const WebSocket = @import("../browser/webapi/net/WebSocket.zig"); const Cookie = @import("../browser/webapi/storage/Cookie.zig"); -const CookieJar = Cookie.Jar; +const Performance = @import("../browser/webapi/Performance.zig"); const http = @import("http.zig"); const Network = @import("Network.zig"); @@ -40,6 +40,7 @@ const UrlBlocklist = @import("UrlBlocklist.zig"); pub const BlockPattern = UrlBlocklist.Pattern; const log = lp.log; +const CookieJar = Cookie.Jar; const Allocator = std.mem.Allocator; pub const Method = http.Method; @@ -1122,7 +1123,7 @@ fn cacheLookup(self: *Client, transfer: *Transfer) !bool { switch (cache_result) { .hit => |cached| { lp.metrics.http_cache.incr(.hit); - try transfer.bufferCached(cached); + try transfer.bufferCached(cached, .local); return true; }, .revalidate => |cached| { @@ -1179,7 +1180,7 @@ fn cacheRevalidated(self: *Client, transfer: *Transfer) !bool { }; lp.metrics.http_cache.incr(.revalidated); - try transfer.bufferCached(stale); + try transfer.bufferCached(stale, .validated); return true; } @@ -1372,6 +1373,7 @@ fn makeRequest(self: *Client, conn: *http.Connection, transfer: *Transfer) anyer }; transfer._conn = conn; transfer.state = .inflight; + transfer._timing.hop_start = lp.datetime.microTimestamp(.boot); // Start the request (and move along any other request). _ = try self.handles.perform(); @@ -1748,6 +1750,7 @@ pub const Request = struct { stylesheet, eventsource, image, + worker, // Allowed Values: Document, Stylesheet, Image, Media, Font, Script, // TextTrack, XHR, Fetch, Prefetch, EventSource, WebSocket, Manifest, @@ -1762,6 +1765,7 @@ pub const Request = struct { .stylesheet => "Stylesheet", .eventsource => "EventSource", .image => "Image", + .worker => "Script", }; } }; @@ -1948,6 +1952,7 @@ fn fulfillRedirect( } } + transfer.redirectTiming(headers); try transfer.applyRedirectTarget(transfer.req.url, location, status); try self.pipeline(transfer, .after_intercept); } @@ -2013,6 +2018,7 @@ pub const Owner = struct { document_frame_id: u32, loader_id: u32, cookie_jar: *CookieJar, + performance: *Performance, notification: *Notification, const Blob = @import("../browser/webapi/Blob.zig"); @@ -2151,6 +2157,7 @@ pub const Transfer = struct { _conn_id: i64 = 0, _conn_reused: bool = false, + _timing: ResourceTiming = .{}, // Set by the first deinit. A retired transfer is unlinked from // everything and sits on client.graveyard @@ -2272,6 +2279,7 @@ pub const Transfer = struct { return; } + self._timing.start(self.req.url); self.client.pipeline(self, .start) catch |err| { self.abortPipelineError(err); return err; @@ -2617,6 +2625,12 @@ pub const Transfer = struct { }); } + if (err != error.TransferCanceled) { + // A failed fetch gets recorded. A cancelled fetch, doesn't. + // (aborted is already skipped above) + self.recordResourceTiming(); + } + self._outcome_delivered = true; self.req.error_callback(self.req.ctx, err); } @@ -2669,6 +2683,183 @@ pub const Transfer = struct { self.client.dispatch_count += 1; } + fn captureTiming(self: *Transfer, conn: *const http.Connection) void { + const t = &self._timing; + const ct = conn.getTiming() catch return; + const base = t.hop_start + ct.queue; + const tls = std.mem.startsWith(u8, self.req.url, "https://"); + + if (self._conn_reused) { + // Nothing was resolved or connected for this fetch + t.dns_start = t.fetch_start; + t.dns_end = t.fetch_start; + t.connect_start = t.fetch_start; + t.connect_end = t.fetch_start; + t.secure_start = if (tls) t.fetch_start else 0; + } else { + t.dns_start = base; + t.dns_end = base + ct.namelookup; + t.connect_start = t.dns_end; + t.connect_end = base + @max(ct.connect, ct.appconnect); + t.secure_start = if (tls) base + ct.connect else 0; + } + t.request_start = base + ct.pretransfer; + t.response_start = base + ct.starttransfer; + // the stream's response_end will get captured in recordResourceTiming + t.response_end = if (self.req.streaming) 0 else base + ct.total; + + t.encoded_body_size = conn.getDownloadSize() catch 0; + t.protocol = switch (conn.getHttpVersion() catch .none) { + .v1_0 => "http/1.0", + .v1_1 => "http/1.1", + .v2 => "h2", + .v3 => "h3", + else => "", + }; + } + + fn redirectTiming(self: *Transfer, headers: []const http.Header) void { + const t = &self._timing; + const now = lp.datetime.microTimestamp(.boot); + if (t.redirect_start == 0) { + t.redirect_start = t.start_time; + } + t.redirect_end = now; + t.fetch_start = now; + if (t.timing_allow) { + const target = self.timingTarget() orelse return; + t.timing_allow = timingAllowPassed(target.origin, self.req.url, headers, t.tainted); + } + } + + // If A -> B -> A, we sent the tainted flag (on the B -> A transition) so + // that we know how to apply the timing-allow-origin header. + fn redirectTaint(self: *Transfer, url: []const u8) void { + const target = self.timingTarget() orelse return; + const origin = target.origin orelse "null"; + if (!URL.isSameOrigin(self.req.url, origin) and !URL.isSameOrigin(url, self.req.url)) { + self._timing.tainted = true; + } + } + + const TimingTarget = struct { + origin: ?[]const u8, + performance: *Performance, + }; + + fn timingTarget(self: *const Transfer) ?TimingTarget { + const owner = self.owner orelse return null; + const target: *const Owner = if (self.req.resource_type == .document) + owner.parent orelse return null + else + owner; + return .{ .performance = target.performance, .origin = target.origin.* }; + } + + // https://fetch.spec.whatwg.org/#concept-tao-check + fn timingAllowPassed(document_origin: ?[]const u8, url: []const u8, headers: []const http.Header, tainted: bool) bool { + const origin = document_origin orelse "null"; + if (!tainted and URL.isSameOrigin(url, origin)) { + return true; + } + for (headers) |hdr| { + if (!std.ascii.eqlIgnoreCase(hdr.name, "timing-allow-origin")) { + continue; + } + var it = std.mem.splitScalar(u8, hdr.value, ','); + while (it.next()) |part| { + const value = std.mem.trim(u8, part, " \t"); + if (std.mem.eql(u8, value, "*") or (!tainted and std.mem.eql(u8, value, origin))) { + return true; + } + } + } + return false; + } + + // Timing gets passed to the owner + fn recordResourceTiming(self: *Transfer) void { + const t = &self._timing; + if (t.recorded or t.start_time == 0 or self.req.internal) { + return; + } + t.recorded = true; + const target = self.timingTarget() orelse return; + + if (t.response_end == 0) { + // Cache hit, interceptor-fulfilled, or failed: delivered now. + t.response_end = lp.datetime.microTimestamp(.boot); + } + // Force phases that never happened to fetch_start + inline for (.{ "dns_start", "dns_end", "connect_start", "connect_end", "request_start", "response_start" }) |phase| { + if (@field(t, phase) == 0) { + @field(t, phase) = t.fetch_start; + } + } + + var status: u16 = 0; + var content_type: []const u8 = ""; + var timing_allow = false; + if (self.res.header) |*rh| { + status = rh.status; + content_type = rh.contentType() orelse ""; + timing_allow = t.timing_allow and timingAllowPassed(target.origin, self.req.url, self.res.headers, t.tainted); + } + + // A cached body is stored decoded; its wire size is long gone. A + // headers_only fetch (images) tears the body off the wire: the + // Content-Length, else whatever arrived before the abort, is the + // best size we have for both. + var decoded_body_size = t.decoded_body_size; + var encoded_body_size = if (t.cache == .none) t.encoded_body_size else decoded_body_size; + if (self.req.headers_only) { + const known = if (self._content_length > 0) self._content_length else t.encoded_body_size; + decoded_body_size = known; + encoded_body_size = known; + } + + target.performance.addResource(.{ + .name = t.url, + .initiator = switch (self.req.resource_type) { + // A document fetch only makes the timeline as a child frame. + .document => "iframe", + .xhr => "xmlhttprequest", + .script => "script", + .fetch => "fetch", + .stylesheet => "link", + .eventsource, .worker => "other", + .image => "img", + }, + .protocol = t.protocol, + .start = t.start_time, + .redirect_start = t.redirect_start, + .redirect_end = t.redirect_end, + .fetch_start = t.fetch_start, + .dns_start = t.dns_start, + .dns_end = t.dns_end, + .connect_start = t.connect_start, + .connect_end = t.connect_end, + .secure_start = t.secure_start, + .request_start = t.request_start, + .response_start = t.response_start, + .response_end = t.response_end, + .transfer_size = switch (t.cache) { + .none => encoded_body_size + 300, // +300 is per spec + .validated => 300, + .local => 0, + }, + .encoded_body_size = encoded_body_size, + .decoded_body_size = decoded_body_size, + .status = status, + .content_type = content_type, + .content_encoding = findHeader(self.res.headers, "content-encoding") orelse "", + .from_cache = t.cache != .none, + .timing_allow = timing_allow, + }) catch |err| { + log.err(.http, "resource timing", .{ .err = err, .req = self }); + }; + } + // Buffer the standard success event sequence. `body` is either owned by // transfer.arena OR, through some other mechanism, outlives the transfer. fn bufferEvents(self: *Transfer, body: []const u8) !void { @@ -2715,7 +2906,7 @@ pub const Transfer = struct { // Serve a cache entry as this transfer's response. Takes ownership of // `cached` (file-backed bodies are read into the arena and closed). - fn bufferCached(self: *Transfer, cached: Cache.CachedResponse) !void { + fn bufferCached(self: *Transfer, cached: Cache.CachedResponse, cache_state: ResourceTiming.CacheState) !void { const body: []const u8 = switch (cached.data) { .buffer => |b| b, }; @@ -2723,6 +2914,7 @@ pub const Transfer = struct { self.setResponseHead(cached.status, cached.content_type); self.res.headers = cached.headers; self._from_cache = true; + self._timing.cache = cache_state; self._content_length = body.len; try self.bufferEvents(body); } @@ -2771,6 +2963,7 @@ pub const Transfer = struct { const conn_id = conn.getConnId() catch -1; self._conn_id = if (conn_id < 0) 0 else conn_id + 1; self._conn_reused = conn.isConnReused() catch false; + self.captureTiming(conn); const arena = self.arena; @@ -2949,6 +3142,7 @@ pub const Transfer = struct { // fresh arena-owned copy that gets stored in transfer.req.url. const base_url = try conn.getEffectiveUrl(); const status = try conn.getResponseCode(); + transfer.redirectTiming(transfer.res.headers); try transfer.applyRedirectTarget(std.mem.span(base_url), location, status); } @@ -3000,6 +3194,7 @@ pub const Transfer = struct { transfer.removeHeader("Authorization"); } + transfer.redirectTaint(url); try transfer.updateURL(url); // 301, 302, 303 → change to GET, drop body. // 307, 308 → keep method and body. @@ -3440,6 +3635,7 @@ pub const Transfer = struct { .transfer = transfer, }); } + transfer._timing.decoded_body_size += chunk.len; req.data_callback(transfer, chunk) catch |err| { return transfer.failDelivery(err); }; @@ -3462,6 +3658,7 @@ pub const Transfer = struct { .transfer = transfer, }); } + transfer._timing.decoded_body_size += chunk.len; req.data_callback(transfer, chunk) catch |err| { return transfer.failDelivery(err); }; @@ -3475,6 +3672,9 @@ pub const Transfer = struct { .content_length = transfer._content_length, }); } + // Before done_callback: a load handler can already see + // the entry, as in a browser. + transfer.recordResourceTiming(); transfer._outcome_delivered = true; req.done_callback(req.ctx) catch |err| { return transfer.failDelivery(err); @@ -3508,6 +3708,51 @@ pub const Transfer = struct { } }; +// What a transfer collects for its PerformanceResourceTiming entry. Absolute +// boot-clock microseconds, 0 = not (yet) known. Lives on the Transfer, not +// on Response: the redirect fields span hops and reset() must not clear it. +const ResourceTiming = struct { + // The URL as requested; the entry's name. Redirects rewrite req.url. + url: [:0]const u8 = "", + start_time: u64 = 0, + redirect_start: u64 = 0, + redirect_end: u64 = 0, + // Start of the final hop (== start_time without a redirect). + fetch_start: u64 = 0, + // When the current hop's conn was handed to the multi. + hop_start: u64 = 0, + dns_start: u64 = 0, + dns_end: u64 = 0, + connect_start: u64 = 0, + connect_end: u64 = 0, + secure_start: u64 = 0, + request_start: u64 = 0, + response_start: u64 = 0, + response_end: u64 = 0, + // Wire bytes, from libcurl; body bytes handed to the consumer, counted + // as they're delivered (so a cache hit or a stream is right too). + encoded_body_size: u64 = 0, + decoded_body_size: u64 = 0, + protocol: []const u8 = "", + cache: CacheState = .none, + // Cleared by the first redirect response that fails the TAO check. + timing_allow: bool = true, + // Set once a redirect hops from one foreign origin to another; from then + // on only a wildcard Timing-Allow-Origin passes, same-origin included. + // https://fetch.spec.whatwg.org/#concept-request-tainted-origin + tainted: bool = false, + recorded: bool = false, + + const CacheState = enum { none, local, validated }; + + fn start(self: *ResourceTiming, url: [:0]const u8) void { + const now = lp.datetime.microTimestamp(.boot); + self.url = url; + self.start_time = now; + self.fetch_start = now; + } +}; + // Response-state owned by this transfer's currently-in-flight response. // Reset on every retry (auth retry, redirect) via Transfer.reset — only // the cross-retry counters (_auth_challenge, _redirect_count) live on @@ -3646,6 +3891,7 @@ fn testOwner() Owner { .loader_id = 0, .cookie_jar = undefined, .notification = undefined, + .performance = undefined, }; } const AdBlocker = @import("adblock/AdBlocker.zig"); diff --git a/src/network/http.zig b/src/network/http.zig index 19295ee59..21d7e0f37 100644 --- a/src/network/http.zig +++ b/src/network/http.zig @@ -601,6 +601,48 @@ pub const Connection = struct { return micros; } + // microseconds from the moment the transfer started. Reused connections + // will report zero (or almost zero) for namelookup/connect/appconnect. + pub const Timing = struct { + queue: u64, + namelookup: u64, + connect: u64, + appconnect: u64, + pretransfer: u64, + starttransfer: u64, + total: u64, + }; + + pub fn getTiming(self: *const Connection) !Timing { + return .{ + .queue = try self.getInfoMicros(.queue_time_t), + .namelookup = try self.getInfoMicros(.namelookup_time_t), + .connect = try self.getInfoMicros(.connect_time_t), + .appconnect = try self.getInfoMicros(.appconnect_time_t), + .pretransfer = try self.getInfoMicros(.pretransfer_time_t), + .starttransfer = try self.getInfoMicros(.starttransfer_time_t), + .total = try self.getInfoMicros(.total_time_t), + }; + } + + fn getInfoMicros(self: *const Connection, comptime info: libcurl.CurlInfo) !u64 { + var micros: c_long = undefined; + try libcurl.curl_easy_getinfo(self._easy, info, µs); + return @intCast(@max(0, micros)); + } + + pub fn getDownloadSize(self: *const Connection) !u64 { + var size: c_long = undefined; + try libcurl.curl_easy_getinfo(self._easy, .size_download_t, &size); + return @intCast(@max(0, size)); + } + + pub fn getHttpVersion(self: *const Connection) !libcurl.CurlHttpVersion { + var version: c_long = undefined; + try libcurl.curl_easy_getinfo(self._easy, .http_version, &version); + return @enumFromInt(version); + } + pub fn getConnectHeader(self: *const Connection, name: [:0]const u8, index: usize) ?HeaderValue { var hdr: ?*libcurl.CurlHeader = null; libcurl.curl_easy_header(self._easy, name, index, .connect, -1, &hdr) catch |err| { diff --git a/src/server/cdp/domains/fetch.zig b/src/server/cdp/domains/fetch.zig index 15b9d6c27..0da0c325e 100644 --- a/src/server/cdp/domains/fetch.zig +++ b/src/server/cdp/domains/fetch.zig @@ -524,7 +524,7 @@ pub const InterceptState = struct { return switch (resource_type) { .document => .Document, .xhr => .XHR, - .script => .Script, + .script, .worker => .Script, .fetch => .Fetch, .stylesheet => .Stylesheet, .eventsource => .EventSource, diff --git a/src/server/cdp/domains/network.zig b/src/server/cdp/domains/network.zig index 2bfe84c82..12a43bc3d 100644 --- a/src/server/cdp/domains/network.zig +++ b/src/server/cdp/domains/network.zig @@ -708,7 +708,7 @@ const ResponseWriter = struct { fn initialPriority(resource_type: HttpClient.Request.ResourceType) []const u8 { return switch (resource_type) { .document, .stylesheet => "VeryHigh", - .script, .xhr, .fetch, .eventsource => "High", + .script, .worker, .xhr, .fetch, .eventsource => "High", .image => "Low", }; } diff --git a/src/sys/libcurl.zig b/src/sys/libcurl.zig index 4ffe54409..0781c3bd8 100644 --- a/src/sys/libcurl.zig +++ b/src/sys/libcurl.zig @@ -234,7 +234,11 @@ pub const CurlOption = enum(c.CURLoption) { pub const CurlHttpVersion = enum(c_long) { none = c.CURL_HTTP_VERSION_NONE, // libcurl's own defaults, picks the best + v1_0 = c.CURL_HTTP_VERSION_1_0, v1_1 = c.CURL_HTTP_VERSION_1_1, + v2 = c.CURL_HTTP_VERSION_2_0, + v3 = c.CURL_HTTP_VERSION_3, + _, }; pub const CurlMOption = enum(c.CURLMoption) { @@ -248,6 +252,14 @@ pub const CurlInfo = enum(c.CURLINFO) { response_code = c.CURLINFO_RESPONSE_CODE, connect_code = c.CURLINFO_HTTP_CONNECTCODE, total_time_t = c.CURLINFO_TOTAL_TIME_T, + queue_time_t = c.CURLINFO_QUEUE_TIME_T, + namelookup_time_t = c.CURLINFO_NAMELOOKUP_TIME_T, + connect_time_t = c.CURLINFO_CONNECT_TIME_T, + appconnect_time_t = c.CURLINFO_APPCONNECT_TIME_T, + pretransfer_time_t = c.CURLINFO_PRETRANSFER_TIME_T, + starttransfer_time_t = c.CURLINFO_STARTTRANSFER_TIME_T, + size_download_t = c.CURLINFO_SIZE_DOWNLOAD_T, + http_version = c.CURLINFO_HTTP_VERSION, num_connects = c.CURLINFO_NUM_CONNECTS, conn_id = c.CURLINFO_CONN_ID, }; @@ -668,11 +680,19 @@ pub fn curl_easy_getinfo(easy: *Curl, comptime info: CurlInfo, out: anytype) Err .connect_code, .redirect_count, .num_connects, + .http_version, => blk: { const p: *c_long = out; break :blk c.curl_easy_getinfo(easy, inf, p); }, .total_time_t, + .queue_time_t, + .namelookup_time_t, + .connect_time_t, + .appconnect_time_t, + .pretransfer_time_t, + .starttransfer_time_t, + .size_download_t, .conn_id, => blk: { const p: *c.curl_off_t = out; diff --git a/src/testing.zig b/src/testing.zig index 19b14eeb2..d38188c3b 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -693,6 +693,33 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void { }); } + // Scripts, so a cross-origin (localhost) load doesn't depend on CORS. + if (std.mem.eql(u8, path, "/resource-timing/tao")) { + return req.respond("window.__rt_tao = true;", .{ + .extra_headers = &.{ + .{ .name = "Content-Type", .value = "text/javascript" }, + .{ .name = "Timing-Allow-Origin", .value = "https://example.com, *" }, + }, + }); + } + + if (std.mem.eql(u8, path, "/resource-timing/plain")) { + return req.respond("window.__rt_plain = true;", .{ + .extra_headers = &.{ + .{ .name = "Content-Type", .value = "text/javascript" }, + }, + }); + } + + if (std.mem.eql(u8, path, "/resource-timing/redirect")) { + return req.respond("", .{ + .status = .found, + .extra_headers = &.{ + .{ .name = "Location", .value = "/resource-timing/plain" }, + }, + }); + } + if (std.mem.eql(u8, path, "/redirect-no-fragment")) { return req.respond("", .{ .status = .found,