Compare commits

..

2 Commits

Author SHA1 Message Date
Karl Seguin
078e13d8a4 Remove DOMContentLoaded and Loaded events from page_navigated
These were moved to their own distinct events, and should have been removed from
here.
2026-03-30 11:39:38 +08:00
Karl Seguin
cabb029bed Improve/Fix CDP navigation event order
These changes all better align with chrome's event ordering/timing.

There are two big changes. The first is that our internal page_navigated event,
which is kind of our heavy hitter, is sent once the header is received as
opposed to (much later) on document load. The main goal of this internal event
is to trigger the "Page.frameNavigated" CDP event which is meant to happen
once the URL is committed, which _is_ on header response.

To accommodate this earlier trigger, new explicit events for DOMContentLoaded
and load have be added.

This drastically changes the flow of events as things go from:
Start Page Navigation
Response Received
  Start Frame Navigation
  Response Received
  End Frame Navigation
End Page Navigation
context clear + reset
DOMContentLoaded
Loaded

TO:
Start Page Navigation
Response Received
End Page Navigation
context clear + reset
Start Frame Navigation
Response Received
End Frame Navigation
DOMContentLoaded
Loaded

So not only does it remove the nesting, but it ensures that the context are
cleared and reset once the main page's navigation is locked in, and before any
frame is created.
2026-03-30 11:02:53 +08:00
18 changed files with 173 additions and 396 deletions

View File

