Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
# `cross-origin` — multi-origin Service Worker registration
## Purpose
`registerCrossOriginPackedResourceServiceWorker` lets an application
register a packed-resource Service Worker hosted on a different origin
(typically a CDN subdomain). Before any browser request is dispatched
the helper validates the SW URL's origin against a caller-supplied
allowlist, so attacker-controlled or misconfigured CDN origins cannot
slip past. On success the helper returns both the new registration and
the container's current registration list so multi-tenant apps can
confirm the CDN-origin SW is actually controlling the page.
## Features
- Strict allowlist validation BEFORE `register()` fires
- Parses `swUrl` via the `URL` constructor — malformed URLs reject with
`PACKER_BROWSER.UNAUTHORIZED_ORIGIN`
- Delegates to `registerPackedResourceServiceWorker` so all the
underlying contract (AbortSignal, error wrapping, message resolution)
remains intact
- Returns the full registration list (parity with
`clients.matchAll({ includeUncontrolled: true })`)
## Usage: Basic
```ts
import { registerCrossOriginPackedResourceServiceWorker } from "@suaveplan/packer-browser/utils";
await registerCrossOriginPackedResourceServiceWorker({
swUrl: "https://cdn.example.com/sw.js",
scope: "/packed/",
allowedOrigins: ["https://cdn.example.com"],
});
```
## Usage: Advanced
```ts
import {
PACKER_BROWSER_CODES,
PackerBrowserError,
registerCrossOriginPackedResourceServiceWorker,
} from "@suaveplan/packer-browser";
try {
const { registration, registrations } =
await registerCrossOriginPackedResourceServiceWorker({
swUrl: "https://cdn.example.com/sw.js",
scope: "/",
allowedOrigins: [
"https://cdn.example.com",
"https://cdn-fallback.example.com",
],
container: navigator.serviceWorker,
signal: shutdownController.signal,
});
console.log("registered", registration.scope, "of", registrations.length);
} catch (err) {
if (
err instanceof PackerBrowserError &&
err.code === PACKER_BROWSER_CODES.UNAUTHORIZED_ORIGIN
) {
console.warn("Origin not allowed; falling back to first-party SW.");
}
}
```
## API Reference
- `registerCrossOriginPackedResourceServiceWorker(opts: RegisterCrossOriginOptions): Promise<RegisterCrossOriginResult>`
- `opts.swUrl: string` — absolute SW URL. Required.
- `opts.scope: string` — registration scope. Required.
- `opts.allowedOrigins: ReadonlyArray<string>` — exact-string match. Required.
- `opts.container?` — injectable container. Default: `navigator.serviceWorker`.
- `opts.messageResolver?` — i18n resolver.
- `opts.signal?` — AbortSignal forwarded to `register()`.
- Returns `{ registration, registrations }`.
## Implementation Notes
- Origin equality is exact-string — there is no host-suffix matching.
Allowlisting both `https://cdn.example.com` and
`https://cdn-fallback.example.com` is explicit by design.
- Malformed `swUrl` strings (relative paths, plain identifiers) yield
`UNAUTHORIZED_ORIGIN`, not `SW_REGISTRATION_FAILED`, because the
helper deliberately rejects before consulting the container.
- The `registrations` list is the full `container.getRegistrations()`
return value when a container is supplied; without one (e.g. in a
Node smoke harness) the underlying `register()` already rejects with
`SW_REGISTRATION_FAILED` so the caller never sees a partial list.

View File

