experimentalsince 0.4.0-pre.12

pwa

PWA shell helpers — storage persistence with a grant/deny signal, a fail-closed eviction guard, install-prompt capture, and an online watcher.

@noy-db/in-pwa · pnpm add @noy-db/hub @noy-db/in-pwa Browser-shell plumbing for an installed noy-db app.

The premise: the PWA starts empty

An installed PWA lives in its own storage partition. On first open it holds nothing: the app runs the online enrollment (unlock + re-invite via on-oidc) and hydrates the vault from the firm's cloud store into to-browser-idb. From then on the installed app is the offline-capable home of the vault — offline boot is a cached app shell plus the local IndexedDB vault.

This package is the plumbing around that lifecycle. It holds no keys and sees no plaintext: every helper operates on browser shell APIs or on opaque ciphertext-store presence, so it adds nothing to the threat model.

Eviction is the top risk

iOS ITP evicts all script-writable storage — IndexedDB included — of non-installed web content after about seven days without interaction. Installed home-screen apps are safer, and navigator.storage.persist() exempts an origin from best-effort eviction where granted. Two helpers cover it.

requestPersistence() — ask, and know the answer

import { requestPersistence } from '@noy-db/in-pwa'

const res = await requestPersistence()
// { persisted: boolean, quota?: number, usage?: number,
//   grantedBy: 'already' | 'granted' | 'denied' | 'unsupported' }

Wraps persist() + estimate() with an explicit grant/deny signal, and never throws — browsers without the Storage API resolve to grantedBy: 'unsupported'. Call it during enrollment and warn the user on 'denied'.

guardLocalVault() — fail closed at boot

If the partition was evicted, the app must never crash and never present a silent empty vault as truth. The guard probes the store before the vault is opened and routes a miss into re-enrollment:

import { guardLocalVault } from '@noy-db/in-pwa'
import { toBrowserIdb } from '@noy-db/to-browser-idb'

const store = toBrowserIdb()
const { healthy } = await guardLocalVault(store, 'firm', (why) => {
  // why: { present: false, reason: 'empty' | 'probe-failed', cause? }
  routeToReEnrollment()
})
if (healthy) {
  /* open the vault normally */
}

probeLocalVault(store, vaultId) is store-agnostic — it speaks only the six-method NoydbStore contract, never to-browser-idb internals. It checks the vault's _keyring marker records first (every encrypted vault persists its owner keyring at creation), then falls back to a loadAll() envelope scan for plaintext-mode vaults.

A broken store is treated exactly like a missing one: probe errors resolve to { present: false, reason: 'probe-failed' } and trigger onEvicted. The probe path never throws.

Install UX

import { captureInstallPrompt, getDisplayContext, isIosSafari } from '@noy-db/in-pwa'

const install = captureInstallPrompt()          // once at boot — defers the browser prompt
const outcome = await install.promptInstall()   // later, on a user gesture:
                                                // 'accepted' | 'dismissed' | 'unavailable'

getDisplayContext() // 'pwa' (standalone display-mode / iOS navigator.standalone) | 'browser'
isIosSafari()       // true → show the "Add to Home Screen" interstitial;
                    // iOS never fires beforeinstallprompt

The shell-context contract shared with in-liff is exported here as a plain string union, with no runtime coupling in either direction:

export type AppShellContext = 'liff' | 'browser' | 'pwa'

Online/offline

import { watchOnline } from '@noy-db/in-pwa'

const stop = watchOnline((online) => syncStrategy.setOnline(online))

Fires immediately with navigator.onLine, then on every online/offline event — shaped for feeding the sync engine's isOnline flag. Reconnect replay of the dirty queue lives in the sync service; this package does not import it.

Service worker: a recipe, not a runtime

This package ships no service-worker code. The app-shell SW is a recipe you copy and version yourself — see the package README.

Vault data never goes in the service-worker cache. The vault lives encrypted in to-browser-idb; the worker caches the app shell only — HTML, JS, CSS, icons. Caching vault responses would create a second, unmanaged copy of ciphertext outside the store contract, and a plaintext copy if you cached decrypted API responses.

See also