@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.
Files changed (76) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +54 -0
  3. package/README.md +497 -2
  4. package/dist/core/config.d.ts +101 -0
  5. package/dist/core/config.d.ts.map +1 -0
  6. package/dist/core/device.d.ts +14 -0
  7. package/dist/core/device.d.ts.map +1 -0
  8. package/dist/core/ecommerce.d.ts +67 -0
  9. package/dist/core/ecommerce.d.ts.map +1 -0
  10. package/dist/core/identity.d.ts +36 -0
  11. package/dist/core/identity.d.ts.map +1 -0
  12. package/dist/core/inapp.d.ts +169 -0
  13. package/dist/core/inapp.d.ts.map +1 -0
  14. package/dist/core/network.d.ts +158 -0
  15. package/dist/core/network.d.ts.map +1 -0
  16. package/dist/core/outbox.d.ts +79 -0
  17. package/dist/core/outbox.d.ts.map +1 -0
  18. package/dist/core/pending-attributes.d.ts +43 -0
  19. package/dist/core/pending-attributes.d.ts.map +1 -0
  20. package/dist/core/push.d.ts +59 -0
  21. package/dist/core/push.d.ts.map +1 -0
  22. package/dist/core/queue.d.ts +107 -0
  23. package/dist/core/queue.d.ts.map +1 -0
  24. package/dist/core/sdk.d.ts +169 -0
  25. package/dist/core/sdk.d.ts.map +1 -0
  26. package/dist/core/session.d.ts +36 -0
  27. package/dist/core/session.d.ts.map +1 -0
  28. package/dist/core/storage.d.ts +17 -0
  29. package/dist/core/storage.d.ts.map +1 -0
  30. package/dist/core/user.d.ts +28 -0
  31. package/dist/core/user.d.ts.map +1 -0
  32. package/dist/core/utm.d.ts +39 -0
  33. package/dist/core/utm.d.ts.map +1 -0
  34. package/dist/core/values.d.ts +3 -0
  35. package/dist/core/values.d.ts.map +1 -0
  36. package/dist/core/write-retry.d.ts +66 -0
  37. package/dist/core/write-retry.d.ts.map +1 -0
  38. package/dist/index.d.mts +711 -0
  39. package/dist/index.d.ts +21 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/joryio-sw.js +6 -0
  42. package/dist/joryio.cjs.js +7265 -0
  43. package/dist/joryio.min.js +7 -0
  44. package/dist/joryio.min.js.map +1 -0
  45. package/dist/joryio.mjs +7254 -0
  46. package/dist/joryio.umd.js +7271 -0
  47. package/dist/utils/bucket-hash.d.ts +2 -0
  48. package/dist/utils/bucket-hash.d.ts.map +1 -0
  49. package/dist/utils/logger.d.ts +14 -0
  50. package/dist/utils/logger.d.ts.map +1 -0
  51. package/dist/utils/uuid.d.ts +3 -0
  52. package/dist/utils/uuid.d.ts.map +1 -0
  53. package/package.json +90 -4
  54. package/src/core/config.ts +362 -0
  55. package/src/core/device.ts +50 -0
  56. package/src/core/ecommerce.ts +482 -0
  57. package/src/core/identity.ts +251 -0
  58. package/src/core/inapp.ts +2419 -0
  59. package/src/core/network.ts +1261 -0
  60. package/src/core/outbox.ts +707 -0
  61. package/src/core/pending-attributes.ts +350 -0
  62. package/src/core/push.ts +667 -0
  63. package/src/core/queue.ts +894 -0
  64. package/src/core/sdk.ts +3108 -0
  65. package/src/core/session.ts +311 -0
  66. package/src/core/storage.ts +157 -0
  67. package/src/core/user.ts +203 -0
  68. package/src/core/utm.ts +304 -0
  69. package/src/core/values.ts +85 -0
  70. package/src/core/write-retry.ts +136 -0
  71. package/src/index.ts +305 -0
  72. package/src/snippet/loader.js +134 -0
  73. package/src/utils/bucket-hash.ts +44 -0
  74. package/src/utils/logger.ts +58 -0
  75. package/src/utils/uuid.ts +29 -0
  76. package/src/worker/joryio-sw.js +218 -0
