Files
tech-log-frontend/src/adapters/web-push/service-worker-runtime.ts
T
DongHyeonkaandClaude Opus 5 4bff9ca151 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>
2026-08-15 12:04:58 +09:00

297 lines
8.5 KiB
TypeScript

import {
webPushCountBucket,
WEB_PUSH_LIMITS,
WEB_PUSH_PROTOCOLS,
webPushFailure,
webPushSuccess,
type WebPushObserver,
} from "../../contracts/web-push.ts";
import type { PushAssociationFenceStore } from "./push-association-fence-store.ts";
import {
createPushEventAdapter,
type PushEventFacade,
} from "./inbound/push-event-adapter.ts";
import {
createNotificationClickAdapter,
type NotificationClickEventFacade,
type WindowClientFacade,
} from "./inbound/notification-click-adapter.ts";
import type {
AssociationNotificationTagDigest,
WebPushNotificationRegistry,
} from "./notification-registry.ts";
import {
createLinkedAbortController,
nativeFailure,
observeWebPush,
withAbortableDeadline,
type TimeoutScheduler,
} from "./runtime-support.ts";
type FunctionalEventFacade = Readonly<{
waitUntil(task: Promise<void>): void;
}>;
type ServiceWorkerRegistrationFacade = Readonly<{
showNotification(
title: string,
options: Readonly<{
body: string;
data: unknown;
requireInteraction: false;
tag: string;
}>,
): Promise<void>;
}>;
type ServiceWorkerClientsFacade = Readonly<{
matchAll(input: Readonly<{
type: "window";
includeUncontrolled: boolean;
}>): Promise<readonly unknown[]>;
openWindow(url: string): Promise<unknown>;
}>;
/**
* Structural worker host used deliberately instead of exposing DOM worker
* globals to application/test compilation. A selected worker entry adapts its
* native scope to this facade; this factory has no registration side effect.
*/
export type ServiceWorkerEventHost = Readonly<{
origin: string;
registration: ServiceWorkerRegistrationFacade;
clients: ServiceWorkerClientsFacade;
addEventListener(type: string, listener: (event: unknown) => void): void;
removeEventListener(type: string, listener: (event: unknown) => void): void;
}>;
export type WebPushServiceWorkerRuntime = Readonly<{
dispose(): void;
}>;
export function createWebPushServiceWorkerRuntime(
dependencies: Readonly<{
host: ServiceWorkerEventHost;
fenceStore: PushAssociationFenceStore;
registry: WebPushNotificationRegistry;
now?: () => number;
scheduler?: TimeoutScheduler;
observer?: WebPushObserver;
tagDigest?: AssociationNotificationTagDigest;
}>,
): WebPushServiceWorkerRuntime {
const lifecycle = new AbortController();
const push = createPushEventAdapter({
fenceStore: dependencies.fenceStore,
registry: dependencies.registry,
notifications: {
showNotification: (title, options) =>
dependencies.host.registration.showNotification(title, options),
},
now: dependencies.now,
scheduler: dependencies.scheduler,
observer: dependencies.observer,
tagDigest: dependencies.tagDigest,
signal: lifecycle.signal,
});
const click = createNotificationClickAdapter({
fenceStore: dependencies.fenceStore,
registry: dependencies.registry,
clients: {
async matchControlledWindowClients() {
const candidates = await dependencies.host.clients.matchAll({
type: "window",
includeUncontrolled: false,
});
return candidates
.map(windowClientFacade)
.filter(
(candidate): candidate is WindowClientFacade =>
candidate !== null,
);
},
async openWindow(url) {
return windowClientFacade(
await dependencies.host.clients.openWindow(url),
);
},
},
origin: dependencies.host.origin,
now: dependencies.now,
scheduler: dependencies.scheduler,
observer: dependencies.observer,
signal: lifecycle.signal,
});
const onPush = (event: unknown) => {
const facade = pushEventFacade(event);
if (facade) void push.handle(facade);
};
const onNotificationClick = (event: unknown) => {
const facade = notificationClickEventFacade(event);
if (facade) void click.handle(facade);
};
const onSubscriptionChange = (event: unknown) => {
const facade = functionalEventFacade(event);
if (!facade) return;
const taskControl = createLinkedAbortController(lifecycle.signal);
// WP-06. Bounded fan-out is policy, but the operator must be able to see
// that only part of the client set was notified.
let observedClientCount = 0;
let truncatedClients = false;
const processing = withAbortableDeadline(
async (signal) => {
let clients: readonly unknown[];
try {
clients = await dependencies.host.clients.matchAll({
type: "window",
includeUncontrolled: true,
});
} catch {
return nativeFailure("SUBSCRIPTION_RECONCILE", true);
}
if (signal.aborted) {
return webPushFailure("ABORTED", "SUBSCRIPTION_RECONCILE");
}
observedClientCount = clients.length;
truncatedClients =
clients.length > WEB_PUSH_LIMITS.clientHandoffCount;
try {
for (const candidate of clients.slice(
0,
WEB_PUSH_LIMITS.clientHandoffCount,
)) {
if (signal.aborted) {
return webPushFailure(
"ABORTED",
"SUBSCRIPTION_RECONCILE",
);
}
const client = windowClientFacade(candidate);
client?.postMessage(
Object.freeze({
protocol: WEB_PUSH_PROTOCOLS.reconcileRequired,
}),
);
}
} catch {
return nativeFailure("SUBSCRIPTION_RECONCILE", true);
}
return webPushSuccess(undefined);
},
{
deadlineMs: WEB_PUSH_LIMITS.handlerDeadlineMs,
operation: "SUBSCRIPTION_RECONCILE",
signal: taskControl.signal,
scheduler: dependencies.scheduler,
},
).finally(taskControl.dispose);
const lifetime = processing.then((result) => {
observeWebPush(dependencies.observer, {
event: "web_push_subscription_rotated",
outcome:
result.ok && !truncatedClients ? "SUCCEEDED" : "DEGRADED",
...(result.ok
? truncatedClients
? { reason: "LIMIT_EXCEEDED" as const }
: {}
: { reason: result.error.code }),
countBucket: webPushCountBucket(observedClientCount),
truncated: truncatedClients,
});
});
try {
facade.waitUntil(lifetime);
} catch {
taskControl.abort();
void lifetime;
}
};
dependencies.host.addEventListener("push", onPush);
dependencies.host.addEventListener(
"notificationclick",
onNotificationClick,
);
dependencies.host.addEventListener(
"pushsubscriptionchange",
onSubscriptionChange,
);
let disposed = false;
return Object.freeze({
dispose() {
if (disposed) return;
disposed = true;
lifecycle.abort();
dependencies.host.removeEventListener("push", onPush);
dependencies.host.removeEventListener(
"notificationclick",
onNotificationClick,
);
dependencies.host.removeEventListener(
"pushsubscriptionchange",
onSubscriptionChange,
);
dependencies.fenceStore.close();
},
});
}
function functionalEventFacade(
value: unknown,
): FunctionalEventFacade | null {
try {
return isRecord(value) && typeof value.waitUntil === "function"
? (value as FunctionalEventFacade)
: null;
} catch {
return null;
}
}
function pushEventFacade(value: unknown): PushEventFacade | null {
if (
!isRecord(value) ||
typeof value.waitUntil !== "function" ||
!(
value.data === null ||
(isRecord(value.data) && typeof value.data.arrayBuffer === "function")
)
) {
return null;
}
return value as PushEventFacade;
}
function notificationClickEventFacade(
value: unknown,
): NotificationClickEventFacade | null {
if (
!isRecord(value) ||
typeof value.waitUntil !== "function" ||
!isRecord(value.notification) ||
typeof value.notification.close !== "function" ||
!Object.hasOwn(value.notification, "data")
) {
return null;
}
return value as NotificationClickEventFacade;
}
function windowClientFacade(value: unknown): WindowClientFacade | null {
if (
!isRecord(value) ||
typeof value.url !== "string" ||
typeof value.focus !== "function" ||
typeof value.postMessage !== "function"
) {
return null;
}
return value as WindowClientFacade;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}