fix: bound Service Worker marker reads and attribute native effects

SW-RR-01. The activation marker was read with response.text() whenever no
Content-Length was present, so a large or non-terminating body could consume
the whole activation step. It now reads through a bounded reader that stops one
byte past the ceiling, cancels its reader, applies a read deadline and decodes
UTF-8 fatally.

SW-RR-02. A matching nonce is not identity. An activation or reset result whose
event.source is null can no longer stand in for the expected worker; only a
strict identity match is admitted.

SW-RR-03. The build generator and the shared manifest decoder now read one
exported extension table, so .mjs and .png stop being emitted-then-refused.
.json is deliberately outside it: every JSON file in a build output is a control
document the generator already excludes, not a cacheable asset.

SW-RR-04. Both caches.open and cache.match are closed as a miss. Letting a
match rejection propagate rejected respondWith itself, so the entry never
reached its network fallback.

WP-RR-01. focus and openWindow now carry the certainty phase showNotification
already had — NOT_APPLIED, MAYBE_APPLIED, CONFIRMED — and an effect that lands
after the handler deadline is observed exactly once. The evidence never
authorizes a retry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-14 14:10:41 +09:00
co-authored by Claude Opus 5
parent bd90e0c983
commit efc577de63
6 changed files with 272 additions and 25 deletions
+7 -10
View File
@@ -1,6 +1,9 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { canonicalStaticManifestBytes } from "../src/contracts/service-worker-static-manifest.ts"; import {
CACHEABLE_ASSET_CONTENT_TYPES,
canonicalStaticManifestBytes,
} from "../src/contracts/service-worker-static-manifest.ts";
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
@@ -20,15 +23,9 @@ import {
const OUTPUT = ".generated/frontend-runtime/service-worker-assets.ts"; const OUTPUT = ".generated/frontend-runtime/service-worker-assets.ts";
const CACHEABLE_EXTENSIONS: Readonly<Record<string, string>> = Object.freeze({ // SW-RR-03. The generator and the shared decoder read the same table, so a
".js": "text/javascript", // manifest this script produces can never be one the runtime contract refuses.
".mjs": "text/javascript", const CACHEABLE_EXTENSIONS = CACHEABLE_ASSET_CONTENT_TYPES;
".css": "text/css",
".woff2": "font/woff2",
".svg": "image/svg+xml",
".png": "image/png",
".webp": "image/webp",
});
const EXCLUDED_FILES: ReadonlySet<string> = new Set([ const EXCLUDED_FILES: ReadonlySet<string> = new Set([
"index.html", "index.html",
@@ -224,13 +224,18 @@ export function createServiceWorkerRuntime(
? staticCacheName(config.manifest.setDigest) ? staticCacheName(config.manifest.setDigest)
: null; : null;
if (!currentCacheName) return null; if (!currentCacheName) return null;
// SW-RR-04. Both `open` and `match` are storage calls that can throw
// synchronously or reject. Either one escaping here rejects `respondWith`
// itself, so the entry never reaches its network fallback and the page
// gets a network error instead of the live response.
let cached: Response | undefined;
let currentCache: Cache; let currentCache: Cache;
try { try {
currentCache = await scope.caches.open(currentCacheName); currentCache = await scope.caches.open(currentCacheName);
cached = await currentCache.match(request.url);
} catch { } catch {
return null; return null;
} }
const cached = await currentCache.match(request.url);
if (!cached) return null; if (!cached) return null;
if (cached.status !== 200 || cached.type === "opaque") { if (cached.status !== 200 || cached.type === "opaque") {
// §18.6. An invalid hit is deleted from the cache it was read from and // §18.6. An invalid hit is deleted from the cache it was read from and
@@ -474,6 +479,69 @@ function isClientWithinRegistrationScope(
} }
} }
/**
* SW-RR-01. Reads at most one byte beyond the marker ceiling, cancels the
* reader as soon as that byte arrives, and fails closed on invalid UTF-8. The
* reader is also raced against a deadline so a stream that never produces a
* chunk cannot hold `activate` open.
*/
const ACTIVATION_MARKER_READ_DEADLINE_MS = 5_000;
async function readBoundedMarkerText(
response: Response,
): Promise<string | null> {
if (!response.body) {
try {
const text = await response.text();
return new TextEncoder().encode(text).byteLength >
ACTIVATION_MARKER_MAX_BYTES
? null
: text;
} catch {
return null;
}
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
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);
}
} catch {
return null;
} finally {
if (timer !== undefined) clearTimeout(timer);
// 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 {
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
return null;
}
}
async function readActivationMarker( async function readActivationMarker(
cache: Cache, cache: Cache,
expectedCacheName: string, expectedCacheName: string,
@@ -489,10 +557,11 @@ async function readActivationMarker(
) { ) {
return null; return null;
} }
const text = await response.text(); // SW-RR-01. A Content-Length is a claim, not a bound. Without one the
if (new TextEncoder().encode(text).byteLength > ACTIVATION_MARKER_MAX_BYTES) { // previous `response.text()` read the whole body, so a large or
return null; // non-terminating stream could consume the activation step indefinitely.
} const text = await readBoundedMarkerText(response);
if (text === null) return null;
const value: unknown = JSON.parse(text); const value: unknown = JSON.parse(text);
if ( if (
value === null || value === null ||
@@ -323,10 +323,11 @@ export function createServiceWorkerPageController(
) { ) {
return; return;
} }
// SW-06. The reply must come from the exact worker this request was // SW-06 / SW-RR-02. The reply must come from the exact worker this
// sent to. A source swap ends the request immediately as a protocol // request was sent to. A matching nonce is not identity: `null`
// mismatch rather than waiting for the drain timeout. // source means the sender cannot be established, so it is refused
if (event.source !== null && event.source !== waiting) { // like any other mismatch rather than accepted as this worker.
if (event.source !== waiting) {
finish(Object.freeze({ kind: "PROTOCOL_MISMATCH" as const })); finish(Object.freeze({ kind: "PROTOCOL_MISMATCH" as const }));
return; return;
} }
@@ -414,8 +415,10 @@ export function createServiceWorkerPageController(
) { ) {
return; return;
} }
// SW-RR-02. An unattributable reset result is never proof this
// controller performed the reset.
if ( if (
(event.source !== null && event.source !== requestedController) || event.source !== requestedController ||
container.controller !== requestedController container.controller !== requestedController
) { ) {
finish( finish(
@@ -3,6 +3,7 @@ import {
WEB_PUSH_PROTOCOLS, WEB_PUSH_PROTOCOLS,
webPushFailure, webPushFailure,
webPushSuccess, webPushSuccess,
type WebPushNativeEffectCertainty,
type WebPushObserver, type WebPushObserver,
type WebPushResult, type WebPushResult,
} from "../../../contracts/web-push.ts"; } from "../../../contracts/web-push.ts";
@@ -44,6 +45,29 @@ export type NotificationClickAdapter = Readonly<{
handle(event: NotificationClickEventFacade): Promise<WebPushResult<void>>; handle(event: NotificationClickEventFacade): Promise<WebPushResult<void>>;
}>; }>;
/**
* WP-RR-01. Bounds a native effect by the handler lifetime while keeping the
* abandoned promise observable exactly once.
*/
const ABORT_OWNED = Symbol("web-push-click-aborted");
async function raceAbort<Value>(
operation: Promise<Value>,
signal: AbortSignal,
): Promise<Value | typeof ABORT_OWNED> {
if (signal.aborted) return ABORT_OWNED;
let onAbort: (() => void) | undefined;
const aborted = new Promise<typeof ABORT_OWNED>((resolve) => {
onAbort = () => resolve(ABORT_OWNED);
signal.addEventListener("abort", onAbort, { once: true });
});
try {
return await Promise.race([operation, aborted]);
} finally {
if (onAbort) signal.removeEventListener("abort", onAbort);
}
}
export function createNotificationClickAdapter(dependencies: Readonly<{ export function createNotificationClickAdapter(dependencies: Readonly<{
fenceStore: PushAssociationFenceStore; fenceStore: PushAssociationFenceStore;
clients: WorkerClientsFacade; clients: WorkerClientsFacade;
@@ -156,23 +180,98 @@ export function createNotificationClickAdapter(dependencies: Readonly<{
expiresAt: decoded.value.expiresAt, expiresAt: decoded.value.expiresAt,
path, path,
}); });
// WP-RR-01. `focus` and `openWindow` are user-visible native effects, so
// they carry the same certainty phase `showNotification` already does:
// NOT_APPLIED before the call, MAYBE_APPLIED while the promise is pending,
// 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,
): 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",
});
},
);
};
try { try {
if (signal.aborted) { if (signal.aborted) {
return webPushFailure("ABORTED", "NOTIFICATION_CLICK"); return webPushFailure("ABORTED", "NOTIFICATION_CLICK");
} }
if (existing) { if (existing) {
existing.postMessage(handoff); existing.postMessage(handoff);
await existing.focus(); const focused = Promise.resolve(existing.focus());
nativeEffect = "MAYBE_APPLIED";
const raced = await raceAbort(focused, signal);
if (raced === ABORT_OWNED) {
observeLateEffect(focused, () => true);
return webPushFailure("ABORTED", "NOTIFICATION_CLICK");
}
nativeEffect = "CONFIRMED";
} else { } else {
const opened = await dependencies.clients.openWindow(target); const opening = Promise.resolve(
if (!opened) return nativeFailure("NOTIFICATION_CLICK", true); dependencies.clients.openWindow(target),
);
nativeEffect = "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,
});
return nativeFailure("NOTIFICATION_CLICK", true);
}
nativeEffect = "CONFIRMED";
} }
if (signal.aborted) { if (signal.aborted) {
observeWebPush(dependencies.observer, {
event: "web_push_click_dispatched",
outcome: "DEGRADED",
reason: "ABORTED",
nativeEffect,
});
return webPushFailure("ABORTED", "NOTIFICATION_CLICK"); return webPushFailure("ABORTED", "NOTIFICATION_CLICK");
} }
} catch { } catch {
observeWebPush(dependencies.observer, {
event: "web_push_click_dispatched",
outcome: "FAILED",
nativeEffect,
});
return nativeFailure("NOTIFICATION_CLICK", true); return nativeFailure("NOTIFICATION_CLICK", true);
} }
observeWebPush(dependencies.observer, {
event: "web_push_click_dispatched",
outcome: "SUCCEEDED",
nativeEffect,
});
return webPushSuccess(undefined); return webPushSuccess(undefined);
} }
@@ -31,13 +31,23 @@ export type StaticAssetManifest = Readonly<{
export const STATIC_ASSET_SET_DOMAIN = "CA_STATIC_ASSET_SET_V1"; export const STATIC_ASSET_SET_DOMAIN = "CA_STATIC_ASSET_SET_V1";
/** Exact allowed extension → content type pairs for a cacheable asset. */ /**
* SW-RR-03. The single authoritative extension → content type table.
*
* The build generator and this decoder must agree exactly: an extension the
* generator emits but the decoder refuses turns a correct build into a runtime
* contract failure, and the reverse admits an asset kind no build produces.
* `.json` is deliberately absent — every JSON file in a build output is a
* control document (runtime config, release manifest, schema), not a cacheable
* static asset, and the generator excludes them by name.
*/
export const CACHEABLE_ASSET_CONTENT_TYPES: Readonly< export const CACHEABLE_ASSET_CONTENT_TYPES: Readonly<
Record<string, string> Record<string, string>
> = Object.freeze({ > = Object.freeze({
".css": "text/css", ".css": "text/css",
".js": "text/javascript", ".js": "text/javascript",
".json": "application/json", ".mjs": "text/javascript",
".png": "image/png",
".svg": "image/svg+xml", ".svg": "image/svg+xml",
".webp": "image/webp", ".webp": "image/webp",
".woff2": "font/woff2", ".woff2": "font/woff2",
@@ -0,0 +1,69 @@
import { describe, expect, it, vi } from "vitest";
import {
CACHEABLE_ASSET_CONTENT_TYPES,
decodeStaticAssetManifest,
} from "../../src/contracts/service-worker-static-manifest.ts";
/**
* SW-RR-03. The build generator and the shared runtime decoder must agree on
* exactly which asset kinds exist. A generator that emits `.mjs` or `.png` while
* the decoder refuses them turns a correct build into a runtime contract
* failure, and the reverse admits a kind no build produces.
*/
describe("SW-RR-03 one authoritative cacheable asset table", () => {
it("covers every extension the generator emits", async () => {
const generator = await import(
"../../scripts/generate-service-worker-assets.ts"
);
expect(generator).toBeDefined();
for (const extension of [
".js",
".mjs",
".css",
".woff2",
".svg",
".png",
".webp",
]) {
expect(CACHEABLE_ASSET_CONTENT_TYPES[extension]).toBeDefined();
}
});
it("decodes a manifest row for every table entry", () => {
const assets = Object.entries(CACHEABLE_ASSET_CONTENT_TYPES).map(
([extension, contentType], index) => ({
url: `/assets/name-abcdefgh${index}${extension}`,
sha256: `sha256:${"a".repeat(64)}`,
bytes: 16,
contentType,
}),
);
const decoded = decodeStaticAssetManifest({
schemaVersion: 1,
buildId: "build-1",
releaseId: "release-1",
assets,
setDigest: `sha256:${"b".repeat(64)}`,
});
expect(decoded).toMatchObject({ ok: true });
});
it("refuses a row whose extension is not in the table", () => {
const decoded = decodeStaticAssetManifest({
schemaVersion: 1,
buildId: "build-1",
releaseId: "release-1",
setDigest: `sha256:${"b".repeat(64)}`,
assets: [
{
url: "/assets/control-abcdefgh.json",
sha256: `sha256:${"a".repeat(64)}`,
bytes: 16,
contentType: "application/json",
},
],
});
expect(decoded.ok).toBe(false);
});
});