@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 +21 -0
- package/README.md +190 -0
- package/dist/index.d.ts +186 -0
- package/dist/index.js +122 -0
- package/dist/index.js.map +1 -0
- package/package.json +59 -0
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
|
+
[](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
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|