@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,894 @@
1
+ /**
2
+ * Event Queue Manager for batching and persisting events
3
+ */
4
+
5
+ import { StorageManager } from './storage';
6
+ import { Logger } from '../utils/logger';
7
+ import { utf8Length } from './network';
8
+
9
+ export const QUEUE_KEY = 'event_queue';
10
+ export const INFLIGHT_KEY = 'event_queue_inflight';
11
+ export const PENDING_IDENTIFY_KEY = 'pending_identify';
12
+ /**
13
+ * Fallback cap when the SDK passes nothing. The REAL cap is `maxQueueSize` from
14
+ * the init config: this constant used to be the only one consulted, so the
15
+ * documented `maxQueueSize` option was stored and never read (audit
16
+ * 2026-09-26, W4) - iOS and Android honour theirs.
17
+ */
18
+ const DEFAULT_MAX_QUEUE_SIZE = 1000; // Prevent unbounded growth
19
+ /** Window over which a burst of enqueues collapses into one storage write. */
20
+ const PERSIST_COALESCE_MS = 150;
21
+
22
+ /** The backend's `@ArrayMaxSize(500)` on `/v1/track/batch`. */
23
+ export const MAX_EVENTS_PER_REQUEST = 500;
24
+ /** Stay well under the API's 1 MB body limit. */
25
+ export const MAX_REQUEST_BYTES = 900 * 1024;
26
+ /** Longest the queue waits between retries of a failing batch. */
27
+ const MAX_BACKOFF_MS = 5 * 60 * 1000;
28
+
29
+ export interface QueuedEvent {
30
+ eventId: string; // Client-generated ID for idempotency
31
+ type: string;
32
+ properties?: Record<string, any>;
33
+ /**
34
+ * When the event happened: ISO 8601 UTC with milliseconds (rule 55, the
35
+ * same on every SDK). A number (epoch ms) in queues stored by an earlier
36
+ * build; the API accepts both.
37
+ */
38
+ timestamp: string | number;
39
+ userId?: string | null;
40
+ anonymousId: string;
41
+ sessionId?: string;
42
+ }
43
+
44
+ export interface PendingIdentify {
45
+ userId: string;
46
+ anonymousId: string;
47
+ attributes?: Record<string, any>;
48
+ /** Failed attempts so far (never sent on the wire). */
49
+ attempts?: number;
50
+ }
51
+
52
+ /**
53
+ * The most identifies waiting at once. They form an ordered list rather than
54
+ * one slot: `identify('A')` while offline followed by `reset(); identify('B')`
55
+ * used to overwrite A's identify with B's, so A's anonymous history was never
56
+ * merged into A (scan 3, web #9).
57
+ */
58
+ const MAX_PENDING_IDENTIFIES = 20;
59
+ /**
60
+ * An identify that keeps being REFUSED is dropped after this many attempts
61
+ * (contract items 28 and 40). Only server answers count (a 2xx with
62
+ * `success: false`, an SDK-authentication 401 for the current user): offline,
63
+ * timeouts, 429 and 5xx never do, so an outage cannot cost a visitor's merge.
64
+ */
65
+ export const MAX_IDENTIFY_ATTEMPTS = 10;
66
+
67
+ /**
68
+ * What became of one batch request. The SDK classifies the HTTP response;
69
+ * the queue decides what to keep.
70
+ *
71
+ * - `ok`: 2xx. `failedIndices` (from a `success: false` body) names the events
72
+ * the server could not store; those are kept for retry, the rest deleted.
73
+ * `identifyFailed`: the events went through but the identify did not - it
74
+ * is kept and retried, never dropped.
75
+ * - `rejected`: 400 / 413 / 422 - the request itself is unacceptable. Retrying
76
+ * it unchanged can never succeed, so the batch is split in half until the
77
+ * bad event is alone, and that one event is dropped.
78
+ * - `retry`: 429 / 5xx / offline / timeout / an SDK-auth 401. Keep everything,
79
+ * back off.
80
+ * - `suspended`: 402 / 403 / an invalid key. Stop sending for this page view;
81
+ * keep everything (bounded by maxQueueSize).
82
+ */
83
+ export type FlushOutcome =
84
+ | {
85
+ status: 'ok';
86
+ failedIndices?: number[];
87
+ /** Refused for good (property limits, invalid): dropped, never retried (contract item 29). */
88
+ rejectedIndices?: number[];
89
+ identifyFailed?: boolean;
90
+ /** The identify's failure counts toward MAX_IDENTIFY_ATTEMPTS (a server refusal, not an outage). */
91
+ identifyAttemptCounts?: boolean;
92
+ /** The identify was refused for good: dropped, never retried. */
93
+ identifyDropped?: boolean;
94
+ }
95
+ | { status: 'rejected'; httpStatus?: number }
96
+ /** `counts`: a server refusal (SDK-auth 401 for the current user), counted against an identify riding along. */
97
+ | { status: 'retry'; retryAfterMs?: number; counts?: boolean }
98
+ | { status: 'suspended'; reason?: string }
99
+ /**
100
+ * SDK authentication refused the batch because it belongs to a user who is
101
+ * no longer the current one (401 `sub_mismatch` / `missing`): no token for
102
+ * that user will ever be minted again, so the batch is dropped rather than
103
+ * retried at the head of the queue forever (contract item 30).
104
+ */
105
+ | { status: 'dropped'; reason?: string };
106
+
107
+ /** Returning nothing means `ok`; throwing means `retry`. */
108
+ export type FlushCallback = (
109
+ events: QueuedEvent[],
110
+ identify?: PendingIdentify,
111
+ ) => Promise<FlushOutcome | void>;
112
+
113
+ function isOffline(): boolean {
114
+ return typeof navigator !== 'undefined' && (navigator as any).onLine === false;
115
+ }
116
+
117
+ /**
118
+ * May this identify ride with this batch? Only with events of the anonymous
119
+ * id it merges, and of no OTHER user: under SDK authentication a request is
120
+ * verified against one user's token.
121
+ */
122
+ function identifyMatches(identify: PendingIdentify, batch: QueuedEvent[]): boolean {
123
+ return batch.every((e) => e.anonymousId === identify.anonymousId && (!e.userId || e.userId === identify.userId));
124
+ }
125
+
126
+ export class QueueManager {
127
+ private storage: StorageManager;
128
+ private logger: Logger;
129
+ private queue: QueuedEvent[] = [];
130
+ private batchSize: number;
131
+ private flushInterval: number;
132
+ private flushTimer: ReturnType<typeof setInterval> | null = null;
133
+ private flushCallback: FlushCallback | null = null;
134
+ private persistQueue: boolean;
135
+ private maxQueueSize: number;
136
+ private retryBackoffMs: number;
137
+ /** A coalesced storage write is outstanding — see schedulePersist(). */
138
+ private pendingPersist = false;
139
+ private persistTimer: ReturnType<typeof setTimeout> | null = null;
140
+ private isFlushing: boolean = false;
141
+ private flushWaiters: (() => void)[] = [];
142
+ /** Identifies not yet confirmed by the server, oldest first. */
143
+ private pendingIdentifies: PendingIdentify[] = [];
144
+ private inflightEvents: QueuedEvent[] = [];
145
+ /** Consent is pending: collect, never send. */
146
+ private paused = false;
147
+ /** 402/403/invalid key: nothing more is sent this page view. */
148
+ private suspended = false;
149
+ /** Consecutive retryable failures, for exponential backoff. */
150
+ private consecutiveFailures = 0;
151
+ /** Earliest time the next send may be attempted (the SDK's own backoff). */
152
+ private nextAttemptAt = 0;
153
+ /**
154
+ * Earliest time the SERVER allowed (Retry-After). Kept apart from the own
155
+ * backoff because a forced flush skips the backoff but never this
156
+ * (contract item 35).
157
+ */
158
+ private retryAfterUntil = 0;
159
+ /**
160
+ * Bumped by clear(). A flush that was awaiting the network when the queue
161
+ * was cleared (opt-out, consent denied) must not put its batch back: the
162
+ * visitor withdrew, and restoring it would send it on the next flush.
163
+ */
164
+ private generation = 0;
165
+ /**
166
+ * Told what a request that was in flight when clear() ran actually
167
+ * delivered (rule 90): the server's answer still settles it, even though
168
+ * nothing of it comes back to the queue.
169
+ */
170
+ private clearedInflightCallback: ((delivered: QueuedEvent[]) => void) | null = null;
171
+
172
+ constructor(
173
+ storage: StorageManager,
174
+ logger: Logger,
175
+ batchSize: number,
176
+ flushInterval: number,
177
+ persistQueue: boolean = true,
178
+ maxQueueSize: number = DEFAULT_MAX_QUEUE_SIZE,
179
+ retryBackoffMs: number = 1000,
180
+ ) {
181
+ this.storage = storage;
182
+ this.logger = logger;
183
+ this.batchSize = batchSize > 0 ? Math.floor(batchSize) : 50;
184
+ this.flushInterval = flushInterval;
185
+ this.persistQueue = persistQueue;
186
+ // A cap below 1 would drop every event on enqueue; treat it as "default".
187
+ this.maxQueueSize = maxQueueSize > 0 ? Math.floor(maxQueueSize) : DEFAULT_MAX_QUEUE_SIZE;
188
+ this.retryBackoffMs = retryBackoffMs > 0 ? retryBackoffMs : 1000;
189
+
190
+ // Load persisted queue and recover any in-flight events from a previous
191
+ // page load that was interrupted (e.g., OAuth redirect canceled the XHR).
192
+ if (this.persistQueue) {
193
+ const inflight = this.storage.get<QueuedEvent[]>(INFLIGHT_KEY) || [];
194
+ const queued = this.storage.get<QueuedEvent[]>(QUEUE_KEY) || [];
195
+ // Put recovered in-flight events first (they were enqueued earlier)
196
+ this.queue = [...inflight, ...queued];
197
+ if (inflight.length > 0) {
198
+ this.storage.remove(INFLIGHT_KEY);
199
+ this.logger.debug(`Recovered ${inflight.length} in-flight events from previous page`);
200
+ }
201
+ if (this.queue.length > this.maxQueueSize) {
202
+ this.queue = this.queue.slice(-this.maxQueueSize);
203
+ }
204
+ if (this.queue.length > 0) {
205
+ this.logger.debug(`Loaded ${this.queue.length} events from storage`);
206
+ this.persistQueueToStorage();
207
+ }
208
+
209
+ // Recover pending identify from a previous page load (e.g., identify flush
210
+ // was interrupted by OAuth redirect or page navigation after signup)
211
+ // An array since 1.3.0; a single object from an earlier version.
212
+ const persisted = this.storage.get<PendingIdentify | PendingIdentify[]>(PENDING_IDENTIFY_KEY);
213
+ const list = Array.isArray(persisted) ? persisted : persisted ? [persisted] : [];
214
+ this.pendingIdentifies = list
215
+ .filter((i) => i && typeof i.userId === 'string' && typeof i.anonymousId === 'string')
216
+ .slice(-MAX_PENDING_IDENTIFIES);
217
+ if (this.pendingIdentifies.length) {
218
+ this.logger.debug(`Recovered ${this.pendingIdentifies.length} pending identify(s) from a previous page`);
219
+ }
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Set the flush callback
225
+ */
226
+ setFlushCallback(callback: FlushCallback): void {
227
+ this.flushCallback = callback;
228
+ }
229
+
230
+ /** Hold everything locally (consent pending) or release it. */
231
+ setPaused(paused: boolean): void {
232
+ this.paused = paused;
233
+ }
234
+
235
+ isPaused(): boolean {
236
+ return this.paused;
237
+ }
238
+
239
+ /** True after a 402/403/invalid key stopped sending for this page view. */
240
+ isSuspended(): boolean {
241
+ return this.suspended;
242
+ }
243
+
244
+ /**
245
+ * The last send failed (offline, timeout, 429, 5xx) or the server asked to
246
+ * wait: a keepalive at unload would most likely fail the same way, and the
247
+ * events it carried would be gone - so the unload path keeps them instead.
248
+ */
249
+ isBackingOff(): boolean {
250
+ return this.consecutiveFailures > 0 || Date.now() < this.retryAfterUntil;
251
+ }
252
+
253
+ /** The browser came back online: try again now rather than after the backoff. */
254
+ resetBackoff(): void {
255
+ this.consecutiveFailures = 0;
256
+ this.nextAttemptAt = 0;
257
+ // NOT retryAfterUntil: the server's Retry-After is honoured even when the
258
+ // browser comes back online.
259
+ }
260
+
261
+ /**
262
+ * Record an identify, to be sent with the events that precede it.
263
+ *
264
+ * Identifies queue up in call order. The same user on the same anonymous id
265
+ * replaces the last one (an identical merge); anything else is appended, so
266
+ * `identify('A')` made offline survives a later `reset(); identify('B')`.
267
+ */
268
+ setPendingIdentify(identify: PendingIdentify): void {
269
+ const last = this.pendingIdentifies[this.pendingIdentifies.length - 1];
270
+ const entry: PendingIdentify = { userId: identify.userId, anonymousId: identify.anonymousId, attributes: identify.attributes || {} };
271
+ if (last && last.userId === entry.userId && last.anonymousId === entry.anonymousId) {
272
+ // Same merge again: nothing new to send (the one in the list may be on
273
+ // the wire right now, and replacing it would orphan its answer).
274
+ return;
275
+ }
276
+ this.pendingIdentifies.push(entry);
277
+ if (this.pendingIdentifies.length > MAX_PENDING_IDENTIFIES) {
278
+ const dropped = this.pendingIdentifies.shift()!;
279
+ this.logger.warn(`Too many identify calls waiting to be sent; dropped the oldest (${dropped.userId})`);
280
+ }
281
+ this.persistIdentifies();
282
+ }
283
+
284
+ private persistIdentifies(): void {
285
+ if (!this.persistQueue) return;
286
+ try {
287
+ if (this.pendingIdentifies.length) this.storage.set(PENDING_IDENTIFY_KEY, this.pendingIdentifies);
288
+ else this.storage.remove(PENDING_IDENTIFY_KEY);
289
+ } catch (error) {
290
+ this.logger.error('Failed to persist pending identify:', error);
291
+ }
292
+ }
293
+
294
+ /** The oldest identify not yet confirmed, or null. */
295
+ getPendingIdentify(): PendingIdentify | null {
296
+ return this.pendingIdentifies[0] || null;
297
+ }
298
+
299
+ /** Every identify not yet confirmed, oldest first. */
300
+ getPendingIdentifies(): PendingIdentify[] {
301
+ return [...this.pendingIdentifies];
302
+ }
303
+
304
+ /** The identify reached the server, or was refused for good: forget it. */
305
+ private removeIdentify(identify: PendingIdentify): void {
306
+ const i = this.pendingIdentifies.indexOf(identify);
307
+ if (i >= 0) this.pendingIdentifies.splice(i, 1);
308
+ this.persistIdentifies();
309
+ }
310
+
311
+ /** A failed attempt: counted when it was a refusal, and dropped once refused too often. */
312
+ private identifyAttemptFailed(identify: PendingIdentify, counts: boolean): void {
313
+ if (!counts) {
314
+ this.persistIdentifies();
315
+ return;
316
+ }
317
+ identify.attempts = (identify.attempts || 0) + 1;
318
+ if (identify.attempts >= MAX_IDENTIFY_ATTEMPTS) {
319
+ this.logger.error(`Identify for ${identify.userId} failed ${identify.attempts} times; dropping it`);
320
+ this.removeIdentify(identify);
321
+ } else {
322
+ this.persistIdentifies();
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Start the automatic flush timer
328
+ */
329
+ start(): void {
330
+ if (this.flushTimer) {
331
+ this.logger.debug('Queue manager already started');
332
+ return;
333
+ }
334
+
335
+ this.flushTimer = setInterval(() => {
336
+ // flush() handles its own errors, but say so explicitly: an unhandled
337
+ // rejection from an SDK fires the HOST page's onunhandledrejection and
338
+ // lands in their monitoring as if their app had failed.
339
+ this.flush().catch((error) => this.logger.error('Scheduled flush failed:', error));
340
+ }, this.flushInterval);
341
+
342
+ this.logger.debug(`Queue manager started (batch: ${this.batchSize}, interval: ${this.flushInterval}ms)`);
343
+
344
+ // Flush any persisted events immediately
345
+ if (this.queue.length > 0 || this.pendingIdentifies.length) {
346
+ this.flush().catch((error) => this.logger.error('Startup flush failed:', error));
347
+ }
348
+ }
349
+
350
+ isStarted(): boolean {
351
+ return this.flushTimer !== null;
352
+ }
353
+
354
+ /**
355
+ * Stop the automatic flush timer
356
+ */
357
+ stop(): void {
358
+ if (this.flushTimer) {
359
+ clearInterval(this.flushTimer);
360
+ this.flushTimer = null;
361
+ this.logger.debug('Queue manager stopped');
362
+ }
363
+ // Teardown must not strand a coalesced write, or events enqueued in the
364
+ // last 150ms would be lost on reset()/destroy.
365
+ this.persistNow();
366
+ }
367
+
368
+ /**
369
+ * Add an event to the queue
370
+ */
371
+ enqueue(event: QueuedEvent): void {
372
+ this.queue.push(event);
373
+ this.logger.debug(`Event queued: ${event.type} (queue size: ${this.queue.length})`);
374
+
375
+ // Coalesced, NOT immediate: a direct write here re-serialized the whole
376
+ // queue on every event (O(N²) for a burst, on the main thread). Unload and
377
+ // flush still persist synchronously, so nothing that could lose events
378
+ // depends on this timer. See schedulePersist().
379
+ this.schedulePersist();
380
+
381
+ // Prevent unbounded growth. Drop the OLDEST events with an honest warning -
382
+ // this is the ONLY place events are dropped for lack of delivery (a retry
383
+ // count never deletes anything; a device offline for a day keeps its
384
+ // newest `maxQueueSize` events).
385
+ this.trimToMax();
386
+
387
+ // Auto-flush if batch size reached (don't await - fire and forget but safely)
388
+ if (this.queue.length >= this.batchSize && !this.isFlushing) {
389
+ this.logger.debug('Batch size reached, flushing queue');
390
+ void this.flush();
391
+ }
392
+ }
393
+
394
+ private trimToMax(): void {
395
+ if (this.queue.length > this.maxQueueSize) {
396
+ const droppedCount = this.queue.length - this.maxQueueSize;
397
+ this.queue = this.queue.slice(-this.maxQueueSize);
398
+ this.logger.warn(`Queue exceeded max size (${this.maxQueueSize}), dropped ${droppedCount} oldest event(s)`);
399
+ // Also coalesced. Once the queue sits AT the cap every further event
400
+ // truncates, so an immediate write here would reinstate the per-event
401
+ // full re-serialize in exactly the case it hurts most.
402
+ this.schedulePersist();
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Take the next request's worth of events off the front of the queue:
408
+ * at most min(batchSize, 500) events and ~900 KB.
409
+ *
410
+ * The whole queue used to go in ONE request - up to maxQueueSize (1000)
411
+ * events against a backend that accepts 500 and 1 MB. The oversized request
412
+ * was refused, re-queued at the front, refused again: one bad batch blocked
413
+ * every later event forever.
414
+ */
415
+ private takeBatch(): QueuedEvent[] {
416
+ const limit = Math.min(this.batchSize, MAX_EVENTS_PER_REQUEST);
417
+ const out: QueuedEvent[] = [];
418
+ let bytes = 64; // envelope + identify headroom
419
+ // One identity per request (contract item 30): under SDK authentication a
420
+ // request is verified against ONE user's token, so user A's events still
421
+ // queued after a logout must not share a request with user B's - the
422
+ // whole request was refused and blocked the queue. Cut at a change of
423
+ // anonymous id (a reset) or of user id; events with no user id (before an
424
+ // identify) ride with the user who follows on the same anonymous id.
425
+ let batchUser: string | null = null;
426
+ while (this.queue.length > 0 && out.length < limit) {
427
+ const event = this.queue[0];
428
+ if (out.length > 0) {
429
+ if (event.anonymousId !== out[0].anonymousId) break;
430
+ if (event.userId && batchUser && event.userId !== batchUser) break;
431
+ }
432
+ let size: number;
433
+ try {
434
+ size = utf8Length(JSON.stringify(event)) + 1;
435
+ } catch {
436
+ size = Number.POSITIVE_INFINITY; // circular properties: unsendable
437
+ }
438
+ if (size > MAX_REQUEST_BYTES) {
439
+ // Can never fit in any request. The API's body-size refusal arrives
440
+ // without CORS headers, so the browser would report it as "offline"
441
+ // and the event would be retried forever.
442
+ this.queue.shift();
443
+ this.logger.error(`Dropping event "${event.type}": it is too large to send (${size} bytes)`);
444
+ continue;
445
+ }
446
+ if (out.length > 0 && bytes + size > MAX_REQUEST_BYTES) break;
447
+ out.push(this.queue.shift()!);
448
+ if (event.userId) batchUser = event.userId;
449
+ bytes += size;
450
+ }
451
+ return out;
452
+ }
453
+
454
+ /** Put events back at the FRONT of the queue (they are older than anything queued since). */
455
+ private restoreToFront(events: QueuedEvent[]): void {
456
+ if (!events.length) return;
457
+ const present = new Set(this.queue.map((e) => e.eventId));
458
+ const missing = events.filter((e) => !present.has(e.eventId));
459
+ this.queue.unshift(...missing);
460
+ this.trimToMax();
461
+ }
462
+
463
+ private backOff(retryAfterMs?: number): void {
464
+ this.consecutiveFailures++;
465
+ const exp = Math.min(
466
+ this.retryBackoffMs * Math.pow(2, Math.min(this.consecutiveFailures - 1, 20)),
467
+ MAX_BACKOFF_MS,
468
+ );
469
+ const jitter = exp * 0.2 * Math.random();
470
+ const delay = Math.max(exp + jitter, Math.min(retryAfterMs ?? 0, MAX_BACKOFF_MS));
471
+ this.nextAttemptAt = Date.now() + delay;
472
+ if (retryAfterMs && retryAfterMs > 0) {
473
+ this.retryAfterUntil = Date.now() + Math.min(retryAfterMs, MAX_BACKOFF_MS);
474
+ }
475
+ this.logger.debug(`Event send failed; next attempt in ${Math.round(delay)}ms`);
476
+ }
477
+
478
+ private async sendBatch(
479
+ batch: QueuedEvent[],
480
+ identify: PendingIdentify | null,
481
+ waiting: QueuedEvent[] = [],
482
+ ): Promise<FlushOutcome> {
483
+ // In flight: kept in storage under INFLIGHT_KEY so a navigation that kills
484
+ // the request does not lose them (recovered on the next page load).
485
+ //
486
+ // `waiting` is the rest of a refused batch, split in halves that have not
487
+ // been sent yet. They are off the queue too, so they ride in the in-flight
488
+ // record: they used to live only in a local array, and a page closed in
489
+ // the middle of a split lost every one of them.
490
+ this.inflightEvents = waiting.length ? [...batch, ...waiting] : batch;
491
+ if (this.persistQueue) {
492
+ this.persistQueueToStorage();
493
+ this.persistInflightToStorage();
494
+ }
495
+ let outcome: FlushOutcome;
496
+ try {
497
+ outcome = (await this.flushCallback!(batch, identify ?? undefined)) || { status: 'ok' };
498
+ } catch (error) {
499
+ this.logger.error('Failed to flush events:', error);
500
+ outcome = { status: 'retry' };
501
+ }
502
+ this.inflightEvents = [];
503
+ if (this.persistQueue) this.storage.remove(INFLIGHT_KEY);
504
+ return outcome;
505
+ }
506
+
507
+ /**
508
+ * Send what is queued, in requests of at most min(batchSize, 500) events,
509
+ * one identity per request. If a flush is already in progress, waits for it,
510
+ * then sends whatever is still queued - so identify() can rely on flush()
511
+ * covering its events.
512
+ *
513
+ * Never blocks the queue on one bad request: see FlushOutcome.
514
+ *
515
+ * @param throwOnError - reject when events could not be delivered (retry /
516
+ * suspended), so a caller can distinguish success from failure.
517
+ * @param force - an explicit flush (flush(), identify, reset): skips the
518
+ * SDK's own backoff, never the server's Retry-After (contract item 35).
519
+ */
520
+ async flush(throwOnError: boolean = false, force: boolean = false): Promise<void> {
521
+ // A flush in progress is waited for even when nothing is queued: it may
522
+ // be carrying the only pending identify, and `await flush()` must mean
523
+ // "what was queued has been dealt with".
524
+ if (this.queue.length === 0 && !this.pendingIdentifies.length && !this.isFlushing) {
525
+ return;
526
+ }
527
+ if (this.paused || this.suspended) {
528
+ if (throwOnError) throw new Error(this.paused ? 'Sending is paused' : 'Sending is suspended');
529
+ return;
530
+ }
531
+
532
+ if (this.isFlushing) {
533
+ // Wait for the in-progress flush to finish
534
+ await new Promise<void>((resolve) => this.flushWaiters.push(resolve));
535
+ // Flush any events that were enqueued during the previous flush
536
+ if (this.queue.length > 0 || this.pendingIdentifies.length) {
537
+ return this.flush(throwOnError, force);
538
+ }
539
+ return;
540
+ }
541
+
542
+ const now = Date.now();
543
+ if (now < this.retryAfterUntil || (!force && now < this.nextAttemptAt) || isOffline()) {
544
+ if (throwOnError) throw new Error('Waiting to retry');
545
+ return;
546
+ }
547
+
548
+ if (!this.flushCallback) {
549
+ this.logger.warn('No flush callback set, cannot flush queue');
550
+ return;
551
+ }
552
+
553
+ this.isFlushing = true;
554
+ let failure: string | null = null;
555
+ const generation = this.generation;
556
+ try {
557
+ // An identify whose request was refused together with events is
558
+ // retried ALONE after them, so a bad identify cannot make good events
559
+ // look bad (and the reverse).
560
+ let identifyAloneLater: PendingIdentify | null = null;
561
+ // Halves of a refused batch, waiting their turn (ahead of the queue).
562
+ const splits: QueuedEvent[][] = [];
563
+ // Bound the work to what was queued when the flush began, so a page
564
+ // that tracks continuously cannot keep one flush running forever.
565
+ let budget = this.queue.length;
566
+ // Identifies already sent alone in this flush (no second try now).
567
+ const triedAlone = new Set<PendingIdentify>();
568
+
569
+ for (;;) {
570
+ let batch: QueuedEvent[];
571
+ if (splits.length > 0) {
572
+ batch = splits.shift()!;
573
+ } else if (budget > 0) {
574
+ batch = this.takeBatch();
575
+ budget -= batch.length;
576
+ if (batch.length === 0) budget = 0;
577
+ } else if (
578
+ this.pendingIdentifies[0] &&
579
+ !triedAlone.has(this.pendingIdentifies[0]) &&
580
+ this.pendingIdentifies[0] !== identifyAloneLater &&
581
+ this.queue.length > 0 &&
582
+ identifyMatches(this.pendingIdentifies[0], [this.queue[0]])
583
+ ) {
584
+ // An identify made while this flush was running: the events of its
585
+ // anonymous id queued before it go WITH it, not after it alone.
586
+ batch = this.takeBatch();
587
+ } else {
588
+ batch = [];
589
+ }
590
+
591
+ // The oldest identify rides with the batch it follows - the events
592
+ // of the anonymous id it merges, or its own user's. A batch of
593
+ // another identity goes without it, and an identify with no batch
594
+ // left goes alone at the end.
595
+ const head = this.pendingIdentifies[0] || null;
596
+ let identify: PendingIdentify | null = null;
597
+ if (head && !triedAlone.has(head)) {
598
+ if (batch.length === 0) identify = head;
599
+ else if (head !== identifyAloneLater && identifyMatches(head, batch)) identify = head;
600
+ }
601
+
602
+ if (batch.length === 0 && !identify) break;
603
+ if (batch.length === 0 && identify) triedAlone.add(identify);
604
+
605
+ this.logger.debug(`Flushing ${batch.length} events${identify ? ' + identify' : ''}`);
606
+ const outcome = await this.sendBatch(batch, identify, splits.flat());
607
+
608
+ if (this.generation !== generation) {
609
+ // clear() ran while this request was in flight (opt-out, consent
610
+ // denied): whatever the outcome, nothing of it may come back.
611
+ this.logger.debug('Queue cleared during a flush; the in-flight batch is not restored');
612
+ if (this.clearedInflightCallback) {
613
+ let delivered: QueuedEvent[] = [];
614
+ if (outcome.status === 'ok') {
615
+ const notStored = new Set([...(outcome.failedIndices || []), ...(outcome.rejectedIndices || [])]);
616
+ delivered = batch.filter((_, i) => !notStored.has(i));
617
+ }
618
+ try {
619
+ this.clearedInflightCallback(delivered);
620
+ } catch (error) {
621
+ this.logger.warn('Cleared in-flight callback failed:', error);
622
+ }
623
+ }
624
+ break;
625
+ }
626
+
627
+ if (outcome.status === 'dropped') {
628
+ this.logger.error(
629
+ `${batch.length} event(s)${identify ? ' and an identify' : ''} of a user who is no longer signed in were ` +
630
+ `refused by SDK authentication (${outcome.reason || '401'}) and have been dropped`,
631
+ );
632
+ if (identify) this.removeIdentify(identify);
633
+ continue;
634
+ }
635
+
636
+ if (outcome.status === 'ok') {
637
+ const inBatch = (list?: number[]) =>
638
+ new Set((list || []).filter((i) => Number.isInteger(i) && i >= 0 && i < batch.length));
639
+ const rejected = inBatch(outcome.rejectedIndices);
640
+ for (const i of rejected) {
641
+ this.logger.error(`Event "${batch[i].type}" was refused by the server for good and has been dropped`);
642
+ }
643
+ const failed = [...inBatch(outcome.failedIndices)].filter((i) => !rejected.has(i)).map((i) => batch[i]);
644
+
645
+ if (identify && outcome.identifyDropped) {
646
+ this.logger.error(`Identify for ${identify.userId} was refused by the server; dropping it`);
647
+ this.removeIdentify(identify);
648
+ identify = null;
649
+ }
650
+ if (identify && outcome.identifyFailed) {
651
+ // The events were stored (bar `failedIndices`); the identify was
652
+ // not. Keep it and come back later rather than drop the merge.
653
+ this.identifyAttemptFailed(identify, outcome.identifyAttemptCounts !== false);
654
+ this.restoreToFront([...failed, ...splits.flat()]);
655
+ this.backOff();
656
+ failure = 'The identify was not confirmed';
657
+ break;
658
+ }
659
+ if (identify) this.removeIdentify(identify);
660
+ this.consecutiveFailures = 0;
661
+ this.nextAttemptAt = 0;
662
+ if (failed.length > 0) {
663
+ // 2xx with `success: false`: the server stored the others. Keep
664
+ // only what it says it could not store, and come back later.
665
+ this.logger.warn(`${failed.length} of ${batch.length} events were not stored by the server; will retry them`);
666
+ this.restoreToFront([...failed, ...splits.flat()]);
667
+ this.backOff();
668
+ failure = 'Some events were not stored';
669
+ break;
670
+ }
671
+ continue;
672
+ }
673
+
674
+ if (outcome.status === 'rejected') {
675
+ if (identify) {
676
+ if (batch.length === 0) {
677
+ this.logger.error(`Identify for ${identify.userId} was refused by the server (HTTP ${outcome.httpStatus ?? '4xx'}); dropping it`);
678
+ this.removeIdentify(identify);
679
+ continue;
680
+ }
681
+ // Was it the identify or the events? Send the events without
682
+ // it; the identify goes alone at the end.
683
+ identifyAloneLater = identify;
684
+ splits.unshift(batch);
685
+ continue;
686
+ }
687
+ if (batch.length > 1) {
688
+ const mid = Math.ceil(batch.length / 2);
689
+ splits.unshift(batch.slice(0, mid), batch.slice(mid));
690
+ continue;
691
+ }
692
+ if (batch.length === 1) {
693
+ this.logger.error(
694
+ `Event "${batch[0].type}" was refused by the server (HTTP ${outcome.httpStatus ?? '4xx'}) and has been dropped`,
695
+ );
696
+ }
697
+ continue;
698
+ }
699
+
700
+ // retry / suspended: keep everything, in order.
701
+ this.restoreToFront([...batch, ...splits.flat()]);
702
+ if (identify && outcome.status === 'retry') this.identifyAttemptFailed(identify, outcome.counts === true);
703
+ if (outcome.status === 'suspended') {
704
+ if (!this.suspended) {
705
+ this.suspended = true;
706
+ // eslint-disable-next-line no-console -- the logger is silent by default and the site owner must see this
707
+ console.warn(
708
+ `[Joryio SDK] Event sending stopped for this page view (${outcome.reason || 'refused'}). ` +
709
+ `${this.queue.length} queued event(s) are kept.`,
710
+ );
711
+ }
712
+ if (this.flushTimer) {
713
+ clearInterval(this.flushTimer);
714
+ this.flushTimer = null;
715
+ }
716
+ failure = 'Sending is suspended';
717
+ } else {
718
+ this.backOff(outcome.retryAfterMs);
719
+ failure = 'Events could not be delivered; will retry';
720
+ }
721
+ break;
722
+ }
723
+
724
+ if (this.persistQueue) {
725
+ this.persistQueueToStorage();
726
+ }
727
+ } finally {
728
+ this.isFlushing = false;
729
+ // Wake up any callers that were waiting for this flush to complete
730
+ const waiters = this.flushWaiters;
731
+ this.flushWaiters = [];
732
+ waiters.forEach((resolve) => resolve());
733
+ }
734
+ if (failure && throwOnError) {
735
+ throw new Error(failure);
736
+ }
737
+ }
738
+
739
+ /**
740
+ * Persist queue to storage
741
+ */
742
+ private persistQueueToStorage(): void {
743
+ this.pendingPersist = false;
744
+ if (this.persistTimer !== null) {
745
+ clearTimeout(this.persistTimer);
746
+ this.persistTimer = null;
747
+ }
748
+ try {
749
+ this.storage.set(QUEUE_KEY, this.queue);
750
+ } catch (error) {
751
+ this.logger.error('Failed to persist queue to storage:', error);
752
+ }
753
+ }
754
+
755
+ /**
756
+ * Persist soon, coalescing a burst of events into ONE write.
757
+ *
758
+ * `enqueue` used to call [persistQueueToStorage] directly, so every single
759
+ * event re-serialized the WHOLE queue and wrote it to localStorage — both
760
+ * synchronous, on the main thread. That is O(queue) per event, i.e. O(N²) to
761
+ * fill the queue: measured at ~261ms of JSON.stringify alone to reach the
762
+ * 1000-event cap (vs ~1.5ms if each event were serialized once), before the
763
+ * localStorage write of a ~295KB string is even counted.
764
+ *
765
+ * It matters most in the worst place: the queue only grows when flushes are
766
+ * failing, so the pathological cost lands on a device that is already on a bad
767
+ * connection.
768
+ *
769
+ * Durability is unchanged in every case that can actually lose data — the
770
+ * unload path and flush persist synchronously (see [persistNow]) — so the only
771
+ * exposure is a hard crash inside the coalescing window.
772
+ */
773
+ private schedulePersist(): void {
774
+ if (!this.persistQueue || this.pendingPersist) return;
775
+ this.pendingPersist = true;
776
+ this.persistTimer = setTimeout(() => {
777
+ this.persistTimer = null;
778
+ if (this.pendingPersist) this.persistQueueToStorage();
779
+ }, PERSIST_COALESCE_MS);
780
+ }
781
+
782
+ /**
783
+ * Write immediately if a coalesced write is still outstanding. Call before
784
+ * anything that must not lose events: page unload, flush, teardown.
785
+ */
786
+ persistNow(): void {
787
+ if (this.persistQueue && this.pendingPersist) {
788
+ this.persistQueueToStorage();
789
+ }
790
+ }
791
+
792
+ /**
793
+ * Persist in-flight events to storage so they survive page navigation
794
+ */
795
+ private persistInflightToStorage(): void {
796
+ try {
797
+ if (this.inflightEvents.length > 0) {
798
+ this.storage.set(INFLIGHT_KEY, this.inflightEvents);
799
+ } else {
800
+ this.storage.remove(INFLIGHT_KEY);
801
+ }
802
+ } catch (error) {
803
+ this.logger.error('Failed to persist in-flight events to storage:', error);
804
+ }
805
+ }
806
+
807
+ /**
808
+ * Get queue size
809
+ */
810
+ getQueueSize(): number {
811
+ return this.queue.length;
812
+ }
813
+
814
+ /**
815
+ * Get a copy of all queued AND in-flight events (for the page-unload send).
816
+ * In-flight events are included because the request may be canceled by navigation.
817
+ */
818
+ getQueuedEvents(): QueuedEvent[] {
819
+ return [...this.inflightEvents, ...this.queue];
820
+ }
821
+
822
+ /** Queued events not yet handed to a request (no in-flight ones). */
823
+ getWaitingEvents(): QueuedEvent[] {
824
+ return [...this.queue];
825
+ }
826
+
827
+ /** In-flight events (a request is carrying them now). */
828
+ getInflightEvents(): QueuedEvent[] {
829
+ return [...this.inflightEvents];
830
+ }
831
+
832
+ /** See clearedInflightCallback. */
833
+ setClearedInflightCallback(cb: ((delivered: QueuedEvent[]) => void) | null): void {
834
+ this.clearedInflightCallback = cb;
835
+ }
836
+
837
+ /**
838
+ * The page is going away and these events (and identify) were handed to the
839
+ * browser in keepalive requests: stop holding them. ONLY these - events the
840
+ * unload path could not fit stay queued and persisted for the next page.
841
+ */
842
+ markHandedOff(events: QueuedEvent[], identify: PendingIdentify | null): void {
843
+ const ids = new Set(events.map((e) => e.eventId));
844
+ this.queue = this.queue.filter((e) => !ids.has(e.eventId));
845
+ this.inflightEvents = this.inflightEvents.filter((e) => !ids.has(e.eventId));
846
+ if (identify) this.removeIdentify(identify);
847
+ if (this.persistQueue) {
848
+ this.persistQueueToStorage();
849
+ this.persistInflightToStorage();
850
+ }
851
+ }
852
+
853
+ /**
854
+ * A keepalive hand-off failed and the page is still here (bfcache, a
855
+ * cancelled unload): take the events back.
856
+ */
857
+ takeBack(events: QueuedEvent[], identify: PendingIdentify | null): void {
858
+ this.restoreToFront(events);
859
+ if (identify && !this.pendingIdentifies.includes(identify)) {
860
+ this.pendingIdentifies.unshift(identify);
861
+ this.persistIdentifies();
862
+ }
863
+ if (this.persistQueue) this.persistQueueToStorage();
864
+ }
865
+
866
+ /**
867
+ * Clear the queue
868
+ */
869
+ clear(): void {
870
+ this.generation++;
871
+ this.queue = [];
872
+ this.inflightEvents = [];
873
+ if (this.persistQueue) {
874
+ this.storage.remove(QUEUE_KEY);
875
+ this.storage.remove(INFLIGHT_KEY);
876
+ this.storage.remove(PENDING_IDENTIFY_KEY);
877
+ }
878
+ this.pendingIdentifies = [];
879
+ this.logger.debug('Queue cleared');
880
+ }
881
+
882
+ /**
883
+ * Destroy the queue manager
884
+ */
885
+ async destroy(): Promise<void> {
886
+ this.stop();
887
+
888
+ // Final flush attempt
889
+ if (this.queue.length > 0) {
890
+ this.logger.debug('Flushing remaining events before destroy');
891
+ await this.flush();
892
+ }
893
+ }
894
+ }