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,