@@ -0,0 +1,207 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
PACKER_BROWSER_CODES,
type PackerBrowserError,
} from "../error-codes/error-codes.js";
import { registerCrossOriginPackedResourceServiceWorker } from "./cross-origin.js";
import type { ServiceWorkerContainerLike } from "./register-service-worker.types.js";
function makeRegistration(scope = "/"): ServiceWorkerRegistration {
return {
scope,
unregister: async () => true,
} as unknown as ServiceWorkerRegistration;
}
function makeContainer(opts: {
register?: ServiceWorkerContainerLike["register"];
getRegistrations?: ServiceWorkerContainerLike["getRegistrations"];
registerCallCount?: { value: number };
registerCalls?: Array<{ scriptUrl: string; options: unknown }>;
}): ServiceWorkerContainerLike {
return {
controller: null,
register:
opts.register ??
(async (scriptUrl, options) => {
opts.registerCallCount && (opts.registerCallCount.value += 1);
opts.registerCalls?.push({ scriptUrl, options });
return makeRegistration();
}),
getRegistrations:
opts.getRegistrations ?? (async () => [makeRegistration("/")]),
ready: Promise.resolve(makeRegistration()),
};
}
describe("registerCrossOriginPackedResourceServiceWorker", () => {
it("rejects with UNAUTHORIZED_ORIGIN when swUrl origin is not allowlisted", {
timeout: 5000,
}, async () => {
const count = { value: 0 };
const container = makeContainer({ registerCallCount: count });
try {
await registerCrossOriginPackedResourceServiceWorker({
swUrl: "https://attacker.com/sw.js",
scope: "/",
allowedOrigins: ["https://cdn.example.com"],
container,
});
throw new Error("expected throw");
} catch (err) {
expect((err as PackerBrowserError).code).toBe(
PACKER_BROWSER_CODES.UNAUTHORIZED_ORIGIN
);
}
expect(count.value).toBe(0);
});
it("rejects with UNAUTHORIZED_ORIGIN when swUrl is unparseable", {
timeout: 5000,
}, async () => {
const count = { value: 0 };
const container = makeContainer({ registerCallCount: count });
await expect(
registerCrossOriginPackedResourceServiceWorker({
swUrl: "not-a-url",
scope: "/",
allowedOrigins: ["https://cdn.example.com"],
container,
})
).rejects.toMatchObject({
code: PACKER_BROWSER_CODES.UNAUTHORIZED_ORIGIN,
});
expect(count.value).toBe(0);
});
it("delegates to registerPackedResourceServiceWorker when origin is allowlisted", {
timeout: 5000,
}, async () => {
const calls: Array<{ scriptUrl: string; options: unknown }> = [];
const container = makeContainer({ registerCalls: calls });
const result = await registerCrossOriginPackedResourceServiceWorker({
swUrl: "https://cdn.example.com/sw.js",
scope: "/packed/",
allowedOrigins: ["https://cdn.example.com"],
container,
});
expect(calls.length).toBe(1);
expect(calls[0]?.scriptUrl).toBe("https://cdn.example.com/sw.js");
expect(calls[0]?.options).toEqual({ scope: "/packed/" });
expect(result.registration).toBeDefined();
});
it("returns the container's registration list after successful registration", {
timeout: 5000,
}, async () => {
const regs = [makeRegistration("/a"), makeRegistration("/b")];
const container = makeContainer({
getRegistrations: async () => regs,
});
const result = await registerCrossOriginPackedResourceServiceWorker({
swUrl: "https://cdn.example.com/sw.js",
scope: "/",
allowedOrigins: ["https://cdn.example.com"],
container,
});
expect(result.registrations.length).toBe(2);
expect(result.registrations).toBe(regs);
});
it("forwards a caller-supplied messageResolver to the underlying register call", {
timeout: 5000,
}, async () => {
const container = makeContainer({
register: async () => {
throw new Error("denied");
},
});
const seen: string[] = [];
await expect(
registerCrossOriginPackedResourceServiceWorker({
swUrl: "https://cdn.example.com/sw.js",
scope: "/",
allowedOrigins: ["https://cdn.example.com"],
container,
messageResolver: (key) => {
seen.push(key);
return key;
},
})
).rejects.toMatchObject({
code: PACKER_BROWSER_CODES.SW_REGISTRATION_FAILED,
});
expect(seen.length).toBeGreaterThan(0);
});
it("respects an injected signal by forwarding it to the underlying register call", {
timeout: 5000,
}, async () => {
const controller = new AbortController();
controller.abort();
await expect(
registerCrossOriginPackedResourceServiceWorker({
swUrl: "https://cdn.example.com/sw.js",
scope: "/",
allowedOrigins: ["https://cdn.example.com"],
container: makeContainer({}),
signal: controller.signal,
})
).rejects.toMatchObject({
code: PACKER_BROWSER_CODES.ABORTED,
});
});
it("falls back to navigator.serviceWorker when no container option is supplied", {
timeout: 5000,
}, async () => {
const fakeReg = makeRegistration("/");
const calls = { value: 0 };
const fakeSW = {
controller: null,
register: async () => fakeReg,
getRegistrations: async () => {
calls.value += 1;
return [fakeReg];
},
ready: Promise.resolve(fakeReg),
};
const originalNavigator = (globalThis as { navigator?: unknown })
.navigator;
(globalThis as { navigator?: unknown }).navigator = {
serviceWorker: fakeSW,
};
try {
const result = await registerCrossOriginPackedResourceServiceWorker(
{
swUrl: "https://cdn.example.com/sw.js",
scope: "/",
allowedOrigins: ["https://cdn.example.com"],
}
);
expect(result.registrations.length).toBe(1);
expect(calls.value).toBe(1);
} finally {
(globalThis as { navigator?: unknown }).navigator =
originalNavigator;
}
});
it("falls back to a single-element registrations list when no container is supplied", {
timeout: 5000,
}, async () => {
// happy-dom does not expose navigator.serviceWorker so the
// helper rejects with SW_REGISTRATION_FAILED — exercises the
// path where origin validation passes but the underlying
// register call has no container.
await expect(
registerCrossOriginPackedResourceServiceWorker({
swUrl: "https://cdn.example.com/sw.js",
scope: "/",
allowedOrigins: ["https://cdn.example.com"],
})
).rejects.toMatchObject({
code: PACKER_BROWSER_CODES.SW_REGISTRATION_FAILED,
});
});
});

