import { createBrowserLifecycleRuntime, type BrowserLifecycleRuntime, } from "../adapters/platform/index.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>; startAfterMount(): Promise; stop(reason?: RuntimeStopReason): Promise; }>; 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[0]; host?: Parameters[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 | null = null; let stopPromise: Promise | null = null; let stopRequested = false; const health: Record = { 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 { // 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 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, }); }