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 @@
}
+
+