Merge branch 'main' into agent

This commit is contained in:
Adrià Arrufat
2026-04-16 09:21:48 +02:00
29 changed files with 485 additions and 105 deletions

View File

@@ -13,7 +13,7 @@ inputs:
zig-v8:
description: 'zig v8 version to install'
required: false
default: 'v0.3.9'
default: 'v0.4.0'
v8:
description: 'v8 version to install'
required: false

View File

@@ -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.9
ARG ZIG_V8=v0.4.0
ARG TARGETPLATFORM
RUN apt-get update -yq && \

View File

@@ -62,8 +62,12 @@ chmod a+x ./lightpanda
*For Windows + WSL2*
The Lightpanda browser is compatible to run on windows inside WSL. Follow the Linux instruction for installation from a WSL terminal.
It is recommended to install clients like Puppeteer on the Windows host.
Lightpanda has no native Windows binary. Install it inside WSL following the Linux steps above.
WSL not installed? Run `wsl --install` from an administrator shell, restart, then open `wsl`.
See [Microsoft's WSL install guide](https://learn.microsoft.com/en-us/windows/wsl/install) for details.
Your automation client (Puppeteer, Playwright, etc.) can run either inside WSL or on the Windows host. WSL forwards `localhost:9222` automatically.
**Install from Docker**

View File

@@ -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.9.tar.gz",
.hash = "v8-0.0.0-xddH64iHBACfPm7oAqZerjmLLO6ftP4Yg5V7dtEGcD0i",
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.4.0.tar.gz",
.hash = "v8-0.0.0-xddH61yIBAD04dV4CHW0qIFiqbOGvkN_-amGdmgbQ3dU",
},
// .v8 = .{ .path = "../zig-v8-fork" },
.brotli = .{

View File

@@ -114,7 +114,7 @@ pub fn httpMaxResponseSize(self: *const Config) ?usize {
}
pub fn wsMaxConcurrent(self: *const Config) u8 {
return self.commonOpts().ws_max_concurrent orelse 8;
return self.commonOpts().ws_max_concurrent orelse 64;
}
pub fn logLevel(self: *const Config) ?log.Level {
@@ -1200,20 +1200,14 @@ fn parseCommonArg(
return error.InvalidArgument;
};
for (str) |c| {
if (!std.ascii.isPrint(c)) {
log.fatal(.app, "not printable character", .{ .arg = opt });
return error.InvalidArgument;
}
}
if (std.ascii.indexOfIgnoreCase(str, "mozilla") != null) {
validateUserAgent(str) catch |err| {
log.fatal(.app, "invalid value", .{
.detail = "user-agent can't contain Mozilla",
.detail = "invalid user agent",
.arg = opt,
.err = err,
});
return error.InvalidArgument;
}
};
common.user_agent = try allocator.dupe(u8, str);
return true;
@@ -1295,3 +1289,61 @@ fn parseCommonArg(
return false;
}
pub fn validateUserAgent(ua: []const u8) !void {
for (ua) |c| {
if (!std.ascii.isPrint(c)) {
return error.NonPrintable;
}
}
if (std.ascii.indexOfIgnoreCase(ua, "mozilla") != null) {
return error.Reserved;
}
}
const testing = @import("testing.zig");
test "Config: HttpHeaders - default user agent" {
var config = try Config.init(testing.allocator, "", .{ .serve = .{} });
defer config.deinit(testing.allocator);
try testing.expectEqual("Lightpanda/1.0", config.http_headers.user_agent);
try testing.expectEqual("User-Agent: Lightpanda/1.0", config.http_headers.user_agent_header);
try testing.expect(config.http_headers.proxy_bearer_header == null);
}
test "Config: HttpHeaders - custom user agent override" {
var config = try Config.init(testing.allocator, "", .{ .serve = .{ .common = .{ .user_agent = "MyBot/2.0" } } });
defer config.deinit(testing.allocator);
try testing.expectEqual("MyBot/2.0", config.http_headers.user_agent);
try testing.expectEqual("User-Agent: MyBot/2.0", config.http_headers.user_agent_header);
}
test "Config: HttpHeaders - user agent suffix" {
var config = try Config.init(testing.allocator, "", .{ .serve = .{ .common = .{ .user_agent_suffix = "CustomSuffix/3.0" } } });
defer config.deinit(testing.allocator);
try testing.expectEqual("Lightpanda/1.0 CustomSuffix/3.0", config.http_headers.user_agent);
try testing.expectEqual("User-Agent: Lightpanda/1.0 CustomSuffix/3.0", config.http_headers.user_agent_header);
}
test "Config: HttpHeaders - fetch mode default user agent" {
var config = try Config.init(testing.allocator, "", .{ .fetch = .{ .url = "https://example.com" } });
defer config.deinit(testing.allocator);
try testing.expectEqual("Lightpanda/1.0", config.http_headers.user_agent);
}
test "Config: HttpHeaders - fetch mode custom user agent" {
var config = try Config.init(testing.allocator, "", .{ .fetch = .{ .url = "https://example.com", .common = .{ .user_agent = "FetchBot/1.0" } } });
defer config.deinit(testing.allocator);
try testing.expectEqual("FetchBot/1.0", config.http_headers.user_agent);
try testing.expectEqual("User-Agent: FetchBot/1.0", config.http_headers.user_agent_header);
}
test "Config: HttpHeaders - proxy bearer header" {
var config = try Config.init(testing.allocator, "", .{ .serve = .{ .common = .{ .proxy_bearer_token = "secret-token" } } });
defer config.deinit(testing.allocator);
try testing.expectEqual("Lightpanda/1.0", config.http_headers.user_agent);
try testing.expectEqual("Proxy-Authorization: Bearer secret-token", config.http_headers.proxy_bearer_header.?);
}

View File

@@ -115,6 +115,12 @@ tls_verify: bool = true,
obey_robots: bool,
// User agent override set via CDP Emulation.setUserAgentOverride.
// When set, takes precedence over the config's http_headers values.
// Both fields are allocated from self.allocator when set, null otherwise.
user_agent_override: ?[:0]const u8 = null,
user_agent_header_override: ?[:0]const u8 = null,
cdp_client: ?CDPClient = null,
max_response_size: usize,
@@ -177,9 +183,33 @@ pub fn deinit(self: *Client) void {
}
self.pending_robots_queue.deinit(self.allocator);
self.clearUserAgentOverride();
self.allocator.destroy(self);
}
// Set a user agent override. Both the raw UA string and the pre-formatted
// "User-Agent: <ua>" header string are allocated from self.allocator.
pub fn setUserAgentOverride(self: *Client, ua: []const u8) !void {
self.clearUserAgentOverride();
const override = try self.allocator.dupeZ(u8, ua);
errdefer self.allocator.free(override);
const header = try std.fmt.allocPrintSentinel(self.allocator, "User-Agent: {s}", .{ua}, 0);
self.user_agent_override = override;
self.user_agent_header_override = header;
}
// Clear any user agent override, restoring the default from config.
pub fn clearUserAgentOverride(self: *Client) void {
if (self.user_agent_override) |ua| {
self.allocator.free(ua);
self.user_agent_override = null;
}
if (self.user_agent_header_override) |uah| {
self.allocator.free(uah);
self.user_agent_header_override = null;
}
}
// Enable TLS verification on all connections.
pub fn setTlsVerify(self: *Client, verify: bool) !void {
// Remove inflight connections check on enable TLS b/c chromiumoxide calls
@@ -209,7 +239,12 @@ pub fn changeProxy(self: *Client, proxy: ?[:0]const u8) !void {
}
pub fn newHeaders(self: *const Client) !http.Headers {
return http.Headers.init(self.network.config.http_headers.user_agent_header);
const ua_header = self.user_agent_header_override orelse self.network.config.http_headers.user_agent_header;
return http.Headers.init(ua_header);
}
pub fn getUserAgent(self: *const Client) [:0]const u8 {
return self.user_agent_override orelse self.network.config.http_headers.user_agent;
}
pub fn abort(self: *Client) void {
@@ -512,7 +547,7 @@ fn robotsDoneCallback(ctx_ptr: *anyopaque) !void {
200 => {
if (ctx.buffer.items.len > 0) {
const robots: ?Robots = ctx.client.network.robot_store.robotsFromBytes(
ctx.client.network.config.http_headers.user_agent,
ctx.client.getUserAgent(),
ctx.buffer.items,
) catch blk: {
log.warn(.browser, "failed to parse robots", .{ .robots_url = ctx.robots_url });

View File

@@ -142,7 +142,11 @@ _blob_urls: std.StringHashMapUnmanaged(*Blob) = .{},
/// `load` events that'll be fired before window's `load` event.
/// A call to `documentIsComplete` (which calls `_documentIsComplete`) resets it.
_to_load: std.ArrayList(*Element.Html) = .{},
/// Double-buffered so that dispatching load events (which may trigger JS that
/// creates new elements) doesn't invalidate the list while iterating.
_to_load_1: std.ArrayList(*Element.Html) = .{},
_to_load_2: std.ArrayList(*Element.Html) = .{},
_to_load: *std.ArrayList(*Element.Html) = undefined,
_style_manager: StyleManager,
_script_manager: ScriptManager,
@@ -281,6 +285,7 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void
._script_manager = undefined,
._event_manager = EventManager.init(session.page_arena, self),
};
self._to_load = &self._to_load_1;
var screen: *Screen = undefined;
var visual_viewport: *VisualViewport = undefined;
@@ -321,7 +326,7 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void
.identity_arena = session.page_arena,
.call_arena = self.call_arena,
});
errdefer self.js.deinit();
errdefer browser.env.destroyContext(self.js);
document._page = self;
@@ -386,7 +391,12 @@ pub fn deinit(self: *Page, abort_http: bool) void {
observer.releaseRef(session);
}
self.window._document._selection.releaseRef(session);
var document = self.window._document;
document._selection.releaseRef(session);
if (document._fonts) |f| {
f.releaseRef(session);
}
}
session.browser.env.destroyContext(self.js);
@@ -463,7 +473,7 @@ pub fn releaseArena(self: *Page, allocator: Allocator) void {
return self._session.releaseArena(allocator);
}
pub fn isSameOrigin(self: *const Page, url: [:0]const u8) !bool {
pub fn isSameOrigin(self: *const Page, url: [:0]const u8) bool {
const current_origin = self.origin orelse return false;
// fastpath
@@ -1448,14 +1458,22 @@ pub fn checkIntersections(self: *Page) !void {
pub fn dispatchLoad(self: *Page) !void {
const has_dom_load_listener = self._event_manager.has_dom_load_listener;
for (self._to_load.items) |html_element| {
// Swap buffers - new additions during dispatch go to the other buffer
const to_process = self._to_load;
self._to_load = if (self._to_load == &self._to_load_1)
&self._to_load_2
else
&self._to_load_1;
for (to_process.items) |html_element| {
if (has_dom_load_listener or html_element.hasAttributeFunction(.onload, self)) {
const event = try Event.initTrusted(comptime .wrap("load"), .{}, self);
try self._event_manager.dispatch(html_element.asEventTarget(), event);
}
}
// We drained everything.
self._to_load.clearRetainingCapacity();
to_process.clearRetainingCapacity();
}
pub fn scheduleMutationDelivery(self: *Page) !void {
@@ -3699,6 +3717,8 @@ fn asUint(comptime string: anytype) std.meta.Int(
const testing = @import("../testing.zig");
test "WebApi: Page" {
const filter: testing.LogFilter = .init(&.{.http});
defer filter.deinit();
try testing.htmlRunner("page", .{});
}

View File

@@ -75,6 +75,10 @@ identity: js.Identity = .{},
// This ensures objects are only freed when ALL v8 wrappers are gone.
finalizer_callbacks: std.AutoHashMapUnmanaged(usize, *FinalizerCallback) = .empty,
// Pool for FinalizerCallback.Identity structs. These must survive page resets
// so V8 weak callbacks can validate the FC before dereferencing it.
fc_identity_pool: std.heap.MemoryPool(FinalizerCallback.Identity),
// Tracked global v8 objects that need to be released on cleanup.
// Lives at Session level so objects can outlive individual Identities.
globals: std.ArrayList(v8.Global) = .empty,
@@ -133,6 +137,7 @@ pub fn init(self: *Session, browser: *Browser, notification: *Notification) !voi
.queued_queued_navigation = .{},
.notification = notification,
.cookie_jar = storage.Cookie.Jar.init(allocator),
.fc_identity_pool = .init(allocator),
};
self.queued_navigation = &self.queued_navigation_1;
}
@@ -142,6 +147,7 @@ pub fn deinit(self: *Session) void {
self.removePage();
}
self.cookie_jar.deinit();
self.fc_identity_pool.deinit();
self.storage_shed.deinit(self.browser.app.allocator);
self.arena_pool.release(self.page_arena);
@@ -506,19 +512,33 @@ pub const FinalizerCallback = struct {
finalizer_ptr_id: usize,
release_ref: *const fn (ptr_id: usize, session: *Session) void,
// Track how many identities (JS worlds) reference this FC.
// Only cleanup when all identities have finalized.
// Linked list of Identities referencing this FC.
identities: ?*Identity = null,
// Count of active identities (for knowing when to clean up FC).
identity_count: u8 = 0,
// For every FinalizerCallback we'll have 1+ FinalizerCallback.Identity: one
// for every identity that gets the instance. In most cases, that'l be 1.
// for every identity that gets the instance. In most cases, that'll be 1.
// Allocated from Session.fc_identity_pool so it survives page resets and
// allows the weak callback to safely check the done flag.
pub const Identity = struct {
session: *Session,
identity: *js.Identity,
fc: *Session.FinalizerCallback,
finalizer_ptr_id: usize,
resolved_ptr_id: usize,
next: ?*Identity = null,
done: bool = false,
};
// Called during page reset to force cleanup regardless of identity_count.
// Called during page reset to force cleanup regardless of identities.
fn deinit(self: *FinalizerCallback, session: *Session) void {
// Mark all identities as done so stale V8 weak callbacks
// won't find the wrong FC if resolved_ptr_id is reused.
var id = self.identities;
while (id) |identity| {
identity.done = true;
id = identity.next;
}
self.release_ref(self.finalizer_ptr_id, session);
session.releaseArena(self.arena);
}

View File

@@ -660,6 +660,17 @@ pub const Function = struct {
switch (cache) {
.internal => |idx| {
// Defensive check: verify object has enough internal fields.
// This guards against edge cases where signature check passes but
// the receiver doesn't have expected internal fields (e.g., global
// proxy vs global object, cross-context scenarios).
if (v8.v8__Object__InternalFieldCount(js_this) <= idx) {
if (comptime IS_DEBUG) {
std.debug.assert(false);
}
return false;
}
if (v8.v8__Object__GetInternalField(js_this, idx)) |cached| {
// means we can't cache undefined, since we can't tell the
// difference between "it isn't in the cache" and "it's

View File

@@ -228,7 +228,13 @@ 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 });
if (comptime IS_DEBUG) {
// A page starts off with an opaque origin. After navigation, setOrigin
// is called. This is the only time setOrigin should be called for that
// page. Therefore, when setOrigin is called, the previous origin should
// have been opaque and its rc should have been 1.
lp.assert(self.origin.rc == 1, "Ref opaque origin", .{ .rc = self.origin.rc });
}
const origin = try self.session.getOrCreateOrigin(key);

View File

@@ -561,7 +561,7 @@ test "Env: Worker context " {
const page = try session.createPage();
defer session.removePage();
const worker = try @import("../webapi/Worker.zig").init("about:blank", &page.js.execution);
const worker = try @import("../webapi/Worker.zig").init("http://localhost:9582/src/browser/tests/testing.js", &page.js.execution);
var ls: js.Local.Scope = undefined;
worker._worker_scope.js.localScope(&ls);

View File

@@ -128,6 +128,10 @@ pub fn contextCreated(
}
}
pub fn getContextId(_: *const Inspector, local: *const js.Local) i32 {
return v8.v8__inspector__executionContextId(local.handle);
}
pub fn contextDestroyed(self: *Inspector, context: *const v8.Context) void {
v8.v8_inspector__Inspector__ContextDestroyed(self.handle, context);

View File

@@ -281,11 +281,15 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object,
finalizer_gop.value_ptr.* = try self.createFinalizerCallback(resolved_ptr_id, finalizer_ptr_id, finalizer.release_ref_from_zig);
}
const fc = finalizer_gop.value_ptr.*;
const identity_finalizer = try fc.arena.create(Session.FinalizerCallback.Identity);
const identity_finalizer = try session.fc_identity_pool.create();
identity_finalizer.* = .{
.fc = fc,
.session = session,
.identity = ctx.identity,
.finalizer_ptr_id = finalizer_ptr_id,
.resolved_ptr_id = resolved_ptr_id,
.next = fc.identities,
};
fc.identities = identity_finalizer;
fc.identity_count += 1;
v8.v8__Global__SetWeakFinalizer(gop.value_ptr, identity_finalizer, finalizer.release_ref, v8.kParameter);
@@ -412,6 +416,9 @@ pub fn zigValueToJs(self: *const Local, value: anytype, comptime opts: CallOpts)
js.Promise.Temp,
js.PromiseResolver.Global,
js.Module.Global => return .{ .local = self, .handle = @ptrCast(value.local(self).handle) },
js.Undefined => return .{.local = self, .handle = isolate.initUndefined() },
else => {}
}
// zig fmt: on
@@ -857,6 +864,18 @@ fn jsValueToTypedArray(comptime T: type, js_val: js.Value) !?[]T {
return ptr[0..num_elements];
}
},
f32 => {
if (js_val.isFloat32Array()) {
const ptr = @as([*]f32, @ptrCast(@alignCast(base)));
return ptr[0..num_elements];
}
},
f64 => {
if (js_val.isFloat64Array()) {
const ptr = @as([*]f64, @ptrCast(@alignCast(base)));
return ptr[0..num_elements];
}
},
else => {},
}
return error.InvalidArgument;
@@ -985,6 +1004,12 @@ fn probeJsValueToZig(self: *const Local, comptime T: type, js_val: js.Value) !Pr
i64 => if (js_val.isBigInt64Array()) {
return .{ .ok = {} };
},
f32 => if (js_val.isFloat32Array()) {
return .{ .ok = {} };
},
f64 => if (js_val.isFloat64Array()) {
return .{ .ok = {} };
},
else => {},
}
return .{ .invalid = {} };
@@ -1200,26 +1225,31 @@ fn resolveT(comptime T: type, value: *T) Resolved {
const ptr = v8.v8__WeakCallbackInfo__GetParameter(handle.?).?;
const identity_finalizer: *Session.FinalizerCallback.Identity = @ptrCast(@alignCast(ptr));
const fc = identity_finalizer.fc;
const session = fc.session;
const finalizer_ptr_id = fc.finalizer_ptr_id;
// Identity is allocated from pool, so it's valid even after page reset.
const session = identity_finalizer.session;
const resolved_ptr_id = identity_finalizer.resolved_ptr_id;
defer session.fc_identity_pool.destroy(identity_finalizer);
// Remove from this identity's map
if (identity_finalizer.identity.identity_map.fetchRemove(fc.resolved_ptr_id)) |kv| {
// Always clean up the identity map entry
if (identity_finalizer.identity.identity_map.fetchRemove(resolved_ptr_id)) |kv| {
var global = kv.value;
v8.v8__Global__Reset(&global);
}
// If done, FC was already cleaned up during page reset. The
// finalizer_ptr_id may have been reused for a new object, so
// we must not look it up in the map.
if (identity_finalizer.done) return;
const finalizer_ptr_id = identity_finalizer.finalizer_ptr_id;
const fc = session.finalizer_callbacks.get(finalizer_ptr_id) orelse return;
const identity_count = fc.identity_count;
if (identity_count == 1) {
// All IsolatedWorlds that reference this object have
// released it. Release the instance ref, remove the
// FinalizerCallback and free it.
// Last identity - clean up the FC.
// Remove from map before releaseRef to prevent address reuse issues.
_ = session.finalizer_callbacks.remove(finalizer_ptr_id);
FT.releaseRef(@ptrFromInt(finalizer_ptr_id), session);
const removed = session.finalizer_callbacks.remove(finalizer_ptr_id);
if (comptime IS_DEBUG) {
std.debug.assert(removed);
}
session.releaseArena(fc.arena);
} else {
fc.identity_count = identity_count - 1;

View File

@@ -155,6 +155,14 @@ pub fn isBigInt64Array(self: Value) bool {
return v8.v8__Value__IsBigInt64Array(self.handle);
}
pub fn isFloat32Array(self: Value) bool {
return v8.v8__Value__IsFloat32Array(self.handle);
}
pub fn isFloat64Array(self: Value) bool {
return v8.v8__Value__IsFloat64Array(self.handle);
}
pub fn isPromise(self: Value) bool {
return v8.v8__Value__IsPromise(self.handle);
}

View File

@@ -319,6 +319,9 @@ pub fn simpleZigValueToJs(isolate: Isolate, value: anytype, comptime fail: bool,
return null;
}
// marker interface
pub const Undefined = struct {};
// These are here, and not in Inspector.zig, because Inspector.zig isn't always
// included (e.g. in the wpt build).

View File

@@ -42,11 +42,17 @@
const audio = document.getElementById('audio1');
testing.expectEqual(true, audio.paused);
audio.play();
var resolved = false;
audio.play().then(() => {
resolved = true;
});
testing.expectEqual(false, audio.paused);
audio.pause();
testing.expectEqual(true, audio.paused);
testing.onload(() => {
testing.expectEqual(true, resolved);
})
}
</script>

View File

@@ -454,6 +454,7 @@ pub fn getFonts(self: *Document, page: *Page) !*FontFaceSet {
return fonts;
}
const fonts = try FontFaceSet.init(page);
fonts.acquireRef();
self._fonts = fonts;
return fonts;
}

View File

@@ -92,7 +92,7 @@ fn goInner(delta: i32, page: *Page) !void {
const entry = page._session.navigation._entries.items[index];
if (entry._url) |url| {
if (try page.isSameOrigin(url)) {
if (page.isSameOrigin(url)) {
const target = page.window.asEventTarget();
if (page._event_manager.hasDirectListeners(target, "popstate", page.window._on_popstate)) {
const event = (try PopStateEvent.initTrusted(comptime .wrap("popstate"), .{ .state = entry._state.value }, page)).asEvent();

View File

@@ -37,7 +37,7 @@ _storage: StorageManager = .{},
pub const init: Navigator = .{};
pub fn getUserAgent(_: *const Navigator, page: *Page) []const u8 {
return page._session.browser.app.config.http_headers.user_agent;
return page._session.browser.http_client.getUserAgent();
}
pub fn getLanguages(_: *const Navigator) [2][]const u8 {
@@ -139,7 +139,7 @@ fn validateProtocolHandlerURL(url: [:0]const u8, page: *const Page) !void {
if (std.mem.indexOf(u8, url, "%s") == null) {
return error.SyntaxError;
}
if (try page.isSameOrigin(url) == false) {
if (page.isSameOrigin(url) == false) {
return error.SyntaxError;
}
}

View File

@@ -244,8 +244,6 @@ pub fn terminate(self: *Worker) void {
resp.abort(error.Abort);
self._http_response = null;
}
self._page.removeWorker(self);
}
// Posts a message from the page to the worker.

View File

@@ -137,7 +137,7 @@ fn isMaybeSupported(mime_type: []const u8) bool {
return false;
}
pub fn play(self: *Media, page: *Page) !void {
pub fn play(self: *Media, page: *Page) !js.Promise {
const was_paused = self._paused;
self._paused = false;
self._ready_state = .HAVE_ENOUGH_DATA;
@@ -146,6 +146,7 @@ pub fn play(self: *Media, page: *Page) !void {
try self.dispatchEvent("play", page);
try self.dispatchEvent("playing", page);
}
return page.js.local.?.resolvePromise(js.Undefined{});
}
pub fn pause(self: *Media, page: *Page) !void {

View File

@@ -86,6 +86,12 @@ pub fn init(input: Input, options: ?InitOpts, page: *Page) !js.Promise {
log.debug(.http, "fetch", .{ .url = request._url });
}
const cookie_jar = switch (request._credentials) {
.omit => null,
.include => &page._session.cookie_jar,
.@"same-origin" => if (page.isSameOrigin(request._url)) &page._session.cookie_jar else null,
};
try http_client.request(.{
.ctx = fetch,
.url = request._url,
@@ -95,7 +101,7 @@ pub fn init(input: Input, options: ?InitOpts, page: *Page) !js.Promise {
.body = request._body,
.headers = headers,
.resource_type = .fetch,
.cookie_jar = &page._session.cookie_jar,
.cookie_jar = cookie_jar,
.cookie_origin = page.url,
.notification = page._session.notification,
.start_callback = httpStartCallback,
@@ -236,7 +242,6 @@ fn httpErrorCallback(ctx: *anyopaque, _: anyerror) void {
defer if (self._owns_response) {
response.deinit(self._page._session);
self._owns_response = false;
};
var ls: js.Local.Scope = undefined;

View File

@@ -38,6 +38,7 @@ const Allocator = std.mem.Allocator;
const IS_DEBUG = @import("builtin").mode == .Debug;
const WebSocket = @This();
_rc: lp.RC(u8) = .{},
_page: *Page,
_proto: *EventTarget,
@@ -88,20 +89,6 @@ pub const BinaryType = enum {
arraybuffer,
};
fn isValidProtocol(protocol: []const u8) bool {
if (protocol.len == 0) return false;
for (protocol) |c| {
// Control characters
if (c <= 31 or c == 127) return false;
// Separators per RFC 2616
switch (c) {
'(', ')', '<', '>', '@', ',', ';', ':', '\\', '"', '/', '[', ']', '?', '=', '{', '}', ' ', '\t' => return false,
else => {},
}
}
return true;
}
pub fn init(url: []const u8, protocols: [][]const u8, page: *Page) !*WebSocket {
{
if (url.len < 6) {
@@ -196,6 +183,18 @@ pub fn deinit(self: *WebSocket, session: *Session) void {
session.releaseArena(self._arena);
}
pub fn releaseRef(self: *WebSocket, session: *Session) void {
self._rc.release(self, session);
}
pub fn acquireRef(self: *WebSocket) void {
self._rc.acquire();
}
fn asEventTarget(self: *WebSocket) *EventTarget {
return self._proto;
}
// we're being aborted internally (e.g. page shutting down)
pub fn kill(self: *WebSocket) void {
self.cleanup();
@@ -211,13 +210,15 @@ pub fn disconnected(self: *WebSocket, err_: ?anyerror) void {
log.info(.websocket, "disconnected", .{ .url = self._url, .reason = "closed" });
}
self.cleanup();
defer self.cleanup();
// Use 1006 (abnormal closure) if connection wasn't cleanly closed
const code = if (was_clean) self._close_code else 1006;
const reason = if (was_clean) self._close_reason else "";
// Spec requires error event before close on abnormal closure
// Spec requires error event before close on abnormal closure.
// Dispatch events before cleanup since cleanup releases the ref count
// which may free our event handler references.
if (!was_clean) {
self.dispatchErrorEvent() catch |err| {
log.err(.websocket, "error event dispatch failed", .{ .err = err });
@@ -239,18 +240,6 @@ fn cleanup(self: *WebSocket) void {
}
}
pub fn releaseRef(self: *WebSocket, session: *Session) void {
self._rc.release(self, session);
}
pub fn acquireRef(self: *WebSocket) void {
self._rc.acquire();
}
fn asEventTarget(self: *WebSocket) *EventTarget {
return self._proto;
}
fn queueMessage(self: *WebSocket, msg: Message) !void {
const was_empty = self._send_queue.items.len == 0;
try self._send_queue.append(self._arena, msg);
@@ -263,6 +252,20 @@ fn queueMessage(self: *WebSocket, msg: Message) !void {
}
}
fn isValidProtocol(protocol: []const u8) bool {
if (protocol.len == 0) return false;
for (protocol) |c| {
// Control characters and non-ASCII
if (c <= 31 or c >= 127) return false;
// Separators per RFC 2616
switch (c) {
'(', ')', '<', '>', '@', ',', ';', ':', '\\', '"', '/', '[', ']', '?', '=', '{', '}', ' ', '\t' => return false,
else => {},
}
}
return true;
}
/// WebSocket send() accepts string, Blob, ArrayBuffer, or TypedArray
const SendData = union(enum) {
blob: *Blob,
@@ -279,17 +282,16 @@ const BinaryData = union(enum) {
uint32: []u32,
int64: []i64,
uint64: []u64,
float32: []f32,
float64: []f64,
fn asBuffer(self: BinaryData) []u8 {
return switch (self) {
.int8 => |b| @as([*]u8, @ptrCast(b.ptr))[0..b.len],
.uint8 => |b| b,
.int16 => |b| @as([*]u8, @ptrCast(b.ptr))[0 .. b.len * 2],
.uint16 => |b| @as([*]u8, @ptrCast(b.ptr))[0 .. b.len * 2],
.int32 => |b| @as([*]u8, @ptrCast(b.ptr))[0 .. b.len * 4],
.uint32 => |b| @as([*]u8, @ptrCast(b.ptr))[0 .. b.len * 4],
.int64 => |b| @as([*]u8, @ptrCast(b.ptr))[0 .. b.len * 8],
.uint64 => |b| @as([*]u8, @ptrCast(b.ptr))[0 .. b.len * 8],
inline .int16, .uint16 => |b| @as([*]u8, @ptrCast(b.ptr))[0 .. b.len * 2],
inline .int32, .uint32, .float32 => |b| @as([*]u8, @ptrCast(b.ptr))[0 .. b.len * 4],
inline .int64, .uint64, .float64 => |b| @as([*]u8, @ptrCast(b.ptr))[0 .. b.len * 8],
};
}
};
@@ -754,7 +756,7 @@ pub const JsApi = struct {
pub const onclose = bridge.accessor(WebSocket.getOnClose, WebSocket.setOnClose, .{});
pub const send = bridge.function(WebSocket.send, .{ .dom_exception = true });
pub const close = bridge.function(WebSocket.close, .{});
pub const close = bridge.function(WebSocket.close, .{ .dom_exception = true });
};
const testing = @import("../../../testing.zig");

View File

@@ -245,7 +245,7 @@ pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void {
var headers = try http_client.newHeaders();
// Only add cookies for same-origin or when withCredentials is true
const cookie_support = self._with_credentials or try page.isSameOrigin(self._url);
const cookie_support = self._with_credentials or page.isSameOrigin(self._url);
try self._request_headers.populateHttpHeader(page.call_arena, &headers);
if (cookie_support) {

View File

@@ -368,6 +368,7 @@ pub const BrowserContext = struct {
next_script_id: u32 = 1,
http_proxy_changed: bool = false,
user_agent_changed: bool = false,
// Extra headers to add to all requests.
extra_headers: std.ArrayList([*c]const u8) = .empty,
@@ -477,6 +478,9 @@ pub const BrowserContext = struct {
log.warn(.http, "changeProxy", .{ .err = err });
};
}
if (self.user_agent_changed) {
browser.http_client.clearUserAgentOverride();
}
self.intercept_state.deinit();
}
@@ -583,7 +587,7 @@ pub const BrowserContext = struct {
pub fn onPageRemove(ctx: *anyopaque, _: Notification.PageRemove) !void {
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
try @import("domains/page.zig").pageRemove(self);
@import("domains/page.zig").pageRemove(self);
}
pub fn onPageCreated(ctx: *anyopaque, page: *Page) !void {
@@ -804,16 +808,18 @@ const IsolatedWorld = struct {
identity: js.Identity = .{},
pub fn deinit(self: *IsolatedWorld) void {
self.removeContext() catch {};
self.identity.deinit();
self.removeContext();
self.browser.arena_pool.release(self.call_arena);
self.browser.arena_pool.release(self.arena);
}
pub fn removeContext(self: *IsolatedWorld) !void {
const ctx = self.context orelse return error.NoIsolatedContextToRemove;
self.browser.env.destroyContext(ctx);
self.context = null;
pub fn removeContext(self: *IsolatedWorld) void {
if (self.context) |ctx| {
self.browser.env.destroyContext(ctx);
self.context = null;
}
// I don't think it's possible to have any identity without a context,
// but there's no harm in being safe.
self.identity.deinit();
self.identity = .{};
}

View File

@@ -19,6 +19,7 @@
const std = @import("std");
const CDP = @import("../CDP.zig");
const log = @import("../../log.zig");
const Config = @import("../../Config.zig");
pub fn processMessage(cmd: *CDP.Command) !void {
const action = std.meta.stringToEnum(enum {
@@ -69,7 +70,146 @@ fn setTouchEmulationEnabled(cmd: *CDP.Command) !void {
return cmd.sendResult(null, .{});
}
fn setUserAgentOverride(cmd: *CDP.Command) !void {
log.info(.app, "setUserAgentOverride ignored", .{});
// Emulation.setUserAgentOverride is also called by Network.setUserAgentOverride
pub fn setUserAgentOverride(cmd: *CDP.Command) !void {
const params = (try cmd.params(struct {
userAgent: []const u8,
acceptLanguage: ?[]const u8 = null,
platform: ?[]const u8 = null,
})) orelse return error.InvalidParams;
if (params.acceptLanguage) |v| {
log.warn(.not_implemented, "Emulation.setUserAgentOverride", .{ .param = "acceptLanguage", .value = v });
}
if (params.platform) |v| {
log.warn(.not_implemented, "Emulation.setUserAgentOverride", .{ .param = "platform", .value = v });
}
const ua = params.userAgent;
Config.validateUserAgent(ua) catch |err| switch (err) {
error.NonPrintable => return cmd.sendError(-32602, "User agent contains non-printable characters", .{}),
error.Reserved => {
log.warn(.not_implemented, "Emulation.setUserAgentOverride", .{ .param = "userAgent", .value = ua, .info = "User agent must not contain Mozilla" });
return cmd.sendResult(null, .{});
},
};
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const http_client = cmd.cdp.browser.http_client;
try http_client.setUserAgentOverride(ua);
bc.user_agent_changed = true;
return cmd.sendResult(null, .{});
}
const testing = @import("../testing.zig");
test "cdp.Emulation: setUserAgentOverride with valid user agent" {
var ctx = try testing.context();
defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-UA1" });
try ctx.processMessage(.{
.id = 1,
.method = "Emulation.setUserAgentOverride",
.params = .{ .userAgent = "CustomBot/1.0" },
});
try ctx.expectSentResult(null, .{ .id = 1 });
}
test "cdp.Emulation: setUserAgentOverride ignores mozilla" {
const filter: testing.LogFilter = .init(&.{.not_implemented});
defer filter.deinit();
var ctx = try testing.context();
defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-UA2" });
try ctx.processMessage(.{
.id = 2,
.method = "Emulation.setUserAgentOverride",
.params = .{ .userAgent = "Mozilla/5.0 (Windows NT 10.0)" },
});
try ctx.expectSentResult(null, .{});
try testing.expectEqual(false, ctx.cdp().browser_context.?.user_agent_changed);
}
test "cdp.Emulation: setUserAgentOverride ignores mozilla case insensitive" {
const filter: testing.LogFilter = .init(&.{.not_implemented});
defer filter.deinit();
var ctx = try testing.context();
defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-UA3" });
try ctx.processMessage(.{
.id = 3,
.method = "Emulation.setUserAgentOverride",
.params = .{ .userAgent = "MOZILLA/5.0 test" },
});
try ctx.expectSentResult(null, .{});
try testing.expectEqual(false, ctx.cdp().browser_context.?.user_agent_changed);
}
test "cdp.Emulation: setUserAgentOverride rejects non-printable characters" {
const filter: testing.LogFilter = .init(&.{.not_implemented});
defer filter.deinit();
var ctx = try testing.context();
defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-UA4" });
try ctx.processMessage(.{
.id = 4,
.method = "Emulation.setUserAgentOverride",
.params = .{ .userAgent = "Bot/1.0\x01hidden" },
});
try ctx.expectSentError(-32602, "User agent contains non-printable characters", .{ .id = 4 });
}
test "cdp.Emulation: setUserAgentOverride with optional params" {
const filter: testing.LogFilter = .init(&.{.not_implemented});
defer filter.deinit();
var ctx = try testing.context();
defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-UA5" });
try ctx.processMessage(.{
.id = 5,
.method = "Emulation.setUserAgentOverride",
.params = .{
.userAgent = "CustomBot/2.0",
.acceptLanguage = "en-US",
.platform = "Linux",
},
});
try ctx.expectSentResult(null, .{ .id = 5 });
}
test "cdp.Emulation: setUserAgentOverride can be called multiple times" {
var ctx = try testing.context();
defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-UA6" });
try ctx.processMessage(.{
.id = 6,
.method = "Emulation.setUserAgentOverride",
.params = .{ .userAgent = "FirstBot/1.0" },
});
try ctx.expectSentResult(null, .{ .id = 6 });
try ctx.processMessage(.{
.id = 7,
.method = "Emulation.setUserAgentOverride",
.params = .{ .userAgent = "SecondBot/2.0" },
});
try ctx.expectSentResult(null, .{ .id = 7 });
}

View File

@@ -51,7 +51,7 @@ pub fn processMessage(cmd: *CDP.Command) !void {
.enable => return enable(cmd),
.disable => return disable(cmd),
.setCacheDisabled => return cmd.sendResult(null, .{}),
.setUserAgentOverride => return cmd.sendResult(null, .{}),
.setUserAgentOverride => return @import("emulation.zig").setUserAgentOverride(cmd),
.setExtraHTTPHeaders => return setExtraHTTPHeaders(cmd),
.deleteCookies => return deleteCookies(cmd),
.clearBrowserCookies => return clearBrowserCookies(cmd),

View File

@@ -257,7 +257,22 @@ fn createIsolatedWorld(cmd: *CDP.Command) !void {
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
const js_context = try world.createContext(page);
return cmd.sendResult(.{ .executionContextId = js_context.id }, .{});
const aux_data = try std.fmt.allocPrint(cmd.arena, "{{\"isDefault\":false,\"type\":\"isolated\",\"frameId\":\"{s}\"}}", .{params.frameId});
var ls: js.Local.Scope = undefined;
js_context.localScope(&ls);
defer ls.deinit();
bc.inspector_session.inspector.contextCreated(
&ls.local,
params.worldName,
page.origin orelse "",
aux_data,
false,
);
const context_id = bc.inspector_session.inspector.getContextId(&ls.local);
return cmd.sendResult(.{ .executionContextId = context_id }, .{});
}
fn navigate(cmd: *CDP.Command) !void {
@@ -283,6 +298,12 @@ fn navigate(cmd: *CDP.Command) !void {
var page = session.currentPage() orelse return error.PageNotLoaded;
if (page._load_state != .waiting) {
// Reset isolated world identities to disable V8 weak callbacks before
// resetPageResources releases refs. Prevents double-release crashes.
for (bc.isolated_worlds.items) |isolated_world| {
isolated_world.identity.deinit();
isolated_world.identity = .{};
}
page = try session.replacePage();
}
@@ -313,6 +334,12 @@ fn doReload(cmd: *CDP.Command) !void {
const reload_url = try cmd.arena.dupeZ(u8, page.url);
if (page._load_state != .waiting) {
// Reset isolated world identities to disable V8 weak callbacks before
// resetPageResources releases refs. Prevents double-release crashes.
for (bc.isolated_worlds.items) |isolated_world| {
isolated_world.identity.deinit();
isolated_world.identity = .{};
}
page = try session.replacePage();
}
@@ -376,14 +403,14 @@ pub fn pageNavigate(bc: *CDP.BrowserContext, event: *const Notification.PageNavi
}, .{ .session_id = session_id });
}
pub fn pageRemove(bc: *CDP.BrowserContext) !void {
pub fn pageRemove(bc: *CDP.BrowserContext) void {
// Clear all remote object mappings to prevent stale objectIds from being used
// after the context is destroy
bc.inspector_session.inspector.resetContextGroup();
// The main page is going to be removed, we need to remove contexts from other worlds first.
for (bc.isolated_worlds.items) |isolated_world| {
try isolated_world.removeContext();
isolated_world.removeContext();
}
}

View File

@@ -32,6 +32,7 @@ pub const expectError = base.expectError;
pub const expectEqualSlices = base.expectEqualSlices;
pub const pageTest = base.pageTest;
pub const newString = base.newString;
pub const LogFilter = base.LogFilter;
const TestContext = struct {
read_at: usize = 0,