onerway-analytics 1.3.0 → 1.5.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.
@@ -1,7 +1,6 @@
1
1
  import { API_KEY_MAP, DEFAULT_CONFIG, STORAGE_KEYS, } from '../types';
2
2
  import { SessionManager } from './session-manager';
3
- import { Sender } from './sender';
4
- import { StorageQueue } from './storage';
3
+ import { DeliveryQueue } from './delivery-queue';
5
4
  import { collectContext, collectMarketing } from './context-collector';
6
5
  import { initAutoTrack } from './auto-track';
7
6
  import { initErrorMonitor } from './error-monitor';
@@ -17,8 +16,8 @@ export class Analytics {
17
16
  this.userId = null;
18
17
  this.userProps = {};
19
18
  this.initialized = false;
20
- this.retrying = 0;
21
19
  this.unloadHandler = null;
20
+ this.visibilityHandler = null;
22
21
  this.gtm = new GTMManager();
23
22
  this.config = {
24
23
  debug: false,
@@ -37,9 +36,13 @@ export class Analytics {
37
36
  this.batchPageId = this.generateBatchPageId();
38
37
  this.sessionManager = new SessionManager(this.config.sessionTimeout);
39
38
  this.sampled = this.resolveSampling();
40
- if (isBrowser()) {
41
- this.recoverFailedEvents();
42
- }
39
+ this.delivery = new DeliveryQueue({
40
+ serverUrl: this.config.serverUrl,
41
+ apiKey: this.resolveApiKey(),
42
+ appKey,
43
+ maxRetries: this.config.maxRetryCount,
44
+ persistKeyEvents: this.config.persistKeyEvents === true && this.sampled && !this.config.disableServer,
45
+ });
43
46
  this.registerBeforeUnload();
44
47
  if (this.config.autoTrack && isBrowser()) {
45
48
  initAutoTrack(this, this.config.autoTrackSelector);
@@ -131,6 +134,7 @@ export class Analytics {
131
134
  this.userId = null;
132
135
  this.userProps = {};
133
136
  this.buffer = [];
137
+ this.delivery.clear();
134
138
  if (this.bufferTimer) {
135
139
  clearTimeout(this.bufferTimer);
136
140
  this.bufferTimer = null;
@@ -242,11 +246,6 @@ export class Analytics {
242
246
  }
243
247
  if (this.buffer.length === 0)
244
248
  return;
245
- // Wait for inflight retries to settle before sending a new batch
246
- if (this.retrying > 0) {
247
- this.scheduleBufferFlush();
248
- return;
249
- }
250
249
  const events = this.buffer.splice(0, this.buffer.length);
251
250
  events.forEach((e, i) => {
252
251
  e.batch_event_index = i;
@@ -260,52 +259,14 @@ export class Analytics {
260
259
  this.log('Flushing key event:', event.event_name);
261
260
  this.sendBatch([event], 0);
262
261
  }
263
- async sendBatch(events, retryCount) {
264
- if (events.length === 0)
265
- return;
266
- const apiKey = this.resolveApiKey();
267
- if (!apiKey) {
268
- this.log('No API key configured, dropping events (retry disabled)');
269
- // HOTFIX (production): localStorage persistence disabled. Original:
270
- // for (const event of events) {
271
- // StorageQueue.enqueue(event);
272
- // }
262
+ sendBatch(events, _retryCount) {
263
+ if (!events.length || this.config.disableServer)
273
264
  return;
274
- }
275
- const payload = this.buildPayload(events);
276
265
  try {
277
- await Sender.send(payload, this.config.serverUrl, apiKey);
278
- this.log(`Batch sent successfully: ${events.length} events`);
266
+ this.delivery.enqueue(this.buildPayload(events));
279
267
  }
280
268
  catch (error) {
281
- this.log(`Batch send failed (attempt ${retryCount + 1}/${this.config.maxRetryCount + 1}):`, error);
282
- // ====================================================================
283
- // HOTFIX (production): retry / localStorage persistence DISABLED.
284
- // Reason: retry loop + parallel recoverFailedEvents + fire-and-forget
285
- // setTimeout caused 70+ concurrent in-flight requests on network errors.
286
- // DO NOT DELETE the block below — the original implementation must be
287
- // restored once the retry mechanism is rewritten (track via ticket).
288
- // ====================================================================
289
- const retryEnabled = Analytics.RETRY_ENABLED;
290
- if (retryEnabled && retryCount < this.config.maxRetryCount) {
291
- const delay = 1000 * Math.pow(2, retryCount);
292
- this.log(`Retrying in ${delay}ms...`);
293
- this.retrying += 1;
294
- setTimeout(() => {
295
- this.retrying -= 1;
296
- this.sendBatch(events, retryCount + 1);
297
- if (this.buffer.length > 0) {
298
- this.scheduleBufferFlush();
299
- }
300
- }, delay);
301
- }
302
- else {
303
- this.log('Max retries reached / retry disabled, dropping events');
304
- // HOTFIX (production): localStorage persistence disabled. Original:
305
- // for (const event of events) {
306
- // StorageQueue.enqueue(event);
307
- // }
308
- }
269
+ this.log('Unable to prepare analytics payload:', error);
309
270
  }
310
271
  }
311
272
  buildPayload(events) {
@@ -319,38 +280,15 @@ export class Analytics {
319
280
  events,
320
281
  };
321
282
  }
322
- recoverFailedEvents() {
323
- if (this.config.disableServer)
324
- return;
325
- if (!this.sampled)
326
- return;
327
- if (!Analytics.RETRY_ENABLED) {
328
- // HOTFIX (production): storage recovery disabled. Clean any stale items
329
- // so they don't accumulate forever.
330
- StorageQueue.clear();
331
- return;
332
- }
333
- const failedEvents = StorageQueue.recover();
334
- if (failedEvents.length > 0) {
335
- this.log(`Recovered ${failedEvents.length} failed events from storage`);
336
- for (const event of failedEvents) {
337
- event.batch_ordering_id = this.generateBatchOrderingId();
338
- event.batch_page_id = this.batchPageId;
339
- event.batch_event_index = 0;
340
- }
341
- const batchSize = this.config.bufferMaxSize;
342
- for (let i = 0; i < failedEvents.length; i += batchSize) {
343
- const batch = failedEvents.slice(i, i + batchSize);
344
- batch.forEach((e, idx) => {
345
- e.batch_event_index = idx;
346
- });
347
- this.sendBatch(batch, 0);
348
- }
349
- }
350
- }
351
283
  destroy() {
284
+ this.flushBuffer();
285
+ this.delivery.destroy();
286
+ if (this.visibilityHandler) {
287
+ document.removeEventListener('visibilitychange', this.visibilityHandler);
288
+ window.removeEventListener('pageshow', this.visibilityHandler);
289
+ this.visibilityHandler = null;
290
+ }
352
291
  if (this.unloadHandler) {
353
- window.removeEventListener('beforeunload', this.unloadHandler);
354
292
  window.removeEventListener('pagehide', this.unloadHandler);
355
293
  this.unloadHandler = null;
356
294
  }
@@ -362,51 +300,22 @@ export class Analytics {
362
300
  registerBeforeUnload() {
363
301
  if (!isBrowser() || this.config.disableServer)
364
302
  return;
365
- const handler = () => {
366
- if (this.buffer.length === 0)
367
- return;
368
- const events = this.buffer.splice(0, this.buffer.length);
369
- events.forEach((e, i) => {
370
- e.batch_event_index = i;
371
- });
372
- const apiKey = this.resolveApiKey();
373
- if (!apiKey)
374
- return;
375
- const payload = this.buildPayload(events);
376
- try {
377
- if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
378
- const url = `${this.config.serverUrl}${this.config.serverUrl.includes('?') ? '&' : '?'}apiKey=${encodeURIComponent(apiKey)}`;
379
- const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' });
380
- const success = navigator.sendBeacon(url, blob);
381
- if (!success)
382
- throw new Error('sendBeacon rejected');
383
- }
384
- else {
385
- // XHR sync as fallback — tries to send but doesn't block page unload
386
- try {
387
- const xhr = new XMLHttpRequest();
388
- xhr.open('POST', this.config.serverUrl, false);
389
- xhr.setRequestHeader('Content-Type', 'application/json');
390
- xhr.setRequestHeader('apiKey', apiKey);
391
- xhr.timeout = 2000;
392
- xhr.send(JSON.stringify(payload));
393
- }
394
- catch {
395
- // XHR failed during unload
396
- }
397
- }
398
- }
399
- catch {
400
- // HOTFIX (production): localStorage persistence disabled. Original:
401
- // for (const event of events) {
402
- // StorageQueue.enqueue(event);
403
- // }
404
- this.log('Failed to flush buffer on unload, dropping events (retry disabled)');
303
+ this.unloadHandler = () => {
304
+ this.delivery.setHidden(true);
305
+ this.flushBuffer();
306
+ this.delivery.flushHidden();
307
+ };
308
+ this.visibilityHandler = () => {
309
+ const hidden = document.visibilityState === 'hidden';
310
+ this.delivery.setHidden(hidden);
311
+ if (hidden) {
312
+ this.flushBuffer();
313
+ this.delivery.flushHidden();
405
314
  }
406
315
  };
407
- this.unloadHandler = handler;
408
- window.addEventListener('beforeunload', handler);
409
- window.addEventListener('pagehide', handler);
316
+ window.addEventListener('pagehide', this.unloadHandler);
317
+ document.addEventListener('visibilitychange', this.visibilityHandler);
318
+ window.addEventListener('pageshow', this.visibilityHandler);
410
319
  }
411
320
  trackPageView() {
412
321
  if (!isBrowser())
@@ -419,12 +328,6 @@ export class Analytics {
419
328
  });
420
329
  }
421
330
  }
422
- // HOTFIX (production): retry / recovery / localStorage persistence DISABLED.
423
- // Reason: retry loop + parallel recoverFailedEvents + fire-and-forget
424
- // setTimeout caused 70+ concurrent in-flight requests on network errors.
425
- // Flip this flag to true (and uncomment the StorageQueue.enqueue blocks)
426
- // to restore the original behavior once the retry mechanism is rewritten.
427
- Analytics.RETRY_ENABLED = false;
428
331
  export function createAnalytics(config) {
429
332
  return new Analytics(config);
430
333
  }
@@ -0,0 +1,41 @@
1
+ import { AnalyticsPayload } from '../types';
2
+ interface Options {
3
+ serverUrl: string;
4
+ apiKey: string;
5
+ appKey: string;
6
+ maxRetries: number;
7
+ persistKeyEvents: boolean;
8
+ }
9
+ /** First attempts are immediate. Only retries/recovery share a two-request pool. */
10
+ export declare class DeliveryQueue {
11
+ private options;
12
+ private jobs;
13
+ private activeRetries;
14
+ private hidden;
15
+ private timer;
16
+ private stopped;
17
+ private storagePrefix;
18
+ private maxAttempts;
19
+ private hiddenSent;
20
+ private hiddenSpent;
21
+ private keepaliveBytes;
22
+ constructor(options: Options);
23
+ enqueue(payload: AnalyticsPayload): void;
24
+ setHidden(hidden: boolean): void;
25
+ /** Submit the whole affordable rescue set synchronously; do not wait for ACKs. */
26
+ flushHidden(): void;
27
+ private bytes;
28
+ private batches;
29
+ private prune;
30
+ private pumpRetries;
31
+ private start;
32
+ private deliver;
33
+ private key;
34
+ private persist;
35
+ private remove;
36
+ private recover;
37
+ clear(): void;
38
+ destroy(): void;
39
+ }
40
+ export {};
41
+ //# sourceMappingURL=delivery-queue.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"delivery-queue.d.ts","sourceRoot":"","sources":["../../src/core/delivery-queue.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AAgB5C,UAAU,OAAO;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,gBAAgB,EAAE,OAAO,CAAC;CAC3B;AAED,oFAAoF;AACpF,qBAAa,aAAa;IAYZ,OAAO,CAAC,OAAO;IAX3B,OAAO,CAAC,IAAI,CAAa;IACzB,OAAO,CAAC,aAAa,CAAK;IAC1B,OAAO,CAAC,MAAM,CAA4E;IAC1F,OAAO,CAAC,KAAK,CAA8C;IAC3D,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,aAAa,CAAS;IAC9B,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,UAAU,CAAkB;IACpC,OAAO,CAAC,WAAW,CAAK;IACxB,OAAO,CAAC,cAAc,CAAK;gBAEP,OAAO,EAAE,OAAO;IAOpC,OAAO,CAAC,OAAO,EAAE,gBAAgB,GAAG,IAAI;IAqBxC,SAAS,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI;IAWhC,kFAAkF;IAClF,WAAW,IAAI,IAAI;IAmBnB,OAAO,CAAC,KAAK;IAIb,OAAO,CAAC,OAAO;IA6Bf,OAAO,CAAC,KAAK;IAMb,OAAO,CAAC,WAAW;IAgBnB,OAAO,CAAC,KAAK;YAaC,OAAO;IAyBrB,OAAO,CAAC,GAAG;IAEX,OAAO,CAAC,OAAO;IAOf,OAAO,CAAC,MAAM;IAOd,OAAO,CAAC,OAAO;IAoBf,KAAK,IAAI,IAAI;IAKb,OAAO,IAAI,IAAI;CAIhB"}
@@ -0,0 +1,248 @@
1
+ import { Sender, DeliveryError } from './sender';
2
+ import { safeLocalStorageGet, safeLocalStorageSet, safeLocalStorageRemove } from '../utils';
3
+ const MAX_BYTES = 48 * 1024;
4
+ const MAX_EVENTS = 500;
5
+ const MAX_AGE = 24 * 60 * 60 * 1000;
6
+ /** First attempts are immediate. Only retries/recovery share a two-request pool. */
7
+ export class DeliveryQueue {
8
+ constructor(options) {
9
+ this.options = options;
10
+ this.jobs = [];
11
+ this.activeRetries = 0;
12
+ this.hidden = typeof document !== 'undefined' && document.visibilityState === 'hidden';
13
+ this.timer = null;
14
+ this.stopped = false;
15
+ this.hiddenSent = new Set();
16
+ this.hiddenSpent = 0;
17
+ this.keepaliveBytes = 0;
18
+ this.maxAttempts = 1 + Math.min(5, Math.max(0, options.maxRetries || 0));
19
+ this.storagePrefix = `__analytics_delivery_v1__:${encodeURIComponent(options.appKey)}:${encodeURIComponent(options.serverUrl)}:`;
20
+ if (options.persistKeyEvents && options.apiKey)
21
+ this.recover();
22
+ this.pumpRetries();
23
+ }
24
+ enqueue(payload) {
25
+ if (this.stopped || !this.options.apiKey)
26
+ return;
27
+ const accepted = [];
28
+ for (const event of payload.events) {
29
+ if (this.jobs.length >= MAX_EVENTS)
30
+ break;
31
+ const single = { ...payload, events: [event] };
32
+ if (this.bytes(single) > MAX_BYTES)
33
+ continue;
34
+ const job = { payload: single, attempts: 0, createdAt: Date.now(), nextAt: 0,
35
+ active: 0, hiddenActive: false, terminal: false };
36
+ this.jobs.push(job);
37
+ this.persist(job);
38
+ accepted.push(job);
39
+ }
40
+ if (this.hidden) {
41
+ this.flushHidden();
42
+ }
43
+ else {
44
+ // Preserve normal batch delivery, but never queue a first attempt behind retries.
45
+ for (const batch of this.batches(accepted, MAX_BYTES))
46
+ this.start(batch.payload, batch.jobs, false, false);
47
+ }
48
+ }
49
+ setHidden(hidden) {
50
+ if (hidden && !this.hidden) {
51
+ this.hiddenSent.clear();
52
+ this.hiddenSpent = 0;
53
+ }
54
+ this.hidden = hidden;
55
+ if (this.timer) {
56
+ clearTimeout(this.timer);
57
+ this.timer = null;
58
+ }
59
+ if (!hidden)
60
+ this.pumpRetries();
61
+ // Caller flushes the SDK buffer before flushHidden, so critical jobs take priority.
62
+ }
63
+ /** Submit the whole affordable rescue set synchronously; do not wait for ACKs. */
64
+ flushHidden() {
65
+ if (this.stopped || !this.hidden)
66
+ return;
67
+ const available = Math.min(MAX_BYTES - this.hiddenSpent, MAX_BYTES - this.keepaliveBytes);
68
+ if (available <= 0)
69
+ return;
70
+ this.prune();
71
+ const candidates = this.jobs.filter(job => !job.terminal && !job.hiddenActive &&
72
+ !this.hiddenSent.has(job) && job.attempts < this.maxAttempts)
73
+ .sort((a, b) => Number(b.payload.events[0].key_event) - Number(a.payload.events[0].key_event));
74
+ let remaining = available;
75
+ for (const batch of this.batches(candidates, available, true)) {
76
+ const bytes = this.bytes(batch.payload);
77
+ if (bytes > remaining)
78
+ continue;
79
+ remaining -= bytes;
80
+ this.hiddenSpent += bytes;
81
+ for (const job of batch.jobs)
82
+ this.hiddenSent.add(job);
83
+ this.start(batch.payload, batch.jobs, true, false);
84
+ }
85
+ }
86
+ bytes(payload) {
87
+ return new Blob([JSON.stringify(payload)]).size;
88
+ }
89
+ batches(jobs, budget, totalBudget = false) {
90
+ const result = [];
91
+ let remaining = budget;
92
+ // Context/client/session/marketing/user fields must match before events can share a request.
93
+ const groups = new Map();
94
+ for (const job of jobs) {
95
+ const { events, ...envelope } = job.payload;
96
+ const key = JSON.stringify(envelope);
97
+ let group = groups.get(key);
98
+ const proposed = { ...envelope, events: [...(group?.payload.events || []), ...events]
99
+ .map((event, index) => ({ ...event, batch_event_index: index })) };
100
+ const delta = this.bytes(proposed) - (group ? this.bytes(group.payload) : 0);
101
+ if (totalBudget && delta > remaining)
102
+ continue;
103
+ if (this.bytes(proposed) > budget) {
104
+ group = undefined;
105
+ }
106
+ if (!group) {
107
+ group = { payload: { ...envelope, events: events.map((event, index) => ({ ...event, batch_event_index: index })) }, jobs: [] };
108
+ groups.set(key, group);
109
+ result.push(group);
110
+ }
111
+ else {
112
+ group.payload = proposed;
113
+ }
114
+ group.jobs.push(job);
115
+ if (totalBudget)
116
+ remaining -= delta;
117
+ }
118
+ return result;
119
+ }
120
+ prune() {
121
+ for (const job of [...this.jobs]) {
122
+ if (!job.active && (job.terminal || Date.now() - job.createdAt >= MAX_AGE || job.attempts >= this.maxAttempts))
123
+ this.remove(job);
124
+ }
125
+ }
126
+ pumpRetries() {
127
+ if (this.stopped || this.hidden)
128
+ return;
129
+ if (this.timer) {
130
+ clearTimeout(this.timer);
131
+ this.timer = null;
132
+ }
133
+ this.prune();
134
+ while (this.activeRetries < 2) {
135
+ const job = this.jobs.find(item => !item.active && item.nextAt <= Date.now());
136
+ if (!job)
137
+ break;
138
+ this.start(job.payload, [job], false, true);
139
+ }
140
+ const waiting = this.jobs.filter(job => !job.active);
141
+ if (waiting.length && this.activeRetries < 2) {
142
+ const delay = Math.max(1, Math.min(...waiting.map(job => job.nextAt)) - Date.now());
143
+ this.timer = setTimeout(() => this.pumpRetries(), delay);
144
+ }
145
+ }
146
+ start(payload, jobs, keepalive, retry) {
147
+ const bytes = keepalive ? this.bytes(payload) : 0;
148
+ this.keepaliveBytes += bytes;
149
+ if (retry)
150
+ this.activeRetries++;
151
+ for (const job of jobs) {
152
+ job.active++;
153
+ job.attempts++;
154
+ if (keepalive)
155
+ job.hiddenActive = true;
156
+ this.persist(job);
157
+ }
158
+ void this.deliver(payload, jobs, keepalive, retry, bytes);
159
+ }
160
+ async deliver(payload, jobs, keepalive, retry, bytes) {
161
+ try {
162
+ await Sender.send(payload, this.options.serverUrl, this.options.apiKey, keepalive);
163
+ for (const job of jobs)
164
+ this.remove(job);
165
+ }
166
+ catch (error) {
167
+ for (const job of jobs) {
168
+ if (!this.jobs.includes(job))
169
+ continue; // A concurrent rescue/normal request may have ACKed already.
170
+ if (error instanceof DeliveryError && !error.retryable)
171
+ job.terminal = true;
172
+ job.nextAt = Date.now() + Math.min(30000, 1000 * 2 ** (job.attempts - 1)) + Math.floor(Math.random() * 250);
173
+ }
174
+ }
175
+ finally {
176
+ for (const job of jobs) {
177
+ job.active--;
178
+ if (keepalive)
179
+ job.hiddenActive = false;
180
+ if (!this.jobs.includes(job))
181
+ continue;
182
+ // Wait for both original and rescue outcomes before retiring a failed event.
183
+ if (!job.active && (job.terminal || job.attempts >= this.maxAttempts))
184
+ this.remove(job);
185
+ else
186
+ this.persist(job);
187
+ }
188
+ this.keepaliveBytes -= bytes;
189
+ if (retry)
190
+ this.activeRetries--;
191
+ this.pumpRetries();
192
+ }
193
+ }
194
+ key(eventId) { return this.storagePrefix + eventId; }
195
+ persist(job) {
196
+ if (!this.options.persistKeyEvents || !job.payload.events[0].key_event)
197
+ return;
198
+ safeLocalStorageSet(this.key(job.payload.events[0].event_id), JSON.stringify({
199
+ payload: job.payload, attempts: job.attempts, createdAt: job.createdAt, nextAt: job.nextAt,
200
+ }));
201
+ }
202
+ remove(job) {
203
+ if (!this.jobs.includes(job))
204
+ return;
205
+ this.jobs = this.jobs.filter(item => item !== job);
206
+ this.hiddenSent.delete(job);
207
+ if (this.options.persistKeyEvents)
208
+ safeLocalStorageRemove(this.key(job.payload.events[0].event_id));
209
+ }
210
+ recover() {
211
+ try {
212
+ const keys = Object.keys(localStorage).filter(key => key.startsWith(this.storagePrefix));
213
+ for (const key of keys) {
214
+ try {
215
+ const job = JSON.parse(safeLocalStorageGet(key) || 'null');
216
+ if (!job || !Number.isFinite(job.createdAt) || !Number.isInteger(job.attempts) || job.attempts < 0 ||
217
+ !Number.isFinite(job.nextAt) || Date.now() - job.createdAt >= MAX_AGE || job.attempts >= this.maxAttempts ||
218
+ !Array.isArray(job.payload?.events) || job.payload.events.length !== 1 ||
219
+ !job.payload.events[0].key_event || this.key(job.payload.events[0].event_id) !== key ||
220
+ this.bytes(job.payload) > MAX_BYTES || this.jobs.length >= MAX_EVENTS) {
221
+ safeLocalStorageRemove(key);
222
+ continue;
223
+ }
224
+ this.jobs.push({ ...job, active: 0, hiddenActive: false, terminal: false });
225
+ }
226
+ catch {
227
+ safeLocalStorageRemove(key);
228
+ }
229
+ }
230
+ }
231
+ catch { /* Storage unavailable: delivery continues in memory. */ }
232
+ }
233
+ clear() {
234
+ for (const job of [...this.jobs])
235
+ this.remove(job);
236
+ if (this.timer) {
237
+ clearTimeout(this.timer);
238
+ this.timer = null;
239
+ }
240
+ }
241
+ destroy() {
242
+ this.stopped = true;
243
+ if (this.timer) {
244
+ clearTimeout(this.timer);
245
+ this.timer = null;
246
+ }
247
+ }
248
+ }
@@ -1,10 +1,14 @@
1
1
  import { AnalyticsPayload } from '../types';
2
+ export declare class DeliveryError extends Error {
3
+ retryable: boolean;
4
+ constructor(message: string, retryable: boolean);
5
+ }
2
6
  export declare class Sender {
3
- static send(payload: AnalyticsPayload, serverUrl: string, apiKey: string): Promise<void>;
7
+ static send(payload: AnalyticsPayload, serverUrl: string, apiKey: string, keepalive?: boolean): Promise<void>;
4
8
  static sendBeacon(payload: AnalyticsPayload, serverUrl: string, apiKey: string): Promise<void>;
5
- static fetchSend(payload: AnalyticsPayload, serverUrl: string, apiKey: string): Promise<void>;
9
+ static fetchSend(payload: AnalyticsPayload, serverUrl: string, apiKey: string, keepalive?: boolean): Promise<void>;
10
+ private static httpError;
6
11
  static xhrSend(payload: AnalyticsPayload, serverUrl: string, apiKey: string): Promise<void>;
7
12
  private static supportsKeepalive;
8
- private static buildBeaconUrl;
9
13
  }
10
14
  //# sourceMappingURL=sender.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"sender.d.ts","sourceRoot":"","sources":["../../src/core/sender.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AAE5C,qBAAa,MAAM;WACJ,IAAI,CAAC,OAAO,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;WAOjF,UAAU,CAAC,OAAO,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;WAcvF,SAAS,CAAC,OAAO,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;WAgBtF,OAAO,CAAC,OAAO,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAuBjG,OAAO,CAAC,MAAM,CAAC,iBAAiB;IAQhC,OAAO,CAAC,MAAM,CAAC,cAAc;CAI9B"}
1
+ {"version":3,"file":"sender.d.ts","sourceRoot":"","sources":["../../src/core/sender.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AAE5C,qBAAa,aAAc,SAAQ,KAAK;IACF,SAAS,EAAE,OAAO;gBAA1C,OAAO,EAAE,MAAM,EAAS,SAAS,EAAE,OAAO;CACvD;AAED,qBAAa,MAAM;WACJ,IAAI,CAAC,OAAO,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,UAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IAYjH,MAAM,CAAC,UAAU,CAAC,OAAO,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;WAIjF,SAAS,CAAC,OAAO,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,UAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IAiBtH,OAAO,CAAC,MAAM,CAAC,SAAS;IAIxB,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAe3F,OAAO,CAAC,MAAM,CAAC,iBAAiB;CAGjC"}
@@ -1,65 +1,61 @@
1
+ export class DeliveryError extends Error {
2
+ constructor(message, retryable) {
3
+ super(message);
4
+ this.retryable = retryable;
5
+ }
6
+ }
1
7
  export class Sender {
2
- static async send(payload, serverUrl, apiKey) {
3
- if (Sender.supportsKeepalive()) {
4
- return Sender.fetchSend(payload, serverUrl, apiKey);
8
+ static async send(payload, serverUrl, apiKey, keepalive = false) {
9
+ if (typeof fetch === 'function' && typeof AbortController !== 'undefined') {
10
+ if (keepalive && !Sender.supportsKeepalive()) {
11
+ throw new DeliveryError('Keepalive unavailable', true);
12
+ }
13
+ return Sender.fetchSend(payload, serverUrl, apiKey, keepalive);
5
14
  }
15
+ if (keepalive)
16
+ throw new DeliveryError('Unload transport unavailable', true);
6
17
  return Sender.xhrSend(payload, serverUrl, apiKey);
7
18
  }
8
- static async sendBeacon(payload, serverUrl, apiKey) {
9
- if (typeof navigator === 'undefined' || !navigator.sendBeacon) {
10
- throw new Error('sendBeacon not available');
19
+ // Compatibility entry point: authenticated delivery must support custom headers.
20
+ static sendBeacon(payload, serverUrl, apiKey) {
21
+ return Sender.send(payload, serverUrl, apiKey, true);
22
+ }
23
+ static async fetchSend(payload, serverUrl, apiKey, keepalive = false) {
24
+ const controller = new AbortController();
25
+ const timer = setTimeout(() => controller.abort(), 10000);
26
+ try {
27
+ const response = await fetch(serverUrl, {
28
+ method: 'POST',
29
+ headers: { 'Content-Type': 'application/json', apiKey },
30
+ body: JSON.stringify(payload),
31
+ keepalive,
32
+ signal: controller.signal,
33
+ });
34
+ if (!response.ok)
35
+ throw Sender.httpError(response.status);
11
36
  }
12
- const url = Sender.buildBeaconUrl(serverUrl, apiKey);
13
- const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' });
14
- const success = navigator.sendBeacon(url, blob);
15
- if (!success) {
16
- throw new Error('sendBeacon rejected payload');
37
+ finally {
38
+ clearTimeout(timer);
17
39
  }
18
40
  }
19
- static async fetchSend(payload, serverUrl, apiKey) {
20
- const response = await fetch(serverUrl, {
21
- method: 'POST',
22
- headers: {
23
- 'Content-Type': 'application/json',
24
- apiKey,
25
- },
26
- body: JSON.stringify(payload),
27
- keepalive: true,
28
- });
29
- if (!response.ok) {
30
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
31
- }
41
+ static httpError(status) {
42
+ return new DeliveryError(`HTTP ${status}`, status === 408 || status === 429 || status >= 500);
32
43
  }
33
- static async xhrSend(payload, serverUrl, apiKey) {
44
+ static xhrSend(payload, serverUrl, apiKey) {
34
45
  return new Promise((resolve, reject) => {
35
46
  const xhr = new XMLHttpRequest();
36
- xhr.open('POST', serverUrl);
47
+ xhr.open('POST', serverUrl, true);
37
48
  xhr.setRequestHeader('Content-Type', 'application/json');
38
49
  xhr.setRequestHeader('apiKey', apiKey);
39
50
  xhr.timeout = 10000;
40
- xhr.onload = () => {
41
- if (xhr.status >= 200 && xhr.status < 300) {
42
- resolve();
43
- }
44
- else {
45
- reject(new Error(`HTTP ${xhr.status}: ${xhr.statusText}`));
46
- }
47
- };
48
- xhr.onerror = () => reject(new Error('XHR network error'));
49
- xhr.ontimeout = () => reject(new Error('XHR timeout'));
51
+ xhr.onload = () => xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(Sender.httpError(xhr.status));
52
+ xhr.onerror = () => reject(new DeliveryError('XHR network error', true));
53
+ xhr.ontimeout = () => reject(new DeliveryError('XHR timeout', true));
54
+ xhr.onabort = () => reject(new DeliveryError('XHR aborted', true));
50
55
  xhr.send(JSON.stringify(payload));
51
56
  });
52
57
  }
53
58
  static supportsKeepalive() {
54
- try {
55
- return typeof Request !== 'undefined' && 'keepalive' in Request.prototype;
56
- }
57
- catch {
58
- return false;
59
- }
60
- }
61
- static buildBeaconUrl(serverUrl, apiKey) {
62
- const separator = serverUrl.includes('?') ? '&' : '?';
63
- return `${serverUrl}${separator}apiKey=${encodeURIComponent(apiKey)}`;
59
+ return typeof Request !== 'undefined' && 'keepalive' in Request.prototype;
64
60
  }
65
61
  }