fix: make Service Worker cache and removal outcomes truthful
SW-URL-01: canonicalize each generated root-relative manifest URL against the registration scope once, re-check same-origin, and share that absolute identity across install cache keys, fetch classification and cache lookup or delete. Previously every verified asset fell through to the network. SW-01: serve verified static requests only from the current release cache. A CacheStorage-wide match could return a previous release's response for the same URL while the delete targeted a cache that was never read. The worker scope facade no longer exposes a wide match at all. SW-02: cache reset deletes only names that parse as owned, so a foreign cache sharing the ca-static-v1- prefix survives. SW-03: unregister() resolving to false is a FAILED unregister, not UNREGISTERED. SW-04: staged removal reports what happened - ABSENT, UNREGISTERED and PURGED map to DISABLED, OWNERSHIP_MISMATCH to INCOMPATIBLE and FAILED to FAILED - so a later release cannot delete the worker while a registration or owned cache is still present. SW-05: add the runtime-neutral service-worker-static-manifest codec that owns exact row keys, the extension and content-type allowlist, the root-relative URL rule and the length-prefixed canonical bytes. The generator and the build gate hash those same bytes, and the build gate now decodes and recomputes the set digest instead of type-casting the manifest. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
cc4e875c2d
commit
58efe6ddbd
@@ -138,12 +138,12 @@ Rollout state starts at `NOT_STARTED`; documented-unimplemented items start at
|
|||||||
|
|
||||||
| ID | Activation | Red test command | Fix commit/PR | Rollout state | Rollback trigger | Evidence |
|
| ID | Activation | Red test command | Fix commit/PR | Rollout state | Rollback trigger | Evidence |
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
| SW-URL-01 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | — | `NOT_STARTED` | static asset cache miss rate | — |
|
| SW-URL-01 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | `fix: make Service Worker cache and removal outcomes truthful` | `FIXED_NOT_RELEASED` | static asset cache miss rate | Red generator-shaped root-relative asset vs absolute Request URL → green; manifest URLs canonicalized once against the registration scope |
|
||||||
| SW-01 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | — | `NOT_STARTED` | stale response served | — |
|
| SW-01 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | `fix: make Service Worker cache and removal outcomes truthful` | `FIXED_NOT_RELEASED` | stale response served | Red previous-cache hit → green network fallback; only the current release cache is opened, matched and deleted from |
|
||||||
| SW-02 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | — | `NOT_STARTED` | foreign cache deletion | — |
|
| SW-02 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | `fix: make Service Worker cache and removal outcomes truthful` | `FIXED_NOT_RELEASED` | foreign cache deletion | Red prefix deletion of `ca-static-v1-not-owned` and longer suffixes → green exact `isOwnedStaticCacheName` only |
|
||||||
| SW-03 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | — | `NOT_STARTED` | false removal success | — |
|
| SW-03 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | `fix: make Service Worker cache and removal outcomes truthful` | `FIXED_NOT_RELEASED` | false removal success | Red `unregister() === false` reported as UNREGISTERED → green FAILED |
|
||||||
| SW-04 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | — | `NOT_STARTED` | removal outcome misreport | — |
|
| SW-04 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | `fix: make Service Worker cache and removal outcomes truthful` | `FIXED_NOT_RELEASED` | removal outcome misreport | Red removal modes always DISABLED → green outcome matrix (ABSENT/UNREGISTERED/PURGED→DISABLED, OWNERSHIP_MISMATCH→INCOMPATIBLE, FAILED→FAILED) |
|
||||||
| SW-05 | Build gate | `corepack pnpm exec vitest run tests/unit/service-worker-build-input.test.ts` | — | `NOT_STARTED` | build admission rejection | — |
|
| SW-05 | Build gate | `corepack pnpm exec vitest run tests/unit/service-worker-build-input.test.ts` | `fix: make Service Worker cache and removal outcomes truthful` | `FIXED_NOT_RELEASED` | build admission rejection | Red tamper table (stale digest, byte length, cross-origin URL, dot segment, extension mismatch, unknown field, duplicate URL) → green; build gate decodes through the shared codec and recomputes the canonical digest |
|
||||||
| SW-06 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | — | `NOT_STARTED` | activation handshake failure | — |
|
| SW-06 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | — | `NOT_STARTED` | activation handshake failure | — |
|
||||||
| SW-07 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | — | `NOT_STARTED` | activation blocked with zero clients | — |
|
| SW-07 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | — | `NOT_STARTED` | activation blocked with zero clients | — |
|
||||||
| SW-08 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | — | `NOT_STARTED` | per-client failure escalation | — |
|
| SW-08 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | — | `NOT_STARTED` | per-client failure escalation | — |
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
import { 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";
|
||||||
|
|
||||||
@@ -74,32 +76,21 @@ export async function collectStaticAssets(
|
|||||||
throw new Error("Static asset set exceeds its byte bound.");
|
throw new Error("Static asset set exceeds its byte bound.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// The set digest is a length-prefixed hash over the sorted asset identities,
|
// SW-05. The canonical byte serialization lives in the shared runtime-neutral
|
||||||
// so a reordered directory listing cannot change it.
|
// codec so the worker can recompute the identical digest with WebCrypto.
|
||||||
const hash = createHash("sha256");
|
const setDigest: `sha256:${string}` = `sha256:${createHash("sha256")
|
||||||
hash.update("CA_STATIC_ASSET_SET_V1\0");
|
.update(canonicalStaticManifestBytes(assets))
|
||||||
for (const asset of assets) {
|
.digest("hex")}`;
|
||||||
hash.update(lengthPrefixed(asset.url));
|
|
||||||
hash.update(lengthPrefixed(asset.sha256));
|
|
||||||
hash.update(lengthPrefixed(String(asset.bytes)));
|
|
||||||
hash.update(lengthPrefixed(asset.contentType));
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
buildId,
|
buildId,
|
||||||
releaseId,
|
releaseId,
|
||||||
setDigest: `sha256:${hash.digest("hex")}`,
|
setDigest,
|
||||||
assets,
|
assets,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function lengthPrefixed(value: string): Buffer {
|
|
||||||
const bytes = Buffer.from(value, "utf8");
|
|
||||||
const prefix = Buffer.alloc(4);
|
|
||||||
prefix.writeUInt32BE(bytes.byteLength, 0);
|
|
||||||
return Buffer.concat([prefix, bytes]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function walk(root: string, current: string): Promise<string[]> {
|
async function walk(root: string, current: string): Promise<string[]> {
|
||||||
const entries = await readdir(current, { withFileTypes: true });
|
const entries = await readdir(current, { withFileTypes: true });
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
import {
|
||||||
|
canonicalStaticManifestBytes,
|
||||||
|
decodeStaticAssetManifest,
|
||||||
|
} from "../../src/contracts/service-worker-static-manifest.ts";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
InstalledServiceWorkerSelection,
|
InstalledServiceWorkerSelection,
|
||||||
ServiceWorkerHandlerId,
|
ServiceWorkerHandlerId,
|
||||||
@@ -79,19 +86,28 @@ export function resolveServiceWorkerBuildInput(input: Readonly<{
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SW-05. The build gate no longer type-casts the manifest. It decodes every row
|
||||||
|
* through the shared runtime-neutral codec and recomputes the set digest from
|
||||||
|
* the same canonical bytes the generator hashed, so a tampered row, a reordered
|
||||||
|
* set or a stale digest fails admission instead of shipping.
|
||||||
|
*/
|
||||||
function parseAssets(value: unknown): StaticAssetManifestV1 {
|
function parseAssets(value: unknown): StaticAssetManifestV1 {
|
||||||
const candidate = record(value);
|
const decoded = decodeStaticAssetManifest(value);
|
||||||
if (
|
if (!decoded.ok) {
|
||||||
candidate?.schemaVersion !== 1 ||
|
throw new TypeError(
|
||||||
typeof candidate.buildId !== "string" ||
|
`Generated Service Worker asset manifest is invalid: ${decoded.error.reason}`,
|
||||||
typeof candidate.releaseId !== "string" ||
|
);
|
||||||
typeof candidate.setDigest !== "string" ||
|
|
||||||
!DIGEST.test(candidate.setDigest) ||
|
|
||||||
!Array.isArray(candidate.assets)
|
|
||||||
) {
|
|
||||||
throw new TypeError("Generated Service Worker asset manifest is invalid.");
|
|
||||||
}
|
}
|
||||||
return candidate as unknown as StaticAssetManifestV1;
|
const expected = `sha256:${createHash("sha256")
|
||||||
|
.update(canonicalStaticManifestBytes(decoded.manifest.assets))
|
||||||
|
.digest("hex")}`;
|
||||||
|
if (expected !== decoded.manifest.setDigest) {
|
||||||
|
throw new TypeError(
|
||||||
|
"Generated Service Worker asset manifest set digest does not match its assets.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return decoded.manifest as unknown as StaticAssetManifestV1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function record(value: unknown): Record<string, unknown> | null {
|
function record(value: unknown): Record<string, unknown> | null {
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ const scope: WorkerScopeLike = {
|
|||||||
open: (name) => caches.open(name),
|
open: (name) => caches.open(name),
|
||||||
keys: () => caches.keys(),
|
keys: () => caches.keys(),
|
||||||
delete: (name) => caches.delete(name),
|
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: {
|
clients: {
|
||||||
matchAll: (options) =>
|
matchAll: (options) =>
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ export type WorkerScopeLike = Readonly<{
|
|||||||
open(cacheName: string): Promise<Cache>;
|
open(cacheName: string): Promise<Cache>;
|
||||||
keys(): Promise<readonly string[]>;
|
keys(): Promise<readonly string[]>;
|
||||||
delete(cacheName: string): Promise<boolean>;
|
delete(cacheName: string): Promise<boolean>;
|
||||||
match(request: string): Promise<Response | undefined>;
|
|
||||||
}>;
|
}>;
|
||||||
clients: Readonly<{
|
clients: Readonly<{
|
||||||
matchAll(
|
matchAll(
|
||||||
@@ -59,6 +58,34 @@ export type WorkerRuntimeConfig = Readonly<{
|
|||||||
releaseManifestUrl: string;
|
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 =
|
const ACTIVATION_MARKER_URL =
|
||||||
"https://clean-architecture.invalid/__service-worker-activation-v1__";
|
"https://clean-architecture.invalid/__service-worker-activation-v1__";
|
||||||
const ACTIVATION_MARKER_MAX_BYTES = 256;
|
const ACTIVATION_MARKER_MAX_BYTES = 256;
|
||||||
@@ -73,8 +100,22 @@ export function createServiceWorkerRuntime(
|
|||||||
config: WorkerRuntimeConfig,
|
config: WorkerRuntimeConfig,
|
||||||
) {
|
) {
|
||||||
const staticEnabled = config.handlers.includes("PWA_STATIC_ASSETS");
|
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>();
|
const consumedNonces = new Set<string>();
|
||||||
type PendingActivation = Readonly<{
|
type PendingActivation = Readonly<{
|
||||||
@@ -176,17 +217,25 @@ export function createServiceWorkerRuntime(
|
|||||||
});
|
});
|
||||||
if (classification !== "VERIFIED_CACHE_FIRST") return null;
|
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
|
||||||
if (!cached) return null;
|
// match could return a previous release's response for the same URL, and
|
||||||
if (cached.status !== 200 || cached.type === "opaque") {
|
// the subsequent delete would then target a cache that was never read.
|
||||||
// §18.6. An invalid hit is deleted and treated as a release mismatch.
|
const currentCacheName = config.manifest
|
||||||
const current = config.manifest
|
|
||||||
? staticCacheName(config.manifest.setDigest)
|
? staticCacheName(config.manifest.setDigest)
|
||||||
: null;
|
: null;
|
||||||
if (current) {
|
if (!currentCacheName) return null;
|
||||||
const cache = await scope.caches.open(current);
|
let currentCache: Cache;
|
||||||
await cache.delete(request.url).catch(() => false);
|
try {
|
||||||
|
currentCache = await scope.caches.open(currentCacheName);
|
||||||
|
} 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
|
||||||
|
// treated as a release mismatch.
|
||||||
|
await currentCache.delete(request.url).catch(() => false);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return cached;
|
return cached;
|
||||||
@@ -347,7 +396,9 @@ export function createServiceWorkerRuntime(
|
|||||||
let cachesDeleted = 0;
|
let cachesDeleted = 0;
|
||||||
const names = await scope.caches.keys();
|
const names = await scope.caches.keys();
|
||||||
for (const name of names) {
|
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 {
|
try {
|
||||||
if (await scope.caches.delete(name)) cachesDeleted += 1;
|
if (await scope.caches.delete(name)) cachesDeleted += 1;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
SERVICE_WORKER_BOUNDS,
|
SERVICE_WORKER_BOUNDS,
|
||||||
type InstalledServiceWorkerSelection,
|
type InstalledServiceWorkerSelection,
|
||||||
type ServiceWorkerActivationOutcome,
|
type ServiceWorkerActivationOutcome,
|
||||||
|
type ServiceWorkerRemovalOutcome,
|
||||||
type ServiceWorkerResetOutcome,
|
type ServiceWorkerResetOutcome,
|
||||||
type ServiceWorkerRuntimeHost,
|
type ServiceWorkerRuntimeHost,
|
||||||
type ServiceWorkerStartOutcome,
|
type ServiceWorkerStartOutcome,
|
||||||
@@ -75,6 +76,29 @@ export function createServiceWorkerPageController(
|
|||||||
return false;
|
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> {
|
async function start(): Promise<ServiceWorkerStartOutcome> {
|
||||||
if (stopped) return failed("STOPPED");
|
if (stopped) return failed("STOPPED");
|
||||||
const container = dependencies.container;
|
const container = dependencies.container;
|
||||||
@@ -90,8 +114,7 @@ export function createServiceWorkerPageController(
|
|||||||
origin: dependencies.origin,
|
origin: dependencies.origin,
|
||||||
});
|
});
|
||||||
observe("disable_cleanup", outcome.kind);
|
observe("disable_cleanup", outcome.kind);
|
||||||
if (outcome.kind === "FAILED") return failed("DISABLE_CLEANUP_FAILED");
|
return removalStartOutcome(outcome, "DISABLE_CLEANUP_FAILED");
|
||||||
return Object.freeze({ kind: "DISABLED" as const });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const selection = dependencies.selection;
|
const selection = dependencies.selection;
|
||||||
@@ -108,7 +131,7 @@ export function createServiceWorkerPageController(
|
|||||||
origin: dependencies.origin,
|
origin: dependencies.origin,
|
||||||
});
|
});
|
||||||
observe("remove_registration", outcome.kind);
|
observe("remove_registration", outcome.kind);
|
||||||
return Object.freeze({ kind: "DISABLED" as const });
|
return removalStartOutcome(outcome, "REMOVE_FAILED");
|
||||||
}
|
}
|
||||||
if (selection.mode === "PURGE_OWNED_RESOURCES") {
|
if (selection.mode === "PURGE_OWNED_RESOURCES") {
|
||||||
const outcome = await purgeOwnedResources({
|
const outcome = await purgeOwnedResources({
|
||||||
@@ -118,7 +141,7 @@ export function createServiceWorkerPageController(
|
|||||||
origin: dependencies.origin,
|
origin: dependencies.origin,
|
||||||
});
|
});
|
||||||
observe("purge_owned_resources", outcome.kind);
|
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
|
// §17.5. StrictMode's repeated effect returns the same in-flight promise
|
||||||
|
|||||||
@@ -109,14 +109,24 @@ export async function removeOwnedRegistration(
|
|||||||
) {
|
) {
|
||||||
return Object.freeze({ kind: "OWNERSHIP_MISMATCH" as const });
|
return Object.freeze({ kind: "OWNERSHIP_MISMATCH" as const });
|
||||||
}
|
}
|
||||||
|
let unregistered: boolean;
|
||||||
try {
|
try {
|
||||||
await registration.unregister();
|
unregistered = await registration.unregister();
|
||||||
} catch {
|
} catch {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
kind: "FAILED" as const,
|
kind: "FAILED" as const,
|
||||||
operation: "UNREGISTER" 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 });
|
return Object.freeze({ kind: "UNREGISTERED" as const });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { SERVICE_WORKER_BOUNDS } from "./service-worker.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SW-05. Runtime-neutral static manifest codec.
|
||||||
|
*
|
||||||
|
* The generator, the Node build gate and the Service Worker all need the same
|
||||||
|
* answer to "is this manifest exactly the one that was generated?". This module
|
||||||
|
* owns the exact row keys, the content-type and extension allowlist, the
|
||||||
|
* root-relative URL rule and the length-prefixed canonical byte serialization.
|
||||||
|
*
|
||||||
|
* It deliberately contains no digest implementation: the generator and build
|
||||||
|
* gate hash these bytes with Node SHA-256 while the worker hashes the very same
|
||||||
|
* bytes with injected WebCrypto, so `node:crypto` never reaches worker code and
|
||||||
|
* the algorithm is never written twice.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type StaticAssetRow = Readonly<{
|
||||||
|
url: string;
|
||||||
|
sha256: string;
|
||||||
|
bytes: number;
|
||||||
|
contentType: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type StaticAssetManifest = Readonly<{
|
||||||
|
schemaVersion: 1;
|
||||||
|
buildId: string;
|
||||||
|
releaseId: string;
|
||||||
|
setDigest: string;
|
||||||
|
assets: readonly StaticAssetRow[];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export const STATIC_ASSET_SET_DOMAIN = "CA_STATIC_ASSET_SET_V1";
|
||||||
|
|
||||||
|
/** Exact allowed extension → content type pairs for a cacheable asset. */
|
||||||
|
export const CACHEABLE_ASSET_CONTENT_TYPES: Readonly<
|
||||||
|
Record<string, string>
|
||||||
|
> = Object.freeze({
|
||||||
|
".css": "text/css",
|
||||||
|
".js": "text/javascript",
|
||||||
|
".json": "application/json",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".woff2": "font/woff2",
|
||||||
|
});
|
||||||
|
|
||||||
|
const MANIFEST_KEYS = Object.freeze([
|
||||||
|
"assets",
|
||||||
|
"buildId",
|
||||||
|
"releaseId",
|
||||||
|
"schemaVersion",
|
||||||
|
"setDigest",
|
||||||
|
] as const);
|
||||||
|
const ASSET_ROW_KEYS = Object.freeze([
|
||||||
|
"bytes",
|
||||||
|
"contentType",
|
||||||
|
"sha256",
|
||||||
|
"url",
|
||||||
|
] as const);
|
||||||
|
const DIGEST = /^sha256:[0-9a-f]{64}$/u;
|
||||||
|
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;
|
||||||
|
|
||||||
|
export type StaticManifestDecodeFailure = Readonly<{
|
||||||
|
reason: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type StaticManifestDecodeResult =
|
||||||
|
| Readonly<{ ok: true; manifest: StaticAssetManifest }>
|
||||||
|
| Readonly<{ ok: false; error: StaticManifestDecodeFailure }>;
|
||||||
|
|
||||||
|
function exactKeys(
|
||||||
|
value: unknown,
|
||||||
|
allowed: readonly string[],
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
if (Object.getOwnPropertySymbols(record).length > 0) return null;
|
||||||
|
const keys = Object.keys(record).sort();
|
||||||
|
return keys.length === allowed.length &&
|
||||||
|
keys.every((key, index) => key === allowed[index])
|
||||||
|
? record
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extensionOf(url: string): string {
|
||||||
|
const lastSlash = url.lastIndexOf("/");
|
||||||
|
const base = url.slice(lastSlash + 1);
|
||||||
|
const dot = base.lastIndexOf(".");
|
||||||
|
return dot < 0 ? "" : base.slice(dot).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes a generated manifest with every row rule applied. It does not verify
|
||||||
|
* `setDigest`; callers pair it with their own digest implementation over
|
||||||
|
* `canonicalStaticManifestBytes`.
|
||||||
|
*/
|
||||||
|
export function decodeStaticAssetManifest(
|
||||||
|
value: unknown,
|
||||||
|
): StaticManifestDecodeResult {
|
||||||
|
const record = exactKeys(value, MANIFEST_KEYS);
|
||||||
|
if (!record) return failure("manifest keys are not exact");
|
||||||
|
if (record.schemaVersion !== 1) return failure("schemaVersion must be 1");
|
||||||
|
if (
|
||||||
|
typeof record.buildId !== "string" ||
|
||||||
|
!IDENTITY.test(record.buildId) ||
|
||||||
|
typeof record.releaseId !== "string" ||
|
||||||
|
!IDENTITY.test(record.releaseId)
|
||||||
|
) {
|
||||||
|
return failure("buildId or releaseId is invalid");
|
||||||
|
}
|
||||||
|
if (typeof record.setDigest !== "string" || !DIGEST.test(record.setDigest)) {
|
||||||
|
return failure("setDigest is not a lower-hex sha256");
|
||||||
|
}
|
||||||
|
if (!Array.isArray(record.assets)) return failure("assets must be an array");
|
||||||
|
if (record.assets.length > SERVICE_WORKER_BOUNDS.assets) {
|
||||||
|
return failure("asset count exceeds its bound");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: StaticAssetRow[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let totalBytes = 0;
|
||||||
|
let previousUrl: string | null = null;
|
||||||
|
for (const candidate of record.assets) {
|
||||||
|
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)) {
|
||||||
|
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) {
|
||||||
|
return failure("asset urls must be sorted");
|
||||||
|
}
|
||||||
|
if (typeof sha256 !== "string" || !DIGEST.test(sha256)) {
|
||||||
|
return failure("asset sha256 is not a lower-hex sha256");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof bytes !== "number" ||
|
||||||
|
!Number.isSafeInteger(bytes) ||
|
||||||
|
bytes < 0 ||
|
||||||
|
bytes > SERVICE_WORKER_BOUNDS.singleAssetBytes
|
||||||
|
) {
|
||||||
|
return failure("asset byte length is invalid");
|
||||||
|
}
|
||||||
|
if (typeof contentType !== "string") {
|
||||||
|
return failure("asset content type is invalid");
|
||||||
|
}
|
||||||
|
const expectedContentType =
|
||||||
|
CACHEABLE_ASSET_CONTENT_TYPES[extensionOf(url)];
|
||||||
|
if (!expectedContentType || expectedContentType !== contentType) {
|
||||||
|
return failure("asset extension and content type do not match");
|
||||||
|
}
|
||||||
|
totalBytes += bytes;
|
||||||
|
if (totalBytes > SERVICE_WORKER_BOUNDS.assetSetBytes) {
|
||||||
|
return failure("asset set exceeds its byte bound");
|
||||||
|
}
|
||||||
|
seen.add(url);
|
||||||
|
previousUrl = url;
|
||||||
|
rows.push(Object.freeze({ url, sha256, bytes, contentType }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
ok: true as const,
|
||||||
|
manifest: Object.freeze({
|
||||||
|
schemaVersion: 1 as const,
|
||||||
|
buildId: record.buildId,
|
||||||
|
releaseId: record.releaseId,
|
||||||
|
setDigest: record.setDigest,
|
||||||
|
assets: Object.freeze(rows),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The exact bytes both the Node generator and the worker hash. A reordered
|
||||||
|
* directory listing, a renamed field or a changed byte length all change these
|
||||||
|
* bytes; nothing else does.
|
||||||
|
*/
|
||||||
|
export function canonicalStaticManifestBytes(
|
||||||
|
assets: readonly StaticAssetRow[],
|
||||||
|
): Uint8Array {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const parts: Uint8Array[] = [encoder.encode(`${STATIC_ASSET_SET_DOMAIN}\0`)];
|
||||||
|
for (const asset of assets) {
|
||||||
|
parts.push(lengthPrefixed(encoder, asset.url));
|
||||||
|
parts.push(lengthPrefixed(encoder, asset.sha256));
|
||||||
|
parts.push(lengthPrefixed(encoder, String(asset.bytes)));
|
||||||
|
parts.push(lengthPrefixed(encoder, asset.contentType));
|
||||||
|
}
|
||||||
|
let total = 0;
|
||||||
|
for (const part of parts) total += part.byteLength;
|
||||||
|
const bytes = new Uint8Array(total);
|
||||||
|
let offset = 0;
|
||||||
|
for (const part of parts) {
|
||||||
|
bytes.set(part, offset);
|
||||||
|
offset += part.byteLength;
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lengthPrefixed(encoder: TextEncoder, value: string): Uint8Array {
|
||||||
|
const encoded = encoder.encode(value);
|
||||||
|
const prefix = encoder.encode(`${encoded.byteLength}:`);
|
||||||
|
const combined = new Uint8Array(prefix.byteLength + encoded.byteLength);
|
||||||
|
combined.set(prefix, 0);
|
||||||
|
combined.set(encoded, prefix.byteLength);
|
||||||
|
return combined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function failure(reason: string): StaticManifestDecodeResult {
|
||||||
|
return Object.freeze({
|
||||||
|
ok: false as const,
|
||||||
|
error: Object.freeze({ reason }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,9 +1,31 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { resolveServiceWorkerBuildInput } from "../../scripts/lib/service-worker-build-input.ts";
|
import { resolveServiceWorkerBuildInput } from "../../scripts/lib/service-worker-build-input.ts";
|
||||||
|
import {
|
||||||
|
canonicalStaticManifestBytes,
|
||||||
|
type StaticAssetRow,
|
||||||
|
} from "../../src/contracts/service-worker-static-manifest.ts";
|
||||||
|
|
||||||
const digest = (character: string) => `sha256:${character.repeat(64)}`;
|
const digest = (character: string) => `sha256:${character.repeat(64)}`;
|
||||||
|
|
||||||
|
/** SW-05. The gate recomputes this from the shared canonical bytes. */
|
||||||
|
function setDigestFor(rows: readonly StaticAssetRow[]): string {
|
||||||
|
return `sha256:${createHash("sha256")
|
||||||
|
.update(canonicalStaticManifestBytes(rows))
|
||||||
|
.digest("hex")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ASSET_ROWS: readonly StaticAssetRow[] = Object.freeze([
|
||||||
|
Object.freeze({
|
||||||
|
url: "/assets/app.0123456789abcdef.js",
|
||||||
|
sha256: digest("c"),
|
||||||
|
bytes: 128,
|
||||||
|
contentType: "text/javascript",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
describe("service worker build input", () => {
|
describe("service worker build input", () => {
|
||||||
const selection = {
|
const selection = {
|
||||||
mode: "ACTIVE" as const,
|
mode: "ACTIVE" as const,
|
||||||
@@ -14,8 +36,8 @@ describe("service worker build input", () => {
|
|||||||
schemaVersion: 1 as const,
|
schemaVersion: 1 as const,
|
||||||
buildId: "build-1",
|
buildId: "build-1",
|
||||||
releaseId: "release-1",
|
releaseId: "release-1",
|
||||||
setDigest: digest("a"),
|
setDigest: setDigestFor(ASSET_ROWS),
|
||||||
assets: [],
|
assets: [...ASSET_ROWS],
|
||||||
};
|
};
|
||||||
|
|
||||||
it("rejects a direct worker build when ACTIVE selection or generated inputs are absent", () => {
|
it("rejects a direct worker build when ACTIVE selection or generated inputs are absent", () => {
|
||||||
@@ -84,4 +106,90 @@ describe("service worker build input", () => {
|
|||||||
releaseManifestUrl: "/release-manifest.json",
|
releaseManifestUrl: "/release-manifest.json",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects asset row or canonical set-digest tampering at build input", () => {
|
||||||
|
const base = {
|
||||||
|
selection,
|
||||||
|
contractSet: { setDigest: digest("b") },
|
||||||
|
runtimeConfig: { RELEASE_MANIFEST_URL: "/release-manifest.json" },
|
||||||
|
buildId: "build-1",
|
||||||
|
releaseId: "release-1",
|
||||||
|
};
|
||||||
|
const tampered: readonly Readonly<{
|
||||||
|
label: string;
|
||||||
|
assets: unknown;
|
||||||
|
}>[] = [
|
||||||
|
{
|
||||||
|
label: "stale set digest",
|
||||||
|
assets: { ...assets, setDigest: digest("a") },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "tampered byte length",
|
||||||
|
assets: {
|
||||||
|
...assets,
|
||||||
|
assets: [{ ...ASSET_ROWS[0]!, bytes: 129 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "cross-origin url",
|
||||||
|
assets: {
|
||||||
|
...assets,
|
||||||
|
assets: [
|
||||||
|
{ ...ASSET_ROWS[0]!, url: "https://evil.example/a.js" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "dot segment",
|
||||||
|
assets: {
|
||||||
|
...assets,
|
||||||
|
assets: [{ ...ASSET_ROWS[0]!, url: "/assets/../a.js" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "extension and content type mismatch",
|
||||||
|
assets: {
|
||||||
|
...assets,
|
||||||
|
assets: [{ ...ASSET_ROWS[0]!, contentType: "text/css" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "unknown row field",
|
||||||
|
assets: {
|
||||||
|
...assets,
|
||||||
|
assets: [{ ...ASSET_ROWS[0]!, extra: "smuggled" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "duplicate url",
|
||||||
|
assets: {
|
||||||
|
...assets,
|
||||||
|
assets: [ASSET_ROWS[0]!, ASSET_ROWS[0]!],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const entry of tampered) {
|
||||||
|
expect(
|
||||||
|
() =>
|
||||||
|
resolveServiceWorkerBuildInput({
|
||||||
|
...base,
|
||||||
|
assets: entry.assets as never,
|
||||||
|
}),
|
||||||
|
entry.label,
|
||||||
|
).toThrow(TypeError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts generator-shaped output unchanged", () => {
|
||||||
|
expect(() =>
|
||||||
|
resolveServiceWorkerBuildInput({
|
||||||
|
selection,
|
||||||
|
assets,
|
||||||
|
contractSet: { setDigest: digest("b") },
|
||||||
|
runtimeConfig: { RELEASE_MANIFEST_URL: "/release-manifest.json" },
|
||||||
|
buildId: "build-1",
|
||||||
|
releaseId: "release-1",
|
||||||
|
}),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -118,6 +118,253 @@ function workerScope() {
|
|||||||
return { scope, clients, deleted };
|
return { scope, clients, deleted };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe("service worker static cache authority", () => {
|
||||||
|
const setDigest = `sha256:${"c".repeat(64)}` as const;
|
||||||
|
const currentCacheName = `${STATIC_CACHE_PREFIX}${setDigest.slice(
|
||||||
|
"sha256:".length,
|
||||||
|
"sha256:".length + 16,
|
||||||
|
)}`;
|
||||||
|
|
||||||
|
function staticRuntime(
|
||||||
|
caches: Readonly<{
|
||||||
|
open: (name: string) => Promise<unknown>;
|
||||||
|
keys: () => Promise<readonly string[]>;
|
||||||
|
delete: (name: string) => Promise<boolean>;
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
return createServiceWorkerRuntime(
|
||||||
|
{
|
||||||
|
caches,
|
||||||
|
clients: { matchAll: vi.fn(async () => []) },
|
||||||
|
registrationScope: `${ORIGIN}/app/`,
|
||||||
|
skipWaiting: vi.fn(async () => {}),
|
||||||
|
fetcher: vi.fn(),
|
||||||
|
digest: vi.fn(),
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
identity: { ...identity, staticAssetSetDigest: setDigest },
|
||||||
|
handlers: ["PWA_STATIC_ASSETS"],
|
||||||
|
manifest: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
buildId: identity.buildId,
|
||||||
|
releaseId: identity.releaseId,
|
||||||
|
setDigest,
|
||||||
|
// SW-URL-01. Generator output is root-relative.
|
||||||
|
assets: [
|
||||||
|
{
|
||||||
|
url: "/assets/app.0123456789abcdef.js",
|
||||||
|
sha256: `sha256:${"d".repeat(64)}`,
|
||||||
|
bytes: 10,
|
||||||
|
contentType: "text/javascript",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
runtimeConfigUrl: "/runtime-config.json",
|
||||||
|
releaseManifestUrl: "/release-manifest.json",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("classifies a generated root-relative asset against an absolute Request URL", async () => {
|
||||||
|
const currentResponse = new Response("current", { status: 200 });
|
||||||
|
const opened: string[] = [];
|
||||||
|
const runtime = staticRuntime({
|
||||||
|
open: vi.fn(async (name: string) => {
|
||||||
|
opened.push(name);
|
||||||
|
return {
|
||||||
|
match: async () => currentResponse.clone(),
|
||||||
|
delete: async () => true,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
keys: vi.fn(async () => [currentCacheName]),
|
||||||
|
delete: vi.fn(async () => true),
|
||||||
|
});
|
||||||
|
|
||||||
|
const served = await runtime.onFetch({
|
||||||
|
method: "GET",
|
||||||
|
url: `${ORIGIN}/assets/app.0123456789abcdef.js`,
|
||||||
|
});
|
||||||
|
expect(served).not.toBeNull();
|
||||||
|
expect(opened).toEqual([currentCacheName]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches static responses only in the current release cache", async () => {
|
||||||
|
const previousCacheName = `${STATIC_CACHE_PREFIX}${"e".repeat(16)}`;
|
||||||
|
const previousMatch = vi.fn(
|
||||||
|
async () => new Response("previous", { status: 200 }),
|
||||||
|
);
|
||||||
|
const runtime = staticRuntime({
|
||||||
|
open: vi.fn(
|
||||||
|
async (
|
||||||
|
name: string,
|
||||||
|
): Promise<Readonly<{ match: unknown; delete: unknown }>> =>
|
||||||
|
name === previousCacheName
|
||||||
|
? {
|
||||||
|
match: previousMatch,
|
||||||
|
delete: async (): Promise<boolean> => true,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
match: async (): Promise<Response | undefined> => undefined,
|
||||||
|
delete: async (): Promise<boolean> => true,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
keys: vi.fn(async () => [currentCacheName, previousCacheName]),
|
||||||
|
delete: vi.fn(async () => true),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only the previous cache holds the entry, so the request falls through to
|
||||||
|
// the network rather than serving a stale release.
|
||||||
|
await expect(
|
||||||
|
runtime.onFetch({
|
||||||
|
method: "GET",
|
||||||
|
url: `${ORIGIN}/assets/app.0123456789abcdef.js`,
|
||||||
|
}),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
expect(previousMatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes an invalid hit only from the current release cache", async () => {
|
||||||
|
const deletes: string[] = [];
|
||||||
|
const runtime = staticRuntime({
|
||||||
|
open: vi.fn(async (name: string) => ({
|
||||||
|
match: async () => new Response("bad", { status: 500 }),
|
||||||
|
delete: async (url: string): Promise<boolean> => {
|
||||||
|
deletes.push(`${name}:${url}`);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
keys: vi.fn(async () => [currentCacheName]),
|
||||||
|
delete: vi.fn(async () => true),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runtime.onFetch({
|
||||||
|
method: "GET",
|
||||||
|
url: `${ORIGIN}/assets/app.0123456789abcdef.js`,
|
||||||
|
}),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
expect(deletes).toEqual([
|
||||||
|
`${currentCacheName}:${ORIGIN}/assets/app.0123456789abcdef.js`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("service worker exact ownership and truthful removal", () => {
|
||||||
|
it("deletes only exact owned static cache names", async () => {
|
||||||
|
const fixture = workerScope();
|
||||||
|
fixture.scope.caches.keys = vi.fn(async () => [
|
||||||
|
`${STATIC_CACHE_PREFIX}${"a".repeat(16)}`,
|
||||||
|
`${STATIC_CACHE_PREFIX}${"b".repeat(16)}`,
|
||||||
|
// SW-02. Same prefix, not owned.
|
||||||
|
`${STATIC_CACHE_PREFIX}not-owned`,
|
||||||
|
`${STATIC_CACHE_PREFIX}${"c".repeat(17)}`,
|
||||||
|
`${STATIC_CACHE_PREFIX}${"A".repeat(16)}`,
|
||||||
|
"foreign-cache",
|
||||||
|
]);
|
||||||
|
const runtime = createServiceWorkerRuntime(fixture.scope as never, {
|
||||||
|
identity,
|
||||||
|
handlers: [],
|
||||||
|
manifest: null,
|
||||||
|
runtimeConfigUrl: "/runtime-config.json",
|
||||||
|
releaseManifestUrl: "/release-manifest.json",
|
||||||
|
});
|
||||||
|
const request = createServiceWorkerMessage({
|
||||||
|
kind: "CACHE_RESET_REQUEST",
|
||||||
|
sourceBuildId: "page-build",
|
||||||
|
targetBuildId: identity.buildId,
|
||||||
|
nonce: "nonce-reset-0001",
|
||||||
|
});
|
||||||
|
await runtime.onCacheResetRequest(request, fixture.clients[0] as never);
|
||||||
|
|
||||||
|
expect(fixture.deleted).toEqual([
|
||||||
|
`${STATIC_CACHE_PREFIX}${"a".repeat(16)}`,
|
||||||
|
`${STATIC_CACHE_PREFIX}${"b".repeat(16)}`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
label: "unregister false",
|
||||||
|
unregister: async () => false,
|
||||||
|
expected: { kind: "FAILED" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "unregister rejects",
|
||||||
|
unregister: async () => {
|
||||||
|
throw new TypeError("unregister exploded");
|
||||||
|
},
|
||||||
|
expected: { kind: "FAILED" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "unregister true",
|
||||||
|
unregister: async () => true,
|
||||||
|
expected: { kind: "DISABLED" },
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
"does not hide $label behind DISABLED",
|
||||||
|
async ({ unregister, expected }) => {
|
||||||
|
const registration = {
|
||||||
|
scope: `${ORIGIN}/`,
|
||||||
|
installing: null,
|
||||||
|
waiting: null,
|
||||||
|
active: { scriptURL: SCRIPT_URL },
|
||||||
|
unregister: vi.fn(unregister),
|
||||||
|
update: vi.fn(async () => {}),
|
||||||
|
} as unknown as ServiceWorkerRegistration;
|
||||||
|
const controller = createServiceWorkerPageController({
|
||||||
|
container: {
|
||||||
|
controller: null,
|
||||||
|
register: vi.fn(),
|
||||||
|
getRegistration: vi.fn(async () => registration),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
} as never,
|
||||||
|
routerBasePath: "/",
|
||||||
|
origin: ORIGIN,
|
||||||
|
selection: {
|
||||||
|
mode: "REMOVE_REGISTRATION",
|
||||||
|
scriptPath: "service-worker.js",
|
||||||
|
handlers: [],
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
await expect(controller.start()).resolves.toMatchObject(expected);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("reports an ownership mismatch as INCOMPATIBLE rather than DISABLED", async () => {
|
||||||
|
const foreign = {
|
||||||
|
scope: `${ORIGIN}/`,
|
||||||
|
installing: null,
|
||||||
|
waiting: null,
|
||||||
|
active: { scriptURL: `${ORIGIN}/someone-else.js` },
|
||||||
|
unregister: vi.fn(async () => true),
|
||||||
|
update: vi.fn(async () => {}),
|
||||||
|
} as unknown as ServiceWorkerRegistration;
|
||||||
|
const controller = createServiceWorkerPageController({
|
||||||
|
container: {
|
||||||
|
controller: null,
|
||||||
|
register: vi.fn(),
|
||||||
|
getRegistration: vi.fn(async () => foreign),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
} as never,
|
||||||
|
routerBasePath: "/",
|
||||||
|
origin: ORIGIN,
|
||||||
|
selection: {
|
||||||
|
mode: "REMOVE_REGISTRATION",
|
||||||
|
scriptPath: "service-worker.js",
|
||||||
|
handlers: [],
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
await expect(controller.start()).resolves.toMatchObject({
|
||||||
|
kind: "INCOMPATIBLE",
|
||||||
|
});
|
||||||
|
expect(foreign.unregister).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
describe("service worker page protocol", () => {
|
describe("service worker page protocol", () => {
|
||||||
it("does not attach late listeners when stopped during registration", async () => {
|
it("does not attach late listeners when stopped during registration", async () => {
|
||||||
const browser = pageContainer();
|
const browser = pageContainer();
|
||||||
|
|||||||
Reference in New Issue
Block a user