diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index d752a0b49..2c393821c 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.4.9' + default: 'v0.5.0' v8: description: 'v8 version to install' required: false diff --git a/.github/actions/v8-snapshot/action.yml b/.github/actions/v8-snapshot/action.yml index 7e3365f83..cbb998f1c 100644 --- a/.github/actions/v8-snapshot/action.yml +++ b/.github/actions/v8-snapshot/action.yml @@ -13,7 +13,7 @@ inputs: zig-v8: description: 'zig-v8 release tag the prebuilt lib came from' required: false - default: 'v0.4.9' + default: 'v0.5.0' runs: using: "composite" diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index e8fc03476..35b17e1a9 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -281,7 +281,7 @@ jobs: fi mkdir -p $CG_ROOT/$CG - cgexec -g memory:$CG ./lightpanda serve & echo $! > LPD.pid + cgexec -g memory:$CG ./lightpanda serve --insecure-disable-tls-host-verification & echo $! > LPD.pid sleep 2 diff --git a/Dockerfile b/Dockerfile index 889c2a78d..cef60fd8d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ FROM debian:stable-slim ARG MINISIG=0.12 ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U ARG V8=14.9.207.35 -ARG ZIG_V8=v0.4.9 +ARG ZIG_V8=v0.5.0 ARG TARGETPLATFORM RUN apt-get update -yq && \ diff --git a/README.md b/README.md index 6114ebf03..806f24d0c 100644 --- a/README.md +++ b/README.md @@ -168,10 +168,11 @@ Run `/save` to export one from your current session, then replay it with you can prototype with the LLM and ship the output to production without a model at runtime. -It supports Anthropic, OpenAI, Gemini, Hugging Face, and local models via -Ollama. You can also run without an LLM using `--no-llm`, which drops you into -the REPL. See the [agent documentation](https://lightpanda.io/docs/usage/agent) -for the full reference. +It supports Anthropic, OpenAI, Gemini, Google Vertex AI, Hugging Face, and +local models via Ollama. You can also run without an LLM using `--no-llm`, +which drops you into the REPL. See the +[agent documentation](https://lightpanda.io/docs/usage/agent) for the full +reference. ```console ./lightpanda agent # auto-detects API key from env @@ -179,6 +180,8 @@ for the full reference. ./lightpanda agent --no-llm # basic REPL, no LLM ./lightpanda agent session.js # run a recorded script ./lightpanda agent --provider gemini --task "..." # force a specific provider +VERTEX_API_KEY=... ./lightpanda agent --provider vertex # Vertex AI, express mode +GOOGLE_CLOUD_PROJECT=my-proj ./lightpanda agent --provider vertex # Vertex AI, token via gcloud auth ``` ### Native MCP and skill diff --git a/build.zig.zon b/build.zig.zon index ce222f12f..81d2eb67e 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -5,8 +5,8 @@ .minimum_zig_version = "0.15.2", .dependencies = .{ .v8 = .{ - .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.4.9.tar.gz", - .hash = "v8-0.0.0-xddH63jnAgB8guO5jPzJD4pnX-CI5od-g4v-fIIjmQXr", + .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.5.0.tar.gz", + .hash = "v8-0.0.0-xddH613pAgDFIxC9Xq1zCPVj2n-8Dxsm-TVMfSRVzq6Z", }, // .v8 = .{ .path = "../zig-v8-fork" }, .brotli = .{ @@ -35,8 +35,8 @@ .hash = "sqlite3-3.51.0-DMxLWssOAABZ8cAvU_LfBIbp0kZjm824PU8sSLXpEDdr", }, .zenai = .{ - .url = "git+https://github.com/lightpanda-io/zenai.git#a8c031222f7545c363b91197394b50024d370a7e", - .hash = "zenai-0.0.0-iOY_VB6zBABxSlIgM38xQQv53QEzy8PuDgHopcQ4mwbV", + .url = "git+https://github.com/lightpanda-io/zenai.git#e58635146733157eb4936388b21a071fcabfa687", + .hash = "zenai-0.0.0-iOY_VIf0BADH7PvSOlnVf564krXnAlns_1IIhh9ZDdwz", }, .isocline = .{ .url = "git+https://github.com/arrufat/isocline?ref=lightpanda#832a9fe25f5f4458fcc47b5acc7c21db669c2f47", diff --git a/src/Config.zig b/src/Config.zig index 68db591aa..11a89fb6a 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -353,14 +353,14 @@ pub fn proxyBearerToken(self: *const Config) ?[:0]const u8 { pub fn httpMaxConcurrent(self: *const Config) u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.http_max_concurrent orelse 10, + inline .serve, .fetch, .mcp, .agent => |opts| opts.http_max_concurrent orelse 40, else => unreachable, }; } pub fn httpMaxHostOpen(self: *const Config) u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.http_max_host_open orelse 4, + inline .serve, .fetch, .mcp, .agent => |opts| opts.http_max_host_open orelse 6, else => unreachable, }; } diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index b62333243..207d84699 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -162,7 +162,7 @@ const script_skill = \\ await Promise.all(pages.map((p, i) => p.goto(items[i].url))); // all in flight at once \\ return pages.map((p, i) => ({ ...items[i], ...p.extract({ /* schema */ }) })); \\ ``` - \\ - Concurrency is bounded by the HTTP connection pool (10 by default, `--http-max-concurrent`); extra navigations queue rather than fail. For long lists, fan out in batches and `page.close()` each page once read so its memory is reclaimed. + \\ - Concurrency is bounded by the HTTP connection pool (40 by default, `--http-max-concurrent`); extra navigations queue rather than fail. For long lists, fan out in batches and `page.close()` each page once read so its memory is reclaimed. \\ - `Promise.all` rejects the whole batch if any `goto` *fails* (a timeout does not reject); use `Promise.allSettled` when partial results are fine. \\ - Walk **serially** on one page (`for (const it of items) { await page.goto(it.url); … }`) only when the steps depend on each other — each page decides the next URL, or they share login/session state. \\3. **`evaluate` is a last resort, not a reading tool.** A `querySelectorAll`-and-parse `page.evaluate` block is always wrong: lift the raw strings with `page.extract`, then trim/split/parse them in top-level JS. Reserve `page.evaluate` for behavior that must run inside the page and no builtin covers — and remember its state dies on every navigation/reload, while script variables persist. @@ -222,6 +222,9 @@ const synthesis_prompt = allocator: std.mem.Allocator, ai_client: ?zenai.provider.Client, model_credentials: ?Credentials, +/// Allocated credentials key (Vertex gcloud token) — other keys are unowned +/// env pointers. The AI client references it: free only after client deinit. +owned_key: ?[:0]const u8, /// True when the no-LLM state is a persisted preference (remembered null /// provider or runtime `/provider null`), so `reportSaved` writes /// `provider = null`. A transient `--no-llm` run leaves it false so saving @@ -340,6 +343,8 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent if (banner_before) welcome.print(resolve); const resolved: ?settings.ResolvedProvider = if (resolve) try settings.resolveCredentials(allocator, opts, remembered, will_repl) else null; + // Before the ai_client errdefer, so on unwind the client goes first. + errdefer if (resolved) |r| if (r.key_owned) allocator.free(r.credentials.key); if (will_repl and !banner_before and resolved != null) welcome.print(resolve); const llm: ?Credentials = if (resolved) |r| r.credentials else null; @@ -394,6 +399,7 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent .allocator = allocator, .ai_client = null, .model_credentials = llm, + .owned_key = if (resolved) |r| (if (r.key_owned) r.credentials.key else null) else null, .no_llm_persisted = remembered_no_llm, .model_base_url = opts.base_url, .model_completions = null, @@ -455,6 +461,7 @@ pub fn deinit(self: *Agent) void { self.browser.deinit(); self.notification.deinit(); if (self.ai_client) |ai_client| ai_client.deinit(self.allocator); + if (self.owned_key) |k| self.allocator.free(k); self.allocator.free(self.model); for (self.available_providers) |p| self.allocator.free(p); self.allocator.free(self.available_providers); @@ -915,11 +922,28 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void { self.terminal.printError("unknown provider: {s} (or 'null' to disable the LLM)", .{trimmed}); return; }; - if (self.model_credentials) |current| if (provider == current.provider) { + // Re-selecting vertex falls through — that's the token-refresh path. + const vertex_project = provider == .vertex and settings.vertexProjectMode(); + if (self.model_credentials) |current| if (provider == current.provider and !vertex_project) { self.terminal.printInfo("provider: {s}", .{@tagName(provider)}); return; }; + if (vertex_project) { + const token = settings.gcloudAccessToken(self.allocator) catch |err| { + self.terminal.printError("could not obtain a Vertex access token: {s} (details above)", .{@errorName(err)}); + return; + }; + self.setProvider(.{ .provider = .vertex, .key = token }, token) catch |err| { + self.allocator.free(token); + self.terminal.printError("failed to set provider: {s}", .{@errorName(err)}); + }; + return; + } const key = zenai.provider.envApiKey(provider) orelse { + if (provider == .vertex) { + self.terminal.printError("vertex needs VERTEX_API_KEY (express mode) or GOOGLE_CLOUD_PROJECT (project mode, token via gcloud)", .{}); + return; + } self.terminal.printError("no API key for {s}; set {s}", .{ @tagName(provider), zenai.provider.envVarName(provider) }); return; }; @@ -932,7 +956,7 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void { self.terminal.printError("no llama.cpp server with a loaded model at {s}", .{self.model_base_url orelse zenai.provider.llama_cpp_default_base_url}); return; } - self.setProvider(.{ .provider = provider, .key = key }) catch |err| { + self.setProvider(.{ .provider = provider, .key = key }, null) catch |err| { self.terminal.printError("failed to set provider: {s}", .{@errorName(err)}); }; } @@ -942,6 +966,8 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void { fn disableProvider(self: *Agent) void { if (self.ai_client) |client| client.deinit(self.allocator); self.ai_client = null; + if (self.owned_key) |k| self.allocator.free(k); + self.owned_key = null; self.model_credentials = null; self.model_completions = null; self.no_llm_persisted = true; @@ -955,12 +981,18 @@ fn hfBillTo(provider: Config.AiProvider) ?[]const u8 { return std.posix.getenv("HF_BILL_TO"); } -fn setProvider(self: *Agent, credentials: Credentials) !void { +/// `owned_key` transfers ownership of an allocated `credentials.key` (Vertex +/// gcloud token) on success; on error the caller still owns it. +fn setProvider(self: *Agent, credentials: Credentials, owned_key: ?[:0]const u8) !void { const new_client = try zenai.provider.Client.init(self.allocator, credentials, .{ .base_url = self.model_base_url, .retry_policy = .long_running, .bill_to = hfBillTo(credentials.provider) }); errdefer new_client.deinit(self.allocator); - const new_model = try self.allocator.dupe(u8, zenai.provider.defaultModel(credentials.provider)); + // A same-provider re-select (vertex token refresh) must not reset the model. + const same_provider = if (self.model_credentials) |c| c.provider == credentials.provider else false; + const new_model = try self.allocator.dupe(u8, if (same_provider) self.model else zenai.provider.defaultModel(credentials.provider)); if (self.ai_client) |client| client.deinit(self.allocator); + if (self.owned_key) |k| self.allocator.free(k); + self.owned_key = owned_key; new_client.setInterrupt(&self.http_interrupt); self.ai_client = new_client; self.model_credentials = credentials; @@ -1527,10 +1559,17 @@ fn recordSlashToolCall( fn formatApiError(self: *Agent, client: zenai.provider.Client, err: anyerror) []const u8 { const e = client.lastError(); const status = e.status orelse return @errorName(err); + const hint = if (status == 401 and client == .vertex) + if (self.owned_key != null) + " (Vertex token may have expired; run /provider vertex to refresh)" + else + " (Vertex express mode needs an express API key — a Gemini Developer key won't work)" + else + ""; if (e.message) |m| { - if (std.fmt.bufPrint(&self.api_error_buf, "HTTP {d} — {s}", .{ status, m })) |s| return s else |_| {} + if (std.fmt.bufPrint(&self.api_error_buf, "HTTP {d} — {s}{s}", .{ status, m, hint })) |s| return s else |_| {} } - return std.fmt.bufPrint(&self.api_error_buf, "HTTP {d}", .{status}) catch @errorName(err); + return std.fmt.bufPrint(&self.api_error_buf, "HTTP {d}{s}", .{ status, hint }) catch @errorName(err); } /// Returned text lives in `conversation.arena`, valid only until the next prune. @@ -1797,10 +1836,16 @@ pub fn listModels(allocator: std.mem.Allocator, opts: Config.Agent) !void { } const resolved = (try settings.resolveCredentials(allocator, opts, null, false)) orelse return error.MissingProvider; const llm = resolved.credentials; + defer if (resolved.key_owned) allocator.free(llm.key); var arena: std.heap.ArenaAllocator = .init(allocator); defer arena.deinit(); - const ids = try zenai.provider.listChatModelIds(allocator, arena.allocator(), llm.provider, llm.key, opts.base_url); + const ids = zenai.provider.listChatModelIds(allocator, arena.allocator(), llm.provider, llm.key, opts.base_url) catch |err| { + if (llm.provider == .vertex and !settings.vertexProjectMode()) { + std.debug.print("Vertex express mode cannot list models (the endpoint requires OAuth); set GOOGLE_CLOUD_PROJECT for project mode.\n", .{}); + } + return err; + }; var stdout_file = std.fs.File.stdout().writer(&.{}); const w = &stdout_file.interface; diff --git a/src/agent/settings.zig b/src/agent/settings.zig index 74447e82a..5d00bd2c2 100644 --- a/src/agent/settings.zig +++ b/src/agent/settings.zig @@ -30,7 +30,7 @@ const Terminal = @import("Terminal.zig"); const string = @import("../string.zig"); const Credentials = zenai.provider.Credentials; -pub const api_keys_hint = "ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, HF_TOKEN, AI_GATEWAY_API_KEY, or MISTRAL_API_KEY"; +pub const api_keys_hint = "ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, HF_TOKEN, AI_GATEWAY_API_KEY, or MISTRAL_API_KEY (Vertex AI: VERTEX_API_KEY, or GOOGLE_CLOUD_PROJECT via gcloud)"; /// Determine which provider to use and read its env key. Returns null /// only when no `--provider` was given AND no env key exists (the caller @@ -38,6 +38,9 @@ pub const api_keys_hint = "ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, HF pub const ResolvedProvider = struct { credentials: Credentials, source: enum { flag, remembered, detected, picked }, + /// Key allocated (Vertex gcloud token) rather than an env pointer; the + /// caller frees it, only after the client that references it is gone. + key_owned: bool = false, }; /// Probe a keyless local provider (Ollama, llama.cpp): its env key is a @@ -52,21 +55,69 @@ pub fn detectLocalProvider(allocator: std.mem.Allocator, tag: Config.AiProvider, return .{ .provider = tag, .key = key }; } +/// With GOOGLE_CLOUD_PROJECT set, zenai's client always sends Bearer auth — +/// an API key can never work, so the credential must be an OAuth token. +pub fn vertexProjectMode() bool { + return std.posix.getenv("GOOGLE_CLOUD_PROJECT") != null; +} + +/// Caller owns the result. Failure prints gcloud's own stderr so the real +/// cause (not logged in, missing SDK) reaches the user. +pub fn gcloudAccessToken(allocator: std.mem.Allocator) ![:0]const u8 { + const result = std.process.Child.run(.{ + .allocator = allocator, + .argv = &.{ "gcloud", "auth", "print-access-token" }, + .max_output_bytes = 64 * 1024, + }) catch |err| { + if (err == error.FileNotFound) { + std.debug.print("gcloud not found on PATH; install the Google Cloud SDK, or unset GOOGLE_CLOUD_PROJECT to use Vertex express mode with GOOGLE_API_KEY.\n", .{}); + return error.GcloudNotFound; + } + return err; + }; + defer allocator.free(result.stdout); + defer allocator.free(result.stderr); + const failed = switch (result.term) { + .Exited => |code| code != 0, + else => true, + }; + const token = std.mem.trim(u8, result.stdout, &std.ascii.whitespace); + if (failed or token.len == 0) { + std.debug.print("`gcloud auth print-access-token` failed:\n{s}", .{result.stderr}); + return error.GcloudTokenFailed; + } + return allocator.dupeZ(u8, token); +} + /// True when a non-Ollama provider key is available (flag, remembered, or /// env-detected). Skips the Ollama probe so it isn't run twice at startup; the /// interactive picker only fires on detected keys, which this still catches. pub fn hasDetectableKey(opts: Config.Agent, remembered: ?Remembered) bool { - if (opts.provider) |p| return zenai.provider.envApiKey(p) != null; - if (remembered) |r| if (r.provider) |p| if (zenai.provider.envApiKey(p)) |_| return true; + if (opts.provider) |p| return zenai.provider.envApiKey(p) != null or (p == .vertex and vertexProjectMode()); + if (remembered) |r| if (r.provider) |p| { + if (zenai.provider.envApiKey(p) != null) return true; + if (p == .vertex and vertexProjectMode()) return true; + }; var buf: [zenai.provider.default_candidates.len]Credentials = undefined; - return zenai.provider.detectKeys(&buf, zenai.provider.default_candidates).len > 0; + return availableProviders(&buf).len > 0; } /// Precedence: `--provider` > remembered (if its key is still set) > first /// detected. Null means no key at all (the reason is already printed). pub fn resolveCredentials(allocator: std.mem.Allocator, opts: Config.Agent, remembered: ?Remembered, allow_pick: bool) !?ResolvedProvider { if (opts.provider) |p| { + if (p == .vertex and vertexProjectMode()) { + const token = try gcloudAccessToken(allocator); + return .{ .credentials = .{ .provider = p, .key = token }, .source = .flag, .key_owned = true }; + } const key = zenai.provider.envApiKey(p) orelse { + if (p == .vertex) { + std.debug.print( + "Vertex needs VERTEX_API_KEY (express mode) or GOOGLE_CLOUD_PROJECT (project mode, token via gcloud) — or pass --no-llm for the basic REPL.\n", + .{}, + ); + return error.MissingApiKey; + } std.debug.print( "Missing API key for --provider {s}: set {s} — or pass --no-llm for the basic REPL.\n", .{ @tagName(p), zenai.provider.envVarName(p) }, @@ -76,12 +127,19 @@ pub fn resolveCredentials(allocator: std.mem.Allocator, opts: Config.Agent, reme return .{ .credentials = .{ .provider = p, .key = key }, .source = .flag }; } - if (remembered) |r| if (r.provider) |p| if (zenai.provider.envApiKey(p)) |key| { - return .{ .credentials = .{ .provider = p, .key = key }, .source = .remembered }; + if (remembered) |r| if (r.provider) |p| { + if (p == .vertex and vertexProjectMode()) { + // On failure the reason is already printed; fall through to detection. + if (gcloudAccessToken(allocator)) |token| { + return .{ .credentials = .{ .provider = p, .key = token }, .source = .remembered, .key_owned = true }; + } else |_| {} + } else if (zenai.provider.envApiKey(p)) |key| { + return .{ .credentials = .{ .provider = p, .key = key }, .source = .remembered }; + } }; var buf: [zenai.provider.default_candidates.len]Credentials = undefined; - const found = zenai.provider.detectKeys(&buf, zenai.provider.default_candidates); + const found = availableProviders(&buf); if (found.len == 0) { if (detectLocalProvider(allocator, .ollama, opts.base_url)) |creds| { return .{ .credentials = creds, .source = .detected }; @@ -99,16 +157,26 @@ pub fn resolveCredentials(allocator: std.mem.Allocator, opts: Config.Agent, reme // A single key needs no choice; non-interactive callers (--list-models, // one-shot tasks, pipes) must not block on a prompt — take the first. if (!allow_pick or found.len == 1 or !Terminal.interactiveTty()) { - return .{ .credentials = found[0], .source = .detected }; + return try finishResolved(allocator, found[0], .detected); } var names: [zenai.provider.default_candidates.len][:0]const u8 = undefined; for (found, 0..) |cred, i| names[i] = @tagName(cred.provider); std.debug.print("\n", .{}); const idx = Terminal.promptNumberedChoice(" Select a provider:", names[0..found.len], 0) catch { - return .{ .credentials = found[0], .source = .detected }; + return try finishResolved(allocator, found[0], .detected); }; - return .{ .credentials = found[idx], .source = .picked }; + return try finishResolved(allocator, found[idx], .picked); +} + +/// Swaps the placeholder key of a detected project-mode Vertex for a real +/// gcloud token. +fn finishResolved(allocator: std.mem.Allocator, credentials: Credentials, source: @FieldType(ResolvedProvider, "source")) !ResolvedProvider { + if (credentials.provider == .vertex and vertexProjectMode()) { + const token = try gcloudAccessToken(allocator); + return .{ .credentials = .{ .provider = .vertex, .key = token }, .source = source, .key_owned = true }; + } + return .{ .credentials = credentials, .source = source }; } pub const remembered_path = ".lp-agent.zon"; @@ -157,8 +225,15 @@ pub fn saveRemembered(remembered: Remembered) !void { /// Cloud providers with a key set. Ollama is excluded — its availability needs /// a live probe (`detectLocalProvider`), too costly for an unconditional startup scan. +/// Vertex project mode joins with a placeholder key — no subprocess during a +/// scan; the gcloud token is fetched on selection (`finishResolved`). pub fn availableProviders(buf: []Credentials) []Credentials { - return zenai.provider.detectKeys(buf, zenai.provider.default_candidates); + const found = zenai.provider.detectKeys(buf, zenai.provider.default_candidates); + if (zenai.provider.useVertex() and vertexProjectMode() and found.len < buf.len) { + buf[found.len] = .{ .provider = .vertex, .key = "" }; + return buf[0 .. found.len + 1]; + } + return found; } pub fn resolveModelName(opts: Config.Agent, resolved: ?ResolvedProvider, remembered: ?Remembered) []const u8 { diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index f903f3e1d..eb0e3b8e9 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -119,6 +119,10 @@ _attribute_named_node_map_lookup: std.AutoHashMapUnmanaged(usize, *Element.Attri // Lazily-created style, classList, and dataset objects. Only stored for elements // that actually access these features via JavaScript, saving 24 bytes per element. _element_styles: Element.StyleLookup = .empty, +// Computed-style views handed out by window.getComputedStyle. The computed +// variant is a stateless lazy view, so one per element suffices — and Chrome +// returns the same object for repeated calls, so identity is also conformance. +_element_computed_styles: Element.StyleLookup = .empty, _element_datasets: Element.DatasetLookup = .empty, _element_class_lists: Element.ClassListLookup = .empty, _element_rel_lists: Element.RelListLookup = .empty, @@ -251,10 +255,15 @@ js: *JS.Context, // An arena for the lifetime of the frame. arena: Allocator, -// An arena with a lifetime guaranteed to be for 1 invoking of a Zig function -// from JS. Best arena to use, when possible. +// An arena with a lifetime for at least the scope of one Zig invocation from +// JS. Prefer local_arena where possible. Use call_arena when allocations may +// need to call back into JS (event dispatch, forEach callback, ....) call_arena: Allocator, +// An arena with a lifetime guaranteed to be for exactly 1 invoking of a Zig +// function from JS. Best arena to use, when possible. +local_arena: Allocator, + parent: ?*Frame, window: *Window, document: *Document, @@ -306,6 +315,9 @@ pub fn init(self: *Frame, frame_id: u32, page: *Page, opts: InitOpts) !void { const call_arena = try session.getArena(.medium, "call_arena"); errdefer session.releaseArena(call_arena); + const local_arena = try session.getArena(.medium, "local_arena"); + errdefer session.releaseArena(local_arena); + const factory = &page.factory; const document = (try factory.document(Node.Document.HTMLDocument{ ._proto = undefined, @@ -320,6 +332,7 @@ pub fn init(self: *Frame, frame_id: u32, page: *Page, opts: InitOpts) !void { .document = document, .window = undefined, .call_arena = call_arena, + .local_arena = local_arena, ._frame_id = frame_id, ._page = page, ._session = session, @@ -382,6 +395,7 @@ pub fn init(self: *Frame, frame_id: u32, page: *Page, opts: InitOpts) !void { .identity = &page.identity, .identity_arena = arena, .call_arena = self.call_arena, + .local_arena = self.local_arena, }); errdefer browser.env.destroyContext(self.js); @@ -480,6 +494,7 @@ pub fn deinit(self: *Frame) void { self._style_manager.deinit(); page.releaseArena(self.call_arena); + page.releaseArena(self.local_arena); } pub fn trackWorker(self: *Frame, worker: *Worker) !void { @@ -1329,6 +1344,10 @@ fn urlBasename(arena: Allocator, url: []const u8) !?[]const u8 { return try arena.dupe(u8, name); } +fn isUtf16Encoding(charset: []const u8) bool { + return std.mem.eql(u8, charset, "UTF-16LE") or std.mem.eql(u8, charset, "UTF-16BE"); +} + fn frameDataCallback(response: HttpClient.Response, data: []const u8) !void { var self: *Frame = @ptrCast(@alignCast(response.ctx)); @@ -1344,8 +1363,10 @@ fn frameDataCallback(response: HttpClient.Response, data: []const u8) !void { // If the HTTP Content-Type header didn't specify a charset and this is HTML, // prescan the first 1024 bytes for a declaration. + var html_prescan_found_charset = false; if (mime.content_type == .text_html and mime.is_default_charset) { if (Mime.prescanCharset(data)) |charset| { + html_prescan_found_charset = true; if (charset.len <= 40) { @memcpy(mime.charset[0..charset.len], charset); mime.charset[charset.len] = 0; @@ -1369,14 +1390,15 @@ fn frameDataCallback(response: HttpClient.Response, data: []const u8) !void { const charset_str = mime.charsetString(); const info = h5e.encoding_for_label(charset_str.ptr, charset_str.len); if (info.isValid()) { - self.charset = info.name(); + const name = info.name(); + self.charset = if (html_prescan_found_charset and isUtf16Encoding(name)) "UTF-8" else name; } self._parse_state = .{ .html = .{ .buffer = .empty, .arena = try self.getArena(.large, "Frame.navigate"), } }; }, - .application_json, .text_javascript, .text_css, .text_plain => { + .application_json, .text_javascript, .text_css, .text_plain, .text_markdown => { var arr: std.ArrayList(u8) = .empty; try arr.appendSlice(self.arena, "
");
                 self._parse_state = .{ .text = arr };
@@ -2188,6 +2210,27 @@ pub fn scheduleCustomElementBackupDrain(self: *Frame) !void {
     try self.js.queueCustomElementBackupDrain();
 }
 
+// Run the network-idle notification checks for this frame and, recursively,
+// its child frames. CDP clients (e.g. puppeteer's networkidle0) expect the
+// networkIdle/networkAlmostIdle lifecycle events on every frame, like Chrome
+// emits them, not just on the root frame.
+pub fn checkIdleNotifications(self: *Frame, total_http_activity: usize) void {
+    switch (self._parse_state) {
+        .html, .complete => {
+            if (self._notified_network_almost_idle.check(total_http_activity <= 2)) {
+                self.notifyNetworkAlmostIdle();
+            }
+            if (self._notified_network_idle.check(total_http_activity == 0)) {
+                self.notifyNetworkIdle();
+            }
+        },
+        else => {},
+    }
+    for (self.child_frames.items) |child| {
+        child.checkIdleNotifications(total_http_activity);
+    }
+}
+
 pub fn notifyNetworkIdle(self: *Frame) void {
     lp.assert(self._notified_network_idle == .done, "Frame.notifyNetworkIdle", .{});
     self._session.notification.dispatch(.frame_network_idle, &.{
@@ -2474,50 +2517,19 @@ pub fn _insertNodeRelative(self: *Frame, comptime from_parser: bool, parent: *No
         }
     }
 
-    const parent_is_connected = parent.isConnected();
-
-    // Tri-state behavior for mutations:
-    // 1. from_parser=true, parse_mode=document -> no mutations (initial document parse)
-    // 2. from_parser=true, parse_mode=fragment -> mutations (innerHTML additions)
-    // 3. from_parser=false, parse_mode=document -> mutation (js manipulation)
-    // split like this because from_parser can be comptime known.
-    const should_notify = if (comptime from_parser)
-        self._parse_mode == .fragment
-    else
-        true;
-
-    if (should_notify) {
-        if (comptime from_parser == false) {
-            // When the parser adds the node, nodeIsReady is only called when the
-            // nodeComplete() callback is executed. nodeIsReady resolves the
-            // node's owning frame itself (only for the few node types that have
-            // ready work), so pass the incumbent `self`.
-            try self.nodeIsReady(false, child);
-
-            // Check if text was added to a script that hasn't started yet.
-            if (child._type == .cdata and parent_is_connected) {
-                if (parent.is(Element.Html.Script)) |script| {
-                    if (!script._executed) {
-                        try self.nodeIsReady(false, parent);
-                    }
-                }
-            }
-        }
-
-        // Notify mutation observers about childList change
-        if (observers.hasMutationObservers(self)) {
-            const previous_sibling = child.previousSibling();
-            const next_sibling = child.nextSibling();
-            const added = [_]*Node{child};
-            observers.notifyChildListChange(self, parent, &added, &.{}, previous_sibling, next_sibling);
-        }
-    }
-
+    // The parser path does its own (limited) notification and connected-callback
+    // work, then returns.
     if (comptime from_parser) {
+        // Of the parser insertions, only fragment parses (innerHTML) mutate a
+        // live tree; the initial document parse suppresses notifications.
+        if (self._parse_mode == .fragment) {
+            self.notifyChildInserted(parent, child);
+        }
+
         if (child.is(Element)) |el| {
-            // Invoke connectedCallback for custom elements during parsing
-            // For main document parsing, we know nodes are connected (fast path)
-            // For fragment parsing (innerHTML), we need to check connectivity
+            // Invoke connectedCallback for custom elements during parsing.
+            // For main document parsing we know nodes are connected (fast path);
+            // for fragment parsing (innerHTML) we check connectivity.
             if (child.isConnected() or child.isInShadowTree()) {
                 if (el.getAttributeSafe(comptime .wrap("id"))) |id| {
                     try self.addElementId(parent, el, id);
@@ -2528,6 +2540,23 @@ pub fn _insertNodeRelative(self: *Frame, comptime from_parser: bool, parent: *No
         return;
     }
 
+    const parent_is_connected = parent.isConnected();
+
+    // nodeIsReady resolves the node's owning frame itself (only for the few node
+    // types that have ready work), so pass the incumbent `self`.
+    try self.nodeIsReady(false, child);
+
+    // Check if text was added to a script that hasn't started yet.
+    if (child._type == .cdata and parent_is_connected) {
+        if (parent.is(Element.Html.Script)) |script| {
+            if (!script._executed) {
+                try self.nodeIsReady(false, parent);
+            }
+        }
+    }
+
+    self.notifyChildInserted(parent, child);
+
     if (opts.child_already_connected and !opts.adopting_to_new_document) {
         // The child is already connected in the same document, we don't have to reconnect it.
         // On cross-document adoption the child has already fired
@@ -2567,6 +2596,17 @@ pub fn _insertNodeRelative(self: *Frame, comptime from_parser: bool, parent: *No
     }
 }
 
+fn notifyChildInserted(self: *Frame, parent: *Node, child: *Node) void {
+    if (!observers.hasMutationObservers(self)) {
+        return;
+    }
+
+    const previous_sibling = child.previousSibling();
+    const next_sibling = child.nextSibling();
+    const added = [_]*Node{child};
+    observers.notifyChildListChange(self, parent, &added, &.{}, previous_sibling, next_sibling);
+}
+
 pub fn attributeChange(self: *Frame, element: *Element, name: String, value: String, old_value: ?String) void {
     _ = Element.Build.call(element, "attributeChange", .{ element, name, value, self }) catch |err| {
         log.err(.bug, "build.attributeChange", .{ .tag = element.getTag(), .name = name, .value = value, .err = err, .type = self._type, .url = self.url });
diff --git a/src/browser/HttpClient.zig b/src/browser/HttpClient.zig
index a4ccc0b02..06e2b22aa 100644
--- a/src/browser/HttpClient.zig
+++ b/src/browser/HttpClient.zig
@@ -578,6 +578,16 @@ fn requestT(self: *Client, req: Request, owner: ?*Owner) !*Transfer {
             if (req.body_outlives_request == false) {
                 owned.body = try arena.dupe(u8, b);
             }
+
+            // Browsers never send Expect: 100-continue; libcurl generates it
+            // for HTTP/1.1 requests whose body exceeds 1MB
+            // (EXPECT_100_THRESHOLD), which stalls the request ~1s against
+            // servers/proxies that never answer the interim response. An
+            // empty value ("Expect:") suppresses the generated header. Only
+            // requests with a body can trigger it, and over HTTP/2 curl never
+            // generates it, so the entry is inert there. Added here (not
+            // configureConn) so redirect/auth retries don't append duplicates.
+            try owned.headers.add("Expect:");
         }
 
         const t = try arena.create(Transfer);
@@ -2088,7 +2098,7 @@ pub const Transfer = struct {
         return result;
     }
 
-    fn dataCallback(buffer: [*]const u8, chunk_count: usize, chunk_len: usize, data: *anyopaque) usize {
+    fn dataCallback(buffer: [*]const u8, chunk_count: usize, chunk_len: usize, data: *anyopaque) callconv(.c) usize {
         // libcurl should only ever emit 1 chunk at a time
         if (comptime IS_DEBUG) {
             std.debug.assert(chunk_count == 1);
diff --git a/src/browser/Mime.zig b/src/browser/Mime.zig
index 16a644fa4..402056079 100644
--- a/src/browser/Mime.zig
+++ b/src/browser/Mime.zig
@@ -40,6 +40,7 @@ pub const ContentTypeEnum = enum {
     text_javascript,
     text_plain,
     text_css,
+    text_markdown,
     image_jpeg,
     image_gif,
     image_png,
@@ -55,6 +56,7 @@ pub const ContentType = union(ContentTypeEnum) {
     text_javascript: void,
     text_plain: void,
     text_css: void,
+    text_markdown: void,
     image_jpeg: void,
     image_gif: void,
     image_png: void,
@@ -72,6 +74,7 @@ pub fn contentTypeString(mime: *const Mime) []const u8 {
         .text_html => "text/html",
         .text_javascript => "application/javascript",
         .text_plain => "text/plain",
+        .text_markdown => "text/markdown",
         .text_css => "text/css",
         .image_jpeg => "image/jpeg",
         .image_png => "image/png",
@@ -342,7 +345,7 @@ pub fn isHTML(self: *const Mime) bool {
 
 pub fn isText(mime: *const Mime) bool {
     return switch (mime.content_type) {
-        .text_xml, .text_html, .text_javascript, .text_plain, .text_css => true,
+        .text_xml, .text_html, .text_javascript, .text_plain, .text_css, .text_markdown => true,
         .application_json => true,
         else => false,
     };
@@ -359,6 +362,7 @@ fn parseContentType(value: []const u8) !struct { ContentType, usize } {
         @"text/html",
         @"text/css",
         @"text/plain",
+        @"text/markdown",
 
         @"text/javascript",
         @"application/javascript",
@@ -377,6 +381,7 @@ fn parseContentType(value: []const u8) !struct { ContentType, usize } {
             .@"text/javascript", .@"application/javascript", .@"application/x-javascript" => .{ .text_javascript = {} },
             .@"text/plain" => .{ .text_plain = {} },
             .@"text/css" => .{ .text_css = {} },
+            .@"text/markdown" => .{ .text_markdown = {} },
             .@"image/jpeg" => .{ .image_jpeg = {} },
             .@"image/png" => .{ .image_png = {} },
             .@"image/gif" => .{ .image_gif = {} },
@@ -790,6 +795,7 @@ test "Mime: parse common" {
 
     try expect(.{ .content_type = .{ .application_json = {} } }, "application/json");
     try expect(.{ .content_type = .{ .text_css = {} } }, "text/css");
+    try expect(.{ .content_type = .{ .text_markdown = {} } }, "text/markdown");
 
     try expect(.{ .content_type = .{ .image_jpeg = {} } }, "image/jpeg");
     try expect(.{ .content_type = .{ .image_png = {} } }, "image/png");
diff --git a/src/browser/Runner.zig b/src/browser/Runner.zig
index d07c1b31f..6665430d5 100644
--- a/src/browser/Runner.zig
+++ b/src/browser/Runner.zig
@@ -263,12 +263,7 @@ fn _tick(self: *Runner, comptime is_cdp: bool, timeout_ms: u32, conditions: []Wa
                 }
             },
             .html, .complete => {
-                if (frame._notified_network_almost_idle.check(total_http_activity <= 2)) {
-                    frame.notifyNetworkAlmostIdle();
-                }
-                if (frame._notified_network_idle.check(total_http_activity == 0)) {
-                    frame.notifyNetworkIdle();
-                }
+                frame.checkIdleNotifications(total_http_activity);
 
                 const met = switch (condition.until) {
                     .done => is_done,
@@ -306,13 +301,13 @@ fn _tick(self: *Runner, comptime is_cdp: bool, timeout_ms: u32, conditions: []Wa
     return .done;
 }
 
-pub fn waitForSelector(self: *Runner, frame_id: u32, selector: [:0]const u8, timeout_ms: u32) !*Node.Element {
+pub fn waitForSelector(self: *Runner, frame_id: u32, input: [:0]const u8, timeout_ms: u32) !*Node.Element {
     const session = self.session;
     const arena = try session.getArena(.small, "Runner.waitForSelector");
     defer session.releaseArena(arena);
 
     var timer = try std.time.Timer.start();
-    const parsed_selector = try Selector.parseLeaky(arena, selector);
+    const selector = try Selector.parseLeaky(arena, input);
 
     while (true) {
         if (session.isCancelled()) {
@@ -324,7 +319,7 @@ pub fn waitForSelector(self: *Runner, frame_id: u32, selector: [:0]const u8, tim
         };
         const frame = &page.frame;
 
-        if (try parsed_selector.query(frame.document.asNode(), frame)) |el| {
+        if (try Selector.query(selector, frame.document.asNode(), frame)) |el| {
             return el;
         }
 
@@ -368,7 +363,7 @@ pub fn waitForScript(self: *Runner, frame_id: u32, src: [:0]const u8, timeout_ms
         defer try_catch.deinit();
 
         const s = ls.local.compile(src, "wait_script") catch |err| {
-            const caught = try_catch.caughtOrError(frame.call_arena, err);
+            const caught = try_catch.caughtOrError(frame.local_arena, err);
             log.err(.app, "wait script error", .{ .err = caught });
             return error.ScriptError;
         };
@@ -396,7 +391,7 @@ pub fn waitForScript(self: *Runner, frame_id: u32, src: [:0]const u8, timeout_ms
 
         const script = compiled.get(ls.local.isolate).bindToCurrentContext(&ls.local);
         const value = script.run() catch |err| {
-            const caught = try_catch.caughtOrError(frame.call_arena, err);
+            const caught = try_catch.caughtOrError(frame.local_arena, err);
             log.err(.app, "wait script error", .{ .err = caught });
             return error.ScriptError;
         };
@@ -473,3 +468,27 @@ test "Runner: waitForScript" {
     var runner = page.session.runner(.{});
     try runner.waitForScript(page.frame_id, "document.querySelector('#sel1')", 10);
 }
+
+test "Runner: networkidle notifies child frames" {
+    const page = try testing.pageTest("runner/iframe_idle.html", .{});
+    defer page.close();
+
+    var runner = page.session.runner(.{});
+    const frame = page.frame().?;
+    try testing.expectEqual(2, frame.child_frames.items.len);
+
+    // A `.networkidle` wait resolves via `is_done` once the page is fully
+    // idle, which can happen before the 500ms idle-notification hold. Keep
+    // ticking (like the CDP serve loop does) until the notifications fire.
+    var attempts: usize = 0;
+    while (frame._notified_network_idle != .done and attempts < 50) : (attempts += 1) {
+        _ = try runner.tickForFrame(page.frame_id, 20, .{ .until = .networkidle });
+        std.Thread.sleep(25 * std.time.ns_per_ms);
+    }
+
+    try testing.expectEqual(true, frame._notified_network_idle == .done);
+    for (frame.child_frames.items) |child| {
+        try testing.expectEqual(true, child._notified_network_almost_idle == .done);
+        try testing.expectEqual(true, child._notified_network_idle == .done);
+    }
+}
diff --git a/src/browser/ScriptManagerBase.zig b/src/browser/ScriptManagerBase.zig
index 07d9e16bb..277332a18 100644
--- a/src/browser/ScriptManagerBase.zig
+++ b/src/browser/ScriptManagerBase.zig
@@ -306,18 +306,19 @@ pub fn preloadModuleHint(self: *ScriptManagerBase, element: *Element.Html, url:
 }
 
 pub fn waitForImport(self: *ScriptManagerBase, url: [:0]const u8) !ModuleSource {
-    const entry = self.imported_modules.getEntry(url) orelse {
-        // It shouldn't be possible for v8 to ask for a module that we didn't
-        // `preloadImport` above.
-        return error.UnknownModule;
-    };
-
     const was_evaluating = self.is_evaluating;
     self.is_evaluating = true;
     defer self.endEvaluationWindow(was_evaluating);
 
     var client = self.client;
     while (true) {
+        // imported_modules can be mutated by client.tick, so we need to lookup
+        // the entry on each iteration.
+        const entry = self.imported_modules.getEntry(url) orelse {
+            // It shouldn't be possible for v8 to ask for a module that we
+            // didn't `preloadImport` above.
+            return error.UnknownModule;
+        };
         switch (entry.value_ptr.state) {
             .loading => {
                 _ = try client.tick(200, .sync_wait);
@@ -935,7 +936,7 @@ pub const Script = struct {
             return;
         }
 
-        const caught = try_catch.caughtOrError(frame.call_arena, error.Unknown);
+        const caught = try_catch.caughtOrError(frame.local_arena, error.Unknown);
         log.warn(.js, "eval script", .{
             .url = url,
             .caught = caught,
diff --git a/src/browser/Session.zig b/src/browser/Session.zig
index 4d113012f..3d6cc242c 100644
--- a/src/browser/Session.zig
+++ b/src/browser/Session.zig
@@ -72,7 +72,10 @@ _nav_cursor: usize = 0,
 
 // Set by the agent script Runtime around one tool call so each `Page` handle's
 // tools act on its own frame, not `pages[0]`. Null for all other callers.
-_tool_frame_override: ?*Frame = null,
+// A frame id, not a pointer: a tool-triggered navigation commits a replacement
+// Page mid-call, freeing the old Frame while keeping its frame id (see
+// `commitPendingPage`).
+_tool_frame_override: ?u32 = null,
 
 // Loader IDs are scoped to the Session: each new BrowserContext gets a
 // fresh counter. Frame IDs (`frame_id_gen`) live on `Browser` instead so
@@ -444,8 +447,9 @@ pub fn primaryPage(self: *Session) ?PageHandle {
 
 // DEPRECATED. Exists during our transition to multi-page sessions.
 pub fn currentFrame(self: *Session) ?*Frame {
-    if (self._tool_frame_override) |frame| {
-        return frame;
+    if (self._tool_frame_override) |frame_id| {
+        // No pages[0] fallthrough: the override targets one specific page.
+        return self.findFrameByFrameId(frame_id);
     }
     if (self.pages.items.len == 0) {
         return null;
@@ -458,8 +462,8 @@ pub fn currentFrame(self: *Session) ?*Frame {
 }
 
 /// See `_tool_frame_override`. Pass null to clear.
-pub fn setToolFrameOverride(self: *Session, frame: ?*Frame) void {
-    self._tool_frame_override = frame;
+pub fn setToolFrameOverride(self: *Session, frame_id: ?u32) void {
+    self._tool_frame_override = frame_id;
 }
 
 // Multi-page aware: frame ids are globally unique (monotonic on `Browser`).
@@ -660,6 +664,7 @@ fn _processFrameNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation)
     const frame_id = frame._frame_id;
     const reuse_window = frame.window;
     const page = frame._page;
+    frame.js.detachGlobal();
     frame.deinit();
     frame.* = undefined;
 
@@ -705,6 +710,7 @@ fn processPopupNavigation(_: *Session, frame: *Frame, qn: *QueuedNavigation) !vo
     const frame_id = frame._frame_id;
     const page = frame._page;
 
+    frame.js.detachGlobal();
     frame.deinit();
     frame.* = undefined;
 
diff --git a/src/browser/frame/node_factory.zig b/src/browser/frame/node_factory.zig
index 43412acb8..5665efcff 100644
--- a/src/browser/frame/node_factory.zig
+++ b/src/browser/frame/node_factory.zig
@@ -356,7 +356,7 @@ pub fn createElementNS(frame: *Frame, namespace: Element.Namespace, name: []cons
                                 defer try_catch.deinit();
 
                                 ls.local.eval(inject_script, "inject_script") catch |err| {
-                                    const caught = try_catch.caughtOrError(frame.call_arena, err);
+                                    const caught = try_catch.caughtOrError(frame.local_arena, err);
                                     log.err(.app, "inject script error", .{ .err = caught });
                                 };
                             }
diff --git a/src/browser/js/Caller.zig b/src/browser/js/Caller.zig
index 55daf7c32..8d5aaf4d8 100644
--- a/src/browser/js/Caller.zig
+++ b/src/browser/js/Caller.zig
@@ -33,6 +33,7 @@ const v8 = js.v8;
 const log = lp.log;
 const ArenaAllocator = std.heap.ArenaAllocator;
 const CALL_ARENA_RETAIN = 1024 * 16;
+const LOCAL_ARENA_RETAIN = 1024 * 16;
 const IS_DEBUG = @import("builtin").mode == .Debug;
 
 const Caller = @This();
@@ -101,13 +102,22 @@ pub fn deinit(self: *Caller) void {
         _ = arena.reset(.{ .retain_with_limit = CALL_ARENA_RETAIN });
     }
 
+    // Unlike call_arena, local_arena is reset on _every_ return, since its
+    // users promise not to hold data across a nested call. In debug, free
+    // back to the backing allocator so a stale pointer trips the
+    // DebugAllocator's use-after-free detection; in release, retain a buffer
+    // to avoid realloc churn.
+    {
+        const local_arena: *ArenaAllocator = @ptrCast(@alignCast(ctx.local_arena.ptr));
+        _ = local_arena.reset(if (comptime IS_DEBUG) .free_all else .{ .retain_with_limit = LOCAL_ARENA_RETAIN });
+    }
+
     ctx.call_depth = call_depth;
     ctx.local = self.prev_local;
     ctx.global.setJs(self.prev_context);
 }
 
 pub const CallOpts = struct {
-    dom_exception: bool = false,
     null_as_undefined: bool = false,
     as_typed_array: bool = false,
     // Constructor-only. When true, `new.target` is pulled from the
@@ -126,12 +136,12 @@ pub fn constructor(self: *Caller, comptime T: type, func: anytype, handle: *cons
     const info = FunctionCallbackInfo{ .handle = handle };
 
     if (!info.isConstructCall()) {
-        handleError(T, @TypeOf(func), local, error.InvalidArgument, info, opts);
+        handleError(T, @TypeOf(func), local, error.InvalidArgument, info);
         return;
     }
 
     self._constructor(func, info, opts) catch |err| {
-        handleError(T, @TypeOf(func), local, err, info, opts);
+        handleError(T, @TypeOf(func), local, err, info);
     };
 }
 
@@ -182,7 +192,7 @@ pub fn getIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, handle
 
     const info = PropertyCallbackInfo{ .handle = handle };
     return _getIndex(T, local, func, idx, info, opts) catch |err| {
-        handleError(T, @TypeOf(func), local, err, info, opts);
+        handleError(T, @TypeOf(func), local, err, info);
         return js.Intercepted.no;
     };
 }
@@ -208,7 +218,7 @@ pub fn getNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *cons
 
     const info = PropertyCallbackInfo{ .handle = handle };
     return _getNamedIndex(T, local, func, name, info, opts) catch |err| {
-        handleError(T, @TypeOf(func), local, err, info, opts);
+        handleError(T, @TypeOf(func), local, err, info);
         return js.Intercepted.no;
     };
 }
@@ -234,7 +244,7 @@ pub fn setNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *cons
 
     const info = PropertyCallbackInfo{ .handle = handle };
     return _setNamedIndex(T, local, func, name, .{ .local = &self.local, .handle = js_value }, info, opts) catch |err| {
-        handleError(T, @TypeOf(func), local, err, info, opts);
+        handleError(T, @TypeOf(func), local, err, info);
         return js.Intercepted.no;
     };
 }
@@ -261,7 +271,7 @@ pub fn deleteNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *c
 
     const info = PropertyCallbackInfo{ .handle = handle };
     return _deleteNamedIndex(T, local, func, name, info, opts) catch |err| {
-        handleError(T, @TypeOf(func), local, err, info, opts);
+        handleError(T, @TypeOf(func), local, err, info);
         return js.Intercepted.no;
     };
 }
@@ -287,7 +297,7 @@ pub fn getEnumerator(self: *Caller, comptime T: type, func: anytype, handle: *co
 
     const info = PropertyCallbackInfo{ .handle = handle };
     return _getEnumerator(T, local, func, info, opts) catch |err| {
-        handleError(T, @TypeOf(func), local, err, info, opts);
+        handleError(T, @TypeOf(func), local, err, info);
         return js.Intercepted.no;
     };
 }
@@ -317,7 +327,7 @@ fn handleIndexedReturn(comptime T: type, comptime F: type, comptime with_value:
                         return js.Intercepted.no;
                     }
                 }
-                handleError(T, F, local, err, info, opts);
+                handleError(T, F, local, err, info);
                 return js.Intercepted.no;
             };
         },
@@ -348,7 +358,7 @@ fn nameToString(local: *const Local, comptime T: type, name: *const v8.Name) !T
     return try js.String.toSlice(.{ .local = local, .handle = handle });
 }
 
-fn handleError(comptime T: type, comptime F: type, local: *const Local, err: anyerror, info: anytype, comptime opts: CallOpts) void {
+fn handleError(comptime T: type, comptime F: type, local: *const Local, err: anyerror, info: anytype) void {
     const isolate = local.isolate;
 
     if (comptime IS_DEBUG and @TypeOf(info) == FunctionCallbackInfo) {
@@ -368,22 +378,21 @@ fn handleError(comptime T: type, comptime F: type, local: *const Local, err: any
         error.RangeError => isolate.createRangeError(""),
         error.OutOfMemory => isolate.createError("out of memory"),
         error.IllegalConstructor => isolate.createError("Illegal Constructor"),
-        else => blk: {
-            if (comptime opts.dom_exception) {
-                const DOMException = @import("../webapi/DOMException.zig");
-                if (DOMException.fromError(err)) |ex| {
-                    const value = local.zigValueToJs(ex, .{}) catch break :blk isolate.createError("internal error");
-                    break :blk value.handle;
-                }
-            }
-            break :blk isolate.createError(@errorName(err));
-        },
+        else => domExceptionToJs(local, err) orelse isolate.createError(@errorName(err)),
     };
 
     const js_exception = isolate.throwException(js_err);
     info.getReturnValue().setValueHandle(js_exception);
 }
 
+// Convert a Zig error to a DOMException. If the error is unknown, return null.
+fn domExceptionToJs(local: *const Local, err: anyerror) ?*const v8.Value {
+    const DOMException = @import("../webapi/DOMException.zig");
+    const ex = DOMException.fromError(err) orelse return null;
+    const value = local.zigValueToJs(ex, .{}) catch return local.isolate.createError("internal error");
+    return value.handle;
+}
+
 // This is extracted to speed up compilation. When left inlined in handleError,
 // this can add as much as 10 seconds of compilation time.
 fn logFunctionCallError(local: *const Local, type_name: []const u8, func: []const u8, err: anyerror, info: FunctionCallbackInfo) void {
@@ -557,13 +566,13 @@ pub const Function = struct {
         static: bool = false,
         wpt_only: bool = false,
         deletable: bool = true,
-        dom_exception: bool = false,
         as_typed_array: bool = false,
         null_as_undefined: bool = false,
         cache: ?Caching = null,
         embedded_receiver: bool = false,
         exposed: Exposed = .both,
         ce_reactions: bool = false,
+        js_name: ?[:0]const u8 = null,
 
         pub const Exposed = enum { both, window, worker };
 
@@ -639,11 +648,7 @@ pub const Function = struct {
         };
 
         const js_value = _call(T, &caller.local, info, func, opts) catch |err| {
-            handleError(T, @TypeOf(func), &caller.local, err, info, .{
-                .dom_exception = opts.dom_exception,
-                .as_typed_array = opts.as_typed_array,
-                .null_as_undefined = opts.null_as_undefined,
-            });
+            handleError(T, @TypeOf(func), &caller.local, err, info);
             return;
         };
 
@@ -666,7 +671,6 @@ pub const Function = struct {
         }
         const res = @call(.auto, func, args);
         const js_value = try local.zigValueToJs(res, .{
-            .dom_exception = opts.dom_exception,
             .as_typed_array = opts.as_typed_array,
             .null_as_undefined = opts.null_as_undefined,
         });
@@ -872,12 +876,6 @@ fn getArgs(comptime F: type, comptime offset: usize, local: *const Local, info:
             @field(args, tupleFieldName(field_index)) = local.jsValueToZig(param.type.?, js_val) catch |err| {
                 const DOMException = @import("../webapi/DOMException.zig");
                 if (DOMException.fromError(err) != null) {
-                    // I don't love this. But we have [a few] cases when trying to
-                    // map a JS Value that we have a specific DOMException to throw.
-                    // Ideally we should only do this if dom_exception = true in the
-                    // bridge definition. But we don't have access to that here.
-                    // Instead, we just rely on the fact that local.jsValueToZig
-                    // only throws a DOMException-known error when it should.
                     return err;
                 }
                 return error.InvalidArgument;
diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig
index 7cc777f23..cf11b3a0e 100644
--- a/src/browser/js/Context.zig
+++ b/src/browser/js/Context.zig
@@ -100,6 +100,10 @@ arena: Allocator,
 // owned by the IsolatedWorld.
 call_arena: Allocator,
 
+// Like call_arena, but reset on _every_ Caller.deinit rather than only at
+// call_depth 0.
+local_arena: Allocator,
+
 // Because calls can be nested (i.e.a function calling a callback),
 // we can only reset the call_arena when call_depth == 0. If we were
 // to reset it within a callback, it would invalidate the data of
@@ -229,6 +233,24 @@ pub fn deinit(self: *Context) void {
     v8.v8__MicrotaskQueue__DELETE(self.microtask_queue);
 }
 
+// The global (e.g. Window) can be reused across contexts. If you do:
+//
+// var w = iframe.contentWindow;
+// iframe.src = 'two.html';
+// w === iframe.contentWindow  (must be true)
+//
+// so when we navigate, the Window/Global is re-used. That's fine with v8, but
+// we need to explicitly detach it from the original before we can safely attach
+// it to the new
+pub fn detachGlobal(self: *Context) void {
+    var hs: js.HandleScope = undefined;
+    hs.init(self.isolate);
+    defer hs.deinit();
+
+    const local_v8_context: *const v8.Context = @ptrCast(v8.v8__Global__Get(&self.handle, self.isolate.handle));
+    v8.v8__Context__DetachGlobal(local_v8_context);
+}
+
 // setOrigin is called at navigation (opaque -> real origin) and again when a
 // script sets document.domain (real origin -> '!'-marked effective domain).
 pub fn setOrigin(self: *Context, key: ?[]const u8) !void {
@@ -294,6 +316,18 @@ pub fn toLocal(self: *Context, global: anytype) js.Local.ToLocalReturnType(@Type
     return l.toLocal(global);
 }
 
+// This context's global (proxy) object, bound to an already-active local.
+// Lets a caller running in a different context — e.g. a [Replaceable] setter
+// invoked on another same-origin window — target this context's global rather
+// than its own.
+pub fn globalObject(self: *Context, local: *const js.Local) js.Object {
+    const local_v8_context: *const v8.Context = @ptrCast(v8.v8__Global__Get(&self.handle, self.isolate.handle));
+    return .{
+        .local = local,
+        .handle = v8.v8__Context__Global(local_v8_context).?,
+    };
+}
+
 pub fn getIncumbent(self: *Context) *Frame {
     const ctx = fromC(v8.v8__Isolate__GetIncumbentContext(self.env.isolate.handle).?).?;
     return switch (ctx.global) {
@@ -700,7 +734,7 @@ fn importMetaResolveCallback(callback_handle: ?*const v8.FunctionCallbackInfo) c
         return;
     };
 
-    const resolved = ctx.script_manager.resolveSpecifier(ctx.call_arena, data.base, specifier) catch {
+    const resolved = ctx.script_manager.resolveSpecifier(ctx.local_arena, data.base, specifier) catch {
         _ = isolate.throwException(isolate.createTypeError("failed to resolve module specifier"));
         return;
     };
@@ -724,8 +758,7 @@ fn _resolveModuleCallback(self: *Context, referrer: js.Module, specifier: [:0]co
         specifier,
     );
 
-    const entry = self.module_cache.getPtr(normalized_specifier).?;
-    if (entry.module) |m| {
+    if (self.module_cache.getPtr(normalized_specifier).?.module) |m| {
         // This import registered a waiter via preloadImport when it was discovered
         // but the compiled module is already cached so we don't have to call
         // waitForImport. Release our waiter so we no longer hold on waiter on
@@ -751,6 +784,9 @@ fn _resolveModuleCallback(self: *Context, referrer: js.Module, specifier: [:0]co
 
     const mod = try compileModule(local, source.src(), normalized_specifier);
     try self.postCompileModule(mod, normalized_specifier, local);
+    // waitForImport can cause module_cache to be mutated (via HttpClient.tick),
+    // so we need to refetch this incase the hashmap changed
+    const entry = self.module_cache.getPtr(normalized_specifier).?;
     entry.module = try mod.persist();
     // Note: We don't instantiate/evaluate here - V8 will handle instantiation
     // as part of the parent module's dependency chain. If there's a resolver
@@ -904,7 +940,7 @@ fn dynamicModuleSourceCallback(ctx: *anyopaque, module_source_: anyerror!ScriptM
         defer try_catch.deinit();
 
         break :blk self.module(true, local, ms.src(), state.specifier, true) catch |err| {
-            const caught = try_catch.caughtOrError(self.call_arena, err);
+            const caught = try_catch.caughtOrError(self.local_arena, err);
             log.err(.js, "module compilation failed", .{
                 .caught = caught,
                 .specifier = state.specifier,
diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig
index 3a37e8d4a..299038ad5 100644
--- a/src/browser/js/Env.zig
+++ b/src/browser/js/Env.zig
@@ -229,6 +229,7 @@ pub const ContextParams = struct {
     identity: *js.Identity,
     identity_arena: Allocator,
     call_arena: Allocator,
+    local_arena: Allocator,
     debug_name: []const u8 = "Context",
 };
 
@@ -315,6 +316,7 @@ fn _createContext(self: *Env, global: anytype, params: ContextParams) !*Context
         .handle = context_global,
         .templates = self.templates,
         .call_arena = params.call_arena,
+        .local_arena = params.local_arena,
         .microtask_queue = microtask_queue,
         .script_manager = if (comptime is_frame) &global._script_manager.base else &global._script_manager,
         .scheduler = .init(context_arena),
@@ -332,6 +334,7 @@ fn _createContext(self: *Env, global: anytype, params: ContextParams) !*Context
         .page = context.page,
         .session = page.session,
         .call_arena = params.call_arena,
+        .local_arena = params.local_arena,
         ._factory = global._factory,
         ._scheduler = &context.scheduler,
     };
diff --git a/src/browser/js/Execution.zig b/src/browser/js/Execution.zig
index b4fbebd8a..ee3abadf6 100644
--- a/src/browser/js/Execution.zig
+++ b/src/browser/js/Execution.zig
@@ -51,6 +51,7 @@ js: *Context,
 buf: []u8,
 arena: Allocator,
 call_arena: Allocator,
+local_arena: Allocator,
 
 page: *Page,
 session: *Session,
diff --git a/src/browser/js/Snapshot.zig b/src/browser/js/Snapshot.zig
index a1cfd9d2e..1560b3794 100644
--- a/src/browser/js/Snapshot.zig
+++ b/src/browser/js/Snapshot.zig
@@ -314,13 +314,8 @@ fn createSnapshotContext(
                 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;
-                // Web IDL: interface objects on the global are non-enumerable
-                // by default. Opt back in via JsApi.Meta.enumerable = true.
-                var properties: v8.PropertyAttribute = v8.DontEnum;
-                if (@hasDecl(JsApi.Meta, "enumerable") and JsApi.Meta.enumerable == true) {
-                    properties = v8.None;
-                }
-                v8.v8__Object__DefineOwnProperty(global_obj, context, v8_class_name, func, properties, &maybe_result);
+                // Web IDL: interface objects on the global are non-enumerable.
+                v8.v8__Object__DefineOwnProperty(global_obj, context, v8_class_name, func, v8.DontEnum, &maybe_result);
             }
         }
 
@@ -759,12 +754,13 @@ fn attachClass(comptime JsApi: type, comptime flatten: bool, isolate: *v8.Isolat
                 if (value.static) {
                     v8.v8__Template__SetAccessorProperty(@ptrCast(template), js_name, getter_callback, setter_callback, attribute);
                 } else {
-                    const accessor_attr = if (own_properties) attribute else attribute | v8.DontEnum;
+                    // Web IDL: attributes on the interface prototype object
+                    // (and mirrored onto [Global] instances) are enumerable.
                     v8.v8__ObjectTemplate__SetAccessorProperty__Config(define_on orelse prototype, &.{
                         .key = js_name,
                         .getter = getter_callback,
                         .setter = setter_callback,
-                        .attribute = accessor_attr,
+                        .attribute = attribute,
                     });
                 }
             },
@@ -780,13 +776,16 @@ fn attachClass(comptime JsApi: type, comptime flatten: bool, isolate: *v8.Isolat
                     .length = value.arity,
                     .signature = func_signature,
                 }).?;
-                const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
+                // A member may override its JS-visible name (see Response.json_static)
+                const fn_name = value.js_name orelse name;
+                const js_name = v8.v8__String__NewFromUtf8(isolate, fn_name.ptr, v8.kNormal, @intCast(fn_name.len));
                 v8.v8__FunctionTemplate__SetClassName(function_template, js_name);
                 if (value.static and !own_properties) {
                     v8.v8__Template__Set(@ptrCast(template), js_name, @ptrCast(function_template), v8.None);
                 } else {
-                    const fn_attr: v8.PropertyAttribute = if (own_properties) v8.None else v8.DontEnum;
-                    v8.v8__Template__Set(@ptrCast(define_on orelse member_template), js_name, @ptrCast(function_template), fn_attr);
+                    // Web IDL: operations on the interface prototype object
+                    // (and mirrored onto [Global] instances) are enumerable.
+                    v8.v8__Template__Set(@ptrCast(define_on orelse member_template), js_name, @ptrCast(function_template), v8.None);
                 }
             },
             bridge.Indexed => {
@@ -825,7 +824,8 @@ fn attachClass(comptime JsApi: type, comptime flatten: bool, isolate: *v8.Isolat
                     v8.v8__Symbol__GetAsyncIterator(isolate)
                 else
                     v8.v8__Symbol__GetIterator(isolate);
-                v8.v8__Template__Set(@ptrCast(prototype), js_name, @ptrCast(function_template), v8.None);
+                // Web IDL: @@iterator is { writable, enumerable: false, configurable }.
+                v8.v8__Template__Set(@ptrCast(prototype), js_name, @ptrCast(function_template), v8.DontEnum);
             },
             bridge.Property => {
                 const js_value = switch (value.value) {
diff --git a/src/browser/js/Value.zig b/src/browser/js/Value.zig
index c777a87e7..358561df8 100644
--- a/src/browser/js/Value.zig
+++ b/src/browser/js/Value.zig
@@ -20,8 +20,10 @@ const std = @import("std");
 const lp = @import("lightpanda");
 
 const js = @import("js.zig");
+const TaggedOpaque = @import("TaggedOpaque.zig");
 
 const v8 = js.v8;
+const bridge = js.bridge;
 
 const IS_DEBUG = @import("builtin").mode == .Debug;
 
@@ -324,8 +326,9 @@ pub fn jsonStringify(self: Value, jws: anytype) !void {
     jws.endWriteRaw();
 }
 
-// Throws a DataCloneError for host objects (Blob, File, etc.) that cannot be serialized.
-// Does not support transferables which require additional delegate callbacks.
+// Clones host objects listed in cloneable_types; throws a DataCloneError for
+// any other. Does not support transferables which require additional delegate
+// callbacks.
 pub fn structuredClone(self: Value) !Value {
     return self.structuredCloneTo(self.local);
 }
@@ -333,76 +336,286 @@ pub fn structuredClone(self: Value) !Value {
 // 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.
-        // Returns false to indicate the object cannot be cloned, and throws a DataCloneError.
-        // V8 asserts has_exception() after this returns false, so we must throw here.
-        fn writeHostObject(_: ?*anyopaque, isolate: ?*v8.Isolate, _: ?*const v8.Object) callconv(.c) v8.MaybeBool {
-            const iso = isolate orelse return .{ .has_value = true, .value = false };
-            const message = v8.v8__String__NewFromUtf8(iso, "The object cannot be cloned.", v8.kNormal, -1);
-            const error_value = v8.v8__Exception__Error(message) orelse return .{ .has_value = true, .value = false };
-            _ = v8.v8__Isolate__ThrowException(iso, error_value);
-            return .{ .has_value = true, .value = false };
-        }
-
-        // Called by V8 to report serialization errors. The exception should already be thrown.
-        fn throwDataCloneError(_: ?*anyopaque, _: ?*const v8.String) callconv(.c) void {}
-
-        // Called when V8 encounters a SharedArrayBuffer. We don't support sharing them across
-        // contexts, so throw a DataCloneError and return false. V8's WriteJSArrayBuffer calls
-        // RETURN_VALUE_IF_EXCEPTION after this, so throwing prevents the fatal FromJust call.
-        fn getSharedArrayBufferId(_: ?*anyopaque, isolate: ?*v8.Isolate, _: ?*const v8.SharedArrayBuffer, _: ?*u32) callconv(.c) bool {
-            const iso = isolate orelse return false;
-            const message = v8.v8__String__NewFromUtf8(iso, "SharedArrayBuffer cannot be cloned.", v8.kNormal, -1);
-            const error_value = v8.v8__Exception__Error(message) orelse return false;
-            _ = v8.v8__Isolate__ThrowException(iso, error_value);
-            return false;
-        }
-    };
-
-    const size, const data = blk: {
-        const serializer = v8.v8__ValueSerializer__New(v8_isolate, &.{
-            .data = null,
-            .get_shared_array_buffer_id = SerializerDelegate.getSharedArrayBufferId,
-            .write_host_object = SerializerDelegate.writeHostObject,
-            .throw_data_clone_error = SerializerDelegate.throwDataCloneError,
-        }) orelse return error.JsException;
-
-        defer v8.v8__ValueSerializer__DELETE(serializer);
-
-        var write_result: v8.MaybeBool = undefined;
-        v8.v8__ValueSerializer__WriteHeader(serializer);
-        v8.v8__ValueSerializer__WriteValue(serializer, source_context, self.handle, &write_result);
-        if (!write_result.has_value or !write_result.value) {
-            return error.JsException;
-        }
-
-        var size: usize = undefined;
-        const data = v8.v8__ValueSerializer__Release(serializer, &size) orelse return error.JsException;
-        break :blk .{ size, data };
-    };
-
-    defer v8.v8__ValueSerializer__FreeBuffer(data);
-
-    const cloned_handle = blk: {
-        const deserializer = v8.v8__ValueDeserializer__New(v8_isolate, data, size, null) orelse return error.JsException;
-        defer v8.v8__ValueDeserializer__DELETE(deserializer);
-
-        var read_header_result: v8.MaybeBool = undefined;
-        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, target_context) orelse return error.JsException;
-    };
-
-    return .{ .local = target, .handle = cloned_handle };
+    const serialized = try self.serialize();
+    defer serialized.deinit();
+    return deserialize(target, serialized.bytes());
 }
 
+// A structured-serialized value: a V8-owned byte buffer. Caller must free it
+// and must dupe the bytes if they want it to outlive the current local scope.
+pub const Serialized = struct {
+    data: [*c]u8,
+    size: usize,
+
+    pub fn bytes(self: Serialized) []const u8 {
+        return self.data[0..self.size];
+    }
+
+    pub fn deinit(self: Serialized) void {
+        v8.v8__ValueSerializer__FreeBuffer(self.data);
+    }
+};
+
+// Serialize `self` into a V8-owned buffer. The caller must call deinit on the
+// result. Raises a JS exception (DataCloneError) for unserializable values.
+pub fn serialize(self: Value) !Serialized {
+    var delegate_ctx = CloneDelegate.SerializeContext{
+        .local = self.local,
+        .serializer = undefined,
+    };
+    const serializer = v8.v8__ValueSerializer__New(self.local.isolate.handle, &.{
+        .data = &delegate_ctx,
+        .get_shared_array_buffer_id = CloneDelegate.getSharedArrayBufferId,
+        .write_host_object = CloneDelegate.writeHostObject,
+        .throw_data_clone_error = CloneDelegate.throwDataCloneError,
+    }) orelse return error.JsException;
+    defer v8.v8__ValueSerializer__DELETE(serializer);
+    // the delegate callbacks only fire during WriteValue, after this is set
+    delegate_ctx.serializer = serializer;
+
+    var write_result: v8.MaybeBool = undefined;
+    v8.v8__ValueSerializer__WriteHeader(serializer);
+    v8.v8__ValueSerializer__WriteValue(serializer, self.local.handle, self.handle, &write_result);
+    if (!write_result.has_value or !write_result.value) {
+        return error.JsException;
+    }
+
+    var size: usize = undefined;
+    const data = v8.v8__ValueSerializer__Release(serializer, &size) orelse return error.JsException;
+    return .{ .data = data, .size = size };
+}
+
+// Deserialize a structured-serialized buffer (from `serialize`) into a value in
+// `local`'s context. A malformed buffer surfaces as error.JsException.
+pub fn deserialize(local: *const js.Local, bytes: []const u8) !Value {
+    var delegate_ctx = CloneDelegate.DeserializeContext{
+        .local = local,
+        .deserializer = undefined,
+    };
+    const deserializer = v8.v8__ValueDeserializer__New(local.isolate.handle, bytes.ptr, bytes.len, &.{
+        .data = &delegate_ctx,
+        .read_host_object = CloneDelegate.readHostObject,
+    }) orelse return error.JsException;
+    defer v8.v8__ValueDeserializer__DELETE(deserializer);
+    delegate_ctx.deserializer = deserializer;
+
+    var read_header_result: v8.MaybeBool = undefined;
+    v8.v8__ValueDeserializer__ReadHeader(deserializer, local.handle, &read_header_result);
+    if (!read_header_result.has_value or !read_header_result.value) {
+        return error.JsException;
+    }
+
+    const handle = v8.v8__ValueDeserializer__ReadValue(deserializer, local.handle) orelse return error.JsException;
+    return .{ .local = local, .handle = handle };
+}
+
+// Host object types that support structured cloning via structuredSerialize /
+// structuredDeserialize hooks. The serialized payload tags each host object
+// with its position in this list; buffers never outlive the process, so the
+// order only has to be consistent within a build.
+const cloneable_types = .{
+    @import("../webapi/Blob.zig"),
+    @import("../webapi/File.zig"),
+    @import("../webapi/FileList.zig"),
+    @import("../webapi/ImageData.zig"),
+    @import("../webapi/DOMPointReadOnly.zig"),
+    @import("../webapi/DOMPoint.zig"),
+};
+
+// Passed to a type's structuredSerialize hook to write its payload into the
+// V8 serialization buffer.
+pub const StructuredWriter = struct {
+    local: *const js.Local,
+    serializer: *v8.ValueSerializer,
+
+    pub fn writeUint32(self: *const StructuredWriter, value: u32) void {
+        v8.v8__ValueSerializer__WriteUint32(self.serializer, value);
+    }
+
+    pub fn writeUint64(self: *const StructuredWriter, value: u64) void {
+        v8.v8__ValueSerializer__WriteUint64(self.serializer, value);
+    }
+
+    pub fn writeBytes(self: *const StructuredWriter, bytes: []const u8) void {
+        v8.v8__ValueSerializer__WriteUint32(self.serializer, @intCast(bytes.len));
+        if (bytes.len > 0) {
+            v8.v8__ValueSerializer__WriteRawBytes(self.serializer, bytes.ptr, bytes.len);
+        }
+    }
+};
+
+// Passed to a type's structuredDeserialize hook to read back the payload its
+// structuredSerialize hook wrote.
+pub const StructuredReader = struct {
+    local: *const js.Local,
+    deserializer: *v8.ValueDeserializer,
+
+    pub fn readUint32(self: *const StructuredReader) !u32 {
+        var out: u32 = undefined;
+        if (!v8.v8__ValueDeserializer__ReadUint32(self.deserializer, &out)) {
+            return error.DataClone;
+        }
+        return out;
+    }
+
+    pub fn readUint64(self: *const StructuredReader) !u64 {
+        var out: u64 = undefined;
+        if (!v8.v8__ValueDeserializer__ReadUint64(self.deserializer, &out)) {
+            return error.DataClone;
+        }
+        return out;
+    }
+
+    // The returned slice points into the serialization buffer; dupe anything
+    // that must outlive deserialization.
+    pub fn readBytes(self: *const StructuredReader) ![]const u8 {
+        const len = try self.readUint32();
+        if (len == 0) {
+            return "";
+        }
+        var ptr: ?*const anyopaque = null;
+        if (!v8.v8__ValueDeserializer__ReadRawBytes(self.deserializer, len, &ptr)) {
+            return error.DataClone;
+        }
+        return @as([*]const u8, @ptrCast(ptr.?))[0..len];
+    }
+};
+
+const CloneDelegate = struct {
+    const SerializeContext = struct {
+        local: *const js.Local,
+        serializer: *v8.ValueSerializer,
+    };
+
+    const DeserializeContext = struct {
+        local: *const js.Local,
+        deserializer: *v8.ValueDeserializer,
+    };
+
+    // Called when V8 encounters an object with embedder fields, i.e. one of
+    // our wrapped Zig instances. Serialize it if its type (or a prototype)
+    // is in cloneable_types, otherwise throw a DataCloneError. V8 asserts
+    // has_exception() after a false return, so we must throw here.
+    fn writeHostObject(data: ?*anyopaque, _: ?*v8.Isolate, object: ?*const v8.Object) callconv(.c) v8.MaybeBool {
+        const ctx: *SerializeContext = @ptrCast(@alignCast(data.?));
+
+        blk: {
+            const obj = object orelse break :blk;
+            if (v8.v8__Object__InternalFieldCount(obj) == 0) {
+                break :blk;
+            }
+            const tao_ptr = v8.v8__Object__GetAlignedPointerFromInternalField(obj, 0) orelse break :blk;
+            const tao: *TaggedOpaque = @ptrCast(@alignCast(tao_ptr));
+
+            const prototype_chain = tao.prototype_chain[0..tao.prototype_len];
+            if (writeCloneable(ctx, prototype_chain[0].index, tao.value)) |result| {
+                return result;
+            }
+
+            // Walk up the prototype chain so a subtype serializes as its
+            // closest cloneable supertype (mirrors TaggedOpaque.fromJS).
+            var ptr = @intFromPtr(tao.value);
+            for (prototype_chain[1..]) |proto| {
+                ptr += proto.offset;
+                const proto_ptr: **anyopaque = @ptrFromInt(ptr);
+                if (writeCloneable(ctx, proto.index, proto_ptr.*)) |result| {
+                    return result;
+                }
+                ptr = @intFromPtr(proto_ptr.*);
+            }
+        }
+
+        throwDataCloneException(ctx.local, null);
+        return .{ .has_value = true, .value = false };
+    }
+
+    fn writeCloneable(
+        ctx: *SerializeContext,
+        type_index: bridge.JsApiLookup.BackingInt,
+        value_ptr: *anyopaque,
+    ) ?v8.MaybeBool {
+        inline for (cloneable_types, 0..) |T, tag| {
+            if (type_index == bridge.JsApiLookup.getId(T.JsApi)) {
+                v8.v8__ValueSerializer__WriteUint32(ctx.serializer, tag);
+                var writer = StructuredWriter{ .local = ctx.local, .serializer = ctx.serializer };
+                const instance: *const T = @ptrCast(@alignCast(value_ptr));
+                instance.structuredSerialize(&writer) catch {
+                    throwDataCloneException(ctx.local, null);
+                    return .{ .has_value = true, .value = false };
+                };
+                return .{ .has_value = true, .value = true };
+            }
+        }
+        return null;
+    }
+
+    // Called by V8 to read back what writeHostObject wrote. Returning null
+    // aborts deserialization, so we throw first to surface a proper error.
+    fn readHostObject(data: ?*anyopaque, _: ?*v8.Isolate) callconv(.c) ?*const v8.Object {
+        const ctx: *DeserializeContext = @ptrCast(@alignCast(data.?));
+        const local = ctx.local;
+
+        var tag: u32 = undefined;
+        if (v8.v8__ValueDeserializer__ReadUint32(ctx.deserializer, &tag)) {
+            var reader = StructuredReader{ .local = local, .deserializer = ctx.deserializer };
+            inline for (cloneable_types, 0..) |T, i| {
+                if (tag == i) {
+                    return readCloneable(T, &reader) orelse {
+                        throwDataCloneException(local, null);
+                        return null;
+                    };
+                }
+            }
+        }
+
+        throwDataCloneException(local, null);
+        return null;
+    }
+
+    fn readCloneable(comptime T: type, reader: *StructuredReader) ?*const v8.Object {
+        const local = reader.local;
+        const instance = T.structuredDeserialize(reader, local.ctx.page) catch return null;
+        const js_obj = local.mapZigInstanceToJs(null, instance) catch return null;
+        return js_obj.handle;
+    }
+
+    // Called by V8 when a built-in can't be serialized (e.g. an out-of-bounds
+    // TypedArray). The delegate is responsible for actually throwing.
+    fn throwDataCloneError(data: ?*anyopaque, message: ?*const v8.String) callconv(.c) void {
+        const ctx: *SerializeContext = @ptrCast(@alignCast(data.?));
+        const local = ctx.local;
+        const msg: ?[]const u8 = blk: {
+            const handle = message orelse break :blk null;
+            const str = js.String{ .local = local, .handle = handle };
+            // the exception can outlive this call; dupe onto the context arena
+            break :blk str.toSliceWithAlloc(local.ctx.arena) catch null;
+        };
+        throwDataCloneException(local, msg);
+    }
+
+    // Called when V8 encounters a SharedArrayBuffer. We don't support sharing
+    // them across contexts, so throw a DataCloneError and return false. V8's
+    // WriteJSArrayBuffer calls RETURN_VALUE_IF_EXCEPTION after this, so
+    // throwing prevents the fatal FromJust call.
+    fn getSharedArrayBufferId(data: ?*anyopaque, _: ?*v8.Isolate, _: ?*const v8.SharedArrayBuffer, _: ?*u32) callconv(.c) bool {
+        const ctx: *SerializeContext = @ptrCast(@alignCast(data.?));
+        throwDataCloneException(ctx.local, "SharedArrayBuffer cannot be cloned");
+        return false;
+    }
+
+    fn throwDataCloneException(local: *const js.Local, message: ?[]const u8) void {
+        const DOMException = @import("../webapi/DOMException.zig");
+        const isolate = local.isolate;
+        const js_value = local.zigValueToJs(DOMException.init(message, "DataCloneError"), .{}) catch {
+            const str = v8.v8__String__NewFromUtf8(isolate.handle, "The object can not be cloned", v8.kNormal, -1);
+            const error_value = v8.v8__Exception__Error(str) orelse return;
+            _ = v8.v8__Isolate__ThrowException(isolate.handle, error_value);
+            return;
+        };
+        _ = isolate.throwException(js_value.handle);
+    }
+};
+
 pub fn persist(self: Value) !Global {
     return self._persist(true);
 }
diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig
index 6131f19b2..116da83b1 100644
--- a/src/browser/js/bridge.zig
+++ b/src/browser/js/bridge.zig
@@ -110,7 +110,6 @@ pub const Constructor = struct {
     func: *const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void,
 
     const Opts = struct {
-        dom_exception: bool = false,
         // When true, the constructor function receives `new.target` (as a
         // js.Function) as its first parameter. Used by HTMLElement to support
         // direct instantiation of custom elements via `new MyElement()`.
@@ -142,7 +141,6 @@ pub const Constructor = struct {
                     defer if (ce_frame) |frame| frame._ce_reactions.popAndInvoke(ce_checkpoint, frame);
 
                     caller.constructor(T, func, handle.?, .{
-                        .dom_exception = opts.dom_exception,
                         .new_target = opts.new_target,
                     });
                 }
@@ -156,6 +154,7 @@ pub const Function = struct {
     arity: usize,
     noop: bool = false,
     wpt_only: bool = false,
+    js_name: ?[:0]const u8 = null,
     exposed: Caller.Function.Opts.Exposed = .both,
     cache: ?Caller.Function.Opts.Caching = null,
     func: *const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void,
@@ -165,6 +164,7 @@ pub const Function = struct {
             .cache = opts.cache,
             .static = opts.static,
             .wpt_only = opts.wpt_only,
+            .js_name = opts.js_name,
             .exposed = opts.exposed,
             // Non-static methods receive `self` as their first param; static
             // methods don't, so don't skip the first param for them.
@@ -822,6 +822,8 @@ pub const PageJsApis = flattenTypes(&.{
     @import("../webapi/DOMRect.zig"),
     @import("../webapi/DOMMatrixReadOnly.zig"),
     @import("../webapi/DOMMatrix.zig"),
+    @import("../webapi/DOMPointReadOnly.zig"),
+    @import("../webapi/DOMPoint.zig"),
     @import("../webapi/DOMParser.zig"),
     @import("../webapi/XMLSerializer.zig"),
     @import("../webapi/AbstractRange.zig"),
@@ -1021,6 +1023,8 @@ pub const WorkerJsApis = flattenTypes(&.{
     @import("../webapi/DOMException.zig"),
     @import("../webapi/DOMMatrixReadOnly.zig"),
     @import("../webapi/DOMMatrix.zig"),
+    @import("../webapi/DOMPointReadOnly.zig"),
+    @import("../webapi/DOMPoint.zig"),
     @import("../webapi/net/URLSearchParams.zig"),
     @import("../webapi/encoding/TextEncoder.zig"),
     @import("../webapi/encoding/TextDecoder.zig"),
@@ -1059,6 +1063,12 @@ pub const WorkerJsApis = flattenTypes(&.{
     @import("../webapi/storage/CookieStore.zig"),
     @import("../webapi/event/CookieChangeEvent.zig"),
     @import("../webapi/BroadcastChannel.zig"),
+    @import("../webapi/event/CustomEvent.zig"),
+    @import("../webapi/event/ProgressEvent.zig"),
+    @import("../webapi/Notification.zig"),
+    @import("../webapi/FileList.zig"),
+    @import("../webapi/MessageChannel.zig"),
+    @import("../webapi/MessagePort.zig"),
 });
 
 // Master list of ALL JS APIs across all contexts.
diff --git a/src/browser/js/js.zig b/src/browser/js/js.zig
index 3095d20b3..cbbba7cbb 100644
--- a/src/browser/js/js.zig
+++ b/src/browser/js/js.zig
@@ -43,6 +43,8 @@ pub const Isolate = @import("Isolate.zig");
 pub const HandleScope = @import("HandleScope.zig");
 
 pub const Value = @import("Value.zig");
+pub const StructuredWriter = Value.StructuredWriter;
+pub const StructuredReader = Value.StructuredReader;
 pub const Array = @import("Array.zig");
 pub const String = @import("String.zig");
 pub const Object = @import("Object.zig");
@@ -178,6 +180,22 @@ pub fn ArrayBufferRef(comptime kind: ArrayType) type {
 
             return .{ .handle = global };
         }
+
+        // Direct view into the typed array's backing memory.
+        pub fn slice(self: *const Self) []BackingInt {
+            const view: *const v8.ArrayBufferView = @ptrCast(self.handle);
+            const byte_len = v8.v8__ArrayBufferView__ByteLength(view);
+            if (byte_len == 0) {
+                return @constCast(&[_]BackingInt{});
+            }
+            const byte_offset = v8.v8__ArrayBufferView__ByteOffset(view);
+            const array_buffer = v8.v8__ArrayBufferView__Buffer(view).?;
+            const backing_store_ptr = v8.v8__ArrayBuffer__GetBackingStore(array_buffer);
+            const backing_store = v8.std__shared_ptr__v8__BackingStore__get(&backing_store_ptr).?;
+            const data = v8.v8__BackingStore__Data(backing_store).?;
+            const base = @as([*]u8, @ptrCast(data)) + byte_offset;
+            return @as([*]BackingInt, @ptrCast(@alignCast(base)))[0 .. byte_len / @sizeOf(BackingInt)];
+        }
     };
 }
 
diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig
index 50c0e26cc..b56d25bb3 100644
--- a/src/browser/markdown.zig
+++ b/src/browser/markdown.zig
@@ -381,7 +381,7 @@ const Context = struct {
 
                 if (!info.has_visible and label == null and href_raw == null) return;
 
-                const href = if (href_raw) |h| URL.resolve(frame.call_arena, frame.base(), h, .{ .encoding = frame.charset }) catch h else null;
+                const href = if (href_raw) |h| URL.resolve(frame.local_arena, frame.base(), h, .{ .encoding = frame.charset }) catch h else null;
 
                 if (info.has_block) {
                     try self.renderChildren(el.asNode());
diff --git a/src/browser/tests/dompoint.html b/src/browser/tests/dompoint.html
new file mode 100644
index 000000000..e485cfa17
--- /dev/null
+++ b/src/browser/tests/dompoint.html
@@ -0,0 +1,173 @@
+
+
+  DOMPoint Test
+  
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/element/html/script/supports.html b/src/browser/tests/element/html/script/supports.html
new file mode 100644
index 000000000..4011b71b0
--- /dev/null
+++ b/src/browser/tests/element/html/script/supports.html
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/src/browser/tests/element/rel_list.html b/src/browser/tests/element/rel_list.html
new file mode 100644
index 000000000..28b231753
--- /dev/null
+++ b/src/browser/tests/element/rel_list.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+ + + + diff --git a/src/browser/tests/element/styles.html b/src/browser/tests/element/styles.html index b4e81aa2b..7418d0ef3 100644 --- a/src/browser/tests/element/styles.html +++ b/src/browser/tests/element/styles.html @@ -216,6 +216,14 @@ document.body.appendChild(plainDiv); testing.expectEqual('block', window.getComputedStyle(plainDiv).getPropertyValue('display')); + // Repeated calls return the same object (as in Chrome); distinct per element. + testing.expectTrue(window.getComputedStyle(div) === window.getComputedStyle(div)); + testing.expectTrue(window.getComputedStyle(div) === cs); + testing.expectFalse(window.getComputedStyle(div) === window.getComputedStyle(plainDiv)); + // The cached object stays a live view. + div.style.setProperty('text-transform', 'lowercase'); + testing.expectEqual('lowercase', window.getComputedStyle(div).getPropertyValue('text-transform')); + // A normal declaration must not override an earlier !important one, and the // computed and inline (el.style) paths must agree on the resolved value. const impDiv = document.createElement('div'); diff --git a/src/browser/tests/net/response.html b/src/browser/tests/net/response.html index 0a80ab573..eb7acf4aa 100644 --- a/src/browser/tests/net/response.html +++ b/src/browser/tests/net/response.html @@ -251,3 +251,53 @@ testing.expectTrue(new Response(new Uint8Array([1, 2, 3])).headers.get('content-type') === null); } + + + + + + diff --git a/src/browser/tests/net/url_search_params.html b/src/browser/tests/net/url_search_params.html index 08d28ec04..48442d221 100644 --- a/src/browser/tests/net/url_search_params.html +++ b/src/browser/tests/net/url_search_params.html @@ -489,23 +489,30 @@ } - diff --git a/src/browser/tests/page/encoding.html b/src/browser/tests/page/encoding.html index a07869329..1d86abad7 100644 --- a/src/browser/tests/page/encoding.html +++ b/src/browser/tests/page/encoding.html @@ -69,6 +69,30 @@ } + +