@noy-db/in-pwa 0.4.0-pre.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 vLannaAi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,190 @@
1
+ # @noy-db/in-pwa
2
+
3
+ [![npm](https://img.shields.io/npm/v/%40noy-db/in-pwa.svg)](https://www.npmjs.com/package/@noy-db/in-pwa)
4
+
5
+ > Installable/offline shell helpers for noy-db SPAs
6
+
7
+ Part of [**`@noy-db/hub`**](https://www.npmjs.com/package/@noy-db/hub) — the zero-knowledge, offline-first, encrypted document store.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pnpm add @noy-db/hub @noy-db/in-pwa
13
+ ```
14
+
15
+ ## The premise: the PWA starts empty
16
+
17
+ An installed PWA lives in **its own storage partition**. On first open it holds no
18
+ data: the app runs the online enrollment (unlock + re-invite via
19
+ [`@noy-db/on-oidc`](https://github.com/vLannaAi/noy-db/tree/main/packages/on-oidc))
20
+ and hydrates the vault from the firm cloud store into
21
+ [`@noy-db/to-browser-idb`](https://github.com/vLannaAi/noy-db/tree/main/packages/to-browser-idb).
22
+ From then on the installed app is the **offline-capable home of the vault**:
23
+ offline boot = cached app shell (service worker) + local IndexedDB vault.
24
+
25
+ This package is the browser-shell plumbing around that lifecycle. It holds **no
26
+ keys** and sees **no plaintext** — every helper here operates on browser shell
27
+ APIs or on opaque ciphertext-store presence, so it adds nothing to the threat
28
+ model.
29
+
30
+ ## Persistence & eviction
31
+
32
+ Eviction of the local vault is the top PWA risk. iOS ITP evicts all
33
+ script-writable storage (IndexedDB included) of **non-installed** web content
34
+ after ~7 days without interaction; installed home-screen apps are safer, and
35
+ `navigator.storage.persist()` exempts an origin from best-effort eviction where
36
+ granted. Two helpers cover the risk:
37
+
38
+ ### `requestPersistence()`
39
+
40
+ ```ts
41
+ import { requestPersistence } from '@noy-db/in-pwa'
42
+
43
+ const res = await requestPersistence()
44
+ // { persisted: boolean, quota?: number, usage?: number,
45
+ // grantedBy: 'already' | 'granted' | 'denied' | 'unsupported' }
46
+ ```
47
+
48
+ Wraps `navigator.storage.persist()` + `estimate()` with a clear grant/deny
49
+ signal. **Never throws** — browsers without the Storage API resolve to
50
+ `grantedBy: 'unsupported'`. Call it during enrollment; warn the user on
51
+ `'denied'`.
52
+
53
+ ### `guardLocalVault()` — fail closed at boot
54
+
55
+ If the partition *was* evicted, the app must never crash and never present a
56
+ silent empty vault as truth. The guard probes the local store before the vault
57
+ is opened and routes a missing vault into re-enrollment:
58
+
59
+ ```ts
60
+ import { guardLocalVault } from '@noy-db/in-pwa'
61
+ import { browserIdb } from '@noy-db/to-browser-idb'
62
+
63
+ const store = browserIdb()
64
+ const { healthy } = await guardLocalVault(store, 'firm', (why) => {
65
+ // why: { present: false, reason: 'empty' | 'probe-failed', cause? }
66
+ routeToReEnrollment()
67
+ })
68
+ if (healthy) {
69
+ /* open the vault normally */
70
+ }
71
+ ```
72
+
73
+ The probe (`probeLocalVault(store, vaultId)`) is **store-agnostic** — it speaks
74
+ only the 6-method `NoydbStore` contract (`@noy-db/hub/to`), never
75
+ `to-browser-idb` internals. It checks the vault's `_keyring` marker records
76
+ first (every encrypted vault persists its owner keyring at creation — the same
77
+ signal the hub uses for "vault provisioned"), then falls back to a `loadAll()`
78
+ envelope scan for plaintext-mode vaults. A **broken store is treated exactly
79
+ like a missing vault**: probe errors resolve to
80
+ `{ present: false, reason: 'probe-failed' }` and trigger `onEvicted` — fail
81
+ closed, never a throw from the probe path.
82
+
83
+ ## Install UX
84
+
85
+ ```ts
86
+ import { captureInstallPrompt, getDisplayContext, isIosSafari } from '@noy-db/in-pwa'
87
+
88
+ // Android/desktop Chromium: defer the browser prompt, re-fire it from your UI.
89
+ const install = captureInstallPrompt() // call once at boot
90
+ // later, on a user gesture:
91
+ const outcome = await install.promptInstall() // 'accepted' | 'dismissed' | 'unavailable'
92
+
93
+ getDisplayContext() // 'pwa' (standalone display-mode / iOS navigator.standalone) | 'browser'
94
+ isIosSafari() // true → show the "Add to Home Screen" share-sheet interstitial
95
+ // (iOS never fires beforeinstallprompt)
96
+ ```
97
+
98
+ The shared shell-context contract with `@noy-db/in-liff` is exported here as a
99
+ plain string union (no runtime coupling):
100
+
101
+ ```ts
102
+ export type AppShellContext = 'liff' | 'browser' | 'pwa'
103
+ ```
104
+
105
+ ## Online/offline transitions
106
+
107
+ ```ts
108
+ import { watchOnline } from '@noy-db/in-pwa'
109
+
110
+ const stop = watchOnline((online) => syncStrategy.setOnline(online))
111
+ ```
112
+
113
+ Invokes the callback immediately with `navigator.onLine`, then on every
114
+ `online`/`offline` event — shaped for feeding the sync engine's `isOnline`
115
+ flag (reconnect replay of the dirty queue lives in `with-sync`; this package
116
+ does not import it).
117
+
118
+ ## Service-worker recipe (copy, don't import)
119
+
120
+ This package ships **no service-worker runtime** — the SW below is a recipe you
121
+ copy into your app and version yourself.
122
+
123
+ > **Hard rule: vault data NEVER goes in the SW cache.** The vault lives
124
+ > encrypted in `to-browser-idb`; the service worker caches the **app shell
125
+ > only** (HTML/JS/CSS/icons). A SW cache of vault responses would create a
126
+ > second, unmanaged copy of ciphertext outside the store contract — and a
127
+ > plaintext copy if you cached decrypted API responses. Don't.
128
+
129
+ ```js
130
+ // sw.js — app-shell caching only. Bump SHELL_CACHE on every deploy.
131
+ const SHELL_CACHE = 'app-shell-v1'
132
+ const SHELL_ASSETS = ['/', '/index.html', '/assets/app.js', '/assets/app.css', '/icons/192.png']
133
+
134
+ self.addEventListener('install', (event) => {
135
+ event.waitUntil(caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL_ASSETS)))
136
+ self.skipWaiting()
137
+ })
138
+
139
+ self.addEventListener('activate', (event) => {
140
+ // Drop caches from previous shell versions.
141
+ event.waitUntil(
142
+ caches
143
+ .keys()
144
+ .then((keys) => Promise.all(keys.filter((k) => k !== SHELL_CACHE).map((k) => caches.delete(k))))
145
+ .then(() => self.clients.claim()),
146
+ )
147
+ })
148
+
149
+ self.addEventListener('fetch', (event) => {
150
+ const url = new URL(event.request.url)
151
+ // App shell only: same-origin GET navigations and static assets.
152
+ // Everything else (sync traffic, APIs) passes straight to the network —
153
+ // vault data is never cached here.
154
+ if (event.request.method !== 'GET' || url.origin !== self.location.origin) return
155
+ event.respondWith(
156
+ caches.match(event.request).then((hit) => hit ?? fetch(event.request)),
157
+ )
158
+ })
159
+ ```
160
+
161
+ Offline boot then composes as: SW serves the cached shell → `guardLocalVault()`
162
+ confirms the local vault is present → the app opens it from `to-browser-idb`
163
+ with no network at all.
164
+
165
+ ## Detaching from LINE / re-enrollment
166
+
167
+ Storage partitions are never transferable: LINE's WebView, the browser tab, and
168
+ the installed PWA each have isolated storage, so moving the vault into the
169
+ installed app is a **re-enroll + re-sync ceremony, never a data transfer** — the
170
+ firm re-invites the device via the
171
+ [`@noy-db/on-oidc`](https://github.com/vLannaAi/noy-db/tree/main/packages/on-oidc)
172
+ unlock flow and the fresh partition hydrates from the firm cloud store, exactly
173
+ like first enrollment. `guardLocalVault()`'s `onEvicted` hook is where that
174
+ ceremony is (re-)entered.
175
+
176
+ ## Status
177
+
178
+ **Pre-release** (`0.4.0-pre.1`). API may change before `1.0`.
179
+
180
+ ## Documentation
181
+
182
+ See the [main repository](https://github.com/vLannaAi/noy-db#readme) for setup, examples, and the full subsystem catalog.
183
+
184
+ - Source — [`packages/in-pwa`](https://github.com/vLannaAi/noy-db/tree/main/packages/in-pwa)
185
+ - Issues — [github.com/vLannaAi/noy-db/issues](https://github.com/vLannaAi/noy-db/issues)
186
+ - Spec — [`SPEC.md`](https://github.com/vLannaAi/noy-db-docs/blob/main/SPEC.md)
187
+
188
+ ## License
189
+
190
+ [MIT](./LICENSE) © vLannaAi
@@ -0,0 +1,186 @@
1
+ import { NoydbStore } from '@noy-db/hub';
2
+
3
+ /**
4
+ * **@noy-db/in-pwa** — installable/offline shell helpers for noy-db SPAs.
5
+ *
6
+ * The premise: **the PWA starts empty.** First open in its own storage
7
+ * partition runs online enrollment and hydrates from the firm cloud
8
+ * store; from then on the installed app is the offline-capable home of
9
+ * the vault (data in `to-browser-idb`, app shell in the SW cache — see
10
+ * the README recipe). This package ships the browser-shell plumbing
11
+ * around that lifecycle:
12
+ *
13
+ * - {@link requestPersistence} — `navigator.storage.persist()` +
14
+ * `estimate()` with a clear grant/deny signal. Never throws.
15
+ * - {@link guardLocalVault} / {@link probeLocalVault} — detect a
16
+ * wiped/missing local store at boot and **fail closed** into the
17
+ * re-enrollment flow. Never a crash, never a silent empty vault
18
+ * presented as truth.
19
+ * - {@link captureInstallPrompt}, {@link getDisplayContext},
20
+ * {@link isIosSafari} — install UX helpers.
21
+ * - {@link watchOnline} — online/offline wiring shaped for the sync
22
+ * engine's `isOnline` flag.
23
+ *
24
+ * No service worker runtime is shipped — the SW is a copy-able recipe
25
+ * in the README. This package holds no keys and sees no plaintext.
26
+ *
27
+ * @packageDocumentation
28
+ */
29
+
30
+ /**
31
+ * The three shells one noy-db SPA can boot in. Shared contract with
32
+ * `@noy-db/in-liff` (which detects `'liff'`); this package's
33
+ * {@link getDisplayContext} distinguishes the other two. A plain string
34
+ * union — no runtime coupling between the packages.
35
+ */
36
+ type AppShellContext = 'liff' | 'browser' | 'pwa';
37
+ /** Result of {@link requestPersistence}. */
38
+ interface PersistenceResult {
39
+ /** True when the origin's storage is durable (not eviction-eligible). */
40
+ persisted: boolean;
41
+ /** `navigator.storage.estimate().quota`, when the browser reports it. */
42
+ quota?: number;
43
+ /** `navigator.storage.estimate().usage`, when the browser reports it. */
44
+ usage?: number;
45
+ /**
46
+ * How the answer was reached:
47
+ * - `'already'` — the origin was persistent before this call.
48
+ * - `'granted'` — `persist()` was requested and the browser granted it.
49
+ * - `'denied'` — `persist()` was requested and the browser declined.
50
+ * - `'unsupported'` — no Storage API on this browser (or it errored).
51
+ */
52
+ grantedBy: 'already' | 'granted' | 'denied' | 'unsupported';
53
+ }
54
+ /**
55
+ * Ask the browser to mark this origin's storage as persistent and report
56
+ * quota/usage. Eviction of the local vault is the top PWA risk (iOS ITP
57
+ * evicts script-writable storage of non-installed web content after ~7
58
+ * days of disuse; installed home-screen apps are safer) — call this
59
+ * during enrollment and surface a warning UI on `'denied'`.
60
+ *
61
+ * Never throws: unsupported browsers resolve to
62
+ * `{ persisted: false, grantedBy: 'unsupported' }`.
63
+ */
64
+ declare function requestPersistence(): Promise<PersistenceResult>;
65
+ /** Outcome of {@link probeLocalVault}. */
66
+ type VaultPresence = {
67
+ present: true;
68
+ /**
69
+ * What proved presence: `'keyring'` — the vault's `_keyring`
70
+ * marker records exist (every encrypted vault persists one at
71
+ * creation); `'envelopes'` — no keyring (plaintext-mode vault)
72
+ * but `loadAll()` returned at least one envelope.
73
+ */
74
+ via: 'keyring' | 'envelopes';
75
+ } | {
76
+ present: false;
77
+ /**
78
+ * `'empty'` — the store answered and holds nothing for this
79
+ * vault (wiped/evicted/never enrolled); `'probe-failed'` — the
80
+ * store itself errored. Both fail closed.
81
+ */
82
+ reason: 'empty' | 'probe-failed';
83
+ /** The underlying error when `reason` is `'probe-failed'`. */
84
+ cause?: unknown;
85
+ };
86
+ /**
87
+ * Cheap, store-agnostic probe: is a local vault actually present in
88
+ * `store`? Works against any `NoydbStore` (the 6-method contract from
89
+ * `@noy-db/hub/to`) — it never touches `to-browser-idb` internals.
90
+ *
91
+ * Presence check, in order:
92
+ * 1. `store.list(vaultId, '_keyring')` — an encrypted vault always
93
+ * persists its owner keyring record at creation, so a non-empty
94
+ * `_keyring` collection is the same marker the hub itself uses to
95
+ * decide a vault is provisioned. One tiny `list()` call.
96
+ * 2. Fallback for plaintext-mode vaults (no keyring): `loadAll()` —
97
+ * any envelope in any collection counts as present.
98
+ *
99
+ * Never throws — a store error resolves to
100
+ * `{ present: false, reason: 'probe-failed', cause }`.
101
+ */
102
+ declare function probeLocalVault(store: NoydbStore, vaultId: string): Promise<VaultPresence>;
103
+ /** Result of {@link guardLocalVault}. */
104
+ interface GuardResult {
105
+ /** True iff the local vault is present. Never true on a failed probe. */
106
+ healthy: boolean;
107
+ /** The underlying probe outcome. */
108
+ presence: VaultPresence;
109
+ }
110
+ /**
111
+ * Boot-time eviction guard: probe the local store and **fail closed**
112
+ * into re-enrollment when the vault is gone.
113
+ *
114
+ * - Vault present → `{ healthy: true }`; `onEvicted` is not called.
115
+ * - Vault missing (wiped/evicted partition) **or the probe itself
116
+ * failed** → `onEvicted` is invoked (and awaited) with the failure
117
+ * detail, then `{ healthy: false }` is returned. A broken store is
118
+ * treated exactly like a missing vault — the guard never presents an
119
+ * empty or unreadable store as a healthy vault, and never throws
120
+ * from the probe path.
121
+ *
122
+ * `onEvicted` is where the app routes to its re-enrollment flow (the
123
+ * online re-invite via `@noy-db/on-oidc`); errors thrown by the handler
124
+ * itself propagate to the caller.
125
+ */
126
+ declare function guardLocalVault(store: NoydbStore, vaultId: string, onEvicted: (presence: Extract<VaultPresence, {
127
+ present: false;
128
+ }>) => void | Promise<void>): Promise<GuardResult>;
129
+ /** Handle returned by {@link captureInstallPrompt}. */
130
+ interface CapturedInstallPrompt {
131
+ /** True once a `beforeinstallprompt` event has been captured (and not yet spent). */
132
+ readonly captured: boolean;
133
+ /**
134
+ * Re-fire the deferred browser install prompt. Resolves to the user's
135
+ * choice, or `'unavailable'` when no event was captured (iOS, already
136
+ * installed, or the prompt was already spent — the browser fires it
137
+ * at most once per capture).
138
+ */
139
+ promptInstall(): Promise<'accepted' | 'dismissed' | 'unavailable'>;
140
+ /** Remove the event listener and drop any captured prompt. */
141
+ dispose(): void;
142
+ }
143
+ /**
144
+ * Capture the `beforeinstallprompt` event (Android/desktop Chromium) so
145
+ * the app can show its own install UI and re-fire the prompt on a user
146
+ * gesture. Call once at boot, before the browser fires the event.
147
+ *
148
+ * On browsers that never fire the event (iOS Safari — see
149
+ * {@link isIosSafari} for the add-to-home-screen interstitial decision)
150
+ * `promptInstall()` simply resolves `'unavailable'`.
151
+ */
152
+ declare function captureInstallPrompt(target?: EventTarget): CapturedInstallPrompt;
153
+ /**
154
+ * Which shell the app is currently displayed in: `'pwa'` when running
155
+ * standalone (installed — `display-mode: standalone` media query, or
156
+ * iOS `navigator.standalone`), else `'browser'`. The `'liff'` value of
157
+ * {@link AppShellContext} is detected by `@noy-db/in-liff`, not here.
158
+ */
159
+ declare function getDisplayContext(): Extract<AppShellContext, 'pwa' | 'browser'>;
160
+ /**
161
+ * True on iOS Safari (including iPadOS masquerading as macOS), where
162
+ * `beforeinstallprompt` never fires and installing means the share-sheet
163
+ * "Add to Home Screen" flow — the signal for showing that interstitial.
164
+ * Third-party iOS browsers (Chrome/Firefox/Edge/Opera shells) return
165
+ * false.
166
+ */
167
+ declare function isIosSafari(): boolean;
168
+ /**
169
+ * Watch connectivity: invokes `callback` immediately with the current
170
+ * `navigator.onLine` state, then on every `online`/`offline` event.
171
+ * Returns an unsubscribe function.
172
+ *
173
+ * Shaped for feeding the sync engine's `isOnline` flag (this package
174
+ * deliberately does not import the sync engine):
175
+ *
176
+ * ```ts
177
+ * const stop = watchOnline((online) => syncStrategy.setOnline(online))
178
+ * ```
179
+ *
180
+ * In hosts without a `window`/events (or without `navigator.onLine`)
181
+ * the callback fires once with `true` (assume online) and the returned
182
+ * unsubscribe is a no-op.
183
+ */
184
+ declare function watchOnline(callback: (online: boolean) => void, target?: EventTarget): () => void;
185
+
186
+ export { type AppShellContext, type CapturedInstallPrompt, type GuardResult, type PersistenceResult, type VaultPresence, captureInstallPrompt, getDisplayContext, guardLocalVault, isIosSafari, probeLocalVault, requestPersistence, watchOnline };
package/dist/index.js ADDED
@@ -0,0 +1,122 @@
1
+ // src/index.ts
2
+ async function requestPersistence() {
3
+ const storage = globalThis.navigator?.storage;
4
+ if (!storage || typeof storage.persist !== "function") {
5
+ return { persisted: false, grantedBy: "unsupported" };
6
+ }
7
+ let persisted = false;
8
+ let grantedBy;
9
+ try {
10
+ const already = typeof storage.persisted === "function" ? await storage.persisted() : false;
11
+ if (already) {
12
+ persisted = true;
13
+ grantedBy = "already";
14
+ } else {
15
+ persisted = await storage.persist();
16
+ grantedBy = persisted ? "granted" : "denied";
17
+ }
18
+ } catch {
19
+ return { persisted: false, grantedBy: "unsupported" };
20
+ }
21
+ const result = { persisted, grantedBy };
22
+ if (typeof storage.estimate === "function") {
23
+ try {
24
+ const est = await storage.estimate();
25
+ if (typeof est.quota === "number") result.quota = est.quota;
26
+ if (typeof est.usage === "number") result.usage = est.usage;
27
+ } catch {
28
+ }
29
+ }
30
+ return result;
31
+ }
32
+ async function probeLocalVault(store, vaultId) {
33
+ try {
34
+ const keyringIds = await store.list(vaultId, "_keyring");
35
+ if (keyringIds.length > 0) return { present: true, via: "keyring" };
36
+ const snapshot = await store.loadAll(vaultId);
37
+ for (const records of Object.values(snapshot)) {
38
+ if (records && Object.keys(records).length > 0) {
39
+ return { present: true, via: "envelopes" };
40
+ }
41
+ }
42
+ return { present: false, reason: "empty" };
43
+ } catch (cause) {
44
+ return { present: false, reason: "probe-failed", cause };
45
+ }
46
+ }
47
+ async function guardLocalVault(store, vaultId, onEvicted) {
48
+ const presence = await probeLocalVault(store, vaultId);
49
+ if (presence.present) return { healthy: true, presence };
50
+ await onEvicted(presence);
51
+ return { healthy: false, presence };
52
+ }
53
+ function captureInstallPrompt(target) {
54
+ const t = target ?? globalThis.window;
55
+ let deferred = null;
56
+ const listener = (event) => {
57
+ event.preventDefault();
58
+ deferred = event;
59
+ };
60
+ t?.addEventListener("beforeinstallprompt", listener);
61
+ return {
62
+ get captured() {
63
+ return deferred !== null;
64
+ },
65
+ async promptInstall() {
66
+ const event = deferred;
67
+ if (!event || typeof event.prompt !== "function") return "unavailable";
68
+ deferred = null;
69
+ await event.prompt();
70
+ const choice = await event.userChoice;
71
+ return choice.outcome;
72
+ },
73
+ dispose() {
74
+ t?.removeEventListener("beforeinstallprompt", listener);
75
+ deferred = null;
76
+ }
77
+ };
78
+ }
79
+ function getDisplayContext() {
80
+ const g = globalThis;
81
+ try {
82
+ if (typeof g.matchMedia === "function" && g.matchMedia("(display-mode: standalone)").matches) {
83
+ return "pwa";
84
+ }
85
+ } catch {
86
+ }
87
+ if (g.navigator?.standalone === true) return "pwa";
88
+ return "browser";
89
+ }
90
+ function isIosSafari() {
91
+ const nav = globalThis.navigator;
92
+ if (!nav) return false;
93
+ const ua = nav.userAgent ?? "";
94
+ const iosDevice = /iPad|iPhone|iPod/.test(ua) || nav.platform === "MacIntel" && (nav.maxTouchPoints ?? 0) > 1;
95
+ if (!iosDevice) return false;
96
+ return /Safari/.test(ua) && !/CriOS|FxiOS|EdgiOS|OPiOS|OPT\//.test(ua);
97
+ }
98
+ function watchOnline(callback, target) {
99
+ const g = globalThis;
100
+ const t = target ?? g.window;
101
+ callback(g.navigator?.onLine ?? true);
102
+ if (!t || typeof t.addEventListener !== "function") return () => {
103
+ };
104
+ const onOnline = () => callback(true);
105
+ const onOffline = () => callback(false);
106
+ t.addEventListener("online", onOnline);
107
+ t.addEventListener("offline", onOffline);
108
+ return () => {
109
+ t.removeEventListener("online", onOnline);
110
+ t.removeEventListener("offline", onOffline);
111
+ };
112
+ }
113
+ export {
114
+ captureInstallPrompt,
115
+ getDisplayContext,
116
+ guardLocalVault,
117
+ isIosSafari,
118
+ probeLocalVault,
119
+ requestPersistence,
120
+ watchOnline
121
+ };
122
+ //# sourceMappingURL=index.js.map
@@ -0,0 +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":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@noy-db/in-pwa",
3
+ "version": "0.4.0-pre.2",
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
+ "license": "MIT",
6
+ "author": "vLannaAi <vicio@lanna.ai>",
7
+ "homepage": "https://github.com/vLannaAi/noy-db/tree/main/packages/in-pwa#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/vLannaAi/noy-db.git",
11
+ "directory": "packages/in-pwa"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/vLannaAi/noy-db/issues"
15
+ },
16
+ "type": "module",
17
+ "sideEffects": false,
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "default": "./dist/index.js"
22
+ }
23
+ },
24
+ "module": "./dist/index.js",
25
+ "types": "./dist/index.d.ts",
26
+ "files": [
27
+ "dist",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "engines": {
32
+ "node": ">=22.0.0"
33
+ },
34
+ "peerDependencies": {
35
+ "@noy-db/hub": "0.4.0-pre.2"
36
+ },
37
+ "devDependencies": {
38
+ "@noy-db/hub": "0.4.0-pre.2",
39
+ "@noy-db/to-memory": "0.4.0-pre.2"
40
+ },
41
+ "keywords": [
42
+ "noy-db",
43
+ "in-pwa",
44
+ "pwa",
45
+ "offline",
46
+ "persistence",
47
+ "service-worker"
48
+ ],
49
+ "publishConfig": {
50
+ "access": "public",
51
+ "tag": "latest"
52
+ },
53
+ "scripts": {
54
+ "build": "tsup",
55
+ "test": "vitest run",
56
+ "lint": "eslint src/",
57
+ "typecheck": "tsc --noEmit"
58
+ }
59
+ }