From bf6dbedbe43c4706c1053e72d99a55f39134e864 Mon Sep 17 00:00:00 2001 From: Salman Muin Kayser Chishti <13schishti@gmail.com> Date: Sat, 14 Mar 2026 09:11:46 +0000 Subject: [PATCH 01/85] Upgrade GitHub Actions for Node 24 compatibility Signed-off-by: Salman Muin Kayser Chishti <13schishti@gmail.com> --- .github/actions/install/action.yml | 2 +- .github/workflows/build.yml | 6 +++--- .github/workflows/e2e-integration-test.yml | 8 ++++---- .github/workflows/e2e-test.yml | 24 +++++++++++----------- .github/workflows/wpt.yml | 12 +++++------ .github/workflows/zig-test.yml | 8 ++++---- 6 files changed, 30 insertions(+), 30 deletions(-) diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index 4c0a28f9..ac054c8f 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -46,7 +46,7 @@ runs: - name: Cache v8 id: cache-v8 - uses: actions/cache@v4 + uses: actions/cache@v5 env: cache-name: cache-v8 with: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d2bcde3b..7cb213a9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -72,7 +72,7 @@ jobs: timeout-minutes: 20 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 # fetch submodules recusively, to get zig-js-runtime submodules also. @@ -116,7 +116,7 @@ jobs: timeout-minutes: 20 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 # fetch submodules recusively, to get zig-js-runtime submodules also. @@ -158,7 +158,7 @@ jobs: timeout-minutes: 20 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 # fetch submodules recusively, to get zig-js-runtime submodules also. diff --git a/.github/workflows/e2e-integration-test.yml b/.github/workflows/e2e-integration-test.yml index 1a0217bb..dff1fb59 100644 --- a/.github/workflows/e2e-integration-test.yml +++ b/.github/workflows/e2e-integration-test.yml @@ -20,7 +20,7 @@ jobs: if: github.event.pull_request.draft == false steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 # fetch submodules recusively, to get zig-js-runtime submodules also. @@ -32,7 +32,7 @@ jobs: run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) - name: upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: lightpanda-build-release path: | @@ -47,7 +47,7 @@ jobs: timeout-minutes: 15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: repository: 'lightpanda-io/demo' fetch-depth: 0 @@ -55,7 +55,7 @@ jobs: - run: npm install - name: download artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: lightpanda-build-release diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 675dd36b..d0e945df 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -61,7 +61,7 @@ jobs: run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) - name: upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: lightpanda-build-release path: | @@ -76,7 +76,7 @@ jobs: timeout-minutes: 15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: repository: 'lightpanda-io/demo' fetch-depth: 0 @@ -84,7 +84,7 @@ jobs: - run: npm install - name: download artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: lightpanda-build-release @@ -126,7 +126,7 @@ jobs: timeout-minutes: 15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: repository: 'lightpanda-io/demo' fetch-depth: 0 @@ -134,7 +134,7 @@ jobs: - run: npm install - name: download artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: lightpanda-build-release @@ -189,7 +189,7 @@ jobs: timeout-minutes: 5 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: repository: 'lightpanda-io/demo' fetch-depth: 0 @@ -197,7 +197,7 @@ jobs: - run: echo "${{ secrets.WBA_PRIVATE_KEY_PEM }}" > private_key.pem - name: download artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: lightpanda-build-release @@ -239,7 +239,7 @@ jobs: timeout-minutes: 15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: repository: 'lightpanda-io/demo' fetch-depth: 0 @@ -247,7 +247,7 @@ jobs: - run: npm install - name: download artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: lightpanda-build-release @@ -333,7 +333,7 @@ jobs: echo "${{github.sha}}" > commit.txt - name: upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: bench-results path: | @@ -361,7 +361,7 @@ jobs: steps: - name: download artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: bench-results @@ -379,7 +379,7 @@ jobs: steps: - name: download artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: lightpanda-build-release diff --git a/.github/workflows/wpt.yml b/.github/workflows/wpt.yml index 71d485d0..ef887612 100644 --- a/.github/workflows/wpt.yml +++ b/.github/workflows/wpt.yml @@ -35,7 +35,7 @@ jobs: run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) - name: upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: lightpanda-build-release path: | @@ -59,7 +59,7 @@ jobs: CGO_ENABLED=0 go build - name: upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: wptrunner path: | @@ -91,14 +91,14 @@ jobs: run: ./wpt manifest - name: download lightpanda release - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: lightpanda-build-release - run: chmod a+x ./lightpanda - name: download wptrunner - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: wptrunner @@ -116,7 +116,7 @@ jobs: echo "${{github.sha}}" > commit.txt - name: upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: wpt-results path: | @@ -139,7 +139,7 @@ jobs: steps: - name: download artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: wpt-results diff --git a/.github/workflows/zig-test.yml b/.github/workflows/zig-test.yml index db2f362d..ca967c3a 100644 --- a/.github/workflows/zig-test.yml +++ b/.github/workflows/zig-test.yml @@ -44,7 +44,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 # fetch submodules recusively, to get zig-js-runtime submodules also. @@ -67,7 +67,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 # fetch submodules recusively, to get zig-js-runtime submodules also. @@ -83,7 +83,7 @@ jobs: echo "${{github.sha}}" > commit.txt - name: upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: bench-results path: | @@ -109,7 +109,7 @@ jobs: steps: - name: download artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: bench-results From f754773bf6ae3d7627bd9683bba1e108447599d6 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 14 Mar 2026 09:13:50 +0800 Subject: [PATCH 02/85] window.postMessage across frames Depends on https://github.com/lightpanda-io/zig-v8-fork/pull/160 Improves postMessage support, specifically for use across frames. This commit also addresses a few other issues (identified while implementing this). 1 - Since macrotasks can schedule more macrotasks, we need to check the time-to- next microtask after all microtasks have completed. 2 - frame's onload callback is triggered from the frame's context, but has to execute on the parents contet. --- build.zig.zon | 4 +- src/browser/Browser.zig | 15 ++++-- src/browser/Page.zig | 12 +++-- src/browser/Session.zig | 32 ++++++------ src/browser/js/Context.zig | 4 ++ src/browser/js/Env.zig | 19 ++++--- src/browser/js/Scheduler.zig | 28 ++++++---- src/browser/tests/element/html/anchor.html | 2 +- src/browser/tests/element/html/form.html | 2 +- src/browser/tests/element/html/image.html | 2 +- src/browser/tests/element/html/link.html | 2 +- src/browser/tests/frames/post_message.html | 25 +++++++++ .../frames/support/message_receiver.html | 9 ++++ src/browser/tests/testing.js | 4 +- src/browser/webapi/Window.zig | 51 +++++++++++++------ 15 files changed, 145 insertions(+), 66 deletions(-) create mode 100644 src/browser/tests/frames/post_message.html create mode 100644 src/browser/tests/frames/support/message_receiver.html diff --git a/build.zig.zon b/build.zig.zon index 9a28408b..33fc0a09 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.3.3.tar.gz", - .hash = "v8-0.0.0-xddH6yx3BAAGD9jSoq_ttt_bk9MectTU44s_HZxxE5LD", + .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/34cb5f50227047c6cc1b2af73dad958c267f0a83.tar.gz", + .hash = "v8-0.0.0-xddH6_F3BAAiFvKY6R1H-gkuQlk19BkDQ0--uZuTrSup", }, // .v8 = .{ .path = "../zig-v8-fork" }, .brotli = .{ diff --git a/src/browser/Browser.zig b/src/browser/Browser.zig index 8f8c4aa2..50a7c037 100644 --- a/src/browser/Browser.zig +++ b/src/browser/Browser.zig @@ -91,25 +91,32 @@ pub fn runMicrotasks(self: *Browser) void { self.env.runMicrotasks(); } -pub fn runMacrotasks(self: *Browser) !?u64 { +pub fn runMacrotasks(self: *Browser) !void { const env = &self.env; - const time_to_next = try self.env.runMacrotasks(); + try self.env.runMacrotasks(); env.pumpMessageLoop(); // either of the above could have queued more microtasks env.runMicrotasks(); - - return time_to_next; } pub fn hasBackgroundTasks(self: *Browser) bool { return self.env.hasBackgroundTasks(); } + pub fn waitForBackgroundTasks(self: *Browser) void { self.env.waitForBackgroundTasks(); } +pub fn msToNextMacrotask(self: *Browser) ?u64 { + return self.env.msToNextMacrotask(); +} + +pub fn msTo(self: *Browser) bool { + return self.env.hasBackgroundTasks(); +} + pub fn runIdleTasks(self: *const Browser) void { self.env.runIdleTasks(); } diff --git a/src/browser/Page.zig b/src/browser/Page.zig index cb62cb31..b290b862 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -709,11 +709,14 @@ pub fn scriptsCompletedLoading(self: *Page) void { } pub fn iframeCompletedLoading(self: *Page, iframe: *IFrame) void { - blk: { - var ls: JS.Local.Scope = undefined; - self.js.localScope(&ls); - defer ls.deinit(); + var ls: JS.Local.Scope = undefined; + self.js.localScope(&ls); + defer ls.deinit(); + const entered = self.js.enter(&ls.handle_scope); + defer entered.exit(); + + blk: { const event = Event.initTrusted(comptime .wrap("load"), .{}, self) catch |err| { log.err(.page, "iframe event init", .{ .err = err, .url = iframe._src }); break :blk; @@ -722,6 +725,7 @@ pub fn iframeCompletedLoading(self: *Page, iframe: *IFrame) void { log.warn(.js, "iframe onload", .{ .err = err, .url = iframe._src }); }; } + self.pendingLoadCompleted(); } diff --git a/src/browser/Session.zig b/src/browser/Session.zig index fea56a87..4f605ec0 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -401,7 +401,7 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult { // scheduler.run could trigger new http transfers, so do not // store http_client.active BEFORE this call and then use // it AFTER. - const ms_to_next_task = try browser.runMacrotasks(); + try browser.runMacrotasks(); // Each call to this runs scheduled load events. try page.dispatchLoad(); @@ -423,16 +423,16 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult { std.debug.assert(http_client.intercepted == 0); } - var ms: u64 = ms_to_next_task orelse blk: { - if (wait_ms - ms_remaining < 100) { - if (comptime builtin.is_test) { - return .done; - } - // Look, we want to exit ASAP, but we don't want - // to exit so fast that we've run none of the - // background jobs. - break :blk 50; - } + var ms = blk: { + // if (wait_ms - ms_remaining < 100) { + // if (comptime builtin.is_test) { + // return .done; + // } + // // Look, we want to exit ASAP, but we don't want + // // to exit so fast that we've run none of the + // // background jobs. + // break :blk 50; + // } if (browser.hasBackgroundTasks()) { // _we_ have nothing to run, but v8 is working on @@ -441,9 +441,7 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult { break :blk 20; } - // No http transfers, no cdp extra socket, no - // scheduled tasks, we're done. - return .done; + break :blk browser.msToNextMacrotask() orelse return .done; }; if (ms > ms_remaining) { @@ -470,9 +468,9 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult { // We're here because we either have active HTTP // connections, or exit_when_done == false (aka, there's // an cdp_socket registered with the http client). - // We should continue to run lowPriority tasks, so we - // minimize how long we'll poll for network I/O. - var ms_to_wait = @min(200, ms_to_next_task orelse 200); + // We should continue to run tasks, so we minimize how long + // we'll poll for network I/O. + var ms_to_wait = @min(200, browser.msToNextMacrotask() orelse 200); if (ms_to_wait > 10 and browser.hasBackgroundTasks()) { // if we have background tasks, we don't want to wait too // long for a message from the client. We want to go back diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 5c58c5cb..a972b6c7 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -252,6 +252,10 @@ pub fn toLocal(self: *Context, global: anytype) js.Local.ToLocalReturnType(@Type return l.toLocal(global); } +pub fn getIncumbent(self: *Context) *Page { + return fromC(v8.v8__Isolate__GetIncumbentContext(self.env.isolate.handle).?).page; +} + pub fn stringToPersistedFunction( self: *Context, function_body: []const u8, diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index ba2e3e5a..1ac9e6b3 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -382,8 +382,7 @@ pub fn runMicrotasks(self: *Env) void { } } -pub fn runMacrotasks(self: *Env) !?u64 { - var ms_to_next_task: ?u64 = null; +pub fn runMacrotasks(self: *Env) !void { for (self.contexts[0..self.context_count]) |ctx| { if (comptime builtin.is_test == false) { // I hate this comptime check as much as you do. But we have tests @@ -398,13 +397,17 @@ pub fn runMacrotasks(self: *Env) !?u64 { var hs: js.HandleScope = undefined; const entered = ctx.enter(&hs); defer entered.exit(); - - const ms = (try ctx.scheduler.run()) orelse continue; - if (ms_to_next_task == null or ms < ms_to_next_task.?) { - ms_to_next_task = ms; - } + try ctx.scheduler.run(); } - return ms_to_next_task; +} + +pub fn msToNextMacrotask(self: *Env) ?u64 { + var next_task: u64 = std.math.maxInt(u64); + for (self.contexts[0..self.context_count]) |ctx| { + const candidate = ctx.scheduler.msToNextHigh() orelse continue; + next_task = @min(candidate, next_task); + } + return if (next_task == std.math.maxInt(u64)) null else next_task; } pub fn pumpMessageLoop(self: *const Env) void { diff --git a/src/browser/js/Scheduler.zig b/src/browser/js/Scheduler.zig index e667a872..322351f3 100644 --- a/src/browser/js/Scheduler.zig +++ b/src/browser/js/Scheduler.zig @@ -74,9 +74,10 @@ pub fn add(self: *Scheduler, ctx: *anyopaque, cb: Callback, run_in_ms: u32, opts }); } -pub fn run(self: *Scheduler) !?u64 { - _ = try self.runQueue(&self.low_priority); - return self.runQueue(&self.high_priority); +pub fn run(self: *Scheduler) !void { + const now = milliTimestamp(.monotonic); + try self.runQueue(&self.low_priority, now); + try self.runQueue(&self.high_priority, now); } pub fn hasReadyTasks(self: *Scheduler) bool { @@ -84,16 +85,23 @@ pub fn hasReadyTasks(self: *Scheduler) bool { return queueuHasReadyTask(&self.low_priority, now) or queueuHasReadyTask(&self.high_priority, now); } -fn runQueue(self: *Scheduler, queue: *Queue) !?u64 { - if (queue.count() == 0) { - return null; - } - +pub fn msToNextHigh(self: *Scheduler) ?u64 { + const task = self.high_priority.peek() orelse return null; const now = milliTimestamp(.monotonic); + if (task.run_at <= now) { + return 0; + } + return @intCast(task.run_at - now); +} + +fn runQueue(self: *Scheduler, queue: *Queue, now: u64) !void { + if (queue.count() == 0) { + return; + } while (queue.peek()) |*task_| { if (task_.run_at > now) { - return @intCast(task_.run_at - now); + return; } var task = queue.remove(); if (comptime IS_DEBUG) { @@ -114,7 +122,7 @@ fn runQueue(self: *Scheduler, queue: *Queue) !?u64 { try self.low_priority.add(task); } } - return null; + return; } fn queueuHasReadyTask(queue: *Queue, now: u64) bool { diff --git a/src/browser/tests/element/html/anchor.html b/src/browser/tests/element/html/anchor.html index 0522163f..74bf486c 100644 --- a/src/browser/tests/element/html/anchor.html +++ b/src/browser/tests/element/html/anchor.html @@ -12,7 +12,7 @@ testing.expectEqual('', $('#a0').href); testing.expectEqual(testing.BASE_URL + 'element/anchor1.html', $('#a1').href); - testing.expectEqual(testing.ORIGIN + 'hello/world/anchor2.html', $('#a2').href); + testing.expectEqual(testing.ORIGIN + '/hello/world/anchor2.html', $('#a2').href); testing.expectEqual('https://www.openmymind.net/Elixirs-With-Statement/', $('#a3').href); testing.expectEqual(testing.BASE_URL + 'element/html/foo', $('#link').href); diff --git a/src/browser/tests/element/html/form.html b/src/browser/tests/element/html/form.html index f62cb221..17743135 100644 --- a/src/browser/tests/element/html/form.html +++ b/src/browser/tests/element/html/form.html @@ -32,7 +32,7 @@ testing.expectEqual(testing.BASE_URL + 'element/html/hello', form.action) form.action = '/hello'; - testing.expectEqual(testing.ORIGIN + 'hello', form.action) + testing.expectEqual(testing.ORIGIN + '/hello', form.action) form.action = 'https://lightpanda.io/hello'; testing.expectEqual('https://lightpanda.io/hello', form.action) diff --git a/src/browser/tests/element/html/image.html b/src/browser/tests/element/html/image.html index 92cd947d..baa09918 100644 --- a/src/browser/tests/element/html/image.html +++ b/src/browser/tests/element/html/image.html @@ -37,7 +37,7 @@ testing.expectEqual('test.png', img.getAttribute('src')); img.src = '/absolute/path.png'; - testing.expectEqual(testing.ORIGIN + 'absolute/path.png', img.src); + testing.expectEqual(testing.ORIGIN + '/absolute/path.png', img.src); testing.expectEqual('/absolute/path.png', img.getAttribute('src')); img.src = 'https://example.com/image.png'; diff --git a/src/browser/tests/element/html/link.html b/src/browser/tests/element/html/link.html index bed5e6ab..4d967e37 100644 --- a/src/browser/tests/element/html/link.html +++ b/src/browser/tests/element/html/link.html @@ -8,7 +8,7 @@ testing.expectEqual('https://lightpanda.io/opensource-browser/15', l2.href); l2.href = '/over/9000'; - testing.expectEqual(testing.ORIGIN + 'over/9000', l2.href); + testing.expectEqual(testing.ORIGIN + '/over/9000', l2.href); l2.crossOrigin = 'nope'; testing.expectEqual('anonymous', l2.crossOrigin); diff --git a/src/browser/tests/frames/post_message.html b/src/browser/tests/frames/post_message.html new file mode 100644 index 00000000..6d206b74 --- /dev/null +++ b/src/browser/tests/frames/post_message.html @@ -0,0 +1,25 @@ + + + + + + diff --git a/src/browser/tests/frames/support/message_receiver.html b/src/browser/tests/frames/support/message_receiver.html new file mode 100644 index 00000000..55612a7c --- /dev/null +++ b/src/browser/tests/frames/support/message_receiver.html @@ -0,0 +1,9 @@ + + diff --git a/src/browser/tests/testing.js b/src/browser/tests/testing.js index 987ba042..01bb19db 100644 --- a/src/browser/tests/testing.js +++ b/src/browser/tests/testing.js @@ -114,7 +114,7 @@ eventually: eventually, IS_TEST_RUNNER: IS_TEST_RUNNER, HOST: '127.0.0.1', - ORIGIN: 'http://127.0.0.1:9582/', + ORIGIN: 'http://127.0.0.1:9582', BASE_URL: 'http://127.0.0.1:9582/src/browser/tests/', }; @@ -124,7 +124,7 @@ // seemless, namely around adapting paths/urls. console.warn(`The page is not being executed in the test runner, certain behavior has been adjusted`); window.testing.HOST = location.hostname; - window.testing.ORIGIN = location.origin + '/'; + window.testing.ORIGIN = location.origin; window.testing.BASE_URL = location.origin + '/src/browser/tests/'; window.addEventListener('load', testing.assertOk); } diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 0f288398..099cad65 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -66,6 +66,7 @@ _on_load: ?js.Function.Global = null, _on_pageshow: ?js.Function.Global = null, _on_popstate: ?js.Function.Global = null, _on_error: ?js.Function.Global = null, +_on_message: ?js.Function.Global = null, _on_unhandled_rejection: ?js.Function.Global = null, // TODO: invoke on error _location: *Location, _timer_id: u30 = 0, @@ -208,6 +209,14 @@ pub fn setOnError(self: *Window, setter: ?FunctionSetter) void { self._on_error = getFunctionFromSetter(setter); } +pub fn getOnMessage(self: *const Window) ?js.Function.Global { + return self._on_message; +} + +pub fn setOnMessage(self: *Window, setter: ?FunctionSetter) void { + self._on_message = getFunctionFromSetter(setter); +} + pub fn getOnUnhandledRejection(self: *const Window) ?js.Function.Global { return self._on_unhandled_rejection; } @@ -369,19 +378,26 @@ pub fn postMessage(self: *Window, message: js.Value.Temp, target_origin: ?[]cons // In a full implementation, we would validate the origin _ = target_origin; - // postMessage queues a task (not a microtask), so use the scheduler - const arena = try page.getArena(.{ .debug = "Window.schedule" }); - errdefer page.releaseArena(arena); + // self = the window that will get the message + // page = the context calling postMessage + const target_page = self._page; + const source_window = target_page.js.getIncumbent().window; - const origin = try self._location.getOrigin(page); + const arena = try target_page.getArena(.{ .debug = "Window.postMessage" }); + errdefer target_page.releaseArena(arena); + + // Origin should be the source window's origin (where the message came from) + const origin = try source_window._location.getOrigin(page); const callback = try arena.create(PostMessageCallback); callback.* = .{ - .page = page, .arena = arena, .message = message, + .page = target_page, + .source = source_window, .origin = try arena.dupe(u8, origin), }; - try page.js.scheduler.add(callback, PostMessageCallback.run, 0, .{ + + try target_page.js.scheduler.add(callback, PostMessageCallback.run, 0, .{ .name = "postMessage", .low_priority = false, .finalizer = PostMessageCallback.cancelled, @@ -702,6 +718,7 @@ const ScheduleCallback = struct { const PostMessageCallback = struct { page: *Page, + source: *Window, arena: Allocator, origin: []const u8, message: js.Value.Temp, @@ -712,7 +729,7 @@ const PostMessageCallback = struct { fn cancelled(ctx: *anyopaque) void { const self: *PostMessageCallback = @ptrCast(@alignCast(ctx)); - self.page.releaseArena(self.arena); + self.deinit(); } fn run(ctx: *anyopaque) !?u32 { @@ -722,14 +739,17 @@ const PostMessageCallback = struct { const page = self.page; const window = page.window; - const event = (try MessageEvent.initTrusted(comptime .wrap("message"), .{ - .data = self.message, - .origin = self.origin, - .source = window, - .bubbles = false, - .cancelable = false, - }, page)).asEvent(); - try page._event_manager.dispatch(window.asEventTarget(), event); + const event_target = window.asEventTarget(); + if (page._event_manager.hasDirectListeners(event_target, "message", window._on_message)) { + const event = (try MessageEvent.initTrusted(comptime .wrap("message"), .{ + .data = self.message, + .origin = self.origin, + .source = self.source, + .bubbles = false, + .cancelable = false, + }, page)).asEvent(); + try page._event_manager.dispatchDirect(event_target, event, window._on_message, .{ .context = "window.postMessage" }); + } return null; } @@ -783,6 +803,7 @@ pub const JsApi = struct { pub const onpageshow = bridge.accessor(Window.getOnPageShow, Window.setOnPageShow, .{}); pub const onpopstate = bridge.accessor(Window.getOnPopState, Window.setOnPopState, .{}); pub const onerror = bridge.accessor(Window.getOnError, Window.setOnError, .{}); + pub const onmessage = bridge.accessor(Window.getOnMessage, Window.setOnMessage, .{}); pub const onunhandledrejection = bridge.accessor(Window.getOnUnhandledRejection, Window.setOnUnhandledRejection, .{}); pub const fetch = bridge.function(Window.fetch, .{}); pub const queueMicrotask = bridge.function(Window.queueMicrotask, .{}); From 3dcdaa0a9bf7c4a138f70dab4672a48d79276c5a Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sat, 14 Mar 2026 14:15:40 -0700 Subject: [PATCH 03/85] Implement charset detection from first 1024 bytes of HTML Per the HTML spec, browsers should detect charset from tags in the first 1024 bytes of a document when the HTTP Content-Type header doesn't specify one. Adds Mime.prescanCharset() which scans for: - - Integrates into the page loading flow to set the detected charset on the Mime when no explicit HTTP charset was provided. Fixes #531 --- src/browser/Mime.zig | 160 +++++++++++++++++++++++++++++++++++++++++++ src/browser/Page.zig | 14 +++- 2 files changed, 173 insertions(+), 1 deletion(-) diff --git a/src/browser/Mime.zig b/src/browser/Mime.zig index 43ca3632..beef2177 100644 --- a/src/browser/Mime.zig +++ b/src/browser/Mime.zig @@ -168,6 +168,137 @@ pub fn parse(input: []u8) !Mime { }; } +/// Prescan the first 1024 bytes of an HTML document for a charset declaration. +/// Looks for `` and ``. +/// Returns the charset value or null if none found. +/// See: https://www.w3.org/International/questions/qa-html-encoding-declarations +pub fn prescanCharset(html: []const u8) ?[]const u8 { + const limit = @min(html.len, 1024); + const data = html[0..limit]; + + // Scan for = data.len) return null; + + // Check for "meta" (case-insensitive) + if (pos + 4 >= data.len) return null; + var tag_buf: [4]u8 = undefined; + _ = std.ascii.lowerString(&tag_buf, data[pos..][0..4]); + if (!std.mem.eql(u8, &tag_buf, "meta")) { + continue; + } + pos += 4; + + // Must be followed by whitespace or end of tag + if (pos >= data.len) return null; + if (data[pos] != ' ' and data[pos] != '\t' and data[pos] != '\n' and + data[pos] != '\r' and data[pos] != '/') + { + continue; + } + + // Scan attributes within this meta tag + const tag_end = std.mem.indexOfScalarPos(u8, data, pos, '>') orelse return null; + const attrs = data[pos..tag_end]; + + // Look for charset= attribute directly + if (findAttrValue(attrs, "charset")) |charset| { + if (charset.len > 0 and charset.len <= 40) return charset; + } + + // Look for http-equiv="content-type" with content="...;charset=X" + if (findAttrValue(attrs, "http-equiv")) |he| { + if (asciiEqlIgnoreCase(he, "content-type")) { + if (findAttrValue(attrs, "content")) |content| { + if (extractCharsetFromContentType(content)) |charset| { + return charset; + } + } + } + } + + pos = tag_end + 1; + } + return null; +} + +fn findAttrValue(attrs: []const u8, name: []const u8) ?[]const u8 { + var pos: usize = 0; + while (pos < attrs.len) { + // Skip whitespace + while (pos < attrs.len and (attrs[pos] == ' ' or attrs[pos] == '\t' or + attrs[pos] == '\n' or attrs[pos] == '\r')) + { + pos += 1; + } + if (pos >= attrs.len) return null; + + // Read attribute name + const attr_start = pos; + while (pos < attrs.len and attrs[pos] != '=' and attrs[pos] != ' ' and + attrs[pos] != '\t' and attrs[pos] != '>' and attrs[pos] != '/') + { + pos += 1; + } + const attr_name = attrs[attr_start..pos]; + + // Skip whitespace around = + while (pos < attrs.len and (attrs[pos] == ' ' or attrs[pos] == '\t')) pos += 1; + if (pos >= attrs.len or attrs[pos] != '=') continue; + pos += 1; // skip '=' + while (pos < attrs.len and (attrs[pos] == ' ' or attrs[pos] == '\t')) pos += 1; + if (pos >= attrs.len) return null; + + // Read attribute value + const value = blk: { + if (attrs[pos] == '"' or attrs[pos] == '\'') { + const quote = attrs[pos]; + pos += 1; + const val_start = pos; + while (pos < attrs.len and attrs[pos] != quote) pos += 1; + const val = attrs[val_start..pos]; + if (pos < attrs.len) pos += 1; // skip closing quote + break :blk val; + } else { + const val_start = pos; + while (pos < attrs.len and attrs[pos] != ' ' and attrs[pos] != '\t' and + attrs[pos] != '>' and attrs[pos] != '/') + { + pos += 1; + } + break :blk attrs[val_start..pos]; + } + }; + + if (asciiEqlIgnoreCase(attr_name, name)) return value; + } + return null; +} + +fn extractCharsetFromContentType(content: []const u8) ?[]const u8 { + var it = std.mem.splitScalar(u8, content, ';'); + while (it.next()) |part| { + const trimmed = std.mem.trimLeft(u8, part, &.{ ' ', '\t' }); + if (trimmed.len > 8 and asciiEqlIgnoreCase(trimmed[0..8], "charset=")) { + const val = std.mem.trim(u8, trimmed[8..], &.{ ' ', '\t', '"', '\'' }); + if (val.len > 0 and val.len <= 40) return val; + } + } + return null; +} + +fn asciiEqlIgnoreCase(a: []const u8, b: []const u8) bool { + if (a.len != b.len) return false; + for (a, b) |ca, cb| { + if (std.ascii.toLower(ca) != std.ascii.toLower(cb)) return false; + } + return true; +} + pub fn sniff(body: []const u8) ?Mime { // 0x0C is form feed const content = std.mem.trimLeft(u8, body, &.{ ' ', '\t', '\n', '\r', 0x0C }); @@ -576,3 +707,32 @@ fn expect(expected: Expectation, input: []const u8) !void { try testing.expectEqual(m.charsetStringZ(), actual.charsetStringZ()); } } + +test "Mime: prescanCharset" { + // + try testing.expectEqual("utf-8", Mime.prescanCharset("").?); + try testing.expectEqual("iso-8859-1", Mime.prescanCharset("").?); + try testing.expectEqual("shift_jis", Mime.prescanCharset("").?); + + // Case-insensitive tag matching + try testing.expectEqual("utf-8", Mime.prescanCharset("").?); + try testing.expectEqual("utf-8", Mime.prescanCharset("").?); + + // + try testing.expectEqual( + "iso-8859-1", + Mime.prescanCharset("").?, + ); + + // No charset found + try testing.expectEqual(@as(?[]const u8, null), Mime.prescanCharset("Test")); + try testing.expectEqual(@as(?[]const u8, null), Mime.prescanCharset("")); + try testing.expectEqual(@as(?[]const u8, null), Mime.prescanCharset("no html here")); + + // Charset after 1024 bytes should not be found + var long_html: [1100]u8 = undefined; + @memset(&long_html, ' '); + const suffix = ""; + @memcpy(long_html[1050 .. 1050 + suffix.len], suffix); + try testing.expectEqual(@as(?[]const u8, null), Mime.prescanCharset(&long_html)); +} diff --git a/src/browser/Page.zig b/src/browser/Page.zig index cb62cb31..ab291bc6 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -848,13 +848,25 @@ fn pageDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void { if (self._parse_state == .pre) { // we lazily do this, because we might need the first chunk of data // to sniff the content type - const mime: Mime = blk: { + var mime: Mime = blk: { if (transfer.response_header.?.contentType()) |ct| { break :blk try Mime.parse(ct); } break :blk Mime.sniff(data); } orelse .unknown; + // If the HTTP header didn't specify a charset and this is HTML, + // prescan the first 1024 bytes for a declaration. + if (mime.content_type == .text_html and std.mem.eql(u8, mime.charsetString(), "UTF-8")) { + if (Mime.prescanCharset(data)) |charset| { + if (charset.len <= 40) { + @memcpy(mime.charset[0..charset.len], charset); + mime.charset[charset.len] = 0; + mime.charset_len = charset.len; + } + } + } + if (comptime IS_DEBUG) { log.debug(.page, "navigate first chunk", .{ .content_type = mime.content_type, From 65627c129658fd406ac874bc7b7da30d7154b2a0 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sun, 15 Mar 2026 09:18:13 +0800 Subject: [PATCH 04/85] Move ScriptManager to ArenaPool. This removes the BufferPool. The BufferPool was per-ScriptManager and only usable for the response. The ArenaPool is shared across pages and threads, so can provide much better re-use. Furthermore, the ArenaPool provides an Allocator, so that a Script's URL or inline content can be owned by the arena/ script itself, rather than the page arena. --- src/browser/HttpClient.zig | 54 +++++---- src/browser/ScriptManager.zig | 220 ++++++++++++---------------------- src/browser/js/Context.zig | 6 +- 3 files changed, 108 insertions(+), 172 deletions(-) diff --git a/src/browser/HttpClient.zig b/src/browser/HttpClient.zig index 1e74c046..136b578b 100644 --- a/src/browser/HttpClient.zig +++ b/src/browser/HttpClient.zig @@ -110,6 +110,8 @@ use_proxy: bool, // Current TLS verification state, applied per-connection in makeRequest. tls_verify: bool = true, +obey_robots: bool, + cdp_client: ?CDPClient = null, // libcurl can monitor arbitrary sockets, this lets us use libcurl to poll @@ -154,6 +156,7 @@ pub fn init(allocator: Allocator, network: *Network) !*Client { .http_proxy = http_proxy, .use_proxy = http_proxy != null, .tls_verify = network.config.tlsVerifyHost(), + .obey_robots = network.config.obeyRobots(), .transfer_pool = transfer_pool, }; @@ -257,34 +260,33 @@ pub fn tick(self: *Client, timeout_ms: u32) !PerformStatus { } pub fn request(self: *Client, req: Request) !void { - if (self.network.config.obeyRobots()) { - const robots_url = try URL.getRobotsUrl(self.allocator, req.url); - errdefer self.allocator.free(robots_url); - - // If we have this robots cached, we can take a fast path. - if (self.network.robot_store.get(robots_url)) |robot_entry| { - defer self.allocator.free(robots_url); - - switch (robot_entry) { - // If we have a found robots entry, we check it. - .present => |robots| { - const path = URL.getPathname(req.url); - if (!robots.isAllowed(path)) { - req.error_callback(req.ctx, error.RobotsBlocked); - return; - } - }, - // Otherwise, we assume we won't find it again. - .absent => {}, - } - - return self.processRequest(req); - } - - return self.fetchRobotsThenProcessRequest(robots_url, req); + if (self.obey_robots == false) { + return self.processRequest(req); } - return self.processRequest(req); + const robots_url = try URL.getRobotsUrl(self.allocator, req.url); + errdefer self.allocator.free(robots_url); + + // If we have this robots cached, we can take a fast path. + if (self.network.robot_store.get(robots_url)) |robot_entry| { + defer self.allocator.free(robots_url); + + switch (robot_entry) { + // If we have a found robots entry, we check it. + .present => |robots| { + const path = URL.getPathname(req.url); + if (!robots.isAllowed(path)) { + req.error_callback(req.ctx, error.RobotsBlocked); + return; + } + }, + // Otherwise, we assume we won't find it again. + .absent => {}, + } + + return self.processRequest(req); + } + return self.fetchRobotsThenProcessRequest(robots_url, req); } fn processRequest(self: *Client, req: Request) !void { diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index 2baeef8d..a37493eb 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -63,9 +63,6 @@ shutdown: bool = false, client: *HttpClient, allocator: Allocator, -buffer_pool: BufferPool, - -script_pool: std.heap.MemoryPool(Script), // We can download multiple sync modules in parallel, but we want to process // them in order. We can't use an std.DoublyLinkedList, like the other script types, @@ -101,18 +98,14 @@ pub fn init(allocator: Allocator, http_client: *HttpClient, page: *Page) ScriptM .imported_modules = .empty, .client = http_client, .static_scripts_done = false, - .buffer_pool = BufferPool.init(allocator, 5), .page_notified_of_completion = false, - .script_pool = std.heap.MemoryPool(Script).init(allocator), }; } pub fn deinit(self: *ScriptManager) void { - // necessary to free any buffers scripts may be referencing + // necessary to free any arenas scripts may be referencing self.reset(); - self.buffer_pool.deinit(); - self.script_pool.deinit(); self.imported_modules.deinit(self.allocator); // we don't deinit self.importmap b/c we use the page's arena for its // allocations. @@ -121,7 +114,10 @@ pub fn deinit(self: *ScriptManager) void { pub fn reset(self: *ScriptManager) void { var it = self.imported_modules.valueIterator(); while (it.next()) |value_ptr| { - self.buffer_pool.release(value_ptr.buffer); + switch (value_ptr.state) { + .done => |script| script.deinit(), + else => {}, + } } self.imported_modules.clearRetainingCapacity(); @@ -138,13 +134,13 @@ pub fn reset(self: *ScriptManager) void { fn clearList(list: *std.DoublyLinkedList) void { while (list.popFirst()) |n| { const script: *Script = @fieldParentPtr("node", n); - script.deinit(true); + script.deinit(); } } -pub fn getHeaders(self: *ScriptManager, url: [:0]const u8) !net_http.Headers { +fn getHeaders(self: *ScriptManager, arena: Allocator, url: [:0]const u8) !net_http.Headers { var headers = try self.client.newHeaders(); - try self.page.headersForRequest(self.page.arena, url, &headers); + try self.page.headersForRequest(arena, url, &headers); return headers; } @@ -191,19 +187,26 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e return; }; + var handover = false; const page = self.page; + + const arena = try page.getArena(.{ .debug = "addFromElement" }); + errdefer if (!handover) { + page.releaseArena(arena); + }; + var source: Script.Source = undefined; var remote_url: ?[:0]const u8 = null; const base_url = page.base(); if (element.getAttributeSafe(comptime .wrap("src"))) |src| { - if (try parseDataURI(page.arena, src)) |data_uri| { + if (try parseDataURI(arena, src)) |data_uri| { source = .{ .@"inline" = data_uri }; } else { - remote_url = try URL.resolve(page.arena, base_url, src, .{}); + remote_url = try URL.resolve(arena, base_url, src, .{}); source = .{ .remote = .{} }; } } else { - var buf = std.Io.Writer.Allocating.init(page.arena); + var buf = std.Io.Writer.Allocating.init(arena); try element.asNode().getChildTextContent(&buf.writer); try buf.writer.writeByte(0); const data = buf.written(); @@ -218,15 +221,13 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e // Only set _executed (already-started) when we actually have content to execute script_element._executed = true; - - const script = try self.script_pool.create(); - errdefer self.script_pool.destroy(script); - const is_inline = source == .@"inline"; + const script = try arena.create(Script); script.* = .{ .kind = kind, .node = .{}, + .arena = arena, .manager = self, .source = source, .script_element = script_element, @@ -270,7 +271,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e if (is_blocking == false) { self.scriptList(script).remove(&script.node); } - script.deinit(true); + script.deinit(); } try self.client.request(.{ @@ -278,7 +279,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e .ctx = script, .method = .GET, .frame_id = page._frame_id, - .headers = try self.getHeaders(url), + .headers = try self.getHeaders(arena, url), .blocking = is_blocking, .cookie_jar = &page._session.cookie_jar, .resource_type = .script, @@ -289,6 +290,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e .done_callback = Script.doneCallback, .error_callback = Script.errorCallback, }); + handover = true; if (comptime IS_DEBUG) { var ls: js.Local.Scope = undefined; @@ -318,7 +320,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e } if (script.status == 0) { // an error (that we already logged) - script.deinit(true); + script.deinit(); return; } @@ -327,7 +329,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e self.is_evaluating = true; defer { self.is_evaluating = was_evaluating; - script.deinit(true); + script.deinit(); } return script.eval(page); } @@ -359,11 +361,14 @@ pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const } errdefer _ = self.imported_modules.remove(url); - const script = try self.script_pool.create(); - errdefer self.script_pool.destroy(script); + const page = self.page; + const arena = try page.getArena(.{ .debug = "preloadImport" }); + errdefer page.releaseArena(arena); + const script = try arena.create(Script); script.* = .{ .kind = .module, + .arena = arena, .url = url, .node = .{}, .manager = self, @@ -373,11 +378,7 @@ pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const .mode = .import, }; - gop.value_ptr.* = ImportedModule{ - .manager = self, - }; - - const page = self.page; + gop.value_ptr.* = ImportedModule{}; if (comptime IS_DEBUG) { var ls: js.Local.Scope = undefined; @@ -392,12 +393,18 @@ pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const }); } - try self.client.request(.{ + // This seems wrong since we're not dealing with an async import (unlike + // getAsyncModule below), but all we're trying to do here is pre-load the + // script for execution at some point in the future (when waitForImport is + // called). + self.async_scripts.append(&script.node); + + self.client.request(.{ .url = url, .ctx = script, .method = .GET, .frame_id = page._frame_id, - .headers = try self.getHeaders(url), + .headers = try self.getHeaders(arena, url), .cookie_jar = &page._session.cookie_jar, .resource_type = .script, .notification = page._session.notification, @@ -406,13 +413,10 @@ pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const .data_callback = Script.dataCallback, .done_callback = Script.doneCallback, .error_callback = Script.errorCallback, - }); - - // This seems wrong since we're not dealing with an async import (unlike - // getAsyncModule below), but all we're trying to do here is pre-load the - // script for execution at some point in the future (when waitForImport is - // called). - self.async_scripts.append(&script.node); + }) catch |err| { + self.async_scripts.remove(&script.node); + return err; + }; } pub fn waitForImport(self: *ScriptManager, url: [:0]const u8) !ModuleSource { @@ -433,12 +437,12 @@ pub fn waitForImport(self: *ScriptManager, url: [:0]const u8) !ModuleSource { _ = try client.tick(200); continue; }, - .done => { + .done => |script| { var shared = false; const buffer = entry.value_ptr.buffer; const waiters = entry.value_ptr.waiters; - if (waiters == 0) { + if (waiters == 1) { self.imported_modules.removeByPtr(entry.key_ptr); } else { shared = true; @@ -447,7 +451,7 @@ pub fn waitForImport(self: *ScriptManager, url: [:0]const u8) !ModuleSource { return .{ .buffer = buffer, .shared = shared, - .buffer_pool = &self.buffer_pool, + .script = script, }; }, .err => return error.Failed, @@ -456,11 +460,14 @@ pub fn waitForImport(self: *ScriptManager, url: [:0]const u8) !ModuleSource { } pub fn getAsyncImport(self: *ScriptManager, url: [:0]const u8, cb: ImportAsync.Callback, cb_data: *anyopaque, referrer: []const u8) !void { - const script = try self.script_pool.create(); - errdefer self.script_pool.destroy(script); + const page = self.page; + const arena = try page.getArena(.{ .debug = "getAsyncImport" }); + errdefer page.releaseArena(arena); + const script = try arena.create(Script); script.* = .{ .kind = .module, + .arena = arena, .url = url, .node = .{}, .manager = self, @@ -473,7 +480,6 @@ pub fn getAsyncImport(self: *ScriptManager, url: [:0]const u8, cb: ImportAsync.C } }, }; - const page = self.page; if (comptime IS_DEBUG) { var ls: js.Local.Scope = undefined; page.js.localScope(&ls); @@ -496,11 +502,12 @@ pub fn getAsyncImport(self: *ScriptManager, url: [:0]const u8, cb: ImportAsync.C self.is_evaluating = true; defer self.is_evaluating = was_evaluating; - try self.client.request(.{ + self.async_scripts.append(&script.node); + self.client.request(.{ .url = url, .method = .GET, .frame_id = page._frame_id, - .headers = try self.getHeaders(url), + .headers = try self.getHeaders(arena, url), .ctx = script, .resource_type = .script, .cookie_jar = &page._session.cookie_jar, @@ -510,9 +517,10 @@ pub fn getAsyncImport(self: *ScriptManager, url: [:0]const u8, cb: ImportAsync.C .data_callback = Script.dataCallback, .done_callback = Script.doneCallback, .error_callback = Script.errorCallback, - }); - - self.async_scripts.append(&script.node); + }) catch |err| { + self.async_scripts.remove(&script.node); + return err; + }; } // Called from the Page to let us know it's done parsing the HTML. Necessary that @@ -537,18 +545,18 @@ fn evaluate(self: *ScriptManager) void { var script: *Script = @fieldParentPtr("node", n); switch (script.mode) { .async => { - defer script.deinit(true); + defer script.deinit(); script.eval(page); }, .import_async => |ia| { - defer script.deinit(false); if (script.status < 200 or script.status > 299) { + script.deinit(); ia.callback(ia.data, error.FailedToLoad); } else { ia.callback(ia.data, .{ .shared = false, + .script = script, .buffer = script.source.remote, - .buffer_pool = &self.buffer_pool, }); } }, @@ -574,7 +582,7 @@ fn evaluate(self: *ScriptManager) void { } defer { _ = self.defer_scripts.popFirst(); - script.deinit(true); + script.deinit(); } script.eval(page); } @@ -625,11 +633,12 @@ fn parseImportmap(self: *ScriptManager, script: *const Script) !void { } pub const Script = struct { - complete: bool, kind: Kind, + complete: bool, status: u16 = 0, source: Source, url: []const u8, + arena: Allocator, mode: ExecutionMode, node: std.DoublyLinkedList.Node, script_element: ?*Element.Html.Script, @@ -680,11 +689,8 @@ pub const Script = struct { import_async: ImportAsync, }; - fn deinit(self: *Script, comptime release_buffer: bool) void { - if ((comptime release_buffer) and self.source == .remote) { - self.manager.buffer_pool.release(self.source.remote); - } - self.manager.script_pool.destroy(self); + fn deinit(self: *Script) void { + self.manager.page.releaseArena(self.arena); } fn startCallback(transfer: *HttpClient.Transfer) !void { @@ -750,9 +756,9 @@ pub const Script = struct { } lp.assert(self.source.remote.capacity == 0, "ScriptManager.Header buffer", .{ .capacity = self.source.remote.capacity }); - var buffer = self.manager.buffer_pool.get(); + var buffer: std.ArrayList(u8) = .empty; if (transfer.getContentLength()) |cl| { - try buffer.ensureTotalCapacity(self.manager.allocator, cl); + try buffer.ensureTotalCapacity(self.arena, cl); } self.source = .{ .remote = buffer }; return true; @@ -766,7 +772,7 @@ pub const Script = struct { }; } fn _dataCallback(self: *Script, _: *HttpClient.Transfer, data: []const u8) !void { - try self.source.remote.appendSlice(self.manager.allocator, data); + try self.source.remote.appendSlice(self.arena, data); } fn doneCallback(ctx: *anyopaque) !void { @@ -783,9 +789,8 @@ pub const Script = struct { } else if (self.mode == .import) { manager.async_scripts.remove(&self.node); const entry = manager.imported_modules.getPtr(self.url).?; - entry.state = .done; + entry.state = .{ .done = self }; entry.buffer = self.source.remote; - self.deinit(false); } manager.evaluate(); } @@ -811,7 +816,7 @@ pub const Script = struct { const manager = self.manager; manager.scriptList(self).remove(&self.node); if (manager.shutdown) { - self.deinit(true); + self.deinit(); return; } @@ -823,7 +828,7 @@ pub const Script = struct { }, else => {}, } - self.deinit(true); + self.deinit(); manager.evaluate(); } @@ -951,76 +956,6 @@ pub const Script = struct { } }; -const BufferPool = struct { - count: usize, - available: List = .{}, - allocator: Allocator, - max_concurrent_transfers: u8, - mem_pool: std.heap.MemoryPool(Container), - - const List = std.SinglyLinkedList; - - const Container = struct { - node: List.Node, - buf: std.ArrayList(u8), - }; - - fn init(allocator: Allocator, max_concurrent_transfers: u8) BufferPool { - return .{ - .available = .{}, - .count = 0, - .allocator = allocator, - .max_concurrent_transfers = max_concurrent_transfers, - .mem_pool = std.heap.MemoryPool(Container).init(allocator), - }; - } - - fn deinit(self: *BufferPool) void { - const allocator = self.allocator; - - var node = self.available.first; - while (node) |n| { - const container: *Container = @fieldParentPtr("node", n); - container.buf.deinit(allocator); - node = n.next; - } - self.mem_pool.deinit(); - } - - fn get(self: *BufferPool) std.ArrayList(u8) { - const node = self.available.popFirst() orelse { - // return a new buffer - return .{}; - }; - - self.count -= 1; - const container: *Container = @fieldParentPtr("node", node); - defer self.mem_pool.destroy(container); - return container.buf; - } - - fn release(self: *BufferPool, buffer: ArrayList(u8)) void { - // create mutable copy - var b = buffer; - - if (self.count == self.max_concurrent_transfers) { - b.deinit(self.allocator); - return; - } - - const container = self.mem_pool.create() catch |err| { - b.deinit(self.allocator); - log.err(.http, "SM BufferPool release", .{ .err = err }); - return; - }; - - b.clearRetainingCapacity(); - container.* = .{ .buf = b, .node = .{} }; - self.count += 1; - self.available.prepend(&container.node); - } -}; - const ImportAsync = struct { data: *anyopaque, callback: ImportAsync.Callback, @@ -1030,12 +965,12 @@ const ImportAsync = struct { pub const ModuleSource = struct { shared: bool, - buffer_pool: *BufferPool, + script: *Script, buffer: std.ArrayList(u8), pub fn deinit(self: *ModuleSource) void { if (self.shared == false) { - self.buffer_pool.release(self.buffer); + self.script.deinit(); } } @@ -1045,15 +980,14 @@ pub const ModuleSource = struct { }; const ImportedModule = struct { - manager: *ScriptManager, + waiters: u16 = 1, state: State = .loading, buffer: std.ArrayList(u8) = .{}, - waiters: u16 = 1, - const State = enum { + const State = union(enum) { err, - done, loading, + done: *Script, }; }; diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 5c58c5cb..6a933f7e 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -303,15 +303,15 @@ pub fn module(self: *Context, comptime want_result: bool, local: *const js.Local } const owned_url = try arena.dupeZ(u8, url); + if (cacheable and !gop.found_existing) { + gop.key_ptr.* = owned_url; + } const m = try compileModule(local, src, owned_url); if (cacheable) { // compileModule is synchronous - nothing can modify the cache during compilation lp.assert(gop.value_ptr.module == null, "Context.module has module", .{}); gop.value_ptr.module = try m.persist(); - if (!gop.found_existing) { - gop.key_ptr.* = owned_url; - } } break :blk .{ m, owned_url }; From a8b147dfc054f1d24710002256c760817fd518ef Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sun, 15 Mar 2026 09:24:42 +0800 Subject: [PATCH 05/85] update v8 --- .github/actions/install/action.yml | 2 +- Dockerfile | 2 +- build.zig.zon | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index 4c0a28f9..5fcbfd85 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -13,7 +13,7 @@ inputs: zig-v8: description: 'zig v8 version to install' required: false - default: 'v0.3.3' + default: 'v0.3.4' v8: description: 'v8 version to install' required: false diff --git a/Dockerfile b/Dockerfile index f106905a..f5cd202d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM debian:stable-slim ARG MINISIG=0.12 ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U ARG V8=14.0.365.4 -ARG ZIG_V8=v0.3.3 +ARG ZIG_V8=v0.3.4 ARG TARGETPLATFORM RUN apt-get update -yq && \ diff --git a/build.zig.zon b/build.zig.zon index 33fc0a09..cee52057 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -5,7 +5,7 @@ .minimum_zig_version = "0.15.2", .dependencies = .{ .v8 = .{ - .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/34cb5f50227047c6cc1b2af73dad958c267f0a83.tar.gz", + .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.4.tar.gz", .hash = "v8-0.0.0-xddH6_F3BAAiFvKY6R1H-gkuQlk19BkDQ0--uZuTrSup", }, // .v8 = .{ .path = "../zig-v8-fork" }, From 3e9fa4ca473923ca1d25eea6df831e8327a73d47 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sun, 15 Mar 2026 12:00:42 +0800 Subject: [PATCH 06/85] Fix use-after-free with certain CDP scripts Origins were introduced to group memory/data that can be owned by multiple frames (on the same origin). There's a general idea that the initial "opaque" origin is very transient and should get replaced before any actual JavaScript is executed (because the real origin is setup as soon as we get the header from the response, long before we execute any script). But...with CDP, this guarantee doesn't hold There's nothing stop a CDP script from executing javascript at any point, including while the main page is still being loaded. This can result on allocations made on the opaque origin which is promptly discarded. To solve this, this commit introduced origin takeover. Rather than just transferring any data from one origin (the opaque) to the new one and then deinit' the opaque one (which is what results in user-after-free), the new origin simply maintains a list of opaque origins it has "taken-over"and is responsible for freeing it (in its own deinit). This ensures that any allocation made in the opaque origin remain valid. --- src/browser/js/Context.zig | 7 +++--- src/browser/js/Origin.zig | 49 ++++++++++++++++++++++---------------- 2 files changed, 32 insertions(+), 24 deletions(-) diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 5c58c5cb..97ea9d9c 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -167,12 +167,11 @@ pub fn setOrigin(self: *Context, key: ?[]const u8) !void { const env = self.env; const isolate = env.isolate; + lp.assert(self.origin.rc == 1, "Ref opaque origin", .{ .rc = self.origin.rc }); + const origin = try self.session.getOrCreateOrigin(key); errdefer self.session.releaseOrigin(origin); - - try self.origin.transferTo(origin); - lp.assert(self.origin.rc == 1, "Ref opaque origin", .{ .rc = self.origin.rc }); - self.origin.deinit(env.app); + try origin.takeover(self.origin); self.origin = origin; diff --git a/src/browser/js/Origin.zig b/src/browser/js/Origin.zig index d7e74e4f..180cfd84 100644 --- a/src/browser/js/Origin.zig +++ b/src/browser/js/Origin.zig @@ -68,6 +68,8 @@ temps: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty, // if v8 hasn't called the finalizer directly itself. finalizer_callbacks: std.AutoHashMapUnmanaged(usize, *FinalizerCallback) = .empty, +taken_over: std.ArrayList(*Origin), + pub fn init(app: *App, isolate: js.Isolate, key: []const u8) !*Origin { const arena = try app.arena_pool.acquire(); errdefer app.arena_pool.release(arena); @@ -86,14 +88,19 @@ pub fn init(app: *App, isolate: js.Isolate, key: []const u8) !*Origin { .rc = 1, .arena = arena, .key = owned_key, - .globals = .empty, .temps = .empty, + .globals = .empty, + .taken_over = .empty, .security_token = token_global, }; return self; } pub fn deinit(self: *Origin, app: *App) void { + for (self.taken_over.items) |o| { + o.deinit(app); + } + // Call finalizers before releasing anything { var it = self.finalizer_callbacks.valueIterator(); @@ -196,42 +203,44 @@ pub fn createFinalizerCallback( return fc; } -pub fn transferTo(self: *Origin, dest: *Origin) !void { - const arena = dest.arena; +pub fn takeover(self: *Origin, original: *Origin) !void { + const arena = self.arena; - try dest.globals.ensureUnusedCapacity(arena, self.globals.items.len); - for (self.globals.items) |obj| { - dest.globals.appendAssumeCapacity(obj); + try self.globals.ensureUnusedCapacity(arena, self.globals.items.len); + for (original.globals.items) |obj| { + self.globals.appendAssumeCapacity(obj); } - self.globals.clearRetainingCapacity(); + original.globals.clearRetainingCapacity(); { - try dest.temps.ensureUnusedCapacity(arena, self.temps.count()); - var it = self.temps.iterator(); + try self.temps.ensureUnusedCapacity(arena, original.temps.count()); + var it = original.temps.iterator(); while (it.next()) |kv| { - try dest.temps.put(arena, kv.key_ptr.*, kv.value_ptr.*); + try self.temps.put(arena, kv.key_ptr.*, kv.value_ptr.*); } - self.temps.clearRetainingCapacity(); + original.temps.clearRetainingCapacity(); } { - try dest.finalizer_callbacks.ensureUnusedCapacity(arena, self.finalizer_callbacks.count()); - var it = self.finalizer_callbacks.iterator(); + try self.finalizer_callbacks.ensureUnusedCapacity(arena, original.finalizer_callbacks.count()); + var it = original.finalizer_callbacks.iterator(); while (it.next()) |kv| { - kv.value_ptr.*.origin = dest; - try dest.finalizer_callbacks.put(arena, kv.key_ptr.*, kv.value_ptr.*); + kv.value_ptr.*.origin = self; + try self.finalizer_callbacks.put(arena, kv.key_ptr.*, kv.value_ptr.*); } - self.finalizer_callbacks.clearRetainingCapacity(); + original.finalizer_callbacks.clearRetainingCapacity(); } { - try dest.identity_map.ensureUnusedCapacity(arena, self.identity_map.count()); - var it = self.identity_map.iterator(); + try self.identity_map.ensureUnusedCapacity(arena, original.identity_map.count()); + var it = original.identity_map.iterator(); while (it.next()) |kv| { - try dest.identity_map.put(arena, kv.key_ptr.*, kv.value_ptr.*); + try self.identity_map.put(arena, kv.key_ptr.*, kv.value_ptr.*); } - self.identity_map.clearRetainingCapacity(); + original.identity_map.clearRetainingCapacity(); } + + try self.taken_over.append(self.arena, original); } // A type that has a finalizer can have its finalizer called one of two ways. From b373fb4a424119c314da7c8a62aa3811c6a4698c Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:20:45 -0700 Subject: [PATCH 07/85] Address review feedback: fix endless loop, use stdlib, add charset flag - Use std.ascii.eqlIgnoreCase instead of custom asciiEqlIgnoreCase - Fix infinite loop in findAttrValue when attribute has no '=' sign (e.g. self-closing ) - Add is_default_charset flag to Mime struct so prescan only overrides charset when Content-Type header didn't set one explicitly - Add regression test for the self-closing meta loop case Co-Authored-By: Claude Opus 4.6 --- src/browser/Mime.zig | 35 +++++++++++++++++++---------------- src/browser/Page.zig | 4 ++-- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/browser/Mime.zig b/src/browser/Mime.zig index beef2177..13951259 100644 --- a/src/browser/Mime.zig +++ b/src/browser/Mime.zig @@ -25,6 +25,7 @@ params: []const u8 = "", // We keep 41 for null-termination since HTML parser expects in this format. charset: [41]u8 = default_charset, charset_len: usize = default_charset_len, +is_default_charset: bool = true, /// String "UTF-8" continued by null characters. const default_charset = .{ 'U', 'T', 'F', '-', '8' } ++ .{0} ** 36; @@ -130,6 +131,7 @@ pub fn parse(input: []u8) !Mime { var charset: [41]u8 = default_charset; var charset_len: usize = default_charset_len; + var has_explicit_charset = false; var it = std.mem.splitScalar(u8, params, ';'); while (it.next()) |attr| { @@ -156,6 +158,7 @@ pub fn parse(input: []u8) !Mime { // Null-terminate right after attribute value. charset[attribute_value.len] = 0; charset_len = attribute_value.len; + has_explicit_charset = true; }, } } @@ -165,6 +168,7 @@ pub fn parse(input: []u8) !Mime { .charset = charset, .charset_len = charset_len, .content_type = content_type, + .is_default_charset = !has_explicit_charset, }; } @@ -212,7 +216,7 @@ pub fn prescanCharset(html: []const u8) ?[]const u8 { // Look for http-equiv="content-type" with content="...;charset=X" if (findAttrValue(attrs, "http-equiv")) |he| { - if (asciiEqlIgnoreCase(he, "content-type")) { + if (std.ascii.eqlIgnoreCase(he, "content-type")) { if (findAttrValue(attrs, "content")) |content| { if (extractCharsetFromContentType(content)) |charset| { return charset; @@ -248,7 +252,11 @@ fn findAttrValue(attrs: []const u8, name: []const u8) ?[]const u8 { // Skip whitespace around = while (pos < attrs.len and (attrs[pos] == ' ' or attrs[pos] == '\t')) pos += 1; - if (pos >= attrs.len or attrs[pos] != '=') continue; + if (pos >= attrs.len or attrs[pos] != '=') { + // No '=' found - skip this token. Advance at least one byte to avoid infinite loop. + if (pos == attr_start) pos += 1; + continue; + } pos += 1; // skip '=' while (pos < attrs.len and (attrs[pos] == ' ' or attrs[pos] == '\t')) pos += 1; if (pos >= attrs.len) return null; @@ -274,7 +282,7 @@ fn findAttrValue(attrs: []const u8, name: []const u8) ?[]const u8 { } }; - if (asciiEqlIgnoreCase(attr_name, name)) return value; + if (std.ascii.eqlIgnoreCase(attr_name, name)) return value; } return null; } @@ -283,7 +291,7 @@ fn extractCharsetFromContentType(content: []const u8) ?[]const u8 { var it = std.mem.splitScalar(u8, content, ';'); while (it.next()) |part| { const trimmed = std.mem.trimLeft(u8, part, &.{ ' ', '\t' }); - if (trimmed.len > 8 and asciiEqlIgnoreCase(trimmed[0..8], "charset=")) { + if (trimmed.len > 8 and std.ascii.eqlIgnoreCase(trimmed[0..8], "charset=")) { const val = std.mem.trim(u8, trimmed[8..], &.{ ' ', '\t', '"', '\'' }); if (val.len > 0 and val.len <= 40) return val; } @@ -291,14 +299,6 @@ fn extractCharsetFromContentType(content: []const u8) ?[]const u8 { return null; } -fn asciiEqlIgnoreCase(a: []const u8, b: []const u8) bool { - if (a.len != b.len) return false; - for (a, b) |ca, cb| { - if (std.ascii.toLower(ca) != std.ascii.toLower(cb)) return false; - } - return true; -} - pub fn sniff(body: []const u8) ?Mime { // 0x0C is form feed const content = std.mem.trimLeft(u8, body, &.{ ' ', '\t', '\n', '\r', 0x0C }); @@ -725,14 +725,17 @@ test "Mime: prescanCharset" { ); // No charset found - try testing.expectEqual(@as(?[]const u8, null), Mime.prescanCharset("Test")); - try testing.expectEqual(@as(?[]const u8, null), Mime.prescanCharset("")); - try testing.expectEqual(@as(?[]const u8, null), Mime.prescanCharset("no html here")); + try testing.expectEqual(null, Mime.prescanCharset("Test")); + try testing.expectEqual(null, Mime.prescanCharset("")); + try testing.expectEqual(null, Mime.prescanCharset("no html here")); + + // Self-closing meta without charset must not loop forever + try testing.expectEqual(null, Mime.prescanCharset("")); // Charset after 1024 bytes should not be found var long_html: [1100]u8 = undefined; @memset(&long_html, ' '); const suffix = ""; @memcpy(long_html[1050 .. 1050 + suffix.len], suffix); - try testing.expectEqual(@as(?[]const u8, null), Mime.prescanCharset(&long_html)); + try testing.expectEqual(null, Mime.prescanCharset(&long_html)); } diff --git a/src/browser/Page.zig b/src/browser/Page.zig index ab291bc6..9f7a22a1 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -855,9 +855,9 @@ fn pageDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void { break :blk Mime.sniff(data); } orelse .unknown; - // If the HTTP header didn't specify a charset and this is HTML, + // If the HTTP Content-Type header didn't specify a charset and this is HTML, // prescan the first 1024 bytes for a declaration. - if (mime.content_type == .text_html and std.mem.eql(u8, mime.charsetString(), "UTF-8")) { + if (mime.content_type == .text_html and mime.is_default_charset) { if (Mime.prescanCharset(data)) |charset| { if (charset.len <= 40) { @memcpy(mime.charset[0..charset.len], charset); From b10d866e4bd7a30da02ce553663ad01aa8f5d281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 16 Mar 2026 13:41:19 +0900 Subject: [PATCH 08/85] Add click, fill, and scroll interaction tools Adds click, fill, and scroll functionality to both CDP and MCP to support programmatic browser interactions. --- src/cdp/domains/lp.zig | 126 +++++++++++++++++++++++++++++ src/mcp/tools.zig | 175 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 301 insertions(+) diff --git a/src/cdp/domains/lp.zig b/src/cdp/domains/lp.zig index 2026b17d..9b51139f 100644 --- a/src/cdp/domains/lp.zig +++ b/src/cdp/domains/lp.zig @@ -32,6 +32,9 @@ pub fn processMessage(cmd: anytype) !void { getSemanticTree, getInteractiveElements, getStructuredData, + clickNode, + fillNode, + scrollNode, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { @@ -39,6 +42,9 @@ pub fn processMessage(cmd: anytype) !void { .getSemanticTree => return getSemanticTree(cmd), .getInteractiveElements => return getInteractiveElements(cmd), .getStructuredData => return getStructuredData(cmd), + .clickNode => return clickNode(cmd), + .fillNode => return fillNode(cmd), + .scrollNode => return scrollNode(cmd), } } @@ -146,6 +152,126 @@ fn getStructuredData(cmd: anytype) !void { }, .{}); } +fn clickNode(cmd: anytype) !void { + const Params = struct { + nodeId: ?Node.Id = null, + backendNodeId: ?Node.Id = null, + }; + const params = (try cmd.params(Params)) orelse Params{}; + + const bc = cmd.browser_context orelse return error.NoBrowserContext; + const page = bc.session.currentPage() orelse return error.PageNotLoaded; + + const input_node_id = params.nodeId orelse params.backendNodeId orelse return error.InvalidParam; + const node = bc.node_registry.lookup_by_id.get(input_node_id) orelse return error.InvalidNodeId; + + if (node.dom.is(DOMNode.Element)) |el| { + if (el.is(DOMNode.Element.Html)) |html_el| { + html_el.click(page) catch |err| { + log.err(.cdp, "click failed", .{ .err = err }); + return error.InternalError; + }; + } else { + return error.InvalidParam; + } + } else { + return error.InvalidParam; + } + + return cmd.sendResult(.{}, .{}); +} + +fn fillNode(cmd: anytype) !void { + const Params = struct { + nodeId: ?Node.Id = null, + backendNodeId: ?Node.Id = null, + text: []const u8, + }; + const params = (try cmd.params(Params)) orelse return error.InvalidParam; + + const bc = cmd.browser_context orelse return error.NoBrowserContext; + const page = bc.session.currentPage() orelse return error.PageNotLoaded; + + const input_node_id = params.nodeId orelse params.backendNodeId orelse return error.InvalidParam; + const node = bc.node_registry.lookup_by_id.get(input_node_id) orelse return error.InvalidNodeId; + + if (node.dom.is(DOMNode.Element)) |el| { + if (el.is(DOMNode.Element.Html.Input)) |input| { + input.setValue(params.text, page) catch |err| { + log.err(.cdp, "fill input failed", .{ .err = err }); + return error.InternalError; + }; + } else if (el.is(DOMNode.Element.Html.TextArea)) |textarea| { + textarea.setValue(params.text, page) catch |err| { + log.err(.cdp, "fill textarea failed", .{ .err = err }); + return error.InternalError; + }; + } else if (el.is(DOMNode.Element.Html.Select)) |select| { + select.setValue(params.text, page) catch |err| { + log.err(.cdp, "fill select failed", .{ .err = err }); + return error.InternalError; + }; + } else { + return error.InvalidParam; + } + + const Event = @import("../../browser/webapi/Event.zig"); + const input_evt = try Event.initTrusted(comptime lp.String.wrap("input"), .{ .bubbles = true }, page); + _ = page._event_manager.dispatch(el.asEventTarget(), input_evt) catch {}; + + const change_evt = try Event.initTrusted(comptime lp.String.wrap("change"), .{ .bubbles = true }, page); + _ = page._event_manager.dispatch(el.asEventTarget(), change_evt) catch {}; + } else { + return error.InvalidParam; + } + + return cmd.sendResult(.{}, .{}); +} + +fn scrollNode(cmd: anytype) !void { + const Params = struct { + nodeId: ?Node.Id = null, + backendNodeId: ?Node.Id = null, + x: ?i32 = null, + y: ?i32 = null, + }; + const params = (try cmd.params(Params)) orelse Params{}; + + const bc = cmd.browser_context orelse return error.NoBrowserContext; + const page = bc.session.currentPage() orelse return error.PageNotLoaded; + + const x = params.x orelse 0; + const y = params.y orelse 0; + + const input_node_id = params.nodeId orelse params.backendNodeId; + + if (input_node_id) |node_id| { + const node = bc.node_registry.lookup_by_id.get(node_id) orelse return error.InvalidNodeId; + + if (node.dom.is(DOMNode.Element)) |el| { + if (params.x != null) { + el.setScrollLeft(x, page) catch {}; + } + if (params.y != null) { + el.setScrollTop(y, page) catch {}; + } + + const Event = @import("../../browser/webapi/Event.zig"); + const scroll_evt = try Event.initTrusted(comptime lp.String.wrap("scroll"), .{ .bubbles = true }, page); + _ = page._event_manager.dispatch(el.asEventTarget(), scroll_evt) catch {}; + } else { + return error.InvalidParam; + } + } else { + page.window.scrollTo(.{ .x = x }, y, page) catch |err| { + log.err(.cdp, "scroll failed", .{ .err = err }); + return error.InternalError; + }; + } + + return cmd.sendResult(.{}, .{}); +} + const testing = @import("../testing.zig"); test "cdp.lp: getMarkdown" { var ctx = testing.context(); diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index f5126be0..adebc120 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -98,6 +98,47 @@ pub const tool_list = [_]protocol.Tool{ \\} ), }, + .{ + .name = "click", + .description = "Click on an interactive element.", + .inputSchema = protocol.minify( + \\{ + \\ "type": "object", + \\ "properties": { + \\ "backendNodeId": { "type": "integer", "description": "The backend node ID of the element to click." } + \\ }, + \\ "required": ["backendNodeId"] + \\} + ), + }, + .{ + .name = "fill", + .description = "Fill text into an input element.", + .inputSchema = protocol.minify( + \\{ + \\ "type": "object", + \\ "properties": { + \\ "backendNodeId": { "type": "integer", "description": "The backend node ID of the input element to fill." }, + \\ "text": { "type": "string", "description": "The text to fill into the input element." } + \\ }, + \\ "required": ["backendNodeId", "text"] + \\} + ), + }, + .{ + .name = "scroll", + .description = "Scroll the page or a specific element.", + .inputSchema = protocol.minify( + \\{ + \\ "type": "object", + \\ "properties": { + \\ "backendNodeId": { "type": "integer", "description": "Optional: The backend node ID of the element to scroll. If omitted, scrolls the window." }, + \\ "x": { "type": "integer", "description": "Optional: The horizontal scroll offset." }, + \\ "y": { "type": "integer", "description": "Optional: The vertical scroll offset." } + \\ } + \\} + ), + }, }; pub fn handleList(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void { @@ -182,6 +223,9 @@ const ToolAction = enum { structuredData, evaluate, semantic_tree, + click, + fill, + scroll, }; const tool_map = std.StaticStringMap(ToolAction).initComptime(.{ @@ -193,6 +237,9 @@ const tool_map = std.StaticStringMap(ToolAction).initComptime(.{ .{ "structuredData", .structuredData }, .{ "evaluate", .evaluate }, .{ "semantic_tree", .semantic_tree }, + .{ "click", .click }, + .{ "fill", .fill }, + .{ "scroll", .scroll }, }); pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void { @@ -221,6 +268,9 @@ pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Reque .structuredData => try handleStructuredData(server, arena, req.id.?, call_params.arguments), .evaluate => try handleEvaluate(server, arena, req.id.?, call_params.arguments), .semantic_tree => try handleSemanticTree(server, arena, req.id.?, call_params.arguments), + .click => try handleClick(server, arena, req.id.?, call_params.arguments), + .fill => try handleFill(server, arena, req.id.?, call_params.arguments), + .scroll => try handleScroll(server, arena, req.id.?, call_params.arguments), } } @@ -380,6 +430,131 @@ fn handleEvaluate(server: *Server, arena: std.mem.Allocator, id: std.json.Value, try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); } +fn handleClick(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { + const ClickParams = struct { + backendNodeId: CDPNode.Id, + }; + const args = try parseArguments(ClickParams, arena, arguments, server, id, "click"); + + const page = server.session.currentPage() orelse { + return server.sendError(id, .PageNotLoaded, "Page not loaded"); + }; + + const node = server.node_registry.lookup_by_id.get(args.backendNodeId) orelse { + return server.sendError(id, .InvalidParams, "Node not found"); + }; + + if (node.dom.is(Element)) |el| { + if (el.is(Element.Html)) |html_el| { + html_el.click(page) catch |err| { + log.err(.mcp, "click failed", .{ .err = err }); + return server.sendError(id, .InternalError, "Failed to click element"); + }; + } else { + return server.sendError(id, .InvalidParams, "Node is not an HTML element"); + } + } else { + return server.sendError(id, .InvalidParams, "Node is not an element"); + } + + const content = [_]protocol.TextContent([]const u8){.{ .text = "Clicked successfully." }}; + try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); +} + +fn handleFill(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { + const FillParams = struct { + backendNodeId: CDPNode.Id, + text: []const u8, + }; + const args = try parseArguments(FillParams, arena, arguments, server, id, "fill"); + + const page = server.session.currentPage() orelse { + return server.sendError(id, .PageNotLoaded, "Page not loaded"); + }; + + const node = server.node_registry.lookup_by_id.get(args.backendNodeId) orelse { + return server.sendError(id, .InvalidParams, "Node not found"); + }; + + if (node.dom.is(Element)) |el| { + if (el.is(Element.Html.Input)) |input| { + input.setValue(args.text, page) catch |err| { + log.err(.mcp, "fill input failed", .{ .err = err }); + return server.sendError(id, .InternalError, "Failed to fill input"); + }; + } else if (el.is(Element.Html.TextArea)) |textarea| { + textarea.setValue(args.text, page) catch |err| { + log.err(.mcp, "fill textarea failed", .{ .err = err }); + return server.sendError(id, .InternalError, "Failed to fill textarea"); + }; + } else if (el.is(Element.Html.Select)) |select| { + select.setValue(args.text, page) catch |err| { + log.err(.mcp, "fill select failed", .{ .err = err }); + return server.sendError(id, .InternalError, "Failed to fill select"); + }; + } else { + return server.sendError(id, .InvalidParams, "Node is not an input, textarea or select"); + } + + const Event = @import("../browser/webapi/Event.zig"); + const input_evt = try Event.initTrusted(comptime lp.String.wrap("input"), .{ .bubbles = true }, page); + _ = page._event_manager.dispatch(el.asEventTarget(), input_evt) catch {}; + + const change_evt = try Event.initTrusted(comptime lp.String.wrap("change"), .{ .bubbles = true }, page); + _ = page._event_manager.dispatch(el.asEventTarget(), change_evt) catch {}; + } else { + return server.sendError(id, .InvalidParams, "Node is not an element"); + } + + const content = [_]protocol.TextContent([]const u8){.{ .text = "Filled successfully." }}; + try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); +} + +fn handleScroll(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { + const ScrollParams = struct { + backendNodeId: ?CDPNode.Id = null, + x: ?i32 = null, + y: ?i32 = null, + }; + const args = try parseArguments(ScrollParams, arena, arguments, server, id, "scroll"); + + const page = server.session.currentPage() orelse { + return server.sendError(id, .PageNotLoaded, "Page not loaded"); + }; + + const x = args.x orelse 0; + const y = args.y orelse 0; + + if (args.backendNodeId) |node_id| { + const node = server.node_registry.lookup_by_id.get(node_id) orelse { + return server.sendError(id, .InvalidParams, "Node not found"); + }; + + if (node.dom.is(Element)) |el| { + if (args.x != null) { + el.setScrollLeft(x, page) catch {}; + } + if (args.y != null) { + el.setScrollTop(y, page) catch {}; + } + + const Event = @import("../browser/webapi/Event.zig"); + const scroll_evt = try Event.initTrusted(comptime lp.String.wrap("scroll"), .{ .bubbles = true }, page); + _ = page._event_manager.dispatch(el.asEventTarget(), scroll_evt) catch {}; + } else { + return server.sendError(id, .InvalidParams, "Node is not an element"); + } + } else { + page.window.scrollTo(.{ .x = x }, y, page) catch |err| { + log.err(.mcp, "scroll failed", .{ .err = err }); + return server.sendError(id, .InternalError, "Failed to scroll"); + }; + } + + const content = [_]protocol.TextContent([]const u8){.{ .text = "Scrolled successfully." }}; + try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); +} + fn parseArguments(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value, server: *Server, id: std.json.Value, tool_name: []const u8) !T { if (arguments == null) { try server.sendError(id, .InvalidParams, "Missing arguments"); From 1972142703ff52887357d93e0b747a2c9ec1c476 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 16 Mar 2026 14:16:20 +0900 Subject: [PATCH 09/85] mcp: add tests for click, fill, and scroll actions --- src/browser/tests/mcp_actions.html | 14 +++++++ src/cdp/domains/lp.zig | 60 ++++++++++++++++++++++++++++ src/mcp/tools.zig | 63 ++++++++++++++++++++++++++++++ 3 files changed, 137 insertions(+) create mode 100644 src/browser/tests/mcp_actions.html diff --git a/src/browser/tests/mcp_actions.html b/src/browser/tests/mcp_actions.html new file mode 100644 index 00000000..88cb70b1 --- /dev/null +++ b/src/browser/tests/mcp_actions.html @@ -0,0 +1,14 @@ + + + + + + +
+
Long content
+
+ + diff --git a/src/cdp/domains/lp.zig b/src/cdp/domains/lp.zig index 9b51139f..d876dddd 100644 --- a/src/cdp/domains/lp.zig +++ b/src/cdp/domains/lp.zig @@ -321,3 +321,63 @@ test "cdp.lp: getStructuredData" { const result = ctx.client.?.sent.items[0].object.get("result").?.object; try testing.expect(result.get("structuredData") != null); } + +test "cdp.lp: action tools" { + var ctx = testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{}); + const page = try bc.session.createPage(); + const url = "http://localhost:9582/src/browser/tests/mcp_actions.html"; + try page.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } }); + _ = bc.session.wait(5000); + + // Test Click + const btn = page.document.getElementById("btn", page).?.asNode(); + const btn_id = (try bc.node_registry.register(btn)).id; + try ctx.processMessage(.{ + .id = 1, + .method = "LP.clickNode", + .params = .{ .backendNodeId = btn_id }, + }); + + // Test Fill Input + const inp = page.document.getElementById("inp", page).?.asNode(); + const inp_id = (try bc.node_registry.register(inp)).id; + try ctx.processMessage(.{ + .id = 2, + .method = "LP.fillNode", + .params = .{ .backendNodeId = inp_id, .text = "hello" }, + }); + + // Test Fill Select + const sel = page.document.getElementById("sel", page).?.asNode(); + const sel_id = (try bc.node_registry.register(sel)).id; + try ctx.processMessage(.{ + .id = 3, + .method = "LP.fillNode", + .params = .{ .backendNodeId = sel_id, .text = "opt2" }, + }); + + // Test Scroll + const scrollbox = page.document.getElementById("scrollbox", page).?.asNode(); + const scrollbox_id = (try bc.node_registry.register(scrollbox)).id; + try ctx.processMessage(.{ + .id = 4, + .method = "LP.scrollNode", + .params = .{ .backendNodeId = scrollbox_id, .y = 50 }, + }); + + // Evaluate assertions + var ls: lp.js.Local.Scope = undefined; + page.js.localScope(&ls); + defer ls.deinit(); + + var try_catch: lp.js.TryCatch = undefined; + try_catch.init(&ls.local); + defer try_catch.deinit(); + + const result = try ls.local.compileAndRun("window.clicked === true && window.inputVal === 'hello' && window.changed === true && window.selChanged === 'opt2' && window.scrolled === true", null); + + try testing.expect(result.isTrue()); +} diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index adebc120..c2881a40 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -630,3 +630,66 @@ test "MCP - evaluate error reporting" { \\} , out_alloc.writer.buffered()); } + +test "MCP - Actions: click, fill, scroll" { + defer testing.reset(); + const allocator = testing.allocator; + const app = testing.test_app; + + var out_alloc: std.io.Writer.Allocating = .init(testing.arena_allocator); + defer out_alloc.deinit(); + + var server = try Server.init(allocator, app, &out_alloc.writer); + defer server.deinit(); + + const aa = testing.arena_allocator; + const page = try server.session.createPage(); + const url = "http://localhost:9582/src/browser/tests/mcp_actions.html"; + try page.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } }); + _ = server.session.wait(5000); + + // Test Click + const btn = page.document.getElementById("btn", page).?.asNode(); + const btn_id = (try server.node_registry.register(btn)).id; + var btn_id_buf: [12]u8 = undefined; + const btn_id_str = std.fmt.bufPrint(&btn_id_buf, "{d}", .{btn_id}) catch unreachable; + const click_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"click\",\"arguments\":{\"backendNodeId\":", btn_id_str, "}}}" }); + try router.handleMessage(server, aa, click_msg); + + // Test Fill Input + const inp = page.document.getElementById("inp", page).?.asNode(); + const inp_id = (try server.node_registry.register(inp)).id; + var inp_id_buf: [12]u8 = undefined; + const inp_id_str = std.fmt.bufPrint(&inp_id_buf, "{d}", .{inp_id}) catch unreachable; + const fill_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"fill\",\"arguments\":{\"backendNodeId\":", inp_id_str, ",\"text\":\"hello\"}}}" }); + try router.handleMessage(server, aa, fill_msg); + + // Test Fill Select + const sel = page.document.getElementById("sel", page).?.asNode(); + const sel_id = (try server.node_registry.register(sel)).id; + var sel_id_buf: [12]u8 = undefined; + const sel_id_str = std.fmt.bufPrint(&sel_id_buf, "{d}", .{sel_id}) catch unreachable; + const fill_sel_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"fill\",\"arguments\":{\"backendNodeId\":", sel_id_str, ",\"text\":\"opt2\"}}}" }); + try router.handleMessage(server, aa, fill_sel_msg); + + // Test Scroll + const scrollbox = page.document.getElementById("scrollbox", page).?.asNode(); + const scrollbox_id = (try server.node_registry.register(scrollbox)).id; + var scroll_id_buf: [12]u8 = undefined; + const scroll_id_str = std.fmt.bufPrint(&scroll_id_buf, "{d}", .{scrollbox_id}) catch unreachable; + const scroll_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{\"name\":\"scroll\",\"arguments\":{\"backendNodeId\":", scroll_id_str, ",\"y\":50}}}" }); + try router.handleMessage(server, aa, scroll_msg); + + // Evaluate assertions + var ls: js.Local.Scope = undefined; + page.js.localScope(&ls); + defer ls.deinit(); + + var try_catch: js.TryCatch = undefined; + try_catch.init(&ls.local); + defer try_catch.deinit(); + + const result = try ls.local.compileAndRun("window.clicked === true && window.inputVal === 'hello' && window.changed === true && window.selChanged === 'opt2' && window.scrolled === true", null); + + try testing.expect(result.isTrue()); +} From 32f450f803960e7d62a06f9f97c328d3847e8f0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 16 Mar 2026 14:22:15 +0900 Subject: [PATCH 10/85] browser: centralize node interaction logic Extracts click, fill, and scroll logic from CDP and MCP domains into a new dedicated actions module to reduce code duplication. --- src/browser/actions.zig | 93 +++++++++++++++++++++++++++++++++++++++++ src/cdp/domains/lp.zig | 77 +++++++--------------------------- src/lightpanda.zig | 1 + src/mcp/tools.zig | 72 +++++++------------------------ 4 files changed, 125 insertions(+), 118 deletions(-) create mode 100644 src/browser/actions.zig diff --git a/src/browser/actions.zig b/src/browser/actions.zig new file mode 100644 index 00000000..641466b8 --- /dev/null +++ b/src/browser/actions.zig @@ -0,0 +1,93 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const lp = @import("../lightpanda.zig"); +const DOMNode = @import("webapi/Node.zig"); +const Element = @import("webapi/Element.zig"); +const Event = @import("webapi/Event.zig"); +const Page = @import("Page.zig"); + +pub fn clickNode(dom_node: *DOMNode, page: *Page) !void { + if (dom_node.is(Element)) |el| { + if (el.is(Element.Html)) |html_el| { + html_el.click(page) catch |err| { + lp.log.err(.app, "click failed", .{ .err = err }); + return error.ActionFailed; + }; + } else { + return error.InvalidNodeType; + } + } else { + return error.InvalidNodeType; + } +} + +pub fn fillNode(dom_node: *DOMNode, text: []const u8, page: *Page) !void { + if (dom_node.is(Element)) |el| { + if (el.is(Element.Html.Input)) |input| { + input.setValue(text, page) catch |err| { + lp.log.err(.app, "fill input failed", .{ .err = err }); + return error.ActionFailed; + }; + } else if (el.is(Element.Html.TextArea)) |textarea| { + textarea.setValue(text, page) catch |err| { + lp.log.err(.app, "fill textarea failed", .{ .err = err }); + return error.ActionFailed; + }; + } else if (el.is(Element.Html.Select)) |select| { + select.setValue(text, page) catch |err| { + lp.log.err(.app, "fill select failed", .{ .err = err }); + return error.ActionFailed; + }; + } else { + return error.InvalidNodeType; + } + + const input_evt = try Event.initTrusted(comptime lp.String.wrap("input"), .{ .bubbles = true }, page); + _ = page._event_manager.dispatch(el.asEventTarget(), input_evt) catch {}; + + const change_evt = try Event.initTrusted(comptime lp.String.wrap("change"), .{ .bubbles = true }, page); + _ = page._event_manager.dispatch(el.asEventTarget(), change_evt) catch {}; + } else { + return error.InvalidNodeType; + } +} + +pub fn scrollNode(dom_node: ?*DOMNode, x: i32, y: i32, page: *Page) !void { + if (dom_node) |n| { + if (n.is(Element)) |el| { + if (x != 0) { + el.setScrollLeft(x, page) catch {}; + } + if (y != 0) { + el.setScrollTop(y, page) catch {}; + } + + const scroll_evt = try Event.initTrusted(comptime lp.String.wrap("scroll"), .{ .bubbles = true }, page); + _ = page._event_manager.dispatch(el.asEventTarget(), scroll_evt) catch {}; + } else { + return error.InvalidNodeType; + } + } else { + page.window.scrollTo(.{ .x = x }, y, page) catch |err| { + lp.log.err(.app, "scroll failed", .{ .err = err }); + return error.ActionFailed; + }; + } +} diff --git a/src/cdp/domains/lp.zig b/src/cdp/domains/lp.zig index d876dddd..4443629e 100644 --- a/src/cdp/domains/lp.zig +++ b/src/cdp/domains/lp.zig @@ -165,18 +165,10 @@ fn clickNode(cmd: anytype) !void { const input_node_id = params.nodeId orelse params.backendNodeId orelse return error.InvalidParam; const node = bc.node_registry.lookup_by_id.get(input_node_id) orelse return error.InvalidNodeId; - if (node.dom.is(DOMNode.Element)) |el| { - if (el.is(DOMNode.Element.Html)) |html_el| { - html_el.click(page) catch |err| { - log.err(.cdp, "click failed", .{ .err = err }); - return error.InternalError; - }; - } else { - return error.InvalidParam; - } - } else { - return error.InvalidParam; - } + lp.actions.clickNode(node.dom, page) catch |err| { + if (err == error.InvalidNodeType) return error.InvalidParam; + return error.InternalError; + }; return cmd.sendResult(.{}, .{}); } @@ -195,35 +187,10 @@ fn fillNode(cmd: anytype) !void { const input_node_id = params.nodeId orelse params.backendNodeId orelse return error.InvalidParam; const node = bc.node_registry.lookup_by_id.get(input_node_id) orelse return error.InvalidNodeId; - if (node.dom.is(DOMNode.Element)) |el| { - if (el.is(DOMNode.Element.Html.Input)) |input| { - input.setValue(params.text, page) catch |err| { - log.err(.cdp, "fill input failed", .{ .err = err }); - return error.InternalError; - }; - } else if (el.is(DOMNode.Element.Html.TextArea)) |textarea| { - textarea.setValue(params.text, page) catch |err| { - log.err(.cdp, "fill textarea failed", .{ .err = err }); - return error.InternalError; - }; - } else if (el.is(DOMNode.Element.Html.Select)) |select| { - select.setValue(params.text, page) catch |err| { - log.err(.cdp, "fill select failed", .{ .err = err }); - return error.InternalError; - }; - } else { - return error.InvalidParam; - } - - const Event = @import("../../browser/webapi/Event.zig"); - const input_evt = try Event.initTrusted(comptime lp.String.wrap("input"), .{ .bubbles = true }, page); - _ = page._event_manager.dispatch(el.asEventTarget(), input_evt) catch {}; - - const change_evt = try Event.initTrusted(comptime lp.String.wrap("change"), .{ .bubbles = true }, page); - _ = page._event_manager.dispatch(el.asEventTarget(), change_evt) catch {}; - } else { - return error.InvalidParam; - } + lp.actions.fillNode(node.dom, params.text, page) catch |err| { + if (err == error.InvalidNodeType) return error.InvalidParam; + return error.InternalError; + }; return cmd.sendResult(.{}, .{}); } @@ -245,33 +212,19 @@ fn scrollNode(cmd: anytype) !void { const input_node_id = params.nodeId orelse params.backendNodeId; + var target_node: ?*DOMNode = null; if (input_node_id) |node_id| { const node = bc.node_registry.lookup_by_id.get(node_id) orelse return error.InvalidNodeId; - - if (node.dom.is(DOMNode.Element)) |el| { - if (params.x != null) { - el.setScrollLeft(x, page) catch {}; - } - if (params.y != null) { - el.setScrollTop(y, page) catch {}; - } - - const Event = @import("../../browser/webapi/Event.zig"); - const scroll_evt = try Event.initTrusted(comptime lp.String.wrap("scroll"), .{ .bubbles = true }, page); - _ = page._event_manager.dispatch(el.asEventTarget(), scroll_evt) catch {}; - } else { - return error.InvalidParam; - } - } else { - page.window.scrollTo(.{ .x = x }, y, page) catch |err| { - log.err(.cdp, "scroll failed", .{ .err = err }); - return error.InternalError; - }; + target_node = node.dom; } + lp.actions.scrollNode(target_node, x, y, page) catch |err| { + if (err == error.InvalidNodeType) return error.InvalidParam; + return error.InternalError; + }; + return cmd.sendResult(.{}, .{}); } - const testing = @import("../testing.zig"); test "cdp.lp: getMarkdown" { var ctx = testing.context(); diff --git a/src/lightpanda.zig b/src/lightpanda.zig index 4fac3921..a9c7a1f0 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -35,6 +35,7 @@ pub const markdown = @import("browser/markdown.zig"); pub const SemanticTree = @import("SemanticTree.zig"); pub const CDPNode = @import("cdp/Node.zig"); pub const interactive = @import("browser/interactive.zig"); +pub const actions = @import("browser/actions.zig"); pub const structured_data = @import("browser/structured_data.zig"); pub const mcp = @import("mcp.zig"); pub const build_config = @import("build_config"); diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index c2881a40..65bd3ee8 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -5,6 +5,7 @@ const log = lp.log; const js = lp.js; const Element = @import("../browser/webapi/Element.zig"); +const DOMNode = @import("../browser/webapi/Node.zig"); const Selector = @import("../browser/webapi/selector/Selector.zig"); const protocol = @import("protocol.zig"); const Server = @import("Server.zig"); @@ -444,18 +445,12 @@ fn handleClick(server: *Server, arena: std.mem.Allocator, id: std.json.Value, ar return server.sendError(id, .InvalidParams, "Node not found"); }; - if (node.dom.is(Element)) |el| { - if (el.is(Element.Html)) |html_el| { - html_el.click(page) catch |err| { - log.err(.mcp, "click failed", .{ .err = err }); - return server.sendError(id, .InternalError, "Failed to click element"); - }; - } else { + lp.actions.clickNode(node.dom, page) catch |err| { + if (err == error.InvalidNodeType) { return server.sendError(id, .InvalidParams, "Node is not an HTML element"); } - } else { - return server.sendError(id, .InvalidParams, "Node is not an element"); - } + return server.sendError(id, .InternalError, "Failed to click element"); + }; const content = [_]protocol.TextContent([]const u8){.{ .text = "Clicked successfully." }}; try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); @@ -476,35 +471,12 @@ fn handleFill(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arg return server.sendError(id, .InvalidParams, "Node not found"); }; - if (node.dom.is(Element)) |el| { - if (el.is(Element.Html.Input)) |input| { - input.setValue(args.text, page) catch |err| { - log.err(.mcp, "fill input failed", .{ .err = err }); - return server.sendError(id, .InternalError, "Failed to fill input"); - }; - } else if (el.is(Element.Html.TextArea)) |textarea| { - textarea.setValue(args.text, page) catch |err| { - log.err(.mcp, "fill textarea failed", .{ .err = err }); - return server.sendError(id, .InternalError, "Failed to fill textarea"); - }; - } else if (el.is(Element.Html.Select)) |select| { - select.setValue(args.text, page) catch |err| { - log.err(.mcp, "fill select failed", .{ .err = err }); - return server.sendError(id, .InternalError, "Failed to fill select"); - }; - } else { + lp.actions.fillNode(node.dom, args.text, page) catch |err| { + if (err == error.InvalidNodeType) { return server.sendError(id, .InvalidParams, "Node is not an input, textarea or select"); } - - const Event = @import("../browser/webapi/Event.zig"); - const input_evt = try Event.initTrusted(comptime lp.String.wrap("input"), .{ .bubbles = true }, page); - _ = page._event_manager.dispatch(el.asEventTarget(), input_evt) catch {}; - - const change_evt = try Event.initTrusted(comptime lp.String.wrap("change"), .{ .bubbles = true }, page); - _ = page._event_manager.dispatch(el.asEventTarget(), change_evt) catch {}; - } else { - return server.sendError(id, .InvalidParams, "Node is not an element"); - } + return server.sendError(id, .InternalError, "Failed to fill element"); + }; const content = [_]protocol.TextContent([]const u8){.{ .text = "Filled successfully." }}; try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); @@ -525,36 +497,24 @@ fn handleScroll(server: *Server, arena: std.mem.Allocator, id: std.json.Value, a const x = args.x orelse 0; const y = args.y orelse 0; + var target_node: ?*DOMNode = null; if (args.backendNodeId) |node_id| { const node = server.node_registry.lookup_by_id.get(node_id) orelse { return server.sendError(id, .InvalidParams, "Node not found"); }; + target_node = node.dom; + } - if (node.dom.is(Element)) |el| { - if (args.x != null) { - el.setScrollLeft(x, page) catch {}; - } - if (args.y != null) { - el.setScrollTop(y, page) catch {}; - } - - const Event = @import("../browser/webapi/Event.zig"); - const scroll_evt = try Event.initTrusted(comptime lp.String.wrap("scroll"), .{ .bubbles = true }, page); - _ = page._event_manager.dispatch(el.asEventTarget(), scroll_evt) catch {}; - } else { + lp.actions.scrollNode(target_node, x, y, page) catch |err| { + if (err == error.InvalidNodeType) { return server.sendError(id, .InvalidParams, "Node is not an element"); } - } else { - page.window.scrollTo(.{ .x = x }, y, page) catch |err| { - log.err(.mcp, "scroll failed", .{ .err = err }); - return server.sendError(id, .InternalError, "Failed to scroll"); - }; - } + return server.sendError(id, .InternalError, "Failed to scroll"); + }; const content = [_]protocol.TextContent([]const u8){.{ .text = "Scrolled successfully." }}; try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); } - fn parseArguments(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value, server: *Server, id: std.json.Value, tool_name: []const u8) !T { if (arguments == null) { try server.sendError(id, .InvalidParams, "Missing arguments"); From 21e9967a8a06ddbab54a5fbd0b559b0a4c6b8f85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 16 Mar 2026 16:31:33 +0900 Subject: [PATCH 11/85] actions: simplify function names --- src/browser/actions.zig | 12 ++++++------ src/cdp/domains/lp.zig | 6 +++--- src/mcp/tools.zig | 6 +++--- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/browser/actions.zig b/src/browser/actions.zig index 641466b8..ceb698e2 100644 --- a/src/browser/actions.zig +++ b/src/browser/actions.zig @@ -23,8 +23,8 @@ const Element = @import("webapi/Element.zig"); const Event = @import("webapi/Event.zig"); const Page = @import("Page.zig"); -pub fn clickNode(dom_node: *DOMNode, page: *Page) !void { - if (dom_node.is(Element)) |el| { +pub fn click(node: *DOMNode, page: *Page) !void { + if (node.is(Element)) |el| { if (el.is(Element.Html)) |html_el| { html_el.click(page) catch |err| { lp.log.err(.app, "click failed", .{ .err = err }); @@ -38,8 +38,8 @@ pub fn clickNode(dom_node: *DOMNode, page: *Page) !void { } } -pub fn fillNode(dom_node: *DOMNode, text: []const u8, page: *Page) !void { - if (dom_node.is(Element)) |el| { +pub fn fill(node: *DOMNode, text: []const u8, page: *Page) !void { + if (node.is(Element)) |el| { if (el.is(Element.Html.Input)) |input| { input.setValue(text, page) catch |err| { lp.log.err(.app, "fill input failed", .{ .err = err }); @@ -69,8 +69,8 @@ pub fn fillNode(dom_node: *DOMNode, text: []const u8, page: *Page) !void { } } -pub fn scrollNode(dom_node: ?*DOMNode, x: i32, y: i32, page: *Page) !void { - if (dom_node) |n| { +pub fn scroll(node: ?*DOMNode, x: i32, y: i32, page: *Page) !void { + if (node) |n| { if (n.is(Element)) |el| { if (x != 0) { el.setScrollLeft(x, page) catch {}; diff --git a/src/cdp/domains/lp.zig b/src/cdp/domains/lp.zig index 4443629e..ed6c6994 100644 --- a/src/cdp/domains/lp.zig +++ b/src/cdp/domains/lp.zig @@ -165,7 +165,7 @@ fn clickNode(cmd: anytype) !void { const input_node_id = params.nodeId orelse params.backendNodeId orelse return error.InvalidParam; const node = bc.node_registry.lookup_by_id.get(input_node_id) orelse return error.InvalidNodeId; - lp.actions.clickNode(node.dom, page) catch |err| { + lp.actions.click(node.dom, page) catch |err| { if (err == error.InvalidNodeType) return error.InvalidParam; return error.InternalError; }; @@ -187,7 +187,7 @@ fn fillNode(cmd: anytype) !void { const input_node_id = params.nodeId orelse params.backendNodeId orelse return error.InvalidParam; const node = bc.node_registry.lookup_by_id.get(input_node_id) orelse return error.InvalidNodeId; - lp.actions.fillNode(node.dom, params.text, page) catch |err| { + lp.actions.fill(node.dom, params.text, page) catch |err| { if (err == error.InvalidNodeType) return error.InvalidParam; return error.InternalError; }; @@ -218,7 +218,7 @@ fn scrollNode(cmd: anytype) !void { target_node = node.dom; } - lp.actions.scrollNode(target_node, x, y, page) catch |err| { + lp.actions.scroll(target_node, x, y, page) catch |err| { if (err == error.InvalidNodeType) return error.InvalidParam; return error.InternalError; }; diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index 65bd3ee8..6f256dca 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -445,7 +445,7 @@ fn handleClick(server: *Server, arena: std.mem.Allocator, id: std.json.Value, ar return server.sendError(id, .InvalidParams, "Node not found"); }; - lp.actions.clickNode(node.dom, page) catch |err| { + lp.actions.click(node.dom, page) catch |err| { if (err == error.InvalidNodeType) { return server.sendError(id, .InvalidParams, "Node is not an HTML element"); } @@ -471,7 +471,7 @@ fn handleFill(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arg return server.sendError(id, .InvalidParams, "Node not found"); }; - lp.actions.fillNode(node.dom, args.text, page) catch |err| { + lp.actions.fill(node.dom, args.text, page) catch |err| { if (err == error.InvalidNodeType) { return server.sendError(id, .InvalidParams, "Node is not an input, textarea or select"); } @@ -505,7 +505,7 @@ fn handleScroll(server: *Server, arena: std.mem.Allocator, id: std.json.Value, a target_node = node.dom; } - lp.actions.scrollNode(target_node, x, y, page) catch |err| { + lp.actions.scroll(target_node, x, y, page) catch |err| { if (err == error.InvalidNodeType) { return server.sendError(id, .InvalidParams, "Node is not an element"); } From f5bc7310b14ca7740b817cc4d0ddfd90e30b8e07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 16 Mar 2026 16:38:21 +0900 Subject: [PATCH 12/85] actions: refactor node type checks for idiomatic flattening --- src/browser/actions.zig | 80 +++++++++++++++++++---------------------- 1 file changed, 36 insertions(+), 44 deletions(-) diff --git a/src/browser/actions.zig b/src/browser/actions.zig index ceb698e2..60b03f1e 100644 --- a/src/browser/actions.zig +++ b/src/browser/actions.zig @@ -24,66 +24,58 @@ const Event = @import("webapi/Event.zig"); const Page = @import("Page.zig"); pub fn click(node: *DOMNode, page: *Page) !void { - if (node.is(Element)) |el| { - if (el.is(Element.Html)) |html_el| { - html_el.click(page) catch |err| { - lp.log.err(.app, "click failed", .{ .err = err }); - return error.ActionFailed; - }; - } else { - return error.InvalidNodeType; - } + if (node.is(Element.Html)) |html_el| { + html_el.click(page) catch |err| { + lp.log.err(.app, "click failed", .{ .err = err }); + return error.ActionFailed; + }; } else { return error.InvalidNodeType; } } pub fn fill(node: *DOMNode, text: []const u8, page: *Page) !void { - if (node.is(Element)) |el| { - if (el.is(Element.Html.Input)) |input| { - input.setValue(text, page) catch |err| { - lp.log.err(.app, "fill input failed", .{ .err = err }); - return error.ActionFailed; - }; - } else if (el.is(Element.Html.TextArea)) |textarea| { - textarea.setValue(text, page) catch |err| { - lp.log.err(.app, "fill textarea failed", .{ .err = err }); - return error.ActionFailed; - }; - } else if (el.is(Element.Html.Select)) |select| { - select.setValue(text, page) catch |err| { - lp.log.err(.app, "fill select failed", .{ .err = err }); - return error.ActionFailed; - }; - } else { - return error.InvalidNodeType; - } + const el = node.is(Element) orelse return error.InvalidNodeType; - const input_evt = try Event.initTrusted(comptime lp.String.wrap("input"), .{ .bubbles = true }, page); - _ = page._event_manager.dispatch(el.asEventTarget(), input_evt) catch {}; - - const change_evt = try Event.initTrusted(comptime lp.String.wrap("change"), .{ .bubbles = true }, page); - _ = page._event_manager.dispatch(el.asEventTarget(), change_evt) catch {}; + if (el.is(Element.Html.Input)) |input| { + input.setValue(text, page) catch |err| { + lp.log.err(.app, "fill input failed", .{ .err = err }); + return error.ActionFailed; + }; + } else if (el.is(Element.Html.TextArea)) |textarea| { + textarea.setValue(text, page) catch |err| { + lp.log.err(.app, "fill textarea failed", .{ .err = err }); + return error.ActionFailed; + }; + } else if (el.is(Element.Html.Select)) |select| { + select.setValue(text, page) catch |err| { + lp.log.err(.app, "fill select failed", .{ .err = err }); + return error.ActionFailed; + }; } else { return error.InvalidNodeType; } + + const input_evt = try Event.initTrusted(comptime lp.String.wrap("input"), .{ .bubbles = true }, page); + _ = page._event_manager.dispatch(el.asEventTarget(), input_evt) catch {}; + + const change_evt = try Event.initTrusted(comptime lp.String.wrap("change"), .{ .bubbles = true }, page); + _ = page._event_manager.dispatch(el.asEventTarget(), change_evt) catch {}; } pub fn scroll(node: ?*DOMNode, x: i32, y: i32, page: *Page) !void { if (node) |n| { - if (n.is(Element)) |el| { - if (x != 0) { - el.setScrollLeft(x, page) catch {}; - } - if (y != 0) { - el.setScrollTop(y, page) catch {}; - } + const el = n.is(Element) orelse return error.InvalidNodeType; - const scroll_evt = try Event.initTrusted(comptime lp.String.wrap("scroll"), .{ .bubbles = true }, page); - _ = page._event_manager.dispatch(el.asEventTarget(), scroll_evt) catch {}; - } else { - return error.InvalidNodeType; + if (x != 0) { + el.setScrollLeft(x, page) catch {}; } + if (y != 0) { + el.setScrollTop(y, page) catch {}; + } + + const scroll_evt = try Event.initTrusted(comptime lp.String.wrap("scroll"), .{ .bubbles = true }, page); + _ = page._event_manager.dispatch(el.asEventTarget(), scroll_evt) catch {}; } else { page.window.scrollTo(.{ .x = x }, y, page) catch |err| { lp.log.err(.app, "scroll failed", .{ .err = err }); From 80c309aa695e7430d159b583ed654323f76dde55 Mon Sep 17 00:00:00 2001 From: jnMetaCode <1394485448@qq.com> Date: Mon, 16 Mar 2026 17:07:56 +0800 Subject: [PATCH 13/85] fix(cdp): add noop Emulation.setUserAgentOverride to prevent Playwright crash Playwright calls Emulation.setUserAgentOverride when creating a browser context with a custom user agent. Without this handler, Lightpanda returns UnknownMethod which crashes the Playwright driver. Add a noop handler matching the existing pattern for other Emulation methods (setDeviceMetricsOverride, setEmulatedMedia, etc.) so the CDP handshake can proceed. Fixes #1436 Signed-off-by: JiangNan <1394485448@qq.com> --- src/cdp/domains/emulation.zig | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/cdp/domains/emulation.zig b/src/cdp/domains/emulation.zig index dd94df3e..ae1f62c2 100644 --- a/src/cdp/domains/emulation.zig +++ b/src/cdp/domains/emulation.zig @@ -24,6 +24,7 @@ pub fn processMessage(cmd: anytype) !void { setFocusEmulationEnabled, setDeviceMetricsOverride, setTouchEmulationEnabled, + setUserAgentOverride, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { @@ -31,6 +32,7 @@ pub fn processMessage(cmd: anytype) !void { .setFocusEmulationEnabled => return setFocusEmulationEnabled(cmd), .setDeviceMetricsOverride => return setDeviceMetricsOverride(cmd), .setTouchEmulationEnabled => return setTouchEmulationEnabled(cmd), + .setUserAgentOverride => return setUserAgentOverride(cmd), } } @@ -64,3 +66,8 @@ fn setDeviceMetricsOverride(cmd: anytype) !void { fn setTouchEmulationEnabled(cmd: anytype) !void { return cmd.sendResult(null, .{}); } + +// TODO: noop method +fn setUserAgentOverride(cmd: anytype) !void { + return cmd.sendResult(null, .{}); +} From 21421d5b5306ef23a1f20bd8127e63720ff0fe8e Mon Sep 17 00:00:00 2001 From: jnMetaCode <1394485448@qq.com> Date: Mon, 16 Mar 2026 17:20:29 +0800 Subject: [PATCH 14/85] fix(dom): add default messages for all DOMException error codes The getMessage() fallback returned raw tag names like "wrong_document_error" instead of human-readable messages. Fill in all 18 error codes with messages based on the WebIDL spec error descriptions. Closes #82 Signed-off-by: JiangNan <1394485448@qq.com> --- src/browser/webapi/DOMException.zig | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/browser/webapi/DOMException.zig b/src/browser/webapi/DOMException.zig index 3e0da288..46294b8d 100644 --- a/src/browser/webapi/DOMException.zig +++ b/src/browser/webapi/DOMException.zig @@ -104,13 +104,27 @@ pub fn getMessage(self: *const DOMException) []const u8 { } return switch (self._code) { .none => "", - .invalid_character_error => "Invalid Character", .index_size_error => "Index or size is negative or greater than the allowed amount", - .syntax_error => "Syntax Error", - .not_supported => "Not Supported", - .not_found => "Not Found", - .hierarchy_error => "Hierarchy Error", - else => @tagName(self._code), + .hierarchy_error => "The operation would yield an incorrect node tree", + .wrong_document_error => "The object is in the wrong document", + .invalid_character_error => "The string contains invalid characters", + .no_modification_allowed_error => "The object can not be modified", + .not_found => "The object can not be found here", + .not_supported => "The operation is not supported", + .inuse_attribute_error => "The attribute already in use", + .invalid_state_error => "The object is in an invalid state", + .syntax_error => "The string did not match the expected pattern", + .invalid_modification_error => "The object can not be modified in this way", + .namespace_error => "The operation is not allowed by Namespaces in XML", + .invalid_access_error => "The object does not support the operation or argument", + .security_error => "The operation is insecure", + .network_error => "A network error occurred", + .abort_error => "The operation was aborted", + .url_mismatch_error => "The given URL does not match another URL", + .quota_exceeded_error => "The quota has been exceeded", + .timeout_error => "The operation timed out", + .invalid_node_type_error => "The supplied node is incorrect or has an incorrect ancestor for this operation", + .data_clone_error => "The object can not be cloned", }; } From 0380df1cb4e599ee57bd15c5e11b9f119a110f23 Mon Sep 17 00:00:00 2001 From: jnMetaCode <1394485448@qq.com> Date: Mon, 16 Mar 2026 17:21:14 +0800 Subject: [PATCH 15/85] fix(cdp): add missing disable method to Performance Signed-off-by: JiangNan <1394485448@qq.com> --- src/cdp/domains/performance.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/cdp/domains/performance.zig b/src/cdp/domains/performance.zig index dad0cebd..b8dea574 100644 --- a/src/cdp/domains/performance.zig +++ b/src/cdp/domains/performance.zig @@ -21,9 +21,11 @@ const std = @import("std"); pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { enable, + disable, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .enable => return cmd.sendResult(null, .{}), + .disable => return cmd.sendResult(null, .{}), } } From ac651328c3f3c38b0b804787f8d0758dbaab6a9b Mon Sep 17 00:00:00 2001 From: jnMetaCode <1394485448@qq.com> Date: Mon, 16 Mar 2026 17:21:18 +0800 Subject: [PATCH 16/85] fix(cdp): add missing disable method to Inspector Signed-off-by: JiangNan <1394485448@qq.com> --- src/cdp/domains/inspector.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/cdp/domains/inspector.zig b/src/cdp/domains/inspector.zig index dad0cebd..b8dea574 100644 --- a/src/cdp/domains/inspector.zig +++ b/src/cdp/domains/inspector.zig @@ -21,9 +21,11 @@ const std = @import("std"); pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { enable, + disable, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .enable => return cmd.sendResult(null, .{}), + .disable => return cmd.sendResult(null, .{}), } } From b09e9f73982045c6955c8df7bf6effafd6138436 Mon Sep 17 00:00:00 2001 From: jnMetaCode <1394485448@qq.com> Date: Mon, 16 Mar 2026 17:21:20 +0800 Subject: [PATCH 17/85] fix(cdp): add missing disable method to Security Signed-off-by: JiangNan <1394485448@qq.com> --- src/cdp/domains/security.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/cdp/domains/security.zig b/src/cdp/domains/security.zig index 0ebfedae..9bbf5b39 100644 --- a/src/cdp/domains/security.zig +++ b/src/cdp/domains/security.zig @@ -21,11 +21,13 @@ const std = @import("std"); pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { enable, + disable, setIgnoreCertificateErrors, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .enable => return cmd.sendResult(null, .{}), + .disable => return cmd.sendResult(null, .{}), .setIgnoreCertificateErrors => return setIgnoreCertificateErrors(cmd), } } From 7b2895ef081e06948221db025ce44d7151719144 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 16 Mar 2026 17:33:12 +0800 Subject: [PATCH 18/85] click event dispastched from CDP should be trusted --- src/browser/Page.zig | 2 +- src/browser/webapi/event/MouseEvent.zig | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index cb62cb31..f11ba35c 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -3255,7 +3255,7 @@ pub fn triggerMouseClick(self: *Page, x: f64, y: f64) !void { .type = self._type, }); } - const event = (try @import("webapi/event/MouseEvent.zig").init("click", .{ + const event = (try @import("webapi/event/MouseEvent.zig").initTrusted(comptime .wrap("click"), .{ .bubbles = true, .cancelable = true, .composed = true, diff --git a/src/browser/webapi/event/MouseEvent.zig b/src/browser/webapi/event/MouseEvent.zig index 6b032433..e13dc1b3 100644 --- a/src/browser/webapi/event/MouseEvent.zig +++ b/src/browser/webapi/event/MouseEvent.zig @@ -28,6 +28,8 @@ const EventTarget = @import("../EventTarget.zig"); const UIEvent = @import("UIEvent.zig"); const PointerEvent = @import("PointerEvent.zig"); +const Allocator = std.mem.Allocator; + const MouseEvent = @This(); pub const MouseButton = enum(u8) { @@ -83,12 +85,21 @@ pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*MouseEvent { const arena = try page.getArena(.{ .debug = "MouseEvent" }); errdefer page.releaseArena(arena); const type_string = try String.init(arena, typ, .{}); + return initWithTrusted(arena, type_string, _opts, false, page); +} +pub fn initTrusted(typ: String, _opts: ?Options, page: *Page) !*MouseEvent { + const arena = try page.getArena(.{ .debug = "MouseEvent.trusted" }); + errdefer page.releaseArena(arena); + return initWithTrusted(arena, typ, _opts, true, page); +} + +fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool, page: *Page) !*MouseEvent { const opts = _opts orelse Options{}; const event = try page._factory.uiEvent( arena, - type_string, + typ, MouseEvent{ ._type = .generic, ._proto = undefined, @@ -106,7 +117,7 @@ pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*MouseEvent { }, ); - Event.populatePrototypes(event, opts, false); + Event.populatePrototypes(event, opts, trusted); return event; } From 422320d9ac47bb6e105daa078d9e7657c576f1d2 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 16 Mar 2026 17:54:01 +0800 Subject: [PATCH 19/85] Set charset based on BOM Small follow up to https://github.com/lightpanda-io/browser/pull/1837 If we sniff the content type from the byte order mark (BOM), then we should set the charset. This has higher precedence than sniffing the content type from the content of the document (e.g. meta tags) --- src/browser/Mime.zig | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/src/browser/Mime.zig b/src/browser/Mime.zig index 13951259..e23d48a2 100644 --- a/src/browser/Mime.zig +++ b/src/browser/Mime.zig @@ -309,15 +309,30 @@ pub fn sniff(body: []const u8) ?Mime { if (content[0] != '<') { if (std.mem.startsWith(u8, content, &.{ 0xEF, 0xBB, 0xBF })) { // UTF-8 BOM - return .{ .content_type = .{ .text_plain = {} } }; + return .{ + .content_type = .{ .text_plain = {} }, + .charset = default_charset, + .charset_len = default_charset_len, + .is_default_charset = false, + }; } if (std.mem.startsWith(u8, content, &.{ 0xFE, 0xFF })) { // UTF-16 big-endian BOM - return .{ .content_type = .{ .text_plain = {} } }; + return .{ + .content_type = .{ .text_plain = {} }, + .charset = .{ 'U', 'T', 'F', '-', '1', '6', 'B', 'E' } ++ .{0} ** 33, + .charset_len = 8, + .is_default_charset = false, + }; } if (std.mem.startsWith(u8, content, &.{ 0xFF, 0xFE })) { // UTF-16 little-endian BOM - return .{ .content_type = .{ .text_plain = {} } }; + return .{ + .content_type = .{ .text_plain = {} }, + .charset = .{ 'U', 'T', 'F', '-', '1', '6', 'L', 'E' } ++ .{0} ** 33, + .charset_len = 8, + .is_default_charset = false, + }; } return null; } @@ -671,6 +686,24 @@ test "Mime: sniff" { try expectHTML(""); try expectHTML(" \n\t "); + + { + const mime = Mime.sniff(&.{ 0xEF, 0xBB, 0xBF }).?; + try testing.expectEqual(.text_plain, std.meta.activeTag(mime.content_type)); + try testing.expectEqual("UTF-8", mime.charsetString()); + } + + { + const mime = Mime.sniff(&.{ 0xFE, 0xFF }).?; + try testing.expectEqual(.text_plain, std.meta.activeTag(mime.content_type)); + try testing.expectEqual("UTF-16BE", mime.charsetString()); + } + + { + const mime = Mime.sniff(&.{ 0xFF, 0xFE }).?; + try testing.expectEqual(.text_plain, std.meta.activeTag(mime.content_type)); + try testing.expectEqual("UTF-16LE", mime.charsetString()); + } } const Expectation = struct { From dac456d98c4b4086ef25f54de944e3f2e6ea699f Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Mon, 16 Mar 2026 09:56:54 +0100 Subject: [PATCH 20/85] ci: fix wba flaky test Sometimes the GHA secret isn't dump in file correctly. So this commit inject the value directly to the command line --- .github/workflows/e2e-test.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 675dd36b..632200f2 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -194,8 +194,6 @@ jobs: repository: 'lightpanda-io/demo' fetch-depth: 0 - - run: echo "${{ secrets.WBA_PRIVATE_KEY_PEM }}" > private_key.pem - - name: download artifact uses: actions/download-artifact@v4 with: @@ -204,17 +202,22 @@ jobs: - run: chmod a+x ./lightpanda - name: run wba test + shell: bash run: | + node webbotauth/validator.js & VALIDATOR_PID=$! sleep 2 - ./lightpanda fetch http://127.0.0.1:8989/ \ - --web_bot_auth_key_file private_key.pem \ + exec 3<<< "${{ secrets.WBA_PRIVATE_KEY_PEM }}" + + ./lightpanda fetch --dump http://127.0.0.1:8989/ \ + --web_bot_auth_key_file /proc/self/fd/3 \ --web_bot_auth_keyid ${{ vars.WBA_KEY_ID }} \ --web_bot_auth_domain ${{ vars.WBA_DOMAIN }} wait $VALIDATOR_PID + exec 3>&- cdp-and-hyperfine-bench: name: cdp-and-hyperfine-bench From 1ceaabe69f2a864343805fc7acbc36628b34bf28 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 16 Mar 2026 20:56:18 +0800 Subject: [PATCH 21/85] Switch to reference counting for Mutation Observer and Intersection Observer This may be a stopgap. Our identity model assumes that v8 won't allow cross-origin access. It turns out that with CDP and Inspector, this isn't true. Inspectors can break / violate cross-origin restrictions. The result is that 2 origins can see the same zig instance, which causes 2 v8::Objects to reference the same Zig instance. This likely causes some consistency issue. Like, if you take mo in 1 context, and write an arbitrary property, mo.hack = true, you won't observe that in the 2nd context (because it's a different v8::Object). But, it _is_ the same Zig instance, so if you set a known/real property, it will be updated. That's probably a pretty minor issue. The bigger issue is that it can result in a use-after-free when using explicit strong/weak ref: 1 - Mutation observer is created in Origin1 2 - It's automatically set to weak 3 - Something is observed, the reference is made strong 4 - The MO is accessed from Origin2 5 - Creates a new v8::Object 6 - Sets it to weak 7 - Object goes out of scope in Origin2 8 - Finalizer is called <- free 9 - MO is manipulated in Origin 1 <- use after free Maybe the right option is to have a single shared identity map. I need to think about it. As a stopgap, switching to reference counting (which we already support) shold prevent the use-after free. While we'll still create 2 v8::Objects, they'll each acquireRef (_rc = 2) and thus it won't be freed until they both release i Maybe the right option is to have a single shared identity map. I need to think about it. As a stopgap, switching to reference counting (which we already support) shold prevent the use-after free. While we'll still create 2 v8::Objects, they'll each acquireRef (_rc = 2) and thus it won't be freed until they both release it. --- src/browser/webapi/IntersectionObserver.zig | 37 ++++++++++++++++----- src/browser/webapi/MutationObserver.zig | 34 ++++++++++++++----- 2 files changed, 54 insertions(+), 17 deletions(-) diff --git a/src/browser/webapi/IntersectionObserver.zig b/src/browser/webapi/IntersectionObserver.zig index 74a5d79e..b4c07e77 100644 --- a/src/browser/webapi/IntersectionObserver.zig +++ b/src/browser/webapi/IntersectionObserver.zig @@ -37,6 +37,7 @@ pub fn registerTypes() []const type { const IntersectionObserver = @This(); +_rc: u8 = 0, _arena: Allocator, _callback: js.Function.Temp, _observing: std.ArrayList(*Element) = .{}, @@ -93,12 +94,24 @@ pub fn init(callback: js.Function.Temp, options: ?ObserverInit, page: *Page) !*I } pub fn deinit(self: *IntersectionObserver, shutdown: bool, session: *Session) void { - self._callback.release(); - if ((comptime IS_DEBUG) and !shutdown) { - std.debug.assert(self._observing.items.len == 0); + const rc = self._rc; + if (comptime IS_DEBUG) { + std.debug.assert(rc != 0); } - session.releaseArena(self._arena); + if (rc == 1 or shutdown) { + self._callback.release(); + if ((comptime IS_DEBUG) and !shutdown) { + std.debug.assert(self._observing.items.len == 0); + } + session.releaseArena(self._arena); + } else { + self._rc = rc - 1; + } +} + +pub fn acquireRef(self: *IntersectionObserver) void { + self._rc += 1; } pub fn observe(self: *IntersectionObserver, target: *Element, page: *Page) !void { @@ -111,7 +124,7 @@ pub fn observe(self: *IntersectionObserver, target: *Element, page: *Page) !void // Register with page if this is our first observation if (self._observing.items.len == 0) { - page.js.strongRef(self); + self._rc += 1; try page.registerIntersectionObserver(self); } @@ -148,20 +161,26 @@ pub fn unobserve(self: *IntersectionObserver, target: *Element, page: *Page) voi } if (self._observing.items.len == 0) { - page.js.safeWeakRef(self); + self.deinit(false, page._session); } } pub fn disconnect(self: *IntersectionObserver, page: *Page) void { - page.unregisterIntersectionObserver(self); - self._observing.clearRetainingCapacity(); self._previous_states.clearRetainingCapacity(); for (self._pending_entries.items) |entry| { entry.deinit(false, page._session); } self._pending_entries.clearRetainingCapacity(); - page.js.safeWeakRef(self); + + const observing_count = self._observing.items.len; + self._observing.clearRetainingCapacity(); + + if (observing_count > 0) { + self.deinit(false, page._session); + } + + page.unregisterIntersectionObserver(self); } pub fn takeRecords(self: *IntersectionObserver, page: *Page) ![]*IntersectionObserverEntry { diff --git a/src/browser/webapi/MutationObserver.zig b/src/browser/webapi/MutationObserver.zig index b8608381..8b625fa8 100644 --- a/src/browser/webapi/MutationObserver.zig +++ b/src/browser/webapi/MutationObserver.zig @@ -39,6 +39,7 @@ pub fn registerTypes() []const type { const MutationObserver = @This(); +_rc: u8 = 0, _arena: Allocator, _callback: js.Function.Temp, _observing: std.ArrayList(Observing) = .{}, @@ -86,12 +87,24 @@ pub fn init(callback: js.Function.Temp, page: *Page) !*MutationObserver { } pub fn deinit(self: *MutationObserver, shutdown: bool, session: *Session) void { - self._callback.release(); - if ((comptime IS_DEBUG) and !shutdown) { - std.debug.assert(self._observing.items.len == 0); + const rc = self._rc; + if (comptime IS_DEBUG) { + std.debug.assert(rc != 0); } - session.releaseArena(self._arena); + if (rc == 1 or shutdown) { + self._callback.release(); + if ((comptime IS_DEBUG) and !shutdown) { + std.debug.assert(self._observing.items.len == 0); + } + session.releaseArena(self._arena); + } else { + self._rc = rc - 1; + } +} + +pub fn acquireRef(self: *MutationObserver) void { + self._rc += 1; } pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions, page: *Page) !void { @@ -158,7 +171,7 @@ pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions, // Register with page if this is our first observation if (self._observing.items.len == 0) { - page.js.strongRef(self); + self._rc += 1; try page.registerMutationObserver(self); } @@ -169,13 +182,18 @@ pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions, } pub fn disconnect(self: *MutationObserver, page: *Page) void { - page.unregisterMutationObserver(self); - self._observing.clearRetainingCapacity(); for (self._pending_records.items) |record| { record.deinit(false, page._session); } self._pending_records.clearRetainingCapacity(); - page.js.safeWeakRef(self); + + const observing_count = self._observing.items.len; + self._observing.clearRetainingCapacity(); + + if (observing_count > 0) { + self.deinit(false, page._session); + } + page.unregisterMutationObserver(self); } pub fn takeRecords(self: *MutationObserver, page: *Page) ![]*MutationRecord { From a74e46debf89476037adcdb4429bf880920d2b50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 16 Mar 2026 22:44:37 +0900 Subject: [PATCH 22/85] actions: make scroll coordinates optional Updates the scroll action to accept optional x and y coordinates. This allows scrolling on a single axis without resetting the other to zero. --- src/browser/actions.zig | 12 ++++++------ src/cdp/domains/lp.zig | 5 +---- src/mcp/tools.zig | 5 +---- 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/src/browser/actions.zig b/src/browser/actions.zig index 60b03f1e..36b96bc5 100644 --- a/src/browser/actions.zig +++ b/src/browser/actions.zig @@ -63,21 +63,21 @@ pub fn fill(node: *DOMNode, text: []const u8, page: *Page) !void { _ = page._event_manager.dispatch(el.asEventTarget(), change_evt) catch {}; } -pub fn scroll(node: ?*DOMNode, x: i32, y: i32, page: *Page) !void { +pub fn scroll(node: ?*DOMNode, x: ?i32, y: ?i32, page: *Page) !void { if (node) |n| { const el = n.is(Element) orelse return error.InvalidNodeType; - if (x != 0) { - el.setScrollLeft(x, page) catch {}; + if (x) |val| { + el.setScrollLeft(val, page) catch {}; } - if (y != 0) { - el.setScrollTop(y, page) catch {}; + if (y) |val| { + el.setScrollTop(val, page) catch {}; } const scroll_evt = try Event.initTrusted(comptime lp.String.wrap("scroll"), .{ .bubbles = true }, page); _ = page._event_manager.dispatch(el.asEventTarget(), scroll_evt) catch {}; } else { - page.window.scrollTo(.{ .x = x }, y, page) catch |err| { + page.window.scrollTo(.{ .x = x orelse 0 }, y, page) catch |err| { lp.log.err(.app, "scroll failed", .{ .err = err }); return error.ActionFailed; }; diff --git a/src/cdp/domains/lp.zig b/src/cdp/domains/lp.zig index ed6c6994..fedd71e6 100644 --- a/src/cdp/domains/lp.zig +++ b/src/cdp/domains/lp.zig @@ -207,9 +207,6 @@ fn scrollNode(cmd: anytype) !void { const bc = cmd.browser_context orelse return error.NoBrowserContext; const page = bc.session.currentPage() orelse return error.PageNotLoaded; - const x = params.x orelse 0; - const y = params.y orelse 0; - const input_node_id = params.nodeId orelse params.backendNodeId; var target_node: ?*DOMNode = null; @@ -218,7 +215,7 @@ fn scrollNode(cmd: anytype) !void { target_node = node.dom; } - lp.actions.scroll(target_node, x, y, page) catch |err| { + lp.actions.scroll(target_node, params.x, params.y, page) catch |err| { if (err == error.InvalidNodeType) return error.InvalidParam; return error.InternalError; }; diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index 6f256dca..d8fd4ead 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -494,9 +494,6 @@ fn handleScroll(server: *Server, arena: std.mem.Allocator, id: std.json.Value, a return server.sendError(id, .PageNotLoaded, "Page not loaded"); }; - const x = args.x orelse 0; - const y = args.y orelse 0; - var target_node: ?*DOMNode = null; if (args.backendNodeId) |node_id| { const node = server.node_registry.lookup_by_id.get(node_id) orelse { @@ -505,7 +502,7 @@ fn handleScroll(server: *Server, arena: std.mem.Allocator, id: std.json.Value, a target_node = node.dom; } - lp.actions.scroll(target_node, x, y, page) catch |err| { + lp.actions.scroll(target_node, args.x, args.y, page) catch |err| { if (err == error.InvalidNodeType) { return server.sendError(id, .InvalidParams, "Node is not an element"); } From c8265f4807c1d2eb6ffa18265314e2ac5ef98e9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 16 Mar 2026 23:41:22 +0900 Subject: [PATCH 23/85] browser.actions: improve error handling --- src/browser/actions.zig | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/browser/actions.zig b/src/browser/actions.zig index 36b96bc5..38cad01d 100644 --- a/src/browser/actions.zig +++ b/src/browser/actions.zig @@ -56,11 +56,15 @@ pub fn fill(node: *DOMNode, text: []const u8, page: *Page) !void { return error.InvalidNodeType; } - const input_evt = try Event.initTrusted(comptime lp.String.wrap("input"), .{ .bubbles = true }, page); - _ = page._event_manager.dispatch(el.asEventTarget(), input_evt) catch {}; + const input_evt: *Event = try .initTrusted(comptime lp.String.wrap("input"), .{ .bubbles = true }, page); + _ = page._event_manager.dispatch(el.asEventTarget(), input_evt) catch |err| { + lp.log.err(.app, "dispatch input event failed", .{ .err = err }); + }; - const change_evt = try Event.initTrusted(comptime lp.String.wrap("change"), .{ .bubbles = true }, page); - _ = page._event_manager.dispatch(el.asEventTarget(), change_evt) catch {}; + const change_evt: *Event = try .initTrusted(comptime lp.String.wrap("change"), .{ .bubbles = true }, page); + _ = page._event_manager.dispatch(el.asEventTarget(), change_evt) catch |err| { + lp.log.err(.app, "dispatch change event failed", .{ .err = err }); + }; } pub fn scroll(node: ?*DOMNode, x: ?i32, y: ?i32, page: *Page) !void { @@ -68,14 +72,22 @@ pub fn scroll(node: ?*DOMNode, x: ?i32, y: ?i32, page: *Page) !void { const el = n.is(Element) orelse return error.InvalidNodeType; if (x) |val| { - el.setScrollLeft(val, page) catch {}; + el.setScrollLeft(val, page) catch |err| { + lp.log.err(.app, "setScrollLeft failed", .{ .err = err }); + return error.ActionFailed; + }; } if (y) |val| { - el.setScrollTop(val, page) catch {}; + el.setScrollTop(val, page) catch |err| { + lp.log.err(.app, "setScrollTop failed", .{ .err = err }); + return error.ActionFailed; + }; } - const scroll_evt = try Event.initTrusted(comptime lp.String.wrap("scroll"), .{ .bubbles = true }, page); - _ = page._event_manager.dispatch(el.asEventTarget(), scroll_evt) catch {}; + const scroll_evt: *Event = try .initTrusted(comptime lp.String.wrap("scroll"), .{ .bubbles = true }, page); + _ = page._event_manager.dispatch(el.asEventTarget(), scroll_evt) catch |err| { + lp.log.err(.app, "dispatch scroll event failed", .{ .err = err }); + }; } else { page.window.scrollTo(.{ .x = x orelse 0 }, y, page) catch |err| { lp.log.err(.app, "scroll failed", .{ .err = err }); From 548c6eeb7af8c859fd326d7fb717fb0ca04fb369 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 16 Mar 2026 23:45:07 +0900 Subject: [PATCH 24/85] browser.actions: remove redundant result ignores --- src/browser/actions.zig | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/browser/actions.zig b/src/browser/actions.zig index 38cad01d..09052881 100644 --- a/src/browser/actions.zig +++ b/src/browser/actions.zig @@ -57,12 +57,12 @@ pub fn fill(node: *DOMNode, text: []const u8, page: *Page) !void { } const input_evt: *Event = try .initTrusted(comptime lp.String.wrap("input"), .{ .bubbles = true }, page); - _ = page._event_manager.dispatch(el.asEventTarget(), input_evt) catch |err| { + page._event_manager.dispatch(el.asEventTarget(), input_evt) catch |err| { lp.log.err(.app, "dispatch input event failed", .{ .err = err }); }; const change_evt: *Event = try .initTrusted(comptime lp.String.wrap("change"), .{ .bubbles = true }, page); - _ = page._event_manager.dispatch(el.asEventTarget(), change_evt) catch |err| { + page._event_manager.dispatch(el.asEventTarget(), change_evt) catch |err| { lp.log.err(.app, "dispatch change event failed", .{ .err = err }); }; } @@ -85,7 +85,7 @@ pub fn scroll(node: ?*DOMNode, x: ?i32, y: ?i32, page: *Page) !void { } const scroll_evt: *Event = try .initTrusted(comptime lp.String.wrap("scroll"), .{ .bubbles = true }, page); - _ = page._event_manager.dispatch(el.asEventTarget(), scroll_evt) catch |err| { + page._event_manager.dispatch(el.asEventTarget(), scroll_evt) catch |err| { lp.log.err(.app, "dispatch scroll event failed", .{ .err = err }); }; } else { From f508d37426f9329013199e7320dd938cc84ec1fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 16 Mar 2026 23:50:15 +0900 Subject: [PATCH 25/85] lp: validate params in node actions and rename variables --- src/cdp/domains/lp.zig | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/cdp/domains/lp.zig b/src/cdp/domains/lp.zig index fedd71e6..19fc8cac 100644 --- a/src/cdp/domains/lp.zig +++ b/src/cdp/domains/lp.zig @@ -157,13 +157,13 @@ fn clickNode(cmd: anytype) !void { nodeId: ?Node.Id = null, backendNodeId: ?Node.Id = null, }; - const params = (try cmd.params(Params)) orelse Params{}; + const params = (try cmd.params(Params)) orelse return error.InvalidParam; const bc = cmd.browser_context orelse return error.NoBrowserContext; const page = bc.session.currentPage() orelse return error.PageNotLoaded; - const input_node_id = params.nodeId orelse params.backendNodeId orelse return error.InvalidParam; - const node = bc.node_registry.lookup_by_id.get(input_node_id) orelse return error.InvalidNodeId; + const node_id = params.nodeId orelse params.backendNodeId orelse return error.InvalidParam; + const node = bc.node_registry.lookup_by_id.get(node_id) orelse return error.InvalidNodeId; lp.actions.click(node.dom, page) catch |err| { if (err == error.InvalidNodeType) return error.InvalidParam; @@ -184,8 +184,8 @@ fn fillNode(cmd: anytype) !void { const bc = cmd.browser_context orelse return error.NoBrowserContext; const page = bc.session.currentPage() orelse return error.PageNotLoaded; - const input_node_id = params.nodeId orelse params.backendNodeId orelse return error.InvalidParam; - const node = bc.node_registry.lookup_by_id.get(input_node_id) orelse return error.InvalidNodeId; + const node_id = params.nodeId orelse params.backendNodeId orelse return error.InvalidParam; + const node = bc.node_registry.lookup_by_id.get(node_id) orelse return error.InvalidNodeId; lp.actions.fill(node.dom, params.text, page) catch |err| { if (err == error.InvalidNodeType) return error.InvalidParam; @@ -202,15 +202,15 @@ fn scrollNode(cmd: anytype) !void { x: ?i32 = null, y: ?i32 = null, }; - const params = (try cmd.params(Params)) orelse Params{}; + const params = (try cmd.params(Params)) orelse return error.InvalidParam; const bc = cmd.browser_context orelse return error.NoBrowserContext; const page = bc.session.currentPage() orelse return error.PageNotLoaded; - const input_node_id = params.nodeId orelse params.backendNodeId; + const maybe_node_id = params.nodeId orelse params.backendNodeId; var target_node: ?*DOMNode = null; - if (input_node_id) |node_id| { + if (maybe_node_id) |node_id| { const node = bc.node_registry.lookup_by_id.get(node_id) orelse return error.InvalidNodeId; target_node = node.dom; } From 84190e1e064eb922a76707f7e862b2951d5f8af3 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 17 Mar 2026 07:07:16 +0800 Subject: [PATCH 26/85] fix test for new messages --- .../tests/document/query_selector.html | 3 +-- .../tests/document/query_selector_all.html | 3 +-- src/browser/tests/domexception.html | 2 +- src/browser/tests/element/attributes.html | 1 - src/browser/tests/element/matches.html | 3 +-- src/browser/tests/element/query_selector.html | 3 +-- .../tests/element/query_selector_all.html | 3 +-- .../tests/element/selector_invalid.html | 4 ++-- src/browser/tests/node/insert_before.html | 1 - src/browser/tests/node/remove_child.html | 1 - src/browser/tests/node/replace_child.html | 1 - src/browser/tests/range.html | 22 +++++++++---------- src/browser/tests/window/window.html | 2 +- 13 files changed, 20 insertions(+), 29 deletions(-) diff --git a/src/browser/tests/document/query_selector.html b/src/browser/tests/document/query_selector.html index b333069e..0837999e 100644 --- a/src/browser/tests/document/query_selector.html +++ b/src/browser/tests/document/query_selector.html @@ -24,11 +24,10 @@ diff --git a/src/browser/tests/domexception.html b/src/browser/tests/domexception.html index 1ed43e8d..05bdc837 100644 --- a/src/browser/tests/domexception.html +++ b/src/browser/tests/domexception.html @@ -127,7 +127,7 @@ testing.withError((err) => { testing.expectEqual(3, err.code); - testing.expectEqual('Hierarchy Error', err.message); + testing.expectEqual('HierarchyRequestError', err.name); testing.expectEqual(true, err instanceof DOMException); testing.expectEqual(true, err instanceof Error); }, () => link.appendChild(content)); diff --git a/src/browser/tests/element/attributes.html b/src/browser/tests/element/attributes.html index 9b8c29d3..0929a3d9 100644 --- a/src/browser/tests/element/attributes.html +++ b/src/browser/tests/element/attributes.html @@ -36,7 +36,6 @@ testing.withError((err) => { testing.expectEqual(8, err.code); testing.expectEqual("NotFoundError", err.name); - testing.expectEqual("Not Found", err.message); }, () => el1.removeAttributeNode(script_id_node)); testing.expectEqual(an1, el1.removeAttributeNode(an1)); diff --git a/src/browser/tests/element/matches.html b/src/browser/tests/element/matches.html index 5e1721b5..f28d7a71 100644 --- a/src/browser/tests/element/matches.html +++ b/src/browser/tests/element/matches.html @@ -66,11 +66,10 @@ { const container = $('#test-container'); - testing.expectError("SyntaxError: Syntax Error", () => container.matches('')); + testing.expectError("SyntaxError", () => container.matches('')); testing.withError((err) => { testing.expectEqual(12, err.code); testing.expectEqual("SyntaxError", err.name); - testing.expectEqual("Syntax Error", err.message); }, () => container.matches('')); } diff --git a/src/browser/tests/element/query_selector.html b/src/browser/tests/element/query_selector.html index 9564ca6d..203524b6 100644 --- a/src/browser/tests/element/query_selector.html +++ b/src/browser/tests/element/query_selector.html @@ -12,11 +12,10 @@ const p1 = $('#p1'); testing.expectEqual(null, p1.querySelector('#p1')); - testing.expectError("SyntaxError: Syntax Error", () => p1.querySelector('')); + testing.expectError("SyntaxError", () => p1.querySelector('')); testing.withError((err) => { testing.expectEqual(12, err.code); testing.expectEqual("SyntaxError", err.name); - testing.expectEqual("Syntax Error", err.message); }, () => p1.querySelector('')); testing.expectEqual($('#c2'), p1.querySelector('#c2')); diff --git a/src/browser/tests/element/query_selector_all.html b/src/browser/tests/element/query_selector_all.html index eeedc876..3b4013c2 100644 --- a/src/browser/tests/element/query_selector_all.html +++ b/src/browser/tests/element/query_selector_all.html @@ -24,11 +24,10 @@ diff --git a/src/browser/tests/element/selector_invalid.html b/src/browser/tests/element/selector_invalid.html index 35409c19..c0d16d59 100644 --- a/src/browser/tests/element/selector_invalid.html +++ b/src/browser/tests/element/selector_invalid.html @@ -43,8 +43,8 @@ const container = $('#container'); // Empty selectors - testing.expectError("SyntaxError: Syntax Error", () => container.querySelector('')); - testing.expectError("SyntaxError: Syntax Error", () => document.querySelectorAll('')); + testing.expectError("SyntaxError", () => container.querySelector('')); + testing.expectError("SyntaxError", () => document.querySelectorAll('')); } diff --git a/src/browser/tests/node/insert_before.html b/src/browser/tests/node/insert_before.html index 8be48e56..50dff07c 100644 --- a/src/browser/tests/node/insert_before.html +++ b/src/browser/tests/node/insert_before.html @@ -19,7 +19,6 @@ testing.withError((err) => { testing.expectEqual(8, err.code); testing.expectEqual("NotFoundError", err.name); - testing.expectEqual("Not Found", err.message); }, () => d1.insertBefore(document.createElement('div'), d2)); let c1 = document.createElement('div'); diff --git a/src/browser/tests/node/remove_child.html b/src/browser/tests/node/remove_child.html index fdf0b813..1118e4cf 100644 --- a/src/browser/tests/node/remove_child.html +++ b/src/browser/tests/node/remove_child.html @@ -7,7 +7,6 @@ testing.withError((err) => { testing.expectEqual(8, err.code); testing.expectEqual("NotFoundError", err.name); - testing.expectEqual("Not Found", err.message); }, () => $('#d1').removeChild($('#p1'))); const p1 = $('#p1'); diff --git a/src/browser/tests/node/replace_child.html b/src/browser/tests/node/replace_child.html index 45ed1bc5..51b0a173 100644 --- a/src/browser/tests/node/replace_child.html +++ b/src/browser/tests/node/replace_child.html @@ -25,7 +25,6 @@ testing.withError((err) => { testing.expectEqual(3, err.code); testing.expectEqual("HierarchyRequestError", err.name); - testing.expectEqual("Hierarchy Error", err.message); }, () => d1.replaceChild(c4, c3)); testing.expectEqual(c2, d1.replaceChild(c4, c2)); diff --git a/src/browser/tests/range.html b/src/browser/tests/range.html index d9a8637b..8440c187 100644 --- a/src/browser/tests/range.html +++ b/src/browser/tests/range.html @@ -451,12 +451,12 @@ const p1 = $('#p1'); // Test setStart with offset beyond node length - testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => { + testing.expectError('IndexSizeError:', () => { range.setStart(p1, 999); }); // Test with negative offset (wraps to large u32) - testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => { + testing.expectError('IndexSizeError:', () => { range.setStart(p1.firstChild, -1); }); } @@ -468,12 +468,12 @@ const p1 = $('#p1'); // Test setEnd with offset beyond node length - testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => { + testing.expectError('IndexSizeError:', () => { range.setEnd(p1, 999); }); // Test with text node - testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => { + testing.expectError('IndexSizeError:', () => { range.setEnd(p1.firstChild, 9999); }); } @@ -525,11 +525,11 @@ range.setEnd(p1, 1); // Test comparePoint with invalid offset - testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => { + testing.expectError('IndexSizeError:', () => { range.comparePoint(p1, 20); }); - testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => { + testing.expectError('IndexSizeError:', () => { range.comparePoint(p1.firstChild, -1); }); } @@ -650,11 +650,11 @@ range.setEnd(p1, 1); // Invalid offset should throw IndexSizeError - testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => { + testing.expectError('IndexSizeError:', () => { range.isPointInRange(p1, 999); }); - testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => { + testing.expectError('IndexSizeError:', () => { range.isPointInRange(p1.firstChild, 9999); }); } @@ -854,11 +854,11 @@ range2.setStart(p, 0); // Invalid how parameter should throw NotSupportedError - testing.expectError('NotSupportedError: Not Supported', () => { + testing.expectError('NotSupportedError:', () => { range1.compareBoundaryPoints(4, range2); }); - testing.expectError('NotSupportedError: Not Supported', () => { + testing.expectError('NotSupportedError:', () => { range1.compareBoundaryPoints(99, range2); }); } @@ -883,7 +883,7 @@ range2.setEnd(foreignP, 1); // Comparing ranges in different documents should throw WrongDocumentError - testing.expectError('WrongDocumentError: wrong_document_error', () => { + testing.expectError('WrongDocumentError:', () => { range1.compareBoundaryPoints(Range.START_TO_START, range2); }); } diff --git a/src/browser/tests/window/window.html b/src/browser/tests/window/window.html index 01025b86..e4094f9b 100644 --- a/src/browser/tests/window/window.html +++ b/src/browser/tests/window/window.html @@ -82,7 +82,7 @@ testing.expectEqual('ceil', atob('Y2VpbA')); // 6 chars, len%4==2, needs '==' // length % 4 == 1 must still throw - testing.expectError('InvalidCharacterError: Invalid Character', () => { + testing.expectError('InvalidCharacterError', () => { atob('Y'); }); From 96e5054ffce6b89c9b1e02546f2cab682b332302 Mon Sep 17 00:00:00 2001 From: JasonOA888 <101583541+JasonOA888@users.noreply.github.com> Date: Tue, 17 Mar 2026 07:41:11 +0800 Subject: [PATCH 27/85] feat: add git_version build option for release version detection - Add git_version option to build.zig (similar to git_commit) - Update version command to output git_version when available - Falls back to git_commit when not on a tagged release - CI can pass -Dgit_version=$(git describe --tags --exact-match) for releases Fixes #1867 --- build.zig | 2 ++ src/main.zig | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/build.zig b/build.zig index 1f9ae5ef..33168b86 100644 --- a/build.zig +++ b/build.zig @@ -27,12 +27,14 @@ pub fn build(b: *Build) !void { const manifest = Manifest.init(b); const git_commit = b.option([]const u8, "git_commit", "Current git commit"); + const git_version = b.option([]const u8, "git_version", "Current git version (from tag)"); const prebuilt_v8_path = b.option([]const u8, "prebuilt_v8_path", "Path to prebuilt libc_v8.a"); const snapshot_path = b.option([]const u8, "snapshot_path", "Path to v8 snapshot"); var opts = b.addOptions(); opts.addOption([]const u8, "version", manifest.version); opts.addOption([]const u8, "git_commit", git_commit orelse "dev"); + opts.addOption([]const u8, "git_version", git_version orelse "dev"); opts.addOption(?[]const u8, "snapshot_path", snapshot_path); const enable_tsan = b.option(bool, "tsan", "Enable Thread Sanitizer") orelse false; diff --git a/src/main.zig b/src/main.zig index 26e29b22..948347b8 100644 --- a/src/main.zig +++ b/src/main.zig @@ -59,7 +59,11 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { return std.process.cleanExit(); }, .version => { - std.debug.print("{s}\n", .{lp.build_config.git_commit}); + const version = if (std.mem.eql(u8, lp.build_config.git_version, "dev")) + lp.build_config.git_commit + else + lp.build_config.git_version; + std.debug.print("{s}\n", .{version}); return std.process.cleanExit(); }, else => {}, From deb08b788004805f40374480e5f452f372171732 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 17 Mar 2026 08:15:49 +0800 Subject: [PATCH 28/85] Try to improve stability of history test Tests cannot navigate away from the page page. If they do, the testRunner will crash, as it tries to access `assertOk` on a page that no longer exists. This commit hacks the history test, using an iframe, to try to test the history API without navigating off the main page. --- src/browser/tests/history.html | 42 +++++++------------------- src/browser/tests/support/history.html | 33 ++++++++++++++++++++ src/browser/tests/testing.js | 5 ++- 3 files changed, 46 insertions(+), 34 deletions(-) create mode 100644 src/browser/tests/support/history.html diff --git a/src/browser/tests/history.html b/src/browser/tests/history.html index 1508e232..e2aa0d35 100644 --- a/src/browser/tests/history.html +++ b/src/browser/tests/history.html @@ -2,37 +2,17 @@ + diff --git a/src/browser/tests/support/history.html b/src/browser/tests/support/history.html new file mode 100644 index 00000000..d3356de3 --- /dev/null +++ b/src/browser/tests/support/history.html @@ -0,0 +1,33 @@ + + + + diff --git a/src/browser/tests/testing.js b/src/browser/tests/testing.js index 01bb19db..2e33c1d3 100644 --- a/src/browser/tests/testing.js +++ b/src/browser/tests/testing.js @@ -99,8 +99,7 @@ } } - // our test runner sets this to true - const IS_TEST_RUNNER = window._lightpanda_skip_auto_assert === true; + const IS_TEST_RUNNER = window.navigator.userAgent.startsWith("Lightpanda/"); window.testing = { fail: fail, @@ -118,7 +117,7 @@ BASE_URL: 'http://127.0.0.1:9582/src/browser/tests/', }; - if (window.navigator.userAgent.startsWith("Lightpanda/") == false) { + if (IS_TEST_RUNNER === false) { // The page is running in a different browser. Probably a developer making sure // a test is correct. There are a few tweaks we need to do to make this a // seemless, namely around adapting paths/urls. From 5d2801c6521a034425b0b653ae03db7f4b5102f4 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 17 Mar 2026 10:31:32 +0800 Subject: [PATCH 29/85] Support blob urls in XHR and Fetch Used quite a bit in WPT. Not sure how common this is in real world though. --- src/browser/Page.zig | 20 ++++++------ src/browser/tests/net/fetch.html | 22 +++++++++++++ src/browser/tests/net/xhr.html | 23 +++++++++++++ src/browser/webapi/net/Fetch.zig | 29 +++++++++++++++-- src/browser/webapi/net/XMLHttpRequest.zig | 39 +++++++++++++++++++++++ 5 files changed, 121 insertions(+), 12 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index b82ca41d..b973aaa2 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -407,16 +407,9 @@ pub fn isSameOrigin(self: *const Page, url: [:0]const u8) !bool { return std.mem.startsWith(u8, url, current_origin); } -/// Look up a blob URL in this page's registry, walking up the parent chain. +/// Look up a blob URL in this page's registry. pub fn lookupBlobUrl(self: *Page, url: []const u8) ?*Blob { - var current: ?*Page = self; - while (current) |page| { - if (page._blob_urls.get(url)) |blob| { - return blob; - } - current = page.parent; - } - return null; + return self._blob_urls.get(url); } pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !void { @@ -457,7 +450,14 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi // Content injection if (is_blob) { - const blob = self.lookupBlobUrl(request_url) orelse { + // For navigation, walk up the parent chain to find blob URLs + // (e.g., parent creates blob URL and sets iframe.src to it) + const blob = blk: { + var current: ?*Page = self.parent; + while (current) |page| { + if (page._blob_urls.get(request_url)) |b| break :blk b; + current = page.parent; + } log.warn(.js, "invalid blob", .{ .url = request_url }); return error.BlobNotFound; }; diff --git a/src/browser/tests/net/fetch.html b/src/browser/tests/net/fetch.html index a545a452..10ce6677 100644 --- a/src/browser/tests/net/fetch.html +++ b/src/browser/tests/net/fetch.html @@ -203,3 +203,25 @@ testing.expectEqual(true, response.body !== null); }); + + diff --git a/src/browser/tests/net/xhr.html b/src/browser/tests/net/xhr.html index 64fac5c3..a8683142 100644 --- a/src/browser/tests/net/xhr.html +++ b/src/browser/tests/net/xhr.html @@ -283,3 +283,26 @@ testing.expectEqual(XMLHttpRequest.UNSENT, req.readyState); }); + + diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index ab98a8e5..7ab7b8ec 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -25,6 +25,7 @@ const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const URL = @import("../../URL.zig"); +const Blob = @import("../Blob.zig"); const Request = @import("Request.zig"); const Response = @import("Response.zig"); @@ -44,11 +45,15 @@ pub const InitOpts = Request.InitOpts; pub fn init(input: Input, options: ?InitOpts, page: *Page) !js.Promise { const request = try Request.init(input, options, page); + const resolver = page.js.local.?.createPromiseResolver(); + + if (std.mem.startsWith(u8, request._url, "blob:")) { + return handleBlobUrl(request._url, resolver, page); + } + const response = try Response.init(null, .{ .status = 0 }, page); errdefer response.deinit(true, page._session); - const resolver = page.js.local.?.createPromiseResolver(); - const fetch = try response._arena.create(Fetch); fetch.* = .{ ._page = page, @@ -90,6 +95,26 @@ pub fn init(input: Input, options: ?InitOpts, page: *Page) !js.Promise { return resolver.promise(); } +fn handleBlobUrl(url: []const u8, resolver: js.PromiseResolver, page: *Page) !js.Promise { + const blob: *Blob = page.lookupBlobUrl(url) orelse { + resolver.rejectError("fetch blob error", .{ .type_error = "BlobNotFound" }); + return resolver.promise(); + }; + + const response = try Response.init(null, .{ .status = 200 }, page); + response._body = try response._arena.dupe(u8, blob._slice); + response._url = try response._arena.dupeZ(u8, url); + response._type = .basic; + + if (blob._mime.len > 0) { + try response._headers.append("Content-Type", blob._mime, page); + } + + const js_val = try page.js.local.?.zigValueToJs(response, .{}); + resolver.resolve("fetch blob done", js_val); + return resolver.promise(); +} + fn httpStartCallback(transfer: *HttpClient.Transfer) !void { const self: *Fetch = @ptrCast(@alignCast(transfer.ctx)); if (comptime IS_DEBUG) { diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index d8d5e369..399e4217 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -29,6 +29,7 @@ const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); const Node = @import("../Node.zig"); +const Blob = @import("../Blob.zig"); const Event = @import("../Event.zig"); const Headers = @import("Headers.zig"); const EventTarget = @import("../EventTarget.zig"); @@ -211,6 +212,11 @@ pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void { } const page = self._page; + + if (std.mem.startsWith(u8, self._url, "blob:")) { + return self.handleBlobUrl(page); + } + const http_client = page._session.browser.http_client; var headers = try http_client.newHeaders(); @@ -242,6 +248,39 @@ pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void { page.js.strongRef(self); } + +fn handleBlobUrl(self: *XMLHttpRequest, page: *Page) !void { + const blob = page.lookupBlobUrl(self._url) orelse { + self.handleError(error.BlobNotFound); + return; + }; + + self._response_status = 200; + self._response_url = self._url; + + try self._response_data.appendSlice(self._arena, blob._slice); + self._response_len = blob._slice.len; + + try self.stateChanged(.headers_received, page); + try self._proto.dispatch(.load_start, .{ .loaded = 0, .total = self._response_len orelse 0 }, page); + try self.stateChanged(.loading, page); + try self._proto.dispatch(.progress, .{ + .total = self._response_len orelse 0, + .loaded = self._response_data.items.len, + }, page); + try self.stateChanged(.done, page); + + const loaded = self._response_data.items.len; + try self._proto.dispatch(.load, .{ + .total = loaded, + .loaded = loaded, + }, page); + try self._proto.dispatch(.load_end, .{ + .total = loaded, + .loaded = loaded, + }, page); +} + pub fn getReadyState(self: *const XMLHttpRequest) u32 { return @intFromEnum(self._ready_state); } From 96f24a2662a1373f1074d252e5bc8b5bf19f5d9c Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:42:45 -0700 Subject: [PATCH 30/85] Implement window.event property Add the deprecated-but-widely-used window.event property that returns the Event currently being handled. Returns undefined when no event is being dispatched. Implementation saves and restores window._current_event around handler invocation in both dispatchDirect and dispatchNode, supporting nested event dispatch correctly. Fixes #1770 Co-Authored-By: Claude Opus 4.6 --- src/browser/EventManager.zig | 13 +++++++++ src/browser/tests/window/window_event.html | 34 ++++++++++++++++++++++ src/browser/webapi/Window.zig | 6 ++++ 3 files changed, 53 insertions(+) create mode 100644 src/browser/tests/window/window_event.html diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index 5588b704..247a298e 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -233,6 +233,12 @@ const DispatchDirectOptions = struct { pub fn dispatchDirect(self: *EventManager, target: *EventTarget, event: *Event, handler: anytype, comptime opts: DispatchDirectOptions) !void { const page = self.page; + // Set window.event to the currently dispatching event (WHATWG spec) + const window = page.window; + const prev_event = window._current_event; + window._current_event = event; + defer window._current_event = prev_event; + event.acquireRef(); defer event.deinit(false, page._session); @@ -398,6 +404,13 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts } const page = self.page; + + // Set window.event to the currently dispatching event (WHATWG spec) + const window = page.window; + const prev_event = window._current_event; + window._current_event = event; + defer window._current_event = prev_event; + var was_handled = false; // Create a single scope for all event handlers in this dispatch. diff --git a/src/browser/tests/window/window_event.html b/src/browser/tests/window/window_event.html new file mode 100644 index 00000000..971651f8 --- /dev/null +++ b/src/browser/tests/window/window_event.html @@ -0,0 +1,34 @@ + + + + + + + + diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 099cad65..a3c64f65 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -68,6 +68,7 @@ _on_popstate: ?js.Function.Global = null, _on_error: ?js.Function.Global = null, _on_message: ?js.Function.Global = null, _on_unhandled_rejection: ?js.Function.Global = null, // TODO: invoke on error +_current_event: ?*Event = null, _location: *Location, _timer_id: u30 = 0, _timers: std.AutoHashMapUnmanaged(u32, *ScheduleCallback) = .{}, @@ -90,6 +91,10 @@ pub fn asEventTarget(self: *Window) *EventTarget { return self._proto; } +pub fn getEvent(self: *const Window) ?*Event { + return self._current_event; +} + pub fn getSelf(self: *Window) *Window { return self; } @@ -805,6 +810,7 @@ pub const JsApi = struct { pub const onerror = bridge.accessor(Window.getOnError, Window.setOnError, .{}); pub const onmessage = bridge.accessor(Window.getOnMessage, Window.setOnMessage, .{}); pub const onunhandledrejection = bridge.accessor(Window.getOnUnhandledRejection, Window.setOnUnhandledRejection, .{}); + pub const event = bridge.accessor(Window.getEvent, null, .{ .null_as_undefined = true }); pub const fetch = bridge.function(Window.fetch, .{}); pub const queueMicrotask = bridge.function(Window.queueMicrotask, .{}); pub const setTimeout = bridge.function(Window.setTimeout, .{}); From 44a83c0e1c759527defa37cd62efdfe546665160 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= <1671644+arrufat@users.noreply.github.com> Date: Tue, 17 Mar 2026 12:55:10 +0900 Subject: [PATCH 31/85] browser.actions: use .wrap directly Co-authored-by: Karl Seguin --- src/browser/actions.zig | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/browser/actions.zig b/src/browser/actions.zig index 09052881..9b4025e8 100644 --- a/src/browser/actions.zig +++ b/src/browser/actions.zig @@ -56,12 +56,12 @@ pub fn fill(node: *DOMNode, text: []const u8, page: *Page) !void { return error.InvalidNodeType; } - const input_evt: *Event = try .initTrusted(comptime lp.String.wrap("input"), .{ .bubbles = true }, page); + const input_evt: *Event = try .initTrusted(comptime .wrap("input"), .{ .bubbles = true }, page); page._event_manager.dispatch(el.asEventTarget(), input_evt) catch |err| { lp.log.err(.app, "dispatch input event failed", .{ .err = err }); }; - const change_evt: *Event = try .initTrusted(comptime lp.String.wrap("change"), .{ .bubbles = true }, page); + const change_evt: *Event = try .initTrusted(comptime .wrap("change"), .{ .bubbles = true }, page); page._event_manager.dispatch(el.asEventTarget(), change_evt) catch |err| { lp.log.err(.app, "dispatch change event failed", .{ .err = err }); }; @@ -84,7 +84,7 @@ pub fn scroll(node: ?*DOMNode, x: ?i32, y: ?i32, page: *Page) !void { }; } - const scroll_evt: *Event = try .initTrusted(comptime lp.String.wrap("scroll"), .{ .bubbles = true }, page); + const scroll_evt: *Event = try .initTrusted(comptime .wrap("scroll"), .{ .bubbles = true }, page); page._event_manager.dispatch(el.asEventTarget(), scroll_evt) catch |err| { lp.log.err(.app, "dispatch scroll event failed", .{ .err = err }); }; From 463aac9b59d643af297fa8e2bdba51850070f9af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Tue, 17 Mar 2026 13:22:55 +0900 Subject: [PATCH 32/85] browser.actions: refactor click to use trusted MouseEvent --- src/browser/Page.zig | 7 ++++--- src/browser/actions.zig | 23 +++++++++++++++-------- src/browser/webapi/event/MouseEvent.zig | 13 +++++++++++-- 3 files changed, 30 insertions(+), 13 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index cb62cb31..0fa5bba3 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -62,6 +62,7 @@ const storage = @import("webapi/storage/storage.zig"); const PageTransitionEvent = @import("webapi/event/PageTransitionEvent.zig"); const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind; const KeyboardEvent = @import("webapi/event/KeyboardEvent.zig"); +const MouseEvent = @import("webapi/event/MouseEvent.zig"); const HttpClient = @import("HttpClient.zig"); const ArenaPool = App.ArenaPool; @@ -3255,14 +3256,14 @@ pub fn triggerMouseClick(self: *Page, x: f64, y: f64) !void { .type = self._type, }); } - const event = (try @import("webapi/event/MouseEvent.zig").init("click", .{ + const mouse_event: *MouseEvent = try .initTrusted(comptime .wrap("click"), .{ .bubbles = true, .cancelable = true, .composed = true, .clientX = x, .clientY = y, - }, self)).asEvent(); - try self._event_manager.dispatch(target.asEventTarget(), event); + }, self); + try self._event_manager.dispatch(target.asEventTarget(), mouse_event.asEvent()); } // callback when the "click" event reaches the pages. diff --git a/src/browser/actions.zig b/src/browser/actions.zig index 9b4025e8..951f2b1e 100644 --- a/src/browser/actions.zig +++ b/src/browser/actions.zig @@ -21,17 +21,24 @@ const lp = @import("../lightpanda.zig"); const DOMNode = @import("webapi/Node.zig"); const Element = @import("webapi/Element.zig"); const Event = @import("webapi/Event.zig"); +const MouseEvent = @import("webapi/event/MouseEvent.zig"); const Page = @import("Page.zig"); pub fn click(node: *DOMNode, page: *Page) !void { - if (node.is(Element.Html)) |html_el| { - html_el.click(page) catch |err| { - lp.log.err(.app, "click failed", .{ .err = err }); - return error.ActionFailed; - }; - } else { - return error.InvalidNodeType; - } + const el = node.is(Element) orelse return error.InvalidNodeType; + + const mouse_event: *MouseEvent = try .initTrusted(comptime .wrap("click"), .{ + .bubbles = true, + .cancelable = true, + .composed = true, + .clientX = 0, + .clientY = 0, + }, page); + + page._event_manager.dispatch(el.asEventTarget(), mouse_event.asEvent()) catch |err| { + lp.log.err(.app, "click failed", .{ .err = err }); + return error.ActionFailed; + }; } pub fn fill(node: *DOMNode, text: []const u8, page: *Page) !void { diff --git a/src/browser/webapi/event/MouseEvent.zig b/src/browser/webapi/event/MouseEvent.zig index 6b032433..cae21509 100644 --- a/src/browser/webapi/event/MouseEvent.zig +++ b/src/browser/webapi/event/MouseEvent.zig @@ -83,12 +83,21 @@ pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*MouseEvent { const arena = try page.getArena(.{ .debug = "MouseEvent" }); errdefer page.releaseArena(arena); const type_string = try String.init(arena, typ, .{}); + return initWithTrusted(arena, type_string, _opts, false, page); +} +pub fn initTrusted(typ: String, _opts: ?Options, page: *Page) !*MouseEvent { + const arena = try page.getArena(.{ .debug = "MouseEvent.trusted" }); + errdefer page.releaseArena(arena); + return initWithTrusted(arena, typ, _opts, true, page); +} + +fn initWithTrusted(arena: std.mem.Allocator, typ: String, _opts: ?Options, trusted: bool, page: *Page) !*MouseEvent { const opts = _opts orelse Options{}; const event = try page._factory.uiEvent( arena, - type_string, + typ, MouseEvent{ ._type = .generic, ._proto = undefined, @@ -106,7 +115,7 @@ pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*MouseEvent { }, ); - Event.populatePrototypes(event, opts, false); + Event.populatePrototypes(event, opts, trusted); return event; } From e048b0372fed4f3907eb19dbbed564e028b12ce4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Tue, 17 Mar 2026 13:32:29 +0900 Subject: [PATCH 33/85] ScriptManager: fix memory leak and resource handover Release the arena when an inline script is empty and ensure the handover flag is set correctly for all script execution modes. --- src/browser/ScriptManager.zig | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index a37493eb..feba8789 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -214,6 +214,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e if (inline_source.len == 0) { // we haven't set script_element._executed = true yet, which is good. // If content is appended to the script, we will execute it then. + page.releaseArena(arena); return; } source = .{ .@"inline" = inline_source }; @@ -260,6 +261,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e break :blk .normal; }, }; + handover = true; const is_blocking = script.mode == .normal; if (is_blocking == false) { @@ -290,7 +292,6 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e .done_callback = Script.doneCallback, .error_callback = Script.errorCallback, }); - handover = true; if (comptime IS_DEBUG) { var ls: js.Local.Scope = undefined; From b698e2d078584b23cc969e376bd9b3b34a439d0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Tue, 17 Mar 2026 13:42:35 +0900 Subject: [PATCH 34/85] LogFilter: init with slice and silence tests --- src/browser/Page.zig | 5 ++++- src/cdp/domains/page.zig | 2 +- src/mcp/router.zig | 2 +- src/testing.zig | 8 ++++---- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index b82ca41d..a6757763 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -3522,13 +3522,16 @@ fn asUint(comptime string: anytype) std.meta.Int( const testing = @import("../testing.zig"); test "WebApi: Page" { - const filter: testing.LogFilter = .init(.http); + const filter: testing.LogFilter = .init(&.{ .http, .js }); defer filter.deinit(); try testing.htmlRunner("page", .{}); } test "WebApi: Frames" { + const filter: testing.LogFilter = .init(&.{.js}); + defer filter.deinit(); + try testing.htmlRunner("frames", .{}); } diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index fe21fcca..f40d54a8 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -663,7 +663,7 @@ test "cdp.page: getFrameTree" { test "cdp.page: captureScreenshot" { const LogFilter = @import("../../testing.zig").LogFilter; - const filter: LogFilter = .init(.not_implemented); + const filter: LogFilter = .init(&.{.not_implemented}); defer filter.deinit(); var ctx = testing.context(); diff --git a/src/mcp/router.zig b/src/mcp/router.zig index 70411814..1bc02624 100644 --- a/src/mcp/router.zig +++ b/src/mcp/router.zig @@ -148,7 +148,7 @@ test "MCP.router - handleMessage - synchronous unit tests" { // 5. Parse error { - const filter: testing.LogFilter = .init(.mcp); + const filter: testing.LogFilter = .init(&.{.mcp}); defer filter.deinit(); try handleMessage(server, aa, "invalid json"); diff --git a/src/testing.zig b/src/testing.zig index f30dbae5..adebdc32 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -616,12 +616,12 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void { pub const LogFilter = struct { old_filter: []const log.Scope, - /// Sets the log filter to only include the specified scope. + /// Sets the log filter to suppress the specified scope(s). /// Returns a LogFilter that should be deinitialized to restore previous filters. - pub fn init(comptime scope: log.Scope) LogFilter { + pub fn init(comptime scopes: []const log.Scope) LogFilter { + comptime std.debug.assert(@TypeOf(scopes) == []const log.Scope); const old_filter = log.opts.filter_scopes; - const new_filter = comptime &[_]log.Scope{scope}; - log.opts.filter_scopes = new_filter; + log.opts.filter_scopes = scopes; return .{ .old_filter = old_filter }; } From 46df3415064300e81b6350ada9d676703b4d2039 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Tue, 17 Mar 2026 15:42:52 +0900 Subject: [PATCH 35/85] ScriptManager: defer resource handover until request success --- src/browser/ScriptManager.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index feba8789..751ba58b 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -261,7 +261,6 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e break :blk .normal; }, }; - handover = true; const is_blocking = script.mode == .normal; if (is_blocking == false) { @@ -273,7 +272,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e if (is_blocking == false) { self.scriptList(script).remove(&script.node); } - script.deinit(); + // Let the outer errdefer handle releasing the arena if client.request fails } try self.client.request(.{ @@ -292,6 +291,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e .done_callback = Script.doneCallback, .error_callback = Script.errorCallback, }); + handover = true; if (comptime IS_DEBUG) { var ls: js.Local.Scope = undefined; From cc4ac99b4a69f2e2fa5372f72999cac27d1a4274 Mon Sep 17 00:00:00 2001 From: Tenith01 Date: Tue, 17 Mar 2026 13:02:55 +0530 Subject: [PATCH 36/85] fix: show actionable error when server port is already in use --- src/main.zig | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main.zig b/src/main.zig index 26e29b22..0196bb7f 100644 --- a/src/main.zig +++ b/src/main.zig @@ -94,7 +94,16 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { }; var server = lp.Server.init(app, address) catch |err| { - log.fatal(.app, "server run error", .{ .err = err }); + if (err == error.AddressInUse) { + log.fatal(.app, "address already in use", .{ + .host = opts.host, + .port = opts.port, + .hint = "Another process is already listening on this address. " ++ + "Stop the other process or use --port to choose a different port.", + }); + } else { + log.fatal(.app, "server run error", .{ .err = err }); + } return err; }; defer server.deinit(); From ce002b999c9daeb83b631080632321cb6fe77c86 Mon Sep 17 00:00:00 2001 From: Tenith01 Date: Tue, 17 Mar 2026 13:49:59 +0530 Subject: [PATCH 37/85] fix: special-case Window#onerror per WHATWG spec (5-arg signature) --- src/browser/tests/event/report_error.html | 38 +++++++++++++++++++++++ src/browser/webapi/Window.zig | 10 +++++- 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 src/browser/tests/event/report_error.html diff --git a/src/browser/tests/event/report_error.html b/src/browser/tests/event/report_error.html new file mode 100644 index 00000000..353dc29e --- /dev/null +++ b/src/browser/tests/event/report_error.html @@ -0,0 +1,38 @@ + + + + + + + + diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 099cad65..1d12677c 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -343,7 +343,11 @@ pub fn reportError(self: *Window, err: js.Value, page: *Page) !void { const event = error_event.asEvent(); event._prevent_default = prevent_default; - try page._event_manager.dispatch(self.asEventTarget(), event); + // Pass null as handler: onerror was already called above with 5 args. + // We still dispatch so that addEventListener('error', ...) listeners fire. + try page._event_manager.dispatchDirect(self.asEventTarget(), event, null, .{ + .context = "window.reportError", + }); if (comptime builtin.is_test == false) { if (!event._prevent_default) { @@ -874,3 +878,7 @@ test "WebApi: Window" { test "WebApi: Window scroll" { try testing.htmlRunner("window_scroll.html", .{}); } + +test "WebApi: Window.onerror" { + try testing.htmlRunner("event/report_error.html", .{}); +} From d2003c7c9ada697296e429afc502efd7cdb4c975 Mon Sep 17 00:00:00 2001 From: Tenith01 Date: Tue, 17 Mar 2026 14:12:13 +0530 Subject: [PATCH 38/85] fix: stub navigator.permissions, storage, deviceMemory to unblock Turnstile --- src/browser/js/bridge.zig | 3 + src/browser/tests/navigator/permissions.html | 37 ++++++++++++ src/browser/webapi/Navigator.zig | 22 +++++++ src/browser/webapi/Permissions.zig | 61 ++++++++++++++++++++ src/browser/webapi/StorageManager.zig | 56 ++++++++++++++++++ 5 files changed, 179 insertions(+) create mode 100644 src/browser/tests/navigator/permissions.html create mode 100644 src/browser/webapi/Permissions.zig create mode 100644 src/browser/webapi/StorageManager.zig diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index cf6e999e..1b035837 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -725,6 +725,9 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/collections.zig"), @import("../webapi/Console.zig"), @import("../webapi/Crypto.zig"), + @import("../webapi/Permissions.zig"), + @import("../webapi/Permissions.zig").PermissionStatus, + @import("../webapi/StorageManager.zig"), @import("../webapi/CSS.zig"), @import("../webapi/css/CSSRule.zig"), @import("../webapi/css/CSSRuleList.zig"), diff --git a/src/browser/tests/navigator/permissions.html b/src/browser/tests/navigator/permissions.html new file mode 100644 index 00000000..befecf8b --- /dev/null +++ b/src/browser/tests/navigator/permissions.html @@ -0,0 +1,37 @@ + + + + + + + + + + diff --git a/src/browser/webapi/Navigator.zig b/src/browser/webapi/Navigator.zig index 5e69db07..36d32f1e 100644 --- a/src/browser/webapi/Navigator.zig +++ b/src/browser/webapi/Navigator.zig @@ -21,10 +21,14 @@ const builtin = @import("builtin"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const PluginArray = @import("PluginArray.zig"); +const Permissions = @import("Permissions.zig"); +const StorageManager = @import("StorageManager.zig"); const Navigator = @This(); _pad: bool = false, _plugins: PluginArray = .{}, +_permissions: Permissions = Permissions.init, +_storage: StorageManager = StorageManager.init, pub const init: Navigator = .{}; @@ -55,6 +59,20 @@ pub fn getPlugins(self: *Navigator) *PluginArray { return &self._plugins; } +pub fn getPermissions(self: *Navigator) *Permissions { + return &self._permissions; +} + +pub fn getStorage(self: *Navigator) *StorageManager { + return &self._storage; +} + +pub fn getBattery(_: *const Navigator, page: *Page) !js.Promise { + // Battery API is not supported in headless mode. + // Return a rejected Promise — callers must .catch() this. + return js.Promise.reject(error.NotSupportedError, page); +} + pub fn registerProtocolHandler(_: *const Navigator, scheme: []const u8, url: [:0]const u8, page: *const Page) !void { try validateProtocolHandlerScheme(scheme); try validateProtocolHandlerURL(url, page); @@ -144,6 +162,7 @@ pub const JsApi = struct { pub const onLine = bridge.property(true, .{ .template = false }); pub const cookieEnabled = bridge.property(true, .{ .template = false }); pub const hardwareConcurrency = bridge.property(4, .{ .template = false }); + pub const deviceMemory = bridge.property(@as(f64, 8.0), .{ .template = false }); pub const maxTouchPoints = bridge.property(0, .{ .template = false }); pub const vendor = bridge.property("", .{ .template = false }); pub const product = bridge.property("Gecko", .{ .template = false }); @@ -156,4 +175,7 @@ pub const JsApi = struct { // Methods pub const javaEnabled = bridge.function(Navigator.javaEnabled, .{}); + pub const getBattery = bridge.function(Navigator.getBattery, .{}); + pub const permissions = bridge.accessor(Navigator.getPermissions, null, .{}); + pub const storage = bridge.accessor(Navigator.getStorage, null, .{}); }; diff --git a/src/browser/webapi/Permissions.zig b/src/browser/webapi/Permissions.zig new file mode 100644 index 00000000..5f5954b3 --- /dev/null +++ b/src/browser/webapi/Permissions.zig @@ -0,0 +1,61 @@ +// src/browser/webapi/Permissions.zig +// +// Minimal Permissions API stub. +// https://www.w3.org/TR/permissions/ +// +// Turnstile probes: navigator.permissions.query({ name: 'notifications' }) +// It expects a Promise resolving to { state: 'granted' | 'denied' | 'prompt' } + +const std = @import("std"); +const js = @import("../js/js.zig"); +const Page = @import("../Page.zig"); + +const Permissions = @This(); + +// Padding to avoid zero-size struct pointer collisions +_pad: bool = false, + +pub const init: Permissions = .{}; + +const QueryDescriptor = struct { + name: []const u8, +}; + +const PermissionStatus = struct { + state: []const u8, + + pub const JsApi = struct { + pub const bridge = js.Bridge(PermissionStatus); + pub const Meta = struct { + pub const name = "PermissionStatus"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + pub const empty_with_no_proto = true; + }; + pub const state = bridge.accessor(getState, null, .{}); + }; + + fn getState(self: *const PermissionStatus) []const u8 { + return self.state; + } +}; + +// query() returns a Promise. +// We always report 'prompt' (the default safe value — neither granted nor denied). +pub fn query(_: *const Permissions, _: QueryDescriptor, page: *Page) !js.Promise { + const status = try page._factory.create(PermissionStatus{ .state = "prompt" }); + return js.Promise.resolve(status, page); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(Permissions); + + pub const Meta = struct { + pub const name = "Permissions"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + pub const empty_with_no_proto = true; + }; + + pub const query = bridge.function(Permissions.query, .{ .dom_exception = true }); +}; diff --git a/src/browser/webapi/StorageManager.zig b/src/browser/webapi/StorageManager.zig new file mode 100644 index 00000000..280614a0 --- /dev/null +++ b/src/browser/webapi/StorageManager.zig @@ -0,0 +1,56 @@ +// src/browser/webapi/StorageManager.zig +// Minimal stub for navigator.storage +// https://storage.spec.whatwg.org/#storagemanager + +const js = @import("../js/js.zig"); +const Page = @import("../Page.zig"); + +const StorageManager = @This(); +_pad: bool = false, + +pub const init: StorageManager = .{}; + +const StorageEstimate = struct { + quota: u64, + usage: u64, + + pub const JsApi = struct { + pub const bridge = js.Bridge(StorageEstimate); + pub const Meta = struct { + pub const name = "StorageEstimate"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + pub const empty_with_no_proto = true; + }; + pub const quota = bridge.accessor(getQuota, null, .{}); + pub const usage = bridge.accessor(getUsage, null, .{}); + }; + + fn getQuota(self: *const StorageEstimate) u64 { + return self.quota; + } + fn getUsage(self: *const StorageEstimate) u64 { + return self.usage; + } +}; + +// Returns a resolved Promise with plausible stub values. +// quota = 1GB, usage = 0 (headless browser has no real storage) +pub fn estimate(_: *const StorageManager, page: *Page) !js.Promise { + const est = try page._factory.create(StorageEstimate{ + .quota = 1024 * 1024 * 1024, // 1 GiB + .usage = 0, + }); + return js.Promise.resolve(est, page); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(StorageManager); + pub const Meta = struct { + pub const name = "StorageManager"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + pub const empty_with_no_proto = true; + }; + pub const estimate = bridge.function(StorageManager.estimate, .{}); +}; From 041d9d41fb0a3e9cca082336cbd20372727f6fd3 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 17 Mar 2026 18:04:44 +0800 Subject: [PATCH 39/85] Fallback to the Incumbent Context when the Current Context is dangling This specifically fixes a WPT crash running: /html/browsers/browsing-the-web/history-traversal/001.html (And probably a few others). Isolate::GetCurrentContext can return a 'detached' context. And, for us, that's a problem, because 'detached' v8::Context references a js.Context that we've deinit'd. This seems to only happen when frames pass values around to other frames and then those frames are removed. It might also require some async'ing, I'm not sure. To solve this, when we destroy a js.Context, we store null in the v8::Context's embedder data, removing the link to our (dead) js.Context. When we load a js.Context from a v8.Context, we check for null. If it is null, we return the Incumbent context instead. This should never be null, as it's always the context currently executing code. I'm not sure if falling back to the Incumbent context is always correct, but it does solve the crash. --- src/browser/js/Caller.zig | 8 +++----- src/browser/js/Context.zig | 29 ++++++++++++++++++++++------- src/browser/js/Env.zig | 8 ++++---- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/src/browser/js/Caller.zig b/src/browser/js/Caller.zig index 297d4ac1..9ecd65b2 100644 --- a/src/browser/js/Caller.zig +++ b/src/browser/js/Caller.zig @@ -40,8 +40,8 @@ prev_context: *Context, // Takes the raw v8 isolate and extracts the context from it. pub fn init(self: *Caller, v8_isolate: *v8.Isolate) void { - const v8_context = v8.v8__Isolate__GetCurrentContext(v8_isolate).?; - initWithContext(self, Context.fromC(v8_context), v8_context); + const ctx, const v8_context = Context.fromIsolate(.{ .handle = v8_isolate }); + initWithContext(self, ctx, v8_context); } fn initWithContext(self: *Caller, ctx: *Context, v8_context: *const v8.Context) void { @@ -537,9 +537,7 @@ pub const Function = struct { pub fn call(comptime T: type, info_handle: *const v8.FunctionCallbackInfo, func: anytype, comptime opts: Opts) void { const v8_isolate = v8.v8__FunctionCallbackInfo__GetIsolate(info_handle).?; - const v8_context = v8.v8__Isolate__GetCurrentContext(v8_isolate).?; - - const ctx = Context.fromC(v8_context); + const ctx, const v8_context = Context.fromIsolate(.{ .handle = v8_isolate }); const info = FunctionCallbackInfo{ .handle = info_handle }; var hs: js.HandleScope = undefined; diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index ea1b6f7a..da7362aa 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -119,12 +119,22 @@ const ModuleEntry = struct { resolver_promise: ?js.Promise.Global = null, }; -pub fn fromC(c_context: *const v8.Context) *Context { +pub fn fromC(c_context: *const v8.Context) ?*Context { return @ptrCast(@alignCast(v8.v8__Context__GetAlignedPointerFromEmbedderData(c_context, 1))); } -pub fn fromIsolate(isolate: js.Isolate) *Context { - return fromC(v8.v8__Isolate__GetCurrentContext(isolate.handle).?); +/// Returns the Context and v8::Context for the given isolate. +/// If the current context is from a destroyed Context (e.g., navigated-away iframe), +/// falls back to the incumbent context (the calling context). +pub fn fromIsolate(isolate: js.Isolate) struct { *Context, *const v8.Context } { + const v8_context = v8.v8__Isolate__GetCurrentContext(isolate.handle).?; + if (fromC(v8_context)) |ctx| { + return .{ ctx, v8_context }; + } + // The current context's Context struct has been freed (e.g., iframe navigated away). + // Fall back to the incumbent context (the calling context). + const v8_incumbent = v8.v8__Isolate__GetIncumbentContext(isolate.handle).?; + return .{ fromC(v8_incumbent).?, v8_incumbent }; } pub fn deinit(self: *Context) void { @@ -155,6 +165,11 @@ pub fn deinit(self: *Context) void { self.session.releaseOrigin(self.origin); + // Clear the embedder data so that if V8 keeps this context alive + // (because objects created in it are still referenced), we don't + // have a dangling pointer to our freed Context struct. + v8.v8__Context__SetAlignedPointerInEmbedderData(entered.handle, 1, null); + v8.v8__Global__Reset(&self.handle); env.isolate.notifyContextDisposed(); // There can be other tasks associated with this context that we need to @@ -255,7 +270,7 @@ pub fn toLocal(self: *Context, global: anytype) js.Local.ToLocalReturnType(@Type } pub fn getIncumbent(self: *Context) *Page { - return fromC(v8.v8__Isolate__GetIncumbentContext(self.env.isolate.handle).?).page; + return fromC(v8.v8__Isolate__GetIncumbentContext(self.env.isolate.handle).?).?.page; } pub fn stringToPersistedFunction( @@ -479,7 +494,7 @@ fn resolveModuleCallback( ) callconv(.c) ?*const v8.Module { _ = import_attributes; - const self = fromC(c_context.?); + const self = fromC(c_context.?).?; const local = js.Local{ .ctx = self, .handle = c_context.?, @@ -512,7 +527,7 @@ pub fn dynamicModuleCallback( _ = host_defined_options; _ = import_attrs; - const self = fromC(c_context.?); + const self = fromC(c_context.?).?; const local = js.Local{ .ctx = self, .handle = c_context.?, @@ -559,7 +574,7 @@ pub fn dynamicModuleCallback( pub fn metaObjectCallback(c_context: ?*v8.Context, c_module: ?*v8.Module, c_meta: ?*v8.Value) callconv(.c) void { // @HandleScope implement this without a fat context/local.. - const self = fromC(c_context.?); + const self = fromC(c_context.?).?; var local = js.Local{ .ctx = self, .handle = c_context.?, diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index 1ac9e6b3..09117eb0 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -497,13 +497,13 @@ pub fn terminate(self: *const Env) void { fn promiseRejectCallback(message_handle: v8.PromiseRejectMessage) callconv(.c) void { const promise_handle = v8.v8__PromiseRejectMessage__GetPromise(&message_handle).?; const v8_isolate = v8.v8__Object__GetIsolate(@ptrCast(promise_handle)).?; - const js_isolate = js.Isolate{ .handle = v8_isolate }; - const ctx = Context.fromIsolate(js_isolate); + const isolate = js.Isolate{ .handle = v8_isolate }; + const ctx, const v8_context = Context.fromIsolate(isolate); const local = js.Local{ .ctx = ctx, - .isolate = js_isolate, - .handle = v8.v8__Isolate__GetCurrentContext(v8_isolate).?, + .isolate = isolate, + .handle = v8_context, .call_arena = ctx.call_arena, }; From 6d0dc6cb1efd1d91bc4eee551a1a7dc69054db93 Mon Sep 17 00:00:00 2001 From: Nikolay Govorov Date: Mon, 16 Mar 2026 20:20:47 +0000 Subject: [PATCH 40/85] Gracefull close ws socket --- src/Server.zig | 5 ++++- src/network/Runtime.zig | 5 ++++- src/network/websocket.zig | 5 +++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/Server.zig b/src/Server.zig index f899d43c..e43313da 100644 --- a/src/Server.zig +++ b/src/Server.zig @@ -242,7 +242,10 @@ pub const Client = struct { fn stop(self: *Client) void { switch (self.mode) { .http => {}, - .cdp => |*cdp| cdp.browser.env.terminate(), + .cdp => |*cdp| { + cdp.browser.env.terminate(); + self.ws.sendClose(); + }, } self.ws.shutdown(); } diff --git a/src/network/Runtime.zig b/src/network/Runtime.zig index d6a08f59..196e2aa2 100644 --- a/src/network/Runtime.zig +++ b/src/network/Runtime.zig @@ -308,10 +308,13 @@ pub fn run(self: *Runtime) void { const socket = posix.accept(listener.socket, null, null, posix.SOCK.NONBLOCK) catch |err| { switch (err) { - error.SocketNotListening, error.ConnectionAborted => { + error.SocketNotListening => { self.pollfds[1] = .{ .fd = -1, .events = 0, .revents = 0 }; self.listener = null; }, + error.ConnectionAborted => { + lp.log.warn(.app, "accept connection aborted", .{}); + }, error.WouldBlock => {}, else => { lp.log.err(.app, "accept", .{ .err = err }); diff --git a/src/network/websocket.zig b/src/network/websocket.zig index 5a5b4747..c6c74ed0 100644 --- a/src/network/websocket.zig +++ b/src/network/websocket.zig @@ -308,6 +308,7 @@ pub fn Reader(comptime EXPECT_MASK: bool) type { pub const WsConnection = struct { // CLOSE, 2 length, code const CLOSE_NORMAL = [_]u8{ 136, 2, 3, 232 }; // code: 1000 + const CLOSE_GOING_AWAY = [_]u8{ 136, 2, 3, 233 }; // code: 1001 const CLOSE_TOO_BIG = [_]u8{ 136, 2, 3, 241 }; // 1009 const CLOSE_PROTOCOL_ERROR = [_]u8{ 136, 2, 3, 234 }; //code: 1002 // "private-use" close codes must be from 4000-49999 @@ -583,6 +584,10 @@ pub const WsConnection = struct { return address; } + pub fn sendClose(self: *WsConnection) void { + self.send(&CLOSE_GOING_AWAY) catch {}; + } + pub fn shutdown(self: *WsConnection) void { posix.shutdown(self.socket, .recv) catch {}; } From 21f7b95db99292a404cb3eefff287103136b1f0a Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 17 Mar 2026 19:54:21 +0800 Subject: [PATCH 41/85] disable observer weak ref https://github.com/lightpanda-io/browser/pull/1870 doesn't work. I think there are ways for the inspector to move objects into a context that skips our reference count (those remote objects?). This disables weak references for MutationObserver and IntersectionObserver. The issue is probably more widespread but these are two types CDP drivers us _a lot_ via inspector, so this should fix a number of immediate crashes. I believe the correct fix is to remove Origin and store things at the Session- level. --- src/browser/js/Origin.zig | 2 +- src/browser/webapi/IntersectionObserver.zig | 31 ++------------------- src/browser/webapi/MutationObserver.zig | 26 ++--------------- 3 files changed, 7 insertions(+), 52 deletions(-) diff --git a/src/browser/js/Origin.zig b/src/browser/js/Origin.zig index 180cfd84..9dc5857b 100644 --- a/src/browser/js/Origin.zig +++ b/src/browser/js/Origin.zig @@ -206,7 +206,7 @@ pub fn createFinalizerCallback( pub fn takeover(self: *Origin, original: *Origin) !void { const arena = self.arena; - try self.globals.ensureUnusedCapacity(arena, self.globals.items.len); + try self.globals.ensureUnusedCapacity(arena, original.globals.items.len); for (original.globals.items) |obj| { self.globals.appendAssumeCapacity(obj); } diff --git a/src/browser/webapi/IntersectionObserver.zig b/src/browser/webapi/IntersectionObserver.zig index b4c07e77..8586a11d 100644 --- a/src/browser/webapi/IntersectionObserver.zig +++ b/src/browser/webapi/IntersectionObserver.zig @@ -37,7 +37,6 @@ pub fn registerTypes() []const type { const IntersectionObserver = @This(); -_rc: u8 = 0, _arena: Allocator, _callback: js.Function.Temp, _observing: std.ArrayList(*Element) = .{}, @@ -94,26 +93,14 @@ pub fn init(callback: js.Function.Temp, options: ?ObserverInit, page: *Page) !*I } pub fn deinit(self: *IntersectionObserver, shutdown: bool, session: *Session) void { - const rc = self._rc; - if (comptime IS_DEBUG) { - std.debug.assert(rc != 0); - } - - if (rc == 1 or shutdown) { + if (shutdown) { self._callback.release(); - if ((comptime IS_DEBUG) and !shutdown) { - std.debug.assert(self._observing.items.len == 0); - } session.releaseArena(self._arena); - } else { - self._rc = rc - 1; + } else if (comptime IS_DEBUG) { + std.debug.assert(false); } } -pub fn acquireRef(self: *IntersectionObserver) void { - self._rc += 1; -} - pub fn observe(self: *IntersectionObserver, target: *Element, page: *Page) !void { // Check if already observing this target for (self._observing.items) |elem| { @@ -124,7 +111,6 @@ pub fn observe(self: *IntersectionObserver, target: *Element, page: *Page) !void // Register with page if this is our first observation if (self._observing.items.len == 0) { - self._rc += 1; try page.registerIntersectionObserver(self); } @@ -159,10 +145,6 @@ pub fn unobserve(self: *IntersectionObserver, target: *Element, page: *Page) voi break; } } - - if (self._observing.items.len == 0) { - self.deinit(false, page._session); - } } pub fn disconnect(self: *IntersectionObserver, page: *Page) void { @@ -173,13 +155,7 @@ pub fn disconnect(self: *IntersectionObserver, page: *Page) void { } self._pending_entries.clearRetainingCapacity(); - const observing_count = self._observing.items.len; self._observing.clearRetainingCapacity(); - - if (observing_count > 0) { - self.deinit(false, page._session); - } - page.unregisterIntersectionObserver(self); } @@ -382,7 +358,6 @@ pub const JsApi = struct { pub const name = "IntersectionObserver"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; - pub const weak = true; pub const finalizer = bridge.finalizer(IntersectionObserver.deinit); }; diff --git a/src/browser/webapi/MutationObserver.zig b/src/browser/webapi/MutationObserver.zig index 8b625fa8..002547e5 100644 --- a/src/browser/webapi/MutationObserver.zig +++ b/src/browser/webapi/MutationObserver.zig @@ -39,7 +39,6 @@ pub fn registerTypes() []const type { const MutationObserver = @This(); -_rc: u8 = 0, _arena: Allocator, _callback: js.Function.Temp, _observing: std.ArrayList(Observing) = .{}, @@ -87,26 +86,14 @@ pub fn init(callback: js.Function.Temp, page: *Page) !*MutationObserver { } pub fn deinit(self: *MutationObserver, shutdown: bool, session: *Session) void { - const rc = self._rc; - if (comptime IS_DEBUG) { - std.debug.assert(rc != 0); - } - - if (rc == 1 or shutdown) { + if (shutdown) { self._callback.release(); - if ((comptime IS_DEBUG) and !shutdown) { - std.debug.assert(self._observing.items.len == 0); - } session.releaseArena(self._arena); - } else { - self._rc = rc - 1; + } else if (comptime IS_DEBUG) { + std.debug.assert(false); } } -pub fn acquireRef(self: *MutationObserver) void { - self._rc += 1; -} - pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions, page: *Page) !void { const arena = self._arena; @@ -171,7 +158,6 @@ pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions, // Register with page if this is our first observation if (self._observing.items.len == 0) { - self._rc += 1; try page.registerMutationObserver(self); } @@ -187,12 +173,7 @@ pub fn disconnect(self: *MutationObserver, page: *Page) void { } self._pending_records.clearRetainingCapacity(); - const observing_count = self._observing.items.len; self._observing.clearRetainingCapacity(); - - if (observing_count > 0) { - self.deinit(false, page._session); - } page.unregisterMutationObserver(self); } @@ -459,7 +440,6 @@ pub const JsApi = struct { pub const name = "MutationObserver"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; - pub const weak = true; pub const finalizer = bridge.finalizer(MutationObserver.deinit); }; From 9f274166032721b0dc9cd762e1f94644d88f46f2 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 17 Mar 2026 20:03:31 +0800 Subject: [PATCH 42/85] zig fmt --- src/browser/webapi/MutationObserver.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/webapi/MutationObserver.zig b/src/browser/webapi/MutationObserver.zig index 002547e5..df86d1e1 100644 --- a/src/browser/webapi/MutationObserver.zig +++ b/src/browser/webapi/MutationObserver.zig @@ -86,7 +86,7 @@ pub fn init(callback: js.Function.Temp, page: *Page) !*MutationObserver { } pub fn deinit(self: *MutationObserver, shutdown: bool, session: *Session) void { - if (shutdown) { + if (shutdown) { self._callback.release(); session.releaseArena(self._arena); } else if (comptime IS_DEBUG) { From b252aa71d0ecc2220ff2cd68087e04b4f94965f9 Mon Sep 17 00:00:00 2001 From: Nikolay Govorov Date: Tue, 17 Mar 2026 11:33:16 +0000 Subject: [PATCH 43/85] Use git_version option for version command --- .github/workflows/build.yml | 9 +++++---- build.zig | 2 +- src/main.zig | 10 +++++----- src/telemetry/lightpanda.zig | 2 +- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7cb213a9..7562c38f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,6 +6,7 @@ env: AWS_BUCKET: ${{ vars.NIGHTLY_BUILD_AWS_BUCKET }} AWS_REGION: ${{ vars.NIGHTLY_BUILD_AWS_REGION }} RELEASE: ${{ github.ref_type == 'tag' && github.ref_name || 'nightly' }} + GIT_VERSION_FLAG: ${{ github.ref_type == 'tag' && format('-Dgit_version={0}', github.ref_name) || '' }} on: push: @@ -45,7 +46,7 @@ jobs: run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin - name: zig build - run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) + run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) ${{ env.GIT_VERSION_FLAG }} - name: Rename binary run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }} @@ -87,7 +88,7 @@ jobs: run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin - name: zig build - run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=generic -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) + run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=generic -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) ${{ env.GIT_VERSION_FLAG }} - name: Rename binary run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }} @@ -131,7 +132,7 @@ jobs: run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin - name: zig build - run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) + run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) ${{ env.GIT_VERSION_FLAG }} - name: Rename binary run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }} @@ -173,7 +174,7 @@ jobs: run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin - name: zig build - run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) + run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) ${{ env.GIT_VERSION_FLAG }} - name: Rename binary run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }} diff --git a/build.zig b/build.zig index 33168b86..0d917b75 100644 --- a/build.zig +++ b/build.zig @@ -34,7 +34,7 @@ pub fn build(b: *Build) !void { var opts = b.addOptions(); opts.addOption([]const u8, "version", manifest.version); opts.addOption([]const u8, "git_commit", git_commit orelse "dev"); - opts.addOption([]const u8, "git_version", git_version orelse "dev"); + opts.addOption(?[]const u8, "git_version", git_version orelse null); opts.addOption(?[]const u8, "snapshot_path", snapshot_path); const enable_tsan = b.option(bool, "tsan", "Enable Thread Sanitizer") orelse false; diff --git a/src/main.zig b/src/main.zig index deee3cd7..1b5ffdb2 100644 --- a/src/main.zig +++ b/src/main.zig @@ -59,11 +59,11 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { return std.process.cleanExit(); }, .version => { - const version = if (std.mem.eql(u8, lp.build_config.git_version, "dev")) - lp.build_config.git_commit - else - lp.build_config.git_version; - std.debug.print("{s}\n", .{version}); + if (lp.build_config.git_version) |version| { + std.debug.print("{s} ({s})\n", .{ version, lp.build_config.git_commit }); + } else { + std.debug.print("{s}\n", .{lp.build_config.git_commit}); + } return std.process.cleanExit(); }, else => {}, diff --git a/src/telemetry/lightpanda.zig b/src/telemetry/lightpanda.zig index 75552eeb..62ac2f99 100644 --- a/src/telemetry/lightpanda.zig +++ b/src/telemetry/lightpanda.zig @@ -153,7 +153,7 @@ const LightPandaEvent = struct { try writer.write(builtin.cpu.arch); try writer.objectField("version"); - try writer.write(build_config.git_commit); + try writer.write(build_config.git_version orelse build_config.git_commit); try writer.objectField("event"); try writer.write(@tagName(std.meta.activeTag(self.event))); From c0c4e26d63238c94f60d51217353d121356b761b Mon Sep 17 00:00:00 2001 From: Nikolay Govorov Date: Tue, 17 Mar 2026 14:24:22 +0000 Subject: [PATCH 44/85] removes artifacts of the past from CI --- .github/workflows/e2e-integration-test.yml | 2 - .github/workflows/e2e-test.yml | 30 +++---- .github/workflows/{build.yml => nightly.yml} | 9 +- .github/workflows/wpt.yml | 2 - .github/workflows/zig-fmt.yml | 60 ------------- .github/workflows/zig-test.yml | 90 +++++++++++++------- 6 files changed, 71 insertions(+), 122 deletions(-) rename .github/workflows/{build.yml => nightly.yml} (93%) delete mode 100644 .github/workflows/zig-fmt.yml diff --git a/.github/workflows/e2e-integration-test.yml b/.github/workflows/e2e-integration-test.yml index dff1fb59..da4056ca 100644 --- a/.github/workflows/e2e-integration-test.yml +++ b/.github/workflows/e2e-integration-test.yml @@ -23,8 +23,6 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 - # fetch submodules recusively, to get zig-js-runtime submodules also. - submodules: recursive - uses: ./.github/actions/install diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 4b31e193..a8e36ca5 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -9,15 +9,13 @@ env: on: push: - branches: - - main + branches: [main] paths: - - "build.zig" - - "src/**/*.zig" - - "src/*.zig" - - "vendor/zig-js-runtime" - ".github/**" - - "vendor/**" + - "src/**" + - "build.zig" + - "build.zig.zon" + pull_request: # By default GH trigger on types opened, synchronize and reopened. @@ -29,12 +27,10 @@ on: paths: - ".github/**" + - "src/**" - "build.zig" - - "src/**/*.zig" - - "src/*.zig" - - "vendor/**" - - ".github/**" - - "vendor/**" + - "build.zig.zon" + # Allows you to run this workflow manually from the Actions tab workflow_dispatch: @@ -52,8 +48,6 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 - # fetch submodules recusively, to get zig-js-runtime submodules also. - submodules: recursive - uses: ./.github/actions/install @@ -182,9 +176,6 @@ jobs: name: wba-test needs: zig-build-release - env: - LIGHTPANDA_DISABLE_TELEMETRY: true - runs-on: ubuntu-latest timeout-minutes: 5 @@ -227,7 +218,6 @@ jobs: MAX_VmHWM: 28000 # 28MB (KB) MAX_CG_PEAK: 8000 # 8MB (KB) MAX_AVG_DURATION: 17 - LIGHTPANDA_DISABLE_TELEMETRY: true # How to give cgroups access to the user actions-runner on the host: # $ sudo apt install cgroup-tools @@ -359,8 +349,8 @@ jobs: container: image: ghcr.io/lightpanda-io/perf-fmt:latest credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} steps: - name: download artifact diff --git a/.github/workflows/build.yml b/.github/workflows/nightly.yml similarity index 93% rename from .github/workflows/build.yml rename to .github/workflows/nightly.yml index 7562c38f..1c1ece06 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/nightly.yml @@ -5,6 +5,7 @@ env: AWS_SECRET_ACCESS_KEY: ${{ secrets.NIGHTLY_BUILD_AWS_SECRET_ACCESS_KEY }} AWS_BUCKET: ${{ vars.NIGHTLY_BUILD_AWS_BUCKET }} AWS_REGION: ${{ vars.NIGHTLY_BUILD_AWS_REGION }} + RELEASE: ${{ github.ref_type == 'tag' && github.ref_name || 'nightly' }} GIT_VERSION_FLAG: ${{ github.ref_type == 'tag' && format('-Dgit_version={0}', github.ref_name) || '' }} @@ -34,8 +35,6 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 - # fetch submodules recusively, to get zig-js-runtime submodules also. - submodules: recursive - uses: ./.github/actions/install with: @@ -76,8 +75,6 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 - # fetch submodules recusively, to get zig-js-runtime submodules also. - submodules: recursive - uses: ./.github/actions/install with: @@ -120,8 +117,6 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 - # fetch submodules recusively, to get zig-js-runtime submodules also. - submodules: recursive - uses: ./.github/actions/install with: @@ -162,8 +157,6 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 - # fetch submodules recusively, to get zig-js-runtime submodules also. - submodules: recursive - uses: ./.github/actions/install with: diff --git a/.github/workflows/wpt.yml b/.github/workflows/wpt.yml index ef887612..5b6f9f9f 100644 --- a/.github/workflows/wpt.yml +++ b/.github/workflows/wpt.yml @@ -26,8 +26,6 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 - # fetch submodules recusively, to get zig-js-runtime submodules also. - submodules: recursive - uses: ./.github/actions/install diff --git a/.github/workflows/zig-fmt.yml b/.github/workflows/zig-fmt.yml deleted file mode 100644 index a6df7e1f..00000000 --- a/.github/workflows/zig-fmt.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: zig-fmt - -on: - pull_request: - - # By default GH trigger on types opened, synchronize and reopened. - # see https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request - # Since we skip the job when the PR is in draft state, we want to force CI - # running when the PR is marked ready_for_review w/o other change. - # see https://github.com/orgs/community/discussions/25722#discussioncomment-3248917 - types: [opened, synchronize, reopened, ready_for_review] - - paths: - - ".github/**" - - "build.zig" - - "src/**/*.zig" - - "src/*.zig" - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: - -jobs: - zig-fmt: - name: zig fmt - - # Don't run the CI with draft PR. - if: github.event.pull_request.draft == false - - runs-on: ubuntu-latest - timeout-minutes: 15 - - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - # Zig version used from the `minimum_zig_version` field in build.zig.zon - - uses: mlugg/setup-zig@v2 - - - name: Run zig fmt - id: fmt - run: | - zig fmt --check ./*.zig ./**/*.zig 2> zig-fmt.err > zig-fmt.err2 || echo "Failed" - delimiter="$(openssl rand -hex 8)" - echo "zig_fmt_errs<<${delimiter}" >> "${GITHUB_OUTPUT}" - - if [ -s zig-fmt.err ]; then - echo "// The following errors occurred:" >> "${GITHUB_OUTPUT}" - cat zig-fmt.err >> "${GITHUB_OUTPUT}" - fi - - if [ -s zig-fmt.err2 ]; then - echo "// The following files were not formatted:" >> "${GITHUB_OUTPUT}" - cat zig-fmt.err2 >> "${GITHUB_OUTPUT}" - fi - - echo "${delimiter}" >> "${GITHUB_OUTPUT}" - - - name: Fail the job - if: steps.fmt.outputs.zig_fmt_errs != '' - run: exit 1 diff --git a/.github/workflows/zig-test.yml b/.github/workflows/zig-test.yml index ca967c3a..a96ec0bf 100644 --- a/.github/workflows/zig-test.yml +++ b/.github/workflows/zig-test.yml @@ -5,19 +5,18 @@ env: AWS_SECRET_ACCESS_KEY: ${{ secrets.LPD_PERF_AWS_SECRET_ACCESS_KEY }} AWS_BUCKET: ${{ vars.LPD_PERF_AWS_BUCKET }} AWS_REGION: ${{ vars.LPD_PERF_AWS_REGION }} + LIGHTPANDA_DISABLE_TELEMETRY: true on: push: - branches: - - main + branches: [main] paths: - - "build.zig" - - "src/**" - - "vendor/zig-js-runtime" - ".github/**" - - "vendor/**" - pull_request: + - "src/**" + - "build.zig" + - "build.zig.zon" + pull_request: # By default GH trigger on types opened, synchronize and reopened. # see https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request # Since we skip the job when the PR is in draft state, we want to force CI @@ -27,28 +26,63 @@ on: paths: - ".github/**" + - "src/**" - "build.zig" - - "src/**/*.zig" - - "src/*.zig" - - "vendor/**" - - ".github/**" - - "vendor/**" + - "build.zig.zon" + # Allows you to run this workflow manually from the Actions tab workflow_dispatch: jobs: - zig-test-debug: - name: zig test using v8 in debug mode - timeout-minutes: 15 + zig-fmt: + name: zig fmt runs-on: ubuntu-latest + timeout-minutes: 15 + + if: github.event.pull_request.draft == false + + steps: + - uses: actions/checkout@v6 + + # Zig version used from the `minimum_zig_version` field in build.zig.zon + - uses: mlugg/setup-zig@v2 + + - name: Run zig fmt + id: fmt + run: | + zig fmt --check ./*.zig ./**/*.zig 2> zig-fmt.err > zig-fmt.err2 || echo "Failed" + delimiter="$(openssl rand -hex 8)" + echo "zig_fmt_errs<<${delimiter}" >> "${GITHUB_OUTPUT}" + + if [ -s zig-fmt.err ]; then + echo "// The following errors occurred:" >> "${GITHUB_OUTPUT}" + cat zig-fmt.err >> "${GITHUB_OUTPUT}" + fi + + if [ -s zig-fmt.err2 ]; then + echo "// The following files were not formatted:" >> "${GITHUB_OUTPUT}" + cat zig-fmt.err2 >> "${GITHUB_OUTPUT}" + fi + + echo "${delimiter}" >> "${GITHUB_OUTPUT}" + + - name: Fail the job + if: steps.fmt.outputs.zig_fmt_errs != '' + run: exit 1 + + zig-test-debug: + name: zig test using v8 in debug mode + + runs-on: ubuntu-latest + timeout-minutes: 15 + + if: github.event.pull_request.draft == false steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - # fetch submodules recusively, to get zig-js-runtime submodules also. - submodules: recursive - uses: ./.github/actions/install with: @@ -57,21 +91,18 @@ jobs: - name: zig build test run: zig build -Dprebuilt_v8_path=v8/libc_v8_debug.a -Dtsan=true test - zig-test: + zig-test-release: name: zig test - timeout-minutes: 15 - - # Don't run the CI with draft PR. - if: github.event.pull_request.draft == false runs-on: ubuntu-latest + timeout-minutes: 15 + + if: github.event.pull_request.draft == false steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - # fetch submodules recusively, to get zig-js-runtime submodules also. - submodules: recursive - uses: ./.github/actions/install @@ -93,19 +124,18 @@ jobs: bench-fmt: name: perf-fmt - needs: zig-test - - # Don't execute on PR - if: github.event_name != 'pull_request' + needs: zig-test-release runs-on: ubuntu-latest timeout-minutes: 15 + if: github.event_name != 'pull_request' + container: image: ghcr.io/lightpanda-io/perf-fmt:latest credentials: - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} steps: - name: download artifact From ba62150f7aa24a066642b69cd9942900ff13b812 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Tue, 17 Mar 2026 17:05:30 +0100 Subject: [PATCH 45/85] add Form.requestSubmit(submitter) --- src/browser/tests/element/html/form.html | 41 ++++++++++++++++++++++++ src/browser/webapi/element/html/Form.zig | 9 ++++++ 2 files changed, 50 insertions(+) diff --git a/src/browser/tests/element/html/form.html b/src/browser/tests/element/html/form.html index 17743135..86d4925b 100644 --- a/src/browser/tests/element/html/form.html +++ b/src/browser/tests/element/html/form.html @@ -343,3 +343,44 @@ testing.expectEqual('', form.elements['choice'].value) } + + +
+ +
+ + + + +
+ +
+ + diff --git a/src/browser/webapi/element/html/Form.zig b/src/browser/webapi/element/html/Form.zig index 24e8433e..12c32291 100644 --- a/src/browser/webapi/element/html/Form.zig +++ b/src/browser/webapi/element/html/Form.zig @@ -117,6 +117,14 @@ pub fn submit(self: *Form, page: *Page) !void { return page.submitForm(null, self, .{ .fire_event = false }); } +/// https://html.spec.whatwg.org/multipage/forms.html#dom-form-requestsubmit +/// Like submit(), but fires the submit event and validates the form. +pub fn requestSubmit(self: *Form, submitter: ?*Element, page: *Page) !void { + // TODO check the submitter is a submit button if not null. + const submitter_element = submitter orelse self.asElement(); + return page.submitForm(submitter_element, self, .{}); +} + pub const JsApi = struct { pub const bridge = js.Bridge(Form); pub const Meta = struct { @@ -132,6 +140,7 @@ pub const JsApi = struct { pub const elements = bridge.accessor(Form.getElements, null, .{}); pub const length = bridge.accessor(Form.getLength, null, .{}); pub const submit = bridge.function(Form.submit, .{}); + pub const requestSubmit = bridge.function(Form.requestSubmit, .{}); }; const testing = @import("../../../../testing.zig"); From 1739ae6b9a80aa3c876a9b57283ab3e00488c8fe Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Tue, 17 Mar 2026 21:34:48 +0100 Subject: [PATCH 46/85] check submit element and form into Form.requestSubmit --- src/browser/tests/element/html/form.html | 79 ++++++++++++++++++++++++ src/browser/webapi/element/html/Form.zig | 39 +++++++++++- 2 files changed, 115 insertions(+), 3 deletions(-) diff --git a/src/browser/tests/element/html/form.html b/src/browser/tests/element/html/form.html index 86d4925b..d3249856 100644 --- a/src/browser/tests/element/html/form.html +++ b/src/browser/tests/element/html/form.html @@ -384,3 +384,82 @@ testing.expectEqual(true, true); } + + +
+ + + + + + +
+ + + + + + + +
+ +
+
+ +
+ + diff --git a/src/browser/webapi/element/html/Form.zig b/src/browser/webapi/element/html/Form.zig index 12c32291..b86744da 100644 --- a/src/browser/webapi/element/html/Form.zig +++ b/src/browser/webapi/element/html/Form.zig @@ -120,11 +120,44 @@ pub fn submit(self: *Form, page: *Page) !void { /// https://html.spec.whatwg.org/multipage/forms.html#dom-form-requestsubmit /// Like submit(), but fires the submit event and validates the form. pub fn requestSubmit(self: *Form, submitter: ?*Element, page: *Page) !void { - // TODO check the submitter is a submit button if not null. - const submitter_element = submitter orelse self.asElement(); + const submitter_element = if (submitter) |s| blk: { + // The submitter must be a submit button. + if (!isSubmitButton(s)) return error.TypeError; + + // The submitter's form owner must be this form element. + const submitter_form = getFormOwner(s, page); + if (submitter_form == null or submitter_form.? != self) return error.NotFound; + + break :blk s; + } else self.asElement(); + return page.submitForm(submitter_element, self, .{}); } +/// Returns true if the element is a submit button per the HTML spec: +/// - or +/// -