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 { 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user