webapi: experimental (and useless, for now) ServiceWorker

This adds the shell for ServiceWorker, behind
`--experimental-features serviceworker`.

It's pretty useless as-is. We don't have the CacheStorage API (next) and don't
have the fetch interceptor (next next). But as-is, the change is quite big but
thankfully largely isolated.
This commit is contained in:
Karl Seguin committed 2026-09-16 17:49:07 +08:00
1 parent afbc8378b1
commit dc1ae129e3
27 files changed
+2063 -58

No files matched your search

+2 -1
View File
@@ -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.
+4
View File
@@ -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| {
+11
View File
@@ -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);
+9
View File
@@ -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();
}
+69 -2
View File
@@ -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");
+7
View File
@@ -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);
+32 -12
View File
@@ -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,
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<body></body>
<script src="../testing.js"></script>
<script id="service_worker_absent_without_the_flag">
// Without --experimental-features serviceworker the accessor is deleted from
// this context's Navigator.prototype. It has to be absent rather than null:
// `'serviceWorker' in navigator` is the standard feature check, and Firefox
// with dom.serviceWorkers.enabled=false makes it undefined for the same reason.
{
testing.expectFalse('serviceWorker' in navigator);
testing.expectEqual(undefined, navigator.serviceWorker);
testing.expectEqual(undefined, Object.getOwnPropertyDescriptor(Navigator.prototype, 'serviceWorker'));
// Only the way in is removed. The interfaces are in the snapshot and stay on
// the global, which is what keeps this a one-property delete.
testing.expectTrue(typeof ServiceWorkerContainer === 'function');
testing.expectTrue(typeof ServiceWorkerRegistration === 'function');
}
</script>
@@ -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;
}
}
@@ -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.
});
@@ -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(', '));
}
}
@@ -0,0 +1,364 @@
<!DOCTYPE html>
<body></body>
<script src="../testing.js"></script>
<script id="service_worker_container">
{
testing.expectTrue('serviceWorker' in navigator);
testing.expectTrue(navigator.serviceWorker instanceof ServiceWorkerContainer);
// Same object every time, like every other navigator sub-object.
testing.expectTrue(navigator.serviceWorker === navigator.serviceWorker);
// Nothing controls a client until fetch events are dispatched.
testing.expectEqual(null, navigator.serviceWorker.controller);
}
</script>
<script id="service_worker_realm_exclusions" type=module>
// A service worker's realm is worker_common_apis, not worker_extended_apis:
// the blocking APIs a dedicated/shared worker gets must not be there. A
// dedicated worker in the same page still has them.
{
const state = await testing.async();
// Its own scope: the default one is shared with every other subtest here,
// and whichever registers first owns the worker for all of them.
const registration = await navigator.serviceWorker.register(
'./realm-worker.js',
{ scope: './realm/' },
);
await new Promise((resolve) => setTimeout(resolve, 50));
const dedicated = new Worker('./realm-worker.js');
const fromDedicated = await new Promise((resolve) => {
dedicated.onmessage = (e) => resolve(e.data);
});
state.resolve();
await state.done(() => {
// realm-worker.js asserts its own realm and throws on a mismatch, and a
// throw stops it before it installs — so reaching "activated" is the
// service-worker half of this test.
testing.expectEqual('activated', registration.active.state);
// ...while the dedicated worker can tell us directly.
testing.expectEqual(true, fromDedicated.hasXHR);
testing.expectEqual(true, fromDedicated.hasFileReaderSync);
// Both realms keep the async pieces.
testing.expectEqual(true, fromDedicated.hasFetch);
testing.expectEqual(true, fromDedicated.hasWebSocket);
// CookieStore is [Exposed=(ServiceWorker,Window)]: none of the three
// should be reachable from a dedicated worker.
testing.expectEqual(false, fromDedicated.hasCookieStoreCtor);
testing.expectEqual(false, fromDedicated.hasCookieStoreProp);
testing.expectEqual(false, fromDedicated.hasCookieChangeEvent);
});
}
</script>
<script id="service_worker_register" type=module>
{
const state = await testing.async();
const registration = await navigator.serviceWorker.register('./lifecycle-worker.js');
state.resolve();
await state.done(() => {
testing.expectTrue(registration instanceof ServiceWorkerRegistration);
// The default scope is the script's own directory.
testing.expectTrue(registration.scope.endsWith('/service_worker/'));
testing.expectEqual(null, registration.waiting);
});
}
</script>
<script id="service_worker_reaches_activated" type=module>
{
const state = await testing.async();
const registration = await navigator.serviceWorker.register('./lifecycle-worker.js');
// Resolves only once install's (deferred) waitUntil promise settles and
// activate has run.
const ready = await navigator.serviceWorker.ready;
state.resolve();
await state.done(() => {
testing.expectTrue(ready instanceof ServiceWorkerRegistration);
testing.expectTrue(registration.active instanceof ServiceWorker);
testing.expectEqual('activated', registration.active.state);
testing.expectEqual(null, registration.installing);
testing.expectTrue(registration.active.scriptURL.endsWith('/lifecycle-worker.js'));
});
}
</script>
<script id="service_worker_ready_is_one_promise" type=module>
// `ready` is the container's single registration-ready promise, so every
// access is the same object — before and after it settles.
{
const state = await testing.async();
const before = navigator.serviceWorker.ready;
testing.expectTrue(navigator.serviceWorker.ready === before);
await navigator.serviceWorker.register('./lifecycle-worker.js');
await before;
state.resolve();
await state.done(() => {
testing.expectTrue(navigator.serviceWorker.ready === before);
});
}
</script>
<script id="service_worker_ready_after_activation" type=module>
// `ready` asked for after the worker is already active resolves immediately
// rather than waiting for a transition that has already happened.
{
const state = await testing.async();
await navigator.serviceWorker.register('./lifecycle-worker.js');
await navigator.serviceWorker.ready;
const again = await navigator.serviceWorker.ready;
state.resolve();
await state.done(() => {
testing.expectTrue(again instanceof ServiceWorkerRegistration);
testing.expectEqual('activated', again.active.state);
});
}
</script>
<script id="service_worker_same_scope_reused" type=module>
// A second register() for the same scope reuses the running worker rather
// than starting a second one.
{
const state = await testing.async();
const first = await navigator.serviceWorker.register('./lifecycle-worker.js');
const second = await navigator.serviceWorker.register('./lifecycle-worker.js');
await navigator.serviceWorker.ready;
state.resolve();
await state.done(() => {
// Same registration object, since it's the same container and scope.
testing.expectTrue(first === second);
testing.expectTrue(first.active === second.active);
});
}
</script>
<script id="service_worker_explicit_scope" type=module>
{
const state = await testing.async();
const registration = await navigator.serviceWorker.register(
'./lifecycle-worker.js',
{ scope: './deep/' },
);
state.resolve();
await state.done(() => {
testing.expectTrue(registration.scope.endsWith('/service_worker/deep/'));
});
}
</script>
<script id="service_worker_statechange" type=module>
// The installing worker's statechange fires as it advances, which is how
// sites detect that a new worker took over.
{
const state = await testing.async();
const registration = await navigator.serviceWorker.register('./lifecycle-worker.js');
const worker = registration.installing;
const states = [];
worker.addEventListener('statechange', () => states.push(worker.state));
await navigator.serviceWorker.ready;
state.resolve();
await state.done(() => {
testing.expectTrue(worker instanceof ServiceWorker);
// We don't promise every intermediate state, only that it got there.
testing.expectEqual('activated', states[states.length - 1]);
});
}
</script>
<script id="service_worker_waituntil_gates_activation" type=module>
// An install handler whose waitUntil promise never settles must leave the
// worker at `installing` — that gate is the whole point of ExtendableEvent.
{
const state = await testing.async();
const registration = await navigator.serviceWorker.register(
'./waiting-worker.js',
{ scope: './stuck/' },
);
await new Promise((resolve) => setTimeout(resolve, 50));
state.resolve();
await state.done(() => {
testing.expectEqual(null, registration.active);
testing.expectTrue(registration.installing instanceof ServiceWorker);
testing.expectEqual('installing', registration.installing.state);
});
}
</script>
<script id="service_worker_script_error" type=module>
// A worker whose top-level script throws never installs.
{
const state = await testing.async();
const registration = await navigator.serviceWorker.register(
'./throwing-worker.js',
{ scope: './broken/' },
);
await new Promise((resolve) => setTimeout(resolve, 50));
state.resolve();
await state.done(() => {
testing.expectEqual(null, registration.active);
testing.expectEqual(null, registration.installing);
});
}
</script>
<script id="service_worker_cross_origin_rejects" type=module>
{
const state = await testing.async();
let err = null;
try {
await navigator.serviceWorker.register('https://example.com/sw.js');
} catch (e) {
err = e;
}
state.resolve();
await state.done(() => {
testing.expectEqual('SecurityError', err.name);
});
}
</script>
<script id="service_worker_get_registration" type=module>
{
const state = await testing.async();
await navigator.serviceWorker.register('./lifecycle-worker.js');
const found = await navigator.serviceWorker.getRegistration();
const all = await navigator.serviceWorker.getRegistrations();
const missing = await navigator.serviceWorker.getRegistration('/nowhere/at/all');
state.resolve();
await state.done(() => {
testing.expectTrue(found instanceof ServiceWorkerRegistration);
testing.expectTrue(all.length > 0);
testing.expectEqual(undefined, missing);
});
}
</script>
<script id="service_worker_unregister" type=module>
{
const state = await testing.async();
const registration = await navigator.serviceWorker.register(
'./lifecycle-worker.js',
{ scope: './gone/' },
);
const first = await registration.unregister();
const second = await registration.unregister();
const all = await navigator.serviceWorker.getRegistrations();
state.resolve();
await state.done(() => {
testing.expectEqual(true, first);
testing.expectEqual(false, second);
// Unregistering detaches this realm's registration: it no longer shows
// up in the container and has no worker in any slot.
testing.expectTrue(all.every((r) => r !== registration));
testing.expectEqual(null, registration.installing);
testing.expectEqual(null, registration.active);
});
}
</script>
<script id="service_worker_different_script_replaces" type=module>
// There is no update pipeline yet, so a second register() for the same scope
// with a different script replaces the registration: the old worker goes
// redundant and the old registration is detached.
{
const state = await testing.async();
const first = await navigator.serviceWorker.register(
'./lifecycle-worker.js',
{ scope: './swap/' },
);
const worker = first.installing;
await new Promise((resolve) => {
if (worker.state === 'activated') resolve();
worker.addEventListener('statechange', () => worker.state === 'activated' && resolve());
});
const second = await navigator.serviceWorker.register(
'./waiting-worker.js',
{ scope: './swap/' },
);
const all = await navigator.serviceWorker.getRegistrations();
state.resolve();
await state.done(() => {
testing.expectTrue(first !== second);
testing.expectEqual('redundant', worker.state);
testing.expectEqual(null, first.active);
testing.expectTrue(all.every((r) => r !== first));
testing.expectTrue(second.installing.scriptURL.endsWith('/waiting-worker.js'));
});
}
</script>
<script id="service_worker_post_message" type=module>
// Page -> worker only for now: a worker has no way to reach a client back
// until `clients` exists. The assertion is just that it doesn't throw.
{
const state = await testing.async();
const registration = await navigator.serviceWorker.register('./lifecycle-worker.js');
await navigator.serviceWorker.ready;
registration.active.postMessage({ hello: 'worker' });
let threw = false;
try {
registration.active.postMessage(function () {});
} catch (e) {
threw = true;
testing.expectEqual('DataCloneError', e.name);
}
state.resolve();
await state.done(() => {
testing.expectEqual(true, threw);
});
}
</script>
<script id="service_worker_object_identity" type=module>
// identity-worker.js asserts the worker-realm half and throws on a mismatch,
// which stops it before it installs — so reaching "activated" is that half of
// the test. Here we check the page realm's own view.
{
const state = await testing.async();
const reg = await navigator.serviceWorker.register('./identity-worker.js', { scope: './ident/' });
await new Promise((r) => setTimeout(r, 50));
const again = await navigator.serviceWorker.getRegistration('./ident/');
// The page itself isn't under ./ident/, so the broader default-scope
// registration is the one that matches this document.
const here = await navigator.serviceWorker.getRegistration();
state.resolve();
await state.done(() => {
testing.expectEqual('activated', reg.active.state);
// Longest matching scope wins: ./ident/ is nested inside the default
// scope, which was registered first and is a prefix of it.
testing.expectTrue(again === reg);
testing.expectTrue(here !== reg);
// One ServiceWorker object for the one worker.
testing.expectTrue(reg.active === reg.active);
});
}
</script>
@@ -0,0 +1 @@
throw new Error('boom');
@@ -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(() => {}));
});
+2
View File
@@ -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,
+15
View File
@@ -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("<EventTarget>"),
.window => writer.writeAll("<Window>"),
.worker => writer.writeAll("<Worker>"),
.service_worker => writer.writeAll("<ServiceWorker>"),
.service_worker_container => writer.writeAll("<ServiceWorkerContainer>"),
.service_worker_registration => writer.writeAll("<ServiceWorkerRegistration>"),
.shared_worker => writer.writeAll("<SharedWorker>"),
.worker_global_scope => writer.writeAll("<WorkerGlobalScope>"),
.xhr => writer.writeAll("<XMLHttpRequestEventTarget>"),
@@ -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]",
+17 -1
View File
@@ -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, .{});
+165
View File
@@ -0,0 +1,165 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// 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, .{});
};
@@ -0,0 +1,312 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const 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 });
}
@@ -0,0 +1,618 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const 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.
};
@@ -0,0 +1,164 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const 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, .{});
};
-23
View File
@@ -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");
@@ -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,
+4 -1
View File
@@ -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;
+2 -10
View File
@@ -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, .{});
@@ -0,0 +1,154 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const 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, .{});
};
+9 -1
View File
@@ -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;