View File

@@ -0,0 +1,73 @@
import {
PACKER_BROWSER_CODES,
PackerBrowserError,
} from "../error-codes/error-codes.js";
import { resolvePackerBrowserMessage } from "../i18n/messages.js";
import type {
RegisterCrossOriginOptions,
RegisterCrossOriginResult,
RegisterOptions,
} from "./cross-origin.types.js";
import { registerPackedResourceServiceWorker } from "./register-service-worker.js";
function parseOrigin(url: string): string | undefined {
try {
return new URL(url).origin;
} catch {
return undefined;
}
}
/**
* Cross-origin variant of
* {@link registerPackedResourceServiceWorker}. Validates that the
* supplied `swUrl`'s origin appears in `allowedOrigins` BEFORE invoking
* `serviceWorker.register()`; an origin outside the allowlist (or an
* unparseable URL) is rejected with
* `PackerBrowserError(PACKER_BROWSER.UNAUTHORIZED_ORIGIN)`.
*
* After a successful registration the helper invokes
* `container.getRegistrations()` (mirroring `clients.matchAll({
* includeUncontrolled: true })` semantics) and returns both the new
* registration and the full registration list — so multi-tenant pages
* can confirm the CDN-origin SW is the one taking control.
*/
export async function registerCrossOriginPackedResourceServiceWorker(
opts: RegisterCrossOriginOptions
): Promise<RegisterCrossOriginResult> {
const origin = parseOrigin(opts.swUrl);
const allowed = new Set(opts.allowedOrigins);
if (origin === undefined || !allowed.has(origin)) {
throw new PackerBrowserError(
PACKER_BROWSER_CODES.UNAUTHORIZED_ORIGIN,
resolvePackerBrowserMessage(
opts.messageResolver,
"packer-browser.error.UNAUTHORIZED_ORIGIN.cross-origin",
{
swUrl: opts.swUrl,
allowed: opts.allowedOrigins.join(", "),
}
)
);
}
const registerOpts: RegisterOptions = {
scriptUrl: opts.swUrl,
scope: opts.scope,
};
if (opts.container !== undefined) registerOpts.container = opts.container;
if (opts.messageResolver !== undefined)
registerOpts.messageResolver = opts.messageResolver;
if (opts.signal !== undefined) registerOpts.signal = opts.signal;
const registration =
await registerPackedResourceServiceWorker(registerOpts);
// `registerPackedResourceServiceWorker` only resolves when a real
// container is available — either caller-supplied or the
// navigator-fallback inside `resolveContainer`. Reuse the same
// resolution here so `clients.matchAll({ includeUncontrolled: true })`
// semantics are preserved regardless of injection.
const container =
opts.container ??
(navigator.serviceWorker as unknown as import("./register-service-worker.types.js").ServiceWorkerContainerLike);
const registrations = await container.getRegistrations();
return { registration, registrations };
}

View File

@@ -0,0 +1,43 @@
import type { MessageResolver } from "@suaveplan/types";
import type {
RegisterOptions,
ServiceWorkerContainerLike,
} from "./register-service-worker.types.js";
export type { MessageResolver, RegisterOptions, ServiceWorkerContainerLike };
/**
* Options for
* {@link registerCrossOriginPackedResourceServiceWorker}. The helper
* enforces an origin allowlist BEFORE invoking the underlying
* `register()` so attacker-controlled SW URLs are rejected before any
* browser request fires.
*/
export interface RegisterCrossOriginOptions {
/** Absolute service-worker URL. The helper parses its origin. */
readonly swUrl: string;
/** SW registration scope (forwarded verbatim). */
readonly scope: string;
/**
* Origins permitted to host the SW. Each value is compared against
* the parsed `swUrl` origin using exact-string equality.
*/
readonly allowedOrigins: ReadonlyArray<string>;
/** Injectable container. Default: `navigator.serviceWorker`. */
readonly container?: ServiceWorkerContainerLike;
/** Optional resolver for the boundary error messages. */
readonly messageResolver?: MessageResolver;
/** Optional cancellation signal forwarded to the underlying register call. */
readonly signal?: AbortSignal;
}
/**
* Successful return value of
* {@link registerCrossOriginPackedResourceServiceWorker}. Exposes the
* registration plus the container's current registration list so
* callers can verify the cross-origin SW is reachable from this page.
*/
export interface RegisterCrossOriginResult {
readonly registration: ServiceWorkerRegistration;
readonly registrations: ReadonlyArray<ServiceWorkerRegistration>;
}

