clone

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

feat/184-task-notice-migration
lofi / package / runtime / pwa.ts
pwa.ts 25.3K
1/// <reference path="./env.d.ts" />2/**3 * Package-owned PWA lifecycle: the browser install flow, production4 * service-worker registration, staged updates discovered by bounded5 * foreground checks, and the coordinated swap that activates a waiting6 * worker and moves controlled tabs onto the new revision.7 *8 * Most applications consume the shared controller through the wrapper9 * functions — {@link getPwaState}, {@link subscribePwaState},10 * {@link requestPwaInstall}, {@link checkPwaUpdate}, {@link applyPwaUpdate} —11 * or the Preact bindings in `@nzip/lofi/preact`; boot wiring registers the12 * worker once through {@link registerProductionServiceWorker}.13 *14 * @module15 */1617/**18 * Service-worker lifecycle states exposed to application UI.19 *20 * - `development-disabled` — registration is skipped outside production21 * builds.22 * - `unsupported` — the environment offers no service-worker container.23 * - `registering` — registration and first activation are in progress.24 * - `ready` — an active worker controls the app and serves the offline shell.25 * - `failed` — registration, installation, or precaching failed;26 * {@link PwaState.failure} carries the cause.27 */28export type PwaWorkerState =29 | "development-disabled"30 | "unsupported"31 | "registering"32 | "ready"33 | "failed";3435/**36 * Browser installation states exposed to application UI.37 *38 * - `installed` — running standalone or the browser reports the app39 * installed.40 * - `available` — the browser offered an install prompt;41 * {@link requestPwaInstall} opens it.42 * - `prompting` — the deferred prompt is open.43 * - `accepted` / `dismissed` — the prompt's outcome.44 * - `manual-ios` — install via Share → Add to Home Screen; iOS exposes no45 * prompt API.46 * - `manual-browser` — install is available only through the browser menu.47 * - `unsupported` — no secure context or no service-worker support.48 */49export type PwaInstallState =50 | "installed"51 | "available"52 | "prompting"53 | "accepted"54 | "dismissed"55 | "manual-ios"56 | "manual-browser"57 | "unsupported";5859/**60 * Foreground update-check and waiting-worker states exposed to application61 * UI.62 *63 * - `idle` — no check running and nothing staged.64 * - `checking` — a bounded update check is in flight.65 * - `installing` — a newly discovered worker is downloading and installing.66 * - `ready` — a new worker is staged and waiting: show the update affordance67 * and call {@link applyPwaUpdate}.68 * - `applying` — covers the swap until controlled tabs reload.69 * - `failed` — the check or installation failed; the cause is in70 * {@link PwaState.failure}.71 */72export type PwaUpdateState =73 | "idle"74 | "checking"75 | "installing"76 | "ready"77 | "applying"78 | "failed";7980/** Stable categories for recoverable offline/PWA failures. */81export type PwaFailureCode =82 | "registration"83 | "installation"84 | "install-prompt"85 | "update-check"86 | "precache"87 | "runtime-cache";8889/** Current install, service-worker, and offline-cache state. */90export type PwaState = {91 worker: PwaWorkerState;92 install: PwaInstallState;93 update: PwaUpdateState;94 failure?: {95 code: PwaFailureCode;96 message: string;97 };98};99100/** Chromium install event retained until application UI requests the prompt. */101export type InstallPromptEvent = Event & {102 prompt(): Promise<void>;103 userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;104};105106/** Browser signals used to classify installed and manual-install experiences. */107export type InstallEnvironment = {108 displayModeStandalone: boolean;109 navigatorStandalone: boolean;110 platform: string;111 maxTouchPoints: number;112 secureContext: boolean;113 serviceWorkerSupported: boolean;114};115116type LofiPwaGlobal = typeof globalThis & { __LOFI_PWA_STATE__?: PwaState };117118const actionableFailureMessages: Record<PwaFailureCode, string> = {119 registration:120 "Offline support could not start. Reload on a stable connection; if it continues, clear this site's storage and retry.",121 installation:122 "The offline update could not install. Reconnect, keep this tab open, and reload to try again.",123 "install-prompt":124 "The browser install prompt could not open. Use the browser menu if it offers Install app or Add to Home Screen.",125 "update-check":126 "The app could not check for an update. Reconnect and bring it to the foreground to try again.",127 precache:128 "Required offline files were not saved. Reconnect, reload, and wait for offline support before disconnecting.",129 "runtime-cache":130 "A recently opened file was not saved for offline use. Reconnect and open it again before going offline.",131};132133/** Returns actionable, non-technical recovery guidance for a PWA failure. */134export function pwaFailureMessage(code: PwaFailureCode): string {135 return actionableFailureMessages[code];136}137138/** Injectable browser surfaces used to test the PWA lifecycle deterministically. */139export type PwaControllerDependencies = {140 readonly eventTarget?: () => EventTarget;141 readonly visibilityTarget?: () => EventTarget;142 readonly serviceWorker?: () => ServiceWorkerContainer | undefined;143 readonly installEnvironment?: () => InstallEnvironment;144 readonly isVisible?: () => boolean;145 readonly now?: () => number;146 readonly setTimeout?: (callback: () => void, milliseconds: number) => unknown;147 readonly clearTimeout?: (handle: unknown) => void;148 /** Minimum time between foreground update checks; defaults to one minute. */149 readonly updateCheckIntervalMs?: number;150 /** Maximum time exposed as an active update check; defaults to ten seconds. */151 readonly updateCheckTimeoutMs?: number;152 readonly production?: () => boolean;153 /** Absolute URL of the configured deployment base, independent of the current route. */154 readonly deploymentBaseUrl?: () => string;155 /** @deprecated Supply deploymentBaseUrl when injecting browser location in tests. */156 readonly reload?: () => void;157 readonly exposeState?: (state: PwaState) => void;158 /**159 * Runs before the waiting worker is told to activate, so sibling tabs can160 * quiesce in-flight writes. When supplied, `applyUpdate()` posts the161 * skip-waiting message only after this settles; a rejection does not block162 * the swap. When omitted, activation is requested synchronously.163 */164 readonly prepareUpdateSwap?: () => Promise<void>;165 /**166 * How a tab that did not initiate an update behaves when a new worker takes167 * control: `reload` (the default) reloads it onto the new revision, `prompt`168 * keeps the document and reports the stale revision via `onStaleTab`.169 */170 readonly staleTabBehavior?: () => "reload" | "prompt";171 /** Notified when this tab stays on a stale revision under `prompt` behavior. */172 readonly onStaleTab?: () => void;173};174175/**176 * Stateful controller for browser installation and service-worker updates.177 * Most apps use the shared {@link pwaController} through the wrapper178 * functions ({@link getPwaState}, {@link applyPwaUpdate}) or the Preact179 * bindings.180 */181export type PwaController = {182 /** The current install, worker, and update state snapshot. */183 getState(): PwaState;184 /** Subscribes to state changes; the subscriber runs immediately with the current state. */185 subscribe(subscriber: (state: PwaState) => void): () => void;186 /**187 * Opens the deferred browser install prompt and resolves with the outcome.188 * When no prompt is available (already installed, iOS, unsupported) it189 * resolves with the current install state unchanged.190 */191 requestInstall(): Promise<PwaInstallState>;192 /**193 * Runs a bounded update check. Resolves `true` when a check ran or was194 * already in flight, `false` when checking is not possible right now (not195 * registered, an update already staged, or rate-limited). The outcome196 * lands in {@link PwaState.update}, not the return value.197 */198 checkForUpdate(): Promise<boolean>;199 /**200 * Activates the waiting worker when {@link PwaState.update} is `ready`;201 * returns `false` when no update is staged. Controlled tabs then reload202 * onto the new revision (see `staleTabBehavior` for the alternative).203 */204 applyUpdate(): boolean;205 /** Registers the worker and starts watching; called once by boot wiring. */206 initialize(): void;207};208209/** Resolves the worker URL and scope from one configured application base. */210export function resolvePwaResources(deploymentBaseUrl: string): {211 workerUrl: URL;212 scope: string;213} {214 const base = new URL(deploymentBaseUrl);215 if (!base.pathname.endsWith("/")) base.pathname += "/";216 base.search = "";217 base.hash = "";218 return { workerUrl: new URL("sw.js", base), scope: base.pathname };219}220221export function classifyInstallExperience(environment: InstallEnvironment): PwaInstallState {222 if (environment.displayModeStandalone || environment.navigatorStandalone) return "installed";223 const iosLike = /^(iPhone|iPad|iPod)$/.test(environment.platform) ||224 (environment.platform === "MacIntel" && environment.maxTouchPoints > 1);225 if (iosLike && environment.secureContext) return "manual-ios";226 return environment.secureContext && environment.serviceWorkerSupported227 ? "manual-browser"228 : "unsupported";229}230231export function waitForActivation(worker: ServiceWorker): Promise<void> {232 if (worker.state === "activated") return Promise.resolve();233 if (worker.state === "redundant") return Promise.reject(new Error("service worker is redundant"));234235 return new Promise((resolve, reject) => {236 const onStateChange = () => {237 if (worker.state === "activated") {238 worker.removeEventListener("statechange", onStateChange);239 resolve();240 } else if (worker.state === "redundant") {241 worker.removeEventListener("statechange", onStateChange);242 reject(new Error("service worker installation failed"));243 }244 };245 worker.addEventListener("statechange", onStateChange);246 onStateChange();247 });248}249250/**251 * Creates an isolated PWA controller over injectable browser surfaces. The252 * package-wide wrappers (`getPwaState`, `subscribePwaState`,253 * `requestPwaInstall`, `checkPwaUpdate`, `applyPwaUpdate`) drive the shared254 * {@link pwaController}; this factory exists for isolated instances —255 * deterministic tests and custom hosts that manage their own controller.256 */257export function createPwaController(dependencies: PwaControllerDependencies = {}): PwaController {258 const subscribers = new Set<(state: PwaState) => void>();259 const observedUpdateWorkers = new WeakSet<ServiceWorker>();260 let deferredInstallPrompt: InstallPromptEvent | undefined;261 let waitingWorker: ServiceWorker | undefined;262 let activeRegistration: ServiceWorkerRegistration | undefined;263 let activeContainer: ServiceWorkerContainer | undefined;264 let updateCheck: Promise<boolean> | null = null;265 let lastUpdateCheckAt = Number.NEGATIVE_INFINITY;266 let reloadForUpdate = false;267 let foregroundChecksAttached = false;268 let initialized = false;269 let state: PwaState = { worker: "registering", install: "unsupported", update: "idle" };270271 const publish = (next: PwaState) => {272 state = next;273 dependencies.exposeState?.(state);274 for (const subscriber of subscribers) subscriber(state);275 };276 const update = (patch: Partial<PwaState>) => publish({ ...state, ...patch });277 const failure = (code: PwaFailureCode) => ({ code, message: pwaFailureMessage(code) });278 const clearFailure = (...codes: PwaFailureCode[]) =>279 state.failure && codes.includes(state.failure.code) ? undefined : state.failure;280 const failWorker = (code: "registration" | "installation" | "precache") => {281 update({ worker: "failed", failure: { code, message: pwaFailureMessage(code) } });282 };283 const failUpdate = (code: "installation" | "update-check") => {284 update({ update: "failed", failure: failure(code) });285 };286 const target = () => dependencies.eventTarget?.() ?? globalThis;287 const visibilityTarget = () =>288 dependencies.visibilityTarget?.() ??289 (typeof document !== "undefined" ? document : target());290 const serviceWorker = () =>291 dependencies.serviceWorker?.() ??292 (typeof navigator !== "undefined" && "serviceWorker" in navigator293 ? navigator.serviceWorker294 : undefined);295 const installEnvironment = () =>296 dependencies.installEnvironment?.() ?? {297 displayModeStandalone: matchMedia("(display-mode: standalone)").matches,298 navigatorStandalone: (navigator as Navigator & { standalone?: boolean }).standalone === true,299 platform: navigator.platform,300 maxTouchPoints: navigator.maxTouchPoints,301 secureContext: globalThis.isSecureContext,302 serviceWorkerSupported: "serviceWorker" in navigator,303 };304 const isVisible = () =>305 dependencies.isVisible?.() ??306 (typeof document === "undefined" || document.visibilityState === "visible");307 const now = () => dependencies.now?.() ?? Date.now();308 const scheduleTimeout = (callback: () => void, milliseconds: number) =>309 dependencies.setTimeout310 ? dependencies.setTimeout(callback, milliseconds)311 : globalThis.setTimeout(callback, milliseconds);312 const cancelTimeout = (handle: unknown) => {313 if (dependencies.clearTimeout) dependencies.clearTimeout(handle);314 else globalThis.clearTimeout(handle as number);315 };316 const updateCheckIntervalMs = dependencies.updateCheckIntervalMs ?? 60_000;317 const updateCheckTimeoutMs = dependencies.updateCheckTimeoutMs ?? 10_000;318319 const boundedRegistrationUpdate = (320 registration: ServiceWorkerRegistration,321 ): Promise<ServiceWorkerRegistration> =>322 new Promise((resolve, reject) => {323 let settled = false;324 let timeoutHandle: unknown = undefined;325 const finish = () => {326 if (settled) return false;327 settled = true;328 if (timeoutHandle !== undefined) cancelTimeout(timeoutHandle);329 return true;330 };331 timeoutHandle = scheduleTimeout(332 () => {333 if (finish()) reject(new Error("service worker update check timed out"));334 },335 updateCheckTimeoutMs,336 );337 Promise.resolve().then(() => registration.update()).then(338 (next) => {339 if (finish()) resolve(next);340 },341 (error) => {342 if (finish()) reject(error instanceof Error ? error : new Error(String(error)));343 },344 );345 });346347 const checkForUpdate = (): Promise<boolean> => {348 const registration = activeRegistration;349 const container = activeContainer;350 if (!registration || !container?.controller || state.worker !== "ready") {351 return Promise.resolve(false);352 }353 if (354 state.update === "installing" || state.update === "ready" || state.update === "applying"355 ) {356 return Promise.resolve(false);357 }358 if (updateCheck) return updateCheck;359 const checkedAt = now();360 if (checkedAt - lastUpdateCheckAt < updateCheckIntervalMs) return Promise.resolve(false);361 lastUpdateCheckAt = checkedAt;362 update({363 update: "checking",364 failure: clearFailure("update-check"),365 });366 const operation = boundedRegistrationUpdate(registration).then(367 () => {368 if (state.update === "checking") {369 update({ update: "idle", failure: clearFailure("update-check") });370 }371 return true;372 },373 () => {374 if (state.update === "checking") failUpdate("update-check");375 return true;376 },377 );378 const tracked = operation.finally(() => {379 if (updateCheck === tracked) updateCheck = null;380 });381 updateCheck = tracked;382 return tracked;383 };384385 const requestUpdateFromEvent = () => {386 void checkForUpdate();387 };388 const onPageShow = (event: Event) => {389 if ((event as PageTransitionEvent).persisted === true) requestUpdateFromEvent();390 };391 const onVisibilityChange = () => {392 if (isVisible()) requestUpdateFromEvent();393 };394 const attachForegroundChecks = () => {395 if (foregroundChecksAttached) return;396 foregroundChecksAttached = true;397 target().addEventListener("pageshow", onPageShow);398 target().addEventListener("online", requestUpdateFromEvent);399 visibilityTarget().addEventListener("visibilitychange", onVisibilityChange);400 };401402 const watchForUpdate = (403 registration: ServiceWorkerRegistration,404 container: ServiceWorkerContainer,405 ) => {406 const exposeWaitingWorker = (worker: ServiceWorker) => {407 waitingWorker = worker;408 update({409 worker: "ready",410 update: "ready",411 failure: clearFailure("installation", "update-check"),412 });413 };414 const observeInstallingWorker = (worker: ServiceWorker) => {415 if (observedUpdateWorkers.has(worker)) return;416 observedUpdateWorkers.add(worker);417 update({418 worker: "ready",419 update: "installing",420 failure: clearFailure("installation", "update-check"),421 });422 const onStateChange = () => {423 if (worker.state === "installed") {424 exposeWaitingWorker(registration.waiting ?? worker);425 } else if (worker.state === "activated") {426 update({ worker: "ready", update: "idle" });427 } else if (worker.state === "redundant") {428 failUpdate("installation");429 }430 };431 worker.addEventListener("statechange", onStateChange);432 onStateChange();433 };434435 if (registration.waiting && container.controller) exposeWaitingWorker(registration.waiting);436 registration.addEventListener("updatefound", () => {437 const worker = registration.installing;438 if (worker && container.controller) observeInstallingWorker(worker);439 });440 if (registration.installing && container.controller) {441 observeInstallingWorker(registration.installing);442 }443 };444445 const watchInstallExperience = () => {446 update({ install: classifyInstallExperience(installEnvironment()) });447 target().addEventListener("beforeinstallprompt", (event) => {448 event.preventDefault();449 deferredInstallPrompt = event as InstallPromptEvent;450 update({ install: "available", failure: clearFailure("install-prompt") });451 });452 target().addEventListener("appinstalled", () => {453 deferredInstallPrompt = undefined;454 update({ install: "installed", failure: clearFailure("install-prompt") });455 });456 };457458 const initialize = () => {459 if (initialized) return;460 initialized = true;461 watchInstallExperience();462 if (!(dependencies.production?.() ?? import.meta.env.PROD)) {463 update({ worker: "development-disabled" });464 return;465 }466 const container = serviceWorker();467 if (!container) {468 update({ worker: "unsupported" });469 return;470 }471 update({ worker: "registering" });472 container.addEventListener("message", (event) => {473 const message = (event as MessageEvent).data as { type?: string; code?: PwaFailureCode };474 if (475 message?.type === "LOFI_PWA_FAILURE" &&476 (message.code === "precache" || message.code === "runtime-cache")477 ) {478 if (message.code === "precache") failWorker("precache");479 else update({ failure: failure("runtime-cache") });480 }481 });482 // A controller change means a new worker took over this tab. By default483 // every controlled tab reloads — the new worker prunes the old revision's484 // caches, so a document kept on the old HTML/module graph goes stale —485 // not just the tab whose applyUpdate() initiated the change. Under the486 // `prompt` stale-tab preference a bystander tab keeps its document and is487 // reported stale instead; the initiating tab always reloads. The first488 // claim of a previously uncontrolled page does not reload, and a document489 // reloads at most once, so a misbehaving worker cannot cause a loop.490 let hadController = Boolean(container.controller);491 let reloadedForController = false;492 container.addEventListener("controllerchange", () => {493 const wasControlled = hadController;494 hadController = true;495 const initiated = reloadForUpdate;496 reloadForUpdate = false;497 if (!initiated && !wasControlled) return;498 if (reloadedForController) return;499 waitingWorker = undefined;500 update({501 worker: "ready",502 update: "idle",503 failure: clearFailure("installation", "update-check"),504 });505 if (!initiated && (dependencies.staleTabBehavior?.() ?? "reload") === "prompt") {506 dependencies.onStaleTab?.();507 return;508 }509 reloadedForController = true;510 if (dependencies.reload) dependencies.reload();511 else location.reload();512 });513 const deploymentBaseUrl = dependencies.deploymentBaseUrl?.() ??514 new URL(import.meta.env.BASE_URL, document.location.origin).href;515 const { workerUrl, scope } = resolvePwaResources(deploymentBaseUrl);516 void (async () => {517 const registration = await container.register(workerUrl, {518 scope,519 });520 activeRegistration = registration;521 activeContainer = container;522 watchForUpdate(registration, container);523 attachForegroundChecks();524 if (registration.waiting && container.controller) return;525 if (registration.installing && container.controller) return;526 const worker = registration.installing ?? registration.active ?? registration.waiting;527 if (!worker) throw new Error("service worker registration has no worker");528 try {529 await waitForActivation(worker);530 } catch {531 failWorker("installation");532 return;533 }534 update({ worker: "ready", update: "idle", failure: undefined });535 })().catch(() => failWorker("registration"));536 };537538 return {539 getState: () => state,540 subscribe(subscriber) {541 subscribers.add(subscriber);542 subscriber(state);543 return () => subscribers.delete(subscriber);544 },545 async requestInstall() {546 const prompt = deferredInstallPrompt;547 if (!prompt) return state.install;548 deferredInstallPrompt = undefined;549 update({ install: "prompting" });550 try {551 await prompt.prompt();552 const choice = await prompt.userChoice;553 const outcome = choice.outcome === "accepted" ? "accepted" : "dismissed";554 update({ install: outcome });555 return outcome;556 } catch {557 const fallback = classifyInstallExperience(installEnvironment());558 update({ install: fallback, failure: failure("install-prompt") });559 return fallback;560 }561 },562 checkForUpdate,563 applyUpdate() {564 if (!waitingWorker || state.update !== "ready") return false;565 const worker = waitingWorker;566 const prepare = dependencies.prepareUpdateSwap;567 reloadForUpdate = true;568 const requestActivation = () => {569 try {570 worker.postMessage({ type: "LOFI_SKIP_WAITING" });571 return true;572 } catch {573 reloadForUpdate = false;574 failUpdate("installation");575 return false;576 }577 };578 if (!prepare) {579 if (!requestActivation()) return false;580 update({ update: "applying", failure: clearFailure("installation", "update-check") });581 return true;582 }583 // With coordination, sibling tabs quiesce writes first; the swap itself584 // must not depend on their cooperation, so a rejection still proceeds.585 update({ update: "applying", failure: clearFailure("installation", "update-check") });586 void Promise.resolve().then(prepare).catch(() => undefined).then(requestActivation);587 return true;588 },589 initialize,590 };591}592593/** Cross-tab coordination hooks the boot wiring attaches to the shared controller. */594export type PwaUpdateCoordination = {595 /** Quiesces sibling-tab writes before the worker swap. */596 prepareUpdateSwap: () => Promise<void>;597 /** The author's stale-tab preference; `reload` is the safe default. */598 staleTabBehavior: () => "reload" | "prompt";599 /** Marks this tab stale when `prompt` keeps it on the old revision. */600 onStaleTab: () => void;601};602603// Late-bound so this module stays free of upgrade-coordination imports; the604// hooks act only once boot wiring attaches them.605let updateCoordination: PwaUpdateCoordination | undefined;606607/** Attaches (or replaces) the shared controller's cross-tab coordination hooks. */608export function attachPwaUpdateCoordination(hooks: PwaUpdateCoordination): void {609 updateCoordination = hooks;610}611612/** Shared controller used by the root runtime and optional Preact bindings. */613export const pwaController: PwaController = createPwaController({614 exposeState(next) {615 if (typeof document !== "undefined") (globalThis as LofiPwaGlobal).__LOFI_PWA_STATE__ = next;616 },617 prepareUpdateSwap: () => updateCoordination?.prepareUpdateSwap() ?? Promise.resolve(),618 staleTabBehavior: () => updateCoordination?.staleTabBehavior() ?? "reload",619 onStaleTab: () => updateCoordination?.onStaleTab(),620});621622/** Returns the shared controller's current state snapshot. */623export function getPwaState(): PwaState {624 return pwaController.getState();625}626627/** Subscribes to shared PWA state and returns an idempotent unsubscribe function. */628export function subscribePwaState(subscriber: (state: PwaState) => void): () => void {629 return pwaController.subscribe(subscriber);630}631632/** Requests the deferred browser installation prompt when one is available. */633export function requestPwaInstall(): Promise<PwaInstallState> {634 return pwaController.requestInstall();635}636637/**638 * Runs the shared controller's bounded update check when registration is ready.639 * Resolves true when a check ran or was already in flight; inspect `PwaState.update` for outcome.640 */641export function checkPwaUpdate(): Promise<boolean> {642 return pwaController.checkForUpdate();643}644645/** Activates a waiting service worker; returns false when no update is ready. */646export function applyPwaUpdate(): boolean {647 return pwaController.applyUpdate();648}649650export function registerProductionServiceWorker(): void {651 pwaController.initialize();652}