Compare commits

..

5 Commits

Author SHA1 Message Date
Adrià Arrufat
72229f715a Merge branch 'main' into mcp-new-action-tools 2026-04-03 07:06:10 +02:00
Adrià Arrufat
6c9a5ddab8 Extract shared helpers to reduce duplication
- Extract dispatchInputAndChangeEvents() in actions.zig, used by fill,
  selectOption, and setChecked
- Extract resolveNodeAndPage() in tools.zig, used by click, fill, hover,
  selectOption, setChecked, and nodeDetails handlers
2026-04-02 11:20:28 +02:00
Adrià Arrufat
46a63e0b4b Add focus before fill and findElement MCP tool
- fill action now calls focus() on the element before setting its value,
  ensuring focus/focusin events fire for JS listeners
- Add findElement MCP tool for locating interactive elements by ARIA role
  and/or accessible name (case-insensitive substring match)
- Add tests for findElement (by role, by name, no matches, missing params)
2026-04-02 11:03:49 +02:00
Adrià Arrufat
58143ee3d1 Fix event order and add tests
- Fix setChecked event order: click fires before input/change to match
  browser behavior
- Add tests for hover, press, selectOption, setChecked MCP tools
- Merge all action tests into a single test case sharing one page load
- Add test elements to mcp_actions.html (hover target, key input,
  second select, checkbox, radio)
2026-04-02 10:46:28 +02:00
Adrià Arrufat
5e79af42f4 mcp: Add hover, press, selectOption, setChecked
New browser actions and MCP tools for AI agent interaction:
- hover: dispatches mouseover/mouseenter events on an element
- press: dispatches keydown/keyup keyboard events (Enter, Tab, etc.)
- selectOption: selects a dropdown option by value with input/change events
- setChecked: checks/unchecks checkbox or radio with input/change/click events
2026-04-02 09:47:22 +02:00
27 changed files with 943 additions and 936 deletions

View File