View File

@@ -0,0 +1,19 @@
export { registerCrossOriginPackedResourceServiceWorker } from "./cross-origin.js";
export type {
RegisterCrossOriginOptions,
RegisterCrossOriginResult,
} from "./cross-origin.types.js";
export {
clearServiceWorkerCache,
getServiceWorkerCacheStatus,
registerPackedResourceServiceWorker,
unregisterPackedResourceServiceWorker,
} from "./register-service-worker.js";
export type {
CacheStatusReply,
MessageResolver,
RegisterOptions,
RegistrationOptions,
ServiceWorkerContainerLike,
ServiceWorkerLifecycleOptions,
} from "./register-service-worker.types.js";

View File

@@ -0,0 +1,95 @@
# `register-service-worker`
## Purpose
`register-service-worker.ts` collects four lifecycle helpers exposed via
the `/utils` subpath: `registerPackedResourceServiceWorker`,
`unregisterPackedResourceServiceWorker`, `clearServiceWorkerCache`, and
`getServiceWorkerCacheStatus`. They wrap the
`navigator.serviceWorker` API with typed errors, injectable container
support (for tests), and `AbortSignal` honoured at every yield. Every
boundary error uses `MessageResolver`-keyed messages rather than
hard-coded English literals.
## Features
- Injectable `container: ServiceWorkerContainerLike` — tests pass an
in-memory implementation; production defaults to
`navigator.serviceWorker`.
- `AbortSignal` honoured pre-call (synchronous reject with
`PackerBrowserError(ABORTED)`) and mid-call (channel teardown +
reject) on both `clearServiceWorkerCache` and
`getServiceWorkerCacheStatus`.
- `getServiceWorkerCacheStatus` includes a hard 5-second timeout so a
hung controller cannot leak a pending Promise.
- Boundary errors:
`SW_REGISTRATION_FAILED` (no SW support, register rejected, no
controller attached), `LOAD_FAILED` (timeout / negative reply),
`ABORTED` (signal fires).
- Returns typed shapes: `ServiceWorkerRegistration`, `boolean`,
`void`, `CacheStatusReply`.
## Usage: Basic
```ts
import {
clearServiceWorkerCache,
getServiceWorkerCacheStatus,
registerPackedResourceServiceWorker,
} from "@suaveplan/packer-browser/utils";
await registerPackedResourceServiceWorker();
const status = await getServiceWorkerCacheStatus();
console.log(status.cacheSize, status.urls);
await clearServiceWorkerCache();
```
## Usage: Advanced
```ts
import type { ServiceWorkerContainerLike } from "@suaveplan/packer-browser/utils";
const ac = new AbortController();
const recorded: string[] = [];
const container: ServiceWorkerContainerLike = {
controller: createMockController(recorded),
register: async () => fakeRegistration(),
getRegistrations: async () => [],
ready: Promise.resolve(fakeRegistration()),
};
await registerPackedResourceServiceWorker({
container,
scriptUrl: "/custom.js",
signal: ac.signal,
});
```
## API Reference
- `registerPackedResourceServiceWorker(opts?: RegisterOptions):
Promise<ServiceWorkerRegistration>` —
- throws `SW_REGISTRATION_FAILED` for no SW support / rejected
`register()`.
- throws `ABORTED` on a pre-aborted signal.
- `unregisterPackedResourceServiceWorker(opts?): Promise<boolean>` —
resolves `false` when no container is available.
- `clearServiceWorkerCache(opts?): Promise<void>` — throws
`SW_REGISTRATION_FAILED` if no controller is attached; throws
`ABORTED` if the signal fires.
- `getServiceWorkerCacheStatus(opts?): Promise<CacheStatusReply>` —
throws `LOAD_FAILED` on 5 s timeout; throws `ABORTED` on signal.
- `ServiceWorkerContainerLike` / `RegisterOptions` /
`ServiceWorkerLifecycleOptions` — type-only injection points.
## Implementation Notes
The helpers route every `controller.postMessage` through a
`MessageChannel` and listen on `port1` for the reply. The
`AbortSignal` integration closes `port1` before rejecting so the
underlying SW does not see a half-open channel. The 5-second
`getServiceWorkerCacheStatus` timeout is hard-coded because the
upstream form had the same value; Cluster B will widen this once the
SW gains range-request support and a single status query can take
arbitrarily long under load.

View File

