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,