@@ -24,7 +24,6 @@ const log = @import("log.zig");
const dump = @import("browser/dump.zig"); const dump = @import("browser/dump.zig");
const WebBotAuthConfig = @import("network/WebBotAuth.zig").Config; const WebBotAuthConfig = @import("network/WebBotAuth.zig").Config;
const mcp = @import("mcp.zig");
pub const RunMode = enum { pub const RunMode = enum {
help, help,
@@ -223,7 +222,6 @@ pub const Serve = struct {
pub const Mcp = struct { pub const Mcp = struct {
common: Common = .{}, common: Common = .{},
version: mcp.Version = .default,
}; };
pub const DumpFormat = enum { pub const DumpFormat = enum {
@@ -455,12 +453,6 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
\\Starts an MCP (Model Context Protocol) server over stdio \\Starts an MCP (Model Context Protocol) server over stdio
\\Example: {s} mcp \\Example: {s} mcp
\\ \\
\\Options:
\\--version
\\ Override the reported MCP version.
\\ Valid: 2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25.
\\ Defaults to "2024-11-05".
\\
++ common_options ++ ++ common_options ++
\\ \\
\\version command \\version command
@@ -648,22 +640,10 @@ fn parseMcpArgs(
allocator: Allocator, allocator: Allocator,
args: *std.process.ArgIterator, args: *std.process.ArgIterator,
) !Mcp { ) !Mcp {
var result: Mcp = .{}; var mcp: Mcp = .{};
while (args.next()) |opt| { while (args.next()) |opt| {
if (std.mem.eql(u8, "--version", opt)) { if (try parseCommonArg(allocator, opt, args, &mcp.common)) {
const str = args.next() orelse {
log.fatal(.mcp, "missing argument value", .{ .arg = opt });
return error.InvalidArgument;
};
result.version = std.meta.stringToEnum(mcp.Version, str) orelse {
log.fatal(.mcp, "invalid protocol version", .{ .value = str });
return error.InvalidArgument;
};
continue;
}
if (try parseCommonArg(allocator, opt, args, &result.common)) {
continue; continue;
} }
@@ -671,7 +651,7 @@ fn parseMcpArgs(
return error.UnkownOption; return error.UnkownOption;
} }
return result; return mcp;
} }
fn parseFetchArgs( fn parseFetchArgs(

View File

@@ -74,6 +74,8 @@ const EventListeners = struct {
page_network_idle: List = .{}, page_network_idle: List = .{},
page_network_almost_idle: List = .{}, page_network_almost_idle: List = .{},
page_frame_created: List = .{}, page_frame_created: List = .{},
page_dom_content_loaded: List = .{},
page_loaded: List = .{},
http_request_fail: List = .{}, http_request_fail: List = .{},
http_request_start: List = .{}, http_request_start: List = .{},
http_request_intercept: List = .{}, http_request_intercept: List = .{},
@@ -91,6 +93,8 @@ const Events = union(enum) {
page_network_idle: *const PageNetworkIdle, page_network_idle: *const PageNetworkIdle,
page_network_almost_idle: *const PageNetworkAlmostIdle, page_network_almost_idle: *const PageNetworkAlmostIdle,
page_frame_created: *const PageFrameCreated, page_frame_created: *const PageFrameCreated,
page_dom_content_loaded: *const PageDOMContentLoaded,
page_loaded: *const PageLoaded,
http_request_fail: *const RequestFail, http_request_fail: *const RequestFail,
http_request_start: *const RequestStart, http_request_start: *const RequestStart,
http_request_intercept: *const RequestIntercept, http_request_intercept: *const RequestIntercept,
@@ -137,6 +141,18 @@ pub const PageFrameCreated = struct {
timestamp: u64, timestamp: u64,
}; };
pub const PageDOMContentLoaded = struct {
req_id: u32,
frame_id: u32,
timestamp: u64,
};
pub const PageLoaded = struct {
req_id: u32,
frame_id: u32,
timestamp: u64,
};
pub const RequestStart = struct { pub const RequestStart = struct {
transfer: *Transfer, transfer: *Transfer,
}; };

View File

@@ -821,16 +821,16 @@ fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *T
break :blk std.ascii.eqlIgnoreCase(hdr.value, "close"); break :blk std.ascii.eqlIgnoreCase(hdr.value, "close");
}; };
// make sure the transfer can't be immediately aborted from a callback
// since we still need it here.
transfer._performing = true;
defer transfer._performing = false;
if (msg.err != null and !is_conn_close_recv) { if (msg.err != null and !is_conn_close_recv) {
transfer.requestFailed(transfer._callback_error orelse msg.err.?, true); transfer.requestFailed(transfer._callback_error orelse msg.err.?, true);
return true; return true;
} }
// make sure the transfer can't be immediately aborted from a callback
// since we still need it here.
transfer._performing = true;
defer transfer._performing = false;
if (!transfer._header_done_called) { if (!transfer._header_done_called) {
// In case of request w/o data, we need to call the header done // In case of request w/o data, we need to call the header done
// callback now. // callback now.
@@ -873,6 +873,7 @@ fn processMessages(self: *Client) !bool {
var processed = false; var processed = false;
while (self.handles.readMessage()) |msg| { while (self.handles.readMessage()) |msg| {
const transfer = try Transfer.fromConnection(&msg.conn); const transfer = try Transfer.fromConnection(&msg.conn);
const done = self.processOneMessage(msg, transfer) catch |err| blk: { const done = self.processOneMessage(msg, transfer) catch |err| blk: {
log.err(.http, "process_messages", .{ .err = err, .req = transfer }); log.err(.http, "process_messages", .{ .err = err, .req = transfer });
transfer.requestFailed(err, true); transfer.requestFailed(err, true);
@@ -1067,24 +1068,6 @@ pub const Transfer = struct {
if (self.req.shutdown_callback) |cb| { if (self.req.shutdown_callback) |cb| {
cb(self.ctx); cb(self.ctx);
} }
if (self._performing or self.client.performing) {
// We're currently inside of a callback. This client, and libcurl
// generally don't expect a transfer to become deinitialized during
// a callback. We can flag the transfer as aborted (which is what
// we do when transfer.abort() is called in this condition) AND,
// since this "kill()"should prevent any future callbacks, the best
// we can do is null/noop them.
self.aborted = true;
self.req.start_callback = null;
self.req.shutdown_callback = null;
self.req.header_callback = Noop.headerCallback;
self.req.data_callback = Noop.dataCallback;
self.req.done_callback = Noop.doneCallback;
self.req.error_callback = Noop.errorCallback;
return;
}
self.deinit(); self.deinit();
} }
@@ -1342,15 +1325,15 @@ pub const Transfer = struct {
} }
} }
transfer.req.notification.dispatch(.http_response_header_done, &.{
.transfer = transfer,
});
const proceed = transfer.req.header_callback(transfer) catch |err| { const proceed = transfer.req.header_callback(transfer) catch |err| {
log.err(.http, "header_callback", .{ .err = err, .req = transfer }); log.err(.http, "header_callback", .{ .err = err, .req = transfer });
return err; return err;
}; };
transfer.req.notification.dispatch(.http_response_header_done, &.{
.transfer = transfer,
});
return proceed and transfer.aborted == false; return proceed and transfer.aborted == false;
} }
@@ -1509,12 +1492,3 @@ pub const Transfer = struct {
return null; return null;
} }
}; };
const Noop = struct {
fn headerCallback(_: *Transfer) !bool {
return true;
}
fn dataCallback(_: *Transfer, _: []const u8) !void {}
fn doneCallback(_: *anyopaque) !void {}
fn errorCallback(_: *anyopaque, _: anyerror) void {}
};

View File

@@ -487,7 +487,6 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi
return error.InjectBlankFailed; return error.InjectBlankFailed;
}; };
} }
self.documentIsComplete();
session.notification.dispatch(.page_navigate, &.{ session.notification.dispatch(.page_navigate, &.{
.frame_id = self._frame_id, .frame_id = self._frame_id,
@@ -519,6 +518,8 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi
// force next request id manually b/c we won't create a real req. // force next request id manually b/c we won't create a real req.
_ = session.browser.http_client.incrReqId(); _ = session.browser.http_client.incrReqId();
self.documentIsComplete();
return; return;
} }
@@ -738,6 +739,12 @@ pub fn _documentIsLoaded(self: *Page) !void {
self.document.asEventTarget(), self.document.asEventTarget(),
event, event,
); );
self._session.notification.dispatch(.page_dom_content_loaded, &.{
.frame_id = self._frame_id,
.req_id = self._req_id,
.timestamp = timestamp(.monotonic),
});
} }
pub fn scriptsCompletedLoading(self: *Page) void { pub fn scriptsCompletedLoading(self: *Page) void {
@@ -796,19 +803,6 @@ pub fn documentIsComplete(self: *Page) void {
self._documentIsComplete() catch |err| { self._documentIsComplete() catch |err| {
log.err(.page, "document is complete", .{ .err = err, .type = self._type, .url = self.url }); log.err(.page, "document is complete", .{ .err = err, .type = self._type, .url = self.url });
}; };
if (self._navigated_options) |no| {
// _navigated_options will be null in special short-circuit cases, like
// "navigating" to about:blank, in which case this notification has
// already been sent
self._session.notification.dispatch(.page_navigated, &.{
.frame_id = self._frame_id,
.req_id = self._req_id,
.opts = no,
.url = self.url,
.timestamp = timestamp(.monotonic),
});
}
} }
fn _documentIsComplete(self: *Page) !void { fn _documentIsComplete(self: *Page) !void {
@@ -827,6 +821,12 @@ fn _documentIsComplete(self: *Page) !void {
try self._event_manager.dispatchDirect(window_target, event, self.window._on_load, .{ .inject_target = false, .context = "page load" }); try self._event_manager.dispatchDirect(window_target, event, self.window._on_load, .{ .inject_target = false, .context = "page load" });
} }
self._session.notification.dispatch(.page_loaded, &.{
.frame_id = self._frame_id,
.req_id = self._req_id,
.timestamp = timestamp(.monotonic),
});
if (self._event_manager.hasDirectListeners(window_target, "pageshow", self.window._on_pageshow)) { if (self._event_manager.hasDirectListeners(window_target, "pageshow", self.window._on_pageshow)) {
const pageshow_event = (try PageTransitionEvent.initTrusted(comptime .wrap("pageshow"), .{}, self)).asEvent(); const pageshow_event = (try PageTransitionEvent.initTrusted(comptime .wrap("pageshow"), .{}, self)).asEvent();
try self._event_manager.dispatchDirect(window_target, pageshow_event, self.window._on_pageshow, .{ .context = "page show" }); try self._event_manager.dispatchDirect(window_target, pageshow_event, self.window._on_pageshow, .{ .context = "page show" });
@@ -879,6 +879,19 @@ fn pageHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool {
}); });
} }
if (self._navigated_options) |no| {
// _navigated_options will be null in special short-circuit cases, like
// "navigating" to about:blank, in which case this notification has
// already been sent
self._session.notification.dispatch(.page_navigated, &.{
.frame_id = self._frame_id,
.req_id = self._req_id,
.opts = no,
.url = self.url,
.timestamp = timestamp(.monotonic),
});
}
return true; return true;
} }