@@ -0,0 +1,373 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
PACKER_BROWSER_CODES,
type PackerBrowserError,
} from "../error-codes/error-codes.js";
import {
clearServiceWorkerCache,
getServiceWorkerCacheStatus,
registerPackedResourceServiceWorker,
unregisterPackedResourceServiceWorker,
} from "./register-service-worker.js";
import type { ServiceWorkerContainerLike } from "./register-service-worker.types.js";
function makeRegistration(): ServiceWorkerRegistration {
return {
unregister: async () => true,
} as unknown as ServiceWorkerRegistration;
}
function makeContainer(opts: {
controller?: ServiceWorker | null;
register?: ServiceWorkerContainerLike["register"];
getRegistrations?: ServiceWorkerContainerLike["getRegistrations"];
}): ServiceWorkerContainerLike {
return {
controller: opts.controller ?? null,
register: opts.register ?? (async () => makeRegistration()),
getRegistrations:
opts.getRegistrations ?? (async () => [makeRegistration()]),
ready: Promise.resolve(makeRegistration()),
};
}
function makeController(
onMessage: (message: unknown, ports: ReadonlyArray<MessagePort>) => void
): ServiceWorker {
return {
postMessage: (message: unknown, transfer: Transferable[]) => {
const ports = transfer as MessagePort[];
onMessage(message, ports);
},
} as unknown as ServiceWorker;
}
describe("registerPackedResourceServiceWorker", () => {
it("returns the registration on success", { timeout: 5000 }, async () => {
const container = makeContainer({});
const reg = await registerPackedResourceServiceWorker({ container });
expect(reg).toBeDefined();
});
it("throws SW_REGISTRATION_FAILED when no container is available", {
timeout: 5000,
}, async () => {
const opts: Parameters<typeof registerPackedResourceServiceWorker>[0] =
{};
// happy-dom does not expose navigator.serviceWorker; the
// helper falls through the "no container" branch.
await expect(
registerPackedResourceServiceWorker(opts)
).rejects.toMatchObject({
code: PACKER_BROWSER_CODES.SW_REGISTRATION_FAILED,
});
});
it("wraps register() rejection as SW_REGISTRATION_FAILED with cause", {
timeout: 5000,
}, async () => {
const container = makeContainer({
register: async () => {
throw new Error("CSP forbids");
},
});
try {
await registerPackedResourceServiceWorker({ container });
} catch (err) {
expect((err as PackerBrowserError).code).toBe(
PACKER_BROWSER_CODES.SW_REGISTRATION_FAILED
);
expect((err as PackerBrowserError).cause).toBeInstanceOf(Error);
}
});
it("wraps non-Error register() throws via String coercion", {
timeout: 5000,
}, async () => {
const container = makeContainer({
register: async () => {
const fault: unknown = "literal";
throw fault;
},
});
try {
await registerPackedResourceServiceWorker({ container });
} catch (err) {
expect((err as PackerBrowserError).code).toBe(
PACKER_BROWSER_CODES.SW_REGISTRATION_FAILED
);
expect((err as PackerBrowserError).message).toContain("literal");
}
});
it("rejects with ABORTED when signal is pre-aborted", {
timeout: 5000,
}, async () => {
const ac = new AbortController();
ac.abort();
await expect(
registerPackedResourceServiceWorker({
container: makeContainer({}),
signal: ac.signal,
})
).rejects.toMatchObject({ code: PACKER_BROWSER_CODES.ABORTED });
});
it("forwards scriptUrl + scope to container.register", {
timeout: 5000,
}, async () => {
let seenUrl = "";
let seenScope = "";
const container = makeContainer({
register: async (url, options) => {
seenUrl = url;
seenScope = options?.scope ?? "";
return makeRegistration();
},
});
await registerPackedResourceServiceWorker({
container,
scriptUrl: "/custom.js",
scope: "/widget",
});
expect(seenUrl).toBe("/custom.js");
expect(seenScope).toBe("/widget");
});
});
describe("unregisterPackedResourceServiceWorker", () => {
it("returns true after unregistering a registration", {
timeout: 5000,
}, async () => {
const container = makeContainer({});
expect(await unregisterPackedResourceServiceWorker({ container })).toBe(
true
);
});
it("returns false when no container is available", {
timeout: 5000,
}, async () => {
expect(await unregisterPackedResourceServiceWorker({})).toBe(false);
});
it("returns false when no registration successfully unregisters", {
timeout: 5000,
}, async () => {
const container = makeContainer({
getRegistrations: async () => [
{
unregister: async () => false,
} as unknown as ServiceWorkerRegistration,
],
});
expect(await unregisterPackedResourceServiceWorker({ container })).toBe(
false
);
});
});
describe("clearServiceWorkerCache", () => {
it("resolves when the controller replies success", {
timeout: 5000,
}, async () => {
const controller = makeController((_msg, ports) => {
ports[0]?.postMessage({ success: true });
});
const container = makeContainer({ controller });
await clearServiceWorkerCache({ container });
});
it("rejects with LOAD_FAILED when the controller replies negative", {
timeout: 5000,
}, async () => {
const controller = makeController((_msg, ports) => {
ports[0]?.postMessage({ success: false });
});
const container = makeContainer({ controller });
await expect(
clearServiceWorkerCache({ container })
).rejects.toMatchObject({ code: PACKER_BROWSER_CODES.LOAD_FAILED });
});
it("rejects with SW_REGISTRATION_FAILED when no controller is attached", {
timeout: 5000,
}, async () => {
const container = makeContainer({ controller: null });
await expect(
clearServiceWorkerCache({ container })
).rejects.toMatchObject({
code: PACKER_BROWSER_CODES.SW_REGISTRATION_FAILED,
});
});
it("rejects with ABORTED when the signal fires before the reply", {
timeout: 5000,
}, async () => {
const ac = new AbortController();
// Never reply.
const controller = makeController(() => undefined);
const container = makeContainer({ controller });
const p = clearServiceWorkerCache({
container,
signal: ac.signal,
});
ac.abort();
await expect(p).rejects.toMatchObject({
code: PACKER_BROWSER_CODES.ABORTED,
});
});
it("rejects with ABORTED when signal is pre-aborted", {
timeout: 5000,
}, async () => {
const ac = new AbortController();
ac.abort();
const controller = makeController(() => undefined);
const container = makeContainer({ controller });
await expect(
clearServiceWorkerCache({ container, signal: ac.signal })
).rejects.toMatchObject({ code: PACKER_BROWSER_CODES.ABORTED });
});
});
describe("getServiceWorkerCacheStatus", () => {
it("resolves with the controller's reply", { timeout: 5000 }, async () => {
const reply = { cacheSize: 3, urls: ["/a", "/b", "/c"] };
const controller = makeController((_msg, ports) => {
ports[0]?.postMessage(reply);
});
const container = makeContainer({ controller });
const out = await getServiceWorkerCacheStatus({ container });
expect(out).toEqual(reply);
});
it("rejects with SW_REGISTRATION_FAILED when no controller is attached", {
timeout: 5000,
}, async () => {
const container = makeContainer({ controller: null });
await expect(
getServiceWorkerCacheStatus({ container })
).rejects.toMatchObject({
code: PACKER_BROWSER_CODES.SW_REGISTRATION_FAILED,
});
});
it("rejects with ABORTED when signal fires mid-flight", {
timeout: 5000,
}, async () => {
const ac = new AbortController();
const controller = makeController(() => undefined);
const container = makeContainer({ controller });
const p = getServiceWorkerCacheStatus({
container,
signal: ac.signal,
});
ac.abort();
await expect(p).rejects.toMatchObject({
code: PACKER_BROWSER_CODES.ABORTED,
});
});
it("rejects with ABORTED when signal is pre-aborted", {
timeout: 5000,
}, async () => {
const ac = new AbortController();
ac.abort();
const controller = makeController(() => undefined);
const container = makeContainer({ controller });
await expect(
getServiceWorkerCacheStatus({
container,
signal: ac.signal,
})
).rejects.toMatchObject({ code: PACKER_BROWSER_CODES.ABORTED });
});
it("rejects with LOAD_FAILED when the reply timeout elapses", {
timeout: 5000,
}, async () => {
const controller = makeController(() => undefined);
const container = makeContainer({ controller });
await expect(
getServiceWorkerCacheStatus({
container,
cacheStatusTimeoutMs: 10,
})
).rejects.toMatchObject({ code: PACKER_BROWSER_CODES.LOAD_FAILED });
});
});
describe("default-parameter branches", () => {
it("registerPackedResourceServiceWorker tolerates being called with no args (no SW support)", {
timeout: 5000,
}, async () => {
await expect(
registerPackedResourceServiceWorker()
).rejects.toMatchObject({
code: PACKER_BROWSER_CODES.SW_REGISTRATION_FAILED,
});
});
it("unregisterPackedResourceServiceWorker tolerates being called with no args", {
timeout: 5000,
}, async () => {
expect(await unregisterPackedResourceServiceWorker()).toBe(false);
});
it("clearServiceWorkerCache tolerates being called with no args (no controller)", {
timeout: 5000,
}, async () => {
await expect(clearServiceWorkerCache()).rejects.toMatchObject({
code: PACKER_BROWSER_CODES.SW_REGISTRATION_FAILED,
});
});
it("getServiceWorkerCacheStatus tolerates being called with no args", {
timeout: 5000,
}, async () => {
await expect(getServiceWorkerCacheStatus()).rejects.toMatchObject({
code: PACKER_BROWSER_CODES.SW_REGISTRATION_FAILED,
});
});
});
describe("navigator.serviceWorker fallback", () => {
it("resolveContainer falls back to navigator.serviceWorker when present", {
timeout: 5000,
}, async () => {
const dummy: ServiceWorkerContainerLike = {
controller: null,
register: async () => makeRegistration(),
getRegistrations: async () => [],
ready: Promise.resolve(makeRegistration()),
};
// Install on globalThis.navigator.
const navAny = globalThis.navigator as unknown as {
serviceWorker?: unknown;
};
const had = "serviceWorker" in navAny;
const prev = navAny.serviceWorker;
Object.defineProperty(navAny, "serviceWorker", {
value: dummy,
configurable: true,
writable: true,
});
try {
// Calling with no `container` should resolve the dummy.
await registerPackedResourceServiceWorker();
// Success means navigator.serviceWorker was used.
expect(true).toBe(true);
} finally {
if (had) {
Object.defineProperty(navAny, "serviceWorker", {
value: prev,
configurable: true,
writable: true,
});
} else {
delete navAny.serviceWorker;
}
}
});
});

