fix: bound the activation marker in bytes and give a click one observer

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>
This commit is contained in:
DongHyeonka
2026-08-15 01:26:14 +09:00
co-authored by Claude Opus 5
parent 39a4a973a8
commit d7b35cfca3
7 changed files with 668 additions and 86 deletions
+23 -2
View File
@@ -3,6 +3,8 @@ import { createHash } from "node:crypto";
import {
CACHEABLE_ASSET_CONTENT_TYPES,
canonicalStaticManifestBytes,
decodeStaticAssetManifest,
isCanonicalStaticAssetUrl,
} from "../src/contracts/service-worker-static-manifest.ts";
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
import path from "node:path";
@@ -57,8 +59,17 @@ export async function collectStaticAssets(
if (bytes.byteLength > SERVICE_WORKER_BOUNDS.singleAssetBytes) {
throw new Error(`Static asset exceeds its byte bound: ${relative}`);
}
// SW-02. The URL is checked against the same predicate the runtime decoder
// applies. Emitting a path the decoder will refuse turned a correct build
// into a runtime contract failure discovered only at install time.
const url = `/${relative.split(path.sep).join("/")}`;
if (!isCanonicalStaticAssetUrl(url)) {
throw new Error(
`Static asset path is not canonical for the service worker manifest: ${relative}`,
);
}
assets.push({
url: `/${relative.split(path.sep).join("/")}`,
url,
sha256: `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
bytes: bytes.byteLength,
contentType,
@@ -79,13 +90,23 @@ export async function collectStaticAssets(
.update(canonicalStaticManifestBytes(assets))
.digest("hex")}`;
return {
const manifest: StaticAssetManifestV1 = {
schemaVersion: 1,
buildId,
releaseId,
setDigest,
assets,
};
// SW-02. Every manifest this generator returns has already passed the exact
// decoder the runtime will apply to it, so the build stops here rather than
// at install time.
const decoded = decodeStaticAssetManifest(manifest);
if (!decoded.ok) {
throw new Error(
`Generated service worker manifest is not decodable: ${decoded.error.reason}`,
);
}
return manifest;
}
@@ -501,47 +501,119 @@ async function readBoundedMarkerText(
return null;
}
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
// SW-01. The allowance is `limit + 1` bytes: one byte past the ceiling is
// enough to prove the marker is oversized, and nothing beyond it is ever
// retained. A BYOB reader asks the source for exactly the remaining
// allowance, so a corrupt body cannot answer a 1 MiB chunk to a 257-byte
// request. Without BYOB the first oversized chunk is refused outright rather
// than copied and then measured.
const allowance = ACTIVATION_MARKER_MAX_BYTES + 1;
const reader = byobReader(response.body) ?? response.body.getReader();
const byob = "read" in reader && isByobReader(reader);
const bytes = new Uint8Array(allowance);
let total = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
let cancelled = false;
const deadline = new Promise<"DEADLINE">((resolve) => {
timer = setTimeout(
() => resolve("DEADLINE"),
ACTIVATION_MARKER_READ_DEADLINE_MS,
);
});
try {
for (;;) {
const next = await Promise.race([reader.read(), deadline]);
if (next === "DEADLINE") return null;
if (next.done) break;
if (!next.value) continue;
total += next.value.byteLength;
if (total > ACTIVATION_MARKER_MAX_BYTES) return null;
chunks.push(next.value);
try {
timer = setTimeout(
() => resolve("DEADLINE"),
ACTIVATION_MARKER_READ_DEADLINE_MS,
);
} catch {
// An unschedulable deadline leaves the read unbounded, so it ends now.
resolve("DEADLINE");
}
} catch {
return null;
} finally {
if (timer !== undefined) clearTimeout(timer);
});
const cancelOnce = (): void => {
if (cancelled) return;
cancelled = true;
// Never awaited: cancelling a stream whose source ignores cancellation can
// itself hang, and the marker read already has its answer.
void reader.cancel().catch(() => {});
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
try {
void reader.cancel().catch(() => {});
} catch {
// A hostile reader cannot block the release below.
}
};
try {
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
for (;;) {
const remaining = allowance - total;
if (remaining <= 0) {
// More than the ceiling has already arrived.
return null;
}
const pending = byob
? (reader as ReadableStreamBYOBReader).read(
new Uint8Array(remaining),
)
: (reader as ReadableStreamDefaultReader<Uint8Array>).read();
const next = await Promise.race([pending, deadline]);
if (next === "DEADLINE") {
cancelOnce();
return null;
}
if (next.done) break;
const chunk = next.value;
if (!chunk) continue;
if (chunk.byteLength > remaining) {
// Refused before it is retained: the oversized chunk is not copied.
cancelOnce();
return null;
}
bytes.set(chunk, total);
total += chunk.byteLength;
}
} catch {
cancelOnce();
return null;
} finally {
if (timer !== undefined) {
try {
clearTimeout(timer);
} catch {
// Releasing the timer is best effort.
}
}
cancelOnce();
try {
reader.releaseLock();
} catch {
// A cancelled reader has already released its lock.
}
}
if (total > ACTIVATION_MARKER_MAX_BYTES) return null;
try {
return new TextDecoder("utf-8", { fatal: true }).decode(
bytes.subarray(0, total),
);
} catch {
return null;
}
}
/** A byte stream can hand out a BYOB reader; a regular one cannot. */
function byobReader(
body: ReadableStream<Uint8Array>,
): ReadableStreamBYOBReader | null {
try {
return (
body as ReadableStream<Uint8Array> & {
getReader(options: { mode: "byob" }): ReadableStreamBYOBReader;
}
).getReader({ mode: "byob" });
} catch {
return null;
}
}
function isByobReader(reader: unknown): reader is ReadableStreamBYOBReader {
return (
typeof ReadableStreamBYOBReader === "function" &&
reader instanceof ReadableStreamBYOBReader
);
}
async function readActivationMarker(
cache: Cache,
expectedCacheName: string,
@@ -555,6 +627,14 @@ async function readActivationMarker(
(!/^\d+$/u.test(declaredLength) ||
Number(declaredLength) > ACTIVATION_MARKER_MAX_BYTES)
) {
// SW-01. A declared oversize ends the read, but the body it declared is
// still an open stream: returning without cancelling it left the source
// holding the connection for the rest of the worker's life.
try {
void response.body?.cancel().catch(() => {});
} catch {
// Cancelling is best effort and cannot change the closed outcome.
}
return null;
}
// SW-RR-01. A Content-Length is a claim, not a bound. Without one the
@@ -51,6 +51,18 @@ export type NotificationClickAdapter = Readonly<{
*/
const ABORT_OWNED = Symbol("web-push-click-aborted");
/**
* WP-01. Per-click observation state: the current native-effect certainty and
* the bounded tail tasks that observe an effect landing after the terminal
* result. `waitUntil` owns the tails so the worker cannot be terminated before
* the evidence lands, and the certainty is monotone from `NOT_APPLIED` through
* `MAYBE_APPLIED` to `CONFIRMED`.
*/
type ClickEffectState = {
certainty: WebPushNativeEffectCertainty;
tails: Promise<unknown>[];
};
async function raceAbort<Value>(
operation: Promise<Value>,
signal: AbortSignal,
@@ -103,8 +115,17 @@ export function createNotificationClickAdapter(dependencies: Readonly<{
const taskControl = createLinkedAbortController(
dependencies.signal,
);
// WP-01. One observation authority per click. The terminal record was
// emitted both inside `process` and again here, so an ordinary click was
// counted twice, and the native-effect evidence was detached from
// `waitUntil` entirely — a worker that shut down after the terminal
// result simply lost it.
const effect: ClickEffectState = {
certainty: "NOT_APPLIED",
tails: [],
};
const processing = withAbortableDeadline(
(signal) => process(event.notification.data, signal),
(signal) => process(event.notification.data, signal, effect),
{
deadlineMs: handlerDeadlineMs,
operation: "NOTIFICATION_CLICK",
@@ -114,12 +135,16 @@ export function createNotificationClickAdapter(dependencies: Readonly<{
).finally(taskControl.dispose);
try {
event.waitUntil(
processing.then((result) => {
processing.then(async (result) => {
observeWebPush(dependencies.observer, {
event: "web_push_click_dispatched",
outcome: result.ok ? "SUCCEEDED" : "FAILED",
...(result.ok ? {} : { reason: result.error.code }),
nativeEffect: effect.certainty,
});
// The late-effect observation is this handler's own work, so the
// worker stays alive for it without extending the public deadline.
await Promise.allSettled(effect.tails);
}),
);
} catch {
@@ -133,6 +158,7 @@ export function createNotificationClickAdapter(dependencies: Readonly<{
async function process(
data: unknown,
signal: AbortSignal,
effect: ClickEffectState,
): Promise<WebPushResult<void>> {
const decoded = decodeNotificationClickData(data, now());
if (!decoded.ok) return decoded;
@@ -186,33 +212,41 @@ export function createNotificationClickAdapter(dependencies: Readonly<{
// CONFIRMED on fulfilment. An effect that lands after this handler's
// deadline is still observed exactly once — as evidence only, never as
// authorization to retry.
let nativeEffect: WebPushNativeEffectCertainty = "NOT_APPLIED";
const observeLateEffect = (
pending: Promise<unknown>,
succeeded: (value: unknown) => boolean,
appliedWhen: (value: unknown) => boolean,
): void => {
let observed = false;
void pending.then(
(value) => {
if (observed) return;
observed = true;
observeWebPush(dependencies.observer, {
event: "web_push_click_dispatched",
outcome: succeeded(value) ? "DEGRADED" : "FAILED",
reason: "ABORTED",
nativeEffect: succeeded(value) ? "CONFIRMED" : "NOT_APPLIED",
});
},
() => {
if (observed) return;
observed = true;
observeWebPush(dependencies.observer, {
event: "web_push_click_dispatched",
outcome: "FAILED",
reason: "ABORTED",
nativeEffect: "NOT_APPLIED",
});
},
// WP-01. The tail is tracked so `waitUntil` owns it. Certainty is
// monotone: once the native call has been made the effect can only be
// confirmed or stay uncertain. A rejection says the call did not report
// success, not that it never happened, so downgrading it to NOT_APPLIED
// told operators the click had definitely not been applied.
effect.tails.push(
pending.then(
(value) => {
if (observed) return;
observed = true;
const applied = appliedWhen(value);
effect.certainty = applied ? "CONFIRMED" : "NOT_APPLIED";
observeWebPush(dependencies.observer, {
event: "web_push_click_dispatched",
outcome: applied ? "DEGRADED" : "FAILED",
reason: "ABORTED",
nativeEffect: effect.certainty,
});
},
() => {
if (observed) return;
observed = true;
observeWebPush(dependencies.observer, {
event: "web_push_click_dispatched",
outcome: "DEGRADED",
reason: "ABORTED",
nativeEffect: "MAYBE_APPLIED",
});
},
),
);
};
try {
@@ -222,56 +256,38 @@ export function createNotificationClickAdapter(dependencies: Readonly<{
if (existing) {
existing.postMessage(handoff);
const focused = Promise.resolve(existing.focus());
nativeEffect = "MAYBE_APPLIED";
effect.certainty = "MAYBE_APPLIED";
const raced = await raceAbort(focused, signal);
if (raced === ABORT_OWNED) {
observeLateEffect(focused, () => true);
return webPushFailure("ABORTED", "NOTIFICATION_CLICK");
}
nativeEffect = "CONFIRMED";
effect.certainty = "CONFIRMED";
} else {
const opening = Promise.resolve(
dependencies.clients.openWindow(target),
);
nativeEffect = "MAYBE_APPLIED";
effect.certainty = "MAYBE_APPLIED";
const raced = await raceAbort(opening, signal);
if (raced === ABORT_OWNED) {
observeLateEffect(opening, (value) => value !== null);
return webPushFailure("ABORTED", "NOTIFICATION_CLICK");
}
if (!raced) {
nativeEffect = "NOT_APPLIED";
observeWebPush(dependencies.observer, {
event: "web_push_click_dispatched",
outcome: "FAILED",
nativeEffect,
});
// An explicit null is the one answer that confirms no window opened.
effect.certainty = "NOT_APPLIED";
return nativeFailure("NOTIFICATION_CLICK", true);
}
nativeEffect = "CONFIRMED";
effect.certainty = "CONFIRMED";
}
if (signal.aborted) {
observeWebPush(dependencies.observer, {
event: "web_push_click_dispatched",
outcome: "DEGRADED",
reason: "ABORTED",
nativeEffect,
});
return webPushFailure("ABORTED", "NOTIFICATION_CLICK");
}
} catch {
observeWebPush(dependencies.observer, {
event: "web_push_click_dispatched",
outcome: "FAILED",
nativeEffect,
});
// The native call threw, so it never reported success; whether it took
// effect is unknown rather than settled.
return nativeFailure("NOTIFICATION_CLICK", true);
}
observeWebPush(dependencies.observer, {
event: "web_push_click_dispatched",
outcome: "SUCCEEDED",
nativeEffect,
});
return webPushSuccess(undefined);
}
@@ -71,6 +71,22 @@ const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
/** Root-relative, hashed, no dot segments, no query and no fragment. */
const ASSET_URL = /^\/(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+$/u;
/**
* SW-02. The one canonical asset-path predicate, shared by the build generator
* and this decoder. Sharing only the extension table left the two with
* different path grammars: the generator emitted a URL for a directory
* containing a space, an `@` or a percent-escape, and the decoder then refused
* the manifest it had just produced, failing the release build.
*/
export function isCanonicalStaticAssetUrl(url: string): boolean {
return (
typeof url === "string" &&
ASSET_URL.test(url) &&
!url.includes("/../") &&
!url.includes("/./")
);
}
export type StaticManifestDecodeFailure = Readonly<{
reason: string;
}>;
@@ -135,12 +151,9 @@ export function decodeStaticAssetManifest(
const row = exactKeys(candidate, ASSET_ROW_KEYS);
if (!row) return failure("asset row keys are not exact");
const { url, sha256, bytes, contentType } = row;
if (typeof url !== "string" || !ASSET_URL.test(url)) {
if (typeof url !== "string" || !isCanonicalStaticAssetUrl(url)) {
return failure("asset url must be root-relative without dot segments");
}
if (url.includes("/../") || url.includes("/./")) {
return failure("asset url must not contain dot segments");
}
if (seen.has(url)) return failure("asset urls must be unique");
// A sorted set makes the canonical bytes independent of directory order.
if (previousUrl !== null && url <= previousUrl) {
+138
View File
@@ -916,3 +916,141 @@ describe("service worker static asset install", () => {
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);
});
});
@@ -1,8 +1,13 @@
import { describe, expect, it, vi } from "vitest";
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
CACHEABLE_ASSET_CONTENT_TYPES,
decodeStaticAssetManifest,
isCanonicalStaticAssetUrl,
} from "../../src/contracts/service-worker-static-manifest.ts";
/**
@@ -67,3 +72,105 @@ describe("SW-RR-03 one authoritative cacheable asset table", () => {
expect(decoded.ok).toBe(false);
});
});
/**
* SW-02. Sharing only the extension table left the generator and the decoder
* with different path grammars: the generator emitted a URL for a directory
* containing an `@` or a space, and the decoder then refused the manifest it
* had just produced, failing the release build at install time.
*/
describe("SW-02 the generator and the decoder share one path grammar", () => {
async function distWith(
files: Readonly<Record<string, string>>,
): Promise<string> {
const root = await mkdtemp(path.join(tmpdir(), "sw-assets-"));
for (const [relative, content] of Object.entries(files)) {
const absolute = path.join(root, relative);
await mkdir(path.dirname(absolute), { recursive: true });
await writeFile(absolute, content);
}
return root;
}
it("emits a manifest the runtime decoder accepts for every table entry", async () => {
const { collectStaticAssets } = await import(
"../../scripts/generate-service-worker-assets.ts"
);
const files: Record<string, string> = {};
for (const [index, extension] of Object.keys(
CACHEABLE_ASSET_CONTENT_TYPES,
).entries()) {
files[`assets/name-abcdefg${index}${extension}`] = `content-${index}`;
}
files["assets/nested/deep/name-abcdefgz.js"] = "nested";
const root = await distWith(files);
const manifest = await collectStaticAssets(root, "build-1", "release-1");
expect(manifest.assets.length).toBe(
Object.keys(CACHEABLE_ASSET_CONTENT_TYPES).length + 1,
);
expect(decodeStaticAssetManifest(manifest)).toMatchObject({ ok: true });
for (const asset of manifest.assets) {
expect(isCanonicalStaticAssetUrl(asset.url)).toBe(true);
}
});
it.each([
{ label: "an at sign", name: "bad@name-abcdefgh.js" },
{ label: "a space", name: "bad name-abcdefgh.js" },
{ label: "a percent escape", name: "bad%20name-abcdefgh.js" },
{ label: "a hash", name: "bad#name-abcdefgh.js" },
])(
"stops the build rather than emitting a path the decoder refuses ($label)",
async ({ name }) => {
const { collectStaticAssets } = await import(
"../../scripts/generate-service-worker-assets.ts"
);
const root = await distWith({
"assets/good-abcdefgh.js": "ok",
[`assets/${name}`]: "bad",
});
await expect(
collectStaticAssets(root, "build-1", "release-1"),
).rejects.toThrow(/not canonical/u);
},
);
it("agrees with the decoder on the whole path policy table", () => {
const cases: readonly (readonly [string, boolean])[] = [
["/assets/name-abcdefgh.js", true],
["/assets/nested/name-abcdefgh.js", true],
["/assets/bad@name-abcdefgh.js", false],
["/assets/bad name-abcdefgh.js", false],
["/assets/bad%20name-abcdefgh.js", false],
["/assets/bad#name-abcdefgh.js", false],
["/assets/../name-abcdefgh.js", false],
["/assets/./name-abcdefgh.js", false],
["assets/name-abcdefgh.js", false],
["/assets/\\name-abcdefgh.js", false],
["/assets/naïve-abcdefgh.js", false],
];
for (const [url, canonical] of cases) {
expect(isCanonicalStaticAssetUrl(url), url).toBe(canonical);
const decoded = decodeStaticAssetManifest({
schemaVersion: 1,
buildId: "build-1",
releaseId: "release-1",
setDigest: `sha256:${"a".repeat(64)}`,
assets: [
{
url,
sha256: `sha256:${"a".repeat(64)}`,
bytes: 4,
contentType: "text/javascript",
},
],
});
// The decoder still owns the digest check, so a canonical path may fail
// for other reasons; a non-canonical one must always fail.
if (!canonical) expect(decoded.ok, url).toBe(false);
}
});
});
+207
View File
@@ -621,3 +621,210 @@ describe("Web Push worker runtime", () => {
expect(listeners.size).toBe(0);
});
});
/**
* WP-01. One click is one terminal record. The adapter emitted the terminal
* event from inside `process` *and* again from the `waitUntil` wrapper, so an
* ordinary click was counted twice. A late rejection also downgraded the
* certainty from `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.
*/
describe("WP-01 the click handler has one observation authority", () => {
type Observation = Readonly<{
event: string;
outcome: string;
reason?: string;
nativeEffect?: string;
}>;
async function clickAdapterWith(
clients: Readonly<{
matchControlledWindowClients(): Promise<readonly unknown[]>;
openWindow(target: string): Promise<unknown>;
}>,
scheduler?: TimeoutScheduler,
) {
const store = await activeFence();
const observations: Observation[] = [];
const adapter = createNotificationClickAdapter({
fenceStore: store,
registry,
origin: "https://app.example.test",
now: () => now,
clients: clients as never,
observer: {
record(observation) {
observations.push(observation as Observation);
},
},
...(scheduler ? { scheduler } : {}),
});
return { adapter, observations };
}
const dispatched = (observations: readonly Observation[]) =>
observations.filter(
(observation) => observation.event === "web_push_click_dispatched",
);
it("records exactly one terminal event for an ordinary focus", async () => {
const focus = vi.fn(async () => {});
const { adapter, observations } = await clickAdapterWith({
async matchControlledWindowClients() {
return [
{
url: "https://app.example.test/current",
focus,
postMessage() {},
},
];
},
openWindow: vi.fn(async () => null),
});
let waited: Promise<void> | null = null;
const result = await adapter.handle({
notification: { data: clickData(), close() {} },
waitUntil(task) {
waited = task;
},
});
await waited;
expect(result).toEqual({ ok: true, value: undefined });
expect(dispatched(observations)).toEqual([
{
event: "web_push_click_dispatched",
outcome: "SUCCEEDED",
nativeEffect: "CONFIRMED",
},
]);
});
it("confirms NOT_APPLIED only for an explicit null window", async () => {
const { adapter, observations } = await clickAdapterWith({
async matchControlledWindowClients() {
return [];
},
openWindow: vi.fn(async () => null),
});
let waited: Promise<void> | null = null;
const result = await adapter.handle({
notification: { data: clickData(), close() {} },
waitUntil(task) {
waited = task;
},
});
await waited;
expect(result.ok).toBe(false);
expect(dispatched(observations)).toEqual([
expect.objectContaining({
outcome: "FAILED",
nativeEffect: "NOT_APPLIED",
}),
]);
});
it("keeps MAYBE_APPLIED when the native effect rejects after the deadline", async () => {
const clock = manualScheduler();
let rejectFocus: ((reason: unknown) => void) | undefined;
const { adapter, observations } = await clickAdapterWith(
{
async matchControlledWindowClients() {
return [
{
url: "https://app.example.test/current",
focus: () =>
new Promise<void>((_resolve, reject) => {
rejectFocus = reject;
}),
postMessage() {},
},
];
},
openWindow: vi.fn(async () => null),
},
clock.scheduler,
);
let waited: Promise<void> | null = null;
const handling = adapter.handle({
notification: { data: clickData(), close() {} },
waitUntil(task) {
waited = task;
},
});
await vi.waitFor(() => expect(rejectFocus).toBeDefined());
clock.expireAll();
const result = await handling;
expect(result.ok).toBe(false);
// The effect lands only now, after the terminal result.
rejectFocus?.(new Error("focus failed late"));
await waited;
const records = dispatched(observations);
expect(records).toHaveLength(2);
// The evidence record never claims the click was definitely not applied.
expect(records.at(-1)).toEqual({
event: "web_push_click_dispatched",
outcome: "DEGRADED",
reason: "ABORTED",
nativeEffect: "MAYBE_APPLIED",
});
});
it("waits for the late effect evidence inside waitUntil", async () => {
const clock = manualScheduler();
let resolveFocus: (() => void) | undefined;
const { adapter, observations } = await clickAdapterWith(
{
async matchControlledWindowClients() {
return [
{
url: "https://app.example.test/current",
focus: () =>
new Promise<void>((resolve) => {
resolveFocus = resolve;
}),
postMessage() {},
},
];
},
openWindow: vi.fn(async () => null),
},
clock.scheduler,
);
let waited: Promise<void> | null = null;
const handling = adapter.handle({
notification: { data: clickData(), close() {} },
waitUntil(task) {
waited = task;
},
});
await vi.waitFor(() => expect(resolveFocus).toBeDefined());
clock.expireAll();
await handling;
let waitUntilSettled = false;
const pendingWait = waited as Promise<void> | null;
void pendingWait?.then(() => {
waitUntilSettled = true;
});
await Promise.resolve();
await Promise.resolve();
// The handler's lifetime is still open because the effect has not landed.
expect(waitUntilSettled).toBe(false);
resolveFocus?.();
await waited;
expect(
dispatched(observations).at(-1),
).toEqual({
event: "web_push_click_dispatched",
outcome: "DEGRADED",
reason: "ABORTED",
nativeEffect: "CONFIRMED",
});
});
});