The marker read added a whole chunk to a running total and compared the total afterwards, so a corrupt body could hand activation a 1 MiB chunk against a 257-byte ceiling. It now reads at most the remaining allowance — through a BYOB reader where the source offers one, and by refusing an oversized chunk before copying it otherwise. A declared oversize cancels the body it refuses instead of leaving the stream open, and the reader lock is released on every path. The build generator and the runtime decoder shared only the extension table, not the path grammar. The generator happily emitted `/assets/bad@name-abcdefgh.js`, which the decoder then refused — a correct build failing at install time. Both now use one exported canonical path predicate and the generator decodes its own output before returning it. The notification click handler emitted its terminal record from inside `process` and again from the `waitUntil` wrapper, so an ordinary click was counted twice. Worse, a late rejection downgraded `MAYBE_APPLIED` to `NOT_APPLIED` — telling operators the click had definitely not been applied when nobody knew that — and the late observation ran outside `waitUntil`, so a worker shutdown lost the evidence. There is one observation authority per click now, certainty is monotone, only an explicit null window confirms `NOT_APPLIED`, and the bounded tail is owned by `waitUntil` without extending the public deadline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1057 lines
33 KiB
TypeScript
1057 lines
33 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import { createServiceWorkerPageController } from "../../src/adapters/service-worker/service-worker-page-controller.ts";
|
|
import { createServiceWorkerRuntime } from "../../src/adapters/service-worker/service-worker-lifecycle.ts";
|
|
import { createServiceWorkerMessage } from "../../src/adapters/service-worker/service-worker-protocol.ts";
|
|
import { installStaticAssets } from "../../src/adapters/service-worker/service-worker-static-assets.ts";
|
|
import {
|
|
SERVICE_WORKER_BOUNDS,
|
|
SERVICE_WORKER_SCRIPT_PATH,
|
|
STATIC_CACHE_PREFIX,
|
|
staticCacheName,
|
|
type ServiceWorkerProtocolIdentity,
|
|
type StaticAssetManifestV1,
|
|
} from "../../src/contracts/service-worker.ts";
|
|
|
|
const ORIGIN = "https://app.example";
|
|
const SCRIPT_URL = `${ORIGIN}/service-worker.js`;
|
|
|
|
function pageContainer(options: { waiting?: boolean; controlled?: boolean } = {}) {
|
|
const listeners = new Set<(event: MessageEvent) => void>();
|
|
const waitingMessages: unknown[] = [];
|
|
const controllerMessages: unknown[] = [];
|
|
const worker = (messages: unknown[]) =>
|
|
({
|
|
scriptURL: SCRIPT_URL,
|
|
postMessage(message: unknown) {
|
|
messages.push(message);
|
|
},
|
|
}) as unknown as ServiceWorker;
|
|
const waiting = options.waiting ? worker(waitingMessages) : null;
|
|
const active = options.waiting ? null : worker([]);
|
|
const controlled = options.controlled ? worker(controllerMessages) : null;
|
|
const registration = {
|
|
scope: `${ORIGIN}/`,
|
|
installing: null,
|
|
waiting,
|
|
active,
|
|
update: vi.fn(async () => {}),
|
|
} as unknown as ServiceWorkerRegistration;
|
|
const container = {
|
|
controller: controlled,
|
|
register: vi.fn(async () => registration),
|
|
addEventListener(_type: string, listener: (event: MessageEvent) => void) {
|
|
listeners.add(listener);
|
|
},
|
|
removeEventListener(_type: string, listener: (event: MessageEvent) => void) {
|
|
listeners.delete(listener);
|
|
},
|
|
} as unknown as ServiceWorkerContainer;
|
|
return {
|
|
container,
|
|
waiting,
|
|
controlled,
|
|
registration,
|
|
waitingMessages,
|
|
controllerMessages,
|
|
listenerCount: () => listeners.size,
|
|
dispatch(data: unknown, source?: { postMessage(message: unknown): void }) {
|
|
const event = { data, origin: ORIGIN, source } as unknown as MessageEvent;
|
|
for (const listener of [...listeners]) listener(event);
|
|
},
|
|
};
|
|
}
|
|
|
|
function pageController(container: ServiceWorkerContainer, blockers = [() => false]) {
|
|
return createServiceWorkerPageController({
|
|
selection: {
|
|
mode: "ACTIVE",
|
|
scriptPath: SERVICE_WORKER_SCRIPT_PATH,
|
|
handlers: [],
|
|
},
|
|
disabledCleanup: false,
|
|
routerBasePath: "/",
|
|
origin: ORIGIN,
|
|
buildId: "page-build",
|
|
container,
|
|
blockers,
|
|
});
|
|
}
|
|
|
|
const identity: ServiceWorkerProtocolIdentity = {
|
|
serviceWorkerProtocolVersion: 1,
|
|
cacheSchemaVersion: 1,
|
|
buildId: "worker-build",
|
|
releaseId: "release-1",
|
|
contractSetDigest: `sha256:${"1".repeat(64)}`,
|
|
staticAssetSetDigest: `sha256:${"2".repeat(64)}`,
|
|
};
|
|
|
|
function workerScope() {
|
|
const deleted: string[] = [];
|
|
const clients = ["client-a", "client-b"].map((id) => ({
|
|
id,
|
|
url: `${ORIGIN}/app/${id}`,
|
|
messages: [] as unknown[],
|
|
postMessage(message: unknown) {
|
|
this.messages.push(message);
|
|
},
|
|
}));
|
|
const scope = {
|
|
caches: {
|
|
open: vi.fn(),
|
|
keys: vi.fn(async () => [
|
|
`${STATIC_CACHE_PREFIX}${"a".repeat(16)}`,
|
|
`${STATIC_CACHE_PREFIX}${"b".repeat(16)}`,
|
|
"foreign-cache",
|
|
]),
|
|
delete: vi.fn(async (name: string) => {
|
|
deleted.push(name);
|
|
return true;
|
|
}),
|
|
match: vi.fn(),
|
|
},
|
|
clients: { matchAll: vi.fn(async () => clients) },
|
|
registrationScope: `${ORIGIN}/app/`,
|
|
skipWaiting: vi.fn(async () => {}),
|
|
fetcher: vi.fn(),
|
|
digest: vi.fn(),
|
|
};
|
|
return { scope, clients, deleted };
|
|
}
|
|
|
|
describe("service worker static cache authority", () => {
|
|
const setDigest = `sha256:${"c".repeat(64)}` as const;
|
|
const currentCacheName = `${STATIC_CACHE_PREFIX}${setDigest.slice(
|
|
"sha256:".length,
|
|
"sha256:".length + 16,
|
|
)}`;
|
|
|
|
function staticRuntime(
|
|
caches: Readonly<{
|
|
open: (name: string) => Promise<unknown>;
|
|
keys: () => Promise<readonly string[]>;
|
|
delete: (name: string) => Promise<boolean>;
|
|
}>,
|
|
) {
|
|
return createServiceWorkerRuntime(
|
|
{
|
|
caches,
|
|
clients: { matchAll: vi.fn(async () => []) },
|
|
registrationScope: `${ORIGIN}/app/`,
|
|
skipWaiting: vi.fn(async () => {}),
|
|
fetcher: vi.fn(),
|
|
digest: vi.fn(),
|
|
} as never,
|
|
{
|
|
identity: { ...identity, staticAssetSetDigest: setDigest },
|
|
handlers: ["PWA_STATIC_ASSETS"],
|
|
manifest: {
|
|
schemaVersion: 1,
|
|
buildId: identity.buildId,
|
|
releaseId: identity.releaseId,
|
|
setDigest,
|
|
// SW-URL-01. Generator output is root-relative.
|
|
assets: [
|
|
{
|
|
url: "/assets/app.0123456789abcdef.js",
|
|
sha256: `sha256:${"d".repeat(64)}`,
|
|
bytes: 10,
|
|
contentType: "text/javascript",
|
|
},
|
|
],
|
|
},
|
|
runtimeConfigUrl: "/runtime-config.json",
|
|
releaseManifestUrl: "/release-manifest.json",
|
|
},
|
|
);
|
|
}
|
|
|
|
it("classifies a generated root-relative asset against an absolute Request URL", async () => {
|
|
const currentResponse = new Response("current", { status: 200 });
|
|
const opened: string[] = [];
|
|
const runtime = staticRuntime({
|
|
open: vi.fn(async (name: string) => {
|
|
opened.push(name);
|
|
return {
|
|
match: async () => currentResponse.clone(),
|
|
delete: async () => true,
|
|
};
|
|
}),
|
|
keys: vi.fn(async () => [currentCacheName]),
|
|
delete: vi.fn(async () => true),
|
|
});
|
|
|
|
const served = await runtime.onFetch({
|
|
method: "GET",
|
|
url: `${ORIGIN}/assets/app.0123456789abcdef.js`,
|
|
});
|
|
expect(served).not.toBeNull();
|
|
expect(opened).toEqual([currentCacheName]);
|
|
});
|
|
|
|
it("matches static responses only in the current release cache", async () => {
|
|
const previousCacheName = `${STATIC_CACHE_PREFIX}${"e".repeat(16)}`;
|
|
const previousMatch = vi.fn(
|
|
async () => new Response("previous", { status: 200 }),
|
|
);
|
|
const deleteEntry = async (): Promise<boolean> => true;
|
|
const emptyMatch = async (): Promise<Response | undefined> => undefined;
|
|
const previousCache = { match: previousMatch, delete: deleteEntry };
|
|
const emptyCache = { match: emptyMatch, delete: deleteEntry };
|
|
const runtime = staticRuntime({
|
|
open: vi.fn(async (name: string) =>
|
|
name === previousCacheName ? previousCache : emptyCache,
|
|
),
|
|
keys: vi.fn(async () => [currentCacheName, previousCacheName]),
|
|
delete: vi.fn(async () => true),
|
|
});
|
|
|
|
// Only the previous cache holds the entry, so the request falls through to
|
|
// the network rather than serving a stale release.
|
|
await expect(
|
|
runtime.onFetch({
|
|
method: "GET",
|
|
url: `${ORIGIN}/assets/app.0123456789abcdef.js`,
|
|
}),
|
|
).resolves.toBeNull();
|
|
expect(previousMatch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("deletes an invalid hit only from the current release cache", async () => {
|
|
const deletes: string[] = [];
|
|
const runtime = staticRuntime({
|
|
open: vi.fn(async (name: string) => {
|
|
const recordDelete = async (url: string): Promise<boolean> => {
|
|
deletes.push(`${name}:${url}`);
|
|
return true;
|
|
};
|
|
return {
|
|
match: async () => new Response("bad", { status: 500 }),
|
|
delete: recordDelete,
|
|
};
|
|
}),
|
|
keys: vi.fn(async () => [currentCacheName]),
|
|
delete: vi.fn(async () => true),
|
|
});
|
|
|
|
await expect(
|
|
runtime.onFetch({
|
|
method: "GET",
|
|
url: `${ORIGIN}/assets/app.0123456789abcdef.js`,
|
|
}),
|
|
).resolves.toBeNull();
|
|
expect(deletes).toEqual([
|
|
`${currentCacheName}:${ORIGIN}/assets/app.0123456789abcdef.js`,
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("service worker exact ownership and truthful removal", () => {
|
|
it("deletes only exact owned static cache names", async () => {
|
|
const fixture = workerScope();
|
|
fixture.scope.caches.keys = vi.fn(async () => [
|
|
`${STATIC_CACHE_PREFIX}${"a".repeat(16)}`,
|
|
`${STATIC_CACHE_PREFIX}${"b".repeat(16)}`,
|
|
// SW-02. Same prefix, not owned.
|
|
`${STATIC_CACHE_PREFIX}not-owned`,
|
|
`${STATIC_CACHE_PREFIX}${"c".repeat(17)}`,
|
|
`${STATIC_CACHE_PREFIX}${"A".repeat(16)}`,
|
|
"foreign-cache",
|
|
]);
|
|
const runtime = createServiceWorkerRuntime(fixture.scope as never, {
|
|
identity,
|
|
handlers: [],
|
|
manifest: null,
|
|
runtimeConfigUrl: "/runtime-config.json",
|
|
releaseManifestUrl: "/release-manifest.json",
|
|
});
|
|
const request = createServiceWorkerMessage({
|
|
kind: "CACHE_RESET_REQUEST",
|
|
sourceBuildId: "page-build",
|
|
targetBuildId: identity.buildId,
|
|
nonce: "nonce-reset-0001",
|
|
});
|
|
await runtime.onCacheResetRequest(request, fixture.clients[0] as never);
|
|
|
|
expect(fixture.deleted).toEqual([
|
|
`${STATIC_CACHE_PREFIX}${"a".repeat(16)}`,
|
|
`${STATIC_CACHE_PREFIX}${"b".repeat(16)}`,
|
|
]);
|
|
});
|
|
it.each([
|
|
{
|
|
label: "unregister false",
|
|
unregister: async () => false,
|
|
expected: { kind: "FAILED" },
|
|
},
|
|
{
|
|
label: "unregister rejects",
|
|
unregister: async () => {
|
|
throw new TypeError("unregister exploded");
|
|
},
|
|
expected: { kind: "FAILED" },
|
|
},
|
|
{
|
|
label: "unregister true",
|
|
unregister: async () => true,
|
|
expected: { kind: "DISABLED" },
|
|
},
|
|
])(
|
|
"does not hide $label behind DISABLED",
|
|
async ({ unregister, expected }) => {
|
|
const registration = {
|
|
scope: `${ORIGIN}/`,
|
|
installing: null,
|
|
waiting: null,
|
|
active: { scriptURL: SCRIPT_URL },
|
|
unregister: vi.fn(unregister),
|
|
update: vi.fn(async () => {}),
|
|
} as unknown as ServiceWorkerRegistration;
|
|
const controller = createServiceWorkerPageController({
|
|
container: {
|
|
controller: null,
|
|
register: vi.fn(),
|
|
getRegistration: vi.fn(async () => registration),
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
} as never,
|
|
routerBasePath: "/",
|
|
origin: ORIGIN,
|
|
selection: {
|
|
mode: "REMOVE_REGISTRATION",
|
|
scriptPath: "service-worker.js",
|
|
handlers: [],
|
|
},
|
|
} as never);
|
|
|
|
await expect(controller.start()).resolves.toMatchObject(expected);
|
|
},
|
|
);
|
|
|
|
it("reports an ownership mismatch as INCOMPATIBLE rather than DISABLED", async () => {
|
|
const foreign = {
|
|
scope: `${ORIGIN}/`,
|
|
installing: null,
|
|
waiting: null,
|
|
active: { scriptURL: `${ORIGIN}/someone-else.js` },
|
|
unregister: vi.fn(async () => true),
|
|
update: vi.fn(async () => {}),
|
|
} as unknown as ServiceWorkerRegistration;
|
|
const controller = createServiceWorkerPageController({
|
|
container: {
|
|
controller: null,
|
|
register: vi.fn(),
|
|
getRegistration: vi.fn(async () => foreign),
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
} as never,
|
|
routerBasePath: "/",
|
|
origin: ORIGIN,
|
|
selection: {
|
|
mode: "REMOVE_REGISTRATION",
|
|
scriptPath: "service-worker.js",
|
|
handlers: [],
|
|
},
|
|
} as never);
|
|
|
|
await expect(controller.start()).resolves.toMatchObject({
|
|
kind: "INCOMPATIBLE",
|
|
});
|
|
expect(foreign.unregister).not.toHaveBeenCalled();
|
|
});
|
|
|
|
});
|
|
|
|
describe("service worker page protocol", () => {
|
|
it("does not attach late listeners when stopped during registration", async () => {
|
|
const browser = pageContainer();
|
|
let completeRegistration: ((value: ServiceWorkerRegistration) => void) | undefined;
|
|
const deferredRegistration = new Promise<ServiceWorkerRegistration>((resolve) => {
|
|
completeRegistration = resolve;
|
|
});
|
|
vi.mocked(browser.container.register).mockReturnValue(deferredRegistration);
|
|
const controller = pageController(browser.container);
|
|
|
|
const starting = controller.start();
|
|
await Promise.resolve();
|
|
await controller.stop();
|
|
completeRegistration?.(browser.registration);
|
|
|
|
await expect(starting).resolves.toEqual({ kind: "FAILED", code: "STOPPED" });
|
|
expect(browser.listenerCount()).toBe(0);
|
|
});
|
|
|
|
it("answers a worker drain request only after local blockers are clear", async () => {
|
|
const browser = pageContainer({ waiting: true });
|
|
const controller = pageController(browser.container);
|
|
await controller.start();
|
|
// SW-06. Replies are correlated by source identity, so the fake request
|
|
// comes from the registration's waiting worker.
|
|
const source = browser.waiting as unknown as {
|
|
postMessage(message: unknown): void;
|
|
};
|
|
const sourceMessages = vi.spyOn(source, "postMessage");
|
|
|
|
browser.dispatch(
|
|
createServiceWorkerMessage({
|
|
kind: "CLIENT_DRAIN_REQUEST",
|
|
sourceBuildId: "worker-build",
|
|
targetBuildId: "page-build",
|
|
nonce: "drain-1",
|
|
}),
|
|
source,
|
|
);
|
|
|
|
expect(sourceMessages).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
kind: "CLIENT_DRAINED",
|
|
sourceBuildId: "page-build",
|
|
targetBuildId: "worker-build",
|
|
nonce: "drain-1",
|
|
}),
|
|
);
|
|
await controller.stop();
|
|
});
|
|
|
|
it("settles a pending activation and removes its listener when stopped", async () => {
|
|
const browser = pageContainer({ waiting: true });
|
|
const controller = pageController(browser.container);
|
|
await controller.start();
|
|
|
|
const activation = controller.requestActivation();
|
|
await Promise.resolve();
|
|
expect(browser.listenerCount()).toBe(2);
|
|
|
|
await controller.stop();
|
|
|
|
await expect(activation).resolves.toEqual({ kind: "FAILED", code: "STOPPED" });
|
|
expect(browser.listenerCount()).toBe(0);
|
|
});
|
|
|
|
it("waits for the correlated cache reset result", async () => {
|
|
const browser = pageContainer({ controlled: true });
|
|
const controller = pageController(browser.container);
|
|
await controller.start();
|
|
|
|
let settled = false;
|
|
const result = controller.resetOwnedCaches().then((outcome) => {
|
|
settled = true;
|
|
return outcome;
|
|
});
|
|
await Promise.resolve();
|
|
expect(settled).toBe(false);
|
|
|
|
const request = browser.controllerMessages.at(-1) as { nonce?: string };
|
|
browser.dispatch({
|
|
...createServiceWorkerMessage({
|
|
kind: "CACHE_RESET_RESULT",
|
|
sourceBuildId: "worker-build",
|
|
targetBuildId: "page-build",
|
|
nonce: request.nonce,
|
|
}),
|
|
cachesDeleted: 2,
|
|
}, browser.controlled ?? undefined);
|
|
|
|
await expect(result).resolves.toEqual({ kind: "RESET", cachesDeleted: 2 });
|
|
await controller.stop();
|
|
});
|
|
});
|
|
|
|
describe("service worker worker-side protocol", () => {
|
|
it("retains the immediately previous verified static cache on activation", async () => {
|
|
const current = staticCacheName(identity.staticAssetSetDigest);
|
|
const stale = `${STATIC_CACHE_PREFIX}${"3".repeat(16)}`;
|
|
const previous = `${STATIC_CACHE_PREFIX}${"4".repeat(16)}`;
|
|
const names = [stale, previous, current, "foreign-cache"];
|
|
const deleted: string[] = [];
|
|
const markerPut = vi.fn(async () => {});
|
|
const cache = {
|
|
match: vi.fn(async () => undefined),
|
|
put: markerPut,
|
|
delete: vi.fn(async () => true),
|
|
} as unknown as Cache;
|
|
const scope = {
|
|
caches: {
|
|
open: vi.fn(async () => cache),
|
|
keys: vi.fn(async () => names),
|
|
delete: vi.fn(async (name: string) => {
|
|
deleted.push(name);
|
|
return true;
|
|
}),
|
|
match: vi.fn(),
|
|
},
|
|
clients: { matchAll: vi.fn(async () => []) },
|
|
skipWaiting: vi.fn(async () => {}),
|
|
fetcher: vi.fn(),
|
|
digest: vi.fn(),
|
|
};
|
|
const runtime = createServiceWorkerRuntime(scope as never, {
|
|
identity,
|
|
handlers: ["PWA_STATIC_ASSETS"],
|
|
manifest: {
|
|
schemaVersion: 1,
|
|
buildId: identity.buildId,
|
|
releaseId: identity.releaseId,
|
|
setDigest: identity.staticAssetSetDigest as `sha256:${string}`,
|
|
assets: [],
|
|
},
|
|
runtimeConfigUrl: "/runtime-config.json",
|
|
releaseManifestUrl: "/release-manifest.json",
|
|
});
|
|
|
|
await expect(runtime.onActivate()).resolves.toBe(1);
|
|
expect(deleted).toEqual([stale]);
|
|
expect(markerPut).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("waits for every exact client drain acknowledgement before activation", async () => {
|
|
const fixture = workerScope();
|
|
const runtime = createServiceWorkerRuntime(fixture.scope as never, {
|
|
identity,
|
|
handlers: [],
|
|
manifest: null,
|
|
runtimeConfigUrl: "/runtime-config.json",
|
|
releaseManifestUrl: "/release-manifest.json",
|
|
});
|
|
const request = createServiceWorkerMessage({
|
|
kind: "ACTIVATE_REQUEST",
|
|
sourceBuildId: "page-build",
|
|
targetBuildId: "worker-build",
|
|
nonce: "activation-1",
|
|
});
|
|
|
|
const activation = runtime.onActivateRequest(request);
|
|
await Promise.resolve();
|
|
expect(fixture.scope.skipWaiting).not.toHaveBeenCalled();
|
|
|
|
runtime.onClientMessage(
|
|
createServiceWorkerMessage({
|
|
kind: "CLIENT_DRAINED",
|
|
sourceBuildId: "page-build",
|
|
targetBuildId: "worker-build",
|
|
nonce: "activation-1",
|
|
}),
|
|
"client-a",
|
|
);
|
|
await Promise.resolve();
|
|
expect(fixture.scope.skipWaiting).not.toHaveBeenCalled();
|
|
|
|
runtime.onClientMessage(
|
|
createServiceWorkerMessage({
|
|
kind: "CLIENT_DRAINED",
|
|
sourceBuildId: "page-build",
|
|
targetBuildId: "worker-build",
|
|
nonce: "activation-1",
|
|
}),
|
|
"client-b",
|
|
);
|
|
|
|
await expect(activation).resolves.toBe("ACCEPTED");
|
|
expect(fixture.scope.skipWaiting).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("enumerates uncontrolled window clients before an activation drain", async () => {
|
|
const fixture = workerScope();
|
|
const runtime = createServiceWorkerRuntime(fixture.scope as never, {
|
|
identity,
|
|
handlers: [],
|
|
manifest: null,
|
|
runtimeConfigUrl: "/runtime-config.json",
|
|
releaseManifestUrl: "/release-manifest.json",
|
|
});
|
|
const request = createServiceWorkerMessage({
|
|
kind: "ACTIVATE_REQUEST",
|
|
sourceBuildId: "page-build",
|
|
targetBuildId: "worker-build",
|
|
nonce: "activation-uncontrolled",
|
|
});
|
|
|
|
const activation = runtime.onActivateRequest(request);
|
|
await Promise.resolve();
|
|
for (const client of fixture.clients) {
|
|
runtime.onClientMessage(
|
|
createServiceWorkerMessage({
|
|
kind: "CLIENT_DRAINED",
|
|
sourceBuildId: "page-build",
|
|
targetBuildId: "worker-build",
|
|
nonce: "activation-uncontrolled",
|
|
}),
|
|
client.id,
|
|
);
|
|
}
|
|
|
|
await expect(activation).resolves.toBe("ACCEPTED");
|
|
expect(fixture.scope.clients.matchAll).toHaveBeenCalledWith({
|
|
type: "window",
|
|
includeUncontrolled: true,
|
|
});
|
|
});
|
|
|
|
it("drains only page clients inside the exact registration scope", async () => {
|
|
const inScope = {
|
|
id: "client-in-scope",
|
|
url: `${ORIGIN}/app/nested/page`,
|
|
messages: [] as unknown[],
|
|
postMessage(message: unknown) {
|
|
this.messages.push(message);
|
|
},
|
|
};
|
|
const outOfScope = {
|
|
id: "client-out-of-scope",
|
|
url: `${ORIGIN}/application/page`,
|
|
messages: [] as unknown[],
|
|
postMessage(message: unknown) {
|
|
this.messages.push(message);
|
|
},
|
|
};
|
|
const fixture = workerScope();
|
|
fixture.scope.clients.matchAll.mockResolvedValue([
|
|
inScope,
|
|
outOfScope,
|
|
]);
|
|
const runtime = createServiceWorkerRuntime(fixture.scope as never, {
|
|
identity,
|
|
handlers: [],
|
|
manifest: null,
|
|
runtimeConfigUrl: "/runtime-config.json",
|
|
releaseManifestUrl: "/release-manifest.json",
|
|
});
|
|
const activation = runtime.onActivateRequest(
|
|
createServiceWorkerMessage({
|
|
kind: "ACTIVATE_REQUEST",
|
|
sourceBuildId: "page-build",
|
|
targetBuildId: "worker-build",
|
|
nonce: "activation-scope",
|
|
}),
|
|
);
|
|
await Promise.resolve();
|
|
runtime.onClientMessage(
|
|
createServiceWorkerMessage({
|
|
kind: "CLIENT_DRAINED",
|
|
sourceBuildId: "page-build",
|
|
targetBuildId: "worker-build",
|
|
nonce: "activation-scope",
|
|
}),
|
|
inScope.id,
|
|
);
|
|
runtime.onClientMessage(
|
|
createServiceWorkerMessage({
|
|
kind: "CLIENT_DRAINED",
|
|
sourceBuildId: "page-build",
|
|
targetBuildId: "worker-build",
|
|
nonce: "activation-scope",
|
|
}),
|
|
outOfScope.id,
|
|
);
|
|
|
|
await expect(activation).resolves.toBe("ACCEPTED");
|
|
expect(inScope.messages).not.toHaveLength(0);
|
|
expect(outOfScope.messages).toEqual([]);
|
|
});
|
|
|
|
it("deletes only owned caches and returns the correlated reset count", async () => {
|
|
const fixture = workerScope();
|
|
const runtime = createServiceWorkerRuntime(fixture.scope as never, {
|
|
identity,
|
|
handlers: [],
|
|
manifest: null,
|
|
runtimeConfigUrl: "/runtime-config.json",
|
|
releaseManifestUrl: "/release-manifest.json",
|
|
});
|
|
const source = {
|
|
id: "client-a",
|
|
url: `${ORIGIN}/app/client-a`,
|
|
postMessage: vi.fn(),
|
|
};
|
|
|
|
await runtime.onCacheResetRequest(
|
|
createServiceWorkerMessage({
|
|
kind: "CACHE_RESET_REQUEST",
|
|
sourceBuildId: "page-build",
|
|
targetBuildId: "worker-build",
|
|
nonce: "reset-1",
|
|
}),
|
|
source,
|
|
);
|
|
|
|
expect(fixture.deleted).toEqual([
|
|
`${STATIC_CACHE_PREFIX}${"a".repeat(16)}`,
|
|
`${STATIC_CACHE_PREFIX}${"b".repeat(16)}`,
|
|
]);
|
|
expect(source.postMessage).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
kind: "CACHE_RESET_RESULT",
|
|
nonce: "reset-1",
|
|
cachesDeleted: 2,
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("service worker static asset install", () => {
|
|
const manifest: StaticAssetManifestV1 = {
|
|
schemaVersion: 1,
|
|
buildId: "worker-build",
|
|
releaseId: "release-1",
|
|
setDigest: `sha256:${"a".repeat(64)}`,
|
|
assets: [
|
|
{
|
|
url: `${ORIGIN}/assets/app.js`,
|
|
sha256: `sha256:${"b".repeat(64)}`,
|
|
bytes: 1,
|
|
contentType: "application/javascript",
|
|
},
|
|
],
|
|
};
|
|
|
|
it("aborts and rolls back a candidate cache at the overall install deadline", async () => {
|
|
vi.useFakeTimers();
|
|
const deleteCache = vi.fn(async (_name: string) => true);
|
|
const fetcher = vi.fn(
|
|
(_input: RequestInfo | URL, init?: RequestInit) =>
|
|
new Promise<Response>((_resolve, reject) => {
|
|
init?.signal?.addEventListener("abort", () => {
|
|
reject(new DOMException("Install deadline", "AbortError"));
|
|
});
|
|
}),
|
|
);
|
|
|
|
const result = installStaticAssets(manifest, {
|
|
caches: {
|
|
open: vi.fn(async () => ({ put: vi.fn() }) as unknown as Cache),
|
|
delete: deleteCache,
|
|
},
|
|
fetcher: fetcher as typeof fetch,
|
|
digest: vi.fn(),
|
|
});
|
|
await vi.advanceTimersByTimeAsync(SERVICE_WORKER_BOUNDS.installDeadlineMs);
|
|
|
|
await expect(result).resolves.toEqual({
|
|
kind: "REJECTED",
|
|
code: "INSTALL_DEADLINE_EXCEEDED",
|
|
});
|
|
expect(fetcher.mock.calls[0]?.[1]?.signal?.aborted).toBe(true);
|
|
// SW-09. The public result closes at the deadline with one exact delete,
|
|
// and a second exact delete is registered once the abandoned install work
|
|
// actually settles. Both target the same owned candidate cache.
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(deleteCache).toHaveBeenCalledTimes(2);
|
|
expect(new Set(deleteCache.mock.calls.map((call) => call[0])).size).toBe(1);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("observes and cleans non-cooperative late install work", async () => {
|
|
vi.useFakeTimers();
|
|
const deleteCache = vi.fn(async (_name: string) => true);
|
|
const cancel = vi.fn(async () => {});
|
|
let releaseFetch: ((response: Response) => void) | undefined;
|
|
// A fetch that ignores the abort signal entirely.
|
|
const fetcher = vi.fn(
|
|
() =>
|
|
new Promise<Response>((resolve) => {
|
|
releaseFetch = resolve;
|
|
}),
|
|
);
|
|
|
|
const result = installStaticAssets(manifest, {
|
|
caches: {
|
|
open: vi.fn(async () => ({ put: vi.fn() }) as unknown as Cache),
|
|
delete: deleteCache,
|
|
},
|
|
fetcher: fetcher as typeof fetch,
|
|
digest: vi.fn(),
|
|
});
|
|
await vi.advanceTimersByTimeAsync(SERVICE_WORKER_BOUNDS.installDeadlineMs);
|
|
await expect(result).resolves.toEqual({
|
|
kind: "REJECTED",
|
|
code: "INSTALL_DEADLINE_EXCEEDED",
|
|
});
|
|
|
|
// The late response arrives after the public bound; its body is cancelled
|
|
// and no unhandled rejection escapes.
|
|
releaseFetch?.({ body: { cancel } } as unknown as Response);
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(cancel).toHaveBeenCalledOnce();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("stops reading as soon as the streamed body exceeds declared bytes", async () => {
|
|
const put = vi.fn(async () => {});
|
|
const cancel = vi.fn(async () => {});
|
|
const body = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(new Uint8Array([1, 2]));
|
|
},
|
|
cancel,
|
|
});
|
|
|
|
await expect(
|
|
installStaticAssets(manifest, {
|
|
caches: {
|
|
open: vi.fn(async () => ({ put }) as unknown as Cache),
|
|
delete: vi.fn(async () => true),
|
|
},
|
|
fetcher: vi.fn(async () =>
|
|
new Response(body, {
|
|
status: 200,
|
|
headers: { "content-type": "application/javascript" },
|
|
}),
|
|
),
|
|
digest: vi.fn(),
|
|
}),
|
|
).resolves.toEqual({ kind: "REJECTED", code: "BYTES_MISMATCH" });
|
|
expect(put).not.toHaveBeenCalled();
|
|
expect(cancel).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("aborts sibling asset fetches after the first install failure", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
let siblingSignal: AbortSignal | undefined;
|
|
const twoAssetManifest: StaticAssetManifestV1 = {
|
|
...manifest,
|
|
assets: [
|
|
manifest.assets[0]!,
|
|
{
|
|
url: `${ORIGIN}/assets/chunk.js`,
|
|
sha256: `sha256:${"c".repeat(64)}`,
|
|
bytes: 1,
|
|
contentType: "application/javascript",
|
|
},
|
|
],
|
|
};
|
|
const fetcher = vi.fn(
|
|
async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
if (String(input).endsWith("app.js")) {
|
|
return new Response(null, { status: 500 });
|
|
}
|
|
siblingSignal = init?.signal ?? undefined;
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 10));
|
|
return new Response(new Uint8Array([1]), {
|
|
status: 200,
|
|
headers: { "content-type": "application/javascript" },
|
|
});
|
|
},
|
|
);
|
|
|
|
const installing = installStaticAssets(twoAssetManifest, {
|
|
caches: {
|
|
open: vi.fn(async () => ({ put: vi.fn() }) as unknown as Cache),
|
|
delete: vi.fn(async () => true),
|
|
},
|
|
fetcher: fetcher as typeof fetch,
|
|
digest: vi.fn(async () => twoAssetManifest.assets[1]!.sha256),
|
|
});
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
await expect(installing).resolves.toEqual({
|
|
kind: "REJECTED",
|
|
code: "STATUS_INVALID",
|
|
});
|
|
expect(siblingSignal?.aborted).toBe(true);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("accepts replies only from the captured waiting or controller source", async () => {
|
|
const browser = pageContainer({ waiting: true });
|
|
const controller = pageController(browser.container);
|
|
await controller.start();
|
|
const foreign = { postMessage: vi.fn() };
|
|
|
|
// SW-06. A same-origin but unrecognised source must not close admission.
|
|
browser.dispatch(
|
|
createServiceWorkerMessage({
|
|
kind: "CLIENT_DRAIN_REQUEST",
|
|
sourceBuildId: "worker-build",
|
|
targetBuildId: "page-build",
|
|
nonce: "drain-foreign",
|
|
}),
|
|
foreign,
|
|
);
|
|
expect(foreign.postMessage).not.toHaveBeenCalled();
|
|
await controller.stop();
|
|
});
|
|
|
|
it("coalesces concurrent activation and reset commands", async () => {
|
|
const browser = pageContainer({ waiting: true });
|
|
const controller = pageController(browser.container);
|
|
await controller.start();
|
|
|
|
// SW-06. Ten concurrent callers issue exactly one request.
|
|
const activations = Array.from({ length: 10 }, () =>
|
|
controller.requestActivation(),
|
|
);
|
|
await Promise.resolve();
|
|
expect(browser.waitingMessages).toHaveLength(1);
|
|
expect(new Set(activations).size).toBe(1);
|
|
|
|
await controller.stop();
|
|
await Promise.allSettled(activations);
|
|
});
|
|
|
|
it("ends an activation whose reply source was swapped", async () => {
|
|
const browser = pageContainer({ waiting: true });
|
|
const controller = pageController(browser.container);
|
|
await controller.start();
|
|
const activation = controller.requestActivation();
|
|
await Promise.resolve();
|
|
|
|
const request = browser.waitingMessages.at(-1) as { nonce?: string };
|
|
// A different worker answers: terminate immediately rather than waiting for
|
|
// the drain timeout.
|
|
browser.dispatch(
|
|
createServiceWorkerMessage({
|
|
kind: "ACTIVATED_RELOAD_REQUIRED",
|
|
sourceBuildId: "worker-build",
|
|
targetBuildId: "page-build",
|
|
nonce: request.nonce,
|
|
}),
|
|
{ postMessage: vi.fn() },
|
|
);
|
|
|
|
await expect(activation).resolves.toEqual({ kind: "PROTOCOL_MISMATCH" });
|
|
await controller.stop();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* SW-01. The marker read must be bounded in bytes, not only in logic. Adding a
|
|
* whole chunk and *then* comparing the running total meant a corrupt body could
|
|
* hand activation a 1 MiB chunk against a 257-byte ceiling, and the reader lock
|
|
* was never released.
|
|
*/
|
|
describe("SW-01 the activation marker read is bounded in bytes", () => {
|
|
function scopeWithMarkerBody(
|
|
body: ReadableStream<Uint8Array> | null,
|
|
headers: Readonly<Record<string, string>> = {},
|
|
) {
|
|
const markerUrl = "__service-worker-activation-v1__";
|
|
const response = body
|
|
? new Response(body, { status: 200, headers })
|
|
: new Response("", { status: 200, headers });
|
|
const cache = {
|
|
match: vi.fn(async (request: RequestInfo | URL) =>
|
|
String(request).includes(markerUrl) ? response : undefined,
|
|
),
|
|
put: vi.fn(async () => {}),
|
|
delete: vi.fn(async () => true),
|
|
} as unknown as Cache;
|
|
return {
|
|
response,
|
|
scope: {
|
|
caches: {
|
|
open: vi.fn(async () => cache),
|
|
// The current static cache must exist for its marker to be read.
|
|
keys: vi.fn(async () => [
|
|
staticCacheName(identity.staticAssetSetDigest),
|
|
]),
|
|
delete: vi.fn(async () => true),
|
|
match: vi.fn(),
|
|
},
|
|
clients: { matchAll: vi.fn(async () => []) },
|
|
skipWaiting: vi.fn(async () => {}),
|
|
fetcher: vi.fn(),
|
|
digest: vi.fn(),
|
|
},
|
|
};
|
|
}
|
|
|
|
const runtimeFor = (scope: unknown) =>
|
|
createServiceWorkerRuntime(scope as never, {
|
|
identity,
|
|
handlers: ["PWA_STATIC_ASSETS"],
|
|
manifest: {
|
|
schemaVersion: 1,
|
|
buildId: identity.buildId,
|
|
releaseId: identity.releaseId,
|
|
setDigest: identity.staticAssetSetDigest as `sha256:${string}`,
|
|
assets: [],
|
|
},
|
|
runtimeConfigUrl: "/runtime-config.json",
|
|
releaseManifestUrl: "/release-manifest.json",
|
|
});
|
|
|
|
it("never retains a single chunk larger than the marker ceiling", async () => {
|
|
let delivered = 0;
|
|
let cancels = 0;
|
|
const oversized = new ReadableStream<Uint8Array>({
|
|
pull(controller) {
|
|
delivered += 1;
|
|
controller.enqueue(new Uint8Array(1_048_576));
|
|
},
|
|
cancel() {
|
|
cancels += 1;
|
|
},
|
|
});
|
|
const fixture = scopeWithMarkerBody(oversized);
|
|
|
|
// Activation still completes; the marker is simply not admitted.
|
|
await expect(runtimeFor(fixture.scope).onActivate()).resolves.toBeTypeOf(
|
|
"number",
|
|
);
|
|
// Every oversized chunk that arrived was refused before being retained,
|
|
// and the reader that saw it was cancelled. The marker is probed once per
|
|
// candidate cache, so the counts track each other rather than a constant.
|
|
expect(cancels).toBeGreaterThanOrEqual(1);
|
|
expect(delivered).toBeLessThanOrEqual(2);
|
|
expect(fixture.response.body?.locked).toBe(false);
|
|
});
|
|
|
|
it("bounds a stream that never produces a chunk", async () => {
|
|
let cancels = 0;
|
|
const stalled = new ReadableStream<Uint8Array>({
|
|
pull() {
|
|
return new Promise<void>(() => {});
|
|
},
|
|
cancel() {
|
|
cancels += 1;
|
|
},
|
|
});
|
|
const fixture = scopeWithMarkerBody(stalled);
|
|
|
|
await expect(
|
|
runtimeFor(fixture.scope).onActivate(),
|
|
).resolves.toBeTypeOf("number");
|
|
expect(cancels).toBeGreaterThanOrEqual(1);
|
|
}, 10_000);
|
|
|
|
it("cancels the body it refuses for a declared oversize", async () => {
|
|
let cancels = 0;
|
|
const declared = new ReadableStream<Uint8Array>({
|
|
pull(controller) {
|
|
controller.enqueue(new Uint8Array(8));
|
|
},
|
|
cancel() {
|
|
cancels += 1;
|
|
},
|
|
});
|
|
const fixture = scopeWithMarkerBody(declared, {
|
|
"content-length": "1048576",
|
|
});
|
|
|
|
await expect(
|
|
runtimeFor(fixture.scope).onActivate(),
|
|
).resolves.toBeTypeOf("number");
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(cancels).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it("refuses a marker body that is not valid UTF-8", async () => {
|
|
const invalid = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(new Uint8Array([0xff, 0xfe, 0xfd]));
|
|
controller.close();
|
|
},
|
|
});
|
|
const fixture = scopeWithMarkerBody(invalid);
|
|
|
|
await expect(
|
|
runtimeFor(fixture.scope).onActivate(),
|
|
).resolves.toBeTypeOf("number");
|
|
expect(fixture.response.body?.locked).toBe(false);
|
|
});
|
|
});
|