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>
172 lines
5.7 KiB
TypeScript
172 lines
5.7 KiB
TypeScript
/// <reference lib="webworker" />
|
|
import { OFFLINE_SYNC_TAG } from "../../contracts/offline-command.ts";
|
|
import type {
|
|
ServiceWorkerHandlerId,
|
|
ServiceWorkerProtocolIdentity,
|
|
StaticAssetManifestV1,
|
|
} from "../../contracts/service-worker.ts";
|
|
import {
|
|
createServiceWorkerRuntime,
|
|
type WorkerScopeLike,
|
|
} from "./service-worker-lifecycle.ts";
|
|
import { parseServiceWorkerMessage } from "./service-worker-protocol.ts";
|
|
|
|
/**
|
|
* §17.1. The one physical worker entry for this scope.
|
|
*
|
|
* PWA lifecycle, verified static asset fetch, Web Push and the optional sync
|
|
* wake-up are all handler factories inside this single entry. A second
|
|
* registration for any of them is prohibited.
|
|
*
|
|
* This module is compiled only by `vite.service-worker.config.ts` when the
|
|
* static selection is `ACTIVE`; it is never part of the page bundle.
|
|
*/
|
|
|
|
declare const self: ServiceWorkerGlobalScope;
|
|
|
|
// Build-time virtual modules (§18.3). They resolve through the Service Worker
|
|
// Vite config only, so the page bundle can never import a worker asset list.
|
|
declare const __CA_SERVICE_WORKER_BUILD_INFO__: ServiceWorkerProtocolIdentity;
|
|
declare const __CA_SERVICE_WORKER_ASSETS__: StaticAssetManifestV1 | null;
|
|
declare const __CA_SERVICE_WORKER_HANDLERS__: readonly ServiceWorkerHandlerId[];
|
|
declare const __CA_RUNTIME_CONFIG_URL__: string;
|
|
declare const __CA_RELEASE_MANIFEST_URL__: string;
|
|
|
|
const identity = __CA_SERVICE_WORKER_BUILD_INFO__;
|
|
const handlers = __CA_SERVICE_WORKER_HANDLERS__;
|
|
|
|
const scope: WorkerScopeLike = {
|
|
caches: {
|
|
open: (name) => caches.open(name),
|
|
keys: () => caches.keys(),
|
|
delete: (name) => caches.delete(name),
|
|
// SW-01. No CacheStorage-wide match: only the current release cache may
|
|
// answer a verified static request.
|
|
},
|
|
clients: {
|
|
matchAll: (options) =>
|
|
self.clients.matchAll(
|
|
options as { type?: "window"; includeUncontrolled?: boolean },
|
|
) as Promise<
|
|
readonly {
|
|
id: string;
|
|
url: string;
|
|
postMessage(m: unknown): void;
|
|
}[]
|
|
>,
|
|
},
|
|
registrationScope: self.registration.scope,
|
|
skipWaiting: () => self.skipWaiting(),
|
|
fetcher: (input: RequestInfo | URL, init?: RequestInit) => fetch(input, init),
|
|
async digest(bytes) {
|
|
const buffer = await crypto.subtle.digest(
|
|
"SHA-256",
|
|
bytes.slice().buffer as ArrayBuffer,
|
|
);
|
|
let hex = "";
|
|
for (const byte of new Uint8Array(buffer)) {
|
|
hex += byte.toString(16).padStart(2, "0");
|
|
}
|
|
return `sha256:${hex}`;
|
|
},
|
|
};
|
|
|
|
const runtime = createServiceWorkerRuntime(scope, {
|
|
identity,
|
|
handlers,
|
|
manifest: __CA_SERVICE_WORKER_ASSETS__,
|
|
runtimeConfigUrl: __CA_RUNTIME_CONFIG_URL__,
|
|
releaseManifestUrl: __CA_RELEASE_MANIFEST_URL__,
|
|
});
|
|
|
|
self.addEventListener("install", (event) => {
|
|
// §17.10. Install never calls skipWaiting(); activation is a page handshake.
|
|
event.waitUntil(runtime.onInstall());
|
|
});
|
|
|
|
self.addEventListener("activate", (event) => {
|
|
// §17.12. No clients.claim() in the baseline.
|
|
event.waitUntil(runtime.onActivate());
|
|
});
|
|
|
|
self.addEventListener("fetch", (event) => {
|
|
const request = event.request;
|
|
event.respondWith(
|
|
runtime
|
|
.onFetch({
|
|
method: request.method,
|
|
url: request.url,
|
|
mode: request.mode,
|
|
})
|
|
.then((cached) => cached ?? fetch(request)),
|
|
);
|
|
});
|
|
|
|
self.addEventListener("message", (event) => {
|
|
const parsed = parseServiceWorkerMessage(event.data);
|
|
if (!parsed.ok) return;
|
|
if (parsed.message.kind === "ACTIVATE_REQUEST") {
|
|
event.waitUntil(runtime.onActivateRequest(event.data));
|
|
return;
|
|
}
|
|
if (
|
|
parsed.message.kind === "CLIENT_DRAINED" ||
|
|
parsed.message.kind === "ACTIVATE_REJECTED"
|
|
) {
|
|
const source = event.source;
|
|
if (source && "id" in source && typeof source.id === "string") {
|
|
runtime.onClientMessage(event.data, source.id);
|
|
}
|
|
return;
|
|
}
|
|
if (parsed.message.kind === "CACHE_RESET_REQUEST") {
|
|
const source = event.source;
|
|
if (
|
|
source &&
|
|
"id" in source &&
|
|
typeof source.id === "string" &&
|
|
"postMessage" in source &&
|
|
typeof source.postMessage === "function"
|
|
) {
|
|
event.waitUntil(runtime.onCacheResetRequest(event.data, source));
|
|
}
|
|
}
|
|
});
|
|
|
|
// §17.1 WEB_PUSH composition point.
|
|
//
|
|
// The Web Push runtime is a handler factory inside this one entry, never a
|
|
// second registration. It is not wired here because the template cannot supply
|
|
// the two product-owned inputs it needs: a PushAssociationFenceStore over the
|
|
// product push control repository, and a WebPushNotificationRegistry of exact
|
|
// notification types with the same-origin routes their clicks may open
|
|
// (§21.11). Selecting WEB_PUSH means adding, inside a
|
|
// `handlers.includes("WEB_PUSH")` guard: import
|
|
// createWebPushServiceWorkerRuntime and createServiceWorkerScopeHost from the
|
|
// sibling web-push adapter, then call the runtime with the scope host built
|
|
// from `self` plus the product fence store and notification registry.
|
|
//
|
|
// Keeping the import out of the baseline entry is also what lets the realtime
|
|
// and Web Push runtime be removed as a pure file deletion (§24.12).
|
|
|
|
if (handlers.includes("OFFLINE_SYNC_WAKEUP")) {
|
|
// §19.18. Wake-up only: the handler records that a sync fired and notifies
|
|
// controlled clients. It never sends an authenticated command (§19.20).
|
|
self.addEventListener("sync", (rawEvent: Event) => {
|
|
const event = rawEvent as ExtendableEvent & { tag?: string };
|
|
if (event.tag !== OFFLINE_SYNC_TAG) return;
|
|
event.waitUntil(
|
|
self.clients.matchAll({ type: "window" }).then((clients) => {
|
|
for (const client of clients) {
|
|
client.postMessage({
|
|
protocolVersion: 1,
|
|
kind: "SYNC_WAKE_OBSERVED",
|
|
messageId: crypto.randomUUID(),
|
|
sourceBuildId: identity.buildId,
|
|
});
|
|
}
|
|
}),
|
|
);
|
|
});
|
|
}
|