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 / storage-fork.ts
storage-fork.ts 16.3K
1/// <reference path="./env.d.ts" />2/**3 * The storage-container fork guard for installed web apps.4 *5 * WebKit gives an installed web app (Add to Home Screen on iOS and iPadOS,6 * Add to Dock on macOS) its own storage container: cookies are copied at7 * install time, but OPFS, IndexedDB, and localStorage are not. Local data8 * created in a browser tab before installing therefore stays in the browser's9 * container, and the installed app starts empty. Chromium installed apps share10 * the installing profile's storage and do not fork.11 *12 * The guard covers both sides of that boundary. In a browser tab it watches13 * for local-only data — writes that do not replicate because the user has not14 * elected sync — and, while any exists, maintains a per-app flag cookie, the15 * one signal that survives installation. In a standalone launch whose local16 * storage has never been touched, that inherited cookie identifies the fork,17 * and the guard surfaces a notice naming where the data lives and how to move18 * it: back up or sync in the browser, then restore in the installed app.19 *20 * Safari caps script-set cookies at seven days, so the flag is refreshed on21 * every boot while data is at risk; a long gap between the last browser visit22 * and installation loses the flag and, with it, the post-install notice. The23 * pre-install warning in the install guidance does not depend on the cookie.24 *25 * Zero configuration is safe: in development the guard is inert, and the26 * framework renders a minimal default notice that apps may replace via the27 * optional `pwa` block of `defineLofiApp` and the `useStorageFork` Preact28 * hook.29 *30 * @module31 */3233import { getLofiApp } from "./app.ts";34import { syncing } from "./config.ts";35import { anchorAppId } from "./data-sink.ts";36import type { RuntimeDiagnostics } from "./diagnostics.ts";37import type { InstallEnvironment } from "./pwa.ts";3839/** The storage-container fork state exposed through the guard and the Preact hook. */40export type StorageForkState =41 /** No verdict: development mode, or the guard has not started. */42 | { state: "unarmed"; reason: "inactive" | "development" }43 /** Nothing to warn about: no local-only data, or writes replicate. */44 | { state: "idle" }45 /** This browsing context holds local-only data; installing on iOS forks it. */46 | { state: "browser-data-at-risk" }47 /** A standalone launch into a fresh container while the flag cookie is present. */48 | { state: "fork-detected"; message: string };4950const forkDetectedMessage =51 "This installed app starts with its own empty storage — the data you created before " +52 "installing is still in Safari. Open the site in Safari, turn on sync or back up your " +53 "account there, then restore it here. Dismiss this to start fresh.";5455/** The diagnostics fields the guard reads to recognize real write activity. */56export type StorageForkDiagnostics = Pick<57 RuntimeDiagnostics,58 "storageState" | "localWaitCalls" | "journaledPendingWrites" | "lastWriteDurability"59>;6061/** The diagnostics feed the guard observes; the runtime supplies the default. */62export type StorageForkDiagnosticsSurface = {63 /** The current diagnostics snapshot. */64 get(): StorageForkDiagnostics;65 /** Subscribes to diagnostics changes; returns an unsubscribe function. */66 subscribe(listener: () => void): () => void;67};6869/** Injectable browser surfaces used to test the guard deterministically. */70export type StorageForkGuardDependencies = {71 readonly production?: () => boolean;72 readonly appId?: () => string;73 readonly storage?: () => Pick<Storage, "getItem" | "setItem"> | undefined;74 /** Every localStorage key in this container, for the freshness snapshot. */75 readonly storageKeys?: () => readonly string[];76 readonly readCookies?: () => string;77 readonly writeCookie?: (cookie: string) => void;78 /** The cookie `Path` attribute; defaults to the deployment base path. */79 readonly cookiePath?: () => string;80 readonly secureContext?: () => boolean;81 readonly environment?: () => Pick<82 InstallEnvironment,83 "displayModeStandalone" | "navigatorStandalone"84 >;85 readonly syncing?: () => boolean;86 /** The write-activity feed; defaults to the runtime's diagnostics, loaded lazily. */87 readonly diagnostics?: () => StorageForkDiagnosticsSurface;88 /** Whether any declared table holds a locally persisted row (pre-guard data). */89 readonly probeLocalRows?: () => Promise<boolean>;90 /** Target for cross-tab `storage` events; defaults to the window. */91 readonly storageEvents?: () => EventTarget | undefined;92};9394/** The storage-container fork guard for one browsing context. */95export type StorageForkGuard = {96 /** Starts the guard once; later calls are no-ops. */97 start(): void;98 /** The current fork state snapshot. */99 getState(): StorageForkState;100 /** Subscribes to state changes; the listener runs immediately. */101 subscribe(listener: (state: StorageForkState) => void): () => void;102 /** Acknowledges a detected fork: clears the flag cookie and settles idle. */103 dismissFork(): void;104};105106function defaultStorageKeys(): readonly string[] {107 try {108 if (typeof localStorage === "undefined") return [];109 const keys: string[] = [];110 for (let index = 0; index < localStorage.length; index += 1) {111 const key = localStorage.key(index);112 if (key !== null) keys.push(key);113 }114 return keys;115 } catch {116 return [];117 }118}119120function defaultCookiePath(): string {121 try {122 const base = new URL(import.meta.env.BASE_URL, document.location.origin);123 return base.pathname.endsWith("/") ? base.pathname : `${base.pathname}/`;124 } catch {125 return "/";126 }127}128129function defaultEnvironment(): Pick<130 InstallEnvironment,131 "displayModeStandalone" | "navigatorStandalone"132> {133 try {134 return {135 displayModeStandalone: matchMedia("(display-mode: standalone)").matches,136 navigatorStandalone: (navigator as Navigator & { standalone?: boolean }).standalone === true,137 };138 } catch {139 return { displayModeStandalone: false, navigatorStandalone: false };140 }141}142143/**144 * Creates an isolated storage-fork guard over injectable browser surfaces.145 * The package-wide wrappers (`getStorageForkState`, `subscribeStorageFork`,146 * `dismissStorageFork`) read the shared {@link storageForkGuard} that boot147 * arms; this factory exists for isolated instances — deterministic tests and148 * custom hosts that manage their own guard.149 */150export function createStorageForkGuard(151 dependencies: StorageForkGuardDependencies = {},152): StorageForkGuard {153 const subscribers = new Set<(state: StorageForkState) => void>();154 let state: StorageForkState = { state: "unarmed", reason: "inactive" };155 let started = false;156 let probed = false;157 // In-memory marker fallback so a private-mode storage failure still warns158 // for the current document.159 let markerMemory = false;160 let lastCookieVerdict: boolean | null = null;161162 const appId = () => dependencies.appId?.() ?? anchorAppId;163 const storage = () => {164 if (dependencies.storage) return dependencies.storage();165 try {166 return typeof localStorage === "undefined" ? undefined : localStorage;167 } catch {168 return undefined;169 }170 };171 const storageKeys = () => {172 try {173 return dependencies.storageKeys?.() ?? defaultStorageKeys();174 } catch {175 return [];176 }177 };178 const readCookies = () => {179 try {180 if (dependencies.readCookies) return dependencies.readCookies();181 return typeof document === "undefined" ? "" : document.cookie;182 } catch {183 return "";184 }185 };186 const writeCookie = (cookie: string) => {187 try {188 if (dependencies.writeCookie) dependencies.writeCookie(cookie);189 else if (typeof document !== "undefined") document.cookie = cookie;190 } catch {191 // A blocked cookie write must not affect a working runtime.192 }193 };194 const cookiePath = () => dependencies.cookiePath?.() ?? defaultCookiePath();195 const secureContext = () => dependencies.secureContext?.() ?? globalThis.isSecureContext === true;196 const environment = () => {197 try {198 return dependencies.environment?.() ?? defaultEnvironment();199 } catch {200 return { displayModeStandalone: false, navigatorStandalone: false };201 }202 };203 const syncingNow = () => {204 try {205 return dependencies.syncing?.() ?? syncing();206 } catch {207 return false;208 }209 };210211 const markerKey = () => `lofi:local-data:${appId()}`;212 const cookieName = () => `lofi-fork-${appId().replace(/[^A-Za-z0-9_-]/g, "_")}`;213214 const publish = (next: StorageForkState) => {215 state = next;216 for (const subscriber of subscribers) subscriber(state);217 };218219 const hasMarker = () => {220 if (markerMemory) return true;221 try {222 return storage()?.getItem(markerKey()) === "1";223 } catch {224 return false;225 }226 };227228 const setMarker = () => {229 markerMemory = true;230 try {231 storage()?.setItem(markerKey(), "1");232 } catch {233 // The in-memory fallback still covers this document.234 }235 };236237 const cookiePresent = () =>238 readCookies().split(";").some((part) => part.trim().startsWith(`${cookieName()}=`));239240 const cookieAttributes = () =>241 `Path=${cookiePath()}; SameSite=Lax${secureContext() ? "; Secure" : ""}`;242243 const maintainCookie = (atRisk: boolean) => {244 if (lastCookieVerdict === atRisk) return;245 lastCookieVerdict = atRisk;246 writeCookie(247 atRisk248 ? `${cookieName()}=1; ${cookieAttributes()}; Max-Age=31536000`249 : `${cookieName()}=; ${cookieAttributes()}; Max-Age=0`,250 );251 };252253 const evaluate = () => {254 // A detected fork stands until the user dismisses it: cookie maintenance255 // must not clear the inherited flag before the notice has been seen.256 if (state.state === "fork-detected") return;257 const atRisk = hasMarker() && !syncingNow();258 maintainCookie(atRisk);259 publish(atRisk ? { state: "browser-data-at-risk" } : { state: "idle" });260 };261262 const observeDiagnostics = (263 surface: StorageForkDiagnosticsSurface,264 probeLocalRows: () => Promise<boolean>,265 ) => {266 const check = () => {267 if (hasMarker()) {268 evaluate();269 return;270 }271 const diagnostics = surface.get();272 const wrote = diagnostics.localWaitCalls > 0 ||273 diagnostics.journaledPendingWrites > 0 ||274 diagnostics.lastWriteDurability === "local" ||275 diagnostics.lastWriteDurability === "global";276 if (wrote) {277 setMarker();278 evaluate();279 return;280 }281 // Data written before the guard existed leaves no marker; one bounded282 // probe answers for it. Waiting for the open driver guarantees the283 // probe reuses the runtime rather than causing account creation.284 if (!probed && diagnostics.storageState === "persistent-driver-open") {285 probed = true;286 void probeLocalRows().then((found) => {287 if (found) {288 setMarker();289 evaluate();290 }291 }).catch(() => undefined);292 }293 };294 surface.subscribe(check);295 check();296 };297298 const attachDiagnostics = () => {299 if (dependencies.diagnostics) {300 const probe = dependencies.probeLocalRows ?? (() => Promise.resolve(false));301 observeDiagnostics(dependencies.diagnostics(), probe);302 return;303 }304 void import("./runtime.ts").then((runtime) => {305 observeDiagnostics(306 { get: runtime.getRuntimeDiagnostics, subscribe: runtime.subscribeRuntimeDiagnostics },307 dependencies.probeLocalRows ?? runtime.hasAnyLocalRows,308 );309 }).catch(() => undefined);310 };311312 const attachStorageEvents = () => {313 const target = dependencies.storageEvents314 ? dependencies.storageEvents()315 : (typeof window === "undefined" ? undefined : window);316 // Another tab electing sync or writing first data changes this tab's317 // verdict in place; every runtime key is suffixed with the app id.318 target?.addEventListener("storage", (event) => {319 const key = (event as StorageEvent).key;320 if (typeof key !== "string" || key.endsWith(`:${appId()}`)) evaluate();321 });322 };323324 return {325 start() {326 if (started) return;327 started = true;328 if (!(dependencies.production?.() ?? import.meta.env.PROD)) {329 publish({ state: "unarmed", reason: "development" });330 return;331 }332 // The freshness snapshot must precede any runtime writes: a container333 // is fresh only if no lofi key for this app has ever been stored.334 const fresh = !storageKeys().some((key) =>335 key.startsWith("lofi:") && key.endsWith(`:${appId()}`)336 );337 const standalone = (() => {338 const env = environment();339 return env.displayModeStandalone || env.navigatorStandalone;340 })();341 attachStorageEvents();342 if (standalone && fresh && cookiePresent()) {343 publish({ state: "fork-detected", message: forkDetectedMessage });344 } else {345 evaluate();346 }347 attachDiagnostics();348 },349 getState: () => state,350 subscribe(listener) {351 subscribers.add(listener);352 listener(state);353 return () => subscribers.delete(listener);354 },355 dismissFork() {356 if (state.state !== "fork-detected") return;357 state = { state: "idle" };358 lastCookieVerdict = null;359 evaluate();360 },361 };362}363364/** Shared guard used by boot and the optional Preact bindings. */365export const storageForkGuard: StorageForkGuard = createStorageForkGuard();366367/** Returns the shared guard's current fork state snapshot. */368export function getStorageForkState(): StorageForkState {369 return storageForkGuard.getState();370}371372/** Subscribes to shared fork state; returns an unsubscribe function. */373export function subscribeStorageFork(374 listener: (state: StorageForkState) => void,375): () => void {376 return storageForkGuard.subscribe(listener);377}378379/** Acknowledges a detected fork on the shared guard. */380export function dismissStorageFork(): void {381 storageForkGuard.dismissFork();382}383384/** One-line label for diagnostic surfaces (development inspector, DeviceStatus). */385export function describeStorageFork(state: StorageForkState): string {386 switch (state.state) {387 case "unarmed":388 return state.reason === "development" ? "not checked (development)" : "not checked";389 case "idle":390 return "no local-only data at risk";391 case "browser-data-at-risk":392 return "local-only data — not backed up";393 case "fork-detected":394 return "fresh install — previous data is in the browser";395 }396}397398// ---------------------------------------------------------------------------399// Default notice: the zero-configuration surface for a detected fork. A plain400// DOM element (no UI framework) so it works in any app; authors opt out with401// `pwa: { forkNotice: "none" }` and render their own from useStorageFork.402// ---------------------------------------------------------------------------403404const noticeClass = "lofi-storage-fork-banner";405406function renderDefaultNotice(state: StorageForkState): void {407 const existing = document.querySelector(`.${noticeClass}`);408 if (state.state !== "fork-detected") {409 existing?.remove();410 return;411 }412 const notice = existing ?? document.createElement("div");413 notice.textContent = "";414 notice.className = noticeClass;415 notice.setAttribute("role", "alert");416 (notice as HTMLElement).style.cssText =417 "position:fixed;inset:auto 0 0 0;z-index:2147483000;display:flex;gap:0.75rem;" +418 "align-items:center;justify-content:center;flex-wrap:wrap;padding:0.75rem 1rem;" +419 "background:#1f2430;color:#fff;font:14px/1.4 system-ui,sans-serif;text-align:center;";420 const message = document.createElement("span");421 message.textContent = state.message;422 notice.append(message);423 const button = document.createElement("button");424 button.type = "button";425 button.textContent = "Dismiss";426 button.style.cssText =427 "padding:0.35rem 0.9rem;border:1px solid #fff;border-radius:0.4rem;background:none;" +428 "color:inherit;font:inherit;cursor:pointer;";429 button.addEventListener("click", () => storageForkGuard.dismissFork());430 notice.append(button);431 if (!existing) document.body.append(notice);432}433434/**435 * Mounts the framework's minimal fork notice unless the app opted out.436 * Called once by boot; safe without a defined app (defaults apply).437 */438export function mountDefaultForkNotice(): void {439 if (typeof document === "undefined") return;440 let preference: "default" | "none" = "default";441 try {442 preference = getLofiApp().pwa?.forkNotice ?? "default";443 } catch {444 // No app definition yet; the safe default applies.445 }446 if (preference === "none") return;447 storageForkGuard.subscribe(renderDefaultNotice);448}