@joryio/web-sdk 0.0.0-stage → 1.3.0
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/CHANGELOG.md +5 -0
- package/LICENSE +54 -0
- package/README.md +497 -2
- package/dist/core/config.d.ts +101 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/device.d.ts +14 -0
- package/dist/core/device.d.ts.map +1 -0
- package/dist/core/ecommerce.d.ts +67 -0
- package/dist/core/ecommerce.d.ts.map +1 -0
- package/dist/core/identity.d.ts +36 -0
- package/dist/core/identity.d.ts.map +1 -0
- package/dist/core/inapp.d.ts +169 -0
- package/dist/core/inapp.d.ts.map +1 -0
- package/dist/core/network.d.ts +158 -0
- package/dist/core/network.d.ts.map +1 -0
- package/dist/core/outbox.d.ts +79 -0
- package/dist/core/outbox.d.ts.map +1 -0
- package/dist/core/pending-attributes.d.ts +43 -0
- package/dist/core/pending-attributes.d.ts.map +1 -0
- package/dist/core/push.d.ts +59 -0
- package/dist/core/push.d.ts.map +1 -0
- package/dist/core/queue.d.ts +107 -0
- package/dist/core/queue.d.ts.map +1 -0
- package/dist/core/sdk.d.ts +169 -0
- package/dist/core/sdk.d.ts.map +1 -0
- package/dist/core/session.d.ts +36 -0
- package/dist/core/session.d.ts.map +1 -0
- package/dist/core/storage.d.ts +17 -0
- package/dist/core/storage.d.ts.map +1 -0
- package/dist/core/user.d.ts +28 -0
- package/dist/core/user.d.ts.map +1 -0
- package/dist/core/utm.d.ts +39 -0
- package/dist/core/utm.d.ts.map +1 -0
- package/dist/core/values.d.ts +3 -0
- package/dist/core/values.d.ts.map +1 -0
- package/dist/core/write-retry.d.ts +66 -0
- package/dist/core/write-retry.d.ts.map +1 -0
- package/dist/index.d.mts +711 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/joryio-sw.js +6 -0
- package/dist/joryio.cjs.js +7265 -0
- package/dist/joryio.min.js +7 -0
- package/dist/joryio.min.js.map +1 -0
- package/dist/joryio.mjs +7254 -0
- package/dist/joryio.umd.js +7271 -0
- package/dist/utils/bucket-hash.d.ts +2 -0
- package/dist/utils/bucket-hash.d.ts.map +1 -0
- package/dist/utils/logger.d.ts +14 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/uuid.d.ts +3 -0
- package/dist/utils/uuid.d.ts.map +1 -0
- package/package.json +90 -4
- package/src/core/config.ts +362 -0
- package/src/core/device.ts +50 -0
- package/src/core/ecommerce.ts +482 -0
- package/src/core/identity.ts +251 -0
- package/src/core/inapp.ts +2419 -0
- package/src/core/network.ts +1261 -0
- package/src/core/outbox.ts +707 -0
- package/src/core/pending-attributes.ts +350 -0
- package/src/core/push.ts +667 -0
- package/src/core/queue.ts +894 -0
- package/src/core/sdk.ts +3108 -0
- package/src/core/session.ts +311 -0
- package/src/core/storage.ts +157 -0
- package/src/core/user.ts +203 -0
- package/src/core/utm.ts +304 -0
- package/src/core/values.ts +85 -0
- package/src/core/write-retry.ts +136 -0
- package/src/index.ts +305 -0
- package/src/snippet/loader.js +134 -0
- package/src/utils/bucket-hash.ts +44 -0
- package/src/utils/logger.ts +58 -0
- package/src/utils/uuid.ts +29 -0
- package/src/worker/joryio-sw.js +218 -0
package/src/core/push.ts
ADDED
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Push subscription manager.
|
|
3
|
+
*
|
|
4
|
+
* Handles the browser-side dance for opting a user into web push:
|
|
5
|
+
* 1. Verify the browser supports the required APIs.
|
|
6
|
+
* 2. Fetch the VAPID public key from the backend.
|
|
7
|
+
* 3. Register the customer-hosted service worker.
|
|
8
|
+
* 4. Call pushManager.subscribe() with the key.
|
|
9
|
+
* 5. POST the resulting subscription to /v1/push/register so backend
|
|
10
|
+
* campaigns/canvases can target this browser.
|
|
11
|
+
*
|
|
12
|
+
* The customer must host a `joryio-sw.js` file at the configured path
|
|
13
|
+
* (default: /joryio-sw.js) — browsers enforce same-origin on service
|
|
14
|
+
* workers, so we can't serve the worker from our CDN directly. The file
|
|
15
|
+
* itself is a one-liner that `importScripts()` our worker code from the CDN.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { NetworkClient } from './network';
|
|
19
|
+
import { IdentityManager } from './identity';
|
|
20
|
+
import { Logger } from '../utils/logger';
|
|
21
|
+
import type { StorageManager } from './storage';
|
|
22
|
+
import { classifyWriteResponse, writeAttemptsExhausted, writeRetryDelay } from './write-retry';
|
|
23
|
+
|
|
24
|
+
/** Last registration this browser sent, so a page load re-registers only on a change. */
|
|
25
|
+
const REGISTRATION_MARKER_KEY = 'push_registration';
|
|
26
|
+
/**
|
|
27
|
+
* Set by unsubscribe(), cleared only by subscribe(): while it is set nothing
|
|
28
|
+
* re-subscribes or re-registers this browser on a later page load (contract
|
|
29
|
+
* addendum 22). Without it, the next load's syncExistingSubscription() found
|
|
30
|
+
* permission still granted and silently subscribed the browser again.
|
|
31
|
+
*/
|
|
32
|
+
const UNREGISTERED_KEY = 'push_unregistered';
|
|
33
|
+
/**
|
|
34
|
+
* Server-side unregisters not acknowledged yet. A WITHDRAWAL (contract item
|
|
35
|
+
* 36): sent even while opted out, with consent denied or pending, and after
|
|
36
|
+
* wipeData(); retried by the one rule of write-retry.ts. A list (an earlier
|
|
37
|
+
* build stored one object).
|
|
38
|
+
*/
|
|
39
|
+
export const PENDING_UNREGISTER_KEY = 'push_pending_unregister';
|
|
40
|
+
|
|
41
|
+
interface PendingUnregister {
|
|
42
|
+
endpoint: string;
|
|
43
|
+
userId?: string;
|
|
44
|
+
anonymousId: string;
|
|
45
|
+
attempts?: number;
|
|
46
|
+
notFoundAttempts?: number;
|
|
47
|
+
failures?: number;
|
|
48
|
+
}
|
|
49
|
+
/** Re-register an unchanged subscription at most this often (catches server-side pruning). */
|
|
50
|
+
const REREGISTER_EVERY_MS = 24 * 60 * 60 * 1000;
|
|
51
|
+
/**
|
|
52
|
+
* Where the page leaves the opt-out flag for the service worker. A worker
|
|
53
|
+
* cannot read localStorage; Cache Storage is the one store both can reach.
|
|
54
|
+
*/
|
|
55
|
+
export const SW_STATE_CACHE = 'joryio-sdk-state';
|
|
56
|
+
export const SW_OPT_OUT_URL = '/__joryio/tracking-opted-out';
|
|
57
|
+
|
|
58
|
+
export interface PushManagerOptions {
|
|
59
|
+
/** False while tracking is opted out / consent is pending: nothing is registered. */
|
|
60
|
+
canSend?: () => boolean;
|
|
61
|
+
/**
|
|
62
|
+
* May a withdrawal (the server-side unregister) be sent? True even while
|
|
63
|
+
* opted out or consent is denied or pending; false only for an inert
|
|
64
|
+
* instance or a suspended workspace.
|
|
65
|
+
*/
|
|
66
|
+
canWithdraw?: () => boolean;
|
|
67
|
+
/**
|
|
68
|
+
* The user a withdrawal is made for: the one identify() named while
|
|
69
|
+
* tracking was off (D-b), else the identified user (rule 63).
|
|
70
|
+
*/
|
|
71
|
+
withdrawalUserId?: () => string | null;
|
|
72
|
+
storage?: StorageManager;
|
|
73
|
+
/**
|
|
74
|
+
* The SDK's stable per-browser device id, sent with every registration so
|
|
75
|
+
* the subscription lands on this browser's existing device row rather than
|
|
76
|
+
* creating a second one - the same field Android and iOS send.
|
|
77
|
+
*/
|
|
78
|
+
getDeviceId?: () => string | undefined;
|
|
79
|
+
/**
|
|
80
|
+
* Called once a subscribe() attempt is over (granted, denied or failed), so
|
|
81
|
+
* the SDK reports the new `push_permission` now rather than on the next
|
|
82
|
+
* page load.
|
|
83
|
+
*/
|
|
84
|
+
onSubscribeSettled?: () => void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface PushSubscribeOptions {
|
|
88
|
+
/**
|
|
89
|
+
* Path (relative to origin) where the customer has hosted joryio-sw.js.
|
|
90
|
+
* Defaults to '/joryio-sw.js'.
|
|
91
|
+
*/
|
|
92
|
+
swPath?: string;
|
|
93
|
+
/**
|
|
94
|
+
* Scope of the service worker registration. Defaults to '/'.
|
|
95
|
+
*/
|
|
96
|
+
scope?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The SAME names on every Joryio SDK (iOS, Android, React Native, Unity, web).
|
|
101
|
+
* The browser says 'default' and has no word for "this browser cannot do
|
|
102
|
+
* push"; those are `notDetermined` and `unavailable` here. The profile
|
|
103
|
+
* attribute `push_permission` uses the snake_case wire value `not_determined`.
|
|
104
|
+
*/
|
|
105
|
+
export type PushPermissionState = 'granted' | 'denied' | 'notDetermined' | 'unavailable';
|
|
106
|
+
|
|
107
|
+
export class PushManager {
|
|
108
|
+
private vapidKeyCache: string | null = null;
|
|
109
|
+
// Pre-warmed (VAPID key + registered SW) so the gesture-bound subscribe() does
|
|
110
|
+
// the minimum async work and the OS prompt fires inside the user-activation window.
|
|
111
|
+
private prepared: { registration: ServiceWorkerRegistration; publicKey: string } | null = null;
|
|
112
|
+
// Why the last prepare() failed, so subscribe() (the user-gesture path) can
|
|
113
|
+
// print a specific reason. prepare() itself stays quiet — it also runs as a
|
|
114
|
+
// fire-and-forget pre-warm where console noise would be wrong.
|
|
115
|
+
private lastPrepareError: 'unsupported' | 'no-key' | 'sw-failed' | null = null;
|
|
116
|
+
|
|
117
|
+
private readonly canSend: () => boolean;
|
|
118
|
+
private readonly canWithdraw: () => boolean;
|
|
119
|
+
private readonly withdrawalUserId: () => string | null;
|
|
120
|
+
private readonly storage: StorageManager | null;
|
|
121
|
+
private unregisterInFlight = false;
|
|
122
|
+
private unregisterTimer: ReturnType<typeof setTimeout> | null = null;
|
|
123
|
+
private readonly getDeviceId: () => string | undefined;
|
|
124
|
+
private readonly onSubscribeSettled: (() => void) | null;
|
|
125
|
+
|
|
126
|
+
constructor(
|
|
127
|
+
private readonly logger: Logger,
|
|
128
|
+
private readonly network: NetworkClient,
|
|
129
|
+
private readonly identity: IdentityManager,
|
|
130
|
+
private readonly apiEndpoint: string,
|
|
131
|
+
options: PushManagerOptions = {},
|
|
132
|
+
) {
|
|
133
|
+
this.canSend = options.canSend || (() => true);
|
|
134
|
+
this.canWithdraw = options.canWithdraw || (() => true);
|
|
135
|
+
this.withdrawalUserId = options.withdrawalUserId || (() => this.identity.getUserId());
|
|
136
|
+
this.storage = options.storage || null;
|
|
137
|
+
this.getDeviceId = options.getDeviceId || (() => undefined);
|
|
138
|
+
this.onSubscribeSettled = options.onSubscribeSettled || null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Returns true if this browser can do web push at all. Must be called before
|
|
143
|
+
* any subscribe() attempt — Safari < 16.4, insecure origins, and private
|
|
144
|
+
* browsing modes will all return false.
|
|
145
|
+
*/
|
|
146
|
+
isSupported(): boolean {
|
|
147
|
+
return (
|
|
148
|
+
typeof window !== 'undefined' &&
|
|
149
|
+
'serviceWorker' in navigator &&
|
|
150
|
+
'PushManager' in window &&
|
|
151
|
+
'Notification' in window
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
getPermissionState(): PushPermissionState {
|
|
156
|
+
if (!this.isSupported()) return 'unavailable';
|
|
157
|
+
const p = Notification.permission;
|
|
158
|
+
if (p === 'granted' || p === 'denied') return p;
|
|
159
|
+
return 'notDetermined';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Returns the current PushSubscription if the browser has one, else null.
|
|
164
|
+
* Useful to check "is this browser already subscribed?" without triggering
|
|
165
|
+
* a permission prompt.
|
|
166
|
+
*/
|
|
167
|
+
async getExistingSubscription(options?: PushSubscribeOptions | null): Promise<PushSubscription | null> {
|
|
168
|
+
if (!this.isSupported()) return null;
|
|
169
|
+
try {
|
|
170
|
+
const registration = await navigator.serviceWorker.getRegistration(options?.scope || '/');
|
|
171
|
+
if (!registration) return null;
|
|
172
|
+
return await registration.pushManager.getSubscription();
|
|
173
|
+
} catch (error) {
|
|
174
|
+
this.logger.warn('Could not read the push subscription:', error);
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Keep the server's copy of this browser's subscription current, without
|
|
181
|
+
* prompting. Runs on init, after reset() and when tracking consent is
|
|
182
|
+
* granted.
|
|
183
|
+
*
|
|
184
|
+
* A browser rotates a subscription on its own (the worker's
|
|
185
|
+
* `pushsubscriptionchange` cannot report it - it has no SDK key), and a
|
|
186
|
+
* VAPID key change on the server makes the old subscription useless. Nothing
|
|
187
|
+
* re-registered either, so the visitor silently stopped receiving push. Now:
|
|
188
|
+
* - permission granted + key changed -> re-subscribe with the current key
|
|
189
|
+
* (no prompt: permission is already granted) and register;
|
|
190
|
+
* - endpoint or identity changed since the last registration, or `force`
|
|
191
|
+
* (reset() moved the visitor to a new anonymous id) -> register;
|
|
192
|
+
* - otherwise at most once a day, which also heals server-side pruning.
|
|
193
|
+
*/
|
|
194
|
+
async syncExistingSubscription(force: boolean = false, options?: PushSubscribeOptions | null): Promise<void> {
|
|
195
|
+
try {
|
|
196
|
+
// An unsubscribe the server has not heard about yet goes first - and
|
|
197
|
+
// goes whatever the tracking state: it is a withdrawal.
|
|
198
|
+
await this.sendPendingUnregister();
|
|
199
|
+
if (!this.canSend()) return;
|
|
200
|
+
// The visitor (or the site) unsubscribed this browser: it stays that way
|
|
201
|
+
// until subscribe() is called again, whatever the permission says.
|
|
202
|
+
if (this.storage?.get<boolean>(UNREGISTERED_KEY) === true) return;
|
|
203
|
+
if (!this.isSupported() || Notification.permission !== 'granted') return;
|
|
204
|
+
const registration = await navigator.serviceWorker.getRegistration(options?.scope || '/');
|
|
205
|
+
if (!registration) return;
|
|
206
|
+
let subscription = await registration.pushManager.getSubscription();
|
|
207
|
+
|
|
208
|
+
const marker = this.storage?.get<{ endpoint: string; userId: string | null; anonymousId: string; at: number }>(
|
|
209
|
+
REGISTRATION_MARKER_KEY,
|
|
210
|
+
);
|
|
211
|
+
const userId = this.identity.getUserId();
|
|
212
|
+
const anonymousId = this.identity.getAnonymousId();
|
|
213
|
+
const due = !marker || Date.now() - (marker.at || 0) > REREGISTER_EVERY_MS;
|
|
214
|
+
const changed =
|
|
215
|
+
!subscription ||
|
|
216
|
+
!marker ||
|
|
217
|
+
marker.endpoint !== subscription.endpoint ||
|
|
218
|
+
marker.userId !== userId ||
|
|
219
|
+
marker.anonymousId !== anonymousId;
|
|
220
|
+
if (!force && !due && !changed) return;
|
|
221
|
+
|
|
222
|
+
const publicKey = await this.fetchVapidPublicKey();
|
|
223
|
+
if (!publicKey) return;
|
|
224
|
+
if (subscription && !this.subscriptionKeyMatches(subscription, publicKey)) {
|
|
225
|
+
this.logger.info('VAPID key changed - re-subscribing this browser');
|
|
226
|
+
await subscription.unsubscribe().catch(() => false);
|
|
227
|
+
subscription = null;
|
|
228
|
+
}
|
|
229
|
+
if (!subscription) {
|
|
230
|
+
subscription = await registration.pushManager.subscribe({
|
|
231
|
+
userVisibleOnly: true,
|
|
232
|
+
applicationServerKey: urlBase64ToUint8Array(publicKey) as BufferSource,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
await this.registerWithBackend(subscription);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
this.logger.warn('Could not refresh the push subscription:', error);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Tell the service worker whether tracking is opted out, so it stops
|
|
243
|
+
* sending delivery and click receipts. Best-effort: Cache Storage is
|
|
244
|
+
* unavailable on insecure origins, where web push does not exist anyway.
|
|
245
|
+
*/
|
|
246
|
+
async setTrackingOptedOut(optedOut: boolean): Promise<void> {
|
|
247
|
+
try {
|
|
248
|
+
if (typeof caches === 'undefined') return;
|
|
249
|
+
if (optedOut) {
|
|
250
|
+
const cache = await caches.open(SW_STATE_CACHE);
|
|
251
|
+
await cache.put(SW_OPT_OUT_URL, new Response('1'));
|
|
252
|
+
} else {
|
|
253
|
+
await caches.delete(SW_STATE_CACHE);
|
|
254
|
+
}
|
|
255
|
+
} catch (error) {
|
|
256
|
+
this.logger.debug('Could not record the opt-out for the service worker:', error);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Pre-warm web push WITHOUT prompting: fetch the VAPID key and register the
|
|
262
|
+
* service worker ahead of any user gesture. Call this when a push primer is
|
|
263
|
+
* about to be shown, so the later, gesture-bound subscribe() does the minimum
|
|
264
|
+
* async work and the OS permission prompt reliably fires inside the
|
|
265
|
+
* user-activation window (transient activation lapses across slow awaits).
|
|
266
|
+
* Idempotent; never prompts; returns false if web push isn't available/enabled.
|
|
267
|
+
*/
|
|
268
|
+
async prepare(options?: PushSubscribeOptions | null): Promise<boolean> {
|
|
269
|
+
options = options || {};
|
|
270
|
+
if (!this.isSupported()) {
|
|
271
|
+
this.lastPrepareError = 'unsupported';
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
if (this.prepared) {
|
|
275
|
+
this.lastPrepareError = null;
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const publicKey = await this.fetchVapidPublicKey();
|
|
280
|
+
if (!publicKey) {
|
|
281
|
+
this.lastPrepareError = 'no-key';
|
|
282
|
+
this.logger.error('Web push is not enabled for this workspace');
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
const registration = await navigator.serviceWorker.register(
|
|
287
|
+
options.swPath || '/joryio-sw.js',
|
|
288
|
+
{ scope: options.scope || '/' },
|
|
289
|
+
);
|
|
290
|
+
await navigator.serviceWorker.ready;
|
|
291
|
+
this.prepared = { registration, publicKey };
|
|
292
|
+
this.lastPrepareError = null;
|
|
293
|
+
return true;
|
|
294
|
+
} catch (err) {
|
|
295
|
+
this.lastPrepareError = 'sw-failed';
|
|
296
|
+
this.logger.error(`Service worker registration failed:`, err);
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Subscribe this browser to web push. Call this from a click handler —
|
|
303
|
+
* browsers require a user gesture to trigger the permission prompt. If
|
|
304
|
+
* prepare() ran earlier, the slow work (VAPID fetch + SW register) is already
|
|
305
|
+
* done, so the click-time path is just getSubscription() + subscribe().
|
|
306
|
+
*
|
|
307
|
+
* Returns the PushSubscription on success, null if the user denied the
|
|
308
|
+
* prompt or the browser rejected the subscribe call.
|
|
309
|
+
*/
|
|
310
|
+
async subscribe(options?: PushSubscribeOptions | null): Promise<PushSubscription | null> {
|
|
311
|
+
try {
|
|
312
|
+
return await this.subscribeOnce(options);
|
|
313
|
+
} finally {
|
|
314
|
+
try {
|
|
315
|
+
this.onSubscribeSettled?.();
|
|
316
|
+
} catch (error) {
|
|
317
|
+
this.logger.warn('Push permission report failed:', error);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private async subscribeOnce(options?: PushSubscribeOptions | null): Promise<PushSubscription | null> {
|
|
323
|
+
options = options || {};
|
|
324
|
+
// subscribe() runs from an explicit user gesture (the push primer). A silent
|
|
325
|
+
// no-op is the worst outcome for an integrator, and the SDK logger is off by
|
|
326
|
+
// default — so the "couldn't enable push, here's why" reasons go to
|
|
327
|
+
// console.warn directly, regardless of log level.
|
|
328
|
+
if (!this.isSupported()) {
|
|
329
|
+
console.warn(
|
|
330
|
+
'[Joryio SDK] Push not enabled: this browser/context does not support web push ' +
|
|
331
|
+
'(needs HTTPS, a service worker, and PushManager - Safari < 16.4 and insecure origins are excluded).',
|
|
332
|
+
);
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Reuse the pre-warmed registration/key if available; otherwise warm now.
|
|
337
|
+
// (Warming now adds awaits before the prompt — fine on a fast connection,
|
|
338
|
+
// but prepare() ahead of the gesture is the reliable path.)
|
|
339
|
+
if (!this.prepared) {
|
|
340
|
+
await this.prepare(options);
|
|
341
|
+
}
|
|
342
|
+
const prep = this.prepared;
|
|
343
|
+
if (!prep) {
|
|
344
|
+
this.logger.error('Web push is not enabled for this workspace');
|
|
345
|
+
const swPath = options.swPath || '/joryio-sw.js';
|
|
346
|
+
if (this.lastPrepareError === 'no-key') {
|
|
347
|
+
console.warn(
|
|
348
|
+
'[Joryio SDK] Push not enabled: the server returned no VAPID key - web push is not ' +
|
|
349
|
+
'configured for this app. Enable web push for THIS app (the one your SDK key belongs to) ' +
|
|
350
|
+
'in the dashboard.',
|
|
351
|
+
);
|
|
352
|
+
} else if (this.lastPrepareError === 'sw-failed') {
|
|
353
|
+
console.warn(
|
|
354
|
+
`[Joryio SDK] Push not enabled: the service worker failed to register. The VAPID key was ` +
|
|
355
|
+
`fetched OK, so the problem is ${swPath}: make sure it is hosted on your origin AND its ` +
|
|
356
|
+
`importScripts() points at https://sdk.joryio.com/web/1/joryio-sw.js (a dead URL makes ` +
|
|
357
|
+
`the worker's install throw and registration fail).`,
|
|
358
|
+
);
|
|
359
|
+
} else {
|
|
360
|
+
console.warn('[Joryio SDK] Push not enabled: could not prepare web push.');
|
|
361
|
+
}
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// An explicit subscribe() is the one thing that lifts an unsubscribe().
|
|
366
|
+
this.storage?.remove(UNREGISTERED_KEY);
|
|
367
|
+
|
|
368
|
+
try {
|
|
369
|
+
const existing = await prep.registration.pushManager.getSubscription();
|
|
370
|
+
if (existing && this.subscriptionKeyMatches(existing, prep.publicKey)) {
|
|
371
|
+
// Already subscribed with the CURRENT VAPID key — re-register with
|
|
372
|
+
// backend in case this browser lost sync (new userId, app reinstall).
|
|
373
|
+
await this.registerWithBackend(existing).catch((error) => this.logRegisterFailure(error));
|
|
374
|
+
return existing;
|
|
375
|
+
}
|
|
376
|
+
if (existing) {
|
|
377
|
+
// The server's VAPID key changed since this browser subscribed (e.g. a
|
|
378
|
+
// regenerate, or the org→per-app web push cutover). The old subscription
|
|
379
|
+
// is cryptographically bound to the OLD applicationServerKey, so every
|
|
380
|
+
// send would 403/410 and get pruned — re-subscribe with the current key
|
|
381
|
+
// so the user isn't silently stranded.
|
|
382
|
+
this.logger.info('VAPID key changed - re-subscribing this browser');
|
|
383
|
+
await existing.unsubscribe().catch(() => {});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const subscription = await prep.registration.pushManager.subscribe({
|
|
387
|
+
userVisibleOnly: true,
|
|
388
|
+
applicationServerKey: urlBase64ToUint8Array(prep.publicKey) as BufferSource,
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// An unregister of an OLDER endpoint goes first, so the server never
|
|
392
|
+
// sees it after this registration.
|
|
393
|
+
await this.sendPendingUnregister();
|
|
394
|
+
// The browser IS subscribed now. A failed registration is retried on the
|
|
395
|
+
// next page load (no marker is written), so it is not a failed subscribe.
|
|
396
|
+
await this.registerWithBackend(subscription).catch((error) => this.logRegisterFailure(error));
|
|
397
|
+
this.logger.info('Web push subscribed');
|
|
398
|
+
return subscription;
|
|
399
|
+
} catch (err: any) {
|
|
400
|
+
if (err?.name === 'NotAllowedError') {
|
|
401
|
+
this.logger.info('User denied push permission');
|
|
402
|
+
console.warn(
|
|
403
|
+
'[Joryio SDK] Push not enabled: notification permission is denied. The browser will not ' +
|
|
404
|
+
're-prompt - the user must re-allow notifications for this site in browser settings.',
|
|
405
|
+
);
|
|
406
|
+
} else {
|
|
407
|
+
this.logger.error('pushManager.subscribe failed:', err);
|
|
408
|
+
console.warn('[Joryio SDK] Push not enabled: pushManager.subscribe failed:', err);
|
|
409
|
+
}
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Unsubscribe this browser: the browser drops the subscription and the
|
|
416
|
+
* server marks it inactive. Durable: nothing re-subscribes or re-registers
|
|
417
|
+
* this browser on a later page load until subscribe() is called again.
|
|
418
|
+
*
|
|
419
|
+
* Never rejects (a failure resolves `false`). The server is told even when
|
|
420
|
+
* the browser-side unsubscribe fails, and when it cannot be told now
|
|
421
|
+
* (offline, consent pending) it is told on a later page view.
|
|
422
|
+
*/
|
|
423
|
+
async unsubscribe(options?: PushSubscribeOptions | null): Promise<boolean> {
|
|
424
|
+
// Captured now: by the time the browser answers, a reset() or wipeData()
|
|
425
|
+
// may have moved this visitor to a new identity.
|
|
426
|
+
const owner = {
|
|
427
|
+
userId: this.withdrawalUserId() || undefined,
|
|
428
|
+
anonymousId: this.identity.getAnonymousId(),
|
|
429
|
+
};
|
|
430
|
+
try {
|
|
431
|
+
this.storage?.set(UNREGISTERED_KEY, true);
|
|
432
|
+
this.storage?.remove(REGISTRATION_MARKER_KEY);
|
|
433
|
+
if (!this.isSupported()) return false;
|
|
434
|
+
const registration = await navigator.serviceWorker.getRegistration(options?.scope || '/');
|
|
435
|
+
if (!registration) return false;
|
|
436
|
+
const subscription = await registration.pushManager.getSubscription();
|
|
437
|
+
if (!subscription) return true;
|
|
438
|
+
|
|
439
|
+
const endpoint = subscription.endpoint;
|
|
440
|
+
const ok = await subscription.unsubscribe().catch(() => false);
|
|
441
|
+
|
|
442
|
+
const pending = this.pendingUnregisters().filter((u) => u.endpoint !== endpoint);
|
|
443
|
+
pending.push({ endpoint, ...owner });
|
|
444
|
+
this.storePendingUnregisters(pending);
|
|
445
|
+
await this.sendPendingUnregister();
|
|
446
|
+
return ok;
|
|
447
|
+
} catch (error) {
|
|
448
|
+
this.logger.warn('Push unsubscribe failed:', error);
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Erase this browser's push state (wipeData): unsubscribe it, tell the
|
|
455
|
+
* server, and forget the registration. Like unsubscribe(), it stays
|
|
456
|
+
* unsubscribed until subscribe() is called again.
|
|
457
|
+
*/
|
|
458
|
+
async forget(): Promise<void> {
|
|
459
|
+
await this.unsubscribe();
|
|
460
|
+
this.prepared = null;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Users with an unregister still waiting (their retired SDK-auth token must be kept). */
|
|
464
|
+
pendingUnregisterUsers(): string[] {
|
|
465
|
+
return this.pendingUnregisters()
|
|
466
|
+
.map((u) => u.userId)
|
|
467
|
+
.filter((u): u is string => typeof u === 'string' && !!u);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private pendingUnregisters(): PendingUnregister[] {
|
|
471
|
+
const stored = this.storage?.get<PendingUnregister | PendingUnregister[]>(PENDING_UNREGISTER_KEY);
|
|
472
|
+
const list = Array.isArray(stored) ? stored : stored ? [stored] : [];
|
|
473
|
+
return list.filter((u) => u && typeof u.endpoint === 'string' && u.endpoint && typeof u.anonymousId === 'string');
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
private storePendingUnregisters(list: PendingUnregister[]): void {
|
|
477
|
+
if (list.length) this.storage?.set(PENDING_UNREGISTER_KEY, list.slice(-20));
|
|
478
|
+
else this.storage?.remove(PENDING_UNREGISTER_KEY);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Re-check the stored unregisters (the SDK calls it when it comes online or may send again). */
|
|
482
|
+
retryPendingUnregister(): void {
|
|
483
|
+
this.sendPendingUnregister().catch(() => undefined);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Send the stored server-side unregisters, oldest first. A withdrawal: not
|
|
488
|
+
* held by an opt-out, by consent or by a suspended workspace (the server
|
|
489
|
+
* accepts withdrawals while ingestion is paused), only by an inert instance. A failure that can heal is retried in this page view
|
|
490
|
+
* (and on the next one); a refusal drops it (contract item 40).
|
|
491
|
+
*/
|
|
492
|
+
private async sendPendingUnregister(): Promise<void> {
|
|
493
|
+
if (this.unregisterInFlight || !this.canWithdraw()) return;
|
|
494
|
+
if (typeof navigator !== 'undefined' && (navigator as any).onLine === false) return;
|
|
495
|
+
if (this.unregisterTimer !== null) {
|
|
496
|
+
clearTimeout(this.unregisterTimer);
|
|
497
|
+
this.unregisterTimer = null;
|
|
498
|
+
}
|
|
499
|
+
this.unregisterInFlight = true;
|
|
500
|
+
try {
|
|
501
|
+
for (;;) {
|
|
502
|
+
const [pending] = this.pendingUnregisters();
|
|
503
|
+
if (!pending) return;
|
|
504
|
+
const { attempts = 0, notFoundAttempts = 0, failures = 0, ...wire } = pending;
|
|
505
|
+
const res = await this.network.request({
|
|
506
|
+
url: `${this.apiEndpoint}/v1/push/unregister`,
|
|
507
|
+
method: 'POST',
|
|
508
|
+
body: { platform: 'web', ...wire },
|
|
509
|
+
subjectUserId: wire.userId || null,
|
|
510
|
+
});
|
|
511
|
+
const verdict = classifyWriteResponse(res, {
|
|
512
|
+
isSdkAuthError: this.network.isSdkAuthError(res.status, res.data),
|
|
513
|
+
ownerIsCurrentUser: (wire.userId || null) === this.withdrawalUserId(),
|
|
514
|
+
});
|
|
515
|
+
// Re-read: another call may have changed the list meanwhile.
|
|
516
|
+
const rest = this.pendingUnregisters().filter((u) => u.endpoint !== pending.endpoint);
|
|
517
|
+
if (verdict.action === 'done' || verdict.action === 'drop') {
|
|
518
|
+
if (verdict.action === 'drop') this.logger.error(`Push unregister dropped: ${verdict.reason}`);
|
|
519
|
+
this.storePendingUnregisters(rest);
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
if (verdict.action === 'hold') return; // kept for the next page view
|
|
523
|
+
const next: PendingUnregister = {
|
|
524
|
+
...pending,
|
|
525
|
+
failures: failures + 1,
|
|
526
|
+
attempts: attempts + (verdict.counts && !verdict.notFound ? 1 : 0),
|
|
527
|
+
notFoundAttempts: notFoundAttempts + (verdict.counts && verdict.notFound ? 1 : 0),
|
|
528
|
+
};
|
|
529
|
+
if (writeAttemptsExhausted(next.attempts!, next.notFoundAttempts!)) {
|
|
530
|
+
this.logger.error('Push unregister failed too many times; dropping it');
|
|
531
|
+
this.storePendingUnregisters(rest);
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
this.storePendingUnregisters([next, ...rest]);
|
|
535
|
+
this.unregisterTimer = setTimeout(() => {
|
|
536
|
+
this.unregisterTimer = null;
|
|
537
|
+
this.sendPendingUnregister().catch(() => undefined);
|
|
538
|
+
}, writeRetryDelay(next.failures!, verdict.retryAfterMs));
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
} catch (err) {
|
|
542
|
+
this.logger.warn('Failed to notify backend about unsubscribe:', err);
|
|
543
|
+
} finally {
|
|
544
|
+
this.unregisterInFlight = false;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Stop the unregister retry timer (SDK destroy). */
|
|
549
|
+
destroy(): void {
|
|
550
|
+
if (this.unregisterTimer !== null) clearTimeout(this.unregisterTimer);
|
|
551
|
+
this.unregisterTimer = null;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
private logRegisterFailure(error: unknown): void {
|
|
555
|
+
this.logger.warn(
|
|
556
|
+
'This browser is subscribed, but registering it with Joryio failed; it is retried on the next page load:',
|
|
557
|
+
error,
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Does an existing browser subscription's applicationServerKey match the
|
|
563
|
+
* current server VAPID public key? A PushSubscription is permanently bound to
|
|
564
|
+
* the key it was created with, so a server-side key change requires
|
|
565
|
+
* re-subscription. Returns true (no churn) when the key can't be read.
|
|
566
|
+
*/
|
|
567
|
+
private subscriptionKeyMatches(sub: PushSubscription, publicKeyB64: string): boolean {
|
|
568
|
+
try {
|
|
569
|
+
const current = (sub.options as any)?.applicationServerKey as
|
|
570
|
+
| ArrayBuffer
|
|
571
|
+
| null
|
|
572
|
+
| undefined;
|
|
573
|
+
if (!current) return true; // can't compare → assume ok, avoid needless churn
|
|
574
|
+
const a = new Uint8Array(current);
|
|
575
|
+
const b = urlBase64ToUint8Array(publicKeyB64);
|
|
576
|
+
if (a.byteLength !== b.byteLength) return false;
|
|
577
|
+
for (let i = 0; i < a.byteLength; i++) {
|
|
578
|
+
if (a[i] !== b[i]) return false;
|
|
579
|
+
}
|
|
580
|
+
return true;
|
|
581
|
+
} catch {
|
|
582
|
+
return true;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
private async fetchVapidPublicKey(): Promise<string | null> {
|
|
587
|
+
if (this.vapidKeyCache) return this.vapidKeyCache;
|
|
588
|
+
try {
|
|
589
|
+
const response = await this.network.request({
|
|
590
|
+
url: `${this.apiEndpoint}/v1/push/vapid-public-key`,
|
|
591
|
+
method: 'GET',
|
|
592
|
+
});
|
|
593
|
+
if (!response.ok || !response.data?.enabled || !response.data?.publicKey) {
|
|
594
|
+
return null;
|
|
595
|
+
}
|
|
596
|
+
this.vapidKeyCache = response.data.publicKey;
|
|
597
|
+
return this.vapidKeyCache;
|
|
598
|
+
} catch (err) {
|
|
599
|
+
this.logger.error('Failed to fetch VAPID public key:', err);
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
private async registerWithBackend(subscription: PushSubscription): Promise<void> {
|
|
605
|
+
// Opted out, or consent still pending: the browser keeps its subscription,
|
|
606
|
+
// but nothing about it is sent. Granting consent re-runs
|
|
607
|
+
// syncExistingSubscription(), which registers it then.
|
|
608
|
+
if (!this.canSend()) {
|
|
609
|
+
this.logger.debug('Push subscription not registered: tracking is off or consent is pending');
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const json = subscription.toJSON();
|
|
613
|
+
const deviceId = this.getDeviceId();
|
|
614
|
+
const body = {
|
|
615
|
+
platform: 'web',
|
|
616
|
+
subscription: {
|
|
617
|
+
endpoint: subscription.endpoint,
|
|
618
|
+
keys: {
|
|
619
|
+
p256dh: json.keys?.p256dh || '',
|
|
620
|
+
auth: json.keys?.auth || '',
|
|
621
|
+
},
|
|
622
|
+
},
|
|
623
|
+
userId: this.identity.getUserId() || undefined,
|
|
624
|
+
anonymousId: this.identity.getAnonymousId(),
|
|
625
|
+
...(deviceId ? { deviceId } : {}),
|
|
626
|
+
};
|
|
627
|
+
// Registering this endpoint again cancels an unregister of it that has
|
|
628
|
+
// not been sent yet: sent afterwards, it would turn push off for good.
|
|
629
|
+
const pendingUnregisters = this.pendingUnregisters();
|
|
630
|
+
if (pendingUnregisters.some((u) => u.endpoint === subscription.endpoint)) {
|
|
631
|
+
this.storePendingUnregisters(pendingUnregisters.filter((u) => u.endpoint !== subscription.endpoint));
|
|
632
|
+
}
|
|
633
|
+
const response = await this.network.request({
|
|
634
|
+
url: `${this.apiEndpoint}/v1/push/register`,
|
|
635
|
+
method: 'POST',
|
|
636
|
+
body,
|
|
637
|
+
subjectUserId: body.userId || null,
|
|
638
|
+
});
|
|
639
|
+
// `ok` is false for a 2xx whose body says `success: false` too - the
|
|
640
|
+
// server did not store the subscription, so no marker is written and the
|
|
641
|
+
// next page load registers it again.
|
|
642
|
+
if (!response.ok) {
|
|
643
|
+
throw new Error(`Failed to register subscription: ${response.error || response.status}`);
|
|
644
|
+
}
|
|
645
|
+
this.storage?.set(REGISTRATION_MARKER_KEY, {
|
|
646
|
+
endpoint: subscription.endpoint,
|
|
647
|
+
userId: body.userId ?? null,
|
|
648
|
+
anonymousId: body.anonymousId,
|
|
649
|
+
at: Date.now(),
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Convert a URL-safe base64 string (VAPID public key) to a Uint8Array,
|
|
656
|
+
* which is what pushManager.subscribe expects.
|
|
657
|
+
*/
|
|
658
|
+
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
|
659
|
+
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
|
660
|
+
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
|
661
|
+
const rawData = atob(base64);
|
|
662
|
+
const output = new Uint8Array(rawData.length);
|
|
663
|
+
for (let i = 0; i < rawData.length; i++) {
|
|
664
|
+
output[i] = rawData.charCodeAt(i);
|
|
665
|
+
}
|
|
666
|
+
return output;
|
|
667
|
+
}
|