chore: sync the frontend template from 4dc033c to 8157ad4
The product was materialized from the template at `4dc033c` and has stayed on it through 43 template commits, so it was missing all three rounds of adapter remediation — including files it never had, such as the shared `abortable-operation` primitive and the `exact-snapshot` decoder that later fixes are written against. Taking only the newest round was not possible for that reason: the delta is coherent only as a whole. The product had not touched `src/adapters` at all since materialization, so the 140-file delta applied with a three-way merge and no conflicts. `package.json` was the single overlap and merged cleanly: the product owns `name`, the template contributed `check:adapter-inventory`, `check:remediation-ledger` and the image-resolve-signal type fixture. All 24 product-owned files — README, index.html, CI workflow, i18n catalog, home page, generated schemas, evidence scripts, component and visual snapshots — are byte-identical to `main`. `template.lock.json` now pins the synced revision and tree. Verified in this repository, not inherited from the template: six type projects, lint, nine gates (adapter inventory, remediation ledger, registries, diagnostics, realtime boundaries, architecture, browser file/storage boundaries, optional recipes, documentation), the production build, and 2,054 of 2,073 tests. The 19 failures are all in `tests/unit/ci-artifact-contract.test.ts` and are the same pre-existing sandbox RLIMIT, EMFILE, umask and `/tmp` permission behaviour the template records; four suites that failed once under parallel load pass in isolation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
002ba3624e
commit
4bff9ca151
@@ -40,7 +40,8 @@ const scope: WorkerScopeLike = {
|
||||
open: (name) => caches.open(name),
|
||||
keys: () => caches.keys(),
|
||||
delete: (name) => caches.delete(name),
|
||||
match: (request) => caches.match(request),
|
||||
// SW-01. No CacheStorage-wide match: only the current release cache may
|
||||
// answer a verified static request.
|
||||
},
|
||||
clients: {
|
||||
matchAll: (options) =>
|
||||
|
||||
@@ -33,7 +33,6 @@ export type WorkerScopeLike = Readonly<{
|
||||
open(cacheName: string): Promise<Cache>;
|
||||
keys(): Promise<readonly string[]>;
|
||||
delete(cacheName: string): Promise<boolean>;
|
||||
match(request: string): Promise<Response | undefined>;
|
||||
}>;
|
||||
clients: Readonly<{
|
||||
matchAll(
|
||||
@@ -59,6 +58,34 @@ export type WorkerRuntimeConfig = Readonly<{
|
||||
releaseManifestUrl: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* SW-URL-01. Root-relative generated URLs become absolute same-origin URLs
|
||||
* exactly once. Anything that escapes the scope origin is dropped rather than
|
||||
* silently classified.
|
||||
*/
|
||||
function canonicalManifestUrls(
|
||||
assets: readonly Readonly<{ url: string }>[],
|
||||
scopeHref: string,
|
||||
): readonly string[] {
|
||||
let base: URL;
|
||||
try {
|
||||
base = new URL(scopeHref);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const canonical: string[] = [];
|
||||
for (const asset of assets) {
|
||||
try {
|
||||
const absolute = new URL(asset.url, base);
|
||||
if (absolute.origin !== base.origin) continue;
|
||||
canonical.push(absolute.href);
|
||||
} catch {
|
||||
// A manifest URL that cannot be canonicalized is never classified.
|
||||
}
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
|
||||
const ACTIVATION_MARKER_URL =
|
||||
"https://clean-architecture.invalid/__service-worker-activation-v1__";
|
||||
const ACTIVATION_MARKER_MAX_BYTES = 256;
|
||||
@@ -73,8 +100,22 @@ export function createServiceWorkerRuntime(
|
||||
config: WorkerRuntimeConfig,
|
||||
) {
|
||||
const staticEnabled = config.handlers.includes("PWA_STATIC_ASSETS");
|
||||
const manifestUrls = new Set(
|
||||
staticEnabled ? (config.manifest?.assets ?? []).map((asset) => asset.url) : [],
|
||||
/**
|
||||
* SW-URL-01. The generated manifest stores root-relative URLs while `Request`
|
||||
* exposes absolute ones, so comparing the two directly classified every
|
||||
* verified asset as a network fallback. Canonicalize once against the
|
||||
* registration scope, re-check same-origin, and share that identity across
|
||||
* install cache keys, fetch classification and cache lookup or delete.
|
||||
*/
|
||||
const manifestUrls: ReadonlySet<string> = Object.freeze(
|
||||
new Set(
|
||||
staticEnabled
|
||||
? canonicalManifestUrls(
|
||||
config.manifest?.assets ?? [],
|
||||
scope.registrationScope,
|
||||
)
|
||||
: [],
|
||||
),
|
||||
);
|
||||
const consumedNonces = new Set<string>();
|
||||
type PendingActivation = Readonly<{
|
||||
@@ -176,17 +217,30 @@ export function createServiceWorkerRuntime(
|
||||
});
|
||||
if (classification !== "VERIFIED_CACHE_FIRST") return null;
|
||||
|
||||
const cached = await scope.caches.match(request.url);
|
||||
// SW-01. Only the current release cache may answer. A CacheStorage-wide
|
||||
// match could return a previous release's response for the same URL, and
|
||||
// the subsequent delete would then target a cache that was never read.
|
||||
const currentCacheName = config.manifest
|
||||
? 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;
|
||||
}
|
||||
if (!cached) return null;
|
||||
if (cached.status !== 200 || cached.type === "opaque") {
|
||||
// §18.6. An invalid hit is deleted and treated as a release mismatch.
|
||||
const current = config.manifest
|
||||
? staticCacheName(config.manifest.setDigest)
|
||||
: null;
|
||||
if (current) {
|
||||
const cache = await scope.caches.open(current);
|
||||
await cache.delete(request.url).catch(() => false);
|
||||
}
|
||||
// §18.6. An invalid hit is deleted from the cache it was read from and
|
||||
// treated as a release mismatch.
|
||||
await currentCache.delete(request.url).catch(() => false);
|
||||
return null;
|
||||
}
|
||||
return cached;
|
||||
@@ -228,49 +282,64 @@ export function createServiceWorkerRuntime(
|
||||
parsed.message.sourceBuildId,
|
||||
);
|
||||
if (!drained) {
|
||||
for (const client of clients) {
|
||||
client.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind: "ACTIVATE_REJECTED",
|
||||
sourceBuildId: config.identity.buildId,
|
||||
targetBuildId: parsed.message.sourceBuildId,
|
||||
nonce,
|
||||
}),
|
||||
);
|
||||
}
|
||||
notifyClients(clients, "ACTIVATE_REJECTED", parsed.message.sourceBuildId, nonce);
|
||||
return "REJECTED";
|
||||
}
|
||||
|
||||
for (const client of clients) {
|
||||
client.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind: "ACTIVATE_ACCEPTED",
|
||||
sourceBuildId: config.identity.buildId,
|
||||
targetBuildId: parsed.message.sourceBuildId,
|
||||
nonce,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await scope.skipWaiting();
|
||||
for (const client of clients) {
|
||||
client.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind: "ACTIVATED_RELOAD_REQUIRED",
|
||||
sourceBuildId: config.identity.buildId,
|
||||
targetBuildId: parsed.message.sourceBuildId,
|
||||
nonce,
|
||||
}),
|
||||
);
|
||||
// SW-08. `skipWaiting()` is the activation commit. It must succeed before
|
||||
// any client is told the activation was accepted, and its failure is a
|
||||
// rejection rather than an accepted-then-failed activation.
|
||||
try {
|
||||
await scope.skipWaiting();
|
||||
} catch {
|
||||
notifyClients(clients, "ACTIVATE_REJECTED", parsed.message.sourceBuildId, nonce);
|
||||
return "REJECTED";
|
||||
}
|
||||
// Post-commit notifications are per-client best effort.
|
||||
notifyClients(clients, "ACTIVATE_ACCEPTED", parsed.message.sourceBuildId, nonce);
|
||||
notifyClients(
|
||||
clients,
|
||||
"ACTIVATED_RELOAD_REQUIRED",
|
||||
parsed.message.sourceBuildId,
|
||||
nonce,
|
||||
);
|
||||
return "ACCEPTED";
|
||||
}
|
||||
|
||||
/**
|
||||
* SW-08. One client's `postMessage()` throwing must not break the whole
|
||||
* activation event; delivery is isolated per client.
|
||||
*/
|
||||
function notifyClients(
|
||||
clients: readonly WorkerClientLike[],
|
||||
kind: "ACTIVATE_REJECTED" | "ACTIVATE_ACCEPTED" | "ACTIVATED_RELOAD_REQUIRED",
|
||||
targetBuildId: string,
|
||||
nonce: string,
|
||||
): void {
|
||||
for (const client of clients) {
|
||||
try {
|
||||
client.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind,
|
||||
sourceBuildId: config.identity.buildId,
|
||||
targetBuildId,
|
||||
nonce,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// A dead client cannot change the already committed activation.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function drainClients(
|
||||
clients: readonly WorkerClientLike[],
|
||||
nonce: string,
|
||||
requesterBuildId: string,
|
||||
): Promise<boolean> {
|
||||
if (clients.length === 0) return false;
|
||||
// SW-07. No in-scope client means nothing dirty to drain, so the set is
|
||||
// vacuously drained. A `clients.matchAll()` failure still rejects upstream.
|
||||
if (clients.length === 0) return true;
|
||||
const drained = new Promise<boolean>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
pendingActivations.delete(nonce);
|
||||
@@ -287,15 +356,24 @@ export function createServiceWorkerRuntime(
|
||||
}),
|
||||
);
|
||||
});
|
||||
// SW-08. A client that cannot receive the drain request can never
|
||||
// acknowledge it, so it fails immediately instead of holding the pending
|
||||
// state until the timeout.
|
||||
for (const client of clients) {
|
||||
client.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind: "CLIENT_DRAIN_REQUEST",
|
||||
sourceBuildId: config.identity.buildId,
|
||||
targetBuildId: requesterBuildId,
|
||||
nonce,
|
||||
}),
|
||||
);
|
||||
try {
|
||||
client.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind: "CLIENT_DRAIN_REQUEST",
|
||||
sourceBuildId: config.identity.buildId,
|
||||
targetBuildId: requesterBuildId,
|
||||
nonce,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
const pending = pendingActivations.get(nonce);
|
||||
if (pending) settlePendingActivation(nonce, pending, false);
|
||||
return await drained;
|
||||
}
|
||||
}
|
||||
return drained;
|
||||
}
|
||||
@@ -347,7 +425,9 @@ export function createServiceWorkerRuntime(
|
||||
let cachesDeleted = 0;
|
||||
const names = await scope.caches.keys();
|
||||
for (const name of names) {
|
||||
if (!name.startsWith("ca-static-v1-")) continue;
|
||||
// SW-02. Exact ownership only: a prefix match would also delete
|
||||
// `ca-static-v1-not-owned` and any longer-suffixed foreign cache.
|
||||
if (!isOwnedStaticCacheName(name)) continue;
|
||||
try {
|
||||
if (await scope.caches.delete(name)) cachesDeleted += 1;
|
||||
} catch {
|
||||
@@ -399,6 +479,141 @@ 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;
|
||||
}
|
||||
}
|
||||
// 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) => {
|
||||
try {
|
||||
timer = setTimeout(
|
||||
() => resolve("DEADLINE"),
|
||||
ACTIVATION_MARKER_READ_DEADLINE_MS,
|
||||
);
|
||||
} catch {
|
||||
// An unschedulable deadline leaves the read unbounded, so it ends now.
|
||||
resolve("DEADLINE");
|
||||
}
|
||||
});
|
||||
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.
|
||||
try {
|
||||
void reader.cancel().catch(() => {});
|
||||
} catch {
|
||||
// A hostile reader cannot block the release below.
|
||||
}
|
||||
};
|
||||
try {
|
||||
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,
|
||||
@@ -412,12 +627,21 @@ 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;
|
||||
}
|
||||
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 ||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
SERVICE_WORKER_BOUNDS,
|
||||
type InstalledServiceWorkerSelection,
|
||||
type ServiceWorkerActivationOutcome,
|
||||
type ServiceWorkerRemovalOutcome,
|
||||
type ServiceWorkerResetOutcome,
|
||||
type ServiceWorkerRuntimeHost,
|
||||
type ServiceWorkerStartOutcome,
|
||||
@@ -57,6 +58,9 @@ export function createServiceWorkerPageController(
|
||||
let registration: ServiceWorkerRegistration | null = null;
|
||||
let messageListener: ((event: MessageEvent) => void) | null = null;
|
||||
let updateTimer: ReturnType<typeof setInterval> | null = null;
|
||||
/** SW-06. Single-flight command state. */
|
||||
let activationInFlight: Promise<ServiceWorkerActivationOutcome> | null = null;
|
||||
let resetInFlight: Promise<ServiceWorkerResetOutcome> | null = null;
|
||||
let stopped = false;
|
||||
const pendingStops = new Set<() => void>();
|
||||
|
||||
@@ -75,6 +79,29 @@ export function createServiceWorkerPageController(
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* SW-04. Staged removal reports what actually happened.
|
||||
*
|
||||
* Returning DISABLED for every outcome let a later release delete the worker
|
||||
* source and handlers while a registration or an owned cache was still
|
||||
* present, or while the registration belonged to someone else.
|
||||
*/
|
||||
function removalStartOutcome(
|
||||
outcome: ServiceWorkerRemovalOutcome,
|
||||
failureReason: string,
|
||||
): ServiceWorkerStartOutcome {
|
||||
switch (outcome.kind) {
|
||||
case "ABSENT":
|
||||
case "UNREGISTERED":
|
||||
case "PURGED":
|
||||
return Object.freeze({ kind: "DISABLED" as const });
|
||||
case "OWNERSHIP_MISMATCH":
|
||||
return Object.freeze({ kind: "INCOMPATIBLE" as const });
|
||||
case "FAILED":
|
||||
return failed(failureReason);
|
||||
}
|
||||
}
|
||||
|
||||
async function start(): Promise<ServiceWorkerStartOutcome> {
|
||||
if (stopped) return failed("STOPPED");
|
||||
const container = dependencies.container;
|
||||
@@ -90,8 +117,7 @@ export function createServiceWorkerPageController(
|
||||
origin: dependencies.origin,
|
||||
});
|
||||
observe("disable_cleanup", outcome.kind);
|
||||
if (outcome.kind === "FAILED") return failed("DISABLE_CLEANUP_FAILED");
|
||||
return Object.freeze({ kind: "DISABLED" as const });
|
||||
return removalStartOutcome(outcome, "DISABLE_CLEANUP_FAILED");
|
||||
}
|
||||
|
||||
const selection = dependencies.selection;
|
||||
@@ -108,7 +134,7 @@ export function createServiceWorkerPageController(
|
||||
origin: dependencies.origin,
|
||||
});
|
||||
observe("remove_registration", outcome.kind);
|
||||
return Object.freeze({ kind: "DISABLED" as const });
|
||||
return removalStartOutcome(outcome, "REMOVE_FAILED");
|
||||
}
|
||||
if (selection.mode === "PURGE_OWNED_RESOURCES") {
|
||||
const outcome = await purgeOwnedResources({
|
||||
@@ -118,7 +144,7 @@ export function createServiceWorkerPageController(
|
||||
origin: dependencies.origin,
|
||||
});
|
||||
observe("purge_owned_resources", outcome.kind);
|
||||
return Object.freeze({ kind: "DISABLED" as const });
|
||||
return removalStartOutcome(outcome, "PURGE_FAILED");
|
||||
}
|
||||
|
||||
// §17.5. StrictMode's repeated effect returns the same in-flight promise
|
||||
@@ -194,6 +220,13 @@ export function createServiceWorkerPageController(
|
||||
observe("client_drain", "MALFORMED");
|
||||
return;
|
||||
}
|
||||
// SW-06. An arbitrary same-origin source must not be able to close this
|
||||
// page's admission. The request has to come from the worker we are
|
||||
// actually waiting on or the one currently controlling us.
|
||||
if (!isExpectedWorkerSource(source)) {
|
||||
observe("client_drain", "SOURCE_MISMATCH");
|
||||
return;
|
||||
}
|
||||
const rejected = isBlocked();
|
||||
source.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
@@ -211,6 +244,23 @@ export function createServiceWorkerPageController(
|
||||
container.addEventListener("message", messageListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* SW-06. Source identity is checked by object identity against the
|
||||
* registration's waiting/installing/active worker and the container's
|
||||
* controller. An empty `event.origin` is never used as a trust signal.
|
||||
*/
|
||||
function isExpectedWorkerSource(source: unknown): boolean {
|
||||
const expected = [
|
||||
registration?.waiting,
|
||||
registration?.installing,
|
||||
registration?.active,
|
||||
dependencies.container?.controller,
|
||||
];
|
||||
return expected.some(
|
||||
(candidate) => candidate != null && candidate === source,
|
||||
);
|
||||
}
|
||||
|
||||
function scheduleUpdateChecks(): void {
|
||||
// §17.14. At most one check per 6 hours, and none while the page is hidden.
|
||||
if (updateTimer) return;
|
||||
@@ -228,7 +278,16 @@ export function createServiceWorkerPageController(
|
||||
* §17.11. Activation is a handshake: every controlled client must close new
|
||||
* admission and acknowledge within 30s. One missing client rejects it.
|
||||
*/
|
||||
async function requestActivation(): Promise<ServiceWorkerActivationOutcome> {
|
||||
function requestActivation(): Promise<ServiceWorkerActivationOutcome> {
|
||||
// SW-06. Concurrent callers share one command: a second call must not issue
|
||||
// a second nonce, a second listener or a second postMessage.
|
||||
activationInFlight ??= runActivation().finally(() => {
|
||||
activationInFlight = null;
|
||||
});
|
||||
return activationInFlight;
|
||||
}
|
||||
|
||||
async function runActivation(): Promise<ServiceWorkerActivationOutcome> {
|
||||
const waiting = registration?.waiting;
|
||||
if (!waiting) return Object.freeze({ kind: "NO_WAITING_WORKER" as const });
|
||||
if (isBlocked()) {
|
||||
@@ -264,6 +323,18 @@ export function createServiceWorkerPageController(
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
if (registration?.waiting !== waiting) {
|
||||
finish(Object.freeze({ kind: "PROTOCOL_MISMATCH" as const }));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
parsed.message.kind === "ACTIVATE_REJECTED" &&
|
||||
parsed.message.nonce === nonce
|
||||
@@ -306,11 +377,21 @@ export function createServiceWorkerPageController(
|
||||
}
|
||||
|
||||
/** §18.10. Static caches only; the registration itself is left in place. */
|
||||
async function resetOwnedCaches(): Promise<ServiceWorkerResetOutcome> {
|
||||
function resetOwnedCaches(): Promise<ServiceWorkerResetOutcome> {
|
||||
// SW-06. Single-flight, like activation.
|
||||
resetInFlight ??= runReset().finally(() => {
|
||||
resetInFlight = null;
|
||||
});
|
||||
return resetInFlight;
|
||||
}
|
||||
|
||||
async function runReset(): Promise<ServiceWorkerResetOutcome> {
|
||||
const container = dependencies.container;
|
||||
if (!container?.controller) {
|
||||
return Object.freeze({ kind: "NOT_CONTROLLED" as const });
|
||||
}
|
||||
// SW-06. The reply must come from the controller this request was sent to.
|
||||
const requestedController = container.controller;
|
||||
const nonce = nonces.issue();
|
||||
return new Promise<ServiceWorkerResetOutcome>((resolve) => {
|
||||
let settled = false;
|
||||
@@ -334,6 +415,20 @@ export function createServiceWorkerPageController(
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// SW-RR-02. An unattributable reset result is never proof this
|
||||
// controller performed the reset.
|
||||
if (
|
||||
event.source !== requestedController ||
|
||||
container.controller !== requestedController
|
||||
) {
|
||||
finish(
|
||||
Object.freeze({
|
||||
kind: "FAILED" as const,
|
||||
code: "PROTOCOL_MISMATCH",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
finish(
|
||||
Object.freeze({
|
||||
kind: "RESET" as const,
|
||||
|
||||
@@ -109,14 +109,24 @@ export async function removeOwnedRegistration(
|
||||
) {
|
||||
return Object.freeze({ kind: "OWNERSHIP_MISMATCH" as const });
|
||||
}
|
||||
let unregistered: boolean;
|
||||
try {
|
||||
await registration.unregister();
|
||||
unregistered = await registration.unregister();
|
||||
} catch {
|
||||
return Object.freeze({
|
||||
kind: "FAILED" as const,
|
||||
operation: "UNREGISTER" as const,
|
||||
});
|
||||
}
|
||||
// SW-03. `unregister()` resolving is not success: `false` means the
|
||||
// registration is still installed, so reporting UNREGISTERED would let a
|
||||
// later release delete the worker source while it is still controlling.
|
||||
if (!unregistered) {
|
||||
return Object.freeze({
|
||||
kind: "FAILED" as const,
|
||||
operation: "UNREGISTER" as const,
|
||||
});
|
||||
}
|
||||
return Object.freeze({ kind: "UNREGISTERED" as const });
|
||||
}
|
||||
|
||||
|
||||
@@ -149,6 +149,19 @@ export async function installStaticAssets(
|
||||
|
||||
if (outcome.kind === "REJECTED") {
|
||||
await dependencies.caches.delete(cacheName).catch(() => false);
|
||||
// SW-09. A non-cooperative fetch, digest or `cache.put` started before the
|
||||
// deadline cannot be cancelled, so it may recreate the candidate cache
|
||||
// after that delete. The public result already closed at the deadline; a
|
||||
// second exact delete is registered once the abandoned work settles. It is
|
||||
// deliberately not awaited, so the public bound is not extended.
|
||||
if (deadlineExceeded) {
|
||||
void installation
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
await dependencies.caches.delete(cacheName).catch(() => false);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
@@ -173,6 +186,11 @@ async function installCandidate(
|
||||
const worker = async (): Promise<void> => {
|
||||
for (;;) {
|
||||
if (failure) return;
|
||||
// SW-09. Once fenced, no new candidate work is started.
|
||||
if (signal.aborted) {
|
||||
failure ??= rejected("INSTALL_DEADLINE_EXCEEDED");
|
||||
return;
|
||||
}
|
||||
const asset = queue.shift();
|
||||
if (!asset) return;
|
||||
const outcome = await storeAsset(asset, cache, dependencies, signal);
|
||||
@@ -205,15 +223,22 @@ async function storeAsset(
|
||||
if (signal.aborted) return rejected("FETCH_FAILED");
|
||||
let response: Response;
|
||||
try {
|
||||
const fetched = await abortable(
|
||||
dependencies.fetcher(asset.url, {
|
||||
cache: "no-store",
|
||||
credentials: "omit",
|
||||
redirect: "error",
|
||||
signal,
|
||||
}),
|
||||
// SW-09. A non-cooperative fetch that ignores the signal still settles
|
||||
// later; its body is compensated so an abandoned response is not left open.
|
||||
const pending = dependencies.fetcher(asset.url, {
|
||||
cache: "no-store",
|
||||
credentials: "omit",
|
||||
redirect: "error",
|
||||
signal,
|
||||
);
|
||||
});
|
||||
const fetched = await abortable(pending, signal);
|
||||
if (fetched === ABORTED) {
|
||||
void pending
|
||||
.then(async (late) => {
|
||||
await late.body?.cancel();
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
if (fetched === ABORTED) return rejected("FETCH_FAILED");
|
||||
response = fetched;
|
||||
} catch {
|
||||
@@ -234,7 +259,17 @@ async function storeAsset(
|
||||
if (!body.ok) return rejected(body.code);
|
||||
const bytes = body.bytes;
|
||||
|
||||
const digest = await abortable(dependencies.digest(bytes), signal);
|
||||
// SW-09. A digest dependency that throws becomes a closed typed outcome
|
||||
// rather than an escaping rejection.
|
||||
let digest: string | typeof ABORTED;
|
||||
try {
|
||||
digest = await abortable(
|
||||
Promise.resolve(dependencies.digest(bytes)),
|
||||
signal,
|
||||
);
|
||||
} catch {
|
||||
return rejected("INTEGRITY_MISMATCH");
|
||||
}
|
||||
if (digest === ABORTED) return rejected("FETCH_FAILED");
|
||||
if (digest !== asset.sha256) return rejected("INTEGRITY_MISMATCH");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user