View File

@@ -148,13 +148,3 @@
} }
</script> </script>
<script id=identity>
{
const element = document.createElement('canvas');
const ctx = element.getContext('2d');
testing.expectTrue(ctx === element.getContext('2d'));
testing.expectEqual(null, element.getContext('webgl'));
}
</script>

View File

@@ -85,13 +85,3 @@
loseContext.restoreContext(); loseContext.restoreContext();
} }
</script> </script>
<script id=identity>
{
const element = document.createElement('canvas');
const ctx = element.getContext('webgl');
testing.expectTrue(ctx === element.getContext('webgl'));
testing.expectEqual(null, element.getContext('2d'));
}
</script>

View File

@@ -15,9 +15,8 @@
testing.expectEqual(true, validPlatforms.includes(navigator.platform)); testing.expectEqual(true, validPlatforms.includes(navigator.platform));
testing.expectEqual('en-US', navigator.language); testing.expectEqual('en-US', navigator.language);
testing.expectEqual(true, Array.isArray(navigator.languages)); testing.expectEqual(true, Array.isArray(navigator.languages));
testing.expectEqual(2, navigator.languages.length); testing.expectEqual(1, navigator.languages.length);
testing.expectEqual('en-US', navigator.languages[0]); testing.expectEqual('en-US', navigator.languages[0]);
testing.expectEqual('en', navigator.languages[1]);
testing.expectEqual(true, navigator.onLine); testing.expectEqual(true, navigator.onLine);
testing.expectEqual(true, navigator.cookieEnabled); testing.expectEqual(true, navigator.cookieEnabled);
testing.expectEqual(true, navigator.hardwareConcurrency > 0); testing.expectEqual(true, navigator.hardwareConcurrency > 0);

View File

@@ -59,7 +59,6 @@ pub fn fromError(err: anyerror) ?DOMException {
error.TimeoutError => .{ ._code = .timeout_error }, error.TimeoutError => .{ ._code = .timeout_error },
error.InvalidNodeType => .{ ._code = .invalid_node_type_error }, error.InvalidNodeType => .{ ._code = .invalid_node_type_error },
error.DataClone => .{ ._code = .data_clone_error }, error.DataClone => .{ ._code = .data_clone_error },
error.InvalidAccessError => .{ ._code = .invalid_access_error },
else => null, else => null,
}; };
} }

View File