@@ -0,0 +1,3108 @@
1
+ /**
2
+ * Joryio Web SDK
3
+ * Main SDK class
4
+ */
5
+
6
+ import { InitConfig, ResolvedConfig, resolveConfig, SDK_VERSION } from './config';
7
+ import { StorageManager } from './storage';
8
+ import { SessionManager, SESSION_ID_KEY } from './session';
9
+ import { IdentityManager } from './identity';
10
+ import { QueueManager, QueuedEvent, PendingIdentify, FlushOutcome, QUEUE_KEY, INFLIGHT_KEY, PENDING_IDENTIFY_KEY } from './queue';
11
+ import {
12
+ NetworkClient,
13
+ NetworkResponse,
14
+ AttributeOps,
15
+ SdkAuthFailureCallback,
16
+ utf8Length,
17
+ } from './network';
18
+ import { UTMManager } from './utm';
19
+ import { InAppMessagingManager, InAppCampaign } from './inapp';
20
+ import { User } from './user';
21
+ import { Logger } from '../utils/logger';
22
+ import { getDeviceProperties } from './device';
23
+ import { generateUUID } from '../utils/uuid';
24
+ import { EcommerceTracker, EcommerceConfig, createEcommerceTracker } from './ecommerce';
25
+ import { PushManager, PENDING_UNREGISTER_KEY } from './push';
26
+ import {
27
+ WriteRecord,
28
+ WriteOwner,
29
+ isWithdrawal,
30
+ classifyWriteResponse,
31
+ WRITE_MAX_ATTEMPTS,
32
+ } from './write-retry';
33
+ import { WriteOutbox, OutboxEntry, OUTBOX_KEY } from './outbox';
34
+ import { valueProblem, withoutUnsendableValues } from './values';
35
+
36
+ /**
37
+ * The visitor's own opt-out, persisted so it survives a reload. It used to
38
+ * live only in memory: a visitor who opted out was tracked again on the very
39
+ * next page view unless every page passed `optOut: true` at init.
40
+ */
41
+ const OPT_OUT_KEY = 'tracking_opted_out';
42
+ /**
43
+ * A `denied` consent answer, persisted (contract item 24). `granted` is not
44
+ * stored - it is the absence of a denial - and `pending` never is: it holds
45
+ * for one page view.
46
+ */
47
+ const CONSENT_KEY = 'tracking_consent';
48
+ /**
49
+ * Written (a timestamp) on every explicit grant, so another tab holding in
50
+ * 'pending' - which stores nothing, and so hears no change of CONSENT_KEY -
51
+ * follows the grant. Never read at init: 'pending' is per page view.
52
+ */
53
+ const CONSENT_GRANT_SIGNAL_KEY = 'tracking_consent_granted_at';
54
+ /**
55
+ * When the stored denial / opt-out was DECIDED, and when the visitor last
56
+ * opted in (rule 77, scan 7 N1). A tab that stores its denial again after
57
+ * another tab's localStorage.clear() writes the decision's own time, so an
58
+ * explicit grant or opt-in made in the clearing tab right after the clear -
59
+ * newer than that decision - is not undone by it: a stored "no" counts only
60
+ * while no newer explicit "yes" is stored.
61
+ */
62
+ const CONSENT_DENIED_AT_KEY = 'tracking_consent_denied_at';
63
+ const OPT_OUT_AT_KEY = 'tracking_opted_out_at';
64
+ const OPT_IN_SIGNAL_KEY = 'tracking_opt_in_at';
65
+ /** The `tracking_opted_out` value the server ACKNOWLEDGED (contract item 33). */
66
+ const REPORTED_OPT_OUT_KEY = 'reported_tracking_opted_out';
67
+ /**
68
+ * A `tracking_opted_out` value the server refused for good, or that failed
69
+ * too often (contract item 40): not re-sent on every page view until the
70
+ * switch changes again.
71
+ */
72
+ const OPT_OUT_MARKER_GIVEN_UP_KEY = 'tracking_opted_out_marker_given_up';
73
+ /** The session whose Session Start has been queued (so a later grant or opt-in sends one, once). */
74
+ const SESSION_START_SENT_KEY = 'session_start_sent';
75
+ /**
76
+ * A user id given to identify() while tracking was off (consent denied or
77
+ * opted out) - owner decision D-b. Kept on this device only and used ONLY so
78
+ * withdrawals (an unsubscribe, the opt-out marker) reach the right person;
79
+ * the normal identify runs when tracking is allowed again.
80
+ */
81
+ const WITHDRAWAL_USER_KEY = 'withdrawal_user_id';
82
+ /** Logged-out users' SDK-auth tokens, kept for their queued items (D-a). */
83
+ const RETIRED_SDK_AUTH_TOKENS_KEY = 'retired_sdk_auth_tokens';
84
+ /** Stored queues that may hold a logged-out user's items (by any tab of the site). */
85
+ const PERSISTED_ITEM_KEYS = [QUEUE_KEY, INFLIGHT_KEY, PENDING_IDENTIFY_KEY, OUTBOX_KEY, PENDING_UNREGISTER_KEY, 'held_attribute_writes'];
86
+
87
+ /** Every `userId` string anywhere in a stored value (formats differ per queue). */
88
+ function collectUserIds(value: unknown, into: Set<string>, depth: number = 0): void {
89
+ if (!value || typeof value !== 'object' || depth > 6) return;
90
+ if (Array.isArray(value)) {
91
+ for (const item of value) collectUserIds(item, into, depth + 1);
92
+ return;
93
+ }
94
+ for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
95
+ if (key === 'userId' && typeof item === 'string' && item) into.add(item);
96
+ else collectUserIds(item, into, depth + 1);
97
+ }
98
+ }
99
+ /**
100
+ * The privacy state each SDK started from, before the snippet's queued
101
+ * decisions were applied: the snippet replay drops data calls queued while
102
+ * tracking was off (rule 73). Internal - not exported from the package.
103
+ */
104
+ export const PRE_INIT_BASELINE = new WeakMap<object, { optedOut: boolean; consent: TrackingConsent }>();
105
+
106
+ /**
107
+ * Why a replayed ready() callback is data-gated (rule 92): queued while the
108
+ * visitor was opted out (lifted only by an optIn() the callback makes),
109
+ * while consent was denied (lifted only by its own grant), or before a
110
+ * queued optOut() / denial (the pre-init purge - never lifted from inside:
111
+ * that decision came after the callback).
112
+ */
113
+ export type DataGateReason = 'optedOut' | 'denied' | 'purged';
114
+
115
+ /**
116
+ * Run `run` with the SDK's data calls dropped (rule 83): the snippet replays
117
+ * a ready() callback queued while tracking was off through this. Internal -
118
+ * not exported from the package.
119
+ */
120
+ export function runWithDataCallsDropped(sdk: JoryioSDK, reasons: Iterable<DataGateReason>, run: () => void): void {
121
+ const gate = sdk as unknown as { dataGate: Set<DataGateReason> };
122
+ const previous = gate.dataGate;
123
+ gate.dataGate = new Set([...previous, ...reasons]);
124
+ try {
125
+ run();
126
+ } finally {
127
+ gate.dataGate = previous;
128
+ }
129
+ }
130
+
131
+ /** Identity keys another tab may change (reset(), identify()). */
132
+ const ANONYMOUS_ID_STORAGE_KEY = 'anonymous_id';
133
+ const USER_ID_STORAGE_KEY = 'user_id';
134
+
135
+ /**
136
+ * SDK keys of another device platform. The backend issues
137
+ * `jry_sdk_<platform>_<random>`; a web page runs web keys and the store
138
+ * integrations' keys (`shopify`, `woocommerce`, `magento`), never a mobile
139
+ * app's (rule 60).
140
+ */
141
+ const FOREIGN_PLATFORM_KEY_PREFIXES = ['jry_sdk_ios_', 'jry_sdk_android_'];
142
+
143
+ function isOffline(): boolean {
144
+ return typeof navigator !== 'undefined' && (navigator as any).onLine === false;
145
+ }
146
+
147
+ /**
148
+ * The profile attribute recording the opt-out. NO `$` prefix: the API strips
149
+ * `$`-prefixed attribute keys from SDK writes, so the old
150
+ * `$tracking_opted_out` never reached a profile on any platform. The same key
151
+ * on all five SDKs (contract addendum 14).
152
+ */
153
+ export const TRACKING_OPTED_OUT_ATTRIBUTE = 'tracking_opted_out';
154
+
155
+ /**
156
+ * Attribute writes not acknowledged yet, kept in storage so a navigation does
157
+ * not lose them (contract item 37) - the same durability the mobile SDKs'
158
+ * databases give. Removed entry by entry as the server acknowledges them.
159
+ * (The key name predates 1.3.0, when only writes held by pending consent
160
+ * were stored.)
161
+ */
162
+ const ATTRIBUTE_WRITES_KEY = 'held_attribute_writes';
163
+ /** Leave room in the browser's 64 KiB keepalive budget for profile writes at unload. */
164
+ const UNLOAD_WRITES_BUDGET_BYTES = 8 * 1024;
165
+ /** The push_permission value the server ACKNOWLEDGED (written on ack, not on send). */
166
+ const REPORTED_PUSH_PERMISSION_KEY = 'joryio_reported_push_permission';
167
+ const PUSH_PERMISSION_ATTRIBUTE = 'push_permission';
168
+
169
+ /** The backend's `@MaxLength(255)` on event type and userId. */
170
+ const MAX_NAME_LENGTH = 255;
171
+
172
+ /**
173
+ * The API's limits on one event's properties (backend
174
+ * `validate-event-properties.util.ts`). An event over them is refused for
175
+ * good, so it is dropped at track() with a log line rather than sent, refused
176
+ * and - before `rejectedIndices` existed - retried forever (contract item 29).
177
+ */
178
+ export const EVENT_PROPERTY_LIMITS = {
179
+ /**
180
+ * JSON.stringify(properties).length, in characters - exactly what the
181
+ * backend measures (SDK_EVENT_LIMITS.propertiesMaxJsonChars). Not UTF-8
182
+ * bytes: those count Hebrew or emoji text 2-4 times and refused events the
183
+ * server accepts.
184
+ */
185
+ maxJsonChars: 51200,
186
+ /** Top-level keys. */
187
+ maxKeys: 200,
188
+ /** Object/array nesting, the properties object itself being level 1. */
189
+ maxDepth: 5,
190
+ } as const;
191
+
192
+ /** Why these properties would be refused by the API, or null when they are fine. */
193
+ export function eventPropertiesProblem(properties: Record<string, any>): string | null {
194
+ const keys = Object.keys(properties).length;
195
+ if (keys > EVENT_PROPERTY_LIMITS.maxKeys) {
196
+ return `${keys} property keys (the limit is ${EVENT_PROPERTY_LIMITS.maxKeys})`;
197
+ }
198
+ const tooDeep = (value: any, depth: number): boolean => {
199
+ if (depth > EVENT_PROPERTY_LIMITS.maxDepth) return true;
200
+ if (value === null || typeof value !== 'object') return false;
201
+ const children = Array.isArray(value) ? value : Object.values(value);
202
+ return children.some((child) => child !== null && typeof child === 'object' && tooDeep(child, depth + 1));
203
+ };
204
+ if (tooDeep(properties, 1)) return `properties nested deeper than ${EVENT_PROPERTY_LIMITS.maxDepth} levels`;
205
+ let json: string;
206
+ try {
207
+ json = JSON.stringify(properties);
208
+ } catch {
209
+ return 'properties that cannot be serialized (a circular reference?)';
210
+ }
211
+ if (json.length > EVENT_PROPERTY_LIMITS.maxJsonChars) {
212
+ return `${json.length} characters of properties (the limit is ${EVENT_PROPERTY_LIMITS.maxJsonChars})`;
213
+ }
214
+ return null;
215
+ }
216
+
217
+ export type TrackingConsent = 'granted' | 'denied' | 'pending';
218
+
219
+ /**
220
+ * A privacy decision made before the SDK started (the async snippet queues
221
+ * calls until the bundle loads). Applied BEFORE any side effect of init - no
222
+ * Session Start, in-app sync, push re-registration or attribute write happens
223
+ * under the state the decision replaces (contract item 25).
224
+ * @internal
225
+ */
226
+ export type PreInitDecision =
227
+ | { method: 'optOut' }
228
+ | { method: 'optIn' }
229
+ | { method: 'updateConsent'; consent: TrackingConsent }
230
+ | { method: 'wipeData' };
231
+
232
+ export class JoryioSDK {
233
+ private static instance: JoryioSDK | null = null;
234
+
235
+ private config!: ResolvedConfig;
236
+ private logger!: Logger;
237
+ private storage!: StorageManager;
238
+ private session!: SessionManager;
239
+ /** Coalescing window for attribute writes. */
240
+ private static readonly ATTRIBUTE_FLUSH_DEBOUNCE_MS = 800;
241
+ private attributeFlushTimer: ReturnType<typeof setTimeout> | null = null;
242
+ /** Serializes attribute flushes; see flushAttributeOps. */
243
+ private attributeFlushInFlight = false;
244
+ private attributeFlushAgain = false;
245
+ /** Bumped when the pending store is cleared: an answer for the old store is ignored. */
246
+ private attributeGeneration = 0;
247
+ /** Consecutive failed attribute flushes, for the retry backoff. */
248
+ private attributeFailures = 0;
249
+ private identity!: IdentityManager;
250
+ private queue!: QueueManager;
251
+ private network!: NetworkClient;
252
+ private utm!: UTMManager;
253
+ private inapp!: InAppMessagingManager;
254
+ private _user!: User;
255
+ private _push!: PushManager;
256
+ private _ecommerce: EcommerceTracker | null = null;
257
+ private isInitialized: boolean = false;
258
+ /**
259
+ * Collect NOTHING: the visitor opted out, Do Not Track is on, or consent
260
+ * was denied. Derived by applyPrivacyState(); never set directly.
261
+ */
262
+ private isOptedOut: boolean = false;
263
+ /** The SDK key was missing or malformed: an inert instance (see the constructor). */
264
+ private disabled: boolean = false;
265
+ /** Consent pending: collect locally, send nothing until it is granted. */
266
+ private consentPending: boolean = false;
267
+ private userOptedOut: boolean = false;
268
+ private dntEnabled: boolean = false;
269
+ private trackingConsent: TrackingConsent = 'granted';
270
+ /** Profile writes (subscriptions, aliases, the opt-out marker): persisted and ordered. */
271
+ private outbox!: WriteOutbox;
272
+ private lastTrackedUrl: string | null = null;
273
+ private deviceId!: string;
274
+ private boundHandlePageUnload: (() => void) | null = null;
275
+ private boundHandleOnline: (() => void) | null = null;
276
+ private boundHandleStorage: ((event: any) => void) | null = null;
277
+ /** See WITHDRAWAL_USER_KEY. */
278
+ private withdrawalUserId: string | null = null;
279
+ /** When this tab's opt-out / denial was decided (see CONSENT_DENIED_AT_KEY); 0 = unknown. */
280
+ private optedOutAt = 0;
281
+ private consentDeniedAt = 0;
282
+ /** The latest privacy-decision time this tab wrote or saw: stamps only increase. */
283
+ private lastDecisionStamp = 0;
284
+ /**
285
+ * Another tab logged out and this one followed: the session it starts may
286
+ * be stored after its new anonymous id (scan 7 N4), and is followed then.
287
+ */
288
+ private awaitingSessionOf: string | null = null;
289
+
290
+ private pruneTimer: ReturnType<typeof setTimeout> | null = null;
291
+
292
+ /**
293
+ * @param preInit - internal: privacy decisions queued before the SDK loaded
294
+ * (see installSnippetQueue). Not part of the public API.
295
+ */
296
+ constructor(config: InitConfig, preInit?: PreInitDecision[]) {
297
+ // Singleton pattern: return existing instance if already initialized
298
+ if (JoryioSDK.instance) {
299
+ // eslint-disable-next-line no-console -- this.logger is not assigned yet on the singleton early-return path
300
+ console.warn('[Joryio] SDK already initialized. Returning existing instance.');
301
+ return JoryioSDK.instance;
302
+ }
303
+ // Validate required config. Never THROWN (contract item 12): a missing or
304
+ // malformed key used to throw out of the constructor, which in a bundle
305
+ // took the host page's own script down with it. The instance is built
306
+ // inert instead - every call on it is a logged no-op and nothing is sent,
307
+ // stored for sending, or registered - and the reason is printed once,
308
+ // whatever the log level.
309
+ const keyProblem =
310
+ !config || typeof config !== 'object' || typeof config.sdkKey !== 'string' || !config.sdkKey
311
+ ? 'sdkKey is required'
312
+ : !config.sdkKey.startsWith('jry_sdk_')
313
+ ? 'invalid SDK key format, expected jry_sdk_*'
314
+ : FOREIGN_PLATFORM_KEY_PREFIXES.some((prefix) => config.sdkKey.startsWith(prefix))
315
+ ? 'this is a mobile app key - use the web SDK key (jry_sdk_web_...) from Settings -> Apps'
316
+ : null;
317
+ if (keyProblem) {
318
+ // eslint-disable-next-line no-console -- the logger is silent by default and this is the reason nothing works
319
+ console.error(`[Joryio] SDK not started: ${keyProblem}. Calls on this instance are ignored.`);
320
+ this.disabled = true;
321
+ }
322
+
323
+ // Resolve config with defaults
324
+ this.config = resolveConfig(
325
+ keyProblem
326
+ ? { ...(config && typeof config === 'object' ? config : {}), sdkKey: 'jry_sdk_invalid', optOut: false }
327
+ : config,
328
+ );
329
+
330
+ // Logger: silent unless `enableDebug` or an explicit `logLevel` was set.
331
+ this.logger = new Logger(this.config.loggingEnabled, this.config.logLevel);
332
+
333
+ this.logger.info(`Initializing Joryio SDK v${SDK_VERSION}`);
334
+
335
+ // Initialize core components
336
+ this.storage = new StorageManager(this.config.sdkKey, this.logger);
337
+
338
+ // --- Privacy state -------------------------------------------------
339
+ // Two independent switches (contract item 24): the app's consent banner
340
+ // and the visitor's own opt-out. Anything is collected only when consent
341
+ // is granted AND the visitor has not opted out; any "no" wins.
342
+ this.dntEnabled = this.config.respectDoNotTrack && this.isDoNotTrackEnabled();
343
+ if (this.dntEnabled) {
344
+ this.logger.warn('Do Not Track is enabled, SDK will not track events');
345
+ }
346
+ if (this.config.optOut) {
347
+ this.writeOptOut();
348
+ }
349
+ this.userOptedOut = this.config.optOut || this.storedOptOut();
350
+ if (this.userOptedOut) this.optedOutAt = this.storedStamp(OPT_OUT_AT_KEY);
351
+ this.trackingConsent = this.initialConsent(this.config.trackingConsent);
352
+
353
+ // Initialize UTM tracking (before session to check for campaign changes)
354
+ this.utm = new UTMManager(
355
+ this.storage,
356
+ this.logger,
357
+ this.config.resetSessionOnNewCampaign,
358
+ );
359
+
360
+ // Check if we should reset session due to new campaign
361
+ const shouldResetSession = this.config.resetSessionOnNewCampaign &&
362
+ this.config.captureUTM &&
363
+ this.utm.hasNewCampaign();
364
+
365
+ this.session = new SessionManager(this.storage, this.logger, this.config.sessionTimeout);
366
+
367
+ // Reset session if new campaign detected
368
+ if (shouldResetSession) {
369
+ this.logger.info('New campaign detected, resetting session');
370
+ this.session.resetSession();
371
+ }
372
+
373
+ this.identity = new IdentityManager(
374
+ this.storage,
375
+ this.logger,
376
+ this.config.userId,
377
+ this.config.anonymousId,
378
+ );
379
+
380
+ this.deviceId = this.getOrCreateDeviceId();
381
+ this.queue = new QueueManager(
382
+ this.storage,
383
+ this.logger,
384
+ this.config.batchSize,
385
+ this.config.batchFlushInterval,
386
+ this.config.persistQueue,
387
+ this.config.maxQueueSize,
388
+ this.config.retryBackoffMs,
389
+ );
390
+ this.network = new NetworkClient(
391
+ this.config.sdkKey,
392
+ this.logger,
393
+ {
394
+ maxRetries: this.config.maxRetries,
395
+ backoffMs: this.config.retryBackoffMs,
396
+ timeoutMs: this.config.requestTimeout,
397
+ },
398
+ );
399
+
400
+ // A logged-out user's token stays bound to their queued items across a
401
+ // reload (D-a), so their withdrawals and last events still authenticate.
402
+ // Every tab shares the stored map: read-modify-write, never a blind
403
+ // overwrite of another tab's entries (rule 62).
404
+ this.network.setRetiredTokenStore({
405
+ load: () => this.storage.get<Record<string, unknown>>(RETIRED_SDK_AUTH_TOKENS_KEY),
406
+ update: (mutate) => {
407
+ const stored = this.storage.get<Record<string, unknown>>(RETIRED_SDK_AUTH_TOKENS_KEY);
408
+ const next = mutate(stored && typeof stored === 'object' && !Array.isArray(stored) ? stored : {});
409
+ if (Object.keys(next).length) this.storage.set(RETIRED_SDK_AUTH_TOKENS_KEY, next);
410
+ else this.storage.remove(RETIRED_SDK_AUTH_TOKENS_KEY);
411
+ },
412
+ });
413
+ // The user kept for withdrawals while tracking was off (D-b). An explicit
414
+ // `userId` in the init config names who this page is about and wins over
415
+ // it (rule 63): a page rendered for A must not send A's unsubscribe as B.
416
+ const storedWithdrawalUser = this.storage.get<string>(WITHDRAWAL_USER_KEY);
417
+ const configUserId = typeof this.config.userId === 'string' && this.config.userId ? this.config.userId : null;
418
+ this.withdrawalUserId =
419
+ typeof storedWithdrawalUser === 'string' &&
420
+ storedWithdrawalUser &&
421
+ storedWithdrawalUser !== this.identity.getUserId() &&
422
+ !configUserId
423
+ ? storedWithdrawalUser
424
+ : null;
425
+ if (!this.withdrawalUserId && storedWithdrawalUser) this.storage.remove(WITHDRAWAL_USER_KEY);
426
+ this.network.setCurrentUserId(this.withdrawalUserId || this.identity.getUserId());
427
+
428
+ // SDK authentication (opt-in). When enabled the network client attaches the
429
+ // customer-minted JWT as `X-Joryio-Auth` and refreshes it on a 401
430
+ // `sdk_authentication_error`. No-op when disabled. Set after the current
431
+ // user, so the token is known to be that user's.
432
+ if (this.config.enableSdkAuthentication) {
433
+ this.network.setSdkAuthenticationEnabled(true);
434
+ if (this.config.sdkAuthenticationToken) {
435
+ this.network.setSdkAuthenticationToken(this.config.sdkAuthenticationToken);
436
+ }
437
+ }
438
+
439
+ this.outbox = this.createOutbox();
440
+
441
+ // Initialize in-app messaging. The bridge lets in-app message HTML call SDK
442
+ // methods via `window.joryioBridge` (the push primer uses requestPushPermission).
443
+ // Arrows capture `this` lazily — `_push` is constructed just below, but the
444
+ // bridge methods are only invoked at message-interaction time, by which point
445
+ // every dependency exists.
446
+ this.inapp = new InAppMessagingManager(
447
+ this.config.apiEndpoint,
448
+ this.config.sdkKey,
449
+ this.identity,
450
+ this.session,
451
+ this.logger,
452
+ this.storage,
453
+ {
454
+ closeButton: true,
455
+ backdropClose: true,
456
+ // The RESOLVED displayOptions, which used to be dropped here: config.ts
457
+ // resolves zIndex / backdropColor / closeButtonColor / defaultPosition /
458
+ // defaultAnimation with defaults, and this literal replaced the lot. A
459
+ // site that set inApp.displayOptions.zIndex to sit above its own header
460
+ // got no effect and nothing to explain it.
461
+ ...this.config.inApp.displayOptions,
462
+ onDisplay: this.config.inApp.onMessageDisplay,
463
+ onClick: this.config.inApp.onMessageClick,
464
+ onDismiss: this.config.inApp.onMessageDismiss,
465
+ },
466
+ {
467
+ logCustomEvent: (name, properties) => this.track(name, properties),
468
+ setCustomUserAttribute: (key, value) => this.setAttribute(key, value),
469
+ changeUser: (userId) => this.identify(userId),
470
+ // Synchronous dispatch — keeps the click's user-gesture activation so the
471
+ // OS push prompt fires (the iframe is same-origin; the call chain stays sync).
472
+ // `.catch`, not bare `void`: `void` silences the linter, not the
473
+ // rejection. An unhandled rejection fires the HOST page's
474
+ // onunhandledrejection handler and lands in their error monitoring as if
475
+ // their app had failed. Our push prompt being declined is our problem.
476
+ requestPushPermission: () => {
477
+ this._push.subscribe().catch((error) => {
478
+ this.logger.warn('Push subscribe failed:', error);
479
+ });
480
+ },
481
+ preparePushPermission: () => {
482
+ this._push.prepare().catch((error) => {
483
+ this.logger.warn('Push prepare failed:', error);
484
+ });
485
+ },
486
+ },
487
+ // Share the network client so in-app's own fetches carry the
488
+ // X-Joryio-Auth header and can trigger a token refresh on a 401.
489
+ this.network,
490
+ {
491
+ canSend: () => this.canSend(),
492
+ requestTimeoutMs: this.config.requestTimeout,
493
+ syncIntervalMs: this.config.inApp.syncInterval,
494
+ evaluationMode: this.config.inApp.evaluationMode,
495
+ noteStatus: (status, data) => {
496
+ this.network.noteSuspended(status, data);
497
+ },
498
+ },
499
+ );
500
+
501
+ // Without this the setter is never called and the flag is permanently
502
+ // false, so every HTML campaign is refused with no way to opt in.
503
+ this.inapp.setAllowHtmlJsInAppMessages(this.config.inApp.allowHtmlJsInAppMessages);
504
+
505
+ // Initialize user object for subscription management
506
+ this._user = new User(
507
+ this.logger,
508
+ this.network,
509
+ this.identity,
510
+ this.config.apiEndpoint,
511
+ (label, record) => this.runWrite(label, record),
512
+ );
513
+
514
+ // Web push manager. No prompt ever happens here; on init it only refreshes
515
+ // a subscription the visitor already granted (see syncExistingSubscription).
516
+ this._push = new PushManager(
517
+ this.logger,
518
+ this.network,
519
+ this.identity,
520
+ this.config.apiEndpoint,
521
+ {
522
+ canSend: () => this.canSend(),
523
+ // A server-side unregister is a withdrawal (contract item 36), sent
524
+ // even while ingestion is paused - the server accepts it.
525
+ canWithdraw: () => this.isInitialized && !this.disabled,
526
+ withdrawalUserId: () => this.effectiveUserId(),
527
+ storage: this.storage,
528
+ getDeviceId: () => this.deviceId,
529
+ // The new permission is reported now, not on the next page load.
530
+ onSubscribeSettled: () => this.reportPushPermissionIfChanged(),
531
+ },
532
+ );
533
+
534
+ // A Session Start that was on the wire when an opt-out / denial cleared
535
+ // the queue: its answer decides whether this session still owes one (rule 90).
536
+ this.queue.setClearedInflightCallback((delivered) => {
537
+ const sessionId = this.inflightSessionStartAtClear;
538
+ if (!sessionId) return;
539
+ this.inflightSessionStartAtClear = null;
540
+ const stored = delivered.some((e) => e.type === 'Session Start' && e.sessionId === sessionId);
541
+ if (!stored && this.storage.get<string>(SESSION_START_SENT_KEY) === sessionId) {
542
+ this.storage.set(SESSION_START_SENT_KEY, '');
543
+ }
544
+ });
545
+
546
+ // Set up queue flush callback
547
+ this.queue.setFlushCallback(async (events, identify) => {
548
+ try {
549
+ return await this.flushEvents(events, identify);
550
+ } finally {
551
+ // A logged-out user's token lives as long as their leftovers (D-a).
552
+ this.scheduleRetiredTokenPrune();
553
+ }
554
+ });
555
+
556
+ // Decisions made before init (the snippet's queue) take effect NOW,
557
+ // before anything below can send under the state they replace.
558
+ PRE_INIT_BASELINE.set(this, { optedOut: this.userOptedOut, consent: this.trackingConsent });
559
+ for (const decision of preInit || []) this.applyPreInitDecision(decision);
560
+
561
+ this.recomputePrivacy();
562
+ if (this.isOptedOut) {
563
+ this.logger.warn('Tracking is off (opt-out, Do Not Track or consent denied)');
564
+ }
565
+ this.queue.setPaused(this.consentPending);
566
+ if (this.isOptedOut) {
567
+ // Events persisted by an earlier page view must not go out after a
568
+ // refusal either.
569
+ this.queue.clear();
570
+ }
571
+
572
+ // Attribute writes not acknowledged on an earlier page view.
573
+ this.restoreAttributeWrites();
574
+
575
+ // Page unload: hand what is queued to the browser (fetch keepalive).
576
+ if (!this.disabled && typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
577
+ this.boundHandlePageUnload = this.handlePageUnload.bind(this);
578
+ window.addEventListener('pagehide', this.boundHandlePageUnload);
579
+ window.addEventListener('beforeunload', this.boundHandlePageUnload);
580
+ // Back online: retry now instead of waiting out the backoff.
581
+ this.boundHandleOnline = () => {
582
+ this.queue.resetBackoff();
583
+ if (this.canSend()) {
584
+ this.queue.flush().catch((error) => this.logger.error('Flush failed:', error));
585
+ }
586
+ // Profile writes and withdrawals that failed while offline.
587
+ this.outbox.kick();
588
+ this._push.retryPendingUnregister();
589
+ // Attribute writes waiting out their own backoff, too.
590
+ if (!this.identity.pending.isEmpty() && this.canSend()) {
591
+ this.attributeFailures = 0;
592
+ if (this.attributeFlushTimer !== null) {
593
+ clearTimeout(this.attributeFlushTimer);
594
+ this.attributeFlushTimer = null;
595
+ }
596
+ this.scheduleAttributeFlush(0);
597
+ }
598
+ };
599
+ window.addEventListener('online', this.boundHandleOnline);
600
+ // Another tab of the site opted out, changed consent, logged out or
601
+ // identified: this tab follows at once instead of at its next reload.
602
+ this.boundHandleStorage = (event: any) => this.handleStorageEvent(event);
603
+ window.addEventListener('storage', this.boundHandleStorage);
604
+ }
605
+
606
+ // Load plugins
607
+ if (!this.disabled) {
608
+ this.loadPlugins().catch((error) => this.logger.error('Plugin load failed:', error));
609
+ }
610
+
611
+ this.isInitialized = true;
612
+ // An inert instance is not THE instance: a later, correct `new JoryioSDK()`
613
+ // must get a working SDK rather than this one.
614
+ if (!this.disabled) JoryioSDK.instance = this;
615
+ this.logger.info('SDK initialized successfully');
616
+
617
+ // Report permission state now the SDK is live. Only sends on a change.
618
+ this.reportPushPermissionIfChanged();
619
+
620
+ // Enqueue Session Start BEFORE starting the queue auto-flush timer.
621
+ // This ensures Session Start is the first event sent, even if there are
622
+ // persisted events from a previous page load (queue.start() flushes those immediately).
623
+ let sessionStartSent = false;
624
+ // A pre-init wipeData() cleared the session: the fresh visitor's starts
625
+ // here (announced below, with its Session Start).
626
+ this.session.ensureSession();
627
+ if (this.config.trackSessionStart && !this.isOptedOut) {
628
+ const sessionInfo = this.session.getSessionInfo();
629
+ // A new session, or one whose Session Start was never queued (tracking
630
+ // was off when it began) - but never a second one for a session that
631
+ // already has it (a cold start resuming an unexpired session).
632
+ // (No marker at all = stored by an earlier SDK version: treated as sent.)
633
+ const startedFor = this.storage.get<string>(SESSION_START_SENT_KEY);
634
+ const startPending = startedFor !== null && startedFor !== sessionInfo.sessionId;
635
+ if (sessionInfo.isNewSession || shouldResetSession || startPending) {
636
+ // While consent is pending this only QUEUES it (the queue is paused):
637
+ // it goes out first when consent is granted and is discarded if it is
638
+ // denied.
639
+ this.trackSessionStart();
640
+ sessionStartSent = true;
641
+ }
642
+ }
643
+
644
+ // Only track initial Page View if Session Start was NOT sent
645
+ // (Session Start includes landing page, backend creates initial Page View from it)
646
+ if (this.config.trackPageViews && !this.isOptedOut && !sessionStartSent) {
647
+ this.trackPageView();
648
+ if (typeof document !== 'undefined' && document.readyState === 'loading') {
649
+ document.addEventListener(
650
+ 'DOMContentLoaded',
651
+ () => {
652
+ this.trackPageView();
653
+ },
654
+ { once: true },
655
+ );
656
+ }
657
+ }
658
+
659
+ // Register callback for when session expires and a new one is created (e.g., tab becomes visible after inactivity).
660
+ // Must be registered BEFORE queue.start() to avoid missing a session change that occurs during startup.
661
+ this.session.onNewSession(() => {
662
+ if (this.isOptedOut) return;
663
+ if (this.config.trackSessionStart) {
664
+ this.logger.debug('New session created (mid-visit), tracking session start');
665
+ this.trackSessionStart();
666
+ }
667
+ // A new session is a sync point in both evaluation modes.
668
+ this.resyncInApp();
669
+ });
670
+
671
+ // Start sending AFTER Session Start is enqueued, so it's included in the
672
+ // first flush along with any persisted events from previous page loads.
673
+ if (this.canSend()) {
674
+ this.queue.start();
675
+
676
+ // Initialize in-app messaging if enabled
677
+ if (this.config.inApp.enabled) {
678
+ this.inapp.initialize().catch((error) => {
679
+ this.logger.error('Failed to initialize in-app messaging:', error);
680
+ });
681
+ }
682
+
683
+ // A subscription the browser rotated, or one bound to an old VAPID key,
684
+ // is re-registered here - nothing else ever did. (After a pre-init
685
+ // wipeData() this only tells the server about the unsubscribe: the
686
+ // browser stays unsubscribed until push.subscribe().)
687
+ this._push.syncExistingSubscription().catch(() => undefined);
688
+
689
+ // Held on an earlier page view while consent was pending, granted now.
690
+ this.releaseHeldWrites();
691
+ }
692
+ this.syncWorkerReporting();
693
+
694
+ // An identify() made while tracking was off on an earlier page view, now
695
+ // that it is allowed (D-b).
696
+ if (!this.isOptedOut) this.releaseDeferredIdentify();
697
+
698
+ // The opt-out marker, if the switch changed since the server last heard
699
+ // (an `optOut: true` in the config or a pre-init optOut() included).
700
+ this.syncOptOutMarker();
701
+ this.scheduleRetiredTokenPrune();
702
+ // Withdrawals go whatever the tracking state; the rest when allowed.
703
+ this.outbox.kick();
704
+ this._push.retryPendingUnregister();
705
+ }
706
+
707
+ /** The persisted outbox, wired to this SDK's privacy state and identity. */
708
+ private createOutbox(): WriteOutbox {
709
+ return new WriteOutbox({
710
+ storage: this.storage,
711
+ logger: this.logger,
712
+ gate: (entry) => {
713
+ if (!this.isInitialized || this.disabled) return 'hold';
714
+ // A withdrawal goes even while ingestion is paused (402): the server
715
+ // accepts it (owner decision 2026-10-06).
716
+ if (entry.withdrawal) return 'send';
717
+ if (this.network.isSuspended()) return 'hold';
718
+ if (this.isOptedOut) return 'drop';
719
+ if (this.consentPending) return 'hold';
720
+ return 'send';
721
+ },
722
+ resolveOwner: (owner) => this.resolveOwner(owner),
723
+ ownerIsCurrentUser: (owner) => (owner.userId || null) === this.effectiveUserId(),
724
+ currentIdentity: () => ({ anonymousId: this.identity.getAnonymousId(), userId: this.effectiveUserId() }),
725
+ send: (entry, owner) => this.sendWrite(entry.record, owner),
726
+ beforeSend: () => this.flushQueueForWrite(),
727
+ isSdkAuthError: (status, data) => this.network.isSdkAuthError(status, data),
728
+ onSettled: (entry, outcome) => {
729
+ this.onWriteSettled(entry, outcome);
730
+ this.scheduleRetiredTokenPrune();
731
+ },
732
+ keepalive: (entry, owner) => {
733
+ const request = this.writeRequest(entry.record, owner);
734
+ const json = JSON.stringify(request.body);
735
+ if (!this.network.postKeepalive(request.url, request.body)) return null;
736
+ return utf8Length(json) + 256;
737
+ },
738
+ });
739
+ }
740
+
741
+ /** Who a write is about right now: the current ids. */
742
+ private currentOwner(): WriteOwner {
743
+ const userId = this.effectiveUserId();
744
+ return { anonymousId: this.identity.getAnonymousId(), ...(userId ? { userId } : {}) };
745
+ }
746
+
747
+ /**
748
+ * The user withdrawals are made for: the one identify() named while
749
+ * tracking was off (D-b), else the identified user.
750
+ */
751
+ private effectiveUserId(): string | null {
752
+ return this.withdrawalUserId || this.identity.getUserId();
753
+ }
754
+
755
+ /**
756
+ * identify() while consent is denied or the visitor opted out (D-b): the
757
+ * user id is kept on this device ONLY - nothing is sent, tracked or written
758
+ * to a profile - so a later unsubscribe or opt-out reaches that person
759
+ * rather than an orphan anonymous profile. The normal identify runs when
760
+ * tracking is allowed again.
761
+ */
762
+ private deferIdentify(userId: string): void {
763
+ if (userId === this.effectiveUserId()) return;
764
+ // The previous user's SDK-auth token stays with the previous user (rule 53).
765
+ this.network.switchUser(userId);
766
+ this.withdrawalUserId = userId === this.identity.getUserId() ? null : userId;
767
+ if (this.withdrawalUserId) this.storage.set(WITHDRAWAL_USER_KEY, this.withdrawalUserId);
768
+ else this.storage.remove(WITHDRAWAL_USER_KEY);
769
+ this.logger.debug('Tracking is off: identify kept on this device for withdrawals only, not sent');
770
+ }
771
+
772
+ /** Tracking is allowed again: run the identify that was kept (D-b). */
773
+ private releaseDeferredIdentify(): void {
774
+ const userId = this.withdrawalUserId;
775
+ if (!userId) return;
776
+ this.forgetDeferredIdentify();
777
+ this.identify(userId);
778
+ }
779
+
780
+ private forgetDeferredIdentify(): void {
781
+ this.withdrawalUserId = null;
782
+ this.storage.remove(WITHDRAWAL_USER_KEY);
783
+ }
784
+
785
+ /**
786
+ * Coalesced: forget the retired tokens of users with nothing left queued
787
+ * (D-a). `delayMs`: a token kept only by its grace period is looked at again.
788
+ */
789
+ private scheduleRetiredTokenPrune(delayMs: number = 0): void {
790
+ if (this.pruneTimer !== null || this.disabled) return;
791
+ this.pruneTimer = setTimeout(() => {
792
+ this.pruneTimer = null;
793
+ this.pruneRetiredTokens();
794
+ }, delayMs);
795
+ }
796
+
797
+ private pruneRetiredTokens(): void {
798
+ try {
799
+ if (!this.network.retiredSdkAuthUsers().length) return;
800
+ const users = new Set<string>();
801
+ // The signed-in and the held (D-b) user's kept token is never "nothing
802
+ // left": they are the ones who may still need it (same on iOS and Android).
803
+ const current = this.network.getCurrentUserId();
804
+ if (current) users.add(current);
805
+ if (this.withdrawalUserId) users.add(this.withdrawalUserId);
806
+ for (const e of this.queue.getQueuedEvents()) if (e.userId) users.add(e.userId);
807
+ for (const i of this.queue.getPendingIdentifies()) users.add(i.userId);
808
+ for (const r of this.outbox.records()) {
809
+ const userId = (r.payload as any).userId;
810
+ if (typeof userId === 'string' && userId) users.add(userId);
811
+ }
812
+ for (const e of this.identity.pending.serialize()) if (e.owner?.userId) users.add(e.owner.userId);
813
+ for (const u of this._push.pendingUnregisterUsers()) users.add(u);
814
+ // ...and what EVERY tab persisted (rule 62): this tab's memory is not
815
+ // the whole picture - another tab may hold the logged-out user's
816
+ // unsubscribe, and once that tab reloads the stored token is all it has.
817
+ for (const key of PERSISTED_ITEM_KEYS) collectUserIds(this.storage.get<unknown>(key), users);
818
+ const wait = this.network.forgetRetiredSdkAuthTokensExcept(users);
819
+ if (wait !== null) this.scheduleRetiredTokenPrune(wait + 50);
820
+ } catch (error) {
821
+ this.logger.warn('Could not prune stored SDK authentication tokens:', error);
822
+ }
823
+ }
824
+
825
+ /**
826
+ * A `storage` event: another tab of this site changed what this one holds
827
+ * in memory (contract item 5 - "enforced on every write path" covers every
828
+ * tab). Opt-out and consent are re-read; a changed identity (a reset() or
829
+ * identify() over there) is adopted.
830
+ */
831
+ private handleStorageEvent(event: any): void {
832
+ try {
833
+ if (!this.isInitialized || this.disabled || !event) return;
834
+ const key: string | null = event.key ?? null;
835
+ const watched = (name: string) => key === null || key === this.storage.storageKeyFor(name);
836
+
837
+ if (key === null) {
838
+ // localStorage.clear() by another tab or script (common at logout).
839
+ // Nothing stored any more is not a decision: an opt-out or a denial
840
+ // held here is never lifted without an explicit grant (rule 62) -
841
+ // and it is stored again, so the next page view keeps it too. With
842
+ // the time it was DECIDED: an explicit grant or opt-in the clearing
843
+ // tab makes after the clear is newer, and wins (rule 77).
844
+ this.repersistPrivacyDecisions();
845
+ // This tab's waiting writes (withdrawals among them) are stored
846
+ // again too, so they survive this tab closing.
847
+ this.outbox.persistNow();
848
+ } else if (
849
+ watched(OPT_OUT_KEY) ||
850
+ watched(OPT_OUT_AT_KEY) ||
851
+ watched(OPT_IN_SIGNAL_KEY) ||
852
+ watched(CONSENT_KEY) ||
853
+ watched(CONSENT_DENIED_AT_KEY) ||
854
+ watched(CONSENT_GRANT_SIGNAL_KEY)
855
+ ) {
856
+ const optedOut = this.storedOptOut();
857
+ const denied = this.storedDenial();
858
+ if (optedOut) this.optedOutAt = this.storedStamp(OPT_OUT_AT_KEY);
859
+ if (denied) this.consentDeniedAt = this.storedStamp(CONSENT_DENIED_AT_KEY);
860
+ this.noteDecisionStamps();
861
+ let changed = false;
862
+ if (optedOut !== this.userOptedOut) {
863
+ this.userOptedOut = optedOut;
864
+ changed = true;
865
+ }
866
+ if (denied && this.trackingConsent !== 'denied') {
867
+ this.trackingConsent = 'denied';
868
+ changed = true;
869
+ } else if (!denied && this.trackingConsent === 'denied') {
870
+ // The other tab lifted the denial (only 'granted' does).
871
+ this.trackingConsent = 'granted';
872
+ changed = true;
873
+ } else if (
874
+ !denied &&
875
+ this.trackingConsent === 'pending' &&
876
+ watched(CONSENT_GRANT_SIGNAL_KEY) &&
877
+ this.storage.get<number>(CONSENT_GRANT_SIGNAL_KEY) !== null
878
+ ) {
879
+ // The visitor answered the banner in another tab: 'pending' is not
880
+ // stored, so this tab hears the grant through its signal.
881
+ this.trackingConsent = 'granted';
882
+ changed = true;
883
+ }
884
+ // The marker was queued by the tab that made the change.
885
+ if (changed) this.applyPrivacyState();
886
+ }
887
+
888
+ if (key !== null && watched(WITHDRAWAL_USER_KEY)) {
889
+ // Another tab kept (or forgot) a user for withdrawals (D-b, rule 77):
890
+ // the next unsubscribe made HERE goes to that person too, with that
891
+ // person's token - not to the user this tab still had in memory.
892
+ const stored = this.storage.get<string>(WITHDRAWAL_USER_KEY);
893
+ const held = typeof stored === 'string' && stored && stored !== this.identity.getUserId() ? stored : null;
894
+ if (held !== this.withdrawalUserId) {
895
+ this.network.switchUser(held || this.identity.getUserId());
896
+ this.withdrawalUserId = held;
897
+ }
898
+ }
899
+
900
+ if (key !== null && watched(SESSION_ID_KEY) && this.awaitingSessionOf) {
901
+ // The session the other tab started at its logout, stored after its
902
+ // new anonymous id (scan 7 N4).
903
+ if (this.awaitingSessionOf === this.identity.getAnonymousId() && this.storage.get<string>(SESSION_ID_KEY)) {
904
+ this.session.followStoredSession();
905
+ }
906
+ this.awaitingSessionOf = null;
907
+ }
908
+
909
+ if (watched(ANONYMOUS_ID_STORAGE_KEY) || watched(USER_ID_STORAGE_KEY)) {
910
+ const anonymousId = this.storage.get<string>(ANONYMOUS_ID_STORAGE_KEY);
911
+ const userId = this.storage.get<string>(USER_ID_STORAGE_KEY) || null;
912
+ if (!anonymousId) return;
913
+ if (anonymousId === this.identity.getAnonymousId() && userId === this.identity.getUserId()) return;
914
+ this.logger.info('Another tab changed the visitor identity; following it');
915
+ const loggedOut = anonymousId !== this.identity.getAnonymousId();
916
+ // The same bookkeeping reset() / identify() does here (rule 62):
917
+ // in-app first (dismissed as the outgoing user, with their token)...
918
+ if (this.config.inApp.enabled) this.inapp.clearAll();
919
+ else this.inapp.invalidate();
920
+ // ...writes made while this anonymous id was anonymous stay with the
921
+ // user it became, not the next visitor...
922
+ this.pinAnonymousWrites();
923
+ this.network.switchUser(userId);
924
+ this.identity.adopt(anonymousId, userId);
925
+ // Whoever was kept for withdrawals here belongs to the old identity.
926
+ this.withdrawalUserId = null;
927
+ if (loggedOut) {
928
+ // ...and the next visitor's events belong to the session the other
929
+ // tab started for them, not the outgoing visitor's. reset() over
930
+ // there stores the new session AFTER the new anonymous id, so it
931
+ // is followed again when it arrives.
932
+ this.utm.reset();
933
+ this.session.followStoredSession();
934
+ this.awaitingSessionOf = anonymousId;
935
+ }
936
+ this.resyncInApp(true);
937
+ }
938
+ } catch (error) {
939
+ this.logger.error('Could not follow a change made in another tab:', error);
940
+ }
941
+ }
942
+
943
+ /**
944
+ * Store this tab's opt-out and denial again (after another tab cleared
945
+ * storage), each with the time it was decided - not now.
946
+ */
947
+ private repersistPrivacyDecisions(): void {
948
+ if (this.userOptedOut && this.storage.get<boolean>(OPT_OUT_KEY) !== true) this.writeOptOut(this.optedOutAt);
949
+ if (this.trackingConsent === 'denied' && this.storage.get<string>(CONSENT_KEY) !== 'denied') {
950
+ this.writeDenial(this.consentDeniedAt);
951
+ }
952
+ if (this.withdrawalUserId && this.storage.get<string>(WITHDRAWAL_USER_KEY) !== this.withdrawalUserId) {
953
+ this.storage.set(WITHDRAWAL_USER_KEY, this.withdrawalUserId);
954
+ }
955
+ }
956
+
957
+ /** A stored decision time (0 when absent or unreadable). */
958
+ private storedStamp(key: string): number {
959
+ const value = this.storage.get<number>(key);
960
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
961
+ }
962
+
963
+ /** Remember the decision times stored by any tab, so the next one is later. */
964
+ private noteDecisionStamps(): void {
965
+ for (const key of [OPT_OUT_AT_KEY, OPT_IN_SIGNAL_KEY, CONSENT_DENIED_AT_KEY, CONSENT_GRANT_SIGNAL_KEY]) {
966
+ this.lastDecisionStamp = Math.max(this.lastDecisionStamp, this.storedStamp(key));
967
+ }
968
+ }
969
+
970
+ /** A time for an explicit decision made now: later than any decision seen. */
971
+ private nextDecisionStamp(): number {
972
+ this.noteDecisionStamps();
973
+ this.lastDecisionStamp = Math.max(
974
+ Date.now(),
975
+ this.lastDecisionStamp + 1,
976
+ this.optedOutAt + 1,
977
+ this.consentDeniedAt + 1,
978
+ );
979
+ return this.lastDecisionStamp;
980
+ }
981
+
982
+ /** The stored opt-out, unless a newer explicit opt-in is stored (rule 77). */
983
+ private storedOptOut(): boolean {
984
+ if (this.storage.get<boolean>(OPT_OUT_KEY) !== true) return false;
985
+ return !(this.storedStamp(OPT_IN_SIGNAL_KEY) > this.storedStamp(OPT_OUT_AT_KEY));
986
+ }
987
+
988
+ /** The stored denial, unless a newer explicit grant is stored (rule 77). */
989
+ private storedDenial(): boolean {
990
+ if (this.storage.get<string>(CONSENT_KEY) !== 'denied') return false;
991
+ return !(this.storedStamp(CONSENT_GRANT_SIGNAL_KEY) > this.storedStamp(CONSENT_DENIED_AT_KEY));
992
+ }
993
+
994
+ /** Store an opt-out decided at `at` (now when omitted). */
995
+ private writeOptOut(at?: number): void {
996
+ const stamp = at === undefined ? this.nextDecisionStamp() : at;
997
+ this.optedOutAt = stamp;
998
+ this.storage.set(OPT_OUT_KEY, true);
999
+ this.storage.set(OPT_OUT_AT_KEY, stamp);
1000
+ }
1001
+
1002
+ /** An explicit opt-in: the opt-out goes, and other tabs hear it (even after a clear). */
1003
+ private writeOptIn(): void {
1004
+ // Stamped BEFORE the opt-out's own time is forgotten: later than it.
1005
+ const stamp = this.nextDecisionStamp();
1006
+ this.optedOutAt = 0;
1007
+ this.storage.remove(OPT_OUT_KEY);
1008
+ this.storage.remove(OPT_OUT_AT_KEY);
1009
+ this.storage.set(OPT_IN_SIGNAL_KEY, stamp);
1010
+ }
1011
+
1012
+ /** Store a denial decided at `at` (now when omitted). */
1013
+ private writeDenial(at?: number): void {
1014
+ const stamp = at === undefined ? this.nextDecisionStamp() : at;
1015
+ this.consentDeniedAt = stamp;
1016
+ this.storage.set(CONSENT_KEY, 'denied');
1017
+ this.storage.set(CONSENT_DENIED_AT_KEY, stamp);
1018
+ }
1019
+
1020
+ /**
1021
+ * An explicit grant: the denial goes, and other tabs hear it (the signal:
1022
+ * a 'pending' tab stores nothing to hear a change of).
1023
+ */
1024
+ private writeGrant(): void {
1025
+ // Stamped BEFORE the denial's own time is forgotten: later than it.
1026
+ const stamp = this.nextDecisionStamp();
1027
+ this.consentDeniedAt = 0;
1028
+ this.storage.remove(CONSENT_KEY);
1029
+ this.storage.remove(CONSENT_DENIED_AT_KEY);
1030
+ this.storage.set(CONSENT_GRANT_SIGNAL_KEY, stamp);
1031
+ }
1032
+
1033
+ /**
1034
+ * The ids a write is SENT with (contract item 38). Its own user id when it
1035
+ * had one; otherwise the current user id - once the server has merged the
1036
+ * captured anonymous id into that user (no identify of it is still
1037
+ * waiting). Under SDK authentication an anonymous write for an anonymous id
1038
+ * that was already identified is refused (403 SDK_ANONYMOUS_ID_IDENTIFIED),
1039
+ * so a newsletter opt-in made just before sign-up was lost.
1040
+ */
1041
+ private resolveOwner(owner: WriteOwner): WriteOwner {
1042
+ if (owner.userId) return owner;
1043
+ // The user kept for withdrawals (D-b) counts (rule 63): an unsubscribe
1044
+ // queued as anonymous just before identify('B') while denied reaches B.
1045
+ const userId = this.effectiveUserId();
1046
+ if (!userId || owner.anonymousId !== this.identity.getAnonymousId()) return owner;
1047
+ const identifyWaiting = this.queue.getPendingIdentifies().some((i) => i.anonymousId === owner.anonymousId);
1048
+ return identifyWaiting ? owner : { anonymousId: owner.anonymousId, userId };
1049
+ }
1050
+
1051
+ /**
1052
+ * Before the identity changes (reset, wipe, identify of another user):
1053
+ * writes made while the current anonymous id was anonymous belong to the
1054
+ * user it became, and must keep going to that user - not to the next one,
1055
+ * and not as an anonymous write of an id that was identified.
1056
+ */
1057
+ private pinAnonymousWrites(): void {
1058
+ const userId = this.identity.getUserId();
1059
+ if (!userId) return;
1060
+ const anonymousId = this.identity.getAnonymousId();
1061
+ this.identity.pending.pinOwner(anonymousId, userId);
1062
+ this.persistAttributeWrites();
1063
+ this.outbox.pinOwner(anonymousId, userId);
1064
+ }
1065
+
1066
+ /** The request one write is sent as. */
1067
+ private writeRequest(record: WriteRecord, owner: WriteOwner): { url: string; body: any } {
1068
+ const endpoint = this.config.apiEndpoint;
1069
+ const ids = { ...(owner.userId ? { userId: owner.userId } : {}), anonymousId: owner.anonymousId };
1070
+ switch (record.kind) {
1071
+ case 'alias':
1072
+ return { url: `${endpoint}/v1/alias/add`, body: { ...record.payload, ...ids } };
1073
+ case 'subscription':
1074
+ return { url: `${endpoint}/v1/subscriptions/channel`, body: { ...record.payload, ...ids } };
1075
+ case 'group':
1076
+ return { url: `${endpoint}/v1/subscriptions/group`, body: { ...record.payload, ...ids } };
1077
+ case 'marker':
1078
+ default:
1079
+ return {
1080
+ url: `${endpoint}/v1/attributes`,
1081
+ body: {
1082
+ ...(owner.userId ? { userId: owner.userId } : { anonymousId: owner.anonymousId }),
1083
+ ...this.markerOps(record as Extract<WriteRecord, { kind: 'marker' }>),
1084
+ },
1085
+ };
1086
+ }
1087
+ }
1088
+
1089
+ /** The opt-out marker's attribute write: the switch's value, `true` or `false`. */
1090
+ private markerOps(record: Extract<WriteRecord, { kind: 'marker' }>): AttributeOps {
1091
+ const desired = record.payload.value;
1092
+ return { attributes: { [TRACKING_OPTED_OUT_ATTRIBUTE]: desired } };
1093
+ }
1094
+
1095
+ private sendWrite(record: WriteRecord, owner: WriteOwner): Promise<NetworkResponse> {
1096
+ const endpoint = this.config.apiEndpoint;
1097
+ const ids = { ...(owner.userId ? { userId: owner.userId } : {}), anonymousId: owner.anonymousId };
1098
+ switch (record.kind) {
1099
+ case 'alias':
1100
+ return this.network.sendAddAlias(endpoint, { ...record.payload, ...ids });
1101
+ case 'subscription':
1102
+ return this.network.sendSubscriptionUpdate(endpoint, { ...record.payload, ...ids });
1103
+ case 'group':
1104
+ return this.network.sendSubscriptionGroupUpdate(endpoint, { ...record.payload, ...ids });
1105
+ case 'marker':
1106
+ default:
1107
+ return this.network.sendSetAttributes(
1108
+ endpoint,
1109
+ this.markerOps(record as Extract<WriteRecord, { kind: 'marker' }>),
1110
+ owner.userId,
1111
+ owner.anonymousId,
1112
+ );
1113
+ }
1114
+ }
1115
+
1116
+ /** Bookkeeping when a write leaves the outbox. */
1117
+ private onWriteSettled(entry: OutboxEntry, outcome: 'done' | 'drop'): void {
1118
+ if (entry.record.kind !== 'marker') return;
1119
+ const value = entry.record.payload.value;
1120
+ if (outcome === 'done') {
1121
+ if (value) this.storage.set(REPORTED_OPT_OUT_KEY, true);
1122
+ else this.storage.remove(REPORTED_OPT_OUT_KEY);
1123
+ this.storage.remove(OPT_OUT_MARKER_GIVEN_UP_KEY);
1124
+ } else {
1125
+ this.storage.set(OPT_OUT_MARKER_GIVEN_UP_KEY, value);
1126
+ }
1127
+ // The switch may have flipped again while this was on the wire.
1128
+ this.syncOptOutMarker();
1129
+ }
1130
+
1131
+ /**
1132
+ * Tell the push service worker whether it may report deliveries and clicks.
1133
+ * It may not whenever the page may not send: an opt-out, but equally
1134
+ * consent denied or pending and Do Not Track. Only the explicit opt-out
1135
+ * used to be written, so a visitor who denied consent - or whose browser
1136
+ * sends Do Not Track - still had every push delivery and click reported.
1137
+ */
1138
+ private syncWorkerReporting(): void {
1139
+ // An inert instance must not touch the worker's flag: a valid instance on
1140
+ // the same origin owns it.
1141
+ if (this.disabled) return;
1142
+ this._push.setTrackingOptedOut(this.isOptedOut || this.consentPending).catch(() => undefined);
1143
+ }
1144
+
1145
+ // ---------------------------------------------------------------------------
1146
+ // Privacy state
1147
+ // ---------------------------------------------------------------------------
1148
+
1149
+ /**
1150
+ * The consent in force at init (contract item 24):
1151
+ * - explicit `granted` lifts a stored denial;
1152
+ * - explicit `denied` is stored;
1153
+ * - explicit `pending` holds for this page view, never over a stored denial;
1154
+ * - unset: a stored denial, else granted (a site without a banner).
1155
+ */
1156
+ private initialConsent(configured: TrackingConsent | undefined): TrackingConsent {
1157
+ const storedDenied = this.storedDenial();
1158
+ if (configured === 'granted') {
1159
+ this.storage.remove(CONSENT_KEY);
1160
+ this.storage.remove(CONSENT_DENIED_AT_KEY);
1161
+ return 'granted';
1162
+ }
1163
+ if (configured === 'denied') {
1164
+ this.writeDenial();
1165
+ return 'denied';
1166
+ }
1167
+ if (storedDenied) {
1168
+ this.consentDeniedAt = this.storedStamp(CONSENT_DENIED_AT_KEY);
1169
+ return 'denied';
1170
+ }
1171
+ return configured === 'pending' ? 'pending' : 'granted';
1172
+ }
1173
+
1174
+ /** Record a consent answer: the same rules for init, pre-init calls and updateConsent(). */
1175
+ private storeConsent(consent: TrackingConsent): TrackingConsent {
1176
+ if (consent === 'granted') {
1177
+ this.writeGrant();
1178
+ return 'granted';
1179
+ }
1180
+ if (consent === 'denied') {
1181
+ this.writeDenial();
1182
+ return 'denied';
1183
+ }
1184
+ // `pending` is for this page view only and never lifts a stored denial.
1185
+ if (this.storedDenial()) {
1186
+ this.logger.info("updateConsent('pending') ignored: consent was denied, and only 'granted' lifts that");
1187
+ return 'denied';
1188
+ }
1189
+ return 'pending';
1190
+ }
1191
+
1192
+ /** One decision from before init, applied before any side effect. */
1193
+ private applyPreInitDecision(decision: PreInitDecision): void {
1194
+ try {
1195
+ switch (decision.method) {
1196
+ case 'optOut':
1197
+ this.userOptedOut = true;
1198
+ this.writeOptOut();
1199
+ break;
1200
+ case 'optIn':
1201
+ this.userOptedOut = false;
1202
+ this.writeOptIn();
1203
+ break;
1204
+ case 'updateConsent':
1205
+ if (decision.consent === 'granted' || decision.consent === 'denied' || decision.consent === 'pending') {
1206
+ this.trackingConsent = this.storeConsent(decision.consent);
1207
+ }
1208
+ break;
1209
+ case 'wipeData':
1210
+ // Push first, while the outgoing ids are still current (it
1211
+ // captures them); it is told to the server once sending starts.
1212
+ this._push.forget().catch(() => undefined);
1213
+ this.wipeLocalState();
1214
+ // The wipe came first; the init config's user id - and its SDK
1215
+ // authentication token, which the wipe retired with the erased
1216
+ // identity - apply after it. Without the token that user's
1217
+ // requests went out unauthenticated until the host's auth
1218
+ // handler minted a new one.
1219
+ if (this.config.userId) {
1220
+ this.identity.identify(this.config.userId);
1221
+ this.network.setCurrentUserId(this.config.userId);
1222
+ }
1223
+ if (this.config.enableSdkAuthentication && this.config.sdkAuthenticationToken) {
1224
+ this.network.setSdkAuthenticationToken(this.config.sdkAuthenticationToken);
1225
+ }
1226
+ break;
1227
+ }
1228
+ } catch (error) {
1229
+ this.logger.error(`Applying ${decision.method} from before init failed:`, error);
1230
+ }
1231
+ }
1232
+
1233
+ /**
1234
+ * The current consent: `granted`, `denied` or `pending`. Independent of the
1235
+ * visitor's own opt-out (see isUserOptedOut()).
1236
+ */
1237
+ getTrackingConsent(): TrackingConsent {
1238
+ return this.trackingConsent;
1239
+ }
1240
+
1241
+ /**
1242
+ * Whether the visitor opted out (optOut(), or `optOut: true` at init).
1243
+ * Reflects ONLY that switch - not consent, not Do Not Track.
1244
+ */
1245
+ isUserOptedOut(): boolean {
1246
+ return this.userOptedOut;
1247
+ }
1248
+
1249
+ /**
1250
+ * Keep the profile's `tracking_opted_out` in step with the opt-out switch
1251
+ * (contract items 33 and 36): queued when it CHANGES - including a decision
1252
+ * made in the init config or before init - and not at all while unchanged.
1253
+ *
1254
+ * `true` is a WITHDRAWAL: queued in the persisted outbox and delivered even
1255
+ * while opted out (that is the point), with consent denied or pending, and
1256
+ * after wipeData() - with the ids of the person who opted out. `false`
1257
+ * (opting back in) is an ordinary write and waits for tracking to be
1258
+ * allowed. Retried by the one rule (contract item 40); a value refused for
1259
+ * good is not re-sent on every page view (a fresh optOut() or optIn() call
1260
+ * clears that and tries again).
1261
+ */
1262
+ private syncOptOutMarker(): void {
1263
+ if (this.disabled || !this.isInitialized) return;
1264
+ const desired = this.userOptedOut;
1265
+ const reported = this.storage.get<boolean>(REPORTED_OPT_OUT_KEY) === true;
1266
+ const anonymousId = this.identity.getAnonymousId();
1267
+ const queuedLast = this.outbox.findLast(
1268
+ (e) => e.record.kind === 'marker' && e.record.payload.anonymousId === anonymousId,
1269
+ );
1270
+ if (queuedLast) {
1271
+ if (queuedLast.record.kind === 'marker' && queuedLast.record.payload.value === desired) return;
1272
+ if (desired === reported && !this.outbox.isInFlight(queuedLast)) {
1273
+ // Flipped back before it was sent: nothing to tell the server.
1274
+ this.outbox.cancel((e) => e.record.kind === 'marker' && e.record.payload.anonymousId === anonymousId);
1275
+ return;
1276
+ }
1277
+ } else {
1278
+ if (desired === reported) return;
1279
+ if (this.storage.get<boolean>(OPT_OUT_MARKER_GIVEN_UP_KEY) === desired) return;
1280
+ }
1281
+ this.outbox.enqueue({ kind: 'marker', payload: { value: desired, ...this.currentOwner() } });
1282
+ }
1283
+
1284
+ private recomputePrivacy(): void {
1285
+ this.isOptedOut = this.disabled || this.userOptedOut || this.dntEnabled || this.trackingConsent === 'denied';
1286
+ this.consentPending = !this.isOptedOut && this.trackingConsent === 'pending';
1287
+ }
1288
+
1289
+ /** May anything leave the device right now? */
1290
+ private canSend(): boolean {
1291
+ return (
1292
+ this.isInitialized &&
1293
+ !this.isOptedOut &&
1294
+ !this.consentPending &&
1295
+ !this.network.isSuspended()
1296
+ );
1297
+ }
1298
+
1299
+ /**
1300
+ * Bring every component in line with the current opt-out / consent state.
1301
+ * The one place that starts and stops collection, so opt-out, consent and
1302
+ * opt-in cannot each stop a different subset of it.
1303
+ */
1304
+ private applyPrivacyState(): void {
1305
+ const wasOptedOut = this.isOptedOut;
1306
+ const wasPending = this.consentPending;
1307
+ this.recomputePrivacy();
1308
+ this.syncWorkerReporting();
1309
+ if (wasOptedOut !== this.isOptedOut || wasPending !== this.consentPending) {
1310
+ // In-app answers requested under the previous state are stale.
1311
+ this.inapp.invalidate();
1312
+ }
1313
+
1314
+ if (this.isOptedOut) {
1315
+ // Collect nothing - and keep nothing that was waiting to be sent. A
1316
+ // Session Start that never left goes too, so a later grant sends one -
1317
+ // also a grant passed at init on a later page view: the marker says
1318
+ // "none queued for any session yet" (''), never absent, which init
1319
+ // reads as "sent" (rule 85, the sibling of the wipe fix, rule 75).
1320
+ //
1321
+ // Only one still WAITING in the queue (rule 90). One already on the
1322
+ // wire is settled by the server's answer: stored = sent (the marker
1323
+ // keeps this session), anything else = owed ('').
1324
+ const sessionId = this.session.getSessionId();
1325
+ const isStart = (e: QueuedEvent) => e.type === 'Session Start' && e.sessionId === sessionId;
1326
+ if (this.queue.getWaitingEvents().some(isStart)) {
1327
+ this.storage.set(SESSION_START_SENT_KEY, '');
1328
+ } else if (sessionId && this.queue.getInflightEvents().some(isStart)) {
1329
+ this.inflightSessionStartAtClear = sessionId;
1330
+ }
1331
+ this.queue.stop();
1332
+ this.queue.clear();
1333
+ this.queue.setPaused(false);
1334
+ this.clearPendingAttributeWrites();
1335
+ // Every profile write stops - except withdrawals (an unsubscribe, the
1336
+ // opt-out marker), which are always delivered (contract item 36).
1337
+ this.outbox.discardNonWithdrawals();
1338
+ this.outbox.kick();
1339
+ if (this.config.inApp.enabled) this.inapp.stop();
1340
+ return;
1341
+ }
1342
+
1343
+ // Collection is allowed again (granted or pending). An identify() made
1344
+ // while it was off now runs normally (D-b) - FIRST, so what follows is
1345
+ // about that user, not the one signed in before (rule 63)...
1346
+ if (wasOptedOut) this.releaseDeferredIdentify();
1347
+ // ...then a session that began while it was off gets its Session Start
1348
+ // (scan 3, web #10).
1349
+ if (wasOptedOut) this.trackSessionStartIfMissing();
1350
+
1351
+ if (this.consentPending) {
1352
+ // Collect, hold, send nothing.
1353
+ this.queue.stop();
1354
+ this.queue.setPaused(true);
1355
+ if (this.config.inApp.enabled) this.inapp.stop();
1356
+ return;
1357
+ }
1358
+
1359
+ // Granted: release what was held, in order - the queue (Session Start
1360
+ // first, then events, with any identify), then attributes and other writes.
1361
+ this.queue.setPaused(false);
1362
+ if (!this.queue.isStarted()) this.queue.start();
1363
+ this.queue.flush().catch((error) => this.logger.error('Flush failed:', error));
1364
+
1365
+ this.releaseHeldWrites();
1366
+
1367
+ if (this.config.inApp.enabled) {
1368
+ this.inapp.initialize().catch((error) => {
1369
+ this.logger.error('Failed to initialize in-app messaging:', error);
1370
+ });
1371
+ }
1372
+ this._push.syncExistingSubscription().catch(() => undefined);
1373
+ }
1374
+
1375
+ /** Send the attribute and profile writes held while consent was pending. */
1376
+ private releaseHeldWrites(): void {
1377
+ if (!this.identity.pending.isEmpty()) {
1378
+ this.scheduleAttributeFlush(0);
1379
+ }
1380
+ this.outbox.kick();
1381
+ }
1382
+
1383
+ /**
1384
+ * Queue a profile write (alias, subscription, group) in the outbox. A
1385
+ * withdrawal (unsubscribe) is always accepted; anything else is refused
1386
+ * while opted out or consent is denied, and held while it is pending.
1387
+ */
1388
+ private runWrite(label: string, record: WriteRecord): void {
1389
+ if (!this.isInitialized || this.disabled) {
1390
+ this.logger.error(`${label}: SDK not initialized`);
1391
+ return;
1392
+ }
1393
+ if ((this.isOptedOut || this.dataGate.size > 0) && !isWithdrawal(record)) {
1394
+ this.logger.debug(`${label} not sent: tracking is off`);
1395
+ return;
1396
+ }
1397
+ // A withdrawal made after identify() while tracking was off goes to the
1398
+ // user that identify() named (D-b), not to the anonymous profile.
1399
+ if (this.withdrawalUserId && isWithdrawal(record) && record.payload.anonymousId === this.identity.getAnonymousId()) {
1400
+ record = { ...record, payload: { ...record.payload, userId: this.withdrawalUserId } } as WriteRecord;
1401
+ }
1402
+ this.outbox.enqueue(record);
1403
+ }
1404
+
1405
+ /**
1406
+ * Bring back the attribute writes an earlier page view did not get
1407
+ * acknowledged. Discarded if the visitor opted out or consent was denied.
1408
+ */
1409
+ private restoreAttributeWrites(): void {
1410
+ const attrs = this.storage.get<{ entries?: unknown; ops?: AttributeOps | null; increment?: Record<string, number> }>(ATTRIBUTE_WRITES_KEY);
1411
+ if (this.isOptedOut) {
1412
+ this.storage.remove(ATTRIBUTE_WRITES_KEY);
1413
+ this.outbox.discardNonWithdrawals();
1414
+ return;
1415
+ }
1416
+ if (attrs && typeof attrs === 'object') {
1417
+ if (Array.isArray(attrs.entries)) {
1418
+ this.identity.pending.restore(attrs.entries);
1419
+ } else {
1420
+ // Stored by an earlier version: { ops, increment }.
1421
+ if (attrs.ops && typeof attrs.ops === 'object') this.identity.pending.mark(attrs.ops);
1422
+ if (attrs.increment && typeof attrs.increment === 'object') this.identity.pending.mark({ increment: attrs.increment });
1423
+ }
1424
+ }
1425
+ }
1426
+
1427
+ /**
1428
+ * The attribute writes not acknowledged yet survive a navigation (contract
1429
+ * item 37). Kept only until the server acknowledges them.
1430
+ */
1431
+ private persistAttributeWrites(): void {
1432
+ if (this.identity.pending.isEmpty()) {
1433
+ this.storage.remove(ATTRIBUTE_WRITES_KEY);
1434
+ return;
1435
+ }
1436
+ this.storage.set(ATTRIBUTE_WRITES_KEY, { entries: this.identity.pending.serialize() });
1437
+ }
1438
+
1439
+ /**
1440
+ * Before a profile write: get the queued events and any identify to the
1441
+ * server first, so the contact the write addresses exists and is merged.
1442
+ */
1443
+ private async flushQueueForWrite(): Promise<void> {
1444
+ if (!this.canSend()) return;
1445
+ if (this.queue.getPendingIdentify() || this.queue.getQueueSize() > 0) {
1446
+ await this.queue.flush().catch(() => undefined);
1447
+ }
1448
+ }
1449
+
1450
+ /**
1451
+ * Track session start event (internal)
1452
+ * Includes landing page data so backend can create initial Page View
1453
+ */
1454
+ private trackSessionStart(): void {
1455
+ this.logger.debug('Tracking session start');
1456
+
1457
+ // Track Session Start event
1458
+ // Note: sessionCount is managed server-side by counting Session Start events
1459
+ // This ensures accurate cross-device session counting
1460
+ const properties: Record<string, any> = {};
1461
+
1462
+ if (this.config.trackDeviceProperties) {
1463
+ Object.assign(properties, getDeviceProperties());
1464
+ properties.$device_id = this.deviceId;
1465
+ }
1466
+
1467
+ // Include page data for backend to create initial Page View
1468
+ if (typeof window !== 'undefined' && window.location) {
1469
+ properties.landing_page = window.location.href;
1470
+ properties.landing_path = window.location.pathname;
1471
+ properties.landing_search = window.location.search;
1472
+ properties.landing_title = typeof document !== 'undefined' ? document.title : '';
1473
+ properties.landing_referrer = typeof document !== 'undefined' ? (document.referrer || null) : null;
1474
+ }
1475
+
1476
+ this.track('Session Start', properties);
1477
+ this.storage.set(SESSION_START_SENT_KEY, this.session.getSessionId());
1478
+ }
1479
+
1480
+ /** Queue this session's Session Start if it never was (tracking was off when it began). */
1481
+ private trackSessionStartIfMissing(): void {
1482
+ if (!this.config.trackSessionStart || this.isOptedOut) return;
1483
+ // No session (after wipeData()): starting one announces it, with its
1484
+ // Session Start, through onNewSession.
1485
+ this.session.ensureSession();
1486
+ if (this.storage.get<string>(SESSION_START_SENT_KEY) === this.session.getSessionId()) return;
1487
+ this.trackSessionStart();
1488
+ }
1489
+
1490
+ private getOrCreateDeviceId(): string {
1491
+ const key = 'device_id';
1492
+ const existing = this.storage.get<string>(key);
1493
+ if (existing) {
1494
+ return existing;
1495
+ }
1496
+
1497
+ const deviceId = generateUUID();
1498
+ this.storage.set(key, deviceId);
1499
+ return deviceId;
1500
+ }
1501
+
1502
+ private trackPageView(): void {
1503
+ if (typeof window === 'undefined' || !window.location) {
1504
+ return;
1505
+ }
1506
+
1507
+ const url = window.location.href;
1508
+ if (this.lastTrackedUrl === url) {
1509
+ return;
1510
+ }
1511
+
1512
+ const properties: Record<string, any> = {
1513
+ url,
1514
+ path: window.location.pathname,
1515
+ search: window.location.search,
1516
+ title: typeof document !== 'undefined' ? document.title : '',
1517
+ };
1518
+
1519
+ if (this.config.trackPageProperties && typeof document !== 'undefined') {
1520
+ properties.referrer = document.referrer || null;
1521
+ properties.landing_page = this.utm.getSessionLandingPage() || null;
1522
+ }
1523
+
1524
+ this.lastTrackedUrl = url;
1525
+ this.track('Page Viewed', properties);
1526
+ }
1527
+
1528
+ /**
1529
+ * Track an event
1530
+ */
1531
+ track(eventName: string, properties?: Record<string, any>): void {
1532
+ if (!this.isInitialized) {
1533
+ this.logger.error('SDK not initialized');
1534
+ return;
1535
+ }
1536
+ if (this.isDroppingDataCalls('track')) return;
1537
+
1538
+ if (this.isOptedOut) {
1539
+ this.logger.debug('Tracking disabled, event not tracked:', eventName);
1540
+ return;
1541
+ }
1542
+
1543
+ // Refused here, not by the server: an empty or over-long name is a 400,
1544
+ // and a 400 used to re-queue the whole batch at the front forever.
1545
+ // Exactly the server's rule (isValidSdkEventName): 1-255 characters. A
1546
+ // whitespace-only name is accepted there, so it is not refused here.
1547
+ if (typeof eventName !== 'string' || eventName.length < 1 || eventName.length > MAX_NAME_LENGTH) {
1548
+ this.logger.error(`track() needs an event name of 1-${MAX_NAME_LENGTH} characters; event dropped`);
1549
+ return;
1550
+ }
1551
+
1552
+ try {
1553
+ // An idle session ends HERE too, not only when the tab becomes visible
1554
+ // again: a tab left open and visible for an hour kept one session
1555
+ // forever (scan 3, web #5). The new session's Session Start (from the
1556
+ // session's onNewSession hook) is queued BEFORE this event.
1557
+ if (eventName !== 'Session Start') this.session.refreshIfExpired();
1558
+ // No session at all (after wipeData()): one starts here, with its
1559
+ // Session Start queued first (rule 54 - reads never start one).
1560
+ this.session.ensureSession();
1561
+
1562
+ // Update session activity on every tracked event
1563
+ this.session.updateActivity();
1564
+
1565
+ // Shallow-copy the caller's properties before injecting internal keys so we
1566
+ // never mutate the object the caller passed (they may reuse it).
1567
+ const eventProperties: Record<string, any> =
1568
+ properties && typeof properties === 'object' && !Array.isArray(properties) ? { ...properties } : {};
1569
+
1570
+ // Merge event properties with UTM parameters if enabled
1571
+ if (this.config.captureUTM) {
1572
+ Object.assign(eventProperties, this.utm.getUTMProperties());
1573
+ }
1574
+
1575
+ // Always include device_id in event properties for tracking
1576
+ if (this.deviceId && !eventProperties.$device_id) {
1577
+ eventProperties.$device_id = this.deviceId;
1578
+ }
1579
+
1580
+ const event: QueuedEvent = {
1581
+ eventId: generateUUID(), // Client-generated ID for idempotency
1582
+ type: eventName,
1583
+ properties: eventProperties,
1584
+ // ISO 8601 UTC with milliseconds, the same on every SDK (rule 55).
1585
+ timestamp: new Date().toISOString(),
1586
+ userId: this.identity.getUserId(),
1587
+ anonymousId: this.identity.getAnonymousId(),
1588
+ sessionId: this.session.getSessionId(),
1589
+ };
1590
+
1591
+ // Run beforeTrack hooks (web-only plugin API)
1592
+ let processedEvent: any = event;
1593
+ for (const plugin of this.config.plugins) {
1594
+ if (plugin.beforeTrack) {
1595
+ try {
1596
+ processedEvent = plugin.beforeTrack(processedEvent);
1597
+ } catch (error) {
1598
+ this.logger.error(`Plugin ${plugin.name} beforeTrack threw; event kept unchanged:`, error);
1599
+ }
1600
+ if (processedEvent === null) {
1601
+ this.logger.debug(`Event blocked by plugin: ${plugin.name}`);
1602
+ return;
1603
+ }
1604
+ }
1605
+ }
1606
+
1607
+ // A property whose value JSON cannot carry (NaN, Infinity, a BigInt,
1608
+ // a function, a circular object) is dropped with a warning; the event
1609
+ // goes without it (rule 49).
1610
+ if (processedEvent && processedEvent.properties && typeof processedEvent.properties === 'object') {
1611
+ processedEvent = {
1612
+ ...processedEvent,
1613
+ properties: withoutUnsendableValues(processedEvent.properties, (key, why) =>
1614
+ this.logger.warn(`Event "${eventName}": property "${key}" dropped - ${why}`),
1615
+ ),
1616
+ };
1617
+ }
1618
+
1619
+ // Refused here, not by the server: properties over the API's limits
1620
+ // are refused for good, so sending them only costs a request
1621
+ // (contract item 29).
1622
+ const problem =
1623
+ processedEvent && processedEvent.properties && typeof processedEvent.properties === 'object'
1624
+ ? eventPropertiesProblem(processedEvent.properties)
1625
+ : null;
1626
+ if (problem) {
1627
+ this.logger.error(`Event "${eventName}" dropped: ${problem}`);
1628
+ return;
1629
+ }
1630
+
1631
+ // Add to queue
1632
+ this.queue.enqueue(processedEvent);
1633
+
1634
+ // Show anything waiting on this event - locally, with no round trip.
1635
+ // AFTER enqueue, deliberately: the event is data and must be recorded
1636
+ // whether or not it triggers a message, and a throwing display path must
1637
+ // not lose the event. Triggering is a display decision, not a substitute
1638
+ // for analytics.
1639
+ if (this.canSend()) {
1640
+ try {
1641
+ this.inapp?.onEventTracked(eventName, eventProperties);
1642
+ } catch (error) {
1643
+ this.logger.error('In-app trigger evaluation failed:', error);
1644
+ }
1645
+ }
1646
+
1647
+ // Run afterTrack hooks
1648
+ for (const plugin of this.config.plugins) {
1649
+ if (plugin.afterTrack) {
1650
+ try {
1651
+ plugin.afterTrack(processedEvent);
1652
+ } catch (error) {
1653
+ this.logger.error(`Plugin ${plugin.name} afterTrack threw:`, error);
1654
+ }
1655
+ }
1656
+ }
1657
+
1658
+ // Send immediately if configured
1659
+ if (this.config.sendImmediately && this.canSend()) {
1660
+ this.flush().catch((error) => this.logger.error('Immediate flush failed:', error));
1661
+ }
1662
+ } catch (error) {
1663
+ // Never throw into the host page.
1664
+ this.logger.error('track() failed:', error);
1665
+ }
1666
+ }
1667
+
1668
+ /**
1669
+ * Identify a user.
1670
+ *
1671
+ * Sends queued events AND identify data in a single atomic HTTP request.
1672
+ * The backend processes events first, then merges the anonymous user —
1673
+ * guaranteed ordering eliminates the Session Start race condition.
1674
+ */
1675
+ identify(userId: string): void {
1676
+ if (!this.isInitialized) {
1677
+ this.logger.error('SDK not initialized');
1678
+ return;
1679
+ }
1680
+
1681
+ if (typeof userId !== 'string' || !userId.trim() || userId.length > MAX_NAME_LENGTH) {
1682
+ this.logger.error(`identify() needs a userId of 1-${MAX_NAME_LENGTH} characters`);
1683
+ return;
1684
+ }
1685
+
1686
+ if (this.isOptedOut) {
1687
+ if (!this.disabled) this.deferIdentify(userId);
1688
+ return;
1689
+ }
1690
+ // An explicit identify replaces any user kept for withdrawals (rule 63).
1691
+ if (this.withdrawalUserId) this.forgetDeferredIdentify();
1692
+
1693
+ const previousUserId = this.identity.getUserId();
1694
+ const anonymousId = this.identity.getAnonymousId();
1695
+
1696
+ // Store identify intent — the next flush will include it in the batch request
1697
+ this.queue.setPendingIdentify({
1698
+ userId,
1699
+ anonymousId,
1700
+ attributes: {},
1701
+ });
1702
+
1703
+ // Update local identity immediately so subsequent track() calls use the new userId
1704
+ const switching = !!previousUserId && previousUserId !== userId;
1705
+ if (switching) {
1706
+ this.pinAnonymousWrites();
1707
+ // A's message on screen is A's: dismissed (tracked as A, with A's
1708
+ // token) before anything else changes, like reset() does.
1709
+ if (this.config.inApp.enabled) this.inapp.clearAll();
1710
+ }
1711
+ this.identity.identify(userId);
1712
+ // identify('B') while A was signed in, without reset(), is a reset for
1713
+ // the token too (rule 53): A's token is retired to A's queued items, so
1714
+ // they go out with A's token instead of B's - unless the token already
1715
+ // set is B's own. Also keeps the user id for auth-failure reporting.
1716
+ this.network.switchUser(userId);
1717
+ // In-app answers requested for the previous identity are stale.
1718
+ if (previousUserId !== userId) this.inapp.invalidate();
1719
+
1720
+ if (!this.canSend()) return; // consent pending: sent when granted
1721
+
1722
+ // Force flush — sends { events: [...], identify: {...} } in one request,
1723
+ // then ask for this person's in-app campaigns (an SPA never reloads, so
1724
+ // nothing else would).
1725
+ void this.queue
1726
+ .flush(false, true)
1727
+ .catch((error) => {
1728
+ this.logger.error('Failed to flush with identify:', error);
1729
+ })
1730
+ .then(() => {
1731
+ // After a switch the in-app state was cleared: start it again.
1732
+ if (previousUserId !== userId) this.resyncInApp(switching);
1733
+ });
1734
+ }
1735
+
1736
+ /**
1737
+ * Add a labelled alias (e.g. `addAlias('crm_id', 'C-123')`) to this visitor.
1738
+ *
1739
+ * Works for anonymous visitors as well as identified ones: the request
1740
+ * carries `anonymousId` always and `userId` once identified.
1741
+ */
1742
+ addAlias(aliasLabel: string, aliasName: string): void {
1743
+ if (!this.isInitialized) {
1744
+ this.logger.error('SDK not initialized');
1745
+ return;
1746
+ }
1747
+ if (this.isDroppingDataCalls('addAlias')) return;
1748
+
1749
+ if (typeof aliasLabel !== 'string' || !aliasLabel.trim() || typeof aliasName !== 'string' || !aliasName.trim()) {
1750
+ this.logger.error('addAlias(aliasLabel, aliasName) needs two non-empty strings');
1751
+ return;
1752
+ }
1753
+
1754
+ // Captured now, so a reset() before the request goes out cannot move the
1755
+ // alias onto the next visitor.
1756
+ const userId = this.identity.getUserId();
1757
+ const payload = {
1758
+ anonymousId: this.identity.getAnonymousId(),
1759
+ ...(userId ? { userId } : {}),
1760
+ aliasLabel,
1761
+ aliasName,
1762
+ };
1763
+
1764
+ this.runWrite('addAlias', { kind: 'alias', payload });
1765
+ }
1766
+
1767
+ /**
1768
+ * Set user attributes
1769
+ */
1770
+ setAttributes(attributes: Record<string, any>): void {
1771
+ if (!this.isInitialized) {
1772
+ this.logger.error('SDK not initialized');
1773
+ return;
1774
+ }
1775
+ if (this.isDroppingDataCalls('setAttributes')) return;
1776
+
1777
+ // Validate that attributes is a plain object
1778
+ if (!attributes || typeof attributes !== 'object' || Array.isArray(attributes)) {
1779
+ this.logger.error('setAttributes requires a plain object, e.g. setAttributes({ email: "user@example.com" })');
1780
+ return;
1781
+ }
1782
+
1783
+ // A null (or undefined) value UNSETS the attribute - the same on every
1784
+ // platform (contract addendum 19). Sent as a `$set` of null it stored a
1785
+ // literal null on web while the native SDKs removed the key, so one call
1786
+ // produced two different profiles.
1787
+ const set: Record<string, any> = {};
1788
+ const unset: string[] = [];
1789
+ for (const [key, value] of Object.entries(attributes)) {
1790
+ if (!key) continue;
1791
+ if (value === null || value === undefined) {
1792
+ unset.push(key);
1793
+ continue;
1794
+ }
1795
+ // A value JSON cannot carry would block every later attribute write
1796
+ // (each request carries the oldest write of every key): that key is
1797
+ // dropped with a warning, the others go (rule 49).
1798
+ const problem = valueProblem(value);
1799
+ if (problem) {
1800
+ this.logger.warn(`Attribute "${key}" not set: its value is ${problem}`);
1801
+ continue;
1802
+ }
1803
+ set[key] = value;
1804
+ }
1805
+
1806
+ this.identity.setAttributes(set);
1807
+ for (const key of unset) this.identity.unsetAttribute(key);
1808
+
1809
+ // Send to backend for both identified and anonymous users.
1810
+ // setAttributes → absolute `$set` section; null values → `$unset`.
1811
+ if (!this.isOptedOut && (Object.keys(set).length || unset.length)) {
1812
+ this.sendAttributesAfterPendingIdentify({
1813
+ ...(Object.keys(set).length ? { attributes: set } : {}),
1814
+ ...(unset.length ? { unset } : {}),
1815
+ });
1816
+ }
1817
+ }
1818
+
1819
+ /**
1820
+ * Queue an attribute-operations payload. It is sent after a short debounce,
1821
+ * and only once any pending identify has reached the server - otherwise the
1822
+ * write hits /attributes before the anon→identified merge and lands on the
1823
+ * wrong profile. Applies to every attribute op (set / increment / append /
1824
+ * remove / unset).
1825
+ */
1826
+ private sendAttributesAfterPendingIdentify(ops: AttributeOps): void {
1827
+ // Coalesce a burst into one request. Six setAttributes calls on page load
1828
+ // should be one round trip, not six.
1829
+ //
1830
+ // Every write lands in the ordered log (PendingAttributes) in call order:
1831
+ // two addToArray calls on one key both reach the server, and a later
1832
+ // write to a key never shares a request with - or overtakes - an earlier
1833
+ // one (contract item 31). Increments are summed while they wait.
1834
+ this.identity.pending.mark(ops, this.currentOwner());
1835
+ this.persistAttributeWrites();
1836
+ this.scheduleAttributeFlush(JoryioSDK.ATTRIBUTE_FLUSH_DEBOUNCE_MS);
1837
+ }
1838
+
1839
+ private scheduleAttributeFlush(delayMs: number): void {
1840
+ if (this.attributeFlushTimer !== null) return;
1841
+ this.attributeFlushTimer = setTimeout(() => {
1842
+ this.attributeFlushTimer = null;
1843
+ // Consent pending (or sending suspended): keep everything for when it
1844
+ // may be sent.
1845
+ if (!this.canSend()) return;
1846
+ this.flushAttributeOps();
1847
+ }, delayMs);
1848
+ }
1849
+
1850
+ /** Drop every attribute write not yet sent (opt-out, consent denied, reset). */
1851
+ private clearPendingAttributeWrites(): void {
1852
+ if (this.attributeFlushTimer !== null) {
1853
+ clearTimeout(this.attributeFlushTimer);
1854
+ this.attributeFlushTimer = null;
1855
+ }
1856
+ this.identity.pending.clear();
1857
+ // An answer still on its way belongs to the store just emptied.
1858
+ this.attributeGeneration++;
1859
+ this.attributeFlushInFlight = false;
1860
+ this.attributeFlushAgain = false;
1861
+ this.attributeFailures = 0;
1862
+ this.storage.remove(ATTRIBUTE_WRITES_KEY);
1863
+ }
1864
+
1865
+ /**
1866
+ * Send the next request's worth of attribute writes: the oldest pending
1867
+ * write of every key. Serialized - one request at a time - and repeated
1868
+ * until the log is empty. scheduleAttributeFlush is the normal entry point.
1869
+ */
1870
+ private flushAttributeOps(): void {
1871
+ const pendingIdentify = this.queue.getPendingIdentify();
1872
+ const ready = pendingIdentify ? this.queue.flush(false, true).catch(() => undefined) : Promise.resolve();
1873
+ void ready.then(() => {
1874
+ // Opted out meanwhile: applyPrivacyState cleared the store. Pending
1875
+ // consent / suspended: everything stays for later.
1876
+ if (!this.canSend()) return;
1877
+ if (this.queue.getPendingIdentify()) {
1878
+ // The identify has not reached the server yet (offline, backing off).
1879
+ // Sending now would write onto the anonymous profile; wait instead.
1880
+ this.scheduleAttributeFlush(this.attributeRetryDelay());
1881
+ return;
1882
+ }
1883
+
1884
+ // Serialize + coalesce. A call arriving mid-flight raises a flag, and
1885
+ // one follow-up flush covers everything it added.
1886
+ if (this.attributeFlushInFlight) {
1887
+ this.attributeFlushAgain = true;
1888
+ return;
1889
+ }
1890
+ const request = this.identity.pending.nextRequest((o) => this.resolveOwner(o ?? this.currentOwner()));
1891
+ if (!request) return;
1892
+ this.attributeFlushInFlight = true;
1893
+ const generation = this.attributeGeneration;
1894
+ const { ops, entries } = request;
1895
+ // The ids of the person who made these writes (contract items 31, 38).
1896
+ const owner = this.resolveOwner(request.owner ?? this.currentOwner());
1897
+
1898
+ this.network.sendSetAttributes(
1899
+ this.config.apiEndpoint,
1900
+ ops,
1901
+ owner.userId,
1902
+ owner.anonymousId,
1903
+ ).then((res: NetworkResponse) => {
1904
+ if (generation !== this.attributeGeneration) return; // cleared meanwhile (opt-out, wipe)
1905
+ // Acknowledge ONLY on success. `request()` never throws - it resolves
1906
+ // `{ ok: false }` - so the old `.then(ack)` acknowledged every FAILED
1907
+ // write too, and the "durable" attribute queue silently dropped them.
1908
+ const verdict = classifyWriteResponse(res, {
1909
+ isSdkAuthError: this.network.isSdkAuthError(res.status, res.data),
1910
+ ownerIsCurrentUser: (owner.userId || null) === this.identity.getUserId(),
1911
+ });
1912
+ if (verdict.action === 'done') {
1913
+ this.attributeFailures = 0;
1914
+ this.identity.pending.acknowledge(entries);
1915
+ this.persistAttributeWrites();
1916
+ const permission = ops.attributes?.[PUSH_PERMISSION_ATTRIBUTE];
1917
+ if (typeof permission === 'string') this.storage.set(REPORTED_PUSH_PERMISSION_KEY, permission);
1918
+ if (this.identity.pending.hasUnsent()) this.attributeFlushAgain = true;
1919
+ this.scheduleRetiredTokenPrune();
1920
+ return;
1921
+ }
1922
+ if (verdict.action === 'drop') {
1923
+ // Refused for good (400/403/413/422), or an SDK-auth 401 of a user
1924
+ // who is no longer signed in: retrying would fail forever and hold
1925
+ // every later write to these keys behind it. Only THIS request's
1926
+ // writes are dropped (contract items 28, 39, 40).
1927
+ this.logger.error(`Attributes dropped: ${verdict.reason}`, res.error);
1928
+ this.identity.pending.drop(entries);
1929
+ this.persistAttributeWrites();
1930
+ if (this.identity.pending.hasUnsent()) this.attributeFlushAgain = true;
1931
+ this.scheduleRetiredTokenPrune();
1932
+ return;
1933
+ }
1934
+ // An increment is lost only when its request MAY have been applied
1935
+ // (no answer, a 5xx): a repeated $inc then double-counts. A clear
1936
+ // refusal (401/402/429, success: false) applied nothing.
1937
+ const ambiguous = !res.softFailure && (res.status === 0 || res.status >= 500);
1938
+ const lost = this.identity.pending.release(entries, ambiguous);
1939
+ if (lost > 0) {
1940
+ this.logger.error(
1941
+ `Failed to send attributes (HTTP ${res.status || 'no response'}); ${lost} increment(s) are LOST `
1942
+ + '(an $inc cannot be safely retried).',
1943
+ );
1944
+ }
1945
+ if (verdict.action === 'hold') {
1946
+ this.persistAttributeWrites();
1947
+ this.logger.error(`Attributes not sent: ${verdict.reason}; they are kept for the next page view.`);
1948
+ return;
1949
+ }
1950
+ // Retry. Offline, 429 and 5xx never count toward the cap; a
1951
+ // refusal that can heal (success: false, a 401 for the current
1952
+ // user) counts, at most WRITE_MAX_ATTEMPTS times (contract item 40).
1953
+ if (verdict.counts) {
1954
+ const exhausted = entries.filter((e) => {
1955
+ e.attempts = (e.attempts || 0) + 1;
1956
+ return e.attempts >= WRITE_MAX_ATTEMPTS;
1957
+ });
1958
+ if (exhausted.length) {
1959
+ this.logger.error(`Attributes failed (HTTP ${res.status}) too many times; dropping them`);
1960
+ this.identity.pending.drop(exhausted);
1961
+ }
1962
+ }
1963
+ this.persistAttributeWrites();
1964
+ this.logger.warn(`Failed to send attributes (HTTP ${res.status || 'no response'}); will retry`);
1965
+ this.attributeFailures++;
1966
+ this.scheduleAttributeFlush(Math.max(this.attributeRetryDelay(), Math.min(verdict.retryAfterMs ?? 0, 5 * 60 * 1000)));
1967
+ }).then(() => {
1968
+ if (generation !== this.attributeGeneration) return;
1969
+ this.attributeFlushInFlight = false;
1970
+ if (this.attributeFlushAgain) {
1971
+ this.attributeFlushAgain = false;
1972
+ this.flushAttributeOps();
1973
+ }
1974
+ // The tail of the chain needs its own handler: anything thrown by the
1975
+ // bookkeeping above - or by the recursive flushAttributeOps - would
1976
+ // otherwise be an unhandled rejection in the host page.
1977
+ }).catch((error) => {
1978
+ if (generation === this.attributeGeneration) this.attributeFlushInFlight = false;
1979
+ this.logger.error('Attribute flush bookkeeping failed:', error);
1980
+ });
1981
+ });
1982
+ }
1983
+
1984
+ private attributeRetryDelay(): number {
1985
+ const n = Math.max(1, this.attributeFailures);
1986
+ return Math.min(5000 * Math.pow(2, n - 1), 5 * 60 * 1000);
1987
+ }
1988
+
1989
+ /**
1990
+ * Report notification-permission state as a user attribute, but ONLY when it
1991
+ * changed since we last reported it.
1992
+ *
1993
+ * Same reserved key and the SAME VALUES as iOS and Android
1994
+ * (`granted | denied | not_determined | unavailable`), so one segment
1995
+ * definition covers every platform. The public API names the states
1996
+ * `notDetermined` / `unavailable` (PushPermissionState); the attribute keeps
1997
+ * the snake_case wire value.
1998
+ *
1999
+ * Change-detection matters: attributes are not deduplicated server-side, so
2000
+ * reporting unconditionally would spend a request on every page load.
2001
+ */
2002
+ private reportPushPermissionIfChanged(): void {
2003
+ if (!this.isInitialized || this.isOptedOut) return;
2004
+ try {
2005
+ const state = this._push.getPermissionState();
2006
+ const current = state === 'notDetermined' ? 'not_determined' : state;
2007
+ // The marker records what the SERVER acknowledged, and is written when
2008
+ // the attribute write is acknowledged (see flushAttributeOps). It used
2009
+ // to be written here, before anything was sent: a write held by pending
2010
+ // consent, refused, or lost with the page was never reported again.
2011
+ if (this.storage.get<string>(REPORTED_PUSH_PERMISSION_KEY) === current) return;
2012
+ this.logger.debug(`Push permission changed to ${current}; reporting`);
2013
+ this.setAttribute(PUSH_PERMISSION_ATTRIBUTE, current);
2014
+ } catch (error) {
2015
+ this.logger.warn('Could not report push permission:', error);
2016
+ }
2017
+ }
2018
+
2019
+ /**
2020
+ * Set a single attribute
2021
+ */
2022
+ setAttribute(key: string, value: any): void {
2023
+ if (typeof key !== 'string' || !key) {
2024
+ this.logger.error('setAttribute(key, value) needs a non-empty key');
2025
+ return;
2026
+ }
2027
+ this.setAttributes({ [key]: value });
2028
+ }
2029
+
2030
+ /**
2031
+ * Increment a numeric attribute
2032
+ */
2033
+ incrementAttribute(key: string, value: number = 1): void {
2034
+ if (!this.isInitialized) {
2035
+ this.logger.error('SDK not initialized');
2036
+ return;
2037
+ }
2038
+ if (this.isDroppingDataCalls('incrementAttribute')) return;
2039
+ if (typeof key !== 'string' || !key || typeof value !== 'number' || !Number.isFinite(value)) {
2040
+ this.logger.error('incrementAttribute(key, value) needs a key and a finite number');
2041
+ return;
2042
+ }
2043
+
2044
+ // In-memory optimistic echo only (for in-session getAttribute read-back).
2045
+ this.identity.incrementAttribute(key, value);
2046
+
2047
+ // Send the DELTA as an atomic `$inc` op — never a locally-computed
2048
+ // absolute — so concurrent tabs/devices don't clobber the counter.
2049
+ if (!this.isOptedOut) {
2050
+ this.sendAttributesAfterPendingIdentify({ increment: { [key]: value } });
2051
+ }
2052
+ }
2053
+
2054
+ /**
2055
+ * Unset an attribute
2056
+ */
2057
+ unsetAttribute(key: string): void {
2058
+ if (!this.isInitialized) {
2059
+ this.logger.error('SDK not initialized');
2060
+ return;
2061
+ }
2062
+ if (this.isDroppingDataCalls('unsetAttribute')) return;
2063
+ if (typeof key !== 'string' || !key) {
2064
+ this.logger.error('unsetAttribute() needs a non-empty attribute key');
2065
+ return;
2066
+ }
2067
+
2068
+ this.identity.unsetAttribute(key);
2069
+
2070
+ // Send an atomic `$unset` op (waits for any pending identify first).
2071
+ if (!this.isOptedOut) {
2072
+ this.sendAttributesAfterPendingIdentify({ unset: [key] });
2073
+ }
2074
+ }
2075
+
2076
+ /**
2077
+ * Add a value to an array attribute
2078
+ */
2079
+ addToArray(key: string, value: any): void {
2080
+ if (!this.isInitialized) {
2081
+ this.logger.error('SDK not initialized');
2082
+ return;
2083
+ }
2084
+ if (this.isDroppingDataCalls('addToArray')) return;
2085
+ if (typeof key !== 'string' || !key) {
2086
+ this.logger.error('addToArray() needs a non-empty attribute key');
2087
+ return;
2088
+ }
2089
+
2090
+ const problem = valueProblem(value);
2091
+ if (problem) {
2092
+ this.logger.warn(`addToArray("${key}") ignored: the value is ${problem}`);
2093
+ return;
2094
+ }
2095
+
2096
+ // In-memory optimistic echo only (for in-session getAttribute read-back).
2097
+ this.identity.addToArray(key, value);
2098
+
2099
+ // Send an atomic `$addToSet` op with just the added value — not the whole
2100
+ // recomputed array — so concurrent writers don't clobber the set.
2101
+ if (!this.isOptedOut) {
2102
+ this.sendAttributesAfterPendingIdentify({ append: { [key]: value } });
2103
+ }
2104
+ }
2105
+
2106
+ /**
2107
+ * Remove a value from an array attribute
2108
+ */
2109
+ removeFromArray(key: string, value: any): void {
2110
+ if (!this.isInitialized) {
2111
+ this.logger.error('SDK not initialized');
2112
+ return;
2113
+ }
2114
+ if (this.isDroppingDataCalls('removeFromArray')) return;
2115
+ if (typeof key !== 'string' || !key) {
2116
+ this.logger.error('removeFromArray() needs a non-empty attribute key');
2117
+ return;
2118
+ }
2119
+
2120
+ const problem = valueProblem(value);
2121
+ if (problem) {
2122
+ this.logger.warn(`removeFromArray("${key}") ignored: the value is ${problem}`);
2123
+ return;
2124
+ }
2125
+
2126
+ // In-memory optimistic echo only (for in-session getAttribute read-back).
2127
+ this.identity.removeFromArray(key, value);
2128
+
2129
+ // Send an atomic `$pull` op with just the removed value — not the whole
2130
+ // recomputed array.
2131
+ if (!this.isOptedOut) {
2132
+ this.sendAttributesAfterPendingIdentify({ remove: { [key]: value } });
2133
+ }
2134
+ }
2135
+
2136
+ /**
2137
+ * Log the current user out: a NEW anonymous id, no user id, nothing of the
2138
+ * previous user's left behind.
2139
+ *
2140
+ * The identity changes SYNCHRONOUSLY, before anything is awaited (contract
2141
+ * addendum 16). It used to await a flush first, so the common
2142
+ * `reset(); identify('B')` ran identify while the old ids were still in
2143
+ * place: identify('B') was queued with user A's anonymousId - which asks the
2144
+ * server to merge A's profile into B's, two real customers - and the reset
2145
+ * then landed afterwards and set the user id back to null. Nothing queued
2146
+ * needs the old ids to still be current: every queued event, and a pending
2147
+ * identify, carries its own.
2148
+ *
2149
+ * It also used to leave the attribute debounce timer and the pending
2150
+ * attribute store running (so user A's last writes went out under user B's
2151
+ * ids), the in-app retry/delay timers alive, and the browser's push
2152
+ * subscription bound to user A on the server - so A's pushes kept arriving
2153
+ * on a shared device.
2154
+ */
2155
+ async reset(): Promise<void> {
2156
+ if (!this.isInitialized) {
2157
+ this.logger.error('SDK not initialized');
2158
+ return;
2159
+ }
2160
+
2161
+ this.logger.info('Resetting user identity');
2162
+
2163
+ // --- Synchronous: everything that decides WHO the next call is about ---
2164
+
2165
+ // The outgoing user's attribute and profile writes not acknowledged yet
2166
+ // are NOT dropped: each carries its own ids and goes out in order after
2167
+ // this (contract items 28 and 37). They used to get one best-effort send
2168
+ // here - which skipped any key that already had a request on the wire,
2169
+ // and ignored a pending identify - and were then forgotten.
2170
+
2171
+ // In-app FIRST: dismiss (tracked as the outgoing user, with that user's
2172
+ // token still the current one), cancel every timer, forget campaigns.
2173
+ // After the token was retired the dismissal went without a token, was
2174
+ // refused, and fired the host's auth handler for a logged-out user.
2175
+ if (this.config.inApp.enabled) {
2176
+ this.inapp.clearAll();
2177
+ }
2178
+
2179
+ // The previous user's SDK-authentication token is theirs, not the next
2180
+ // visitor's (contract item 39): the session forgets it, but it stays bound
2181
+ // to the outgoing user so their own queued events and writes still go out
2182
+ // authenticated - persisted until the last of them settles (D-a).
2183
+ // Leftovers refused with a 401 anyway are dropped, never reported to the
2184
+ // host's handler.
2185
+ this.network.retireSdkAuthToken();
2186
+ // A user kept only for withdrawals (D-b) is logged out too.
2187
+ this.forgetDeferredIdentify();
2188
+
2189
+ // New anonymous id, no user id.
2190
+ this.pinAnonymousWrites();
2191
+ this.identity.reset();
2192
+ this.network.setCurrentUserId(this.identity.getUserId());
2193
+
2194
+ // UTM before the session: the new session's Session Start (tracked by the
2195
+ // session's onNewSession hook) must not carry the outgoing user's campaign.
2196
+ this.utm.reset();
2197
+ this.session.resetSession();
2198
+
2199
+ // --- Asynchronous: delivery. Each queued event carries its own ids. ---
2200
+ await this.flush().catch(() => undefined);
2201
+
2202
+ if (this.canSend()) {
2203
+ // The new anonymous visitor's campaigns, and the push subscription moved
2204
+ // to them (force: the identity changed even if the endpoint did not).
2205
+ this.resyncInApp(true);
2206
+ this._push.syncExistingSubscription(true).catch(() => undefined);
2207
+ }
2208
+ this.outbox.kick();
2209
+
2210
+ this.logger.info('User identity reset');
2211
+ }
2212
+
2213
+ /**
2214
+ * Erase everything this SDK keeps about the visitor on this browser - for
2215
+ * an erasure request (contract addendum 21): the identity (a new anonymous
2216
+ * id, no user id), queued events and a pending identify, pending attribute
2217
+ * and profile writes, in-app state and impression history, the session,
2218
+ * UTM data, the device id, and the push subscription (unsubscribed in the
2219
+ * browser and unregistered on the server; it stays unsubscribed until
2220
+ * `push.subscribe()` is called again).
2221
+ *
2222
+ * KEEPS the opt-out: "delete what you have" is not "start collecting
2223
+ * again", and a wipe that silently re-enabled tracking would undo the more
2224
+ * important of the two requests. Does not opt anyone out either - that is
2225
+ * optOut(). The same split Android and iOS make.
2226
+ */
2227
+ async wipeData(): Promise<void> {
2228
+ if (!this.isInitialized) {
2229
+ this.logger.error('SDK not initialized');
2230
+ return;
2231
+ }
2232
+ this.logger.info('Wiping local SDK data');
2233
+
2234
+ // Push first, while the outgoing ids are still current (it captures them).
2235
+ const pushWiped = this._push.forget().catch(() => undefined);
2236
+
2237
+ this.wipeLocalState();
2238
+
2239
+ // The fresh visitor starts a fresh session - with its one Session Start
2240
+ // when tracking is on (contract item 32).
2241
+ this.session.ensureSession();
2242
+ this.trackSessionStartIfMissing();
2243
+
2244
+ await pushWiped;
2245
+ if (this.canSend()) this.resyncInApp(true);
2246
+ this.logger.info('Local SDK data wiped');
2247
+ }
2248
+
2249
+ /**
2250
+ * The synchronous part of wipeData(), shared with a wipeData() queued
2251
+ * before init. Keeps the opt-out and a denied consent: both are decisions,
2252
+ * not data.
2253
+ */
2254
+ private wipeLocalState(): void {
2255
+ this.clearPendingAttributeWrites();
2256
+ // Withdrawals (unsubscribes, the opt-out marker) stay and are delivered
2257
+ // under the ids they were made with (contract item 36).
2258
+ this.outbox.discardNonWithdrawals();
2259
+ this.storage.remove(REPORTED_PUSH_PERMISSION_KEY);
2260
+ // "No Session Start queued for any session yet" - NOT the absence of the
2261
+ // marker, which reads as "sent" (rule 75): a wipe while tracking was off,
2262
+ // then a reload and a grant, resumed the post-wipe session with no
2263
+ // Session Start at all.
2264
+ this.storage.set(SESSION_START_SENT_KEY, '');
2265
+ this.queue.clear();
2266
+ if (this.config.inApp.enabled) {
2267
+ this.inapp.clearAll();
2268
+ } else {
2269
+ this.inapp.invalidate();
2270
+ }
2271
+ this.inapp.forgetImpressions();
2272
+
2273
+ // The erased user's token, like at reset(): no longer the session's, still
2274
+ // bound to their withdrawals waiting in the outbox (the same on every SDK).
2275
+ // A token set for a NEXT user (standby) belongs to no one being kept:
2276
+ // deleted, not left in localStorage for 30 days (rule 82). Never the
2277
+ // erased (or held) user's own token, nor that of anyone with a withdrawal
2278
+ // still queued, here or in another tab's stored outbox (rule 89).
2279
+ const keepTokens = new Set<string>();
2280
+ for (const u of [this.effectiveUserId(), this.identity.getUserId(), this.network.getCurrentUserId()]) {
2281
+ if (u) keepTokens.add(u);
2282
+ }
2283
+ for (const r of this.outbox.records()) {
2284
+ const userId = (r.payload as any).userId;
2285
+ if (typeof userId === 'string' && userId) keepTokens.add(userId);
2286
+ }
2287
+ for (const u of this._push.pendingUnregisterUsers()) keepTokens.add(u);
2288
+ for (const key of [OUTBOX_KEY, PENDING_UNREGISTER_KEY]) collectUserIds(this.storage.get<unknown>(key), keepTokens);
2289
+ this.network.retireSdkAuthToken();
2290
+ this.network.dropStandbySdkAuthTokens(keepTokens);
2291
+ this.forgetDeferredIdentify();
2292
+ this.pinAnonymousWrites();
2293
+ this.identity.reset();
2294
+ this.network.setCurrentUserId(null);
2295
+ this.utm.reset();
2296
+ this.session.clear();
2297
+ this.lastTrackedUrl = null;
2298
+ this.storage.remove('device_id');
2299
+ this.deviceId = this.getOrCreateDeviceId();
2300
+ }
2301
+
2302
+ /** Ask the server for this visitor's campaigns again (identify, reset, new session). */
2303
+ private resyncInApp(fullInit: boolean = false): void {
2304
+ if (!this.config.inApp.enabled || !this.canSend()) return;
2305
+ const run = fullInit ? this.inapp.initialize() : this.inapp.sync().then(() => undefined);
2306
+ run.catch((error) => this.logger.debug('In-app re-sync failed:', error));
2307
+ }
2308
+
2309
+ /**
2310
+ * Send what is queued now. Skips the SDK's own retry backoff, but never a
2311
+ * server's Retry-After (contract item 35).
2312
+ */
2313
+ async flush(): Promise<void> {
2314
+ if (!this.isInitialized) {
2315
+ this.logger.error('SDK not initialized');
2316
+ return;
2317
+ }
2318
+
2319
+ await this.queue.flush(false, true);
2320
+ }
2321
+
2322
+ /**
2323
+ * Classify one batch response for the queue (see FlushOutcome).
2324
+ */
2325
+ private async flushEvents(events: QueuedEvent[], identify?: PendingIdentify): Promise<FlushOutcome> {
2326
+ if (!this.canSend()) {
2327
+ return { status: 'retry' };
2328
+ }
2329
+
2330
+ this.logger.debug(`Sending ${events.length} events${identify ? ' + identify' : ''} to backend`);
2331
+
2332
+ const response = await this.network.sendEvents(this.config.apiEndpoint, events, identify);
2333
+
2334
+ // 2xx: `ok`, or `softFailure` when the body says `success: false` - some
2335
+ // (or all) events were not stored; `failedIndices` names them, and
2336
+ // without it all of them are kept. `rejectedIndices` (either way) names
2337
+ // events refused for GOOD: those are dropped, never retried.
2338
+ if (response.ok || response.softFailure) {
2339
+ const rejectedIndices = Array.isArray(response.data?.rejectedIndices) ? response.data.rejectedIndices : undefined;
2340
+ let identifyFailed = false;
2341
+ let identifyDropped = false;
2342
+ // Offline, 429 and 5xx never count toward the identify's cap (contract item 40).
2343
+ let identifyAttemptCounts = true;
2344
+ // If identify was included but the backend did not confirm it, send it
2345
+ // on its own. Failing that, the queue keeps it: it used to be dropped
2346
+ // with a log line, leaving the visitor unmerged for good.
2347
+ if (identify && !response.data?.identified && response.softFailure) {
2348
+ // The batch itself failed (in part): keep the identify for the retry
2349
+ // so it still travels with - and after - the events it follows.
2350
+ identifyFailed = true;
2351
+ } else if (identify && !response.data?.identified) {
2352
+ this.logger.debug('Backend did not confirm atomic identify, falling back to standalone');
2353
+ const res = await this.network.sendIdentify(
2354
+ this.config.apiEndpoint,
2355
+ identify.userId,
2356
+ identify.attributes || {},
2357
+ identify.anonymousId,
2358
+ );
2359
+ if (!res.ok) {
2360
+ // Retried only when the failure can heal (no answer, 408, 429, 5xx,
2361
+ // success: false). A refusal (400, 403, 404, ...) is final - it was
2362
+ // retried forever (scan 3, web #8) - and so is an SDK-auth refusal
2363
+ // for a user who is no longer the current one.
2364
+ const outage = !res.softFailure && (res.status === 0 || res.status === 408 || res.status === 429 || res.status >= 500);
2365
+ const retryable =
2366
+ res.softFailure === true ||
2367
+ outage ||
2368
+ (res.status === 401 && this.network.isSdkAuthError(res.status, res.data) && identify.userId === this.identity.getUserId()) ||
2369
+ res.status === 402;
2370
+ if (retryable) {
2371
+ this.logger.error('Standalone identify fallback failed; it is kept and retried:', res.error);
2372
+ identifyFailed = true;
2373
+ identifyAttemptCounts = !outage && res.status !== 402;
2374
+ } else {
2375
+ this.logger.error(`Standalone identify fallback refused (HTTP ${res.status}); dropping it:`, res.error);
2376
+ identifyDropped = true;
2377
+ }
2378
+ }
2379
+ }
2380
+ if (response.softFailure) {
2381
+ const failedIndices = Array.isArray(response.data?.failedIndices)
2382
+ ? response.data.failedIndices
2383
+ : events.map((_e, i) => i);
2384
+ return { status: 'ok', failedIndices, rejectedIndices, identifyFailed, identifyAttemptCounts, identifyDropped };
2385
+ }
2386
+ this.logger.debug('Events sent successfully');
2387
+ return { status: 'ok', rejectedIndices, identifyFailed, identifyAttemptCounts, identifyDropped };
2388
+ }
2389
+
2390
+ return this.classifyFailure(response, events, identify);
2391
+ }
2392
+
2393
+ private classifyFailure(response: NetworkResponse, events: QueuedEvent[] = [], identify?: PendingIdentify): FlushOutcome {
2394
+ const status = response.status;
2395
+ if (status === 400 || status === 413 || status === 422) {
2396
+ return { status: 'rejected', httpStatus: status };
2397
+ }
2398
+ if (status === 401) {
2399
+ if (this.network.isSdkAuthError(status, response.data)) {
2400
+ // A batch belongs to one user (the queue never mixes them). If that
2401
+ // user is no longer the current one, no token for them will ever be
2402
+ // set again: a `sub_mismatch` / `missing` refusal is final, and
2403
+ // retrying it at the head of the queue blocked every later event of
2404
+ // the NEW user (contract item 30).
2405
+ // ANY reason (missing, sub_mismatch, expired, invalid): reset()
2406
+ // forgot that user's token and no new one will be minted for them
2407
+ // (contract item 39).
2408
+ const reason = response.data?.reason;
2409
+ const batchUser = identify?.userId || events.find((e) => e.userId)?.userId || null;
2410
+ if (batchUser && batchUser !== this.identity.getUserId()) {
2411
+ return { status: 'dropped', reason: `401 ${reason || 'sdk_authentication_error'}` };
2412
+ }
2413
+ // The current user's token: the refresh already ran in request();
2414
+ // keep the events and try again after a backoff. A refusal, so it
2415
+ // counts against an identify riding along (contract item 40).
2416
+ return { status: 'retry', counts: true };
2417
+ }
2418
+ return { status: 'suspended', reason: 'the SDK key was refused (401)' };
2419
+ }
2420
+ if (status === 402) {
2421
+ return { status: 'suspended', reason: 'event ingestion is paused for this workspace (402)' };
2422
+ }
2423
+ if (status === 403) {
2424
+ return { status: 'suspended', reason: 'the request was forbidden (403)' };
2425
+ }
2426
+ if (status === 0 || status === 408 || status === 429 || status >= 500) {
2427
+ return { status: 'retry', retryAfterMs: response.retryAfterMs };
2428
+ }
2429
+ // Any other 4xx (404, 405, ...): a configuration problem no retry fixes,
2430
+ // and dropping the events for it would lose data. Stop and keep them.
2431
+ return { status: 'suspended', reason: `the API answered HTTP ${status}` };
2432
+ }
2433
+
2434
+ /**
2435
+ * Page is going away: hand what is queued to the browser with fetch
2436
+ * keepalive, and stop holding ONLY what was actually handed off.
2437
+ *
2438
+ * This used navigator.sendBeacon with a JSON blob: a credentialed CORS
2439
+ * request the API's wildcard CORS must refuse - and since sendBeacon returns
2440
+ * true for "queued", the queue, the in-flight batch and the pending identify
2441
+ * were all cleared and lost on every navigation.
2442
+ */
2443
+ private handlePageUnload(): void {
2444
+ if (!this.isInitialized) return;
2445
+ // Offline: nothing can leave. Everything stays stored for the next page
2446
+ // view - a hand-off would only take events out of storage for a request
2447
+ // that cannot arrive.
2448
+ if (isOffline()) {
2449
+ this.queue.persistNow();
2450
+ return;
2451
+ }
2452
+ // Profile writes first - withdrawals even while opted out. They stay
2453
+ // stored until acknowledged, so a page that survives sends them again.
2454
+ try {
2455
+ this.outbox.handOffAtUnload(UNLOAD_WRITES_BUDGET_BYTES);
2456
+ } catch (error) {
2457
+ this.logger.error('Page-unload write hand-off failed:', error);
2458
+ }
2459
+ if (!this.canSend() || this.queue.isSuspended() || this.queue.isBackingOff()) {
2460
+ // Opted out: nothing to keep. Pending / suspended: keep it all for
2461
+ // later. Backing off (the API failed, or asked to wait): a keepalive
2462
+ // would most likely fail too, and the events it took would be lost -
2463
+ // they stay queued for the next page view (contract item 3).
2464
+ this.queue.persistNow();
2465
+ return;
2466
+ }
2467
+
2468
+ try {
2469
+ this.sendAttributesAtUnload();
2470
+ // What is left of the browser's 64 KiB keepalive budget - shared by
2471
+ // every hand-off of this page, so the second of `beforeunload` and
2472
+ // `pagehide` cannot hand off a batch the browser would refuse.
2473
+ // (Less the request overhead postKeepalive counts.)
2474
+ const budget = Math.max(0, this.network.keepaliveBudget() - 256);
2475
+
2476
+ const events = this.queue.getQueuedEvents();
2477
+ const head = this.queue.getPendingIdentify();
2478
+ if (events.length === 0 && !head) return;
2479
+
2480
+ if (!this.network.supportsKeepalive()) {
2481
+ // The request would die with the page; keep everything for the next one.
2482
+ this.queue.persistNow();
2483
+ return;
2484
+ }
2485
+
2486
+ // One request, within the browser's 64 KiB keepalive budget, and of ONE
2487
+ // identity (contract item 30): the first queued event's. The identify
2488
+ // rides along only when it belongs with those events. What does not fit
2489
+ // stays queued (and persisted) for the next page view.
2490
+ const first = events[0];
2491
+ const identify =
2492
+ head && (!first || (first.anonymousId === head.anonymousId && (!first.userId || first.userId === head.userId)))
2493
+ ? head
2494
+ : null;
2495
+ let bytes = 64 + (identify ? utf8Length(JSON.stringify(identify)) : 0);
2496
+ const handed: QueuedEvent[] = [];
2497
+ let batchUser: string | null = null;
2498
+ for (const event of events) {
2499
+ if (first && event.anonymousId !== first.anonymousId) break;
2500
+ if (event.userId && batchUser && event.userId !== batchUser) break;
2501
+ if (identify && (event.anonymousId !== identify.anonymousId || (event.userId && event.userId !== identify.userId))) break;
2502
+ const size = utf8Length(JSON.stringify(event)) + 1;
2503
+ if (bytes + size > budget) break;
2504
+ handed.push(event);
2505
+ if (event.userId) batchUser = event.userId;
2506
+ bytes += size;
2507
+ }
2508
+ if (handed.length === 0 && !identify) {
2509
+ this.queue.persistNow();
2510
+ return;
2511
+ }
2512
+
2513
+ this.logger.debug(`Page unload: handing ${handed.length} of ${events.length} events${identify ? ' + identify' : ''} to keepalive`);
2514
+ const pending = this.network.sendEventsKeepalive(this.config.apiEndpoint, handed, identify || undefined);
2515
+ if (!pending) {
2516
+ this.queue.persistNow();
2517
+ return;
2518
+ }
2519
+ this.queue.markHandedOff(handed, identify);
2520
+ // Only matters if the page survives (bfcache, a cancelled unload): a
2521
+ // failed hand-off comes back into the queue (event ids make a resend
2522
+ // harmless). A refusal (4xx) is left to the normal path's rules.
2523
+ pending.then((res) => {
2524
+ if (!res.ok && (res.softFailure || res.status === 0 || res.status === 429 || res.status >= 500)) {
2525
+ this.queue.takeBack(handed, identify);
2526
+ }
2527
+ }).catch(() => undefined);
2528
+ } catch (error) {
2529
+ this.logger.error('Page-unload send failed:', error);
2530
+ this.queue.persistNow();
2531
+ }
2532
+ }
2533
+
2534
+ /**
2535
+ * Hand the next attribute request to the browser at unload. The writes stay
2536
+ * stored (and are acknowledged on a later page view); only increments are
2537
+ * forgotten, since a second $inc would count twice. Returns the bytes used.
2538
+ */
2539
+ private sendAttributesAtUnload(): number {
2540
+ if (this.queue.getPendingIdentify()) return 0; // would land on the anonymous profile
2541
+ const request = this.identity.pending.peekRequest((o) => this.resolveOwner(o ?? this.currentOwner()));
2542
+ if (!request || !this.network.supportsKeepalive()) return 0;
2543
+ try {
2544
+ const owner = this.resolveOwner(request.owner ?? this.currentOwner());
2545
+ const body = {
2546
+ userId: owner.userId || undefined,
2547
+ anonymousId: owner.userId ? undefined : owner.anonymousId,
2548
+ ...request.ops,
2549
+ };
2550
+ const pending = this.network.postKeepalive(`${this.config.apiEndpoint}/v1/attributes`, body);
2551
+ if (!pending) return 0;
2552
+ pending.catch(() => undefined);
2553
+ // Handed off: an increment must not go out a second time if the page
2554
+ // survives (the idempotent writes stay, harmlessly, until acknowledged).
2555
+ this.identity.pending.dropIncrements(request.entries);
2556
+ this.persistAttributeWrites();
2557
+ return utf8Length(JSON.stringify(body)) + 256;
2558
+ } catch {
2559
+ return 0; // best-effort
2560
+ }
2561
+ }
2562
+
2563
+ /**
2564
+ * Opt out of tracking - remembered across page loads until optIn().
2565
+ *
2566
+ * TRACKING consent, not marketing subscription - this does not unsubscribe
2567
+ * anyone from email or SMS. Independent of updateConsent(): it never
2568
+ * changes the stored consent, and a later `updateConsent('granted')` does
2569
+ * not lift it - only optIn() does.
2570
+ */
2571
+ optOut(): void {
2572
+ if (!this.isInitialized) {
2573
+ this.logger.error('SDK not initialized');
2574
+ return;
2575
+ }
2576
+ this.logger.info('User opted out of tracking');
2577
+ this.userOptedOut = true;
2578
+ this.writeOptOut();
2579
+ /*
2580
+ * The marker is queued FIRST, under the current ids: it is a consent
2581
+ * RECORD, not tracking, and the only way the server learns this device
2582
+ * opted out (a segment excluding opted-out people must catch web users as
2583
+ * well as mobile ones). A withdrawal: persisted and delivered whatever
2584
+ * happens next (contract item 36).
2585
+ */
2586
+ this.storage.remove(OPT_OUT_MARKER_GIVEN_UP_KEY);
2587
+ this.syncOptOutMarker();
2588
+ this.applyPrivacyState();
2589
+ }
2590
+
2591
+ /**
2592
+ * Opt in to tracking: lifts the visitor's own opt-out. Does not grant
2593
+ * consent - a denied consent stays denied until updateConsent('granted').
2594
+ */
2595
+ optIn(): void {
2596
+ if (!this.isInitialized) {
2597
+ this.logger.error('SDK not initialized');
2598
+ return;
2599
+ }
2600
+ this.logger.info('User opted in to tracking');
2601
+ this.userOptedOut = false;
2602
+ // A decision made inside a data-gated ready() callback is the visitor's
2603
+ // newer choice: the callback's later calls run under it (rule 83) - an
2604
+ // opt-out gate only; a denial or the purge stays (rule 92).
2605
+ this.liftDataGate('optedOut');
2606
+ this.writeOptIn();
2607
+ this.applyPrivacyState();
2608
+ // And clear it on the way back in, or a person who declined and later
2609
+ // accepted would stay excluded by `tracking_opted_out == true` forever.
2610
+ this.storage.remove(OPT_OUT_MARKER_GIVEN_UP_KEY);
2611
+ this.syncOptOutMarker();
2612
+ }
2613
+
2614
+ /**
2615
+ * Update tracking consent at runtime (e.g., after user accepts cookie banner).
2616
+ *
2617
+ * - `granted`: release everything collected while consent was pending
2618
+ * (Session Start, events, attributes, identify, subscriptions, push
2619
+ * registration) and run normally. Lifts a stored denial.
2620
+ * - `denied`: collect nothing, and discard what was waiting. Remembered on
2621
+ * later page views until `granted`.
2622
+ * - `pending`: collect locally, send nothing - for this page view only, and
2623
+ * never over a stored denial.
2624
+ *
2625
+ * Independent of optOut(): a visitor who opted out stays opted out whatever
2626
+ * the consent banner says.
2627
+ */
2628
+ updateConsent(consent: TrackingConsent): void {
2629
+ if (!this.isInitialized) {
2630
+ this.logger.error('SDK not initialized');
2631
+ return;
2632
+ }
2633
+ if (consent !== 'granted' && consent !== 'denied' && consent !== 'pending') {
2634
+ this.logger.error(`updateConsent expects 'granted', 'denied' or 'pending', got ${String(consent)}`);
2635
+ return;
2636
+ }
2637
+ this.trackingConsent = this.storeConsent(consent);
2638
+ this.config.trackingConsent = this.trackingConsent;
2639
+ if (consent === 'granted') this.liftDataGate('denied'); // see optIn()
2640
+ this.applyPrivacyState();
2641
+ // A marker change that waited for consent goes out once it is granted.
2642
+ this.syncOptOutMarker();
2643
+ }
2644
+
2645
+ /**
2646
+ * Check if Do Not Track is enabled
2647
+ */
2648
+ private isDoNotTrackEnabled(): boolean {
2649
+ if (typeof window === 'undefined' || typeof navigator === 'undefined') {
2650
+ return false;
2651
+ }
2652
+
2653
+ // Check various DNT implementations
2654
+ const dnt = (navigator as any).doNotTrack || (window as any).doNotTrack || (navigator as any).msDoNotTrack;
2655
+
2656
+ return dnt === '1' || dnt === 'yes';
2657
+ }
2658
+
2659
+ /**
2660
+ * Load plugins
2661
+ */
2662
+ private async loadPlugins(): Promise<void> {
2663
+ for (const plugin of this.config.plugins) {
2664
+ try {
2665
+ this.logger.debug(`Loading plugin: ${plugin.name} v${plugin.version}`);
2666
+ await plugin.load(this);
2667
+ this.logger.info(`Plugin loaded: ${plugin.name}`);
2668
+ } catch (error) {
2669
+ this.logger.error(`Failed to load plugin ${plugin.name}:`, error);
2670
+ }
2671
+ }
2672
+ }
2673
+
2674
+ /**
2675
+ * Unload plugins
2676
+ */
2677
+ private async unloadPlugins(): Promise<void> {
2678
+ for (const plugin of this.config.plugins) {
2679
+ try {
2680
+ this.logger.debug(`Unloading plugin: ${plugin.name}`);
2681
+ await plugin.unload();
2682
+ } catch (error) {
2683
+ this.logger.error(`Failed to unload plugin ${plugin.name}:`, error);
2684
+ }
2685
+ }
2686
+ }
2687
+
2688
+ /**
2689
+ * User object for subscription management
2690
+ * Use this to update subscription preferences (write-only, no getters for security)
2691
+ *
2692
+ * @example
2693
+ * sdk.user.setSubscription('email', SubscriptionStatus.OPTED_IN);
2694
+ * sdk.user.setSubscription('sms', SubscriptionStatus.UNSUBSCRIBED);
2695
+ * sdk.user.addToSubscriptionGroup('list_abc123', 'email');
2696
+ */
2697
+ get user(): User {
2698
+ if (!this.isInitialized) {
2699
+ // Logged, not thrown: an SDK must never throw into the host page. The
2700
+ // object's writes are refused while the SDK is not initialized.
2701
+ this.logger?.error('SDK not initialized');
2702
+ }
2703
+ return this._user;
2704
+ }
2705
+
2706
+ /**
2707
+ * Web push subscription manager
2708
+ *
2709
+ * @example
2710
+ * if (sdk.push.isSupported() && sdk.push.getPermissionState() === 'notDetermined') {
2711
+ * button.onclick = () => sdk.push.subscribe();
2712
+ * }
2713
+ */
2714
+ get push(): PushManager {
2715
+ if (!this.isInitialized) {
2716
+ this.logger?.error('SDK not initialized');
2717
+ }
2718
+ return this._push;
2719
+ }
2720
+
2721
+ /**
2722
+ * E-commerce tracking helper
2723
+ * Provides standardized e-commerce event tracking
2724
+ *
2725
+ * @example
2726
+ * // Initialize e-commerce tracking
2727
+ * const ecommerce = sdk.ecommerce({ currency: 'USD' });
2728
+ *
2729
+ * // Track product view
2730
+ * ecommerce.viewProduct({ productId: 'SKU123', name: 'Blue Shirt', price: 29.99 });
2731
+ *
2732
+ * // Track add to cart
2733
+ * ecommerce.addToCart({ productId: 'SKU123', name: 'Blue Shirt', price: 29.99, quantity: 1 });
2734
+ *
2735
+ * // Track purchase
2736
+ * ecommerce.purchase({
2737
+ * orderId: 'ORD123',
2738
+ * items: [{ productId: 'SKU123', name: 'Blue Shirt', price: 29.99, quantity: 1 }],
2739
+ * value: 29.99
2740
+ * });
2741
+ */
2742
+ ecommerce(config?: EcommerceConfig): EcommerceTracker {
2743
+ if (!this.isInitialized) {
2744
+ this.logger?.error('SDK not initialized');
2745
+ }
2746
+
2747
+ // Create or return cached instance
2748
+ if (!this._ecommerce || config) {
2749
+ this._ecommerce = createEcommerceTracker(this, config, this.logger);
2750
+ }
2751
+
2752
+ return this._ecommerce;
2753
+ }
2754
+
2755
+ /**
2756
+ * SDK Authentication — set / refresh the customer-minted JWT.
2757
+ *
2758
+ * When SDK authentication is enabled (`enableSdkAuthentication: true`), the
2759
+ * SDK attaches this token as `X-Joryio-Auth` to every ingest request. Call
2760
+ * this on init (or from the failure callback) whenever you mint a fresh JWT.
2761
+ */
2762
+ setSdkAuthenticationToken(token: string): void {
2763
+ if (!this.isInitialized) {
2764
+ this.logger?.error('SDK not initialized');
2765
+ return;
2766
+ }
2767
+ if (!this.config.enableSdkAuthentication) {
2768
+ this.logger.warn('setSdkAuthenticationToken called but enableSdkAuthentication is false - ignoring');
2769
+ return;
2770
+ }
2771
+ this.network.setSdkAuthenticationToken(token);
2772
+ }
2773
+
2774
+ /**
2775
+ * SDK Authentication — register a callback fired when the backend rejects the
2776
+ * token with a 401 `sdk_authentication_error`. The callback receives the
2777
+ * failure reason and the endpoint/user; mint a fresh JWT and either return it
2778
+ * (string / Promise<string>) or call setSdkAuthenticationToken(). The failed
2779
+ * request is then retried once with the new token.
2780
+ */
2781
+ setSdkAuthenticationErrorCallback(cb: SdkAuthFailureCallback): void {
2782
+ if (!this.isInitialized) {
2783
+ this.logger?.error('SDK not initialized');
2784
+ return;
2785
+ }
2786
+ this.network.setSdkAuthenticationFailureCallback(cb);
2787
+ }
2788
+
2789
+ /**
2790
+ * Get the current anonymous ID.
2791
+ *
2792
+ * Always present: generated on first init and persisted in localStorage under
2793
+ * `jry_<hash(sdkKey)>_anonymous_id`. Stable across calls for the life of the
2794
+ * anonymous visitor; `reset()` (logout) regenerates it. This is the exact
2795
+ * `anonymousId` attached to every event `track()` sends, so it's the right id
2796
+ * to stamp on a consent log or a server-side event for stitching.
2797
+ *
2798
+ * Async-snippet note: `window.joryio` is the command queue until the SDK
2799
+ * finishes loading, and a queued stub can't return a value. Read this after
2800
+ * load — call it on the resolved instance (`window.joryio.getAnonymousId()`),
2801
+ * or from inside `ready(cb)`.
2802
+ */
2803
+ getAnonymousId(): string {
2804
+ return this.identity ? this.identity.getAnonymousId() : '';
2805
+ }
2806
+
2807
+ /**
2808
+ * Get the current identified user ID, or `null` if the visitor is still
2809
+ * anonymous. Same async-snippet caveat as {@link getAnonymousId}: read it
2810
+ * after load (on the instance or inside `ready(cb)`).
2811
+ */
2812
+ getUserId(): string | null {
2813
+ return this.identity ? this.identity.getUserId() : null;
2814
+ }
2815
+
2816
+ /**
2817
+ * Run a callback once the SDK is loaded and initialized, passing the SDK
2818
+ * instance. On the async snippet this is the safe way to read a value that
2819
+ * queued stubs can't return before load, e.g.:
2820
+ *
2821
+ * joryio.ready(function (sdk) {
2822
+ * var anonId = sdk.getAnonymousId();
2823
+ * });
2824
+ *
2825
+ * By the time this method exists on the resolved instance the SDK is already
2826
+ * initialized, so the callback runs synchronously.
2827
+ */
2828
+ /**
2829
+ * Data calls dropped while non-empty: the synchronous run of a ready() callback
2830
+ * the snippet queued while tracking was off (rule 83). It runs at its
2831
+ * queue position's privacy state, not the state the later queued
2832
+ * decisions produced - its track(), attribute writes, aliases, conversions
2833
+ * and non-withdrawal subscription writes are dropped like a queued
2834
+ * track() made there (rule 73). Withdrawals, identify() and reads work,
2835
+ * and an optIn() / updateConsent('granted') the callback itself makes
2836
+ * lifts the matching reason for the rest of it (rule 92).
2837
+ */
2838
+ private dataGate: Set<DataGateReason> = new Set();
2839
+
2840
+ private liftDataGate(reason: DataGateReason): void {
2841
+ if (!this.dataGate.has(reason)) return;
2842
+ const next = new Set(this.dataGate);
2843
+ next.delete(reason);
2844
+ this.dataGate = next;
2845
+ }
2846
+
2847
+ /** See setClearedInflightCallback (rule 90). */
2848
+ private inflightSessionStartAtClear: string | null = null;
2849
+
2850
+ private isDroppingDataCalls(method: string): boolean {
2851
+ if (this.dataGate.size === 0) return false;
2852
+ this.logger.debug(`${method}() in a ready() callback queued while tracking was off: dropped`);
2853
+ return true;
2854
+ }
2855
+
2856
+ ready(callback: (sdk: JoryioSDK) => void): void {
2857
+ if (typeof callback !== 'function') {
2858
+ this.logger.error('ready(callback) requires a function');
2859
+ return;
2860
+ }
2861
+ try {
2862
+ callback(this);
2863
+ } catch (error) {
2864
+ this.logger.error('Error in ready() callback:', error);
2865
+ }
2866
+ }
2867
+
2868
+ /**
2869
+ * Get current user identity
2870
+ */
2871
+ getIdentity() {
2872
+ return this.identity.getIdentity();
2873
+ }
2874
+
2875
+ /**
2876
+ * Get current session info
2877
+ */
2878
+ getSessionInfo() {
2879
+ return this.session.getSessionInfo();
2880
+ }
2881
+
2882
+ /**
2883
+ * Get UTM data (current, first touch, last touch)
2884
+ */
2885
+ getUTMData() {
2886
+ return this.utm.getUTMData();
2887
+ }
2888
+
2889
+ /**
2890
+ * Manually update UTM parameters (useful for SPAs that change URL without reload)
2891
+ */
2892
+ updateUTM(): void {
2893
+ if (!this.isInitialized) {
2894
+ this.logger.error('SDK not initialized');
2895
+ return;
2896
+ }
2897
+
2898
+ this.utm.updateUTM();
2899
+
2900
+ // Check if session should be reset due to new campaign
2901
+ if (this.config.resetSessionOnNewCampaign && this.utm.hasNewCampaign()) {
2902
+ this.logger.info('New campaign detected, resetting session');
2903
+ // resetSession() starts the new session through the session's
2904
+ // onNewSession hook, which tracks its Session Start. Tracking one here
2905
+ // as well sent every campaign change's Session Start twice.
2906
+ this.session.resetSession();
2907
+ }
2908
+ }
2909
+
2910
+ /**
2911
+ * Sync in-app campaigns from backend
2912
+ * @param attributeChanges - Optional object with attribute changes for differential sync
2913
+ */
2914
+ async syncInAppCampaigns(attributeChanges?: Record<string, any>): Promise<void> {
2915
+ if (!this.isInitialized) {
2916
+ this.logger.error('SDK not initialized');
2917
+ return;
2918
+ }
2919
+
2920
+ if (!this.config.inApp.enabled) {
2921
+ this.logger.warn('In-app messaging is not enabled');
2922
+ return;
2923
+ }
2924
+
2925
+ if (!this.canSend()) {
2926
+ this.logger.debug('Tracking disabled or consent pending, not syncing in-app campaigns');
2927
+ return;
2928
+ }
2929
+
2930
+ try {
2931
+ await this.inapp.sync(attributeChanges);
2932
+ } catch (error) {
2933
+ this.logger.error('In-app sync failed:', error);
2934
+ }
2935
+ }
2936
+
2937
+ /**
2938
+ * Get all eligible in-app campaigns
2939
+ */
2940
+ getInAppCampaigns(): InAppCampaign[] {
2941
+ if (!this.isInitialized) {
2942
+ this.logger.error('SDK not initialized');
2943
+ return [];
2944
+ }
2945
+
2946
+ if (!this.config.inApp.enabled) {
2947
+ return [];
2948
+ }
2949
+
2950
+ return this.inapp.getCampaigns();
2951
+ }
2952
+
2953
+ /**
2954
+ * Manually display an in-app campaign
2955
+ * @param campaignId - Campaign ID to display
2956
+ */
2957
+ async displayInAppCampaign(campaignId: string): Promise<void> {
2958
+ if (!this.isInitialized) {
2959
+ this.logger.error('SDK not initialized');
2960
+ return;
2961
+ }
2962
+
2963
+ if (!this.config.inApp.enabled) {
2964
+ this.logger.warn('In-app messaging is not enabled');
2965
+ return;
2966
+ }
2967
+
2968
+ if (!this.canSend()) {
2969
+ this.logger.debug('Tracking disabled or consent pending, not displaying in-app campaign');
2970
+ return;
2971
+ }
2972
+
2973
+ const campaigns = this.inapp.getCampaigns();
2974
+ const campaign = campaigns.find((c) => c.id === campaignId);
2975
+
2976
+ if (!campaign) {
2977
+ this.logger.error(`Campaign ${campaignId} not found`);
2978
+ return;
2979
+ }
2980
+
2981
+ try {
2982
+ await this.inapp.displayCampaign(campaign);
2983
+ } catch (error) {
2984
+ this.logger.error('In-app display failed:', error);
2985
+ }
2986
+ }
2987
+
2988
+ /**
2989
+ * Dismiss an active in-app message
2990
+ * @param campaignId - Campaign ID to dismiss
2991
+ */
2992
+ dismissInAppMessage(campaignId: string): void {
2993
+ if (!this.isInitialized) {
2994
+ this.logger.error('SDK not initialized');
2995
+ return;
2996
+ }
2997
+
2998
+ if (!this.config.inApp.enabled) {
2999
+ return;
3000
+ }
3001
+
3002
+ this.inapp.dismiss(campaignId).catch((error) =>
3003
+ this.logger.error('In-app dismiss failed:', error));
3004
+ }
3005
+
3006
+ /**
3007
+ * Track a conversion event for an in-app campaign
3008
+ * @param campaignId - Campaign ID
3009
+ * @param eventName - Conversion event name
3010
+ */
3011
+ async trackInAppConversion(campaignId: string, eventName: string): Promise<void> {
3012
+ if (!this.isInitialized) {
3013
+ this.logger.error('SDK not initialized');
3014
+ return;
3015
+ }
3016
+ if (this.isDroppingDataCalls('trackInAppConversion')) return;
3017
+
3018
+ if (!this.config.inApp.enabled) {
3019
+ this.logger.warn('In-app messaging is not enabled');
3020
+ return;
3021
+ }
3022
+
3023
+ if (!this.canSend()) {
3024
+ this.logger.debug('Tracking disabled or consent pending, not tracking in-app conversion');
3025
+ return;
3026
+ }
3027
+
3028
+ try {
3029
+ await this.inapp.trackConversion(campaignId, eventName);
3030
+ } catch (error) {
3031
+ this.logger.error('In-app conversion failed:', error);
3032
+ }
3033
+ }
3034
+
3035
+ /**
3036
+ * Destroy the SDK instance
3037
+ */
3038
+ async destroy(): Promise<void> {
3039
+ if (!this.isInitialized) {
3040
+ return;
3041
+ }
3042
+ this.logger.info('Destroying SDK instance');
3043
+
3044
+ // Send any debounced attributes NOW, then stop the timer.
3045
+ //
3046
+ // destroy() left this timer running, so a pending 800ms debounce fired
3047
+ // after teardown and tried to send from a destroyed SDK - and dropped the
3048
+ // attributes if the page went first. Flushing before clearing means a
3049
+ // setAttribute immediately followed by destroy() is delivered rather than
3050
+ // silently discarded.
3051
+ if (this.attributeFlushTimer !== null) {
3052
+ clearTimeout(this.attributeFlushTimer);
3053
+ this.attributeFlushTimer = null;
3054
+ if (this.canSend()) this.flushAttributeOps();
3055
+ }
3056
+ // Stored writes stay stored (the next page view sends them); only the timers stop.
3057
+ this.outbox.destroy();
3058
+ this._push.destroy();
3059
+
3060
+ // Remove page listeners
3061
+ if (typeof window !== 'undefined' && typeof window.removeEventListener === 'function') {
3062
+ if (this.boundHandlePageUnload) {
3063
+ window.removeEventListener('pagehide', this.boundHandlePageUnload);
3064
+ window.removeEventListener('beforeunload', this.boundHandlePageUnload);
3065
+ }
3066
+ if (this.boundHandleOnline) {
3067
+ window.removeEventListener('online', this.boundHandleOnline);
3068
+ }
3069
+ if (this.boundHandleStorage) {
3070
+ window.removeEventListener('storage', this.boundHandleStorage);
3071
+ }
3072
+ }
3073
+ this.boundHandlePageUnload = null;
3074
+ this.boundHandleOnline = null;
3075
+ this.boundHandleStorage = null;
3076
+ if (this.pruneTimer !== null) {
3077
+ clearTimeout(this.pruneTimer);
3078
+ this.pruneTimer = null;
3079
+ }
3080
+
3081
+ // Flush pending events
3082
+ await this.queue.flush().catch(() => undefined);
3083
+
3084
+ // Stop queue
3085
+ this.queue.stop();
3086
+
3087
+ // Unload plugins
3088
+ await this.unloadPlugins();
3089
+
3090
+ // Cleanup managers
3091
+ this.session.destroy();
3092
+
3093
+ // Clear in-app messaging (every timer included)
3094
+ this.inapp.clearAll();
3095
+ this.inapp.destroy();
3096
+
3097
+ this.isInitialized = false;
3098
+ if (JoryioSDK.instance === this) JoryioSDK.instance = null;
3099
+ this.logger.info('SDK destroyed');
3100
+ }
3101
+
3102
+ /**
3103
+ * Get the current SDK instance (if initialized)
3104
+ */
3105
+ static getInstance(): JoryioSDK | null {
3106
+ return JoryioSDK.instance;
3107
+ }
3108
+ }