View File

@@ -0,0 +1,235 @@
import {
PACKER_BROWSER_CODES,
PackerBrowserError,
} from "../error-codes/error-codes.js";
import { resolvePackerBrowserMessage } from "../i18n/messages.js";
import type {
CacheStatusReply,
MessageResolver,
RegisterOptions,
ServiceWorkerContainerLike,
ServiceWorkerLifecycleOptions,
} from "./register-service-worker.types.js";
const CACHE_STATUS_TIMEOUT_MS = 5_000;
function resolveContainer(
opts: ServiceWorkerLifecycleOptions
): ServiceWorkerContainerLike | undefined {
if (opts.container !== undefined) return opts.container;
if (
typeof navigator !== "undefined" &&
"serviceWorker" in navigator &&
navigator.serviceWorker
) {
return navigator.serviceWorker as unknown as ServiceWorkerContainerLike;
}
return undefined;
}
function requireController(
container: ServiceWorkerContainerLike | undefined,
resolver: MessageResolver | undefined
): ServiceWorker {
if (container?.controller == null) {
throw new PackerBrowserError(
PACKER_BROWSER_CODES.SW_REGISTRATION_FAILED,
resolvePackerBrowserMessage(
resolver,
"packer-browser.error.SW_REGISTRATION_FAILED.not-active"
)
);
}
return container.controller;
}
function checkAborted(
signal: AbortSignal | undefined,
key: string,
resolver: MessageResolver | undefined
): void {
if (signal?.aborted === true) {
throw new PackerBrowserError(
PACKER_BROWSER_CODES.ABORTED,
resolvePackerBrowserMessage(resolver, key)
);
}
}
/**
* Register the packed-resource Service Worker on the configured
* container. Returns the `ServiceWorkerRegistration`. Throws
* `PackerBrowserError(SW_REGISTRATION_FAILED)` if the environment
* lacks SW support OR the underlying `register()` rejects.
*/
export async function registerPackedResourceServiceWorker(
opts: RegisterOptions = {}
): Promise<ServiceWorkerRegistration> {
checkAborted(
opts.signal,
"packer-browser.error.ABORTED.register",
opts.messageResolver
);
const container = resolveContainer(opts);
if (container === undefined) {
throw new PackerBrowserError(
PACKER_BROWSER_CODES.SW_REGISTRATION_FAILED,
resolvePackerBrowserMessage(
opts.messageResolver,
"packer-browser.error.SW_REGISTRATION_FAILED.not-supported"
)
);
}
const scriptUrl = opts.scriptUrl ?? "/service-worker.js";
const scope = opts.scope ?? "/";
try {
const registration = await container.register(scriptUrl, { scope });
await container.ready;
return registration;
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
throw new PackerBrowserError(
PACKER_BROWSER_CODES.SW_REGISTRATION_FAILED,
resolvePackerBrowserMessage(
opts.messageResolver,
"packer-browser.error.SW_REGISTRATION_FAILED.register-rejected",
{ path: scriptUrl, detail }
),
err instanceof Error ? { cause: err } : {}
);
}
}
/** Unregister every active SW registration on the container. */
export async function unregisterPackedResourceServiceWorker(
opts: ServiceWorkerLifecycleOptions = {}
): Promise<boolean> {
const container = resolveContainer(opts);
if (container === undefined) return false;
const registrations = await container.getRegistrations();
for (const registration of registrations) {
const unregistered = await registration.unregister();
if (unregistered) return true;
}
return false;
}
/**
* Send a `CACHE_CLEAR` message to the controller SW. Resolves once the
* controller replies; rejects with `SW_REGISTRATION_FAILED` if no
* controller is attached.
*/
export async function clearServiceWorkerCache(
opts: ServiceWorkerLifecycleOptions = {}
): Promise<void> {
checkAborted(
opts.signal,
"packer-browser.error.ABORTED.cache-clear",
opts.messageResolver
);
const controller = requireController(
resolveContainer(opts),
opts.messageResolver
);
return await new Promise<void>((resolve, reject) => {
const channel = new MessageChannel();
const onAbort = (): void => {
channel.port1.close();
reject(
new PackerBrowserError(
PACKER_BROWSER_CODES.ABORTED,
resolvePackerBrowserMessage(
opts.messageResolver,
"packer-browser.error.ABORTED.cache-clear"
)
)
);
};
if (opts.signal !== undefined) {
opts.signal.addEventListener("abort", onAbort, { once: true });
}
channel.port1.onmessage = (event: MessageEvent): void => {
opts.signal?.removeEventListener("abort", onAbort);
const data = event.data as { success?: boolean };
if (data.success === true) {
resolve();
} else {
reject(
new PackerBrowserError(
PACKER_BROWSER_CODES.LOAD_FAILED,
resolvePackerBrowserMessage(
opts.messageResolver,
"packer-browser.error.LOAD_FAILED.fetch",
{
url: "<sw-cache>",
detail: "CACHE_CLEAR negative",
}
)
)
);
}
};
controller.postMessage({ type: "CACHE_CLEAR" }, [channel.port2]);
});
}
/**
* Query the controller SW for the current cache status. Resolves to a
* {@link CacheStatusReply}. Rejects with `LOAD_FAILED` on timeout (5 s)
* or `SW_REGISTRATION_FAILED` if no controller is attached.
*/
export async function getServiceWorkerCacheStatus(
opts: ServiceWorkerLifecycleOptions = {}
): Promise<CacheStatusReply> {
checkAborted(
opts.signal,
"packer-browser.error.ABORTED.cache-status",
opts.messageResolver
);
const controller = requireController(
resolveContainer(opts),
opts.messageResolver
);
const timeoutMs = opts.cacheStatusTimeoutMs ?? CACHE_STATUS_TIMEOUT_MS;
return await new Promise<CacheStatusReply>((resolve, reject) => {
const channel = new MessageChannel();
const timer = setTimeout(() => {
channel.port1.close();
reject(
new PackerBrowserError(
PACKER_BROWSER_CODES.LOAD_FAILED,
resolvePackerBrowserMessage(
opts.messageResolver,
"packer-browser.error.LOAD_FAILED.fetch",
{
url: "<sw-cache-status>",
detail: "timeout waiting for reply",
}
)
)
);
}, timeoutMs);
const onAbort = (): void => {
clearTimeout(timer);
channel.port1.close();
reject(
new PackerBrowserError(
PACKER_BROWSER_CODES.ABORTED,
resolvePackerBrowserMessage(
opts.messageResolver,
"packer-browser.error.ABORTED.cache-status"
)
)
);
};
if (opts.signal !== undefined) {
opts.signal.addEventListener("abort", onAbort, { once: true });
}
channel.port1.onmessage = (event: MessageEvent): void => {
clearTimeout(timer);
opts.signal?.removeEventListener("abort", onAbort);
resolve(event.data as CacheStatusReply);
};
controller.postMessage({ type: "CACHE_STATUS" }, [channel.port2]);
});
}