@@ -40,8 +40,8 @@ pub fn getUserAgent(_: *const Navigator, page: *Page) []const u8 {
return page._session.browser.app.config.http_headers.user_agent; return page._session.browser.app.config.http_headers.user_agent;
} }
pub fn getLanguages(_: *const Navigator) [2][]const u8 { pub fn getLanguages(_: *const Navigator) [1][]const u8 {
return .{ "en-US", "en" }; return .{"en-US"};
} }
pub fn getPlatform(_: *const Navigator) []const u8 { pub fn getPlatform(_: *const Navigator) []const u8 {

View File

@@ -29,9 +29,6 @@ const OffscreenCanvas = @import("../../canvas/OffscreenCanvas.zig");
const Canvas = @This(); const Canvas = @This();
_proto: *HtmlElement, _proto: *HtmlElement,
_cached: ?DrawingContext = null,
const ContextType = enum { none, @"2d", webgl };
pub fn asElement(self: *Canvas) *Element { pub fn asElement(self: *Canvas) *Element {
return self._proto._proto; return self._proto._proto;
@@ -71,28 +68,17 @@ const DrawingContext = union(enum) {
}; };
pub fn getContext(self: *Canvas, context_type: []const u8, page: *Page) !?DrawingContext { pub fn getContext(self: *Canvas, context_type: []const u8, page: *Page) !?DrawingContext {
if (self._cached) |cached| { if (std.mem.eql(u8, context_type, "2d")) {
const matches = switch (cached) { const ctx = try page._factory.create(CanvasRenderingContext2D{ ._canvas = self });
.@"2d" => std.mem.eql(u8, context_type, "2d"), return .{ .@"2d" = ctx };
.webgl => std.mem.eql(u8, context_type, "webgl") or std.mem.eql(u8, context_type, "experimental-webgl"),
};
return if (matches) cached else null;
} }
const drawing_context: DrawingContext = blk: { if (std.mem.eql(u8, context_type, "webgl") or std.mem.eql(u8, context_type, "experimental-webgl")) {
if (std.mem.eql(u8, context_type, "2d")) { const ctx = try page._factory.create(WebGLRenderingContext{});
const ctx = try page._factory.create(CanvasRenderingContext2D{ ._canvas = self }); return .{ .webgl = ctx };
break :blk .{ .@"2d" = ctx }; }
}
if (std.mem.eql(u8, context_type, "webgl") or std.mem.eql(u8, context_type, "experimental-webgl")) { return null;
const ctx = try page._factory.create(WebGLRenderingContext{});
break :blk .{ .webgl = ctx };
}
return null;
};
self._cached = drawing_context;
return drawing_context;
} }
/// Transfers control of the canvas to an OffscreenCanvas. /// Transfers control of the canvas to an OffscreenCanvas.

View File

@@ -363,11 +363,6 @@ pub fn BrowserContext(comptime CDP_T: type) type {
inspector_session: *js.Inspector.Session, inspector_session: *js.Inspector.Session,
isolated_worlds: std.ArrayList(*IsolatedWorld), isolated_worlds: std.ArrayList(*IsolatedWorld),
// Scripts registered via Page.addScriptToEvaluateOnNewDocument.
// Evaluated in each new document after navigation completes.
scripts_on_new_document: std.ArrayList(ScriptOnNewDocument) = .empty,
next_script_id: u32 = 1,
http_proxy_changed: bool = false, http_proxy_changed: bool = false,
// Extra headers to add to all requests. // Extra headers to add to all requests.
@@ -432,6 +427,8 @@ pub fn BrowserContext(comptime CDP_T: type) type {
try notification.register(.page_navigate, self, onPageNavigate); try notification.register(.page_navigate, self, onPageNavigate);
try notification.register(.page_navigated, self, onPageNavigated); try notification.register(.page_navigated, self, onPageNavigated);
try notification.register(.page_frame_created, self, onPageFrameCreated); try notification.register(.page_frame_created, self, onPageFrameCreated);
try notification.register(.page_dom_content_loaded, self, onPageDOMContentLoaded);
try notification.register(.page_loaded, self, onPageLoaded);
} }
pub fn deinit(self: *Self) void { pub fn deinit(self: *Self) void {
@@ -607,6 +604,16 @@ pub fn BrowserContext(comptime CDP_T: type) type {
return @import("domains/page.zig").pageFrameCreated(self, msg); return @import("domains/page.zig").pageFrameCreated(self, msg);
} }
pub fn onPageDOMContentLoaded(ctx: *anyopaque, msg: *const Notification.PageDOMContentLoaded) !void {
const self: *Self = @ptrCast(@alignCast(ctx));
return @import("domains/page.zig").pageDOMContentLoaded(self, msg);
}
pub fn onPageLoaded(ctx: *anyopaque, msg: *const Notification.PageLoaded) !void {
const self: *Self = @ptrCast(@alignCast(ctx));
return @import("domains/page.zig").pageLoaded(self, msg);
}
pub fn onPageNetworkIdle(ctx: *anyopaque, msg: *const Notification.PageNetworkIdle) !void { pub fn onPageNetworkIdle(ctx: *anyopaque, msg: *const Notification.PageNetworkIdle) !void {
const self: *Self = @ptrCast(@alignCast(ctx)); const self: *Self = @ptrCast(@alignCast(ctx));
return @import("domains/page.zig").pageNetworkIdle(self, msg); return @import("domains/page.zig").pageNetworkIdle(self, msg);
@@ -767,11 +774,6 @@ pub fn BrowserContext(comptime CDP_T: type) type {
/// Clients create this to be able to create variables and run code without interfering with the /// Clients create this to be able to create variables and run code without interfering with the
/// normal namespace and values of the webpage. Similar to the main context we need to pretend to recreate it after /// normal namespace and values of the webpage. Similar to the main context we need to pretend to recreate it after
/// a executionContextsCleared event which happens when navigating to a new page. A client can have a command be executed /// a executionContextsCleared event which happens when navigating to a new page. A client can have a command be executed
const ScriptOnNewDocument = struct {
identifier: u32,
source: []const u8,
};
/// in the isolated world by using its Context ID or the worldName. /// in the isolated world by using its Context ID or the worldName.
/// grantUniveralAccess Indecated whether the isolated world can reference objects like the DOM or other JS Objects. /// grantUniveralAccess Indecated whether the isolated world can reference objects like the DOM or other JS Objects.
/// An isolated world has it's own instance of globals like Window. /// An isolated world has it's own instance of globals like Window.

View File

@@ -37,7 +37,6 @@ pub fn processMessage(cmd: anytype) !void {
getFrameTree, getFrameTree,
setLifecycleEventsEnabled, setLifecycleEventsEnabled,
addScriptToEvaluateOnNewDocument, addScriptToEvaluateOnNewDocument,
removeScriptToEvaluateOnNewDocument,
createIsolatedWorld, createIsolatedWorld,
navigate, navigate,
reload, reload,
@@ -52,7 +51,6 @@ pub fn processMessage(cmd: anytype) !void {
.getFrameTree => return getFrameTree(cmd), .getFrameTree => return getFrameTree(cmd),
.setLifecycleEventsEnabled => return setLifecycleEventsEnabled(cmd), .setLifecycleEventsEnabled => return setLifecycleEventsEnabled(cmd),
.addScriptToEvaluateOnNewDocument => return addScriptToEvaluateOnNewDocument(cmd), .addScriptToEvaluateOnNewDocument => return addScriptToEvaluateOnNewDocument(cmd),
.removeScriptToEvaluateOnNewDocument => return removeScriptToEvaluateOnNewDocument(cmd),
.createIsolatedWorld => return createIsolatedWorld(cmd), .createIsolatedWorld => return createIsolatedWorld(cmd),
.navigate => return navigate(cmd), .navigate => return navigate(cmd),
.reload => return doReload(cmd), .reload => return doReload(cmd),
@@ -149,55 +147,22 @@ fn setLifecycleEventsEnabled(cmd: anytype) !void {
return cmd.sendResult(null, .{}); return cmd.sendResult(null, .{});
} }
// TODO: hard coded method
// With the command we receive a script we need to store and run for each new document.
// Note that the worldName refers to the name given to the isolated world.
fn addScriptToEvaluateOnNewDocument(cmd: anytype) !void { fn addScriptToEvaluateOnNewDocument(cmd: anytype) !void {
const params = (try cmd.params(struct { // const params = (try cmd.params(struct {
source: []const u8, // source: []const u8,
worldName: ?[]const u8 = null, // worldName: ?[]const u8 = null,
includeCommandLineAPI: bool = false, // includeCommandLineAPI: bool = false,
runImmediately: bool = false, // runImmediately: bool = false,
})) orelse return error.InvalidParams; // })) orelse return error.InvalidParams;
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
if (params.runImmediately) {
log.warn(.not_implemented, "addScriptOnNewDocument", .{ .param = "runImmediately" });
}
const script_id = bc.next_script_id;
bc.next_script_id += 1;
const source_dupe = try bc.arena.dupe(u8, params.source);
try bc.scripts_on_new_document.append(bc.arena, .{
.identifier = script_id,
.source = source_dupe,
});
var id_buf: [16]u8 = undefined;
const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{script_id}) catch "1";
return cmd.sendResult(.{ return cmd.sendResult(.{
.identifier = id_str, .identifier = "1",
}, .{}); }, .{});
} }
fn removeScriptToEvaluateOnNewDocument(cmd: anytype) !void {
const params = (try cmd.params(struct {
identifier: []const u8,
})) orelse return error.InvalidParams;
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const target_id = std.fmt.parseInt(u32, params.identifier, 10) catch
return cmd.sendResult(null, .{});
for (bc.scripts_on_new_document.items, 0..) |script, i| {
if (script.identifier == target_id) {
_ = bc.scripts_on_new_document.orderedRemove(i);
break;
}
}
return cmd.sendResult(null, .{});
}
fn close(cmd: anytype) !void { fn close(cmd: anytype) !void {
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
@@ -420,7 +385,6 @@ pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.P
// things, but no session. // things, but no session.
const session_id = bc.session_id orelse return; const session_id = bc.session_id orelse return;
const timestamp = event.timestamp;
const frame_id = &id.toFrameId(event.frame_id); const frame_id = &id.toFrameId(event.frame_id);
const loader_id = &id.toLoaderId(event.req_id); const loader_id = &id.toLoaderId(event.req_id);
@@ -472,9 +436,9 @@ pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.P
const page = bc.session.currentPage() orelse return error.PageNotLoaded; const page = bc.session.currentPage() orelse return error.PageNotLoaded;
// When we actually recreated the context we should have the inspector send // When we actually recreated the context we should have the inspector send
// this event, see: resetContextGroup Sending this event will tell the // this event, see: resetContextGroup. Sending this event will tell the
// client that the context ids they had are invalid and the context shouls // client that the context ids they had are invalid and the context should
// be dropped The client will expect us to send new contextCreated events, // be dropped. The client will expect us to send new contextCreated events,
// such that the client has new id's for the active contexts. // such that the client has new id's for the active contexts.
// Only send executionContextsCleared for main frame navigations. For child // Only send executionContextsCleared for main frame navigations. For child
// frames (iframes), clearing all contexts would destroy the main frame's // frames (iframes), clearing all contexts would destroy the main frame's
@@ -484,6 +448,18 @@ pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.P
try cdp.sendEvent("Runtime.executionContextsCleared", null, .{ .session_id = session_id }); try cdp.sendEvent("Runtime.executionContextsCleared", null, .{ .session_id = session_id });
} }
// frameNavigated event
try cdp.sendEvent("Page.frameNavigated", .{
.type = "Navigation",
.frame = Frame{
.id = frame_id,
.url = event.url,
.loaderId = loader_id,
.securityOrigin = bc.security_origin,
.secureContextType = bc.secure_context_type,
},
}, .{ .session_id = session_id });
{ {
const aux_data = try std.fmt.allocPrint(arena, "{{\"isDefault\":true,\"type\":\"default\",\"frameId\":\"{s}\",\"loaderId\":\"{s}\"}}", .{ frame_id, loader_id }); const aux_data = try std.fmt.allocPrint(arena, "{{\"isDefault\":true,\"type\":\"default\",\"frameId\":\"{s}\",\"loaderId\":\"{s}\"}}", .{ frame_id, loader_id });
@@ -517,27 +493,6 @@ pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.P
); );
} }
// Evaluate scripts registered via Page.addScriptToEvaluateOnNewDocument.
// Must run after the execution context is created but before the client
// receives frameNavigated/loadEventFired so polyfills are available for
// subsequent CDP commands.
if (bc.scripts_on_new_document.items.len > 0) {
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer ls.deinit();
for (bc.scripts_on_new_document.items) |script| {
var try_catch: lp.js.TryCatch = undefined;
try_catch.init(&ls.local);
defer try_catch.deinit();
ls.local.eval(script.source, null) catch |err| {
const caught = try_catch.caughtOrError(arena, err);
log.warn(.cdp, "script on new doc", .{ .caught = caught });
};
}
}
// frameNavigated event // frameNavigated event
try cdp.sendEvent("Page.frameNavigated", .{ try cdp.sendEvent("Page.frameNavigated", .{
.type = "Navigation", .type = "Navigation",
@@ -554,18 +509,22 @@ pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.P
// chromedp client expects to receive the events is this order. // chromedp client expects to receive the events is this order.
// see https://github.com/chromedp/chromedp/issues/1558 // see https://github.com/chromedp/chromedp/issues/1558
try cdp.sendEvent("DOM.documentUpdated", null, .{ .session_id = session_id }); try cdp.sendEvent("DOM.documentUpdated", null, .{ .session_id = session_id });
}
pub fn pageDOMContentLoaded(bc: anytype, event: *const Notification.PageDOMContentLoaded) !void {
const session_id = bc.session_id orelse return;
const timestamp = event.timestamp;
var cdp = bc.cdp;
// domContentEventFired event
// TODO: partially hard coded
try cdp.sendEvent( try cdp.sendEvent(
"Page.domContentEventFired", "Page.domContentEventFired",
.{ .timestamp = timestamp }, .{ .timestamp = timestamp },
.{ .session_id = session_id }, .{ .session_id = session_id },
); );
// lifecycle DOMContentLoaded event
// TODO: partially hard coded
if (bc.page_life_cycle_events) { if (bc.page_life_cycle_events) {
const frame_id = &id.toFrameId(event.frame_id);
const loader_id = &id.toLoaderId(event.req_id);
try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{ try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{
.timestamp = timestamp, .timestamp = timestamp,
.name = "DOMContentLoaded", .name = "DOMContentLoaded",
@@ -573,16 +532,23 @@ pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.P
.loaderId = loader_id, .loaderId = loader_id,
}, .{ .session_id = session_id }); }, .{ .session_id = session_id });
} }
}
pub fn pageLoaded(bc: anytype, event: *const Notification.PageLoaded) !void {
const session_id = bc.session_id orelse return;
const timestamp = event.timestamp;
var cdp = bc.cdp;
const frame_id = &id.toFrameId(event.frame_id);
// loadEventFired event
try cdp.sendEvent( try cdp.sendEvent(
"Page.loadEventFired", "Page.loadEventFired",
.{ .timestamp = timestamp }, .{ .timestamp = timestamp },
.{ .session_id = session_id }, .{ .session_id = session_id },
); );
// lifecycle DOMContentLoaded event
if (bc.page_life_cycle_events) { if (bc.page_life_cycle_events) {
const loader_id = &id.toLoaderId(event.req_id);
try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{ try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{
.timestamp = timestamp, .timestamp = timestamp,
.name = "load", .name = "load",
@@ -591,7 +557,6 @@ pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.P
}, .{ .session_id = session_id }); }, .{ .session_id = session_id });
} }
// frameStoppedLoading
return cdp.sendEvent("Page.frameStoppedLoading", .{ return cdp.sendEvent("Page.frameStoppedLoading", .{
.frameId = frame_id, .frameId = frame_id,
}, .{ .session_id = session_id }); }, .{ .session_id = session_id });
@@ -896,55 +861,3 @@ test "cdp.page: reload" {
try ctx.processMessage(.{ .id = 32, .method = "Page.reload", .params = .{ .ignoreCache = true } }); try ctx.processMessage(.{ .id = 32, .method = "Page.reload", .params = .{ .ignoreCache = true } });
} }
} }
test "cdp.page: addScriptToEvaluateOnNewDocument" {
var ctx = try testing.context();
defer ctx.deinit();
var bc = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* });
{
// Register a script — should return unique identifier "1"
try ctx.processMessage(.{ .id = 20, .method = "Page.addScriptToEvaluateOnNewDocument", .params = .{ .source = "window.__test = 1" } });
try ctx.expectSentResult(.{
.identifier = "1",
}, .{ .id = 20 });
}
{
// Register another script — should return identifier "2"
try ctx.processMessage(.{ .id = 21, .method = "Page.addScriptToEvaluateOnNewDocument", .params = .{ .source = "window.__test2 = 2" } });
try ctx.expectSentResult(.{
.identifier = "2",
}, .{ .id = 21 });
}
{
// Remove the first script — should succeed
try ctx.processMessage(.{ .id = 22, .method = "Page.removeScriptToEvaluateOnNewDocument", .params = .{ .identifier = "1" } });
try ctx.expectSentResult(null, .{ .id = 22 });
}
{
// Remove a non-existent identifier — should succeed silently
try ctx.processMessage(.{ .id = 23, .method = "Page.removeScriptToEvaluateOnNewDocument", .params = .{ .identifier = "999" } });
try ctx.expectSentResult(null, .{ .id = 23 });
}
{
try ctx.processMessage(.{ .id = 34, .method = "Page.reload" });
// wait for this event, which is sent after we've run the registered scripts
try ctx.expectSentEvent("Page.frameNavigated", .{
.frame = .{ .loaderId = "LID-0000000002" },
}, .{});
const page = bc.session.currentPage() orelse unreachable;
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer ls.deinit();
const test_val = try ls.local.exec("window.__test2", null);
try testing.expectEqual(2, try test_val.toI32());
}
}

