diff --git a/src/Config.zig b/src/Config.zig index a94ecb6df..7eb5bf809 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -245,8 +245,9 @@ pub const LoadResources = packed struct(u4) { stylesheet: bool = false, }; -const ExperimentalFeatures = packed struct(u1) { +pub const ExperimentalFeatures = packed struct(u2) { cors: bool = false, + serviceworker: bool = false, }; /// Common CLI args. diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 9282ca0c4..9c0c000f1 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -463,6 +463,10 @@ pub fn deinit(self: *Frame) void { cs.detach(); } + if (self.window._navigator._service_worker) |container| { + container.detach(); + } + const page = self.page; if (self._queued_navigation) |qn| { diff --git a/src/browser/Page.zig b/src/browser/Page.zig index ad5f45285..045d0d93d 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -37,6 +37,7 @@ const AnimatedLength = @import("webapi/svg/AnimatedLength.zig"); const AnimatedNumber = @import("webapi/svg/AnimatedNumber.zig"); const AnimatedString = @import("webapi/svg/AnimatedString.zig"); const AnimatedTransformList = @import("webapi/svg/AnimatedTransformList.zig"); +const ServiceWorkerGlobalScope = @import("webapi/ServiceWorkerGlobalScope.zig"); const AnimatedPreserveAspectRatio = @import("webapi/svg/AnimatedPreserveAspectRatio.zig"); const Allocator = std.mem.Allocator; @@ -202,6 +203,11 @@ closed_frames: std.ArrayList(*Frame) = .empty, // session.shared_workers so other pages can connect). shared_workers: std.ArrayList(*SharedWorkerGlobalScope) = .empty, +// ServiceWorkerGlobalScopes created by this Page's frames. The page "owns" it, +// but it's also shared with the Session so that two registers with the same URL +// return the same SWGS, even across pages (but the owning page will tear it down) +service_workers: std.ArrayList(*ServiceWorkerGlobalScope) = .empty, + // In-flight navigation for a root page. When not null, this page will "replace" // the referenced page once the response header arrives. This is necessary // because, during navigation, both the "old" and "new" pages remain addressable @@ -285,6 +291,11 @@ pub fn deinit(self: *Page) void { } self.shared_workers = .empty; + for (self.service_workers.items) |scope| { + scope.deinit(); + } + self.service_workers = .empty; + { if (comptime lp.IS_DEBUG) { std.debug.assert(self.blob_urls.count() == 0); diff --git a/src/browser/Session.zig b/src/browser/Session.zig index f885095a0..4c9cda141 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -36,6 +36,7 @@ pub const Runner = @import("Runner.zig"); const Notification = @import("../Notification.zig"); const QueuedNavigation = Frame.QueuedNavigation; const SharedWorkerGlobalScope = @import("webapi/SharedWorkerGlobalScope.zig"); +const ServiceWorkerGlobalScope = @import("webapi/ServiceWorkerGlobalScope.zig"); const log = lp.log; const ArenaPool = App.ArenaPool; @@ -74,6 +75,10 @@ pages: std.ArrayList(*Page) = .empty, // Owned by the Page that creates it. shared_workers: std.StringHashMapUnmanaged(*SharedWorkerGlobalScope) = .empty, +// url => SWGS. The SWGS is owned by the page, but can be shared with other +// pages by url. +service_workers: std.StringHashMapUnmanaged(*ServiceWorkerGlobalScope) = .empty, + _page_destruction_queue: std.ArrayList(*Page) = .empty, // Round-robin cursor for fair page iteration (processQueuedNavigation) @@ -105,6 +110,9 @@ _console_capture: bool = false, // configured external resources (images, stylesheet, worker, iframe) to load load_resources: Config.LoadResources, +// opt-in unstable features (--experimental-features) +experimental_features: Config.ExperimentalFeatures, + /// Caller-supplied cancellation probe. `Runner._wait` polls it between /// ticks; once `check` returns true the wait returns `error.Cancelled`. /// The agent installs this so SIGINT can abort an in-flight tool call @@ -167,6 +175,7 @@ pub fn init(self: *Session, browser: *Browser, notification: *Notification) !voi .cookie_jar = storage.Cookie.Jar.init(allocator, notification), ._console_messages = .init(allocator), .load_resources = browser.app.config.loadResources(), + .experimental_features = browser.app.config.experimentalFeatures(), }; errdefer self._console_messages.deinit(); } diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index 38a9c8edd..f0b46ea29 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -27,10 +27,12 @@ const Platform = @import("Platform.zig"); const Inspector = @import("Inspector.zig"); const App = @import("../../App.zig"); + const Frame = @import("../Frame.zig"); const Window = @import("../webapi/Window.zig"); const WorkerGlobalScope = @import("../webapi/WorkerGlobalScope.zig"); const SharedWorkerGlobalScope = @import("../webapi/SharedWorkerGlobalScope.zig"); +const ServiceWorkerGlobalScope = @import("../webapi/ServiceWorkerGlobalScope.zig"); const DedicatedWorkerGlobalScope = @import("../webapi/DedicatedWorkerGlobalScope.zig"); const v8 = js.v8; @@ -90,9 +92,12 @@ templates: []*const v8.FunctionTemplate, inspector: ?*Inspector, // We can store data in a v8::Object's Private data bag. The keys are v8::Private -// which an be created once per isolaet. +// which an be created once per isolate. private_symbols: PrivateSymbols, +// Interned names for `hideServiceWorker`. +disabled_api_names: DisabledApiNames, + microtask_queues_are_running: bool, // Serializes V8 calls that race with TerminateExecution (which can fire from @@ -177,6 +182,7 @@ pub fn init(app: *App, opts: InitOpts) !Env { errdefer allocator.free(templates); var private_symbols: PrivateSymbols = undefined; + var disabled_api_names: DisabledApiNames = undefined; { var temp_scope: js.HandleScope = undefined; temp_scope.init(isolate); @@ -194,6 +200,7 @@ pub fn init(app: *App, opts: InitOpts) !Env { } private_symbols = PrivateSymbols.init(isolate_handle); + disabled_api_names = DisabledApiNames.init(isolate_handle); } var inspector: ?*js.Inspector = null; @@ -212,6 +219,7 @@ pub fn init(app: *App, opts: InitOpts) !Env { .isolate_params = params, .inspector = inspector, .private_symbols = private_symbols, + .disabled_api_names = disabled_api_names, .microtask_queues_are_running = false, .eternal_function_templates = eternal_function_templates, }; @@ -293,10 +301,11 @@ fn _createContext(self: *Env, global: anytype, params: ContextParams) !*Context }; // Restore the context from the snapshot - // (0 = Page, 1 = DedicatedWorker, 2 = SharedWorker) + // (0 = Page, 1 = DedicatedWorker, 2 = SharedWorker, 3 = ServiceWorker) const snapshot_index: u32 = if (comptime is_frame) 0 else switch (global._type) { .dedicated => 1, .shared => 2, + .service => 3, }; const v8_context = v8.v8__Context__FromSnapshot__Config(isolate.handle, snapshot_index, &.{ .global_template = null, @@ -311,6 +320,12 @@ fn _createContext(self: *Env, global: anytype, params: ContextParams) !*Context // Get the global object for the context const global_obj = v8.v8__Context__Global(v8_context).?; + if (comptime is_frame) { + if (global._session.experimental_features.serviceworker == false) { + self.hideServiceWorker(v8_context, global_obj); + } + } + // 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")); @@ -332,6 +347,12 @@ fn _createContext(self: *Env, global: anytype, params: ContextParams) !*Context .prototype_len = @intCast(SharedWorkerGlobalScope.JsApi.Meta.prototype_chain.len), .subtype = null, }, + .service => |scope| .{ + .value = @ptrCast(scope), + .prototype_chain = (&ServiceWorkerGlobalScope.JsApi.Meta.prototype_chain).ptr, + .prototype_len = @intCast(ServiceWorkerGlobalScope.JsApi.Meta.prototype_chain.len), + .subtype = null, + }, }; v8.v8__Object__SetAlignedPointerInInternalField(global_obj, 0, tao); @@ -401,6 +422,28 @@ fn _createContext(self: *Env, global: anytype, params: ContextParams) !*Context return context; } +// When ServiceWorkers are disabled (as they are by default), this must be false: +// 'serviceWorker' in navigator +// If you disable ServiceWorkers in FireFox (about:config) this is the behavior +// you get, and it seems to be the safest way to not break sites. BUT, the +// accessor is baked into the snapshot, so every frame context deletes it from +// its own Navigator.prototype (2 Gets + 1 Delete). +// (If this proves to be an issue, we could swap the logic, and dynamically ADD +// it when it is enabled, but that's a lot more code). +fn hideServiceWorker(self: *const Env, v8_context: *const v8.Context, global_obj: *const v8.Object) void { + const isolate = self.isolate.handle; + const names = &self.disabled_api_names; + + const constructor = v8.v8__Object__Get(global_obj, v8_context, @ptrCast(names.get(isolate, "navigator"))) orelse return; + const prototype = v8.v8__Object__Get(@ptrCast(constructor), v8_context, @ptrCast(names.get(isolate, "prototype"))) orelse return; + + var deleted: v8.MaybeBool = undefined; + v8.v8__Object__Delete(@ptrCast(prototype), v8_context, @ptrCast(names.get(isolate, "service_worker")), &deleted); + if (deleted.has_value == false or deleted.value == false) { + log.warn(.js, "failed to hide navigator.serviceWorker", .{}); + } +} + pub fn destroyContext(self: *Env, context: *Context) void { for (self.contexts.items, 0..) |ctx, i| { if (ctx == context) { @@ -711,6 +754,30 @@ fn oomCallback(c_location: [*c]const u8, details: ?*const v8.OOMDetails) callcon @import("../../crash_handler.zig").crash("V8 OOM", .{ .location = location, .detail = detail }, @returnAddress()); } +const DisabledApiNames = struct { + navigator: v8.Eternal, + prototype: v8.Eternal, + service_worker: v8.Eternal, + + fn init(isolate: *v8.Isolate) DisabledApiNames { + var self: DisabledApiNames = undefined; + intern(isolate, &self.navigator, "Navigator"); + intern(isolate, &self.prototype, "prototype"); + intern(isolate, &self.service_worker, "serviceWorker"); + return self; + } + + fn intern(isolate: *v8.Isolate, out: *v8.Eternal, comptime name: [:0]const u8) void { + const str = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, name.len); + v8.v8__Eternal__New(isolate, @ptrCast(str), out); + } + + fn get(self: *const DisabledApiNames, isolate: *v8.Isolate, comptime field: []const u8) *const v8.String { + const eternal = &@field(self, field); + return @ptrCast(@alignCast(v8.v8__Eternal__Get(@constCast(eternal), isolate).?)); + } +}; + const PrivateSymbols = struct { const Private = @import("Private.zig"); diff --git a/src/browser/js/Snapshot.zig b/src/browser/js/Snapshot.zig index 1a88e6832..e5f216026 100644 --- a/src/browser/js/Snapshot.zig +++ b/src/browser/js/Snapshot.zig @@ -33,6 +33,7 @@ const log = lp.log; const JsApis = bridge.JsApis; const PageJsApis = bridge.PageJsApis; const SharedWorkerJsApis = bridge.SharedWorkerJsApis; +const ServiceWorkerJsApis = bridge.ServiceWorkerJsApis; const DedicatedWorkerJsApis = bridge.DedicatedWorkerJsApis; const Snapshot = @This(); @@ -219,6 +220,12 @@ pub fn create() !Snapshot { const index = try createSnapshotContext(.worker, &SharedWorkerJsApis, SharedWorkerGlobalScope.JsApi, isolate, snapshot_creator.?, &templates); std.debug.assert(index == 2); } + + { + const ServiceWorkerGlobalScope = @import("../webapi/ServiceWorkerGlobalScope.zig"); + const index = try createSnapshotContext(.worker, &ServiceWorkerJsApis, ServiceWorkerGlobalScope.JsApi, isolate, snapshot_creator.?, &templates); + std.debug.assert(index == 3); + } } const blob = v8.v8__SnapshotCreator__createBlob(snapshot_creator, v8.kKeep); diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 53159a8f8..95dbe17c2 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -1190,6 +1190,9 @@ pub const PageJsApis = flattenTypes(&.{ @import("../webapi/BroadcastChannel.zig"), @import("../webapi/Worker.zig"), @import("../webapi/SharedWorker.zig"), + @import("../webapi/ServiceWorker.zig"), + @import("../webapi/ServiceWorkerContainer.zig"), + @import("../webapi/ServiceWorkerRegistration.zig"), @import("../webapi/media/MediaError.zig"), @import("../webapi/media/TextTrackCue.zig"), @import("../webapi/media/VTTCue.zig"), @@ -1268,10 +1271,10 @@ pub const PageJsApis = flattenTypes(&.{ @import("../webapi/collections/DOMStringList.zig"), }); -// APIs available on every Worker context global (constructors like URL, -// Headers, etc.), regardless of worker kind. Each kind's snapshot context -// adds its own global-scope type on top (DedicatedWorkerJsApis, -// SharedWorkerJsApis below). +// APIs available on EVERY worker global — dedicated, shared and service. This +// is the WebIDL `[Exposed=Worker]` set, which covers all three. Each kind's +// snapshot context adds its own global-scope type on top; dedicated and shared +// additionally get worker_extended_apis below. // This is a subset of PageJsApis plus WorkerGlobalScope. // TODO: Expand this list to include all worker-appropriate APIs. const worker_common_apis = [_]type{ @@ -1332,19 +1335,13 @@ const worker_common_apis = [_]type{ @import("../webapi/canvas/TextMetrics.zig"), @import("../webapi/canvas/CanvasGradient.zig"), @import("../webapi/canvas/CanvasPattern.zig"), - @import("../webapi/net/XMLHttpRequest.zig"), - @import("../webapi/net/XMLHttpRequestEventTarget.zig"), - @import("../webapi/net/XMLHttpRequestUpload.zig"), @import("../webapi/net/WebSocket.zig"), @import("../webapi/net/EventSource.zig"), @import("../webapi/FileReader.zig"), - @import("../webapi/FileReaderSync.zig"), @import("../webapi/ImageData.zig"), @import("../webapi/Performance.zig"), @import("../webapi/PerformanceObserver.zig"), - @import("../webapi/storage/CookieStore.zig"), @import("../webapi/storage/idb/idb.zig"), - @import("../webapi/event/CookieChangeEvent.zig"), @import("../webapi/BroadcastChannel.zig"), @import("../webapi/event/CustomEvent.zig"), @import("../webapi/event/ProgressEvent.zig"), @@ -1355,8 +1352,28 @@ const worker_common_apis = [_]type{ @import("../webapi/collections/DOMStringList.zig"), }; -pub const DedicatedWorkerJsApis = flattenTypes(&([_]type{@import("../webapi/DedicatedWorkerGlobalScope.zig")} ++ worker_common_apis)); -pub const SharedWorkerJsApis = flattenTypes(&([_]type{@import("../webapi/SharedWorkerGlobalScope.zig")} ++ worker_common_apis)); +// Additionally available on a dedicated or shared worker, but NOT on a service +// worker. Both are blocking APIs that a service worker — which has to stay +// responsive to lifecycle and (eventually) fetch events — must not have: +// XMLHttpRequest is [Exposed=(Window,DedicatedWorker,SharedWorker)] and +// FileReaderSync is [Exposed=(DedicatedWorker,SharedWorker)]. +const worker_extended_apis = worker_common_apis ++ [_]type{ + @import("../webapi/net/XMLHttpRequest.zig"), + @import("../webapi/net/XMLHttpRequestEventTarget.zig"), + @import("../webapi/net/XMLHttpRequestUpload.zig"), + @import("../webapi/FileReaderSync.zig"), +}; + +pub const DedicatedWorkerJsApis = flattenTypes(&([_]type{@import("../webapi/DedicatedWorkerGlobalScope.zig")} ++ worker_extended_apis)); +pub const SharedWorkerJsApis = flattenTypes(&([_]type{@import("../webapi/SharedWorkerGlobalScope.zig")} ++ worker_extended_apis)); + +pub const ServiceWorkerJsApis = flattenTypes(&([_]type{ + @import("../webapi/ServiceWorkerGlobalScope.zig"), + @import("../webapi/ServiceWorker.zig"), + @import("../webapi/ServiceWorkerRegistration.zig"), + @import("../webapi/event/ExtendableEvent.zig"), + @import("../webapi/storage/CookieStore.zig"), +} ++ worker_common_apis)); // Master list of ALL JS APIs across all contexts. // Used by Env (class IDs, templates), JsApiLookup, and anywhere that needs @@ -1368,6 +1385,9 @@ pub const JsApis = blk: { @import("../webapi/FileReaderSync.zig").JsApi, @import("../webapi/DedicatedWorkerGlobalScope.zig").JsApi, @import("../webapi/SharedWorkerGlobalScope.zig").JsApi, + @import("../webapi/ServiceWorkerGlobalScope.zig").JsApi, + //ServiceWorker-only, so it isn't in PageJsApis either. + @import("../webapi/event/ExtendableEvent.zig").JsApi, @import("../webapi/WorkerGlobalScope.zig").JsApi, @import("../webapi/WorkerLocation.zig").JsApi, @import("../webapi/WorkerNavigator.zig").JsApi, diff --git a/src/browser/tests/service_worker/disabled.html b/src/browser/tests/service_worker/disabled.html new file mode 100644 index 000000000..63df252dd --- /dev/null +++ b/src/browser/tests/service_worker/disabled.html @@ -0,0 +1,19 @@ + + + + + diff --git a/src/browser/tests/service_worker/identity-worker.js b/src/browser/tests/service_worker/identity-worker.js new file mode 100644 index 000000000..6274f6e0c --- /dev/null +++ b/src/browser/tests/service_worker/identity-worker.js @@ -0,0 +1,21 @@ +// Per-realm ServiceWorker object identity: within one realm there is one +// ServiceWorker object per worker, however you reach it. Asserted at top level +// so a mismatch throws before install — a throw inside a listener is swallowed +// by the dispatcher and would make this test pass regardless. +if (self.serviceWorker !== self.registration.installing) { + throw new Error('self.serviceWorker !== self.registration.installing'); +} +if (self.registration !== self.registration) { + throw new Error('self.registration is not stable'); +} + +// waitUntil is only for the lifecycle event being dispatched: on an event the +// script constructed itself it must throw rather than hold a promise. +try { + new ExtendableEvent('x').waitUntil(Promise.resolve()); + throw new Error('waitUntil on a constructed event did not throw'); +} catch (e) { + if (e.name !== 'InvalidStateError') { + throw e; + } +} diff --git a/src/browser/tests/service_worker/lifecycle-worker.js b/src/browser/tests/service_worker/lifecycle-worker.js new file mode 100644 index 000000000..e68478784 --- /dev/null +++ b/src/browser/tests/service_worker/lifecycle-worker.js @@ -0,0 +1,23 @@ +// Records the lifecycle it went through so the page can read it back. +const seen = []; + +self.addEventListener('install', (e) => { + seen.push('install'); + // The canonical install handler shape: waitUntil with a promise that only + // settles on a later turn of the loop. + e.waitUntil(new Promise((resolve) => setTimeout(resolve, 0))); +}); + +self.addEventListener('activate', (e) => { + seen.push('activate'); + e.waitUntil(self.skipWaiting()); +}); + +self.addEventListener('message', (e) => { + seen.push('message:' + e.data); +}); + +self.addEventListener('message', () => { + // Nothing to reply through yet (no `clients`), so the page reads state + // indirectly, via the registration's slots. +}); diff --git a/src/browser/tests/service_worker/realm-worker.js b/src/browser/tests/service_worker/realm-worker.js new file mode 100644 index 000000000..e452bc54a --- /dev/null +++ b/src/browser/tests/service_worker/realm-worker.js @@ -0,0 +1,34 @@ +// Loaded into two realms: as a service worker and as a dedicated worker. +// +// The dedicated side reports back and the page asserts on the message. The +// service worker side has no way to reach a client yet, so it asserts on +// itself and throws — a throw here stops it before it installs, which the page +// sees as `active` never becoming "activated". +const report = { + hasXHR: typeof XMLHttpRequest !== 'undefined', + hasFileReaderSync: typeof FileReaderSync !== 'undefined', + hasFetch: typeof fetch !== 'undefined', + hasWebSocket: typeof WebSocket !== 'undefined', + // [Exposed=(ServiceWorker,Window)] — the constructor and the property that + // hands one out should both be service-worker-only. + hasCookieStoreCtor: typeof CookieStore !== 'undefined', + hasCookieStoreProp: 'cookieStore' in self, + // Blink has this as [Exposed=Window]; a service worker would get + // ExtendableCookieChangeEvent, which we don't implement. + hasCookieChangeEvent: typeof CookieChangeEvent !== 'undefined', +}; + +// A global postMessage exists on DedicatedWorkerGlobalScope only. +if (typeof postMessage === 'function') { + postMessage(report); +} else { + const bad = []; + if (report.hasXHR) bad.push('XMLHttpRequest'); + if (report.hasFileReaderSync) bad.push('FileReaderSync'); + if (report.hasCookieChangeEvent) bad.push('CookieChangeEvent'); + if (!report.hasCookieStoreCtor) bad.push('missing CookieStore'); + if (!report.hasCookieStoreProp) bad.push('missing cookieStore'); + if (bad.length) { + throw new Error('service worker realm: ' + bad.join(', ')); + } +} diff --git a/src/browser/tests/service_worker/service_worker.html b/src/browser/tests/service_worker/service_worker.html new file mode 100644 index 000000000..707b7b31e --- /dev/null +++ b/src/browser/tests/service_worker/service_worker.html @@ -0,0 +1,364 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/service_worker/throwing-worker.js b/src/browser/tests/service_worker/throwing-worker.js new file mode 100644 index 000000000..580cecd09 --- /dev/null +++ b/src/browser/tests/service_worker/throwing-worker.js @@ -0,0 +1 @@ +throw new Error('boom'); diff --git a/src/browser/tests/service_worker/waiting-worker.js b/src/browser/tests/service_worker/waiting-worker.js new file mode 100644 index 000000000..4b25ea3ea --- /dev/null +++ b/src/browser/tests/service_worker/waiting-worker.js @@ -0,0 +1,5 @@ +// Never settles its install promise, so this worker stays at `installing` +// forever. Used to check that a pending waitUntil actually gates activation. +self.addEventListener('install', (e) => { + e.waitUntil(new Promise(() => {})); +}); diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index 0be7ad4bf..d025c240d 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -93,6 +93,7 @@ pub const Type = union(enum) { device_orientation_event: *@import("event/DeviceOrientationEvent.zig"), ui_event: *@import("event/UIEvent.zig"), promise_rejection_event: *@import("event/PromiseRejectionEvent.zig"), + extendable_event: *@import("event/ExtendableEvent.zig"), submit_event: *@import("event/SubmitEvent.zig"), form_data_event: *@import("event/FormDataEvent.zig"), close_event: *@import("event/CloseEvent.zig"), @@ -208,6 +209,7 @@ pub fn is(self: *Event, comptime T: type) ?*T { .gamepad_event => |e| return if (T == @import("event/GamepadEvent.zig")) e else null, .device_orientation_event => |e| return if (T == @import("event/DeviceOrientationEvent.zig")) e else null, .promise_rejection_event => |e| return if (T == @import("event/PromiseRejectionEvent.zig")) e else null, + .extendable_event => |e| return if (T == @import("event/ExtendableEvent.zig")) e else null, .submit_event => |e| return if (T == @import("event/SubmitEvent.zig")) e else null, .form_data_event => |e| return if (T == @import("event/FormDataEvent.zig")) e else null, .close_event => |e| return if (T == @import("event/CloseEvent.zig")) e else null, diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index a33b38376..905b6557e 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -35,6 +35,9 @@ const MessagePort = @import("MessagePort.zig"); const Performance = @import("Performance.zig"); const Notification = @import("Notification.zig"); const SharedWorker = @import("SharedWorker.zig"); +const ServiceWorker = @import("ServiceWorker.zig"); +const ServiceWorkerContainer = @import("ServiceWorkerContainer.zig"); +const ServiceWorkerRegistration = @import("ServiceWorkerRegistration.zig"); const VisualViewport = @import("VisualViewport.zig"); const BroadcastChannel = @import("BroadcastChannel.zig"); const WorkerGlobalScope = @import("WorkerGlobalScope.zig"); @@ -87,6 +90,9 @@ pub const Type = enum(u8) { performance, screen, screen_orientation, + service_worker, + service_worker_container, + service_worker_registration, shared_worker, text_track_cue, visual_viewport, @@ -120,6 +126,9 @@ pub fn Subtype(comptime tag: Type) type { .performance => Performance, .screen => Screen, .screen_orientation => Screen.Orientation, + .service_worker => ServiceWorker, + .service_worker_container => ServiceWorkerContainer, + .service_worker_registration => ServiceWorkerRegistration, .shared_worker => SharedWorker, .text_track_cue => TextTrackCue, .visual_viewport => VisualViewport, @@ -312,6 +321,9 @@ pub fn format(self: *EventTarget, writer: *std.Io.Writer) !void { .generic => writer.writeAll(""), .window => writer.writeAll(""), .worker => writer.writeAll(""), + .service_worker => writer.writeAll(""), + .service_worker_container => writer.writeAll(""), + .service_worker_registration => writer.writeAll(""), .shared_worker => writer.writeAll(""), .worker_global_scope => writer.writeAll(""), .xhr => writer.writeAll(""), @@ -358,6 +370,9 @@ pub fn toString(self: *EventTarget) []const u8 { .performance => return "[object Performance]", .screen => return "[object Screen]", .screen_orientation => return "[object ScreenOrientation]", + .service_worker => return "[object ServiceWorker]", + .service_worker_container => return "[object ServiceWorkerContainer]", + .service_worker_registration => return "[object ServiceWorkerRegistration]", .shared_worker => return "[object SharedWorker]", .text_track_cue => return "[object TextTrackCue]", .visual_viewport => return "[object VisualViewport]", diff --git a/src/browser/webapi/Navigator.zig b/src/browser/webapi/Navigator.zig index b73b6e906..6b846f485 100644 --- a/src/browser/webapi/Navigator.zig +++ b/src/browser/webapi/Navigator.zig @@ -29,6 +29,7 @@ const ModelContext = @import("ModelContext.zig"); const StorageManager = @import("StorageManager.zig"); const NavigatorUAData = @import("NavigatorUAData.zig"); const Geolocation = @import("geolocation/Geolocation.zig"); +const ServiceWorkerContainer = @import("ServiceWorkerContainer.zig"); const Navigator = @This(); @@ -46,6 +47,7 @@ _permissions: Permissions = .{}, _geolocation: ?*Geolocation = null, _storage: StorageManager = .{}, _ua_data: NavigatorUAData = .{}, +_service_worker: ?*ServiceWorkerContainer = null, pub const init: Navigator = .{}; @@ -162,7 +164,20 @@ fn getStorage(self: *Navigator) *StorageManager { return &self._storage; } -fn getUserAgentData(self: *Navigator) *NavigatorUAData { +// NOTE, Env.createContext will remove the binding for this API at runtime if +// ServiceWorkers are not enabled (and by default, they are not). +// TODO: service workers should only exist where window.isSecureContext === true, +// but we always return false. +fn getServiceWorker(self: *Navigator, frame: *Frame) !*ServiceWorkerContainer { + if (self._service_worker) |sw| { + return sw; + } + const sw = try ServiceWorkerContainer.init(frame); + self._service_worker = sw; + return sw; +} + +pub fn getUserAgentData(self: *Navigator) *NavigatorUAData { return &self._ua_data; } @@ -269,6 +284,7 @@ pub const JsApi = struct { pub const sendBeacon = bridge.function(Navigator.sendBeacon, .{}); pub const permissions = bridge.accessor(Navigator.getPermissions, null, .{}); pub const storage = bridge.accessor(Navigator.getStorage, null, .{}); + pub const serviceWorker = bridge.accessor(Navigator.getServiceWorker, null, .{}); pub const userAgentData = bridge.accessor(Navigator.getUserAgentData, null, .{}); pub const plugins = bridge.accessor(Navigator.getPlugins, null, .{}); pub const geolocation = bridge.accessor(Navigator.getGeolocation, null, .{}); diff --git a/src/browser/webapi/ServiceWorker.zig b/src/browser/webapi/ServiceWorker.zig new file mode 100644 index 000000000..28e035cbe --- /dev/null +++ b/src/browser/webapi/ServiceWorker.zig @@ -0,0 +1,165 @@ +// 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. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// There's at least 1 instance of this at both ends: the page and the worker. +// They are all just views into a single ServiceWorkerGlobalScope which holds +// all the state. The ServiceWorker just holds its own event registration. + +const std = @import("std"); +const lp = @import("lightpanda"); + +const js = @import("../js/js.zig"); + +const Event = @import("Event.zig"); +const EventTarget = @import("EventTarget.zig"); +const WorkerGlobalScope = @import("WorkerGlobalScope.zig"); +const ServiceWorkerGlobalScope = @import("ServiceWorkerGlobalScope.zig"); + +const log = lp.log; +const Execution = js.Execution; + +const ServiceWorker = @This(); + +pub const Proto = EventTarget; + +_proto: *EventTarget, +_exec: *Execution, +// Null once the scope is gone. Reports as `redundant` in that case +_scope: ?*ServiceWorkerGlobalScope, +_on_state_change: ?js.Function.Global = null, + +pub const State = enum { + parsed, + installing, + installed, + activating, + activated, + redundant, + + pub fn toString(self: State) []const u8 { + return switch (self) { + .parsed => "parsed", + .installing => "installing", + .installed => "installed", + .activating => "activating", + .activated => "activated", + .redundant => "redundant", + }; + } +}; + +pub fn init(scope: *ServiceWorkerGlobalScope, exec: *Execution) !*ServiceWorker { + return exec._factory.eventTargetWithAllocator(exec.arena, ServiceWorker{ + ._proto = undefined, + ._exec = exec, + ._scope = scope, + }); +} + +pub fn detach(self: *ServiceWorker) void { + self._scope = null; +} + +pub fn asEventTarget(self: *ServiceWorker) *EventTarget { + return self._proto; +} + +pub fn getScriptURL(self: *const ServiceWorker) []const u8 { + const scope = self._scope orelse return ""; + return scope._proto.url; +} + +pub fn getState(self: *const ServiceWorker) State { + const scope = self._scope orelse return .redundant; + return scope._state; +} + +pub fn postMessage(self: *ServiceWorker, data: js.Value) !void { + const scope = self._scope orelse return; + return scope.receiveMessage(data); +} + +pub fn stateChanged(self: *ServiceWorker) void { + const exec = self._exec; + if (exec.hasDirectListeners(self._proto, "statechange", self._on_state_change) == false) { + return; + } + + self.scheduleStateChange() catch |err| { + log.warn(.browser, "service worker statechange", .{ .err = err }); + }; +} + +fn scheduleStateChange(self: *ServiceWorker) !void { + const exec = self._exec; + + const arena = try exec.getArena(.tiny, "ServiceWorker.statechange"); + errdefer arena.release(); + + const callback = try arena.create(StateChangeCallback); + callback.* = .{ .worker = self, .arena = arena }; + + try exec.js.scheduler.add(callback, StateChangeCallback.run, 0, .{ + .name = "ServiceWorker.statechange", + .finalizer = StateChangeCallback.cancelled, + }); +} +pub fn getOnStateChange(self: *const ServiceWorker) ?js.Function.Global { + return self._on_state_change; +} + +pub fn setOnStateChange(self: *ServiceWorker, setter: ?WorkerGlobalScope.FunctionSetter) void { + self._on_state_change = WorkerGlobalScope.getFunctionFromSetter(setter); +} + +const StateChangeCallback = struct { + arena: *lp.Arena, + worker: *ServiceWorker, + + fn cancelled(ctx: *anyopaque) void { + const self: *StateChangeCallback = @ptrCast(@alignCast(ctx)); + self.arena.release(); + } + + fn run(ctx: *anyopaque) !?u32 { + const self: *StateChangeCallback = @ptrCast(@alignCast(ctx)); + defer self.arena.release(); + + const worker = self.worker; + const exec = worker._exec; + const event = (try Event.initTrusted(comptime .wrap("statechange"), .{ + .bubbles = false, + .cancelable = false, + }, exec.page)); + + try exec.dispatch(worker._proto, event, worker._on_state_change, .{ + .context = "ServiceWorker.statechange", + }); + return null; + } +}; + +pub const JsApi = struct { + pub const bridge = js.Bridge(ServiceWorker); + + pub const Meta = struct { + pub const name = "ServiceWorker"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const scriptURL = bridge.accessor(ServiceWorker.getScriptURL, null, .{}); + pub const state = bridge.accessor(ServiceWorker.getState, null, .{}); + pub const postMessage = bridge.function(ServiceWorker.postMessage, .{}); + pub const onstatechange = bridge.accessor(ServiceWorker.getOnStateChange, ServiceWorker.setOnStateChange, .{}); +}; diff --git a/src/browser/webapi/ServiceWorkerContainer.zig b/src/browser/webapi/ServiceWorkerContainer.zig new file mode 100644 index 000000000..102f00454 --- /dev/null +++ b/src/browser/webapi/ServiceWorkerContainer.zig @@ -0,0 +1,312 @@ +// 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. +// +// 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 lp = @import("lightpanda"); + +const js = @import("../js/js.zig"); + +const URL = @import("../URL.zig"); +const Frame = @import("../Frame.zig"); + +const Worker = @import("Worker.zig"); +const EventTarget = @import("EventTarget.zig"); +const ServiceWorker = @import("ServiceWorker.zig"); +const ServiceWorkerGlobalScope = @import("ServiceWorkerGlobalScope.zig"); +const ServiceWorkerRegistration = @import("ServiceWorkerRegistration.zig"); + +const log = lp.log; + +const ServiceWorkerContainer = @This(); + +pub const Proto = EventTarget; + +_proto: *EventTarget, +_frame: *Frame, + +// One per scope this container has registered +_registrations: std.ArrayList(*ServiceWorkerRegistration) = .empty, + +_ready: ?js.Promise.Global = null, +_ready_resolver: ?js.PromiseResolver.Global = null, + +const RegisterOptions = struct { + scope: ?[]const u8 = null, + type: Worker.WorkerType = .classic, + updateViaCache: ?[]const u8 = null, +}; + +pub fn init(frame: *Frame) !*ServiceWorkerContainer { + const self = try frame._factory.eventTargetWithAllocator(frame.arena, ServiceWorkerContainer{ + ._proto = undefined, + ._frame = frame, + }); + return self; +} + +// Called from Frame.deinit +pub fn detach(self: *ServiceWorkerContainer) void { + for (self._registrations.items) |registration| { + registration.detach(); + } + self._registrations.clearRetainingCapacity(); + + if (self._ready_resolver) |resolver| { + self._ready_resolver = null; + resolver.release(); + } + if (self._ready) |promise| { + self._ready = null; + promise.release(); + } +} + +pub fn asEventTarget(self: *ServiceWorkerContainer) *EventTarget { + return self._proto; +} + +// Called by a worker once it reaches `activated`. +pub fn workerActivated(self: *ServiceWorkerContainer) void { + if (self._ready_resolver == null) { + // Nobody waiting: `ready` was never asked for, or has already settled. + return; + } + + self.scheduleReady() catch |err| { + log.warn(.browser, "service worker ready", .{ .err = err }); + }; +} + +fn scheduleReady(self: *ServiceWorkerContainer) !void { + const frame = self._frame; + + const arena = try frame._session.getArena(.tiny, "ServiceWorkerContainer.ready"); + errdefer arena.release(); + + const callback = try arena.create(ReadyCallback); + callback.* = .{ .container = self, .arena = arena }; + + try frame.js.scheduler.add(callback, ReadyCallback.run, 0, .{ + .name = "ServiceWorkerContainer.ready", + .finalizer = ReadyCallback.cancelled, + }); +} + +pub fn register(self: *ServiceWorkerContainer, url: []const u8, options: ?RegisterOptions, frame: *Frame) !js.Promise { + const resolver = frame.js.local.?.createPromiseResolver(); + + const opts = options orelse RegisterOptions{}; + const script_url = URL.resolve(frame.local_arena, frame.base(), url, .{ .encoding = frame.charset }) catch { + resolver.rejectError("ServiceWorkerContainer.register", .{ .type_error = "Failed to resolve script URL" }); + return resolver.promise(); + }; + + // A worker may only be registered by, and control, its own origin. + if (frame.isSameOrigin(script_url) == false) { + resolver.reject("ServiceWorkerContainer.register", @import("DOMException.zig").init( + "The origin of the provided scriptURL does not match the current origin.", + "SecurityError", + )); + return resolver.promise(); + } + + const scope_url = blk: { + const raw = opts.scope orelse blk2: { + // A registration's default scope is the script's own directory. + const end = std.mem.lastIndexOfScalar(u8, script_url, '/') orelse break :blk2 script_url; + break :blk2 script_url[0 .. end + 1]; + }; + + break :blk URL.resolve(frame.local_arena, frame.base(), raw, .{ .encoding = frame.charset }) catch { + resolver.rejectError("ServiceWorkerContainer.register", .{ .type_error = "Failed to resolve scope URL" }); + return resolver.promise(); + }; + }; + + const scope = ServiceWorkerGlobalScope.getOrCreate(frame, script_url, scope_url, opts.type) catch |err| { + log.err(.browser, "ServiceWorker register", .{ .url = script_url, .err = err }); + resolver.rejectError("ServiceWorkerContainer.register", .{ .type_error = "Failed to register a ServiceWorker" }); + return resolver.promise(); + }; + + const registration = try self.track(scope); + resolver.resolve("ServiceWorkerContainer.register", registration); + return resolver.promise(); +} + +fn track(self: *ServiceWorkerContainer, scope: *ServiceWorkerGlobalScope) !*ServiceWorkerRegistration { + for (self._registrations.items) |registration| { + if (registration._scope == scope) { + return registration; + } + } + + const frame = self._frame; + const registration = try ServiceWorkerRegistration.init(scope, self, &frame.js.execution); + errdefer registration.detach(); + try self._registrations.append(frame.arena, registration); + return registration; +} + +// For now, always null: we never dispatch fetch events. +pub fn getController(_: *ServiceWorkerContainer) ?*ServiceWorker { + return null; +} + +pub fn getReady(self: *ServiceWorkerContainer, exec: *const js.Execution) !js.Promise { + if (self._ready != null) { + return exec.js.toLocal(self._ready).?; + } + + const resolver = exec.js.local.?.createPromiseResolver(); + const promise = resolver.promise(); + self._ready = try promise.persist(); + + if (self.activeRegistration()) |registration| { + // already have an active worker, resolve immediately. + resolver.resolve("ServiceWorkerContainer.ready", registration); + } else { + self._ready_resolver = try resolver.persist(); + } + return promise; +} + +pub fn getRegistration(self: *ServiceWorkerContainer, url: ?[]const u8, frame: *Frame) !js.Promise { + const resolver = frame.js.local.?.createPromiseResolver(); + + const client_url = if (url) |u| + URL.resolve(frame.local_arena, frame.base(), u, .{ .encoding = frame.charset }) catch { + resolver.resolve("ServiceWorkerContainer.getRegistration", {}); + return resolver.promise(); + } + else + frame.url; + + if (self.matchScope(false, client_url)) |registration| { + resolver.resolve("ServiceWorkerContainer.getRegistration", registration); + return resolver.promise(); + } + + // Resolves with undefined when nothing matches. + resolver.resolve("ServiceWorkerContainer.getRegistration", {}); + return resolver.promise(); +} + +pub fn getRegistrations(self: *ServiceWorkerContainer, exec: *const js.Execution) !js.Promise { + const resolver = exec.js.local.?.createPromiseResolver(); + + var registrations: std.ArrayList(*ServiceWorkerRegistration) = try .initCapacity(exec.local_arena, self._registrations.items.len); + for (self._registrations.items) |registration| { + if (registration._scope == null) { + continue; + } + registrations.appendAssumeCapacity(registration); + } + + resolver.resolve("ServiceWorkerContainer.getRegistrations", registrations.items); + return resolver.promise(); +} + +// The registration with an active worker whose scope covers this frame's URL. +fn activeRegistration(self: *ServiceWorkerContainer) ?*ServiceWorkerRegistration { + return self.matchScope(true, self._frame.url); +} + +// The registration whose scope covers `client_url`, longest scope winning. +fn matchScope(self: *ServiceWorkerContainer, only_active: bool, client_url: []const u8) ?*ServiceWorkerRegistration { + var best_len: usize = 0; + var best: ?*ServiceWorkerRegistration = null; + + for (self._registrations.items) |registration| { + const scope = registration._scope orelse continue; + if (only_active and scope.hasActiveWorker() == false) { + continue; + } + const scope_url = scope._scope_url; + if (std.mem.startsWith(u8, client_url, scope_url) == false) { + continue; + } + if (best == null or scope_url.len > best_len) { + best = registration; + best_len = scope_url.len; + } + } + return best; +} + +// Messages from a worker are delivered as soon as they're posted +pub fn startMessages(_: *ServiceWorkerContainer) void {} + +const ReadyCallback = struct { + arena: *lp.Arena, + container: *ServiceWorkerContainer, + + fn cancelled(ctx: *anyopaque) void { + const self: *ReadyCallback = @ptrCast(@alignCast(ctx)); + self.arena.release(); + } + + fn run(ctx: *anyopaque) !?u32 { + const self: *ReadyCallback = @ptrCast(@alignCast(ctx)); + defer self.arena.release(); + + const container = self.container; + const resolver = container._ready_resolver orelse return null; + const registration = container.activeRegistration() orelse return null; + + // clear this so we don't resolve again + container._ready_resolver = null; + defer resolver.release(); + + var ls: js.Local.Scope = undefined; + container._frame.js.localScope(&ls); + defer ls.deinit(); + + ls.toLocal(resolver).resolve("ServiceWorkerContainer.ready", registration); + return null; + } +}; + +pub const JsApi = struct { + pub const bridge = js.Bridge(ServiceWorkerContainer); + + pub const Meta = struct { + pub const name = "ServiceWorkerContainer"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const controller = bridge.accessor(ServiceWorkerContainer.getController, null, .{}); + pub const ready = bridge.accessor(ServiceWorkerContainer.getReady, null, .{}); + pub const register = bridge.function(ServiceWorkerContainer.register, .{}); + pub const getRegistration = bridge.function(ServiceWorkerContainer.getRegistration, .{}); + pub const getRegistrations = bridge.function(ServiceWorkerContainer.getRegistrations, .{}); + pub const startMessages = bridge.function(ServiceWorkerContainer.startMessages, .{}); + + // A worker has no way to reach a client yet, so the API is missing things + // like onmessage for now +}; + +const testing = @import("../../testing.zig"); +test "WebApi: ServiceWorker" { + // The throwing-worker and cross-origin cases log at error level on purpose. + testing.silenceLog(&.{ .http, .browser }); + try testing.htmlRunner("service_worker/service_worker.html", .{ + .timeout_ms = 8000, + .experimental_features = .{ .serviceworker = true }, + }); +} + +test "WebApi: ServiceWorker disabled" { + try testing.htmlRunner("service_worker/disabled.html", .{ .timeout_ms = 8000 }); +} diff --git a/src/browser/webapi/ServiceWorkerGlobalScope.zig b/src/browser/webapi/ServiceWorkerGlobalScope.zig new file mode 100644 index 000000000..e403e03ea --- /dev/null +++ b/src/browser/webapi/ServiceWorkerGlobalScope.zig @@ -0,0 +1,618 @@ +// 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. +// +// 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 lp = @import("lightpanda"); + +const js = @import("../js/js.zig"); +const Transfer = @import("../../network/HttpClient.zig").Transfer; + +const URL = @import("../URL.zig"); +const Frame = @import("../Frame.zig"); + +const Worker = @import("Worker.zig"); +const ServiceWorker = @import("ServiceWorker.zig"); +const WorkerGlobalScope = @import("WorkerGlobalScope.zig"); +const ServiceWorkerRegistration = @import("ServiceWorkerRegistration.zig"); + +const CookieStore = @import("storage/CookieStore.zig"); +const MessageEvent = @import("event/MessageEvent.zig"); +const ExtendableEvent = @import("event/ExtendableEvent.zig"); + +const log = lp.log; +const String = lp.String; + +const ServiceWorkerGlobalScope = @This(); + +pub const Proto = WorkerGlobalScope; + +const ScriptLoad = struct { + arena: *lp.Arena, + buffer: std.ArrayList(u8) = .empty, + transfer: ?*Transfer = null, +}; + +_proto: *WorkerGlobalScope, +_arena: *lp.Arena, + +_scope_url: []const u8, +_state: ServiceWorker.State = .parsed, + +// The in-flight fetch of the initial script. +_script_load: ?ScriptLoad = null, + +// The registration associated with this side, if any +_self_registration: ?*ServiceWorkerRegistration = null, + +_cookie_store: ?*CookieStore = null, +_on_install: ?js.Function.Global = null, +_on_activate: ?js.Function.Global = null, +_on_message: ?js.Function.Global = null, + +// Our event dispatching is more complicated, because it can be held by an +// abitrary JS promise (ExtendableEvent.waitUntil). So we create this, hold it +// (aka acquireRef() it), dispatch it when the promise resolve, and then +// releaseRef(). +_pending_event: ?*ExtendableEvent = null, + +// Every SWR related to us. If 2 frames register the same URL, that's 2 SWR +// minimum (on the page-side, they are immediately created since that's what +// register(URL) returns). + 2 optional SWR, one for each _self_registration +// (a few fields up) which is lazily created on demand (on the "worker side"). +_registrations: std.DoublyLinkedList = .{}, + +// Messages posted before the script finished evaluating. Same reasoning as +// DedicatedWorkerGlobalScope: a message that arrives before the script has had +// a chance to register onmessage would otherwise be dropped silently. +_pending_messages: std.ArrayList(?js.Value.Global) = .empty, + +// Never created directly, always via the getOrCreate factory +fn init( + frame: *Frame, + script_url: [:0]const u8, + scope_url: []const u8, + worker_type: Worker.WorkerType, +) !*ServiceWorkerGlobalScope { + const session = frame._session; + + const arena = try session.getArena(.small, "ServiceWorker"); + errdefer arena.release(); + + const owned_url = try arena.dupeZ(u8, script_url); + const frame_id = session.nextFrameId(); + const loader_id = session.nextLoaderId(); + + const self = try WorkerGlobalScope.init( + arena.allocator(), + owned_url, + .service, + ServiceWorkerGlobalScope{ + ._proto = undefined, + ._arena = arena, + ._scope_url = try arena.dupe(u8, scope_url), + }, + worker_type == .module, + frame_id, + loader_id, + frame, + ); + const proto = self._proto; + errdefer proto.deinit(); + + self._script_load = .{ .arena = try session.getArena(.large, "ServiceWorker.script") }; + errdefer self.releaseScriptLoad(); + + const transfer = proto.newRequest(.{ + .ctx = self, + .method = .GET, + .url = owned_url, + .resource_type = .worker, + .origin = frame.origin, + .credentials_mode = .same_origin, + .request_mode = .same_origin, + .header_callback = httpHeaderCallback, + .data_callback = httpDataCallback, + .done_callback = httpDoneCallback, + .error_callback = httpErrorCallback, + .shutdown_callback = httpShutdownCallback, + }) catch |err| { + log.err(.browser, "ServiceWorker request", .{ .url = owned_url, .err = err }); + return err; + }; + self._script_load.?.transfer = transfer; + transfer.submit() catch |err| { + log.err(.browser, "ServiceWorker request", .{ .url = owned_url, .err = err }); + return err; + }; + return self; +} + +// Called from Page.deinit of the owning (creating) page. +pub fn deinit(self: *ServiceWorkerGlobalScope) void { + if (self._script_load) |*load| { + if (load.transfer) |transfer| { + load.transfer = null; + transfer.cancel(); // re-enters httpErrorCallback -> releaseScriptLoad + } + } + for (self._pending_messages.items) |data_| { + if (data_) |d| { + d.release(); + } + } + + self.detachRegistrations(); + self.releasePendingEvent(); + + self.releaseScriptLoad(); + _ = self.unregister(); + self._proto.deinit(); + self._arena.release(); +} + +pub fn getOrCreate( + frame: *Frame, + script_url: [:0]const u8, + scope_url: []const u8, + worker_type: Worker.WorkerType, +) !*ServiceWorkerGlobalScope { + const session = frame._session; + if (session.service_workers.get(scope_url)) |existing| { + if (std.mem.eql(u8, existing._proto.url, script_url)) { + return existing; + } + // No update pipeline yet: a different script for the same scope + // replaces the registration outright. The old worker goes redundant and + // every realm's registration for it is detached. + _ = existing.unregister(); + } + + const self = try init(frame, script_url, scope_url, worker_type); + errdefer self.deinit(); + + const page = frame.page; + try page.service_workers.append(page.frame_arena, self); + errdefer _ = page.service_workers.pop(); + + try session.service_workers.put(session.arena.allocator(), self._scope_url, self); + return self; +} + +pub fn unregister(self: *ServiceWorkerGlobalScope) bool { + const session = self._proto._session; + const entry = session.service_workers.getEntry(self._scope_url) orelse return false; + + if (entry.value_ptr.* != self) { + // scope was re-used by another worker, we don't know it anymore + return false; + } + + session.service_workers.removeByPtr(entry.key_ptr); + self.setState(.redundant); + self.detachRegistrations(); + return true; +} + +fn detachRegistrations(self: *ServiceWorkerGlobalScope) void { + while (self._registrations.first) |node| { + const registration: *ServiceWorkerRegistration = @alignCast(@fieldParentPtr("_node", node)); + registration.detach(); // removes from self._registrations + } +} + +pub fn hasActiveWorker(self: *const ServiceWorkerGlobalScope) bool { + return self._state == .activating or self._state == .activated; +} + +fn httpHeaderCallback(transfer: *Transfer) !Transfer.HeaderResult { + const self: *ServiceWorkerGlobalScope = @ptrCast(@alignCast(transfer.req.ctx)); + + const status = transfer.responseStatus() orelse return .abort; + if (status < 200 or status >= 300) { + log.warn(.browser, "ServiceWorker status", .{ .url = self._proto.url, .status = status }); + return .abort; + } + + const load = &self._script_load.?; + try load.buffer.ensureTotalCapacityPrecise(load.arena.allocator(), transfer.bodyLen()); + + return .proceed; +} + +fn httpDataCallback(transfer: *Transfer, data: []const u8) !void { + const self: *ServiceWorkerGlobalScope = @ptrCast(@alignCast(transfer.req.ctx)); + const load = &self._script_load.?; + try load.buffer.appendSlice(load.arena.allocator(), data); +} + +fn httpDoneCallback(ctx: *anyopaque) !void { + const self: *ServiceWorkerGlobalScope = @ptrCast(@alignCast(ctx)); + // clear immediately, we 100% don't own this anymore + self._script_load.?.transfer = null; + defer self.releaseScriptLoad(); + + if (comptime lp.IS_DEBUG) { + log.info(.browser, "service worker fetch done", .{ + .url = self._proto.url, + .len = self._script_load.?.buffer.items.len, + }); + } + + try self.loadInitialScript(self._script_load.?.buffer.items); +} + +fn httpShutdownCallback(ctx: *anyopaque) void { + const self: *ServiceWorkerGlobalScope = @ptrCast(@alignCast(ctx)); + self.releaseScriptLoad(); +} + +fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void { + const self: *ServiceWorkerGlobalScope = @ptrCast(@alignCast(ctx)); + self.releaseScriptLoad(); + + if (err != error.TransferCanceled) { + log.err(.browser, "service worker fetch error", .{ .url = self._proto.url, .err = err }); + } + + self.setState(.redundant); + _ = self.unregister(); + + // The script will never run, so nothing can ever handle these. + for (self._pending_messages.items) |cloned_data| { + if (cloned_data) |d| d.release(); + } + self._pending_messages.clearRetainingCapacity(); +} + +fn loadInitialScript(self: *ServiceWorkerGlobalScope, script: []const u8) !void { + const js_context = self._proto.js; + + if (js_context.env.terminatePending()) { + return; + } + + var ls: js.Local.Scope = undefined; + js_context.localScope(&ls); + defer ls.deinit(); + + var try_catch: js.TryCatch = undefined; + try_catch.init(&ls.local); + defer try_catch.deinit(); + + const url = self._proto.url; + var evaluated = true; + if (self._proto._is_module) { + js_context.module(false, &ls.local, script, url, true) catch |err| { + if (js_context.env.terminatePending()) { + return; + } + js_context.page.recordJsError(err); + const caught = try_catch.caughtOrError(self._script_load.?.arena.allocator(), err); + log.err(.browser, "service worker module error", .{ .url = url, .caught = caught }); + evaluated = false; + }; + } else { + ls.local.eval(script, url) catch |err| { + if (js_context.env.terminatePending()) { + return; + } + js_context.page.recordJsError(err); + const caught = try_catch.caughtOrError(self._script_load.?.arena.allocator(), err); + log.err(.browser, "service worker script error", .{ .url = url, .caught = caught }); + evaluated = false; + }; + } + + ls.local.runMacrotasks(); + + if (evaluated == false) { + self.setState(.redundant); + _ = self.unregister(); + self.drainPendingMessages(); + return; + } + + self.beginInstall(); + self.drainPendingMessages(); +} + +fn releaseScriptLoad(self: *ServiceWorkerGlobalScope) void { + const load = self._script_load orelse return; + self._script_load = null; + load.arena.release(); +} + +fn beginInstall(self: *ServiceWorkerGlobalScope) void { + self.setState(.installing); + self.dispatchExtendable(comptime .wrap("install"), self._on_install, struct { + fn done(ctx: *anyopaque) void { + const s: *ServiceWorkerGlobalScope = @ptrCast(@alignCast(ctx)); + s.releasePendingEvent(); + s.setState(.installed); + s.beginActivate(); + } + }.done) catch |err| { + log.warn(.browser, "service worker install", .{ .url = self._proto.url, .err = err }); + self.beginActivate(); + }; +} + +fn beginActivate(self: *ServiceWorkerGlobalScope) void { + if (self._state == .redundant) { + // Unregistered (or replaced) while install's waitUntil was pending. + return; + } + self.setState(.activating); + self.dispatchExtendable(comptime .wrap("activate"), self._on_activate, struct { + fn done(ctx: *anyopaque) void { + const s: *ServiceWorkerGlobalScope = @ptrCast(@alignCast(ctx)); + s.releasePendingEvent(); + s.finishActivation(); + } + }.done) catch |err| { + log.warn(.browser, "service worker activate", .{ .url = self._proto.url, .err = err }); + self.finishActivation(); + }; +} + +fn finishActivation(self: *ServiceWorkerGlobalScope) void { + self.setState(.activated); + + // Containers only *schedule* the `ready` resolution, they don't execute JS + // directly, i.e. no mutation of `_registrations` is possible here. + var node = self._registrations.first; + while (node) |n| : (node = n.next) { + const registration: *ServiceWorkerRegistration = @alignCast(@fieldParentPtr("_node", n)); + if (registration._container) |container| { + // registration._container is only set for SWR from the "page side". + // so this is us signaling the page-side container of the activation. + container.workerActivated(); + } + } +} + +fn dispatchExtendable( + self: *ServiceWorkerGlobalScope, + typ: String, + handler: ?js.Function.Global, + comptime on_done: fn (ctx: *anyopaque) void, +) !void { + const wgs = self._proto; + const event = try ExtendableEvent.initTrusted(typ, .{ + .bubbles = false, + .cancelable = false, + }, wgs.page); + + const base = event.asEvent(); + + base.acquireRef(); // on_done is responsible for releasing this + self._pending_event = event; + errdefer self.releasePendingEvent(); + + try wgs.dispatch(wgs.asEventTarget(), base, handler, .{ .context = "ServiceWorkerGlobalScope lifecycle" }); + + // Seal only after the handlers have run, so a synchronous waitUntil is + // counted before an empty pending set can complete the phase. + event.seal(.{ .ctx = self, .func = on_done }); +} + +fn releasePendingEvent(self: *ServiceWorkerGlobalScope) void { + const event = self._pending_event orelse return; + self._pending_event = null; + event.releaseRef(self._proto.page); +} + +fn setState(self: *ServiceWorkerGlobalScope, state: ServiceWorker.State) void { + if (self._state == state or self._state == .redundant) { + // redundant is terminal, once reached, we cannot put the worker back + // into intalled/activating + return; + } + self._state = state; + + // Handles only *schedule* their statechange event, so this walk never + // crosses into user JS and can't have the list mutated under it. + var node = self._registrations.first; + while (node) |n| : (node = n.next) { + const registration: *ServiceWorkerRegistration = @alignCast(@fieldParentPtr("_node", n)); + registration.stateChanged(); + } +} + +pub fn receiveMessage(self: *ServiceWorkerGlobalScope, data: js.Value) !void { + if (self._state == .redundant) { + return; + } + + const cloned_data: ?js.Value.Global = blk: { + var ls: js.Local.Scope = undefined; + self._proto.js.localScope(&ls); + defer ls.deinit(); + + const cloned = data.structuredCloneTo(&ls.local) catch break :blk null; + break :blk cloned.persist() catch break :blk null; + }; + + if (self._state == .parsed) { + // script hasn't loaded yet + try self._pending_messages.append(self._proto.arena, cloned_data); + return; + } + + try self.scheduleMessage(cloned_data); +} + +fn scheduleMessage(self: *ServiceWorkerGlobalScope, cloned_data: ?js.Value.Global) !void { + const wgs = self._proto; + const session = wgs._session; + + const message_arena = try session.getArena(.tiny, "ServiceWorkerGlobalScope.receiveMessage"); + errdefer message_arena.release(); + + const callback = try message_arena.create(ReceiveMessageCallback); + callback.* = .{ + .data = cloned_data, + .worker_scope = self, + .arena = message_arena, + }; + + try wgs.js.scheduler.add(callback, ReceiveMessageCallback.run, 0, .{ + .name = "ServiceWorkerGlobalScope.receiveMessage", + .finalizer = ReceiveMessageCallback.cancelled, + }); +} + +fn drainPendingMessages(self: *ServiceWorkerGlobalScope) void { + for (self._pending_messages.items) |cloned_data| { + self.scheduleMessage(cloned_data) catch |err| { + log.warn(.browser, "service worker drain msg failed", .{ .err = err }); + if (cloned_data) |d| { + d.release(); + } + }; + } + self._pending_messages.clearRetainingCapacity(); +} + +const ReceiveMessageCallback = struct { + data: ?js.Value.Global, + arena: *lp.Arena, + worker_scope: *ServiceWorkerGlobalScope, + + 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.arena.release(); + } + + fn run(ctx: *anyopaque) !?u32 { + const self: *ReceiveMessageCallback = @ptrCast(@alignCast(ctx)); + defer self.deinit(); + + const wgs = self.worker_scope._proto; + const target = wgs.asEventTarget(); + + const data = self.data orelse { + if (wgs._event_manager.hasDirectListeners(target, "messageerror", null)) { + const event = (try MessageEvent.initTrusted(comptime .wrap("messageerror"), .{ + .bubbles = false, + .cancelable = false, + }, wgs.page)).asEvent(); + try wgs.dispatch(target, event, null, .{}); + } + return null; + }; + + const on_message = self.worker_scope._on_message; + if (wgs._event_manager.hasDirectListeners(target, "message", on_message) == false) { + data.release(); + return null; + } + + const event = (try MessageEvent.initTrusted(comptime .wrap("message"), .{ + .data = .{ .value = self.data.? }, + .bubbles = false, + .cancelable = false, + }, wgs.page)).asEvent(); + try wgs.dispatch(target, event, on_message, .{}); + return null; + } +}; + +pub fn getRegistration(self: *ServiceWorkerGlobalScope, exec: *js.Execution) !*ServiceWorkerRegistration { + if (self._self_registration) |r| { + return r; + } + const r = try ServiceWorkerRegistration.init(self, null, exec); + self._self_registration = r; + return r; +} + +pub fn getServiceWorker(self: *ServiceWorkerGlobalScope, exec: *js.Execution) !*ServiceWorker { + const registration = try self.getRegistration(exec); + return registration.worker(self); +} + +// TODO: Need an `ExtendableCookieChangeEvent` so that we can fire change +// notifications (for now, reads work) +pub fn getCookieStore(self: *ServiceWorkerGlobalScope) !*CookieStore { + if (self._cookie_store) |cs| { + return cs; + } + const wgs = self._proto; + const cs = try wgs._factory.eventTargetWithAllocator(wgs.arena, CookieStore{ ._proto = undefined }); + self._cookie_store = cs; + return cs; +} + +// We don't yet have anything that "waits", so we can resolvethis immediately. +// Makes sure that the following common usage doesn't fail: +// self.addEventListener('install', e => e.waitUntil(self.skipWaiting())) +pub fn skipWaiting(_: *ServiceWorkerGlobalScope, exec: *const js.Execution) !js.Promise { + const resolver = exec.js.local.?.createPromiseResolver(); + resolver.resolve("ServiceWorkerGlobalScope.skipWaiting", {}); + return resolver.promise(); +} + +pub fn getOnInstall(self: *const ServiceWorkerGlobalScope) ?js.Function.Global { + return self._on_install; +} + +pub fn setOnInstall(self: *ServiceWorkerGlobalScope, setter: ?WorkerGlobalScope.FunctionSetter) void { + self._on_install = WorkerGlobalScope.getFunctionFromSetter(setter); +} + +pub fn getOnActivate(self: *const ServiceWorkerGlobalScope) ?js.Function.Global { + return self._on_activate; +} + +pub fn setOnActivate(self: *ServiceWorkerGlobalScope, setter: ?WorkerGlobalScope.FunctionSetter) void { + self._on_activate = WorkerGlobalScope.getFunctionFromSetter(setter); +} + +pub fn getOnMessage(self: *const ServiceWorkerGlobalScope) ?js.Function.Global { + return self._on_message; +} + +pub fn setOnMessage(self: *ServiceWorkerGlobalScope, setter: ?WorkerGlobalScope.FunctionSetter) void { + self._on_message = WorkerGlobalScope.getFunctionFromSetter(setter); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(ServiceWorkerGlobalScope); + + pub const Meta = struct { + pub const name = "ServiceWorkerGlobalScope"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const registration = bridge.accessor(ServiceWorkerGlobalScope.getRegistration, null, .{}); + pub const serviceWorker = bridge.accessor(ServiceWorkerGlobalScope.getServiceWorker, null, .{}); + pub const skipWaiting = bridge.function(ServiceWorkerGlobalScope.skipWaiting, .{}); + pub const cookieStore = bridge.accessor(ServiceWorkerGlobalScope.getCookieStore, null, .{}); + + pub const oninstall = bridge.accessor(ServiceWorkerGlobalScope.getOnInstall, ServiceWorkerGlobalScope.setOnInstall, .{}); + pub const onactivate = bridge.accessor(ServiceWorkerGlobalScope.getOnActivate, ServiceWorkerGlobalScope.setOnActivate, .{}); + pub const onmessage = bridge.accessor(ServiceWorkerGlobalScope.getOnMessage, ServiceWorkerGlobalScope.setOnMessage, .{}); + + // Deliberately absent: `onfetch` and `clients`. Nothing dispatches fetch + // events yet, and an accessor that silently never fires is worse than a + // missing one — a site can at least feature-detect the latter. +}; diff --git a/src/browser/webapi/ServiceWorkerRegistration.zig b/src/browser/webapi/ServiceWorkerRegistration.zig new file mode 100644 index 000000000..a12f21353 --- /dev/null +++ b/src/browser/webapi/ServiceWorkerRegistration.zig @@ -0,0 +1,164 @@ +// 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. +// +// 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 lp = @import("lightpanda"); + +const js = @import("../js/js.zig"); + +const EventTarget = @import("EventTarget.zig"); +const ServiceWorker = @import("ServiceWorker.zig"); +const WorkerGlobalScope = @import("WorkerGlobalScope.zig"); +const ServiceWorkerContainer = @import("ServiceWorkerContainer.zig"); +const ServiceWorkerGlobalScope = @import("ServiceWorkerGlobalScope.zig"); + +const log = lp.log; +const Execution = js.Execution; + +const ServiceWorkerRegistration = @This(); + +pub const Proto = EventTarget; + +_proto: *EventTarget, +_exec: *Execution, + +// Null once the scope is gone +_scope: ?*ServiceWorkerGlobalScope, +_scope_url: []const u8, + +_worker: ?*ServiceWorker = null, + +// null when registered with the SWGC (the "worker side"). Set when registered +// on the "page side". +_container: ?*ServiceWorkerContainer, + +// ServiceWorkerGlobalScope._registrations +_node: std.DoublyLinkedList.Node = .{}, + +_on_update_found: ?js.Function.Global = null, + +pub fn init(scope: *ServiceWorkerGlobalScope, container: ?*ServiceWorkerContainer, exec: *Execution) !*ServiceWorkerRegistration { + const self = try exec._factory.eventTargetWithAllocator(exec.arena, ServiceWorkerRegistration{ + ._proto = undefined, + ._exec = exec, + ._scope = scope, + ._scope_url = try exec.arena.dupe(u8, scope._scope_url), + ._container = container, + }); + scope._registrations.append(&self._node); + return self; +} + +// Called from the scope when it's unregistered or torn down +pub fn detach(self: *ServiceWorkerRegistration) void { + const scope = self._scope orelse return; + self._scope = null; + scope._registrations.remove(&self._node); + if (self._worker) |w| { + w.detach(); + } +} + +pub fn stateChanged(self: *ServiceWorkerRegistration) void { + if (self._worker) |w| { + w.stateChanged(); + } +} + +pub fn asEventTarget(self: *ServiceWorkerRegistration) *EventTarget { + return self._proto; +} + +pub fn getScope(self: *const ServiceWorkerRegistration) []const u8 { + return self._scope_url; +} + +pub fn getInstalling(self: *ServiceWorkerRegistration) !?*ServiceWorker { + const scope = self._scope orelse return null; + return switch (scope._state) { + .parsed, .installing => try self.worker(scope), + else => null, + }; +} + +pub fn getWaiting(self: *ServiceWorkerRegistration) !?*ServiceWorker { + const scope = self._scope orelse return null; + return switch (scope._state) { + .installed => try self.worker(scope), + else => null, + }; +} + +pub fn getActive(self: *ServiceWorkerRegistration) !?*ServiceWorker { + const scope = self._scope orelse return null; + return switch (scope._state) { + .activating, .activated => try self.worker(scope), + else => null, + }; +} + +pub fn getUpdateViaCache(_: *const ServiceWorkerRegistration) []const u8 { + // this is all we implement right now, and it still goes through the HTTP + // cache like anything else for now. + return "imports"; +} + +pub fn update(_: *ServiceWorkerRegistration, exec: *const Execution) !js.Promise { + log.warn(.not_implemented, "ServiceWorkerRegistration.update", .{}); + const resolver = exec.js.local.?.createPromiseResolver(); + resolver.resolve("ServiceWorkerRegistration.update", {}); + return resolver.promise(); +} + +pub fn unregister(self: *ServiceWorkerRegistration, exec: *const Execution) !js.Promise { + const resolver = exec.js.local.?.createPromiseResolver(); + const was_registered = if (self._scope) |scope| scope.unregister() else false; + resolver.resolve("ServiceWorkerRegistration.unregister", was_registered); + return resolver.promise(); +} + +pub fn getOnUpdateFound(self: *const ServiceWorkerRegistration) ?js.Function.Global { + return self._on_update_found; +} + +pub fn setOnUpdateFound(self: *ServiceWorkerRegistration, setter: ?WorkerGlobalScope.FunctionSetter) void { + self._on_update_found = WorkerGlobalScope.getFunctionFromSetter(setter); +} + +pub fn worker(self: *ServiceWorkerRegistration, scope: *ServiceWorkerGlobalScope) !*ServiceWorker { + if (self._worker) |w| { + return w; + } + const w = try ServiceWorker.init(scope, self._exec); + self._worker = w; + return w; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(ServiceWorkerRegistration); + + pub const Meta = struct { + pub const name = "ServiceWorkerRegistration"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const scope = bridge.accessor(ServiceWorkerRegistration.getScope, null, .{}); + pub const installing = bridge.accessor(ServiceWorkerRegistration.getInstalling, null, .{}); + pub const waiting = bridge.accessor(ServiceWorkerRegistration.getWaiting, null, .{}); + pub const active = bridge.accessor(ServiceWorkerRegistration.getActive, null, .{}); + pub const updateViaCache = bridge.accessor(ServiceWorkerRegistration.getUpdateViaCache, null, .{}); + pub const update = bridge.function(ServiceWorkerRegistration.update, .{}); + pub const unregister = bridge.function(ServiceWorkerRegistration.unregister, .{}); + pub const onupdatefound = bridge.accessor(ServiceWorkerRegistration.getOnUpdateFound, ServiceWorkerRegistration.setOnUpdateFound, .{}); +}; diff --git a/src/browser/webapi/SharedWorker.zig b/src/browser/webapi/SharedWorker.zig index 62e481c8d..cb9d7072f 100644 --- a/src/browser/webapi/SharedWorker.zig +++ b/src/browser/webapi/SharedWorker.zig @@ -46,7 +46,6 @@ pub const Proto = EventTarget; _proto: *EventTarget, _port: *MessagePort, -_on_error: ?js.Function.Global = null, const NameOrOpts = union(enum) { name: []const u8, @@ -101,27 +100,6 @@ pub fn getPort(self: *const SharedWorker) *MessagePort { return self._port; } -fn getOnError(self: *const SharedWorker) ?js.Function.Global { - return self._on_error; -} - -fn setOnError(self: *SharedWorker, 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, - }; -} - pub const JsApi = struct { pub const bridge = js.Bridge(SharedWorker); @@ -134,7 +112,6 @@ pub const JsApi = struct { pub const constructor = bridge.constructor(SharedWorker.init, .{}); pub const port = bridge.accessor(SharedWorker.getPort, null, .{}); - pub const onerror = bridge.accessor(SharedWorker.getOnError, SharedWorker.setOnError, .{}); }; const testing = @import("../../testing.zig"); diff --git a/src/browser/webapi/SharedWorkerGlobalScope.zig b/src/browser/webapi/SharedWorkerGlobalScope.zig index 74fde3df8..3c2547745 100644 --- a/src/browser/webapi/SharedWorkerGlobalScope.zig +++ b/src/browser/webapi/SharedWorkerGlobalScope.zig @@ -46,11 +46,6 @@ _type: Worker.WorkerType, // until registered; deinit/close use it to unregister. _registry_key: []const u8 = "", -// used by HttpClient when generating notification -// Ultimately used by CDP to generate request/loader ids. -_frame_id: u32, -_loader_id: u32, - _closed: bool = false, _script_loaded: bool = false, _script_arena: ?*lp.Arena = null, @@ -81,8 +76,6 @@ pub fn init(frame: *Frame, url: [:0]const u8, name: []const u8, worker_type: Wor ._url = owned_url, ._name = try arena.dupe(u8, name), ._type = worker_type, - ._frame_id = frame_id, - ._loader_id = loader_id, }, worker_type == .module, frame_id, diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index f4f8a7838..3953e7bda 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -298,7 +298,10 @@ fn getSessionStorage(self: *Window) *storage.Lookup { } fn getCookieStore(self: *Window, exec: *Execution) !*CookieStore { - if (self._cookie_store) |cs| return cs; + if (self._cookie_store) |cs| { + return cs; + } + const cs = try exec._factory.eventTarget(CookieStore{ ._proto = undefined }); try cs.attach(exec); self._cookie_store = cs; diff --git a/src/browser/webapi/WorkerGlobalScope.zig b/src/browser/webapi/WorkerGlobalScope.zig index 7bf186f5b..229d047c2 100644 --- a/src/browser/webapi/WorkerGlobalScope.zig +++ b/src/browser/webapi/WorkerGlobalScope.zig @@ -46,9 +46,9 @@ const WorkerLocation = @import("WorkerLocation.zig"); const ErrorEvent = @import("event/ErrorEvent.zig"); const Fetch = @import("net/Fetch.zig"); const idb = @import("storage/idb/idb.zig"); -const CookieStore = @import("storage/CookieStore.zig"); const MessagePort = @import("MessagePort.zig"); const SharedWorkerGlobalScope = @import("SharedWorkerGlobalScope.zig"); +const ServiceWorkerGlobalScope = @import("ServiceWorkerGlobalScope.zig"); const DedicatedWorkerGlobalScope = @import("DedicatedWorkerGlobalScope.zig"); const log = lp.log; @@ -112,7 +112,6 @@ _idb_factory: ?*idb.IDBFactory = null, _on_error: ?JS.Function.Global = null, _on_rejection_handled: ?JS.Function.Global = null, _on_unhandled_rejection: ?JS.Function.Global = null, -_cookie_store: ?*CookieStore = null, _location: WorkerLocation, @@ -121,6 +120,7 @@ _scheduler: Scheduler = .{}, pub const Type = union(enum) { shared: *SharedWorkerGlobalScope, + service: *ServiceWorkerGlobalScope, dedicated: *DedicatedWorkerGlobalScope, }; @@ -316,13 +316,6 @@ pub fn getLocation(self: *WorkerGlobalScope) *WorkerLocation { return &self._location; } -fn getCookieStore(self: *WorkerGlobalScope) !*CookieStore { - if (self._cookie_store) |cs| return cs; - const cs = try self._factory.eventTargetWithAllocator(self.arena, CookieStore{ ._proto = undefined }); - self._cookie_store = cs; - return cs; -} - fn getOnError(self: *const WorkerGlobalScope) ?JS.Function.Global { return self._on_error; } @@ -606,7 +599,6 @@ pub const JsApi = struct { }.wrap, null, .{}); pub const self = bridge.accessor(WorkerGlobalScope.getSelf, WorkerGlobalScope.setSelf, .{}); pub const location = bridge.accessor(WorkerGlobalScope.getLocation, null, .{}); - pub const cookieStore = bridge.accessor(WorkerGlobalScope.getCookieStore, null, .{}); pub const indexedDB = bridge.accessor(WorkerGlobalScope.getIndexedDB, null, .{}); pub const onerror = bridge.accessor(WorkerGlobalScope.getOnError, WorkerGlobalScope.setOnError, .{}); diff --git a/src/browser/webapi/event/ExtendableEvent.zig b/src/browser/webapi/event/ExtendableEvent.zig new file mode 100644 index 000000000..2a09a33c8 --- /dev/null +++ b/src/browser/webapi/event/ExtendableEvent.zig @@ -0,0 +1,154 @@ +// 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 lp = @import("lightpanda"); + +const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); + +const Event = @import("../Event.zig"); + +const String = lp.String; + +const ExtendableEvent = @This(); + +pub const Proto = Event; + +_proto: *Event, + +_pending: usize = 0, // Number of waitUntil promises that haven't settled yet. +_sealed: bool = false, // once sealed, _pending reaching 0 fires on_done +_on_done: ?Callback = null, + +pub const Callback = struct { + ctx: *anyopaque, + func: *const fn (ctx: *anyopaque) void, +}; + +const Options = Event.inheritOptions(ExtendableEvent, struct {}); + +pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*ExtendableEvent { + const arena = try page.getArena(.tiny, "ExtendableEvent"); + errdefer arena.release(); + const type_string = try String.init(arena.allocator(), typ, .{}); + + const opts = opts_ orelse Options{}; + const event = try page.factory.event(arena, type_string, ExtendableEvent{ + ._proto = undefined, + }); + + Event.populatePrototypes(event, opts, false); + return event; +} + +pub fn initTrusted(typ: String, opts_: ?Options, page: *Page) !*ExtendableEvent { + const arena = try page.getArena(.tiny, "ExtendableEvent.trusted"); + errdefer arena.release(); + + const opts = opts_ orelse Options{}; + const event = try page.factory.event(arena, typ, ExtendableEvent{ + ._proto = undefined, + }); + + Event.populatePrototypes(event, opts, true); + return event; +} + +pub fn deinit(self: *ExtendableEvent, page: *Page) void { + self._proto.deinit(page); +} + +pub fn releaseRef(self: *ExtendableEvent, page: *Page) void { + self._proto._rc.release(self, page); +} + +pub fn acquireRef(self: *ExtendableEvent) void { + self._proto.acquireRef(); +} + +pub fn asEvent(self: *ExtendableEvent) *Event { + return self._proto; +} + +pub fn waitUntil(self: *ExtendableEvent, value: js.Value) !void { + // Only a lifecycle event we're holding a ref on may register a promise: the + // settle callback below keeps a raw pointer to us, and a page-constructed + // event would be freed by GC before that promise settles. + if (self._proto._is_trusted == false) { + return error.InvalidStateError; + } + + // Spec: no longer "active" once dispatch is over and nothing is pending. + if (self._sealed and self._pending == 0) { + return error.InvalidStateError; + } + + if (value.isPromise() == false) { + return; + } + + self._pending += 1; + + const promise = value.toPromise(); + const local = promise.local; + const settled = local.newCallback(onSettled, self); + _ = promise.thenAndCatch(settled, settled) catch { + self.settle(); + }; +} + +fn onSettled(self: *ExtendableEvent, _: ?js.Value) void { + self.settle(); +} + +fn settle(self: *ExtendableEvent) void { + if (self._pending == 0) { + return; + } + self._pending -= 1; + self.checkDone(); +} +// Called by the dispatcher once its handlers have run. +pub fn seal(self: *ExtendableEvent, on_done: Callback) void { + self._on_done = on_done; + self._sealed = true; + // From here on the event is done as soon as its outstanding promises are. + self.checkDone(); +} + +fn checkDone(self: *ExtendableEvent) void { + if (self._sealed == false or self._pending > 0) { + return; + } + const on_done = self._on_done orelse return; + self._on_done = null; + on_done.func(on_done.ctx); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(ExtendableEvent); + + pub const Meta = struct { + pub const name = "ExtendableEvent"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(ExtendableEvent.init, .{}); + pub const waitUntil = bridge.function(ExtendableEvent.waitUntil, .{}); +}; diff --git a/src/testing.zig b/src/testing.zig index 4715f75e6..9dc0f0c42 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -333,6 +333,7 @@ const HtmlRunnerOpts = struct { .worker = true, .iframe = true, }, + experimental_features: Config.ExperimentalFeatures = .{}, }; // Create a fresh page on `test_session` and return its root frame — for tests @@ -365,6 +366,9 @@ pub fn htmlRunner(comptime path: []const u8, opts: HtmlRunnerOpts) !void { .iframe = true, }; + test_session.experimental_features = opts.experimental_features; + defer test_session.experimental_features = .{}; + const root = try std.fs.path.joinZ(arena_allocator, &.{ WEB_API_TEST_ROOT, path }); const stat = std.Io.Dir.cwd().statFile(io, root, .{}) catch |err| { std.debug.print("Failed to stat file: '{s}'", .{root}); @@ -448,7 +452,11 @@ fn runWebApiTest(test_file: [:0]const u8, timeout_ms: u32) !void { try_catch.init(&ls.local); defer try_catch.deinit(); - const js_val = ls.local.exec("testing.assertOk()", "testing.assertOk()") catch |err| { + const js_val = ls.local.exec( + // testing is undefined until testing.js is run + "typeof testing === 'undefined' ? false : testing.assertOk()", + "testing.assertOk()", + ) catch |err| { const caught = try_catch.caughtOrError(arena_allocator, err); std.debug.print("{s}: test failure\nError: {f}\n", .{ test_file, caught }); return err;