Files
clean-architecture-frontend…/src/bootstrap/optional-runtime-host.ts
T
2026-08-01 19:39:59 +09:00

153 lines
5.5 KiB
TypeScript

import {
createBrowserLifecycleRuntime,
type BrowserLifecycleRuntime,
} from "../adapters/platform/browser-lifecycle.ts";
import type {
ResolvedRuntimeCapabilities,
RuntimeHealth,
RuntimeStopReason,
} from "../contracts/runtime-capabilities.ts";
import type { ServiceWorkerRuntimeHost } from "../contracts/service-worker.ts";
import { createServiceWorkerRuntimeHost } from "./register-service-worker.ts";
/**
* §3.4 / §20.3. Optional runtime host.
*
* Creating this object has no side effect: no listener, timer, network call,
* IndexedDB open or worker is created until `startAfterMount()` runs from the
* first committed React effect. `stop()` unwinds in reverse order.
*/
export type OptionalRuntimeHost = Readonly<{
readonly realtime: null;
readonly webWorkers: null;
readonly serviceWorker: ServiceWorkerRuntimeHost | null;
readonly offlineCommands: null;
browserLifecycle(): BrowserLifecycleRuntime | null;
health(): Readonly<Record<string, RuntimeHealth>>;
startAfterMount(): Promise<void>;
stop(reason?: RuntimeStopReason): Promise<void>;
}>;
export type OptionalRuntimeHostInput = Readonly<{
capabilities: ResolvedRuntimeCapabilities;
routerBasePath: string;
buildId: string;
/** Explicit host seam for deterministic lifecycle tests and platform shells. */
serviceWorkerHost?: ServiceWorkerRuntimeHost | null;
browserLifecycleHost?: Parameters<typeof createBrowserLifecycleRuntime>[0];
host?: Parameters<typeof createServiceWorkerRuntimeHost>[0]["host"];
blockers?: readonly (() => boolean)[];
observe?: (observation: Readonly<{ event: string; outcome: string }>) => void;
}>;
export function createOptionalRuntimeHost(
input: OptionalRuntimeHostInput,
): OptionalRuntimeHost {
const { capabilities } = input;
const serviceWorker = Object.hasOwn(input, "serviceWorkerHost")
? (input.serviceWorkerHost ?? null)
: createServiceWorkerRuntimeHost({
capabilities,
routerBasePath: input.routerBasePath,
buildId: input.buildId,
...(input.host ? { host: input.host } : {}),
...(input.blockers ? { blockers: input.blockers } : {}),
...(input.observe ? { observe: input.observe } : {}),
});
let lifecycle: BrowserLifecycleRuntime | null = null;
let started = false;
let startPromise: Promise<void> | null = null;
let stopPromise: Promise<void> | null = null;
let stopRequested = false;
const health: Record<string, RuntimeHealth> = {
realtime: capabilities.realtime.length === 0 ? "DISABLED" : "UNAVAILABLE",
webWorkers: capabilities.webWorkers.length === 0 ? "DISABLED" : "UNAVAILABLE",
serviceWorker: serviceWorker ? "UNAVAILABLE" : "DISABLED",
offlineCommands: capabilities.offlineCommands ? "UNAVAILABLE" : "DISABLED",
};
/**
* §3.4 start order:
* 1. offline foreground browser lifecycle observer
* 2. realtime runtime
* 3. no Web Worker prewarm
* 4. Service Worker active/cleanup controller
*/
async function startAfterMount(): Promise<void> {
// A stopped host is terminal. Its children (notably the Service Worker page
// controller) own one-shot listeners and timers and cannot be resurrected.
if (stopPromise) return stopPromise;
if (startPromise) return startPromise;
startPromise = (async () => {
// 1. The lifecycle observer is the single window listener owner. It is
// only created when something downstream can actually consume it.
if (serviceWorker || capabilities.realtime.length > 0) {
lifecycle = createBrowserLifecycleRuntime(input.browserLifecycleHost);
}
// 2. Realtime stays NOT_SELECTED until a product contribution exists
// (§13.6), so there is nothing to start and nothing to observe.
// 3. Web Workers are created lazily on first task; there is no prewarm.
// 4. Service Worker registration or the exact-owned cleanup action.
if (serviceWorker) {
const outcome = await serviceWorker.start();
// `stop()` may have fenced this generation while start was awaiting a
// browser operation. A late result must never reactivate health.
if (!stopRequested) {
health.serviceWorker =
outcome.kind === "ACTIVE"
? "AVAILABLE"
: outcome.kind === "DISABLED"
? "DISABLED"
: outcome.kind === "INCOMPATIBLE"
? "INCOMPATIBLE"
: "DEGRADED";
}
}
if (!stopRequested) started = true;
})();
return startPromise;
}
async function stop(
reason: RuntimeStopReason = "APPLICATION_SHUTDOWN",
): Promise<void> {
void reason;
stopRequested = true;
if (stopPromise) return stopPromise;
stopPromise = (async () => {
// Serialize teardown behind any in-flight browser registration. Cleanup
// then observes the final acquired resources and unwinds them exactly once.
await startPromise?.catch(() => {});
// Reverse of the start order.
if (serviceWorker && (started || startPromise)) {
await serviceWorker.stop().catch(() => {});
}
if (serviceWorker) {
health.serviceWorker = "DISABLED";
}
lifecycle?.dispose();
lifecycle = null;
started = false;
startPromise = null;
})();
return stopPromise;
}
return Object.freeze({
realtime: null,
webWorkers: null,
serviceWorker,
offlineCommands: null,
browserLifecycle: () => lifecycle,
health: () => Object.freeze({ ...health }),
startAfterMount,
stop,
});
}