mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-10-09 04:42:30 -04:00
1634 lines
65 KiB
Zig
1634 lines
65 KiB
Zig
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
|
|
//
|
|
// Francis Bouvier <francis@lightpanda.io>
|
|
// Pierre Tachoire <pierre@lightpanda.io>
|
|
//
|
|
// 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 <https://www.gnu.org/licenses/>.
|
|
|
|
const std = @import("std");
|
|
const lp = @import("lightpanda");
|
|
|
|
const App = @import("../../App.zig");
|
|
const Inbox = @import("../../Inbox.zig");
|
|
const Notification = @import("../../Notification.zig");
|
|
|
|
const sys_net = @import("../../sys/net.zig");
|
|
const http = @import("../../network/http.zig");
|
|
const HttpClient = @import("../../network/HttpClient.zig");
|
|
|
|
const Page = @import("../../browser/Page.zig");
|
|
const Mime = @import("../../browser/Mime.zig");
|
|
const Frame = @import("../../browser/Frame.zig");
|
|
const Browser = @import("../../browser/Browser.zig");
|
|
const Session = @import("../../browser/Session.zig");
|
|
const Label = @import("../../browser/webapi/element/html/Label.zig");
|
|
|
|
const WS = @import("../WS.zig");
|
|
const Link = @import("../Link.zig");
|
|
const Incrementing = @import("id.zig").Incrementing;
|
|
|
|
const fetch = @import("domains/fetch.zig");
|
|
const network_domain = @import("domains/network.zig");
|
|
|
|
const js = lp.js;
|
|
const log = lp.log;
|
|
const json = std.json;
|
|
const posix = std.posix;
|
|
const Allocator = std.mem.Allocator;
|
|
const InterceptState = fetch.InterceptState;
|
|
|
|
pub const URL_BASE = "chrome://newtab/";
|
|
|
|
const SessionIdGen = Incrementing(u32, "SID");
|
|
const BrowserSessionIdGen = Incrementing(u32, "BSID");
|
|
const BrowserContextIdGen = Incrementing(u32, "BID");
|
|
// webmcp tool invocation
|
|
pub const InvocationIdGen = Incrementing(u32, "INV");
|
|
|
|
// Generic so that we can inject mocks into it.
|
|
const CDP = @This();
|
|
|
|
app: *App,
|
|
link: Link,
|
|
browser: Browser,
|
|
allocator: Allocator,
|
|
|
|
// when true, any target creation must be attached.
|
|
target_auto_attach: bool = false,
|
|
|
|
session_id_gen: SessionIdGen = .{},
|
|
browser_session_id_gen: BrowserSessionIdGen = .{},
|
|
browser_context_id_gen: BrowserContextIdGen = .{},
|
|
|
|
// Session from Target.attachToBrowserTarget. Distinct from the page
|
|
// session (BrowserContext.session_id) as it targets the browser itself and
|
|
// outlives any browser context.
|
|
browser_session_id: ?[]const u8 = null,
|
|
|
|
browser_context: ?BrowserContext,
|
|
disable_set_cache_disabled: bool = false,
|
|
|
|
// Re-used arena for processing a message. Works because we strictly process
|
|
// one message at a time.
|
|
message_arena: std.heap.ArenaAllocator,
|
|
|
|
// Retained until the outermost notification handler returns.
|
|
notification_arena: std.heap.ArenaAllocator,
|
|
notification_depth: u32 = 0,
|
|
|
|
// Valid for 1 frame navigation (what CDP calls a "renderer")
|
|
frame_arena: std.heap.ArenaAllocator,
|
|
|
|
// Valid for the entire lifetime of the BrowserContext. Should minimize
|
|
// (or altogether eliminate) our use of this.
|
|
browser_context_arena: std.heap.ArenaAllocator,
|
|
|
|
// Files handed out as IO stream handles (Page.printToPDF ReturnAsStream).
|
|
streams: @import("domains/io.zig").Streams,
|
|
|
|
pub fn init(self: *CDP, app: *App, socket: posix.socket_t, inbox: *Inbox) !void {
|
|
const allocator = app.allocator;
|
|
{
|
|
// this is documentation, and future-proofing, to show exactly where
|
|
// the socket's ownership is
|
|
errdefer sys_net.close(socket);
|
|
|
|
self.* = .{
|
|
.app = app,
|
|
.link = undefined,
|
|
.browser = undefined,
|
|
.allocator = allocator,
|
|
.browser_context = null,
|
|
.frame_arena = std.heap.ArenaAllocator.init(allocator),
|
|
.message_arena = std.heap.ArenaAllocator.init(allocator),
|
|
.notification_arena = std.heap.ArenaAllocator.init(allocator),
|
|
.browser_context_arena = std.heap.ArenaAllocator.init(allocator),
|
|
.streams = .{ .allocator = allocator },
|
|
};
|
|
}
|
|
|
|
// takes ownership of the socket
|
|
try self.link.init(app, socket, .cdp, inbox);
|
|
errdefer self.link.deinit();
|
|
|
|
try self.browser.init(app, .{ .env = .{ .with_inspector = true } });
|
|
}
|
|
|
|
pub fn deinit(self: *CDP) void {
|
|
if (self.browser_context) |*bc| {
|
|
bc.deinit();
|
|
}
|
|
self.browser.deinit();
|
|
self.frame_arena.deinit();
|
|
self.message_arena.deinit();
|
|
self.notification_arena.deinit();
|
|
self.browser_context_arena.deinit();
|
|
self.streams.deinit();
|
|
self.link.deinit();
|
|
}
|
|
// Called by the Server run loop when readable bytes arrive on the CDP
|
|
// socket. Feeds them through the WS framer and pushes each parsed frame
|
|
// into the worker's inbox. Returns false if a close frame was seen (or a
|
|
// fatal frame error) so the loop drops the link from its poll set.
|
|
//
|
|
// Called in the Worker to dispatch a single CDP message bubbled up by
|
|
// HttpClient.drainInbox. The Server run loop already parsed the JSON
|
|
// when it pushed the message to the inbox, so we skip straight to
|
|
// dispatchParsed without re-parsing. `c.raw` and `c.arena` outlive
|
|
// this call (they're owned by the inbox Message which drainInbox
|
|
// frees right after we return), so `c.input`'s string slices stay
|
|
// valid for the duration of dispatch.
|
|
pub fn onMessage(self: *CDP, c: *Inbox.Message.Cdp) anyerror!void {
|
|
// Once a terminate is pending, don't dispatch
|
|
if (self.browser.env.terminatePending()) {
|
|
return;
|
|
}
|
|
|
|
const arena = &self.message_arena;
|
|
defer _ = arena.reset(.{ .retain_with_limit = 1024 * 16 });
|
|
return self.dispatchParsed(arena.allocator(), .{ .cdp = self }, c.raw, c.input);
|
|
}
|
|
|
|
// Parse + dispatch a raw JSON CDP message. Used by tests (which
|
|
// don't go through the Network thread / inbox pipeline) and by any
|
|
// caller that has bytes rather than a pre-parsed InputMessage.
|
|
pub fn processMessage(self: *CDP, msg: []const u8) !void {
|
|
const arena = &self.message_arena;
|
|
defer _ = arena.reset(.{ .retain_with_limit = 1024 * 16 });
|
|
return self.dispatch(arena.allocator(), .{ .cdp = self }, msg);
|
|
}
|
|
|
|
pub fn sendJSON(self: *CDP, message: anytype) !void {
|
|
try self.link.sendJSON(message, .{ .emit_null_optional_fields = false });
|
|
}
|
|
|
|
// Parse-then-dispatch entry point. Used by:
|
|
// - CDP.processMessage (tests, and any other caller that hands us
|
|
// raw JSON bytes).
|
|
// - Target.sendMessageToTarget (a CDP command that wraps another
|
|
// CDP message as a string parameter and forwards it through the
|
|
// dispatch table).
|
|
// The normal Network-thread path doesn't go through here — it parses
|
|
// once on the Network thread and reaches dispatchParsed directly via
|
|
// onMessage.
|
|
pub fn dispatch(self: *CDP, arena: Allocator, sender: Command.Sender, str: []const u8) !void {
|
|
const input = json.parseFromSliceLeaky(InputMessage, arena, str, .{
|
|
.ignore_unknown_fields = true,
|
|
}) catch return error.InvalidJSON;
|
|
return self.dispatchParsed(arena, sender, str, input);
|
|
}
|
|
|
|
// Dispatch a pre-parsed CDP message. The caller is responsible for
|
|
// keeping `str` and the backing storage for `input`'s string slices
|
|
// alive for the duration of the call.
|
|
fn dispatchParsed(self: *CDP, arena: Allocator, sender: Command.Sender, str: []const u8, input: InputMessage) !void {
|
|
lp.metrics.serve_commands.incr(.cdp);
|
|
|
|
var command = Command{
|
|
.input = .{
|
|
.json = str,
|
|
.id = input.id,
|
|
.action = "",
|
|
.params = input.params,
|
|
.session_id = input.sessionId,
|
|
},
|
|
.cdp = self,
|
|
.arena = arena,
|
|
.sender = sender,
|
|
.browser_context = if (self.browser_context) |*bc| bc else null,
|
|
};
|
|
|
|
// See dispatchStartupCommand for more info on this.
|
|
var is_startup = false;
|
|
if (input.sessionId) |input_session_id| {
|
|
if (std.mem.eql(u8, input_session_id, "STARTUP")) {
|
|
is_startup = true;
|
|
} else if (self.isValidSessionId(input_session_id) == false) {
|
|
return command.sendError(-32001, "Unknown sessionId", .{});
|
|
}
|
|
}
|
|
|
|
if (is_startup) {
|
|
dispatchStartupCommand(&command, input.method) catch |err| {
|
|
command.sendError(-31999, @errorName(err), .{}) catch return err;
|
|
};
|
|
} else {
|
|
dispatchCommand(&command, input.method) catch |err| {
|
|
switch (err) {
|
|
error.InvalidMethod, error.UnknownDomain, error.UnknownMethod => {
|
|
lp.metrics.serve_unknown_commands.incr(.cdp);
|
|
// Chrome's code and wording; drivers feature-detect on it.
|
|
const message = std.fmt.allocPrint(command.arena, "'{s}' wasn't found", .{input.method}) catch return err;
|
|
command.sendError(-32601, message, .{}) catch return err;
|
|
},
|
|
else => command.sendError(-31998, @errorName(err), .{}) catch return err,
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
// A CDP session isn't 100% fully driven by the driver. There's are
|
|
// independent actions that the browser is expected to take. For example
|
|
// Puppeteer expects the browser to startup a tab and thus have existing
|
|
// targets.
|
|
// To this end, we create a [very] dummy BrowserContext, Target and
|
|
// Session. There isn't actually a BrowserContext, just a special id.
|
|
// When messages are received with the "STARTUP" sessionId, we do
|
|
// "special" handling - the bare minimum we need to do until the driver
|
|
// switches to a real BrowserContext.
|
|
// (I can imagine this logic will become driver-specific)
|
|
fn dispatchStartupCommand(command: *Command, method: []const u8) !void {
|
|
// Stagehand parses the response and error if we don't return a
|
|
// correct one for Page.getFrameTree on startup call.
|
|
if (std.mem.eql(u8, method, "Page.getFrameTree")) {
|
|
// The Page.getFrameTree handles startup response gracefully.
|
|
return dispatchCommand(command, method);
|
|
}
|
|
|
|
return command.sendResult(null, .{});
|
|
}
|
|
|
|
fn dispatchCommand(command: *Command, method: []const u8) !void {
|
|
const domain = blk: {
|
|
const i = std.mem.indexOfScalarPos(u8, method, 0, '.') orelse {
|
|
return error.InvalidMethod;
|
|
};
|
|
command.input.action = method[i + 1 ..];
|
|
break :blk method[0..i];
|
|
};
|
|
|
|
switch (domain.len) {
|
|
2 => switch (@as(u16, @bitCast(domain[0..2].*))) {
|
|
asUint(u16, "LP") => return @import("domains/lp.zig").processMessage(command),
|
|
asUint(u16, "IO") => return @import("domains/io.zig").processMessage(command),
|
|
else => {},
|
|
},
|
|
3 => switch (@as(u24, @bitCast(domain[0..3].*))) {
|
|
asUint(u24, "DOM") => return @import("domains/dom.zig").processMessage(command),
|
|
asUint(u24, "Log") => return @import("domains/log.zig").processMessage(command),
|
|
asUint(u24, "CSS") => return @import("domains/css.zig").processMessage(command),
|
|
else => {},
|
|
},
|
|
4 => switch (@as(u32, @bitCast(domain[0..4].*))) {
|
|
asUint(u32, "Page") => return @import("domains/page.zig").processMessage(command),
|
|
else => {},
|
|
},
|
|
5 => switch (@as(u40, @bitCast(domain[0..5].*))) {
|
|
asUint(u40, "Fetch") => return @import("domains/fetch.zig").processMessage(command),
|
|
asUint(u40, "Input") => return @import("domains/input.zig").processMessage(command),
|
|
else => {},
|
|
},
|
|
6 => switch (@as(u48, @bitCast(domain[0..6].*))) {
|
|
asUint(u48, "Target") => return @import("domains/target.zig").processMessage(command),
|
|
asUint(u48, "Audits") => return @import("domains/audits.zig").processMessage(command),
|
|
asUint(u48, "WebMCP") => return @import("domains/webmcp.zig").processMessage(command),
|
|
else => {},
|
|
},
|
|
7 => switch (@as(u56, @bitCast(domain[0..7].*))) {
|
|
asUint(u56, "Browser") => return @import("domains/browser.zig").processMessage(command),
|
|
asUint(u56, "Runtime") => return @import("domains/runtime.zig").processMessage(command),
|
|
asUint(u56, "Network") => return network_domain.processMessage(command),
|
|
asUint(u56, "Storage") => return @import("domains/storage.zig").processMessage(command),
|
|
asUint(u56, "Console") => return @import("domains/console.zig").processMessage(command),
|
|
else => {},
|
|
},
|
|
8 => switch (@as(u64, @bitCast(domain[0..8].*))) {
|
|
asUint(u64, "Security") => return @import("domains/security.zig").processMessage(command),
|
|
else => {},
|
|
},
|
|
9 => switch (@as(u72, @bitCast(domain[0..9].*))) {
|
|
asUint(u72, "Emulation") => return @import("domains/emulation.zig").processMessage(command),
|
|
asUint(u72, "Inspector") => return @import("domains/inspector.zig").processMessage(command),
|
|
else => {},
|
|
},
|
|
11 => switch (@as(u88, @bitCast(domain[0..11].*))) {
|
|
asUint(u88, "Performance") => return @import("domains/performance.zig").processMessage(command),
|
|
else => {},
|
|
},
|
|
13 => switch (@as(u104, @bitCast(domain[0..13].*))) {
|
|
asUint(u104, "Accessibility") => return @import("domains/accessibility.zig").processMessage(command),
|
|
else => {},
|
|
},
|
|
|
|
else => {},
|
|
}
|
|
|
|
return error.UnknownDomain;
|
|
}
|
|
|
|
pub fn resolveSessionId(self: *const CDP, input_session_id: []const u8) ?[]const u8 {
|
|
if (self.browser_session_id) |browser_session_id| {
|
|
if (std.mem.eql(u8, browser_session_id, input_session_id)) {
|
|
return browser_session_id;
|
|
}
|
|
}
|
|
const browser_context = &(self.browser_context orelse return null);
|
|
if (browser_context.session_id) |session_id| {
|
|
if (std.mem.eql(u8, session_id, input_session_id)) {
|
|
return session_id;
|
|
}
|
|
}
|
|
for (browser_context.attached_sessions.items) |session| {
|
|
if (std.mem.eql(u8, session.id, input_session_id)) {
|
|
return session.id;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
fn isValidSessionId(self: *const CDP, input_session_id: []const u8) bool {
|
|
return self.resolveSessionId(input_session_id) != null;
|
|
}
|
|
|
|
pub fn createBrowserContext(self: *CDP) ![]const u8 {
|
|
if (self.browser_context != null) {
|
|
return error.AlreadyExists;
|
|
}
|
|
const id = self.browser_context_id_gen.next();
|
|
|
|
self.browser_context = @as(BrowserContext, undefined);
|
|
const browser_context = &self.browser_context.?;
|
|
|
|
try BrowserContext.init(browser_context, id, self);
|
|
return id;
|
|
}
|
|
|
|
pub fn disposeBrowserContext(self: *CDP, browser_context_id: []const u8) bool {
|
|
const bc = &(self.browser_context orelse return false);
|
|
if (std.mem.eql(u8, bc.id, browser_context_id) == false) {
|
|
return false;
|
|
}
|
|
bc.deinit();
|
|
self.browser.closeSession();
|
|
self.browser_context = null;
|
|
_ = self.browser_context_arena.reset(.{ .retain_with_limit = 1024 * 16 });
|
|
return true;
|
|
}
|
|
|
|
const SendEventOpts = struct {
|
|
session_id: ?[]const u8 = null,
|
|
};
|
|
pub fn sendEvent(self: *CDP, method: []const u8, p: anytype, opts: SendEventOpts) !void {
|
|
return self.sendJSON(.{
|
|
.method = method,
|
|
.params = if (comptime @typeInfo(@TypeOf(p)) == .null) struct {}{} else p,
|
|
.sessionId = opts.session_id,
|
|
});
|
|
}
|
|
|
|
pub const BrowserContext = struct {
|
|
const Node = @import("Node.zig");
|
|
const AXNode = @import("AXNode.zig");
|
|
const NodeRegistry = @import("../../NodeRegistry.zig");
|
|
|
|
const CapturedResponse = struct {
|
|
must_encode: bool,
|
|
// null once evicted (size limit). We keep the map entry so that we
|
|
// can give an "evicted" error instead of "unknown"
|
|
data: ?std.ArrayList(u8),
|
|
};
|
|
|
|
// Key for `captured_responses` / `captured_requests`. Documents are
|
|
// keyed by `loader_id`, everything else by `request_id` — the two
|
|
// id-spaces are independent counters and overlap numerically (loader 1
|
|
// / request 1, loader 2 / request 2, ...), so the map key has to carry
|
|
// the namespace or entries collide. The wire-format prefix (`LID-` /
|
|
// `REQ-`) provides the same disambiguation on lookup; see
|
|
// `idFromRequestId` in domains/network.zig.
|
|
pub const CapturedKey = struct {
|
|
kind: enum { request, loader },
|
|
id: u32,
|
|
};
|
|
|
|
const AttachedSession = struct {
|
|
id: []const u8,
|
|
parent_id: ?[]const u8,
|
|
};
|
|
id: []const u8,
|
|
cdp: *CDP,
|
|
|
|
// Represents the browser session. There is no equivalent in CDP. For
|
|
// all intents and purpose, from CDP's point of view our Browser and
|
|
// our Session more or less maps to a BrowserContext. THIS HAS ZERO
|
|
// RELATION TO SESSION_ID
|
|
session: *Session,
|
|
|
|
// Tied to the lifetime of the BrowserContext
|
|
arena: Allocator,
|
|
|
|
// Tied to the lifetime of 1 page rendered in the BrowserContext.
|
|
frame_arena: Allocator,
|
|
|
|
// From the parent's notification_arena.allocator(). Most of the CDP
|
|
// code paths deal with a cmd which has its own arena (from the
|
|
// message_arena). But notifications happen outside of the typical CDP
|
|
// request->response, and thus don't have a cmd and don't have an arena.
|
|
notification_arena: Allocator,
|
|
|
|
// Maps to our Page. (There are other types of targets, but we only
|
|
// deal with "pages" for now). Since we only allow 1 open page at a
|
|
// time, we only have 1 target_id.
|
|
target_id: ?[14]u8,
|
|
|
|
// Navigation-safe handle to this context's page, set when `frameCreated`
|
|
// fires. A BrowserContext is single-target by protocol, so it resolves its
|
|
// (live) page/frame through this handle — see `mainFrame` / `mainPage`. The
|
|
// handle's frame_id is stable across navigations (the replacement reuses
|
|
// it). null until the first page is created.
|
|
page_handle: ?Session.PageHandle = null,
|
|
|
|
// The CDP session_id. After the target/page is created, the client
|
|
// "attaches" to it (either explicitly or automatically). We return a
|
|
// "sessionId" which identifies this link. `sessionId` is the how
|
|
// the CDP client informs us what it's trying to manipulate. Because we
|
|
// only support 1 BrowserContext at a time, and 1 page at a time, this
|
|
// is all pretty straightforward, but it still needs to be enforced, i.e.
|
|
// if we get a request with a sessionId that doesn't match the current one
|
|
// we should reject it.
|
|
session_id: ?[]const u8,
|
|
attached_sessions: std.ArrayList(AttachedSession) = .empty,
|
|
|
|
// A cancelled text-less keyDown drops the char message that follows it
|
|
// (chromedp's keyDown/char/keyUp split), as Chrome does.
|
|
suppress_next_char: bool = false,
|
|
|
|
security_origin: []const u8,
|
|
page_life_cycle_events: bool,
|
|
secure_context_type: []const u8,
|
|
node_registry: NodeRegistry,
|
|
node_search_list: Node.Search.List,
|
|
|
|
// Node ids a DOM.setChildNodes event was already sent for. CDP protocol
|
|
// bookkeeping; ids are never reused within a registry's lifetime, so
|
|
// entries evicted by resetFrame can linger harmlessly until reset.
|
|
set_child_nodes_sent: std.AutoHashMapUnmanaged(NodeRegistry.Id, void) = .empty,
|
|
|
|
inspector_session: *js.Inspector.Session,
|
|
isolated_worlds: std.ArrayList(*IsolatedWorld),
|
|
|
|
// True when Runtime.evaluate has been run in the main world. Optimization
|
|
// so that, if it has _not_ and the page is on its initial about:blank, we
|
|
// can use the existing Frame/js.Context as-is.
|
|
main_world_touched: bool = false,
|
|
|
|
// 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,
|
|
user_agent_changed: bool = false,
|
|
|
|
// Extra headers to add to all requests.
|
|
extra_headers: std.ArrayList(http.Header) = .empty,
|
|
|
|
intercept_state: InterceptState,
|
|
fetch_session_id: ?[]const u8 = null,
|
|
|
|
// Request bodies retained for Network.getRequestPostData, which can be
|
|
// called after the transfer is gone. Capped at max_post_data_size.
|
|
captured_requests: std.AutoHashMapUnmanaged(CapturedKey, []const u8),
|
|
|
|
// When network is enabled, we'll capture the transfer.id -> body
|
|
// This is awfully memory intensive, but our underlying http client and
|
|
// its users (script manager and frame) correctly do not hold the body
|
|
// memory longer than they have to. In fact, the main request is only
|
|
// ever streamed. So if CDP is the only thing that needs bodies in
|
|
// memory for an arbitrary amount of time, then that's where we're going
|
|
// to store them.
|
|
captured_responses_size: usize = 0,
|
|
captured_responses: std.AutoArrayHashMapUnmanaged(CapturedKey, CapturedResponse),
|
|
network_limits: network_domain.BufferLimits = .{},
|
|
|
|
notification: *Notification,
|
|
|
|
// Pre-armed response for the next JS dialog (alert/confirm/prompt).
|
|
// Set by Page.handleJavaScriptDialog; consumed (and cleared) when the
|
|
// next javascript_dialog_opening notification is dispatched. Strings
|
|
// are duplicated into self.arena so they outlive the CDP command's
|
|
// own message arena.
|
|
pending_dialog_response: ?Notification.DialogResponse = null,
|
|
|
|
// webmcp tool invocation
|
|
invocation_id_gen: InvocationIdGen = .{},
|
|
// WebMCP domain state. Populated when `WebMCP.enable` is received.
|
|
webmcp_invocations: std.AutoHashMapUnmanaged(u32, *@import("domains/webmcp.zig").Invocation) = .empty,
|
|
|
|
fn init(self: *BrowserContext, id: []const u8, cdp: *CDP) !void {
|
|
const allocator = cdp.allocator;
|
|
|
|
// Create notification for this BrowserContext
|
|
const notification = try Notification.init(allocator);
|
|
errdefer notification.deinit();
|
|
|
|
const session = try cdp.browser.newSession(notification);
|
|
if (cdp.app.config.cookieFile()) |cookie_path| {
|
|
lp.cookies.loadFromFile(session, cookie_path);
|
|
}
|
|
|
|
const browser = &cdp.browser;
|
|
const inspector_session = browser.env.inspector.?.startSession(self);
|
|
errdefer browser.env.inspector.?.stopSession();
|
|
|
|
var registry = NodeRegistry.init(allocator);
|
|
errdefer registry.deinit();
|
|
|
|
self.* = .{
|
|
.id = id,
|
|
.cdp = cdp,
|
|
.target_id = null,
|
|
.session_id = null,
|
|
.session = session,
|
|
.security_origin = URL_BASE,
|
|
.secure_context_type = "Secure", // TODO = enum
|
|
.page_life_cycle_events = false, // TODO; Target based value
|
|
.node_registry = registry,
|
|
.node_search_list = undefined,
|
|
.isolated_worlds = .empty,
|
|
.inspector_session = inspector_session,
|
|
.frame_arena = cdp.frame_arena.allocator(),
|
|
.arena = cdp.browser_context_arena.allocator(),
|
|
.notification_arena = cdp.notification_arena.allocator(),
|
|
.intercept_state = try InterceptState.init(allocator),
|
|
.captured_requests = .empty,
|
|
.captured_responses = .empty,
|
|
.notification = notification,
|
|
};
|
|
self.node_search_list = Node.Search.List.init(allocator, &self.node_registry);
|
|
errdefer self.deinit();
|
|
|
|
try notification.register(.frame_remove, self, onFrameRemove);
|
|
try notification.register(.frame_created, self, onFrameCreated);
|
|
try notification.register(.frame_navigate, self, onFrameNavigate);
|
|
try notification.register(.frame_navigated, self, onFrameNavigated);
|
|
try notification.register(.frame_navigated_within_document, self, onFrameNavigatedWithinDocument);
|
|
try notification.register(.frame_navigate_failed, self, onFrameNavigateFailed);
|
|
try notification.register(.frame_child_frame_created, self, onFrameChildFrameCreated);
|
|
try notification.register(.frame_destroyed, self, onFrameDestroyed);
|
|
try notification.register(.frame_dom_content_loaded, self, onFrameDOMContentLoaded);
|
|
try notification.register(.frame_loaded, self, onFrameLoaded);
|
|
try notification.register(.javascript_dialog_opening, self, onJavascriptDialogOpening);
|
|
}
|
|
|
|
pub fn deinit(self: *BrowserContext) void {
|
|
const browser = &self.cdp.browser;
|
|
const env = &browser.env;
|
|
|
|
// resetContextGroup detach the inspector from all contexts.
|
|
// It appends async tasks, so we make sure we run the message loop
|
|
// before deinit it.
|
|
env.inspector.?.resetContextGroup();
|
|
env.inspector.?.stopSession();
|
|
|
|
// abort all intercepted requests before closing the session/page
|
|
// since some of these might callback into the page/scriptmanager.
|
|
// intercept_state stores ids.
|
|
const http_client = &browser.http_client;
|
|
for (self.intercept_state.pendingIntercepts()) |transfer_id| {
|
|
if (http_client.findTransfer(transfer_id)) |transfer| {
|
|
transfer.abortParked(error.ClientDisconnect);
|
|
}
|
|
}
|
|
|
|
// Notify any CDP client waiting on an in-flight WebMCP invocation
|
|
// before the V8 context (and its promise callbacks) get torn down
|
|
// by browser.closeSession below.
|
|
@import("domains/webmcp.zig").cancelAllPending(self);
|
|
|
|
for (self.isolated_worlds.items) |world| {
|
|
world.deinit();
|
|
}
|
|
self.isolated_worlds.clearRetainingCapacity();
|
|
|
|
// do this before closeSession, since we don't want to process any
|
|
// new notification (Or maybe, instead of the deinit above, we just
|
|
// rely on those notifications to do our normal cleanup?)
|
|
|
|
self.notification.unregisterAll(self);
|
|
self.clearCapturedResponses();
|
|
|
|
// If the session has a frame, we need to clear it first. The page
|
|
// context is always nested inside of the isolated world context,
|
|
// so we need to shutdown the page one first.
|
|
browser.closeSession();
|
|
|
|
self.node_registry.deinit();
|
|
self.node_search_list.deinit();
|
|
self.set_child_nodes_sent.deinit(self.cdp.allocator);
|
|
|
|
// Session.deinit (called via closeSession above) already cleared this
|
|
// notification off any ownerless CorsGate/RobotsGate transfers.
|
|
self.notification.deinit();
|
|
|
|
if (self.http_proxy_changed) {
|
|
// has to be called after browser.closeSession, since it won't
|
|
// work if there are active connections.
|
|
browser.http_client.changeProxy(null) catch |err| {
|
|
log.warn(.http, "changeProxy", .{ .err = err });
|
|
};
|
|
}
|
|
if (self.user_agent_changed) {
|
|
browser.http_client.clearUserAgentOverride();
|
|
}
|
|
browser.http_client.clearAcceptLanguageOverride();
|
|
self.intercept_state.deinit();
|
|
}
|
|
|
|
pub fn reset(self: *BrowserContext) void {
|
|
self.node_registry.reset();
|
|
self.node_search_list.reset();
|
|
self.set_child_nodes_sent.clearRetainingCapacity();
|
|
}
|
|
|
|
const GetOrPutIsolatedWorld = struct {
|
|
world: *IsolatedWorld,
|
|
found_existing: bool,
|
|
};
|
|
|
|
pub fn findIsolatedWorld(self: *const BrowserContext, world_name: []const u8) ?*IsolatedWorld {
|
|
for (self.isolated_worlds.items) |world| {
|
|
if (std.mem.eql(u8, world.name, world_name)) {
|
|
return world;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
pub fn createIsolatedWorld(self: *BrowserContext, world_name: []const u8, grant_universal_access: bool) !GetOrPutIsolatedWorld {
|
|
if (self.findIsolatedWorld(world_name)) |world| {
|
|
if (world.grant_universal_access != grant_universal_access) {
|
|
log.warn(.cdp, "isolated world mismatch", .{ .name = world_name, .gua = grant_universal_access });
|
|
}
|
|
return .{ .world = world, .found_existing = true };
|
|
}
|
|
|
|
const browser = &self.cdp.browser;
|
|
const arena = try browser.arena_pool.acquire(.small, "IsolatedWorld");
|
|
errdefer arena.release();
|
|
|
|
const world = try arena.create(IsolatedWorld);
|
|
world.* = .{
|
|
.arena = arena,
|
|
.browser = browser,
|
|
.name = try arena.dupe(u8, world_name),
|
|
.grant_universal_access = grant_universal_access,
|
|
};
|
|
|
|
try self.isolated_worlds.append(self.arena, world);
|
|
|
|
return .{ .world = world, .found_existing = false };
|
|
}
|
|
|
|
// only called when we fail to fully create a world (e.g. errdefer in
|
|
// Page.createIsolatedWorld).
|
|
pub fn removeIsolatedWorld(self: *BrowserContext, world: *IsolatedWorld) void {
|
|
for (self.isolated_worlds.items, 0..) |w, i| {
|
|
if (w == world) {
|
|
_ = self.isolated_worlds.swapRemove(i);
|
|
world.deinit();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn nodeWriter(self: *BrowserContext, root: *const NodeRegistry.Node, opts: Node.Writer.Opts) Node.Writer {
|
|
return .{
|
|
.root = root,
|
|
.depth = opts.depth,
|
|
.exclude_root = opts.exclude_root,
|
|
.registry = &self.node_registry,
|
|
};
|
|
}
|
|
|
|
pub fn axnodeWriter(self: *BrowserContext, temp_arena: *lp.Arena, root: *const NodeRegistry.Node, opts: AXNode.Writer.Opts) !AXNode.Writer {
|
|
// Bind the writer to the frame that owns the root node. Name resolution
|
|
// (`Label.findLabelByFor` against `ownerDocument`) and visibility
|
|
// checks (`frame._style_manager`) are per-frame; getting this wrong on
|
|
// cross-frame queries produces names/visibility from the wrong document.
|
|
const main_frame = self.mainFrame() orelse return error.FrameNotLoaded;
|
|
const frame = root.dom.ownerFrame(main_frame) orelse return error.InvalidNodeId;
|
|
const label_index = try frame.call_arena.create(Label.LabelByForIndex);
|
|
label_index.* = .{};
|
|
return .{
|
|
.frame = frame,
|
|
.root = root,
|
|
.registry = &self.node_registry,
|
|
.label_index = label_index,
|
|
.temp_arena = temp_arena,
|
|
.filter = opts.filter,
|
|
};
|
|
}
|
|
|
|
// The root frame of this context's live Page.
|
|
pub fn mainFrame(self: *const BrowserContext) ?*Frame {
|
|
return &(self.mainPage() orelse return null).frame;
|
|
}
|
|
|
|
// True while this context's page is mid-commit (a root navigation's
|
|
// replacement is being promoted).
|
|
pub fn inCommit(self: *const BrowserContext) bool {
|
|
return (self.page_handle orelse return false).inCommit();
|
|
}
|
|
|
|
// The live Page for this single-target context, or null if no page is
|
|
// loaded.
|
|
pub fn mainPage(self: *const BrowserContext) ?*Page {
|
|
return (self.page_handle orelse return null).page();
|
|
}
|
|
|
|
pub fn getURL(self: *const BrowserContext) ?[:0]const u8 {
|
|
const frame = self.mainFrame() orelse return null;
|
|
const url = frame.url;
|
|
return if (url.len == 0) null else url;
|
|
}
|
|
|
|
pub fn getTitle(self: *const BrowserContext) ?[]const u8 {
|
|
const frame = self.mainFrame() orelse return null;
|
|
return frame.getTitle() catch |err| {
|
|
log.err(.cdp, "page title", .{ .err = err });
|
|
return null;
|
|
};
|
|
}
|
|
|
|
pub fn networkEnable(self: *BrowserContext, limits: network_domain.BufferLimits) !void {
|
|
const previous = self.network_limits;
|
|
self.network_limits = limits;
|
|
if (limits.resource < previous.resource) {
|
|
// evict responses which are over the new small limit
|
|
for (self.captured_responses.values()) |*resp| {
|
|
const data = resp.data orelse continue;
|
|
if (data.items.len > limits.resource) {
|
|
self.evictCapturedResponse(resp);
|
|
}
|
|
}
|
|
}
|
|
if (limits.total < previous.total) {
|
|
self.ensureCapturedSpace(0);
|
|
}
|
|
|
|
try self.notification.register(.http_request_fail, self, onHttpRequestFail);
|
|
try self.notification.register(.http_request_start, self, onHttpRequestStart);
|
|
try self.notification.register(.http_request_done, self, onHttpRequestDone);
|
|
try self.notification.register(.http_response_data, self, onHttpResponseData);
|
|
try self.notification.register(.http_response_header_done, self, onHttpResponseHeadersDone);
|
|
try self.notification.register(.http_request_served_from_cache, self, onHttpRequestServedFromCache);
|
|
}
|
|
|
|
pub fn networkDisable(self: *BrowserContext) void {
|
|
self.notification.unregister(.http_request_fail, self);
|
|
self.notification.unregister(.http_request_start, self);
|
|
self.notification.unregister(.http_request_done, self);
|
|
self.notification.unregister(.http_response_data, self);
|
|
self.notification.unregister(.http_response_header_done, self);
|
|
self.notification.unregister(.http_request_served_from_cache, self);
|
|
self.clearCapturedResponses();
|
|
}
|
|
|
|
pub fn clearCapturedResponses(self: *BrowserContext) void {
|
|
const allocator = self.cdp.allocator;
|
|
for (self.captured_responses.values()) |*resp| {
|
|
if (resp.data) |*data| {
|
|
data.deinit(allocator);
|
|
}
|
|
}
|
|
self.captured_responses.deinit(allocator);
|
|
self.captured_responses = .empty;
|
|
self.captured_responses_size = 0;
|
|
}
|
|
|
|
fn evictCapturedResponse(self: *BrowserContext, resp: *CapturedResponse) void {
|
|
if (resp.data) |*data| {
|
|
self.captured_responses_size -= data.items.len;
|
|
data.deinit(self.cdp.allocator);
|
|
resp.data = null;
|
|
}
|
|
}
|
|
|
|
// Evict captured responses, ordered by age, until we can fit `size` more bytes
|
|
fn ensureCapturedSpace(self: *BrowserContext, size: usize) void {
|
|
for (self.captured_responses.values()) |*resp| {
|
|
if (self.captured_responses_size + size <= self.network_limits.total) {
|
|
return;
|
|
}
|
|
const data = resp.data orelse continue;
|
|
if (data.items.len == 0) {
|
|
continue;
|
|
}
|
|
self.evictCapturedResponse(resp);
|
|
}
|
|
}
|
|
|
|
pub fn fetchEnable(self: *BrowserContext, patterns: ?[]const fetch.RequestPattern, authRequests: bool, session_id: []const u8) !void {
|
|
self.fetchDisable(); //in case of multiple calls
|
|
errdefer self.fetchDisable();
|
|
try self.intercept_state.setPatterns(patterns);
|
|
self.fetch_session_id = session_id;
|
|
try self.notification.register(.http_request_intercept, self, onHttpRequestIntercept);
|
|
if (authRequests) {
|
|
try self.notification.register(.http_request_auth_required, self, onHttpRequestAuthRequired);
|
|
}
|
|
}
|
|
|
|
pub fn fetchDisableForSession(self: *BrowserContext, session_id: []const u8) void {
|
|
const active_session_id = self.fetch_session_id orelse return;
|
|
if (std.mem.eql(u8, active_session_id, session_id)) {
|
|
self.fetchDisable();
|
|
}
|
|
}
|
|
|
|
fn fetchDisable(self: *BrowserContext) void {
|
|
self.notification.unregister(.http_request_intercept, self);
|
|
self.notification.unregister(.http_request_auth_required, self);
|
|
self.intercept_state.clearPatterns();
|
|
self.fetch_session_id = null;
|
|
}
|
|
|
|
pub fn lifecycleEventsEnable(self: *BrowserContext) !void {
|
|
self.page_life_cycle_events = true;
|
|
try self.notification.register(.frame_network_idle, self, onFrameNetworkIdle);
|
|
try self.notification.register(.frame_network_almost_idle, self, onFrameNetworkAlmostIdle);
|
|
}
|
|
|
|
pub fn lifecycleEventsDisable(self: *BrowserContext) void {
|
|
self.page_life_cycle_events = false;
|
|
self.notification.unregister(.frame_network_idle, self);
|
|
self.notification.unregister(.frame_network_almost_idle, self);
|
|
}
|
|
|
|
pub fn consoleEnable(self: *BrowserContext) !void {
|
|
try self.notification.register(.console_message, self, onConsoleMessage);
|
|
}
|
|
|
|
pub fn consoleDisable(self: *BrowserContext) void {
|
|
self.notification.unregister(.console_message, self);
|
|
}
|
|
|
|
pub fn runtimeEnable(self: *BrowserContext) !void {
|
|
try self.notification.register(.runtime_console_message, self, onRuntimeConsoleMessage);
|
|
}
|
|
|
|
pub fn runtimeDisable(self: *BrowserContext) void {
|
|
self.notification.unregister(.runtime_console_message, self);
|
|
}
|
|
|
|
// Registers for the download notifications dispatched by Frame when a
|
|
// navigation is treated as a file download. Idempotent. See issue #2701.
|
|
pub fn downloadEventsEnable(self: *BrowserContext) !void {
|
|
try self.notification.register(.download_will_begin, self, onDownloadWillBegin);
|
|
try self.notification.register(.download_progress, self, onDownloadProgress);
|
|
}
|
|
|
|
pub fn downloadEventsDisable(self: *BrowserContext) void {
|
|
self.notification.unregister(.download_will_begin, self);
|
|
self.notification.unregister(.download_progress, self);
|
|
}
|
|
|
|
fn onDownloadWillBegin(ctx: *anyopaque, msg: *const Notification.DownloadWillBegin) !void {
|
|
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
|
|
return @import("domains/browser.zig").downloadWillBegin(self, msg);
|
|
}
|
|
|
|
fn onDownloadProgress(ctx: *anyopaque, msg: *const Notification.DownloadProgress) !void {
|
|
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
|
|
return @import("domains/browser.zig").downloadProgress(self, msg);
|
|
}
|
|
|
|
pub fn webmcpEnable(self: *BrowserContext) !void {
|
|
try self.notification.register(.model_context_tool_added, self, onModelContextToolAdded);
|
|
try self.notification.register(.model_context_tool_removed, self, onModelContextToolRemoved);
|
|
}
|
|
|
|
pub fn webmcpDisable(self: *BrowserContext) void {
|
|
self.notification.unregister(.model_context_tool_added, self);
|
|
self.notification.unregister(.model_context_tool_removed, self);
|
|
}
|
|
|
|
fn onModelContextToolAdded(ctx: *anyopaque, event: *const Notification.ModelContextToolEvent) !void {
|
|
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
|
|
return @import("domains/webmcp.zig").onToolAdded(self, event);
|
|
}
|
|
|
|
fn onModelContextToolRemoved(ctx: *anyopaque, event: *const Notification.ModelContextToolEvent) !void {
|
|
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
|
|
return @import("domains/webmcp.zig").onToolRemoved(self, event);
|
|
}
|
|
|
|
fn onFrameRemove(ctx: *anyopaque, _: Notification.FrameRemove) !void {
|
|
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
|
|
@import("domains/page.zig").frameRemove(self);
|
|
}
|
|
|
|
fn onFrameCreated(ctx: *anyopaque, frame: *Frame) !void {
|
|
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
|
|
return @import("domains/page.zig").frameCreated(self, frame);
|
|
}
|
|
|
|
fn onFrameNavigate(ctx: *anyopaque, msg: *const Notification.FrameNavigate) !void {
|
|
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
|
|
return @import("domains/page.zig").frameNavigate(self, msg);
|
|
}
|
|
|
|
fn onFrameNavigated(ctx: *anyopaque, msg: *const Notification.FrameNavigated) !void {
|
|
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
|
|
const arena = self.acquireNotificationArena();
|
|
defer self.releaseNotificationArena();
|
|
return @import("domains/page.zig").frameNavigated(arena, self, msg);
|
|
}
|
|
|
|
fn onFrameNavigateFailed(ctx: *anyopaque, msg: *const Notification.FrameNavigateFailed) !void {
|
|
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
|
|
return @import("domains/page.zig").frameNavigateFailed(self, msg);
|
|
}
|
|
|
|
fn onFrameNavigatedWithinDocument(ctx: *anyopaque, msg: *const Notification.FrameNavigatedWithinDocument) !void {
|
|
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
|
|
return @import("domains/page.zig").frameNavigatedWithinDocument(self, msg);
|
|
}
|
|
|
|
fn onFrameDestroyed(ctx: *anyopaque, frame: *const Frame) !void {
|
|
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
|
|
@import("domains/page.zig").frameDestroyed(self, frame);
|
|
}
|
|
|
|
fn onFrameChildFrameCreated(ctx: *anyopaque, msg: *const Notification.FrameChildFrameCreated) !void {
|
|
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
|
|
return @import("domains/page.zig").frameChildFrameCreated(self, msg);
|
|
}
|
|
|
|
fn onFrameNetworkIdle(ctx: *anyopaque, msg: *const Notification.FrameNetworkIdle) !void {
|
|
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
|
|
return @import("domains/page.zig").frameNetworkIdle(self, msg);
|
|
}
|
|
|
|
fn onFrameNetworkAlmostIdle(ctx: *anyopaque, msg: *const Notification.FrameNetworkAlmostIdle) !void {
|
|
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
|
|
return @import("domains/page.zig").frameNetworkAlmostIdle(self, msg);
|
|
}
|
|
|
|
fn onHttpRequestStart(ctx: *anyopaque, msg: *const Notification.RequestStart) !void {
|
|
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
|
|
{
|
|
// capture the request
|
|
const transfer = msg.transfer;
|
|
const key = keyFromTransfer(transfer);
|
|
const body = transfer.req.body orelse "";
|
|
if (body.len == 0 or body.len > network_domain.max_post_data_size) {
|
|
_ = self.captured_requests.remove(key);
|
|
} else {
|
|
const owned_body = try self.frame_arena.dupe(u8, body);
|
|
try self.captured_requests.put(self.frame_arena, key, owned_body);
|
|
}
|
|
}
|
|
const arena = self.acquireNotificationArena();
|
|
defer self.releaseNotificationArena();
|
|
try network_domain.httpRequestStart(arena, self, msg);
|
|
}
|
|
|
|
fn onHttpRequestIntercept(ctx: *anyopaque, msg: *const Notification.RequestIntercept) !void {
|
|
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
|
|
const arena = self.acquireNotificationArena();
|
|
defer self.releaseNotificationArena();
|
|
try @import("domains/fetch.zig").requestIntercept(arena, self, msg);
|
|
}
|
|
|
|
fn onHttpRequestFail(ctx: *anyopaque, msg: *const Notification.RequestFail) !void {
|
|
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
|
|
return network_domain.httpRequestFail(self, msg);
|
|
}
|
|
|
|
fn onFrameDOMContentLoaded(ctx: *anyopaque, msg: *const Notification.FrameDOMContentLoaded) !void {
|
|
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
|
|
return @import("domains/page.zig").frameDOMContentLoaded(self, msg);
|
|
}
|
|
|
|
fn onFrameLoaded(ctx: *anyopaque, msg: *const Notification.FrameLoaded) !void {
|
|
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
|
|
return @import("domains/page.zig").frameLoaded(self, msg);
|
|
}
|
|
|
|
fn onJavascriptDialogOpening(ctx: *anyopaque, msg: *const Notification.JavascriptDialogOpening) !void {
|
|
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
|
|
return @import("domains/page.zig").javascriptDialogOpening(self, msg);
|
|
}
|
|
|
|
fn keyFromTransfer(transfer: *const HttpClient.Transfer) CDP.BrowserContext.CapturedKey {
|
|
return if (transfer.req.resource_type == .document)
|
|
.{ .kind = .loader, .id = transfer.req.loader_id }
|
|
else
|
|
.{ .kind = .request, .id = transfer.id };
|
|
}
|
|
|
|
fn onHttpResponseHeadersDone(ctx: *anyopaque, msg: *const Notification.ResponseHeaderDone) !void {
|
|
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
|
|
const arena = self.acquireNotificationArena();
|
|
defer self.releaseNotificationArena();
|
|
|
|
// Prepare the captured response value.
|
|
const key = keyFromTransfer(msg.transfer);
|
|
const gop = try self.captured_responses.getOrPut(self.cdp.allocator, key);
|
|
if (!gop.found_existing) {
|
|
gop.value_ptr.* = .{
|
|
.data = blk: {
|
|
const body_len = msg.transfer.bodyLen();
|
|
if (body_len > self.network_limits.resource) {
|
|
break :blk null;
|
|
}
|
|
break :blk try std.ArrayList(u8).initCapacity(self.cdp.allocator, body_len);
|
|
},
|
|
// Encode the data in base64 by default, but don't encode
|
|
// for well known content-type.
|
|
.must_encode = blk: {
|
|
if (msg.transfer.contentType()) |ct| {
|
|
const mime = try Mime.parse(ct);
|
|
|
|
if (!mime.isText()) {
|
|
break :blk true;
|
|
}
|
|
|
|
if (std.ascii.eqlIgnoreCase("UTF-8", mime.charsetString())) {
|
|
break :blk false;
|
|
}
|
|
}
|
|
break :blk true;
|
|
},
|
|
};
|
|
}
|
|
|
|
return network_domain.httpResponseHeaderDone(arena, self, msg);
|
|
}
|
|
|
|
fn onHttpRequestDone(ctx: *anyopaque, msg: *const Notification.RequestDone) !void {
|
|
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
|
|
return network_domain.httpRequestDone(self, msg);
|
|
}
|
|
|
|
pub fn onHttpResponseData(ctx: *anyopaque, msg: *const Notification.ResponseData) !void {
|
|
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
|
|
|
|
const key = keyFromTransfer(msg.transfer);
|
|
const resp = self.captured_responses.getPtr(key) orelse lp.assert(false, "onHttpResponseData missing captured response", .{});
|
|
|
|
const data = &(resp.data orelse return);
|
|
const chunk = msg.data;
|
|
const limits = &self.network_limits;
|
|
if (data.items.len + chunk.len > limits.resource or chunk.len > limits.total) {
|
|
return self.evictCapturedResponse(resp);
|
|
}
|
|
// Can evict `resp` itself when it's the oldest, hence the re-check.
|
|
self.ensureCapturedSpace(chunk.len);
|
|
if (resp.data == null) {
|
|
return;
|
|
}
|
|
try data.appendSlice(self.cdp.allocator, chunk);
|
|
self.captured_responses_size += chunk.len;
|
|
}
|
|
|
|
fn onHttpRequestAuthRequired(ctx: *anyopaque, data: *const Notification.RequestAuthRequired) !void {
|
|
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
|
|
const arena = self.acquireNotificationArena();
|
|
defer self.releaseNotificationArena();
|
|
try @import("domains/fetch.zig").requestAuthRequired(arena, self, data);
|
|
}
|
|
|
|
fn onHttpRequestServedFromCache(ctx: *anyopaque, msg: *const Notification.RequestServedFromCache) !void {
|
|
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
|
|
return network_domain.httpServedFromCache(self, msg);
|
|
}
|
|
|
|
fn onConsoleMessage(ctx: *anyopaque, msg: *const Notification.ConsoleMessage) !void {
|
|
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
|
|
const arena = self.acquireNotificationArena();
|
|
defer self.releaseNotificationArena();
|
|
return @import("domains/console.zig").consoleMessage(arena, self, msg);
|
|
}
|
|
|
|
fn onRuntimeConsoleMessage(ctx: *anyopaque, msg: *const Notification.ConsoleMessage) !void {
|
|
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
|
|
const arena = self.acquireNotificationArena();
|
|
defer self.releaseNotificationArena();
|
|
return @import("domains/runtime.zig").consoleMessage(arena, self, msg);
|
|
}
|
|
|
|
fn acquireNotificationArena(self: *BrowserContext) Allocator {
|
|
self.cdp.notification_depth += 1;
|
|
return self.notification_arena;
|
|
}
|
|
|
|
fn releaseNotificationArena(self: *BrowserContext) void {
|
|
self.cdp.notification_depth -= 1;
|
|
if (self.cdp.notification_depth == 0) {
|
|
_ = self.cdp.notification_arena.reset(.{ .retain_with_limit = 1024 * 64 });
|
|
}
|
|
}
|
|
|
|
pub fn callInspector(self: *const BrowserContext, msg: []const u8) void {
|
|
self.inspector_session.send(msg);
|
|
self.session.browser.env.runMicrotasks();
|
|
}
|
|
|
|
pub fn onInspectorResponse(ctx: *anyopaque, _: u32, msg: []const u8) void {
|
|
sendInspectorMessage(@ptrCast(@alignCast(ctx)), msg) catch |err| {
|
|
log.err(.cdp, "send inspector response", .{ .err = err });
|
|
};
|
|
}
|
|
|
|
pub fn onInspectorEvent(ctx: *anyopaque, msg: []const u8) void {
|
|
if (log.enabled(.cdp, .debug)) {
|
|
// msg should be {"method":<method>,...
|
|
lp.assert(std.mem.startsWith(u8, msg, "{\"method\":"), "onInspectorEvent prefix", .{});
|
|
const method_end = std.mem.indexOfScalar(u8, msg, ',') orelse {
|
|
log.err(.cdp, "invalid inspector event", .{ .msg = msg });
|
|
return;
|
|
};
|
|
const method = msg[10..method_end];
|
|
log.debug(.cdp, "inspector event", .{ .method = method });
|
|
}
|
|
|
|
sendInspectorMessage(@ptrCast(@alignCast(ctx)), msg) catch |err| {
|
|
log.err(.cdp, "send inspector event", .{ .err = err });
|
|
};
|
|
}
|
|
|
|
// This is hacky x 2. First, we create the JSON payload by gluing our
|
|
// session_id onto it. Second, we're much more client/websocket aware than
|
|
// we should be.
|
|
fn sendInspectorMessage(self: *BrowserContext, msg: []const u8) !void {
|
|
const session_id = self.session_id orelse {
|
|
// We no longer have an active session. What should we do
|
|
// in this case?
|
|
return;
|
|
};
|
|
|
|
const cdp = self.cdp;
|
|
const allocator = cdp.link.acquireSendArena();
|
|
defer cdp.link.releaseSendArena();
|
|
|
|
const field = ",\"sessionId\":\"";
|
|
|
|
// + 1 for the closing quote after the session id
|
|
// + 10 for the max websocket header
|
|
const message_len = msg.len + session_id.len + 1 + field.len + 10;
|
|
|
|
var buf: std.ArrayList(u8) = .empty;
|
|
buf.ensureTotalCapacityPrecise(allocator, message_len) catch |err| {
|
|
log.err(.cdp, "inspector buffer", .{ .err = err });
|
|
return;
|
|
};
|
|
|
|
// reserve 10 bytes for websocket header
|
|
buf.appendSliceAssumeCapacity(&.{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 });
|
|
|
|
// -1 because we don't want the closing brace '}'
|
|
buf.appendSliceAssumeCapacity(msg[0 .. msg.len - 1]);
|
|
buf.appendSliceAssumeCapacity(field);
|
|
buf.appendSliceAssumeCapacity(session_id);
|
|
buf.appendSliceAssumeCapacity("\"}");
|
|
if (comptime lp.IS_DEBUG) {
|
|
std.debug.assert(buf.items.len == message_len);
|
|
}
|
|
|
|
try cdp.link.sendJSONRaw(buf);
|
|
}
|
|
};
|
|
|
|
/// see: https://chromium.googlesource.com/chromium/src/+/master/third_party/blink/renderer/bindings/core/v8/V8BindingDesign.md#world
|
|
/// The current understanding. An isolated world lives in the same isolate, but a separated context.
|
|
/// Clients create this to be able to create variables and run code without interfering with the
|
|
/// normal namespace and values of the webframe. Similar to the main context we need to pretend to recreate it after
|
|
/// a executionContextsCleared event which happens when navigating to a new frame. A client can have a command be executed
|
|
const ScriptOnNewDocument = struct {
|
|
identifier: u32,
|
|
source: []const u8,
|
|
// Page.addScriptToEvaluateOnNewDocument's worldName. null means the main
|
|
// world. A named world is seeded into every frame (see IsolatedWorld).
|
|
world_name: ?[]const u8,
|
|
};
|
|
|
|
/// An isolated world is identified by its name and has one V8::Context per
|
|
/// frame it has been seeded into. A world enters a frame on an explicit
|
|
/// trigger: Page.createIsolatedWorld or, or a preload script which is seeded
|
|
/// into every frame. Once seeded, the frame's context is rebuilt on every
|
|
/// navigation with no further client involvement.
|
|
/// Frame ids are stable across a child frame's re-navigation (the Frame is
|
|
/// torn down and re-initialized in place), so a per-frame context is removed
|
|
/// on frame_destroyed and created again on the frame's next frame_navigated.
|
|
pub const IsolatedWorld = struct {
|
|
arena: *lp.Arena,
|
|
browser: *Browser,
|
|
name: []const u8,
|
|
grant_universal_access: bool,
|
|
contexts: std.ArrayList(FrameContext) = .empty,
|
|
|
|
// Frames this world has been seeded into, by frame id.
|
|
seeded_frames: std.ArrayList(u32) = .empty,
|
|
|
|
// Identity tracking for this isolated world (separate from main world).
|
|
// Shared by all of the world's frame contexts, like the main world shares
|
|
// Page.identity across frames, and reset with them on root teardown.
|
|
identity: js.Identity = .{},
|
|
|
|
const FrameContext = struct {
|
|
frame: *const Frame,
|
|
context: *js.Context,
|
|
// Per-context, not per-world: the call_arena is reset when a context's
|
|
// call depth returns to 0, which would free the data of another frame's
|
|
// in-flight call if they shared one.
|
|
call_arena: *lp.Arena,
|
|
local_arena: *lp.Arena,
|
|
};
|
|
|
|
pub fn deinit(self: *IsolatedWorld) void {
|
|
self.removeAllContexts();
|
|
self.arena.release();
|
|
}
|
|
|
|
pub fn seed(self: *IsolatedWorld, frame_id: u32) !void {
|
|
if (self.isSeeded(frame_id)) {
|
|
return;
|
|
}
|
|
return self.seeded_frames.append(self.arena.allocator(), frame_id);
|
|
}
|
|
|
|
pub fn isSeeded(self: *const IsolatedWorld, frame_id: u32) bool {
|
|
return std.mem.indexOfScalar(u32, self.seeded_frames.items, frame_id) != null;
|
|
}
|
|
|
|
// Keyed by Frame, not frame id: a retired root Page keeps its frame id
|
|
// while its deferred teardown is pending, and that teardown must not
|
|
// touch the live page's context.
|
|
pub fn contextFor(self: *const IsolatedWorld, frame: *const Frame) ?*js.Context {
|
|
for (self.contexts.items) |fc| {
|
|
if (fc.frame == frame) {
|
|
return fc.context;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Callers must register the returned context with the inspector
|
|
pub fn createContext(self: *IsolatedWorld, frame: *Frame) !*js.Context {
|
|
lp.assert(self.contextFor(frame) == null, "IsolatedWorld.createContext duplicate", .{ .frame_id = frame._frame_id });
|
|
|
|
const browser = self.browser;
|
|
const call_arena = try browser.arena_pool.acquire(.tiny, "IsolatedWorld.call_arena");
|
|
errdefer call_arena.release();
|
|
|
|
const local_arena = try browser.arena_pool.acquire(.tiny, "IsolatedWorld.local_arena");
|
|
errdefer local_arena.release();
|
|
|
|
const ctx = try browser.env.createContext(frame, .{
|
|
.identity = &self.identity,
|
|
.identity_arena = self.arena.allocator(),
|
|
.call_arena = call_arena.allocator(),
|
|
.local_arena = local_arena.allocator(),
|
|
.debug_name = "IsolatedContext",
|
|
});
|
|
errdefer browser.env.destroyContext(ctx);
|
|
try ctx.setOrigin(frame.origin);
|
|
|
|
try self.contexts.append(self.arena.allocator(), .{
|
|
.frame = frame,
|
|
.context = ctx,
|
|
.call_arena = call_arena,
|
|
.local_arena = local_arena,
|
|
});
|
|
return ctx;
|
|
}
|
|
|
|
pub fn removeContext(self: *IsolatedWorld, frame: *const Frame) void {
|
|
for (self.contexts.items, 0..) |fc, i| {
|
|
if (fc.frame == frame) {
|
|
self.destroyFrameContext(fc);
|
|
_ = self.contexts.swapRemove(i);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn removeAllContexts(self: *IsolatedWorld) void {
|
|
for (self.contexts.items) |fc| {
|
|
self.destroyFrameContext(fc);
|
|
}
|
|
self.contexts.clearRetainingCapacity();
|
|
|
|
// The page's objects are going away with the root frame; wrappers
|
|
// keyed by their addresses must not survive to alias a new page's.
|
|
self.identity.deinit();
|
|
self.identity = .{};
|
|
}
|
|
|
|
fn destroyFrameContext(self: *IsolatedWorld, fc: FrameContext) void {
|
|
// A re-navigating child frame keeps its Window, and the identity map
|
|
// keeps the window's global proxy; detach it from this context so the
|
|
// frame's next context can reattach it (as the main world does).
|
|
fc.context.detachGlobal();
|
|
self.browser.env.destroyContext(fc.context);
|
|
fc.call_arena.release();
|
|
fc.local_arena.release();
|
|
}
|
|
};
|
|
|
|
// This is a generic because when we send a result we have two different
|
|
// behaviors. Normally, we're sending the result to the client. But in some cases
|
|
// we want to capture the result. So we want the command.sendResult to be
|
|
// generic.
|
|
pub const Command = struct {
|
|
// A misc arena that can be used for any allocation for processing
|
|
// the message
|
|
arena: Allocator,
|
|
|
|
// reference to our CDP instance
|
|
cdp: *CDP,
|
|
|
|
// The browser context this command targets
|
|
browser_context: ?*BrowserContext,
|
|
|
|
// The command input (the id, optional session_id, params, ...)
|
|
input: Input,
|
|
|
|
// In most cases, Sender is going to be cdp itself. We'll call
|
|
// sender.sendJSON() and CDP will send it to the client. But some
|
|
// commands are dispatched internally, in which cases the Sender will
|
|
// be code to capture the data that we were "sending".
|
|
sender: Sender,
|
|
|
|
const Sender = union(enum) {
|
|
cdp: *CDP,
|
|
capture: *std.Io.Writer,
|
|
|
|
pub fn sendJSON(self: Sender, message: anytype) !void {
|
|
switch (self) {
|
|
.cdp => |cdp| return cdp.sendJSON(message),
|
|
.capture => |writer| {
|
|
return std.json.Stringify.value(message, .{
|
|
.emit_null_optional_fields = false,
|
|
}, writer);
|
|
},
|
|
}
|
|
}
|
|
};
|
|
|
|
pub fn params(self: *const Command, comptime T: type) !?T {
|
|
if (self.input.params) |p| {
|
|
return try json.parseFromSliceLeaky(
|
|
T,
|
|
self.arena,
|
|
p.raw,
|
|
.{ .ignore_unknown_fields = true },
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
pub fn createBrowserContext(self: *Command) !*BrowserContext {
|
|
_ = try self.cdp.createBrowserContext();
|
|
self.browser_context = &(self.cdp.browser_context.?);
|
|
return self.browser_context.?;
|
|
}
|
|
|
|
const SendResultOpts = struct {};
|
|
pub fn sendResult(self: *Command, result: anytype, _: SendResultOpts) !void {
|
|
return self.sender.sendJSON(.{
|
|
.id = self.input.id,
|
|
.result = if (comptime @typeInfo(@TypeOf(result)) == .null) struct {}{} else result,
|
|
.sessionId = self.input.session_id,
|
|
});
|
|
}
|
|
|
|
pub fn sendEvent(self: *Command, method: []const u8, p: anytype, opts: SendEventOpts) !void {
|
|
// Events ALWAYS go to the client. self.sender should not be used
|
|
return self.cdp.sendEvent(method, p, opts);
|
|
}
|
|
|
|
const SendErrorOpts = struct {};
|
|
pub fn sendError(self: *Command, code: i32, message: []const u8, _: SendErrorOpts) !void {
|
|
return self.sender.sendJSON(.{
|
|
.id = self.input.id,
|
|
.@"error" = .{ .code = code, .message = message },
|
|
.sessionId = self.input.session_id,
|
|
});
|
|
}
|
|
|
|
const Input = struct {
|
|
// When we reply to a message, we echo back the message id
|
|
id: ?i64,
|
|
|
|
// The "action" of the message.Given a method of "LOG.enable", the
|
|
// action is "enable"
|
|
action: []const u8,
|
|
|
|
// See notes in BrowserContext about session_id
|
|
session_id: ?[]const u8,
|
|
|
|
// Unparsed / untyped input.params.
|
|
params: ?InputParams,
|
|
|
|
// The full raw json input
|
|
json: []const u8,
|
|
};
|
|
};
|
|
|
|
// When we parse a JSON message from the client, this is the structure
|
|
// we always expect. Parsed on the Network thread inside
|
|
// Link.handleMessage; the slices reference the raw JSON bytes
|
|
// (or arena allocations for fields that needed unescaping). Both
|
|
// outlive the InputMessage for the inbox message's lifetime.
|
|
pub const InputMessage = struct {
|
|
id: ?i64 = null,
|
|
method: []const u8,
|
|
params: ?InputParams = null,
|
|
sessionId: ?[]const u8 = null,
|
|
};
|
|
|
|
// The JSON "params" field changes based on the "method". Initially, we just
|
|
// capture the raw json object (including the opening and closing braces).
|
|
// Then, when we're processing the message, and we know what type it is, we
|
|
// can parse it (in Disaptch(T).params).
|
|
const InputParams = struct {
|
|
raw: []const u8,
|
|
|
|
pub fn jsonParse(
|
|
_: Allocator,
|
|
scanner: *json.Scanner,
|
|
_: json.ParseOptions,
|
|
) !InputParams {
|
|
const height = scanner.stackHeight();
|
|
|
|
const start = scanner.cursor;
|
|
if (try scanner.next() != .object_begin) {
|
|
return error.UnexpectedToken;
|
|
}
|
|
try scanner.skipUntilStackHeight(height);
|
|
const end = scanner.cursor;
|
|
|
|
return .{ .raw = scanner.input[start..end] };
|
|
}
|
|
};
|
|
|
|
fn asUint(comptime T: type, comptime string: []const u8) T {
|
|
return @bitCast(string[0..string.len].*);
|
|
}
|
|
|
|
const testing = @import("testing.zig");
|
|
test "cdp: invalid json" {
|
|
var ctx = try testing.context();
|
|
defer ctx.deinit();
|
|
|
|
try testing.expectError(error.InvalidJSON, ctx.processMessage("invalid"));
|
|
|
|
// method is required
|
|
try testing.expectError(error.InvalidJSON, ctx.processMessage(.{}));
|
|
|
|
try ctx.processMessage(.{
|
|
.method = "Target",
|
|
});
|
|
try ctx.expectSentError(-32601, "'Target' wasn't found", .{});
|
|
|
|
try ctx.processMessage(.{
|
|
.method = "Unknown.domain",
|
|
});
|
|
try ctx.expectSentError(-32601, "'Unknown.domain' wasn't found", .{});
|
|
|
|
try ctx.processMessage(.{
|
|
.method = "Target.over9000",
|
|
});
|
|
try ctx.expectSentError(-32601, "'Target.over9000' wasn't found", .{});
|
|
}
|
|
|
|
test "cdp: invalid sessionId" {
|
|
var ctx = try testing.context();
|
|
defer ctx.deinit();
|
|
|
|
{
|
|
// we have no browser context
|
|
try ctx.processMessage(.{ .method = "Hi", .sessionId = "nope" });
|
|
try ctx.expectSentError(-32001, "Unknown sessionId", .{});
|
|
}
|
|
|
|
{
|
|
// we have a browser context but no session_id
|
|
_ = try ctx.loadBrowserContext(.{});
|
|
try ctx.processMessage(.{ .method = "Hi", .sessionId = "BC-Has-No-SessionId" });
|
|
try ctx.expectSentError(-32001, "Unknown sessionId", .{});
|
|
}
|
|
|
|
{
|
|
// we have a browser context with a different session_id
|
|
_ = try ctx.loadBrowserContext(.{ .session_id = "SESS-2" });
|
|
try ctx.processMessage(.{ .method = "Hi", .sessionId = "SESS-1" });
|
|
try ctx.expectSentError(-32001, "Unknown sessionId", .{});
|
|
}
|
|
}
|
|
|
|
test "cdp: STARTUP sessionId" {
|
|
var ctx = try testing.context();
|
|
defer ctx.deinit();
|
|
|
|
{
|
|
// we have no browser context
|
|
try ctx.processMessage(.{ .id = 2, .method = "Hi", .sessionId = "STARTUP" });
|
|
try ctx.expectSentResult(null, .{ .id = 2, .index = 0, .session_id = "STARTUP" });
|
|
}
|
|
|
|
{
|
|
// we have a browser context but no session_id
|
|
_ = try ctx.loadBrowserContext(.{});
|
|
try ctx.processMessage(.{ .id = 3, .method = "Hi", .sessionId = "STARTUP" });
|
|
try ctx.expectSentResult(null, .{ .id = 3, .index = 1, .session_id = "STARTUP" });
|
|
}
|
|
|
|
{
|
|
// we have a browser context with a different session_id
|
|
_ = try ctx.loadBrowserContext(.{ .session_id = "SESS-2" });
|
|
try ctx.processMessage(.{ .id = 4, .method = "Hi", .sessionId = "STARTUP" });
|
|
try ctx.expectSentResult(null, .{ .id = 4, .index = 2, .session_id = "STARTUP" });
|
|
}
|
|
}
|
|
|
|
test "cdp: disconnect latches so the worker keeps exiting" {
|
|
var ctx = try testing.context();
|
|
defer ctx.deinit();
|
|
|
|
const client = &ctx.cdp().browser.http_client;
|
|
|
|
// Simulate the Server run loop delivering a peer disconnect into the
|
|
// worker's inbox — the dropLink(notify=true) path used on peer EOF and,
|
|
// since #2510, on shutdown via shutdownLinks.
|
|
{
|
|
const arena = try client.arena_pool.acquire(.tiny, "test disconnect");
|
|
ctx.inbox.push(arena, .{ .disconnect = null });
|
|
}
|
|
|
|
// First tick drains the .disconnect and tears the link down.
|
|
try testing.expectError(error.ClientDisconnected, client.tick(0));
|
|
|
|
// The inbox is now empty. Without the latch this second tick would fall
|
|
// through to perform/poll with no producer left to wake it, so the worker
|
|
// would never exit and Server.deinit() would spin on active_threads
|
|
// (#2510). The latch keeps the terminal state sticky so the worker exits.
|
|
try testing.expectError(error.ClientDisconnected, client.tick(0));
|
|
}
|
|
|
|
test "cdp: run sends a close frame on pending terminate" {
|
|
testing.expectLog(&.{.cdp});
|
|
|
|
var ctx = try testing.context();
|
|
defer ctx.deinit();
|
|
|
|
const cdp = ctx.cdp();
|
|
cdp.browser.env.requestTerminate();
|
|
// Clear the pending terminate so deinit's V8 calls don't trip over the
|
|
// terminating-state asserts.
|
|
defer cdp.browser.env.cancelTerminate();
|
|
|
|
// The pending terminate makes the first tick the last one, so run returns.
|
|
ctx.driver.run();
|
|
|
|
// The client should receive a close frame (code 1001, going away), not
|
|
// just an abrupt socket close.
|
|
var buf: [WS.CLOSE_GOING_AWAY.len]u8 = undefined;
|
|
const n = try posix.read(ctx.socket, &buf);
|
|
try testing.expectEqualSlices(u8, &WS.CLOSE_GOING_AWAY, buf[0..n]);
|
|
}
|
|
|
|
test "cdp: syncRequest short-circuits after disconnect" {
|
|
var ctx = try testing.context();
|
|
defer ctx.deinit();
|
|
|
|
const client = &ctx.cdp().browser.http_client;
|
|
|
|
// Latch disconnected via a drained disconnect (as above).
|
|
{
|
|
const arena = try client.arena_pool.acquire(.tiny, "test disconnect");
|
|
ctx.inbox.push(arena, .{ .disconnect = null });
|
|
}
|
|
try testing.expectError(error.ClientDisconnected, client.tick(0));
|
|
|
|
// A synchronous fetch attempted after the latch returns ClientDisconnected
|
|
// without starting the request: syncRequest consumes (deinits) the
|
|
// transfer on the early-return path, before any of the callbacks are
|
|
// installed. The latch check runs before any req field is read, so the
|
|
// rest are placeholders.
|
|
const transfer = try client.newRequest(.{
|
|
.method = .GET,
|
|
.url = "http://127.0.0.1:9582/",
|
|
.origin = null,
|
|
.credentials_mode = .omit,
|
|
.request_mode = .no_cors,
|
|
.resource_type = .fetch,
|
|
.shutdown_callback = HttpClient.noopShutdown,
|
|
}, null);
|
|
try testing.expectError(error.ClientDisconnected, transfer.submitSync());
|
|
}
|