@noy-db/in-pwa 0.6.0 → 0.7.0-pre.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { NoydbStore } from '@noy-db/hub';
1
+ import { NoydbStore } from '@noy-db/hub/to';
2
2
 
3
3
  /**
4
4
  * **@noy-db/in-pwa** — installable/offline shell helpers for noy-db SPAs.
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/in-pwa** — installable/offline shell helpers for noy-db SPAs.\n *\n * The premise: **the PWA starts empty.** First open in its own storage\n * partition runs online enrollment and hydrates from the firm cloud\n * store; from then on the installed app is the offline-capable home of\n * the vault (data in `to-browser-idb`, app shell in the SW cache — see\n * the README recipe). This package ships the browser-shell plumbing\n * around that lifecycle:\n *\n * - {@link requestPersistence} — `navigator.storage.persist()` +\n * `estimate()` with a clear grant/deny signal. Never throws.\n * - {@link guardLocalVault} / {@link probeLocalVault} — detect a\n * wiped/missing local store at boot and **fail closed** into the\n * re-enrollment flow. Never a crash, never a silent empty vault\n * presented as truth.\n * - {@link captureInstallPrompt}, {@link getDisplayContext},\n * {@link isIosSafari} — install UX helpers.\n * - {@link watchOnline} — online/offline wiring shaped for the sync\n * engine's `isOnline` flag.\n *\n * No service worker runtime is shipped — the SW is a copy-able recipe\n * in the README. This package holds no keys and sees no plaintext.\n *\n * @packageDocumentation\n */\n\nimport type { NoydbStore } from '@noy-db/hub'\n\n// ---------------------------------------------------------------------------\n// Shared app-shell context contract (also consumed/mirrored by @noy-db/in-liff)\n// ---------------------------------------------------------------------------\n\n/**\n * The three shells one noy-db SPA can boot in. Shared contract with\n * `@noy-db/in-liff` (which detects `'liff'`); this package's\n * {@link getDisplayContext} distinguishes the other two. A plain string\n * union — no runtime coupling between the packages.\n */\nexport type AppShellContext = 'liff' | 'browser' | 'pwa'\n\n// ---------------------------------------------------------------------------\n// Storage persistence\n// ---------------------------------------------------------------------------\n\n/** Result of {@link requestPersistence}. */\nexport interface PersistenceResult {\n /** True when the origin's storage is durable (not eviction-eligible). */\n persisted: boolean\n /** `navigator.storage.estimate().quota`, when the browser reports it. */\n quota?: number\n /** `navigator.storage.estimate().usage`, when the browser reports it. */\n usage?: number\n /**\n * How the answer was reached:\n * - `'already'` — the origin was persistent before this call.\n * - `'granted'` — `persist()` was requested and the browser granted it.\n * - `'denied'` — `persist()` was requested and the browser declined.\n * - `'unsupported'` — no Storage API on this browser (or it errored).\n */\n grantedBy: 'already' | 'granted' | 'denied' | 'unsupported'\n}\n\n/**\n * Ask the browser to mark this origin's storage as persistent and report\n * quota/usage. Eviction of the local vault is the top PWA risk (iOS ITP\n * evicts script-writable storage of non-installed web content after ~7\n * days of disuse; installed home-screen apps are safer) — call this\n * during enrollment and surface a warning UI on `'denied'`.\n *\n * Never throws: unsupported browsers resolve to\n * `{ persisted: false, grantedBy: 'unsupported' }`.\n */\nexport async function requestPersistence(): Promise<PersistenceResult> {\n const storage = (globalThis as { navigator?: { storage?: StorageManager } }).navigator?.storage\n if (!storage || typeof storage.persist !== 'function') {\n return { persisted: false, grantedBy: 'unsupported' }\n }\n\n let persisted = false\n let grantedBy: PersistenceResult['grantedBy']\n try {\n const already = typeof storage.persisted === 'function' ? await storage.persisted() : false\n if (already) {\n persisted = true\n grantedBy = 'already'\n } else {\n persisted = await storage.persist()\n grantedBy = persisted ? 'granted' : 'denied'\n }\n } catch {\n // A throwing Storage API is indistinguishable from an absent one\n // for the caller's purposes — report unsupported, never throw.\n return { persisted: false, grantedBy: 'unsupported' }\n }\n\n const result: PersistenceResult = { persisted, grantedBy }\n if (typeof storage.estimate === 'function') {\n try {\n const est = await storage.estimate()\n if (typeof est.quota === 'number') result.quota = est.quota\n if (typeof est.usage === 'number') result.usage = est.usage\n } catch {\n // estimate() failing must not mask the persistence answer.\n }\n }\n return result\n}\n\n// ---------------------------------------------------------------------------\n// Eviction guard\n// ---------------------------------------------------------------------------\n\n/** Outcome of {@link probeLocalVault}. */\nexport type VaultPresence =\n | {\n present: true\n /**\n * What proved presence: `'keyring'` — the vault's `_keyring`\n * marker records exist (every encrypted vault persists one at\n * creation); `'envelopes'` — no keyring (plaintext-mode vault)\n * but `loadAll()` returned at least one envelope.\n */\n via: 'keyring' | 'envelopes'\n }\n | {\n present: false\n /**\n * `'empty'` — the store answered and holds nothing for this\n * vault (wiped/evicted/never enrolled); `'probe-failed'` — the\n * store itself errored. Both fail closed.\n */\n reason: 'empty' | 'probe-failed'\n /** The underlying error when `reason` is `'probe-failed'`. */\n cause?: unknown\n }\n\n/**\n * Cheap, store-agnostic probe: is a local vault actually present in\n * `store`? Works against any `NoydbStore` (the 6-method contract from\n * `@noy-db/hub/to`) — it never touches `to-browser-idb` internals.\n *\n * Presence check, in order:\n * 1. `store.list(vaultId, '_keyring')` — an encrypted vault always\n * persists its owner keyring record at creation, so a non-empty\n * `_keyring` collection is the same marker the hub itself uses to\n * decide a vault is provisioned. One tiny `list()` call.\n * 2. Fallback for plaintext-mode vaults (no keyring): `loadAll()` —\n * any envelope in any collection counts as present.\n *\n * Never throws — a store error resolves to\n * `{ present: false, reason: 'probe-failed', cause }`.\n */\nexport async function probeLocalVault(store: NoydbStore, vaultId: string): Promise<VaultPresence> {\n try {\n const keyringIds = await store.list(vaultId, '_keyring')\n if (keyringIds.length > 0) return { present: true, via: 'keyring' }\n\n const snapshot = await store.loadAll(vaultId)\n for (const records of Object.values(snapshot)) {\n if (records && Object.keys(records).length > 0) {\n return { present: true, via: 'envelopes' }\n }\n }\n return { present: false, reason: 'empty' }\n } catch (cause) {\n return { present: false, reason: 'probe-failed', cause }\n }\n}\n\n/** Result of {@link guardLocalVault}. */\nexport interface GuardResult {\n /** True iff the local vault is present. Never true on a failed probe. */\n healthy: boolean\n /** The underlying probe outcome. */\n presence: VaultPresence\n}\n\n/**\n * Boot-time eviction guard: probe the local store and **fail closed**\n * into re-enrollment when the vault is gone.\n *\n * - Vault present → `{ healthy: true }`; `onEvicted` is not called.\n * - Vault missing (wiped/evicted partition) **or the probe itself\n * failed** → `onEvicted` is invoked (and awaited) with the failure\n * detail, then `{ healthy: false }` is returned. A broken store is\n * treated exactly like a missing vault — the guard never presents an\n * empty or unreadable store as a healthy vault, and never throws\n * from the probe path.\n *\n * `onEvicted` is where the app routes to its re-enrollment flow (the\n * online re-invite via `@noy-db/on-oidc`); errors thrown by the handler\n * itself propagate to the caller.\n */\nexport async function guardLocalVault(\n store: NoydbStore,\n vaultId: string,\n onEvicted: (presence: Extract<VaultPresence, { present: false }>) => void | Promise<void>,\n): Promise<GuardResult> {\n const presence = await probeLocalVault(store, vaultId)\n if (presence.present) return { healthy: true, presence }\n await onEvicted(presence)\n return { healthy: false, presence }\n}\n\n// ---------------------------------------------------------------------------\n// Install UX helpers\n// ---------------------------------------------------------------------------\n\n/**\n * The `beforeinstallprompt` event shape (Chromium-only, not in the DOM\n * lib types).\n */\ninterface BeforeInstallPromptLike extends Event {\n prompt(): Promise<void>\n userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>\n}\n\n/** Handle returned by {@link captureInstallPrompt}. */\nexport interface CapturedInstallPrompt {\n /** True once a `beforeinstallprompt` event has been captured (and not yet spent). */\n readonly captured: boolean\n /**\n * Re-fire the deferred browser install prompt. Resolves to the user's\n * choice, or `'unavailable'` when no event was captured (iOS, already\n * installed, or the prompt was already spent — the browser fires it\n * at most once per capture).\n */\n promptInstall(): Promise<'accepted' | 'dismissed' | 'unavailable'>\n /** Remove the event listener and drop any captured prompt. */\n dispose(): void\n}\n\n/**\n * Capture the `beforeinstallprompt` event (Android/desktop Chromium) so\n * the app can show its own install UI and re-fire the prompt on a user\n * gesture. Call once at boot, before the browser fires the event.\n *\n * On browsers that never fire the event (iOS Safari — see\n * {@link isIosSafari} for the add-to-home-screen interstitial decision)\n * `promptInstall()` simply resolves `'unavailable'`.\n */\nexport function captureInstallPrompt(target?: EventTarget): CapturedInstallPrompt {\n const t = target ?? (globalThis as { window?: EventTarget }).window\n let deferred: BeforeInstallPromptLike | null = null\n\n const listener = (event: Event): void => {\n // Suppress the browser's own mini-infobar; the app re-fires the\n // prompt from its install UI instead.\n event.preventDefault()\n deferred = event as BeforeInstallPromptLike\n }\n t?.addEventListener('beforeinstallprompt', listener)\n\n return {\n get captured() {\n return deferred !== null\n },\n async promptInstall() {\n const event = deferred\n if (!event || typeof event.prompt !== 'function') return 'unavailable'\n deferred = null // the deferred prompt is single-use\n await event.prompt()\n const choice = await event.userChoice\n return choice.outcome\n },\n dispose() {\n t?.removeEventListener('beforeinstallprompt', listener)\n deferred = null\n },\n }\n}\n\n/**\n * Which shell the app is currently displayed in: `'pwa'` when running\n * standalone (installed — `display-mode: standalone` media query, or\n * iOS `navigator.standalone`), else `'browser'`. The `'liff'` value of\n * {@link AppShellContext} is detected by `@noy-db/in-liff`, not here.\n */\nexport function getDisplayContext(): Extract<AppShellContext, 'pwa' | 'browser'> {\n const g = globalThis as {\n matchMedia?: (query: string) => { matches: boolean }\n navigator?: { standalone?: boolean }\n }\n try {\n if (typeof g.matchMedia === 'function' && g.matchMedia('(display-mode: standalone)').matches) {\n return 'pwa'\n }\n } catch {\n // matchMedia throwing (non-browser host) means not standalone.\n }\n if (g.navigator?.standalone === true) return 'pwa'\n return 'browser'\n}\n\n/**\n * True on iOS Safari (including iPadOS masquerading as macOS), where\n * `beforeinstallprompt` never fires and installing means the share-sheet\n * \"Add to Home Screen\" flow — the signal for showing that interstitial.\n * Third-party iOS browsers (Chrome/Firefox/Edge/Opera shells) return\n * false.\n */\nexport function isIosSafari(): boolean {\n const nav = (globalThis as {\n navigator?: { userAgent?: string; platform?: string; maxTouchPoints?: number }\n }).navigator\n if (!nav) return false\n const ua = nav.userAgent ?? ''\n const iosDevice =\n /iPad|iPhone|iPod/.test(ua) || (nav.platform === 'MacIntel' && (nav.maxTouchPoints ?? 0) > 1)\n if (!iosDevice) return false\n return /Safari/.test(ua) && !/CriOS|FxiOS|EdgiOS|OPiOS|OPT\\//.test(ua)\n}\n\n// ---------------------------------------------------------------------------\n// Online/offline transitions\n// ---------------------------------------------------------------------------\n\n/**\n * Watch connectivity: invokes `callback` immediately with the current\n * `navigator.onLine` state, then on every `online`/`offline` event.\n * Returns an unsubscribe function.\n *\n * Shaped for feeding the sync engine's `isOnline` flag (this package\n * deliberately does not import the sync engine):\n *\n * ```ts\n * const stop = watchOnline((online) => syncStrategy.setOnline(online))\n * ```\n *\n * In hosts without a `window`/events (or without `navigator.onLine`)\n * the callback fires once with `true` (assume online) and the returned\n * unsubscribe is a no-op.\n */\nexport function watchOnline(\n callback: (online: boolean) => void,\n target?: EventTarget,\n): () => void {\n const g = globalThis as { window?: EventTarget; navigator?: { onLine?: boolean } }\n const t = target ?? g.window\n callback(g.navigator?.onLine ?? true)\n\n if (!t || typeof t.addEventListener !== 'function') return () => {}\n const onOnline = (): void => callback(true)\n const onOffline = (): void => callback(false)\n t.addEventListener('online', onOnline)\n t.addEventListener('offline', onOffline)\n return () => {\n t.removeEventListener('online', onOnline)\n t.removeEventListener('offline', onOffline)\n }\n}\n"],"mappings":";AAyEA,eAAsB,qBAAiD;AACrE,QAAM,UAAW,WAA4D,WAAW;AACxF,MAAI,CAAC,WAAW,OAAO,QAAQ,YAAY,YAAY;AACrD,WAAO,EAAE,WAAW,OAAO,WAAW,cAAc;AAAA,EACtD;AAEA,MAAI,YAAY;AAChB,MAAI;AACJ,MAAI;AACF,UAAM,UAAU,OAAO,QAAQ,cAAc,aAAa,MAAM,QAAQ,UAAU,IAAI;AACtF,QAAI,SAAS;AACX,kBAAY;AACZ,kBAAY;AAAA,IACd,OAAO;AACL,kBAAY,MAAM,QAAQ,QAAQ;AAClC,kBAAY,YAAY,YAAY;AAAA,IACtC;AAAA,EACF,QAAQ;AAGN,WAAO,EAAE,WAAW,OAAO,WAAW,cAAc;AAAA,EACtD;AAEA,QAAM,SAA4B,EAAE,WAAW,UAAU;AACzD,MAAI,OAAO,QAAQ,aAAa,YAAY;AAC1C,QAAI;AACF,YAAM,MAAM,MAAM,QAAQ,SAAS;AACnC,UAAI,OAAO,IAAI,UAAU,SAAU,QAAO,QAAQ,IAAI;AACtD,UAAI,OAAO,IAAI,UAAU,SAAU,QAAO,QAAQ,IAAI;AAAA,IACxD,QAAQ;AAAA,IAER;AAAA,EACF;AACA,SAAO;AACT;AA8CA,eAAsB,gBAAgB,OAAmB,SAAyC;AAChG,MAAI;AACF,UAAM,aAAa,MAAM,MAAM,KAAK,SAAS,UAAU;AACvD,QAAI,WAAW,SAAS,EAAG,QAAO,EAAE,SAAS,MAAM,KAAK,UAAU;AAElE,UAAM,WAAW,MAAM,MAAM,QAAQ,OAAO;AAC5C,eAAW,WAAW,OAAO,OAAO,QAAQ,GAAG;AAC7C,UAAI,WAAW,OAAO,KAAK,OAAO,EAAE,SAAS,GAAG;AAC9C,eAAO,EAAE,SAAS,MAAM,KAAK,YAAY;AAAA,MAC3C;AAAA,IACF;AACA,WAAO,EAAE,SAAS,OAAO,QAAQ,QAAQ;AAAA,EAC3C,SAAS,OAAO;AACd,WAAO,EAAE,SAAS,OAAO,QAAQ,gBAAgB,MAAM;AAAA,EACzD;AACF;AA0BA,eAAsB,gBACpB,OACA,SACA,WACsB;AACtB,QAAM,WAAW,MAAM,gBAAgB,OAAO,OAAO;AACrD,MAAI,SAAS,QAAS,QAAO,EAAE,SAAS,MAAM,SAAS;AACvD,QAAM,UAAU,QAAQ;AACxB,SAAO,EAAE,SAAS,OAAO,SAAS;AACpC;AAuCO,SAAS,qBAAqB,QAA6C;AAChF,QAAM,IAAI,UAAW,WAAwC;AAC7D,MAAI,WAA2C;AAE/C,QAAM,WAAW,CAAC,UAAuB;AAGvC,UAAM,eAAe;AACrB,eAAW;AAAA,EACb;AACA,KAAG,iBAAiB,uBAAuB,QAAQ;AAEnD,SAAO;AAAA,IACL,IAAI,WAAW;AACb,aAAO,aAAa;AAAA,IACtB;AAAA,IACA,MAAM,gBAAgB;AACpB,YAAM,QAAQ;AACd,UAAI,CAAC,SAAS,OAAO,MAAM,WAAW,WAAY,QAAO;AACzD,iBAAW;AACX,YAAM,MAAM,OAAO;AACnB,YAAM,SAAS,MAAM,MAAM;AAC3B,aAAO,OAAO;AAAA,IAChB;AAAA,IACA,UAAU;AACR,SAAG,oBAAoB,uBAAuB,QAAQ;AACtD,iBAAW;AAAA,IACb;AAAA,EACF;AACF;AAQO,SAAS,oBAAiE;AAC/E,QAAM,IAAI;AAIV,MAAI;AACF,QAAI,OAAO,EAAE,eAAe,cAAc,EAAE,WAAW,4BAA4B,EAAE,SAAS;AAC5F,aAAO;AAAA,IACT;AAAA,EACF,QAAQ;AAAA,EAER;AACA,MAAI,EAAE,WAAW,eAAe,KAAM,QAAO;AAC7C,SAAO;AACT;AASO,SAAS,cAAuB;AACrC,QAAM,MAAO,WAEV;AACH,MAAI,CAAC,IAAK,QAAO;AACjB,QAAM,KAAK,IAAI,aAAa;AAC5B,QAAM,YACJ,mBAAmB,KAAK,EAAE,KAAM,IAAI,aAAa,eAAe,IAAI,kBAAkB,KAAK;AAC7F,MAAI,CAAC,UAAW,QAAO;AACvB,SAAO,SAAS,KAAK,EAAE,KAAK,CAAC,iCAAiC,KAAK,EAAE;AACvE;AAsBO,SAAS,YACd,UACA,QACY;AACZ,QAAM,IAAI;AACV,QAAM,IAAI,UAAU,EAAE;AACtB,WAAS,EAAE,WAAW,UAAU,IAAI;AAEpC,MAAI,CAAC,KAAK,OAAO,EAAE,qBAAqB,WAAY,QAAO,MAAM;AAAA,EAAC;AAClE,QAAM,WAAW,MAAY,SAAS,IAAI;AAC1C,QAAM,YAAY,MAAY,SAAS,KAAK;AAC5C,IAAE,iBAAiB,UAAU,QAAQ;AACrC,IAAE,iBAAiB,WAAW,SAAS;AACvC,SAAO,MAAM;AACX,MAAE,oBAAoB,UAAU,QAAQ;AACxC,MAAE,oBAAoB,WAAW,SAAS;AAAA,EAC5C;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/in-pwa** — installable/offline shell helpers for noy-db SPAs.\n *\n * The premise: **the PWA starts empty.** First open in its own storage\n * partition runs online enrollment and hydrates from the firm cloud\n * store; from then on the installed app is the offline-capable home of\n * the vault (data in `to-browser-idb`, app shell in the SW cache — see\n * the README recipe). This package ships the browser-shell plumbing\n * around that lifecycle:\n *\n * - {@link requestPersistence} — `navigator.storage.persist()` +\n * `estimate()` with a clear grant/deny signal. Never throws.\n * - {@link guardLocalVault} / {@link probeLocalVault} — detect a\n * wiped/missing local store at boot and **fail closed** into the\n * re-enrollment flow. Never a crash, never a silent empty vault\n * presented as truth.\n * - {@link captureInstallPrompt}, {@link getDisplayContext},\n * {@link isIosSafari} — install UX helpers.\n * - {@link watchOnline} — online/offline wiring shaped for the sync\n * engine's `isOnline` flag.\n *\n * No service worker runtime is shipped — the SW is a copy-able recipe\n * in the README. This package holds no keys and sees no plaintext.\n *\n * @packageDocumentation\n */\n\nimport type { NoydbStore } from '@noy-db/hub/to'\n\n// ---------------------------------------------------------------------------\n// Shared app-shell context contract (also consumed/mirrored by @noy-db/in-liff)\n// ---------------------------------------------------------------------------\n\n/**\n * The three shells one noy-db SPA can boot in. Shared contract with\n * `@noy-db/in-liff` (which detects `'liff'`); this package's\n * {@link getDisplayContext} distinguishes the other two. A plain string\n * union — no runtime coupling between the packages.\n */\nexport type AppShellContext = 'liff' | 'browser' | 'pwa'\n\n// ---------------------------------------------------------------------------\n// Storage persistence\n// ---------------------------------------------------------------------------\n\n/** Result of {@link requestPersistence}. */\nexport interface PersistenceResult {\n /** True when the origin's storage is durable (not eviction-eligible). */\n persisted: boolean\n /** `navigator.storage.estimate().quota`, when the browser reports it. */\n quota?: number\n /** `navigator.storage.estimate().usage`, when the browser reports it. */\n usage?: number\n /**\n * How the answer was reached:\n * - `'already'` — the origin was persistent before this call.\n * - `'granted'` — `persist()` was requested and the browser granted it.\n * - `'denied'` — `persist()` was requested and the browser declined.\n * - `'unsupported'` — no Storage API on this browser (or it errored).\n */\n grantedBy: 'already' | 'granted' | 'denied' | 'unsupported'\n}\n\n/**\n * Ask the browser to mark this origin's storage as persistent and report\n * quota/usage. Eviction of the local vault is the top PWA risk (iOS ITP\n * evicts script-writable storage of non-installed web content after ~7\n * days of disuse; installed home-screen apps are safer) — call this\n * during enrollment and surface a warning UI on `'denied'`.\n *\n * Never throws: unsupported browsers resolve to\n * `{ persisted: false, grantedBy: 'unsupported' }`.\n */\nexport async function requestPersistence(): Promise<PersistenceResult> {\n const storage = (globalThis as { navigator?: { storage?: StorageManager } }).navigator?.storage\n if (!storage || typeof storage.persist !== 'function') {\n return { persisted: false, grantedBy: 'unsupported' }\n }\n\n let persisted = false\n let grantedBy: PersistenceResult['grantedBy']\n try {\n const already = typeof storage.persisted === 'function' ? await storage.persisted() : false\n if (already) {\n persisted = true\n grantedBy = 'already'\n } else {\n persisted = await storage.persist()\n grantedBy = persisted ? 'granted' : 'denied'\n }\n } catch {\n // A throwing Storage API is indistinguishable from an absent one\n // for the caller's purposes — report unsupported, never throw.\n return { persisted: false, grantedBy: 'unsupported' }\n }\n\n const result: PersistenceResult = { persisted, grantedBy }\n if (typeof storage.estimate === 'function') {\n try {\n const est = await storage.estimate()\n if (typeof est.quota === 'number') result.quota = est.quota\n if (typeof est.usage === 'number') result.usage = est.usage\n } catch {\n // estimate() failing must not mask the persistence answer.\n }\n }\n return result\n}\n\n// ---------------------------------------------------------------------------\n// Eviction guard\n// ---------------------------------------------------------------------------\n\n/** Outcome of {@link probeLocalVault}. */\nexport type VaultPresence =\n | {\n present: true\n /**\n * What proved presence: `'keyring'` — the vault's `_keyring`\n * marker records exist (every encrypted vault persists one at\n * creation); `'envelopes'` — no keyring (plaintext-mode vault)\n * but `loadAll()` returned at least one envelope.\n */\n via: 'keyring' | 'envelopes'\n }\n | {\n present: false\n /**\n * `'empty'` — the store answered and holds nothing for this\n * vault (wiped/evicted/never enrolled); `'probe-failed'` — the\n * store itself errored. Both fail closed.\n */\n reason: 'empty' | 'probe-failed'\n /** The underlying error when `reason` is `'probe-failed'`. */\n cause?: unknown\n }\n\n/**\n * Cheap, store-agnostic probe: is a local vault actually present in\n * `store`? Works against any `NoydbStore` (the 6-method contract from\n * `@noy-db/hub/to`) — it never touches `to-browser-idb` internals.\n *\n * Presence check, in order:\n * 1. `store.list(vaultId, '_keyring')` — an encrypted vault always\n * persists its owner keyring record at creation, so a non-empty\n * `_keyring` collection is the same marker the hub itself uses to\n * decide a vault is provisioned. One tiny `list()` call.\n * 2. Fallback for plaintext-mode vaults (no keyring): `loadAll()` —\n * any envelope in any collection counts as present.\n *\n * Never throws — a store error resolves to\n * `{ present: false, reason: 'probe-failed', cause }`.\n */\nexport async function probeLocalVault(store: NoydbStore, vaultId: string): Promise<VaultPresence> {\n try {\n const keyringIds = await store.list(vaultId, '_keyring')\n if (keyringIds.length > 0) return { present: true, via: 'keyring' }\n\n const snapshot = await store.loadAll(vaultId)\n for (const records of Object.values(snapshot)) {\n if (records && Object.keys(records).length > 0) {\n return { present: true, via: 'envelopes' }\n }\n }\n return { present: false, reason: 'empty' }\n } catch (cause) {\n return { present: false, reason: 'probe-failed', cause }\n }\n}\n\n/** Result of {@link guardLocalVault}. */\nexport interface GuardResult {\n /** True iff the local vault is present. Never true on a failed probe. */\n healthy: boolean\n /** The underlying probe outcome. */\n presence: VaultPresence\n}\n\n/**\n * Boot-time eviction guard: probe the local store and **fail closed**\n * into re-enrollment when the vault is gone.\n *\n * - Vault present → `{ healthy: true }`; `onEvicted` is not called.\n * - Vault missing (wiped/evicted partition) **or the probe itself\n * failed** → `onEvicted` is invoked (and awaited) with the failure\n * detail, then `{ healthy: false }` is returned. A broken store is\n * treated exactly like a missing vault — the guard never presents an\n * empty or unreadable store as a healthy vault, and never throws\n * from the probe path.\n *\n * `onEvicted` is where the app routes to its re-enrollment flow (the\n * online re-invite via `@noy-db/on-oidc`); errors thrown by the handler\n * itself propagate to the caller.\n */\nexport async function guardLocalVault(\n store: NoydbStore,\n vaultId: string,\n onEvicted: (presence: Extract<VaultPresence, { present: false }>) => void | Promise<void>,\n): Promise<GuardResult> {\n const presence = await probeLocalVault(store, vaultId)\n if (presence.present) return { healthy: true, presence }\n await onEvicted(presence)\n return { healthy: false, presence }\n}\n\n// ---------------------------------------------------------------------------\n// Install UX helpers\n// ---------------------------------------------------------------------------\n\n/**\n * The `beforeinstallprompt` event shape (Chromium-only, not in the DOM\n * lib types).\n */\ninterface BeforeInstallPromptLike extends Event {\n prompt(): Promise<void>\n userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>\n}\n\n/** Handle returned by {@link captureInstallPrompt}. */\nexport interface CapturedInstallPrompt {\n /** True once a `beforeinstallprompt` event has been captured (and not yet spent). */\n readonly captured: boolean\n /**\n * Re-fire the deferred browser install prompt. Resolves to the user's\n * choice, or `'unavailable'` when no event was captured (iOS, already\n * installed, or the prompt was already spent — the browser fires it\n * at most once per capture).\n */\n promptInstall(): Promise<'accepted' | 'dismissed' | 'unavailable'>\n /** Remove the event listener and drop any captured prompt. */\n dispose(): void\n}\n\n/**\n * Capture the `beforeinstallprompt` event (Android/desktop Chromium) so\n * the app can show its own install UI and re-fire the prompt on a user\n * gesture. Call once at boot, before the browser fires the event.\n *\n * On browsers that never fire the event (iOS Safari — see\n * {@link isIosSafari} for the add-to-home-screen interstitial decision)\n * `promptInstall()` simply resolves `'unavailable'`.\n */\nexport function captureInstallPrompt(target?: EventTarget): CapturedInstallPrompt {\n const t = target ?? (globalThis as { window?: EventTarget }).window\n let deferred: BeforeInstallPromptLike | null = null\n\n const listener = (event: Event): void => {\n // Suppress the browser's own mini-infobar; the app re-fires the\n // prompt from its install UI instead.\n event.preventDefault()\n deferred = event as BeforeInstallPromptLike\n }\n t?.addEventListener('beforeinstallprompt', listener)\n\n return {\n get captured() {\n return deferred !== null\n },\n async promptInstall() {\n const event = deferred\n if (!event || typeof event.prompt !== 'function') return 'unavailable'\n deferred = null // the deferred prompt is single-use\n await event.prompt()\n const choice = await event.userChoice\n return choice.outcome\n },\n dispose() {\n t?.removeEventListener('beforeinstallprompt', listener)\n deferred = null\n },\n }\n}\n\n/**\n * Which shell the app is currently displayed in: `'pwa'` when running\n * standalone (installed — `display-mode: standalone` media query, or\n * iOS `navigator.standalone`), else `'browser'`. The `'liff'` value of\n * {@link AppShellContext} is detected by `@noy-db/in-liff`, not here.\n */\nexport function getDisplayContext(): Extract<AppShellContext, 'pwa' | 'browser'> {\n const g = globalThis as {\n matchMedia?: (query: string) => { matches: boolean }\n navigator?: { standalone?: boolean }\n }\n try {\n if (typeof g.matchMedia === 'function' && g.matchMedia('(display-mode: standalone)').matches) {\n return 'pwa'\n }\n } catch {\n // matchMedia throwing (non-browser host) means not standalone.\n }\n if (g.navigator?.standalone === true) return 'pwa'\n return 'browser'\n}\n\n/**\n * True on iOS Safari (including iPadOS masquerading as macOS), where\n * `beforeinstallprompt` never fires and installing means the share-sheet\n * \"Add to Home Screen\" flow — the signal for showing that interstitial.\n * Third-party iOS browsers (Chrome/Firefox/Edge/Opera shells) return\n * false.\n */\nexport function isIosSafari(): boolean {\n const nav = (globalThis as {\n navigator?: { userAgent?: string; platform?: string; maxTouchPoints?: number }\n }).navigator\n if (!nav) return false\n const ua = nav.userAgent ?? ''\n const iosDevice =\n /iPad|iPhone|iPod/.test(ua) || (nav.platform === 'MacIntel' && (nav.maxTouchPoints ?? 0) > 1)\n if (!iosDevice) return false\n return /Safari/.test(ua) && !/CriOS|FxiOS|EdgiOS|OPiOS|OPT\\//.test(ua)\n}\n\n// ---------------------------------------------------------------------------\n// Online/offline transitions\n// ---------------------------------------------------------------------------\n\n/**\n * Watch connectivity: invokes `callback` immediately with the current\n * `navigator.onLine` state, then on every `online`/`offline` event.\n * Returns an unsubscribe function.\n *\n * Shaped for feeding the sync engine's `isOnline` flag (this package\n * deliberately does not import the sync engine):\n *\n * ```ts\n * const stop = watchOnline((online) => syncStrategy.setOnline(online))\n * ```\n *\n * In hosts without a `window`/events (or without `navigator.onLine`)\n * the callback fires once with `true` (assume online) and the returned\n * unsubscribe is a no-op.\n */\nexport function watchOnline(\n callback: (online: boolean) => void,\n target?: EventTarget,\n): () => void {\n const g = globalThis as { window?: EventTarget; navigator?: { onLine?: boolean } }\n const t = target ?? g.window\n callback(g.navigator?.onLine ?? true)\n\n if (!t || typeof t.addEventListener !== 'function') return () => {}\n const onOnline = (): void => callback(true)\n const onOffline = (): void => callback(false)\n t.addEventListener('online', onOnline)\n t.addEventListener('offline', onOffline)\n return () => {\n t.removeEventListener('online', onOnline)\n t.removeEventListener('offline', onOffline)\n }\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noy-db/in-pwa",
3
- "version": "0.6.0",
3
+ "version": "0.7.0-pre.0",
4
4
  "description": "PWA shell helpers for noy-db — storage persistence with a clear grant/deny signal, fail-closed eviction guard for the local vault, install-prompt capture, display-mode context detection, online/offline watcher, and the app-shell service-worker recipe.",
5
5
  "license": "MIT",
6
6
  "author": "vLannaAi <vicio@lanna.ai>",
@@ -32,11 +32,11 @@
32
32
  "node": ">=22.0.0"
33
33
  },
34
34
  "peerDependencies": {
35
- "@noy-db/hub": "0.6.0"
35
+ "@noy-db/hub": "0.7.0-pre.0"
36
36
  },
37
37
  "devDependencies": {
38
- "@noy-db/hub": "0.6.0",
39
- "@noy-db/to-memory": "0.6.0"
38
+ "@noy-db/to-memory": "0.7.0-pre.0",
39
+ "@noy-db/hub": "0.7.0-pre.0"
40
40
  },
41
41
  "keywords": [
42
42
  "noy-db",