@faststats/web 0.1.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/replay.ts CHANGED
@@ -33,570 +33,345 @@ export interface ReplayTrackerOptions {
33
33
  recordConsole?: boolean;
34
34
  }
35
35
 
36
- interface PendingBatch {
37
- batch: {
38
- token: string;
39
- sessionId: string | undefined;
40
- sequence: number;
41
- timestamp: number;
42
- url: string;
43
- events: eventWithTime[];
44
- };
45
- isCompressed: boolean;
46
- retries: number;
47
- }
48
-
49
- class ReplayTracker {
36
+ type ReplayBatch = {
37
+ token: string;
38
+ sessionId: string | undefined;
39
+ identifier?: string;
40
+ sequence: number;
41
+ timestamp: number;
42
+ url: string;
43
+ events: eventWithTime[];
44
+ };
45
+
46
+ const defaultSampling: recordOptions<eventWithTime>["sampling"] = {
47
+ mousemove: 50,
48
+ mouseInteraction: true,
49
+ scroll: 150,
50
+ media: 800,
51
+ input: "last",
52
+ };
53
+
54
+ const defaultSlimDOMOptions: SlimDOMOptions = {
55
+ script: true,
56
+ comment: true,
57
+ headFavicon: true,
58
+ headWhitespace: true,
59
+ headMetaDescKeywords: true,
60
+ headMetaSocial: true,
61
+ headMetaRobots: true,
62
+ headMetaHttpEquiv: true,
63
+ headMetaAuthorship: true,
64
+ };
65
+
66
+ export default class ReplayTracker {
50
67
  private readonly endpoint: string;
51
- private readonly siteKey: string;
52
- private readonly debug: boolean;
53
- private readonly flushInterval: number;
54
- private readonly maxEvents: number;
55
- private readonly sampling: recordOptions<eventWithTime>["sampling"];
56
- private readonly slimDOMOptions: SlimDOMOptions;
57
- private readonly maskAllInputs: boolean;
58
- private readonly maskInputOptions?: recordOptions<eventWithTime>["maskInputOptions"];
59
- private readonly blockClass?: string;
60
- private readonly blockSelector?: string;
61
- private readonly maskTextClass?: string;
62
- private readonly maskTextSelector?: string;
63
- private readonly checkoutEveryNms: number;
64
- private readonly checkoutEveryNth?: number;
65
- private readonly samplingPercentage: number;
66
- private readonly recordConsole: boolean;
67
-
68
- private events: eventWithTime[] = [];
69
- private flushTimer: ReturnType<typeof setInterval> | null = null;
70
- private stopRecording: listenerHandler | undefined = undefined;
68
+ private readonly compressionSupported =
69
+ typeof window !== "undefined" && "CompressionStream" in window;
70
+
71
+ private readonly sampled: boolean;
72
+ private readonly events: eventWithTime[] = [];
73
+ private readonly pending: ReplayBatch[] = [];
74
+
71
75
  private started = false;
72
- private startTime: number = 0;
73
- private minLengthFlushScheduled = false;
74
- private sequenceNumber = 0;
75
- private pendingBatches: PendingBatch[] = [];
76
- private isFlushing = false;
77
- private compressionSupported = false;
78
- private readonly sessionSamplingSeed: number;
79
- private readonly minReplayLengthMs: number;
80
- private readonly maxPendingBatches: number;
81
- private flushScheduled = false;
82
- private flushTimeout: ReturnType<typeof setTimeout> | null = null;
83
- private idleFlushId: number | null = null;
84
- private minLengthFlushTimer: ReturnType<typeof setTimeout> | null = null;
85
- private pendingRetryTimer: ReturnType<typeof setTimeout> | null = null;
86
- private isProcessingPending = false;
87
-
88
- constructor(options: ReplayTrackerOptions) {
89
- this.siteKey = options.siteKey;
76
+ private startTime = 0;
77
+ private sequence = 0;
78
+ private intervalId: ReturnType<typeof setInterval> | null = null;
79
+ private flushTask: ReturnType<typeof setTimeout> | null = null;
80
+ private retryTask: ReturnType<typeof setTimeout> | null = null;
81
+ private stopRecording?: listenerHandler;
82
+ private sending = false;
83
+
84
+ constructor(private readonly options: ReplayTrackerOptions) {
90
85
  this.endpoint = replayEventsUrl(options.baseUrl);
91
- this.debug = options.debug ?? false;
92
- this.samplingPercentage = normalizeSamplingPercentage(
93
- options.samplingPercentage,
94
- );
95
- this.flushInterval = options.flushInterval ?? 10_000;
96
- this.maxEvents = options.maxEvents ?? 500;
97
- this.sampling = options.sampling ?? {
98
- mousemove: 50,
99
- mouseInteraction: true,
100
- scroll: 150,
101
- media: 800,
102
- input: "last",
103
- };
104
- this.slimDOMOptions = options.slimDOMOptions ?? {
105
- script: true,
106
- comment: true,
107
- headFavicon: true,
108
- headWhitespace: true,
109
- headMetaDescKeywords: true,
110
- headMetaSocial: true,
111
- headMetaRobots: true,
112
- headMetaHttpEquiv: true,
113
- headMetaAuthorship: true,
114
- };
115
- this.maskAllInputs = options.maskAllInputs ?? true;
116
- this.maskInputOptions = options.maskInputOptions ?? {
117
- password: true,
118
- email: true,
119
- tel: true,
120
- };
121
- this.blockClass = options.blockClass;
122
- this.blockSelector = options.blockSelector;
123
- this.maskTextClass = options.maskTextClass;
124
- this.maskTextSelector = options.maskTextSelector;
125
- this.checkoutEveryNms = options.checkoutEveryNms ?? 60_000;
126
- this.checkoutEveryNth = options.checkoutEveryNth;
127
- this.recordConsole = options.recordConsole ?? true;
128
- this.minReplayLengthMs = options.minReplayLengthMs ?? 3000;
129
- this.maxPendingBatches = options.maxPendingBatches ?? 30;
130
- this.sessionSamplingSeed = Math.random() * 100;
131
-
132
- if (typeof window !== "undefined") {
133
- this.compressionSupported = "CompressionStream" in window;
134
- }
86
+ this.sampled =
87
+ Math.random() * 100 <
88
+ normalizeSamplingPercentage(options.samplingPercentage);
135
89
  }
136
90
 
137
- start(): void {
138
- if (this.started || typeof window === "undefined") return;
91
+ private get debug(): boolean {
92
+ return this.options.debug ?? false;
93
+ }
139
94
 
140
- if (this.samplingPercentage < 100) {
141
- if (this.sessionSamplingSeed >= this.samplingPercentage) {
142
- return;
143
- }
144
- }
95
+ private get flushInterval(): number {
96
+ return this.options.flushInterval ?? 10_000;
97
+ }
145
98
 
146
- this.started = true;
147
- this.startTime = Date.now();
99
+ private get maxEvents(): number {
100
+ return this.options.maxEvents ?? 500;
101
+ }
148
102
 
149
- if (this.debug) {
150
- console.log("[Replay] Recording started");
151
- }
103
+ private get maxPendingBatches(): number {
104
+ return this.options.maxPendingBatches ?? 30;
105
+ }
152
106
 
153
- const recordOptions: recordOptions<eventWithTime> = {
154
- emit: (event, isCheckout) => this.handleEvent(event, isCheckout),
155
- sampling: this.sampling,
156
- slimDOMOptions: this.slimDOMOptions,
157
- maskAllInputs: this.maskAllInputs,
158
- checkoutEveryNms: this.checkoutEveryNms,
159
- };
107
+ private get minReplayLengthMs(): number {
108
+ return this.options.minReplayLengthMs ?? 3000;
109
+ }
160
110
 
161
- if (this.maskInputOptions) {
162
- recordOptions.maskInputOptions = this.maskInputOptions;
163
- }
164
- if (this.blockClass) {
165
- recordOptions.blockClass = this.blockClass;
166
- }
167
- if (this.blockSelector) {
168
- recordOptions.blockSelector = this.blockSelector;
169
- }
170
- if (this.maskTextClass) {
171
- recordOptions.maskTextClass = this.maskTextClass;
172
- }
173
- if (this.maskTextSelector) {
174
- recordOptions.maskTextSelector = this.maskTextSelector;
175
- }
176
- if (this.checkoutEveryNth) {
177
- recordOptions.checkoutEveryNth = this.checkoutEveryNth;
178
- }
179
- recordOptions.plugins = [
180
- getRecordSequentialIdPlugin({
181
- key: RRWEB_SEQUENTIAL_ID_KEY,
182
- }),
183
- ...(this.recordConsole ? [getRecordConsolePlugin()] : []),
184
- ];
185
-
186
- this.stopRecording = record(recordOptions);
187
-
188
- this.flushTimer = setInterval(() => {
189
- this.scheduleFlush();
190
- }, this.flushInterval);
191
-
192
- window.addEventListener("beforeunload", this.handleUnload);
193
- window.addEventListener("pagehide", this.handleUnload);
194
- document.addEventListener("visibilitychange", this.handleVisibilityChange);
195
- this.scheduleMinLengthFlush();
111
+ private log(...args: unknown[]): void {
112
+ if (this.debug) console.log("[Replay]", ...args);
113
+ }
114
+
115
+ start(): void {
116
+ if (this.started || typeof window === "undefined" || !this.sampled) return;
117
+
118
+ this.started = true;
119
+ this.startTime = Date.now();
120
+
121
+ this.stopRecording = record({
122
+ emit: this.onEvent,
123
+ sampling: this.options.sampling ?? defaultSampling,
124
+ slimDOMOptions: this.options.slimDOMOptions ?? defaultSlimDOMOptions,
125
+ maskAllInputs: this.options.maskAllInputs ?? true,
126
+ maskInputOptions: this.options.maskInputOptions ?? {
127
+ password: true,
128
+ email: true,
129
+ tel: true,
130
+ },
131
+ blockClass: this.options.blockClass,
132
+ blockSelector: this.options.blockSelector,
133
+ maskTextClass: this.options.maskTextClass,
134
+ maskTextSelector: this.options.maskTextSelector,
135
+ checkoutEveryNms: this.options.checkoutEveryNms ?? 60_000,
136
+ checkoutEveryNth: this.options.checkoutEveryNth,
137
+ plugins: [
138
+ getRecordSequentialIdPlugin({ key: RRWEB_SEQUENTIAL_ID_KEY }),
139
+ ...((this.options.recordConsole ?? true)
140
+ ? [getRecordConsolePlugin()]
141
+ : []),
142
+ ],
143
+ });
144
+
145
+ this.intervalId = setInterval(this.requestFlush, this.flushInterval);
146
+
147
+ window.addEventListener("beforeunload", this.onUnload);
148
+ window.addEventListener("pagehide", this.onUnload);
149
+ document.addEventListener("visibilitychange", this.onVisibilityChange);
150
+
151
+ this.log("Recording started");
196
152
  }
197
153
 
198
154
  stop(): void {
199
155
  if (!this.started) return;
200
156
  this.started = false;
201
157
 
202
- if (this.debug) {
203
- console.log("[Replay] Recording stopped");
204
- }
205
-
206
158
  this.stopRecording?.();
207
159
  this.stopRecording = undefined;
208
- this.clearScheduledFlush();
209
- this.clearMinLengthFlushTimer();
210
- this.clearPendingRetryTimer();
211
160
 
212
- if (this.flushTimer) {
213
- clearInterval(this.flushTimer);
214
- this.flushTimer = null;
215
- }
161
+ if (this.intervalId) clearInterval(this.intervalId);
162
+ if (this.flushTask) clearTimeout(this.flushTask);
163
+ if (this.retryTask) clearTimeout(this.retryTask);
216
164
 
217
- window.removeEventListener("beforeunload", this.handleUnload);
218
- window.removeEventListener("pagehide", this.handleUnload);
219
- document.removeEventListener(
220
- "visibilitychange",
221
- this.handleVisibilityChange,
222
- );
165
+ this.intervalId = null;
166
+ this.flushTask = null;
167
+ this.retryTask = null;
168
+
169
+ window.removeEventListener("beforeunload", this.onUnload);
170
+ window.removeEventListener("pagehide", this.onUnload);
171
+ document.removeEventListener("visibilitychange", this.onVisibilityChange);
223
172
 
224
173
  if (!this.hasReachedMinLength()) {
225
- this.events = [];
226
- if (this.debug) {
227
- console.log(
228
- `[Replay] Session too short (${Date.now() - this.startTime}ms), discarding events`,
229
- );
230
- }
174
+ this.events.length = 0;
175
+ this.log(
176
+ `Session too short (${Date.now() - this.startTime}ms), discarding events`,
177
+ );
231
178
  return;
232
179
  }
233
180
 
234
- void this.flush();
181
+ void this.flush(true);
182
+ this.log("Recording stopped");
235
183
  }
236
184
 
237
- private handleEvent(event: eventWithTime, isCheckout?: boolean): void {
238
- this.events.push(event);
239
-
240
- if (isCheckout) {
241
- this.scheduleFlush();
242
- } else if (this.events.length >= this.maxEvents) {
243
- this.scheduleFlush();
244
- } else if (
245
- event.type === EventType.FullSnapshot &&
246
- this.hasReachedMinLength()
247
- ) {
248
- this.minLengthFlushScheduled = true;
249
- this.scheduleFlush();
250
- } else if (!this.minLengthFlushScheduled && this.hasReachedMinLength()) {
251
- this.minLengthFlushScheduled = true;
252
- this.scheduleFlush();
253
- }
254
- }
255
-
256
- private hasReachedMinLength(): boolean {
257
- if (this.minReplayLengthMs <= 0) return true;
258
- return Date.now() - this.startTime >= this.minReplayLengthMs;
185
+ getSessionId(): string | undefined {
186
+ return getOrCreateSessionId();
259
187
  }
260
188
 
261
- private scheduleFlush(): void {
262
- if (this.isFlushing || this.events.length === 0 || this.flushScheduled)
263
- return;
264
-
265
- this.flushScheduled = true;
266
- const runFlush = () => {
267
- this.flushScheduled = false;
268
- this.flushTimeout = null;
269
- this.idleFlushId = null;
270
- void this.flush();
271
- };
189
+ private onEvent = (event: eventWithTime, isCheckout?: boolean): void => {
190
+ this.events.push(event);
272
191
 
273
- if (typeof window !== "undefined" && "requestIdleCallback" in window) {
274
- this.idleFlushId = window.requestIdleCallback(runFlush, {
275
- timeout: 2000,
276
- });
277
- return;
192
+ if (
193
+ isCheckout ||
194
+ this.events.length >= this.maxEvents ||
195
+ (event.type === EventType.FullSnapshot && this.hasReachedMinLength())
196
+ ) {
197
+ this.requestFlush();
278
198
  }
199
+ };
279
200
 
280
- this.flushTimeout = setTimeout(runFlush, 0);
281
- }
282
-
283
- private handleUnload = (): void => {
284
- this.clearScheduledFlush();
285
- void this.flush({ lowLatency: true });
201
+ private onUnload = (): void => {
202
+ void this.flush(true);
286
203
  };
287
204
 
288
- private handleVisibilityChange = (): void => {
205
+ private onVisibilityChange = (): void => {
289
206
  if (document.visibilityState === "hidden") {
290
- if (this.flushTimer) {
291
- clearInterval(this.flushTimer);
292
- this.flushTimer = null;
293
- }
294
- this.clearScheduledFlush();
295
- void this.flush({ lowLatency: true });
296
- } else if (document.visibilityState === "visible" && this.started) {
297
- if (!this.flushTimer) {
298
- this.flushTimer = setInterval(() => {
299
- this.scheduleFlush();
300
- }, this.flushInterval);
301
- }
207
+ void this.flush(true);
302
208
  }
303
209
  };
304
210
 
305
- private clearScheduledFlush(): void {
306
- if (
307
- this.idleFlushId !== null &&
308
- typeof window !== "undefined" &&
309
- "cancelIdleCallback" in window
310
- ) {
311
- window.cancelIdleCallback(this.idleFlushId);
312
- }
313
- this.idleFlushId = null;
314
-
315
- if (this.flushTimeout) {
316
- clearTimeout(this.flushTimeout);
317
- this.flushTimeout = null;
318
- }
319
-
320
- this.flushScheduled = false;
321
- }
322
-
323
- private scheduleMinLengthFlush(): void {
324
- if (this.minReplayLengthMs <= 0) return;
325
-
326
- this.clearMinLengthFlushTimer();
327
- this.minLengthFlushTimer = setTimeout(() => {
328
- this.minLengthFlushTimer = null;
329
- if (this.events.length === 0) return;
330
-
331
- this.minLengthFlushScheduled = true;
332
- this.scheduleFlush();
333
- }, this.minReplayLengthMs);
334
- }
335
-
336
- private clearMinLengthFlushTimer(): void {
337
- if (this.minLengthFlushTimer) {
338
- clearTimeout(this.minLengthFlushTimer);
339
- this.minLengthFlushTimer = null;
340
- }
341
- }
342
-
343
- private clearPendingRetryTimer(): void {
344
- if (this.pendingRetryTimer) {
345
- clearTimeout(this.pendingRetryTimer);
346
- this.pendingRetryTimer = null;
347
- }
348
- }
349
-
350
- private queuePendingBatch(batch: PendingBatch): void {
351
- if (this.pendingBatches.length >= this.maxPendingBatches) {
352
- if (this.debug) {
353
- console.warn(
354
- `[Replay] Pending batch buffer full, dropping batch ${batch.batch.sequence}`,
355
- );
356
- }
357
- return;
358
- }
359
- this.pendingBatches.push(batch);
360
- }
361
-
362
- private schedulePendingRetry(delayMs: number): void {
363
- if (this.pendingRetryTimer) return;
364
- this.pendingRetryTimer = setTimeout(() => {
365
- this.pendingRetryTimer = null;
366
- void this.processPendingBatches();
367
- }, delayMs);
211
+ private hasReachedMinLength(): boolean {
212
+ return (
213
+ this.minReplayLengthMs <= 0 ||
214
+ Date.now() - this.startTime >= this.minReplayLengthMs
215
+ );
368
216
  }
369
217
 
370
- private async flush(options: { lowLatency?: boolean } = {}): Promise<void> {
371
- if (this.events.length === 0) {
372
- void this.processPendingBatches();
373
- return;
374
- }
218
+ private requestFlush = (): void => {
219
+ if (this.flushTask || this.sending || this.events.length === 0) return;
375
220
 
376
- if (!this.hasReachedMinLength()) {
377
- if (this.debug) {
378
- console.log(
379
- `[Replay] Too short (${Date.now() - this.startTime}ms), skipping`,
380
- );
381
- }
382
- void this.processPendingBatches();
383
- return;
384
- }
385
-
386
- if (this.isFlushing) return;
387
- this.isFlushing = true;
221
+ this.flushTask = setTimeout(() => {
222
+ this.flushTask = null;
223
+ void this.flush(false);
224
+ }, 0);
225
+ };
388
226
 
389
- const eventsToSend = this.events;
390
- this.events = [];
227
+ private createBatch(events: eventWithTime[]): ReplayBatch {
228
+ const identifier = getAnonymousId();
391
229
 
392
- const anonymousId = getAnonymousId();
393
- const batch = {
394
- token: this.siteKey,
230
+ return {
231
+ token: this.options.siteKey,
395
232
  sessionId: getOrCreateSessionId(),
396
- ...(anonymousId ? { identifier: anonymousId } : {}),
397
- sequence: this.sequenceNumber++,
233
+ ...(identifier ? { identifier } : {}),
234
+ sequence: this.sequence++,
398
235
  timestamp: Date.now(),
399
236
  url: window.location.href,
400
- events: eventsToSend,
237
+ events,
401
238
  };
239
+ }
402
240
 
403
- if (this.pendingBatches.length > 0 || this.isProcessingPending) {
404
- this.queuePendingBatch({
405
- batch,
406
- isCompressed: this.compressionSupported && !options.lowLatency,
407
- retries: 0,
408
- });
409
- this.isFlushing = false;
410
- void this.processPendingBatches();
411
- return;
412
- }
241
+ private async encodeBatch(
242
+ batch: ReplayBatch,
243
+ lowLatency: boolean,
244
+ ): Promise<{ data: Blob; isCompressed: boolean }> {
245
+ const json = JSON.stringify(batch);
413
246
 
414
- if (this.debug) {
415
- console.log(
416
- `[Replay] Sending ${eventsToSend.length} events (seq: ${batch.sequence})`,
417
- );
247
+ if (!this.compressionSupported || lowLatency) {
248
+ return {
249
+ data: new Blob([json], { type: "application/json" }),
250
+ isCompressed: false,
251
+ };
418
252
  }
419
253
 
420
254
  try {
421
- let compressed: Blob;
422
- let isCompressed = false;
423
-
424
- if (this.compressionSupported && !options.lowLatency) {
425
- try {
426
- compressed = await this.compress(JSON.stringify(batch));
427
- isCompressed = true;
428
- } catch {
429
- if (this.debug) {
430
- console.warn("[Replay] Compression failed, using uncompressed");
431
- }
432
- compressed = new Blob([JSON.stringify(batch)], {
433
- type: "application/json",
434
- });
435
- }
436
- } else {
437
- compressed = new Blob([JSON.stringify(batch)], {
438
- type: "application/json",
439
- });
440
- }
441
-
442
- const success = await this.send(compressed, isCompressed, {
443
- useBeacon: options.lowLatency === true,
444
- });
445
- if (!success) {
446
- this.queuePendingBatch({
447
- batch,
448
- isCompressed,
449
- retries: 0,
450
- });
451
- }
452
- } catch (error) {
453
- if (this.debug) {
454
- console.warn("[Replay] Flush error:", error);
455
- }
456
- this.queuePendingBatch({
457
- batch,
255
+ return {
256
+ data: await this.compress(json),
257
+ isCompressed: true,
258
+ };
259
+ } catch {
260
+ this.log("Compression failed, using uncompressed");
261
+ return {
262
+ data: new Blob([json], { type: "application/json" }),
458
263
  isCompressed: false,
459
- retries: 0,
460
- });
461
- } finally {
462
- this.isFlushing = false;
463
- void this.processPendingBatches();
264
+ };
464
265
  }
465
266
  }
466
267
 
467
- private async processPendingBatches(): Promise<void> {
468
- if (this.isProcessingPending || this.pendingBatches.length === 0) return;
469
- this.clearPendingRetryTimer();
470
- this.isProcessingPending = true;
268
+ private async flush(lowLatency: boolean): Promise<void> {
269
+ if (this.sending) return;
471
270
 
472
- try {
473
- while (this.pendingBatches.length > 0) {
474
- const batch = this.pendingBatches[0];
475
- if (!batch) {
476
- break;
477
- }
271
+ if (this.events.length > 0) {
272
+ if (!this.hasReachedMinLength()) {
273
+ this.log(
274
+ `Too short (${Date.now() - this.startTime}ms), skipping flush`,
275
+ );
276
+ } else {
277
+ this.pending.push(this.createBatch(this.events.splice(0)));
278
+ }
279
+ }
478
280
 
479
- if (batch.retries >= 3) {
480
- if (this.debug) {
481
- console.warn(
482
- `[Replay] Max retries reached, restoring batch ${batch.batch.sequence} to buffer`,
483
- );
484
- }
485
- this.pendingBatches.shift();
486
- this.events = [...batch.batch.events, ...this.events];
487
- this.scheduleFlush();
488
- continue;
489
- }
281
+ if (this.pending.length === 0) return;
282
+ this.sending = true;
490
283
 
491
- batch.retries++;
492
-
493
- let blob: Blob;
494
- if (batch.isCompressed && this.compressionSupported) {
495
- try {
496
- blob = await this.compress(JSON.stringify(batch.batch));
497
- } catch {
498
- blob = new Blob([JSON.stringify(batch.batch)], {
499
- type: "application/json",
500
- });
501
- batch.isCompressed = false;
502
- }
503
- } else {
504
- blob = new Blob([JSON.stringify(batch.batch)], {
505
- type: "application/json",
506
- });
507
- }
284
+ try {
285
+ while (this.pending.length > 0) {
286
+ const batch = this.pending[0];
287
+ if (!batch) break;
288
+
289
+ const encoded = await this.encodeBatch(batch, lowLatency);
290
+ const ok = await this.send(
291
+ encoded.data,
292
+ encoded.isCompressed,
293
+ lowLatency,
294
+ );
508
295
 
509
- const success = await this.send(blob, batch.isCompressed, {
510
- useBeacon: false,
511
- });
512
- if (success) {
513
- this.pendingBatches.shift();
514
- continue;
296
+ if (!ok) {
297
+ if (this.pending.length >= this.maxPendingBatches) {
298
+ this.log(`Pending buffer full, dropping batch ${batch.sequence}`);
299
+ this.pending.shift();
300
+ }
301
+ this.scheduleRetry();
302
+ break;
515
303
  }
516
304
 
517
- this.schedulePendingRetry(1000 * batch.retries);
518
- break;
305
+ this.log(`Sent ${batch.events.length} events (seq: ${batch.sequence})`);
306
+ this.pending.shift();
307
+ lowLatency = false;
519
308
  }
520
- } catch {
521
- const current = this.pendingBatches[0];
522
- if (this.debug && current) {
523
- console.warn(`[Replay] Retry ${current.retries} failed`);
524
- }
525
- this.schedulePendingRetry(current ? 1000 * current.retries : 1000);
526
309
  } finally {
527
- this.isProcessingPending = false;
310
+ this.sending = false;
528
311
  }
529
312
  }
530
313
 
531
- private async compress(data: string): Promise<Blob> {
532
- if (!this.compressionSupported) {
533
- throw new Error("Compression not supported");
534
- }
314
+ private scheduleRetry(): void {
315
+ if (this.retryTask) return;
535
316
 
536
- const encoder = new TextEncoder();
537
- const inputData = encoder.encode(data);
317
+ this.retryTask = setTimeout(() => {
318
+ this.retryTask = null;
319
+ void this.flush(false);
320
+ }, 1000);
321
+ }
538
322
 
323
+ private async compress(data: string): Promise<Blob> {
324
+ const input = new TextEncoder().encode(data);
539
325
  const cs = new CompressionStream("gzip");
540
326
  const writer = cs.writable.getWriter();
541
- writer.write(inputData);
542
- writer.close();
543
327
 
544
- const compressedChunks: Uint8Array[] = [];
328
+ await writer.write(input);
329
+ await writer.close();
330
+
331
+ const chunks: Uint8Array[] = [];
545
332
  const reader = cs.readable.getReader();
546
333
 
547
334
  while (true) {
548
335
  const { done, value } = await reader.read();
549
336
  if (done) break;
550
- if (value) compressedChunks.push(value);
337
+ if (value) chunks.push(value);
551
338
  }
552
339
 
553
- const totalLength = compressedChunks.reduce(
554
- (acc, chunk) => acc + chunk.length,
555
- 0,
556
- );
557
- const compressed = new Uint8Array(totalLength);
340
+ const size = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
341
+ const output = new Uint8Array(size);
342
+
558
343
  let offset = 0;
559
- for (const chunk of compressedChunks) {
560
- compressed.set(chunk, offset);
344
+ for (const chunk of chunks) {
345
+ output.set(chunk, offset);
561
346
  offset += chunk.length;
562
347
  }
563
348
 
564
- if (this.debug) {
565
- const ratio = ((compressed.length / inputData.length) * 100).toFixed(1);
566
- console.log(
567
- `[Replay] Compressed: ${inputData.length} → ${compressed.length} bytes (${ratio}%)`,
568
- );
569
- }
349
+ this.log(
350
+ `Compressed: ${input.length} ${output.length} bytes (${(
351
+ (output.length / input.length) * 100
352
+ ).toFixed(1)}%)`,
353
+ );
570
354
 
571
- return new Blob([compressed], { type: "application/octet-stream" });
355
+ return new Blob([output], { type: "application/octet-stream" });
572
356
  }
573
357
 
574
- private async send(
358
+ private send(
575
359
  data: Blob,
576
360
  isCompressed: boolean,
577
- options: { useBeacon?: boolean } = {},
361
+ lowLatency: boolean,
578
362
  ): Promise<boolean> {
579
- const url = isCompressed ? `${this.endpoint}?encoding=gzip` : this.endpoint;
580
- const sizeKB = (data.size / 1024).toFixed(1);
581
-
582
363
  return (
583
364
  sendData({
584
- url,
365
+ url: isCompressed ? `${this.endpoint}?encoding=gzip` : this.endpoint,
585
366
  data,
586
367
  contentType: isCompressed
587
368
  ? "application/octet-stream"
588
369
  : "application/json",
589
370
  debug: this.debug,
590
- debugPrefix: `[Replay] ${sizeKB}KB`,
591
- useBeacon: options.useBeacon ?? false,
592
- keepalive: options.useBeacon === true,
371
+ debugPrefix: `[Replay] ${(data.size / 1024).toFixed(1)}KB`,
372
+ useBeacon: lowLatency,
373
+ keepalive: lowLatency,
593
374
  }) ?? Promise.resolve(false)
594
375
  );
595
376
  }
596
-
597
- getSessionId(): string | undefined {
598
- return getOrCreateSessionId();
599
- }
600
377
  }
601
-
602
- export default ReplayTracker;