clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat/141-effect-library
lofi / package / runtime / boot-progress.ts
boot-progress.ts 5.8K
1/**2 * Package-owned first-load progress: the phases between a painted shell and an3 * open runtime, including the engine download that dominates a cold first4 * visit.5 *6 * The build stamps every prerendered page with a preload link for the engine7 * binary carrying its uncompressed byte size (`data-lofi-engine`). Before the8 * runtime opens, {@link BootProgressTracker.warmEngineDownload} streams that9 * URL through the same preload the browser already started, reporting byte10 * progress as it goes; the engine's own fetch then finds the bytes in the HTTP11 * cache. On a repeat visit the service worker answers the warm-up from the12 * precached shell, so the download phase passes in one step. The warm-up only13 * informs — any failure leaves the engine to resolve its binary exactly as it14 * would without one.15 *16 * @module17 */1819/**20 * Phases between a painted shell and an open runtime.21 *22 * - `pending` — the runtime has not been requested yet.23 * - `downloading` — the engine binary is downloading; on a cold first visit24 * this is the long phase, with byte progress in {@link BootProgress}.25 * - `opening` — the engine is instantiating and persistent storage is opening.26 * - `ready` — the runtime is open; live queries answer from local data.27 * - `failed` — the runtime could not open; the cause is in runtime28 * diagnostics (`startupFailure`).29 */30export type BootProgressPhase = "pending" | "downloading" | "opening" | "ready" | "failed";3132/** Live first-load progress for application status UI. */33export type BootProgress = {34 phase: BootProgressPhase;35 /** Engine bytes received so far, uncompressed. */36 loadedBytes: number;37 /** The engine binary's uncompressed size when the shell declares it, else `null`. */38 totalBytes: number | null;39};4041/** One engine binary reference declared by the built shell. */42export type EngineAssetReference = {43 url: string;44 bytes: number | null;45};4647/** Injectable surfaces used to test the tracker deterministically. */48export type BootProgressTrackerDependencies = {49 /** The shell's engine preload declaration, or `null` outside a built page. */50 readonly engineAsset?: () => EngineAssetReference | null;51 readonly fetchImpl?: typeof fetch;52};5354/** Stateful first-load progress shared by the runtime and application UI. */55export type BootProgressTracker = {56 /** The current progress snapshot. */57 get(): BootProgress;58 /** Subscribes to progress changes; returns an idempotent unsubscribe function. */59 subscribe(listener: (progress: BootProgress) => void): () => void;60 /**61 * Downloads the engine binary once per document with byte progress, priming62 * the HTTP cache the engine's own fetch reads. Resolves without throwing on63 * any failure; the engine then resolves its binary as it would without a64 * warm-up.65 */66 warmEngineDownload(): Promise<void>;67 /** Records a phase transition driven by the runtime lifecycle. */68 mark(phase: "opening" | "ready" | "failed"): void;69};7071function declaredEngineAsset(): EngineAssetReference | null {72 if (typeof document === "undefined") return null;73 const link = document.querySelector('link[rel="preload"][data-lofi-engine]');74 if (!(link instanceof HTMLLinkElement) || !link.href) return null;75 const bytes = Number(link.getAttribute("data-lofi-engine"));76 return { url: link.href, bytes: Number.isFinite(bytes) && bytes > 0 ? bytes : null };77}7879/**80 * Creates an isolated progress tracker over injectable surfaces. The81 * package-wide wrappers ({@link getBootProgress},82 * {@link subscribeBootProgress}) read the shared tracker the runtime drives;83 * this factory exists for isolated instances — deterministic tests and custom84 * hosts that manage their own tracker.85 */86export function createBootProgressTracker(87 dependencies: BootProgressTrackerDependencies = {},88): BootProgressTracker {89 const listeners = new Set<(progress: BootProgress) => void>();90 let progress: BootProgress = { phase: "pending", loadedBytes: 0, totalBytes: null };91 let warmup: Promise<void> | null = null;9293 const publish = (next: BootProgress) => {94 progress = next;95 for (const listener of listeners) listener(progress);96 };9798 const download = async (): Promise<void> => {99 const asset = (dependencies.engineAsset ?? declaredEngineAsset)();100 if (!asset) return;101 publish({ phase: "downloading", loadedBytes: 0, totalBytes: asset.bytes });102 const fetchImpl = dependencies.fetchImpl ?? fetch;103 const response = await fetchImpl(asset.url);104 if (!response.ok || !response.body) return;105 const reader = response.body.getReader();106 let loaded = 0;107 while (true) {108 const { done, value } = await reader.read();109 if (done) break;110 loaded += value.byteLength;111 publish({ ...progress, loadedBytes: loaded });112 }113 };114115 return {116 get: () => progress,117 subscribe(listener) {118 listeners.add(listener);119 let active = true;120 return () => {121 if (!active) return;122 active = false;123 listeners.delete(listener);124 };125 },126 warmEngineDownload() {127 // One warm-up per document: a recreated runtime reopens against bytes128 // that are already local, so only the phases replay.129 warmup ??= download().catch(() => undefined);130 return warmup;131 },132 mark(phase) {133 publish({ ...progress, phase });134 },135 };136}137138/** Shared tracker used by the package runtime and the optional Preact bindings. */139export const bootProgressTracker: BootProgressTracker = createBootProgressTracker();140141/** Returns the current first-load progress snapshot. */142export function getBootProgress(): BootProgress {143 return bootProgressTracker.get();144}145146/** Subscribes to first-load progress and returns an idempotent unsubscribe function. */147export function subscribeBootProgress(148 listener: (progress: BootProgress) => void,149): () => void {150 return bootProgressTracker.subscribe(listener);151}