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;