diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index 3e29e96a..f78b307c 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -13,7 +13,7 @@ inputs: zig-v8: description: 'zig v8 version to install' required: false - default: 'v0.3.8' + default: 'v0.3.9' v8: description: 'v8 version to install' required: false diff --git a/Dockerfile b/Dockerfile index ed90e014..715441a0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM debian:stable-slim ARG MINISIG=0.12 ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U ARG V8=14.0.365.4 -ARG ZIG_V8=v0.3.8 +ARG ZIG_V8=v0.3.9 ARG TARGETPLATFORM RUN apt-get update -yq && \ diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index 97c0a9cc..3c9b8797 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -24,25 +24,24 @@ const String = @import("../string.zig").String; const js = @import("js/js.zig"); const Page = @import("Page.zig"); +const EventManagerBase = @import("EventManagerBase.zig"); const Node = @import("webapi/Node.zig"); const Event = @import("webapi/Event.zig"); const EventTarget = @import("webapi/EventTarget.zig"); const Element = @import("webapi/Element.zig"); -const EventManagerBase = @import("EventManagerBase.zig"); - const Allocator = std.mem.Allocator; -const IS_DEBUG = builtin.mode == .Debug; - -pub const EventManager = @This(); - // Re-export types from EventManagerBase for API compatibility pub const RegisterOptions = EventManagerBase.RegisterOptions; pub const Callback = EventManagerBase.Callback; pub const Listener = EventManagerBase.Listener; +const IS_DEBUG = builtin.mode == .Debug; + +pub const EventManager = @This(); + page: *Page, base: EventManagerBase, diff --git a/src/browser/Page.zig b/src/browser/Page.zig index e0735936..0455bb54 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -57,6 +57,7 @@ const PerformanceObserver = @import("webapi/PerformanceObserver.zig"); const AbstractRange = @import("webapi/AbstractRange.zig"); const MutationObserver = @import("webapi/MutationObserver.zig"); const IntersectionObserver = @import("webapi/IntersectionObserver.zig"); +const Worker = @import("webapi/Worker.zig"); const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig"); const PageTransitionEvent = @import("webapi/event/PageTransitionEvent.zig"); const SubmitEvent = @import("webapi/event/SubmitEvent.zig"); @@ -231,6 +232,9 @@ iframe: ?*IFrame = null, frames: std.ArrayList(*Page) = .{}, frames_sorted: bool = true, +// Workers created by this page. Cleaned up when page is destroyed. +workers: std.ArrayList(*Worker) = .{}, + // DOM version used to invalidate cached state of "live" collections version: usize = 0, @@ -340,6 +344,10 @@ pub fn deinit(self: *Page, abort_http: bool) void { frame.deinit(abort_http); } + for (self.workers.items) |worker| { + worker.deinit(); + } + if (comptime IS_DEBUG) { log.debug(.page, "page.deinit", .{ .url = self.url, .type = self._type }); @@ -400,6 +408,19 @@ pub fn deinit(self: *Page, abort_http: bool) void { session.releaseArena(self.call_arena); } +pub fn trackWorker(self: *Page, worker: *Worker) !void { + try self.workers.append(self.arena, worker); +} + +pub fn removeWorker(self: *Page, worker: *Worker) void { + for (self.workers.items, 0..) |w, i| { + if (w == worker) { + _ = self.workers.swapRemove(i); + break; + } + } +} + pub fn base(self: *const Page) [:0]const u8 { return self.base_url orelse self.url; } @@ -1545,7 +1566,7 @@ pub fn deliverSlotchangeEvents(self: *Page) void { continue; }; const target = slot.asNode().asEventTarget(); - _ = target.dispatchEvent(event, self) catch |err| { + self._event_manager.dispatch(target, event) catch |err| { log.err(.page, "deliverSlotchange.dispatch", .{ .err = err, .type = self._type, .url = self.url }); }; } diff --git a/src/browser/js/Caller.zig b/src/browser/js/Caller.zig index 5604b7a8..01e88a74 100644 --- a/src/browser/js/Caller.zig +++ b/src/browser/js/Caller.zig @@ -21,6 +21,8 @@ const log = @import("../../log.zig"); const string = @import("../../string.zig"); const Page = @import("../Page.zig"); +const Session = @import("../Session.zig"); +const WorkerGlobalScope = @import("../webapi/WorkerGlobalScope.zig"); const js = @import("js.zig"); const Local = @import("Local.zig"); @@ -57,7 +59,7 @@ fn throwDetachedError(isolate: *v8.Isolate) void { _ = v8.v8__Isolate__ThrowException(isolate, js_exception); } -fn initWithContext(self: *Caller, ctx: *Context, v8_context: *const v8.Context) void { +pub fn initWithContext(self: *Caller, ctx: *Context, v8_context: *const v8.Context) void { ctx.call_depth += 1; self.* = Caller{ .local = .{ @@ -67,9 +69,9 @@ fn initWithContext(self: *Caller, ctx: *Context, v8_context: *const v8.Context) .isolate = ctx.isolate, }, .prev_local = ctx.local, - .prev_context = ctx.page.js, + .prev_context = ctx.global.getJs(), }; - ctx.page.js = ctx; + ctx.global.setJs(ctx); ctx.local = &self.local; } @@ -100,7 +102,7 @@ pub fn deinit(self: *Caller) void { ctx.call_depth = call_depth; ctx.local = self.prev_local; - ctx.page.js = self.prev_context; + ctx.global.setJs(self.prev_context); } pub const CallOpts = struct { @@ -182,7 +184,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, "1") = idx; if (@typeInfo(F).@"fn".params.len == 3) { - @field(args, "2") = local.ctx.page; + @field(args, "2") = getGlobalArg(@TypeOf(args.@"2"), local.ctx); } const ret = @call(.auto, func, args); return handleIndexedReturn(T, F, true, local, ret, info, opts); @@ -209,7 +211,7 @@ fn _getNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *c @field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis()); @field(args, "1") = try nameToString(local, @TypeOf(args.@"1"), name); if (@typeInfo(F).@"fn".params.len == 3) { - @field(args, "2") = local.ctx.page; + @field(args, "2") = getGlobalArg(@TypeOf(args.@"2"), local.ctx); } const ret = @call(.auto, func, args); return handleIndexedReturn(T, F, true, local, ret, info, opts); @@ -237,7 +239,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, "2") = try local.jsValueToZig(@TypeOf(@field(args, "2")), js_value); if (@typeInfo(F).@"fn".params.len == 4) { - @field(args, "3") = local.ctx.page; + @field(args, "3") = getGlobalArg(@TypeOf(args.@"3"), local.ctx); } const ret = @call(.auto, func, args); return handleIndexedReturn(T, F, false, local, ret, info, opts); @@ -263,7 +265,7 @@ fn _deleteNamedIndex(comptime T: type, local: *const Local, func: anytype, name: @field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis()); @field(args, "1") = try nameToString(local, @TypeOf(args.@"1"), name); if (@typeInfo(F).@"fn".params.len == 3) { - @field(args, "2") = local.ctx.page; + @field(args, "2") = getGlobalArg(@TypeOf(args.@"2"), local.ctx); } const ret = @call(.auto, func, args); return handleIndexedReturn(T, F, false, local, ret, info, opts); @@ -289,7 +291,7 @@ fn _getEnumerator(comptime T: type, local: *const Local, func: anytype, info: Pr var args: ParameterTypes(F) = undefined; @field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis()); if (@typeInfo(F).@"fn".params.len == 2) { - @field(args, "1") = local.ctx.page; + @field(args, "1") = getGlobalArg(@TypeOf(args.@"1"), local.ctx); } const ret = @call(.auto, func, args); return handleIndexedReturn(T, F, true, local, ret, info, opts); @@ -448,6 +450,33 @@ fn isPage(comptime T: type) bool { return T == *Page or T == *const Page; } +fn isSession(comptime T: type) bool { + return T == *Session or T == *const Session; +} + +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; + } + + if (comptime isSession(T)) { + return ctx.session; + } + + @compileError("Unsupported global arg type: " ++ @typeName(T)); +} + // These wrap the raw v8 C API to provide a cleaner interface. pub const FunctionCallbackInfo = struct { handle: *const v8.FunctionCallbackInfo, @@ -719,15 +748,16 @@ fn getArgs(comptime F: type, comptime offset: usize, local: *const Local, info: return args; } - // If the last parameter is the Page, set it, and exclude it + // If the last parameter is the Page or Worker, set it, and exclude it // from our params slice, because we don't want to bind it to // a JS argument - if (comptime isPage(params[params.len - 1].type.?)) { - @field(args, tupleFieldName(params.len - 1 + offset)) = local.ctx.page; + const LastParamType = params[params.len - 1].type.?; + if (comptime isPage(LastParamType) or isExecution(LastParamType) or isSession(LastParamType)) { + @field(args, tupleFieldName(params.len - 1 + offset)) = getGlobalArg(LastParamType, local.ctx); break :blk params[0 .. params.len - 1]; } - // we have neither a Page nor a JsObject. All params must be + // we have neither a Page, Execution, nor a JsObject. All params must be // bound to a JavaScript value. break :blk params; }; @@ -776,7 +806,11 @@ fn getArgs(comptime F: type, comptime offset: usize, local: *const Local, info: } if (comptime isPage(param.type.?)) { - @compileError("Page must be the last parameter (or 2nd last if there's a JsThis): " ++ @typeName(F)); + @compileError("Page must be the last parameter: " ++ @typeName(F)); + } else if (comptime isExecution(param.type.?)) { + @compileError("Execution must be the last parameter: " ++ @typeName(F)); + } else if (comptime isSession(param.type.?)) { + @compileError("Session must be the last parameter: " ++ @typeName(F)); } else if (i >= js_parameter_count) { if (@typeInfo(param.type.?) != .optional) { return error.InvalidArgument; diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index ffc71db2..575e2f3a 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -25,10 +25,12 @@ const bridge = @import("bridge.zig"); const Env = @import("Env.zig"); const Origin = @import("Origin.zig"); const Scheduler = @import("Scheduler.zig"); +const Execution = @import("Execution.zig"); const Page = @import("../Page.zig"); const Session = @import("../Session.zig"); const ScriptManager = @import("../ScriptManager.zig"); +const WorkerGlobalScope = @import("../webapi/WorkerGlobalScope.zig"); const v8 = js.v8; const Caller = js.Caller; @@ -37,12 +39,38 @@ const Allocator = std.mem.Allocator; const IS_DEBUG = @import("builtin").mode == .Debug; -// Loosely maps to a Browser Page. +// Loosely maps to a Browser Page or Worker. 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, env: *Env, -page: *Page, +global: GlobalScope, session: *Session, isolate: js.Isolate, @@ -111,6 +139,10 @@ script_manager: ?*ScriptManager, // Our macrotasks 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 {}, const ModuleEntry = struct { @@ -259,7 +291,11 @@ pub fn toLocal(self: *Context, global: anytype) js.Local.ToLocalReturnType(@Type } pub fn getIncumbent(self: *Context) *Page { - return fromC(v8.v8__Isolate__GetIncumbentContext(self.env.isolate.handle).?).?.page; + const ctx = fromC(v8.v8__Isolate__GetIncumbentContext(self.env.isolate.handle).?).?; + return switch (ctx.global) { + .page => |page| page, + .worker => unreachable, + }; } pub fn stringToPersistedFunction( @@ -529,7 +565,7 @@ pub fn dynamicModuleCallback( if (resource_value.isNullOrUndefined()) { // will only be null / undefined in extreme cases (e.g. WPT tests) // where you're - break :blk self.page.base(); + break :blk self.global.base(); } break :blk js.String.toSliceZ(.{ .local = &local, .handle = resource_name.? }) catch |err| { @@ -871,17 +907,16 @@ pub fn enter(self: *Context, hs: *js.HandleScope) Entered { const isolate = self.isolate; js.HandleScope.init(hs, isolate); - const page = self.page; - const original = page.js; - page.js = self; + const original = self.global.getJs(); + self.global.setJs(self); const handle: *const v8.Context = @ptrCast(v8.v8__Global__Get(&self.handle, isolate.handle)); v8.v8__Context__Enter(handle); - return .{ .original = original, .handle = handle, .handle_scope = hs }; + return .{ .original = original, .handle = handle, .handle_scope = hs, .global = self.global }; } const Entered = struct { - // the context we should restore on the page + // the context we should restore on the page/worker original: *Context, // the handle of the entered context @@ -889,8 +924,10 @@ const Entered = struct { handle_scope: *js.HandleScope, + global: GlobalScope, + pub fn exit(self: Entered) void { - self.original.page.js = self.original; + self.global.setJs(self.original); v8.v8__Context__Exit(self.handle); self.handle_scope.deinit(); } @@ -899,7 +936,10 @@ const Entered = struct { pub fn queueMutationDelivery(self: *Context) !void { self.enqueueMicrotask(struct { fn run(ctx: *Context) void { - ctx.page.deliverMutations(); + switch (ctx.global) { + .page => |page| page.deliverMutations(), + .worker => unreachable, + } } }.run); } @@ -907,7 +947,10 @@ pub fn queueMutationDelivery(self: *Context) !void { pub fn queueIntersectionChecks(self: *Context) !void { self.enqueueMicrotask(struct { fn run(ctx: *Context) void { - ctx.page.performScheduledIntersectionChecks(); + switch (ctx.global) { + .page => |page| page.performScheduledIntersectionChecks(), + .worker => unreachable, + } } }.run); } @@ -915,7 +958,10 @@ pub fn queueIntersectionChecks(self: *Context) !void { pub fn queueIntersectionDelivery(self: *Context) !void { self.enqueueMicrotask(struct { fn run(ctx: *Context) void { - ctx.page.deliverIntersections(); + switch (ctx.global) { + .page => |page| page.deliverIntersections(), + .worker => unreachable, + } } }.run); } @@ -923,7 +969,10 @@ pub fn queueIntersectionDelivery(self: *Context) !void { pub fn queueSlotchangeDelivery(self: *Context) !void { self.enqueueMicrotask(struct { fn run(ctx: *Context) void { - ctx.page.deliverSlotchangeEvents(); + switch (ctx.global) { + .page => |page| page.deliverSlotchangeEvents(), + .worker => unreachable, + } } }.run); } diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index 03eadac3..2158e51d 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -34,6 +34,7 @@ const Inspector = @import("Inspector.zig"); const Page = @import("../Page.zig"); const Window = @import("../webapi/Window.zig"); +const WorkerGlobalScope = @import("../webapi/WorkerGlobalScope.zig"); const JsApis = bridge.JsApis; const Allocator = std.mem.Allocator; @@ -83,9 +84,6 @@ eternal_function_templates: []v8.Eternal, // Dynamic slice to avoid circular dependency on JsApis.len at comptime 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: ?*Inspector, @@ -146,7 +144,6 @@ pub fn init(app: *App, opts: InitOpts) !Env { const templates = try allocator.alloc(*const v8.FunctionTemplate, JsApis.len); errdefer allocator.free(templates); - var global_eternal: v8.Eternal = undefined; var private_symbols: PrivateSymbols = undefined; { var temp_scope: js.HandleScope = undefined; @@ -164,44 +161,6 @@ pub fn init(app: *App, opts: InitOpts) !Env { 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); } @@ -221,7 +180,6 @@ pub fn init(app: *App, opts: InitOpts) !Env { .templates = templates, .isolate_params = params, .inspector = inspector, - .global_template = global_eternal, .private_symbols = private_symbols, .microtask_queues_are_running = false, .eternal_function_templates = eternal_function_templates, @@ -261,7 +219,18 @@ pub const ContextParams = struct { }; pub fn createContext(self: *Env, page: *Page, params: ContextParams) !*Context { - const context_arena = try self.app.arena_pool.acquire(.large, params.debug_name); + 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(.medium, params.debug_name); errdefer self.app.arena_pool.release(context_arena); const isolate = self.isolate; @@ -273,12 +242,10 @@ pub fn createContext(self: *Env, page: *Page, params: ContextParams) !*Context { const microtask_queue = v8.v8__MicrotaskQueue__New(isolate.handle, v8.kExplicit).?; errdefer v8.v8__MicrotaskQueue__DELETE(microtask_queue); - // Get the global template that was created once per isolate - const global_template: *const v8.ObjectTemplate = @ptrCast(@alignCast(v8.v8__Eternal__Get(&self.global_template, isolate.handle).?)); - v8.v8__ObjectTemplate__SetInternalFieldCount(global_template, comptime Snapshot.countInternalFields(Window.JsApi)); - - const v8_context = v8.v8__Context__New__Config(isolate.handle, &.{ - .global_template = global_template, + // Restore the context from the snapshot (0 = Page, 1 = Worker) + const snapshot_index: u32 = if (comptime is_page) 0 else 1; + const v8_context = v8.v8__Context__FromSnapshot__Config(isolate.handle, snapshot_index, &.{ + .global_template = null, .global_object = null, .microtask_queue = microtask_queue, }).?; @@ -287,36 +254,36 @@ pub fn createContext(self: *Env, page: *Page, params: ContextParams) !*Context { var context_global: v8.Global = undefined; v8.v8__Global__New(isolate.handle, v8_context, &context_global); - // get the global object for the context, this maps to our Window + // Get the global object for the 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. Almost all objects have this, and - // it gets setup automatically as objects are created, but the Window - // object already exists in v8 (it's the global) so we manually create - // the mapping here. - const tao = try params.identity_arena.create(@import("TaggedOpaque.zig")); - tao.* = .{ - .value = @ptrCast(page.window), - .prototype_chain = (&Window.JsApi.Meta.prototype_chain).ptr, - .prototype_len = @intCast(Window.JsApi.Meta.prototype_chain.len), - .subtype = .node, // this probably isn't right, but it's what we've been doing all along - }; - v8.v8__Object__SetAlignedPointerInInternalField(global_obj, 0, tao); - } + // Store our TAO inside the internal field of the global object. This + // maps the v8::Object -> Zig instance. + const tao = try params.identity_arena.create(@import("TaggedOpaque.zig")); + tao.* = if (comptime is_page) .{ + .value = @ptrCast(global.window), + .prototype_chain = (&Window.JsApi.Meta.prototype_chain).ptr, + .prototype_len = @intCast(Window.JsApi.Meta.prototype_chain.len), + .subtype = .node, + } else .{ + .value = @ptrCast(global), + .prototype_chain = (&WorkerGlobalScope.JsApi.Meta.prototype_chain).ptr, + .prototype_len = @intCast(WorkerGlobalScope.JsApi.Meta.prototype_chain.len), + .subtype = null, + }; + v8.v8__Object__SetAlignedPointerInInternalField(global_obj, 0, tao); const context_id = self.context_id; self.context_id = context_id + 1; - const session = page._session; + const session = global._session; const origin = try session.getOrCreateOrigin(null); errdefer session.releaseOrigin(origin); const context = try context_arena.create(Context); context.* = .{ .env = self, - .page = page, + .global = if (comptime is_page) .{ .page = global } else .{ .worker = global }, .origin = origin, .id = context_id, .session = session, @@ -326,22 +293,31 @@ pub fn createContext(self: *Env, page: *Page, params: ContextParams) !*Context { .templates = self.templates, .call_arena = params.call_arena, .microtask_queue = microtask_queue, - .script_manager = &page._script_manager, + .script_manager = if (comptime is_page) &global._script_manager else null, .scheduler = .init(context_arena), .identity = params.identity, .identity_arena = params.identity_arena, + .execution = undefined, }; - { - // Multiple contexts can be created for the same Window (via CDP). We only - // need to register the first one. - const gop = try params.identity.identity_map.getOrPut(params.identity_arena, @intFromPtr(page.window)); - if (gop.found_existing == false) { - // our window wrapped in a v8::Global - var global_global: v8.Global = undefined; - v8.v8__Global__New(isolate.handle, global_obj, &global_global); - gop.value_ptr.* = global_global; - } + context.execution = .{ + .url = &global.url, + .buf = &global.buf, + .context = context, + .arena = global.arena, + .call_arena = params.call_arena, + ._factory = global._factory, + ._scheduler = &context.scheduler, + }; + + // 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 @@ -528,13 +504,25 @@ fn promiseRejectCallback(message_handle: v8.PromiseRejectMessage) callconv(.c) v .call_arena = ctx.call_arena, }; - const page = ctx.page; - page.window.unhandledPromiseRejection(promise_event == v8.kPromiseRejectWithNoHandler, .{ - .local = &local, - .handle = &message_handle, - }, page) catch |err| { - log.warn(.browser, "unhandled rejection handler", .{ .err = err }); - }; + const no_handler = promise_event == v8.kPromiseRejectWithNoHandler; + switch (ctx.global) { + .page => |page| { + page.window.unhandledPromiseRejection(no_handler, .{ + .local = &local, + .handle = &message_handle, + }, page) catch |err| { + log.warn(.browser, "unhandled rejection handler", .{ .err = err, .target = "window" }); + }; + }, + .worker => |wsg| { + wsg.unhandledPromiseRejection(no_handler, .{ + .local = &local, + .handle = &message_handle, + }) catch |err| { + log.warn(.browser, "unhandled rejection handler", .{ .err = err, .target = "worker" }); + }; + }, + } } fn fatalCallback(c_location: [*c]const u8, c_message: [*c]const u8) callconv(.c) void { @@ -566,3 +554,35 @@ const PrivateSymbols = struct { self.child_nodes.deinit(); } }; + +const testing = @import("../../testing.zig"); +test "Env: Worker context " { + const session = testing.test_session; + const page = try session.createPage(); + defer session.removePage(); + + const worker = try @import("../webapi/Worker.zig").init("about:blank", &page.js.execution); + + var ls: js.Local.Scope = undefined; + worker._worker_scope.js.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()); +} diff --git a/src/browser/js/Execution.zig b/src/browser/js/Execution.zig new file mode 100644 index 00000000..877cd9e3 --- /dev/null +++ b/src/browser/js/Execution.zig @@ -0,0 +1,47 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! 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, diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index 213f7af2..a4757221 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -332,7 +332,15 @@ pub fn zigValueToJs(self: *const Local, value: anytype, comptime opts: CallOpts) } if (@typeInfo(ptr.child) == .@"struct" and @hasDecl(ptr.child, "runtimeGenericWrap")) { - const wrap = try value.runtimeGenericWrap(self.ctx.page); + const page = switch (self.ctx.global) { + .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); } @@ -409,7 +417,15 @@ pub fn zigValueToJs(self: *const Local, value: anytype, comptime opts: CallOpts) // zig fmt: on if (@hasDecl(T, "runtimeGenericWrap")) { - const wrap = try value.runtimeGenericWrap(self.ctx.page); + const page = switch (self.ctx.global) { + .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); } diff --git a/src/browser/js/Scheduler.zig b/src/browser/js/Scheduler.zig index d9fb417e..1055c4d0 100644 --- a/src/browser/js/Scheduler.zig +++ b/src/browser/js/Scheduler.zig @@ -52,6 +52,11 @@ pub fn deinit(self: *Scheduler) void { finalizeTasks(&self.high_priority); } +pub fn reset(self: *Scheduler) void { + self.low_priority.clearRetainingCapacity(); + self.high_priority.clearRetainingCapacity(); +} + const AddOpts = struct { name: []const u8 = "", low_priority: bool = false, diff --git a/src/browser/js/Snapshot.zig b/src/browser/js/Snapshot.zig index 0b6a7fd1..b63e26cb 100644 --- a/src/browser/js/Snapshot.zig +++ b/src/browser/js/Snapshot.zig @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire @@ -17,6 +17,7 @@ // along with this program. If not, see . const std = @import("std"); +const lp = @import("lightpanda"); const js = @import("js.zig"); const bridge = @import("bridge.zig"); const log = @import("../../log.zig"); @@ -25,6 +26,8 @@ const IS_DEBUG = @import("builtin").mode == .Debug; const v8 = js.v8; const JsApis = bridge.JsApis; +const PageJsApis = bridge.PageJsApis; +const WorkerJsApis = bridge.WorkerJsApis; const Snapshot = @This(); @@ -136,7 +139,7 @@ pub fn create() !Snapshot { v8.v8__HandleScope__CONSTRUCT(&handle_scope, isolate); defer v8.v8__HandleScope__DESTRUCT(&handle_scope); - // Create templates (constructors only) FIRST + // Create templates for ALL types (JsApis) var templates: [JsApis.len]*const v8.FunctionTemplate = undefined; inline for (JsApis, 0..) |JsApi, i| { @setEvalBranchQuota(10_000); @@ -145,114 +148,51 @@ pub fn create() !Snapshot { } // Set up prototype chains BEFORE attaching properties - // This must come before attachClass so inheritance is set up first inline for (JsApis, 0..) |JsApi, i| { if (comptime protoIndexLookup(JsApi)) |proto_index| { v8.v8__FunctionTemplate__Inherit(templates[i], templates[proto_index]); } } - // Set up the global template to inherit from Window's template - // This way the global object gets all Window properties through inheritance - const context = v8.v8__Context__New(isolate, null, null); - v8.v8__Context__Enter(context); - defer v8.v8__Context__Exit(context); + // Add ALL templates to snapshot (done once, in any context) + // We need a context to call AddData, so create a temporary one + { + const temp_context = v8.v8__Context__New(isolate, null, null); + v8.v8__Context__Enter(temp_context); + defer v8.v8__Context__Exit(temp_context); - // Add templates to context snapshot - var last_data_index: usize = 0; - inline for (JsApis, 0..) |_, i| { - @setEvalBranchQuota(10_000); - const data_index = v8.v8__SnapshotCreator__AddData(snapshot_creator, @ptrCast(templates[i])); - if (i == 0) { - 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); + var last_data_index: usize = 0; + inline for (JsApis, 0..) |_, i| { + @setEvalBranchQuota(10_000); + const data_index = v8.v8__SnapshotCreator__AddData(snapshot_creator, @ptrCast(templates[i])); + if (i == 0) { + data_start = data_index; + last_data_index = data_index; } 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; + if (data_index != last_data_index + 1) { + return error.InvalidDataIndex; } - v8.v8__Object__DefineOwnProperty(global_obj, context, v8_class_name, func, properties, &maybe_result); + last_data_index = data_index; } } + + // V8 requires a default context. We could probably make this our + // Page context, but having both the Page and Worker context be + // added via addContext makes things a little more consistent. + v8.v8__SnapshotCreator__setDefaultContext(snapshot_creator, temp_context); } { - // If we want to overwrite the built-in console, we have to - // delete the built-in one. - 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 Window = @import("../webapi/Window.zig"); + const index = try createSnapshotContext(&PageJsApis, Window.JsApi, isolate, snapshot_creator.?, &templates); + std.debug.assert(index == 0); } { - // Custom exception - // TODO: this is an horrible hack, I can't figure out how to do this cleanly. - 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; + const WorkerGlobalScope = @import("../webapi/WorkerGlobalScope.zig"); + const index = try createSnapshotContext(&WorkerJsApis, WorkerGlobalScope.JsApi, isolate, snapshot_creator.?, &templates); + std.debug.assert(index == 1); } - - v8.v8__SnapshotCreator__setDefaultContext(snapshot_creator, context); } const blob = v8.v8__SnapshotCreator__createBlob(snapshot_creator, v8.kKeep); @@ -260,25 +200,127 @@ pub fn create() !Snapshot { return .{ .owns_data = true, .data_start = data_start, - .external_references = external_references, .startup_data = blob, + .external_references = external_references, }; } -// Helper to check if a JsApi has a NamedIndexed handler -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; +fn createSnapshotContext( + comptime ContextApis: []const type, + comptime GlobalScopeApi: type, + isolate: *v8.Isolate, + snapshot_creator: *v8.SnapshotCreator, + templates: []*const v8.FunctionTemplate, +) !usize { + // Create a global template that inherits from the GlobalScopeApi (Window or WorkerGlobalScope) + 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 { @setEvalBranchQuota(100_000); @@ -290,24 +332,24 @@ fn countExternalReferences() comptime_int { // +1 for the noop function shared by various types count += 1; + // +1 for unknownWindowPropertyCallback used on Window's global template + count += 1; + inline for (JsApis) |JsApi| { - // Constructor (only if explicit) if (@hasDecl(JsApi, "constructor")) { count += 1; } - // Callable (htmldda) if (@hasDecl(JsApi, "callable")) { count += 1; } - // All other callbacks const declarations = @typeInfo(JsApi).@"struct".decls; inline for (declarations) |d| { const value = @field(JsApi, d.name); const T = @TypeOf(value); if (T == bridge.Accessor) { - count += 1; // getter + count += 1; if (value.setter != null) { count += 1; } @@ -321,14 +363,13 @@ fn countExternalReferences() comptime_int { count += 1; } } else if (T == bridge.NamedIndexed) { - count += 1; // getter + count += 1; if (value.setter != null) count += 1; if (value.deleter != null) count += 1; } } } - // In debug mode, add unknown property callbacks for types without NamedIndexed if (comptime IS_DEBUG) { inline for (JsApis) |JsApi| { if (!hasNamedIndexedGetter(JsApi)) { @@ -350,6 +391,9 @@ fn collectExternalReferences() [countExternalReferences()]isize { references[idx] = @bitCast(@intFromPtr(&bridge.Function.noopFunction)); idx += 1; + references[idx] = @bitCast(@intFromPtr(&bridge.unknownWindowPropertyCallback)); + idx += 1; + inline for (JsApis) |JsApi| { if (@hasDecl(JsApi, "constructor")) { references[idx] = @bitCast(@intFromPtr(JsApi.constructor.func)); @@ -400,7 +444,6 @@ fn collectExternalReferences() [countExternalReferences()]isize { } } - // In debug mode, collect unknown property callbacks for types without NamedIndexed if (comptime IS_DEBUG) { inline for (JsApis) |JsApi| { if (!hasNamedIndexedGetter(JsApi)) { @@ -413,37 +456,7 @@ fn collectExternalReferences() [countExternalReferences()]isize { return references; } -// Even if a struct doesn't have a `constructor` function, we still -// `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; -} - -pub fn countInternalFields(comptime JsApi: type) u8 { +fn countInternalFields(comptime JsApi: type) u8 { var last_used_id = 0; var cache_count: u8 = 0; @@ -481,14 +494,80 @@ pub fn countInternalFields(comptime JsApi: type) u8 { return cache_count + 1; } -// Attaches JsApi members to the prototype template (normal case) +// 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); +} + +// 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 protoIndexLookup(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 (JsApis, 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 { const instance = v8.v8__FunctionTemplate__InstanceTemplate(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 declarations = @typeInfo(JsApi).@"struct".decls; @@ -524,7 +603,6 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *const v8.F } if (value.static) { - // Static accessors: use Template's SetAccessorProperty v8.v8__Template__SetAccessorProperty(@ptrCast(template), js_name, getter_callback, setter_callback, attribute); } else { v8.v8__ObjectTemplate__SetAccessorProperty__Config(prototype, &.{ @@ -600,11 +678,10 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *const v8.F } 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); } }, - bridge.Constructor => {}, // already handled in generateConstructor + bridge.Constructor => {}, else => {}, } } @@ -637,30 +714,3 @@ 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); -} diff --git a/src/browser/js/Value.zig b/src/browser/js/Value.zig index 1491bcdd..f80727b0 100644 --- a/src/browser/js/Value.zig +++ b/src/browser/js/Value.zig @@ -248,9 +248,15 @@ pub fn toJson(self: Value, allocator: Allocator) ![]u8 { // Throws a DataCloneError for host objects (Blob, File, etc.) that cannot be serialized. // Does not support transferables which require additional delegate callbacks. pub fn structuredClone(self: Value) !Value { - const local = self.local; - const v8_context = local.handle; - const v8_isolate = local.isolate.handle; + return self.structuredCloneTo(self.local); +} + +// Clone a value to a different context (within the same isolate). +// Used for cross-context messaging (e.g., Worker <-> Page). +pub fn structuredCloneTo(self: Value, target: *const js.Local) !Value { + const source_context = self.local.handle; + const target_context = target.handle; + const v8_isolate = target.isolate.handle; const SerializerDelegate = struct { // Called when V8 encounters a host object it doesn't know how to serialize. @@ -280,7 +286,7 @@ pub fn structuredClone(self: Value) !Value { var write_result: v8.MaybeBool = undefined; v8.v8__ValueSerializer__WriteHeader(serializer); - v8.v8__ValueSerializer__WriteValue(serializer, v8_context, self.handle, &write_result); + v8.v8__ValueSerializer__WriteValue(serializer, source_context, self.handle, &write_result); if (!write_result.has_value or !write_result.value) { return error.JsException; } @@ -297,14 +303,14 @@ pub fn structuredClone(self: Value) !Value { defer v8.v8__ValueDeserializer__DELETE(deserializer); var read_header_result: v8.MaybeBool = undefined; - v8.v8__ValueDeserializer__ReadHeader(deserializer, v8_context, &read_header_result); + v8.v8__ValueDeserializer__ReadHeader(deserializer, target_context, &read_header_result); if (!read_header_result.has_value or !read_header_result.value) { return error.JsException; } - break :blk v8.v8__ValueDeserializer__ReadValue(deserializer, v8_context) orelse return error.JsException; + break :blk v8.v8__ValueDeserializer__ReadValue(deserializer, target_context) orelse return error.JsException; }; - return .{ .local = local, .handle = cloned_handle }; + return .{ .local = target, .handle = cloned_handle }; } pub fn persist(self: Value) !Global { diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index dc22a996..22cbed30 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -24,6 +24,7 @@ const Session = @import("../Session.zig"); const v8 = js.v8; const Caller = @import("Caller.zig"); +const Context = @import("Context.zig"); const IS_DEBUG = @import("builtin").mode == .Debug; @@ -398,10 +399,15 @@ pub const Property = struct { pub fn unknownWindowPropertyCallback(c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 { const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?; + + // During snapshot creation, there's no Context in embedder data yet. + // I hate this check, but there doesn't seem to be a way to add this method + // to the global, without triggering it during snapshot creation. + const v8_context = v8.v8__Isolate__GetCurrentContext(v8_isolate) orelse return 0; + const ctx: *Context = @ptrCast(@alignCast(v8.v8__Context__GetAlignedPointerFromEmbedderData(v8_context, 1) orelse return 0)); + var caller: Caller = undefined; - if (!caller.init(v8_isolate)) { - return 0; - } + caller.initWithContext(ctx, v8_context); defer caller.deinit(); const local = &caller.local; @@ -414,14 +420,18 @@ pub fn unknownWindowPropertyCallback(c_name: ?*const v8.Name, handle: ?*const v8 return 0; }; - const page = local.ctx.page; - const document = page.document; - - if (document.getElementById(property, page)) |el| { - const js_val = local.zigValueToJs(el, .{}) catch return 0; - var pc = Caller.PropertyCallbackInfo{ .handle = handle.? }; - pc.getReturnValue().set(js_val); - return 1; + // Only Page contexts have document.getElementById lookup + switch (local.ctx.global) { + .page => |page| { + const document = page.document; + if (document.getElementById(property, page)) |el| { + const js_val = local.zigValueToJs(el, .{}) catch return 0; + var pc = Caller.PropertyCallbackInfo{ .handle = handle.? }; + pc.getReturnValue().set(js_val); + return 1; + } + }, + .worker => {}, // no global lookup in a worker } if (comptime IS_DEBUG) { @@ -459,7 +469,8 @@ pub fn unknownWindowPropertyCallback(c_name: ?*const v8.Name, handle: ?*const v8 .{ "ApplePaySession", {} }, }); if (!ignored.has(property)) { - const key = std.fmt.bufPrint(&local.ctx.page.buf, "Window:{s}", .{property}) catch return 0; + var buf: [2048]u8 = undefined; + const key = std.fmt.bufPrint(&buf, "Window:{s}", .{property}) catch return 0; logUnknownProperty(local, key) catch return 0; } } @@ -524,7 +535,8 @@ pub fn unknownObjectPropertyCallback(comptime JsApi: type) *const fn (?*const v8 const ignored = std.StaticStringMap(void).initComptime(.{}); if (!ignored.has(property)) { - 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; + var buf: [2048]u8 = undefined; + 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; } // not intercepted @@ -689,7 +701,8 @@ pub const SubType = enum { webassemblymemory, }; -pub const JsApis = flattenTypes(&.{ +// APIs for Page/Window contexts. Used by Snapshot.zig for Page snapshot creation. +pub const PageJsApis = flattenTypes(&.{ @import("../webapi/AbortController.zig"), @import("../webapi/AbortSignal.zig"), @import("../webapi/CData.zig"), @@ -831,6 +844,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/event/FormDataEvent.zig"), @import("../webapi/MessageChannel.zig"), @import("../webapi/MessagePort.zig"), + @import("../webapi/Worker.zig"), @import("../webapi/media/MediaError.zig"), @import("../webapi/media/TextTrackCue.zig"), @import("../webapi/media/VTTCue.zig"), @@ -885,3 +899,33 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/Selection.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/net/URLSearchParams.zig"), + @import("../webapi/encoding/TextEncoder.zig"), + @import("../webapi/encoding/TextDecoder.zig"), + @import("../webapi/File.zig"), + @import("../webapi/Console.zig"), + @import("../webapi/Crypto.zig"), + // @import("../webapi/URL.zig"), + // @import("../webapi/Blob.zig"), + // @import("../webapi/net/FormData.zig"), + // @import("../webapi/Performance.zig"), + // @import("../webapi/net/Response.zig"), + // @import("../webapi/net/Request.zig"), + // @import("../webapi/net/Headers.zig"), + // @import("../webapi/AbortSignal.zig"), + // @import("../webapi/AbortController.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}; diff --git a/src/browser/js/js.zig b/src/browser/js/js.zig index 10867167..74ee0c7a 100644 --- a/src/browser/js/js.zig +++ b/src/browser/js/js.zig @@ -27,6 +27,7 @@ pub const Caller = @import("Caller.zig"); pub const Origin = @import("Origin.zig"); pub const Identity = @import("Identity.zig"); pub const Context = @import("Context.zig"); +pub const Execution = @import("Execution.zig"); pub const Local = @import("Local.zig"); pub const Inspector = @import("Inspector.zig"); pub const Snapshot = @import("Snapshot.zig"); diff --git a/src/browser/tests/worker/echo-worker.js b/src/browser/tests/worker/echo-worker.js new file mode 100644 index 00000000..af92d9b1 --- /dev/null +++ b/src/browser/tests/worker/echo-worker.js @@ -0,0 +1,4 @@ +// Simple worker that echoes messages back with a prefix +onmessage = function(event) { + postMessage({ echo: event.data, from: 'worker' }); +}; diff --git a/src/browser/tests/worker/worker.html b/src/browser/tests/worker/worker.html new file mode 100644 index 00000000..95c5b93e --- /dev/null +++ b/src/browser/tests/worker/worker.html @@ -0,0 +1,206 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/Console.zig b/src/browser/webapi/Console.zig index 084c5a09..9b717ed2 100644 --- a/src/browser/webapi/Console.zig +++ b/src/browser/webapi/Console.zig @@ -29,61 +29,61 @@ _counts: std.StringHashMapUnmanaged(u64) = .{}, pub const init: Console = .{}; -pub fn trace(_: *const Console, values: []js.Value, page: *Page) !void { +pub fn trace(_: *const Console, values: []js.Value, exec: *js.Execution) !void { logger.debug(.js, "console.trace", .{ - .stack = page.js.local.?.stackTrace() catch "???", - .args = ValueWriter{ .page = page, .values = values }, + .stack = exec.context.local.?.stackTrace() catch "???", + .args = ValueWriter{ .values = values }, }); } -pub fn debug(_: *const Console, values: []js.Value, page: *Page) void { - logger.debug(.js, "console.debug", .{ValueWriter{ .page = page, .values = values }}); - page.appendConsoleMessage(.debug, values); +pub fn debug(_: *const Console, values: []js.Value, exec: *js.Execution) void { + logger.debug(.js, "console.debug", .{ValueWriter{ .values = values }}); + appendMessage(exec, .debug, values); } -pub fn info(_: *const Console, values: []js.Value, page: *Page) void { - logger.info(.js, "console.info", .{ValueWriter{ .page = page, .values = values }}); - page.appendConsoleMessage(.info, values); +pub fn info(_: *const Console, values: []js.Value, exec: *js.Execution) void { + logger.info(.js, "console.info", .{ValueWriter{ .values = values }}); + appendMessage(exec, .info, values); } -pub fn log(_: *const Console, values: []js.Value, page: *Page) void { - logger.info(.js, "console.log", .{ValueWriter{ .page = page, .values = values }}); - page.appendConsoleMessage(.log, values); +pub fn log(_: *const Console, values: []js.Value, exec: *js.Execution) void { + logger.info(.js, "console.log", .{ValueWriter{ .values = values }}); + appendMessage(exec, .log, values); } -pub fn warn(_: *const Console, values: []js.Value, page: *Page) void { - logger.warn(.js, "console.warn", .{ValueWriter{ .page = page, .values = values }}); - page.appendConsoleMessage(.warn, values); +pub fn warn(_: *const Console, values: []js.Value, exec: *js.Execution) void { + logger.warn(.js, "console.warn", .{ValueWriter{ .values = values }}); + appendMessage(exec, .warn, values); } pub fn clear(_: *const Console) void {} -pub fn assert(_: *const Console, assertion: js.Value, values: []js.Value, page: *Page) void { +pub fn assert(_: *const Console, assertion: js.Value, values: []js.Value, exec: *js.Execution) void { if (assertion.toBool()) { return; } - logger.warn(.js, "console.assert", .{ValueWriter{ .page = page, .values = values }}); - page.appendConsoleMessage(.warn, values); + logger.warn(.js, "console.assert", .{ValueWriter{ .values = values }}); + appendMessage(exec, .warn, values); } -pub fn @"error"(_: *const Console, values: []js.Value, page: *Page) void { - logger.warn(.js, "console.error", .{ValueWriter{ .page = page, .values = values, .include_stack = true }}); - page.appendConsoleMessage(.@"error", values); +pub fn @"error"(_: *const Console, values: []js.Value, exec: *js.Execution) void { + logger.warn(.js, "console.error", .{ValueWriter{ .values = values, .stack = exec.context.local.?.stackTrace() catch |err| @errorName(err) orelse "???" }}); + appendMessage(exec, .@"error", values); } pub fn table(_: *const Console, data: js.Value, columns: ?js.Value) void { logger.info(.js, "console.table", .{ .data = data, .columns = columns }); } -pub fn count(self: *Console, label_: ?[]const u8, page: *Page) !void { +pub fn count(self: *Console, label_: ?[]const u8, exec: *js.Execution) !void { const label = label_ orelse "default"; - const gop = try self._counts.getOrPut(page.arena, label); + const gop = try self._counts.getOrPut(exec.arena, label); var current: u64 = 0; if (gop.found_existing) { current = gop.value_ptr.*; } else { - gop.key_ptr.* = try page.dupeString(label); + gop.key_ptr.* = try exec.arena.dupe(u8, label); } const c = current + 1; @@ -101,15 +101,15 @@ pub fn countReset(self: *Console, label_: ?[]const u8) !void { logger.info(.js, "console.countReset", .{ .label = label, .count = kv.value }); } -pub fn time(self: *Console, label_: ?[]const u8, page: *Page) !void { +pub fn time(self: *Console, label_: ?[]const u8, exec: *js.Execution) !void { const label = label_ orelse "default"; - const gop = try self._timers.getOrPut(page.arena, label); + const gop = try self._timers.getOrPut(exec.arena, label); if (gop.found_existing) { logger.info(.js, "console.time", .{ .label = label, .err = "duplicate timer" }); return; } - gop.key_ptr.* = try page.dupeString(label); + gop.key_ptr.* = try exec.arena.dupe(u8, label); gop.value_ptr.* = timestamp(); } @@ -134,12 +134,12 @@ pub fn timeEnd(self: *Console, label_: ?[]const u8) void { logger.info(.js, "console.timeEnd", .{ .label = label, .elapsed = elapsed - kv.value }); } -pub fn group(_: *const Console, values: []js.Value, page: *Page) void { - logger.info(.js, "console.group", .{ValueWriter{ .page = page, .values = values }}); +pub fn group(_: *const Console, values: []js.Value) void { + logger.info(.js, "console.group", .{ValueWriter{ .values = values }}); } -pub fn groupCollapsed(_: *const Console, values: []js.Value, page: *Page) void { - logger.info(.js, "console.groupCollapsed", .{ValueWriter{ .page = page, .values = values }}); +pub fn groupCollapsed(_: *const Console, values: []js.Value) void { + logger.info(.js, "console.groupCollapsed", .{ValueWriter{ .values = values }}); } pub fn groupEnd(_: *const Console) void {} @@ -148,17 +148,26 @@ fn timestamp() u64 { return @import("../../datetime.zig").timestamp(.monotonic); } +// Forwards page-context console output to the Page's message buffer (read by +// the `consoleLogs` tool / CDP Runtime.consoleAPICalled). Worker contexts are +// dropped — no buffer is attached there. +fn appendMessage(exec: *js.Execution, level: Page.ConsoleMessage.Level, values: []js.Value) void { + switch (exec.context.global) { + .page => |p| p.appendConsoleMessage(level, values), + .worker => {}, + } +} + const ValueWriter = struct { - page: *Page, values: []js.Value, - include_stack: bool = false, + stack: ?[]const u8 = null, pub fn format(self: ValueWriter, writer: *std.io.Writer) !void { for (self.values, 1..) |value, i| { try writer.print("\n arg({d}): {f}", .{ i, value }); } - if (self.include_stack) { - try writer.print("\n stack: {s}", .{self.page.js.local.?.stackTrace() catch |err| @errorName(err) orelse "???"}); + if (self.stack) |s| { + try writer.print("\n stack: {s}", .{s}); } } diff --git a/src/browser/webapi/DOMException.zig b/src/browser/webapi/DOMException.zig index 55c47e74..c0011cd4 100644 --- a/src/browser/webapi/DOMException.zig +++ b/src/browser/webapi/DOMException.zig @@ -18,7 +18,6 @@ const std = @import("std"); const js = @import("../js/js.zig"); -const Page = @import("../Page.zig"); const DOMException = @This(); @@ -129,7 +128,7 @@ pub fn getMessage(self: *const DOMException) []const u8 { }; } -pub fn toString(self: *const DOMException, page: *Page) ![]const u8 { +pub fn toString(self: *const DOMException, exec: *js.Execution) ![]const u8 { const msg = blk: { if (self._custom_message) |msg| { break :blk msg; @@ -139,7 +138,7 @@ pub fn toString(self: *const DOMException, page: *Page) ![]const u8 { else => break :blk self.getMessage(), } }; - return std.fmt.bufPrint(&page.buf, "{s}: {s}", .{ self.getName(), msg }) catch return msg; + return std.fmt.bufPrint(exec.buf, "{s}: {s}", .{ self.getName(), msg }) catch return msg; } const Code = enum(u8) { diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index 60dfbf11..6147d6a9 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -19,11 +19,13 @@ const std = @import("std"); const js = @import("../js/js.zig"); -const Page = @import("../Page.zig"); +const Session = @import("../Session.zig"); const EventManager = @import("../EventManager.zig"); -const RegisterOptions = EventManager.RegisterOptions; const Event = @import("Event.zig"); +const WorkerGlobalScope = @import("WorkerGlobalScope.zig"); + +const RegisterOptions = EventManager.RegisterOptions; const EventTarget = @This(); @@ -34,6 +36,8 @@ pub const Type = union(enum) { generic: void, node: *@import("Node.zig"), window: *@import("Window.zig"), + worker: *@import("Worker.zig"), + worker_global_scope: *@import("WorkerGlobalScope.zig"), xhr: *@import("net/XMLHttpRequestEventTarget.zig"), abort_signal: *@import("AbortSignal.zig"), media_query_list: *@import("css/MediaQueryList.zig"), @@ -48,21 +52,26 @@ pub const Type = union(enum) { websocket: *@import("net/WebSocket.zig"), }; -pub fn init(page: *Page) !*EventTarget { - return page._factory.create(EventTarget{ +pub fn init(session: *Session) !*EventTarget { + return session.factory.create(EventTarget{ ._type = .generic, }); } -pub fn dispatchEvent(self: *EventTarget, event: *Event, page: *Page) !bool { +pub fn dispatchEvent(self: *EventTarget, event: *Event, exec: *js.Execution) !bool { if (event._event_phase != .none) { return error.InvalidStateError; } event._is_trusted = false; - event.acquireRef(); - defer _ = event.releaseRef(page._session); - try page._event_manager.dispatch(self, event); + switch (exec.context.global) { + .page => |page| { + event.acquireRef(); + defer _ = event.releaseRef(page._session); + try page._event_manager.dispatch(self, event); + }, + .worker => |wgs| try wgs.dispatch(self, event, null), + } return !event._cancelable or !event._prevent_default; } @@ -75,12 +84,12 @@ pub const EventListenerCallback = union(enum) { function: js.Function, object: js.Object, }; -pub fn addEventListener(self: *EventTarget, typ: []const u8, callback_: ?EventListenerCallback, opts_: ?AddEventListenerOptions, page: *Page) !void { +pub fn addEventListener(self: *EventTarget, typ: []const u8, callback_: ?EventListenerCallback, opts_: ?AddEventListenerOptions, exec: *js.Execution) !void { const callback = callback_ orelse return; - const em_callback = switch (callback) { - .object => |obj| EventManager.Callback{ .object = obj }, - .function => |func| EventManager.Callback{ .function = func }, + const em_callback: EventManager.Callback = switch (callback) { + .object => |obj| .{ .object = obj }, + .function => |func| .{ .function = func }, }; const options = blk: { @@ -90,7 +99,11 @@ pub fn addEventListener(self: *EventTarget, typ: []const u8, callback_: ?EventLi .capture => |capture| RegisterOptions{ .capture = capture }, }; }; - return page._event_manager.register(self, typ, em_callback, options); + + switch (exec.context.global) { + .page => |page| _ = try page._event_manager.register(self, typ, em_callback, options), + .worker => |wgs| _ = try wgs._event_manager.register(self, typ, em_callback, options), + } } const RemoveEventListenerOptions = union(enum) { @@ -101,7 +114,7 @@ const RemoveEventListenerOptions = union(enum) { capture: bool = false, }; }; -pub fn removeEventListener(self: *EventTarget, typ: []const u8, callback_: ?EventListenerCallback, opts_: ?RemoveEventListenerOptions, page: *Page) !void { +pub fn removeEventListener(self: *EventTarget, typ: []const u8, callback_: ?EventListenerCallback, opts_: ?RemoveEventListenerOptions, exec: *js.Execution) !void { const callback = callback_ orelse return; // For object callbacks, check if handleEvent exists @@ -111,9 +124,9 @@ pub fn removeEventListener(self: *EventTarget, typ: []const u8, callback_: ?Even } } - const em_callback = switch (callback) { - .function => |func| EventManager.Callback{ .function = func }, - .object => |obj| EventManager.Callback{ .object = obj }, + const em_callback: EventManager.Callback = switch (callback) { + .function => |func| .{ .function = func }, + .object => |obj| .{ .object = obj }, }; const use_capture = blk: { @@ -123,7 +136,11 @@ pub fn removeEventListener(self: *EventTarget, typ: []const u8, callback_: ?Even .options => |opts| opts.capture, }; }; - return page._event_manager.remove(self, typ, em_callback, use_capture); + + switch (exec.context.global) { + .page => |page| page._event_manager.remove(self, typ, em_callback, use_capture), + .worker => |wgs| wgs._event_manager.remove(self, typ, em_callback, use_capture), + } } pub fn format(self: *EventTarget, writer: *std.Io.Writer) !void { @@ -131,6 +148,8 @@ pub fn format(self: *EventTarget, writer: *std.Io.Writer) !void { .node => |n| n.format(writer), .generic => writer.writeAll(""), .window => writer.writeAll(""), + .worker => writer.writeAll(""), + .worker_global_scope => writer.writeAll(""), .xhr => writer.writeAll(""), .abort_signal => writer.writeAll(""), .media_query_list => writer.writeAll(""), @@ -151,6 +170,8 @@ pub fn toString(self: *EventTarget) []const u8 { .node => return "[object Node]", .generic => return "[object EventTarget]", .window => return "[object Window]", + .worker => return "[object Worker]", + .worker_global_scope => return "[object WorkerGlobalScope]", .xhr => return "[object XMLHttpRequestEventTarget]", .abort_signal => return "[object AbortSignal]", .media_query_list => return "[object MediaQueryList]", diff --git a/src/browser/webapi/File.zig b/src/browser/webapi/File.zig index e4c70662..9f4cfb46 100644 --- a/src/browser/webapi/File.zig +++ b/src/browser/webapi/File.zig @@ -20,7 +20,6 @@ const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../js/js.zig"); -const Page = @import("../Page.zig"); const Session = @import("../Session.zig"); const Blob = @import("Blob.zig"); @@ -30,10 +29,10 @@ const File = @This(); _proto: *Blob, // TODO: Implement File API. -pub fn init(page: *Page) !*File { - const arena = try page.getArena(.tiny, "File"); - errdefer page.releaseArena(arena); - return page._factory.blob(arena, File{ ._proto = undefined }); +pub fn init(session: *Session) !*File { + const arena = try session.getArena(.tiny, "File"); + errdefer session.releaseArena(arena); + return session.factory.blob(arena, File{ ._proto = undefined }); } pub const JsApi = struct { diff --git a/src/browser/webapi/History.zig b/src/browser/webapi/History.zig index ccbb4f43..19325b7a 100644 --- a/src/browser/webapi/History.zig +++ b/src/browser/webapi/History.zig @@ -62,7 +62,7 @@ pub fn pushState(_: *History, state: js.Value, _: ?[]const u8, _url: ?[]const u8 _ = try page._session.navigation.pushEntry(url, .{ .source = .history, .value = json }, page, true); page.url = url; - page.window._location._url = try URL.init(url, null, page); + page.window._location._url = try URL.init(url, null, &page.js.execution); } pub fn replaceState(_: *History, state: js.Value, _: ?[]const u8, _url: ?[]const u8, page: *Page) !void { diff --git a/src/browser/webapi/KeyValueList.zig b/src/browser/webapi/KeyValueList.zig index dea6f7cb..7a19c15b 100644 --- a/src/browser/webapi/KeyValueList.zig +++ b/src/browser/webapi/KeyValueList.zig @@ -24,6 +24,7 @@ const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const h5e = @import("../parser/html5ever.zig"); +const Execution = js.Execution; const Allocator = std.mem.Allocator; pub fn registerTypes() []const type { @@ -34,7 +35,7 @@ pub fn registerTypes() []const type { }; } -const Normalizer = *const fn ([]const u8, *Page) []const u8; +const Normalizer = *const fn ([]const u8, []u8) []const u8; pub const Entry = struct { name: String, @@ -62,14 +63,14 @@ pub fn copy(arena: Allocator, original: KeyValueList) !KeyValueList { return list; } -pub fn fromJsObject(arena: Allocator, js_obj: js.Object, comptime normalizer: ?Normalizer, page: *Page) !KeyValueList { +pub fn fromJsObject(arena: Allocator, js_obj: js.Object, comptime normalizer: ?Normalizer, buf: []u8) !KeyValueList { var it = try js_obj.nameIterator(); var list = KeyValueList.init(); try list.ensureTotalCapacity(arena, it.count); while (try it.next()) |name| { const js_value = try js_obj.get(name); - const normalized = if (comptime normalizer) |n| n(name, page) else name; + const normalized = if (comptime normalizer) |n| n(name, buf) else name; list._entries.appendAssumeCapacity(.{ .name = try String.init(arena, normalized, .{}), @@ -80,12 +81,12 @@ pub fn fromJsObject(arena: Allocator, js_obj: js.Object, comptime normalizer: ?N return list; } -pub fn fromArray(arena: Allocator, kvs: []const [2][]const u8, comptime normalizer: ?Normalizer, page: *Page) !KeyValueList { +pub fn fromArray(arena: Allocator, kvs: []const [2][]const u8, comptime normalizer: ?Normalizer, buf: []u8) !KeyValueList { var list = KeyValueList.init(); try list.ensureTotalCapacity(arena, kvs.len); for (kvs) |pair| { - const normalized = if (comptime normalizer) |n| n(pair[0], page) else pair[0]; + const normalized = if (comptime normalizer) |n| n(pair[0], buf) else pair[0]; list._entries.appendAssumeCapacity(.{ .name = try String.init(arena, normalized, .{}), @@ -112,12 +113,11 @@ pub fn get(self: *const KeyValueList, name: []const u8) ?[]const u8 { return null; } -pub fn getAll(self: *const KeyValueList, name: []const u8, page: *Page) ![]const []const u8 { - const arena = page.call_arena; +pub fn getAll(self: *const KeyValueList, allocator: Allocator, name: []const u8) ![]const []const u8 { var arr: std.ArrayList([]const u8) = .empty; for (self._entries.items) |*entry| { if (entry.name.eqlSlice(name)) { - try arr.append(arena, entry.value.str()); + try arr.append(allocator, entry.value.str()); } } return arr.items; @@ -321,7 +321,7 @@ pub const Iterator = struct { pub const Entry = struct { []const u8, []const u8 }; - pub fn next(self: *Iterator, _: *const Page) ?Iterator.Entry { + pub fn next(self: *Iterator, _: *const Execution) ?Iterator.Entry { const index = self.index; const entries = self.kv._entries.items; if (index >= entries.len) { diff --git a/src/browser/webapi/Location.zig b/src/browser/webapi/Location.zig index 9055abbb..caf1c4ae 100644 --- a/src/browser/webapi/Location.zig +++ b/src/browser/webapi/Location.zig @@ -27,7 +27,7 @@ const Location = @This(); _url: *URL, pub fn init(raw_url: [:0]const u8, page: *Page) !*Location { - const url = try URL.init(raw_url, null, page); + const url = try URL.init(raw_url, null, &page.js.execution); return page._factory.create(Location{ ._url = url, }); @@ -53,12 +53,12 @@ pub fn getPort(self: *const Location) []const u8 { return self._url.getPort(); } -pub fn getOrigin(self: *const Location, page: *const Page) ![]const u8 { - return self._url.getOrigin(page); +pub fn getOrigin(self: *const Location, exec: *const js.Execution) ![]const u8 { + return self._url.getOrigin(exec); } -pub fn getSearch(self: *const Location, page: *const Page) ![]const u8 { - return self._url.getSearch(page); +pub fn getSearch(self: *const Location, exec: *const js.Execution) ![]const u8 { + return self._url.getSearch(exec); } 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 }); } -pub fn toString(self: *const Location, page: *const Page) ![:0]const u8 { - return self._url.toString(page); +pub fn toString(self: *const Location, exec: *const js.Execution) ![:0]const u8 { + return self._url.toString(exec); } pub const JsApi = struct { diff --git a/src/browser/webapi/MessagePort.zig b/src/browser/webapi/MessagePort.zig index a7bb9bfc..9164f377 100644 --- a/src/browser/webapi/MessagePort.zig +++ b/src/browser/webapi/MessagePort.zig @@ -128,7 +128,7 @@ const PostMessageCallback = struct { .data = .{ .value = self.message }, .origin = "", .source = null, - }, page) catch |err| { + }, page._session) catch |err| { log.err(.dom, "MessagePort.postMessage", .{ .err = err }); return null; }).asEvent(); diff --git a/src/browser/webapi/URL.zig b/src/browser/webapi/URL.zig index 8856c83d..c2d40765 100644 --- a/src/browser/webapi/URL.zig +++ b/src/browser/webapi/URL.zig @@ -23,6 +23,7 @@ const U = @import("../URL.zig"); const Page = @import("../Page.zig"); const URLSearchParams = @import("net/URLSearchParams.zig"); const Blob = @import("Blob.zig"); +const Execution = js.Execution; const Allocator = std.mem.Allocator; @@ -36,11 +37,12 @@ _search_params: ?*URLSearchParams = null, pub const resolve = @import("../URL.zig").resolve; pub const eqlDocument = @import("../URL.zig").eqlDocument; -pub fn init(url: [:0]const u8, base_: ?[:0]const u8, page: *Page) !*URL { - const arena = page.arena; +pub fn init(url: [:0]const u8, base_: ?[:0]const u8, exec: *const Execution) !*URL { + const arena = exec.arena; + const context_url = exec.url.*; if (std.mem.eql(u8, url, "about:blank")) { - return page._factory.create(URL{ + return exec._factory.create(URL{ ._raw = "about:blank", ._arena = arena, }); @@ -48,9 +50,9 @@ pub fn init(url: [:0]const u8, base_: ?[:0]const u8, page: *Page) !*URL { const url_is_absolute = @import("../URL.zig").isCompleteHTTPUrl(url); const base = if (base_) |b| blk: { - // If URL is absolute, base is ignored (but we still use page.url internally) + // If URL is absolute, base is ignored (but we still use context url internally) if (url_is_absolute) { - break :blk page.url; + break :blk context_url; } // For relative URLs, base must be a valid absolute URL if (!@import("../URL.zig").isCompleteHTTPUrl(b)) { @@ -59,11 +61,11 @@ pub fn init(url: [:0]const u8, base_: ?[:0]const u8, page: *Page) !*URL { break :blk b; } else if (!url_is_absolute) { return error.TypeError; - } else page.url; + } else context_url; const raw = try resolve(arena, base, url, .{ .always_dupe = true }); - return page._factory.create(URL{ + return exec._factory.create(URL{ ._raw = raw, ._arena = arena, }); @@ -107,20 +109,20 @@ pub fn getPort(self: *const URL) []const u8 { return U.getPort(self._raw); } -pub fn getOrigin(self: *const URL, page: *const Page) ![]const u8 { - return (try U.getOrigin(page.call_arena, self._raw)) orelse { +pub fn getOrigin(self: *const URL, exec: *const Execution) ![]const u8 { + return (try U.getOrigin(exec.call_arena, self._raw)) orelse { // yes, a null string, that's what the spec wants return "null"; }; } -pub fn getSearch(self: *const URL, page: *const Page) ![]const u8 { +pub fn getSearch(self: *const URL, exec: *const Execution) ![]const u8 { // If searchParams has been accessed, generate search from it if (self._search_params) |sp| { if (sp.getSize() == 0) { return ""; } - var buf = std.Io.Writer.Allocating.init(page.call_arena); + var buf = std.Io.Writer.Allocating.init(exec.call_arena); try buf.writer.writeByte('?'); try sp.toString(&buf.writer); return buf.written(); @@ -132,30 +134,30 @@ pub fn getHash(self: *const URL) []const u8 { return U.getHash(self._raw); } -pub fn getSearchParams(self: *URL, page: *Page) !*URLSearchParams { +pub fn getSearchParams(self: *URL, exec: *const Execution) !*URLSearchParams { if (self._search_params) |sp| { return sp; } // Get current search string (without the '?') - const search = try self.getSearch(page); + const search = try self.getSearch(exec); const search_value = if (search.len > 0) search[1..] else ""; - const params = try URLSearchParams.init(.{ .query_string = search_value }, page); + const params = try URLSearchParams.init(.{ .query_string = search_value }, exec); self._search_params = params; return params; } -pub fn setHref(self: *URL, value: []const u8, page: *Page) !void { - const base = if (U.isCompleteHTTPUrl(value)) page.url else self._raw; - const raw = try U.resolve(self._arena orelse page.arena, base, value, .{ .always_dupe = true }); +pub fn setHref(self: *URL, value: []const u8, exec: *const Execution) !void { + const base = if (U.isCompleteHTTPUrl(value)) exec.url.* else self._raw; + const raw = try U.resolve(self._arena orelse exec.arena, base, value, .{ .always_dupe = true }); self._raw = raw; // Update existing searchParams if it exists if (self._search_params) |sp| { const search = U.getSearch(raw); const search_value = if (search.len > 0) search[1..] else ""; - try sp.updateFromString(search_value, page); + try sp.updateFromString(search_value, exec); } } @@ -184,7 +186,7 @@ pub fn setPathname(self: *URL, value: []const u8) !void { self._raw = try U.setPathname(self._raw, value, allocator); } -pub fn setSearch(self: *URL, value: []const u8, page: *Page) !void { +pub fn setSearch(self: *URL, value: []const u8, exec: *const Execution) !void { const allocator = self._arena orelse return error.NoAllocator; self._raw = try U.setSearch(self._raw, value, allocator); @@ -192,7 +194,7 @@ pub fn setSearch(self: *URL, value: []const u8, page: *Page) !void { if (self._search_params) |sp| { const search = U.getSearch(self._raw); const search_value = if (search.len > 0) search[1..] else ""; - try sp.updateFromString(search_value, page); + try sp.updateFromString(search_value, exec); } } @@ -201,7 +203,7 @@ pub fn setHash(self: *URL, value: []const u8) !void { self._raw = try U.setHash(self._raw, value, allocator); } -pub fn toString(self: *const URL, page: *const Page) ![:0]const u8 { +pub fn toString(self: *const URL, exec: *const Execution) ![:0]const u8 { const sp = self._search_params orelse { return self._raw; }; @@ -217,7 +219,7 @@ pub fn toString(self: *const URL, page: *const Page) ![:0]const u8 { const hash = self.getHash(); // Build the new URL string - var buf = std.Io.Writer.Allocating.init(page.call_arena); + var buf = std.Io.Writer.Allocating.init(exec.call_arena); try buf.writer.writeAll(base); // Add / if missing (e.g., "https://example.com" -> "https://example.com/") diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index ef076663..9e716e91 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -335,7 +335,7 @@ pub fn reportError(self: *Window, err: js.Value, page: *Page) !void { .message = err.toStringSlice() catch "Unknown error", .bubbles = false, .cancelable = true, - }, page); + }, page._session); // Invoke window.onerror callback if set (per WHATWG spec, this is called // with 5 arguments: message, source, lineno, colno, error) @@ -411,7 +411,7 @@ pub fn postMessage(self: *Window, message: js.Value.Temp, target_origin: ?[]cons errdefer target_page.releaseArena(arena); // Origin should be the source window's origin (where the message came from) - const origin = try source_window._location.getOrigin(page); + const origin = try source_window._location.getOrigin(&page.js.execution); const callback = try arena.create(PostMessageCallback); callback.* = .{ .arena = arena, @@ -429,27 +429,11 @@ pub fn postMessage(self: *Window, message: js.Value.Temp, target_origin: ?[]cons } pub fn btoa(_: *const Window, input: []const u8, page: *Page) ![]const u8 { - 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); + return @import("encoding/base64.zig").encode(page.call_arena, input); } pub fn atob(_: *const Window, input: []const u8, page: *Page) ![]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 page.call_arena.alloc(u8, decoded_len); - std.base64.standard_no_pad.Decoder.decode(decoded, unpadded) catch return error.InvalidCharacterError; - return decoded; + return @import("encoding/base64.zig").decode(page.call_arena, input); } pub fn structuredClone(_: *const Window, value: js.Value) !js.Value { @@ -590,6 +574,7 @@ pub fn scrollBy(self: *Window, opts: ScrollToOpts, y: ?i32, page: *Page) !void { pub fn unhandledPromiseRejection(self: *Window, no_handler: bool, rejection: js.PromiseRejection, page: *Page) !void { if (comptime IS_DEBUG) { log.debug(.js, "unhandled rejection", .{ + .target = "window", .value = rejection.reason(), .stack = rejection.local.stackTrace() catch |err| @errorName(err) orelse "???", }); @@ -607,7 +592,7 @@ pub fn unhandledPromiseRejection(self: *Window, no_handler: bool, rejection: js. const event = (try @import("event/PromiseRejectionEvent.zig").init(event_name, .{ .reason = if (rejection.reason()) |r| try r.temp() else null, .promise = try rejection.promise().temp(), - }, page)).asEvent(); + }, page._session)).asEvent(); try page._event_manager.dispatchDirect(target, event, attribute_callback, .{ .context = "window.unhandledrejection" }); } } @@ -796,7 +781,7 @@ const PostMessageCallback = struct { .source = self.source, .bubbles = false, .cancelable = false, - }, page)).asEvent(); + }, page._session)).asEvent(); try page._event_manager.dispatchDirect(event_target, event, window._on_message, .{ .context = "window.postMessage" }); } diff --git a/src/browser/webapi/Worker.zig b/src/browser/webapi/Worker.zig new file mode 100644 index 00000000..d4f7f54b --- /dev/null +++ b/src/browser/webapi/Worker.zig @@ -0,0 +1,405 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); + +const js = @import("../js/js.zig"); +const log = @import("../../log.zig"); +const http = @import("../../network/http.zig"); + +const URL = @import("../URL.zig"); +const Page = @import("../Page.zig"); +const Session = @import("../Session.zig"); +const HttpClient = @import("../HttpClient.zig"); + +const Blob = @import("Blob.zig"); +const EventTarget = @import("EventTarget.zig"); +const MessageEvent = @import("event/MessageEvent.zig"); +const ErrorEvent = @import("event/ErrorEvent.zig"); +const WorkerGlobalScope = @import("WorkerGlobalScope.zig"); + +const Execution = js.Execution; +const Allocator = std.mem.Allocator; +const IS_DEBUG = @import("builtin").mode == .Debug; + +const Worker = @This(); + +// used by HttpClient when generating notification +// Ultimately used by CDP to generate request/loader ids. +id: u32, +_pseudo_frame_id: u32, + +_proto: *EventTarget, +_page: *Page, +_arena: Allocator, +_worker_scope: *WorkerGlobalScope, + +_url: [:0]const u8, +_script_loaded: bool = false, +_script_buffer: std.ArrayList(u8) = .empty, +_http_response: ?HttpClient.Response = null, + +// Event handlers +_on_error: ?js.Function.Global = null, +_on_message: ?js.Function.Global = null, +_on_messageerror: ?js.Function.Global = null, + +pub fn init(url: []const u8, exec: *Execution) !*Worker { + const page = switch (exec.context.global) { + .page => |p| p, + .worker => return error.WorkerCannotCreateWorker, + }; + const session = page._session; + + const arena = try session.getArena(.large, "Worker"); + errdefer session.releaseArena(arena); + + const resolved_url = try URL.resolve(arena, exec.url.*, url, .{}); + const self = try session.factory.eventTargetWithAllocator(arena, Worker{ + .id = session.nextPageId(), + ._pseudo_frame_id = session.nextFrameId(), + ._arena = arena, + ._proto = undefined, + ._page = page, + ._url = resolved_url, + ._worker_scope = undefined, + }); + self._worker_scope = try WorkerGlobalScope.init(self, resolved_url); + errdefer self._worker_scope.deinit(); + try page.trackWorker(self); + + if (std.mem.startsWith(u8, url, "blob:")) { + errdefer page.removeWorker(self); + const blob: *Blob = page.lookupBlobUrl(url) orelse { + log.warn(.js, "invalid blob", .{ .target = "worker" }); + return error.BlobNotFound; + }; + try self.loadInitialScript(blob._slice); + return self; + } + + const http_client = session.browser.http_client; + http_client.request(.{ + .ctx = self, + .url = resolved_url, + .method = .GET, + .headers = try http_client.newHeaders(), + .page_id = self.id, + .frame_id = self._pseudo_frame_id, + .resource_type = .script, + .cookie_jar = &session.cookie_jar, + .cookie_origin = resolved_url, + .notification = session.notification, + .header_callback = httpHeaderCallback, + .data_callback = httpDataCallback, + .done_callback = httpDoneCallback, + .error_callback = httpErrorCallback, + }) catch |err| { + log.err(.browser, "Worker request", .{ .url = resolved_url, .err = err }); + page.removeWorker(self); + return err; + }; + return self; +} + +// Called from Page.deinit when the page is destroyed, so we don't need to +// remove from the page's worker list. +pub fn deinit(self: *Worker) void { + if (self._http_response) |res| { + res.abort(error.Abort); + self._http_response = null; + } + self._worker_scope.deinit(); + self._page._session.releaseArena(self._arena); +} + +pub fn asEventTarget(self: *Worker) *EventTarget { + return self._proto; +} + +fn httpHeaderCallback(response: HttpClient.Response) !bool { + const self: *Worker = @ptrCast(@alignCast(response.ctx)); + + const status = response.status() orelse return false; + if (status < 200 or status >= 300) { + log.warn(.browser, "Worker status", .{ + .url = self._url, + .status = status, + }); + return false; + } + + self._http_response = response; + if (response.contentLength()) |cl| { + try self._script_buffer.ensureTotalCapacity(self._arena, cl); + } + + return true; +} + +fn httpDataCallback(response: HttpClient.Response, data: []const u8) !void { + const self: *Worker = @ptrCast(@alignCast(response.ctx)); + try self._script_buffer.appendSlice(self._arena, data); +} + +fn httpDoneCallback(ctx: *anyopaque) !void { + const self: *Worker = @ptrCast(@alignCast(ctx)); + self._http_response = null; + self._script_loaded = true; + + const url = self._url; + const script = self._script_buffer.items; + + if (comptime IS_DEBUG) { + log.info(.browser, "worker fetch done", .{ + .url = url, + .len = script.len, + }); + } + + try self.loadInitialScript(script); +} + +fn loadInitialScript(self: *Worker, script: []const u8) !void { + var ls: js.Local.Scope = undefined; + self._worker_scope.js.localScope(&ls); + defer ls.deinit(); + + var try_catch: js.TryCatch = undefined; + try_catch.init(&ls.local); + defer try_catch.deinit(); + + _ = ls.local.eval(script, self._url) catch |err| { + const caught = try_catch.caughtOrError(self._arena, err); + log.err(.browser, "worker script error", .{ .url = self._url, .caught = caught }); + self.fireErrorEvent(caught.exception orelse @errorName(err), null); + return; + }; + + ls.local.runMacrotasks(); +} + +fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void { + const self: *Worker = @ptrCast(@alignCast(ctx)); + self._http_response = null; + + log.err(.browser, "worker fetch error", .{ + .url = self._worker_scope.url, + .err = err, + }); + + self.fireErrorEvent(@errorName(err), null); +} + +// Fire an error event on the Worker object (parent context) +fn fireErrorEvent(self: *Worker, message: []const u8, error_value: ?js.Value.Temp) void { + self._fireErrorEvent(message, error_value) catch |err| { + log.warn(.browser, "worker fire error", .{ .err = err, .message = message }); + }; +} + +fn _fireErrorEvent(self: *Worker, message: []const u8, error_value: ?js.Value.Temp) !void { + const page = self._page; + const session = page._session; + const target = self.asEventTarget(); + const on_error = self._on_error; + + // Check if there are any listeners + if (!page._event_manager.hasDirectListeners(target, "error", on_error)) { + if (error_value) |ev| ev.release(); + return; + } + + const error_event = try ErrorEvent.initTrusted(comptime .wrap("error"), .{ + .@"error" = error_value, + .message = message, + .filename = self._url, + .bubbles = false, + .cancelable = true, + }, session); + + try page._event_manager.dispatchDirect(target, error_event.asEvent(), on_error, .{ + .context = "Worker.onerror", + }); +} + +pub fn terminate(self: *Worker) void { + // Abort any pending script fetch + if (self._http_response) |resp| { + resp.abort(error.Abort); + self._http_response = null; + } + + self._page.removeWorker(self); +} + +// Posts a message from the page to the worker. +pub fn postMessage(self: *Worker, data: js.Value) !void { + try self._worker_scope.receiveMessage(data); +} + +// Called internally by WorkerGlobalScope when it wants to post a message to us +pub fn receiveMessage(self: *Worker, data: js.Value) !void { + const page = self._page; + const cloned_data = blk: { + var ls: js.Local.Scope = undefined; + page.js.localScope(&ls); + defer ls.deinit(); + + // clones from where it currently is (the Worker context) to our Page's context + const cloned = data.structuredCloneTo(&ls.local) catch |err| break :blk err; + break :blk cloned.temp(); + }; + + const message_arena = try page.getArena(.tiny, "Worker.receiveMessage"); + errdefer page.releaseArena(message_arena); + + const callback = try message_arena.create(ReceiveMessageCallback); + callback.* = .{ + .worker = self, + .data = cloned_data, + .arena = message_arena, + }; + + try page.js.scheduler.add(callback, ReceiveMessageCallback.run, 0, .{ + .name = "Worker.receiveMessage", + .low_priority = false, + .finalizer = ReceiveMessageCallback.cancelled, + }); +} + +pub fn getOnMessage(self: *const Worker) ?js.Function.Global { + return self._on_message; +} + +pub fn setOnMessage(self: *Worker, setter: ?FunctionSetter) void { + self._on_message = getFunctionFromSetter(setter); +} + +pub fn getOnMessageError(self: *const Worker) ?js.Function.Global { + return self._on_messageerror; +} + +pub fn setOnMessageError(self: *Worker, setter: ?FunctionSetter) void { + self._on_messageerror = getFunctionFromSetter(setter); +} + +pub fn getOnError(self: *const Worker) ?js.Function.Global { + return self._on_error; +} + +pub fn setOnError(self: *Worker, setter: ?FunctionSetter) void { + self._on_error = getFunctionFromSetter(setter); +} + +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, + }; +} + +const ReceiveMessageCallback = struct { + data: anyerror!js.Value.Temp, + arena: Allocator, + worker: *Worker, + + fn cancelled(ctx: *anyopaque) void { + const self: *ReceiveMessageCallback = @ptrCast(@alignCast(ctx)); + if (self.data) |d| { + d.release(); + } else |_| {} + self.deinit(); + } + + fn deinit(self: *ReceiveMessageCallback) void { + self.worker._page._session.releaseArena(self.arena); + } + + fn run(ctx: *anyopaque) !?u32 { + const self: *ReceiveMessageCallback = @ptrCast(@alignCast(ctx)); + defer self.deinit(); + + const worker = self.worker; + const page = worker._page; + const target = worker.asEventTarget(); + + // If data is null, structured clone failed - fire messageerror + const data = self.data catch |err| { + const on_messageerror = worker._on_messageerror; + if (!page._event_manager.hasDirectListeners(target, "messageerror", on_messageerror)) { + return null; + } + const event = (try MessageEvent.initTrusted(comptime .wrap("messageerror"), .{ + .data = .{ .string = @errorName(err) }, + .bubbles = false, + .cancelable = false, + }, page._session)).asEvent(); + try page._event_manager.dispatchDirect(target, event, on_messageerror, .{ .context = "Worker.messageerror" }); + return null; + }; + + const on_message = worker._on_message; + + // Check if there are any listeners before creating the event + if (!page._event_manager.hasDirectListeners(target, "message", on_message)) { + data.release(); + return null; + } + + const event = (try MessageEvent.initTrusted(comptime .wrap("message"), .{ + .data = .{ .value = data }, + .bubbles = false, + .cancelable = false, + }, page._session)).asEvent(); + + try page._event_manager.dispatchDirect(target, event, on_message, .{ .context = "Worker.receiveMessage" }); + + return null; + } +}; + +pub const JsApi = struct { + pub const bridge = js.Bridge(Worker); + + pub const Meta = struct { + pub const name = "Worker"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(Worker.init, .{}); + + pub const terminate = bridge.function(Worker.terminate, .{}); + pub const postMessage = bridge.function(Worker.postMessage, .{}); + + pub const onmessage = bridge.accessor(Worker.getOnMessage, Worker.setOnMessage, .{}); + pub const onmessageerror = bridge.accessor(Worker.getOnMessageError, Worker.setOnMessageError, .{}); + pub const onerror = bridge.accessor(Worker.getOnError, Worker.setOnError, .{}); +}; + +const testing = @import("../../testing.zig"); +test "WebApi: Worker" { + try testing.htmlRunner("worker", .{}); +} diff --git a/src/browser/webapi/WorkerGlobalScope.zig b/src/browser/webapi/WorkerGlobalScope.zig new file mode 100644 index 00000000..ecae102a --- /dev/null +++ b/src/browser/webapi/WorkerGlobalScope.zig @@ -0,0 +1,427 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// The struct is like a mix of Page and Window, but a very limited Page and +// a very limited Window. This dual-purpose does make it a bit harder to know +// what's what...e.g what is a WebAPI call and what it called internally. + +const std = @import("std"); + +const log = @import("../../log.zig"); + +const JS = @import("../js/js.zig"); +const Factory = @import("../Factory.zig"); +const Session = @import("../Session.zig"); +const EventManagerBase = @import("../EventManagerBase.zig"); + +const Worker = @import("Worker.zig"); +const Crypto = @import("Crypto.zig"); +const Console = @import("Console.zig"); +const EventTarget = @import("EventTarget.zig"); +const MessageEvent = @import("event/MessageEvent.zig"); +const ErrorEvent = @import("event/ErrorEvent.zig"); + +const builtin = @import("builtin"); +const IS_DEBUG = builtin.mode == .Debug; + +const Allocator = std.mem.Allocator; + +const WorkerGlobalScope = @This(); + +// Meant to follow the same field naming as Page so that an anytype of generic +// can access these the same for a Page of a WGS. +// These fields represent the "Page"-like component of the WGS +_session: *Session, +_factory: *Factory, +_identity: JS.Identity = .{}, +arena: Allocator, +call_arena: Allocator, +url: [:0]const u8, +buf: [1024]u8 = undefined, // same size as page.buf +js: *JS.Context, + +// Reference back to the Worker object (for postMessage to page) +_worker: *Worker, + +// Event management for non-DOM targets in worker context +_event_manager: EventManagerBase, + +// These fields represent the "Window"-like component of the WGS +_closed: bool = false, +_proto: *EventTarget, +_console: Console = .init, +_crypto: Crypto = .init, +_on_error: ?JS.Function.Global = null, +_on_rejection_handled: ?JS.Function.Global = null, +_on_unhandled_rejection: ?JS.Function.Global = null, +_on_message: ?JS.Function.Global = null, +_on_messageerror: ?JS.Function.Global = null, + +pub fn init(worker: *Worker, url: [:0]const u8) !*WorkerGlobalScope { + const arena = worker._arena; + const session = worker._page._session; + const factory = &session.factory; + + const call_arena = try session.getArena(.small, "WorkerGlobalScope.call_arena"); + errdefer session.releaseArena(call_arena); + + const self = try factory.eventTargetWithAllocator(arena, WorkerGlobalScope{ + .url = url, + .arena = arena, + .js = undefined, + .call_arena = call_arena, + ._session = session, + ._identity = .{}, + ._proto = undefined, + ._factory = factory, + ._worker = worker, + ._event_manager = .init(arena), + }); + errdefer factory.destroy(self); + + self.js = try session.browser.env.createWorkerContext(self, .{ + .call_arena = call_arena, + .identity_arena = arena, + .identity = &self._identity, + }); + + return self; +} + +pub fn deinit(self: *WorkerGlobalScope) void { + self._identity.deinit(); + const session = self._session; + session.browser.env.destroyContext(self.js); + session.releaseArena(self.call_arena); +} + +pub fn base(self: *const WorkerGlobalScope) [:0]const u8 { + return self.url; +} + +pub fn asEventTarget(self: *WorkerGlobalScope) *EventTarget { + return self._proto; +} + +const Event = @import("Event.zig"); + +// Dispatch an event to listeners on the given target within this worker context. +pub fn dispatch(self: *WorkerGlobalScope, target: *EventTarget, event: *Event, handler: anytype) !void { + try self._event_manager.dispatchDirect( + self.call_arena, + self.js, + target, + event, + handler, + self._session, + .{}, + ); +} + +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 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 getOnMessage(self: *const WorkerGlobalScope) ?JS.Function.Global { + return self._on_message; +} + +pub fn setOnMessage(self: *WorkerGlobalScope, setter: ?FunctionSetter) void { + self._on_message = getFunctionFromSetter(setter); +} + +pub fn getOnMessageError(self: *const WorkerGlobalScope) ?JS.Function.Global { + return self._on_messageerror; +} + +pub fn setOnMessageError(self: *WorkerGlobalScope, setter: ?FunctionSetter) void { + self._on_messageerror = getFunctionFromSetter(setter); +} + +// Posts a message from the worker back to the page. +// The message is cloned via structured clone and dispatched on the Worker object. +pub fn postMessage(self: *WorkerGlobalScope, data: JS.Value) !void { + try self._worker.receiveMessage(data); +} + +// Called internally by Worker when it wants to post a message to us +pub fn receiveMessage(self: *WorkerGlobalScope, data: JS.Value) !void { + if (self._closed) { + return; + } + + const cloned_data: ?JS.Value.Temp = blk: { + // Enter our context to clone the message + var ls: JS.Local.Scope = undefined; + self.js.localScope(&ls); + defer ls.deinit(); + + // clones from where it currently is (the Worker's Page context) to our Context + const cloned = data.structuredCloneTo(&ls.local) catch break :blk null; + break :blk cloned.temp() catch break :blk null; + }; + + const session = self._session; + + const message_arena = try session.getArena(.tiny, "WorkerGlobalScope.receiveMessage"); + errdefer session.releaseArena(message_arena); + + const callback = try message_arena.create(ReceiveMessageCallback); + callback.* = .{ + .data = cloned_data, + .worker_scope = self, + .arena = message_arena, + }; + + try self.js.scheduler.add(callback, ReceiveMessageCallback.run, 0, .{ + .name = "WorkerGlobalScope.receiveMessage", + .low_priority = false, + .finalizer = ReceiveMessageCallback.cancelled, + }); +} + +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(); +} + +pub fn unhandledPromiseRejection(self: *WorkerGlobalScope, no_handler: bool, rejection: JS.PromiseRejection) !void { + if (comptime IS_DEBUG) { + log.debug(.js, "unhandled rejection", .{ + .target = "worker", + .value = rejection.reason(), + .stack = rejection.local.stackTrace() catch |err| @errorName(err) orelse "???", + }); + } + + const event_name, const attribute_callback = blk: { + if (no_handler) { + break :blk .{ "unhandledrejection", self._on_unhandled_rejection }; + } + break :blk .{ "rejectionhandled", self._on_rejection_handled }; + }; + + const target = self.asEventTarget(); + if (self._event_manager.hasDirectListeners(target, event_name, attribute_callback)) { + const event = (try @import("event/PromiseRejectionEvent.zig").init(event_name, .{ + .reason = if (rejection.reason()) |r| try r.temp() else null, + .promise = try rejection.promise().temp(), + }, self._session)).asEvent(); + try self.dispatch(target, event, attribute_callback); + } +} + +pub fn close(self: *WorkerGlobalScope) void { + // TOOD: we should also stop new tasks from being scheduled + self.js.scheduler.reset(); + self._closed = true; +} + +pub fn reportError(self: *WorkerGlobalScope, err: JS.Value) !void { + const error_event = try ErrorEvent.initTrusted(comptime .wrap("error"), .{ + .@"error" = try err.temp(), + .message = err.toStringSlice() catch "Unknown error", + .bubbles = false, + .cancelable = true, + }, self._session); + + // Invoke onerror callback if set (per WHATWG spec, this is called + // with 5 arguments: message, source, lineno, colno, error) + // If it returns true, the event is cancelled. + var prevent_default = false; + if (self._on_error) |on_error| { + var ls: JS.Local.Scope = undefined; + self.js.localScope(&ls); + defer ls.deinit(); + + const local_func = ls.toLocal(on_error); + const result = local_func.call(JS.Value, .{ + error_event._message, + error_event._filename, + error_event._line_number, + error_event._column_number, + err, + }) catch null; + + // Per spec: returning true from onerror cancels the event + if (result) |r| { + prevent_default = r.isTrue(); + } + } + + const event = error_event.asEvent(); + event._prevent_default = prevent_default; + // Pass null as handler: onerror was already called above with 5 args. + // We still dispatch so that addEventListener('error', ...) listeners fire. + try self.dispatch(self.asEventTarget(), event, null); + + if (comptime builtin.is_test == false) { + if (!event._prevent_default) { + log.warn(.js, "worker.reportError", .{ + .message = error_event._message, + .filename = error_event._filename, + .line_number = error_event._line_number, + .column_number = error_event._column_number, + }); + } + } +} + +// 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, + }; +} + +const ReceiveMessageCallback = struct { + data: ?JS.Value.Temp, + arena: Allocator, + worker_scope: *WorkerGlobalScope, + + fn cancelled(ctx: *anyopaque) void { + const self: *ReceiveMessageCallback = @ptrCast(@alignCast(ctx)); + if (self.data) |d| d.release(); + self.deinit(); + } + + fn deinit(self: *ReceiveMessageCallback) void { + self.worker_scope._session.releaseArena(self.arena); + } + + fn run(ctx: *anyopaque) !?u32 { + const self: *ReceiveMessageCallback = @ptrCast(@alignCast(ctx)); + defer self.deinit(); + + const worker_scope = self.worker_scope; + const target = worker_scope.asEventTarget(); + + // If data is null, structured clone failed - fire messageerror + if (self.data == null) { + const on_messageerror = worker_scope._on_messageerror; + if (!worker_scope._event_manager.hasDirectListeners(target, "messageerror", on_messageerror)) { + return null; + } + const event = (try MessageEvent.initTrusted(comptime .wrap("messageerror"), .{ + .bubbles = false, + .cancelable = false, + }, worker_scope._session)).asEvent(); + try worker_scope.dispatch(target, event, on_messageerror); + return null; + } + + const on_message = worker_scope._on_message; + + // Check if there are any listeners before creating the event + if (!worker_scope._event_manager.hasDirectListeners(target, "message", on_message)) { + self.data.?.release(); + return null; + } + + const event = (try MessageEvent.initTrusted(comptime .wrap("message"), .{ + .data = .{ .value = self.data.? }, + .bubbles = false, + .cancelable = false, + }, worker_scope._session)).asEvent(); + try worker_scope.dispatch(target, event, on_message); + return 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 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, .{}); + pub const postMessage = bridge.function(WorkerGlobalScope.postMessage, .{}); + pub const reportError = bridge.function(WorkerGlobalScope.reportError, .{}); + pub const close = bridge.function(WorkerGlobalScope.close, .{}); + + pub const onmessage = bridge.accessor(WorkerGlobalScope.getOnMessage, WorkerGlobalScope.setOnMessage, .{}); + pub const onmessageerror = bridge.accessor(WorkerGlobalScope.getOnMessageError, WorkerGlobalScope.setOnMessageError, .{}); + + // Return false since workers don't have secure-context-only APIs + pub const isSecureContext = bridge.property(false, .{ .template = false }); +}; diff --git a/src/browser/webapi/collections/ChildNodes.zig b/src/browser/webapi/collections/ChildNodes.zig index 410c12b7..ee64764f 100644 --- a/src/browser/webapi/collections/ChildNodes.zig +++ b/src/browser/webapi/collections/ChildNodes.zig @@ -18,6 +18,7 @@ const std = @import("std"); +const js = @import("../../js/js.zig"); const Node = @import("../Node.zig"); const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); @@ -140,7 +141,7 @@ const Iterator = struct { const Entry = struct { u32, *Node }; - pub fn next(self: *Iterator, page: *Page) !?Entry { + pub fn next(self: *Iterator, page: *const Page) !?Entry { const index = self.index; const node = try self.list.getAtIndex(index, page) orelse return null; self.index = index + 1; diff --git a/src/browser/webapi/collections/DOMTokenList.zig b/src/browser/webapi/collections/DOMTokenList.zig index c9843895..cab25447 100644 --- a/src/browser/webapi/collections/DOMTokenList.zig +++ b/src/browser/webapi/collections/DOMTokenList.zig @@ -43,7 +43,7 @@ const Lookup = std.StringArrayHashMapUnmanaged(void); const WHITESPACE = " \t\n\r\x0C"; pub fn length(self: *const DOMTokenList, page: *Page) !u32 { - const tokens = try self.getTokens(page); + const tokens = try self.getTokens(page.call_arena); return @intCast(tokens.count()); } @@ -82,8 +82,8 @@ pub fn add(self: *DOMTokenList, tokens: []const []const u8, page: *Page) !void { try validateToken(token); } - var lookup = try self.getTokens(page); const allocator = page.call_arena; + var lookup = try self.getTokens(allocator); try lookup.ensureUnusedCapacity(allocator, tokens.len); for (tokens) |token| { @@ -98,7 +98,7 @@ pub fn remove(self: *DOMTokenList, tokens: []const []const u8, page: *Page) !voi try validateToken(token); } - var lookup = try self.getTokens(page); + var lookup = try self.getTokens(page.call_arena); for (tokens) |token| { _ = lookup.orderedRemove(token); } @@ -149,7 +149,8 @@ pub fn replace(self: *DOMTokenList, old_token: []const u8, new_token: []const u8 return error.InvalidCharacterError; } - var lookup = try self.getTokens(page); + const allocator = page.call_arena; + var lookup = try self.getTokens(page.call_arena); // Check if old_token exists if (!lookup.contains(old_token)) { @@ -162,7 +163,6 @@ pub fn replace(self: *DOMTokenList, old_token: []const u8, new_token: []const u8 return true; } - const allocator = page.call_arena; // Build new token list preserving order but replacing old with new var new_tokens = try std.ArrayList([]const u8).initCapacity(allocator, lookup.count()); var replaced_old = false; @@ -237,14 +237,13 @@ pub fn forEach(self: *DOMTokenList, cb_: js.Function, js_this_: ?js.Object, page } } -fn getTokens(self: *const DOMTokenList, page: *Page) !Lookup { +fn getTokens(self: *const DOMTokenList, allocator: std.mem.Allocator) !Lookup { const value = self.getValue(); if (value.len == 0) { return .empty; } var list: Lookup = .empty; - const allocator = page.call_arena; try list.ensureTotalCapacity(allocator, 4); var it = std.mem.tokenizeAny(u8, value, WHITESPACE); diff --git a/src/browser/webapi/collections/HTMLAllCollection.zig b/src/browser/webapi/collections/HTMLAllCollection.zig index acada474..ddad1d08 100644 --- a/src/browser/webapi/collections/HTMLAllCollection.zig +++ b/src/browser/webapi/collections/HTMLAllCollection.zig @@ -24,6 +24,7 @@ const Page = @import("../../Page.zig"); const Node = @import("../Node.zig"); const Element = @import("../Element.zig"); const TreeWalker = @import("../TreeWalker.zig"); +const Execution = js.Execution; const HTMLAllCollection = @This(); @@ -133,11 +134,11 @@ pub fn callable(self: *HTMLAllCollection, arg: CAllAsFunctionArg, page: *Page) ? }; } -pub fn iterator(self: *HTMLAllCollection, page: *Page) !*Iterator { +pub fn iterator(self: *HTMLAllCollection, exec: *const Execution) !*Iterator { return Iterator.init(.{ .list = self, .tw = self._tw.clone(), - }, page); + }, exec); } const GenericIterator = @import("iterator.zig").Entry; @@ -145,7 +146,7 @@ pub const Iterator = GenericIterator(struct { list: *HTMLAllCollection, tw: TreeWalker.FullExcludeSelf, - pub fn next(self: *@This(), _: *Page) ?*Element { + pub fn next(self: *@This(), _: *const Execution) ?*Element { while (self.tw.next()) |node| { if (node.is(Element)) |el| { return el; diff --git a/src/browser/webapi/collections/HTMLCollection.zig b/src/browser/webapi/collections/HTMLCollection.zig index fc73ec6d..c3842e77 100644 --- a/src/browser/webapi/collections/HTMLCollection.zig +++ b/src/browser/webapi/collections/HTMLCollection.zig @@ -23,6 +23,7 @@ const Page = @import("../../Page.zig"); const Element = @import("../Element.zig"); const TreeWalker = @import("../TreeWalker.zig"); const NodeLive = @import("node_live.zig").NodeLive; +const Execution = js.Execution; const Mode = enum { tag, @@ -77,7 +78,7 @@ pub fn getByName(self: *HTMLCollection, name: []const u8, page: *Page) ?*Element }; } -pub fn iterator(self: *HTMLCollection, page: *Page) !*Iterator { +pub fn iterator(self: *HTMLCollection, exec: *const Execution) !*Iterator { return Iterator.init(.{ .list = self, .tw = switch (self._data) { @@ -94,7 +95,7 @@ pub fn iterator(self: *HTMLCollection, page: *Page) !*Iterator { .form => |*impl| .{ .form = impl._tw.clone() }, .empty => .empty, }, - }, page); + }, exec); } const GenericIterator = @import("iterator.zig").Entry; @@ -115,7 +116,7 @@ pub const Iterator = GenericIterator(struct { empty: void, }, - pub fn next(self: *@This(), _: *Page) ?*Element { + pub fn next(self: *@This(), _: *const Execution) ?*Element { return switch (self.list._data) { .tag => |*impl| impl.nextTw(&self.tw.tag), .tag_name => |*impl| impl.nextTw(&self.tw.tag_name), diff --git a/src/browser/webapi/collections/iterator.zig b/src/browser/webapi/collections/iterator.zig index ba3c4ddc..8d0d6df7 100644 --- a/src/browser/webapi/collections/iterator.zig +++ b/src/browser/webapi/collections/iterator.zig @@ -21,6 +21,7 @@ const lp = @import("lightpanda"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); +const Execution = js.Execution; pub fn Entry(comptime Inner: type, comptime field: ?[]const u8) type { const R = reflect(Inner, field); @@ -38,8 +39,8 @@ pub fn Entry(comptime Inner: type, comptime field: ?[]const u8) type { pub const js_as_object = true; }; - pub fn init(inner: Inner, page: *Page) !*Self { - const self = try page._factory.create(Self{ ._inner = inner }); + pub fn init(inner: Inner, executor: R.Executor) !*Self { + const self = try executor._factory.create(Self{ ._inner = inner }); if (@hasDecl(Inner, "acquireRef")) { self._inner.acquireRef(); @@ -62,8 +63,8 @@ pub fn Entry(comptime Inner: type, comptime field: ?[]const u8) type { self._rc.acquire(); } - 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(page) else self._inner.next(page)) orelse { + pub fn next(self: *Self, executor: R.Executor) 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 { return .{ .done = true, .value = null }; }; @@ -92,17 +93,22 @@ pub fn Entry(comptime Inner: type, comptime field: ?[]const u8) type { } fn reflect(comptime Inner: type, comptime field: ?[]const u8) Reflect { - const R = @typeInfo(@TypeOf(Inner.next)).@"fn".return_type.?; + const fn_info = @typeInfo(@TypeOf(Inner.next)).@"fn"; + const R = fn_info.return_type.?; 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 .{ .has_error_return = has_error_return, .ValueType = ValueType(unwrapOptional(unwrapError(R)), field), + .Executor = Executor, }; } const Reflect = struct { has_error_return: bool, ValueType: type, + Executor: type, }; fn unwrapError(comptime T: type) type { diff --git a/src/browser/webapi/encoding/TextDecoder.zig b/src/browser/webapi/encoding/TextDecoder.zig index 89ef3023..16176d66 100644 --- a/src/browser/webapi/encoding/TextDecoder.zig +++ b/src/browser/webapi/encoding/TextDecoder.zig @@ -21,7 +21,6 @@ const lp = @import("lightpanda"); const js = @import("../../js/js.zig"); const html5ever = @import("../../parser/html5ever.zig"); -const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); const Allocator = std.mem.Allocator; @@ -42,7 +41,7 @@ const InitOpts = struct { ignoreBOM: bool = false, }; -pub fn init(label_: ?[]const u8, opts_: ?InitOpts, page: *Page) !*TextDecoder { +pub fn init(label_: ?[]const u8, opts_: ?InitOpts, session: *Session) !*TextDecoder { const label = label_ orelse "utf-8"; const info = html5ever.encoding_for_label(label.ptr, label.len); @@ -56,8 +55,8 @@ pub fn init(label_: ?[]const u8, opts_: ?InitOpts, page: *Page) !*TextDecoder { return error.RangeError; } - const arena = try page.getArena(.large, "TextDecoder"); - errdefer page.releaseArena(arena); + const arena = try session.getArena(.large, "TextDecoder"); + errdefer session.releaseArena(arena); const opts = opts_ orelse InitOpts{}; const self = try arena.create(TextDecoder); diff --git a/src/browser/webapi/encoding/base64.zig b/src/browser/webapi/encoding/base64.zig new file mode 100644 index 00000000..cdbe98a7 --- /dev/null +++ b/src/browser/webapi/encoding/base64.zig @@ -0,0 +1,50 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! 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; +} diff --git a/src/browser/webapi/event/ErrorEvent.zig b/src/browser/webapi/event/ErrorEvent.zig index 4bb68573..56659d20 100644 --- a/src/browser/webapi/event/ErrorEvent.zig +++ b/src/browser/webapi/event/ErrorEvent.zig @@ -20,7 +20,6 @@ const std = @import("std"); const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); -const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); const Event = @import("../Event.zig"); @@ -46,23 +45,23 @@ pub const ErrorEventOptions = struct { const Options = Event.inheritOptions(ErrorEvent, ErrorEventOptions); -pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*ErrorEvent { - const arena = try page.getArena(.small, "ErrorEvent"); - errdefer page.releaseArena(arena); +pub fn init(typ: []const u8, opts_: ?Options, session: *Session) !*ErrorEvent { + const arena = try session.getArena(.small, "ErrorEvent"); + errdefer session.releaseArena(arena); const type_string = try String.init(arena, typ, .{}); - return initWithTrusted(arena, type_string, opts_, false, page); + return initWithTrusted(arena, type_string, opts_, false, session); } -pub fn initTrusted(typ: String, opts_: ?Options, page: *Page) !*ErrorEvent { - const arena = try page.getArena(.small, "ErrorEvent.trusted"); - errdefer page.releaseArena(arena); - return initWithTrusted(arena, typ, opts_, true, page); +pub fn initTrusted(typ: String, opts_: ?Options, session: *Session) !*ErrorEvent { + const arena = try session.getArena(.small, "ErrorEvent.trusted"); + errdefer session.releaseArena(arena); + return initWithTrusted(arena, typ, opts_, true, session); } -fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool, page: *Page) !*ErrorEvent { +fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool, session: *Session) !*ErrorEvent { const opts = opts_ orelse Options{}; - const event = try page._factory.event( + const event = try session.factory.event( arena, typ, ErrorEvent{ diff --git a/src/browser/webapi/event/MessageEvent.zig b/src/browser/webapi/event/MessageEvent.zig index 27fdfb23..8673e450 100644 --- a/src/browser/webapi/event/MessageEvent.zig +++ b/src/browser/webapi/event/MessageEvent.zig @@ -20,11 +20,13 @@ const std = @import("std"); const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); - const Page = @import("../../Page.zig"); +const Factory = @import("../../Factory.zig"); const Session = @import("../../Session.zig"); + const Event = @import("../Event.zig"); const Window = @import("../Window.zig"); + const Allocator = std.mem.Allocator; const MessageEvent = @This(); @@ -53,19 +55,19 @@ pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*MessageEvent { const arena = try page.getArena(.small, "MessageEvent"); errdefer page.releaseArena(arena); const type_string = try String.init(arena, typ, .{}); - return initWithTrusted(arena, type_string, opts_, false, page); + return initWithTrusted(arena, type_string, opts_, false, page._factory); } -pub fn initTrusted(typ: String, opts_: ?Options, page: *Page) !*MessageEvent { - const arena = try page.getArena(.small, "MessageEvent.trusted"); - errdefer page.releaseArena(arena); - return initWithTrusted(arena, typ, opts_, true, page); +pub fn initTrusted(typ: String, opts_: ?Options, session: *Session) !*MessageEvent { + const arena = try session.getArena(.small, "MessageEvent.trusted"); + errdefer session.releaseArena(arena); + return initWithTrusted(arena, typ, opts_, true, &session.factory); } -fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool, page: *Page) !*MessageEvent { +fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool, factory: *Factory) !*MessageEvent { const opts = opts_ orelse Options{}; - const event = try page._factory.event( + const event = try factory.event( arena, typ, MessageEvent{ diff --git a/src/browser/webapi/event/PromiseRejectionEvent.zig b/src/browser/webapi/event/PromiseRejectionEvent.zig index 44af3904..86883449 100644 --- a/src/browser/webapi/event/PromiseRejectionEvent.zig +++ b/src/browser/webapi/event/PromiseRejectionEvent.zig @@ -19,8 +19,8 @@ const std = @import("std"); const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); -const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); + const Event = @import("../Event.zig"); const PromiseRejectionEvent = @This(); @@ -36,13 +36,13 @@ const PromiseRejectionEventOptions = struct { const Options = Event.inheritOptions(PromiseRejectionEvent, PromiseRejectionEventOptions); -pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*PromiseRejectionEvent { - const arena = try page.getArena(.tiny, "PromiseRejectionEvent"); - errdefer page.releaseArena(arena); +pub fn init(typ: []const u8, opts_: ?Options, session: *Session) !*PromiseRejectionEvent { + const arena = try session.getArena(.tiny, "PromiseRejectionEvent"); + errdefer session.releaseArena(arena); const type_string = try String.init(arena, typ, .{}); const opts = opts_ orelse Options{}; - const event = try page._factory.event( + const event = try session.factory.event( arena, type_string, PromiseRejectionEvent{ diff --git a/src/browser/webapi/net/FormData.zig b/src/browser/webapi/net/FormData.zig index 924b065c..c7bfbc30 100644 --- a/src/browser/webapi/net/FormData.zig +++ b/src/browser/webapi/net/FormData.zig @@ -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 { - return self._list.getAll(name, page); + return self._list.getAll(page.call_arena, name); } 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); } -pub fn keys(self: *FormData, page: *Page) !*KeyValueList.KeyIterator { - return KeyValueList.KeyIterator.init(.{ .list = self, .kv = &self._list }, page); +pub fn keys(self: *FormData, exec: *const js.Execution) !*KeyValueList.KeyIterator { + return KeyValueList.KeyIterator.init(.{ .list = self, .kv = &self._list }, exec); } -pub fn values(self: *FormData, page: *Page) !*KeyValueList.ValueIterator { - return KeyValueList.ValueIterator.init(.{ .list = self, .kv = &self._list }, page); +pub fn values(self: *FormData, exec: *const js.Execution) !*KeyValueList.ValueIterator { + return KeyValueList.ValueIterator.init(.{ .list = self, .kv = &self._list }, exec); } -pub fn entries(self: *FormData, page: *Page) !*KeyValueList.EntryIterator { - return KeyValueList.EntryIterator.init(.{ .list = self, .kv = &self._list }, page); +pub fn entries(self: *FormData, exec: *const js.Execution) !*KeyValueList.EntryIterator { + return KeyValueList.EntryIterator.init(.{ .list = self, .kv = &self._list }, exec); } pub fn forEach(self: *FormData, cb_: js.Function, js_this_: ?js.Object) !void { diff --git a/src/browser/webapi/net/Headers.zig b/src/browser/webapi/net/Headers.zig index 62c505c6..438e3532 100644 --- a/src/browser/webapi/net/Headers.zig +++ b/src/browser/webapi/net/Headers.zig @@ -20,8 +20,8 @@ pub const InitOpts = union(enum) { pub fn init(opts_: ?InitOpts, page: *Page) !*Headers { const list = if (opts_) |opts| switch (opts) { .obj => |obj| try KeyValueList.copy(page.arena, obj._list), - .js_obj => |js_obj| try KeyValueList.fromJsObject(page.arena, js_obj, normalizeHeaderName, page), - .strings => |kvs| try KeyValueList.fromArray(page.arena, kvs, normalizeHeaderName, page), + .js_obj => |js_obj| try KeyValueList.fromJsObject(page.arena, js_obj, normalizeHeaderName, &page.buf), + .strings => |kvs| try KeyValueList.fromArray(page.arena, kvs, normalizeHeaderName, &page.buf), } else KeyValueList.init(); 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 { - const normalized_name = normalizeHeaderName(name, page); + const normalized_name = normalizeHeaderName(name, &page.buf); try self._list.append(page.arena, normalized_name, value); } pub fn delete(self: *Headers, name: []const u8, page: *Page) void { - const normalized_name = normalizeHeaderName(name, page); + const normalized_name = normalizeHeaderName(name, &page.buf); self._list.delete(normalized_name, null); } pub fn get(self: *const Headers, name: []const u8, page: *Page) !?[]const u8 { - const normalized_name = normalizeHeaderName(name, page); - const all_values = try self._list.getAll(normalized_name, page); + const normalized_name = normalizeHeaderName(name, &page.buf); + const all_values = try self._list.getAll(page.call_arena, normalized_name); if (all_values.len == 0) { 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 { - const normalized_name = normalizeHeaderName(name, page); + const normalized_name = normalizeHeaderName(name, &page.buf); return self._list.has(normalized_name); } pub fn set(self: *Headers, name: []const u8, value: []const u8, page: *Page) !void { - const normalized_name = normalizeHeaderName(name, page); + const normalized_name = normalizeHeaderName(name, &page.buf); try self._list.set(page.arena, normalized_name, value); } -pub fn keys(self: *Headers, page: *Page) !*KeyValueList.KeyIterator { - return KeyValueList.KeyIterator.init(.{ .list = self, .kv = &self._list }, page); +pub fn keys(self: *Headers, exec: *const js.Execution) !*KeyValueList.KeyIterator { + return KeyValueList.KeyIterator.init(.{ .list = self, .kv = &self._list }, exec); } -pub fn values(self: *Headers, page: *Page) !*KeyValueList.ValueIterator { - return KeyValueList.ValueIterator.init(.{ .list = self, .kv = &self._list }, page); +pub fn values(self: *Headers, exec: *const js.Execution) !*KeyValueList.ValueIterator { + return KeyValueList.ValueIterator.init(.{ .list = self, .kv = &self._list }, exec); } -pub fn entries(self: *Headers, page: *Page) !*KeyValueList.EntryIterator { - return KeyValueList.EntryIterator.init(.{ .list = self, .kv = &self._list }, page); +pub fn entries(self: *Headers, exec: *const js.Execution) !*KeyValueList.EntryIterator { + return KeyValueList.EntryIterator.init(.{ .list = self, .kv = &self._list }, exec); } 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, page: *Page) []const u8 { - if (name.len > page.buf.len) { +fn normalizeHeaderName(name: []const u8, buf: []u8) []const u8 { + if (name.len > buf.len) { return name; } - return std.ascii.lowerString(&page.buf, name); + return std.ascii.lowerString(buf, name); } pub const JsApi = struct { diff --git a/src/browser/webapi/net/URLSearchParams.zig b/src/browser/webapi/net/URLSearchParams.zig index e6384914..b0a31760 100644 --- a/src/browser/webapi/net/URLSearchParams.zig +++ b/src/browser/webapi/net/URLSearchParams.zig @@ -23,9 +23,9 @@ const log = @import("../../../log.zig"); const String = @import("../../../string.zig").String; const Allocator = std.mem.Allocator; -const Page = @import("../../Page.zig"); const FormData = @import("FormData.zig"); const KeyValueList = @import("../KeyValueList.zig"); +const Execution = js.Execution; const URLSearchParams = @This(); @@ -38,12 +38,12 @@ const InitOpts = union(enum) { query_string: []const u8, }; -pub fn init(opts_: ?InitOpts, page: *Page) !*URLSearchParams { - const arena = page.arena; +pub fn init(opts_: ?InitOpts, exec: *const Execution) !*URLSearchParams { + const arena = exec.arena; const params: KeyValueList = blk: { const opts = opts_ orelse break :blk .empty; switch (opts) { - .query_string => |qs| break :blk try paramsFromString(arena, qs, &page.buf), + .query_string => |qs| break :blk try paramsFromString(arena, qs, exec.buf), .form_data => |fd| break :blk try KeyValueList.copy(arena, fd._list), .value => |js_val| { // Order matters here; Array is also an Object. @@ -51,24 +51,25 @@ pub fn init(opts_: ?InitOpts, page: *Page) !*URLSearchParams { break :blk try paramsFromArray(arena, js_val.toArray()); } if (js_val.isObject()) { - break :blk try KeyValueList.fromJsObject(arena, js_val.toObject(), null, page); + // normalizer is null, so page won't be used + break :blk try KeyValueList.fromJsObject(arena, js_val.toObject(), null, exec.buf); } if (js_val.isString()) |js_str| { - break :blk try paramsFromString(arena, try js_str.toSliceWithAlloc(arena), &page.buf); + break :blk try paramsFromString(arena, try js_str.toSliceWithAlloc(arena), exec.buf); } return error.InvalidArgument; }, } }; - return page._factory.create(URLSearchParams{ + return exec._factory.create(URLSearchParams{ ._arena = arena, ._params = params, }); } -pub fn updateFromString(self: *URLSearchParams, query_string: []const u8, page: *Page) !void { - self._params = try paramsFromString(self._arena, query_string, &page.buf); +pub fn updateFromString(self: *URLSearchParams, query_string: []const u8, exec: *const Execution) !void { + self._params = try paramsFromString(self._arena, query_string, exec.buf); } pub fn getSize(self: *const URLSearchParams) usize { @@ -79,8 +80,8 @@ pub fn get(self: *const URLSearchParams, name: []const u8) ?[]const u8 { return self._params.get(name); } -pub fn getAll(self: *const URLSearchParams, name: []const u8, page: *Page) ![]const []const u8 { - return self._params.getAll(name, page); +pub fn getAll(self: *const URLSearchParams, name: []const u8, exec: *const Execution) ![]const []const u8 { + return self._params.getAll(exec.call_arena, name); } pub fn has(self: *const URLSearchParams, name: []const u8) bool { @@ -99,16 +100,16 @@ pub fn delete(self: *URLSearchParams, name: []const u8, value: ?[]const u8) void self._params.delete(name, value); } -pub fn keys(self: *URLSearchParams, page: *Page) !*KeyValueList.KeyIterator { - return KeyValueList.KeyIterator.init(.{ .list = self, .kv = &self._params }, page); +pub fn keys(self: *URLSearchParams, exec: *const Execution) !*KeyValueList.KeyIterator { + return KeyValueList.KeyIterator.init(.{ .list = self, .kv = &self._params }, exec); } -pub fn values(self: *URLSearchParams, page: *Page) !*KeyValueList.ValueIterator { - return KeyValueList.ValueIterator.init(.{ .list = self, .kv = &self._params }, page); +pub fn values(self: *URLSearchParams, exec: *const Execution) !*KeyValueList.ValueIterator { + return KeyValueList.ValueIterator.init(.{ .list = self, .kv = &self._params }, exec); } -pub fn entries(self: *URLSearchParams, page: *Page) !*KeyValueList.EntryIterator { - return KeyValueList.EntryIterator.init(.{ .list = self, .kv = &self._params }, page); +pub fn entries(self: *URLSearchParams, exec: *const Execution) !*KeyValueList.EntryIterator { + return KeyValueList.EntryIterator.init(.{ .list = self, .kv = &self._params }, exec); } pub fn toString(self: *const URLSearchParams, writer: *std.Io.Writer) !void { @@ -287,7 +288,7 @@ pub const Iterator = struct { const Entry = struct { []const u8, []const u8 }; - pub fn next(self: *Iterator, _: *Page) !?Iterator.Entry { + pub fn next(self: *Iterator, _: *const Execution) !?Iterator.Entry { const index = self.index; const items = self.list._params.items; if (index >= items.len) { @@ -325,8 +326,8 @@ pub const JsApi = struct { pub const sort = bridge.function(URLSearchParams.sort, .{}); pub const toString = bridge.function(_toString, .{}); - fn _toString(self: *const URLSearchParams, page: *Page) ![]const u8 { - var buf = std.Io.Writer.Allocating.init(page.call_arena); + fn _toString(self: *const URLSearchParams, exec: *const Execution) ![]const u8 { + var buf = std.Io.Writer.Allocating.init(exec.call_arena); try self.toString(&buf.writer); return buf.written(); } diff --git a/src/browser/webapi/net/WebSocket.zig b/src/browser/webapi/net/WebSocket.zig index 5f0c09ac..32be22fb 100644 --- a/src/browser/webapi/net/WebSocket.zig +++ b/src/browser/webapi/net/WebSocket.zig @@ -475,7 +475,7 @@ fn dispatchMessageEvent(self: *WebSocket, data: []const u8, frame_type: http.WsF const event = try MessageEvent.initTrusted(comptime .wrap("message"), .{ .data = msg_data, .origin = "", - }, page); + }, page._session); try page._event_manager.dispatchDirect(target, event.asEvent(), self._on_message, .{ .context = "WebSocket message" }); } } diff --git a/src/cdp/domains/network.zig b/src/cdp/domains/network.zig index f2d60c91..ea6bc020 100644 --- a/src/cdp/domains/network.zig +++ b/src/cdp/domains/network.zig @@ -206,7 +206,7 @@ fn getCookies(cmd: *CDP.Command) !void { fn getResponseBody(cmd: *CDP.Command) !void { const params = (try cmd.params(struct { - requestId: []const u8, // "REQ-{d}" + requestId: []const u8, // "REQ-{d}" or "LID-{d}" })) orelse return error.InvalidParams; const request_id = try idFromRequestId(params.requestId); @@ -439,7 +439,8 @@ const TransferAsResponseWriter = struct { }; fn idFromRequestId(request_id: []const u8) !u64 { - if (!std.mem.startsWith(u8, request_id, "REQ-")) { + // The requesIid for the original document is its loaderId. + if (!std.mem.startsWith(u8, request_id, "REQ-") and !std.mem.startsWith(u8, request_id, "LID-")) { return error.InvalidParams; } return std.fmt.parseInt(u64, request_id[4..], 10) catch return error.InvalidParams; diff --git a/src/network/cache/Cache.zig b/src/network/cache/Cache.zig index d270310e..d61778b5 100644 --- a/src/network/cache/Cache.zig +++ b/src/network/cache/Cache.zig @@ -55,25 +55,33 @@ pub const CacheControl = struct { var max_age_set = false; var max_s_age_set = false; - var is_public = false; var iter = std.mem.splitScalar(u8, value, ','); while (iter.next()) |part| { - const directive = std.mem.trim(u8, part, &std.ascii.whitespace); - if (std.ascii.eqlIgnoreCase(directive, "no-store")) { + const stripped = std.mem.trim(u8, part, &std.ascii.whitespace); + + var buf: [16]u8 = undefined; + const len = @min(buf.len, stripped.len); + const directive = std.ascii.lowerString(buf[0..len], stripped[0..len]); + + if (std.mem.eql(u8, directive, "no-store")) { return null; - } else if (std.ascii.eqlIgnoreCase(directive, "no-cache")) { + } + if (std.mem.eql(u8, directive, "no-cache")) { return null; - } else if (std.ascii.eqlIgnoreCase(directive, "public")) { - is_public = true; - } else if (std.ascii.startsWithIgnoreCase(directive, "max-age=")) { + } + if (std.mem.eql(u8, directive, "private")) { + return null; + } + + if (std.mem.startsWith(u8, directive, "max-age=")) { if (!max_s_age_set) { if (std.fmt.parseInt(u64, directive[8..], 10) catch null) |max_age| { cc.max_age = max_age; max_age_set = true; } } - } else if (std.ascii.startsWithIgnoreCase(directive, "s-maxage=")) { + } else if (std.mem.startsWith(u8, directive, "s-maxage=")) { if (std.fmt.parseInt(u64, directive[9..], 10) catch null) |max_age| { cc.max_age = max_age; max_age_set = true; @@ -83,7 +91,6 @@ pub const CacheControl = struct { } if (!max_age_set) return null; - if (!is_public) return null; if (cc.max_age == 0) return null; return cc; @@ -211,3 +218,31 @@ pub fn tryCache( .vary_headers = &.{}, }; } +const testing = @import("../../testing.zig"); +test "Cache: CacheControl.parse" { + try testing.expectEqual(300, CacheControl.parse("max-age=300").?.max_age); + + try testing.expectEqual(300, CacheControl.parse("Max-Age=300").?.max_age); + try testing.expectEqual(300, CacheControl.parse("MAX-AGE=300").?.max_age); + + try testing.expectEqual(300, CacheControl.parse("public, max-age=300").?.max_age); + try testing.expectEqual(300, CacheControl.parse(" max-age=300 ").?.max_age); + + try testing.expectEqual(600, CacheControl.parse("max-age=300, s-maxage=600").?.max_age); + try testing.expectEqual(600, CacheControl.parse("s-maxage=600, max-age=300").?.max_age); + + try testing.expectEqual(null, CacheControl.parse("no-store")); + try testing.expectEqual(null, CacheControl.parse("no-cache")); + try testing.expectEqual(null, CacheControl.parse("private")); + try testing.expectEqual(null, CacheControl.parse("max-age=300, no-store")); + try testing.expectEqual(null, CacheControl.parse("no-cache, max-age=300")); + try testing.expectEqual(null, CacheControl.parse("Private, max-age=300")); + + try testing.expectEqual(null, CacheControl.parse("max-age=0")); + + try testing.expectEqual(null, CacheControl.parse("public")); + try testing.expectEqual(null, CacheControl.parse("")); + + try testing.expectEqual(null, CacheControl.parse("max-age=abc")); + try testing.expectEqual(null, CacheControl.parse("max-age=")); +}