View File

@@ -158,20 +158,15 @@ fn createTarget(cmd: anytype) !void {
else => return err, else => return err,
}; };
if (bc.target_id != null) {
return error.TargetAlreadyLoaded;
}
if (params.browserContextId) |param_browser_context_id| { if (params.browserContextId) |param_browser_context_id| {
if (std.mem.eql(u8, param_browser_context_id, bc.id) == false) { if (std.mem.eql(u8, param_browser_context_id, bc.id) == false) {
return error.UnknownBrowserContextId; return error.UnknownBrowserContextId;
} }
} }
// If a target already exists, close it first. Lightpanda only supports
// one page at a time, so we replace the existing target rather than
// rejecting the request. This unblocks automation frameworks (e.g.
// Stagehand) that call createTarget multiple times.
if (bc.target_id != null) {
try doCloseTarget(cmd, bc);
}
// if target_id is null, we should never have a page // if target_id is null, we should never have a page
lp.assert(bc.session.page == null, "CDP.target.createTarget not null page", .{}); lp.assert(bc.session.page == null, "CDP.target.createTarget not null page", .{});
@@ -285,9 +280,34 @@ fn closeTarget(cmd: anytype) !void {
return error.UnknownTargetId; return error.UnknownTargetId;
} }
// can't be null if we have a target_id
lp.assert(bc.session.page != null, "CDP.target.closeTarget null page", .{});
try cmd.sendResult(.{ .success = true }, .{ .include_session_id = false }); try cmd.sendResult(.{ .success = true }, .{ .include_session_id = false });
try doCloseTarget(cmd, bc); // could be null, created but never attached
if (bc.session_id) |session_id| {
// Inspector.detached event
try cmd.sendEvent("Inspector.detached", .{
.reason = "Render process gone.",
}, .{ .session_id = session_id });
// detachedFromTarget event
try cmd.sendEvent("Target.detachedFromTarget", .{
.targetId = target_id,
.sessionId = session_id,
.reason = "Render process gone.",
}, .{});
bc.session_id = null;
}
bc.session.removePage();
for (bc.isolated_worlds.items) |world| {
world.deinit();
}
bc.isolated_worlds.clearRetainingCapacity();
bc.target_id = null;
} }
fn getTargetInfo(cmd: anytype) !void { fn getTargetInfo(cmd: anytype) !void {
@@ -448,41 +468,6 @@ fn setAutoAttach(cmd: anytype) !void {
try cmd.sendResult(null, .{}); try cmd.sendResult(null, .{});
} }
/// Close the current target in a browser context: send detach events,
/// remove the page, clean up isolated worlds, and clear the target_id.
/// Shared by closeTarget and createTarget (which auto-closes before
/// creating a replacement).
fn doCloseTarget(cmd: anytype, bc: anytype) !void {
// can't be null if we have a target_id
lp.assert(bc.session.page != null, "CDP.target.doCloseTarget null page", .{});
// could be null, created but never attached
if (bc.session_id) |session_id| {
try cmd.sendEvent("Inspector.detached", .{
.reason = "Render process gone.",
}, .{ .session_id = session_id });
try cmd.sendEvent("Target.detachedFromTarget", .{
.targetId = &bc.target_id.?,
.sessionId = session_id,
.reason = "Render process gone.",
}, .{});
bc.session_id = null;
}
try cmd.sendEvent("Target.targetDestroyed", .{
.targetId = &bc.target_id.?,
}, .{});
bc.session.removePage();
for (bc.isolated_worlds.items) |world| {
world.deinit();
}
bc.isolated_worlds.clearRetainingCapacity();
bc.target_id = null;
}
fn doAttachtoTarget(cmd: anytype, target_id: []const u8) !void { fn doAttachtoTarget(cmd: anytype, target_id: []const u8) !void {
const bc = cmd.browser_context.?; const bc = cmd.browser_context.?;
const session_id = bc.session_id orelse cmd.cdp.session_id_gen.next(); const session_id = bc.session_id orelse cmd.cdp.session_id_gen.next();
@@ -661,7 +646,6 @@ test "cdp.target: closeTarget" {
{ {
try ctx.processMessage(.{ .id = 11, .method = "Target.closeTarget", .params = .{ .targetId = "TID-000000000A" } }); try ctx.processMessage(.{ .id = 11, .method = "Target.closeTarget", .params = .{ .targetId = "TID-000000000A" } });
try ctx.expectSentResult(.{ .success = true }, .{ .id = 11 }); try ctx.expectSentResult(.{ .success = true }, .{ .id = 11 });
try ctx.expectSentEvent("Target.targetDestroyed", .{ .targetId = "TID-000000000A" }, .{});
try testing.expectEqual(null, bc.session.page); try testing.expectEqual(null, bc.session.page);
try testing.expectEqual(null, bc.target_id); try testing.expectEqual(null, bc.target_id);
} }
@@ -787,53 +771,6 @@ test "cdp.target: detachFromTarget" {
} }
} }
test "cdp.target: createTarget closes existing target (issue #1962)" {
var ctx = try testing.context();
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" });
{
// Create first target
try ctx.processMessage(.{ .id = 10, .method = "Target.createTarget", .params = .{ .browserContextId = "BID-9" } });
try testing.expectEqual(true, bc.target_id != null);
try ctx.expectSentResult(.{ .targetId = bc.target_id.? }, .{ .id = 10 });
// Create second target — should succeed by auto-closing the first
try ctx.processMessage(.{ .id = 11, .method = "Target.createTarget", .params = .{ .browserContextId = "BID-9" } });
try ctx.expectSentEvent("Target.targetDestroyed", .{ .targetId = "FID-0000000001" }, .{});
try testing.expectEqual(true, bc.target_id != null);
try ctx.expectSentResult(.{ .targetId = bc.target_id.? }, .{ .id = 11 });
// Page should exist (new target is active)
try testing.expectEqual(true, bc.session.page != null);
}
}
test "cdp.target: createTarget closes existing attached target (issue #1962)" {
var ctx = try testing.context();
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" });
{
// Create and attach first target
try ctx.processMessage(.{ .id = 10, .method = "Target.createTarget", .params = .{ .browserContextId = "BID-9" } });
try testing.expectEqual(true, bc.target_id != null);
try ctx.expectSentResult(.{ .targetId = bc.target_id.? }, .{ .id = 10 });
try ctx.processMessage(.{ .id = 11, .method = "Target.attachToTarget", .params = .{ .targetId = bc.target_id.? } });
const session_id = bc.session_id.?;
try ctx.expectSentResult(.{ .sessionId = session_id }, .{ .id = 11 });
// Create second target — should close and detach the first
try ctx.processMessage(.{ .id = 12, .method = "Target.createTarget", .params = .{ .browserContextId = "BID-9" } });
// Should have sent detach events for the old session
try ctx.expectSentEvent("Inspector.detached", .{ .reason = "Render process gone." }, .{ .session_id = session_id });
try ctx.expectSentEvent("Target.detachedFromTarget", .{ .sessionId = session_id, .reason = "Render process gone." }, .{});
try ctx.expectSentEvent("Target.targetDestroyed", .{ .targetId = "FID-0000000001" }, .{});
// New target should be created
try testing.expectEqual(true, bc.target_id != null);
try ctx.expectSentResult(.{ .targetId = bc.target_id.? }, .{ .id = 12 });
}
}
test "cdp.target: detachFromTarget without session" { test "cdp.target: detachFromTarget without session" {
var ctx = try testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();

View File

@@ -168,26 +168,13 @@ const TestContext = struct {
index: ?usize = null, index: ?usize = null,
}; };
pub fn expectSent(self: *TestContext, expected: anytype, opts: SentOpts) !void { pub fn expectSent(self: *TestContext, expected: anytype, opts: SentOpts) !void {
const expected_json = blk: { const serialized = try json.Stringify.valueAlloc(base.arena_allocator, expected, .{
// Zig makes this hard. When sendJSON is called, we're sending an anytype. .whitespace = .indent_2,
// We can't record that in an ArrayList(???), so we serialize it to JSON. .emit_null_optional_fields = false,
// Now, ideally, we could just take our expected structure, serialize it to });
// json and check if the two are equal.
// Except serializing to JSON isn't deterministic.
// So we serialize the JSON then we deserialize to json.Value. And then we can
// compare our anytype expectation with the json.Value that we captured
const serialized = try json.Stringify.valueAlloc(base.arena_allocator, expected, .{
.whitespace = .indent_2,
.emit_null_optional_fields = false,
});
break :blk try std.json.parseFromSliceLeaky(json.Value, base.arena_allocator, serialized, .{});
};
for (0..5) |_| { for (0..5) |_| {
for (self.received.items, 0..) |received, i| { for (self.received.items, 0..) |received, i| {
if (try base.isEqualJson(expected_json, received) == false) { if (try compareExpectedToSent(serialized, received) == false) {
continue; continue;
} }
@@ -200,15 +187,6 @@ const TestContext = struct {
} }
return; return;
} }
if (self.cdp_) |*cdp__| {
if (cdp__.browser_context) |*bc| {
if (bc.session.page != null) {
var runner = try bc.session.runner(.{});
_ = try runner.tick(.{ .ms = 1000 });
}
}
}
std.Thread.sleep(5 * std.time.ns_per_ms); std.Thread.sleep(5 * std.time.ns_per_ms);
try self.read(); try self.read();
} }
@@ -321,3 +299,17 @@ pub fn context() !TestContext {
.socket = pair[0], .socket = pair[0],
}; };
} }
// Zig makes this hard. When sendJSON is called, we're sending an anytype.
// We can't record that in an ArrayList(???), so we serialize it to JSON.
// Now, ideally, we could just take our expected structure, serialize it to
// json and check if the two are equal.
// Except serializing to JSON isn't deterministic.
// So we serialize the JSON then we deserialize to json.Value. And then we can
// compare our anytype expectation with the json.Value that we captured
fn compareExpectedToSent(expected: []const u8, actual: json.Value) !bool {
const expected_value = try std.json.parseFromSlice(json.Value, std.testing.allocator, expected, .{});
defer expected_value.deinit();
return base.isEqualJson(expected_value.value, actual);
}

View File

@@ -1,7 +1,6 @@
const std = @import("std"); const std = @import("std");
pub const protocol = @import("mcp/protocol.zig"); pub const protocol = @import("mcp/protocol.zig");
pub const Version = protocol.Version;
pub const router = @import("mcp/router.zig"); pub const router = @import("mcp/router.zig");
pub const Server = @import("mcp/Server.zig"); pub const Server = @import("mcp/Server.zig");

View File

@@ -114,7 +114,7 @@ test "MCP.Server - Integration: synchronous smoke test" {
try router.processRequests(server, &in_reader); try router.processRequests(server, &in_reader);
try testing.expectJson(.{ .jsonrpc = "2.0", .id = 1, .result = .{ .protocolVersion = "2024-11-05" } }, out_alloc.writer.buffered()); try testing.expectJson(.{ .jsonrpc = "2.0", .id = 1 }, out_alloc.writer.buffered());
} }
test "MCP.Server - Integration: ping request returns an empty result" { test "MCP.Server - Integration: ping request returns an empty result" {

View File

@@ -1,14 +1,5 @@
const std = @import("std"); const std = @import("std");
pub const Version = enum {
@"2024-11-05",
@"2025-03-26",
@"2025-06-18",
@"2025-11-25",
pub const default: Version = .@"2024-11-05";
};
pub const Request = struct { pub const Request = struct {
jsonrpc: []const u8 = "2.0", jsonrpc: []const u8 = "2.0",
id: ?std.json.Value = null, id: ?std.json.Value = null,

View File

@@ -81,12 +81,8 @@ pub fn handleMessage(server: *Server, arena: std.mem.Allocator, msg: []const u8)
fn handleInitialize(server: *Server, req: protocol.Request) !void { fn handleInitialize(server: *Server, req: protocol.Request) !void {
const id = req.id orelse return; const id = req.id orelse return;
const version: protocol.Version = switch (server.app.config.mode) { const result = protocol.InitializeResult{
.mcp => |opts| opts.version, .protocolVersion = "2025-11-25",
else => .default,
};
const result: protocol.InitializeResult = .{
.protocolVersion = @tagName(version),
.capabilities = .{ .capabilities = .{
.resources = .{}, .resources = .{},
.tools = .{}, .tools = .{},
@@ -125,7 +121,7 @@ test "MCP.router - handleMessage - synchronous unit tests" {
\\{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test-client","version":"1.0.0"}}} \\{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test-client","version":"1.0.0"}}}
); );
try testing.expectJson( try testing.expectJson(
\\{ "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2024-11-05", "capabilities": { "tools": {} } } } \\{ "jsonrpc": "2.0", "id": 1, "result": { "capabilities": { "tools": {} } } }
, out_alloc.writer.buffered()); , out_alloc.writer.buffered());
out_alloc.writer.end = 0; out_alloc.writer.end = 0;