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:
co-authored by
Claude Opus 5
parent
bd90e0c983
commit
efc577de63
@@ -1,6 +1,9 @@
|
||||
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 path from "node:path";
|
||||
|
||||
@@ -20,15 +23,9 @@ import {
|
||||
|
||||
const OUTPUT = ".generated/frontend-runtime/service-worker-assets.ts";
|
||||
|
||||
const CACHEABLE_EXTENSIONS: Readonly<Record<string, string>> = Object.freeze({
|
||||
".js": "text/javascript",
|
||||
".mjs": "text/javascript",
|
||||
".css": "text/css",
|
||||
".woff2": "font/woff2",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
});
|
||||
// SW-RR-03. The generator and the shared decoder read the same table, so a
|
||||
// manifest this script produces can never be one the runtime contract refuses.
|
||||
const CACHEABLE_EXTENSIONS = CACHEABLE_ASSET_CONTENT_TYPES;
|
||||
|
||||
const EXCLUDED_FILES: ReadonlySet<string> = new Set([
|
||||
"index.html",
|
||||
|
||||
@@ -224,13 +224,18 @@ export function createServiceWorkerRuntime(
|
||||
? staticCacheName(config.manifest.setDigest)
|
||||
: 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;
|
||||
try {
|
||||
currentCache = await scope.caches.open(currentCacheName);
|
||||
cached = await currentCache.match(request.url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const cached = await currentCache.match(request.url);
|
||||
if (!cached) return null;
|
||||
if (cached.status !== 200 || cached.type === "opaque") {
|
||||
// §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(
|
||||
cache: Cache,
|
||||
expectedCacheName: string,
|
||||
@@ -489,10 +557,11 @@ async function readActivationMarker(
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const text = await response.text();
|
||||
if (new TextEncoder().encode(text).byteLength > ACTIVATION_MARKER_MAX_BYTES) {
|
||||
return null;
|
||||
}
|
||||
// SW-RR-01. A Content-Length is a claim, not a bound. Without one the
|
||||
// previous `response.text()` read the whole body, so a large or
|
||||
// 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);
|
||||
if (
|
||||
value === null ||
|
||||
|
||||
@@ -323,10 +323,11 @@ export function createServiceWorkerPageController(
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// SW-06. The reply must come from the exact worker this request was
|
||||
// sent to. A source swap ends the request immediately as a protocol
|
||||
// mismatch rather than waiting for the drain timeout.
|
||||
if (event.source !== null && event.source !== waiting) {
|
||||
// SW-06 / SW-RR-02. The reply must come from the exact worker this
|
||||
// request was sent to. A matching nonce is not identity: `null`
|
||||
// source means the sender cannot be established, so it is refused
|
||||
// like any other mismatch rather than accepted as this worker.
|
||||
if (event.source !== waiting) {
|
||||
finish(Object.freeze({ kind: "PROTOCOL_MISMATCH" as const }));
|
||||
return;
|
||||
}
|
||||
@@ -414,8 +415,10 @@ export function createServiceWorkerPageController(
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// SW-RR-02. An unattributable reset result is never proof this
|
||||
// controller performed the reset.
|
||||
if (
|
||||
(event.source !== null && event.source !== requestedController) ||
|
||||
event.source !== requestedController ||
|
||||
container.controller !== requestedController
|
||||
) {
|
||||
finish(
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
WEB_PUSH_PROTOCOLS,
|
||||
webPushFailure,
|
||||
webPushSuccess,
|
||||
type WebPushNativeEffectCertainty,
|
||||
type WebPushObserver,
|
||||
type WebPushResult,
|
||||
} from "../../../contracts/web-push.ts";
|
||||
@@ -44,6 +45,29 @@ export type NotificationClickAdapter = Readonly<{
|
||||
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<{
|
||||
fenceStore: PushAssociationFenceStore;
|
||||
clients: WorkerClientsFacade;
|
||||
@@ -156,23 +180,98 @@ export function createNotificationClickAdapter(dependencies: Readonly<{
|
||||
expiresAt: decoded.value.expiresAt,
|
||||
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 {
|
||||
if (signal.aborted) {
|
||||
return webPushFailure("ABORTED", "NOTIFICATION_CLICK");
|
||||
}
|
||||
if (existing) {
|
||||
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 {
|
||||
const opened = await dependencies.clients.openWindow(target);
|
||||
if (!opened) return nativeFailure("NOTIFICATION_CLICK", true);
|
||||
const opening = Promise.resolve(
|
||||
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) {
|
||||
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,
|
||||
});
|
||||
return nativeFailure("NOTIFICATION_CLICK", true);
|
||||
}
|
||||
observeWebPush(dependencies.observer, {
|
||||
event: "web_push_click_dispatched",
|
||||
outcome: "SUCCEEDED",
|
||||
nativeEffect,
|
||||
});
|
||||
return webPushSuccess(undefined);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,13 +31,23 @@ export type StaticAssetManifest = Readonly<{
|
||||
|
||||
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<
|
||||
Record<string, string>
|
||||
> = Object.freeze({
|
||||
".css": "text/css",
|
||||
".js": "text/javascript",
|
||||
".json": "application/json",
|
||||
".mjs": "text/javascript",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".webp": "image/webp",
|
||||
".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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user