View File

@@ -0,0 +1,49 @@
import type { MessageResolver } from "@suaveplan/types";
import type { CacheStatusReply } from "../service-worker/service-worker.types.js";
export type { CacheStatusReply, MessageResolver };
/**
* Minimal `ServiceWorkerContainer` surface needed by the lifecycle
* helpers. Injectable so tests do not need to monkey-patch `navigator`.
*/
export interface ServiceWorkerContainerLike {
readonly controller: ServiceWorker | null;
register(
scriptUrl: string,
options?: RegistrationOptions
): Promise<ServiceWorkerRegistration>;
getRegistrations(): Promise<ReadonlyArray<ServiceWorkerRegistration>>;
readonly ready: Promise<ServiceWorkerRegistration>;
}
/** Registration options forwarded to `serviceWorker.register`. */
export interface RegistrationOptions {
scope?: string;
updateViaCache?: ServiceWorkerUpdateViaCache;
type?: WorkerType;
}
/** Common option bag for every lifecycle helper. */
export interface ServiceWorkerLifecycleOptions {
/** Injectable container. Default: `navigator.serviceWorker`. */
container?: ServiceWorkerContainerLike;
/** Optional resolver for the boundary error messages. */
messageResolver?: MessageResolver;
/** Optional cancellation signal. */
signal?: AbortSignal;
/**
* Override the default 5000 ms timeout
* used by `getServiceWorkerCacheStatus`. Tests inject a small value
* to exercise the timeout branch.
*/
cacheStatusTimeoutMs?: number;
}
/** Options for {@link registerPackedResourceServiceWorker}. */
export interface RegisterOptions extends ServiceWorkerLifecycleOptions {
/** Service-worker script URL. Default: `"/service-worker.js"`. */
scriptUrl?: string;
/** Registration scope. Default: `"/"`. */
scope?: string;
}