From 47792378eefa821844661fd624fcd3fd439fb14a Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 6 May 2026 12:38:12 +0800 Subject: [PATCH 1/5] On an unloaded-page, fast-path navigation This re-implements the CDP navigate action fast-path when a page is in a waiting state. It was removed for https://github.com/lightpanda-io/browser/pull/2297. I believe this fast-path is still safe to do given that a page in _waiting has had no navigation event yet and thus has nothing to preserve. The upside is being able to re-use the existing [bare] v8::Context. --- src/cdp/domains/page.zig | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 65068028..b714da1e 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -301,6 +301,20 @@ fn navigate(cmd: *CDP.Command) !void { const frame = session.currentFrame() orelse return error.FrameNotLoaded; const encoded_url = try URL.ensureEncoded(frame.call_arena, params.url, "UTF-8"); + + // Fast path: a freshly-created target whose root frame hasn't navigated + // yet has nothing to preserve across the HTTP round-trip. Skip the + // pending-Page allocation (which would create a V8 context just to + // throw the OLD blank one away at commit) and navigate the active + // frame in place. + if (frame._load_state == .waiting) { + return frame.navigate(encoded_url, .{ + .reason = .address_bar, + .cdp_id = cmd.input.id, + .kind = .{ .push = null }, + }); + } + try session.initiateRootNavigation(frame._frame_id, encoded_url, .{ .reason = .address_bar, .cdp_id = cmd.input.id, From 95f647d4ae9b9a4c0129a0bdc805d4ba8f230b39 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 6 May 2026 16:12:41 +0800 Subject: [PATCH 2/5] Make SubtleCrypto work on Worker This is a source of many WPT crashes right now. worker.crypto is exposed, and therefore worker.crypto.subtle also is. --- src/browser/js/bridge.zig | 1 + src/browser/tests/crypto-worker.js | 92 +++++++++++++++++++++++++++ src/browser/tests/crypto.html | 93 ++++++++++++++++++++++++++++ src/browser/webapi/SubtleCrypto.zig | 67 ++++++++++---------- src/browser/webapi/crypto/HMAC.zig | 25 ++++---- src/browser/webapi/crypto/X25519.zig | 27 ++++---- 6 files changed, 248 insertions(+), 57 deletions(-) create mode 100644 src/browser/tests/crypto-worker.js diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index f2ec997e..3877871a 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -949,6 +949,7 @@ pub const WorkerJsApis = flattenTypes(&.{ @import("../webapi/File.zig"), @import("../webapi/Console.zig"), @import("../webapi/Crypto.zig"), + @import("../webapi/SubtleCrypto.zig"), @import("../webapi/net/FormData.zig"), @import("../webapi/net/Headers.zig"), @import("../webapi/net/Request.zig"), diff --git a/src/browser/tests/crypto-worker.js b/src/browser/tests/crypto-worker.js new file mode 100644 index 00000000..90d948d0 --- /dev/null +++ b/src/browser/tests/crypto-worker.js @@ -0,0 +1,92 @@ +// Exercises crypto APIs inside a worker. Posts 'ready' once the message +// handler is wired so the page knows it can send a command without racing +// worker startup. Receives the command, runs the crypto operation, and +// posts the result back. +self.onmessage = async function(e) { + const cmd = e.data; + try { + if (cmd.kind === 'getRandomValues') { + const ta = new Uint8Array(32); + const same = crypto.getRandomValues(ta) === ta; + const uniq = new Set(Array.from(ta)); + postMessage({ ok: true, same, looks_random: uniq.size > 8 }); + return; + } + + if (cmd.kind === 'randomUUID') { + const uuid = crypto.randomUUID(); + const regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + postMessage({ ok: true, type: typeof uuid, length: uuid.length, valid: regex.test(uuid) }); + return; + } + + if (cmd.kind === 'digest') { + const buffer = await crypto.subtle.digest('sha-256', new TextEncoder().encode('over 9000')); + const hex = [...new Uint8Array(buffer)].map(x => x.toString(16).padStart(2, '0')).join(''); + postMessage({ ok: true, hex }); + return; + } + + if (cmd.kind === 'hmac') { + const key = await crypto.subtle.generateKey( + { name: 'HMAC', hash: { name: 'SHA-512' } }, + true, + ['sign', 'verify'], + ); + const raw = await crypto.subtle.exportKey('raw', key); + const encoder = new TextEncoder(); + const signature = await crypto.subtle.sign('HMAC', key, encoder.encode('Hello, world!')); + const verified = await crypto.subtle.verify( + { name: 'HMAC' }, + key, + signature, + encoder.encode('Hello, world!'), + ); + postMessage({ + ok: true, + key_type: typeof key, + raw_byte_length: raw.byteLength, + is_array_buffer: signature instanceof ArrayBuffer, + verified, + }); + return; + } + + if (cmd.kind === 'x25519') { + const { privateKey, publicKey } = await crypto.subtle.generateKey( + { name: 'X25519' }, + true, + ['deriveBits'], + ); + const sharedKey = await crypto.subtle.deriveBits( + { name: 'X25519', public: publicKey }, + privateKey, + 128, + ); + postMessage({ + ok: true, + private_key_type: typeof privateKey, + public_key_type: typeof publicKey, + shared_byte_length: sharedKey.byteLength, + }); + return; + } + + if (cmd.kind === 'generateKey-rejection') { + let err_name = null; + try { + await crypto.subtle.generateKey({ name: 'AES-CBC', length: 128 }, true, ['sign']); + } catch (err) { + err_name = err.name; + } + postMessage({ ok: true, err_name }); + return; + } + + postMessage({ ok: false, err: 'unknown command' }); + } catch (err) { + postMessage({ ok: false, err: String(err), stack: err.stack }); + } +}; + +postMessage({ ready: true }); diff --git a/src/browser/tests/crypto.html b/src/browser/tests/crypto.html index 59d6522e..f588b743 100644 --- a/src/browser/tests/crypto.html +++ b/src/browser/tests/crypto.html @@ -261,3 +261,96 @@ testing.expectEqual("6cad17e6f3f76680d6dd18ed043b75b4f6e1aa1d08b917294942e882fb6466c3510948c34af8b903ed0725b582b3b39c0e485ae2c1b7dfdb192ee38b79c782b6", await hash('sha-512', 'over 9000')); }); + + + + + + + + + + + + + + diff --git a/src/browser/webapi/SubtleCrypto.zig b/src/browser/webapi/SubtleCrypto.zig index f6b45ccf..3220352c 100644 --- a/src/browser/webapi/SubtleCrypto.zig +++ b/src/browser/webapi/SubtleCrypto.zig @@ -20,7 +20,6 @@ const std = @import("std"); const lp = @import("lightpanda"); const crypto = @import("../../sys/libcrypto.zig"); -const Frame = @import("../Frame.zig"); const js = @import("../js/js.zig"); const CryptoKey = @import("CryptoKey.zig"); @@ -34,6 +33,7 @@ const X25519 = @import("crypto/X25519.zig"); const log = lp.log; const String = lp.String; +const Execution = js.Execution; /// The SubtleCrypto interface of the Web Crypto API provides a number of low-level /// cryptographic functions. @@ -49,11 +49,11 @@ pub fn generateKey( algo: algorithm.Init, extractable: bool, key_usages: []const []const u8, - frame: *Frame, + exec: *const Execution, ) !js.Promise { - const local = frame.js.local.?; + const local = exec.context.local.?; switch (algo) { - .hmac_key_gen => |params| return HMAC.init(params, extractable, key_usages, frame), + .hmac_key_gen => |params| return HMAC.init(params, extractable, key_usages, exec), .aes_key_gen => |params| { AES.validate(params, key_usages) catch |err| { return local.rejectPromise(.{ .dom_exception = .{ .err = err } }); @@ -72,8 +72,8 @@ pub fn generateKey( }; log.warn(.not_implemented, "generateKey", .{ .name = params.name }); }, - .name => |js_name| return generateKeyFromName(try js_name.toSSO(false), extractable, key_usages, frame), - .object => |object| return generateKeyFromName(try object.name.toSSO(false), extractable, key_usages, frame), + .name => |js_name| return generateKeyFromName(try js_name.toSSO(false), extractable, key_usages, exec), + .object => |object| return generateKeyFromName(try object.name.toSSO(false), extractable, key_usages, exec), .invalid => return local.rejectPromise(.{ .type_error = "invalid algorithm" }), } @@ -84,10 +84,10 @@ fn generateKeyFromName( name: String, extractable: bool, key_usages: []const []const u8, - frame: *Frame, + exec: *const Execution, ) !js.Promise { - return _generateKeyFromName(name, extractable, key_usages, frame) catch |err| { - return frame.js.local.?.rejectPromise(.{ .dom_exception = .{ .err = err } }); + return _generateKeyFromName(name, extractable, key_usages, exec) catch |err| { + return exec.context.local.?.rejectPromise(.{ .dom_exception = .{ .err = err } }); }; } @@ -95,10 +95,10 @@ fn _generateKeyFromName( name: String, extractable: bool, key_usages: []const []const u8, - frame: *Frame, + exec: *const Execution, ) !js.Promise { if (name.eql(comptime .wrap("X25519"))) { - return X25519.init(extractable, key_usages, frame); + return X25519.init(extractable, key_usages, exec); } { @@ -145,14 +145,15 @@ pub fn exportKey( _: *const SubtleCrypto, format: []const u8, key: *CryptoKey, - frame: *Frame, + exec: *const Execution, ) !js.Promise { + const local = exec.context.local.?; if (!key.canExportKey()) { - return frame.js.local.?.rejectPromise(.{ .dom_exception = .{ .err = error.InvalidAccessError } }); + return local.rejectPromise(.{ .dom_exception = .{ .err = error.InvalidAccessError } }); } if (std.mem.eql(u8, format, "raw")) { - return frame.js.local.?.resolvePromise(js.ArrayBuffer{ .values = key._key }); + return local.resolvePromise(js.ArrayBuffer{ .values = key._key }); } const is_unsupported = std.mem.eql(u8, format, "pkcs8") or @@ -160,10 +161,10 @@ pub fn exportKey( if (is_unsupported) { log.warn(.not_implemented, "SubtleCrypto.exportKey", .{ .format = format }); - return frame.js.local.?.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } }); + return local.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } }); } - return frame.js.local.?.rejectPromise(.{ .type_error = "invalid format" }); + return local.rejectPromise(.{ .type_error = "invalid format" }); } /// Derive a secret key from a master key. @@ -172,27 +173,28 @@ pub fn deriveBits( algo: algorithm.Derive, base_key: *const CryptoKey, // Private key. length: usize, - frame: *Frame, + exec: *const Execution, ) !js.Promise { + const local = exec.context.local.?; return switch (algo) { .ecdh_or_x25519 => |params| { const name = params.name; if (std.mem.eql(u8, name, "X25519")) { - const result = X25519.deriveBits(base_key, params.public, length, frame) catch |err| switch (err) { - error.InvalidAccessError => return frame.js.local.?.rejectPromise(.{ + const result = X25519.deriveBits(base_key, params.public, length, exec) catch |err| switch (err) { + error.InvalidAccessError => return local.rejectPromise(.{ .dom_exception = .{ .err = error.InvalidAccessError }, }), else => return err, }; - return frame.js.local.?.resolvePromise(result); + return local.resolvePromise(result); } if (std.mem.eql(u8, name, "ECDH")) { log.warn(.not_implemented, "SubtleCrypto.deriveBits", .{ .name = name }); } - return frame.js.local.?.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } }); + return local.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } }); }, }; } @@ -204,14 +206,14 @@ pub fn sign( algo: algorithm.Sign, key: *CryptoKey, data: []const u8, // ArrayBuffer. - frame: *Frame, + exec: *const Execution, ) !js.Promise { return switch (key._type) { // Call sign for HMAC. - .hmac => return HMAC.sign(algo, key, data, frame), + .hmac => return HMAC.sign(algo, key, data, exec), else => { log.warn(.not_implemented, "SubtleCrypto.sign", .{ .key_type = key._type }); - return frame.js.local.?.rejectPromise(.{ .dom_exception = .{ .err = error.InvalidAccessError } }); + return exec.context.local.?.rejectPromise(.{ .dom_exception = .{ .err = error.InvalidAccessError } }); }, }; } @@ -223,33 +225,34 @@ pub fn verify( key: *const CryptoKey, signature: []const u8, // ArrayBuffer. data: []const u8, // ArrayBuffer. - frame: *Frame, + exec: *const Execution, ) !js.Promise { + const local = exec.context.local.?; if (!algo.isHMAC()) { - return frame.js.local.?.rejectPromise(.{ .dom_exception = .{ .err = error.InvalidAccessError } }); + return local.rejectPromise(.{ .dom_exception = .{ .err = error.InvalidAccessError } }); } return switch (key._type) { - .hmac => HMAC.verify(key, signature, data, frame), - else => frame.js.local.?.rejectPromise(.{ .dom_exception = .{ .err = error.InvalidAccessError } }), + .hmac => HMAC.verify(key, signature, data, exec), + else => local.rejectPromise(.{ .dom_exception = .{ .err = error.InvalidAccessError } }), }; } /// Generates a digest of the given data, using the specified hash function. -pub fn digest(_: *const SubtleCrypto, algo: []const u8, data: js.TypedArray(u8), frame: *Frame) !js.Promise { - const local = frame.js.local.?; +pub fn digest(_: *const SubtleCrypto, algo: []const u8, data: js.TypedArray(u8), exec: *const Execution) !js.Promise { + const local = exec.context.local.?; if (algo.len > 10) { return local.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } }); } - const normalized = std.ascii.upperString(&frame.buf, algo); + const normalized = std.ascii.upperString(exec.buf, algo); const digest_type = crypto.findDigest(normalized) catch { return local.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } }); }; const bytes = data.values; - const out = frame.buf[0..crypto.EVP_MAX_MD_SIZE]; + const out = exec.buf[0..crypto.EVP_MAX_MD_SIZE]; var out_size: c_uint = 0; const result = crypto.EVP_Digest(bytes.ptr, bytes.len, out, &out_size, digest_type, null); lp.assert(result == 1, "SubtleCrypto.digest", .{ .algo = algo }); diff --git a/src/browser/webapi/crypto/HMAC.zig b/src/browser/webapi/crypto/HMAC.zig index 9d0d4335..3ab0e507 100644 --- a/src/browser/webapi/crypto/HMAC.zig +++ b/src/browser/webapi/crypto/HMAC.zig @@ -22,19 +22,20 @@ const std = @import("std"); const lp = @import("lightpanda"); const crypto = @import("../../../sys/libcrypto.zig"); -const Frame = @import("../../Frame.zig"); const js = @import("../../js/js.zig"); const algorithm = @import("algorithm.zig"); const CryptoKey = @import("../CryptoKey.zig"); +const Execution = js.Execution; + pub fn init( params: algorithm.Init.HmacKeyGen, extractable: bool, key_usages: []const []const u8, - frame: *Frame, + exec: *const Execution, ) !js.Promise { - const local = frame.js.local.?; + const local = exec.context.local.?; // Per spec, an unrecognized hash is caught during algorithm normalization // and surfaces as NotSupportedError. const digest = crypto.findDigest(switch (params.hash) { @@ -74,14 +75,14 @@ pub fn init( }; // Should we reject this in promise too? - const key = try frame.arena.alloc(u8, block_size); - errdefer frame.arena.free(key); + const key = try exec.arena.alloc(u8, block_size); + errdefer exec.arena.free(key); // HMAC is simply CSPRNG. const res = crypto.RAND_bytes(key.ptr, key.len); lp.assert(res == 1, "HMAC.init", .{ .res = res }); - const crypto_key = try frame._factory.create(CryptoKey{ + const crypto_key = try exec._factory.create(CryptoKey{ ._type = .hmac, ._extractable = extractable, ._usages = mask, @@ -96,16 +97,16 @@ pub fn sign( algo: algorithm.Sign, crypto_key: *const CryptoKey, data: []const u8, - frame: *Frame, + exec: *const Execution, ) !js.Promise { - var resolver = frame.js.local.?.createPromiseResolver(); + var resolver = exec.context.local.?.createPromiseResolver(); if (!algo.isHMAC() or !crypto_key.canSign()) { resolver.rejectError("HMAC.sign", .{ .dom_exception = .{ .err = error.InvalidAccessError } }); return resolver.promise(); } - const buffer = try frame.call_arena.alloc(u8, crypto.EVP_MD_size(crypto_key.getDigest())); + const buffer = try exec.call_arena.alloc(u8, crypto.EVP_MD_size(crypto_key.getDigest())); var out_len: u32 = 0; // Try to sign. _ = crypto.HMAC( @@ -117,7 +118,7 @@ pub fn sign( buffer.ptr, &out_len, ) orelse { - frame.call_arena.free(buffer); + exec.call_arena.free(buffer); // Failure. resolver.rejectError("HMAC.sign", .{ .dom_exception = .{ .err = error.InvalidAccessError } }); return resolver.promise(); @@ -132,9 +133,9 @@ pub fn verify( crypto_key: *const CryptoKey, signature: []const u8, data: []const u8, - frame: *Frame, + exec: *const Execution, ) !js.Promise { - var resolver = frame.js.local.?.createPromiseResolver(); + var resolver = exec.context.local.?.createPromiseResolver(); if (!crypto_key.canVerify()) { resolver.rejectError("HMAC.verify", .{ .dom_exception = .{ .err = error.InvalidAccessError } }); diff --git a/src/browser/webapi/crypto/X25519.zig b/src/browser/webapi/crypto/X25519.zig index 968d1833..aaa1798c 100644 --- a/src/browser/webapi/crypto/X25519.zig +++ b/src/browser/webapi/crypto/X25519.zig @@ -22,21 +22,22 @@ const std = @import("std"); const lp = @import("lightpanda"); const crypto = @import("../../../sys/libcrypto.zig"); -const Frame = @import("../../Frame.zig"); const js = @import("../../js/js.zig"); const CryptoKey = @import("../CryptoKey.zig"); +const Execution = js.Execution; + pub fn init( extractable: bool, key_usages: []const []const u8, - frame: *Frame, + exec: *const Execution, ) !js.Promise { // This code has too many allocations here and there, might be nice to // gather them together with a single alloc call. Not sure if factory // pattern is suitable for it though. - const local = frame.js.local.?; + const local = exec.context.local.?; // Calculate usages; only matters for private key. // Only deriveKey() and deriveBits() be used for X25519. @@ -59,11 +60,11 @@ pub fn init( }); } - const public_value = try frame.arena.alloc(u8, crypto.X25519_PUBLIC_VALUE_LEN); - errdefer frame.arena.free(public_value); + const public_value = try exec.arena.alloc(u8, crypto.X25519_PUBLIC_VALUE_LEN); + errdefer exec.arena.free(public_value); - const private_key = try frame.arena.alloc(u8, crypto.X25519_PRIVATE_KEY_LEN); - errdefer frame.arena.free(private_key); + const private_key = try exec.arena.alloc(u8, crypto.X25519_PRIVATE_KEY_LEN); + errdefer exec.arena.free(private_key); // There's no info about whether this can fail; so I assume it cannot. crypto.X25519_keypair(@ptrCast(public_value), @ptrCast(private_key)); @@ -90,16 +91,16 @@ pub fn init( private_key.len, ) orelse return error.OutOfMemory; - const private = try frame._factory.create(CryptoKey{ + const private = try exec._factory.create(CryptoKey{ ._type = .x25519, ._extractable = extractable, ._usages = mask, ._key = private_key, ._vary = .{ .pkey = private_pkey }, }); - errdefer frame._factory.destroy(private); + errdefer exec._factory.destroy(private); - const public = try frame._factory.create(CryptoKey{ + const public = try exec._factory.create(CryptoKey{ ._type = .x25519, // Public keys are always extractable. ._extractable = true, @@ -116,7 +117,7 @@ pub fn deriveBits( private: *const CryptoKey, public: *const CryptoKey, length_in_bits: usize, - frame: *Frame, + exec: *const Execution, ) !js.ArrayBuffer { if (!private.canDeriveBits()) { return error.InvalidAccessError; @@ -137,8 +138,8 @@ pub fn deriveBits( return error.Internal; } - const derived_key = try frame.call_arena.alloc(u8, 32); - errdefer frame.call_arena.free(derived_key); + const derived_key = try exec.call_arena.alloc(u8, 32); + errdefer exec.call_arena.free(derived_key); var out_key_len: usize = derived_key.len; const result = crypto.EVP_PKEY_derive(ctx, derived_key.ptr, &out_key_len); From 042df590a2e6ab746331ede71647f7274e47d48a Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 6 May 2026 18:42:06 +0800 Subject: [PATCH 3/5] Abort http_client _before_ destroying context XHR, for example, needs the context to properly shutdown. It get the page via exec.context.page. Now, we could store the page on the XHR instance, sure. But the point is the js.Context should never need the HttpClient, so it should always be safe to abort inflight requests before canceling the context. Fixes some flaky WPT crashes. --- src/browser/Frame.zig | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 51c6254b..7ea7474c 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -405,6 +405,10 @@ pub fn deinit(self: *Frame) void { } const browser = page.session.browser; + + // don't abort pending frames. + browser.http_client.abortFrame(self._frame_id, .{}); + browser.env.destroyContext(self.js); // Must be after context is destroyed. A finalizer can reach into the *Worker @@ -415,9 +419,6 @@ pub fn deinit(self: *Frame) void { self._script_manager.base.shutdown = true; - // don't abort pending frames. - browser.http_client.abortFrame(self._frame_id, .{}); - self._script_manager.deinit(); self._style_manager.deinit(); From 15021b730282f0ef3246668ef7db70344ef88c6d Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 6 May 2026 18:57:31 +0800 Subject: [PATCH 4/5] add WorkerLocation This is a distinct type from Location, a read-only subset. --- src/browser/js/bridge.zig | 6 +- src/browser/tests/worker/api-worker.js | 15 +++++ src/browser/tests/worker/worker.html | 8 +++ src/browser/webapi/WorkerGlobalScope.zig | 9 +++ src/browser/webapi/WorkerLocation.zig | 83 ++++++++++++++++++++++++ 5 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 src/browser/webapi/WorkerLocation.zig diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index f2ec997e..a5907f4a 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -940,6 +940,7 @@ pub const PageJsApis = flattenTypes(&.{ // TODO: Expand this list to include all worker-appropriate APIs. pub const WorkerJsApis = flattenTypes(&.{ @import("../webapi/WorkerGlobalScope.zig"), + @import("../webapi/WorkerLocation.zig"), @import("../webapi/EventTarget.zig"), @import("../webapi/DOMException.zig"), @import("../webapi/net/URLSearchParams.zig"), @@ -977,7 +978,10 @@ pub const WorkerJsApis = flattenTypes(&.{ // to know about all possible types. Individual snapshots use their own // subsets (PageJsApis, WorkerSnapshot.JsApis). pub const JsApis = blk: { - const base = PageJsApis ++ [_]type{@import("../webapi/WorkerGlobalScope.zig").JsApi}; + const base = PageJsApis ++ [_]type{ + @import("../webapi/WorkerGlobalScope.zig").JsApi, + @import("../webapi/WorkerLocation.zig").JsApi, + }; if (lp.build_config.wpt_extensions == false) { break :blk base; } diff --git a/src/browser/tests/worker/api-worker.js b/src/browser/tests/worker/api-worker.js index edfea446..1f75d7e1 100644 --- a/src/browser/tests/worker/api-worker.js +++ b/src/browser/tests/worker/api-worker.js @@ -50,6 +50,15 @@ const blob_url_is_blob = blob_url.startsWith('blob:'); URL.revokeObjectURL(blob_url); + // self.location + const loc = self.location; + const loc_is_worker_location = loc instanceof WorkerLocation; + const loc_identity_stable = self.location === loc; + const loc_href = loc.href; + const loc_protocol = loc.protocol; + const loc_pathname = loc.pathname; + const loc_to_string = String(loc); + postMessage({ ok: true, results: { @@ -75,6 +84,12 @@ pre_aborted, pre_threw, blob_url_is_blob, + loc_is_worker_location, + loc_identity_stable, + loc_href, + loc_protocol, + loc_pathname, + loc_to_string, }, }); } catch (e) { diff --git a/src/browser/tests/worker/worker.html b/src/browser/tests/worker/worker.html index e660b492..2740d7c0 100644 --- a/src/browser/tests/worker/worker.html +++ b/src/browser/tests/worker/worker.html @@ -220,6 +220,14 @@ // URL.createObjectURL / revokeObjectURL testing.expectEqual(true, r.blob_url_is_blob); + + // WorkerLocation + testing.expectEqual(true, r.loc_is_worker_location); + testing.expectEqual(true, r.loc_identity_stable); + testing.expectTrue(r.loc_href.endsWith('api-worker.js')); + testing.expectEqual(r.loc_href, r.loc_to_string); + testing.expectEqual('http:', r.loc_protocol); + testing.expectTrue(r.loc_pathname.endsWith('api-worker.js')); }); } diff --git a/src/browser/webapi/WorkerGlobalScope.zig b/src/browser/webapi/WorkerGlobalScope.zig index 1034f0e6..e9b8fe13 100644 --- a/src/browser/webapi/WorkerGlobalScope.zig +++ b/src/browser/webapi/WorkerGlobalScope.zig @@ -39,6 +39,7 @@ const Crypto = @import("Crypto.zig"); const Console = @import("Console.zig"); const Timers = @import("Timers.zig"); const EventTarget = @import("EventTarget.zig"); +const WorkerLocation = @import("WorkerLocation.zig"); const MessageEvent = @import("event/MessageEvent.zig"); const ErrorEvent = @import("event/ErrorEvent.zig"); const Fetch = @import("net/Fetch.zig"); @@ -98,6 +99,8 @@ _on_unhandled_rejection: ?JS.Function.Global = null, _on_message: ?JS.Function.Global = null, _on_messageerror: ?JS.Function.Global = null, +_location: WorkerLocation, + _timers: Timers = .{}, pub fn init(worker: *Worker, url: [:0]const u8) !*WorkerGlobalScope { @@ -125,6 +128,7 @@ pub fn init(worker: *Worker, url: [:0]const u8) !*WorkerGlobalScope { ._loader_id = worker._loader_id, ._event_manager = .init(arena), ._script_manager = undefined, + ._location = .{ ._url = url }, }); errdefer factory.destroy(self); @@ -218,6 +222,10 @@ pub fn getCrypto(self: *WorkerGlobalScope) *Crypto { return &self._crypto; } +pub fn getLocation(self: *WorkerGlobalScope) *WorkerLocation { + return &self._location; +} + pub fn getOnError(self: *const WorkerGlobalScope) ?JS.Function.Global { return self._on_error; } @@ -566,6 +574,7 @@ pub const JsApi = struct { pub const self = bridge.accessor(WorkerGlobalScope.getSelf, null, .{}); pub const console = bridge.accessor(WorkerGlobalScope.getConsole, null, .{}); pub const crypto = bridge.accessor(WorkerGlobalScope.getCrypto, null, .{}); + pub const location = bridge.accessor(WorkerGlobalScope.getLocation, null, .{}); pub const onerror = bridge.accessor(WorkerGlobalScope.getOnError, WorkerGlobalScope.setOnError, .{}); pub const onrejectionhandled = bridge.accessor(WorkerGlobalScope.getOnRejectionHandled, WorkerGlobalScope.setOnRejectionHandled, .{}); diff --git a/src/browser/webapi/WorkerLocation.zig b/src/browser/webapi/WorkerLocation.zig new file mode 100644 index 00000000..6de8e9fd --- /dev/null +++ b/src/browser/webapi/WorkerLocation.zig @@ -0,0 +1,83 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../js/js.zig"); + +const U = @import("../URL.zig"); + +const WorkerLocation = @This(); + +// Workers can't navigate, so the URL is fixed for the lifetime of the worker. +_url: [:0]const u8, + +pub fn getProtocol(self: *const WorkerLocation) []const u8 { + return U.getProtocol(self._url); +} + +pub fn getHostname(self: *const WorkerLocation) []const u8 { + return U.getHostname(self._url); +} + +pub fn getHost(self: *const WorkerLocation) []const u8 { + return U.getHost(self._url); +} + +pub fn getPort(self: *const WorkerLocation) []const u8 { + return U.getPort(self._url); +} + +pub fn getPathname(self: *const WorkerLocation) []const u8 { + return U.getPathname(self._url); +} + +pub fn getSearch(self: *const WorkerLocation) []const u8 { + return U.getSearch(self._url); +} + +pub fn getHash(self: *const WorkerLocation) []const u8 { + return U.getHash(self._url); +} + +pub fn getOrigin(self: *const WorkerLocation, exec: *const js.Execution) ![]const u8 { + return (try U.getOrigin(exec.call_arena, self._url)) orelse "null"; +} + +pub fn toString(self: *const WorkerLocation) [:0]const u8 { + return self._url; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(WorkerLocation); + + pub const Meta = struct { + pub const name = "WorkerLocation"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const toString = bridge.function(WorkerLocation.toString, .{}); + pub const href = bridge.accessor(WorkerLocation.toString, null, .{}); + pub const origin = bridge.accessor(WorkerLocation.getOrigin, null, .{}); + pub const protocol = bridge.accessor(WorkerLocation.getProtocol, null, .{}); + pub const host = bridge.accessor(WorkerLocation.getHost, null, .{}); + pub const hostname = bridge.accessor(WorkerLocation.getHostname, null, .{}); + pub const port = bridge.accessor(WorkerLocation.getPort, null, .{}); + pub const pathname = bridge.accessor(WorkerLocation.getPathname, null, .{}); + pub const search = bridge.accessor(WorkerLocation.getSearch, null, .{}); + pub const hash = bridge.accessor(WorkerLocation.getHash, null, .{}); +}; From f352c49a3aa8268e02c3f7c766c32d07196523ca Mon Sep 17 00:00:00 2001 From: Patrick Wyatt Date: Wed, 6 May 2026 12:44:38 -0700 Subject: [PATCH 5/5] Fix /json/version advertising ws://host:0/ when --port 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildJSONVersionResponse read the port from app.config.port(), which is the configured value (still 0 with --port 0). Pass the OS-assigned port from the bound address instead, so webSocketDebuggerUrl matches the actual listener. The integration test previously asserted a literal ws://127.0.0.1:9222/ that happened to match the test config's default port but not the harness's hardcoded bind address (9583) — drop the URL assertion; the buildJSONVersionResponse unit test already covers URL formatting. --- src/Server.zig | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/Server.zig b/src/Server.zig index 0b5d8ef5..dd8e75ba 100644 --- a/src/Server.zig +++ b/src/Server.zig @@ -44,23 +44,22 @@ conns_mutex: std.Thread.Mutex = .{}, conns_pool: std.heap.MemoryPool(CDP), pub fn init(app: *App, address: net.Address) !*Server { - const json_version_response = try buildJSONVersionResponse(app); - errdefer app.allocator.free(json_version_response); - const self = try app.allocator.create(Server); errdefer app.allocator.destroy(self); self.* = .{ .app = app, .conns_pool = .init(app.allocator), - .json_version_response = json_version_response, + .json_version_response = "", }; errdefer self.conns_pool.deinit(); + // Bind first so /json/version can advertise the OS-assigned port (--port 0). var bound_address = address; try self.app.network.bind(&bound_address, self, onAccept); log.info(.app, "server running", .{ .address = bound_address }); + self.json_version_response = try buildJSONVersionResponse(app, bound_address.getPort()); return self; } @@ -237,10 +236,7 @@ fn unregisterConn(self: *Server, conn: *CDP) void { // Utils // -------- -fn buildJSONVersionResponse( - app: *const App, -) ![]const u8 { - const port = app.config.port(); +fn buildJSONVersionResponse(app: *const App, port: u16) ![]const u8 { const host = app.config.advertiseHost(); if (std.mem.eql(u8, host, "0.0.0.0")) { log.info(.cdp, "unreachable advertised host", .{ @@ -276,7 +272,7 @@ pub const milliTimestamp = @import("datetime.zig").milliTimestamp; const testing = @import("testing.zig"); test "server: buildJSONVersionResponse" { - const res = try buildJSONVersionResponse(testing.test_app); + const res = try buildJSONVersionResponse(testing.test_app, testing.test_app.config.port()); defer testing.test_app.allocator.free(res); // The response includes the build version, so check structure rather than exact bytes. @@ -509,7 +505,7 @@ test "server: get /json/version" { try testing.expect(std.mem.startsWith(u8, res1, "HTTP/1.1 200 OK\r\n")); try testing.expect(std.mem.indexOf(u8, res1, "\"Browser\": \"Lightpanda/") != null); try testing.expect(std.mem.indexOf(u8, res1, "\"Protocol-Version\": \"1.3\"") != null); - try testing.expect(std.mem.indexOf(u8, res1, "\"webSocketDebuggerUrl\": \"ws://127.0.0.1:9222/\"") != null); + try testing.expect(std.mem.indexOf(u8, res1, "\"webSocketDebuggerUrl\": \"ws://127.0.0.1:9583/\"") != null); } {