@spooky-sync/core 0.0.1-canary.33 → 0.0.1-canary.331
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 +84 -0
- package/README.md +56 -16
- package/dist/index.d.ts +2080 -142
- package/dist/index.js +16009 -2480
- package/dist/live.d.ts +149 -0
- package/dist/live.js +739 -0
- package/dist/otel/index.d.ts +1 -1
- package/dist/otel/index.js +3 -3
- package/dist/pure.d.ts +373 -0
- package/dist/pure.js +24 -0
- package/dist/sqlite-open.js +392 -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 +477 -0
- package/dist/types.d.ts +1857 -38
- package/dist/worker-edges.js +480 -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 +28 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/scripts/check-worker-entries.mjs +173 -0
- package/skills/sp00ky-core/SKILL.md +32 -7
- package/skills/sp00ky-core/references/config.md +7 -1
- 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 +203 -0
- package/src/boot/boot.saga.ts +146 -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 +80 -0
- package/src/boot/preload.saga.ts +40 -0
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/client/bucket-handle.test.ts +195 -0
- package/src/client/bucket-handle.ts +352 -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 +74 -0
- package/src/client/services.ts +434 -0
- package/src/client/sp00ky-client.test.ts +341 -0
- package/src/client/sp00ky-client.ts +625 -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 +167 -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 +512 -0
- package/src/live/index.ts +1059 -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 +228 -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 +476 -22
- package/src/modules/crdt/acceptance.ts +75 -0
- package/src/modules/crdt/bytes.ts +50 -0
- package/src/modules/crdt/crdt-field.ts +125 -0
- package/src/modules/crdt/crdt.test.ts +469 -0
- package/src/modules/crdt/hash.ts +103 -0
- package/src/modules/crdt/index.ts +757 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/crdt/mirror.ts +37 -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 +301 -0
- package/src/mutation/push.saga.ts +225 -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 +527 -0
- package/src/query/env.test.ts +23 -0
- package/src/query/env.ts +65 -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 +186 -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 +407 -0
- package/src/query/membership.saga.ts +282 -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 +235 -0
- package/src/query/render-set.test.ts +47 -0
- package/src/query/render-set.ts +71 -0
- package/src/query/sql.test.ts +118 -0
- package/src/query/sql.ts +157 -0
- package/src/query/window-query.test.ts +70 -0
- package/src/query/window-query.ts +208 -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 +209 -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 +156 -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 +61 -16
- package/src/services/database/local.test.ts +150 -0
- package/src/services/database/local.ts +508 -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 +285 -6
- package/src/services/database/sqlite-cache-engine.test.ts +628 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1401 -0
- package/src/services/database/sqlite-column-reset.integration.test.ts +100 -0
- package/src/services/database/sqlite-column-reset.ts +91 -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 +164 -0
- package/src/services/database/sqlite-plan-sql.ts +196 -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 +417 -0
- package/src/services/database/surreal-cache-engine.ts +163 -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 +806 -67
- 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 +234 -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 +284 -0
- package/src/services/tabs/broker.test.ts +381 -0
- package/src/services/tabs/coordinator.test.ts +386 -0
- package/src/services/tabs/coordinator.ts +646 -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 +652 -0
- package/src/sp00ky.ts +5 -395
- package/src/state/client-state.ts +208 -0
- package/src/state/lifecycle.test.ts +141 -0
- package/src/state/lifecycle.ts +99 -0
- package/src/state/reducers.test.ts +306 -0
- package/src/state/reducers.ts +531 -0
- package/src/state/selectors.test.ts +188 -0
- package/src/state/selectors.ts +258 -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 +137 -0
- package/src/testing/build.ts +63 -0
- package/src/testing/fake-services.ts +63 -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 +633 -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 +89 -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 +29 -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
|
@@ -0,0 +1,1027 @@
|
|
|
1
|
+
import { RecordId } from 'surrealdb';
|
|
2
|
+
import type { Logger } from '../../services/logger/index';
|
|
3
|
+
import type { SignOutContext } from '../auth/index';
|
|
4
|
+
import type { PersistenceClient } from '../../types';
|
|
5
|
+
import { encodeRecordId, withTimeout } from '../../utils/index';
|
|
6
|
+
import {
|
|
7
|
+
base64UrlToBytes,
|
|
8
|
+
sameBytes,
|
|
9
|
+
type BridgeTokenMessage,
|
|
10
|
+
type PushNotification,
|
|
11
|
+
type PushUrgency,
|
|
12
|
+
type WebPushClientMessage,
|
|
13
|
+
} from '../../push/types';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `db.webPush`: this browser's Web Push subscription, on the page.
|
|
17
|
+
*
|
|
18
|
+
* Every server call is a `fn::push::*` function over the client's remote
|
|
19
|
+
* connection (see `apps/cli/src/push_tables.surql`). Nothing here touches a
|
|
20
|
+
* browser API at import time, and every method answers "unsupported" instead
|
|
21
|
+
* of throwing where there is no window, service worker or push manager
|
|
22
|
+
* (SSR, Node, workers, iOS Safari outside a home-screen app).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// ── config & public types ───────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export interface WebPushConfig {
|
|
28
|
+
/**
|
|
29
|
+
* Registered with `navigator.serviceWorker.register(url)` when the page has
|
|
30
|
+
* no service worker yet. Leave unset when the app (or vite-plugin-pwa)
|
|
31
|
+
* registers its own.
|
|
32
|
+
*/
|
|
33
|
+
serviceWorkerUrl?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Reconcile this browser's subscription after sign-in (see `sync()`): a
|
|
36
|
+
* rotated key, a browser that dropped the subscription or a server row that
|
|
37
|
+
* went away are repaired without a prompt. Default `true`.
|
|
38
|
+
*/
|
|
39
|
+
autoSync?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Let `sync()` subscribe a signed-in user whenever notification permission
|
|
42
|
+
* is already granted, even if they never subscribed on this browser.
|
|
43
|
+
* Default `false`: only a user who called `subscribe()` here is restored.
|
|
44
|
+
*/
|
|
45
|
+
autoResubscribe?: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Remove this device's subscription for the user on `signOut()` (before the
|
|
48
|
+
* token is dropped, bounded by a short timeout). Default `true`.
|
|
49
|
+
*/
|
|
50
|
+
unsubscribeOnSignOut?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Post the session token to the app's service worker whenever it changes,
|
|
53
|
+
* while notifications are allowed, so `@spooky-sync/core/sw` can render from
|
|
54
|
+
* live data. Never posts an impersonation token. Default `true`.
|
|
55
|
+
*/
|
|
56
|
+
bridge?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type WebPushUnsupportedReason =
|
|
60
|
+
| 'no-window'
|
|
61
|
+
| 'insecure-context'
|
|
62
|
+
| 'no-service-worker'
|
|
63
|
+
| 'no-push-manager'
|
|
64
|
+
| 'no-notification'
|
|
65
|
+
| 'ios-not-installed';
|
|
66
|
+
|
|
67
|
+
export interface WebPushSupport {
|
|
68
|
+
supported: boolean;
|
|
69
|
+
permission: NotificationPermission | 'unsupported';
|
|
70
|
+
reason?: WebPushUnsupportedReason;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface WebPushInfo {
|
|
74
|
+
/** A host with push on has published its key. */
|
|
75
|
+
enabled: boolean;
|
|
76
|
+
/** VAPID public key (base64url), the browser's `applicationServerKey`. */
|
|
77
|
+
publicKey?: string;
|
|
78
|
+
/** Id of that key. */
|
|
79
|
+
kid?: string;
|
|
80
|
+
/**
|
|
81
|
+
* Every kind of device the project reaches: `web` once a VAPID key is
|
|
82
|
+
* published, `apns` / `fcm` when `push.apns` / `push.fcm` are configured.
|
|
83
|
+
*/
|
|
84
|
+
providers?: PushDeviceKind[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** How a device is reached: a browser (Web Push), APNs or FCM. */
|
|
88
|
+
export type PushDeviceKind = 'web' | 'apns' | 'fcm';
|
|
89
|
+
|
|
90
|
+
export interface WebPushDevice {
|
|
91
|
+
/** `_00_push_subscription:<id>` */
|
|
92
|
+
id: string;
|
|
93
|
+
/** Web: the push service URL. Native: `<kind>:<token>`. */
|
|
94
|
+
endpoint: string;
|
|
95
|
+
/** Rows the Flutter / native side registered are `apns` or `fcm`. */
|
|
96
|
+
kind: PushDeviceKind;
|
|
97
|
+
/** `web`, `ios`, `android` or `macos`. */
|
|
98
|
+
platform?: string;
|
|
99
|
+
/** Native: bundle id / package name. */
|
|
100
|
+
appId?: string;
|
|
101
|
+
/** APNs: `sandbox` or `production`. */
|
|
102
|
+
environment?: string;
|
|
103
|
+
/** Key id a web device subscribed under (empty for native ones). */
|
|
104
|
+
kid: string;
|
|
105
|
+
label?: string;
|
|
106
|
+
userAgent?: string;
|
|
107
|
+
/** Rule names this device receives; undefined = every rule. */
|
|
108
|
+
rules?: string[];
|
|
109
|
+
meta?: Record<string, unknown>;
|
|
110
|
+
createdAt?: Date;
|
|
111
|
+
updatedAt?: Date;
|
|
112
|
+
lastOkAt?: Date;
|
|
113
|
+
lastError?: string;
|
|
114
|
+
failures: number;
|
|
115
|
+
disabledAt?: Date;
|
|
116
|
+
disabledReason?: string;
|
|
117
|
+
/** Subscribed under the current key (pushes reach it). Undefined when unknown. */
|
|
118
|
+
current?: boolean;
|
|
119
|
+
/** This browser. */
|
|
120
|
+
thisDevice: boolean;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface WebPushSubscribeOptions {
|
|
124
|
+
/** Defaults to the page's service worker registration (`serviceWorker.ready`). */
|
|
125
|
+
registration?: ServiceWorkerRegistration;
|
|
126
|
+
/** Shown in the device list ("Work laptop"). */
|
|
127
|
+
label?: string;
|
|
128
|
+
/** Rule names this device wants. Omitted or empty: every rule. */
|
|
129
|
+
rules?: string[];
|
|
130
|
+
/** Free-form app data about the device. */
|
|
131
|
+
meta?: Record<string, unknown>;
|
|
132
|
+
/** Ask for notification permission when it is still `default`. Default `true`. Call from a user gesture. */
|
|
133
|
+
requestPermission?: boolean;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface WebPushUnsubscribeOptions {
|
|
137
|
+
/** Remove every device of the user, not only this one. */
|
|
138
|
+
all?: boolean;
|
|
139
|
+
/** Keep the browser's PushSubscription (only the server row goes). */
|
|
140
|
+
keepBrowserSubscription?: boolean;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface WebPushSyncOptions {
|
|
144
|
+
registration?: ServiceWorkerRegistration;
|
|
145
|
+
/** Overrides `webPush.autoResubscribe` for this call. */
|
|
146
|
+
autoResubscribe?: boolean;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* What `sync()` did. `ok`: nothing to repair. `resubscribed`: the browser
|
|
151
|
+
* subscription was (re)created. `registered`: the server row was re-created.
|
|
152
|
+
* The rest say why nothing happened.
|
|
153
|
+
*/
|
|
154
|
+
export type WebPushSyncStatus =
|
|
155
|
+
| 'ok'
|
|
156
|
+
| 'resubscribed'
|
|
157
|
+
| 'registered'
|
|
158
|
+
| 'unsupported'
|
|
159
|
+
| 'signed-out'
|
|
160
|
+
| 'impersonating'
|
|
161
|
+
| 'permission-default'
|
|
162
|
+
| 'permission-denied'
|
|
163
|
+
| 'not-subscribed'
|
|
164
|
+
| 'disabled'
|
|
165
|
+
| 'no-registration'
|
|
166
|
+
| 'error';
|
|
167
|
+
|
|
168
|
+
export interface WebPushUpdateOptions {
|
|
169
|
+
label?: string;
|
|
170
|
+
/** Rule names; `null` or `[]` resets the device to every rule. */
|
|
171
|
+
rules?: string[] | null;
|
|
172
|
+
meta?: Record<string, unknown>;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A push to yourself (`fn::push::notify`). */
|
|
176
|
+
export interface WebPushMessageInput {
|
|
177
|
+
/** Omit for a content-free nudge. */
|
|
178
|
+
notification?: PushNotification;
|
|
179
|
+
data?: Record<string, unknown>;
|
|
180
|
+
topic?: string;
|
|
181
|
+
urgency?: PushUrgency;
|
|
182
|
+
/** Push-service TTL, seconds. */
|
|
183
|
+
ttl?: number;
|
|
184
|
+
/** In the future: scheduled. */
|
|
185
|
+
sendAt?: Date | string | number;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface WebPushMessage {
|
|
189
|
+
/** `_00_push_message:<id>` */
|
|
190
|
+
id: string;
|
|
191
|
+
status: string;
|
|
192
|
+
sendAt?: Date;
|
|
193
|
+
createdAt?: Date;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export type WebPushErrorCode =
|
|
197
|
+
| 'unsupported'
|
|
198
|
+
| 'signed-out'
|
|
199
|
+
| 'impersonating'
|
|
200
|
+
| 'disabled'
|
|
201
|
+
| 'permission-denied'
|
|
202
|
+
| 'permission-default'
|
|
203
|
+
| 'no-registration'
|
|
204
|
+
| 'not-subscribed'
|
|
205
|
+
| 'subscribe-failed'
|
|
206
|
+
| 'server';
|
|
207
|
+
|
|
208
|
+
export class WebPushError extends Error {
|
|
209
|
+
constructor(
|
|
210
|
+
public readonly code: WebPushErrorCode,
|
|
211
|
+
message: string,
|
|
212
|
+
public readonly cause?: unknown
|
|
213
|
+
) {
|
|
214
|
+
super(message);
|
|
215
|
+
this.name = 'WebPushError';
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── ports ────────────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
export interface WebPushAuthPort {
|
|
222
|
+
readonly token: string | null;
|
|
223
|
+
readonly currentUser: { id?: unknown } | null;
|
|
224
|
+
readonly isAuthenticated: boolean;
|
|
225
|
+
readonly impersonation: unknown;
|
|
226
|
+
subscribe(cb: (userId: unknown) => void): () => void;
|
|
227
|
+
onBeforeSignOut(hook: (ctx: SignOutContext) => Promise<void> | void): () => void;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export interface WebPushRemotePort {
|
|
231
|
+
query<T extends unknown[]>(sql: string, vars?: Record<string, unknown>): Promise<T>;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Browser globals, read at call time. Injectable for tests. */
|
|
235
|
+
export interface WebPushGlobals {
|
|
236
|
+
window?: unknown;
|
|
237
|
+
navigator?: Navigator & { standalone?: boolean };
|
|
238
|
+
Notification?: {
|
|
239
|
+
permission: NotificationPermission;
|
|
240
|
+
requestPermission(
|
|
241
|
+
cb?: (p: NotificationPermission) => void
|
|
242
|
+
): Promise<NotificationPermission> | void;
|
|
243
|
+
};
|
|
244
|
+
isSecureContext?: boolean;
|
|
245
|
+
matchMedia?: (query: string) => { matches: boolean };
|
|
246
|
+
PushManager?: unknown;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export interface WebPushModuleDeps {
|
|
250
|
+
remote: WebPushRemotePort;
|
|
251
|
+
auth: WebPushAuthPort;
|
|
252
|
+
persistence: PersistenceClient;
|
|
253
|
+
logger: Logger;
|
|
254
|
+
database: { endpoint?: string; namespace: string; database: string };
|
|
255
|
+
/** Transport state; auto-sync waits for `connected`. */
|
|
256
|
+
connection?: { subscribe(cb: (state: string) => void): () => void };
|
|
257
|
+
config?: WebPushConfig;
|
|
258
|
+
globals?: () => WebPushGlobals;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── helpers ──────────────────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
export const PUSH_ENDPOINT_KEY = 'sp00ky_push_endpoint';
|
|
264
|
+
const SIGN_OUT_UNSUBSCRIBE_TIMEOUT_MS = 1_500;
|
|
265
|
+
const SUBSCRIBE_READY_TIMEOUT_MS = 10_000;
|
|
266
|
+
const SYNC_READY_TIMEOUT_MS = 5_000;
|
|
267
|
+
const AUTO_SYNC_DELAY_MS = 1_500;
|
|
268
|
+
|
|
269
|
+
interface StoredEndpoint {
|
|
270
|
+
endpoint: string;
|
|
271
|
+
userId: string;
|
|
272
|
+
kid?: string;
|
|
273
|
+
publicKey?: string;
|
|
274
|
+
at: number;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
interface DeviceRow {
|
|
278
|
+
id?: unknown;
|
|
279
|
+
endpoint?: string;
|
|
280
|
+
kind?: string | null;
|
|
281
|
+
platform?: string | null;
|
|
282
|
+
app_id?: string | null;
|
|
283
|
+
environment?: string | null;
|
|
284
|
+
kid?: string;
|
|
285
|
+
label?: string | null;
|
|
286
|
+
user_agent?: string | null;
|
|
287
|
+
rules?: string[] | null;
|
|
288
|
+
meta?: Record<string, unknown> | null;
|
|
289
|
+
created_at?: unknown;
|
|
290
|
+
updated_at?: unknown;
|
|
291
|
+
last_ok_at?: unknown;
|
|
292
|
+
last_error?: string | null;
|
|
293
|
+
failures?: number;
|
|
294
|
+
disabled_at?: unknown;
|
|
295
|
+
disabled_reason?: string | null;
|
|
296
|
+
current?: boolean;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const noop = () => {};
|
|
300
|
+
|
|
301
|
+
/** `table:id` of a record id value, without the SDK's escaping. */
|
|
302
|
+
export function recordIdString(value: unknown): string {
|
|
303
|
+
if (value instanceof RecordId) return encodeRecordId(value as RecordId<string>);
|
|
304
|
+
const v = value as { table?: unknown; id?: unknown } | null;
|
|
305
|
+
if (v && typeof v === 'object' && v.table !== undefined && v.id !== undefined) {
|
|
306
|
+
const table =
|
|
307
|
+
typeof v.table === 'string'
|
|
308
|
+
? v.table
|
|
309
|
+
: String((v.table as { name?: unknown }).name ?? v.table);
|
|
310
|
+
return `${table}:${String(v.id)}`;
|
|
311
|
+
}
|
|
312
|
+
return String(value ?? '');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function toDate(value: unknown): Date | undefined {
|
|
316
|
+
if (value === null || value === undefined) return undefined;
|
|
317
|
+
if (value instanceof Date) return value;
|
|
318
|
+
const d = value as { toDate?: () => Date };
|
|
319
|
+
if (typeof d.toDate === 'function') return d.toDate();
|
|
320
|
+
const parsed = new Date(value as string | number);
|
|
321
|
+
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function dropUndefined<T extends Record<string, unknown>>(obj: T): T {
|
|
325
|
+
for (const key of Object.keys(obj)) if (obj[key] === undefined) delete obj[key];
|
|
326
|
+
return obj;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function toDevice(row: DeviceRow, thisEndpoint: string | null): WebPushDevice {
|
|
330
|
+
return {
|
|
331
|
+
id: recordIdString(row.id),
|
|
332
|
+
endpoint: String(row.endpoint ?? ''),
|
|
333
|
+
kind: row.kind === 'apns' || row.kind === 'fcm' ? row.kind : 'web',
|
|
334
|
+
platform: row.platform ?? undefined,
|
|
335
|
+
appId: row.app_id ?? undefined,
|
|
336
|
+
environment: row.environment ?? undefined,
|
|
337
|
+
kid: typeof row.kid === 'string' ? row.kid : '',
|
|
338
|
+
label: row.label ?? undefined,
|
|
339
|
+
userAgent: row.user_agent ?? undefined,
|
|
340
|
+
rules: Array.isArray(row.rules) ? row.rules : undefined,
|
|
341
|
+
meta: row.meta ?? undefined,
|
|
342
|
+
createdAt: toDate(row.created_at),
|
|
343
|
+
updatedAt: toDate(row.updated_at),
|
|
344
|
+
lastOkAt: toDate(row.last_ok_at),
|
|
345
|
+
lastError: row.last_error ?? undefined,
|
|
346
|
+
failures: typeof row.failures === 'number' ? row.failures : 0,
|
|
347
|
+
disabledAt: toDate(row.disabled_at),
|
|
348
|
+
disabledReason: row.disabled_reason ?? undefined,
|
|
349
|
+
current: typeof row.current === 'boolean' ? row.current : undefined,
|
|
350
|
+
thisDevice: !!thisEndpoint && row.endpoint === thisEndpoint,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function toMessage(
|
|
355
|
+
row:
|
|
356
|
+
| { id?: unknown; status?: unknown; send_at?: unknown; created_at?: unknown }
|
|
357
|
+
| null
|
|
358
|
+
| undefined
|
|
359
|
+
): WebPushMessage {
|
|
360
|
+
return {
|
|
361
|
+
id: recordIdString(row?.id),
|
|
362
|
+
status: typeof row?.status === 'string' ? row.status : 'pending',
|
|
363
|
+
sendAt: toDate(row?.send_at),
|
|
364
|
+
createdAt: toDate(row?.created_at),
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Where Web Push can work, from the given globals. */
|
|
369
|
+
export function detectWebPushSupport(g: WebPushGlobals): WebPushSupport {
|
|
370
|
+
const nav = g.navigator;
|
|
371
|
+
const permission: WebPushSupport['permission'] = g.Notification?.permission ?? 'unsupported';
|
|
372
|
+
if (!g.window || !nav)
|
|
373
|
+
return { supported: false, permission: 'unsupported', reason: 'no-window' };
|
|
374
|
+
if (g.isSecureContext === false)
|
|
375
|
+
return { supported: false, permission, reason: 'insecure-context' };
|
|
376
|
+
const ua = nav.userAgent ?? '';
|
|
377
|
+
const ios =
|
|
378
|
+
/iPad|iPhone|iPod/.test(ua) || (nav.platform === 'MacIntel' && (nav.maxTouchPoints ?? 0) > 1);
|
|
379
|
+
if (ios) {
|
|
380
|
+
const standalone =
|
|
381
|
+
nav.standalone === true || g.matchMedia?.('(display-mode: standalone)').matches === true;
|
|
382
|
+
if (!standalone) return { supported: false, permission, reason: 'ios-not-installed' };
|
|
383
|
+
}
|
|
384
|
+
if (!('serviceWorker' in nav) || !nav.serviceWorker)
|
|
385
|
+
return { supported: false, permission, reason: 'no-service-worker' };
|
|
386
|
+
if (!g.PushManager) return { supported: false, permission, reason: 'no-push-manager' };
|
|
387
|
+
if (!g.Notification)
|
|
388
|
+
return { supported: false, permission: 'unsupported', reason: 'no-notification' };
|
|
389
|
+
return { supported: true, permission };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function browserGlobals(): WebPushGlobals {
|
|
393
|
+
const g = globalThis as Record<string, any>;
|
|
394
|
+
return {
|
|
395
|
+
window: typeof g.window !== 'undefined' ? g.window : undefined,
|
|
396
|
+
navigator: g.navigator,
|
|
397
|
+
Notification: g.Notification,
|
|
398
|
+
isSecureContext: g.isSecureContext,
|
|
399
|
+
matchMedia: typeof g.matchMedia === 'function' ? (q: string) => g.matchMedia(q) : undefined,
|
|
400
|
+
PushManager: g.PushManager,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function requestPermission(
|
|
405
|
+
N: NonNullable<WebPushGlobals['Notification']>
|
|
406
|
+
): Promise<NotificationPermission> {
|
|
407
|
+
return new Promise((resolve) => {
|
|
408
|
+
// Old Safari only has the callback form; current browsers return a promise.
|
|
409
|
+
const ret = N.requestPermission((p) => resolve(p));
|
|
410
|
+
if (ret && typeof (ret as Promise<NotificationPermission>).then === 'function') {
|
|
411
|
+
(ret as Promise<NotificationPermission>).then(resolve, () => resolve(N.permission));
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** `true`/`false` when the subscription says which key it was made with, `null` when it does not. */
|
|
417
|
+
function keyMatches(sub: PushSubscription, key: Uint8Array): boolean | null {
|
|
418
|
+
const k = sub.options?.applicationServerKey;
|
|
419
|
+
if (!k) return null;
|
|
420
|
+
return sameBytes(k, key);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ── module ───────────────────────────────────────────────────────────────
|
|
424
|
+
|
|
425
|
+
export class WebPushModule {
|
|
426
|
+
private readonly logger: Logger;
|
|
427
|
+
private readonly config: WebPushConfig;
|
|
428
|
+
private readonly globals: () => WebPushGlobals;
|
|
429
|
+
private infoCache: Promise<WebPushInfo> | null = null;
|
|
430
|
+
private lastInfo: WebPushInfo | null = null;
|
|
431
|
+
private readonly bridges = new Set<{ reset(): void }>();
|
|
432
|
+
private readonly disposers: Array<() => void> = [];
|
|
433
|
+
private syncedFor: string | null = null;
|
|
434
|
+
private syncTimer: ReturnType<typeof setTimeout> | null = null;
|
|
435
|
+
private attached = false;
|
|
436
|
+
|
|
437
|
+
constructor(private readonly deps: WebPushModuleDeps) {
|
|
438
|
+
this.logger = deps.logger.child({ service: 'WebPush' });
|
|
439
|
+
this.config = deps.config ?? {};
|
|
440
|
+
this.globals = deps.globals ?? browserGlobals;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Wire sign-out, auto-sync and the token bridge. Called by the client once;
|
|
445
|
+
* a no-op beyond the sign-out hook where push is unsupported.
|
|
446
|
+
*/
|
|
447
|
+
attach(): () => void {
|
|
448
|
+
if (this.attached) return () => this.dispose();
|
|
449
|
+
this.attached = true;
|
|
450
|
+
this.disposers.push(this.deps.auth.onBeforeSignOut((ctx) => this.beforeSignOut(ctx)));
|
|
451
|
+
const g = this.globals();
|
|
452
|
+
if (!g.window || !g.navigator?.serviceWorker) return () => this.dispose();
|
|
453
|
+
if (this.config.bridge !== false) this.disposers.push(this.startBridge(undefined, true));
|
|
454
|
+
if (this.config.autoSync !== false) {
|
|
455
|
+
let connected = !this.deps.connection;
|
|
456
|
+
let user: string | null = null;
|
|
457
|
+
const maybeSync = () => {
|
|
458
|
+
if (!connected || !user || this.syncedFor === user) return;
|
|
459
|
+
if (!this.support().supported) return;
|
|
460
|
+
this.syncedFor = user;
|
|
461
|
+
if (this.syncTimer) clearTimeout(this.syncTimer);
|
|
462
|
+
this.syncTimer = setTimeout(() => {
|
|
463
|
+
this.syncTimer = null;
|
|
464
|
+
void this.sync().then((status) => {
|
|
465
|
+
if (status === 'error') this.syncedFor = null;
|
|
466
|
+
});
|
|
467
|
+
}, AUTO_SYNC_DELAY_MS);
|
|
468
|
+
};
|
|
469
|
+
if (this.deps.connection) {
|
|
470
|
+
this.disposers.push(
|
|
471
|
+
this.deps.connection.subscribe((state) => {
|
|
472
|
+
connected = state === 'connected';
|
|
473
|
+
maybeSync();
|
|
474
|
+
})
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
this.disposers.push(
|
|
478
|
+
this.deps.auth.subscribe((uid) => {
|
|
479
|
+
user = uid ? recordIdString(uid) : null;
|
|
480
|
+
if (!user) this.syncedFor = null;
|
|
481
|
+
maybeSync();
|
|
482
|
+
}),
|
|
483
|
+
this.onMessage((msg) => {
|
|
484
|
+
if (msg.type !== 'sp00ky:subscriptionchange') return;
|
|
485
|
+
void this.sync();
|
|
486
|
+
})
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
return () => this.dispose();
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
dispose(): void {
|
|
493
|
+
if (this.syncTimer) clearTimeout(this.syncTimer);
|
|
494
|
+
this.syncTimer = null;
|
|
495
|
+
for (const off of this.disposers.splice(0)) off();
|
|
496
|
+
this.bridges.clear();
|
|
497
|
+
this.attached = false;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ── reads ──
|
|
501
|
+
|
|
502
|
+
support(): WebPushSupport {
|
|
503
|
+
try {
|
|
504
|
+
return detectWebPushSupport(this.globals());
|
|
505
|
+
} catch {
|
|
506
|
+
return { supported: false, permission: 'unsupported', reason: 'no-window' };
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Is push on for this deployment, and with which key. Cached; `refresh` re-reads. */
|
|
511
|
+
info(options: { refresh?: boolean } = {}): Promise<WebPushInfo> {
|
|
512
|
+
if (!this.infoCache || options.refresh) {
|
|
513
|
+
const pending = this.call<{
|
|
514
|
+
enabled?: boolean;
|
|
515
|
+
publicKey?: string | null;
|
|
516
|
+
kid?: string | null;
|
|
517
|
+
providers?: unknown;
|
|
518
|
+
} | null>('RETURN fn::push::info()').then((r) => {
|
|
519
|
+
const providers = Array.isArray(r?.providers)
|
|
520
|
+
? (r.providers.filter((p) => p === 'web' || p === 'apns' || p === 'fcm') as PushDeviceKind[])
|
|
521
|
+
: undefined;
|
|
522
|
+
const info: WebPushInfo = dropUndefined({
|
|
523
|
+
enabled: r?.enabled === true && typeof r.publicKey === 'string',
|
|
524
|
+
publicKey: typeof r?.publicKey === 'string' ? r.publicKey : undefined,
|
|
525
|
+
kid: typeof r?.kid === 'string' ? r.kid : undefined,
|
|
526
|
+
providers,
|
|
527
|
+
});
|
|
528
|
+
this.lastInfo = info;
|
|
529
|
+
return info;
|
|
530
|
+
});
|
|
531
|
+
this.infoCache = pending;
|
|
532
|
+
pending.catch(() => {
|
|
533
|
+
if (this.infoCache === pending) this.infoCache = null;
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
return this.infoCache;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** This browser holds a push subscription registered for the signed-in user. No server round trip. */
|
|
540
|
+
async isSubscribed(): Promise<boolean> {
|
|
541
|
+
const support = this.support();
|
|
542
|
+
if (!support.supported || support.permission !== 'granted') return false;
|
|
543
|
+
const userId = this.userId();
|
|
544
|
+
if (!userId) return false;
|
|
545
|
+
const stored = await this.readStored();
|
|
546
|
+
if (!stored || stored.userId !== userId) return false;
|
|
547
|
+
const sub = await this.currentSubscription();
|
|
548
|
+
return !!sub && sub.endpoint === stored.endpoint;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** The user's devices, this one marked. */
|
|
552
|
+
async devices(): Promise<WebPushDevice[]> {
|
|
553
|
+
this.assertSignedIn();
|
|
554
|
+
const rows = await this.listRaw();
|
|
555
|
+
const mine = await this.thisEndpoint();
|
|
556
|
+
return rows.map((r) => toDevice(r, mine));
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// ── subscription ──
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Ask for permission (when still `default`), subscribe this browser with the
|
|
563
|
+
* deployment's key and register it for the signed-in user. Call it from a
|
|
564
|
+
* click handler: permission prompts need a user gesture.
|
|
565
|
+
*/
|
|
566
|
+
async subscribe(options: WebPushSubscribeOptions = {}): Promise<WebPushDevice> {
|
|
567
|
+
const support = this.support();
|
|
568
|
+
if (!support.supported)
|
|
569
|
+
throw new WebPushError('unsupported', `web push is not available here (${support.reason})`);
|
|
570
|
+
const userId = this.userId();
|
|
571
|
+
if (!userId)
|
|
572
|
+
throw new WebPushError('signed-out', 'sign in before subscribing to push notifications');
|
|
573
|
+
if (this.deps.auth.impersonation)
|
|
574
|
+
throw new WebPushError(
|
|
575
|
+
'impersonating',
|
|
576
|
+
'push notifications are not available while impersonating'
|
|
577
|
+
);
|
|
578
|
+
const N = this.globals().Notification!;
|
|
579
|
+
// First await: the prompt must stay inside the caller's user gesture.
|
|
580
|
+
let permission = support.permission as NotificationPermission;
|
|
581
|
+
if (permission === 'default' && options.requestPermission !== false)
|
|
582
|
+
permission = await requestPermission(N);
|
|
583
|
+
if (permission === 'denied')
|
|
584
|
+
throw new WebPushError('permission-denied', 'notification permission was denied');
|
|
585
|
+
if (permission !== 'granted')
|
|
586
|
+
throw new WebPushError('permission-default', 'notification permission was not granted');
|
|
587
|
+
|
|
588
|
+
const info = await this.info();
|
|
589
|
+
if (!info.enabled || !info.publicKey)
|
|
590
|
+
throw new WebPushError('disabled', 'push is not enabled on this deployment yet');
|
|
591
|
+
const registration = await this.registration(options.registration, SUBSCRIBE_READY_TIMEOUT_MS);
|
|
592
|
+
if (!registration?.pushManager)
|
|
593
|
+
throw new WebPushError('no-registration', 'no active service worker to subscribe with');
|
|
594
|
+
|
|
595
|
+
const key = base64UrlToBytes(info.publicKey);
|
|
596
|
+
let sub: PushSubscription | null;
|
|
597
|
+
try {
|
|
598
|
+
sub = await registration.pushManager.getSubscription();
|
|
599
|
+
// A subscription made with another key would be rejected by the push service.
|
|
600
|
+
if (sub && keyMatches(sub, key) === false) {
|
|
601
|
+
await sub.unsubscribe().catch(() => false);
|
|
602
|
+
sub = null;
|
|
603
|
+
}
|
|
604
|
+
sub ??= await registration.pushManager.subscribe({
|
|
605
|
+
userVisibleOnly: true,
|
|
606
|
+
applicationServerKey: key as BufferSource,
|
|
607
|
+
});
|
|
608
|
+
} catch (error) {
|
|
609
|
+
throw new WebPushError(
|
|
610
|
+
'subscribe-failed',
|
|
611
|
+
error instanceof Error ? error.message : String(error),
|
|
612
|
+
error
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
const row = await this.register(sub, options);
|
|
616
|
+
await this.writeStored({
|
|
617
|
+
endpoint: sub.endpoint,
|
|
618
|
+
userId,
|
|
619
|
+
kid: row.kid || info.kid,
|
|
620
|
+
publicKey: info.publicKey,
|
|
621
|
+
at: Date.now(),
|
|
622
|
+
});
|
|
623
|
+
this.refreshBridges();
|
|
624
|
+
return toDevice(row, sub.endpoint);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** Remove this device (or every device, `all`) for the signed-in user. Returns how many rows went. */
|
|
628
|
+
async unsubscribe(options: WebPushUnsubscribeOptions = {}): Promise<number> {
|
|
629
|
+
const userId = this.userId();
|
|
630
|
+
let removed = 0;
|
|
631
|
+
const sub = await this.currentSubscription();
|
|
632
|
+
if (userId && !this.deps.auth.impersonation) {
|
|
633
|
+
if (options.all) {
|
|
634
|
+
removed = Number(await this.call<number>('RETURN fn::push::unsubscribe(NONE)')) || 0;
|
|
635
|
+
} else {
|
|
636
|
+
const stored = await this.readStored();
|
|
637
|
+
const endpoint = sub?.endpoint ?? (stored?.userId === userId ? stored.endpoint : null);
|
|
638
|
+
if (endpoint)
|
|
639
|
+
removed =
|
|
640
|
+
Number(
|
|
641
|
+
await this.call<number>('RETURN fn::push::unsubscribe($endpoint)', { endpoint })
|
|
642
|
+
) || 0;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
if (sub && !options.keepBrowserSubscription) await sub.unsubscribe().catch(() => false);
|
|
646
|
+
await this.deps.persistence.remove(PUSH_ENDPOINT_KEY).catch(noop);
|
|
647
|
+
this.refreshBridges();
|
|
648
|
+
return removed;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Reconcile after sign-in (runs by itself with `autoSync`). Needs granted
|
|
653
|
+
* permission and a user who subscribed on this browser (or
|
|
654
|
+
* `autoResubscribe`). Repairs a missing browser subscription, one made with
|
|
655
|
+
* a rotated key, and a missing, disabled or stale server row. Never throws.
|
|
656
|
+
*/
|
|
657
|
+
async sync(options: WebPushSyncOptions = {}): Promise<WebPushSyncStatus> {
|
|
658
|
+
try {
|
|
659
|
+
const support = this.support();
|
|
660
|
+
if (!support.supported) return 'unsupported';
|
|
661
|
+
const userId = this.userId();
|
|
662
|
+
if (!userId) return 'signed-out';
|
|
663
|
+
if (this.deps.auth.impersonation) return 'impersonating';
|
|
664
|
+
if (support.permission === 'denied') return 'permission-denied';
|
|
665
|
+
if (support.permission !== 'granted') return 'permission-default';
|
|
666
|
+
const stored = await this.readStored();
|
|
667
|
+
const mine = stored && stored.userId === userId ? stored : null;
|
|
668
|
+
if (!mine && !(options.autoResubscribe ?? this.config.autoResubscribe ?? false))
|
|
669
|
+
return 'not-subscribed';
|
|
670
|
+
const info = await this.info({ refresh: true });
|
|
671
|
+
if (!info.enabled || !info.publicKey) return 'disabled';
|
|
672
|
+
const registration = await this.registration(options.registration, SYNC_READY_TIMEOUT_MS);
|
|
673
|
+
if (!registration?.pushManager) return 'no-registration';
|
|
674
|
+
|
|
675
|
+
const key = base64UrlToBytes(info.publicKey);
|
|
676
|
+
const devices = await this.listRaw();
|
|
677
|
+
let sub = await registration.pushManager.getSubscription();
|
|
678
|
+
const previousEndpoint = sub?.endpoint ?? mine?.endpoint;
|
|
679
|
+
const previous =
|
|
680
|
+
devices.find((d) => d.endpoint === previousEndpoint) ??
|
|
681
|
+
devices.find((d) => d.endpoint === mine?.endpoint);
|
|
682
|
+
let status: WebPushSyncStatus = 'ok';
|
|
683
|
+
if (sub) {
|
|
684
|
+
const match = keyMatches(sub, key);
|
|
685
|
+
const rotated =
|
|
686
|
+
match === false || (match === null && !!mine?.kid && !!info.kid && mine.kid !== info.kid);
|
|
687
|
+
const broken = previous?.endpoint === sub.endpoint && previous.disabled_at != null;
|
|
688
|
+
if (rotated || broken) {
|
|
689
|
+
await sub.unsubscribe().catch(() => false);
|
|
690
|
+
sub = null;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
if (!sub) {
|
|
694
|
+
sub = await registration.pushManager.subscribe({
|
|
695
|
+
userVisibleOnly: true,
|
|
696
|
+
applicationServerKey: key as BufferSource,
|
|
697
|
+
});
|
|
698
|
+
status = 'resubscribed';
|
|
699
|
+
}
|
|
700
|
+
const row = devices.find((d) => d.endpoint === sub!.endpoint);
|
|
701
|
+
if (status === 'resubscribed' || !row || row.current === false || row.disabled_at != null) {
|
|
702
|
+
await this.register(sub, {
|
|
703
|
+
label: previous?.label ?? undefined,
|
|
704
|
+
rules: previous?.rules ?? undefined,
|
|
705
|
+
meta: previous?.meta ?? undefined,
|
|
706
|
+
});
|
|
707
|
+
if (status === 'ok') status = 'registered';
|
|
708
|
+
if (previous?.endpoint && previous.endpoint !== sub.endpoint) {
|
|
709
|
+
await this.call('RETURN fn::push::unsubscribe($endpoint)', {
|
|
710
|
+
endpoint: previous.endpoint,
|
|
711
|
+
}).catch(noop);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
await this.writeStored({
|
|
715
|
+
endpoint: sub.endpoint,
|
|
716
|
+
userId,
|
|
717
|
+
kid: info.kid,
|
|
718
|
+
publicKey: info.publicKey,
|
|
719
|
+
at: Date.now(),
|
|
720
|
+
});
|
|
721
|
+
this.refreshBridges();
|
|
722
|
+
return status;
|
|
723
|
+
} catch (error) {
|
|
724
|
+
this.logger.warn({ error, Category: 'sp00ky-client::WebPush::sync' }, 'Web Push sync failed');
|
|
725
|
+
return 'error';
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** Change this device's label, rule filter or meta. */
|
|
730
|
+
async update(options: WebPushUpdateOptions): Promise<WebPushDevice | null> {
|
|
731
|
+
this.assertSignedIn();
|
|
732
|
+
this.assertNotImpersonating();
|
|
733
|
+
const endpoint = await this.thisEndpoint();
|
|
734
|
+
if (!endpoint) throw new WebPushError('not-subscribed', 'this browser is not subscribed');
|
|
735
|
+
const opts = dropUndefined({
|
|
736
|
+
label: options.label,
|
|
737
|
+
rules: options.rules === null ? [] : options.rules,
|
|
738
|
+
meta: options.meta,
|
|
739
|
+
});
|
|
740
|
+
const rows = await this.call<DeviceRow[]>('RETURN fn::push::update($endpoint, $opts)', {
|
|
741
|
+
endpoint,
|
|
742
|
+
opts,
|
|
743
|
+
});
|
|
744
|
+
const row = Array.isArray(rows) ? rows[0] : null;
|
|
745
|
+
return row ? toDevice(row, endpoint) : null;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// ── direct messages ──
|
|
749
|
+
|
|
750
|
+
/** Push to yourself, now or at `sendAt` (reminders). Omit `notification` for a nudge. */
|
|
751
|
+
async notify(message: WebPushMessageInput): Promise<WebPushMessage> {
|
|
752
|
+
this.assertSignedIn();
|
|
753
|
+
this.assertNotImpersonating();
|
|
754
|
+
const sendAt =
|
|
755
|
+
message.sendAt === undefined
|
|
756
|
+
? undefined
|
|
757
|
+
: new Date(message.sendAt as string | number | Date).toISOString();
|
|
758
|
+
const msg = dropUndefined({
|
|
759
|
+
notification: message.notification,
|
|
760
|
+
data: message.data,
|
|
761
|
+
topic: message.topic,
|
|
762
|
+
urgency: message.urgency,
|
|
763
|
+
ttl: message.ttl,
|
|
764
|
+
sendAt,
|
|
765
|
+
});
|
|
766
|
+
return toMessage(await this.call('RETURN fn::push::notify($msg)', { msg }));
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/** Cancel a scheduled message. `true` when it was still pending. */
|
|
770
|
+
async cancel(id: string | RecordId): Promise<boolean> {
|
|
771
|
+
this.assertSignedIn();
|
|
772
|
+
const rid =
|
|
773
|
+
id instanceof RecordId
|
|
774
|
+
? id
|
|
775
|
+
: new RecordId('_00_push_message', String(id).replace(/^_00_push_message:/, ''));
|
|
776
|
+
return (await this.call<boolean>('RETURN fn::push::cancel($id)', { id: rid })) === true;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** A visible test notification to every device of the user. */
|
|
780
|
+
async test(options: { title?: string; body?: string } = {}): Promise<WebPushMessage> {
|
|
781
|
+
this.assertSignedIn();
|
|
782
|
+
this.assertNotImpersonating();
|
|
783
|
+
return toMessage(
|
|
784
|
+
await this.call('RETURN fn::push::test($opts)', { opts: dropUndefined({ ...options }) })
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// ── service worker bridge ──
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Post the session to the service worker now and whenever the token
|
|
792
|
+
* changes (`{ type: 'sp00ky:token', token, userId, endpoint, namespace,
|
|
793
|
+
* database }`). Never posts an impersonation token. Returns a disposer.
|
|
794
|
+
* With `webPush.bridge` (the default) this already runs while notification
|
|
795
|
+
* permission is granted.
|
|
796
|
+
*/
|
|
797
|
+
bridge(registration?: ServiceWorkerRegistration): () => void {
|
|
798
|
+
return this.startBridge(registration, false);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** Messages from the push service worker (`sp00ky:push` while visible, `sp00ky:navigate`, ...). */
|
|
802
|
+
onMessage(cb: (message: WebPushClientMessage) => void): () => void {
|
|
803
|
+
const sw = this.globals().navigator?.serviceWorker;
|
|
804
|
+
if (!sw || typeof sw.addEventListener !== 'function') return noop;
|
|
805
|
+
const handler = (event: MessageEvent) => {
|
|
806
|
+
const data = event.data as { type?: unknown } | null;
|
|
807
|
+
if (data && typeof data.type === 'string' && data.type.startsWith('sp00ky:'))
|
|
808
|
+
cb(data as WebPushClientMessage);
|
|
809
|
+
};
|
|
810
|
+
sw.addEventListener('message', handler);
|
|
811
|
+
return () => sw.removeEventListener('message', handler);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// ── internals ──
|
|
815
|
+
|
|
816
|
+
private userId(): string | null {
|
|
817
|
+
const auth = this.deps.auth;
|
|
818
|
+
if (!auth.isAuthenticated || !auth.currentUser?.id) return null;
|
|
819
|
+
return recordIdString(auth.currentUser.id);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
private assertSignedIn(): void {
|
|
823
|
+
if (!this.userId()) throw new WebPushError('signed-out', 'sign in first');
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
private assertNotImpersonating(): void {
|
|
827
|
+
if (this.deps.auth.impersonation)
|
|
828
|
+
throw new WebPushError(
|
|
829
|
+
'impersonating',
|
|
830
|
+
'push notifications are not available while impersonating'
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
private async call<T>(sql: string, vars?: Record<string, unknown>): Promise<T> {
|
|
835
|
+
try {
|
|
836
|
+
const [result] = await this.deps.remote.query<[T]>(sql, vars);
|
|
837
|
+
return result;
|
|
838
|
+
} catch (error) {
|
|
839
|
+
throw new WebPushError(
|
|
840
|
+
'server',
|
|
841
|
+
error instanceof Error ? error.message : String(error),
|
|
842
|
+
error
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
private async listRaw(): Promise<DeviceRow[]> {
|
|
848
|
+
const rows = await this.call<DeviceRow[]>('RETURN fn::push::list()');
|
|
849
|
+
return Array.isArray(rows) ? rows : [];
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
private async register(
|
|
853
|
+
sub: PushSubscription,
|
|
854
|
+
options: { label?: string; rules?: string[]; meta?: Record<string, unknown> }
|
|
855
|
+
): Promise<DeviceRow> {
|
|
856
|
+
const opts = dropUndefined({
|
|
857
|
+
label: options.label,
|
|
858
|
+
rules: options.rules && options.rules.length > 0 ? options.rules : undefined,
|
|
859
|
+
meta: options.meta,
|
|
860
|
+
userAgent: this.globals().navigator?.userAgent,
|
|
861
|
+
});
|
|
862
|
+
const row = await this.call<DeviceRow | null>('RETURN fn::push::subscribe($sub, $opts)', {
|
|
863
|
+
sub: sub.toJSON(),
|
|
864
|
+
opts,
|
|
865
|
+
});
|
|
866
|
+
return row ?? { endpoint: sub.endpoint };
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
private async readStored(): Promise<StoredEndpoint | null> {
|
|
870
|
+
try {
|
|
871
|
+
const value = await this.deps.persistence.get<StoredEndpoint>(PUSH_ENDPOINT_KEY);
|
|
872
|
+
return value && typeof value.endpoint === 'string' && typeof value.userId === 'string'
|
|
873
|
+
? value
|
|
874
|
+
: null;
|
|
875
|
+
} catch {
|
|
876
|
+
return null;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
private async writeStored(value: StoredEndpoint): Promise<void> {
|
|
881
|
+
await this.deps.persistence
|
|
882
|
+
.set(PUSH_ENDPOINT_KEY, dropUndefined({ ...value }))
|
|
883
|
+
.catch((error) => {
|
|
884
|
+
this.logger.debug(
|
|
885
|
+
{ error, Category: 'sp00ky-client::WebPush' },
|
|
886
|
+
'Could not persist the push endpoint'
|
|
887
|
+
);
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/** The page's registration: given, existing, registered from config, or `ready` within `waitMs`. */
|
|
892
|
+
private async registration(
|
|
893
|
+
given: ServiceWorkerRegistration | undefined,
|
|
894
|
+
waitMs: number
|
|
895
|
+
): Promise<ServiceWorkerRegistration | null> {
|
|
896
|
+
if (given) return given;
|
|
897
|
+
const sw = this.globals().navigator?.serviceWorker;
|
|
898
|
+
if (!sw) return null;
|
|
899
|
+
let reg = (await sw.getRegistration().catch(() => undefined)) ?? null;
|
|
900
|
+
if (!reg && this.config.serviceWorkerUrl)
|
|
901
|
+
reg = await sw.register(this.config.serviceWorkerUrl).catch(() => null);
|
|
902
|
+
if (reg?.active) return reg;
|
|
903
|
+
const ready = await withTimeout(sw.ready, waitMs, 'service worker not ready').catch(() => null);
|
|
904
|
+
return ready ?? reg;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/** This browser's subscription, without waiting for a worker. */
|
|
908
|
+
private async currentSubscription(): Promise<PushSubscription | null> {
|
|
909
|
+
try {
|
|
910
|
+
const sw = this.globals().navigator?.serviceWorker;
|
|
911
|
+
const reg = sw ? await sw.getRegistration() : undefined;
|
|
912
|
+
return (await reg?.pushManager?.getSubscription()) ?? null;
|
|
913
|
+
} catch {
|
|
914
|
+
return null;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
private async thisEndpoint(): Promise<string | null> {
|
|
919
|
+
const sub = this.support().supported ? await this.currentSubscription() : null;
|
|
920
|
+
if (sub) return sub.endpoint;
|
|
921
|
+
const stored = await this.readStored();
|
|
922
|
+
return stored && stored.userId === this.userId() ? stored.endpoint : null;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
private async postToWorker(
|
|
926
|
+
message: unknown,
|
|
927
|
+
registration?: ServiceWorkerRegistration,
|
|
928
|
+
wait = false
|
|
929
|
+
): Promise<void> {
|
|
930
|
+
const sw = this.globals().navigator?.serviceWorker;
|
|
931
|
+
if (!sw) return;
|
|
932
|
+
let reg: ServiceWorkerRegistration | undefined | null = registration;
|
|
933
|
+
if (!reg) reg = wait ? await sw.ready : await sw.getRegistration().catch(() => undefined);
|
|
934
|
+
const target = reg?.active ?? sw.controller;
|
|
935
|
+
target?.postMessage(message);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
private startBridge(
|
|
939
|
+
registration: ServiceWorkerRegistration | undefined,
|
|
940
|
+
requirePermission: boolean
|
|
941
|
+
): () => void {
|
|
942
|
+
const g = this.globals();
|
|
943
|
+
if (!g.navigator?.serviceWorker || !this.deps.database.endpoint) return noop;
|
|
944
|
+
let last: string | null = null;
|
|
945
|
+
let disposed = false;
|
|
946
|
+
const post = () => {
|
|
947
|
+
if (disposed) return;
|
|
948
|
+
const auth = this.deps.auth;
|
|
949
|
+
const token = auth.token;
|
|
950
|
+
const userId = this.userId();
|
|
951
|
+
if (!token || !userId || auth.impersonation) return;
|
|
952
|
+
if (requirePermission && this.support().permission !== 'granted') return;
|
|
953
|
+
if (token === last) return;
|
|
954
|
+
last = token;
|
|
955
|
+
void this.tokenMessage(token, userId)
|
|
956
|
+
.then((message) => this.postToWorker(message, registration, true))
|
|
957
|
+
.catch((error) => {
|
|
958
|
+
last = null;
|
|
959
|
+
this.logger.debug(
|
|
960
|
+
{ error, Category: 'sp00ky-client::WebPush::bridge' },
|
|
961
|
+
'Could not post the session to the service worker'
|
|
962
|
+
);
|
|
963
|
+
});
|
|
964
|
+
};
|
|
965
|
+
const entry = {
|
|
966
|
+
reset: () => {
|
|
967
|
+
last = null;
|
|
968
|
+
post();
|
|
969
|
+
},
|
|
970
|
+
};
|
|
971
|
+
this.bridges.add(entry);
|
|
972
|
+
const off = this.deps.auth.subscribe(() => post());
|
|
973
|
+
return () => {
|
|
974
|
+
disposed = true;
|
|
975
|
+
off();
|
|
976
|
+
this.bridges.delete(entry);
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
private refreshBridges(): void {
|
|
981
|
+
for (const b of this.bridges) b.reset();
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
private async tokenMessage(token: string, userId: string): Promise<BridgeTokenMessage> {
|
|
985
|
+
const stored = await this.readStored();
|
|
986
|
+
const { endpoint, namespace, database } = this.deps.database;
|
|
987
|
+
return dropUndefined({
|
|
988
|
+
type: 'sp00ky:token' as const,
|
|
989
|
+
token,
|
|
990
|
+
userId,
|
|
991
|
+
endpoint: endpoint ?? '',
|
|
992
|
+
namespace,
|
|
993
|
+
database,
|
|
994
|
+
publicKey: this.lastInfo?.publicKey ?? stored?.publicKey,
|
|
995
|
+
pushEndpoint: stored?.userId === userId ? stored.endpoint : undefined,
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/** Sign-out hook: tell the worker, and drop this device's row for the user. Bounded. */
|
|
1000
|
+
private async beforeSignOut(ctx: SignOutContext): Promise<void> {
|
|
1001
|
+
const tasks: Promise<unknown>[] = [this.postToWorker({ type: 'sp00ky:signout' }).catch(noop)];
|
|
1002
|
+
if (this.config.unsubscribeOnSignOut !== false && !ctx.impersonating) {
|
|
1003
|
+
const userId = this.userId();
|
|
1004
|
+
const stored = await this.readStored();
|
|
1005
|
+
if (userId && stored && stored.userId === userId) {
|
|
1006
|
+
tasks.push(
|
|
1007
|
+
withTimeout(
|
|
1008
|
+
this.deps.remote.query('RETURN fn::push::unsubscribe($endpoint)', {
|
|
1009
|
+
endpoint: stored.endpoint,
|
|
1010
|
+
}),
|
|
1011
|
+
SIGN_OUT_UNSUBSCRIBE_TIMEOUT_MS,
|
|
1012
|
+
'push unsubscribe timed out'
|
|
1013
|
+
)
|
|
1014
|
+
.catch((error) =>
|
|
1015
|
+
this.logger.debug(
|
|
1016
|
+
{ error, Category: 'sp00ky-client::WebPush::signOut' },
|
|
1017
|
+
'Could not remove the push subscription'
|
|
1018
|
+
)
|
|
1019
|
+
)
|
|
1020
|
+
.finally(() => this.deps.persistence.remove(PUSH_ENDPOINT_KEY).catch(noop))
|
|
1021
|
+
);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
this.syncedFor = null;
|
|
1025
|
+
await Promise.allSettled(tasks);
|
|
1026
|
+
}
|
|
1027
|
+
}
|