clone

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

feat/prf-provision-custody
lofi / package / runtime / pwa.ts
pwa.ts 19.1K
1/// <reference path="./env.d.ts" />2// Package-owned PWA lifecycle.3/** Service-worker lifecycle states exposed to application UI. */4export type PwaWorkerState =5 | "development-disabled"6 | "unsupported"7 | "registering"8 | "ready"9 | "failed";1011/** Browser installation states exposed to application UI. */12export type PwaInstallState =13 | "installed"14 | "available"15 | "prompting"16 | "accepted"17 | "dismissed"18 | "manual-ios"19 | "manual-browser"20 | "unsupported";2122/** Foreground update-check and waiting-worker states exposed to application UI. */23export type PwaUpdateState =24 | "idle"25 | "checking"26 | "installing"27 | "ready"28 | "applying"29 | "failed";3031/** Stable categories for recoverable offline/PWA failures. */32export type PwaFailureCode =33 | "registration"34 | "installation"35 | "install-prompt"36 | "update-check"37 | "precache"38 | "runtime-cache";3940/** Current install, service-worker, and offline-cache state. */41export type PwaState = {42 worker: PwaWorkerState;43 install: PwaInstallState;44 update: PwaUpdateState;45 failure?: {46 code: PwaFailureCode;47 message: string;48 };49};5051/** Chromium install event retained until application UI requests the prompt. */52export type InstallPromptEvent = Event & {53 prompt(): Promise<void>;54 userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;55};5657/** Browser signals used to classify installed and manual-install experiences. */58export type InstallEnvironment = {59 displayModeStandalone: boolean;60 navigatorStandalone: boolean;61 platform: string;62 maxTouchPoints: number;63 secureContext: boolean;64 serviceWorkerSupported: boolean;65};6667type LofiPwaGlobal = typeof globalThis & { __LOFI_PWA_STATE__?: PwaState };6869const actionableFailureMessages: Record<PwaFailureCode, string> = {70 registration:71 "Offline support could not start. Reload on a stable connection; if it continues, clear this site's storage and retry.",72 installation:73 "The offline update could not install. Reconnect, keep this tab open, and reload to try again.",74 "install-prompt":75 "The browser install prompt could not open. Use the browser menu if it offers Install app or Add to Home Screen.",76 "update-check":77 "The app could not check for an update. Reconnect and bring it to the foreground to try again.",78 precache:79 "Required offline files were not saved. Reconnect, reload, and wait for offline support before disconnecting.",80 "runtime-cache":81 "A recently opened file was not saved for offline use. Reconnect and open it again before going offline.",82};8384/** Returns actionable, non-technical recovery guidance for a PWA failure. */85export function pwaFailureMessage(code: PwaFailureCode): string {86 return actionableFailureMessages[code];87}8889/** Injectable browser surfaces used to test the PWA lifecycle deterministically. */90export type PwaControllerDependencies = {91 readonly eventTarget?: () => EventTarget;92 readonly visibilityTarget?: () => EventTarget;93 readonly serviceWorker?: () => ServiceWorkerContainer | undefined;94 readonly installEnvironment?: () => InstallEnvironment;95 readonly isVisible?: () => boolean;96 readonly now?: () => number;97 readonly setTimeout?: (callback: () => void, milliseconds: number) => unknown;98 readonly clearTimeout?: (handle: unknown) => void;99 /** Minimum time between foreground update checks; defaults to one minute. */100 readonly updateCheckIntervalMs?: number;101 /** Maximum time exposed as an active update check; defaults to ten seconds. */102 readonly updateCheckTimeoutMs?: number;103 readonly production?: () => boolean;104 /** Absolute URL of the configured deployment base, independent of the current route. */105 readonly deploymentBaseUrl?: () => string;106 /** @deprecated Supply deploymentBaseUrl when injecting browser location in tests. */107 readonly reload?: () => void;108 readonly exposeState?: (state: PwaState) => void;109};110111/** Stateful controller for browser installation and service-worker updates. */112export type PwaController = {113 getState(): PwaState;114 subscribe(subscriber: (state: PwaState) => void): () => void;115 requestInstall(): Promise<PwaInstallState>;116 checkForUpdate(): Promise<boolean>;117 applyUpdate(): boolean;118 initialize(): void;119};120121/** Resolves the worker URL and scope from one configured application base. */122export function resolvePwaResources(deploymentBaseUrl: string): {123 workerUrl: URL;124 scope: string;125} {126 const base = new URL(deploymentBaseUrl);127 if (!base.pathname.endsWith("/")) base.pathname += "/";128 base.search = "";129 base.hash = "";130 return { workerUrl: new URL("sw.js", base), scope: base.pathname };131}132133export function classifyInstallExperience(environment: InstallEnvironment): PwaInstallState {134 if (environment.displayModeStandalone || environment.navigatorStandalone) return "installed";135 const iosLike = /^(iPhone|iPad|iPod)$/.test(environment.platform) ||136 (environment.platform === "MacIntel" && environment.maxTouchPoints > 1);137 if (iosLike && environment.secureContext) return "manual-ios";138 return environment.secureContext && environment.serviceWorkerSupported139 ? "manual-browser"140 : "unsupported";141}142143export function waitForActivation(worker: ServiceWorker): Promise<void> {144 if (worker.state === "activated") return Promise.resolve();145 if (worker.state === "redundant") return Promise.reject(new Error("service worker is redundant"));146147 return new Promise((resolve, reject) => {148 const onStateChange = () => {149 if (worker.state === "activated") {150 worker.removeEventListener("statechange", onStateChange);151 resolve();152 } else if (worker.state === "redundant") {153 worker.removeEventListener("statechange", onStateChange);154 reject(new Error("service worker installation failed"));155 }156 };157 worker.addEventListener("statechange", onStateChange);158 onStateChange();159 });160}161162/** Creates an isolated PWA controller, primarily for custom integration and tests. */163export function createPwaController(dependencies: PwaControllerDependencies = {}): PwaController {164 const subscribers = new Set<(state: PwaState) => void>();165 const observedUpdateWorkers = new WeakSet<ServiceWorker>();166 let deferredInstallPrompt: InstallPromptEvent | undefined;167 let waitingWorker: ServiceWorker | undefined;168 let activeRegistration: ServiceWorkerRegistration | undefined;169 let activeContainer: ServiceWorkerContainer | undefined;170 let updateCheck: Promise<boolean> | null = null;171 let lastUpdateCheckAt = Number.NEGATIVE_INFINITY;172 let reloadForUpdate = false;173 let foregroundChecksAttached = false;174 let initialized = false;175 let state: PwaState = { worker: "registering", install: "unsupported", update: "idle" };176177 const publish = (next: PwaState) => {178 state = next;179 dependencies.exposeState?.(state);180 for (const subscriber of subscribers) subscriber(state);181 };182 const update = (patch: Partial<PwaState>) => publish({ ...state, ...patch });183 const failure = (code: PwaFailureCode) => ({ code, message: pwaFailureMessage(code) });184 const clearFailure = (...codes: PwaFailureCode[]) =>185 state.failure && codes.includes(state.failure.code) ? undefined : state.failure;186 const failWorker = (code: "registration" | "installation" | "precache") => {187 update({ worker: "failed", failure: { code, message: pwaFailureMessage(code) } });188 };189 const failUpdate = (code: "installation" | "update-check") => {190 update({ update: "failed", failure: failure(code) });191 };192 const target = () => dependencies.eventTarget?.() ?? globalThis;193 const visibilityTarget = () =>194 dependencies.visibilityTarget?.() ??195 (typeof document !== "undefined" ? document : target());196 const serviceWorker = () =>197 dependencies.serviceWorker?.() ??198 (typeof navigator !== "undefined" && "serviceWorker" in navigator199 ? navigator.serviceWorker200 : undefined);201 const installEnvironment = () =>202 dependencies.installEnvironment?.() ?? {203 displayModeStandalone: matchMedia("(display-mode: standalone)").matches,204 navigatorStandalone: (navigator as Navigator & { standalone?: boolean }).standalone === true,205 platform: navigator.platform,206 maxTouchPoints: navigator.maxTouchPoints,207 secureContext: globalThis.isSecureContext,208 serviceWorkerSupported: "serviceWorker" in navigator,209 };210 const isVisible = () =>211 dependencies.isVisible?.() ??212 (typeof document === "undefined" || document.visibilityState === "visible");213 const now = () => dependencies.now?.() ?? Date.now();214 const scheduleTimeout = (callback: () => void, milliseconds: number) =>215 dependencies.setTimeout216 ? dependencies.setTimeout(callback, milliseconds)217 : globalThis.setTimeout(callback, milliseconds);218 const cancelTimeout = (handle: unknown) => {219 if (dependencies.clearTimeout) dependencies.clearTimeout(handle);220 else globalThis.clearTimeout(handle as number);221 };222 const updateCheckIntervalMs = dependencies.updateCheckIntervalMs ?? 60_000;223 const updateCheckTimeoutMs = dependencies.updateCheckTimeoutMs ?? 10_000;224225 const boundedRegistrationUpdate = (226 registration: ServiceWorkerRegistration,227 ): Promise<ServiceWorkerRegistration> =>228 new Promise((resolve, reject) => {229 let settled = false;230 let timeoutHandle: unknown = undefined;231 const finish = () => {232 if (settled) return false;233 settled = true;234 if (timeoutHandle !== undefined) cancelTimeout(timeoutHandle);235 return true;236 };237 timeoutHandle = scheduleTimeout(238 () => {239 if (finish()) reject(new Error("service worker update check timed out"));240 },241 updateCheckTimeoutMs,242 );243 Promise.resolve().then(() => registration.update()).then(244 (next) => {245 if (finish()) resolve(next);246 },247 (error) => {248 if (finish()) reject(error instanceof Error ? error : new Error(String(error)));249 },250 );251 });252253 const checkForUpdate = (): Promise<boolean> => {254 const registration = activeRegistration;255 const container = activeContainer;256 if (!registration || !container?.controller || state.worker !== "ready") {257 return Promise.resolve(false);258 }259 if (260 state.update === "installing" || state.update === "ready" || state.update === "applying"261 ) {262 return Promise.resolve(false);263 }264 if (updateCheck) return updateCheck;265 const checkedAt = now();266 if (checkedAt - lastUpdateCheckAt < updateCheckIntervalMs) return Promise.resolve(false);267 lastUpdateCheckAt = checkedAt;268 update({269 update: "checking",270 failure: clearFailure("update-check"),271 });272 const operation = boundedRegistrationUpdate(registration).then(273 () => {274 if (state.update === "checking") {275 update({ update: "idle", failure: clearFailure("update-check") });276 }277 return true;278 },279 () => {280 if (state.update === "checking") failUpdate("update-check");281 return true;282 },283 );284 const tracked = operation.finally(() => {285 if (updateCheck === tracked) updateCheck = null;286 });287 updateCheck = tracked;288 return tracked;289 };290291 const requestUpdateFromEvent = () => {292 void checkForUpdate();293 };294 const onPageShow = (event: Event) => {295 if ((event as PageTransitionEvent).persisted === true) requestUpdateFromEvent();296 };297 const onVisibilityChange = () => {298 if (isVisible()) requestUpdateFromEvent();299 };300 const attachForegroundChecks = () => {301 if (foregroundChecksAttached) return;302 foregroundChecksAttached = true;303 target().addEventListener("pageshow", onPageShow);304 target().addEventListener("online", requestUpdateFromEvent);305 visibilityTarget().addEventListener("visibilitychange", onVisibilityChange);306 };307308 const watchForUpdate = (309 registration: ServiceWorkerRegistration,310 container: ServiceWorkerContainer,311 ) => {312 const exposeWaitingWorker = (worker: ServiceWorker) => {313 waitingWorker = worker;314 update({315 worker: "ready",316 update: "ready",317 failure: clearFailure("installation", "update-check"),318 });319 };320 const observeInstallingWorker = (worker: ServiceWorker) => {321 if (observedUpdateWorkers.has(worker)) return;322 observedUpdateWorkers.add(worker);323 update({324 worker: "ready",325 update: "installing",326 failure: clearFailure("installation", "update-check"),327 });328 const onStateChange = () => {329 if (worker.state === "installed") {330 exposeWaitingWorker(registration.waiting ?? worker);331 } else if (worker.state === "activated") {332 update({ worker: "ready", update: "idle" });333 } else if (worker.state === "redundant") {334 failUpdate("installation");335 }336 };337 worker.addEventListener("statechange", onStateChange);338 onStateChange();339 };340341 if (registration.waiting && container.controller) exposeWaitingWorker(registration.waiting);342 registration.addEventListener("updatefound", () => {343 const worker = registration.installing;344 if (worker && container.controller) observeInstallingWorker(worker);345 });346 if (registration.installing && container.controller) {347 observeInstallingWorker(registration.installing);348 }349 };350351 const watchInstallExperience = () => {352 update({ install: classifyInstallExperience(installEnvironment()) });353 target().addEventListener("beforeinstallprompt", (event) => {354 event.preventDefault();355 deferredInstallPrompt = event as InstallPromptEvent;356 update({ install: "available", failure: clearFailure("install-prompt") });357 });358 target().addEventListener("appinstalled", () => {359 deferredInstallPrompt = undefined;360 update({ install: "installed", failure: clearFailure("install-prompt") });361 });362 };363364 const initialize = () => {365 if (initialized) return;366 initialized = true;367 watchInstallExperience();368 if (!(dependencies.production?.() ?? import.meta.env.PROD)) {369 update({ worker: "development-disabled" });370 return;371 }372 const container = serviceWorker();373 if (!container) {374 update({ worker: "unsupported" });375 return;376 }377 update({ worker: "registering" });378 container.addEventListener("message", (event) => {379 const message = (event as MessageEvent).data as { type?: string; code?: PwaFailureCode };380 if (381 message?.type === "LOFI_PWA_FAILURE" &&382 (message.code === "precache" || message.code === "runtime-cache")383 ) {384 if (message.code === "precache") failWorker("precache");385 else update({ failure: failure("runtime-cache") });386 }387 });388 // A controller change means a new worker took over this tab. Every389 // controlled tab must reload — the new worker prunes the old revision's390 // caches, so a document kept on the old HTML/module graph goes stale —391 // not just the tab whose applyUpdate() initiated the change. The first392 // claim of a previously uncontrolled page does not reload, and a document393 // reloads at most once, so a misbehaving worker cannot cause a loop.394 let hadController = Boolean(container.controller);395 let reloadedForController = false;396 container.addEventListener("controllerchange", () => {397 const wasControlled = hadController;398 hadController = true;399 const initiated = reloadForUpdate;400 reloadForUpdate = false;401 if (!initiated && !wasControlled) return;402 if (reloadedForController) return;403 reloadedForController = true;404 waitingWorker = undefined;405 update({406 worker: "ready",407 update: "idle",408 failure: clearFailure("installation", "update-check"),409 });410 if (dependencies.reload) dependencies.reload();411 else location.reload();412 });413 const deploymentBaseUrl = dependencies.deploymentBaseUrl?.() ??414 new URL(import.meta.env.BASE_URL, document.location.origin).href;415 const { workerUrl, scope } = resolvePwaResources(deploymentBaseUrl);416 void (async () => {417 const registration = await container.register(workerUrl, {418 scope,419 });420 activeRegistration = registration;421 activeContainer = container;422 watchForUpdate(registration, container);423 attachForegroundChecks();424 if (registration.waiting && container.controller) return;425 if (registration.installing && container.controller) return;426 const worker = registration.installing ?? registration.active ?? registration.waiting;427 if (!worker) throw new Error("service worker registration has no worker");428 try {429 await waitForActivation(worker);430 } catch {431 failWorker("installation");432 return;433 }434 update({ worker: "ready", update: "idle", failure: undefined });435 })().catch(() => failWorker("registration"));436 };437438 return {439 getState: () => state,440 subscribe(subscriber) {441 subscribers.add(subscriber);442 subscriber(state);443 return () => subscribers.delete(subscriber);444 },445 async requestInstall() {446 const prompt = deferredInstallPrompt;447 if (!prompt) return state.install;448 deferredInstallPrompt = undefined;449 update({ install: "prompting" });450 try {451 await prompt.prompt();452 const choice = await prompt.userChoice;453 const outcome = choice.outcome === "accepted" ? "accepted" : "dismissed";454 update({ install: outcome });455 return outcome;456 } catch {457 const fallback = classifyInstallExperience(installEnvironment());458 update({ install: fallback, failure: failure("install-prompt") });459 return fallback;460 }461 },462 checkForUpdate,463 applyUpdate() {464 if (!waitingWorker || state.update !== "ready") return false;465 reloadForUpdate = true;466 try {467 waitingWorker.postMessage({ type: "LOFI_SKIP_WAITING" });468 update({ update: "applying", failure: clearFailure("installation", "update-check") });469 return true;470 } catch {471 reloadForUpdate = false;472 failUpdate("installation");473 return false;474 }475 },476 initialize,477 };478}479480/** Shared controller used by the root runtime and optional Preact bindings. */481export const pwaController: PwaController = createPwaController({482 exposeState(next) {483 if (typeof document !== "undefined") (globalThis as LofiPwaGlobal).__LOFI_PWA_STATE__ = next;484 },485});486487/** Returns the shared controller's current state snapshot. */488export function getPwaState(): PwaState {489 return pwaController.getState();490}491492/** Subscribes to shared PWA state and returns an idempotent unsubscribe function. */493export function subscribePwaState(subscriber: (state: PwaState) => void): () => void {494 return pwaController.subscribe(subscriber);495}496497/** Requests the deferred browser installation prompt when one is available. */498export function requestPwaInstall(): Promise<PwaInstallState> {499 return pwaController.requestInstall();500}501502/**503 * Runs the shared controller's bounded update check when registration is ready.504 * Resolves true when a check ran or was already in flight; inspect `PwaState.update` for outcome.505 */506export function checkPwaUpdate(): Promise<boolean> {507 return pwaController.checkForUpdate();508}509510/** Activates a waiting service worker; returns false when no update is ready. */511export function applyPwaUpdate(): boolean {512 return pwaController.applyUpdate();513}514515export function registerProductionServiceWorker(): void {516 pwaController.initialize();517}