@@ -5,8 +5,8 @@
.minimum_zig_version = "0.15.2", .minimum_zig_version = "0.15.2",
.dependencies = .{ .dependencies = .{
.v8 = .{ .v8 = .{
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/715ccbae21d7528eba951f78af4dfd48835fc172.tar.gz", .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.7.tar.gz",
.hash = "v8-0.0.0-xddH65-HBADXFCII9ucZE3NgbkWmwsbTbsx8qevYVki5", .hash = "v8-0.0.0-xddH67uBBAD95hWsPQz3Ni1PlZjdywtPXrGUAp8rSKco",
}, },
// .v8 = .{ .path = "../zig-v8-fork" }, // .v8 = .{ .path = "../zig-v8-fork" },
.brotli = .{ .brotli = .{

View File

@@ -22,10 +22,23 @@ const DOMNode = @import("webapi/Node.zig");
const Element = @import("webapi/Element.zig"); const Element = @import("webapi/Element.zig");
const Event = @import("webapi/Event.zig"); const Event = @import("webapi/Event.zig");
const MouseEvent = @import("webapi/event/MouseEvent.zig"); const MouseEvent = @import("webapi/event/MouseEvent.zig");
const KeyboardEvent = @import("webapi/event/KeyboardEvent.zig");
const Page = @import("Page.zig"); const Page = @import("Page.zig");
const Session = @import("Session.zig"); const Session = @import("Session.zig");
const Selector = @import("webapi/selector/Selector.zig"); const Selector = @import("webapi/selector/Selector.zig");
fn dispatchInputAndChangeEvents(el: *Element, page: *Page) !void {
const input_evt: *Event = try .initTrusted(comptime .wrap("input"), .{ .bubbles = true }, page);
page._event_manager.dispatch(el.asEventTarget(), input_evt) catch |err| {
lp.log.err(.app, "dispatch input event failed", .{ .err = err });
};
const change_evt: *Event = try .initTrusted(comptime .wrap("change"), .{ .bubbles = true }, page);
page._event_manager.dispatch(el.asEventTarget(), change_evt) catch |err| {
lp.log.err(.app, "dispatch change event failed", .{ .err = err });
};
}
pub fn click(node: *DOMNode, page: *Page) !void { pub fn click(node: *DOMNode, page: *Page) !void {
const el = node.is(Element) orelse return error.InvalidNodeType; const el = node.is(Element) orelse return error.InvalidNodeType;
@@ -43,9 +56,107 @@ pub fn click(node: *DOMNode, page: *Page) !void {
}; };
} }
pub fn hover(node: *DOMNode, page: *Page) !void {
const el = node.is(Element) orelse return error.InvalidNodeType;
const mouseover_event: *MouseEvent = try .initTrusted(comptime .wrap("mouseover"), .{
.bubbles = true,
.cancelable = true,
.composed = true,
}, page);
page._event_manager.dispatch(el.asEventTarget(), mouseover_event.asEvent()) catch |err| {
lp.log.err(.app, "hover mouseover failed", .{ .err = err });
return error.ActionFailed;
};
const mouseenter_event: *MouseEvent = try .initTrusted(comptime .wrap("mouseenter"), .{
.composed = true,
}, page);
page._event_manager.dispatch(el.asEventTarget(), mouseenter_event.asEvent()) catch |err| {
lp.log.err(.app, "hover mouseenter failed", .{ .err = err });
return error.ActionFailed;
};
}
pub fn press(node: ?*DOMNode, key: []const u8, page: *Page) !void {
const target = if (node) |n|
(n.is(Element) orelse return error.InvalidNodeType).asEventTarget()
else
page.document.asNode().asEventTarget();
const keydown_event: *KeyboardEvent = try .initTrusted(comptime .wrap("keydown"), .{
.bubbles = true,
.cancelable = true,
.composed = true,
.key = key,
}, page);
page._event_manager.dispatch(target, keydown_event.asEvent()) catch |err| {
lp.log.err(.app, "press keydown failed", .{ .err = err });
return error.ActionFailed;
};
const keyup_event: *KeyboardEvent = try .initTrusted(comptime .wrap("keyup"), .{
.bubbles = true,
.cancelable = true,
.composed = true,
.key = key,
}, page);
page._event_manager.dispatch(target, keyup_event.asEvent()) catch |err| {
lp.log.err(.app, "press keyup failed", .{ .err = err });
return error.ActionFailed;
};
}
pub fn selectOption(node: *DOMNode, value: []const u8, page: *Page) !void {
const el = node.is(Element) orelse return error.InvalidNodeType;
const select = el.is(Element.Html.Select) orelse return error.InvalidNodeType;
select.setValue(value, page) catch |err| {
lp.log.err(.app, "select setValue failed", .{ .err = err });
return error.ActionFailed;
};
try dispatchInputAndChangeEvents(el, page);
}
pub fn setChecked(node: *DOMNode, checked: bool, page: *Page) !void {
const el = node.is(Element) orelse return error.InvalidNodeType;
const input = el.is(Element.Html.Input) orelse return error.InvalidNodeType;
if (input._input_type != .checkbox and input._input_type != .radio) {
return error.InvalidNodeType;
}
input.setChecked(checked, page) catch |err| {
lp.log.err(.app, "setChecked failed", .{ .err = err });
return error.ActionFailed;
};
// Match browser event order: click fires first, then input and change.
const click_event: *MouseEvent = try .initTrusted(comptime .wrap("click"), .{
.bubbles = true,
.cancelable = true,
.composed = true,
}, page);
page._event_manager.dispatch(el.asEventTarget(), click_event.asEvent()) catch |err| {
lp.log.err(.app, "dispatch click event failed", .{ .err = err });
};
try dispatchInputAndChangeEvents(el, page);
}
pub fn fill(node: *DOMNode, text: []const u8, page: *Page) !void { pub fn fill(node: *DOMNode, text: []const u8, page: *Page) !void {
const el = node.is(Element) orelse return error.InvalidNodeType; const el = node.is(Element) orelse return error.InvalidNodeType;
el.focus(page) catch |err| {
lp.log.err(.app, "fill focus failed", .{ .err = err });
};
if (el.is(Element.Html.Input)) |input| { if (el.is(Element.Html.Input)) |input| {
input.setValue(text, page) catch |err| { input.setValue(text, page) catch |err| {
lp.log.err(.app, "fill input failed", .{ .err = err }); lp.log.err(.app, "fill input failed", .{ .err = err });
@@ -65,15 +176,7 @@ pub fn fill(node: *DOMNode, text: []const u8, page: *Page) !void {
return error.InvalidNodeType; return error.InvalidNodeType;
} }
const input_evt: *Event = try .initTrusted(comptime .wrap("input"), .{ .bubbles = true }, page); try dispatchInputAndChangeEvents(el, page);
page._event_manager.dispatch(el.asEventTarget(), input_evt) catch |err| {
lp.log.err(.app, "dispatch input event failed", .{ .err = err });
};
const change_evt: *Event = try .initTrusted(comptime .wrap("change"), .{ .bubbles = true }, page);
page._event_manager.dispatch(el.asEventTarget(), change_evt) catch |err| {
lp.log.err(.app, "dispatch change event failed", .{ .err = err });
};
} }
pub fn scroll(node: ?*DOMNode, x: ?i32, y: ?i32, page: *Page) !void { pub fn scroll(node: ?*DOMNode, x: ?i32, y: ?i32, page: *Page) !void {

View File

@@ -21,7 +21,6 @@ const log = @import("../../log.zig");
const string = @import("../../string.zig"); const string = @import("../../string.zig");
const Page = @import("../Page.zig"); const Page = @import("../Page.zig");
const WorkerGlobalScope = @import("../webapi/WorkerGlobalScope.zig");
const js = @import("js.zig"); const js = @import("js.zig");
const Local = @import("Local.zig"); const Local = @import("Local.zig");
@@ -55,9 +54,9 @@ fn initWithContext(self: *Caller, ctx: *Context, v8_context: *const v8.Context)
.isolate = ctx.isolate, .isolate = ctx.isolate,
}, },
.prev_local = ctx.local, .prev_local = ctx.local,
.prev_context = ctx.global.getJs(), .prev_context = ctx.page.js,
}; };
ctx.global.setJs(ctx); ctx.page.js = ctx;
ctx.local = &self.local; ctx.local = &self.local;
} }
@@ -88,7 +87,7 @@ pub fn deinit(self: *Caller) void {
ctx.call_depth = call_depth; ctx.call_depth = call_depth;
ctx.local = self.prev_local; ctx.local = self.prev_local;
ctx.global.setJs(self.prev_context); ctx.page.js = self.prev_context;
} }
pub const CallOpts = struct { pub const CallOpts = struct {
@@ -170,7 +169,7 @@ fn _getIndex(comptime T: type, local: *const Local, func: anytype, idx: u32, inf
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis()); @field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@field(args, "1") = idx; @field(args, "1") = idx;
if (@typeInfo(F).@"fn".params.len == 3) { if (@typeInfo(F).@"fn".params.len == 3) {
@field(args, "2") = getGlobalArg(@TypeOf(args.@"2"), local.ctx); @field(args, "2") = local.ctx.page;
} }
const ret = @call(.auto, func, args); const ret = @call(.auto, func, args);
return handleIndexedReturn(T, F, true, local, ret, info, opts); return handleIndexedReturn(T, F, true, local, ret, info, opts);
@@ -197,7 +196,7 @@ fn _getNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *c
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis()); @field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@field(args, "1") = try nameToString(local, @TypeOf(args.@"1"), name); @field(args, "1") = try nameToString(local, @TypeOf(args.@"1"), name);
if (@typeInfo(F).@"fn".params.len == 3) { if (@typeInfo(F).@"fn".params.len == 3) {
@field(args, "2") = getGlobalArg(@TypeOf(args.@"2"), local.ctx); @field(args, "2") = local.ctx.page;
} }
const ret = @call(.auto, func, args); const ret = @call(.auto, func, args);
return handleIndexedReturn(T, F, true, local, ret, info, opts); return handleIndexedReturn(T, F, true, local, ret, info, opts);
@@ -225,7 +224,7 @@ fn _setNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *c
@field(args, "1") = try nameToString(local, @TypeOf(args.@"1"), name); @field(args, "1") = try nameToString(local, @TypeOf(args.@"1"), name);
@field(args, "2") = try local.jsValueToZig(@TypeOf(@field(args, "2")), js_value); @field(args, "2") = try local.jsValueToZig(@TypeOf(@field(args, "2")), js_value);
if (@typeInfo(F).@"fn".params.len == 4) { if (@typeInfo(F).@"fn".params.len == 4) {
@field(args, "3") = getGlobalArg(@TypeOf(args.@"3"), local.ctx); @field(args, "3") = local.ctx.page;
} }
const ret = @call(.auto, func, args); const ret = @call(.auto, func, args);
return handleIndexedReturn(T, F, false, local, ret, info, opts); return handleIndexedReturn(T, F, false, local, ret, info, opts);
@@ -251,7 +250,7 @@ fn _deleteNamedIndex(comptime T: type, local: *const Local, func: anytype, name:
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis()); @field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@field(args, "1") = try nameToString(local, @TypeOf(args.@"1"), name); @field(args, "1") = try nameToString(local, @TypeOf(args.@"1"), name);
if (@typeInfo(F).@"fn".params.len == 3) { if (@typeInfo(F).@"fn".params.len == 3) {
@field(args, "2") = getGlobalArg(@TypeOf(args.@"2"), local.ctx); @field(args, "2") = local.ctx.page;
} }
const ret = @call(.auto, func, args); const ret = @call(.auto, func, args);
return handleIndexedReturn(T, F, false, local, ret, info, opts); return handleIndexedReturn(T, F, false, local, ret, info, opts);
@@ -277,7 +276,7 @@ fn _getEnumerator(comptime T: type, local: *const Local, func: anytype, info: Pr
var args: ParameterTypes(F) = undefined; var args: ParameterTypes(F) = undefined;
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis()); @field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
if (@typeInfo(F).@"fn".params.len == 2) { if (@typeInfo(F).@"fn".params.len == 2) {
@field(args, "1") = getGlobalArg(@TypeOf(args.@"1"), local.ctx); @field(args, "1") = local.ctx.page;
} }
const ret = @call(.auto, func, args); const ret = @call(.auto, func, args);
return handleIndexedReturn(T, F, true, local, ret, info, opts); return handleIndexedReturn(T, F, true, local, ret, info, opts);
@@ -435,25 +434,6 @@ fn isPage(comptime T: type) bool {
return T == *Page or T == *const Page; return T == *Page or T == *const Page;
} }
fn isExecution(comptime T: type) bool {
return T == *js.Execution or T == *const js.Execution;
}
fn getGlobalArg(comptime T: type, ctx: *Context) T {
if (comptime isPage(T)) {
return switch (ctx.global) {
.page => |page| page,
.worker => unreachable,
};
}
if (comptime isExecution(T)) {
return &ctx.execution;
}
@compileError("Unsupported global arg type: " ++ @typeName(T));
}
// These wrap the raw v8 C API to provide a cleaner interface. // These wrap the raw v8 C API to provide a cleaner interface.
pub const FunctionCallbackInfo = struct { pub const FunctionCallbackInfo = struct {
handle: *const v8.FunctionCallbackInfo, handle: *const v8.FunctionCallbackInfo,
@@ -722,16 +702,15 @@ fn getArgs(comptime F: type, comptime offset: usize, local: *const Local, info:
return args; return args;
} }
// If the last parameter is the Page or Worker, set it, and exclude it // If the last parameter is the Page, set it, and exclude it
// from our params slice, because we don't want to bind it to // from our params slice, because we don't want to bind it to
// a JS argument // a JS argument
const LastParamType = params[params.len - 1].type.?; if (comptime isPage(params[params.len - 1].type.?)) {
if (comptime isPage(LastParamType) or isExecution(LastParamType)) { @field(args, tupleFieldName(params.len - 1 + offset)) = local.ctx.page;
@field(args, tupleFieldName(params.len - 1 + offset)) = getGlobalArg(LastParamType, local.ctx);
break :blk params[0 .. params.len - 1]; break :blk params[0 .. params.len - 1];
} }
// we have neither a Page, Execution, nor a JsObject. All params must be // we have neither a Page nor a JsObject. All params must be
// bound to a JavaScript value. // bound to a JavaScript value.
break :blk params; break :blk params;
}; };
@@ -780,9 +759,7 @@ fn getArgs(comptime F: type, comptime offset: usize, local: *const Local, info:
} }
if (comptime isPage(param.type.?)) { if (comptime isPage(param.type.?)) {
@compileError("Page must be the last parameter: " ++ @typeName(F)); @compileError("Page must be the last parameter (or 2nd last if there's a JsThis): " ++ @typeName(F));
} else if (comptime isExecution(param.type.?)) {
@compileError("Execution must be the last parameter: " ++ @typeName(F));
} else if (i >= js_parameter_count) { } else if (i >= js_parameter_count) {
if (@typeInfo(param.type.?) != .optional) { if (@typeInfo(param.type.?) != .optional) {
return error.InvalidArgument; return error.InvalidArgument;

View File

@@ -25,12 +25,10 @@ const bridge = @import("bridge.zig");
const Env = @import("Env.zig"); const Env = @import("Env.zig");
const Origin = @import("Origin.zig"); const Origin = @import("Origin.zig");
const Scheduler = @import("Scheduler.zig"); const Scheduler = @import("Scheduler.zig");
const Execution = @import("Execution.zig");
const Page = @import("../Page.zig"); const Page = @import("../Page.zig");
const Session = @import("../Session.zig"); const Session = @import("../Session.zig");
const ScriptManager = @import("../ScriptManager.zig"); const ScriptManager = @import("../ScriptManager.zig");
const WorkerGlobalScope = @import("../webapi/WorkerGlobalScope.zig");
const v8 = js.v8; const v8 = js.v8;
const Caller = js.Caller; const Caller = js.Caller;
@@ -39,38 +37,12 @@ const Allocator = std.mem.Allocator;
const IS_DEBUG = @import("builtin").mode == .Debug; const IS_DEBUG = @import("builtin").mode == .Debug;
// Loosely maps to a Browser Page or Worker. // Loosely maps to a Browser Page.
const Context = @This(); const Context = @This();
pub const GlobalScope = union(enum) {
page: *Page,
worker: *WorkerGlobalScope,
pub fn base(self: GlobalScope) [:0]const u8 {
return switch (self) {
.page => |page| page.base(),
.worker => |worker| worker.base(),
};
}
pub fn getJs(self: GlobalScope) *Context {
return switch (self) {
.page => |page| page.js,
.worker => |worker| worker.js,
};
}
pub fn setJs(self: GlobalScope, ctx: *Context) void {
switch (self) {
.page => |page| page.js = ctx,
.worker => |worker| worker.js = ctx,
}
}
};
id: usize, id: usize,
env: *Env, env: *Env,
global: GlobalScope, page: *Page,
session: *Session, session: *Session,
isolate: js.Isolate, isolate: js.Isolate,
@@ -139,10 +111,6 @@ script_manager: ?*ScriptManager,
// Our macrotasks // Our macrotasks
scheduler: Scheduler, scheduler: Scheduler,
// Execution context for worker-compatible APIs. This provides a common
// interface that works in both Page and Worker contexts.
execution: Execution,
unknown_properties: (if (IS_DEBUG) std.StringHashMapUnmanaged(UnknownPropertyStat) else void) = if (IS_DEBUG) .{} else {}, unknown_properties: (if (IS_DEBUG) std.StringHashMapUnmanaged(UnknownPropertyStat) else void) = if (IS_DEBUG) .{} else {},
const ModuleEntry = struct { const ModuleEntry = struct {
@@ -289,16 +257,7 @@ pub fn toLocal(self: *Context, global: anytype) js.Local.ToLocalReturnType(@Type
} }
pub fn getIncumbent(self: *Context) *Page { pub fn getIncumbent(self: *Context) *Page {
const ctx = fromC(v8.v8__Isolate__GetIncumbentContext(self.env.isolate.handle).?).?; return fromC(v8.v8__Isolate__GetIncumbentContext(self.env.isolate.handle).?).?.page;
return switch (ctx.global) {
.page => |page| page,
.worker => {
if (comptime IS_DEBUG) {
std.debug.assert(false);
}
unreachable;
},
};
} }
pub fn stringToPersistedFunction( pub fn stringToPersistedFunction(
@@ -568,7 +527,7 @@ pub fn dynamicModuleCallback(
if (resource_value.isNullOrUndefined()) { if (resource_value.isNullOrUndefined()) {
// will only be null / undefined in extreme cases (e.g. WPT tests) // will only be null / undefined in extreme cases (e.g. WPT tests)
// where you're // where you're
break :blk self.global.base(); break :blk self.page.base();
} }
break :blk js.String.toSliceZ(.{ .local = &local, .handle = resource_name.? }) catch |err| { break :blk js.String.toSliceZ(.{ .local = &local, .handle = resource_name.? }) catch |err| {
@@ -908,16 +867,17 @@ pub fn enter(self: *Context, hs: *js.HandleScope) Entered {
const isolate = self.isolate; const isolate = self.isolate;
js.HandleScope.init(hs, isolate); js.HandleScope.init(hs, isolate);
const original = self.global.getJs(); const page = self.page;
self.global.setJs(self); const original = page.js;
page.js = self;
const handle: *const v8.Context = @ptrCast(v8.v8__Global__Get(&self.handle, isolate.handle)); const handle: *const v8.Context = @ptrCast(v8.v8__Global__Get(&self.handle, isolate.handle));
v8.v8__Context__Enter(handle); v8.v8__Context__Enter(handle);
return .{ .original = original, .handle = handle, .handle_scope = hs, .global = self.global }; return .{ .original = original, .handle = handle, .handle_scope = hs };
} }
const Entered = struct { const Entered = struct {
// the context we should restore on the page/worker // the context we should restore on the page
original: *Context, original: *Context,
// the handle of the entered context // the handle of the entered context
@@ -925,10 +885,8 @@ const Entered = struct {
handle_scope: *js.HandleScope, handle_scope: *js.HandleScope,
global: GlobalScope,
pub fn exit(self: Entered) void { pub fn exit(self: Entered) void {
self.global.setJs(self.original); self.original.page.js = self.original;
v8.v8__Context__Exit(self.handle); v8.v8__Context__Exit(self.handle);
self.handle_scope.deinit(); self.handle_scope.deinit();
} }
@@ -937,10 +895,7 @@ const Entered = struct {
pub fn queueMutationDelivery(self: *Context) !void { pub fn queueMutationDelivery(self: *Context) !void {
self.enqueueMicrotask(struct { self.enqueueMicrotask(struct {
fn run(ctx: *Context) void { fn run(ctx: *Context) void {
switch (ctx.global) { ctx.page.deliverMutations();
.page => |page| page.deliverMutations(),
.worker => unreachable,
}
} }
}.run); }.run);
} }
@@ -948,10 +903,7 @@ pub fn queueMutationDelivery(self: *Context) !void {
pub fn queueIntersectionChecks(self: *Context) !void { pub fn queueIntersectionChecks(self: *Context) !void {
self.enqueueMicrotask(struct { self.enqueueMicrotask(struct {
fn run(ctx: *Context) void { fn run(ctx: *Context) void {
switch (ctx.global) { ctx.page.performScheduledIntersectionChecks();
.page => |page| page.performScheduledIntersectionChecks(),
.worker => unreachable,
}
} }
}.run); }.run);
} }
@@ -959,10 +911,7 @@ pub fn queueIntersectionChecks(self: *Context) !void {
pub fn queueIntersectionDelivery(self: *Context) !void { pub fn queueIntersectionDelivery(self: *Context) !void {
self.enqueueMicrotask(struct { self.enqueueMicrotask(struct {
fn run(ctx: *Context) void { fn run(ctx: *Context) void {
switch (ctx.global) { ctx.page.deliverIntersections();
.page => |page| page.deliverIntersections(),
.worker => unreachable,
}
} }
}.run); }.run);
} }
@@ -970,10 +919,7 @@ pub fn queueIntersectionDelivery(self: *Context) !void {
pub fn queueSlotchangeDelivery(self: *Context) !void { pub fn queueSlotchangeDelivery(self: *Context) !void {
self.enqueueMicrotask(struct { self.enqueueMicrotask(struct {
fn run(ctx: *Context) void { fn run(ctx: *Context) void {
switch (ctx.global) { ctx.page.deliverSlotchangeEvents();
.page => |page| page.deliverSlotchangeEvents(),
.worker => unreachable,
}
} }
}.run); }.run);
} }

View File

@@ -34,7 +34,6 @@ const Inspector = @import("Inspector.zig");
const Page = @import("../Page.zig"); const Page = @import("../Page.zig");
const Window = @import("../webapi/Window.zig"); const Window = @import("../webapi/Window.zig");
const WorkerGlobalScope = @import("../webapi/WorkerGlobalScope.zig");
const JsApis = bridge.JsApis; const JsApis = bridge.JsApis;
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
@@ -84,6 +83,9 @@ eternal_function_templates: []v8.Eternal,
// Dynamic slice to avoid circular dependency on JsApis.len at comptime // Dynamic slice to avoid circular dependency on JsApis.len at comptime
templates: []*const v8.FunctionTemplate, templates: []*const v8.FunctionTemplate,
// Global template created once per isolate and reused across all contexts
global_template: v8.Eternal,
// Inspector associated with the Isolate. Exists when CDP is being used. // Inspector associated with the Isolate. Exists when CDP is being used.
inspector: ?*Inspector, inspector: ?*Inspector,
@@ -144,6 +146,7 @@ pub fn init(app: *App, opts: InitOpts) !Env {
const templates = try allocator.alloc(*const v8.FunctionTemplate, JsApis.len); const templates = try allocator.alloc(*const v8.FunctionTemplate, JsApis.len);
errdefer allocator.free(templates); errdefer allocator.free(templates);
var global_eternal: v8.Eternal = undefined;
var private_symbols: PrivateSymbols = undefined; var private_symbols: PrivateSymbols = undefined;
{ {
var temp_scope: js.HandleScope = undefined; var temp_scope: js.HandleScope = undefined;
@@ -161,6 +164,44 @@ pub fn init(app: *App, opts: InitOpts) !Env {
templates[i] = @ptrCast(@alignCast(eternal_ptr.?)); templates[i] = @ptrCast(@alignCast(eternal_ptr.?));
} }
// Create global template once per isolate
const js_global = v8.v8__FunctionTemplate__New__DEFAULT(isolate_handle);
const window_name = v8.v8__String__NewFromUtf8(isolate_handle, "Window", v8.kNormal, 6);
v8.v8__FunctionTemplate__SetClassName(js_global, window_name);
// Find Window in JsApis by name (avoids circular import)
const window_index = comptime bridge.JsApiLookup.getId(Window.JsApi);
v8.v8__FunctionTemplate__Inherit(js_global, templates[window_index]);
const global_template_local = v8.v8__FunctionTemplate__InstanceTemplate(js_global).?;
v8.v8__ObjectTemplate__SetNamedHandler(global_template_local, &.{
.getter = bridge.unknownWindowPropertyCallback,
.setter = null,
.query = null,
.deleter = null,
.enumerator = null,
.definer = null,
.descriptor = null,
.data = null,
.flags = v8.kOnlyInterceptStrings | v8.kNonMasking,
});
// I don't 100% understand this. We actually set this up in the snapshot,
// but for the global instance, it doesn't work. SetIndexedHandler and
// SetNamedHandler are set on the Instance template, and that's the key
// difference. The context has its own global instance, so we need to set
// these back up directly on it. There might be a better way to do this.
v8.v8__ObjectTemplate__SetIndexedHandler(global_template_local, &.{
.getter = Window.JsApi.index.getter,
.setter = null,
.query = null,
.deleter = null,
.enumerator = null,
.definer = null,
.descriptor = null,
.data = null,
.flags = 0,
});
v8.v8__Eternal__New(isolate_handle, @ptrCast(global_template_local), &global_eternal);
private_symbols = PrivateSymbols.init(isolate_handle); private_symbols = PrivateSymbols.init(isolate_handle);
} }
@@ -180,6 +221,7 @@ pub fn init(app: *App, opts: InitOpts) !Env {
.templates = templates, .templates = templates,
.isolate_params = params, .isolate_params = params,
.inspector = inspector, .inspector = inspector,
.global_template = global_eternal,
.private_symbols = private_symbols, .private_symbols = private_symbols,
.microtask_queues_are_running = false, .microtask_queues_are_running = false,
.eternal_function_templates = eternal_function_templates, .eternal_function_templates = eternal_function_templates,
@@ -219,17 +261,6 @@ pub const ContextParams = struct {
}; };
pub fn createContext(self: *Env, page: *Page, params: ContextParams) !*Context { pub fn createContext(self: *Env, page: *Page, params: ContextParams) !*Context {
return self._createContext(page, params);
}
pub fn createWorkerContext(self: *Env, worker: *WorkerGlobalScope, params: ContextParams) !*Context {
return self._createContext(worker, params);
}
fn _createContext(self: *Env, global: anytype, params: ContextParams) !*Context {
const T = @TypeOf(global);
const is_page = T == *Page;
const context_arena = try self.app.arena_pool.acquire(.{ .debug = params.debug_name }); const context_arena = try self.app.arena_pool.acquire(.{ .debug = params.debug_name });
errdefer self.app.arena_pool.release(context_arena); errdefer self.app.arena_pool.release(context_arena);
@@ -242,10 +273,12 @@ fn _createContext(self: *Env, global: anytype, params: ContextParams) !*Context
const microtask_queue = v8.v8__MicrotaskQueue__New(isolate.handle, v8.kExplicit).?; const microtask_queue = v8.v8__MicrotaskQueue__New(isolate.handle, v8.kExplicit).?;
errdefer v8.v8__MicrotaskQueue__DELETE(microtask_queue); errdefer v8.v8__MicrotaskQueue__DELETE(microtask_queue);
// Restore the context from the snapshot (0 = Page, 1 = Worker) // Get the global template that was created once per isolate
const snapshot_index: u32 = if (comptime is_page) 0 else 1; const global_template: *const v8.ObjectTemplate = @ptrCast(@alignCast(v8.v8__Eternal__Get(&self.global_template, isolate.handle).?));
const v8_context = v8.v8__Context__FromSnapshot__Config(isolate.handle, snapshot_index, &.{ v8.v8__ObjectTemplate__SetInternalFieldCount(global_template, comptime Snapshot.countInternalFields(Window.JsApi));
.global_template = null,
const v8_context = v8.v8__Context__New__Config(isolate.handle, &.{
.global_template = global_template,
.global_object = null, .global_object = null,
.microtask_queue = microtask_queue, .microtask_queue = microtask_queue,
}).?; }).?;
@@ -254,36 +287,36 @@ fn _createContext(self: *Env, global: anytype, params: ContextParams) !*Context
var context_global: v8.Global = undefined; var context_global: v8.Global = undefined;
v8.v8__Global__New(isolate.handle, v8_context, &context_global); v8.v8__Global__New(isolate.handle, v8_context, &context_global);
// Get the global object for the context // get the global object for the context, this maps to our Window
const global_obj = v8.v8__Context__Global(v8_context).?; const global_obj = v8.v8__Context__Global(v8_context).?;
// Store our TAO inside the internal field of the global object. This {
// maps the v8::Object -> Zig instance. // Store our TAO inside the internal field of the global object. This
const tao = try params.identity_arena.create(@import("TaggedOpaque.zig")); // maps the v8::Object -> Zig instance. Almost all objects have this, and
tao.* = if (comptime is_page) .{ // it gets setup automatically as objects are created, but the Window
.value = @ptrCast(global.window), // object already exists in v8 (it's the global) so we manually create
.prototype_chain = (&Window.JsApi.Meta.prototype_chain).ptr, // the mapping here.
.prototype_len = @intCast(Window.JsApi.Meta.prototype_chain.len), const tao = try params.identity_arena.create(@import("TaggedOpaque.zig"));
.subtype = .node, tao.* = .{
} else .{ .value = @ptrCast(page.window),
.value = @ptrCast(global), .prototype_chain = (&Window.JsApi.Meta.prototype_chain).ptr,
.prototype_chain = (&WorkerGlobalScope.JsApi.Meta.prototype_chain).ptr, .prototype_len = @intCast(Window.JsApi.Meta.prototype_chain.len),
.prototype_len = @intCast(WorkerGlobalScope.JsApi.Meta.prototype_chain.len), .subtype = .node, // this probably isn't right, but it's what we've been doing all along
.subtype = null, };
}; v8.v8__Object__SetAlignedPointerInInternalField(global_obj, 0, tao);
v8.v8__Object__SetAlignedPointerInInternalField(global_obj, 0, tao); }
const context_id = self.context_id; const context_id = self.context_id;
self.context_id = context_id + 1; self.context_id = context_id + 1;
const session = global._session; const session = page._session;
const origin = try session.getOrCreateOrigin(null); const origin = try session.getOrCreateOrigin(null);
errdefer session.releaseOrigin(origin); errdefer session.releaseOrigin(origin);
const context = try context_arena.create(Context); const context = try context_arena.create(Context);
context.* = .{ context.* = .{
.env = self, .env = self,
.global = if (comptime is_page) .{ .page = global } else .{ .worker = global }, .page = page,
.origin = origin, .origin = origin,
.id = context_id, .id = context_id,
.session = session, .session = session,
@@ -293,31 +326,22 @@ fn _createContext(self: *Env, global: anytype, params: ContextParams) !*Context
.templates = self.templates, .templates = self.templates,
.call_arena = params.call_arena, .call_arena = params.call_arena,
.microtask_queue = microtask_queue, .microtask_queue = microtask_queue,
.script_manager = if (comptime is_page) &global._script_manager else null, .script_manager = &page._script_manager,
.scheduler = .init(context_arena), .scheduler = .init(context_arena),
.identity = params.identity, .identity = params.identity,
.identity_arena = params.identity_arena, .identity_arena = params.identity_arena,
.execution = undefined,
}; };
context.execution = .{ {
.url = &global.url, // Multiple contexts can be created for the same Window (via CDP). We only
.buf = &global.buf, // need to register the first one.
.context = context, const gop = try params.identity.identity_map.getOrPut(params.identity_arena, @intFromPtr(page.window));
.arena = global.arena, if (gop.found_existing == false) {
.call_arena = params.call_arena, // our window wrapped in a v8::Global
._factory = global._factory, var global_global: v8.Global = undefined;
._scheduler = &context.scheduler, v8.v8__Global__New(isolate.handle, global_obj, &global_global);
}; gop.value_ptr.* = global_global;
}
// Register in the identity map. Multiple contexts can be created for the
// same global (via CDP), so we only register the first one.
const identity_ptr = if (comptime is_page) @intFromPtr(global.window) else @intFromPtr(global);
const gop = try params.identity.identity_map.getOrPut(params.identity_arena, identity_ptr);
if (gop.found_existing == false) {
var global_global: v8.Global = undefined;
v8.v8__Global__New(isolate.handle, global_obj, &global_global);
gop.value_ptr.* = global_global;
} }
// Store a pointer to our context inside the v8 context so that, given // Store a pointer to our context inside the v8 context so that, given
@@ -504,19 +528,13 @@ fn promiseRejectCallback(message_handle: v8.PromiseRejectMessage) callconv(.c) v
.call_arena = ctx.call_arena, .call_arena = ctx.call_arena,
}; };
switch (ctx.global) { const page = ctx.page;
.page => |page| { page.window.unhandledPromiseRejection(promise_event == v8.kPromiseRejectWithNoHandler, .{
page.window.unhandledPromiseRejection(promise_event == v8.kPromiseRejectWithNoHandler, .{ .local = &local,
.local = &local, .handle = &message_handle,
.handle = &message_handle, }, page) catch |err| {
}, page) catch |err| { log.warn(.browser, "unhandled rejection handler", .{ .err = err });
log.warn(.browser, "unhandled rejection handler", .{ .err = err }); };
};
},
.worker => {
// TODO: Worker promise rejection handling
},
}
} }
fn fatalCallback(c_location: [*c]const u8, c_message: [*c]const u8) callconv(.c) void { fn fatalCallback(c_location: [*c]const u8, c_message: [*c]const u8) callconv(.c) void {
@@ -548,50 +566,3 @@ const PrivateSymbols = struct {
self.child_nodes.deinit(); self.child_nodes.deinit();
} }
}; };
const testing = @import("../../testing.zig");
const EventTarget = @import("../webapi/EventTarget.zig");
test "Env: Worker context " {
const session = testing.test_session;
// Create a dummy WorkerGlobalScope using page's resources (hackish until session.createWorker exists)
const worker = try session.factory.eventTarget(WorkerGlobalScope{
._session = session,
._factory = &session.factory,
.arena = session.arena,
.url = "about:blank",
._proto = undefined,
._performance = .init(),
});
const ctx = try testing.test_browser.env.createWorkerContext(worker, .{
.identity = &session.identity,
.identity_arena = session.arena,
.call_arena = session.arena,
});
defer testing.test_browser.env.destroyContext(ctx);
var ls: js.Local.Scope = undefined;
ctx.localScope(&ls);
defer ls.deinit();
try testing.expectEqual(true, (try ls.local.exec("typeof Node === 'undefined'", null)).isTrue());
try testing.expectEqual(true, (try ls.local.exec("typeof WorkerGlobalScope !== 'undefined'", null)).isTrue());
}
test "Env: Page context" {
const session = testing.test_session;
const page = try session.createPage();
defer session.removePage();
// Page already has a context created, use it directly
const ctx = page.js;
var ls: js.Local.Scope = undefined;
ctx.localScope(&ls);
defer ls.deinit();
try testing.expectEqual(true, (try ls.local.exec("typeof Node !== 'undefined'", null)).isTrue());
try testing.expectEqual(true, (try ls.local.exec("typeof WorkerGlobalScope === 'undefined'", null)).isTrue());
}

View File

@@ -1,47 +0,0 @@
// Copyright (C) 2023-2026 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/>.
//! Execution context for worker-compatible APIs.
//!
//! This provides a common interface for APIs that work in both Window and Worker
//! contexts. Instead of taking `*Page` (which is DOM-specific), these APIs take
//! `*Execution` which abstracts the common infrastructure.
//!
//! The bridge constructs an Execution on-the-fly from the current context,
//! whether it's a Page context or a Worker context.
const std = @import("std");
const Context = @import("Context.zig");
const Scheduler = @import("Scheduler.zig");
const Factory = @import("../Factory.zig");
const Allocator = std.mem.Allocator;
const Execution = @This();
context: *Context,
// Fields named to match Page for generic code (executor._factory works for both)
buf: []u8,
arena: Allocator,
call_arena: Allocator,
_factory: *Factory,
_scheduler: *Scheduler,
// Pointer to the url field (Page or WorkerGlobalScope) - allows access to current url even after navigation
url: *[:0]const u8,

View File

@@ -332,15 +332,7 @@ pub fn zigValueToJs(self: *const Local, value: anytype, comptime opts: CallOpts)
} }
if (@typeInfo(ptr.child) == .@"struct" and @hasDecl(ptr.child, "runtimeGenericWrap")) { if (@typeInfo(ptr.child) == .@"struct" and @hasDecl(ptr.child, "runtimeGenericWrap")) {
const page = switch (self.ctx.global) { const wrap = try value.runtimeGenericWrap(self.ctx.page);
.page => |p| p,
.worker => {
// No Worker-related API currently uses this, so haven't
// added support for it
unreachable;
},
};
const wrap = try value.runtimeGenericWrap(page);
return self.zigValueToJs(wrap, opts); return self.zigValueToJs(wrap, opts);
} }
@@ -417,15 +409,7 @@ pub fn zigValueToJs(self: *const Local, value: anytype, comptime opts: CallOpts)
// zig fmt: on // zig fmt: on
if (@hasDecl(T, "runtimeGenericWrap")) { if (@hasDecl(T, "runtimeGenericWrap")) {
const page = switch (self.ctx.global) { const wrap = try value.runtimeGenericWrap(self.ctx.page);
.page => |p| p,
.worker => {
// No Worker-related API currently uses this, so haven't
// added support for it
unreachable;
},
};
const wrap = try value.runtimeGenericWrap(page);
return self.zigValueToJs(wrap, opts); return self.zigValueToJs(wrap, opts);
} }

View File

@@ -1,4 +1,4 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
// //
// Francis Bouvier <francis@lightpanda.io> // Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io>
@@ -17,7 +17,6 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std"); const std = @import("std");
const lp = @import("lightpanda");
const js = @import("js.zig"); const js = @import("js.zig");
const bridge = @import("bridge.zig"); const bridge = @import("bridge.zig");
const log = @import("../../log.zig"); const log = @import("../../log.zig");
@@ -26,8 +25,6 @@ const IS_DEBUG = @import("builtin").mode == .Debug;
const v8 = js.v8; const v8 = js.v8;
const JsApis = bridge.JsApis; const JsApis = bridge.JsApis;
const PageJsApis = bridge.PageJsApis;
const WorkerJsApis = bridge.WorkerJsApis;
const Snapshot = @This(); const Snapshot = @This();
@@ -138,7 +135,7 @@ pub fn create() !Snapshot {
v8.v8__HandleScope__CONSTRUCT(&handle_scope, isolate); v8.v8__HandleScope__CONSTRUCT(&handle_scope, isolate);
defer v8.v8__HandleScope__DESTRUCT(&handle_scope); defer v8.v8__HandleScope__DESTRUCT(&handle_scope);
// Create templates for ALL types (JsApis) // Create templates (constructors only) FIRST
var templates: [JsApis.len]*const v8.FunctionTemplate = undefined; var templates: [JsApis.len]*const v8.FunctionTemplate = undefined;
inline for (JsApis, 0..) |JsApi, i| { inline for (JsApis, 0..) |JsApi, i| {
@setEvalBranchQuota(10_000); @setEvalBranchQuota(10_000);
@@ -147,51 +144,114 @@ pub fn create() !Snapshot {
} }
// Set up prototype chains BEFORE attaching properties // Set up prototype chains BEFORE attaching properties
// This must come before attachClass so inheritance is set up first
inline for (JsApis, 0..) |JsApi, i| { inline for (JsApis, 0..) |JsApi, i| {
if (comptime protoIndexLookup(JsApi)) |proto_index| { if (comptime protoIndexLookup(JsApi)) |proto_index| {
v8.v8__FunctionTemplate__Inherit(templates[i], templates[proto_index]); v8.v8__FunctionTemplate__Inherit(templates[i], templates[proto_index]);
} }
} }
// Add ALL templates to snapshot (done once, in any context) // Set up the global template to inherit from Window's template
// We need a context to call AddData, so create a temporary one // This way the global object gets all Window properties through inheritance
{ const context = v8.v8__Context__New(isolate, null, null);
const temp_context = v8.v8__Context__New(isolate, null, null); v8.v8__Context__Enter(context);
v8.v8__Context__Enter(temp_context); defer v8.v8__Context__Exit(context);
defer v8.v8__Context__Exit(temp_context);
var last_data_index: usize = 0; // Add templates to context snapshot
inline for (JsApis, 0..) |_, i| { var last_data_index: usize = 0;
@setEvalBranchQuota(10_000); inline for (JsApis, 0..) |_, i| {
const data_index = v8.v8__SnapshotCreator__AddData(snapshot_creator, @ptrCast(templates[i])); @setEvalBranchQuota(10_000);
if (i == 0) { const data_index = v8.v8__SnapshotCreator__AddData(snapshot_creator, @ptrCast(templates[i]));
data_start = data_index; if (i == 0) {
last_data_index = data_index; data_start = data_index;
last_data_index = data_index;
} else {
// This isn't strictly required, but it means we only need to keep
// the first data_index. This is based on the assumption that
// addDataWithContext always increases by 1. If we ever hit this
// error, then that assumption is wrong and we should capture
// all the indexes explicitly in an array.
if (data_index != last_data_index + 1) {
return error.InvalidDataIndex;
}
last_data_index = data_index;
}
}
// Realize all templates by getting their functions and attaching to global
const global_obj = v8.v8__Context__Global(context);
inline for (JsApis, 0..) |JsApi, i| {
const func = v8.v8__FunctionTemplate__GetFunction(templates[i], context);
// Attach to global if it has a name
if (@hasDecl(JsApi.Meta, "name")) {
if (@hasDecl(JsApi.Meta, "constructor_alias")) {
const alias = JsApi.Meta.constructor_alias;
const v8_class_name = v8.v8__String__NewFromUtf8(isolate, alias.ptr, v8.kNormal, @intCast(alias.len));
var maybe_result: v8.MaybeBool = undefined;
v8.v8__Object__Set(global_obj, context, v8_class_name, func, &maybe_result);
// @TODO: This is wrong. This name should be registered with the
// illegalConstructorCallback. I.e. new Image() is OK, but
// new HTMLImageElement() isn't.
// But we _have_ to register the name, i.e. HTMLImageElement
// has to be registered so, for now, instead of creating another
// template, we just hook it into the constructor.
const name = JsApi.Meta.name;
const illegal_class_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
var maybe_result2: v8.MaybeBool = undefined;
v8.v8__Object__DefineOwnProperty(global_obj, context, illegal_class_name, func, 0, &maybe_result2);
} else { } else {
if (data_index != last_data_index + 1) { const name = JsApi.Meta.name;
return error.InvalidDataIndex; const v8_class_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
var maybe_result: v8.MaybeBool = undefined;
var properties: v8.PropertyAttribute = v8.None;
if (@hasDecl(JsApi.Meta, "enumerable") and JsApi.Meta.enumerable == false) {
properties |= v8.DontEnum;
} }
last_data_index = data_index; v8.v8__Object__DefineOwnProperty(global_obj, context, v8_class_name, func, properties, &maybe_result);
} }
} }
// V8 requires a default context. We could probably make this our
// Page context, but having both the Page and Worker context be
// indexed via addContext makes things a little more consistent.
v8.v8__SnapshotCreator__setDefaultContext(snapshot_creator, temp_context);
} }
{ {
const Window = @import("../webapi/Window.zig"); // If we want to overwrite the built-in console, we have to
const index = try createSnapshotContext(&PageJsApis, Window.JsApi, isolate, snapshot_creator.?, &templates); // delete the built-in one.
std.debug.assert(index == 0); const console_key = v8.v8__String__NewFromUtf8(isolate, "console", v8.kNormal, 7);
var maybe_deleted: v8.MaybeBool = undefined;
v8.v8__Object__Delete(global_obj, context, console_key, &maybe_deleted);
if (maybe_deleted.value == false) {
return error.ConsoleDeleteError;
}
}
// This shouldn't be necessary, but it is:
// https://groups.google.com/g/v8-users/c/qAQQBmbi--8
// TODO: see if newer V8 engines have a way around this.
inline for (JsApis, 0..) |JsApi, i| {
if (comptime protoIndexLookup(JsApi)) |proto_index| {
const proto_func = v8.v8__FunctionTemplate__GetFunction(templates[proto_index], context);
const proto_obj: *const v8.Object = @ptrCast(proto_func);
const self_func = v8.v8__FunctionTemplate__GetFunction(templates[i], context);
const self_obj: *const v8.Object = @ptrCast(self_func);
var maybe_result: v8.MaybeBool = undefined;
v8.v8__Object__SetPrototype(self_obj, context, proto_obj, &maybe_result);
}
} }
{ {
const WorkerGlobalScope = @import("../webapi/WorkerGlobalScope.zig"); // Custom exception
const index = try createSnapshotContext(&WorkerJsApis, WorkerGlobalScope.JsApi, isolate, snapshot_creator.?, &templates); // TODO: this is an horrible hack, I can't figure out how to do this cleanly.
std.debug.assert(index == 1); const code_str = "DOMException.prototype.__proto__ = Error.prototype";
const code = v8.v8__String__NewFromUtf8(isolate, code_str.ptr, v8.kNormal, @intCast(code_str.len));
const script = v8.v8__Script__Compile(context, code, null) orelse return error.ScriptCompileFailed;
_ = v8.v8__Script__Run(script, context) orelse return error.ScriptRunFailed;
} }
v8.v8__SnapshotCreator__setDefaultContext(snapshot_creator, context);
} }
const blob = v8.v8__SnapshotCreator__createBlob(snapshot_creator, v8.kKeep); const blob = v8.v8__SnapshotCreator__createBlob(snapshot_creator, v8.kKeep);
@@ -199,127 +259,25 @@ pub fn create() !Snapshot {
return .{ return .{
.owns_data = true, .owns_data = true,
.data_start = data_start, .data_start = data_start,
.startup_data = blob,
.external_references = external_references, .external_references = external_references,
.startup_data = blob,
}; };
} }
fn createSnapshotContext( // Helper to check if a JsApi has a NamedIndexed handler
comptime ContextApis: []const type, fn hasNamedIndexedGetter(comptime JsApi: type) bool {
comptime GlobalScopeApi: type, const declarations = @typeInfo(JsApi).@"struct".decls;
isolate: *v8.Isolate, inline for (declarations) |d| {
snapshot_creator: *v8.SnapshotCreator, const value = @field(JsApi, d.name);
templates: []*const v8.FunctionTemplate, const T = @TypeOf(value);
) !usize { if (T == bridge.NamedIndexed) {
// Create a global template that inherits from the GlobalScopeApi (Window or WorkerGlobalScope) return true;
const global_scope_index = comptime bridge.JsApiLookup.getId(GlobalScopeApi);
const js_global = v8.v8__FunctionTemplate__New__DEFAULT(isolate);
const class_name = v8.v8__String__NewFromUtf8(isolate, GlobalScopeApi.Meta.name.ptr, v8.kNormal, @intCast(GlobalScopeApi.Meta.name.len));
v8.v8__FunctionTemplate__SetClassName(js_global, class_name);
v8.v8__FunctionTemplate__Inherit(js_global, templates[global_scope_index]);
const global_template = v8.v8__FunctionTemplate__InstanceTemplate(js_global).?;
v8.v8__ObjectTemplate__SetInternalFieldCount(global_template, comptime countInternalFields(GlobalScopeApi));
// Set up named/indexed handlers for Window's global object (for named element access like window.myDiv)
if (comptime std.mem.eql(u8, GlobalScopeApi.Meta.name, "Window")) {
v8.v8__ObjectTemplate__SetNamedHandler(global_template, &.{
.getter = bridge.unknownWindowPropertyCallback,
.setter = null,
.query = null,
.deleter = null,
.enumerator = null,
.definer = null,
.descriptor = null,
.data = null,
.flags = v8.kOnlyInterceptStrings | v8.kNonMasking,
});
v8.v8__ObjectTemplate__SetIndexedHandler(global_template, &.{
.getter = @import("../webapi/Window.zig").JsApi.index.getter,
.setter = null,
.query = null,
.deleter = null,
.enumerator = null,
.definer = null,
.descriptor = null,
.data = null,
.flags = 0,
});
}
const context = v8.v8__Context__New(isolate, global_template, null);
v8.v8__Context__Enter(context);
defer v8.v8__Context__Exit(context);
// Initialize embedder data to null so callbacks can detect snapshot creation
v8.v8__Context__SetAlignedPointerInEmbedderData(context, 1, null);
const global_obj = v8.v8__Context__Global(context);
// Attach constructors for this context's APIs to the global
inline for (ContextApis) |JsApi| {
const template_index = comptime bridge.JsApiLookup.getId(JsApi);
const func = v8.v8__FunctionTemplate__GetFunction(templates[template_index], context);
if (@hasDecl(JsApi.Meta, "name")) {
if (@hasDecl(JsApi.Meta, "constructor_alias")) {
const alias = JsApi.Meta.constructor_alias;
const v8_class_name = v8.v8__String__NewFromUtf8(isolate, alias.ptr, v8.kNormal, @intCast(alias.len));
var maybe_result: v8.MaybeBool = undefined;
v8.v8__Object__Set(global_obj, context, v8_class_name, func, &maybe_result);
const name = JsApi.Meta.name;
const illegal_class_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
var maybe_result2: v8.MaybeBool = undefined;
v8.v8__Object__DefineOwnProperty(global_obj, context, illegal_class_name, func, 0, &maybe_result2);
} else {
const name = JsApi.Meta.name;
const v8_class_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
var maybe_result: v8.MaybeBool = undefined;
var properties: v8.PropertyAttribute = v8.None;
if (@hasDecl(JsApi.Meta, "enumerable") and JsApi.Meta.enumerable == false) {
properties |= v8.DontEnum;
}
v8.v8__Object__DefineOwnProperty(global_obj, context, v8_class_name, func, properties, &maybe_result);
}
} }
} }
return false;
{
// Delete built-in console so we can inject our own
const console_key = v8.v8__String__NewFromUtf8(isolate, "console", v8.kNormal, 7);
var maybe_deleted: v8.MaybeBool = undefined;
v8.v8__Object__Delete(global_obj, context, console_key, &maybe_deleted);
if (maybe_deleted.value == false) {
return error.ConsoleDeleteError;
}
}
// Set prototype chains on function objects
// https://groups.google.com/g/v8-users/c/qAQQBmbi--8
inline for (JsApis, 0..) |JsApi, i| {
if (comptime protoIndexLookup(JsApi)) |proto_index| {
const proto_func = v8.v8__FunctionTemplate__GetFunction(templates[proto_index], context);
const proto_obj: *const v8.Object = @ptrCast(proto_func);
const self_func = v8.v8__FunctionTemplate__GetFunction(templates[i], context);
const self_obj: *const v8.Object = @ptrCast(self_func);
var maybe_result: v8.MaybeBool = undefined;
v8.v8__Object__SetPrototype(self_obj, context, proto_obj, &maybe_result);
}
}
{
// DOMException prototype setup
const code_str = "DOMException.prototype.__proto__ = Error.prototype";
const code = v8.v8__String__NewFromUtf8(isolate, code_str.ptr, v8.kNormal, @intCast(code_str.len));
const script = v8.v8__Script__Compile(context, code, null) orelse return error.ScriptCompileFailed;
_ = v8.v8__Script__Run(script, context) orelse return error.ScriptRunFailed;
}
return v8.v8__SnapshotCreator__AddContext(snapshot_creator, context);
} }
// Count total callbacks needed for external_references array
fn countExternalReferences() comptime_int { fn countExternalReferences() comptime_int {
@setEvalBranchQuota(100_000); @setEvalBranchQuota(100_000);
@@ -331,24 +289,24 @@ fn countExternalReferences() comptime_int {
// +1 for the noop function shared by various types // +1 for the noop function shared by various types
count += 1; count += 1;
// +1 for unknownWindowPropertyCallback used on Window's global template
count += 1;
inline for (JsApis) |JsApi| { inline for (JsApis) |JsApi| {
// Constructor (only if explicit)
if (@hasDecl(JsApi, "constructor")) { if (@hasDecl(JsApi, "constructor")) {
count += 1; count += 1;
} }
// Callable (htmldda)
if (@hasDecl(JsApi, "callable")) { if (@hasDecl(JsApi, "callable")) {
count += 1; count += 1;
} }
// All other callbacks
const declarations = @typeInfo(JsApi).@"struct".decls; const declarations = @typeInfo(JsApi).@"struct".decls;
inline for (declarations) |d| { inline for (declarations) |d| {
const value = @field(JsApi, d.name); const value = @field(JsApi, d.name);
const T = @TypeOf(value); const T = @TypeOf(value);
if (T == bridge.Accessor) { if (T == bridge.Accessor) {
count += 1; count += 1; // getter
if (value.setter != null) { if (value.setter != null) {
count += 1; count += 1;
} }
@@ -362,13 +320,14 @@ fn countExternalReferences() comptime_int {
count += 1; count += 1;
} }
} else if (T == bridge.NamedIndexed) { } else if (T == bridge.NamedIndexed) {
count += 1; count += 1; // getter
if (value.setter != null) count += 1; if (value.setter != null) count += 1;
if (value.deleter != null) count += 1; if (value.deleter != null) count += 1;
} }
} }
} }
// In debug mode, add unknown property callbacks for types without NamedIndexed
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
inline for (JsApis) |JsApi| { inline for (JsApis) |JsApi| {
if (!hasNamedIndexedGetter(JsApi)) { if (!hasNamedIndexedGetter(JsApi)) {
@@ -390,9 +349,6 @@ fn collectExternalReferences() [countExternalReferences()]isize {
references[idx] = @bitCast(@intFromPtr(&bridge.Function.noopFunction)); references[idx] = @bitCast(@intFromPtr(&bridge.Function.noopFunction));
idx += 1; idx += 1;
references[idx] = @bitCast(@intFromPtr(&bridge.unknownWindowPropertyCallback));
idx += 1;
inline for (JsApis) |JsApi| { inline for (JsApis) |JsApi| {
if (@hasDecl(JsApi, "constructor")) { if (@hasDecl(JsApi, "constructor")) {
references[idx] = @bitCast(@intFromPtr(JsApi.constructor.func)); references[idx] = @bitCast(@intFromPtr(JsApi.constructor.func));
@@ -443,6 +399,7 @@ fn collectExternalReferences() [countExternalReferences()]isize {
} }
} }
// In debug mode, collect unknown property callbacks for types without NamedIndexed
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
inline for (JsApis) |JsApi| { inline for (JsApis) |JsApi| {
if (!hasNamedIndexedGetter(JsApi)) { if (!hasNamedIndexedGetter(JsApi)) {
@@ -455,11 +412,37 @@ fn collectExternalReferences() [countExternalReferences()]isize {
return references; return references;
} }
fn protoIndexLookup(comptime JsApi: type) ?u16 { // Even if a struct doesn't have a `constructor` function, we still
return protoIndexLookupFor(&JsApis, JsApi); // `generateConstructor`, because this is how we create our
// FunctionTemplate. Such classes exist, but they can't be instantiated
// via `new ClassName()` - but they could, for example, be created in
// Zig and returned from a function call, which is why we need the
// FunctionTemplate.
fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *const v8.FunctionTemplate {
const callback = blk: {
if (@hasDecl(JsApi, "constructor")) {
break :blk JsApi.constructor.func;
}
// Use shared illegal constructor callback
break :blk illegalConstructorCallback;
};
const template = v8.v8__FunctionTemplate__New__DEFAULT2(isolate, callback).?;
{
const internal_field_count = comptime countInternalFields(JsApi);
if (internal_field_count > 0) {
const instance_template = v8.v8__FunctionTemplate__InstanceTemplate(template);
v8.v8__ObjectTemplate__SetInternalFieldCount(instance_template, internal_field_count);
}
}
const name_str = if (@hasDecl(JsApi.Meta, "name")) JsApi.Meta.name else @typeName(JsApi);
const class_name = v8.v8__String__NewFromUtf8(isolate, name_str.ptr, v8.kNormal, @intCast(name_str.len));
v8.v8__FunctionTemplate__SetClassName(template, class_name);
return template;
} }
fn countInternalFields(comptime JsApi: type) u8 { pub fn countInternalFields(comptime JsApi: type) u8 {
var last_used_id = 0; var last_used_id = 0;
var cache_count: u8 = 0; var cache_count: u8 = 0;
@@ -497,80 +480,14 @@ fn countInternalFields(comptime JsApi: type) u8 {
return cache_count + 1; return cache_count + 1;
} }
// Shared illegal constructor callback for types without explicit constructors // Attaches JsApi members to the prototype template (normal case)
fn illegalConstructorCallback(raw_info: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
const isolate = v8.v8__FunctionCallbackInfo__GetIsolate(raw_info);
log.warn(.js, "Illegal constructor call", .{});
const message = v8.v8__String__NewFromUtf8(isolate, "Illegal Constructor", v8.kNormal, 19);
const js_exception = v8.v8__Exception__TypeError(message);
_ = v8.v8__Isolate__ThrowException(isolate, js_exception);
var return_value: v8.ReturnValue = undefined;
v8.v8__FunctionCallbackInfo__GetReturnValue(raw_info, &return_value);
v8.v8__ReturnValue__Set(return_value, js_exception);
}
// Helper to check if a JsApi has a NamedIndexed handler (public for reuse)
fn hasNamedIndexedGetter(comptime JsApi: type) bool {
const declarations = @typeInfo(JsApi).@"struct".decls;
inline for (declarations) |d| {
const value = @field(JsApi, d.name);
const T = @TypeOf(value);
if (T == bridge.NamedIndexed) {
return true;
}
}
return false;
}
// Generic prototype index lookup for a given API list
fn protoIndexLookupFor(comptime ApiList: []const type, comptime JsApi: type) ?u16 {
@setEvalBranchQuota(100_000);
comptime {
const T = JsApi.bridge.type;
if (!@hasField(T, "_proto")) {
return null;
}
const Ptr = std.meta.fieldInfo(T, ._proto).type;
const F = @typeInfo(Ptr).pointer.child;
// Look up in the provided API list
for (ApiList, 0..) |Api, i| {
if (Api == F.JsApi) {
return i;
}
}
@compileError("Prototype " ++ @typeName(F.JsApi) ++ " not found in API list");
}
}
// Generate a constructor template for a JsApi type (public for reuse)
pub fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *const v8.FunctionTemplate {
const callback = blk: {
if (@hasDecl(JsApi, "constructor")) {
break :blk JsApi.constructor.func;
}
break :blk illegalConstructorCallback;
};
const template = v8.v8__FunctionTemplate__New__DEFAULT2(isolate, callback).?;
{
const internal_field_count = comptime countInternalFields(JsApi);
if (internal_field_count > 0) {
const instance_template = v8.v8__FunctionTemplate__InstanceTemplate(template);
v8.v8__ObjectTemplate__SetInternalFieldCount(instance_template, internal_field_count);
}
}
const name_str = if (@hasDecl(JsApi.Meta, "name")) JsApi.Meta.name else @typeName(JsApi);
const class_name = v8.v8__String__NewFromUtf8(isolate, name_str.ptr, v8.kNormal, @intCast(name_str.len));
v8.v8__FunctionTemplate__SetClassName(template, class_name);
return template;
}
// Attach JsApi members to a template (public for reuse)
fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *const v8.FunctionTemplate) void { fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *const v8.FunctionTemplate) void {
const instance = v8.v8__FunctionTemplate__InstanceTemplate(template); const instance = v8.v8__FunctionTemplate__InstanceTemplate(template);
const prototype = v8.v8__FunctionTemplate__PrototypeTemplate(template); const prototype = v8.v8__FunctionTemplate__PrototypeTemplate(template);
// Create a signature that validates the receiver is an instance of this template.
// This prevents crashes when JavaScript extracts a getter/method and calls it
// with the wrong `this` (e.g., documentGetter.call(null)).
const signature = v8.v8__Signature__New(isolate, template); const signature = v8.v8__Signature__New(isolate, template);
const declarations = @typeInfo(JsApi).@"struct".decls; const declarations = @typeInfo(JsApi).@"struct".decls;
@@ -606,6 +523,7 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *const v8.F
} }
if (value.static) { if (value.static) {
// Static accessors: use Template's SetAccessorProperty
v8.v8__Template__SetAccessorProperty(@ptrCast(template), js_name, getter_callback, setter_callback, attribute); v8.v8__Template__SetAccessorProperty(@ptrCast(template), js_name, getter_callback, setter_callback, attribute);
} else { } else {
v8.v8__ObjectTemplate__SetAccessorProperty__Config(prototype, &.{ v8.v8__ObjectTemplate__SetAccessorProperty__Config(prototype, &.{
@@ -617,6 +535,7 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *const v8.F
} }
}, },
bridge.Function => { bridge.Function => {
// For non-static functions, use the signature to validate the receiver
const func_signature = if (value.static) null else signature; const func_signature = if (value.static) null else signature;
const function_template = v8.v8__FunctionTemplate__New__Config(isolate, &.{ const function_template = v8.v8__FunctionTemplate__New__Config(isolate, &.{
.callback = value.func, .callback = value.func,
@@ -670,7 +589,7 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *const v8.F
bridge.Property => { bridge.Property => {
const js_value = switch (value.value) { const js_value = switch (value.value) {
.null => js.simpleZigValueToJs(.{ .handle = isolate }, null, true, false), .null => js.simpleZigValueToJs(.{ .handle = isolate }, null, true, false),
inline .bool, .int, .float, .string => |pv| js.simpleZigValueToJs(.{ .handle = isolate }, pv, true, false), inline .bool, .int, .float, .string => |v| js.simpleZigValueToJs(.{ .handle = isolate }, v, true, false),
}; };
const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len)); const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
@@ -680,10 +599,11 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *const v8.F
} }
if (value.template) { if (value.template) {
// apply it both to the type itself (e.g. Node.Elem)
v8.v8__Template__Set(@ptrCast(template), js_name, js_value, v8.ReadOnly + v8.DontDelete); v8.v8__Template__Set(@ptrCast(template), js_name, js_value, v8.ReadOnly + v8.DontDelete);
} }
}, },
bridge.Constructor => {}, bridge.Constructor => {}, // already handled in generateConstructor
else => {}, else => {},
} }
} }
@@ -716,3 +636,30 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *const v8.F
} }
} }
} }
fn protoIndexLookup(comptime JsApi: type) ?bridge.JsApiLookup.BackingInt {
@setEvalBranchQuota(2000);
comptime {
const T = JsApi.bridge.type;
if (!@hasField(T, "_proto")) {
return null;
}
const Ptr = std.meta.fieldInfo(T, ._proto).type;
const F = @typeInfo(Ptr).pointer.child;
return bridge.JsApiLookup.getId(F.JsApi);
}
}
// Shared illegal constructor callback for types without explicit constructors
fn illegalConstructorCallback(raw_info: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
const isolate = v8.v8__FunctionCallbackInfo__GetIsolate(raw_info);
log.warn(.js, "Illegal constructor call", .{});
const message = v8.v8__String__NewFromUtf8(isolate, "Illegal Constructor", v8.kNormal, 19);
const js_exception = v8.v8__Exception__TypeError(message);
_ = v8.v8__Isolate__ThrowException(isolate, js_exception);
var return_value: v8.ReturnValue = undefined;
v8.v8__FunctionCallbackInfo__GetReturnValue(raw_info, &return_value);
v8.v8__ReturnValue__Set(return_value, js_exception);
}

View File

@@ -24,7 +24,6 @@ const Session = @import("../Session.zig");
const v8 = js.v8; const v8 = js.v8;
const Caller = @import("Caller.zig"); const Caller = @import("Caller.zig");
const Context = @import("Context.zig");
const IS_DEBUG = @import("builtin").mode == .Debug; const IS_DEBUG = @import("builtin").mode == .Debug;
@@ -387,11 +386,6 @@ pub const Property = struct {
pub fn unknownWindowPropertyCallback(c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 { pub fn unknownWindowPropertyCallback(c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 {
const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?; const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?;
// During snapshot creation, there's no Context in embedder data yet
const v8_context = v8.v8__Isolate__GetCurrentContext(v8_isolate) orelse return 0;
if (v8.v8__Context__GetAlignedPointerFromEmbedderData(v8_context, 1) == null) return 0;
var caller: Caller = undefined; var caller: Caller = undefined;
caller.init(v8_isolate); caller.init(v8_isolate);
defer caller.deinit(); defer caller.deinit();
@@ -406,18 +400,14 @@ pub fn unknownWindowPropertyCallback(c_name: ?*const v8.Name, handle: ?*const v8
return 0; return 0;
}; };
// Only Page contexts have document.getElementById lookup const page = local.ctx.page;
switch (local.ctx.global) { const document = page.document;
.page => |page| {
const document = page.document; if (document.getElementById(property, page)) |el| {
if (document.getElementById(property, page)) |el| { const js_val = local.zigValueToJs(el, .{}) catch return 0;
const js_val = local.zigValueToJs(el, .{}) catch return 0; var pc = Caller.PropertyCallbackInfo{ .handle = handle.? };
var pc = Caller.PropertyCallbackInfo{ .handle = handle.? }; pc.getReturnValue().set(js_val);
pc.getReturnValue().set(js_val); return 1;
return 1;
}
},
.worker => {}, // no global lookup in a worker
} }
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
@@ -455,8 +445,7 @@ pub fn unknownWindowPropertyCallback(c_name: ?*const v8.Name, handle: ?*const v8
.{ "ApplePaySession", {} }, .{ "ApplePaySession", {} },
}); });
if (!ignored.has(property)) { if (!ignored.has(property)) {
var buf: [2048]u8 = undefined; const key = std.fmt.bufPrint(&local.ctx.page.buf, "Window:{s}", .{property}) catch return 0;
const key = std.fmt.bufPrint(&buf, "Window:{s}", .{property}) catch return 0;
logUnknownProperty(local, key) catch return 0; logUnknownProperty(local, key) catch return 0;
} }
} }
@@ -519,8 +508,7 @@ pub fn unknownObjectPropertyCallback(comptime JsApi: type) *const fn (?*const v8
const ignored = std.StaticStringMap(void).initComptime(.{}); const ignored = std.StaticStringMap(void).initComptime(.{});
if (!ignored.has(property)) { if (!ignored.has(property)) {
var buf: [2048]u8 = undefined; const key = std.fmt.bufPrint(&local.ctx.page.buf, "{s}:{s}", .{ if (@hasDecl(JsApi.Meta, "name")) JsApi.Meta.name else @typeName(JsApi), property }) catch return 0;
const key = std.fmt.bufPrint(&buf, "{s}:{s}", .{ if (@hasDecl(JsApi.Meta, "name")) JsApi.Meta.name else @typeName(JsApi), property }) catch return 0;
logUnknownProperty(local, key) catch return 0; logUnknownProperty(local, key) catch return 0;
} }
// not intercepted // not intercepted
@@ -562,7 +550,7 @@ fn PrototypeType(comptime T: type) ?type {
return Struct(std.meta.fieldInfo(T, ._proto).type); return Struct(std.meta.fieldInfo(T, ._proto).type);
} }
pub fn flattenTypes(comptime Types: []const type) [countFlattenedTypes(Types)]type { fn flattenTypes(comptime Types: []const type) [countFlattenedTypes(Types)]type {
var index: usize = 0; var index: usize = 0;
var flat: [countFlattenedTypes(Types)]type = undefined; var flat: [countFlattenedTypes(Types)]type = undefined;
for (Types) |T| { for (Types) |T| {
@@ -685,8 +673,7 @@ pub const SubType = enum {
webassemblymemory, webassemblymemory,
}; };
// APIs for Page/Window contexts. Used by Snapshot.zig for Page snapshot creation. pub const JsApis = flattenTypes(&.{
pub const PageJsApis = flattenTypes(&.{
@import("../webapi/AbortController.zig"), @import("../webapi/AbortController.zig"),
@import("../webapi/AbortSignal.zig"), @import("../webapi/AbortSignal.zig"),
@import("../webapi/CData.zig"), @import("../webapi/CData.zig"),
@@ -879,33 +866,3 @@ pub const PageJsApis = flattenTypes(&.{
@import("../webapi/Selection.zig"), @import("../webapi/Selection.zig"),
@import("../webapi/ImageData.zig"), @import("../webapi/ImageData.zig"),
}); });
// APIs available on Worker context globals (constructors like URL, Headers, etc.)
// This is a subset of PageJsApis plus WorkerGlobalScope.
// TODO: Expand this list to include all worker-appropriate APIs.
pub const WorkerJsApis = flattenTypes(&.{
@import("../webapi/WorkerGlobalScope.zig"),
@import("../webapi/EventTarget.zig"),
@import("../webapi/DOMException.zig"),
@import("../webapi/AbortController.zig"),
@import("../webapi/AbortSignal.zig"),
@import("../webapi/URL.zig"),
@import("../webapi/net/URLSearchParams.zig"),
@import("../webapi/net/Headers.zig"),
@import("../webapi/net/Request.zig"),
@import("../webapi/net/Response.zig"),
@import("../webapi/encoding/TextEncoder.zig"),
@import("../webapi/encoding/TextDecoder.zig"),
@import("../webapi/Blob.zig"),
@import("../webapi/File.zig"),
@import("../webapi/net/FormData.zig"),
@import("../webapi/Console.zig"),
@import("../webapi/Crypto.zig"),
@import("../webapi/Performance.zig"),
});
// Master list of ALL JS APIs across all contexts.
// Used by Env (class IDs, templates), JsApiLookup, and anywhere that needs
// to know about all possible types. Individual snapshots use their own
// subsets (PageJsApis, WorkerSnapshot.JsApis).
pub const JsApis = PageJsApis ++ [_]type{@import("../webapi/WorkerGlobalScope.zig").JsApi};

View File

@@ -27,7 +27,6 @@ pub const Caller = @import("Caller.zig");
pub const Origin = @import("Origin.zig"); pub const Origin = @import("Origin.zig");
pub const Identity = @import("Identity.zig"); pub const Identity = @import("Identity.zig");
pub const Context = @import("Context.zig"); pub const Context = @import("Context.zig");
pub const Execution = @import("Execution.zig");
pub const Local = @import("Local.zig"); pub const Local = @import("Local.zig");
pub const Inspector = @import("Inspector.zig"); pub const Inspector = @import("Inspector.zig");
pub const Snapshot = @import("Snapshot.zig"); pub const Snapshot = @import("Snapshot.zig");

View File

@@ -10,5 +10,20 @@
<div id="scrollbox" style="width: 100px; height: 100px; overflow: scroll;" onscroll="window.scrolled = true;"> <div id="scrollbox" style="width: 100px; height: 100px; overflow: scroll;" onscroll="window.scrolled = true;">
<div style="height: 500px;">Long content</div> <div style="height: 500px;">Long content</div>
</div> </div>
<div id="hoverTarget" onmouseover="window.hovered = true;">Hover Me</div>
<input id="keyTarget" onkeydown="window.keyPressed = event.key;" onkeyup="window.keyReleased = event.key;">
<select id="sel2" onchange="window.sel2Changed = this.value">
<option value="a">Alpha</option>
<option value="b">Beta</option>
<option value="c">Gamma</option>
</select>
<input id="chk" type="checkbox">
<input id="rad" type="radio" name="group1">
<script>
document.getElementById('chk').addEventListener('click', function() { window.chkClicked = true; });
document.getElementById('chk').addEventListener('change', function() { window.chkChanged = true; });
document.getElementById('rad').addEventListener('click', function() { window.radClicked = true; });
document.getElementById('rad').addEventListener('change', function() { window.radChanged = true; });
</script>
</body> </body>
</html> </html>

View File

@@ -34,7 +34,6 @@ pub const Type = union(enum) {
generic: void, generic: void,
node: *@import("Node.zig"), node: *@import("Node.zig"),
window: *@import("Window.zig"), window: *@import("Window.zig"),
worker_global_scope: *@import("WorkerGlobalScope.zig"),
xhr: *@import("net/XMLHttpRequestEventTarget.zig"), xhr: *@import("net/XMLHttpRequestEventTarget.zig"),
abort_signal: *@import("AbortSignal.zig"), abort_signal: *@import("AbortSignal.zig"),
media_query_list: *@import("css/MediaQueryList.zig"), media_query_list: *@import("css/MediaQueryList.zig"),
@@ -131,7 +130,6 @@ pub fn format(self: *EventTarget, writer: *std.Io.Writer) !void {
.node => |n| n.format(writer), .node => |n| n.format(writer),
.generic => writer.writeAll("<EventTarget>"), .generic => writer.writeAll("<EventTarget>"),
.window => writer.writeAll("<Window>"), .window => writer.writeAll("<Window>"),
.worker_global_scope => writer.writeAll("<WorkerGlobalScope>"),
.xhr => writer.writeAll("<XMLHttpRequestEventTarget>"), .xhr => writer.writeAll("<XMLHttpRequestEventTarget>"),
.abort_signal => writer.writeAll("<AbortSignal>"), .abort_signal => writer.writeAll("<AbortSignal>"),
.media_query_list => writer.writeAll("<MediaQueryList>"), .media_query_list => writer.writeAll("<MediaQueryList>"),
@@ -151,7 +149,6 @@ pub fn toString(self: *EventTarget) []const u8 {
.node => return "[object Node]", .node => return "[object Node]",
.generic => return "[object EventTarget]", .generic => return "[object EventTarget]",
.window => return "[object Window]", .window => return "[object Window]",
.worker_global_scope => return "[object WorkerGlobalScope]",
.xhr => return "[object XMLHttpRequestEventTarget]", .xhr => return "[object XMLHttpRequestEventTarget]",
.abort_signal => return "[object AbortSignal]", .abort_signal => return "[object AbortSignal]",
.media_query_list => return "[object MediaQueryList]", .media_query_list => return "[object MediaQueryList]",

View File

@@ -22,7 +22,6 @@ const String = @import("../../string.zig").String;
const js = @import("../js/js.zig"); const js = @import("../js/js.zig");
const Page = @import("../Page.zig"); const Page = @import("../Page.zig");
const Execution = js.Execution;
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
@@ -34,7 +33,7 @@ pub fn registerTypes() []const type {
}; };
} }
const Normalizer = *const fn ([]const u8, []u8) []const u8; const Normalizer = *const fn ([]const u8, *Page) []const u8;
pub const Entry = struct { pub const Entry = struct {
name: String, name: String,
@@ -62,14 +61,14 @@ pub fn copy(arena: Allocator, original: KeyValueList) !KeyValueList {
return list; return list;
} }
pub fn fromJsObject(arena: Allocator, js_obj: js.Object, comptime normalizer: ?Normalizer, buf: []u8) !KeyValueList { pub fn fromJsObject(arena: Allocator, js_obj: js.Object, comptime normalizer: ?Normalizer, page: *Page) !KeyValueList {
var it = try js_obj.nameIterator(); var it = try js_obj.nameIterator();
var list = KeyValueList.init(); var list = KeyValueList.init();
try list.ensureTotalCapacity(arena, it.count); try list.ensureTotalCapacity(arena, it.count);
while (try it.next()) |name| { while (try it.next()) |name| {
const js_value = try js_obj.get(name); const js_value = try js_obj.get(name);
const normalized = if (comptime normalizer) |n| n(name, buf) else name; const normalized = if (comptime normalizer) |n| n(name, page) else name;
list._entries.appendAssumeCapacity(.{ list._entries.appendAssumeCapacity(.{
.name = try String.init(arena, normalized, .{}), .name = try String.init(arena, normalized, .{}),
@@ -80,12 +79,12 @@ pub fn fromJsObject(arena: Allocator, js_obj: js.Object, comptime normalizer: ?N
return list; return list;
} }
pub fn fromArray(arena: Allocator, kvs: []const [2][]const u8, comptime normalizer: ?Normalizer, buf: []u8) !KeyValueList { pub fn fromArray(arena: Allocator, kvs: []const [2][]const u8, comptime normalizer: ?Normalizer, page: *Page) !KeyValueList {
var list = KeyValueList.init(); var list = KeyValueList.init();
try list.ensureTotalCapacity(arena, kvs.len); try list.ensureTotalCapacity(arena, kvs.len);
for (kvs) |pair| { for (kvs) |pair| {
const normalized = if (comptime normalizer) |n| n(pair[0], buf) else pair[0]; const normalized = if (comptime normalizer) |n| n(pair[0], page) else pair[0];
list._entries.appendAssumeCapacity(.{ list._entries.appendAssumeCapacity(.{
.name = try String.init(arena, normalized, .{}), .name = try String.init(arena, normalized, .{}),
@@ -112,11 +111,12 @@ pub fn get(self: *const KeyValueList, name: []const u8) ?[]const u8 {
return null; return null;
} }
pub fn getAll(self: *const KeyValueList, allocator: Allocator, name: []const u8) ![]const []const u8 { pub fn getAll(self: *const KeyValueList, name: []const u8, page: *Page) ![]const []const u8 {
const arena = page.call_arena;
var arr: std.ArrayList([]const u8) = .empty; var arr: std.ArrayList([]const u8) = .empty;
for (self._entries.items) |*entry| { for (self._entries.items) |*entry| {
if (entry.name.eqlSlice(name)) { if (entry.name.eqlSlice(name)) {
try arr.append(allocator, entry.value.str()); try arr.append(arena, entry.value.str());
} }
} }
return arr.items; return arr.items;
@@ -260,7 +260,7 @@ pub const Iterator = struct {
pub const Entry = struct { []const u8, []const u8 }; pub const Entry = struct { []const u8, []const u8 };
pub fn next(self: *Iterator, _: *const Execution) ?Iterator.Entry { pub fn next(self: *Iterator, _: *const Page) ?Iterator.Entry {
const index = self.index; const index = self.index;
const entries = self.kv._entries.items; const entries = self.kv._entries.items;
if (index >= entries.len) { if (index >= entries.len) {

View File

@@ -27,7 +27,7 @@ const Location = @This();
_url: *URL, _url: *URL,
pub fn init(raw_url: [:0]const u8, page: *Page) !*Location { pub fn init(raw_url: [:0]const u8, page: *Page) !*Location {
const url = try URL.init(raw_url, null, &page.js.execution); const url = try URL.init(raw_url, null, page);
return page._factory.create(Location{ return page._factory.create(Location{
._url = url, ._url = url,
}); });
@@ -53,12 +53,12 @@ pub fn getPort(self: *const Location) []const u8 {
return self._url.getPort(); return self._url.getPort();
} }
pub fn getOrigin(self: *const Location, exec: *const js.Execution) ![]const u8 { pub fn getOrigin(self: *const Location, page: *const Page) ![]const u8 {
return self._url.getOrigin(exec); return self._url.getOrigin(page);
} }
pub fn getSearch(self: *const Location, exec: *const js.Execution) ![]const u8 { pub fn getSearch(self: *const Location, page: *const Page) ![]const u8 {
return self._url.getSearch(exec); return self._url.getSearch(page);
} }
pub fn getHash(self: *const Location) []const u8 { pub fn getHash(self: *const Location) []const u8 {
@@ -98,8 +98,8 @@ pub fn reload(_: *const Location, page: *Page) !void {
return page.scheduleNavigation(page.url, .{ .reason = .script, .kind = .reload }, .{ .script = page }); return page.scheduleNavigation(page.url, .{ .reason = .script, .kind = .reload }, .{ .script = page });
} }
pub fn toString(self: *const Location, exec: *const js.Execution) ![:0]const u8 { pub fn toString(self: *const Location, page: *const Page) ![:0]const u8 {
return self._url.toString(exec); return self._url.toString(page);
} }
pub const JsApi = struct { pub const JsApi = struct {

View File

@@ -23,7 +23,6 @@ const U = @import("../URL.zig");
const Page = @import("../Page.zig"); const Page = @import("../Page.zig");
const URLSearchParams = @import("net/URLSearchParams.zig"); const URLSearchParams = @import("net/URLSearchParams.zig");
const Blob = @import("Blob.zig"); const Blob = @import("Blob.zig");
const Execution = js.Execution;
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
@@ -37,12 +36,11 @@ _search_params: ?*URLSearchParams = null,
pub const resolve = @import("../URL.zig").resolve; pub const resolve = @import("../URL.zig").resolve;
pub const eqlDocument = @import("../URL.zig").eqlDocument; pub const eqlDocument = @import("../URL.zig").eqlDocument;
pub fn init(url: [:0]const u8, base_: ?[:0]const u8, exec: *const Execution) !*URL { pub fn init(url: [:0]const u8, base_: ?[:0]const u8, page: *Page) !*URL {
const arena = exec.arena; const arena = page.arena;
const context_url = exec.url.*;
if (std.mem.eql(u8, url, "about:blank")) { if (std.mem.eql(u8, url, "about:blank")) {
return exec._factory.create(URL{ return page._factory.create(URL{
._raw = "about:blank", ._raw = "about:blank",
._arena = arena, ._arena = arena,
}); });
@@ -50,9 +48,9 @@ pub fn init(url: [:0]const u8, base_: ?[:0]const u8, exec: *const Execution) !*U
const url_is_absolute = @import("../URL.zig").isCompleteHTTPUrl(url); const url_is_absolute = @import("../URL.zig").isCompleteHTTPUrl(url);
const base = if (base_) |b| blk: { const base = if (base_) |b| blk: {
// If URL is absolute, base is ignored (but we still use context url internally) // If URL is absolute, base is ignored (but we still use page.url internally)
if (url_is_absolute) { if (url_is_absolute) {
break :blk context_url; break :blk page.url;
} }
// For relative URLs, base must be a valid absolute URL // For relative URLs, base must be a valid absolute URL
if (!@import("../URL.zig").isCompleteHTTPUrl(b)) { if (!@import("../URL.zig").isCompleteHTTPUrl(b)) {
@@ -61,11 +59,11 @@ pub fn init(url: [:0]const u8, base_: ?[:0]const u8, exec: *const Execution) !*U
break :blk b; break :blk b;
} else if (!url_is_absolute) { } else if (!url_is_absolute) {
return error.TypeError; return error.TypeError;
} else context_url; } else page.url;
const raw = try resolve(arena, base, url, .{ .always_dupe = true }); const raw = try resolve(arena, base, url, .{ .always_dupe = true });
return exec._factory.create(URL{ return page._factory.create(URL{
._raw = raw, ._raw = raw,
._arena = arena, ._arena = arena,
}); });
@@ -109,20 +107,20 @@ pub fn getPort(self: *const URL) []const u8 {
return U.getPort(self._raw); return U.getPort(self._raw);
} }
pub fn getOrigin(self: *const URL, exec: *const Execution) ![]const u8 { pub fn getOrigin(self: *const URL, page: *const Page) ![]const u8 {
return (try U.getOrigin(exec.call_arena, self._raw)) orelse { return (try U.getOrigin(page.call_arena, self._raw)) orelse {
// yes, a null string, that's what the spec wants // yes, a null string, that's what the spec wants
return "null"; return "null";
}; };
} }
pub fn getSearch(self: *const URL, exec: *const Execution) ![]const u8 { pub fn getSearch(self: *const URL, page: *const Page) ![]const u8 {
// If searchParams has been accessed, generate search from it // If searchParams has been accessed, generate search from it
if (self._search_params) |sp| { if (self._search_params) |sp| {
if (sp.getSize() == 0) { if (sp.getSize() == 0) {
return ""; return "";
} }
var buf = std.Io.Writer.Allocating.init(exec.call_arena); var buf = std.Io.Writer.Allocating.init(page.call_arena);
try buf.writer.writeByte('?'); try buf.writer.writeByte('?');
try sp.toString(&buf.writer); try sp.toString(&buf.writer);
return buf.written(); return buf.written();
@@ -134,30 +132,30 @@ pub fn getHash(self: *const URL) []const u8 {
return U.getHash(self._raw); return U.getHash(self._raw);
} }
pub fn getSearchParams(self: *URL, exec: *const Execution) !*URLSearchParams { pub fn getSearchParams(self: *URL, page: *Page) !*URLSearchParams {
if (self._search_params) |sp| { if (self._search_params) |sp| {
return sp; return sp;
} }
// Get current search string (without the '?') // Get current search string (without the '?')
const search = try self.getSearch(exec); const search = try self.getSearch(page);
const search_value = if (search.len > 0) search[1..] else ""; const search_value = if (search.len > 0) search[1..] else "";
const params = try URLSearchParams.init(.{ .query_string = search_value }, exec); const params = try URLSearchParams.init(.{ .query_string = search_value }, page);
self._search_params = params; self._search_params = params;
return params; return params;
} }
pub fn setHref(self: *URL, value: []const u8, exec: *const Execution) !void { pub fn setHref(self: *URL, value: []const u8, page: *Page) !void {
const base = if (U.isCompleteHTTPUrl(value)) exec.url.* else self._raw; const base = if (U.isCompleteHTTPUrl(value)) page.url else self._raw;
const raw = try U.resolve(self._arena orelse exec.arena, base, value, .{ .always_dupe = true }); const raw = try U.resolve(self._arena orelse page.arena, base, value, .{ .always_dupe = true });
self._raw = raw; self._raw = raw;
// Update existing searchParams if it exists // Update existing searchParams if it exists
if (self._search_params) |sp| { if (self._search_params) |sp| {
const search = U.getSearch(raw); const search = U.getSearch(raw);
const search_value = if (search.len > 0) search[1..] else ""; const search_value = if (search.len > 0) search[1..] else "";
try sp.updateFromString(search_value, exec); try sp.updateFromString(search_value, page);
} }
} }
@@ -186,7 +184,7 @@ pub fn setPathname(self: *URL, value: []const u8) !void {
self._raw = try U.setPathname(self._raw, value, allocator); self._raw = try U.setPathname(self._raw, value, allocator);
} }
pub fn setSearch(self: *URL, value: []const u8, exec: *const Execution) !void { pub fn setSearch(self: *URL, value: []const u8, page: *Page) !void {
const allocator = self._arena orelse return error.NoAllocator; const allocator = self._arena orelse return error.NoAllocator;
self._raw = try U.setSearch(self._raw, value, allocator); self._raw = try U.setSearch(self._raw, value, allocator);
@@ -194,7 +192,7 @@ pub fn setSearch(self: *URL, value: []const u8, exec: *const Execution) !void {
if (self._search_params) |sp| { if (self._search_params) |sp| {
const search = U.getSearch(self._raw); const search = U.getSearch(self._raw);
const search_value = if (search.len > 0) search[1..] else ""; const search_value = if (search.len > 0) search[1..] else "";
try sp.updateFromString(search_value, exec); try sp.updateFromString(search_value, page);
} }
} }
@@ -203,7 +201,7 @@ pub fn setHash(self: *URL, value: []const u8) !void {
self._raw = try U.setHash(self._raw, value, allocator); self._raw = try U.setHash(self._raw, value, allocator);
} }
pub fn toString(self: *const URL, exec: *const Execution) ![:0]const u8 { pub fn toString(self: *const URL, page: *const Page) ![:0]const u8 {
const sp = self._search_params orelse { const sp = self._search_params orelse {
return self._raw; return self._raw;
}; };
@@ -219,7 +217,7 @@ pub fn toString(self: *const URL, exec: *const Execution) ![:0]const u8 {
const hash = self.getHash(); const hash = self.getHash();
// Build the new URL string // Build the new URL string
var buf = std.Io.Writer.Allocating.init(exec.call_arena); var buf = std.Io.Writer.Allocating.init(page.call_arena);
try buf.writer.writeAll(base); try buf.writer.writeAll(base);
// Add / if missing (e.g., "https://example.com" -> "https://example.com/") // Add / if missing (e.g., "https://example.com" -> "https://example.com/")

View File

@@ -411,7 +411,7 @@ pub fn postMessage(self: *Window, message: js.Value.Temp, target_origin: ?[]cons
errdefer target_page.releaseArena(arena); errdefer target_page.releaseArena(arena);
// Origin should be the source window's origin (where the message came from) // Origin should be the source window's origin (where the message came from)
const origin = try source_window._location.getOrigin(&page.js.execution); const origin = try source_window._location.getOrigin(page);
const callback = try arena.create(PostMessageCallback); const callback = try arena.create(PostMessageCallback);
callback.* = .{ callback.* = .{
.arena = arena, .arena = arena,
@@ -429,11 +429,27 @@ pub fn postMessage(self: *Window, message: js.Value.Temp, target_origin: ?[]cons
} }
pub fn btoa(_: *const Window, input: []const u8, page: *Page) ![]const u8 { pub fn btoa(_: *const Window, input: []const u8, page: *Page) ![]const u8 {
return @import("encoding/base64.zig").encode(page.call_arena, input); const encoded_len = std.base64.standard.Encoder.calcSize(input.len);
const encoded = try page.call_arena.alloc(u8, encoded_len);
return std.base64.standard.Encoder.encode(encoded, input);
} }
pub fn atob(_: *const Window, input: []const u8, page: *Page) ![]const u8 { pub fn atob(_: *const Window, input: []const u8, page: *Page) ![]const u8 {
return @import("encoding/base64.zig").decode(page.call_arena, input); const trimmed = std.mem.trim(u8, input, &std.ascii.whitespace);
// Forgiving base64 decode per WHATWG spec:
// https://infra.spec.whatwg.org/#forgiving-base64-decode
// Remove trailing padding to use standard_no_pad decoder
const unpadded = std.mem.trimRight(u8, trimmed, "=");
// Length % 4 == 1 is invalid (can't represent valid base64)
if (unpadded.len % 4 == 1) {
return error.InvalidCharacterError;
}
const decoded_len = std.base64.standard_no_pad.Decoder.calcSizeForSlice(unpadded) catch return error.InvalidCharacterError;
const decoded = try page.call_arena.alloc(u8, decoded_len);
std.base64.standard_no_pad.Decoder.decode(decoded, unpadded) catch return error.InvalidCharacterError;
return decoded;
} }
pub fn structuredClone(_: *const Window, value: js.Value) !js.Value { pub fn structuredClone(_: *const Window, value: js.Value) !js.Value {

View File

@@ -1,154 +0,0 @@
// Copyright (C) 2023-2026 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 JS = @import("../js/js.zig");
const Console = @import("Console.zig");
const Crypto = @import("Crypto.zig");
const EventTarget = @import("EventTarget.zig");
const Factory = @import("../Factory.zig");
const Performance = @import("Performance.zig");
const Session = @import("../Session.zig");
const Allocator = std.mem.Allocator;
const WorkerGlobalScope = @This();
// Infrastructure fields (similar to Page)
_session: *Session,
_factory: *Factory,
arena: Allocator,
url: [:0]const u8,
buf: [1024]u8 = undefined, // same size as page.buf
js: *JS.Context = undefined,
// WebAPI fields
_proto: *EventTarget,
_console: Console = .init,
_crypto: Crypto = .init,
_performance: Performance,
_on_error: ?JS.Function.Global = null,
_on_rejection_handled: ?JS.Function.Global = null,
_on_unhandled_rejection: ?JS.Function.Global = null,
pub fn base(self: *const WorkerGlobalScope) [:0]const u8 {
return self.url;
}
pub fn asEventTarget(self: *WorkerGlobalScope) *EventTarget {
return self._proto;
}
pub fn getSelf(self: *WorkerGlobalScope) *WorkerGlobalScope {
return self;
}
pub fn getConsole(self: *WorkerGlobalScope) *Console {
return &self._console;
}
pub fn getCrypto(self: *WorkerGlobalScope) *Crypto {
return &self._crypto;
}
pub fn getPerformance(self: *WorkerGlobalScope) *Performance {
return &self._performance;
}
pub fn getOnError(self: *const WorkerGlobalScope) ?JS.Function.Global {
return self._on_error;
}
pub fn setOnError(self: *WorkerGlobalScope, setter: ?FunctionSetter) void {
self._on_error = getFunctionFromSetter(setter);
}
pub fn getOnRejectionHandled(self: *const WorkerGlobalScope) ?JS.Function.Global {
return self._on_rejection_handled;
}
pub fn setOnRejectionHandled(self: *WorkerGlobalScope, setter: ?FunctionSetter) void {
self._on_rejection_handled = getFunctionFromSetter(setter);
}
pub fn getOnUnhandledRejection(self: *const WorkerGlobalScope) ?JS.Function.Global {
return self._on_unhandled_rejection;
}
pub fn setOnUnhandledRejection(self: *WorkerGlobalScope, setter: ?FunctionSetter) void {
self._on_unhandled_rejection = getFunctionFromSetter(setter);
}
pub fn btoa(_: *const WorkerGlobalScope, input: []const u8, exec: *JS.Execution) ![]const u8 {
const base64 = @import("encoding/base64.zig");
return base64.encode(exec.call_arena, input);
}
pub fn atob(_: *const WorkerGlobalScope, input: []const u8, exec: *JS.Execution) ![]const u8 {
const base64 = @import("encoding/base64.zig");
return base64.decode(exec.call_arena, input);
}
pub fn structuredClone(_: *const WorkerGlobalScope, value: JS.Value) !JS.Value {
return value.structuredClone();
}
// TODO: importScripts - needs script loading infrastructure
// TODO: location - needs WorkerLocation
// TODO: navigator - needs WorkerNavigator
// TODO: Timer functions - need scheduler integration
const FunctionSetter = union(enum) {
func: JS.Function.Global,
anything: JS.Value,
};
fn getFunctionFromSetter(setter_: ?FunctionSetter) ?JS.Function.Global {
const setter = setter_ orelse return null;
return switch (setter) {
.func => |func| func,
.anything => null,
};
}
pub const JsApi = struct {
pub const bridge = JS.Bridge(WorkerGlobalScope);
pub const Meta = struct {
pub const name = "WorkerGlobalScope";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const self = bridge.accessor(WorkerGlobalScope.getSelf, null, .{});
pub const console = bridge.accessor(WorkerGlobalScope.getConsole, null, .{});
pub const crypto = bridge.accessor(WorkerGlobalScope.getCrypto, null, .{});
pub const performance = bridge.accessor(WorkerGlobalScope.getPerformance, null, .{});
pub const onerror = bridge.accessor(WorkerGlobalScope.getOnError, WorkerGlobalScope.setOnError, .{});
pub const onrejectionhandled = bridge.accessor(WorkerGlobalScope.getOnRejectionHandled, WorkerGlobalScope.setOnRejectionHandled, .{});
pub const onunhandledrejection = bridge.accessor(WorkerGlobalScope.getOnUnhandledRejection, WorkerGlobalScope.setOnUnhandledRejection, .{});
pub const btoa = bridge.function(WorkerGlobalScope.btoa, .{});
pub const atob = bridge.function(WorkerGlobalScope.atob, .{ .dom_exception = true });
pub const structuredClone = bridge.function(WorkerGlobalScope.structuredClone, .{});
// Return false since workers don't have secure-context-only APIs
pub const isSecureContext = bridge.property(false, .{ .template = false });
};

View File

@@ -18,7 +18,6 @@
const std = @import("std"); const std = @import("std");
const js = @import("../../js/js.zig");
const Node = @import("../Node.zig"); const Node = @import("../Node.zig");
const Page = @import("../../Page.zig"); const Page = @import("../../Page.zig");
const Session = @import("../../Session.zig"); const Session = @import("../../Session.zig");
@@ -137,7 +136,7 @@ const Iterator = struct {
const Entry = struct { u32, *Node }; const Entry = struct { u32, *Node };
pub fn next(self: *Iterator, page: *const Page) !?Entry { pub fn next(self: *Iterator, page: *Page) !?Entry {
const index = self.index; const index = self.index;
const node = try self.list.getAtIndex(index, page) orelse return null; const node = try self.list.getAtIndex(index, page) orelse return null;
self.index = index + 1; self.index = index + 1;

View File

@@ -43,7 +43,7 @@ const Lookup = std.StringArrayHashMapUnmanaged(void);
const WHITESPACE = " \t\n\r\x0C"; const WHITESPACE = " \t\n\r\x0C";
pub fn length(self: *const DOMTokenList, page: *Page) !u32 { pub fn length(self: *const DOMTokenList, page: *Page) !u32 {
const tokens = try self.getTokens(page.call_arena); const tokens = try self.getTokens(page);
return @intCast(tokens.count()); return @intCast(tokens.count());
} }
@@ -82,8 +82,8 @@ pub fn add(self: *DOMTokenList, tokens: []const []const u8, page: *Page) !void {
try validateToken(token); try validateToken(token);
} }
var lookup = try self.getTokens(page);
const allocator = page.call_arena; const allocator = page.call_arena;
var lookup = try self.getTokens(allocator);
try lookup.ensureUnusedCapacity(allocator, tokens.len); try lookup.ensureUnusedCapacity(allocator, tokens.len);
for (tokens) |token| { for (tokens) |token| {
@@ -98,7 +98,7 @@ pub fn remove(self: *DOMTokenList, tokens: []const []const u8, page: *Page) !voi
try validateToken(token); try validateToken(token);
} }
var lookup = try self.getTokens(page.call_arena); var lookup = try self.getTokens(page);
for (tokens) |token| { for (tokens) |token| {
_ = lookup.orderedRemove(token); _ = lookup.orderedRemove(token);
} }
@@ -149,8 +149,7 @@ pub fn replace(self: *DOMTokenList, old_token: []const u8, new_token: []const u8
return error.InvalidCharacterError; return error.InvalidCharacterError;
} }
const allocator = page.call_arena; var lookup = try self.getTokens(page);
var lookup = try self.getTokens(page.call_arena);
// Check if old_token exists // Check if old_token exists
if (!lookup.contains(old_token)) { if (!lookup.contains(old_token)) {
@@ -163,6 +162,7 @@ pub fn replace(self: *DOMTokenList, old_token: []const u8, new_token: []const u8
return true; return true;
} }
const allocator = page.call_arena;
// Build new token list preserving order but replacing old with new // Build new token list preserving order but replacing old with new
var new_tokens = try std.ArrayList([]const u8).initCapacity(allocator, lookup.count()); var new_tokens = try std.ArrayList([]const u8).initCapacity(allocator, lookup.count());
var replaced_old = false; var replaced_old = false;
@@ -237,13 +237,14 @@ pub fn forEach(self: *DOMTokenList, cb_: js.Function, js_this_: ?js.Object, page
} }
} }
fn getTokens(self: *const DOMTokenList, allocator: std.mem.Allocator) !Lookup { fn getTokens(self: *const DOMTokenList, page: *Page) !Lookup {
const value = self.getValue(); const value = self.getValue();
if (value.len == 0) { if (value.len == 0) {
return .empty; return .empty;
} }
var list: Lookup = .empty; var list: Lookup = .empty;
const allocator = page.call_arena;
try list.ensureTotalCapacity(allocator, 4); try list.ensureTotalCapacity(allocator, 4);
var it = std.mem.tokenizeAny(u8, value, WHITESPACE); var it = std.mem.tokenizeAny(u8, value, WHITESPACE);

View File

@@ -24,7 +24,6 @@ const Page = @import("../../Page.zig");
const Node = @import("../Node.zig"); const Node = @import("../Node.zig");
const Element = @import("../Element.zig"); const Element = @import("../Element.zig");
const TreeWalker = @import("../TreeWalker.zig"); const TreeWalker = @import("../TreeWalker.zig");
const Execution = js.Execution;
const HTMLAllCollection = @This(); const HTMLAllCollection = @This();
@@ -134,11 +133,11 @@ pub fn callable(self: *HTMLAllCollection, arg: CAllAsFunctionArg, page: *Page) ?
}; };
} }
pub fn iterator(self: *HTMLAllCollection, exec: *const Execution) !*Iterator { pub fn iterator(self: *HTMLAllCollection, page: *Page) !*Iterator {
return Iterator.init(.{ return Iterator.init(.{
.list = self, .list = self,
.tw = self._tw.clone(), .tw = self._tw.clone(),
}, exec); }, page);
} }
const GenericIterator = @import("iterator.zig").Entry; const GenericIterator = @import("iterator.zig").Entry;
@@ -146,7 +145,7 @@ pub const Iterator = GenericIterator(struct {
list: *HTMLAllCollection, list: *HTMLAllCollection,
tw: TreeWalker.FullExcludeSelf, tw: TreeWalker.FullExcludeSelf,
pub fn next(self: *@This(), _: *const Execution) ?*Element { pub fn next(self: *@This(), _: *Page) ?*Element {
while (self.tw.next()) |node| { while (self.tw.next()) |node| {
if (node.is(Element)) |el| { if (node.is(Element)) |el| {
return el; return el;

View File

@@ -23,7 +23,6 @@ const Page = @import("../../Page.zig");
const Element = @import("../Element.zig"); const Element = @import("../Element.zig");
const TreeWalker = @import("../TreeWalker.zig"); const TreeWalker = @import("../TreeWalker.zig");
const NodeLive = @import("node_live.zig").NodeLive; const NodeLive = @import("node_live.zig").NodeLive;
const Execution = js.Execution;
const Mode = enum { const Mode = enum {
tag, tag,
@@ -78,7 +77,7 @@ pub fn getByName(self: *HTMLCollection, name: []const u8, page: *Page) ?*Element
}; };
} }
pub fn iterator(self: *HTMLCollection, exec: *const Execution) !*Iterator { pub fn iterator(self: *HTMLCollection, page: *Page) !*Iterator {
return Iterator.init(.{ return Iterator.init(.{
.list = self, .list = self,
.tw = switch (self._data) { .tw = switch (self._data) {
@@ -95,7 +94,7 @@ pub fn iterator(self: *HTMLCollection, exec: *const Execution) !*Iterator {
.form => |*impl| .{ .form = impl._tw.clone() }, .form => |*impl| .{ .form = impl._tw.clone() },
.empty => .empty, .empty => .empty,
}, },
}, exec); }, page);
} }
const GenericIterator = @import("iterator.zig").Entry; const GenericIterator = @import("iterator.zig").Entry;
@@ -116,7 +115,7 @@ pub const Iterator = GenericIterator(struct {
empty: void, empty: void,
}, },
pub fn next(self: *@This(), _: *const Execution) ?*Element { pub fn next(self: *@This(), _: *Page) ?*Element {
return switch (self.list._data) { return switch (self.list._data) {
.tag => |*impl| impl.nextTw(&self.tw.tag), .tag => |*impl| impl.nextTw(&self.tw.tag),
.tag_name => |*impl| impl.nextTw(&self.tw.tag_name), .tag_name => |*impl| impl.nextTw(&self.tw.tag_name),

View File

@@ -21,7 +21,6 @@ const lp = @import("lightpanda");
const js = @import("../../js/js.zig"); const js = @import("../../js/js.zig");
const Page = @import("../../Page.zig"); const Page = @import("../../Page.zig");
const Session = @import("../../Session.zig"); const Session = @import("../../Session.zig");
const Execution = js.Execution;
pub fn Entry(comptime Inner: type, comptime field: ?[]const u8) type { pub fn Entry(comptime Inner: type, comptime field: ?[]const u8) type {
const R = reflect(Inner, field); const R = reflect(Inner, field);
@@ -39,8 +38,8 @@ pub fn Entry(comptime Inner: type, comptime field: ?[]const u8) type {
pub const js_as_object = true; pub const js_as_object = true;
}; };
pub fn init(inner: Inner, executor: R.Executor) !*Self { pub fn init(inner: Inner, page: *Page) !*Self {
const self = try executor._factory.create(Self{ ._inner = inner }); const self = try page._factory.create(Self{ ._inner = inner });
if (@hasDecl(Inner, "acquireRef")) { if (@hasDecl(Inner, "acquireRef")) {
self._inner.acquireRef(); self._inner.acquireRef();
@@ -63,8 +62,8 @@ pub fn Entry(comptime Inner: type, comptime field: ?[]const u8) type {
self._rc.acquire(); self._rc.acquire();
} }
pub fn next(self: *Self, executor: R.Executor) if (R.has_error_return) anyerror!Result else Result { pub fn next(self: *Self, page: *Page) if (R.has_error_return) anyerror!Result else Result {
const entry = (if (comptime R.has_error_return) try self._inner.next(executor) else self._inner.next(executor)) orelse { const entry = (if (comptime R.has_error_return) try self._inner.next(page) else self._inner.next(page)) orelse {
return .{ .done = true, .value = null }; return .{ .done = true, .value = null };
}; };
@@ -93,22 +92,17 @@ pub fn Entry(comptime Inner: type, comptime field: ?[]const u8) type {
} }
fn reflect(comptime Inner: type, comptime field: ?[]const u8) Reflect { fn reflect(comptime Inner: type, comptime field: ?[]const u8) Reflect {
const fn_info = @typeInfo(@TypeOf(Inner.next)).@"fn"; const R = @typeInfo(@TypeOf(Inner.next)).@"fn".return_type.?;
const R = fn_info.return_type.?;
const has_error_return = @typeInfo(R) == .error_union; const has_error_return = @typeInfo(R) == .error_union;
// The executor type is the last parameter of inner.next (after self)
const Executor = fn_info.params[1].type.?;
return .{ return .{
.has_error_return = has_error_return, .has_error_return = has_error_return,
.ValueType = ValueType(unwrapOptional(unwrapError(R)), field), .ValueType = ValueType(unwrapOptional(unwrapError(R)), field),
.Executor = Executor,
}; };
} }
const Reflect = struct { const Reflect = struct {
has_error_return: bool, has_error_return: bool,
ValueType: type, ValueType: type,
Executor: type,
}; };
fn unwrapError(comptime T: type) type { fn unwrapError(comptime T: type) type {

View File

@@ -1,50 +0,0 @@
// Copyright (C) 2023-2026 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/>.
//! Base64 encoding/decoding helpers for btoa/atob.
//! Used by both Window and WorkerGlobalScope.
const std = @import("std");
const Allocator = std.mem.Allocator;
/// Encodes input to base64 (btoa).
pub fn encode(alloc: Allocator, input: []const u8) ![]const u8 {
const encoded_len = std.base64.standard.Encoder.calcSize(input.len);
const encoded = try alloc.alloc(u8, encoded_len);
return std.base64.standard.Encoder.encode(encoded, input);
}
/// Decodes base64 input (atob).
/// Implements forgiving base64 decode per WHATWG spec.
pub fn decode(alloc: Allocator, input: []const u8) ![]const u8 {
const trimmed = std.mem.trim(u8, input, &std.ascii.whitespace);
// Forgiving base64 decode per WHATWG spec:
// https://infra.spec.whatwg.org/#forgiving-base64-decode
// Remove trailing padding to use standard_no_pad decoder
const unpadded = std.mem.trimRight(u8, trimmed, "=");
// Length % 4 == 1 is invalid (can't represent valid base64)
if (unpadded.len % 4 == 1) {
return error.InvalidCharacterError;
}
const decoded_len = std.base64.standard_no_pad.Decoder.calcSizeForSlice(unpadded) catch return error.InvalidCharacterError;
const decoded = try alloc.alloc(u8, decoded_len);
std.base64.standard_no_pad.Decoder.decode(decoded, unpadded) catch return error.InvalidCharacterError;
return decoded;
}

View File

@@ -57,7 +57,7 @@ pub fn get(self: *const FormData, name: []const u8) ?[]const u8 {
} }
pub fn getAll(self: *const FormData, name: []const u8, page: *Page) ![]const []const u8 { pub fn getAll(self: *const FormData, name: []const u8, page: *Page) ![]const []const u8 {
return self._list.getAll(page.call_arena, name); return self._list.getAll(name, page);
} }
pub fn has(self: *const FormData, name: []const u8) bool { pub fn has(self: *const FormData, name: []const u8) bool {
@@ -76,16 +76,16 @@ pub fn delete(self: *FormData, name: []const u8) void {
self._list.delete(name, null); self._list.delete(name, null);
} }
pub fn keys(self: *FormData, exec: *const js.Execution) !*KeyValueList.KeyIterator { pub fn keys(self: *FormData, page: *Page) !*KeyValueList.KeyIterator {
return KeyValueList.KeyIterator.init(.{ .list = self, .kv = &self._list }, exec); return KeyValueList.KeyIterator.init(.{ .list = self, .kv = &self._list }, page);
} }
pub fn values(self: *FormData, exec: *const js.Execution) !*KeyValueList.ValueIterator { pub fn values(self: *FormData, page: *Page) !*KeyValueList.ValueIterator {
return KeyValueList.ValueIterator.init(.{ .list = self, .kv = &self._list }, exec); return KeyValueList.ValueIterator.init(.{ .list = self, .kv = &self._list }, page);
} }
pub fn entries(self: *FormData, exec: *const js.Execution) !*KeyValueList.EntryIterator { pub fn entries(self: *FormData, page: *Page) !*KeyValueList.EntryIterator {
return KeyValueList.EntryIterator.init(.{ .list = self, .kv = &self._list }, exec); return KeyValueList.EntryIterator.init(.{ .list = self, .kv = &self._list }, page);
} }
pub fn forEach(self: *FormData, cb_: js.Function, js_this_: ?js.Object) !void { pub fn forEach(self: *FormData, cb_: js.Function, js_this_: ?js.Object) !void {

View File

@@ -20,8 +20,8 @@ pub const InitOpts = union(enum) {
pub fn init(opts_: ?InitOpts, page: *Page) !*Headers { pub fn init(opts_: ?InitOpts, page: *Page) !*Headers {
const list = if (opts_) |opts| switch (opts) { const list = if (opts_) |opts| switch (opts) {
.obj => |obj| try KeyValueList.copy(page.arena, obj._list), .obj => |obj| try KeyValueList.copy(page.arena, obj._list),
.js_obj => |js_obj| try KeyValueList.fromJsObject(page.arena, js_obj, normalizeHeaderName, &page.buf), .js_obj => |js_obj| try KeyValueList.fromJsObject(page.arena, js_obj, normalizeHeaderName, page),
.strings => |kvs| try KeyValueList.fromArray(page.arena, kvs, normalizeHeaderName, &page.buf), .strings => |kvs| try KeyValueList.fromArray(page.arena, kvs, normalizeHeaderName, page),
} else KeyValueList.init(); } else KeyValueList.init();
return page._factory.create(Headers{ return page._factory.create(Headers{
@@ -30,18 +30,18 @@ pub fn init(opts_: ?InitOpts, page: *Page) !*Headers {
} }
pub fn append(self: *Headers, name: []const u8, value: []const u8, page: *Page) !void { pub fn append(self: *Headers, name: []const u8, value: []const u8, page: *Page) !void {
const normalized_name = normalizeHeaderName(name, &page.buf); const normalized_name = normalizeHeaderName(name, page);
try self._list.append(page.arena, normalized_name, value); try self._list.append(page.arena, normalized_name, value);
} }
pub fn delete(self: *Headers, name: []const u8, page: *Page) void { pub fn delete(self: *Headers, name: []const u8, page: *Page) void {
const normalized_name = normalizeHeaderName(name, &page.buf); const normalized_name = normalizeHeaderName(name, page);
self._list.delete(normalized_name, null); self._list.delete(normalized_name, null);
} }
pub fn get(self: *const Headers, name: []const u8, page: *Page) !?[]const u8 { pub fn get(self: *const Headers, name: []const u8, page: *Page) !?[]const u8 {
const normalized_name = normalizeHeaderName(name, &page.buf); const normalized_name = normalizeHeaderName(name, page);
const all_values = try self._list.getAll(page.call_arena, normalized_name); const all_values = try self._list.getAll(normalized_name, page);
if (all_values.len == 0) { if (all_values.len == 0) {
return null; return null;
@@ -53,25 +53,25 @@ pub fn get(self: *const Headers, name: []const u8, page: *Page) !?[]const u8 {
} }
pub fn has(self: *const Headers, name: []const u8, page: *Page) bool { pub fn has(self: *const Headers, name: []const u8, page: *Page) bool {
const normalized_name = normalizeHeaderName(name, &page.buf); const normalized_name = normalizeHeaderName(name, page);
return self._list.has(normalized_name); return self._list.has(normalized_name);
} }
pub fn set(self: *Headers, name: []const u8, value: []const u8, page: *Page) !void { pub fn set(self: *Headers, name: []const u8, value: []const u8, page: *Page) !void {
const normalized_name = normalizeHeaderName(name, &page.buf); const normalized_name = normalizeHeaderName(name, page);
try self._list.set(page.arena, normalized_name, value); try self._list.set(page.arena, normalized_name, value);
} }
pub fn keys(self: *Headers, exec: *const js.Execution) !*KeyValueList.KeyIterator { pub fn keys(self: *Headers, page: *Page) !*KeyValueList.KeyIterator {
return KeyValueList.KeyIterator.init(.{ .list = self, .kv = &self._list }, exec); return KeyValueList.KeyIterator.init(.{ .list = self, .kv = &self._list }, page);
} }
pub fn values(self: *Headers, exec: *const js.Execution) !*KeyValueList.ValueIterator { pub fn values(self: *Headers, page: *Page) !*KeyValueList.ValueIterator {
return KeyValueList.ValueIterator.init(.{ .list = self, .kv = &self._list }, exec); return KeyValueList.ValueIterator.init(.{ .list = self, .kv = &self._list }, page);
} }
pub fn entries(self: *Headers, exec: *const js.Execution) !*KeyValueList.EntryIterator { pub fn entries(self: *Headers, page: *Page) !*KeyValueList.EntryIterator {
return KeyValueList.EntryIterator.init(.{ .list = self, .kv = &self._list }, exec); return KeyValueList.EntryIterator.init(.{ .list = self, .kv = &self._list }, page);
} }
pub fn forEach(self: *Headers, cb_: js.Function, js_this_: ?js.Object) !void { pub fn forEach(self: *Headers, cb_: js.Function, js_this_: ?js.Object) !void {
@@ -94,11 +94,11 @@ pub fn populateHttpHeader(self: *Headers, allocator: Allocator, http_headers: *h
} }
} }
fn normalizeHeaderName(name: []const u8, buf: []u8) []const u8 { fn normalizeHeaderName(name: []const u8, page: *Page) []const u8 {
if (name.len > buf.len) { if (name.len > page.buf.len) {
return name; return name;
} }
return std.ascii.lowerString(buf, name); return std.ascii.lowerString(&page.buf, name);
} }
pub const JsApi = struct { pub const JsApi = struct {

View File

@@ -26,7 +26,6 @@ const Allocator = std.mem.Allocator;
const Page = @import("../../Page.zig"); const Page = @import("../../Page.zig");
const FormData = @import("FormData.zig"); const FormData = @import("FormData.zig");
const KeyValueList = @import("../KeyValueList.zig"); const KeyValueList = @import("../KeyValueList.zig");
const Execution = js.Execution;
const URLSearchParams = @This(); const URLSearchParams = @This();
@@ -39,12 +38,12 @@ const InitOpts = union(enum) {
query_string: []const u8, query_string: []const u8,
}; };
pub fn init(opts_: ?InitOpts, exec: *const Execution) !*URLSearchParams { pub fn init(opts_: ?InitOpts, page: *Page) !*URLSearchParams {
const arena = exec.arena; const arena = page.arena;
const params: KeyValueList = blk: { const params: KeyValueList = blk: {
const opts = opts_ orelse break :blk .empty; const opts = opts_ orelse break :blk .empty;
switch (opts) { switch (opts) {
.query_string => |qs| break :blk try paramsFromString(arena, qs, exec.buf), .query_string => |qs| break :blk try paramsFromString(arena, qs, &page.buf),
.form_data => |fd| break :blk try KeyValueList.copy(arena, fd._list), .form_data => |fd| break :blk try KeyValueList.copy(arena, fd._list),
.value => |js_val| { .value => |js_val| {
// Order matters here; Array is also an Object. // Order matters here; Array is also an Object.
@@ -52,25 +51,24 @@ pub fn init(opts_: ?InitOpts, exec: *const Execution) !*URLSearchParams {
break :blk try paramsFromArray(arena, js_val.toArray()); break :blk try paramsFromArray(arena, js_val.toArray());
} }
if (js_val.isObject()) { if (js_val.isObject()) {
// normalizer is null, so page won't be used break :blk try KeyValueList.fromJsObject(arena, js_val.toObject(), null, page);
break :blk try KeyValueList.fromJsObject(arena, js_val.toObject(), null, exec.buf);
} }
if (js_val.isString()) |js_str| { if (js_val.isString()) |js_str| {
break :blk try paramsFromString(arena, try js_str.toSliceWithAlloc(arena), exec.buf); break :blk try paramsFromString(arena, try js_str.toSliceWithAlloc(arena), &page.buf);
} }
return error.InvalidArgument; return error.InvalidArgument;
}, },
} }
}; };
return exec._factory.create(URLSearchParams{ return page._factory.create(URLSearchParams{
._arena = arena, ._arena = arena,
._params = params, ._params = params,
}); });
} }
pub fn updateFromString(self: *URLSearchParams, query_string: []const u8, exec: *const Execution) !void { pub fn updateFromString(self: *URLSearchParams, query_string: []const u8, page: *Page) !void {
self._params = try paramsFromString(self._arena, query_string, exec.buf); self._params = try paramsFromString(self._arena, query_string, &page.buf);
} }
pub fn getSize(self: *const URLSearchParams) usize { pub fn getSize(self: *const URLSearchParams) usize {
@@ -81,8 +79,8 @@ pub fn get(self: *const URLSearchParams, name: []const u8) ?[]const u8 {
return self._params.get(name); return self._params.get(name);
} }
pub fn getAll(self: *const URLSearchParams, name: []const u8, exec: *const Execution) ![]const []const u8 { pub fn getAll(self: *const URLSearchParams, name: []const u8, page: *Page) ![]const []const u8 {
return self._params.getAll(exec.call_arena, name); return self._params.getAll(name, page);
} }
pub fn has(self: *const URLSearchParams, name: []const u8) bool { pub fn has(self: *const URLSearchParams, name: []const u8) bool {
@@ -101,16 +99,16 @@ pub fn delete(self: *URLSearchParams, name: []const u8, value: ?[]const u8) void
self._params.delete(name, value); self._params.delete(name, value);
} }
pub fn keys(self: *URLSearchParams, exec: *const Execution) !*KeyValueList.KeyIterator { pub fn keys(self: *URLSearchParams, page: *Page) !*KeyValueList.KeyIterator {
return KeyValueList.KeyIterator.init(.{ .list = self, .kv = &self._params }, exec); return KeyValueList.KeyIterator.init(.{ .list = self, .kv = &self._params }, page);
} }
pub fn values(self: *URLSearchParams, exec: *const Execution) !*KeyValueList.ValueIterator { pub fn values(self: *URLSearchParams, page: *Page) !*KeyValueList.ValueIterator {
return KeyValueList.ValueIterator.init(.{ .list = self, .kv = &self._params }, exec); return KeyValueList.ValueIterator.init(.{ .list = self, .kv = &self._params }, page);
} }
pub fn entries(self: *URLSearchParams, exec: *const Execution) !*KeyValueList.EntryIterator { pub fn entries(self: *URLSearchParams, page: *Page) !*KeyValueList.EntryIterator {
return KeyValueList.EntryIterator.init(.{ .list = self, .kv = &self._params }, exec); return KeyValueList.EntryIterator.init(.{ .list = self, .kv = &self._params }, page);
} }
pub fn toString(self: *const URLSearchParams, writer: *std.Io.Writer) !void { pub fn toString(self: *const URLSearchParams, writer: *std.Io.Writer) !void {
@@ -316,7 +314,7 @@ pub const Iterator = struct {
const Entry = struct { []const u8, []const u8 }; const Entry = struct { []const u8, []const u8 };
pub fn next(self: *Iterator, _: *const Execution) !?Iterator.Entry { pub fn next(self: *Iterator, _: *Page) !?Iterator.Entry {
const index = self.index; const index = self.index;
const items = self.list._params.items; const items = self.list._params.items;
if (index >= items.len) { if (index >= items.len) {
@@ -354,8 +352,8 @@ pub const JsApi = struct {
pub const sort = bridge.function(URLSearchParams.sort, .{}); pub const sort = bridge.function(URLSearchParams.sort, .{});
pub const toString = bridge.function(_toString, .{}); pub const toString = bridge.function(_toString, .{});
fn _toString(self: *const URLSearchParams, exec: *const Execution) ![]const u8 { fn _toString(self: *const URLSearchParams, page: *Page) ![]const u8 {
var buf = std.Io.Writer.Allocating.init(exec.call_arena); var buf = std.Io.Writer.Allocating.init(page.call_arena);
try self.toString(&buf.writer); try self.toString(&buf.writer);
return buf.written(); return buf.written();
} }

View File

@@ -175,6 +175,74 @@ pub const tool_list = [_]protocol.Tool{
\\} \\}
), ),
}, },
.{
.name = "hover",
.description = "Hover over an element, triggering mouseover and mouseenter events. Useful for menus, tooltips, and hover states.",
.inputSchema = protocol.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "backendNodeId": { "type": "integer", "description": "The backend node ID of the element to hover over." }
\\ },
\\ "required": ["backendNodeId"]
\\}
),
},
.{
.name = "press",
.description = "Press a keyboard key, dispatching keydown and keyup events. Use key names like 'Enter', 'Tab', 'Escape', 'ArrowDown', 'Backspace', or single characters like 'a', '1'.",
.inputSchema = protocol.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "key": { "type": "string", "description": "The key to press (e.g. 'Enter', 'Tab', 'a')." },
\\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID of the element to target. Defaults to the document." }
\\ },
\\ "required": ["key"]
\\}
),
},
.{
.name = "selectOption",
.description = "Select an option in a <select> dropdown element by its value. Dispatches input and change events.",
.inputSchema = protocol.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "backendNodeId": { "type": "integer", "description": "The backend node ID of the <select> element." },
\\ "value": { "type": "string", "description": "The value of the option to select." }
\\ },
\\ "required": ["backendNodeId", "value"]
\\}
),
},
.{
.name = "setChecked",
.description = "Check or uncheck a checkbox or radio button. Dispatches input, change, and click events.",
.inputSchema = protocol.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "backendNodeId": { "type": "integer", "description": "The backend node ID of the checkbox or radio input element." },
\\ "checked": { "type": "boolean", "description": "Whether to check (true) or uncheck (false) the element." }
\\ },
\\ "required": ["backendNodeId", "checked"]
\\}
),
},
.{
.name = "findElement",
.description = "Find interactive elements by role and/or accessible name. Returns matching elements with their backend node IDs. Useful for locating specific elements without parsing the full semantic tree.",
.inputSchema = protocol.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "role": { "type": "string", "description": "Optional ARIA role to match (e.g. 'button', 'link', 'textbox', 'checkbox')." },
\\ "name": { "type": "string", "description": "Optional accessible name substring to match (case-insensitive)." }
\\ }
\\}
),
},
}; };
pub fn handleList(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void { pub fn handleList(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void {
@@ -282,6 +350,11 @@ const ToolAction = enum {
fill, fill,
scroll, scroll,
waitForSelector, waitForSelector,
hover,
press,
selectOption,
setChecked,
findElement,
}; };
const tool_map = std.StaticStringMap(ToolAction).initComptime(.{ const tool_map = std.StaticStringMap(ToolAction).initComptime(.{
@@ -300,6 +373,11 @@ const tool_map = std.StaticStringMap(ToolAction).initComptime(.{
.{ "fill", .fill }, .{ "fill", .fill },
.{ "scroll", .scroll }, .{ "scroll", .scroll },
.{ "waitForSelector", .waitForSelector }, .{ "waitForSelector", .waitForSelector },
.{ "hover", .hover },
.{ "press", .press },
.{ "selectOption", .selectOption },
.{ "setChecked", .setChecked },
.{ "findElement", .findElement },
}); });
pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void { pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void {
@@ -334,6 +412,11 @@ pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Reque
.fill => try handleFill(server, arena, req.id.?, call_params.arguments), .fill => try handleFill(server, arena, req.id.?, call_params.arguments),
.scroll => try handleScroll(server, arena, req.id.?, call_params.arguments), .scroll => try handleScroll(server, arena, req.id.?, call_params.arguments),
.waitForSelector => try handleWaitForSelector(server, arena, req.id.?, call_params.arguments), .waitForSelector => try handleWaitForSelector(server, arena, req.id.?, call_params.arguments),
.hover => try handleHover(server, arena, req.id.?, call_params.arguments),
.press => try handlePress(server, arena, req.id.?, call_params.arguments),
.selectOption => try handleSelectOption(server, arena, req.id.?, call_params.arguments),
.setChecked => try handleSetChecked(server, arena, req.id.?, call_params.arguments),
.findElement => try handleFindElement(server, arena, req.id.?, call_params.arguments),
} }
} }
@@ -400,17 +483,9 @@ fn handleNodeDetails(server: *Server, arena: std.mem.Allocator, id: std.json.Val
backendNodeId: CDPNode.Id, backendNodeId: CDPNode.Id,
}; };
const args = try parseArgs(Params, arena, arguments, server, id, "nodeDetails"); const args = try parseArgs(Params, arena, arguments, server, id, "nodeDetails");
const resolved = try resolveNodeAndPage(server, id, args.backendNodeId);
_ = server.session.currentPage() orelse { const details = lp.SemanticTree.getNodeDetails(arena, resolved.node, &server.node_registry, resolved.page) catch {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
const node = server.node_registry.lookup_by_id.get(args.backendNodeId) orelse {
return server.sendError(id, .InvalidParams, "Node not found");
};
const page = server.session.currentPage().?;
const details = lp.SemanticTree.getNodeDetails(arena, node.dom, &server.node_registry, page) catch {
return server.sendError(id, .InternalError, "Failed to get node details"); return server.sendError(id, .InternalError, "Failed to get node details");
}; };
@@ -510,26 +585,19 @@ fn handleClick(server: *Server, arena: std.mem.Allocator, id: std.json.Value, ar
backendNodeId: CDPNode.Id, backendNodeId: CDPNode.Id,
}; };
const args = try parseArgs(ClickParams, arena, arguments, server, id, "click"); const args = try parseArgs(ClickParams, arena, arguments, server, id, "click");
const resolved = try resolveNodeAndPage(server, id, args.backendNodeId);
const page = server.session.currentPage() orelse { lp.actions.click(resolved.node, resolved.page) catch |err| {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
const node = server.node_registry.lookup_by_id.get(args.backendNodeId) orelse {
return server.sendError(id, .InvalidParams, "Node not found");
};
lp.actions.click(node.dom, page) catch |err| {
if (err == error.InvalidNodeType) { if (err == error.InvalidNodeType) {
return server.sendError(id, .InvalidParams, "Node is not an HTML element"); return server.sendError(id, .InvalidParams, "Node is not an HTML element");
} }
return server.sendError(id, .InternalError, "Failed to click element"); return server.sendError(id, .InternalError, "Failed to click element");
}; };
const page_title = page.getTitle() catch null; const page_title = resolved.page.getTitle() catch null;
const result_text = try std.fmt.allocPrint(arena, "Clicked element (backendNodeId: {d}). Page url: {s}, title: {s}", .{ const result_text = try std.fmt.allocPrint(arena, "Clicked element (backendNodeId: {d}). Page url: {s}, title: {s}", .{
args.backendNodeId, args.backendNodeId,
page.url, resolved.page.url,
page_title orelse "(none)", page_title orelse "(none)",
}); });
const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }}; const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }};
@@ -542,27 +610,20 @@ fn handleFill(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arg
text: []const u8, text: []const u8,
}; };
const args = try parseArgs(FillParams, arena, arguments, server, id, "fill"); const args = try parseArgs(FillParams, arena, arguments, server, id, "fill");
const resolved = try resolveNodeAndPage(server, id, args.backendNodeId);
const page = server.session.currentPage() orelse { lp.actions.fill(resolved.node, args.text, resolved.page) catch |err| {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
const node = server.node_registry.lookup_by_id.get(args.backendNodeId) orelse {
return server.sendError(id, .InvalidParams, "Node not found");
};
lp.actions.fill(node.dom, args.text, page) catch |err| {
if (err == error.InvalidNodeType) { if (err == error.InvalidNodeType) {
return server.sendError(id, .InvalidParams, "Node is not an input, textarea or select"); return server.sendError(id, .InvalidParams, "Node is not an input, textarea or select");
} }
return server.sendError(id, .InternalError, "Failed to fill element"); return server.sendError(id, .InternalError, "Failed to fill element");
}; };
const page_title = page.getTitle() catch null; const page_title = resolved.page.getTitle() catch null;
const result_text = try std.fmt.allocPrint(arena, "Filled element (backendNodeId: {d}) with \"{s}\". Page url: {s}, title: {s}", .{ const result_text = try std.fmt.allocPrint(arena, "Filled element (backendNodeId: {d}) with \"{s}\". Page url: {s}, title: {s}", .{
args.backendNodeId, args.backendNodeId,
args.text, args.text,
page.url, resolved.page.url,
page_title orelse "(none)", page_title orelse "(none)",
}); });
const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }}; const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }};
@@ -636,6 +697,189 @@ fn handleWaitForSelector(server: *Server, arena: std.mem.Allocator, id: std.json
return server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); return server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
} }
fn handleHover(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const Params = struct {
backendNodeId: CDPNode.Id,
};
const args = try parseArgs(Params, arena, arguments, server, id, "hover");
const resolved = try resolveNodeAndPage(server, id, args.backendNodeId);
lp.actions.hover(resolved.node, resolved.page) catch |err| {
if (err == error.InvalidNodeType) {
return server.sendError(id, .InvalidParams, "Node is not an HTML element");
}
return server.sendError(id, .InternalError, "Failed to hover element");
};
const page_title = resolved.page.getTitle() catch null;
const result_text = try std.fmt.allocPrint(arena, "Hovered element (backendNodeId: {d}). Page url: {s}, title: {s}", .{
args.backendNodeId,
resolved.page.url,
page_title orelse "(none)",
});
const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }};
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
fn handlePress(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const Params = struct {
key: []const u8,
backendNodeId: ?CDPNode.Id = null,
};
const args = try parseArgs(Params, arena, arguments, server, id, "press");
const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
var target_node: ?*DOMNode = null;
if (args.backendNodeId) |node_id| {
const node = server.node_registry.lookup_by_id.get(node_id) orelse {
return server.sendError(id, .InvalidParams, "Node not found");
};
target_node = node.dom;
}
lp.actions.press(target_node, args.key, page) catch |err| {
if (err == error.InvalidNodeType) {
return server.sendError(id, .InvalidParams, "Node is not an HTML element");
}
return server.sendError(id, .InternalError, "Failed to press key");
};
const page_title = page.getTitle() catch null;
const result_text = try std.fmt.allocPrint(arena, "Pressed key '{s}'. Page url: {s}, title: {s}", .{
args.key,
page.url,
page_title orelse "(none)",
});
const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }};
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
fn handleSelectOption(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const Params = struct {
backendNodeId: CDPNode.Id,
value: []const u8,
};
const args = try parseArgs(Params, arena, arguments, server, id, "selectOption");
const resolved = try resolveNodeAndPage(server, id, args.backendNodeId);
lp.actions.selectOption(resolved.node, args.value, resolved.page) catch |err| {
if (err == error.InvalidNodeType) {
return server.sendError(id, .InvalidParams, "Node is not a <select> element");
}
return server.sendError(id, .InternalError, "Failed to select option");
};
const page_title = resolved.page.getTitle() catch null;
const result_text = try std.fmt.allocPrint(arena, "Selected option '{s}' (backendNodeId: {d}). Page url: {s}, title: {s}", .{
args.value,
args.backendNodeId,
resolved.page.url,
page_title orelse "(none)",
});
const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }};
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
fn handleSetChecked(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const Params = struct {
backendNodeId: CDPNode.Id,
checked: bool,
};
const args = try parseArgs(Params, arena, arguments, server, id, "setChecked");
const resolved = try resolveNodeAndPage(server, id, args.backendNodeId);
lp.actions.setChecked(resolved.node, args.checked, resolved.page) catch |err| {
if (err == error.InvalidNodeType) {
return server.sendError(id, .InvalidParams, "Node is not a checkbox or radio input");
}
return server.sendError(id, .InternalError, "Failed to set checked state");
};
const state_str = if (args.checked) "checked" else "unchecked";
const page_title = resolved.page.getTitle() catch null;
const result_text = try std.fmt.allocPrint(arena, "Set element (backendNodeId: {d}) to {s}. Page url: {s}, title: {s}", .{
args.backendNodeId,
state_str,
resolved.page.url,
page_title orelse "(none)",
});
const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }};
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
fn handleFindElement(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const Params = struct {
role: ?[]const u8 = null,
name: ?[]const u8 = null,
};
const args = try parseArgsOrDefault(Params, arena, arguments, server, id);
if (args.role == null and args.name == null) {
return server.sendError(id, .InvalidParams, "At least one of 'role' or 'name' must be provided");
}
const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
const elements = lp.interactive.collectInteractiveElements(page.document.asNode(), arena, page) catch |err| {
log.err(.mcp, "elements collection failed", .{ .err = err });
return server.sendError(id, .InternalError, "Failed to collect interactive elements");
};
var matches: std.ArrayList(lp.interactive.InteractiveElement) = .empty;
for (elements) |el| {
if (args.role) |role| {
const el_role = el.role orelse continue;
if (!std.ascii.eqlIgnoreCase(el_role, role)) continue;
}
if (args.name) |name| {
const el_name = el.name orelse continue;
if (!containsIgnoreCase(el_name, name)) continue;
}
try matches.append(arena, el);
}
const matched = try matches.toOwnedSlice(arena);
lp.interactive.registerNodes(matched, &server.node_registry) catch |err| {
log.err(.mcp, "node registration failed", .{ .err = err });
return server.sendError(id, .InternalError, "Failed to register element nodes");
};
var aw: std.Io.Writer.Allocating = .init(arena);
try std.json.Stringify.value(matched, .{}, &aw.writer);
const content = [_]protocol.TextContent([]const u8){.{ .text = aw.written() }};
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
fn containsIgnoreCase(haystack: []const u8, needle: []const u8) bool {
if (needle.len > haystack.len) return false;
if (needle.len == 0) return true;
const end = haystack.len - needle.len + 1;
for (0..end) |i| {
if (std.ascii.eqlIgnoreCase(haystack[i..][0..needle.len], needle)) return true;
}
return false;
}
const NodeAndPage = struct { node: *DOMNode, page: *lp.Page };
fn resolveNodeAndPage(server: *Server, id: std.json.Value, node_id: CDPNode.Id) !NodeAndPage {
const page = server.session.currentPage() orelse {
try server.sendError(id, .PageNotLoaded, "Page not loaded");
return error.PageNotLoaded;
};
const node = server.node_registry.lookup_by_id.get(node_id) orelse {
try server.sendError(id, .InvalidParams, "Node not found");
return error.InvalidParams;
};
return .{ .node = node.dom, .page = page };
}
fn ensurePage(server: *Server, id: std.json.Value, url: ?[:0]const u8, timeout: ?u32, waitUntil: ?lp.Config.WaitUntil) !*lp.Page { fn ensurePage(server: *Server, id: std.json.Value, url: ?[:0]const u8, timeout: ?u32, waitUntil: ?lp.Config.WaitUntil) !*lp.Page {
if (url) |u| { if (url) |u| {
try performGoto(server, u, id, timeout, waitUntil); try performGoto(server, u, id, timeout, waitUntil);
@@ -736,7 +980,7 @@ test "MCP - evaluate error reporting" {
} }, out.written()); } }, out.written());
} }
test "MCP - Actions: click, fill, scroll" { test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked" {
defer testing.reset(); defer testing.reset();
const aa = testing.arena_allocator; const aa = testing.arena_allocator;
@@ -797,7 +1041,67 @@ test "MCP - Actions: click, fill, scroll" {
out.clearRetainingCapacity(); out.clearRetainingCapacity();
} }
// Evaluate assertions {
// Test Hover
const el = page.document.getElementById("hoverTarget", page).?.asNode();
const el_id = (try server.node_registry.register(el)).id;
var id_buf: [12]u8 = undefined;
const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{el_id}) catch unreachable;
const msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/call\",\"params\":{\"name\":\"hover\",\"arguments\":{\"backendNodeId\":", id_str, "}}}" });
try router.handleMessage(server, aa, msg);
try testing.expect(std.mem.indexOf(u8, out.written(), "Hovered element") != null);
out.clearRetainingCapacity();
}
{
// Test Press
const el = page.document.getElementById("keyTarget", page).?.asNode();
const el_id = (try server.node_registry.register(el)).id;
var id_buf: [12]u8 = undefined;
const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{el_id}) catch unreachable;
const msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":6,\"method\":\"tools/call\",\"params\":{\"name\":\"press\",\"arguments\":{\"key\":\"Enter\",\"backendNodeId\":", id_str, "}}}" });
try router.handleMessage(server, aa, msg);
try testing.expect(std.mem.indexOf(u8, out.written(), "Pressed key") != null);
out.clearRetainingCapacity();
}
{
// Test SelectOption
const el = page.document.getElementById("sel2", page).?.asNode();
const el_id = (try server.node_registry.register(el)).id;
var id_buf: [12]u8 = undefined;
const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{el_id}) catch unreachable;
const msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/call\",\"params\":{\"name\":\"selectOption\",\"arguments\":{\"backendNodeId\":", id_str, ",\"value\":\"b\"}}}" });
try router.handleMessage(server, aa, msg);
try testing.expect(std.mem.indexOf(u8, out.written(), "Selected option") != null);
out.clearRetainingCapacity();
}
{
// Test SetChecked (checkbox)
const el = page.document.getElementById("chk", page).?.asNode();
const el_id = (try server.node_registry.register(el)).id;
var id_buf: [12]u8 = undefined;
const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{el_id}) catch unreachable;
const msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":8,\"method\":\"tools/call\",\"params\":{\"name\":\"setChecked\",\"arguments\":{\"backendNodeId\":", id_str, ",\"checked\":true}}}" });
try router.handleMessage(server, aa, msg);
try testing.expect(std.mem.indexOf(u8, out.written(), "checked") != null);
out.clearRetainingCapacity();
}
{
// Test SetChecked (radio)
const el = page.document.getElementById("rad", page).?.asNode();
const el_id = (try server.node_registry.register(el)).id;
var id_buf: [12]u8 = undefined;
const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{el_id}) catch unreachable;
const msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"tools/call\",\"params\":{\"name\":\"setChecked\",\"arguments\":{\"backendNodeId\":", id_str, ",\"checked\":true}}}" });
try router.handleMessage(server, aa, msg);
try testing.expect(std.mem.indexOf(u8, out.written(), "checked") != null);
out.clearRetainingCapacity();
}
// Evaluate JS assertions for all actions
var ls: js.Local.Scope = undefined; var ls: js.Local.Scope = undefined;
page.js.localScope(&ls); page.js.localScope(&ls);
defer ls.deinit(); defer ls.deinit();
@@ -809,12 +1113,66 @@ test "MCP - Actions: click, fill, scroll" {
const result = try ls.local.exec( const result = try ls.local.exec(
\\ window.clicked === true && window.inputVal === 'hello' && \\ window.clicked === true && window.inputVal === 'hello' &&
\\ window.changed === true && window.selChanged === 'opt2' && \\ window.changed === true && window.selChanged === 'opt2' &&
\\ window.scrolled === true \\ window.scrolled === true &&
\\ window.hovered === true &&
\\ window.keyPressed === 'Enter' && window.keyReleased === 'Enter' &&
\\ window.sel2Changed === 'b' &&
\\ window.chkClicked === true && window.chkChanged === true &&
\\ window.radClicked === true && window.radChanged === true
, null); , null);
try testing.expect(result.isTrue()); try testing.expect(result.isTrue());
} }
test "MCP - findElement" {
defer testing.reset();
const aa = testing.arena_allocator;
var out: std.io.Writer.Allocating = .init(aa);
const server = try testLoadPage("http://localhost:9582/src/browser/tests/mcp_actions.html", &out.writer);
defer server.deinit();
{
// Find by role
const msg =
\\{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"findElement","arguments":{"role":"button"}}}
;
try router.handleMessage(server, aa, msg);
try testing.expect(std.mem.indexOf(u8, out.written(), "Click Me") != null);
out.clearRetainingCapacity();
}
{
// Find by name (case-insensitive substring)
const msg =
\\{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"findElement","arguments":{"name":"click"}}}
;
try router.handleMessage(server, aa, msg);
try testing.expect(std.mem.indexOf(u8, out.written(), "Click Me") != null);
out.clearRetainingCapacity();
}
{
// Find with no matches
const msg =
\\{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"findElement","arguments":{"role":"slider"}}}
;
try router.handleMessage(server, aa, msg);
try testing.expect(std.mem.indexOf(u8, out.written(), "[]") != null);
out.clearRetainingCapacity();
}
{
// Error: no params provided
const msg =
\\{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"findElement","arguments":{}}}
;
try router.handleMessage(server, aa, msg);
try testing.expect(std.mem.indexOf(u8, out.written(), "error") != null);
out.clearRetainingCapacity();
}
}
test "MCP - waitForSelector: existing element" { test "MCP - waitForSelector: existing element" {
defer testing.reset(); defer testing.reset();
var out: std.io.Writer.Allocating = .init(testing.arena_allocator); var out: std.io.Writer.Allocating = .init(testing.arena_allocator);