From 3be913750e67d8187c23df1731ecbc4d8bcb8023 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 11 Apr 2026 07:23:56 +0800 Subject: [PATCH 01/18] Cache-Control is public by default - If private isn't specified, default to public. - Add some tests - Optimize parsing by lower-casing once and switch to std.mem --- src/network/cache/Cache.zig | 53 ++++++++++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/src/network/cache/Cache.zig b/src/network/cache/Cache.zig index d270310e..d61778b5 100644 --- a/src/network/cache/Cache.zig +++ b/src/network/cache/Cache.zig @@ -55,25 +55,33 @@ pub const CacheControl = struct { var max_age_set = false; var max_s_age_set = false; - var is_public = false; var iter = std.mem.splitScalar(u8, value, ','); while (iter.next()) |part| { - const directive = std.mem.trim(u8, part, &std.ascii.whitespace); - if (std.ascii.eqlIgnoreCase(directive, "no-store")) { + const stripped = std.mem.trim(u8, part, &std.ascii.whitespace); + + var buf: [16]u8 = undefined; + const len = @min(buf.len, stripped.len); + const directive = std.ascii.lowerString(buf[0..len], stripped[0..len]); + + if (std.mem.eql(u8, directive, "no-store")) { return null; - } else if (std.ascii.eqlIgnoreCase(directive, "no-cache")) { + } + if (std.mem.eql(u8, directive, "no-cache")) { return null; - } else if (std.ascii.eqlIgnoreCase(directive, "public")) { - is_public = true; - } else if (std.ascii.startsWithIgnoreCase(directive, "max-age=")) { + } + if (std.mem.eql(u8, directive, "private")) { + return null; + } + + if (std.mem.startsWith(u8, directive, "max-age=")) { if (!max_s_age_set) { if (std.fmt.parseInt(u64, directive[8..], 10) catch null) |max_age| { cc.max_age = max_age; max_age_set = true; } } - } else if (std.ascii.startsWithIgnoreCase(directive, "s-maxage=")) { + } else if (std.mem.startsWith(u8, directive, "s-maxage=")) { if (std.fmt.parseInt(u64, directive[9..], 10) catch null) |max_age| { cc.max_age = max_age; max_age_set = true; @@ -83,7 +91,6 @@ pub const CacheControl = struct { } if (!max_age_set) return null; - if (!is_public) return null; if (cc.max_age == 0) return null; return cc; @@ -211,3 +218,31 @@ pub fn tryCache( .vary_headers = &.{}, }; } +const testing = @import("../../testing.zig"); +test "Cache: CacheControl.parse" { + try testing.expectEqual(300, CacheControl.parse("max-age=300").?.max_age); + + try testing.expectEqual(300, CacheControl.parse("Max-Age=300").?.max_age); + try testing.expectEqual(300, CacheControl.parse("MAX-AGE=300").?.max_age); + + try testing.expectEqual(300, CacheControl.parse("public, max-age=300").?.max_age); + try testing.expectEqual(300, CacheControl.parse(" max-age=300 ").?.max_age); + + try testing.expectEqual(600, CacheControl.parse("max-age=300, s-maxage=600").?.max_age); + try testing.expectEqual(600, CacheControl.parse("s-maxage=600, max-age=300").?.max_age); + + try testing.expectEqual(null, CacheControl.parse("no-store")); + try testing.expectEqual(null, CacheControl.parse("no-cache")); + try testing.expectEqual(null, CacheControl.parse("private")); + try testing.expectEqual(null, CacheControl.parse("max-age=300, no-store")); + try testing.expectEqual(null, CacheControl.parse("no-cache, max-age=300")); + try testing.expectEqual(null, CacheControl.parse("Private, max-age=300")); + + try testing.expectEqual(null, CacheControl.parse("max-age=0")); + + try testing.expectEqual(null, CacheControl.parse("public")); + try testing.expectEqual(null, CacheControl.parse("")); + + try testing.expectEqual(null, CacheControl.parse("max-age=abc")); + try testing.expectEqual(null, CacheControl.parse("max-age=")); +} From a11410679070d14f2b7b1a01d285b101081b5dc5 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Mon, 13 Apr 2026 23:46:37 +0200 Subject: [PATCH 02/18] cdp: accept LID- as requestId prefix --- src/cdp/domains/network.zig | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/cdp/domains/network.zig b/src/cdp/domains/network.zig index f2d60c91..ea6bc020 100644 --- a/src/cdp/domains/network.zig +++ b/src/cdp/domains/network.zig @@ -206,7 +206,7 @@ fn getCookies(cmd: *CDP.Command) !void { fn getResponseBody(cmd: *CDP.Command) !void { const params = (try cmd.params(struct { - requestId: []const u8, // "REQ-{d}" + requestId: []const u8, // "REQ-{d}" or "LID-{d}" })) orelse return error.InvalidParams; const request_id = try idFromRequestId(params.requestId); @@ -439,7 +439,8 @@ const TransferAsResponseWriter = struct { }; fn idFromRequestId(request_id: []const u8) !u64 { - if (!std.mem.startsWith(u8, request_id, "REQ-")) { + // The requesIid for the original document is its loaderId. + if (!std.mem.startsWith(u8, request_id, "REQ-") and !std.mem.startsWith(u8, request_id, "LID-")) { return error.InvalidParams; } return std.fmt.parseInt(u64, request_id[4..], 10) catch return error.InvalidParams; From 4e385a3d132aa838e1ee944569908f156c62bb37 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 3 Apr 2026 09:36:40 +0800 Subject: [PATCH 03/18] Introduce Execution A Worker has no page. So any API that is accessible to a worker cannot take a *Page parameter. Such APIs will now take a js.Execution which the context will own and create from the Page (or from the WorkerGlobalScope when that's created). To test this, in addition to introducing the Execution, this change also updates URLSearchParams which is accessible to Worker (and the Page obviously). This change is obviously viral..if URLSearchParams no longer has a *Page but instead has an *Execution, then any function it calls must also be updated. So some APIs will take a *Page (those only accessible from a Page) and some will take an *Execution (those accessible from a Page or Worker). I'm ok with that. A lot of private/internal functions take a *Page, because it's simple, but all they want is a call_arena or something. We'll try to update those as much as possible. The Page/Execution being injected from the bridge is convenient, but we should be more specific for internal calls and pass only what's needed. --- src/browser/js/Caller.zig | 16 +++++- src/browser/js/Context.zig | 5 ++ src/browser/js/Env.zig | 3 + src/browser/js/Execution.zig | 56 +++++++++++++++++++ src/browser/js/js.zig | 1 + src/browser/webapi/KeyValueList.zig | 8 +-- src/browser/webapi/Location.zig | 8 +-- src/browser/webapi/URL.zig | 39 +++++++------ src/browser/webapi/collections/ChildNodes.zig | 6 +- .../webapi/collections/DOMTokenList.zig | 30 +++++----- .../webapi/collections/HTMLAllCollection.zig | 7 ++- .../webapi/collections/HTMLCollection.zig | 7 ++- src/browser/webapi/collections/NodeList.zig | 11 ++-- src/browser/webapi/collections/iterator.zig | 16 ++++-- src/browser/webapi/net/FormData.zig | 14 ++--- src/browser/webapi/net/Headers.zig | 14 ++--- src/browser/webapi/net/URLSearchParams.zig | 40 ++++++------- 17 files changed, 187 insertions(+), 94 deletions(-) create mode 100644 src/browser/js/Execution.zig diff --git a/src/browser/js/Caller.zig b/src/browser/js/Caller.zig index 5604b7a8..761ed147 100644 --- a/src/browser/js/Caller.zig +++ b/src/browser/js/Caller.zig @@ -448,6 +448,10 @@ fn isPage(comptime T: type) bool { return T == *Page or T == *const Page; } +fn isExecution(comptime T: type) bool { + return T == *js.Execution or T == *const js.Execution; +} + // These wrap the raw v8 C API to provide a cleaner interface. pub const FunctionCallbackInfo = struct { handle: *const v8.FunctionCallbackInfo, @@ -727,7 +731,13 @@ fn getArgs(comptime F: type, comptime offset: usize, local: *const Local, info: break :blk params[0 .. params.len - 1]; } - // we have neither a Page nor a JsObject. All params must be + // If the last parameter is Execution, set it from the context + if (comptime isExecution(params[params.len - 1].type.?)) { + @field(args, tupleFieldName(params.len - 1 + offset)) = &local.ctx.execution; + break :blk params[0 .. params.len - 1]; + } + + // we have neither a Page, Execution, nor a JsObject. All params must be // bound to a JavaScript value. break :blk params; }; @@ -776,7 +786,9 @@ fn getArgs(comptime F: type, comptime offset: usize, local: *const Local, info: } if (comptime isPage(param.type.?)) { - @compileError("Page must be the last parameter (or 2nd last if there's a JsThis): " ++ @typeName(F)); + @compileError("Page must be the last parameter: " ++ @typeName(F)); + } else if (comptime isExecution(param.type.?)) { + @compileError("Execution must be the last parameter: " ++ @typeName(F)); } else if (i >= js_parameter_count) { if (@typeInfo(param.type.?) != .optional) { return error.InvalidArgument; diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index ffc71db2..2cf5730b 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -25,6 +25,7 @@ const bridge = @import("bridge.zig"); const Env = @import("Env.zig"); const Origin = @import("Origin.zig"); const Scheduler = @import("Scheduler.zig"); +const Execution = @import("Execution.zig"); const Page = @import("../Page.zig"); const Session = @import("../Session.zig"); @@ -111,6 +112,10 @@ script_manager: ?*ScriptManager, // Our macrotasks scheduler: Scheduler, +// Execution context for worker-compatible APIs. This provides a common +// interface that works in both Page and Worker contexts. +execution: Execution, + unknown_properties: (if (IS_DEBUG) std.StringHashMapUnmanaged(UnknownPropertyStat) else void) = if (IS_DEBUG) .{} else {}, const ModuleEntry = struct { diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index 03eadac3..eaecee85 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -330,7 +330,10 @@ pub fn createContext(self: *Env, page: *Page, params: ContextParams) !*Context { .scheduler = .init(context_arena), .identity = params.identity, .identity_arena = params.identity_arena, + .execution = undefined, }; + // Initialize execution after context is created since it contains self-references + context.execution = js.Execution.fromContext(context); { // Multiple contexts can be created for the same Window (via CDP). We only diff --git a/src/browser/js/Execution.zig b/src/browser/js/Execution.zig new file mode 100644 index 00000000..6c807aa2 --- /dev/null +++ b/src/browser/js/Execution.zig @@ -0,0 +1,56 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Execution context for worker-compatible APIs. +//! +//! This provides a common interface for APIs that work in both Window and Worker +//! contexts. Instead of taking `*Page` (which is DOM-specific), these APIs take +//! `*Execution` which abstracts the common infrastructure. +//! +//! The bridge constructs an Execution on-the-fly from the current context, +//! whether it's a Page context or a Worker context. + +const std = @import("std"); +const Context = @import("Context.zig"); +const Scheduler = @import("Scheduler.zig"); +const Factory = @import("../Factory.zig"); + +const Allocator = std.mem.Allocator; + +const Execution = @This(); + +context: *Context, + +// Fields named to match Page for generic code (executor._factory works for both) +_factory: *Factory, +arena: Allocator, +call_arena: Allocator, +_scheduler: *Scheduler, +buf: []u8, + +pub fn fromContext(ctx: *Context) Execution { + const page = ctx.page; + return .{ + .context = ctx, + ._factory = page._factory, + .arena = page.arena, + .call_arena = ctx.call_arena, + ._scheduler = &ctx.scheduler, + .buf = &page.buf, + }; +} diff --git a/src/browser/js/js.zig b/src/browser/js/js.zig index 10867167..74ee0c7a 100644 --- a/src/browser/js/js.zig +++ b/src/browser/js/js.zig @@ -27,6 +27,7 @@ pub const Caller = @import("Caller.zig"); pub const Origin = @import("Origin.zig"); pub const Identity = @import("Identity.zig"); pub const Context = @import("Context.zig"); +pub const Execution = @import("Execution.zig"); pub const Local = @import("Local.zig"); pub const Inspector = @import("Inspector.zig"); pub const Snapshot = @import("Snapshot.zig"); diff --git a/src/browser/webapi/KeyValueList.zig b/src/browser/webapi/KeyValueList.zig index dea6f7cb..671e3604 100644 --- a/src/browser/webapi/KeyValueList.zig +++ b/src/browser/webapi/KeyValueList.zig @@ -24,6 +24,7 @@ const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const h5e = @import("../parser/html5ever.zig"); +const Execution = js.Execution; const Allocator = std.mem.Allocator; pub fn registerTypes() []const type { @@ -112,12 +113,11 @@ pub fn get(self: *const KeyValueList, name: []const u8) ?[]const u8 { return null; } -pub fn getAll(self: *const KeyValueList, name: []const u8, page: *Page) ![]const []const u8 { - const arena = page.call_arena; +pub fn getAll(self: *const KeyValueList, allocator: Allocator, name: []const u8) ![]const []const u8 { var arr: std.ArrayList([]const u8) = .empty; for (self._entries.items) |*entry| { if (entry.name.eqlSlice(name)) { - try arr.append(arena, entry.value.str()); + try arr.append(allocator, entry.value.str()); } } return arr.items; @@ -321,7 +321,7 @@ pub const Iterator = struct { pub const Entry = struct { []const u8, []const u8 }; - pub fn next(self: *Iterator, _: *const Page) ?Iterator.Entry { + pub fn next(self: *Iterator, _: *const Execution) ?Iterator.Entry { const index = self.index; const entries = self.kv._entries.items; if (index >= entries.len) { diff --git a/src/browser/webapi/Location.zig b/src/browser/webapi/Location.zig index 9055abbb..cb8806ff 100644 --- a/src/browser/webapi/Location.zig +++ b/src/browser/webapi/Location.zig @@ -27,7 +27,7 @@ const Location = @This(); _url: *URL, pub fn init(raw_url: [:0]const u8, page: *Page) !*Location { - const url = try URL.init(raw_url, null, page); + const url = try URL.init(raw_url, null, &page.js.execution); return page._factory.create(Location{ ._url = url, }); @@ -54,11 +54,11 @@ pub fn getPort(self: *const Location) []const u8 { } pub fn getOrigin(self: *const Location, page: *const Page) ![]const u8 { - return self._url.getOrigin(page); + return self._url.getOrigin(&page.js.execution); } pub fn getSearch(self: *const Location, page: *const Page) ![]const u8 { - return self._url.getSearch(page); + return self._url.getSearch(&page.js.execution); } pub fn getHash(self: *const Location) []const u8 { @@ -99,7 +99,7 @@ pub fn reload(_: *const Location, page: *Page) !void { } pub fn toString(self: *const Location, page: *const Page) ![:0]const u8 { - return self._url.toString(page); + return self._url.toString(&page.js.execution); } pub const JsApi = struct { diff --git a/src/browser/webapi/URL.zig b/src/browser/webapi/URL.zig index 8856c83d..3070b7a1 100644 --- a/src/browser/webapi/URL.zig +++ b/src/browser/webapi/URL.zig @@ -23,6 +23,7 @@ const U = @import("../URL.zig"); const Page = @import("../Page.zig"); const URLSearchParams = @import("net/URLSearchParams.zig"); const Blob = @import("Blob.zig"); +const Execution = js.Execution; const Allocator = std.mem.Allocator; @@ -36,11 +37,12 @@ _search_params: ?*URLSearchParams = null, pub const resolve = @import("../URL.zig").resolve; pub const eqlDocument = @import("../URL.zig").eqlDocument; -pub fn init(url: [:0]const u8, base_: ?[:0]const u8, page: *Page) !*URL { - const arena = page.arena; +pub fn init(url: [:0]const u8, base_: ?[:0]const u8, exec: *const Execution) !*URL { + const arena = exec.arena; + const page = exec.context.page; if (std.mem.eql(u8, url, "about:blank")) { - return page._factory.create(URL{ + return exec._factory.create(URL{ ._raw = "about:blank", ._arena = arena, }); @@ -63,7 +65,7 @@ pub fn init(url: [:0]const u8, base_: ?[:0]const u8, page: *Page) !*URL { const raw = try resolve(arena, base, url, .{ .always_dupe = true }); - return page._factory.create(URL{ + return exec._factory.create(URL{ ._raw = raw, ._arena = arena, }); @@ -107,20 +109,20 @@ pub fn getPort(self: *const URL) []const u8 { return U.getPort(self._raw); } -pub fn getOrigin(self: *const URL, page: *const Page) ![]const u8 { - return (try U.getOrigin(page.call_arena, self._raw)) orelse { +pub fn getOrigin(self: *const URL, exec: *const Execution) ![]const u8 { + return (try U.getOrigin(exec.call_arena, self._raw)) orelse { // yes, a null string, that's what the spec wants return "null"; }; } -pub fn getSearch(self: *const URL, page: *const Page) ![]const u8 { +pub fn getSearch(self: *const URL, exec: *const Execution) ![]const u8 { // If searchParams has been accessed, generate search from it if (self._search_params) |sp| { if (sp.getSize() == 0) { return ""; } - var buf = std.Io.Writer.Allocating.init(page.call_arena); + var buf = std.Io.Writer.Allocating.init(exec.call_arena); try buf.writer.writeByte('?'); try sp.toString(&buf.writer); return buf.written(); @@ -132,30 +134,31 @@ pub fn getHash(self: *const URL) []const u8 { return U.getHash(self._raw); } -pub fn getSearchParams(self: *URL, page: *Page) !*URLSearchParams { +pub fn getSearchParams(self: *URL, exec: *const Execution) !*URLSearchParams { if (self._search_params) |sp| { return sp; } // Get current search string (without the '?') - const search = try self.getSearch(page); + const search = try self.getSearch(exec); const search_value = if (search.len > 0) search[1..] else ""; - const params = try URLSearchParams.init(.{ .query_string = search_value }, page); + const params = try URLSearchParams.init(.{ .query_string = search_value }, exec); self._search_params = params; return params; } -pub fn setHref(self: *URL, value: []const u8, page: *Page) !void { +pub fn setHref(self: *URL, value: []const u8, exec: *const Execution) !void { + const page = exec.context.page; const base = if (U.isCompleteHTTPUrl(value)) page.url else self._raw; - const raw = try U.resolve(self._arena orelse page.arena, base, value, .{ .always_dupe = true }); + const raw = try U.resolve(self._arena orelse exec.arena, base, value, .{ .always_dupe = true }); self._raw = raw; // Update existing searchParams if it exists if (self._search_params) |sp| { const search = U.getSearch(raw); const search_value = if (search.len > 0) search[1..] else ""; - try sp.updateFromString(search_value, page); + try sp.updateFromString(search_value, exec); } } @@ -184,7 +187,7 @@ pub fn setPathname(self: *URL, value: []const u8) !void { self._raw = try U.setPathname(self._raw, value, allocator); } -pub fn setSearch(self: *URL, value: []const u8, page: *Page) !void { +pub fn setSearch(self: *URL, value: []const u8, exec: *const Execution) !void { const allocator = self._arena orelse return error.NoAllocator; self._raw = try U.setSearch(self._raw, value, allocator); @@ -192,7 +195,7 @@ pub fn setSearch(self: *URL, value: []const u8, page: *Page) !void { if (self._search_params) |sp| { const search = U.getSearch(self._raw); const search_value = if (search.len > 0) search[1..] else ""; - try sp.updateFromString(search_value, page); + try sp.updateFromString(search_value, exec); } } @@ -201,7 +204,7 @@ pub fn setHash(self: *URL, value: []const u8) !void { self._raw = try U.setHash(self._raw, value, allocator); } -pub fn toString(self: *const URL, page: *const Page) ![:0]const u8 { +pub fn toString(self: *const URL, exec: *const Execution) ![:0]const u8 { const sp = self._search_params orelse { return self._raw; }; @@ -217,7 +220,7 @@ pub fn toString(self: *const URL, page: *const Page) ![:0]const u8 { const hash = self.getHash(); // Build the new URL string - var buf = std.Io.Writer.Allocating.init(page.call_arena); + var buf = std.Io.Writer.Allocating.init(exec.call_arena); try buf.writer.writeAll(base); // Add / if missing (e.g., "https://example.com" -> "https://example.com/") diff --git a/src/browser/webapi/collections/ChildNodes.zig b/src/browser/webapi/collections/ChildNodes.zig index 410c12b7..15c33210 100644 --- a/src/browser/webapi/collections/ChildNodes.zig +++ b/src/browser/webapi/collections/ChildNodes.zig @@ -18,10 +18,12 @@ const std = @import("std"); +const js = @import("../../js/js.zig"); const Node = @import("../Node.zig"); const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); const GenericIterator = @import("iterator.zig").Entry; +const Execution = js.Execution; // Optimized for node.childNodes, which has to be a live list. // No need to go through a TreeWalker or add any filtering. @@ -140,9 +142,9 @@ const Iterator = struct { const Entry = struct { u32, *Node }; - pub fn next(self: *Iterator, page: *Page) !?Entry { + pub fn next(self: *Iterator, exec: *const Execution) !?Entry { const index = self.index; - const node = try self.list.getAtIndex(index, page) orelse return null; + const node = try self.list.getAtIndex(index, exec.context.page) orelse return null; self.index = index + 1; return .{ index, node }; } diff --git a/src/browser/webapi/collections/DOMTokenList.zig b/src/browser/webapi/collections/DOMTokenList.zig index c9843895..a0409bcf 100644 --- a/src/browser/webapi/collections/DOMTokenList.zig +++ b/src/browser/webapi/collections/DOMTokenList.zig @@ -24,6 +24,7 @@ const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Element = @import("../Element.zig"); const GenericIterator = @import("iterator.zig").Entry; +const Execution = js.Execution; pub const DOMTokenList = @This(); @@ -43,7 +44,7 @@ const Lookup = std.StringArrayHashMapUnmanaged(void); const WHITESPACE = " \t\n\r\x0C"; pub fn length(self: *const DOMTokenList, page: *Page) !u32 { - const tokens = try self.getTokens(page); + const tokens = try self.getTokens(page.call_arena); return @intCast(tokens.count()); } @@ -82,8 +83,8 @@ pub fn add(self: *DOMTokenList, tokens: []const []const u8, page: *Page) !void { try validateToken(token); } - var lookup = try self.getTokens(page); const allocator = page.call_arena; + var lookup = try self.getTokens(allocator); try lookup.ensureUnusedCapacity(allocator, tokens.len); for (tokens) |token| { @@ -98,7 +99,7 @@ pub fn remove(self: *DOMTokenList, tokens: []const []const u8, page: *Page) !voi try validateToken(token); } - var lookup = try self.getTokens(page); + var lookup = try self.getTokens(page.call_arena); for (tokens) |token| { _ = lookup.orderedRemove(token); } @@ -149,7 +150,8 @@ pub fn replace(self: *DOMTokenList, old_token: []const u8, new_token: []const u8 return error.InvalidCharacterError; } - var lookup = try self.getTokens(page); + const allocator = page.call_arena; + var lookup = try self.getTokens(page.call_arena); // Check if old_token exists if (!lookup.contains(old_token)) { @@ -162,7 +164,6 @@ pub fn replace(self: *DOMTokenList, old_token: []const u8, new_token: []const u8 return true; } - const allocator = page.call_arena; // Build new token list preserving order but replacing old with new var new_tokens = try std.ArrayList([]const u8).initCapacity(allocator, lookup.count()); var replaced_old = false; @@ -202,16 +203,16 @@ pub fn setValue(self: *DOMTokenList, value: String, page: *Page) !void { try self._element.setAttribute(self._attribute_name, value, page); } -pub fn keys(self: *DOMTokenList, page: *Page) !*KeyIterator { - return .init(.{ .list = self }, page); +pub fn keys(self: *DOMTokenList, exec: *const Execution) !*KeyIterator { + return .init(.{ .list = self }, exec); } -pub fn values(self: *DOMTokenList, page: *Page) !*ValueIterator { - return .init(.{ .list = self }, page); +pub fn values(self: *DOMTokenList, exec: *const Execution) !*ValueIterator { + return .init(.{ .list = self }, exec); } -pub fn entries(self: *DOMTokenList, page: *Page) !*EntryIterator { - return .init(.{ .list = self }, page); +pub fn entries(self: *DOMTokenList, exec: *const Execution) !*EntryIterator { + return .init(.{ .list = self }, exec); } pub fn forEach(self: *DOMTokenList, cb_: js.Function, js_this_: ?js.Object, page: *Page) !void { @@ -237,14 +238,13 @@ pub fn forEach(self: *DOMTokenList, cb_: js.Function, js_this_: ?js.Object, page } } -fn getTokens(self: *const DOMTokenList, page: *Page) !Lookup { +fn getTokens(self: *const DOMTokenList, allocator: std.mem.Allocator) !Lookup { const value = self.getValue(); if (value.len == 0) { return .empty; } var list: Lookup = .empty; - const allocator = page.call_arena; try list.ensureTotalCapacity(allocator, 4); var it = std.mem.tokenizeAny(u8, value, WHITESPACE); @@ -282,9 +282,9 @@ const Iterator = struct { const Entry = struct { u32, []const u8 }; - pub fn next(self: *Iterator, page: *Page) !?Entry { + pub fn next(self: *Iterator, exec: *const Execution) !?Entry { const index = self.index; - const node = try self.list.item(index, page) orelse return null; + const node = try self.list.item(index, exec.context.page) orelse return null; self.index = index + 1; return .{ index, node }; } diff --git a/src/browser/webapi/collections/HTMLAllCollection.zig b/src/browser/webapi/collections/HTMLAllCollection.zig index acada474..ddad1d08 100644 --- a/src/browser/webapi/collections/HTMLAllCollection.zig +++ b/src/browser/webapi/collections/HTMLAllCollection.zig @@ -24,6 +24,7 @@ const Page = @import("../../Page.zig"); const Node = @import("../Node.zig"); const Element = @import("../Element.zig"); const TreeWalker = @import("../TreeWalker.zig"); +const Execution = js.Execution; const HTMLAllCollection = @This(); @@ -133,11 +134,11 @@ pub fn callable(self: *HTMLAllCollection, arg: CAllAsFunctionArg, page: *Page) ? }; } -pub fn iterator(self: *HTMLAllCollection, page: *Page) !*Iterator { +pub fn iterator(self: *HTMLAllCollection, exec: *const Execution) !*Iterator { return Iterator.init(.{ .list = self, .tw = self._tw.clone(), - }, page); + }, exec); } const GenericIterator = @import("iterator.zig").Entry; @@ -145,7 +146,7 @@ pub const Iterator = GenericIterator(struct { list: *HTMLAllCollection, tw: TreeWalker.FullExcludeSelf, - pub fn next(self: *@This(), _: *Page) ?*Element { + pub fn next(self: *@This(), _: *const Execution) ?*Element { while (self.tw.next()) |node| { if (node.is(Element)) |el| { return el; diff --git a/src/browser/webapi/collections/HTMLCollection.zig b/src/browser/webapi/collections/HTMLCollection.zig index fc73ec6d..c3842e77 100644 --- a/src/browser/webapi/collections/HTMLCollection.zig +++ b/src/browser/webapi/collections/HTMLCollection.zig @@ -23,6 +23,7 @@ const Page = @import("../../Page.zig"); const Element = @import("../Element.zig"); const TreeWalker = @import("../TreeWalker.zig"); const NodeLive = @import("node_live.zig").NodeLive; +const Execution = js.Execution; const Mode = enum { tag, @@ -77,7 +78,7 @@ pub fn getByName(self: *HTMLCollection, name: []const u8, page: *Page) ?*Element }; } -pub fn iterator(self: *HTMLCollection, page: *Page) !*Iterator { +pub fn iterator(self: *HTMLCollection, exec: *const Execution) !*Iterator { return Iterator.init(.{ .list = self, .tw = switch (self._data) { @@ -94,7 +95,7 @@ pub fn iterator(self: *HTMLCollection, page: *Page) !*Iterator { .form => |*impl| .{ .form = impl._tw.clone() }, .empty => .empty, }, - }, page); + }, exec); } const GenericIterator = @import("iterator.zig").Entry; @@ -115,7 +116,7 @@ pub const Iterator = GenericIterator(struct { empty: void, }, - pub fn next(self: *@This(), _: *Page) ?*Element { + pub fn next(self: *@This(), _: *const Execution) ?*Element { return switch (self.list._data) { .tag => |*impl| impl.nextTw(&self.tw.tag), .tag_name => |*impl| impl.nextTw(&self.tw.tag_name), diff --git a/src/browser/webapi/collections/NodeList.zig b/src/browser/webapi/collections/NodeList.zig index cd905df7..8ff1c3ef 100644 --- a/src/browser/webapi/collections/NodeList.zig +++ b/src/browser/webapi/collections/NodeList.zig @@ -24,6 +24,7 @@ const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); const Node = @import("../Node.zig"); +const Execution = js.Execution; const ChildNodes = @import("ChildNodes.zig"); const RadioNodeList = @import("RadioNodeList.zig"); @@ -79,15 +80,15 @@ pub fn getAtIndex(self: *NodeList, index: usize, page: *Page) !?*Node { } pub fn keys(self: *NodeList, page: *Page) !*KeyIterator { - return .init(.{ .list = self }, page); + return .init(.{ .list = self }, page.js.execution); } pub fn values(self: *NodeList, page: *Page) !*ValueIterator { - return .init(.{ .list = self }, page); + return .init(.{ .list = self }, page.js.execution); } pub fn entries(self: *NodeList, page: *Page) !*EntryIterator { - return .init(.{ .list = self }, page); + return .init(.{ .list = self }, page.js.execution); } pub fn forEach(self: *NodeList, cb: js.Function, page: *Page) !void { @@ -126,9 +127,9 @@ const Iterator = struct { self.list.acquireRef(); } - pub fn next(self: *Iterator, page: *Page) !?Entry { + pub fn next(self: *Iterator, exec: *const Execution) !?Entry { const index = self.index; - const node = try self.list.getAtIndex(index, page) orelse return null; + const node = try self.list.getAtIndex(index, exec.context.page) orelse return null; self.index = index + 1; return .{ index, node }; } diff --git a/src/browser/webapi/collections/iterator.zig b/src/browser/webapi/collections/iterator.zig index ba3c4ddc..8d0d6df7 100644 --- a/src/browser/webapi/collections/iterator.zig +++ b/src/browser/webapi/collections/iterator.zig @@ -21,6 +21,7 @@ const lp = @import("lightpanda"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); +const Execution = js.Execution; pub fn Entry(comptime Inner: type, comptime field: ?[]const u8) type { const R = reflect(Inner, field); @@ -38,8 +39,8 @@ pub fn Entry(comptime Inner: type, comptime field: ?[]const u8) type { pub const js_as_object = true; }; - pub fn init(inner: Inner, page: *Page) !*Self { - const self = try page._factory.create(Self{ ._inner = inner }); + pub fn init(inner: Inner, executor: R.Executor) !*Self { + const self = try executor._factory.create(Self{ ._inner = inner }); if (@hasDecl(Inner, "acquireRef")) { self._inner.acquireRef(); @@ -62,8 +63,8 @@ pub fn Entry(comptime Inner: type, comptime field: ?[]const u8) type { self._rc.acquire(); } - pub fn next(self: *Self, page: *Page) if (R.has_error_return) anyerror!Result else Result { - const entry = (if (comptime R.has_error_return) try self._inner.next(page) else self._inner.next(page)) orelse { + pub fn next(self: *Self, executor: R.Executor) if (R.has_error_return) anyerror!Result else Result { + const entry = (if (comptime R.has_error_return) try self._inner.next(executor) else self._inner.next(executor)) orelse { return .{ .done = true, .value = null }; }; @@ -92,17 +93,22 @@ pub fn Entry(comptime Inner: type, comptime field: ?[]const u8) type { } fn reflect(comptime Inner: type, comptime field: ?[]const u8) Reflect { - const R = @typeInfo(@TypeOf(Inner.next)).@"fn".return_type.?; + const fn_info = @typeInfo(@TypeOf(Inner.next)).@"fn"; + const R = fn_info.return_type.?; const has_error_return = @typeInfo(R) == .error_union; + // The executor type is the last parameter of inner.next (after self) + const Executor = fn_info.params[1].type.?; return .{ .has_error_return = has_error_return, .ValueType = ValueType(unwrapOptional(unwrapError(R)), field), + .Executor = Executor, }; } const Reflect = struct { has_error_return: bool, ValueType: type, + Executor: type, }; fn unwrapError(comptime T: type) type { diff --git a/src/browser/webapi/net/FormData.zig b/src/browser/webapi/net/FormData.zig index 924b065c..c7bfbc30 100644 --- a/src/browser/webapi/net/FormData.zig +++ b/src/browser/webapi/net/FormData.zig @@ -57,7 +57,7 @@ pub fn get(self: *const FormData, name: []const u8) ?[]const u8 { } pub fn getAll(self: *const FormData, name: []const u8, page: *Page) ![]const []const u8 { - return self._list.getAll(name, page); + return self._list.getAll(page.call_arena, name); } pub fn has(self: *const FormData, name: []const u8) bool { @@ -76,16 +76,16 @@ pub fn delete(self: *FormData, name: []const u8) void { self._list.delete(name, null); } -pub fn keys(self: *FormData, page: *Page) !*KeyValueList.KeyIterator { - return KeyValueList.KeyIterator.init(.{ .list = self, .kv = &self._list }, page); +pub fn keys(self: *FormData, exec: *const js.Execution) !*KeyValueList.KeyIterator { + return KeyValueList.KeyIterator.init(.{ .list = self, .kv = &self._list }, exec); } -pub fn values(self: *FormData, page: *Page) !*KeyValueList.ValueIterator { - return KeyValueList.ValueIterator.init(.{ .list = self, .kv = &self._list }, page); +pub fn values(self: *FormData, exec: *const js.Execution) !*KeyValueList.ValueIterator { + return KeyValueList.ValueIterator.init(.{ .list = self, .kv = &self._list }, exec); } -pub fn entries(self: *FormData, page: *Page) !*KeyValueList.EntryIterator { - return KeyValueList.EntryIterator.init(.{ .list = self, .kv = &self._list }, page); +pub fn entries(self: *FormData, exec: *const js.Execution) !*KeyValueList.EntryIterator { + return KeyValueList.EntryIterator.init(.{ .list = self, .kv = &self._list }, exec); } pub fn forEach(self: *FormData, cb_: js.Function, js_this_: ?js.Object) !void { diff --git a/src/browser/webapi/net/Headers.zig b/src/browser/webapi/net/Headers.zig index 62c505c6..615cc255 100644 --- a/src/browser/webapi/net/Headers.zig +++ b/src/browser/webapi/net/Headers.zig @@ -41,7 +41,7 @@ pub fn delete(self: *Headers, name: []const u8, page: *Page) void { pub fn get(self: *const Headers, name: []const u8, page: *Page) !?[]const u8 { const normalized_name = normalizeHeaderName(name, page); - const all_values = try self._list.getAll(normalized_name, page); + const all_values = try self._list.getAll(page.call_arena, normalized_name); if (all_values.len == 0) { return null; @@ -62,16 +62,16 @@ pub fn set(self: *Headers, name: []const u8, value: []const u8, page: *Page) !vo try self._list.set(page.arena, normalized_name, value); } -pub fn keys(self: *Headers, page: *Page) !*KeyValueList.KeyIterator { - return KeyValueList.KeyIterator.init(.{ .list = self, .kv = &self._list }, page); +pub fn keys(self: *Headers, exec: *const js.Execution) !*KeyValueList.KeyIterator { + return KeyValueList.KeyIterator.init(.{ .list = self, .kv = &self._list }, exec); } -pub fn values(self: *Headers, page: *Page) !*KeyValueList.ValueIterator { - return KeyValueList.ValueIterator.init(.{ .list = self, .kv = &self._list }, page); +pub fn values(self: *Headers, exec: *const js.Execution) !*KeyValueList.ValueIterator { + return KeyValueList.ValueIterator.init(.{ .list = self, .kv = &self._list }, exec); } -pub fn entries(self: *Headers, page: *Page) !*KeyValueList.EntryIterator { - return KeyValueList.EntryIterator.init(.{ .list = self, .kv = &self._list }, page); +pub fn entries(self: *Headers, exec: *const js.Execution) !*KeyValueList.EntryIterator { + return KeyValueList.EntryIterator.init(.{ .list = self, .kv = &self._list }, exec); } pub fn forEach(self: *Headers, cb_: js.Function, js_this_: ?js.Object) !void { diff --git a/src/browser/webapi/net/URLSearchParams.zig b/src/browser/webapi/net/URLSearchParams.zig index e6384914..8cd59bcf 100644 --- a/src/browser/webapi/net/URLSearchParams.zig +++ b/src/browser/webapi/net/URLSearchParams.zig @@ -26,6 +26,7 @@ const Allocator = std.mem.Allocator; const Page = @import("../../Page.zig"); const FormData = @import("FormData.zig"); const KeyValueList = @import("../KeyValueList.zig"); +const Execution = js.Execution; const URLSearchParams = @This(); @@ -38,12 +39,12 @@ const InitOpts = union(enum) { query_string: []const u8, }; -pub fn init(opts_: ?InitOpts, page: *Page) !*URLSearchParams { - const arena = page.arena; +pub fn init(opts_: ?InitOpts, exec: *const Execution) !*URLSearchParams { + const arena = exec.arena; const params: KeyValueList = blk: { const opts = opts_ orelse break :blk .empty; switch (opts) { - .query_string => |qs| break :blk try paramsFromString(arena, qs, &page.buf), + .query_string => |qs| break :blk try paramsFromString(arena, qs, exec.buf), .form_data => |fd| break :blk try KeyValueList.copy(arena, fd._list), .value => |js_val| { // Order matters here; Array is also an Object. @@ -51,24 +52,25 @@ pub fn init(opts_: ?InitOpts, page: *Page) !*URLSearchParams { break :blk try paramsFromArray(arena, js_val.toArray()); } if (js_val.isObject()) { - break :blk try KeyValueList.fromJsObject(arena, js_val.toObject(), null, page); + // normalizer is null, so page won't be used + break :blk try KeyValueList.fromJsObject(arena, js_val.toObject(), null, exec.context.page); } if (js_val.isString()) |js_str| { - break :blk try paramsFromString(arena, try js_str.toSliceWithAlloc(arena), &page.buf); + break :blk try paramsFromString(arena, try js_str.toSliceWithAlloc(arena), exec.buf); } return error.InvalidArgument; }, } }; - return page._factory.create(URLSearchParams{ + return exec._factory.create(URLSearchParams{ ._arena = arena, ._params = params, }); } -pub fn updateFromString(self: *URLSearchParams, query_string: []const u8, page: *Page) !void { - self._params = try paramsFromString(self._arena, query_string, &page.buf); +pub fn updateFromString(self: *URLSearchParams, query_string: []const u8, exec: *const Execution) !void { + self._params = try paramsFromString(self._arena, query_string, exec.buf); } pub fn getSize(self: *const URLSearchParams) usize { @@ -79,8 +81,8 @@ pub fn get(self: *const URLSearchParams, name: []const u8) ?[]const u8 { return self._params.get(name); } -pub fn getAll(self: *const URLSearchParams, name: []const u8, page: *Page) ![]const []const u8 { - return self._params.getAll(name, page); +pub fn getAll(self: *const URLSearchParams, name: []const u8, exec: *const Execution) ![]const []const u8 { + return self._params.getAll(exec.call_arena, name); } pub fn has(self: *const URLSearchParams, name: []const u8) bool { @@ -99,16 +101,16 @@ pub fn delete(self: *URLSearchParams, name: []const u8, value: ?[]const u8) void self._params.delete(name, value); } -pub fn keys(self: *URLSearchParams, page: *Page) !*KeyValueList.KeyIterator { - return KeyValueList.KeyIterator.init(.{ .list = self, .kv = &self._params }, page); +pub fn keys(self: *URLSearchParams, exec: *const Execution) !*KeyValueList.KeyIterator { + return KeyValueList.KeyIterator.init(.{ .list = self, .kv = &self._params }, exec); } -pub fn values(self: *URLSearchParams, page: *Page) !*KeyValueList.ValueIterator { - return KeyValueList.ValueIterator.init(.{ .list = self, .kv = &self._params }, page); +pub fn values(self: *URLSearchParams, exec: *const Execution) !*KeyValueList.ValueIterator { + return KeyValueList.ValueIterator.init(.{ .list = self, .kv = &self._params }, exec); } -pub fn entries(self: *URLSearchParams, page: *Page) !*KeyValueList.EntryIterator { - return KeyValueList.EntryIterator.init(.{ .list = self, .kv = &self._params }, page); +pub fn entries(self: *URLSearchParams, exec: *const Execution) !*KeyValueList.EntryIterator { + return KeyValueList.EntryIterator.init(.{ .list = self, .kv = &self._params }, exec); } pub fn toString(self: *const URLSearchParams, writer: *std.Io.Writer) !void { @@ -287,7 +289,7 @@ pub const Iterator = struct { const Entry = struct { []const u8, []const u8 }; - pub fn next(self: *Iterator, _: *Page) !?Iterator.Entry { + pub fn next(self: *Iterator, _: *const Execution) !?Iterator.Entry { const index = self.index; const items = self.list._params.items; if (index >= items.len) { @@ -325,8 +327,8 @@ pub const JsApi = struct { pub const sort = bridge.function(URLSearchParams.sort, .{}); pub const toString = bridge.function(_toString, .{}); - fn _toString(self: *const URLSearchParams, page: *Page) ![]const u8 { - var buf = std.Io.Writer.Allocating.init(page.call_arena); + fn _toString(self: *const URLSearchParams, exec: *const Execution) ![]const u8 { + var buf = std.Io.Writer.Allocating.init(exec.call_arena); try self.toString(&buf.writer); return buf.written(); } From cd2bb28c6c3ff99ccfc4558b126bf336694df371 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 3 Apr 2026 10:26:51 +0800 Subject: [PATCH 04/18] Tweak snapshot for workers We'll have two types of contexts: one for pages and one for workers. They'll [probably] both be js.Context, but they'll have distinct FunctionTemplates attached to their global. The Worker template will only contain a small subset of the main Page's types, along with 1 or 2 of its own specific ones. The Snapshot now creates the templates for both, so that the Env contains the function templates for both contexts. Furthermore, having a "merged" view like this ensures that the env.template[N] indices are consistent between the two. However, the snapshot only attaches the Page-specific types to the snapshot context. This allows the Page-context to be created as-is (e.g. efficiently). The worker context will be created lazily, on demand, but from the templates loaded into the env (since, again, the env contains templates for both). --- src/browser/js/Snapshot.zig | 231 ++++++++++++----------- src/browser/js/bridge.zig | 16 +- src/browser/webapi/EventTarget.zig | 3 + src/browser/webapi/Window.zig | 20 +- src/browser/webapi/WorkerGlobalScope.zig | 135 +++++++++++++ src/browser/webapi/encoding/base64.zig | 50 +++++ 6 files changed, 322 insertions(+), 133 deletions(-) create mode 100644 src/browser/webapi/WorkerGlobalScope.zig create mode 100644 src/browser/webapi/encoding/base64.zig diff --git a/src/browser/js/Snapshot.zig b/src/browser/js/Snapshot.zig index 0b6a7fd1..28248087 100644 --- a/src/browser/js/Snapshot.zig +++ b/src/browser/js/Snapshot.zig @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire @@ -25,6 +25,7 @@ const IS_DEBUG = @import("builtin").mode == .Debug; const v8 = js.v8; const JsApis = bridge.JsApis; +const PageJsApis = bridge.PageJsApis; const Snapshot = @This(); @@ -113,6 +114,8 @@ fn isValid(self: Snapshot) bool { } pub fn create() !Snapshot { + comptime validatePrototypeChains(&JsApis); + var external_references = collectExternalReferences(); var params: v8.CreateParams = undefined; @@ -136,7 +139,7 @@ pub fn create() !Snapshot { v8.v8__HandleScope__CONSTRUCT(&handle_scope, isolate); defer v8.v8__HandleScope__DESTRUCT(&handle_scope); - // Create templates (constructors only) FIRST + // Create templates for ALL types (JsApis) var templates: [JsApis.len]*const v8.FunctionTemplate = undefined; inline for (JsApis, 0..) |JsApi, i| { @setEvalBranchQuota(10_000); @@ -145,20 +148,17 @@ pub fn create() !Snapshot { } // Set up prototype chains BEFORE attaching properties - // This must come before attachClass so inheritance is set up first inline for (JsApis, 0..) |JsApi, i| { if (comptime protoIndexLookup(JsApi)) |proto_index| { v8.v8__FunctionTemplate__Inherit(templates[i], templates[proto_index]); } } - // Set up the global template to inherit from Window's template - // This way the global object gets all Window properties through inheritance const context = v8.v8__Context__New(isolate, null, null); v8.v8__Context__Enter(context); defer v8.v8__Context__Exit(context); - // Add templates to context snapshot + // Add ALL templates to context snapshot var last_data_index: usize = 0; inline for (JsApis, 0..) |_, i| { @setEvalBranchQuota(10_000); @@ -167,11 +167,6 @@ pub fn create() !Snapshot { data_start = data_index; last_data_index = data_index; } else { - // This isn't strictly required, but it means we only need to keep - // the first data_index. This is based on the assumption that - // addDataWithContext always increases by 1. If we ever hit this - // error, then that assumption is wrong and we should capture - // all the indexes explicitly in an array. if (data_index != last_data_index + 1) { return error.InvalidDataIndex; } @@ -179,13 +174,12 @@ pub fn create() !Snapshot { } } - // Realize all templates by getting their functions and attaching to global const global_obj = v8.v8__Context__Global(context); - inline for (JsApis, 0..) |JsApi, i| { + // Attach only PAGE types to the default context's global + inline for (PageJsApis, 0..) |JsApi, i| { + // PageJsApis[i] == JsApis[i] because the PageJsApis are position at the start of the list const func = v8.v8__FunctionTemplate__GetFunction(templates[i], context); - - // Attach to global if it has a name if (@hasDecl(JsApi.Meta, "name")) { if (@hasDecl(JsApi.Meta, "constructor_alias")) { const alias = JsApi.Meta.constructor_alias; @@ -193,12 +187,6 @@ pub fn create() !Snapshot { var maybe_result: v8.MaybeBool = undefined; v8.v8__Object__Set(global_obj, context, v8_class_name, func, &maybe_result); - // @TODO: This is wrong. This name should be registered with the - // illegalConstructorCallback. I.e. new Image() is OK, but - // new HTMLImageElement() isn't. - // But we _have_ to register the name, i.e. HTMLImageElement - // has to be registered so, for now, instead of creating another - // template, we just hook it into the constructor. const name = JsApi.Meta.name; const illegal_class_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len)); var maybe_result2: v8.MaybeBool = undefined; @@ -217,8 +205,7 @@ pub fn create() !Snapshot { } { - // If we want to overwrite the built-in console, we have to - // delete the built-in one. + // Delete built-in console so we can inject our own const console_key = v8.v8__String__NewFromUtf8(isolate, "console", v8.kNormal, 7); var maybe_deleted: v8.MaybeBool = undefined; v8.v8__Object__Delete(global_obj, context, console_key, &maybe_deleted); @@ -227,9 +214,8 @@ pub fn create() !Snapshot { } } - // This shouldn't be necessary, but it is: + // Set prototype chains on function objects // https://groups.google.com/g/v8-users/c/qAQQBmbi--8 - // TODO: see if newer V8 engines have a way around this. inline for (JsApis, 0..) |JsApi, i| { if (comptime protoIndexLookup(JsApi)) |proto_index| { const proto_func = v8.v8__FunctionTemplate__GetFunction(templates[proto_index], context); @@ -244,8 +230,7 @@ pub fn create() !Snapshot { } { - // Custom exception - // TODO: this is an horrible hack, I can't figure out how to do this cleanly. + // DOMException prototype setup const code_str = "DOMException.prototype.__proto__ = Error.prototype"; const code = v8.v8__String__NewFromUtf8(isolate, code_str.ptr, v8.kNormal, @intCast(code_str.len)); const script = v8.v8__Script__Compile(context, code, null) orelse return error.ScriptCompileFailed; @@ -265,20 +250,6 @@ pub fn create() !Snapshot { }; } -// Helper to check if a JsApi has a NamedIndexed handler -fn hasNamedIndexedGetter(comptime JsApi: type) bool { - const declarations = @typeInfo(JsApi).@"struct".decls; - inline for (declarations) |d| { - const value = @field(JsApi, d.name); - const T = @TypeOf(value); - if (T == bridge.NamedIndexed) { - return true; - } - } - return false; -} - -// Count total callbacks needed for external_references array fn countExternalReferences() comptime_int { @setEvalBranchQuota(100_000); @@ -291,23 +262,20 @@ fn countExternalReferences() comptime_int { count += 1; inline for (JsApis) |JsApi| { - // Constructor (only if explicit) if (@hasDecl(JsApi, "constructor")) { count += 1; } - // Callable (htmldda) if (@hasDecl(JsApi, "callable")) { count += 1; } - // All other callbacks const declarations = @typeInfo(JsApi).@"struct".decls; inline for (declarations) |d| { const value = @field(JsApi, d.name); const T = @TypeOf(value); if (T == bridge.Accessor) { - count += 1; // getter + count += 1; if (value.setter != null) { count += 1; } @@ -321,14 +289,13 @@ fn countExternalReferences() comptime_int { count += 1; } } else if (T == bridge.NamedIndexed) { - count += 1; // getter + count += 1; if (value.setter != null) count += 1; if (value.deleter != null) count += 1; } } } - // In debug mode, add unknown property callbacks for types without NamedIndexed if (comptime IS_DEBUG) { inline for (JsApis) |JsApi| { if (!hasNamedIndexedGetter(JsApi)) { @@ -400,7 +367,6 @@ fn collectExternalReferences() [countExternalReferences()]isize { } } - // In debug mode, collect unknown property callbacks for types without NamedIndexed if (comptime IS_DEBUG) { inline for (JsApis) |JsApi| { if (!hasNamedIndexedGetter(JsApi)) { @@ -413,34 +379,8 @@ fn collectExternalReferences() [countExternalReferences()]isize { return references; } -// Even if a struct doesn't have a `constructor` function, we still -// `generateConstructor`, because this is how we create our -// FunctionTemplate. Such classes exist, but they can't be instantiated -// via `new ClassName()` - but they could, for example, be created in -// Zig and returned from a function call, which is why we need the -// FunctionTemplate. -fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *const v8.FunctionTemplate { - const callback = blk: { - if (@hasDecl(JsApi, "constructor")) { - break :blk JsApi.constructor.func; - } - - // Use shared illegal constructor callback - break :blk illegalConstructorCallback; - }; - - const template = v8.v8__FunctionTemplate__New__DEFAULT2(isolate, callback).?; - { - const internal_field_count = comptime countInternalFields(JsApi); - if (internal_field_count > 0) { - const instance_template = v8.v8__FunctionTemplate__InstanceTemplate(template); - v8.v8__ObjectTemplate__SetInternalFieldCount(instance_template, internal_field_count); - } - } - const name_str = if (@hasDecl(JsApi.Meta, "name")) JsApi.Meta.name else @typeName(JsApi); - const class_name = v8.v8__String__NewFromUtf8(isolate, name_str.ptr, v8.kNormal, @intCast(name_str.len)); - v8.v8__FunctionTemplate__SetClassName(template, class_name); - return template; +fn protoIndexLookup(comptime JsApi: type) ?u16 { + return protoIndexLookupFor(&JsApis, JsApi); } pub fn countInternalFields(comptime JsApi: type) u8 { @@ -481,14 +421,109 @@ pub fn countInternalFields(comptime JsApi: type) u8 { return cache_count + 1; } -// Attaches JsApi members to the prototype template (normal case) +// Shared illegal constructor callback for types without explicit constructors +fn illegalConstructorCallback(raw_info: ?*const v8.FunctionCallbackInfo) callconv(.c) void { + const isolate = v8.v8__FunctionCallbackInfo__GetIsolate(raw_info); + log.warn(.js, "Illegal constructor call", .{}); + + const message = v8.v8__String__NewFromUtf8(isolate, "Illegal Constructor", v8.kNormal, 19); + const js_exception = v8.v8__Exception__TypeError(message); + + _ = v8.v8__Isolate__ThrowException(isolate, js_exception); + var return_value: v8.ReturnValue = undefined; + v8.v8__FunctionCallbackInfo__GetReturnValue(raw_info, &return_value); + v8.v8__ReturnValue__Set(return_value, js_exception); +} + +// Helper to check if a JsApi has a NamedIndexed handler (public for reuse) +fn hasNamedIndexedGetter(comptime JsApi: type) bool { + const declarations = @typeInfo(JsApi).@"struct".decls; + inline for (declarations) |d| { + const value = @field(JsApi, d.name); + const T = @TypeOf(value); + if (T == bridge.NamedIndexed) { + return true; + } + } + return false; +} + +// Generic prototype index lookup for a given API list +fn protoIndexLookupFor(comptime ApiList: []const type, comptime JsApi: type) ?u16 { + @setEvalBranchQuota(100_000); + comptime { + const T = JsApi.bridge.type; + if (!@hasField(T, "_proto")) { + return null; + } + const Ptr = std.meta.fieldInfo(T, ._proto).type; + const F = @typeInfo(Ptr).pointer.child; + // Look up in the provided API list + for (ApiList, 0..) |Api, i| { + if (Api == F.JsApi) { + return i; + } + } + @compileError("Prototype " ++ @typeName(F.JsApi) ++ " not found in API list"); + } +} + +// Validates that every type in the API list has its full prototype chain +// contained within that same list. This catches errors where a type is added +// to a snapshot but its prototype dependencies are missing. +// See bridge.AllJsApis for more information. +fn validatePrototypeChains(comptime ApiList: []const type) void { + @setEvalBranchQuota(100_000); + inline for (ApiList) |JsApi| { + const T = JsApi.bridge.type; + if (@hasField(T, "_proto")) { + const Ptr = std.meta.fieldInfo(T, ._proto).type; + const ProtoType = @typeInfo(Ptr).pointer.child; + // Verify the prototype's JsApi is in our list + var found = false; + inline for (ApiList) |Api| { + if (Api == ProtoType.JsApi) { + found = true; + break; + } + } + if (!found) { + @compileError( + @typeName(JsApi) ++ " has prototype " ++ + @typeName(ProtoType.JsApi) ++ " which is not in the API list", + ); + } + } + } +} + +// Generate a constructor template for a JsApi type (public for reuse) +pub fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *const v8.FunctionTemplate { + const callback = blk: { + if (@hasDecl(JsApi, "constructor")) { + break :blk JsApi.constructor.func; + } + break :blk illegalConstructorCallback; + }; + + const template = v8.v8__FunctionTemplate__New__DEFAULT2(isolate, callback).?; + { + const internal_field_count = comptime countInternalFields(JsApi); + if (internal_field_count > 0) { + const instance_template = v8.v8__FunctionTemplate__InstanceTemplate(template); + v8.v8__ObjectTemplate__SetInternalFieldCount(instance_template, internal_field_count); + } + } + const name_str = if (@hasDecl(JsApi.Meta, "name")) JsApi.Meta.name else @typeName(JsApi); + const class_name = v8.v8__String__NewFromUtf8(isolate, name_str.ptr, v8.kNormal, @intCast(name_str.len)); + v8.v8__FunctionTemplate__SetClassName(template, class_name); + return template; +} + +// Attach JsApi members to a template (public for reuse) fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *const v8.FunctionTemplate) void { const instance = v8.v8__FunctionTemplate__InstanceTemplate(template); const prototype = v8.v8__FunctionTemplate__PrototypeTemplate(template); - - // Create a signature that validates the receiver is an instance of this template. - // This prevents crashes when JavaScript extracts a getter/method and calls it - // with the wrong `this` (e.g., documentGetter.call(null)). const signature = v8.v8__Signature__New(isolate, template); const declarations = @typeInfo(JsApi).@"struct".decls; @@ -524,7 +559,6 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *const v8.F } if (value.static) { - // Static accessors: use Template's SetAccessorProperty v8.v8__Template__SetAccessorProperty(@ptrCast(template), js_name, getter_callback, setter_callback, attribute); } else { v8.v8__ObjectTemplate__SetAccessorProperty__Config(prototype, &.{ @@ -536,7 +570,6 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *const v8.F } }, bridge.Function => { - // For non-static functions, use the signature to validate the receiver const func_signature = if (value.static) null else signature; const function_template = v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.func, @@ -590,7 +623,7 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *const v8.F bridge.Property => { const js_value = switch (value.value) { .null => js.simpleZigValueToJs(.{ .handle = isolate }, null, true, false), - inline .bool, .int, .float, .string => |v| js.simpleZigValueToJs(.{ .handle = isolate }, v, true, false), + inline .bool, .int, .float, .string => |pv| js.simpleZigValueToJs(.{ .handle = isolate }, pv, true, false), }; const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len)); @@ -600,11 +633,10 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *const v8.F } if (value.template) { - // apply it both to the type itself (e.g. Node.Elem) v8.v8__Template__Set(@ptrCast(template), js_name, js_value, v8.ReadOnly + v8.DontDelete); } }, - bridge.Constructor => {}, // already handled in generateConstructor + bridge.Constructor => {}, else => {}, } } @@ -637,30 +669,3 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *const v8.F } } } - -fn protoIndexLookup(comptime JsApi: type) ?bridge.JsApiLookup.BackingInt { - @setEvalBranchQuota(2000); - comptime { - const T = JsApi.bridge.type; - if (!@hasField(T, "_proto")) { - return null; - } - const Ptr = std.meta.fieldInfo(T, ._proto).type; - const F = @typeInfo(Ptr).pointer.child; - return bridge.JsApiLookup.getId(F.JsApi); - } -} - -// Shared illegal constructor callback for types without explicit constructors -fn illegalConstructorCallback(raw_info: ?*const v8.FunctionCallbackInfo) callconv(.c) void { - const isolate = v8.v8__FunctionCallbackInfo__GetIsolate(raw_info); - log.warn(.js, "Illegal constructor call", .{}); - - const message = v8.v8__String__NewFromUtf8(isolate, "Illegal Constructor", v8.kNormal, 19); - const js_exception = v8.v8__Exception__TypeError(message); - - _ = v8.v8__Isolate__ThrowException(isolate, js_exception); - var return_value: v8.ReturnValue = undefined; - v8.v8__FunctionCallbackInfo__GetReturnValue(raw_info, &return_value); - v8.v8__ReturnValue__Set(return_value, js_exception); -} diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index dc22a996..6f096f49 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -566,7 +566,7 @@ fn PrototypeType(comptime T: type) ?type { return Struct(std.meta.fieldInfo(T, ._proto).type); } -fn flattenTypes(comptime Types: []const type) [countFlattenedTypes(Types)]type { +pub fn flattenTypes(comptime Types: []const type) [countFlattenedTypes(Types)]type { var index: usize = 0; var flat: [countFlattenedTypes(Types)]type = undefined; for (Types) |T| { @@ -689,7 +689,8 @@ pub const SubType = enum { webassemblymemory, }; -pub const JsApis = flattenTypes(&.{ +/// APIs for Page/Window contexts. Used by Snapshot.zig for Page snapshot creation. +pub const PageJsApis = flattenTypes(&.{ @import("../webapi/AbortController.zig"), @import("../webapi/AbortSignal.zig"), @import("../webapi/CData.zig"), @@ -885,3 +886,14 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/Selection.zig"), @import("../webapi/ImageData.zig"), }); + +/// APIs that exist only in Worker contexts (not in Page/Window). +const WorkerOnlyApis = flattenTypes(&.{ + @import("../webapi/WorkerGlobalScope.zig"), +}); + +/// Master list of ALL JS APIs across all contexts. +/// Used by Env (class IDs, templates), JsApiLookup, and anywhere that needs +/// to know about all possible types. Individual snapshots use their own +/// subsets (PageJsApis, WorkerSnapshot.JsApis). +pub const JsApis = PageJsApis ++ WorkerOnlyApis; diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index 60dfbf11..304fe584 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -34,6 +34,7 @@ pub const Type = union(enum) { generic: void, node: *@import("Node.zig"), window: *@import("Window.zig"), + worker_global_scope: *@import("WorkerGlobalScope.zig"), xhr: *@import("net/XMLHttpRequestEventTarget.zig"), abort_signal: *@import("AbortSignal.zig"), media_query_list: *@import("css/MediaQueryList.zig"), @@ -131,6 +132,7 @@ pub fn format(self: *EventTarget, writer: *std.Io.Writer) !void { .node => |n| n.format(writer), .generic => writer.writeAll(""), .window => writer.writeAll(""), + .worker_global_scope => writer.writeAll(""), .xhr => writer.writeAll(""), .abort_signal => writer.writeAll(""), .media_query_list => writer.writeAll(""), @@ -151,6 +153,7 @@ pub fn toString(self: *EventTarget) []const u8 { .node => return "[object Node]", .generic => return "[object EventTarget]", .window => return "[object Window]", + .worker_global_scope => return "[object WorkerGlobalScope]", .xhr => return "[object XMLHttpRequestEventTarget]", .abort_signal => return "[object AbortSignal]", .media_query_list => return "[object MediaQueryList]", diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index ef076663..02e46660 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -429,27 +429,11 @@ pub fn postMessage(self: *Window, message: js.Value.Temp, target_origin: ?[]cons } pub fn btoa(_: *const Window, input: []const u8, page: *Page) ![]const u8 { - const encoded_len = std.base64.standard.Encoder.calcSize(input.len); - const encoded = try page.call_arena.alloc(u8, encoded_len); - return std.base64.standard.Encoder.encode(encoded, input); + return @import("encoding/base64.zig").encode(page.call_arena, input); } pub fn atob(_: *const Window, input: []const u8, page: *Page) ![]const u8 { - const trimmed = std.mem.trim(u8, input, &std.ascii.whitespace); - // Forgiving base64 decode per WHATWG spec: - // https://infra.spec.whatwg.org/#forgiving-base64-decode - // Remove trailing padding to use standard_no_pad decoder - const unpadded = std.mem.trimRight(u8, trimmed, "="); - - // Length % 4 == 1 is invalid (can't represent valid base64) - if (unpadded.len % 4 == 1) { - return error.InvalidCharacterError; - } - - const decoded_len = std.base64.standard_no_pad.Decoder.calcSizeForSlice(unpadded) catch return error.InvalidCharacterError; - const decoded = try page.call_arena.alloc(u8, decoded_len); - std.base64.standard_no_pad.Decoder.decode(decoded, unpadded) catch return error.InvalidCharacterError; - return decoded; + return @import("encoding/base64.zig").decode(page.call_arena, input); } pub fn structuredClone(_: *const Window, value: js.Value) !js.Value { diff --git a/src/browser/webapi/WorkerGlobalScope.zig b/src/browser/webapi/WorkerGlobalScope.zig new file mode 100644 index 00000000..9ad3b0c1 --- /dev/null +++ b/src/browser/webapi/WorkerGlobalScope.zig @@ -0,0 +1,135 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../js/js.zig"); + +const base64 = @import("encoding/base64.zig"); +const Console = @import("Console.zig"); +const Crypto = @import("Crypto.zig"); +const EventTarget = @import("EventTarget.zig"); +const Performance = @import("Performance.zig"); + +const WorkerGlobalScope = @This(); + +_proto: *EventTarget, +_console: Console = .init, +_crypto: Crypto = .init, +_performance: Performance, +_on_error: ?js.Function.Global = null, +_on_rejection_handled: ?js.Function.Global = null, +_on_unhandled_rejection: ?js.Function.Global = null, + +pub fn asEventTarget(self: *WorkerGlobalScope) *EventTarget { + return self._proto; +} + +pub fn getSelf(self: *WorkerGlobalScope) *WorkerGlobalScope { + return self; +} + +pub fn getConsole(self: *WorkerGlobalScope) *Console { + return &self._console; +} + +pub fn getCrypto(self: *WorkerGlobalScope) *Crypto { + return &self._crypto; +} + +pub fn getPerformance(self: *WorkerGlobalScope) *Performance { + return &self._performance; +} + +pub fn getOnError(self: *const WorkerGlobalScope) ?js.Function.Global { + return self._on_error; +} + +pub fn setOnError(self: *WorkerGlobalScope, setter: ?FunctionSetter) void { + self._on_error = getFunctionFromSetter(setter); +} + +pub fn getOnRejectionHandled(self: *const WorkerGlobalScope) ?js.Function.Global { + return self._on_rejection_handled; +} + +pub fn setOnRejectionHandled(self: *WorkerGlobalScope, setter: ?FunctionSetter) void { + self._on_rejection_handled = getFunctionFromSetter(setter); +} + +pub fn getOnUnhandledRejection(self: *const WorkerGlobalScope) ?js.Function.Global { + return self._on_unhandled_rejection; +} + +pub fn setOnUnhandledRejection(self: *WorkerGlobalScope, setter: ?FunctionSetter) void { + self._on_unhandled_rejection = getFunctionFromSetter(setter); +} + +pub fn btoa(_: *const WorkerGlobalScope, input: []const u8, exec: *js.Execution) ![]const u8 { + return base64.encode(exec.call_arena, input); +} + +pub fn atob(_: *const WorkerGlobalScope, input: []const u8, exec: *js.Execution) ![]const u8 { + return base64.decode(exec.call_arena, input); +} + +pub fn structuredClone(_: *const WorkerGlobalScope, value: js.Value) !js.Value { + return value.structuredClone(); +} + +// TODO: importScripts - needs script loading infrastructure +// TODO: location - needs WorkerLocation +// TODO: navigator - needs WorkerNavigator +// TODO: Timer functions - need scheduler integration + +const FunctionSetter = union(enum) { + func: js.Function.Global, + anything: js.Value, +}; + +fn getFunctionFromSetter(setter_: ?FunctionSetter) ?js.Function.Global { + const setter = setter_ orelse return null; + return switch (setter) { + .func => |func| func, + .anything => null, + }; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(WorkerGlobalScope); + + pub const Meta = struct { + pub const name = "WorkerGlobalScope"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const self = bridge.accessor(WorkerGlobalScope.getSelf, null, .{}); + pub const console = bridge.accessor(WorkerGlobalScope.getConsole, null, .{}); + pub const crypto = bridge.accessor(WorkerGlobalScope.getCrypto, null, .{}); + pub const performance = bridge.accessor(WorkerGlobalScope.getPerformance, null, .{}); + + pub const onerror = bridge.accessor(WorkerGlobalScope.getOnError, WorkerGlobalScope.setOnError, .{}); + pub const onrejectionhandled = bridge.accessor(WorkerGlobalScope.getOnRejectionHandled, WorkerGlobalScope.setOnRejectionHandled, .{}); + pub const onunhandledrejection = bridge.accessor(WorkerGlobalScope.getOnUnhandledRejection, WorkerGlobalScope.setOnUnhandledRejection, .{}); + + pub const btoa = bridge.function(WorkerGlobalScope.btoa, .{}); + pub const atob = bridge.function(WorkerGlobalScope.atob, .{ .dom_exception = true }); + pub const structuredClone = bridge.function(WorkerGlobalScope.structuredClone, .{}); + + // Return false since workers don't have secure-context-only APIs + pub const isSecureContext = bridge.property(false, .{ .template = false }); +}; diff --git a/src/browser/webapi/encoding/base64.zig b/src/browser/webapi/encoding/base64.zig new file mode 100644 index 00000000..cdbe98a7 --- /dev/null +++ b/src/browser/webapi/encoding/base64.zig @@ -0,0 +1,50 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Base64 encoding/decoding helpers for btoa/atob. +//! Used by both Window and WorkerGlobalScope. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +/// Encodes input to base64 (btoa). +pub fn encode(alloc: Allocator, input: []const u8) ![]const u8 { + const encoded_len = std.base64.standard.Encoder.calcSize(input.len); + const encoded = try alloc.alloc(u8, encoded_len); + return std.base64.standard.Encoder.encode(encoded, input); +} + +/// Decodes base64 input (atob). +/// Implements forgiving base64 decode per WHATWG spec. +pub fn decode(alloc: Allocator, input: []const u8) ![]const u8 { + const trimmed = std.mem.trim(u8, input, &std.ascii.whitespace); + // Forgiving base64 decode per WHATWG spec: + // https://infra.spec.whatwg.org/#forgiving-base64-decode + // Remove trailing padding to use standard_no_pad decoder + const unpadded = std.mem.trimRight(u8, trimmed, "="); + + // Length % 4 == 1 is invalid (can't represent valid base64) + if (unpadded.len % 4 == 1) { + return error.InvalidCharacterError; + } + + const decoded_len = std.base64.standard_no_pad.Decoder.calcSizeForSlice(unpadded) catch return error.InvalidCharacterError; + const decoded = try alloc.alloc(u8, decoded_len); + std.base64.standard_no_pad.Decoder.decode(decoded, unpadded) catch return error.InvalidCharacterError; + return decoded; +} From 7ca3aa61e9d46881248f67982bdd44748972a748 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 3 Apr 2026 15:56:19 +0800 Subject: [PATCH 05/18] Make context work with Page of WGS A context can be created for either a Page or a Worker. This removes the Context.page field and replaces it with a Context.global union. --- src/browser/js/Caller.zig | 63 ++++++++++--- src/browser/js/Context.zig | 92 ++++++++++++++++--- src/browser/js/Env.zig | 34 +++++-- src/browser/js/Execution.zig | 13 +-- src/browser/js/Local.zig | 26 +++++- src/browser/js/bridge.zig | 26 ++++-- src/browser/webapi/KeyValueList.zig | 10 +- src/browser/webapi/URL.zig | 11 +-- src/browser/webapi/WorkerGlobalScope.zig | 46 +++++++--- src/browser/webapi/collections/ChildNodes.zig | 5 +- .../webapi/collections/DOMTokenList.zig | 17 ++-- src/browser/webapi/collections/NodeList.zig | 10 +- src/browser/webapi/net/Headers.zig | 20 ++-- src/browser/webapi/net/URLSearchParams.zig | 2 +- 14 files changed, 263 insertions(+), 112 deletions(-) diff --git a/src/browser/js/Caller.zig b/src/browser/js/Caller.zig index 761ed147..f0c76d14 100644 --- a/src/browser/js/Caller.zig +++ b/src/browser/js/Caller.zig @@ -21,6 +21,7 @@ const log = @import("../../log.zig"); const string = @import("../../string.zig"); const Page = @import("../Page.zig"); +const WorkerGlobalScope = @import("../webapi/WorkerGlobalScope.zig"); const js = @import("js.zig"); const Local = @import("Local.zig"); @@ -67,9 +68,15 @@ fn initWithContext(self: *Caller, ctx: *Context, v8_context: *const v8.Context) .isolate = ctx.isolate, }, .prev_local = ctx.local, - .prev_context = ctx.page.js, + .prev_context = switch (ctx.global) { + .page => |page| page.js, + .worker => |worker| worker.js, + }, }; - ctx.page.js = ctx; + switch (ctx.global) { + .page => |page| page.js = ctx, + .worker => |worker| worker.js = ctx, + } ctx.local = &self.local; } @@ -100,7 +107,10 @@ pub fn deinit(self: *Caller) void { ctx.call_depth = call_depth; ctx.local = self.prev_local; - ctx.page.js = self.prev_context; + switch (ctx.global) { + .page => |page| page.js = self.prev_context, + .worker => |worker| worker.js = self.prev_context, + } } pub const CallOpts = struct { @@ -182,7 +192,7 @@ fn _getIndex(comptime T: type, local: *const Local, func: anytype, idx: u32, inf @field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis()); @field(args, "1") = idx; if (@typeInfo(F).@"fn".params.len == 3) { - @field(args, "2") = local.ctx.page; + @field(args, "2") = getGlobalArg(@TypeOf(args.@"2"), local.ctx); } const ret = @call(.auto, func, args); return handleIndexedReturn(T, F, true, local, ret, info, opts); @@ -209,7 +219,7 @@ fn _getNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *c @field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis()); @field(args, "1") = try nameToString(local, @TypeOf(args.@"1"), name); if (@typeInfo(F).@"fn".params.len == 3) { - @field(args, "2") = local.ctx.page; + @field(args, "2") = getGlobalArg(@TypeOf(args.@"2"), local.ctx); } const ret = @call(.auto, func, args); return handleIndexedReturn(T, F, true, local, ret, info, opts); @@ -237,7 +247,7 @@ fn _setNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *c @field(args, "1") = try nameToString(local, @TypeOf(args.@"1"), name); @field(args, "2") = try local.jsValueToZig(@TypeOf(@field(args, "2")), js_value); if (@typeInfo(F).@"fn".params.len == 4) { - @field(args, "3") = local.ctx.page; + @field(args, "3") = getGlobalArg(@TypeOf(args.@"3"), local.ctx); } const ret = @call(.auto, func, args); return handleIndexedReturn(T, F, false, local, ret, info, opts); @@ -263,7 +273,7 @@ fn _deleteNamedIndex(comptime T: type, local: *const Local, func: anytype, name: @field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis()); @field(args, "1") = try nameToString(local, @TypeOf(args.@"1"), name); if (@typeInfo(F).@"fn".params.len == 3) { - @field(args, "2") = local.ctx.page; + @field(args, "2") = getGlobalArg(@TypeOf(args.@"2"), local.ctx); } const ret = @call(.auto, func, args); return handleIndexedReturn(T, F, false, local, ret, info, opts); @@ -289,7 +299,7 @@ fn _getEnumerator(comptime T: type, local: *const Local, func: anytype, info: Pr var args: ParameterTypes(F) = undefined; @field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis()); if (@typeInfo(F).@"fn".params.len == 2) { - @field(args, "1") = local.ctx.page; + @field(args, "1") = getGlobalArg(@TypeOf(args.@"1"), local.ctx); } const ret = @call(.auto, func, args); return handleIndexedReturn(T, F, true, local, ret, info, opts); @@ -448,10 +458,38 @@ fn isPage(comptime T: type) bool { return T == *Page or T == *const Page; } +fn isWorker(comptime T: type) bool { + return T == *WorkerGlobalScope or T == *const WorkerGlobalScope; +} + fn isExecution(comptime T: type) bool { return T == *js.Execution or T == *const js.Execution; } +fn getGlobalArg(comptime T: type, ctx: *Context) T { + if (comptime isPage(T)) { + return switch (ctx.global) { + .page => |page| page, + .worker => { + if (comptime IS_DEBUG) std.debug.assert(false); + unreachable; + }, + }; + } + + if (comptime isWorker(T)) { + return switch (ctx.global) { + .page => { + if (comptime IS_DEBUG) std.debug.assert(false); + unreachable; + }, + .worker => |worker| worker, + }; + } + + @compileError("Unsupported global arg type: " ++ @typeName(T)); +} + // These wrap the raw v8 C API to provide a cleaner interface. pub const FunctionCallbackInfo = struct { handle: *const v8.FunctionCallbackInfo, @@ -723,16 +761,17 @@ fn getArgs(comptime F: type, comptime offset: usize, local: *const Local, info: return args; } - // If the last parameter is the Page, set it, and exclude it + // If the last parameter is the Page or Worker, set it, and exclude it // from our params slice, because we don't want to bind it to // a JS argument - if (comptime isPage(params[params.len - 1].type.?)) { - @field(args, tupleFieldName(params.len - 1 + offset)) = local.ctx.page; + const LastParamType = params[params.len - 1].type.?; + if (comptime isPage(LastParamType) or isWorker(LastParamType)) { + @field(args, tupleFieldName(params.len - 1 + offset)) = getGlobalArg(LastParamType, local.ctx); break :blk params[0 .. params.len - 1]; } // If the last parameter is Execution, set it from the context - if (comptime isExecution(params[params.len - 1].type.?)) { + if (comptime isExecution(LastParamType)) { @field(args, tupleFieldName(params.len - 1 + offset)) = &local.ctx.execution; break :blk params[0 .. params.len - 1]; } diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 2cf5730b..d025a95d 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -30,6 +30,7 @@ const Execution = @import("Execution.zig"); const Page = @import("../Page.zig"); const Session = @import("../Session.zig"); const ScriptManager = @import("../ScriptManager.zig"); +const WorkerGlobalScope = @import("../webapi/WorkerGlobalScope.zig"); const v8 = js.v8; const Caller = js.Caller; @@ -38,12 +39,17 @@ const Allocator = std.mem.Allocator; const IS_DEBUG = @import("builtin").mode == .Debug; -// Loosely maps to a Browser Page. +// Loosely maps to a Browser Page or Worker. const Context = @This(); +pub const GlobalScope = union(enum) { + page: *Page, + worker: *WorkerGlobalScope, +}; + id: usize, env: *Env, -page: *Page, +global: GlobalScope, session: *Session, isolate: js.Isolate, @@ -264,7 +270,16 @@ pub fn toLocal(self: *Context, global: anytype) js.Local.ToLocalReturnType(@Type } pub fn getIncumbent(self: *Context) *Page { - return fromC(v8.v8__Isolate__GetIncumbentContext(self.env.isolate.handle).?).?.page; + const ctx = fromC(v8.v8__Isolate__GetIncumbentContext(self.env.isolate.handle).?).?; + return switch (ctx.global) { + .page => |page| page, + .worker => { + if (comptime IS_DEBUG) { + std.debug.assert(false); + } + unreachable; + }, + }; } pub fn stringToPersistedFunction( @@ -534,7 +549,10 @@ pub fn dynamicModuleCallback( if (resource_value.isNullOrUndefined()) { // will only be null / undefined in extreme cases (e.g. WPT tests) // where you're - break :blk self.page.base(); + break :blk switch (self.global) { + .page => |page| page.base(), + .worker => |worker| worker.base(), + }; } break :blk js.String.toSliceZ(.{ .local = &local, .handle = resource_name.? }) catch |err| { @@ -876,17 +894,26 @@ pub fn enter(self: *Context, hs: *js.HandleScope) Entered { const isolate = self.isolate; js.HandleScope.init(hs, isolate); - const page = self.page; - const original = page.js; - page.js = self; + const original = switch (self.global) { + .page => |page| blk: { + const orig = page.js; + page.js = self; + break :blk orig; + }, + .worker => |worker| blk: { + const orig = worker.js; + worker.js = self; + break :blk orig; + }, + }; const handle: *const v8.Context = @ptrCast(v8.v8__Global__Get(&self.handle, isolate.handle)); v8.v8__Context__Enter(handle); - return .{ .original = original, .handle = handle, .handle_scope = hs }; + return .{ .original = original, .handle = handle, .handle_scope = hs, .global = self.global }; } const Entered = struct { - // the context we should restore on the page + // the context we should restore on the page/worker original: *Context, // the handle of the entered context @@ -894,8 +921,13 @@ const Entered = struct { handle_scope: *js.HandleScope, + global: GlobalScope, + pub fn exit(self: Entered) void { - self.original.page.js = self.original; + switch (self.global) { + .page => |page| page.js = self.original, + .worker => |worker| worker.js = self.original, + } v8.v8__Context__Exit(self.handle); self.handle_scope.deinit(); } @@ -904,7 +936,15 @@ const Entered = struct { pub fn queueMutationDelivery(self: *Context) !void { self.enqueueMicrotask(struct { fn run(ctx: *Context) void { - ctx.page.deliverMutations(); + switch (ctx.global) { + .page => |page| page.deliverMutations(), + .worker => { + if (comptime IS_DEBUG) { + std.debug.assert(false); + } + unreachable; + }, + } } }.run); } @@ -912,7 +952,15 @@ pub fn queueMutationDelivery(self: *Context) !void { pub fn queueIntersectionChecks(self: *Context) !void { self.enqueueMicrotask(struct { fn run(ctx: *Context) void { - ctx.page.performScheduledIntersectionChecks(); + switch (ctx.global) { + .page => |page| page.performScheduledIntersectionChecks(), + .worker => { + if (comptime IS_DEBUG) { + std.debug.assert(false); + } + unreachable; + }, + } } }.run); } @@ -920,7 +968,15 @@ pub fn queueIntersectionChecks(self: *Context) !void { pub fn queueIntersectionDelivery(self: *Context) !void { self.enqueueMicrotask(struct { fn run(ctx: *Context) void { - ctx.page.deliverIntersections(); + switch (ctx.global) { + .page => |page| page.deliverIntersections(), + .worker => { + if (comptime IS_DEBUG) { + std.debug.assert(false); + } + unreachable; + }, + } } }.run); } @@ -928,7 +984,15 @@ pub fn queueIntersectionDelivery(self: *Context) !void { pub fn queueSlotchangeDelivery(self: *Context) !void { self.enqueueMicrotask(struct { fn run(ctx: *Context) void { - ctx.page.deliverSlotchangeEvents(); + switch (ctx.global) { + .page => |page| page.deliverSlotchangeEvents(), + .worker => { + if (comptime IS_DEBUG) { + std.debug.assert(false); + } + unreachable; + }, + } } }.run); } diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index eaecee85..6d5431ea 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -316,7 +316,7 @@ pub fn createContext(self: *Env, page: *Page, params: ContextParams) !*Context { const context = try context_arena.create(Context); context.* = .{ .env = self, - .page = page, + .global = .{ .page = page }, .origin = origin, .id = context_id, .session = session, @@ -332,8 +332,16 @@ pub fn createContext(self: *Env, page: *Page, params: ContextParams) !*Context { .identity_arena = params.identity_arena, .execution = undefined, }; - // Initialize execution after context is created since it contains self-references - context.execution = js.Execution.fromContext(context); + + context.execution = .{ + .buf = &page.buf, + .context = context, + .arena = page.arena, + .call_arena = params.call_arena, + ._factory = page._factory, + ._scheduler = &context.scheduler, + .url = &page.url, + }; { // Multiple contexts can be created for the same Window (via CDP). We only @@ -531,13 +539,19 @@ fn promiseRejectCallback(message_handle: v8.PromiseRejectMessage) callconv(.c) v .call_arena = ctx.call_arena, }; - const page = ctx.page; - page.window.unhandledPromiseRejection(promise_event == v8.kPromiseRejectWithNoHandler, .{ - .local = &local, - .handle = &message_handle, - }, page) catch |err| { - log.warn(.browser, "unhandled rejection handler", .{ .err = err }); - }; + switch (ctx.global) { + .page => |page| { + page.window.unhandledPromiseRejection(promise_event == v8.kPromiseRejectWithNoHandler, .{ + .local = &local, + .handle = &message_handle, + }, page) catch |err| { + log.warn(.browser, "unhandled rejection handler", .{ .err = err }); + }; + }, + .worker => { + // TODO: Worker promise rejection handling + }, + } } fn fatalCallback(c_location: [*c]const u8, c_message: [*c]const u8) callconv(.c) void { diff --git a/src/browser/js/Execution.zig b/src/browser/js/Execution.zig index 6c807aa2..7d6ad6fd 100644 --- a/src/browser/js/Execution.zig +++ b/src/browser/js/Execution.zig @@ -43,14 +43,5 @@ call_arena: Allocator, _scheduler: *Scheduler, buf: []u8, -pub fn fromContext(ctx: *Context) Execution { - const page = ctx.page; - return .{ - .context = ctx, - ._factory = page._factory, - .arena = page.arena, - .call_arena = ctx.call_arena, - ._scheduler = &ctx.scheduler, - .buf = &page.buf, - }; -} +// Pointer to the url field (Page or WorkerGlobalScope) - allows access to current url even after navigation +url: *[:0]const u8, diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index 213f7af2..2db5ba65 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -332,7 +332,18 @@ pub fn zigValueToJs(self: *const Local, value: anytype, comptime opts: CallOpts) } if (@typeInfo(ptr.child) == .@"struct" and @hasDecl(ptr.child, "runtimeGenericWrap")) { - const wrap = try value.runtimeGenericWrap(self.ctx.page); + const page = switch (self.ctx.global) { + .page => |p| p, + .worker => { + // No Worker-related API currently uses this, so haven't + // added support for it + if (comptime IS_DEBUG) { + std.debug.assert(false); + } + unreachable; + }, + }; + const wrap = try value.runtimeGenericWrap(page); return self.zigValueToJs(wrap, opts); } @@ -409,7 +420,18 @@ pub fn zigValueToJs(self: *const Local, value: anytype, comptime opts: CallOpts) // zig fmt: on if (@hasDecl(T, "runtimeGenericWrap")) { - const wrap = try value.runtimeGenericWrap(self.ctx.page); + const page = switch (self.ctx.global) { + .page => |p| p, + .worker => { + // No Worker-related API currently uses this, so haven't + // added support for it + if (comptime IS_DEBUG) { + std.debug.assert(false); + } + unreachable; + }, + }; + const wrap = try value.runtimeGenericWrap(page); return self.zigValueToJs(wrap, opts); } diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 6f096f49..0e2176b0 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -414,14 +414,18 @@ pub fn unknownWindowPropertyCallback(c_name: ?*const v8.Name, handle: ?*const v8 return 0; }; - const page = local.ctx.page; - const document = page.document; - - if (document.getElementById(property, page)) |el| { - const js_val = local.zigValueToJs(el, .{}) catch return 0; - var pc = Caller.PropertyCallbackInfo{ .handle = handle.? }; - pc.getReturnValue().set(js_val); - return 1; + // Only Page contexts have document.getElementById lookup + switch (local.ctx.global) { + .page => |page| { + const document = page.document; + if (document.getElementById(property, page)) |el| { + const js_val = local.zigValueToJs(el, .{}) catch return 0; + var pc = Caller.PropertyCallbackInfo{ .handle = handle.? }; + pc.getReturnValue().set(js_val); + return 1; + } + }, + .worker => {}, // no global lookup in a worker } if (comptime IS_DEBUG) { @@ -459,7 +463,8 @@ pub fn unknownWindowPropertyCallback(c_name: ?*const v8.Name, handle: ?*const v8 .{ "ApplePaySession", {} }, }); if (!ignored.has(property)) { - const key = std.fmt.bufPrint(&local.ctx.page.buf, "Window:{s}", .{property}) catch return 0; + var buf: [2048]u8 = undefined; + const key = std.fmt.bufPrint(&buf, "Window:{s}", .{property}) catch return 0; logUnknownProperty(local, key) catch return 0; } } @@ -524,7 +529,8 @@ pub fn unknownObjectPropertyCallback(comptime JsApi: type) *const fn (?*const v8 const ignored = std.StaticStringMap(void).initComptime(.{}); if (!ignored.has(property)) { - const key = std.fmt.bufPrint(&local.ctx.page.buf, "{s}:{s}", .{ if (@hasDecl(JsApi.Meta, "name")) JsApi.Meta.name else @typeName(JsApi), property }) catch return 0; + var buf: [2048]u8 = undefined; + const key = std.fmt.bufPrint(&buf, "{s}:{s}", .{ if (@hasDecl(JsApi.Meta, "name")) JsApi.Meta.name else @typeName(JsApi), property }) catch return 0; logUnknownProperty(local, key) catch return 0; } // not intercepted diff --git a/src/browser/webapi/KeyValueList.zig b/src/browser/webapi/KeyValueList.zig index 671e3604..7a19c15b 100644 --- a/src/browser/webapi/KeyValueList.zig +++ b/src/browser/webapi/KeyValueList.zig @@ -35,7 +35,7 @@ pub fn registerTypes() []const type { }; } -const Normalizer = *const fn ([]const u8, *Page) []const u8; +const Normalizer = *const fn ([]const u8, []u8) []const u8; pub const Entry = struct { name: String, @@ -63,14 +63,14 @@ pub fn copy(arena: Allocator, original: KeyValueList) !KeyValueList { return list; } -pub fn fromJsObject(arena: Allocator, js_obj: js.Object, comptime normalizer: ?Normalizer, page: *Page) !KeyValueList { +pub fn fromJsObject(arena: Allocator, js_obj: js.Object, comptime normalizer: ?Normalizer, buf: []u8) !KeyValueList { var it = try js_obj.nameIterator(); var list = KeyValueList.init(); try list.ensureTotalCapacity(arena, it.count); while (try it.next()) |name| { const js_value = try js_obj.get(name); - const normalized = if (comptime normalizer) |n| n(name, page) else name; + const normalized = if (comptime normalizer) |n| n(name, buf) else name; list._entries.appendAssumeCapacity(.{ .name = try String.init(arena, normalized, .{}), @@ -81,12 +81,12 @@ pub fn fromJsObject(arena: Allocator, js_obj: js.Object, comptime normalizer: ?N return list; } -pub fn fromArray(arena: Allocator, kvs: []const [2][]const u8, comptime normalizer: ?Normalizer, page: *Page) !KeyValueList { +pub fn fromArray(arena: Allocator, kvs: []const [2][]const u8, comptime normalizer: ?Normalizer, buf: []u8) !KeyValueList { var list = KeyValueList.init(); try list.ensureTotalCapacity(arena, kvs.len); for (kvs) |pair| { - const normalized = if (comptime normalizer) |n| n(pair[0], page) else pair[0]; + const normalized = if (comptime normalizer) |n| n(pair[0], buf) else pair[0]; list._entries.appendAssumeCapacity(.{ .name = try String.init(arena, normalized, .{}), diff --git a/src/browser/webapi/URL.zig b/src/browser/webapi/URL.zig index 3070b7a1..c2d40765 100644 --- a/src/browser/webapi/URL.zig +++ b/src/browser/webapi/URL.zig @@ -39,7 +39,7 @@ pub const eqlDocument = @import("../URL.zig").eqlDocument; pub fn init(url: [:0]const u8, base_: ?[:0]const u8, exec: *const Execution) !*URL { const arena = exec.arena; - const page = exec.context.page; + const context_url = exec.url.*; if (std.mem.eql(u8, url, "about:blank")) { return exec._factory.create(URL{ @@ -50,9 +50,9 @@ pub fn init(url: [:0]const u8, base_: ?[:0]const u8, exec: *const Execution) !*U const url_is_absolute = @import("../URL.zig").isCompleteHTTPUrl(url); const base = if (base_) |b| blk: { - // If URL is absolute, base is ignored (but we still use page.url internally) + // If URL is absolute, base is ignored (but we still use context url internally) if (url_is_absolute) { - break :blk page.url; + break :blk context_url; } // For relative URLs, base must be a valid absolute URL if (!@import("../URL.zig").isCompleteHTTPUrl(b)) { @@ -61,7 +61,7 @@ pub fn init(url: [:0]const u8, base_: ?[:0]const u8, exec: *const Execution) !*U break :blk b; } else if (!url_is_absolute) { return error.TypeError; - } else page.url; + } else context_url; const raw = try resolve(arena, base, url, .{ .always_dupe = true }); @@ -149,8 +149,7 @@ pub fn getSearchParams(self: *URL, exec: *const Execution) !*URLSearchParams { } pub fn setHref(self: *URL, value: []const u8, exec: *const Execution) !void { - const page = exec.context.page; - const base = if (U.isCompleteHTTPUrl(value)) page.url else self._raw; + const base = if (U.isCompleteHTTPUrl(value)) exec.url.* else self._raw; const raw = try U.resolve(self._arena orelse exec.arena, base, value, .{ .always_dupe = true }); self._raw = raw; diff --git a/src/browser/webapi/WorkerGlobalScope.zig b/src/browser/webapi/WorkerGlobalScope.zig index 9ad3b0c1..6ea9bd90 100644 --- a/src/browser/webapi/WorkerGlobalScope.zig +++ b/src/browser/webapi/WorkerGlobalScope.zig @@ -16,23 +16,41 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -const js = @import("../js/js.zig"); +const std = @import("std"); +const JS = @import("../js/js.zig"); const base64 = @import("encoding/base64.zig"); const Console = @import("Console.zig"); const Crypto = @import("Crypto.zig"); const EventTarget = @import("EventTarget.zig"); +const Factory = @import("../Factory.zig"); const Performance = @import("Performance.zig"); +const Session = @import("../Session.zig"); + +const Allocator = std.mem.Allocator; const WorkerGlobalScope = @This(); +// Infrastructure fields (similar to Page) +_session: *Session, +_factory: *Factory, +arena: Allocator, +url: [:0]const u8, +buf: [1024]u8 = undefined, // same size as page.buf +js: *JS.Context = undefined, + +// WebAPI fields _proto: *EventTarget, _console: Console = .init, _crypto: Crypto = .init, _performance: Performance, -_on_error: ?js.Function.Global = null, -_on_rejection_handled: ?js.Function.Global = null, -_on_unhandled_rejection: ?js.Function.Global = null, +_on_error: ?JS.Function.Global = null, +_on_rejection_handled: ?JS.Function.Global = null, +_on_unhandled_rejection: ?JS.Function.Global = null, + +pub fn base(self: *const WorkerGlobalScope) [:0]const u8 { + return self.url; +} pub fn asEventTarget(self: *WorkerGlobalScope) *EventTarget { return self._proto; @@ -54,7 +72,7 @@ pub fn getPerformance(self: *WorkerGlobalScope) *Performance { return &self._performance; } -pub fn getOnError(self: *const WorkerGlobalScope) ?js.Function.Global { +pub fn getOnError(self: *const WorkerGlobalScope) ?JS.Function.Global { return self._on_error; } @@ -62,7 +80,7 @@ pub fn setOnError(self: *WorkerGlobalScope, setter: ?FunctionSetter) void { self._on_error = getFunctionFromSetter(setter); } -pub fn getOnRejectionHandled(self: *const WorkerGlobalScope) ?js.Function.Global { +pub fn getOnRejectionHandled(self: *const WorkerGlobalScope) ?JS.Function.Global { return self._on_rejection_handled; } @@ -70,7 +88,7 @@ pub fn setOnRejectionHandled(self: *WorkerGlobalScope, setter: ?FunctionSetter) self._on_rejection_handled = getFunctionFromSetter(setter); } -pub fn getOnUnhandledRejection(self: *const WorkerGlobalScope) ?js.Function.Global { +pub fn getOnUnhandledRejection(self: *const WorkerGlobalScope) ?JS.Function.Global { return self._on_unhandled_rejection; } @@ -78,15 +96,15 @@ pub fn setOnUnhandledRejection(self: *WorkerGlobalScope, setter: ?FunctionSetter self._on_unhandled_rejection = getFunctionFromSetter(setter); } -pub fn btoa(_: *const WorkerGlobalScope, input: []const u8, exec: *js.Execution) ![]const u8 { +pub fn btoa(_: *const WorkerGlobalScope, input: []const u8, exec: *JS.Execution) ![]const u8 { return base64.encode(exec.call_arena, input); } -pub fn atob(_: *const WorkerGlobalScope, input: []const u8, exec: *js.Execution) ![]const u8 { +pub fn atob(_: *const WorkerGlobalScope, input: []const u8, exec: *JS.Execution) ![]const u8 { return base64.decode(exec.call_arena, input); } -pub fn structuredClone(_: *const WorkerGlobalScope, value: js.Value) !js.Value { +pub fn structuredClone(_: *const WorkerGlobalScope, value: JS.Value) !JS.Value { return value.structuredClone(); } @@ -96,11 +114,11 @@ pub fn structuredClone(_: *const WorkerGlobalScope, value: js.Value) !js.Value { // TODO: Timer functions - need scheduler integration const FunctionSetter = union(enum) { - func: js.Function.Global, - anything: js.Value, + func: JS.Function.Global, + anything: JS.Value, }; -fn getFunctionFromSetter(setter_: ?FunctionSetter) ?js.Function.Global { +fn getFunctionFromSetter(setter_: ?FunctionSetter) ?JS.Function.Global { const setter = setter_ orelse return null; return switch (setter) { .func => |func| func, @@ -109,7 +127,7 @@ fn getFunctionFromSetter(setter_: ?FunctionSetter) ?js.Function.Global { } pub const JsApi = struct { - pub const bridge = js.Bridge(WorkerGlobalScope); + pub const bridge = JS.Bridge(WorkerGlobalScope); pub const Meta = struct { pub const name = "WorkerGlobalScope"; diff --git a/src/browser/webapi/collections/ChildNodes.zig b/src/browser/webapi/collections/ChildNodes.zig index 15c33210..ee64764f 100644 --- a/src/browser/webapi/collections/ChildNodes.zig +++ b/src/browser/webapi/collections/ChildNodes.zig @@ -23,7 +23,6 @@ const Node = @import("../Node.zig"); const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); const GenericIterator = @import("iterator.zig").Entry; -const Execution = js.Execution; // Optimized for node.childNodes, which has to be a live list. // No need to go through a TreeWalker or add any filtering. @@ -142,9 +141,9 @@ const Iterator = struct { const Entry = struct { u32, *Node }; - pub fn next(self: *Iterator, exec: *const Execution) !?Entry { + pub fn next(self: *Iterator, page: *const Page) !?Entry { const index = self.index; - const node = try self.list.getAtIndex(index, exec.context.page) orelse return null; + const node = try self.list.getAtIndex(index, page) orelse return null; self.index = index + 1; return .{ index, node }; } diff --git a/src/browser/webapi/collections/DOMTokenList.zig b/src/browser/webapi/collections/DOMTokenList.zig index a0409bcf..cab25447 100644 --- a/src/browser/webapi/collections/DOMTokenList.zig +++ b/src/browser/webapi/collections/DOMTokenList.zig @@ -24,7 +24,6 @@ const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Element = @import("../Element.zig"); const GenericIterator = @import("iterator.zig").Entry; -const Execution = js.Execution; pub const DOMTokenList = @This(); @@ -203,16 +202,16 @@ pub fn setValue(self: *DOMTokenList, value: String, page: *Page) !void { try self._element.setAttribute(self._attribute_name, value, page); } -pub fn keys(self: *DOMTokenList, exec: *const Execution) !*KeyIterator { - return .init(.{ .list = self }, exec); +pub fn keys(self: *DOMTokenList, page: *Page) !*KeyIterator { + return .init(.{ .list = self }, page); } -pub fn values(self: *DOMTokenList, exec: *const Execution) !*ValueIterator { - return .init(.{ .list = self }, exec); +pub fn values(self: *DOMTokenList, page: *Page) !*ValueIterator { + return .init(.{ .list = self }, page); } -pub fn entries(self: *DOMTokenList, exec: *const Execution) !*EntryIterator { - return .init(.{ .list = self }, exec); +pub fn entries(self: *DOMTokenList, page: *Page) !*EntryIterator { + return .init(.{ .list = self }, page); } pub fn forEach(self: *DOMTokenList, cb_: js.Function, js_this_: ?js.Object, page: *Page) !void { @@ -282,9 +281,9 @@ const Iterator = struct { const Entry = struct { u32, []const u8 }; - pub fn next(self: *Iterator, exec: *const Execution) !?Entry { + pub fn next(self: *Iterator, page: *Page) !?Entry { const index = self.index; - const node = try self.list.item(index, exec.context.page) orelse return null; + const node = try self.list.item(index, page) orelse return null; self.index = index + 1; return .{ index, node }; } diff --git a/src/browser/webapi/collections/NodeList.zig b/src/browser/webapi/collections/NodeList.zig index 8ff1c3ef..82e23f9e 100644 --- a/src/browser/webapi/collections/NodeList.zig +++ b/src/browser/webapi/collections/NodeList.zig @@ -24,7 +24,6 @@ const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); const Node = @import("../Node.zig"); -const Execution = js.Execution; const ChildNodes = @import("ChildNodes.zig"); const RadioNodeList = @import("RadioNodeList.zig"); @@ -80,7 +79,7 @@ pub fn getAtIndex(self: *NodeList, index: usize, page: *Page) !?*Node { } pub fn keys(self: *NodeList, page: *Page) !*KeyIterator { - return .init(.{ .list = self }, page.js.execution); + return .init(.{ .list = self }, page); } pub fn values(self: *NodeList, page: *Page) !*ValueIterator { @@ -88,7 +87,7 @@ pub fn values(self: *NodeList, page: *Page) !*ValueIterator { } pub fn entries(self: *NodeList, page: *Page) !*EntryIterator { - return .init(.{ .list = self }, page.js.execution); + return .init(.{ .list = self }, page); } pub fn forEach(self: *NodeList, cb: js.Function, page: *Page) !void { @@ -103,6 +102,7 @@ pub fn forEach(self: *NodeList, cb: js.Function, page: *Page) !void { }; } } +} const GenericIterator = @import("iterator.zig").Entry; pub const KeyIterator = GenericIterator(Iterator, "0"); @@ -127,9 +127,9 @@ const Iterator = struct { self.list.acquireRef(); } - pub fn next(self: *Iterator, exec: *const Execution) !?Entry { + pub fn next(self: *Iterator, page: *Page) !?Entry { const index = self.index; - const node = try self.list.getAtIndex(index, exec.context.page) orelse return null; + const node = try self.list.getAtIndex(index, page) orelse return null; self.index = index + 1; return .{ index, node }; } diff --git a/src/browser/webapi/net/Headers.zig b/src/browser/webapi/net/Headers.zig index 615cc255..438e3532 100644 --- a/src/browser/webapi/net/Headers.zig +++ b/src/browser/webapi/net/Headers.zig @@ -20,8 +20,8 @@ pub const InitOpts = union(enum) { pub fn init(opts_: ?InitOpts, page: *Page) !*Headers { const list = if (opts_) |opts| switch (opts) { .obj => |obj| try KeyValueList.copy(page.arena, obj._list), - .js_obj => |js_obj| try KeyValueList.fromJsObject(page.arena, js_obj, normalizeHeaderName, page), - .strings => |kvs| try KeyValueList.fromArray(page.arena, kvs, normalizeHeaderName, page), + .js_obj => |js_obj| try KeyValueList.fromJsObject(page.arena, js_obj, normalizeHeaderName, &page.buf), + .strings => |kvs| try KeyValueList.fromArray(page.arena, kvs, normalizeHeaderName, &page.buf), } else KeyValueList.init(); return page._factory.create(Headers{ @@ -30,17 +30,17 @@ pub fn init(opts_: ?InitOpts, page: *Page) !*Headers { } pub fn append(self: *Headers, name: []const u8, value: []const u8, page: *Page) !void { - const normalized_name = normalizeHeaderName(name, page); + const normalized_name = normalizeHeaderName(name, &page.buf); try self._list.append(page.arena, normalized_name, value); } pub fn delete(self: *Headers, name: []const u8, page: *Page) void { - const normalized_name = normalizeHeaderName(name, page); + const normalized_name = normalizeHeaderName(name, &page.buf); self._list.delete(normalized_name, null); } pub fn get(self: *const Headers, name: []const u8, page: *Page) !?[]const u8 { - const normalized_name = normalizeHeaderName(name, page); + const normalized_name = normalizeHeaderName(name, &page.buf); const all_values = try self._list.getAll(page.call_arena, normalized_name); if (all_values.len == 0) { @@ -53,12 +53,12 @@ pub fn get(self: *const Headers, name: []const u8, page: *Page) !?[]const u8 { } pub fn has(self: *const Headers, name: []const u8, page: *Page) bool { - const normalized_name = normalizeHeaderName(name, page); + const normalized_name = normalizeHeaderName(name, &page.buf); return self._list.has(normalized_name); } pub fn set(self: *Headers, name: []const u8, value: []const u8, page: *Page) !void { - const normalized_name = normalizeHeaderName(name, page); + const normalized_name = normalizeHeaderName(name, &page.buf); try self._list.set(page.arena, normalized_name, value); } @@ -94,11 +94,11 @@ pub fn populateHttpHeader(self: *Headers, allocator: Allocator, http_headers: *h } } -fn normalizeHeaderName(name: []const u8, page: *Page) []const u8 { - if (name.len > page.buf.len) { +fn normalizeHeaderName(name: []const u8, buf: []u8) []const u8 { + if (name.len > buf.len) { return name; } - return std.ascii.lowerString(&page.buf, name); + return std.ascii.lowerString(buf, name); } pub const JsApi = struct { diff --git a/src/browser/webapi/net/URLSearchParams.zig b/src/browser/webapi/net/URLSearchParams.zig index 8cd59bcf..95c0594e 100644 --- a/src/browser/webapi/net/URLSearchParams.zig +++ b/src/browser/webapi/net/URLSearchParams.zig @@ -53,7 +53,7 @@ pub fn init(opts_: ?InitOpts, exec: *const Execution) !*URLSearchParams { } if (js_val.isObject()) { // normalizer is null, so page won't be used - break :blk try KeyValueList.fromJsObject(arena, js_val.toObject(), null, exec.context.page); + break :blk try KeyValueList.fromJsObject(arena, js_val.toObject(), null, exec.buf); } if (js_val.isString()) |js_str| { break :blk try paramsFromString(arena, try js_str.toSliceWithAlloc(arena), exec.buf); From 951986b6551baaaf9b4424d4ed38a458f79a5f46 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 3 Apr 2026 19:47:07 +0800 Subject: [PATCH 06/18] Create Worker context in snapshot Turns out you can embed multiple contexts within a snapshot. So our snapshot now contains the Page context (as before) but also the Worker context. This gives us the performance benefit of snapshots and makes context creation for pages and workers much more similar. --- src/browser/js/Caller.zig | 42 +--- src/browser/js/Context.zig | 73 +++---- src/browser/js/Env.zig | 178 ++++++++-------- src/browser/js/Execution.zig | 4 +- src/browser/js/Local.zig | 6 - src/browser/js/Snapshot.zig | 258 ++++++++++++++--------- src/browser/js/bridge.zig | 41 +++- src/browser/webapi/Location.zig | 12 +- src/browser/webapi/Window.zig | 2 +- src/browser/webapi/WorkerGlobalScope.zig | 3 +- 10 files changed, 328 insertions(+), 291 deletions(-) diff --git a/src/browser/js/Caller.zig b/src/browser/js/Caller.zig index f0c76d14..08ab845f 100644 --- a/src/browser/js/Caller.zig +++ b/src/browser/js/Caller.zig @@ -68,15 +68,9 @@ fn initWithContext(self: *Caller, ctx: *Context, v8_context: *const v8.Context) .isolate = ctx.isolate, }, .prev_local = ctx.local, - .prev_context = switch (ctx.global) { - .page => |page| page.js, - .worker => |worker| worker.js, - }, + .prev_context = ctx.global.getJs(), }; - switch (ctx.global) { - .page => |page| page.js = ctx, - .worker => |worker| worker.js = ctx, - } + ctx.global.setJs(ctx); ctx.local = &self.local; } @@ -107,10 +101,7 @@ pub fn deinit(self: *Caller) void { ctx.call_depth = call_depth; ctx.local = self.prev_local; - switch (ctx.global) { - .page => |page| page.js = self.prev_context, - .worker => |worker| worker.js = self.prev_context, - } + ctx.global.setJs(self.prev_context); } pub const CallOpts = struct { @@ -458,10 +449,6 @@ fn isPage(comptime T: type) bool { return T == *Page or T == *const Page; } -fn isWorker(comptime T: type) bool { - return T == *WorkerGlobalScope or T == *const WorkerGlobalScope; -} - fn isExecution(comptime T: type) bool { return T == *js.Execution or T == *const js.Execution; } @@ -470,21 +457,12 @@ fn getGlobalArg(comptime T: type, ctx: *Context) T { if (comptime isPage(T)) { return switch (ctx.global) { .page => |page| page, - .worker => { - if (comptime IS_DEBUG) std.debug.assert(false); - unreachable; - }, + .worker => unreachable, }; } - if (comptime isWorker(T)) { - return switch (ctx.global) { - .page => { - if (comptime IS_DEBUG) std.debug.assert(false); - unreachable; - }, - .worker => |worker| worker, - }; + if (comptime isExecution(T)) { + return &ctx.execution; } @compileError("Unsupported global arg type: " ++ @typeName(T)); @@ -765,17 +743,11 @@ fn getArgs(comptime F: type, comptime offset: usize, local: *const Local, info: // from our params slice, because we don't want to bind it to // a JS argument const LastParamType = params[params.len - 1].type.?; - if (comptime isPage(LastParamType) or isWorker(LastParamType)) { + if (comptime isPage(LastParamType) or isExecution(LastParamType)) { @field(args, tupleFieldName(params.len - 1 + offset)) = getGlobalArg(LastParamType, local.ctx); break :blk params[0 .. params.len - 1]; } - // If the last parameter is Execution, set it from the context - if (comptime isExecution(LastParamType)) { - @field(args, tupleFieldName(params.len - 1 + offset)) = &local.ctx.execution; - break :blk params[0 .. params.len - 1]; - } - // we have neither a Page, Execution, nor a JsObject. All params must be // bound to a JavaScript value. break :blk params; diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index d025a95d..8e1b6d0e 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -45,6 +45,27 @@ const Context = @This(); pub const GlobalScope = union(enum) { page: *Page, worker: *WorkerGlobalScope, + + pub fn base(self: GlobalScope) [:0]const u8 { + return switch (self) { + .page => |page| page.base(), + .worker => |worker| worker.base(), + }; + } + + pub fn getJs(self: GlobalScope) *Context { + return switch (self) { + .page => |page| page.js, + .worker => |worker| worker.js, + }; + } + + pub fn setJs(self: GlobalScope, ctx: *Context) void { + switch (self) { + .page => |page| page.js = ctx, + .worker => |worker| worker.js = ctx, + } + } }; id: usize, @@ -549,10 +570,7 @@ pub fn dynamicModuleCallback( if (resource_value.isNullOrUndefined()) { // will only be null / undefined in extreme cases (e.g. WPT tests) // where you're - break :blk switch (self.global) { - .page => |page| page.base(), - .worker => |worker| worker.base(), - }; + break :blk self.global.base(); } break :blk js.String.toSliceZ(.{ .local = &local, .handle = resource_name.? }) catch |err| { @@ -894,18 +912,8 @@ pub fn enter(self: *Context, hs: *js.HandleScope) Entered { const isolate = self.isolate; js.HandleScope.init(hs, isolate); - const original = switch (self.global) { - .page => |page| blk: { - const orig = page.js; - page.js = self; - break :blk orig; - }, - .worker => |worker| blk: { - const orig = worker.js; - worker.js = self; - break :blk orig; - }, - }; + const original = self.global.getJs(); + self.global.setJs(self); const handle: *const v8.Context = @ptrCast(v8.v8__Global__Get(&self.handle, isolate.handle)); v8.v8__Context__Enter(handle); @@ -924,10 +932,7 @@ const Entered = struct { global: GlobalScope, pub fn exit(self: Entered) void { - switch (self.global) { - .page => |page| page.js = self.original, - .worker => |worker| worker.js = self.original, - } + self.global.setJs(self.original); v8.v8__Context__Exit(self.handle); self.handle_scope.deinit(); } @@ -938,12 +943,7 @@ pub fn queueMutationDelivery(self: *Context) !void { fn run(ctx: *Context) void { switch (ctx.global) { .page => |page| page.deliverMutations(), - .worker => { - if (comptime IS_DEBUG) { - std.debug.assert(false); - } - unreachable; - }, + .worker => unreachable, } } }.run); @@ -954,12 +954,7 @@ pub fn queueIntersectionChecks(self: *Context) !void { fn run(ctx: *Context) void { switch (ctx.global) { .page => |page| page.performScheduledIntersectionChecks(), - .worker => { - if (comptime IS_DEBUG) { - std.debug.assert(false); - } - unreachable; - }, + .worker => unreachable, } } }.run); @@ -970,12 +965,7 @@ pub fn queueIntersectionDelivery(self: *Context) !void { fn run(ctx: *Context) void { switch (ctx.global) { .page => |page| page.deliverIntersections(), - .worker => { - if (comptime IS_DEBUG) { - std.debug.assert(false); - } - unreachable; - }, + .worker => unreachable, } } }.run); @@ -986,12 +976,7 @@ pub fn queueSlotchangeDelivery(self: *Context) !void { fn run(ctx: *Context) void { switch (ctx.global) { .page => |page| page.deliverSlotchangeEvents(), - .worker => { - if (comptime IS_DEBUG) { - std.debug.assert(false); - } - unreachable; - }, + .worker => unreachable, } } }.run); diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index 6d5431ea..3dfb32d4 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -34,6 +34,7 @@ const Inspector = @import("Inspector.zig"); const Page = @import("../Page.zig"); const Window = @import("../webapi/Window.zig"); +const WorkerGlobalScope = @import("../webapi/WorkerGlobalScope.zig"); const JsApis = bridge.JsApis; const Allocator = std.mem.Allocator; @@ -83,9 +84,6 @@ eternal_function_templates: []v8.Eternal, // Dynamic slice to avoid circular dependency on JsApis.len at comptime templates: []*const v8.FunctionTemplate, -// Global template created once per isolate and reused across all contexts -global_template: v8.Eternal, - // Inspector associated with the Isolate. Exists when CDP is being used. inspector: ?*Inspector, @@ -146,7 +144,6 @@ pub fn init(app: *App, opts: InitOpts) !Env { const templates = try allocator.alloc(*const v8.FunctionTemplate, JsApis.len); errdefer allocator.free(templates); - var global_eternal: v8.Eternal = undefined; var private_symbols: PrivateSymbols = undefined; { var temp_scope: js.HandleScope = undefined; @@ -164,44 +161,6 @@ pub fn init(app: *App, opts: InitOpts) !Env { templates[i] = @ptrCast(@alignCast(eternal_ptr.?)); } - // Create global template once per isolate - const js_global = v8.v8__FunctionTemplate__New__DEFAULT(isolate_handle); - const window_name = v8.v8__String__NewFromUtf8(isolate_handle, "Window", v8.kNormal, 6); - v8.v8__FunctionTemplate__SetClassName(js_global, window_name); - - // Find Window in JsApis by name (avoids circular import) - const window_index = comptime bridge.JsApiLookup.getId(Window.JsApi); - v8.v8__FunctionTemplate__Inherit(js_global, templates[window_index]); - - const global_template_local = v8.v8__FunctionTemplate__InstanceTemplate(js_global).?; - v8.v8__ObjectTemplate__SetNamedHandler(global_template_local, &.{ - .getter = bridge.unknownWindowPropertyCallback, - .setter = null, - .query = null, - .deleter = null, - .enumerator = null, - .definer = null, - .descriptor = null, - .data = null, - .flags = v8.kOnlyInterceptStrings | v8.kNonMasking, - }); - // I don't 100% understand this. We actually set this up in the snapshot, - // but for the global instance, it doesn't work. SetIndexedHandler and - // SetNamedHandler are set on the Instance template, and that's the key - // difference. The context has its own global instance, so we need to set - // these back up directly on it. There might be a better way to do this. - v8.v8__ObjectTemplate__SetIndexedHandler(global_template_local, &.{ - .getter = Window.JsApi.index.getter, - .setter = null, - .query = null, - .deleter = null, - .enumerator = null, - .definer = null, - .descriptor = null, - .data = null, - .flags = 0, - }); - v8.v8__Eternal__New(isolate_handle, @ptrCast(global_template_local), &global_eternal); private_symbols = PrivateSymbols.init(isolate_handle); } @@ -221,7 +180,6 @@ pub fn init(app: *App, opts: InitOpts) !Env { .templates = templates, .isolate_params = params, .inspector = inspector, - .global_template = global_eternal, .private_symbols = private_symbols, .microtask_queues_are_running = false, .eternal_function_templates = eternal_function_templates, @@ -261,7 +219,18 @@ pub const ContextParams = struct { }; pub fn createContext(self: *Env, page: *Page, params: ContextParams) !*Context { - const context_arena = try self.app.arena_pool.acquire(.large, params.debug_name); + return self._createContext(page, params); +} + +pub fn createWorkerContext(self: *Env, worker: *WorkerGlobalScope, params: ContextParams) !*Context { + return self._createContext(worker, params); +} + +fn _createContext(self: *Env, global: anytype, params: ContextParams) !*Context { + const T = @TypeOf(global); + const is_page = T == *Page; + + const context_arena = try self.app.arena_pool.acquire(.medium, params.debug_name); errdefer self.app.arena_pool.release(context_arena); const isolate = self.isolate; @@ -273,12 +242,10 @@ pub fn createContext(self: *Env, page: *Page, params: ContextParams) !*Context { const microtask_queue = v8.v8__MicrotaskQueue__New(isolate.handle, v8.kExplicit).?; errdefer v8.v8__MicrotaskQueue__DELETE(microtask_queue); - // Get the global template that was created once per isolate - const global_template: *const v8.ObjectTemplate = @ptrCast(@alignCast(v8.v8__Eternal__Get(&self.global_template, isolate.handle).?)); - v8.v8__ObjectTemplate__SetInternalFieldCount(global_template, comptime Snapshot.countInternalFields(Window.JsApi)); - - const v8_context = v8.v8__Context__New__Config(isolate.handle, &.{ - .global_template = global_template, + // Restore the context from the snapshot (0 = Page, 1 = Worker) + const snapshot_index: u32 = if (comptime is_page) 0 else 1; + const v8_context = v8.v8__Context__FromSnapshot__Config(isolate.handle, snapshot_index, &.{ + .global_template = null, .global_object = null, .microtask_queue = microtask_queue, }).?; @@ -287,36 +254,36 @@ pub fn createContext(self: *Env, page: *Page, params: ContextParams) !*Context { var context_global: v8.Global = undefined; v8.v8__Global__New(isolate.handle, v8_context, &context_global); - // get the global object for the context, this maps to our Window + // Get the global object for the context const global_obj = v8.v8__Context__Global(v8_context).?; - { - // Store our TAO inside the internal field of the global object. This - // maps the v8::Object -> Zig instance. Almost all objects have this, and - // it gets setup automatically as objects are created, but the Window - // object already exists in v8 (it's the global) so we manually create - // the mapping here. - const tao = try params.identity_arena.create(@import("TaggedOpaque.zig")); - tao.* = .{ - .value = @ptrCast(page.window), - .prototype_chain = (&Window.JsApi.Meta.prototype_chain).ptr, - .prototype_len = @intCast(Window.JsApi.Meta.prototype_chain.len), - .subtype = .node, // this probably isn't right, but it's what we've been doing all along - }; - v8.v8__Object__SetAlignedPointerInInternalField(global_obj, 0, tao); - } + // Store our TAO inside the internal field of the global object. This + // maps the v8::Object -> Zig instance. + const tao = try params.identity_arena.create(@import("TaggedOpaque.zig")); + tao.* = if (comptime is_page) .{ + .value = @ptrCast(global.window), + .prototype_chain = (&Window.JsApi.Meta.prototype_chain).ptr, + .prototype_len = @intCast(Window.JsApi.Meta.prototype_chain.len), + .subtype = .node, + } else .{ + .value = @ptrCast(global), + .prototype_chain = (&WorkerGlobalScope.JsApi.Meta.prototype_chain).ptr, + .prototype_len = @intCast(WorkerGlobalScope.JsApi.Meta.prototype_chain.len), + .subtype = null, + }; + v8.v8__Object__SetAlignedPointerInInternalField(global_obj, 0, tao); const context_id = self.context_id; self.context_id = context_id + 1; - const session = page._session; + const session = global._session; const origin = try session.getOrCreateOrigin(null); errdefer session.releaseOrigin(origin); const context = try context_arena.create(Context); context.* = .{ .env = self, - .global = .{ .page = page }, + .global = if (comptime is_page) .{ .page = global } else .{ .worker = global }, .origin = origin, .id = context_id, .session = session, @@ -326,7 +293,7 @@ pub fn createContext(self: *Env, page: *Page, params: ContextParams) !*Context { .templates = self.templates, .call_arena = params.call_arena, .microtask_queue = microtask_queue, - .script_manager = &page._script_manager, + .script_manager = if (comptime is_page) &global._script_manager else null, .scheduler = .init(context_arena), .identity = params.identity, .identity_arena = params.identity_arena, @@ -334,25 +301,23 @@ pub fn createContext(self: *Env, page: *Page, params: ContextParams) !*Context { }; context.execution = .{ - .buf = &page.buf, + .url = &global.url, + .buf = &global.buf, .context = context, - .arena = page.arena, + .arena = global.arena, .call_arena = params.call_arena, - ._factory = page._factory, + ._factory = global._factory, ._scheduler = &context.scheduler, - .url = &page.url, }; - { - // Multiple contexts can be created for the same Window (via CDP). We only - // need to register the first one. - const gop = try params.identity.identity_map.getOrPut(params.identity_arena, @intFromPtr(page.window)); - if (gop.found_existing == false) { - // our window wrapped in a v8::Global - var global_global: v8.Global = undefined; - v8.v8__Global__New(isolate.handle, global_obj, &global_global); - gop.value_ptr.* = global_global; - } + // Register in the identity map. Multiple contexts can be created for the + // same global (via CDP), so we only register the first one. + const identity_ptr = if (comptime is_page) @intFromPtr(global.window) else @intFromPtr(global); + const gop = try params.identity.identity_map.getOrPut(params.identity_arena, identity_ptr); + if (gop.found_existing == false) { + var global_global: v8.Global = undefined; + v8.v8__Global__New(isolate.handle, global_obj, &global_global); + gop.value_ptr.* = global_global; } // Store a pointer to our context inside the v8 context so that, given @@ -583,3 +548,50 @@ const PrivateSymbols = struct { self.child_nodes.deinit(); } }; + +const testing = @import("../../testing.zig"); +const EventTarget = @import("../webapi/EventTarget.zig"); + +test "Env: Worker context " { + const session = testing.test_session; + + // Create a dummy WorkerGlobalScope using page's resources (hackish until session.createWorker exists) + const worker = try session.factory.eventTarget(WorkerGlobalScope{ + ._session = session, + ._factory = &session.factory, + .arena = session.arena, + .url = "about:blank", + ._proto = undefined, + ._performance = .init(), + }); + + const ctx = try testing.test_browser.env.createWorkerContext(worker, .{ + .identity = &session.identity, + .identity_arena = session.arena, + .call_arena = session.arena, + }); + defer testing.test_browser.env.destroyContext(ctx); + + var ls: js.Local.Scope = undefined; + ctx.localScope(&ls); + defer ls.deinit(); + + try testing.expectEqual(true, (try ls.local.exec("typeof Node === 'undefined'", null)).isTrue()); + try testing.expectEqual(true, (try ls.local.exec("typeof WorkerGlobalScope !== 'undefined'", null)).isTrue()); +} + +test "Env: Page context" { + const session = testing.test_session; + const page = try session.createPage(); + defer session.removePage(); + + // Page already has a context created, use it directly + const ctx = page.js; + + var ls: js.Local.Scope = undefined; + ctx.localScope(&ls); + defer ls.deinit(); + + try testing.expectEqual(true, (try ls.local.exec("typeof Node !== 'undefined'", null)).isTrue()); + try testing.expectEqual(true, (try ls.local.exec("typeof WorkerGlobalScope === 'undefined'", null)).isTrue()); +} diff --git a/src/browser/js/Execution.zig b/src/browser/js/Execution.zig index 7d6ad6fd..877cd9e3 100644 --- a/src/browser/js/Execution.zig +++ b/src/browser/js/Execution.zig @@ -37,11 +37,11 @@ const Execution = @This(); context: *Context, // Fields named to match Page for generic code (executor._factory works for both) -_factory: *Factory, +buf: []u8, arena: Allocator, call_arena: Allocator, +_factory: *Factory, _scheduler: *Scheduler, -buf: []u8, // Pointer to the url field (Page or WorkerGlobalScope) - allows access to current url even after navigation url: *[:0]const u8, diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index 2db5ba65..a4757221 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -337,9 +337,6 @@ pub fn zigValueToJs(self: *const Local, value: anytype, comptime opts: CallOpts) .worker => { // No Worker-related API currently uses this, so haven't // added support for it - if (comptime IS_DEBUG) { - std.debug.assert(false); - } unreachable; }, }; @@ -425,9 +422,6 @@ pub fn zigValueToJs(self: *const Local, value: anytype, comptime opts: CallOpts) .worker => { // No Worker-related API currently uses this, so haven't // added support for it - if (comptime IS_DEBUG) { - std.debug.assert(false); - } unreachable; }, }; diff --git a/src/browser/js/Snapshot.zig b/src/browser/js/Snapshot.zig index 28248087..23f6c445 100644 --- a/src/browser/js/Snapshot.zig +++ b/src/browser/js/Snapshot.zig @@ -17,6 +17,7 @@ // along with this program. If not, see . const std = @import("std"); +const lp = @import("lightpanda"); const js = @import("js.zig"); const bridge = @import("bridge.zig"); const log = @import("../../log.zig"); @@ -26,6 +27,7 @@ const IS_DEBUG = @import("builtin").mode == .Debug; const v8 = js.v8; const JsApis = bridge.JsApis; const PageJsApis = bridge.PageJsApis; +const WorkerJsApis = bridge.WorkerJsApis; const Snapshot = @This(); @@ -114,8 +116,6 @@ fn isValid(self: Snapshot) bool { } pub fn create() !Snapshot { - comptime validatePrototypeChains(&JsApis); - var external_references = collectExternalReferences(); var params: v8.CreateParams = undefined; @@ -154,90 +154,45 @@ pub fn create() !Snapshot { } } - const context = v8.v8__Context__New(isolate, null, null); - v8.v8__Context__Enter(context); - defer v8.v8__Context__Exit(context); + // Add ALL templates to snapshot (done once, in any context) + // We need a context to call AddData, so create a temporary one + { + const temp_context = v8.v8__Context__New(isolate, null, null); + v8.v8__Context__Enter(temp_context); + defer v8.v8__Context__Exit(temp_context); - // Add ALL templates to context snapshot - var last_data_index: usize = 0; - inline for (JsApis, 0..) |_, i| { - @setEvalBranchQuota(10_000); - const data_index = v8.v8__SnapshotCreator__AddData(snapshot_creator, @ptrCast(templates[i])); - if (i == 0) { - data_start = data_index; - last_data_index = data_index; - } else { - if (data_index != last_data_index + 1) { - return error.InvalidDataIndex; - } - last_data_index = data_index; - } - } - - const global_obj = v8.v8__Context__Global(context); - - // Attach only PAGE types to the default context's global - inline for (PageJsApis, 0..) |JsApi, i| { - // PageJsApis[i] == JsApis[i] because the PageJsApis are position at the start of the list - const func = v8.v8__FunctionTemplate__GetFunction(templates[i], context); - if (@hasDecl(JsApi.Meta, "name")) { - if (@hasDecl(JsApi.Meta, "constructor_alias")) { - const alias = JsApi.Meta.constructor_alias; - const v8_class_name = v8.v8__String__NewFromUtf8(isolate, alias.ptr, v8.kNormal, @intCast(alias.len)); - var maybe_result: v8.MaybeBool = undefined; - v8.v8__Object__Set(global_obj, context, v8_class_name, func, &maybe_result); - - const name = JsApi.Meta.name; - const illegal_class_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len)); - var maybe_result2: v8.MaybeBool = undefined; - v8.v8__Object__DefineOwnProperty(global_obj, context, illegal_class_name, func, 0, &maybe_result2); + var last_data_index: usize = 0; + inline for (JsApis, 0..) |_, i| { + @setEvalBranchQuota(10_000); + const data_index = v8.v8__SnapshotCreator__AddData(snapshot_creator, @ptrCast(templates[i])); + if (i == 0) { + data_start = data_index; + last_data_index = data_index; } else { - const name = JsApi.Meta.name; - const v8_class_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len)); - var maybe_result: v8.MaybeBool = undefined; - var properties: v8.PropertyAttribute = v8.None; - if (@hasDecl(JsApi.Meta, "enumerable") and JsApi.Meta.enumerable == false) { - properties |= v8.DontEnum; + if (data_index != last_data_index + 1) { + return error.InvalidDataIndex; } - v8.v8__Object__DefineOwnProperty(global_obj, context, v8_class_name, func, properties, &maybe_result); + last_data_index = data_index; } } + + // V8 requires a default context. We could probably make this our + // Page context, but having both the Page and Worker context be + // indexed via addContext makes things a little more consistent. + v8.v8__SnapshotCreator__setDefaultContext(snapshot_creator, temp_context); } { - // Delete built-in console so we can inject our own - const console_key = v8.v8__String__NewFromUtf8(isolate, "console", v8.kNormal, 7); - var maybe_deleted: v8.MaybeBool = undefined; - v8.v8__Object__Delete(global_obj, context, console_key, &maybe_deleted); - if (maybe_deleted.value == false) { - return error.ConsoleDeleteError; - } - } - - // Set prototype chains on function objects - // https://groups.google.com/g/v8-users/c/qAQQBmbi--8 - inline for (JsApis, 0..) |JsApi, i| { - if (comptime protoIndexLookup(JsApi)) |proto_index| { - const proto_func = v8.v8__FunctionTemplate__GetFunction(templates[proto_index], context); - const proto_obj: *const v8.Object = @ptrCast(proto_func); - - const self_func = v8.v8__FunctionTemplate__GetFunction(templates[i], context); - const self_obj: *const v8.Object = @ptrCast(self_func); - - var maybe_result: v8.MaybeBool = undefined; - v8.v8__Object__SetPrototype(self_obj, context, proto_obj, &maybe_result); - } + const Window = @import("../webapi/Window.zig"); + const index = try createSnapshotContext(&PageJsApis, Window.JsApi, isolate, snapshot_creator.?, &templates); + std.debug.assert(index == 0); } { - // DOMException prototype setup - const code_str = "DOMException.prototype.__proto__ = Error.prototype"; - const code = v8.v8__String__NewFromUtf8(isolate, code_str.ptr, v8.kNormal, @intCast(code_str.len)); - const script = v8.v8__Script__Compile(context, code, null) orelse return error.ScriptCompileFailed; - _ = v8.v8__Script__Run(script, context) orelse return error.ScriptRunFailed; + const WorkerGlobalScope = @import("../webapi/WorkerGlobalScope.zig"); + const index = try createSnapshotContext(&WorkerJsApis, WorkerGlobalScope.JsApi, isolate, snapshot_creator.?, &templates); + std.debug.assert(index == 1); } - - v8.v8__SnapshotCreator__setDefaultContext(snapshot_creator, context); } const blob = v8.v8__SnapshotCreator__createBlob(snapshot_creator, v8.kKeep); @@ -245,11 +200,127 @@ pub fn create() !Snapshot { return .{ .owns_data = true, .data_start = data_start, - .external_references = external_references, .startup_data = blob, + .external_references = external_references, }; } +fn createSnapshotContext( + comptime ContextApis: []const type, + comptime GlobalScopeApi: type, + isolate: *v8.Isolate, + snapshot_creator: *v8.SnapshotCreator, + templates: []*const v8.FunctionTemplate, +) !usize { + // Create a global template that inherits from the GlobalScopeApi (Window or WorkerGlobalScope) + const global_scope_index = comptime bridge.JsApiLookup.getId(GlobalScopeApi); + const js_global = v8.v8__FunctionTemplate__New__DEFAULT(isolate); + const class_name = v8.v8__String__NewFromUtf8(isolate, GlobalScopeApi.Meta.name.ptr, v8.kNormal, @intCast(GlobalScopeApi.Meta.name.len)); + v8.v8__FunctionTemplate__SetClassName(js_global, class_name); + v8.v8__FunctionTemplate__Inherit(js_global, templates[global_scope_index]); + + const global_template = v8.v8__FunctionTemplate__InstanceTemplate(js_global).?; + v8.v8__ObjectTemplate__SetInternalFieldCount(global_template, comptime countInternalFields(GlobalScopeApi)); + + // Set up named/indexed handlers for Window's global object (for named element access like window.myDiv) + if (comptime std.mem.eql(u8, GlobalScopeApi.Meta.name, "Window")) { + v8.v8__ObjectTemplate__SetNamedHandler(global_template, &.{ + .getter = bridge.unknownWindowPropertyCallback, + .setter = null, + .query = null, + .deleter = null, + .enumerator = null, + .definer = null, + .descriptor = null, + .data = null, + .flags = v8.kOnlyInterceptStrings | v8.kNonMasking, + }); + v8.v8__ObjectTemplate__SetIndexedHandler(global_template, &.{ + .getter = @import("../webapi/Window.zig").JsApi.index.getter, + .setter = null, + .query = null, + .deleter = null, + .enumerator = null, + .definer = null, + .descriptor = null, + .data = null, + .flags = 0, + }); + } + + const context = v8.v8__Context__New(isolate, global_template, null); + v8.v8__Context__Enter(context); + defer v8.v8__Context__Exit(context); + + // Initialize embedder data to null so callbacks can detect snapshot creation + v8.v8__Context__SetAlignedPointerInEmbedderData(context, 1, null); + + const global_obj = v8.v8__Context__Global(context); + + // Attach constructors for this context's APIs to the global + inline for (ContextApis) |JsApi| { + const template_index = comptime bridge.JsApiLookup.getId(JsApi); + const func = v8.v8__FunctionTemplate__GetFunction(templates[template_index], context); + if (@hasDecl(JsApi.Meta, "name")) { + if (@hasDecl(JsApi.Meta, "constructor_alias")) { + const alias = JsApi.Meta.constructor_alias; + const v8_class_name = v8.v8__String__NewFromUtf8(isolate, alias.ptr, v8.kNormal, @intCast(alias.len)); + var maybe_result: v8.MaybeBool = undefined; + v8.v8__Object__Set(global_obj, context, v8_class_name, func, &maybe_result); + + const name = JsApi.Meta.name; + const illegal_class_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len)); + var maybe_result2: v8.MaybeBool = undefined; + v8.v8__Object__DefineOwnProperty(global_obj, context, illegal_class_name, func, 0, &maybe_result2); + } else { + const name = JsApi.Meta.name; + const v8_class_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len)); + var maybe_result: v8.MaybeBool = undefined; + var properties: v8.PropertyAttribute = v8.None; + if (@hasDecl(JsApi.Meta, "enumerable") and JsApi.Meta.enumerable == false) { + properties |= v8.DontEnum; + } + v8.v8__Object__DefineOwnProperty(global_obj, context, v8_class_name, func, properties, &maybe_result); + } + } + } + + { + // Delete built-in console so we can inject our own + const console_key = v8.v8__String__NewFromUtf8(isolate, "console", v8.kNormal, 7); + var maybe_deleted: v8.MaybeBool = undefined; + v8.v8__Object__Delete(global_obj, context, console_key, &maybe_deleted); + if (maybe_deleted.value == false) { + return error.ConsoleDeleteError; + } + } + + // Set prototype chains on function objects + // https://groups.google.com/g/v8-users/c/qAQQBmbi--8 + inline for (JsApis, 0..) |JsApi, i| { + if (comptime protoIndexLookup(JsApi)) |proto_index| { + const proto_func = v8.v8__FunctionTemplate__GetFunction(templates[proto_index], context); + const proto_obj: *const v8.Object = @ptrCast(proto_func); + + const self_func = v8.v8__FunctionTemplate__GetFunction(templates[i], context); + const self_obj: *const v8.Object = @ptrCast(self_func); + + var maybe_result: v8.MaybeBool = undefined; + v8.v8__Object__SetPrototype(self_obj, context, proto_obj, &maybe_result); + } + } + + { + // DOMException prototype setup + const code_str = "DOMException.prototype.__proto__ = Error.prototype"; + const code = v8.v8__String__NewFromUtf8(isolate, code_str.ptr, v8.kNormal, @intCast(code_str.len)); + const script = v8.v8__Script__Compile(context, code, null) orelse return error.ScriptCompileFailed; + _ = v8.v8__Script__Run(script, context) orelse return error.ScriptRunFailed; + } + + return v8.v8__SnapshotCreator__AddContext(snapshot_creator, context); +} + fn countExternalReferences() comptime_int { @setEvalBranchQuota(100_000); @@ -261,6 +332,9 @@ fn countExternalReferences() comptime_int { // +1 for the noop function shared by various types count += 1; + // +1 for unknownWindowPropertyCallback used on Window's global template + count += 1; + inline for (JsApis) |JsApi| { if (@hasDecl(JsApi, "constructor")) { count += 1; @@ -317,6 +391,9 @@ fn collectExternalReferences() [countExternalReferences()]isize { references[idx] = @bitCast(@intFromPtr(&bridge.Function.noopFunction)); idx += 1; + references[idx] = @bitCast(@intFromPtr(&bridge.unknownWindowPropertyCallback)); + idx += 1; + inline for (JsApis) |JsApi| { if (@hasDecl(JsApi, "constructor")) { references[idx] = @bitCast(@intFromPtr(JsApi.constructor.func)); @@ -383,7 +460,7 @@ fn protoIndexLookup(comptime JsApi: type) ?u16 { return protoIndexLookupFor(&JsApis, JsApi); } -pub fn countInternalFields(comptime JsApi: type) u8 { +fn countInternalFields(comptime JsApi: type) u8 { var last_used_id = 0; var cache_count: u8 = 0; @@ -468,35 +545,6 @@ fn protoIndexLookupFor(comptime ApiList: []const type, comptime JsApi: type) ?u1 } } -// Validates that every type in the API list has its full prototype chain -// contained within that same list. This catches errors where a type is added -// to a snapshot but its prototype dependencies are missing. -// See bridge.AllJsApis for more information. -fn validatePrototypeChains(comptime ApiList: []const type) void { - @setEvalBranchQuota(100_000); - inline for (ApiList) |JsApi| { - const T = JsApi.bridge.type; - if (@hasField(T, "_proto")) { - const Ptr = std.meta.fieldInfo(T, ._proto).type; - const ProtoType = @typeInfo(Ptr).pointer.child; - // Verify the prototype's JsApi is in our list - var found = false; - inline for (ApiList) |Api| { - if (Api == ProtoType.JsApi) { - found = true; - break; - } - } - if (!found) { - @compileError( - @typeName(JsApi) ++ " has prototype " ++ - @typeName(ProtoType.JsApi) ++ " which is not in the API list", - ); - } - } - } -} - // Generate a constructor template for a JsApi type (public for reuse) pub fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *const v8.FunctionTemplate { const callback = blk: { diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 0e2176b0..90ebf851 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -24,6 +24,7 @@ const Session = @import("../Session.zig"); const v8 = js.v8; const Caller = @import("Caller.zig"); +const Context = @import("Context.zig"); const IS_DEBUG = @import("builtin").mode == .Debug; @@ -398,6 +399,11 @@ pub const Property = struct { pub fn unknownWindowPropertyCallback(c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 { const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?; + + // During snapshot creation, there's no Context in embedder data yet + const v8_context = v8.v8__Isolate__GetCurrentContext(v8_isolate) orelse return 0; + if (v8.v8__Context__GetAlignedPointerFromEmbedderData(v8_context, 1) == null) return 0; + var caller: Caller = undefined; if (!caller.init(v8_isolate)) { return 0; @@ -695,7 +701,7 @@ pub const SubType = enum { webassemblymemory, }; -/// APIs for Page/Window contexts. Used by Snapshot.zig for Page snapshot creation. +// APIs for Page/Window contexts. Used by Snapshot.zig for Page snapshot creation. pub const PageJsApis = flattenTypes(&.{ @import("../webapi/AbortController.zig"), @import("../webapi/AbortSignal.zig"), @@ -893,13 +899,32 @@ pub const PageJsApis = flattenTypes(&.{ @import("../webapi/ImageData.zig"), }); -/// APIs that exist only in Worker contexts (not in Page/Window). -const WorkerOnlyApis = flattenTypes(&.{ +// APIs available on Worker context globals (constructors like URL, Headers, etc.) +// This is a subset of PageJsApis plus WorkerGlobalScope. +// TODO: Expand this list to include all worker-appropriate APIs. +pub const WorkerJsApis = flattenTypes(&.{ @import("../webapi/WorkerGlobalScope.zig"), + @import("../webapi/EventTarget.zig"), + @import("../webapi/DOMException.zig"), + @import("../webapi/AbortController.zig"), + @import("../webapi/AbortSignal.zig"), + @import("../webapi/URL.zig"), + @import("../webapi/net/URLSearchParams.zig"), + @import("../webapi/net/Headers.zig"), + @import("../webapi/net/Request.zig"), + @import("../webapi/net/Response.zig"), + @import("../webapi/encoding/TextEncoder.zig"), + @import("../webapi/encoding/TextDecoder.zig"), + @import("../webapi/Blob.zig"), + @import("../webapi/File.zig"), + @import("../webapi/net/FormData.zig"), + @import("../webapi/Console.zig"), + @import("../webapi/Crypto.zig"), + @import("../webapi/Performance.zig"), }); -/// Master list of ALL JS APIs across all contexts. -/// Used by Env (class IDs, templates), JsApiLookup, and anywhere that needs -/// to know about all possible types. Individual snapshots use their own -/// subsets (PageJsApis, WorkerSnapshot.JsApis). -pub const JsApis = PageJsApis ++ WorkerOnlyApis; +// Master list of ALL JS APIs across all contexts. +// Used by Env (class IDs, templates), JsApiLookup, and anywhere that needs +// to know about all possible types. Individual snapshots use their own +// subsets (PageJsApis, WorkerSnapshot.JsApis). +pub const JsApis = PageJsApis ++ [_]type{@import("../webapi/WorkerGlobalScope.zig").JsApi}; diff --git a/src/browser/webapi/Location.zig b/src/browser/webapi/Location.zig index cb8806ff..caf1c4ae 100644 --- a/src/browser/webapi/Location.zig +++ b/src/browser/webapi/Location.zig @@ -53,12 +53,12 @@ pub fn getPort(self: *const Location) []const u8 { return self._url.getPort(); } -pub fn getOrigin(self: *const Location, page: *const Page) ![]const u8 { - return self._url.getOrigin(&page.js.execution); +pub fn getOrigin(self: *const Location, exec: *const js.Execution) ![]const u8 { + return self._url.getOrigin(exec); } -pub fn getSearch(self: *const Location, page: *const Page) ![]const u8 { - return self._url.getSearch(&page.js.execution); +pub fn getSearch(self: *const Location, exec: *const js.Execution) ![]const u8 { + return self._url.getSearch(exec); } pub fn getHash(self: *const Location) []const u8 { @@ -98,8 +98,8 @@ pub fn reload(_: *const Location, page: *Page) !void { return page.scheduleNavigation(page.url, .{ .reason = .script, .kind = .reload }, .{ .script = page }); } -pub fn toString(self: *const Location, page: *const Page) ![:0]const u8 { - return self._url.toString(&page.js.execution); +pub fn toString(self: *const Location, exec: *const js.Execution) ![:0]const u8 { + return self._url.toString(exec); } pub const JsApi = struct { diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 02e46660..c6f63daa 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -411,7 +411,7 @@ pub fn postMessage(self: *Window, message: js.Value.Temp, target_origin: ?[]cons errdefer target_page.releaseArena(arena); // Origin should be the source window's origin (where the message came from) - const origin = try source_window._location.getOrigin(page); + const origin = try source_window._location.getOrigin(&page.js.execution); const callback = try arena.create(PostMessageCallback); callback.* = .{ .arena = arena, diff --git a/src/browser/webapi/WorkerGlobalScope.zig b/src/browser/webapi/WorkerGlobalScope.zig index 6ea9bd90..604176e4 100644 --- a/src/browser/webapi/WorkerGlobalScope.zig +++ b/src/browser/webapi/WorkerGlobalScope.zig @@ -19,7 +19,6 @@ const std = @import("std"); const JS = @import("../js/js.zig"); -const base64 = @import("encoding/base64.zig"); const Console = @import("Console.zig"); const Crypto = @import("Crypto.zig"); const EventTarget = @import("EventTarget.zig"); @@ -97,10 +96,12 @@ pub fn setOnUnhandledRejection(self: *WorkerGlobalScope, setter: ?FunctionSetter } pub fn btoa(_: *const WorkerGlobalScope, input: []const u8, exec: *JS.Execution) ![]const u8 { + const base64 = @import("encoding/base64.zig"); return base64.encode(exec.call_arena, input); } pub fn atob(_: *const WorkerGlobalScope, input: []const u8, exec: *JS.Execution) ![]const u8 { + const base64 = @import("encoding/base64.zig"); return base64.decode(exec.call_arena, input); } From fb844a17b6f261da19c4f863ff4a6b5a4299f37e Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sun, 5 Apr 2026 08:09:20 +0800 Subject: [PATCH 07/18] Worker WebAPI --- src/browser/Page.zig | 21 ++ src/browser/js/Env.zig | 23 +- src/browser/js/bridge.zig | 1 + src/browser/tests/worker/echo-worker.js | 4 + src/browser/tests/worker/worker.html | 33 +++ src/browser/webapi/EventTarget.zig | 3 + src/browser/webapi/Worker.zig | 324 +++++++++++++++++++++++ src/browser/webapi/WorkerGlobalScope.zig | 129 ++++++++- 8 files changed, 516 insertions(+), 22 deletions(-) create mode 100644 src/browser/tests/worker/echo-worker.js create mode 100644 src/browser/tests/worker/worker.html create mode 100644 src/browser/webapi/Worker.zig diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 92cb5237..43430ccb 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -57,6 +57,7 @@ const PerformanceObserver = @import("webapi/PerformanceObserver.zig"); const AbstractRange = @import("webapi/AbstractRange.zig"); const MutationObserver = @import("webapi/MutationObserver.zig"); const IntersectionObserver = @import("webapi/IntersectionObserver.zig"); +const Worker = @import("webapi/Worker.zig"); const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig"); const PageTransitionEvent = @import("webapi/event/PageTransitionEvent.zig"); const SubmitEvent = @import("webapi/event/SubmitEvent.zig"); @@ -231,6 +232,9 @@ iframe: ?*IFrame = null, frames: std.ArrayList(*Page) = .{}, frames_sorted: bool = true, +// Workers created by this page. Cleaned up when page is destroyed. +workers: std.ArrayList(*Worker) = .{}, + // DOM version used to invalidate cached state of "live" collections version: usize = 0, @@ -339,6 +343,10 @@ pub fn deinit(self: *Page, abort_http: bool) void { frame.deinit(abort_http); } + for (self.workers.items) |worker| { + worker.deinit(); + } + if (comptime IS_DEBUG) { log.debug(.page, "page.deinit", .{ .url = self.url, .type = self._type }); @@ -399,6 +407,19 @@ pub fn deinit(self: *Page, abort_http: bool) void { session.releaseArena(self.call_arena); } +pub fn trackWorker(self: *Page, worker: *Worker) !void { + try self.workers.append(self.arena, worker); +} + +pub fn removeWorker(self: *Page, worker: *Worker) void { + for (self.workers.items, 0..) |w, i| { + if (w == worker) { + _ = self.workers.swapRemove(i); + break; + } + } +} + pub fn base(self: *const Page) [:0]const u8 { return self.base_url orelse self.url; } diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index 3dfb32d4..4539d007 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -550,30 +550,15 @@ const PrivateSymbols = struct { }; const testing = @import("../../testing.zig"); -const EventTarget = @import("../webapi/EventTarget.zig"); - test "Env: Worker context " { const session = testing.test_session; + const page = try session.createPage(); + defer session.removePage(); - // Create a dummy WorkerGlobalScope using page's resources (hackish until session.createWorker exists) - const worker = try session.factory.eventTarget(WorkerGlobalScope{ - ._session = session, - ._factory = &session.factory, - .arena = session.arena, - .url = "about:blank", - ._proto = undefined, - ._performance = .init(), - }); - - const ctx = try testing.test_browser.env.createWorkerContext(worker, .{ - .identity = &session.identity, - .identity_arena = session.arena, - .call_arena = session.arena, - }); - defer testing.test_browser.env.destroyContext(ctx); + const worker = try @import("../webapi/Worker.zig").init("about:blank", &page.js.execution); var ls: js.Local.Scope = undefined; - ctx.localScope(&ls); + worker._worker_scope.js.localScope(&ls); defer ls.deinit(); try testing.expectEqual(true, (try ls.local.exec("typeof Node === 'undefined'", null)).isTrue()); diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 90ebf851..fad648ec 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -844,6 +844,7 @@ pub const PageJsApis = flattenTypes(&.{ @import("../webapi/event/FormDataEvent.zig"), @import("../webapi/MessageChannel.zig"), @import("../webapi/MessagePort.zig"), + @import("../webapi/Worker.zig"), @import("../webapi/media/MediaError.zig"), @import("../webapi/media/TextTrackCue.zig"), @import("../webapi/media/VTTCue.zig"), diff --git a/src/browser/tests/worker/echo-worker.js b/src/browser/tests/worker/echo-worker.js new file mode 100644 index 00000000..af92d9b1 --- /dev/null +++ b/src/browser/tests/worker/echo-worker.js @@ -0,0 +1,4 @@ +// Simple worker that echoes messages back with a prefix +onmessage = function(event) { + postMessage({ echo: event.data, from: 'worker' }); +}; diff --git a/src/browser/tests/worker/worker.html b/src/browser/tests/worker/worker.html new file mode 100644 index 00000000..922a7786 --- /dev/null +++ b/src/browser/tests/worker/worker.html @@ -0,0 +1,33 @@ + + + + + + + diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index 304fe584..9bb0a039 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -34,6 +34,7 @@ pub const Type = union(enum) { generic: void, node: *@import("Node.zig"), window: *@import("Window.zig"), + worker: *@import("Worker.zig"), worker_global_scope: *@import("WorkerGlobalScope.zig"), xhr: *@import("net/XMLHttpRequestEventTarget.zig"), abort_signal: *@import("AbortSignal.zig"), @@ -132,6 +133,7 @@ pub fn format(self: *EventTarget, writer: *std.Io.Writer) !void { .node => |n| n.format(writer), .generic => writer.writeAll(""), .window => writer.writeAll(""), + .worker => writer.writeAll(""), .worker_global_scope => writer.writeAll(""), .xhr => writer.writeAll(""), .abort_signal => writer.writeAll(""), @@ -153,6 +155,7 @@ pub fn toString(self: *EventTarget) []const u8 { .node => return "[object Node]", .generic => return "[object EventTarget]", .window => return "[object Window]", + .worker => return "[object Worker]", .worker_global_scope => return "[object WorkerGlobalScope]", .xhr => return "[object XMLHttpRequestEventTarget]", .abort_signal => return "[object AbortSignal]", diff --git a/src/browser/webapi/Worker.zig b/src/browser/webapi/Worker.zig new file mode 100644 index 00000000..3724d156 --- /dev/null +++ b/src/browser/webapi/Worker.zig @@ -0,0 +1,324 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); + +const js = @import("../js/js.zig"); +const log = @import("../../log.zig"); +const http = @import("../../network/http.zig"); + +const URL = @import("../URL.zig"); +const Page = @import("../Page.zig"); +const Session = @import("../Session.zig"); +const HttpClient = @import("../HttpClient.zig"); + +const EventTarget = @import("EventTarget.zig"); +const WorkerGlobalScope = @import("WorkerGlobalScope.zig"); + +const Execution = js.Execution; +const Allocator = std.mem.Allocator; +const IS_DEBUG = @import("builtin").mode == .Debug; + +const Worker = @This(); + +_proto: *EventTarget, +_page: *Page, +_arena: Allocator, +_worker_scope: *WorkerGlobalScope, + +_url: [:0]const u8, +_script_loaded: bool = false, +_script_buffer: std.ArrayList(u8) = .empty, +_http_response: ?HttpClient.Response = null, + +// Event handlers +_on_error: ?js.Function.Global = null, +_on_message: ?js.Function.Global = null, +_on_messageerror: ?js.Function.Global = null, + +pub fn init(url: []const u8, exec: *Execution) !*Worker { + const page = switch (exec.context.global) { + .page => |p| p, + .worker => return error.WorkerCannotCreateWorker, + }; + const session = page._session; + + const arena = try session.getArena(.{ .debug = "Worker" }); + errdefer session.releaseArena(arena); + + // Resolve URL relative to current context + const resolved_url = try URL.resolve(arena, exec.url.*, url, .{}); + + const self = try session.factory.eventTargetWithAllocator(arena, Worker{ + ._arena = arena, + ._proto = undefined, + ._page = page, + ._url = resolved_url, + ._worker_scope = undefined, + }); + self._worker_scope = try WorkerGlobalScope.init(self, resolved_url); + errdefer self._worker_scope.deinit(); + + try page.trackWorker(self); + + const http_client = session.browser.http_client; + http_client.request(.{ + .ctx = self, + .url = resolved_url, + .method = .GET, + .headers = try http_client.newHeaders(), + .frame_id = 0, // Workers don't belong to frames + .resource_type = .script, + .cookie_jar = &session.cookie_jar, + .cookie_origin = resolved_url, + .notification = session.notification, + .header_callback = httpHeaderCallback, + .data_callback = httpDataCallback, + .done_callback = httpDoneCallback, + .error_callback = httpErrorCallback, + }) catch |err| { + log.err(.browser, "Worker request", .{ .url = resolved_url, .err = err }); + page.removeWorker(self); + return err; + }; + return self; +} + +// Called from Page.deinit when the page is destroyed, so we don't need to +// remove from the page's worker list. +pub fn deinit(self: *Worker) void { + if (self._http_response) |res| { + res.abort(error.Abort); + self._http_response = null; + } + self._worker_scope.deinit(); + self._page._session.releaseArena(self._arena); +} + +pub fn asEventTarget(self: *Worker) *EventTarget { + return self._proto; +} + +fn httpHeaderCallback(response: HttpClient.Response) !bool { + const self: *Worker = @ptrCast(@alignCast(response.ctx)); + + const status = response.status() orelse return false; + if (status < 200 or status >= 300) { + log.warn(.browser, "Worker status", .{ + .url = self._url, + .status = status, + }); + return false; + } + + self._http_response = response; + if (response.contentLength()) |cl| { + try self._script_buffer.ensureTotalCapacity(self._arena, cl); + } + + return true; +} + +fn httpDataCallback(response: HttpClient.Response, data: []const u8) !void { + const self: *Worker = @ptrCast(@alignCast(response.ctx)); + try self._script_buffer.appendSlice(self._arena, data); +} + +fn httpDoneCallback(ctx: *anyopaque) !void { + const self: *Worker = @ptrCast(@alignCast(ctx)); + self._http_response = null; + self._script_loaded = true; + + const url = self._url; + const script = self._script_buffer.items; + + if (comptime IS_DEBUG) { + log.info(.browser, "worker fetch done", .{ + .url = url, + .len = script.len, + }); + } + + var ls: js.Local.Scope = undefined; + self._worker_scope.js.localScope(&ls); + defer ls.deinit(); + + _ = ls.local.eval(script, url) catch |err| { + log.err(.browser, "worker script error", .{ .url = url, .err = err }); + // TODO: Fire error event on Worker + return; + }; + + ls.local.runMacrotasks(); +} + +fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void { + const self: *Worker = @ptrCast(@alignCast(ctx)); + self._http_response = null; + + log.err(.browser, "worker fetch error", .{ + .url = self._worker_scope.url, + .err = err, + }); + + // TODO: Fire error event on Worker +} + +pub fn terminate(self: *Worker) void { + // Abort any pending script fetch + if (self._http_response) |resp| { + resp.abort(error.Abort); + self._http_response = null; + } + + self._page.removeWorker(self); +} + +// Posts a message from the page to the worker. +// The message is serialized via JSON and dispatched on the WorkerGlobalScope. +pub fn postMessage(self: *Worker, message: js.Value) !void { + const session = self._page._session; + const message_arena = try session.getArena(.{ .debug = "Worker.postMessage" }); + errdefer session.releaseArena(message_arena); + + const json = try message.toJson(message_arena); + + const worker_scope = self._worker_scope; + + const callback = try message_arena.create(PostMessageToWorkerCallback); + callback.* = .{ + .json = json, + .arena = message_arena, + .worker_scope = worker_scope, + }; + + try worker_scope.js.scheduler.add(callback, PostMessageToWorkerCallback.run, 0, .{ + .name = "Worker.postMessage", + .low_priority = false, + .finalizer = PostMessageToWorkerCallback.cancelled, + }); +} + +const PostMessageToWorkerCallback = struct { + json: []const u8, + arena: Allocator, + worker_scope: *WorkerGlobalScope, + + fn cancelled(ctx: *anyopaque) void { + var self: *PostMessageToWorkerCallback = @ptrCast(@alignCast(ctx)); + self.deinit(); + } + + fn deinit(self: *PostMessageToWorkerCallback) void { + self.worker_scope._session.releaseArena(self.arena); + } + + fn run(ctx: *anyopaque) !?u32 { + const self: *PostMessageToWorkerCallback = @ptrCast(@alignCast(ctx)); + defer self.deinit(); + + const worker_scope = self.worker_scope; + const on_message = worker_scope._on_message orelse return null; + + var ls: js.Local.Scope = undefined; + worker_scope.js.localScope(&ls); + defer ls.deinit(); + + // Deserialize the message in worker context + const data = ls.local.parseJSON(self.json) catch |err| { + log.err(.browser, "worker msg parse fail", .{ .err = err }); + return null; + }; + + // Call the onmessage handler with a simple object {data: value} + // TODO: Create proper MessageEvent + const message_obj = ls.local.newObject(); + _ = message_obj.set("data", data, .{}) catch |err| { + log.err(.browser, "message data set fail", .{ .err = err }); + return null; + }; + + const func = on_message.local(&ls.local); + _ = func.call(void, .{message_obj.toValue()}) catch |err| { + log.err(.browser, "worker onmessage fail", .{ .err = err }); + }; + + return null; + } +}; + +pub fn getOnMessage(self: *const Worker) ?js.Function.Global { + return self._on_message; +} + +pub fn setOnMessage(self: *Worker, setter: ?FunctionSetter) void { + self._on_message = getFunctionFromSetter(setter); +} + +pub fn getOnMessageError(self: *const Worker) ?js.Function.Global { + return self._on_messageerror; +} + +pub fn setOnMessageError(self: *Worker, setter: ?FunctionSetter) void { + self._on_messageerror = getFunctionFromSetter(setter); +} + +pub fn getOnError(self: *const Worker) ?js.Function.Global { + return self._on_error; +} + +pub fn setOnError(self: *Worker, setter: ?FunctionSetter) void { + self._on_error = getFunctionFromSetter(setter); +} + +const FunctionSetter = union(enum) { + func: js.Function.Global, + anything: js.Value, +}; + +fn getFunctionFromSetter(setter_: ?FunctionSetter) ?js.Function.Global { + const setter = setter_ orelse return null; + return switch (setter) { + .func => |func| func, + .anything => null, + }; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(Worker); + + pub const Meta = struct { + pub const name = "Worker"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(Worker.init, .{}); + + pub const terminate = bridge.function(Worker.terminate, .{}); + pub const postMessage = bridge.function(Worker.postMessage, .{}); + + pub const onmessage = bridge.accessor(Worker.getOnMessage, Worker.setOnMessage, .{}); + pub const onmessageerror = bridge.accessor(Worker.getOnMessageError, Worker.setOnMessageError, .{}); + pub const onerror = bridge.accessor(Worker.getOnError, Worker.setOnError, .{}); +}; + +const testing = @import("../../testing.zig"); +test "WebApi: Worker" { + try testing.htmlRunner("worker", .{}); +} diff --git a/src/browser/webapi/WorkerGlobalScope.zig b/src/browser/webapi/WorkerGlobalScope.zig index 604176e4..8384aabc 100644 --- a/src/browser/webapi/WorkerGlobalScope.zig +++ b/src/browser/webapi/WorkerGlobalScope.zig @@ -19,26 +19,36 @@ const std = @import("std"); const JS = @import("../js/js.zig"); +const log = @import("../../log.zig"); + const Console = @import("Console.zig"); const Crypto = @import("Crypto.zig"); const EventTarget = @import("EventTarget.zig"); const Factory = @import("../Factory.zig"); const Performance = @import("Performance.zig"); const Session = @import("../Session.zig"); +const Worker = @import("Worker.zig"); const Allocator = std.mem.Allocator; const WorkerGlobalScope = @This(); -// Infrastructure fields (similar to Page) +// Meant to follow the same field naming as Page so that an anytype of generic +// can access these the same for a Page of a WGS. +// These fields represent the "Page"-like component of the WGS _session: *Session, _factory: *Factory, +_identity: JS.Identity = .{}, arena: Allocator, +call_arena: Allocator, url: [:0]const u8, buf: [1024]u8 = undefined, // same size as page.buf -js: *JS.Context = undefined, +js: *JS.Context, -// WebAPI fields +// Reference back to the Worker object (for postMessage to page) +_worker: *Worker, + +// These fields represent the "Window"-like component of the WGS _proto: *EventTarget, _console: Console = .init, _crypto: Crypto = .init, @@ -46,6 +56,45 @@ _performance: Performance, _on_error: ?JS.Function.Global = null, _on_rejection_handled: ?JS.Function.Global = null, _on_unhandled_rejection: ?JS.Function.Global = null, +_on_message: ?JS.Function.Global = null, + +pub fn init(worker: *Worker, url: [:0]const u8) !*WorkerGlobalScope { + const arena = worker._arena; + const session = worker._page._session; + const factory = &session.factory; + + const call_arena = try session.getArena(.{ .debug = "WorkerGlobalScope.call_arena" }); + errdefer session.releaseArena(call_arena); + + const self = try factory.eventTargetWithAllocator(arena, WorkerGlobalScope{ + .url = url, + .arena = arena, + .js = undefined, + .call_arena = call_arena, + ._session = session, + ._identity = .{}, + ._proto = undefined, + ._factory = factory, + ._worker = worker, + ._performance = .init(), + }); + errdefer factory.destroy(self); + + self.js = try session.browser.env.createWorkerContext(self, .{ + .call_arena = call_arena, + .identity_arena = arena, + .identity = &self._identity, + }); + + return self; +} + +pub fn deinit(self: *WorkerGlobalScope) void { + self._identity.deinit(); + const session = self._session; + session.browser.env.destroyContext(self.js); + session.releaseArena(self.call_arena); +} pub fn base(self: *const WorkerGlobalScope) [:0]const u8 { return self.url; @@ -95,6 +144,77 @@ pub fn setOnUnhandledRejection(self: *WorkerGlobalScope, setter: ?FunctionSetter self._on_unhandled_rejection = getFunctionFromSetter(setter); } +pub fn getOnMessage(self: *const WorkerGlobalScope) ?JS.Function.Global { + return self._on_message; +} + +pub fn setOnMessage(self: *WorkerGlobalScope, setter: ?FunctionSetter) void { + self._on_message = getFunctionFromSetter(setter); +} + +/// Posts a message from the worker back to the page. +/// The message is serialized via JSON and dispatched on the Worker object. +pub fn postMessage(self: *WorkerGlobalScope, message: JS.Value, exec: *JS.Execution) !void { + const worker = self._worker; + const page = worker._page; + + // Serialize message to JSON + const json = try message.toJson(self.arena); + + // Create callback to deliver message to Worker + const callback = try self.arena.create(PostMessageToPageCallback); + callback.* = .{ + .worker = worker, + .json = json, + }; + + try page.js.scheduler.add(callback, PostMessageToPageCallback.run, 0, .{ + .name = "WorkerGlobalScope.postMessage", + .low_priority = false, + }); + + _ = exec; +} + +const PostMessageToPageCallback = struct { + worker: *Worker, + json: []const u8, + + fn run(ctx: *anyopaque) !?u32 { + const self: *PostMessageToPageCallback = @ptrCast(@alignCast(ctx)); + const worker = self.worker; + + const on_message = worker._on_message orelse return null; + + const page = worker._page; + + var ls: JS.Local.Scope = undefined; + page.js.localScope(&ls); + defer ls.deinit(); + + // Deserialize the message in page context + const data = ls.local.parseJSON(self.json) catch |err| { + log.err(.browser, "page msg parse fail", .{ .err = err }); + return null; + }; + + // Call the onmessage handler with a simple object {data: value} + // TODO: Create proper MessageEvent + const message_obj = ls.local.newObject(); + _ = message_obj.set("data", data, .{}) catch |err| { + log.err(.browser, "message data set fail", .{ .err = err }); + return null; + }; + + const func = on_message.local(&ls.local); + _ = func.call(void, .{message_obj.toValue()}) catch |err| { + log.err(.browser, "page onmessage fail", .{ .err = err }); + }; + + return null; + } +}; + pub fn btoa(_: *const WorkerGlobalScope, input: []const u8, exec: *JS.Execution) ![]const u8 { const base64 = @import("encoding/base64.zig"); return base64.encode(exec.call_arena, input); @@ -148,6 +268,9 @@ pub const JsApi = struct { pub const btoa = bridge.function(WorkerGlobalScope.btoa, .{}); pub const atob = bridge.function(WorkerGlobalScope.atob, .{ .dom_exception = true }); pub const structuredClone = bridge.function(WorkerGlobalScope.structuredClone, .{}); + pub const postMessage = bridge.function(WorkerGlobalScope.postMessage, .{}); + + pub const onmessage = bridge.accessor(WorkerGlobalScope.getOnMessage, WorkerGlobalScope.setOnMessage, .{}); // Return false since workers don't have secure-context-only APIs pub const isSecureContext = bridge.property(false, .{ .template = false }); From ce6b01f7f62143f6605229f0b6ef6b6156438df4 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 7 Apr 2026 12:18:51 +0800 Subject: [PATCH 08/18] Allow structuredClone to target a different context --- src/browser/js/Value.zig | 20 ++- src/browser/tests/worker/worker.html | 175 ++++++++++++++++++++++- src/browser/webapi/Worker.zig | 33 +++-- src/browser/webapi/WorkerGlobalScope.zig | 49 +++++-- 4 files changed, 241 insertions(+), 36 deletions(-) diff --git a/src/browser/js/Value.zig b/src/browser/js/Value.zig index 1491bcdd..f80727b0 100644 --- a/src/browser/js/Value.zig +++ b/src/browser/js/Value.zig @@ -248,9 +248,15 @@ pub fn toJson(self: Value, allocator: Allocator) ![]u8 { // Throws a DataCloneError for host objects (Blob, File, etc.) that cannot be serialized. // Does not support transferables which require additional delegate callbacks. pub fn structuredClone(self: Value) !Value { - const local = self.local; - const v8_context = local.handle; - const v8_isolate = local.isolate.handle; + return self.structuredCloneTo(self.local); +} + +// Clone a value to a different context (within the same isolate). +// Used for cross-context messaging (e.g., Worker <-> Page). +pub fn structuredCloneTo(self: Value, target: *const js.Local) !Value { + const source_context = self.local.handle; + const target_context = target.handle; + const v8_isolate = target.isolate.handle; const SerializerDelegate = struct { // Called when V8 encounters a host object it doesn't know how to serialize. @@ -280,7 +286,7 @@ pub fn structuredClone(self: Value) !Value { var write_result: v8.MaybeBool = undefined; v8.v8__ValueSerializer__WriteHeader(serializer); - v8.v8__ValueSerializer__WriteValue(serializer, v8_context, self.handle, &write_result); + v8.v8__ValueSerializer__WriteValue(serializer, source_context, self.handle, &write_result); if (!write_result.has_value or !write_result.value) { return error.JsException; } @@ -297,14 +303,14 @@ pub fn structuredClone(self: Value) !Value { defer v8.v8__ValueDeserializer__DELETE(deserializer); var read_header_result: v8.MaybeBool = undefined; - v8.v8__ValueDeserializer__ReadHeader(deserializer, v8_context, &read_header_result); + v8.v8__ValueDeserializer__ReadHeader(deserializer, target_context, &read_header_result); if (!read_header_result.has_value or !read_header_result.value) { return error.JsException; } - break :blk v8.v8__ValueDeserializer__ReadValue(deserializer, v8_context) orelse return error.JsException; + break :blk v8.v8__ValueDeserializer__ReadValue(deserializer, target_context) orelse return error.JsException; }; - return .{ .local = local, .handle = cloned_handle }; + return .{ .local = target, .handle = cloned_handle }; } pub fn persist(self: Value) !Global { diff --git a/src/browser/tests/worker/worker.html b/src/browser/tests/worker/worker.html index 922a7786..95c5b93e 100644 --- a/src/browser/tests/worker/worker.html +++ b/src/browser/tests/worker/worker.html @@ -8,7 +8,6 @@ // as that would destroy the context before the script loads const worker = new Worker('./echo-worker.js'); testing.expectTrue(worker instanceof Worker); - // Note: worker will be cleaned up when page is removed } @@ -31,3 +30,177 @@ testing.expectEqual('worker', response.from); }); + + + + + + + + + + + + + + diff --git a/src/browser/webapi/Worker.zig b/src/browser/webapi/Worker.zig index 3724d156..d3557b11 100644 --- a/src/browser/webapi/Worker.zig +++ b/src/browser/webapi/Worker.zig @@ -190,19 +190,26 @@ pub fn terminate(self: *Worker) void { } // Posts a message from the page to the worker. -// The message is serialized via JSON and dispatched on the WorkerGlobalScope. pub fn postMessage(self: *Worker, message: js.Value) !void { - const session = self._page._session; + const worker_scope = self._worker_scope; + + // Enter worker context to clone the message + var ls: js.Local.Scope = undefined; + worker_scope.js.localScope(&ls); + defer ls.deinit(); + + // Clone message from page context to worker context + const cloned = try message.structuredCloneTo(&ls.local); + const data = try cloned.temp(); + errdefer data.release(); + + const session = worker_scope._session; const message_arena = try session.getArena(.{ .debug = "Worker.postMessage" }); errdefer session.releaseArena(message_arena); - const json = try message.toJson(message_arena); - - const worker_scope = self._worker_scope; - const callback = try message_arena.create(PostMessageToWorkerCallback); callback.* = .{ - .json = json, + .data = data, .arena = message_arena, .worker_scope = worker_scope, }; @@ -215,16 +222,17 @@ pub fn postMessage(self: *Worker, message: js.Value) !void { } const PostMessageToWorkerCallback = struct { - json: []const u8, + data: js.Value.Temp, arena: Allocator, worker_scope: *WorkerGlobalScope, fn cancelled(ctx: *anyopaque) void { - var self: *PostMessageToWorkerCallback = @ptrCast(@alignCast(ctx)); + const self: *PostMessageToWorkerCallback = @ptrCast(@alignCast(ctx)); self.deinit(); } fn deinit(self: *PostMessageToWorkerCallback) void { + self.data.release(); self.worker_scope._session.releaseArena(self.arena); } @@ -239,11 +247,8 @@ const PostMessageToWorkerCallback = struct { worker_scope.js.localScope(&ls); defer ls.deinit(); - // Deserialize the message in worker context - const data = ls.local.parseJSON(self.json) catch |err| { - log.err(.browser, "worker msg parse fail", .{ .err = err }); - return null; - }; + // Get the cloned message data in worker context + const data = self.data.local(&ls.local); // Call the onmessage handler with a simple object {data: value} // TODO: Create proper MessageEvent diff --git a/src/browser/webapi/WorkerGlobalScope.zig b/src/browser/webapi/WorkerGlobalScope.zig index 8384aabc..af9652e2 100644 --- a/src/browser/webapi/WorkerGlobalScope.zig +++ b/src/browser/webapi/WorkerGlobalScope.zig @@ -153,37 +153,61 @@ pub fn setOnMessage(self: *WorkerGlobalScope, setter: ?FunctionSetter) void { } /// Posts a message from the worker back to the page. -/// The message is serialized via JSON and dispatched on the Worker object. +/// The message is cloned via structured clone and dispatched on the Worker object. pub fn postMessage(self: *WorkerGlobalScope, message: JS.Value, exec: *JS.Execution) !void { + _ = exec; + const worker = self._worker; const page = worker._page; + const session = self._session; - // Serialize message to JSON - const json = try message.toJson(self.arena); + const message_arena = try session.getArena(.{ .debug = "WorkerGlobalScope.postMessage" }); + errdefer session.releaseArena(message_arena); + + // Enter page context to clone the message + var ls: JS.Local.Scope = undefined; + page.js.localScope(&ls); + defer ls.deinit(); + + // Clone message from worker context to page context + const cloned = try message.structuredCloneTo(&ls.local); + const data = try cloned.temp(); // Create callback to deliver message to Worker - const callback = try self.arena.create(PostMessageToPageCallback); + const callback = try message_arena.create(PostMessageToPageCallback); callback.* = .{ + .data = data, + .arena = message_arena, .worker = worker, - .json = json, }; try page.js.scheduler.add(callback, PostMessageToPageCallback.run, 0, .{ .name = "WorkerGlobalScope.postMessage", .low_priority = false, + .finalizer = PostMessageToPageCallback.cancelled, }); - - _ = exec; } const PostMessageToPageCallback = struct { + data: JS.Value.Temp, + arena: Allocator, worker: *Worker, - json: []const u8, + + fn cancelled(ctx: *anyopaque) void { + const self: *PostMessageToPageCallback = @ptrCast(@alignCast(ctx)); + self.deinit(); + } + + fn deinit(self: *PostMessageToPageCallback) void { + self.data.release(); + self.worker._page._session.releaseArena(self.arena); + } fn run(ctx: *anyopaque) !?u32 { const self: *PostMessageToPageCallback = @ptrCast(@alignCast(ctx)); - const worker = self.worker; + defer self.deinit(); + const worker = self.worker; const on_message = worker._on_message orelse return null; const page = worker._page; @@ -192,11 +216,8 @@ const PostMessageToPageCallback = struct { page.js.localScope(&ls); defer ls.deinit(); - // Deserialize the message in page context - const data = ls.local.parseJSON(self.json) catch |err| { - log.err(.browser, "page msg parse fail", .{ .err = err }); - return null; - }; + // Get the cloned message data in page context + const data = self.data.local(&ls.local); // Call the onmessage handler with a simple object {data: value} // TODO: Create proper MessageEvent From 0247b21483fa1cfadd60261ef4cf5fe991992a2d Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 7 Apr 2026 16:27:16 +0800 Subject: [PATCH 09/18] Improve event dispatching between/from workers --- src/browser/EventManager.zig | 11 +- src/browser/Page.zig | 2 +- src/browser/js/Caller.zig | 2 +- src/browser/js/Context.zig | 7 +- src/browser/js/Snapshot.zig | 13 +- src/browser/js/bridge.zig | 12 +- src/browser/webapi/EventTarget.zig | 45 ++++-- src/browser/webapi/MessagePort.zig | 2 +- src/browser/webapi/Window.zig | 2 +- src/browser/webapi/Worker.zig | 137 +++++++++--------- src/browser/webapi/WorkerGlobalScope.zig | 165 ++++++++++++---------- src/browser/webapi/event/MessageEvent.zig | 18 +-- src/browser/webapi/net/WebSocket.zig | 2 +- 13 files changed, 221 insertions(+), 197 deletions(-) diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index 97c0a9cc..3c9b8797 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -24,25 +24,24 @@ const String = @import("../string.zig").String; const js = @import("js/js.zig"); const Page = @import("Page.zig"); +const EventManagerBase = @import("EventManagerBase.zig"); const Node = @import("webapi/Node.zig"); const Event = @import("webapi/Event.zig"); const EventTarget = @import("webapi/EventTarget.zig"); const Element = @import("webapi/Element.zig"); -const EventManagerBase = @import("EventManagerBase.zig"); - const Allocator = std.mem.Allocator; -const IS_DEBUG = builtin.mode == .Debug; - -pub const EventManager = @This(); - // Re-export types from EventManagerBase for API compatibility pub const RegisterOptions = EventManagerBase.RegisterOptions; pub const Callback = EventManagerBase.Callback; pub const Listener = EventManagerBase.Listener; +const IS_DEBUG = builtin.mode == .Debug; + +pub const EventManager = @This(); + page: *Page, base: EventManagerBase, diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 43430ccb..66523574 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -1565,7 +1565,7 @@ pub fn deliverSlotchangeEvents(self: *Page) void { continue; }; const target = slot.asNode().asEventTarget(); - _ = target.dispatchEvent(event, self) catch |err| { + self._event_manager.dispatch(target, event) catch |err| { log.err(.page, "deliverSlotchange.dispatch", .{ .err = err, .type = self._type, .url = self.url }); }; } diff --git a/src/browser/js/Caller.zig b/src/browser/js/Caller.zig index 08ab845f..8c272cfe 100644 --- a/src/browser/js/Caller.zig +++ b/src/browser/js/Caller.zig @@ -58,7 +58,7 @@ fn throwDetachedError(isolate: *v8.Isolate) void { _ = v8.v8__Isolate__ThrowException(isolate, js_exception); } -fn initWithContext(self: *Caller, ctx: *Context, v8_context: *const v8.Context) void { +pub fn initWithContext(self: *Caller, ctx: *Context, v8_context: *const v8.Context) void { ctx.call_depth += 1; self.* = Caller{ .local = .{ diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 8e1b6d0e..575e2f3a 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -294,12 +294,7 @@ pub fn getIncumbent(self: *Context) *Page { const ctx = fromC(v8.v8__Isolate__GetIncumbentContext(self.env.isolate.handle).?).?; return switch (ctx.global) { .page => |page| page, - .worker => { - if (comptime IS_DEBUG) { - std.debug.assert(false); - } - unreachable; - }, + .worker => unreachable, }; } diff --git a/src/browser/js/Snapshot.zig b/src/browser/js/Snapshot.zig index 23f6c445..b63e26cb 100644 --- a/src/browser/js/Snapshot.zig +++ b/src/browser/js/Snapshot.zig @@ -178,7 +178,7 @@ pub fn create() !Snapshot { // V8 requires a default context. We could probably make this our // Page context, but having both the Page and Worker context be - // indexed via addContext makes things a little more consistent. + // added via addContext makes things a little more consistent. v8.v8__SnapshotCreator__setDefaultContext(snapshot_creator, temp_context); } @@ -456,10 +456,6 @@ fn collectExternalReferences() [countExternalReferences()]isize { return references; } -fn protoIndexLookup(comptime JsApi: type) ?u16 { - return protoIndexLookupFor(&JsApis, JsApi); -} - fn countInternalFields(comptime JsApi: type) u8 { var last_used_id = 0; var cache_count: u8 = 0; @@ -526,7 +522,7 @@ fn hasNamedIndexedGetter(comptime JsApi: type) bool { } // Generic prototype index lookup for a given API list -fn protoIndexLookupFor(comptime ApiList: []const type, comptime JsApi: type) ?u16 { +fn protoIndexLookup(comptime JsApi: type) ?u16 { @setEvalBranchQuota(100_000); comptime { const T = JsApi.bridge.type; @@ -536,7 +532,7 @@ fn protoIndexLookupFor(comptime ApiList: []const type, comptime JsApi: type) ?u1 const Ptr = std.meta.fieldInfo(T, ._proto).type; const F = @typeInfo(Ptr).pointer.child; // Look up in the provided API list - for (ApiList, 0..) |Api, i| { + for (JsApis, 0..) |Api, i| { if (Api == F.JsApi) { return i; } @@ -618,6 +614,7 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *const v8.F } }, bridge.Function => { + // For non-static functions, use the signature to validate the receiver const func_signature = if (value.static) null else signature; const function_template = v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.func, @@ -671,7 +668,7 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *const v8.F bridge.Property => { const js_value = switch (value.value) { .null => js.simpleZigValueToJs(.{ .handle = isolate }, null, true, false), - inline .bool, .int, .float, .string => |pv| js.simpleZigValueToJs(.{ .handle = isolate }, pv, true, false), + inline .bool, .int, .float, .string => |v| js.simpleZigValueToJs(.{ .handle = isolate }, v, true, false), }; const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len)); diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index fad648ec..0485d037 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -400,14 +400,14 @@ pub const Property = struct { pub fn unknownWindowPropertyCallback(c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 { const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?; - // During snapshot creation, there's no Context in embedder data yet + // During snapshot creation, there's no Context in embedder data yet. + // I hate this check, but there doesn't seem to be a way to add this method + // to the global, without triggering it during snapshot creation. const v8_context = v8.v8__Isolate__GetCurrentContext(v8_isolate) orelse return 0; - if (v8.v8__Context__GetAlignedPointerFromEmbedderData(v8_context, 1) == null) return 0; + const ctx: *Context = @ptrCast(@alignCast(v8.v8__Context__GetAlignedPointerFromEmbedderData(v8_context, 1) orelse return 0)); var caller: Caller = undefined; - if (!caller.init(v8_isolate)) { - return 0; - } + caller.initWithContext(ctx, v8_context); defer caller.deinit(); const local = &caller.local; @@ -578,7 +578,7 @@ fn PrototypeType(comptime T: type) ?type { return Struct(std.meta.fieldInfo(T, ._proto).type); } -pub fn flattenTypes(comptime Types: []const type) [countFlattenedTypes(Types)]type { +fn flattenTypes(comptime Types: []const type) [countFlattenedTypes(Types)]type { var index: usize = 0; var flat: [countFlattenedTypes(Types)]type = undefined; for (Types) |T| { diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index 9bb0a039..d72908ba 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -21,9 +21,11 @@ const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const EventManager = @import("../EventManager.zig"); -const RegisterOptions = EventManager.RegisterOptions; const Event = @import("Event.zig"); +const WorkerGlobalScope = @import("WorkerGlobalScope.zig"); + +const RegisterOptions = EventManager.RegisterOptions; const EventTarget = @This(); @@ -56,15 +58,20 @@ pub fn init(page: *Page) !*EventTarget { }); } -pub fn dispatchEvent(self: *EventTarget, event: *Event, page: *Page) !bool { +pub fn dispatchEvent(self: *EventTarget, event: *Event, exec: *js.Execution) !bool { if (event._event_phase != .none) { return error.InvalidStateError; } event._is_trusted = false; - event.acquireRef(); - defer _ = event.releaseRef(page._session); - try page._event_manager.dispatch(self, event); + switch (exec.context.global) { + .page => |page| { + event.acquireRef(); + defer _ = event.releaseRef(page._session); + try page._event_manager.dispatch(self, event); + }, + .worker => |wgs| try wgs.dispatch(self, event, null), + } return !event._cancelable or !event._prevent_default; } @@ -77,12 +84,12 @@ pub const EventListenerCallback = union(enum) { function: js.Function, object: js.Object, }; -pub fn addEventListener(self: *EventTarget, typ: []const u8, callback_: ?EventListenerCallback, opts_: ?AddEventListenerOptions, page: *Page) !void { +pub fn addEventListener(self: *EventTarget, typ: []const u8, callback_: ?EventListenerCallback, opts_: ?AddEventListenerOptions, exec: *js.Execution) !void { const callback = callback_ orelse return; - const em_callback = switch (callback) { - .object => |obj| EventManager.Callback{ .object = obj }, - .function => |func| EventManager.Callback{ .function = func }, + const em_callback: EventManager.Callback = switch (callback) { + .object => |obj| .{ .object = obj }, + .function => |func| .{ .function = func }, }; const options = blk: { @@ -92,7 +99,11 @@ pub fn addEventListener(self: *EventTarget, typ: []const u8, callback_: ?EventLi .capture => |capture| RegisterOptions{ .capture = capture }, }; }; - return page._event_manager.register(self, typ, em_callback, options); + + switch (exec.context.global) { + .page => |page| _ = try page._event_manager.register(self, typ, em_callback, options), + .worker => |wgs| _ = try wgs._event_manager.register(self, typ, em_callback, options), + } } const RemoveEventListenerOptions = union(enum) { @@ -103,7 +114,7 @@ const RemoveEventListenerOptions = union(enum) { capture: bool = false, }; }; -pub fn removeEventListener(self: *EventTarget, typ: []const u8, callback_: ?EventListenerCallback, opts_: ?RemoveEventListenerOptions, page: *Page) !void { +pub fn removeEventListener(self: *EventTarget, typ: []const u8, callback_: ?EventListenerCallback, opts_: ?RemoveEventListenerOptions, exec: *js.Execution) !void { const callback = callback_ orelse return; // For object callbacks, check if handleEvent exists @@ -113,9 +124,9 @@ pub fn removeEventListener(self: *EventTarget, typ: []const u8, callback_: ?Even } } - const em_callback = switch (callback) { - .function => |func| EventManager.Callback{ .function = func }, - .object => |obj| EventManager.Callback{ .object = obj }, + const em_callback: EventManager.Callback = switch (callback) { + .function => |func| .{ .function = func }, + .object => |obj| .{ .object = obj }, }; const use_capture = blk: { @@ -125,7 +136,11 @@ pub fn removeEventListener(self: *EventTarget, typ: []const u8, callback_: ?Even .options => |opts| opts.capture, }; }; - return page._event_manager.remove(self, typ, em_callback, use_capture); + + switch (exec.context.global) { + .page => |page| page._event_manager.remove(self, typ, em_callback, use_capture), + .worker => |wgs| wgs._event_manager.remove(self, typ, em_callback, use_capture), + } } pub fn format(self: *EventTarget, writer: *std.Io.Writer) !void { diff --git a/src/browser/webapi/MessagePort.zig b/src/browser/webapi/MessagePort.zig index a7bb9bfc..9164f377 100644 --- a/src/browser/webapi/MessagePort.zig +++ b/src/browser/webapi/MessagePort.zig @@ -128,7 +128,7 @@ const PostMessageCallback = struct { .data = .{ .value = self.message }, .origin = "", .source = null, - }, page) catch |err| { + }, page._session) catch |err| { log.err(.dom, "MessagePort.postMessage", .{ .err = err }); return null; }).asEvent(); diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index c6f63daa..f8ea6145 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -780,7 +780,7 @@ const PostMessageCallback = struct { .source = self.source, .bubbles = false, .cancelable = false, - }, page)).asEvent(); + }, page._session)).asEvent(); try page._event_manager.dispatchDirect(event_target, event, window._on_message, .{ .context = "window.postMessage" }); } diff --git a/src/browser/webapi/Worker.zig b/src/browser/webapi/Worker.zig index d3557b11..6c7a0d5a 100644 --- a/src/browser/webapi/Worker.zig +++ b/src/browser/webapi/Worker.zig @@ -28,6 +28,7 @@ const Session = @import("../Session.zig"); const HttpClient = @import("../HttpClient.zig"); const EventTarget = @import("EventTarget.zig"); +const MessageEvent = @import("event/MessageEvent.zig"); const WorkerGlobalScope = @import("WorkerGlobalScope.zig"); const Execution = js.Execution; @@ -190,82 +191,40 @@ pub fn terminate(self: *Worker) void { } // Posts a message from the page to the worker. -pub fn postMessage(self: *Worker, message: js.Value) !void { - const worker_scope = self._worker_scope; - - // Enter worker context to clone the message - var ls: js.Local.Scope = undefined; - worker_scope.js.localScope(&ls); - defer ls.deinit(); - - // Clone message from page context to worker context - const cloned = try message.structuredCloneTo(&ls.local); - const data = try cloned.temp(); - errdefer data.release(); - - const session = worker_scope._session; - const message_arena = try session.getArena(.{ .debug = "Worker.postMessage" }); - errdefer session.releaseArena(message_arena); - - const callback = try message_arena.create(PostMessageToWorkerCallback); - callback.* = .{ - .data = data, - .arena = message_arena, - .worker_scope = worker_scope, - }; - - try worker_scope.js.scheduler.add(callback, PostMessageToWorkerCallback.run, 0, .{ - .name = "Worker.postMessage", - .low_priority = false, - .finalizer = PostMessageToWorkerCallback.cancelled, - }); +pub fn postMessage(self: *Worker, data: js.Value) !void { + try self._worker_scope.receiveMessage(data); } -const PostMessageToWorkerCallback = struct { - data: js.Value.Temp, - arena: Allocator, - worker_scope: *WorkerGlobalScope, - - fn cancelled(ctx: *anyopaque) void { - const self: *PostMessageToWorkerCallback = @ptrCast(@alignCast(ctx)); - self.deinit(); - } - - fn deinit(self: *PostMessageToWorkerCallback) void { - self.data.release(); - self.worker_scope._session.releaseArena(self.arena); - } - - fn run(ctx: *anyopaque) !?u32 { - const self: *PostMessageToWorkerCallback = @ptrCast(@alignCast(ctx)); - defer self.deinit(); - - const worker_scope = self.worker_scope; - const on_message = worker_scope._on_message orelse return null; - +// Called internally by WorkerGlobalScope when it wants to post a message to use +pub fn receiveMessage(self: *Worker, data: js.Value) !void { + const page = self._page; + const cloned_data = blk: { var ls: js.Local.Scope = undefined; - worker_scope.js.localScope(&ls); + page.js.localScope(&ls); defer ls.deinit(); - // Get the cloned message data in worker context - const data = self.data.local(&ls.local); + // clones from where it currently is (the Worker context) to our Page's context + const cloned = try data.structuredCloneTo(&ls.local); + break :blk try cloned.temp(); + }; + errdefer cloned_data.release(); - // Call the onmessage handler with a simple object {data: value} - // TODO: Create proper MessageEvent - const message_obj = ls.local.newObject(); - _ = message_obj.set("data", data, .{}) catch |err| { - log.err(.browser, "message data set fail", .{ .err = err }); - return null; - }; + const message_arena = try page.getArena(.{ .debug = "Worker.receiveMessage" }); + errdefer page.releaseArena(message_arena); - const func = on_message.local(&ls.local); - _ = func.call(void, .{message_obj.toValue()}) catch |err| { - log.err(.browser, "worker onmessage fail", .{ .err = err }); - }; + const callback = try message_arena.create(ReceiveMessageCallback); + callback.* = .{ + .worker = self, + .data = cloned_data, + .arena = message_arena, + }; - return null; - } -}; + try page.js.scheduler.add(callback, ReceiveMessageCallback.run, 0, .{ + .name = "Worker.receiveMessage", + .low_priority = false, + .finalizer = ReceiveMessageCallback.cancelled, + }); +} pub fn getOnMessage(self: *const Worker) ?js.Function.Global { return self._on_message; @@ -304,6 +263,48 @@ fn getFunctionFromSetter(setter_: ?FunctionSetter) ?js.Function.Global { }; } +const ReceiveMessageCallback = struct { + data: js.Value.Temp, + arena: Allocator, + worker: *Worker, + + fn cancelled(ctx: *anyopaque) void { + const self: *ReceiveMessageCallback = @ptrCast(@alignCast(ctx)); + self.data.release(); + self.deinit(); + } + + fn deinit(self: *ReceiveMessageCallback) void { + self.worker._page._session.releaseArena(self.arena); + } + + fn run(ctx: *anyopaque) !?u32 { + const self: *ReceiveMessageCallback = @ptrCast(@alignCast(ctx)); + defer self.deinit(); + + const worker = self.worker; + const page = worker._page; + const target = worker.asEventTarget(); + const on_message = worker._on_message; + + // Check if there are any listeners before creating the event + if (!page._event_manager.hasDirectListeners(target, "message", on_message)) { + self.data.release(); + return null; + } + + const event = (try MessageEvent.initTrusted(comptime .wrap("message"), .{ + .data = .{ .value = self.data }, + .bubbles = false, + .cancelable = false, + }, page._session)).asEvent(); + + try page._event_manager.dispatchDirect(target, event, on_message, .{ .context = "Worker.receiveMessage" }); + + return null; + } +}; + pub const JsApi = struct { pub const bridge = js.Bridge(Worker); diff --git a/src/browser/webapi/WorkerGlobalScope.zig b/src/browser/webapi/WorkerGlobalScope.zig index af9652e2..41454b39 100644 --- a/src/browser/webapi/WorkerGlobalScope.zig +++ b/src/browser/webapi/WorkerGlobalScope.zig @@ -23,8 +23,10 @@ const log = @import("../../log.zig"); const Console = @import("Console.zig"); const Crypto = @import("Crypto.zig"); +const EventManagerBase = @import("../EventManagerBase.zig"); const EventTarget = @import("EventTarget.zig"); const Factory = @import("../Factory.zig"); +const MessageEvent = @import("event/MessageEvent.zig"); const Performance = @import("Performance.zig"); const Session = @import("../Session.zig"); const Worker = @import("Worker.zig"); @@ -48,6 +50,9 @@ js: *JS.Context, // Reference back to the Worker object (for postMessage to page) _worker: *Worker, +// Event management for non-DOM targets in worker context +_event_manager: EventManagerBase, + // These fields represent the "Window"-like component of the WGS _proto: *EventTarget, _console: Console = .init, @@ -76,6 +81,7 @@ pub fn init(worker: *Worker, url: [:0]const u8) !*WorkerGlobalScope { ._proto = undefined, ._factory = factory, ._worker = worker, + ._event_manager = EventManagerBase.init(arena), ._performance = .init(), }); errdefer factory.destroy(self); @@ -104,6 +110,21 @@ pub fn asEventTarget(self: *WorkerGlobalScope) *EventTarget { return self._proto; } +const Event = @import("Event.zig"); + +/// Dispatch an event to listeners on the given target within this worker context. +pub fn dispatch(self: *WorkerGlobalScope, target: *EventTarget, event: *Event, handler: anytype) !void { + try self._event_manager.dispatchDirect( + self.call_arena, + self.js, + target, + event, + handler, + self._session, + .{}, + ); +} + pub fn getSelf(self: *WorkerGlobalScope) *WorkerGlobalScope { return self; } @@ -152,89 +173,44 @@ pub fn setOnMessage(self: *WorkerGlobalScope, setter: ?FunctionSetter) void { self._on_message = getFunctionFromSetter(setter); } -/// Posts a message from the worker back to the page. -/// The message is cloned via structured clone and dispatched on the Worker object. -pub fn postMessage(self: *WorkerGlobalScope, message: JS.Value, exec: *JS.Execution) !void { - _ = exec; - - const worker = self._worker; - const page = worker._page; - const session = self._session; - - const message_arena = try session.getArena(.{ .debug = "WorkerGlobalScope.postMessage" }); - errdefer session.releaseArena(message_arena); - - // Enter page context to clone the message - var ls: JS.Local.Scope = undefined; - page.js.localScope(&ls); - defer ls.deinit(); - - // Clone message from worker context to page context - const cloned = try message.structuredCloneTo(&ls.local); - const data = try cloned.temp(); - - // Create callback to deliver message to Worker - const callback = try message_arena.create(PostMessageToPageCallback); - callback.* = .{ - .data = data, - .arena = message_arena, - .worker = worker, - }; - - try page.js.scheduler.add(callback, PostMessageToPageCallback.run, 0, .{ - .name = "WorkerGlobalScope.postMessage", - .low_priority = false, - .finalizer = PostMessageToPageCallback.cancelled, - }); +// Posts a message from the worker back to the page. +// The message is cloned via structured clone and dispatched on the Worker object. +pub fn postMessage(self: *WorkerGlobalScope, data: JS.Value) !void { + try self._worker.receiveMessage(data); } -const PostMessageToPageCallback = struct { - data: JS.Value.Temp, - arena: Allocator, - worker: *Worker, - - fn cancelled(ctx: *anyopaque) void { - const self: *PostMessageToPageCallback = @ptrCast(@alignCast(ctx)); - self.deinit(); - } - - fn deinit(self: *PostMessageToPageCallback) void { - self.data.release(); - self.worker._page._session.releaseArena(self.arena); - } - - fn run(ctx: *anyopaque) !?u32 { - const self: *PostMessageToPageCallback = @ptrCast(@alignCast(ctx)); - defer self.deinit(); - - const worker = self.worker; - const on_message = worker._on_message orelse return null; - - const page = worker._page; - +// Called internally by Worker when it wants to post a message to us +pub fn receiveMessage(self: *WorkerGlobalScope, data: JS.Value) !void { + const cloned_data = blk: { + // Enter our context to clone the message var ls: JS.Local.Scope = undefined; - page.js.localScope(&ls); + self.js.localScope(&ls); defer ls.deinit(); - // Get the cloned message data in page context - const data = self.data.local(&ls.local); + // clones from where it currently is (the Worker's Page context) to our Context + const cloned = try data.structuredCloneTo(&ls.local); + break :blk try cloned.temp(); + }; + errdefer cloned_data.release(); - // Call the onmessage handler with a simple object {data: value} - // TODO: Create proper MessageEvent - const message_obj = ls.local.newObject(); - _ = message_obj.set("data", data, .{}) catch |err| { - log.err(.browser, "message data set fail", .{ .err = err }); - return null; - }; + const session = self._session; - const func = on_message.local(&ls.local); - _ = func.call(void, .{message_obj.toValue()}) catch |err| { - log.err(.browser, "page onmessage fail", .{ .err = err }); - }; + const message_arena = try session.getArena(.{ .debug = "WorkerGlobalScope.receiveMessage" }); + errdefer session.releaseArena(message_arena); - return null; - } -}; + const callback = try message_arena.create(ReceiveMessageCallback); + callback.* = .{ + .data = cloned_data, + .worker_scope = self, + .arena = message_arena, + }; + + try self.js.scheduler.add(callback, ReceiveMessageCallback.run, 0, .{ + .name = "WorkerGlobalScope.receiveMessage", + .low_priority = false, + .finalizer = ReceiveMessageCallback.cancelled, + }); +} pub fn btoa(_: *const WorkerGlobalScope, input: []const u8, exec: *JS.Execution) ![]const u8 { const base64 = @import("encoding/base64.zig"); @@ -268,6 +244,45 @@ fn getFunctionFromSetter(setter_: ?FunctionSetter) ?JS.Function.Global { }; } +const ReceiveMessageCallback = struct { + data: JS.Value.Temp, + arena: Allocator, + worker_scope: *WorkerGlobalScope, + + fn cancelled(ctx: *anyopaque) void { + const self: *ReceiveMessageCallback = @ptrCast(@alignCast(ctx)); + self.data.release(); + self.deinit(); + } + + fn deinit(self: *ReceiveMessageCallback) void { + self.worker_scope._session.releaseArena(self.arena); + } + + fn run(ctx: *anyopaque) !?u32 { + const self: *ReceiveMessageCallback = @ptrCast(@alignCast(ctx)); + defer self.deinit(); + + const worker_scope = self.worker_scope; + const target = worker_scope.asEventTarget(); + const on_message = worker_scope._on_message; + + // Check if there are any listeners before creating the event + if (!worker_scope._event_manager.hasDirectListeners(target, "message", on_message)) { + self.data.release(); + return null; + } + + const event = (try MessageEvent.initTrusted(comptime .wrap("message"), .{ + .data = .{ .value = self.data }, + .bubbles = false, + .cancelable = false, + }, worker_scope._session)).asEvent(); + try worker_scope.dispatch(target, event, on_message); + return null; + } +}; + pub const JsApi = struct { pub const bridge = JS.Bridge(WorkerGlobalScope); diff --git a/src/browser/webapi/event/MessageEvent.zig b/src/browser/webapi/event/MessageEvent.zig index 27fdfb23..8673e450 100644 --- a/src/browser/webapi/event/MessageEvent.zig +++ b/src/browser/webapi/event/MessageEvent.zig @@ -20,11 +20,13 @@ const std = @import("std"); const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); - const Page = @import("../../Page.zig"); +const Factory = @import("../../Factory.zig"); const Session = @import("../../Session.zig"); + const Event = @import("../Event.zig"); const Window = @import("../Window.zig"); + const Allocator = std.mem.Allocator; const MessageEvent = @This(); @@ -53,19 +55,19 @@ pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*MessageEvent { const arena = try page.getArena(.small, "MessageEvent"); errdefer page.releaseArena(arena); const type_string = try String.init(arena, typ, .{}); - return initWithTrusted(arena, type_string, opts_, false, page); + return initWithTrusted(arena, type_string, opts_, false, page._factory); } -pub fn initTrusted(typ: String, opts_: ?Options, page: *Page) !*MessageEvent { - const arena = try page.getArena(.small, "MessageEvent.trusted"); - errdefer page.releaseArena(arena); - return initWithTrusted(arena, typ, opts_, true, page); +pub fn initTrusted(typ: String, opts_: ?Options, session: *Session) !*MessageEvent { + const arena = try session.getArena(.small, "MessageEvent.trusted"); + errdefer session.releaseArena(arena); + return initWithTrusted(arena, typ, opts_, true, &session.factory); } -fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool, page: *Page) !*MessageEvent { +fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool, factory: *Factory) !*MessageEvent { const opts = opts_ orelse Options{}; - const event = try page._factory.event( + const event = try factory.event( arena, typ, MessageEvent{ diff --git a/src/browser/webapi/net/WebSocket.zig b/src/browser/webapi/net/WebSocket.zig index 5f0c09ac..32be22fb 100644 --- a/src/browser/webapi/net/WebSocket.zig +++ b/src/browser/webapi/net/WebSocket.zig @@ -475,7 +475,7 @@ fn dispatchMessageEvent(self: *WebSocket, data: []const u8, frame_type: http.WsF const event = try MessageEvent.initTrusted(comptime .wrap("message"), .{ .data = msg_data, .origin = "", - }, page); + }, page._session); try page._event_manager.dispatchDirect(target, event.asEvent(), self._on_message, .{ .context = "WebSocket message" }); } } From 5e7f891546e3506d594141ca61b5948d9a6011bd Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 7 Apr 2026 18:21:33 +0800 Subject: [PATCH 10/18] Add unhandledPromiseRejection for Worker Allow *Session to be injected into WebAPI for more generic webapis between page and workers. --- src/browser/js/Caller.zig | 13 ++++- src/browser/js/Env.zig | 14 ++++-- src/browser/webapi/Window.zig | 3 +- src/browser/webapi/WorkerGlobalScope.zig | 49 ++++++++++++++++--- .../webapi/event/PromiseRejectionEvent.zig | 10 ++-- 5 files changed, 70 insertions(+), 19 deletions(-) diff --git a/src/browser/js/Caller.zig b/src/browser/js/Caller.zig index 8c272cfe..01e88a74 100644 --- a/src/browser/js/Caller.zig +++ b/src/browser/js/Caller.zig @@ -21,6 +21,7 @@ const log = @import("../../log.zig"); const string = @import("../../string.zig"); const Page = @import("../Page.zig"); +const Session = @import("../Session.zig"); const WorkerGlobalScope = @import("../webapi/WorkerGlobalScope.zig"); const js = @import("js.zig"); @@ -449,6 +450,10 @@ fn isPage(comptime T: type) bool { return T == *Page or T == *const Page; } +fn isSession(comptime T: type) bool { + return T == *Session or T == *const Session; +} + fn isExecution(comptime T: type) bool { return T == *js.Execution or T == *const js.Execution; } @@ -465,6 +470,10 @@ fn getGlobalArg(comptime T: type, ctx: *Context) T { return &ctx.execution; } + if (comptime isSession(T)) { + return ctx.session; + } + @compileError("Unsupported global arg type: " ++ @typeName(T)); } @@ -743,7 +752,7 @@ fn getArgs(comptime F: type, comptime offset: usize, local: *const Local, info: // from our params slice, because we don't want to bind it to // a JS argument const LastParamType = params[params.len - 1].type.?; - if (comptime isPage(LastParamType) or isExecution(LastParamType)) { + if (comptime isPage(LastParamType) or isExecution(LastParamType) or isSession(LastParamType)) { @field(args, tupleFieldName(params.len - 1 + offset)) = getGlobalArg(LastParamType, local.ctx); break :blk params[0 .. params.len - 1]; } @@ -800,6 +809,8 @@ fn getArgs(comptime F: type, comptime offset: usize, local: *const Local, info: @compileError("Page must be the last parameter: " ++ @typeName(F)); } else if (comptime isExecution(param.type.?)) { @compileError("Execution must be the last parameter: " ++ @typeName(F)); + } else if (comptime isSession(param.type.?)) { + @compileError("Session must be the last parameter: " ++ @typeName(F)); } else if (i >= js_parameter_count) { if (@typeInfo(param.type.?) != .optional) { return error.InvalidArgument; diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index 4539d007..2158e51d 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -504,17 +504,23 @@ fn promiseRejectCallback(message_handle: v8.PromiseRejectMessage) callconv(.c) v .call_arena = ctx.call_arena, }; + const no_handler = promise_event == v8.kPromiseRejectWithNoHandler; switch (ctx.global) { .page => |page| { - page.window.unhandledPromiseRejection(promise_event == v8.kPromiseRejectWithNoHandler, .{ + page.window.unhandledPromiseRejection(no_handler, .{ .local = &local, .handle = &message_handle, }, page) catch |err| { - log.warn(.browser, "unhandled rejection handler", .{ .err = err }); + log.warn(.browser, "unhandled rejection handler", .{ .err = err, .target = "window" }); }; }, - .worker => { - // TODO: Worker promise rejection handling + .worker => |wsg| { + wsg.unhandledPromiseRejection(no_handler, .{ + .local = &local, + .handle = &message_handle, + }) catch |err| { + log.warn(.browser, "unhandled rejection handler", .{ .err = err, .target = "worker" }); + }; }, } } diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index f8ea6145..27a5c38a 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -574,6 +574,7 @@ pub fn scrollBy(self: *Window, opts: ScrollToOpts, y: ?i32, page: *Page) !void { pub fn unhandledPromiseRejection(self: *Window, no_handler: bool, rejection: js.PromiseRejection, page: *Page) !void { if (comptime IS_DEBUG) { log.debug(.js, "unhandled rejection", .{ + .target = "window", .value = rejection.reason(), .stack = rejection.local.stackTrace() catch |err| @errorName(err) orelse "???", }); @@ -591,7 +592,7 @@ pub fn unhandledPromiseRejection(self: *Window, no_handler: bool, rejection: js. const event = (try @import("event/PromiseRejectionEvent.zig").init(event_name, .{ .reason = if (rejection.reason()) |r| try r.temp() else null, .promise = try rejection.promise().temp(), - }, page)).asEvent(); + }, page._session)).asEvent(); try page._event_manager.dispatchDirect(target, event, attribute_callback, .{ .context = "window.unhandledrejection" }); } } diff --git a/src/browser/webapi/WorkerGlobalScope.zig b/src/browser/webapi/WorkerGlobalScope.zig index 41454b39..06f66c01 100644 --- a/src/browser/webapi/WorkerGlobalScope.zig +++ b/src/browser/webapi/WorkerGlobalScope.zig @@ -16,20 +16,27 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +// The struct is like a mix of Page and Window, but a very limited Page and +// a very limited Window. This dual-purpose does make it a bit harder to know +// what's what...e.g what is a WebAPI call and what it called internally. + const std = @import("std"); -const JS = @import("../js/js.zig"); const log = @import("../../log.zig"); -const Console = @import("Console.zig"); -const Crypto = @import("Crypto.zig"); -const EventManagerBase = @import("../EventManagerBase.zig"); -const EventTarget = @import("EventTarget.zig"); +const JS = @import("../js/js.zig"); const Factory = @import("../Factory.zig"); -const MessageEvent = @import("event/MessageEvent.zig"); -const Performance = @import("Performance.zig"); const Session = @import("../Session.zig"); +const EventManagerBase = @import("../EventManagerBase.zig"); + const Worker = @import("Worker.zig"); +const Crypto = @import("Crypto.zig"); +const Console = @import("Console.zig"); +const EventTarget = @import("EventTarget.zig"); +const Performance = @import("Performance.zig"); +const MessageEvent = @import("event/MessageEvent.zig"); + +const IS_DEBUG = @import("builtin").mode == .Debug; const Allocator = std.mem.Allocator; @@ -112,7 +119,7 @@ pub fn asEventTarget(self: *WorkerGlobalScope) *EventTarget { const Event = @import("Event.zig"); -/// Dispatch an event to listeners on the given target within this worker context. +// Dispatch an event to listeners on the given target within this worker context. pub fn dispatch(self: *WorkerGlobalScope, target: *EventTarget, event: *Event, handler: anytype) !void { try self._event_manager.dispatchDirect( self.call_arena, @@ -226,6 +233,32 @@ pub fn structuredClone(_: *const WorkerGlobalScope, value: JS.Value) !JS.Value { return value.structuredClone(); } +pub fn unhandledPromiseRejection(self: *WorkerGlobalScope, no_handler: bool, rejection: JS.PromiseRejection) !void { + if (comptime IS_DEBUG) { + log.debug(.js, "unhandled rejection", .{ + .target = "worker", + .value = rejection.reason(), + .stack = rejection.local.stackTrace() catch |err| @errorName(err) orelse "???", + }); + } + + const event_name, const attribute_callback = blk: { + if (no_handler) { + break :blk .{ "unhandledrejection", self._on_unhandled_rejection }; + } + break :blk .{ "rejectionhandled", self._on_rejection_handled }; + }; + + const target = self.asEventTarget(); + if (self._event_manager.hasDirectListeners(target, event_name, attribute_callback)) { + const event = (try @import("event/PromiseRejectionEvent.zig").init(event_name, .{ + .reason = if (rejection.reason()) |r| try r.temp() else null, + .promise = try rejection.promise().temp(), + }, self._session)).asEvent(); + try self.dispatch(target, event, attribute_callback); + } +} + // TODO: importScripts - needs script loading infrastructure // TODO: location - needs WorkerLocation // TODO: navigator - needs WorkerNavigator diff --git a/src/browser/webapi/event/PromiseRejectionEvent.zig b/src/browser/webapi/event/PromiseRejectionEvent.zig index 44af3904..86883449 100644 --- a/src/browser/webapi/event/PromiseRejectionEvent.zig +++ b/src/browser/webapi/event/PromiseRejectionEvent.zig @@ -19,8 +19,8 @@ const std = @import("std"); const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); -const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); + const Event = @import("../Event.zig"); const PromiseRejectionEvent = @This(); @@ -36,13 +36,13 @@ const PromiseRejectionEventOptions = struct { const Options = Event.inheritOptions(PromiseRejectionEvent, PromiseRejectionEventOptions); -pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*PromiseRejectionEvent { - const arena = try page.getArena(.tiny, "PromiseRejectionEvent"); - errdefer page.releaseArena(arena); +pub fn init(typ: []const u8, opts_: ?Options, session: *Session) !*PromiseRejectionEvent { + const arena = try session.getArena(.tiny, "PromiseRejectionEvent"); + errdefer session.releaseArena(arena); const type_string = try String.init(arena, typ, .{}); const opts = opts_ orelse Options{}; - const event = try page._factory.event( + const event = try session.factory.event( arena, type_string, PromiseRejectionEvent{ From b3a8a7454ebf0db458009a1acdbdbd4eff7f86de Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 7 Apr 2026 19:50:30 +0800 Subject: [PATCH 11/18] add error callbacks for workers --- src/browser/webapi/Window.zig | 2 +- src/browser/webapi/Worker.zig | 68 ++++++++++++++--- src/browser/webapi/WorkerGlobalScope.zig | 95 +++++++++++++++++++++--- src/browser/webapi/event/ErrorEvent.zig | 21 +++--- 4 files changed, 155 insertions(+), 31 deletions(-) diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 27a5c38a..9e716e91 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -335,7 +335,7 @@ pub fn reportError(self: *Window, err: js.Value, page: *Page) !void { .message = err.toStringSlice() catch "Unknown error", .bubbles = false, .cancelable = true, - }, page); + }, page._session); // Invoke window.onerror callback if set (per WHATWG spec, this is called // with 5 arguments: message, source, lineno, colno, error) diff --git a/src/browser/webapi/Worker.zig b/src/browser/webapi/Worker.zig index 6c7a0d5a..a56f62d5 100644 --- a/src/browser/webapi/Worker.zig +++ b/src/browser/webapi/Worker.zig @@ -29,6 +29,7 @@ const HttpClient = @import("../HttpClient.zig"); const EventTarget = @import("EventTarget.zig"); const MessageEvent = @import("event/MessageEvent.zig"); +const ErrorEvent = @import("event/ErrorEvent.zig"); const WorkerGlobalScope = @import("WorkerGlobalScope.zig"); const Execution = js.Execution; @@ -161,7 +162,9 @@ fn httpDoneCallback(ctx: *anyopaque) !void { _ = ls.local.eval(script, url) catch |err| { log.err(.browser, "worker script error", .{ .url = url, .err = err }); - // TODO: Fire error event on Worker + self.fireErrorEvent(@errorName(err), null) catch |e| { + log.warn(.browser, "worker error event failed", .{ .err = e }); + }; return; }; @@ -177,7 +180,35 @@ fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void { .err = err, }); - // TODO: Fire error event on Worker + self.fireErrorEvent(@errorName(err), null) catch |e| { + log.warn(.browser, "worker error event failed", .{ .err = e }); + }; +} + +// Fire an error event on the Worker object (parent context) +fn fireErrorEvent(self: *Worker, message: []const u8, error_value: ?js.Value.Temp) !void { + const page = self._page; + const session = page._session; + const target = self.asEventTarget(); + const on_error = self._on_error; + + // Check if there are any listeners + if (!page._event_manager.hasDirectListeners(target, "error", on_error)) { + if (error_value) |ev| ev.release(); + return; + } + + const error_event = try ErrorEvent.initTrusted(comptime .wrap("error"), .{ + .@"error" = error_value, + .message = message, + .filename = self._url, + .bubbles = false, + .cancelable = true, + }, session); + + try page._event_manager.dispatchDirect(target, error_event.asEvent(), on_error, .{ + .context = "Worker.onerror", + }); } pub fn terminate(self: *Worker) void { @@ -195,7 +226,7 @@ pub fn postMessage(self: *Worker, data: js.Value) !void { try self._worker_scope.receiveMessage(data); } -// Called internally by WorkerGlobalScope when it wants to post a message to use +// Called internally by WorkerGlobalScope when it wants to post a message to us pub fn receiveMessage(self: *Worker, data: js.Value) !void { const page = self._page; const cloned_data = blk: { @@ -204,10 +235,9 @@ pub fn receiveMessage(self: *Worker, data: js.Value) !void { defer ls.deinit(); // clones from where it currently is (the Worker context) to our Page's context - const cloned = try data.structuredCloneTo(&ls.local); - break :blk try cloned.temp(); + const cloned = data.structuredCloneTo(&ls.local) catch |err| break :blk err; + break :blk cloned.temp(); }; - errdefer cloned_data.release(); const message_arena = try page.getArena(.{ .debug = "Worker.receiveMessage" }); errdefer page.releaseArena(message_arena); @@ -264,13 +294,15 @@ fn getFunctionFromSetter(setter_: ?FunctionSetter) ?js.Function.Global { } const ReceiveMessageCallback = struct { - data: js.Value.Temp, + data: anyerror!js.Value.Temp, arena: Allocator, worker: *Worker, fn cancelled(ctx: *anyopaque) void { const self: *ReceiveMessageCallback = @ptrCast(@alignCast(ctx)); - self.data.release(); + if (self.data) |d| { + d.release(); + } else |_| {} self.deinit(); } @@ -285,16 +317,32 @@ const ReceiveMessageCallback = struct { const worker = self.worker; const page = worker._page; const target = worker.asEventTarget(); + + // If data is null, structured clone failed - fire messageerror + const data = self.data catch |err| { + const on_messageerror = worker._on_messageerror; + if (!page._event_manager.hasDirectListeners(target, "messageerror", on_messageerror)) { + return null; + } + const event = (try MessageEvent.initTrusted(comptime .wrap("messageerror"), .{ + .data = .{ .string = @errorName(err) }, + .bubbles = false, + .cancelable = false, + }, page._session)).asEvent(); + try page._event_manager.dispatchDirect(target, event, on_messageerror, .{ .context = "Worker.messageerror" }); + return null; + }; + const on_message = worker._on_message; // Check if there are any listeners before creating the event if (!page._event_manager.hasDirectListeners(target, "message", on_message)) { - self.data.release(); + data.release(); return null; } const event = (try MessageEvent.initTrusted(comptime .wrap("message"), .{ - .data = .{ .value = self.data }, + .data = .{ .value = data }, .bubbles = false, .cancelable = false, }, page._session)).asEvent(); diff --git a/src/browser/webapi/WorkerGlobalScope.zig b/src/browser/webapi/WorkerGlobalScope.zig index 06f66c01..2512baf5 100644 --- a/src/browser/webapi/WorkerGlobalScope.zig +++ b/src/browser/webapi/WorkerGlobalScope.zig @@ -35,8 +35,10 @@ const Console = @import("Console.zig"); const EventTarget = @import("EventTarget.zig"); const Performance = @import("Performance.zig"); const MessageEvent = @import("event/MessageEvent.zig"); +const ErrorEvent = @import("event/ErrorEvent.zig"); -const IS_DEBUG = @import("builtin").mode == .Debug; +const builtin = @import("builtin"); +const IS_DEBUG = builtin.mode == .Debug; const Allocator = std.mem.Allocator; @@ -69,6 +71,7 @@ _on_error: ?JS.Function.Global = null, _on_rejection_handled: ?JS.Function.Global = null, _on_unhandled_rejection: ?JS.Function.Global = null, _on_message: ?JS.Function.Global = null, +_on_messageerror: ?JS.Function.Global = null, pub fn init(worker: *Worker, url: [:0]const u8) !*WorkerGlobalScope { const arena = worker._arena; @@ -180,6 +183,14 @@ pub fn setOnMessage(self: *WorkerGlobalScope, setter: ?FunctionSetter) void { self._on_message = getFunctionFromSetter(setter); } +pub fn getOnMessageError(self: *const WorkerGlobalScope) ?JS.Function.Global { + return self._on_messageerror; +} + +pub fn setOnMessageError(self: *WorkerGlobalScope, setter: ?FunctionSetter) void { + self._on_messageerror = getFunctionFromSetter(setter); +} + // Posts a message from the worker back to the page. // The message is cloned via structured clone and dispatched on the Worker object. pub fn postMessage(self: *WorkerGlobalScope, data: JS.Value) !void { @@ -188,17 +199,16 @@ pub fn postMessage(self: *WorkerGlobalScope, data: JS.Value) !void { // Called internally by Worker when it wants to post a message to us pub fn receiveMessage(self: *WorkerGlobalScope, data: JS.Value) !void { - const cloned_data = blk: { + const cloned_data: ?JS.Value.Temp = blk: { // Enter our context to clone the message var ls: JS.Local.Scope = undefined; self.js.localScope(&ls); defer ls.deinit(); // clones from where it currently is (the Worker's Page context) to our Context - const cloned = try data.structuredCloneTo(&ls.local); - break :blk try cloned.temp(); + const cloned = data.structuredCloneTo(&ls.local) catch break :blk null; + break :blk cloned.temp() catch break :blk null; }; - errdefer cloned_data.release(); const session = self._session; @@ -259,6 +269,56 @@ pub fn unhandledPromiseRejection(self: *WorkerGlobalScope, no_handler: bool, rej } } +pub fn reportError(self: *WorkerGlobalScope, err: JS.Value) !void { + const error_event = try ErrorEvent.initTrusted(comptime .wrap("error"), .{ + .@"error" = try err.temp(), + .message = err.toStringSlice() catch "Unknown error", + .bubbles = false, + .cancelable = true, + }, self._session); + + // Invoke onerror callback if set (per WHATWG spec, this is called + // with 5 arguments: message, source, lineno, colno, error) + // If it returns true, the event is cancelled. + var prevent_default = false; + if (self._on_error) |on_error| { + var ls: JS.Local.Scope = undefined; + self.js.localScope(&ls); + defer ls.deinit(); + + const local_func = ls.toLocal(on_error); + const result = local_func.call(JS.Value, .{ + error_event._message, + error_event._filename, + error_event._line_number, + error_event._column_number, + err, + }) catch null; + + // Per spec: returning true from onerror cancels the event + if (result) |r| { + prevent_default = r.isTrue(); + } + } + + const event = error_event.asEvent(); + event._prevent_default = prevent_default; + // Pass null as handler: onerror was already called above with 5 args. + // We still dispatch so that addEventListener('error', ...) listeners fire. + try self.dispatch(self.asEventTarget(), event, null); + + if (comptime builtin.is_test == false) { + if (!event._prevent_default) { + log.warn(.js, "worker.reportError", .{ + .message = error_event._message, + .filename = error_event._filename, + .line_number = error_event._line_number, + .column_number = error_event._column_number, + }); + } + } +} + // TODO: importScripts - needs script loading infrastructure // TODO: location - needs WorkerLocation // TODO: navigator - needs WorkerNavigator @@ -278,13 +338,13 @@ fn getFunctionFromSetter(setter_: ?FunctionSetter) ?JS.Function.Global { } const ReceiveMessageCallback = struct { - data: JS.Value.Temp, + data: ?JS.Value.Temp, arena: Allocator, worker_scope: *WorkerGlobalScope, fn cancelled(ctx: *anyopaque) void { const self: *ReceiveMessageCallback = @ptrCast(@alignCast(ctx)); - self.data.release(); + if (self.data) |d| d.release(); self.deinit(); } @@ -298,16 +358,31 @@ const ReceiveMessageCallback = struct { const worker_scope = self.worker_scope; const target = worker_scope.asEventTarget(); + + // If data is null, structured clone failed - fire messageerror + if (self.data == null) { + const on_messageerror = worker_scope._on_messageerror; + if (!worker_scope._event_manager.hasDirectListeners(target, "messageerror", on_messageerror)) { + return null; + } + const event = (try MessageEvent.initTrusted(comptime .wrap("messageerror"), .{ + .bubbles = false, + .cancelable = false, + }, worker_scope._session)).asEvent(); + try worker_scope.dispatch(target, event, on_messageerror); + return null; + } + const on_message = worker_scope._on_message; // Check if there are any listeners before creating the event if (!worker_scope._event_manager.hasDirectListeners(target, "message", on_message)) { - self.data.release(); + self.data.?.release(); return null; } const event = (try MessageEvent.initTrusted(comptime .wrap("message"), .{ - .data = .{ .value = self.data }, + .data = .{ .value = self.data.? }, .bubbles = false, .cancelable = false, }, worker_scope._session)).asEvent(); @@ -338,8 +413,10 @@ pub const JsApi = struct { pub const atob = bridge.function(WorkerGlobalScope.atob, .{ .dom_exception = true }); pub const structuredClone = bridge.function(WorkerGlobalScope.structuredClone, .{}); pub const postMessage = bridge.function(WorkerGlobalScope.postMessage, .{}); + pub const reportError = bridge.function(WorkerGlobalScope.reportError, .{}); pub const onmessage = bridge.accessor(WorkerGlobalScope.getOnMessage, WorkerGlobalScope.setOnMessage, .{}); + pub const onmessageerror = bridge.accessor(WorkerGlobalScope.getOnMessageError, WorkerGlobalScope.setOnMessageError, .{}); // Return false since workers don't have secure-context-only APIs pub const isSecureContext = bridge.property(false, .{ .template = false }); diff --git a/src/browser/webapi/event/ErrorEvent.zig b/src/browser/webapi/event/ErrorEvent.zig index 4bb68573..56659d20 100644 --- a/src/browser/webapi/event/ErrorEvent.zig +++ b/src/browser/webapi/event/ErrorEvent.zig @@ -20,7 +20,6 @@ const std = @import("std"); const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); -const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); const Event = @import("../Event.zig"); @@ -46,23 +45,23 @@ pub const ErrorEventOptions = struct { const Options = Event.inheritOptions(ErrorEvent, ErrorEventOptions); -pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*ErrorEvent { - const arena = try page.getArena(.small, "ErrorEvent"); - errdefer page.releaseArena(arena); +pub fn init(typ: []const u8, opts_: ?Options, session: *Session) !*ErrorEvent { + const arena = try session.getArena(.small, "ErrorEvent"); + errdefer session.releaseArena(arena); const type_string = try String.init(arena, typ, .{}); - return initWithTrusted(arena, type_string, opts_, false, page); + return initWithTrusted(arena, type_string, opts_, false, session); } -pub fn initTrusted(typ: String, opts_: ?Options, page: *Page) !*ErrorEvent { - const arena = try page.getArena(.small, "ErrorEvent.trusted"); - errdefer page.releaseArena(arena); - return initWithTrusted(arena, typ, opts_, true, page); +pub fn initTrusted(typ: String, opts_: ?Options, session: *Session) !*ErrorEvent { + const arena = try session.getArena(.small, "ErrorEvent.trusted"); + errdefer session.releaseArena(arena); + return initWithTrusted(arena, typ, opts_, true, session); } -fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool, page: *Page) !*ErrorEvent { +fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool, session: *Session) !*ErrorEvent { const opts = opts_ orelse Options{}; - const event = try page._factory.event( + const event = try session.factory.event( arena, typ, ErrorEvent{ From 3830ccfdf2609d6a0ebd8865fa6c9fb5d26d6fef Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 8 Apr 2026 08:23:09 +0800 Subject: [PATCH 12/18] support worker navigating using object url blobs --- src/browser/webapi/Worker.zig | 43 +++++++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/src/browser/webapi/Worker.zig b/src/browser/webapi/Worker.zig index a56f62d5..82a71ac6 100644 --- a/src/browser/webapi/Worker.zig +++ b/src/browser/webapi/Worker.zig @@ -27,6 +27,7 @@ const Page = @import("../Page.zig"); const Session = @import("../Session.zig"); const HttpClient = @import("../HttpClient.zig"); +const Blob = @import("Blob.zig"); const EventTarget = @import("EventTarget.zig"); const MessageEvent = @import("event/MessageEvent.zig"); const ErrorEvent = @import("event/ErrorEvent.zig"); @@ -63,9 +64,7 @@ pub fn init(url: []const u8, exec: *Execution) !*Worker { const arena = try session.getArena(.{ .debug = "Worker" }); errdefer session.releaseArena(arena); - // Resolve URL relative to current context const resolved_url = try URL.resolve(arena, exec.url.*, url, .{}); - const self = try session.factory.eventTargetWithAllocator(arena, Worker{ ._arena = arena, ._proto = undefined, @@ -75,9 +74,18 @@ pub fn init(url: []const u8, exec: *Execution) !*Worker { }); self._worker_scope = try WorkerGlobalScope.init(self, resolved_url); errdefer self._worker_scope.deinit(); - try page.trackWorker(self); + if (std.mem.startsWith(u8, url, "blob:")) { + errdefer page.removeWorker(self); + const blob: *Blob = page.lookupBlobUrl(url) orelse { + log.warn(.js, "invalid blob", .{ .target = "worker" }); + return error.BlobNotFound; + }; + try self.execute(blob._slice); + return self; + } + const http_client = session.browser.http_client; http_client.request(.{ .ctx = self, @@ -156,15 +164,22 @@ fn httpDoneCallback(ctx: *anyopaque) !void { }); } + try self.execute(script); +} + +fn execute(self: *Worker, script: []const u8) !void { var ls: js.Local.Scope = undefined; self._worker_scope.js.localScope(&ls); defer ls.deinit(); - _ = ls.local.eval(script, url) catch |err| { - log.err(.browser, "worker script error", .{ .url = url, .err = err }); - self.fireErrorEvent(@errorName(err), null) catch |e| { - log.warn(.browser, "worker error event failed", .{ .err = e }); - }; + var try_catch: js.TryCatch = undefined; + try_catch.init(&ls.local); + defer try_catch.deinit(); + + _ = ls.local.eval(script, self._url) catch |err| { + const caught = try_catch.caughtOrError(self._arena, err); + log.err(.browser, "worker script error", .{ .url = self._url, .caught = caught }); + self.fireErrorEvent(caught.exception orelse @errorName(err), null); return; }; @@ -180,13 +195,17 @@ fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void { .err = err, }); - self.fireErrorEvent(@errorName(err), null) catch |e| { - log.warn(.browser, "worker error event failed", .{ .err = e }); - }; + self.fireErrorEvent(@errorName(err), null); } // Fire an error event on the Worker object (parent context) -fn fireErrorEvent(self: *Worker, message: []const u8, error_value: ?js.Value.Temp) !void { +fn fireErrorEvent(self: *Worker, message: []const u8, error_value: ?js.Value.Temp) void { + self._fireErrorEvent(message, error_value) catch |err| { + log.warn(.browser, "worker fire error", .{ .err = err, .message = message }); + }; +} + +fn _fireErrorEvent(self: *Worker, message: []const u8, error_value: ?js.Value.Temp) !void { const page = self._page; const session = page._session; const target = self.asEventTarget(); From d1d561f5c1dccec9b705ab2428e56fdae780de3d Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 8 Apr 2026 09:09:25 +0800 Subject: [PATCH 13/18] Worker.close() --- src/browser/js/Scheduler.zig | 5 +++++ src/browser/webapi/Worker.zig | 6 +++--- src/browser/webapi/WorkerGlobalScope.zig | 12 ++++++++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/browser/js/Scheduler.zig b/src/browser/js/Scheduler.zig index d9fb417e..1055c4d0 100644 --- a/src/browser/js/Scheduler.zig +++ b/src/browser/js/Scheduler.zig @@ -52,6 +52,11 @@ pub fn deinit(self: *Scheduler) void { finalizeTasks(&self.high_priority); } +pub fn reset(self: *Scheduler) void { + self.low_priority.clearRetainingCapacity(); + self.high_priority.clearRetainingCapacity(); +} + const AddOpts = struct { name: []const u8 = "", low_priority: bool = false, diff --git a/src/browser/webapi/Worker.zig b/src/browser/webapi/Worker.zig index 82a71ac6..df923458 100644 --- a/src/browser/webapi/Worker.zig +++ b/src/browser/webapi/Worker.zig @@ -82,7 +82,7 @@ pub fn init(url: []const u8, exec: *Execution) !*Worker { log.warn(.js, "invalid blob", .{ .target = "worker" }); return error.BlobNotFound; }; - try self.execute(blob._slice); + try self.loadInitialScript(blob._slice); return self; } @@ -164,10 +164,10 @@ fn httpDoneCallback(ctx: *anyopaque) !void { }); } - try self.execute(script); + try self.loadInitialScript(script); } -fn execute(self: *Worker, script: []const u8) !void { +fn loadInitialScript(self: *Worker, script: []const u8) !void { var ls: js.Local.Scope = undefined; self._worker_scope.js.localScope(&ls); defer ls.deinit(); diff --git a/src/browser/webapi/WorkerGlobalScope.zig b/src/browser/webapi/WorkerGlobalScope.zig index 2512baf5..f8f7ec7f 100644 --- a/src/browser/webapi/WorkerGlobalScope.zig +++ b/src/browser/webapi/WorkerGlobalScope.zig @@ -63,6 +63,7 @@ _worker: *Worker, _event_manager: EventManagerBase, // These fields represent the "Window"-like component of the WGS +_closed: bool = false, _proto: *EventTarget, _console: Console = .init, _crypto: Crypto = .init, @@ -199,6 +200,10 @@ pub fn postMessage(self: *WorkerGlobalScope, data: JS.Value) !void { // Called internally by Worker when it wants to post a message to us pub fn receiveMessage(self: *WorkerGlobalScope, data: JS.Value) !void { + if (self._closed) { + return; + } + const cloned_data: ?JS.Value.Temp = blk: { // Enter our context to clone the message var ls: JS.Local.Scope = undefined; @@ -269,6 +274,12 @@ pub fn unhandledPromiseRejection(self: *WorkerGlobalScope, no_handler: bool, rej } } +pub fn close(self: *WorkerGlobalScope) void { + // TOOD: we should also stop new tasks from being scheduled + self.js.scheduler.reset(); + self._closed = true; +} + pub fn reportError(self: *WorkerGlobalScope, err: JS.Value) !void { const error_event = try ErrorEvent.initTrusted(comptime .wrap("error"), .{ .@"error" = try err.temp(), @@ -414,6 +425,7 @@ pub const JsApi = struct { pub const structuredClone = bridge.function(WorkerGlobalScope.structuredClone, .{}); pub const postMessage = bridge.function(WorkerGlobalScope.postMessage, .{}); pub const reportError = bridge.function(WorkerGlobalScope.reportError, .{}); + pub const close = bridge.function(WorkerGlobalScope.close, .{}); pub const onmessage = bridge.accessor(WorkerGlobalScope.getOnMessage, WorkerGlobalScope.setOnMessage, .{}); pub const onmessageerror = bridge.accessor(WorkerGlobalScope.getOnMessageError, WorkerGlobalScope.setOnMessageError, .{}); From 197de3dc0c48b2505dee1dd90aa66800cde2e984 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 8 Apr 2026 09:23:11 +0800 Subject: [PATCH 14/18] Limit Worker API to supported types In order for an API to be supported by workers, their dependency on *Page has to be removed. To keep this PR smaller, we're only converting a minimum number of APIs from Page to Execution. All other APIs should not be exposed to the worker (better to get a FormData undefined than to try to segfault trying to execute FormData without a page). --- src/browser/js/bridge.zig | 18 ++++---- src/browser/webapi/Console.zig | 50 ++++++++++----------- src/browser/webapi/DOMException.zig | 5 +-- src/browser/webapi/EventTarget.zig | 6 +-- src/browser/webapi/File.zig | 9 ++-- src/browser/webapi/WorkerGlobalScope.zig | 10 +---- src/browser/webapi/collections/NodeList.zig | 3 +- src/browser/webapi/encoding/TextDecoder.zig | 7 ++- src/browser/webapi/net/URLSearchParams.zig | 1 - 9 files changed, 47 insertions(+), 62 deletions(-) diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 0485d037..22cbed30 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -907,21 +907,21 @@ pub const WorkerJsApis = flattenTypes(&.{ @import("../webapi/WorkerGlobalScope.zig"), @import("../webapi/EventTarget.zig"), @import("../webapi/DOMException.zig"), - @import("../webapi/AbortController.zig"), - @import("../webapi/AbortSignal.zig"), - @import("../webapi/URL.zig"), @import("../webapi/net/URLSearchParams.zig"), - @import("../webapi/net/Headers.zig"), - @import("../webapi/net/Request.zig"), - @import("../webapi/net/Response.zig"), @import("../webapi/encoding/TextEncoder.zig"), @import("../webapi/encoding/TextDecoder.zig"), - @import("../webapi/Blob.zig"), @import("../webapi/File.zig"), - @import("../webapi/net/FormData.zig"), @import("../webapi/Console.zig"), @import("../webapi/Crypto.zig"), - @import("../webapi/Performance.zig"), + // @import("../webapi/URL.zig"), + // @import("../webapi/Blob.zig"), + // @import("../webapi/net/FormData.zig"), + // @import("../webapi/Performance.zig"), + // @import("../webapi/net/Response.zig"), + // @import("../webapi/net/Request.zig"), + // @import("../webapi/net/Headers.zig"), + // @import("../webapi/AbortSignal.zig"), + // @import("../webapi/AbortController.zig"), }); // Master list of ALL JS APIs across all contexts. diff --git a/src/browser/webapi/Console.zig b/src/browser/webapi/Console.zig index 036eea04..8d4ebc88 100644 --- a/src/browser/webapi/Console.zig +++ b/src/browser/webapi/Console.zig @@ -19,7 +19,6 @@ const std = @import("std"); const js = @import("../js/js.zig"); -const Page = @import("../Page.zig"); const logger = @import("../../log.zig"); const Console = @This(); @@ -29,55 +28,55 @@ _counts: std.StringHashMapUnmanaged(u64) = .{}, pub const init: Console = .{}; -pub fn trace(_: *const Console, values: []js.Value, page: *Page) !void { +pub fn trace(_: *const Console, values: []js.Value, exec: *js.Execution) !void { logger.debug(.js, "console.trace", .{ - .stack = page.js.local.?.stackTrace() catch "???", - .args = ValueWriter{ .page = page, .values = values }, + .stack = exec.context.local.?.stackTrace() catch "???", + .args = ValueWriter{ .values = values }, }); } -pub fn debug(_: *const Console, values: []js.Value, page: *Page) void { - logger.debug(.js, "console.debug", .{ValueWriter{ .page = page, .values = values }}); +pub fn debug(_: *const Console, values: []js.Value) void { + logger.debug(.js, "console.debug", .{ValueWriter{ .values = values }}); } -pub fn info(_: *const Console, values: []js.Value, page: *Page) void { - logger.info(.js, "console.info", .{ValueWriter{ .page = page, .values = values }}); +pub fn info(_: *const Console, values: []js.Value) void { + logger.info(.js, "console.info", .{ValueWriter{ .values = values }}); } -pub fn log(_: *const Console, values: []js.Value, page: *Page) void { - logger.info(.js, "console.log", .{ValueWriter{ .page = page, .values = values }}); +pub fn log(_: *const Console, values: []js.Value) void { + logger.info(.js, "console.log", .{ValueWriter{ .values = values }}); } -pub fn warn(_: *const Console, values: []js.Value, page: *Page) void { - logger.warn(.js, "console.warn", .{ValueWriter{ .page = page, .values = values }}); +pub fn warn(_: *const Console, values: []js.Value) void { + logger.warn(.js, "console.warn", .{ValueWriter{ .values = values }}); } pub fn clear(_: *const Console) void {} -pub fn assert(_: *const Console, assertion: js.Value, values: []js.Value, page: *Page) void { +pub fn assert(_: *const Console, assertion: js.Value, values: []js.Value) void { if (assertion.toBool()) { return; } - logger.warn(.js, "console.assert", .{ValueWriter{ .page = page, .values = values }}); + logger.warn(.js, "console.assert", .{ValueWriter{ .values = values }}); } -pub fn @"error"(_: *const Console, values: []js.Value, page: *Page) void { - logger.warn(.js, "console.error", .{ValueWriter{ .page = page, .values = values, .include_stack = true }}); +pub fn @"error"(_: *const Console, values: []js.Value, exec: *js.Execution) void { + logger.warn(.js, "console.error", .{ValueWriter{ .values = values, .stack = exec.context.local.?.stackTrace() catch |err| @errorName(err) orelse "???" }}); } pub fn table(_: *const Console, data: js.Value, columns: ?js.Value) void { logger.info(.js, "console.table", .{ .data = data, .columns = columns }); } -pub fn count(self: *Console, label_: ?[]const u8, page: *Page) !void { +pub fn count(self: *Console, label_: ?[]const u8, exec: *js.Execution) !void { const label = label_ orelse "default"; - const gop = try self._counts.getOrPut(page.arena, label); + const gop = try self._counts.getOrPut(exec.arena, label); var current: u64 = 0; if (gop.found_existing) { current = gop.value_ptr.*; } else { - gop.key_ptr.* = try page.dupeString(label); + gop.key_ptr.* = try exec.arena.dupe(u8, label); } const c = current + 1; @@ -95,15 +94,15 @@ pub fn countReset(self: *Console, label_: ?[]const u8) !void { logger.info(.js, "console.countReset", .{ .label = label, .count = kv.value }); } -pub fn time(self: *Console, label_: ?[]const u8, page: *Page) !void { +pub fn time(self: *Console, label_: ?[]const u8, exec: *js.Execution) !void { const label = label_ orelse "default"; - const gop = try self._timers.getOrPut(page.arena, label); + const gop = try self._timers.getOrPut(exec.arena, label); if (gop.found_existing) { logger.info(.js, "console.time", .{ .label = label, .err = "duplicate timer" }); return; } - gop.key_ptr.* = try page.dupeString(label); + gop.key_ptr.* = try exec.arena.dupe(u8, label); gop.value_ptr.* = timestamp(); } @@ -143,16 +142,15 @@ fn timestamp() u64 { } const ValueWriter = struct { - page: *Page, values: []js.Value, - include_stack: bool = false, + stack: ?[]const u8 = null, pub fn format(self: ValueWriter, writer: *std.io.Writer) !void { for (self.values, 1..) |value, i| { try writer.print("\n arg({d}): {f}", .{ i, value }); } - if (self.include_stack) { - try writer.print("\n stack: {s}", .{self.page.js.local.?.stackTrace() catch |err| @errorName(err) orelse "???"}); + if (self.stack) |s| { + try writer.print("\n stack: {s}", .{s}); } } diff --git a/src/browser/webapi/DOMException.zig b/src/browser/webapi/DOMException.zig index 55c47e74..c0011cd4 100644 --- a/src/browser/webapi/DOMException.zig +++ b/src/browser/webapi/DOMException.zig @@ -18,7 +18,6 @@ const std = @import("std"); const js = @import("../js/js.zig"); -const Page = @import("../Page.zig"); const DOMException = @This(); @@ -129,7 +128,7 @@ pub fn getMessage(self: *const DOMException) []const u8 { }; } -pub fn toString(self: *const DOMException, page: *Page) ![]const u8 { +pub fn toString(self: *const DOMException, exec: *js.Execution) ![]const u8 { const msg = blk: { if (self._custom_message) |msg| { break :blk msg; @@ -139,7 +138,7 @@ pub fn toString(self: *const DOMException, page: *Page) ![]const u8 { else => break :blk self.getMessage(), } }; - return std.fmt.bufPrint(&page.buf, "{s}: {s}", .{ self.getName(), msg }) catch return msg; + return std.fmt.bufPrint(exec.buf, "{s}: {s}", .{ self.getName(), msg }) catch return msg; } const Code = enum(u8) { diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index d72908ba..6147d6a9 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -19,7 +19,7 @@ const std = @import("std"); const js = @import("../js/js.zig"); -const Page = @import("../Page.zig"); +const Session = @import("../Session.zig"); const EventManager = @import("../EventManager.zig"); const Event = @import("Event.zig"); @@ -52,8 +52,8 @@ pub const Type = union(enum) { websocket: *@import("net/WebSocket.zig"), }; -pub fn init(page: *Page) !*EventTarget { - return page._factory.create(EventTarget{ +pub fn init(session: *Session) !*EventTarget { + return session.factory.create(EventTarget{ ._type = .generic, }); } diff --git a/src/browser/webapi/File.zig b/src/browser/webapi/File.zig index e4c70662..9f4cfb46 100644 --- a/src/browser/webapi/File.zig +++ b/src/browser/webapi/File.zig @@ -20,7 +20,6 @@ const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../js/js.zig"); -const Page = @import("../Page.zig"); const Session = @import("../Session.zig"); const Blob = @import("Blob.zig"); @@ -30,10 +29,10 @@ const File = @This(); _proto: *Blob, // TODO: Implement File API. -pub fn init(page: *Page) !*File { - const arena = try page.getArena(.tiny, "File"); - errdefer page.releaseArena(arena); - return page._factory.blob(arena, File{ ._proto = undefined }); +pub fn init(session: *Session) !*File { + const arena = try session.getArena(.tiny, "File"); + errdefer session.releaseArena(arena); + return session.factory.blob(arena, File{ ._proto = undefined }); } pub const JsApi = struct { diff --git a/src/browser/webapi/WorkerGlobalScope.zig b/src/browser/webapi/WorkerGlobalScope.zig index f8f7ec7f..a1c5a3c0 100644 --- a/src/browser/webapi/WorkerGlobalScope.zig +++ b/src/browser/webapi/WorkerGlobalScope.zig @@ -33,7 +33,6 @@ const Worker = @import("Worker.zig"); const Crypto = @import("Crypto.zig"); const Console = @import("Console.zig"); const EventTarget = @import("EventTarget.zig"); -const Performance = @import("Performance.zig"); const MessageEvent = @import("event/MessageEvent.zig"); const ErrorEvent = @import("event/ErrorEvent.zig"); @@ -67,7 +66,6 @@ _closed: bool = false, _proto: *EventTarget, _console: Console = .init, _crypto: Crypto = .init, -_performance: Performance, _on_error: ?JS.Function.Global = null, _on_rejection_handled: ?JS.Function.Global = null, _on_unhandled_rejection: ?JS.Function.Global = null, @@ -92,8 +90,7 @@ pub fn init(worker: *Worker, url: [:0]const u8) !*WorkerGlobalScope { ._proto = undefined, ._factory = factory, ._worker = worker, - ._event_manager = EventManagerBase.init(arena), - ._performance = .init(), + ._event_manager = .init(arena), }); errdefer factory.destroy(self); @@ -148,10 +145,6 @@ pub fn getCrypto(self: *WorkerGlobalScope) *Crypto { return &self._crypto; } -pub fn getPerformance(self: *WorkerGlobalScope) *Performance { - return &self._performance; -} - pub fn getOnError(self: *const WorkerGlobalScope) ?JS.Function.Global { return self._on_error; } @@ -414,7 +407,6 @@ pub const JsApi = struct { pub const self = bridge.accessor(WorkerGlobalScope.getSelf, null, .{}); pub const console = bridge.accessor(WorkerGlobalScope.getConsole, null, .{}); pub const crypto = bridge.accessor(WorkerGlobalScope.getCrypto, null, .{}); - pub const performance = bridge.accessor(WorkerGlobalScope.getPerformance, null, .{}); pub const onerror = bridge.accessor(WorkerGlobalScope.getOnError, WorkerGlobalScope.setOnError, .{}); pub const onrejectionhandled = bridge.accessor(WorkerGlobalScope.getOnRejectionHandled, WorkerGlobalScope.setOnRejectionHandled, .{}); diff --git a/src/browser/webapi/collections/NodeList.zig b/src/browser/webapi/collections/NodeList.zig index 82e23f9e..cd905df7 100644 --- a/src/browser/webapi/collections/NodeList.zig +++ b/src/browser/webapi/collections/NodeList.zig @@ -83,7 +83,7 @@ pub fn keys(self: *NodeList, page: *Page) !*KeyIterator { } pub fn values(self: *NodeList, page: *Page) !*ValueIterator { - return .init(.{ .list = self }, page.js.execution); + return .init(.{ .list = self }, page); } pub fn entries(self: *NodeList, page: *Page) !*EntryIterator { @@ -102,7 +102,6 @@ pub fn forEach(self: *NodeList, cb: js.Function, page: *Page) !void { }; } } -} const GenericIterator = @import("iterator.zig").Entry; pub const KeyIterator = GenericIterator(Iterator, "0"); diff --git a/src/browser/webapi/encoding/TextDecoder.zig b/src/browser/webapi/encoding/TextDecoder.zig index 89ef3023..16176d66 100644 --- a/src/browser/webapi/encoding/TextDecoder.zig +++ b/src/browser/webapi/encoding/TextDecoder.zig @@ -21,7 +21,6 @@ const lp = @import("lightpanda"); const js = @import("../../js/js.zig"); const html5ever = @import("../../parser/html5ever.zig"); -const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); const Allocator = std.mem.Allocator; @@ -42,7 +41,7 @@ const InitOpts = struct { ignoreBOM: bool = false, }; -pub fn init(label_: ?[]const u8, opts_: ?InitOpts, page: *Page) !*TextDecoder { +pub fn init(label_: ?[]const u8, opts_: ?InitOpts, session: *Session) !*TextDecoder { const label = label_ orelse "utf-8"; const info = html5ever.encoding_for_label(label.ptr, label.len); @@ -56,8 +55,8 @@ pub fn init(label_: ?[]const u8, opts_: ?InitOpts, page: *Page) !*TextDecoder { return error.RangeError; } - const arena = try page.getArena(.large, "TextDecoder"); - errdefer page.releaseArena(arena); + const arena = try session.getArena(.large, "TextDecoder"); + errdefer session.releaseArena(arena); const opts = opts_ orelse InitOpts{}; const self = try arena.create(TextDecoder); diff --git a/src/browser/webapi/net/URLSearchParams.zig b/src/browser/webapi/net/URLSearchParams.zig index 95c0594e..b0a31760 100644 --- a/src/browser/webapi/net/URLSearchParams.zig +++ b/src/browser/webapi/net/URLSearchParams.zig @@ -23,7 +23,6 @@ const log = @import("../../../log.zig"); const String = @import("../../../string.zig").String; const Allocator = std.mem.Allocator; -const Page = @import("../../Page.zig"); const FormData = @import("FormData.zig"); const KeyValueList = @import("../KeyValueList.zig"); const Execution = js.Execution; From 60249f5e5d571ef1c20b305a7ddb726620b12a61 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 9 Apr 2026 15:58:44 +0800 Subject: [PATCH 15/18] update v8 dep --- .github/actions/install/action.yml | 2 +- Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index 3e29e96a..f78b307c 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -13,7 +13,7 @@ inputs: zig-v8: description: 'zig v8 version to install' required: false - default: 'v0.3.8' + default: 'v0.3.9' v8: description: 'v8 version to install' required: false diff --git a/Dockerfile b/Dockerfile index ed90e014..715441a0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM debian:stable-slim ARG MINISIG=0.12 ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U ARG V8=14.0.365.4 -ARG ZIG_V8=v0.3.8 +ARG ZIG_V8=v0.3.9 ARG TARGETPLATFORM RUN apt-get update -yq && \ From 4c029595962c526447f6be0ad224d36f0b9c1804 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 10 Apr 2026 23:06:46 +0800 Subject: [PATCH 16/18] update workers to use new arena pool --- src/browser/webapi/Worker.zig | 4 ++-- src/browser/webapi/WorkerGlobalScope.zig | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/browser/webapi/Worker.zig b/src/browser/webapi/Worker.zig index df923458..fd5aaa78 100644 --- a/src/browser/webapi/Worker.zig +++ b/src/browser/webapi/Worker.zig @@ -61,7 +61,7 @@ pub fn init(url: []const u8, exec: *Execution) !*Worker { }; const session = page._session; - const arena = try session.getArena(.{ .debug = "Worker" }); + const arena = try session.getArena(.large, "Worker"); errdefer session.releaseArena(arena); const resolved_url = try URL.resolve(arena, exec.url.*, url, .{}); @@ -258,7 +258,7 @@ pub fn receiveMessage(self: *Worker, data: js.Value) !void { break :blk cloned.temp(); }; - const message_arena = try page.getArena(.{ .debug = "Worker.receiveMessage" }); + const message_arena = try page.getArena(.tiny, "Worker.receiveMessage"); errdefer page.releaseArena(message_arena); const callback = try message_arena.create(ReceiveMessageCallback); diff --git a/src/browser/webapi/WorkerGlobalScope.zig b/src/browser/webapi/WorkerGlobalScope.zig index a1c5a3c0..ecae102a 100644 --- a/src/browser/webapi/WorkerGlobalScope.zig +++ b/src/browser/webapi/WorkerGlobalScope.zig @@ -77,7 +77,7 @@ pub fn init(worker: *Worker, url: [:0]const u8) !*WorkerGlobalScope { const session = worker._page._session; const factory = &session.factory; - const call_arena = try session.getArena(.{ .debug = "WorkerGlobalScope.call_arena" }); + const call_arena = try session.getArena(.small, "WorkerGlobalScope.call_arena"); errdefer session.releaseArena(call_arena); const self = try factory.eventTargetWithAllocator(arena, WorkerGlobalScope{ @@ -210,7 +210,7 @@ pub fn receiveMessage(self: *WorkerGlobalScope, data: JS.Value) !void { const session = self._session; - const message_arena = try session.getArena(.{ .debug = "WorkerGlobalScope.receiveMessage" }); + const message_arena = try session.getArena(.tiny, "WorkerGlobalScope.receiveMessage"); errdefer session.releaseArena(message_arena); const callback = try message_arena.create(ReceiveMessageCallback); From 1adb3719029ea84c9ec1ac8b644d19307a79a1db Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sun, 12 Apr 2026 19:49:08 +0800 Subject: [PATCH 17/18] fix rebase --- src/browser/webapi/History.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/webapi/History.zig b/src/browser/webapi/History.zig index ccbb4f43..19325b7a 100644 --- a/src/browser/webapi/History.zig +++ b/src/browser/webapi/History.zig @@ -62,7 +62,7 @@ pub fn pushState(_: *History, state: js.Value, _: ?[]const u8, _url: ?[]const u8 _ = try page._session.navigation.pushEntry(url, .{ .source = .history, .value = json }, page, true); page.url = url; - page.window._location._url = try URL.init(url, null, page); + page.window._location._url = try URL.init(url, null, &page.js.execution); } pub fn replaceState(_: *History, state: js.Value, _: ?[]const u8, _url: ?[]const u8, page: *Page) !void { From b088c42980b786574dbef62df10d3f6dcc827a74 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 14 Apr 2026 12:42:07 +0800 Subject: [PATCH 18/18] update workers for better cdp integration --- src/browser/webapi/Console.zig | 8 ++++---- src/browser/webapi/Worker.zig | 10 +++++++++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/browser/webapi/Console.zig b/src/browser/webapi/Console.zig index 8d4ebc88..ccba3536 100644 --- a/src/browser/webapi/Console.zig +++ b/src/browser/webapi/Console.zig @@ -127,12 +127,12 @@ pub fn timeEnd(self: *Console, label_: ?[]const u8) void { logger.info(.js, "console.timeEnd", .{ .label = label, .elapsed = elapsed - kv.value }); } -pub fn group(_: *const Console, values: []js.Value, page: *Page) void { - logger.info(.js, "console.group", .{ValueWriter{ .page = page, .values = values }}); +pub fn group(_: *const Console, values: []js.Value) void { + logger.info(.js, "console.group", .{ValueWriter{ .values = values }}); } -pub fn groupCollapsed(_: *const Console, values: []js.Value, page: *Page) void { - logger.info(.js, "console.groupCollapsed", .{ValueWriter{ .page = page, .values = values }}); +pub fn groupCollapsed(_: *const Console, values: []js.Value) void { + logger.info(.js, "console.groupCollapsed", .{ValueWriter{ .values = values }}); } pub fn groupEnd(_: *const Console) void {} diff --git a/src/browser/webapi/Worker.zig b/src/browser/webapi/Worker.zig index fd5aaa78..d4f7f54b 100644 --- a/src/browser/webapi/Worker.zig +++ b/src/browser/webapi/Worker.zig @@ -39,6 +39,11 @@ const IS_DEBUG = @import("builtin").mode == .Debug; const Worker = @This(); +// used by HttpClient when generating notification +// Ultimately used by CDP to generate request/loader ids. +id: u32, +_pseudo_frame_id: u32, + _proto: *EventTarget, _page: *Page, _arena: Allocator, @@ -66,6 +71,8 @@ pub fn init(url: []const u8, exec: *Execution) !*Worker { const resolved_url = try URL.resolve(arena, exec.url.*, url, .{}); const self = try session.factory.eventTargetWithAllocator(arena, Worker{ + .id = session.nextPageId(), + ._pseudo_frame_id = session.nextFrameId(), ._arena = arena, ._proto = undefined, ._page = page, @@ -92,7 +99,8 @@ pub fn init(url: []const u8, exec: *Execution) !*Worker { .url = resolved_url, .method = .GET, .headers = try http_client.newHeaders(), - .frame_id = 0, // Workers don't belong to frames + .page_id = self.id, + .frame_id = self._pseudo_frame_id, .resource_type = .script, .cookie_jar = &session.cookie_jar, .cookie_origin = resolved_url,