153 lines
5.5 KiB
TypeScript
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,
|
|
});
|
|
}
|