@spooky-sync/core 0.0.1-canary.32 → 0.0.1-canary.321
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/AGENTS.md +81 -0
- package/README.md +56 -16
- package/dist/index.d.ts +2034 -137
- package/dist/index.js +15236 -2467
- package/dist/live.d.ts +141 -0
- package/dist/live.js +733 -0
- package/dist/otel/index.d.ts +1 -1
- package/dist/otel/index.js +3 -3
- package/dist/pure.d.ts +365 -0
- package/dist/pure.js +24 -0
- package/dist/sqlite-open.js +379 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +439 -0
- package/dist/sw.d.ts +207 -0
- package/dist/sw.js +393 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +472 -0
- package/dist/types.d.ts +1821 -38
- package/dist/worker-edges.js +470 -0
- package/dist/worker-impersonation.js +60 -0
- package/dist/worker-ref-tables.d.ts +48 -0
- package/dist/worker-types.d.ts +112 -0
- package/dist/worker-types.js +73 -0
- package/package.json +27 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/scripts/check-worker-entries.mjs +172 -0
- package/skills/sp00ky-core/SKILL.md +31 -6
- package/skills/sp00ky-core/references/config.md +6 -0
- package/src/boot/auth-flip.saga.test.ts +135 -0
- package/src/boot/auth-flip.saga.ts +77 -0
- package/src/boot/boot.saga.test.ts +180 -0
- package/src/boot/boot.saga.ts +142 -0
- package/src/boot/bucket-switch.saga.test.ts +187 -0
- package/src/boot/bucket-switch.saga.ts +133 -0
- package/src/boot/preload.saga.test.ts +51 -0
- package/src/boot/preload.saga.ts +31 -0
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/client/bucket-handle.test.ts +117 -0
- package/src/client/bucket-handle.ts +227 -0
- package/src/client/router.test.ts +58 -0
- package/src/client/router.ts +100 -0
- package/src/client/runtime.test.ts +195 -0
- package/src/client/runtime.ts +296 -0
- package/src/client/services.test.ts +60 -0
- package/src/client/services.ts +418 -0
- package/src/client/sp00ky-client.test.ts +303 -0
- package/src/client/sp00ky-client.ts +586 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +69 -1
- package/src/kernel/constants.test.ts +19 -0
- package/src/kernel/constants.ts +80 -0
- package/src/kernel/effects.test.ts +40 -0
- package/src/kernel/effects.ts +163 -0
- package/src/kernel/events.ts +57 -0
- package/src/kernel/interpreter.test.ts +116 -0
- package/src/kernel/interpreter.ts +148 -0
- package/src/kernel/saga.test.ts +89 -0
- package/src/kernel/saga.ts +73 -0
- package/src/live/index.test.ts +501 -0
- package/src/live/index.ts +1039 -0
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +209 -0
- package/src/modules/auth/auth.local-first.test.ts +169 -0
- package/src/modules/auth/events/index.ts +9 -2
- package/src/modules/auth/impersonation-banner.ts +443 -0
- package/src/modules/auth/impersonation.integration.test.ts +205 -0
- package/src/modules/auth/impersonation.test.ts +377 -0
- package/src/modules/auth/impersonation.ts +179 -0
- package/src/modules/auth/index.ts +466 -19
- package/src/modules/crdt/crdt-field.ts +294 -0
- package/src/modules/crdt/crdt-hydration.test.ts +210 -0
- package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
- package/src/modules/crdt/index.ts +463 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +746 -95
- package/src/modules/devtools/mutations.test.ts +92 -0
- package/src/modules/devtools/mutations.ts +160 -0
- package/src/modules/devtools/notify-throttle.test.ts +166 -0
- package/src/modules/devtools/state-shape.test.ts +263 -0
- package/src/modules/devtools/storage-info.test.ts +79 -0
- package/src/modules/devtools/storage-info.ts +168 -0
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +110 -0
- package/src/modules/feature-flag/index.test.ts +298 -0
- package/src/modules/feature-flag/index.ts +316 -0
- package/src/modules/ref-tables.test.ts +93 -0
- package/src/modules/ref-tables.ts +91 -0
- package/src/modules/web-push/index.test.ts +824 -0
- package/src/modules/web-push/index.ts +1027 -0
- package/src/mutation/jobs.test.ts +25 -0
- package/src/mutation/jobs.ts +41 -0
- package/src/mutation/mutation-id.test.ts +37 -0
- package/src/mutation/mutation-id.ts +35 -0
- package/src/mutation/pending-writes.integration.test.ts +175 -0
- package/src/mutation/push.saga.test.ts +285 -0
- package/src/mutation/push.saga.ts +217 -0
- package/src/mutation/rows.test.ts +231 -0
- package/src/mutation/rows.ts +427 -0
- package/src/mutation/tray.saga.test.ts +63 -0
- package/src/mutation/tray.saga.ts +48 -0
- package/src/mutation/write.saga.test.ts +316 -0
- package/src/mutation/write.saga.ts +268 -0
- package/src/otel/index.ts +10 -7
- package/src/pure.ts +66 -0
- package/src/push/types.ts +174 -0
- package/src/query/anon-query-isolation.integration.test.ts +337 -0
- package/src/query/env.test.ts +23 -0
- package/src/query/env.ts +56 -0
- package/src/query/fetch.saga.test.ts +199 -0
- package/src/query/fetch.saga.ts +120 -0
- package/src/query/hash.test.ts +14 -0
- package/src/query/hash.ts +18 -0
- package/src/query/id-set-plan.test.ts +132 -0
- package/src/query/lifecycle.saga.test.ts +274 -0
- package/src/query/lifecycle.saga.ts +159 -0
- package/src/query/list-ref-live.integration.test.ts +203 -0
- package/src/query/materialize.saga.test.ts +115 -0
- package/src/query/materialize.saga.ts +84 -0
- package/src/query/materialize.test.ts +58 -0
- package/src/query/materialize.ts +57 -0
- package/src/query/membership.saga.test.ts +350 -0
- package/src/query/membership.saga.ts +278 -0
- package/src/query/membership.test.ts +118 -0
- package/src/query/membership.ts +201 -0
- package/src/query/register.saga.test.ts +311 -0
- package/src/query/register.saga.ts +229 -0
- package/src/query/render-set.test.ts +47 -0
- package/src/query/render-set.ts +71 -0
- package/src/query/sql.test.ts +112 -0
- package/src/query/sql.ts +149 -0
- package/src/query/window-query.test.ts +70 -0
- package/src/query/window-query.ts +195 -0
- package/src/services/blobs/blob-cache.test.ts +359 -0
- package/src/services/blobs/blob-cache.ts +603 -0
- package/src/services/blobs/blob-manifest.ts +227 -0
- package/src/services/blobs/blob-store.test.ts +77 -0
- package/src/services/blobs/blob-store.ts +359 -0
- package/src/services/blobs/blob.fixture.ts +90 -0
- package/src/services/blobs/index.ts +70 -0
- package/src/services/database/cache-engine.ts +199 -0
- package/src/services/database/connection-supervisor.test.ts +318 -0
- package/src/services/database/connection-supervisor.ts +422 -0
- package/src/services/database/database.query-timeout.test.ts +140 -0
- package/src/services/database/database.responses.test.ts +43 -0
- package/src/services/database/database.ts +146 -61
- package/src/services/database/engine-factory.ts +33 -0
- package/src/services/database/errors.ts +34 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +7 -0
- package/src/services/database/landed-body-clear.integration.test.ts +236 -0
- package/src/services/database/local-migrator.integration.test.ts +110 -0
- package/src/services/database/local-migrator.ts +59 -16
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +487 -63
- package/src/services/database/plan-render.test.ts +159 -0
- package/src/services/database/plan-render.ts +108 -0
- package/src/services/database/relation-resolver.test.ts +484 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +273 -6
- package/src/services/database/sqlite-cache-engine.test.ts +617 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1372 -0
- package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
- package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
- package/src/services/database/sqlite-lock-verify.test.ts +33 -0
- package/src/services/database/sqlite-lock-verify.ts +45 -0
- package/src/services/database/sqlite-open.test.ts +150 -0
- package/src/services/database/sqlite-open.ts +164 -0
- package/src/services/database/sqlite-plan-sql.test.ts +145 -0
- package/src/services/database/sqlite-plan-sql.ts +182 -0
- package/src/services/database/sqlite-projection.test.ts +121 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +261 -0
- package/src/services/database/sqlite-select.ts +131 -0
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +224 -0
- package/src/services/database/sqlite-worker.ts +437 -0
- package/src/services/database/surql-translate.ts +416 -0
- package/src/services/database/surreal-cache-engine.ts +161 -0
- package/src/services/logger/index.test.ts +132 -0
- package/src/services/logger/index.ts +189 -7
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +10 -3
- package/src/services/persistence/surrealdb.ts +4 -4
- package/src/services/stream-processor/index.ts +783 -68
- package/src/services/stream-processor/permissions.test.ts +47 -0
- package/src/services/stream-processor/permissions.ts +53 -0
- package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
- package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +58 -2
- package/src/services/tabs/broker-client.ts +283 -0
- package/src/services/tabs/broker.test.ts +327 -0
- package/src/services/tabs/coordinator.test.ts +386 -0
- package/src/services/tabs/coordinator.ts +638 -0
- package/src/services/tabs/fake-ports.fixture.ts +112 -0
- package/src/services/tabs/leader-locks.ts +75 -0
- package/src/services/tabs/protocol.ts +262 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +640 -0
- package/src/sp00ky.ts +5 -395
- package/src/state/client-state.ts +200 -0
- package/src/state/lifecycle.test.ts +141 -0
- package/src/state/lifecycle.ts +99 -0
- package/src/state/reducers.test.ts +304 -0
- package/src/state/reducers.ts +526 -0
- package/src/state/selectors.test.ts +175 -0
- package/src/state/selectors.ts +222 -0
- package/src/sw/index.test.ts +582 -0
- package/src/sw/index.ts +607 -0
- package/src/sw/store.ts +96 -0
- package/src/sync/connection.saga.test.ts +97 -0
- package/src/sync/connection.saga.ts +89 -0
- package/src/sync/live.saga.test.ts +178 -0
- package/src/sync/live.saga.ts +107 -0
- package/src/sync/policy.health.test.ts +56 -0
- package/src/sync/policy.test.ts +663 -0
- package/src/sync/policy.ts +515 -0
- package/src/sync/poll.saga.test.ts +51 -0
- package/src/sync/poll.saga.ts +36 -0
- package/src/sync/tabs.saga.test.ts +78 -0
- package/src/sync/tabs.saga.ts +96 -0
- package/src/testing/adapters.ts +133 -0
- package/src/testing/build.ts +63 -0
- package/src/testing/fake-services.ts +48 -0
- package/src/testing/run-pure.test.ts +92 -0
- package/src/testing/run-pure.ts +128 -0
- package/src/testing/services.ts +17 -0
- package/src/types.ts +624 -10
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/edges.ts +39 -0
- package/src/utils/error-classification.test.ts +54 -0
- package/src/utils/error-classification.ts +25 -0
- package/src/utils/index.ts +78 -12
- package/src/utils/parser.test.ts +67 -102
- package/src/utils/parser.ts +64 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/sha256.ts +15 -0
- package/src/utils/surql.ts +30 -18
- package/src/utils/utils.test.ts +3 -2
- package/tsdown.config.ts +117 -3
- package/vitest.config.ts +48 -0
- package/src/modules/cache/index.ts +0 -241
- package/src/modules/cache/types.ts +0 -19
- package/src/modules/data/data.test.ts +0 -58
- package/src/modules/data/index.ts +0 -879
- package/src/modules/sync/engine.ts +0 -173
- package/src/modules/sync/events/index.ts +0 -77
- package/src/modules/sync/index.ts +0 -3
- package/src/modules/sync/queue/index.ts +0 -2
- package/src/modules/sync/queue/queue-down.ts +0 -89
- package/src/modules/sync/queue/queue-up.ts +0 -223
- package/src/modules/sync/scheduler.ts +0 -84
- package/src/modules/sync/sync.ts +0 -421
- package/src/modules/sync/utils.test.ts +0 -311
- package/src/modules/sync/utils.ts +0 -167
package/src/sw/index.ts
ADDED
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@spooky-sync/core/sw`: Web Push for the app's service worker.
|
|
3
|
+
*
|
|
4
|
+
* ```js
|
|
5
|
+
* // sw.js
|
|
6
|
+
* import { installPushHandlers } from '@spooky-sync/core/sw';
|
|
7
|
+
* installPushHandlers({ placeholder: { title: 'My app', body: 'Something new' } });
|
|
8
|
+
* ```
|
|
9
|
+
*
|
|
10
|
+
* Shows content pushes as sent, hands content-free nudges to the app (or
|
|
11
|
+
* renders them from live data), suppresses notifications while a page of the
|
|
12
|
+
* app is visible, routes clicks, and keeps the session the page bridges over
|
|
13
|
+
* (`db.webPush.bridge()`) in IndexedDB.
|
|
14
|
+
*
|
|
15
|
+
* No wasm, no `surrealdb`, no `window`: it only imports the live feed's TYPES.
|
|
16
|
+
* Pass a feed factory (`createLiveFeed` from `@spooky-sync/core/live`) when you
|
|
17
|
+
* want live rendering.
|
|
18
|
+
*/
|
|
19
|
+
import type { LiveFeed, LiveQuery } from '../live/index';
|
|
20
|
+
import {
|
|
21
|
+
base64UrlToBytes,
|
|
22
|
+
isPushPayload,
|
|
23
|
+
type BridgeTokenMessage,
|
|
24
|
+
type PushNotification,
|
|
25
|
+
type PushPayload,
|
|
26
|
+
type WebPushClientMessage,
|
|
27
|
+
} from '../push/types';
|
|
28
|
+
import { decodeTokenClaims } from '../modules/auth/impersonation';
|
|
29
|
+
import { defaultStore, type KvStore } from './store';
|
|
30
|
+
|
|
31
|
+
export {
|
|
32
|
+
idbStore,
|
|
33
|
+
memoryStore,
|
|
34
|
+
defaultStore,
|
|
35
|
+
PUSH_DB_NAME,
|
|
36
|
+
PUSH_STORE_NAME,
|
|
37
|
+
type KvStore,
|
|
38
|
+
} from './store';
|
|
39
|
+
export * from '../push/types';
|
|
40
|
+
|
|
41
|
+
// ── structural service worker types (no WebWorker lib needed) ─────────────
|
|
42
|
+
|
|
43
|
+
export interface SwNotification {
|
|
44
|
+
title: string;
|
|
45
|
+
tag: string;
|
|
46
|
+
body?: string;
|
|
47
|
+
data?: any;
|
|
48
|
+
close(): void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface SwPushSubscription {
|
|
52
|
+
endpoint: string;
|
|
53
|
+
options?: { applicationServerKey?: ArrayBuffer | null };
|
|
54
|
+
toJSON(): unknown;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SwRegistration {
|
|
58
|
+
scope: string;
|
|
59
|
+
showNotification(title: string, options?: Record<string, unknown>): Promise<void>;
|
|
60
|
+
getNotifications(filter?: { tag?: string }): Promise<SwNotification[]>;
|
|
61
|
+
pushManager?: {
|
|
62
|
+
getSubscription(): Promise<SwPushSubscription | null>;
|
|
63
|
+
subscribe(options: {
|
|
64
|
+
userVisibleOnly: boolean;
|
|
65
|
+
applicationServerKey: unknown;
|
|
66
|
+
}): Promise<SwPushSubscription>;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface SwWindowClient {
|
|
71
|
+
url: string;
|
|
72
|
+
visibilityState?: string;
|
|
73
|
+
focused?: boolean;
|
|
74
|
+
focus(): Promise<unknown>;
|
|
75
|
+
navigate?(url: string): Promise<unknown>;
|
|
76
|
+
postMessage(message: unknown): void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface SwClients {
|
|
80
|
+
matchAll(options?: { type?: string; includeUncontrolled?: boolean }): Promise<SwWindowClient[]>;
|
|
81
|
+
openWindow(url: string): Promise<unknown>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface SwScope {
|
|
85
|
+
registration: SwRegistration;
|
|
86
|
+
clients: SwClients;
|
|
87
|
+
location: { origin: string };
|
|
88
|
+
navigator?: { userAgent?: string };
|
|
89
|
+
addEventListener(type: string, listener: (event: any) => void): void;
|
|
90
|
+
removeEventListener(type: string, listener: (event: any) => void): void;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface SwExtendableEvent {
|
|
94
|
+
waitUntil(promise: Promise<unknown>): void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface SwPushEvent extends SwExtendableEvent {
|
|
98
|
+
data: { json(): unknown; text(): string } | null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface SwNotificationEvent extends SwExtendableEvent {
|
|
102
|
+
notification: SwNotification;
|
|
103
|
+
action: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface SwPushSubscriptionChangeEvent extends SwExtendableEvent {
|
|
107
|
+
oldSubscription?: SwPushSubscription | null;
|
|
108
|
+
newSubscription?: SwPushSubscription | null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── public types ──────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
/** What `showNotification` gets: a title plus any notification option. */
|
|
114
|
+
export interface NotificationSpec extends PushNotification {
|
|
115
|
+
title: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The session a page bridged over (`db.webPush.bridge()`). Fits `createLiveFeed` as is. */
|
|
119
|
+
export interface BridgedToken {
|
|
120
|
+
token: string;
|
|
121
|
+
userId: string;
|
|
122
|
+
endpoint: string;
|
|
123
|
+
namespace: string;
|
|
124
|
+
database: string;
|
|
125
|
+
publicKey?: string;
|
|
126
|
+
pushEndpoint?: string;
|
|
127
|
+
/** Epoch ms it was stored. */
|
|
128
|
+
at: number;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface PushHandlerContext {
|
|
132
|
+
event: SwPushEvent;
|
|
133
|
+
registration: SwRegistration;
|
|
134
|
+
/** Show a notification the way the bridge would (tag, url and payload in `data`). */
|
|
135
|
+
show(spec: NotificationSpec): Promise<void>;
|
|
136
|
+
showPlaceholder(): Promise<void>;
|
|
137
|
+
/** Visible windows of this app, right now. */
|
|
138
|
+
visibleClients(): Promise<SwWindowClient[]>;
|
|
139
|
+
/** The bridged session, or null (signed out, never bridged, expired). */
|
|
140
|
+
bridged(): Promise<BridgedToken | null>;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface LiveRenderOptions<Row = Record<string, any>> {
|
|
144
|
+
/** Open a feed for the bridged session: `(t) => createLiveFeed(t)`. */
|
|
145
|
+
feed(bridged: BridgedToken): LiveFeed | Promise<LiveFeed>;
|
|
146
|
+
/** What to read for this user (a built query, or `{ surql, params }`). */
|
|
147
|
+
query(userId: string, payload: PushPayload): LiveQuery;
|
|
148
|
+
/** Should this row be on screen as a notification? */
|
|
149
|
+
visible(row: Row): boolean;
|
|
150
|
+
/** The notification for one row; `tag` identifies it (one notification per row). */
|
|
151
|
+
render(row: Row): NotificationSpec & { tag: string };
|
|
152
|
+
/** Budget for loading the rows, ms. Default 8000 (the push event lives ~30 s). */
|
|
153
|
+
timeoutMs?: number;
|
|
154
|
+
/** Quiet period `idle()` waits for, ms. Default 300. */
|
|
155
|
+
quietMs?: number;
|
|
156
|
+
/**
|
|
157
|
+
* A push whose only effect was closing notifications (their rows stopped
|
|
158
|
+
* being visible, e.g. read on another device) shows nothing. Default true.
|
|
159
|
+
* `false` shows the placeholder instead, for origins that hit Chrome's
|
|
160
|
+
* silent-push allowance.
|
|
161
|
+
*/
|
|
162
|
+
silentClose?: boolean;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface InstallPushHandlersOptions<Row = Record<string, any>> {
|
|
166
|
+
/** Turn a notification url into the one to open. Default: resolved against the worker's scope. */
|
|
167
|
+
resolveUrl?(url: string, payload: PushPayload | undefined): string;
|
|
168
|
+
/** Sees every valid push first. Return `true` when you handled it (and showed something). */
|
|
169
|
+
onPush?(payload: PushPayload, ctx: PushHandlerContext): Promise<boolean | void> | boolean | void;
|
|
170
|
+
/** Handles content-free nudges (you must show a notification). */
|
|
171
|
+
onNudge?(payload: PushPayload, ctx: PushHandlerContext): Promise<void> | void;
|
|
172
|
+
/**
|
|
173
|
+
* Custom rendering. For a content push, replaces the default mapping of
|
|
174
|
+
* `payload.notification`; for a nudge, a returned spec is shown instead of
|
|
175
|
+
* the live renderer. `null`/`undefined` keeps the default.
|
|
176
|
+
*/
|
|
177
|
+
render?(payload: PushPayload): NotificationSpec | null | undefined;
|
|
178
|
+
/** Post `{ type: 'sp00ky:push', payload }` to visible windows instead of showing. Default true. */
|
|
179
|
+
suppressWhenVisible?: boolean;
|
|
180
|
+
/** Shown when a push cannot be rendered (browsers require a notification per push). */
|
|
181
|
+
placeholder?: { title: string; body?: string; icon?: string; [key: string]: unknown };
|
|
182
|
+
/** Return `true` when you handled the click yourself. */
|
|
183
|
+
onClick?(
|
|
184
|
+
event: SwNotificationEvent,
|
|
185
|
+
payload: PushPayload | undefined,
|
|
186
|
+
action: string | undefined
|
|
187
|
+
): Promise<boolean | void> | boolean | void;
|
|
188
|
+
onClose?(event: SwNotificationEvent, payload: PushPayload | undefined): Promise<void> | void;
|
|
189
|
+
/**
|
|
190
|
+
* How a click reaches an already open window: `navigate` (default) loads the
|
|
191
|
+
* url in it, `message` posts `{ type: 'sp00ky:navigate', url }` for in-app
|
|
192
|
+
* routing (`db.webPush.onMessage`).
|
|
193
|
+
*/
|
|
194
|
+
clickMode?: 'navigate' | 'message';
|
|
195
|
+
/** Render nudges from live data. */
|
|
196
|
+
live?: LiveRenderOptions<Row>;
|
|
197
|
+
/** Feed factory for `pushsubscriptionchange` when `live` is not configured. */
|
|
198
|
+
feed?(bridged: BridgedToken): LiveFeed | Promise<LiveFeed>;
|
|
199
|
+
/** Re-subscribe on `pushsubscriptionchange`. Default true. */
|
|
200
|
+
resubscribe?: boolean;
|
|
201
|
+
/** Defaults to `self`. */
|
|
202
|
+
scope?: SwScope;
|
|
203
|
+
/** Defaults to IndexedDB `sp00ky-push` / `kv`. */
|
|
204
|
+
store?: KvStore;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export const DEFAULT_PLACEHOLDER = {
|
|
208
|
+
title: 'New notification',
|
|
209
|
+
body: 'Open the app to see what is new.',
|
|
210
|
+
};
|
|
211
|
+
const PLACEHOLDER_TAG = 'sp00ky-placeholder';
|
|
212
|
+
const TOKEN_KEY = 'token';
|
|
213
|
+
|
|
214
|
+
/** The session the page bridged, or null (none, signed out, or its JWT expired). */
|
|
215
|
+
export async function getBridgedToken(
|
|
216
|
+
store: KvStore = defaultStore()
|
|
217
|
+
): Promise<BridgedToken | null> {
|
|
218
|
+
let value: BridgedToken | undefined;
|
|
219
|
+
try {
|
|
220
|
+
value = await store.get<BridgedToken>(TOKEN_KEY);
|
|
221
|
+
} catch {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
if (!value || typeof value.token !== 'string') return null;
|
|
225
|
+
const { exp } = decodeTokenClaims(value.token);
|
|
226
|
+
if (exp !== null && exp * 1000 <= Date.now()) return null;
|
|
227
|
+
return value;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function isTokenMessage(data: unknown): data is BridgeTokenMessage {
|
|
231
|
+
const m = data as Partial<BridgeTokenMessage> | null;
|
|
232
|
+
return (
|
|
233
|
+
!!m &&
|
|
234
|
+
m.type === 'sp00ky:token' &&
|
|
235
|
+
typeof m.token === 'string' &&
|
|
236
|
+
typeof m.userId === 'string' &&
|
|
237
|
+
typeof m.endpoint === 'string' &&
|
|
238
|
+
typeof m.namespace === 'string' &&
|
|
239
|
+
typeof m.database === 'string'
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function dropUndefined(obj: Record<string, unknown>): Record<string, unknown> {
|
|
244
|
+
for (const key of Object.keys(obj)) if (obj[key] === undefined) delete obj[key];
|
|
245
|
+
return obj;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* `showNotification(title, options)` arguments for a spec: every key passes
|
|
250
|
+
* through except `title` and `url`; `tag` defaults to the topic; `data` gets
|
|
251
|
+
* `url`, the payload and per-action urls next to the spec's own data.
|
|
252
|
+
*/
|
|
253
|
+
export function notificationOptions(
|
|
254
|
+
spec: NotificationSpec,
|
|
255
|
+
payload?: PushPayload,
|
|
256
|
+
extraData?: Record<string, unknown>
|
|
257
|
+
): [string, Record<string, unknown>] {
|
|
258
|
+
const { title, url, data, tag, actions, renotify, ...rest } = spec;
|
|
259
|
+
const actionUrls: Record<string, string> = {};
|
|
260
|
+
for (const a of actions ?? []) if (a && typeof a.url === 'string') actionUrls[a.action] = a.url;
|
|
261
|
+
const finalTag = tag ?? payload?.topic;
|
|
262
|
+
const options: Record<string, unknown> = {
|
|
263
|
+
...rest,
|
|
264
|
+
tag: finalTag,
|
|
265
|
+
actions: actions?.map(({ url: _url, ...a }) => a),
|
|
266
|
+
// `renotify` without a tag is a TypeError in Chrome.
|
|
267
|
+
renotify: finalTag ? renotify : undefined,
|
|
268
|
+
data: dropUndefined({
|
|
269
|
+
...data,
|
|
270
|
+
url,
|
|
271
|
+
payload,
|
|
272
|
+
actionUrls: Object.keys(actionUrls).length > 0 ? actionUrls : undefined,
|
|
273
|
+
...extraData,
|
|
274
|
+
}),
|
|
275
|
+
};
|
|
276
|
+
return [title, dropUndefined(options)];
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Wire the push, click, close, message and subscription-change handlers. Returns an uninstall. */
|
|
280
|
+
export function installPushHandlers<Row = Record<string, any>>(
|
|
281
|
+
options: InstallPushHandlersOptions<Row> = {}
|
|
282
|
+
): () => void {
|
|
283
|
+
const scope = options.scope ?? (globalThis as unknown as SwScope);
|
|
284
|
+
const store = options.store ?? defaultStore();
|
|
285
|
+
const suppress = options.suppressWhenVisible !== false;
|
|
286
|
+
const placeholder = options.placeholder ?? DEFAULT_PLACEHOLDER;
|
|
287
|
+
|
|
288
|
+
const resolveUrl = (url: string, payload: PushPayload | undefined): string => {
|
|
289
|
+
if (options.resolveUrl) return options.resolveUrl(url, payload);
|
|
290
|
+
try {
|
|
291
|
+
return new URL(url, scope.registration?.scope || scope.location.origin).href;
|
|
292
|
+
} catch {
|
|
293
|
+
return url;
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const windows = async (): Promise<SwWindowClient[]> => {
|
|
298
|
+
const all = await scope.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
|
299
|
+
return all.filter((c) => {
|
|
300
|
+
try {
|
|
301
|
+
return new URL(c.url).origin === scope.location.origin;
|
|
302
|
+
} catch {
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
};
|
|
307
|
+
const visibleClients = async () =>
|
|
308
|
+
(await windows()).filter((c) => c.visibilityState === 'visible');
|
|
309
|
+
|
|
310
|
+
const show = async (
|
|
311
|
+
spec: NotificationSpec,
|
|
312
|
+
payload?: PushPayload,
|
|
313
|
+
extraData?: Record<string, unknown>
|
|
314
|
+
) => {
|
|
315
|
+
const [title, opts] = notificationOptions(spec, payload, extraData);
|
|
316
|
+
await scope.registration.showNotification(title, opts);
|
|
317
|
+
};
|
|
318
|
+
const showPlaceholder = () => show({ ...placeholder, tag: PLACEHOLDER_TAG, url: '/' });
|
|
319
|
+
|
|
320
|
+
const ctxFor = (event: SwPushEvent, flag: { shown: boolean }): PushHandlerContext => ({
|
|
321
|
+
event,
|
|
322
|
+
registration: scope.registration,
|
|
323
|
+
show: async (spec) => {
|
|
324
|
+
flag.shown = true;
|
|
325
|
+
await show(spec);
|
|
326
|
+
},
|
|
327
|
+
showPlaceholder: async () => {
|
|
328
|
+
flag.shown = true;
|
|
329
|
+
await showPlaceholder();
|
|
330
|
+
},
|
|
331
|
+
visibleClients,
|
|
332
|
+
bridged: () => getBridgedToken(store),
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const renderLive = async (live: LiveRenderOptions<Row>, payload: PushPayload): Promise<void> => {
|
|
336
|
+
const bridged = await getBridgedToken(store);
|
|
337
|
+
if (!bridged) throw new Error('sp00ky push: no bridged session for live rendering');
|
|
338
|
+
const budget = live.timeoutMs ?? 8_000;
|
|
339
|
+
const started = Date.now();
|
|
340
|
+
const feed = await live.feed(bridged);
|
|
341
|
+
let rows: Row[] = [];
|
|
342
|
+
try {
|
|
343
|
+
const sub = feed.subscribe<Row>(live.query(bridged.userId, payload), {
|
|
344
|
+
onSet: (r) => void (rows = r),
|
|
345
|
+
});
|
|
346
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
347
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
348
|
+
timer = setTimeout(
|
|
349
|
+
() => reject(new Error('sp00ky push: live rows did not load in time')),
|
|
350
|
+
budget
|
|
351
|
+
);
|
|
352
|
+
});
|
|
353
|
+
try {
|
|
354
|
+
rows = await Promise.race([sub.ready, deadline]);
|
|
355
|
+
} finally {
|
|
356
|
+
clearTimeout(timer);
|
|
357
|
+
}
|
|
358
|
+
await feed.idle({
|
|
359
|
+
timeoutMs: Math.max(0, budget - (Date.now() - started)),
|
|
360
|
+
quietMs: live.quietMs,
|
|
361
|
+
});
|
|
362
|
+
rows = sub.rows();
|
|
363
|
+
} finally {
|
|
364
|
+
await feed.close().catch(() => undefined);
|
|
365
|
+
}
|
|
366
|
+
const specs = rows.filter((row) => live.visible(row)).map((row) => live.render(row));
|
|
367
|
+
const wanted = new Set(specs.map((s) => s.tag));
|
|
368
|
+
const shown = await scope.registration.getNotifications();
|
|
369
|
+
let closed = 0;
|
|
370
|
+
for (const n of shown) {
|
|
371
|
+
if (n.data?.sp00kyLive && !wanted.has(n.tag)) {
|
|
372
|
+
n.close();
|
|
373
|
+
closed += 1;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
for (const spec of specs) {
|
|
377
|
+
const existing = shown.find((n) => n.tag === spec.tag && n.data?.sp00kyLive);
|
|
378
|
+
if (existing && existing.title === spec.title && (existing.body ?? '') === (spec.body ?? ''))
|
|
379
|
+
continue;
|
|
380
|
+
await show(spec, undefined, { sp00kyLive: true });
|
|
381
|
+
}
|
|
382
|
+
// A push must leave something on screen (Chrome shows its own "updated in
|
|
383
|
+
// the background" notice otherwise). Closing a notification because its
|
|
384
|
+
// row was read elsewhere is the point of the push, so by default that
|
|
385
|
+
// case stays silent: Chrome tolerates occasional silent pushes, and a
|
|
386
|
+
// placeholder there would replace "read on the phone" with "something new".
|
|
387
|
+
const after = await scope.registration.getNotifications();
|
|
388
|
+
if (after.length === 0 && (closed === 0 || live.silentClose === false)) await showPlaceholder();
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const handlePush = async (event: SwPushEvent): Promise<void> => {
|
|
392
|
+
let payload: unknown = null;
|
|
393
|
+
try {
|
|
394
|
+
payload = event.data ? event.data.json() : null;
|
|
395
|
+
} catch {
|
|
396
|
+
payload = null;
|
|
397
|
+
}
|
|
398
|
+
if (!isPushPayload(payload)) {
|
|
399
|
+
await showPlaceholder();
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const flag = { shown: false };
|
|
403
|
+
const ctx = ctxFor(event, flag);
|
|
404
|
+
try {
|
|
405
|
+
if (options.onPush && (await options.onPush(payload, ctx)) === true) return;
|
|
406
|
+
} catch {
|
|
407
|
+
if (!flag.shown) await showPlaceholder();
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (suppress) {
|
|
411
|
+
const visible = await visibleClients().catch(() => [] as SwWindowClient[]);
|
|
412
|
+
if (visible.length > 0) {
|
|
413
|
+
const message: WebPushClientMessage = { type: 'sp00ky:push', payload };
|
|
414
|
+
for (const client of visible) client.postMessage(message);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if (payload.notification) {
|
|
419
|
+
const custom = options.render?.(payload);
|
|
420
|
+
const spec: NotificationSpec = custom ?? {
|
|
421
|
+
...payload.notification,
|
|
422
|
+
title: payload.notification.title ?? placeholder.title,
|
|
423
|
+
};
|
|
424
|
+
await show(spec, payload);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
try {
|
|
428
|
+
if (options.onNudge) {
|
|
429
|
+
await options.onNudge(payload, ctx);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const custom = options.render?.(payload);
|
|
433
|
+
if (custom) {
|
|
434
|
+
await show(custom, payload);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
if (options.live) {
|
|
438
|
+
await renderLive(options.live, payload);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
} catch {
|
|
442
|
+
if (!flag.shown) await showPlaceholder();
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
await showPlaceholder();
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
const handleClick = async (event: SwNotificationEvent): Promise<void> => {
|
|
449
|
+
const notification = event.notification;
|
|
450
|
+
const data = (notification.data ?? {}) as {
|
|
451
|
+
url?: string;
|
|
452
|
+
payload?: PushPayload;
|
|
453
|
+
actionUrls?: Record<string, string>;
|
|
454
|
+
};
|
|
455
|
+
const payload = isPushPayload(data.payload) ? data.payload : undefined;
|
|
456
|
+
const action = event.action || undefined;
|
|
457
|
+
if (options.onClick && (await options.onClick(event, payload, action)) === true) return;
|
|
458
|
+
notification.close();
|
|
459
|
+
const raw = (action && data.actionUrls?.[action]) || data.url || '/';
|
|
460
|
+
const url = resolveUrl(raw, payload);
|
|
461
|
+
const open = await windows();
|
|
462
|
+
const target =
|
|
463
|
+
open.find((c) => c.focused) ?? open.find((c) => c.visibilityState === 'visible') ?? open[0];
|
|
464
|
+
if (!target) {
|
|
465
|
+
await scope.clients.openWindow(url);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
await target.focus().catch(() => undefined);
|
|
469
|
+
const message: WebPushClientMessage = { type: 'sp00ky:navigate', url, payload, action };
|
|
470
|
+
if (options.clickMode === 'message') {
|
|
471
|
+
target.postMessage(message);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (target.url === url) return;
|
|
475
|
+
if (target.navigate) {
|
|
476
|
+
try {
|
|
477
|
+
await target.navigate(url);
|
|
478
|
+
return;
|
|
479
|
+
} catch {
|
|
480
|
+
/* an uncontrolled window cannot be navigated from here; tell it instead */
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
target.postMessage(message);
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
const handleClose = async (event: SwNotificationEvent): Promise<void> => {
|
|
487
|
+
const data = (event.notification.data ?? {}) as { payload?: PushPayload };
|
|
488
|
+
await options.onClose?.(event, isPushPayload(data.payload) ? data.payload : undefined);
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
const signOut = async (): Promise<void> => {
|
|
492
|
+
await store.delete(TOKEN_KEY).catch(() => undefined);
|
|
493
|
+
const shown = await scope.registration.getNotifications().catch(() => [] as SwNotification[]);
|
|
494
|
+
for (const n of shown) n.close();
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
const handleMessage = (event: {
|
|
498
|
+
data?: unknown;
|
|
499
|
+
origin?: string;
|
|
500
|
+
waitUntil?: (p: Promise<unknown>) => void;
|
|
501
|
+
}) => {
|
|
502
|
+
const data = event.data as { type?: unknown } | null | undefined;
|
|
503
|
+
if (!data || typeof data !== 'object') return;
|
|
504
|
+
if (event.origin && event.origin !== scope.location.origin) return;
|
|
505
|
+
let work: Promise<unknown> | null = null;
|
|
506
|
+
if (isTokenMessage(data)) {
|
|
507
|
+
const value: BridgedToken = {
|
|
508
|
+
token: data.token,
|
|
509
|
+
userId: data.userId,
|
|
510
|
+
endpoint: data.endpoint,
|
|
511
|
+
namespace: data.namespace,
|
|
512
|
+
database: data.database,
|
|
513
|
+
publicKey: typeof data.publicKey === 'string' ? data.publicKey : undefined,
|
|
514
|
+
pushEndpoint: typeof data.pushEndpoint === 'string' ? data.pushEndpoint : undefined,
|
|
515
|
+
at: Date.now(),
|
|
516
|
+
};
|
|
517
|
+
work = store.set(TOKEN_KEY, dropUndefined({ ...value })).catch(() => undefined);
|
|
518
|
+
} else if (data.type === 'sp00ky:signout') {
|
|
519
|
+
work = signOut();
|
|
520
|
+
}
|
|
521
|
+
if (work) event.waitUntil?.(work);
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
const handleSubscriptionChange = async (event: SwPushSubscriptionChangeEvent): Promise<void> => {
|
|
525
|
+
if (options.resubscribe === false) return;
|
|
526
|
+
const bridged = await getBridgedToken(store);
|
|
527
|
+
let sub = event.newSubscription ?? null;
|
|
528
|
+
if (!sub) {
|
|
529
|
+
const key =
|
|
530
|
+
event.oldSubscription?.options?.applicationServerKey ??
|
|
531
|
+
(bridged?.publicKey ? base64UrlToBytes(bridged.publicKey) : null);
|
|
532
|
+
const manager = scope.registration.pushManager;
|
|
533
|
+
if (key && manager) {
|
|
534
|
+
try {
|
|
535
|
+
sub = await manager.subscribe({ userVisibleOnly: true, applicationServerKey: key });
|
|
536
|
+
} catch {
|
|
537
|
+
sub = null;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
const factory = options.live?.feed ?? options.feed;
|
|
542
|
+
if (sub && bridged && factory) {
|
|
543
|
+
let feed: LiveFeed | null = null;
|
|
544
|
+
try {
|
|
545
|
+
feed = await factory(bridged);
|
|
546
|
+
const oldEndpoint = event.oldSubscription?.endpoint ?? bridged.pushEndpoint;
|
|
547
|
+
// Carry the device's label and rule filter over to the new endpoint.
|
|
548
|
+
const [devices] =
|
|
549
|
+
await feed.query<
|
|
550
|
+
[Array<{ endpoint?: string; label?: unknown; rules?: unknown; meta?: unknown }>]
|
|
551
|
+
>('RETURN fn::push::list()');
|
|
552
|
+
const old = Array.isArray(devices)
|
|
553
|
+
? devices.find((d) => d.endpoint === oldEndpoint)
|
|
554
|
+
: undefined;
|
|
555
|
+
await feed.query('RETURN fn::push::subscribe($sub, $opts)', {
|
|
556
|
+
sub: sub.toJSON(),
|
|
557
|
+
opts: dropUndefined({
|
|
558
|
+
label: old?.label ?? undefined,
|
|
559
|
+
rules: old?.rules ?? undefined,
|
|
560
|
+
meta: old?.meta ?? undefined,
|
|
561
|
+
userAgent: scope.navigator?.userAgent,
|
|
562
|
+
}),
|
|
563
|
+
});
|
|
564
|
+
if (oldEndpoint && oldEndpoint !== sub.endpoint) {
|
|
565
|
+
await feed.query('RETURN fn::push::unsubscribe($endpoint)', { endpoint: oldEndpoint });
|
|
566
|
+
}
|
|
567
|
+
await store.set(TOKEN_KEY, { ...bridged, pushEndpoint: sub.endpoint });
|
|
568
|
+
} catch {
|
|
569
|
+
/* the page's next sync() registers the new endpoint */
|
|
570
|
+
} finally {
|
|
571
|
+
await feed?.close().catch(() => undefined);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
const message: WebPushClientMessage = {
|
|
575
|
+
type: 'sp00ky:subscriptionchange',
|
|
576
|
+
endpoint: sub?.endpoint,
|
|
577
|
+
};
|
|
578
|
+
for (const client of await windows().catch(() => [] as SwWindowClient[]))
|
|
579
|
+
client.postMessage(message);
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
const listeners: Array<[string, (event: any) => void]> = [
|
|
583
|
+
[
|
|
584
|
+
'push',
|
|
585
|
+
(event: SwPushEvent) =>
|
|
586
|
+
event.waitUntil(handlePush(event).catch(() => showPlaceholder().catch(() => undefined))),
|
|
587
|
+
],
|
|
588
|
+
[
|
|
589
|
+
'notificationclick',
|
|
590
|
+
(event: SwNotificationEvent) => event.waitUntil(handleClick(event).catch(() => undefined)),
|
|
591
|
+
],
|
|
592
|
+
[
|
|
593
|
+
'notificationclose',
|
|
594
|
+
(event: SwNotificationEvent) => event.waitUntil?.(handleClose(event).catch(() => undefined)),
|
|
595
|
+
],
|
|
596
|
+
['message', handleMessage],
|
|
597
|
+
[
|
|
598
|
+
'pushsubscriptionchange',
|
|
599
|
+
(event: SwPushSubscriptionChangeEvent) =>
|
|
600
|
+
event.waitUntil(handleSubscriptionChange(event).catch(() => undefined)),
|
|
601
|
+
],
|
|
602
|
+
];
|
|
603
|
+
for (const [type, listener] of listeners) scope.addEventListener(type, listener);
|
|
604
|
+
return () => {
|
|
605
|
+
for (const [type, listener] of listeners) scope.removeEventListener(type, listener);
|
|
606
|
+
};
|
|
607
|
+
}
|
package/src/sw/store.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The service worker's tiny key-value store: IndexedDB database `sp00ky-push`,
|
|
3
|
+
* object store `kv`. Holds the bridged session (`token`) so a push that wakes
|
|
4
|
+
* the worker with no page open can still read live data. Falls back to memory
|
|
5
|
+
* where IndexedDB does not exist (Node, tests).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface KvStore {
|
|
9
|
+
get<T>(key: string): Promise<T | undefined>;
|
|
10
|
+
set(key: string, value: unknown): Promise<void>;
|
|
11
|
+
delete(key: string): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const PUSH_DB_NAME = 'sp00ky-push';
|
|
15
|
+
export const PUSH_STORE_NAME = 'kv';
|
|
16
|
+
|
|
17
|
+
export function memoryStore(): KvStore {
|
|
18
|
+
const map = new Map<string, unknown>();
|
|
19
|
+
return {
|
|
20
|
+
get: async <T>(key: string) => map.get(key) as T | undefined,
|
|
21
|
+
set: async (key, value) => void map.set(key, value),
|
|
22
|
+
delete: async (key) => void map.delete(key),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type IdbFactoryLike = {
|
|
27
|
+
open(name: string, version?: number): IDBOpenDBRequest;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function idbStore(
|
|
31
|
+
dbName = PUSH_DB_NAME,
|
|
32
|
+
storeName = PUSH_STORE_NAME,
|
|
33
|
+
factory?: IdbFactoryLike
|
|
34
|
+
): KvStore {
|
|
35
|
+
let opening: Promise<IDBDatabase> | null = null;
|
|
36
|
+
const open = (): Promise<IDBDatabase> => {
|
|
37
|
+
if (opening) return opening;
|
|
38
|
+
const idb = factory ?? (globalThis as { indexedDB?: IdbFactoryLike }).indexedDB;
|
|
39
|
+
if (!idb) return Promise.reject(new Error('indexedDB unavailable'));
|
|
40
|
+
opening = new Promise<IDBDatabase>((resolve, reject) => {
|
|
41
|
+
const req = idb.open(dbName, 1);
|
|
42
|
+
req.onupgradeneeded = () => {
|
|
43
|
+
if (!req.result.objectStoreNames.contains(storeName))
|
|
44
|
+
req.result.createObjectStore(storeName);
|
|
45
|
+
};
|
|
46
|
+
req.onsuccess = () => {
|
|
47
|
+
const db = req.result;
|
|
48
|
+
// Another context upgrading the database closes ours; reopen next time.
|
|
49
|
+
db.onversionchange = () => {
|
|
50
|
+
db.close();
|
|
51
|
+
opening = null;
|
|
52
|
+
};
|
|
53
|
+
resolve(db);
|
|
54
|
+
};
|
|
55
|
+
req.onerror = () => {
|
|
56
|
+
opening = null;
|
|
57
|
+
reject(req.error);
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
return opening;
|
|
61
|
+
};
|
|
62
|
+
const run = async <T>(
|
|
63
|
+
mode: IDBTransactionMode,
|
|
64
|
+
op: (store: IDBObjectStore) => IDBRequest
|
|
65
|
+
): Promise<T> => {
|
|
66
|
+
const db = await open();
|
|
67
|
+
return new Promise<T>((resolve, reject) => {
|
|
68
|
+
const tx = db.transaction(storeName, mode);
|
|
69
|
+
const req = op(tx.objectStore(storeName));
|
|
70
|
+
tx.oncomplete = () => resolve(req.result as T);
|
|
71
|
+
tx.onerror = () => reject(tx.error ?? req.error);
|
|
72
|
+
tx.onabort = () => reject(tx.error ?? req.error);
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
return {
|
|
76
|
+
get: <T>(key: string) => run<T | undefined>('readonly', (s) => s.get(key)),
|
|
77
|
+
set: async (key, value) => {
|
|
78
|
+
await run('readwrite', (s) => s.put(value, key));
|
|
79
|
+
},
|
|
80
|
+
delete: async (key) => {
|
|
81
|
+
await run('readwrite', (s) => s.delete(key));
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let shared: KvStore | null = null;
|
|
87
|
+
|
|
88
|
+
/** IndexedDB when it exists, else one process-wide memory store. */
|
|
89
|
+
export function defaultStore(): KvStore {
|
|
90
|
+
if (shared) return shared;
|
|
91
|
+
shared =
|
|
92
|
+
typeof (globalThis as { indexedDB?: unknown }).indexedDB !== 'undefined'
|
|
93
|
+
? idbStore()
|
|
94
|
+
: memoryStore();
|
|
95
|
+
return shared;
|
|
96
|
+
}
|