@usero/sdk 0.3.2 → 0.4.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,43 +1,181 @@
1
1
  'use strict';
2
2
 
3
+ // src/identity.ts
4
+ var ANON_STORAGE_KEY = "usero:anonymous-id";
5
+ var cachedAnonymousId = null;
6
+ function generateRandomId() {
7
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
8
+ return crypto.randomUUID();
9
+ }
10
+ const bytes = new Uint8Array(16);
11
+ if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
12
+ crypto.getRandomValues(bytes);
13
+ } else {
14
+ for (let i = 0; i < bytes.length; i += 1) bytes[i] = Math.floor(Math.random() * 256);
15
+ }
16
+ let out = "";
17
+ for (const b of bytes) out += b.toString(16).padStart(2, "0");
18
+ return out;
19
+ }
20
+ function safeReadLocalStorage(key) {
21
+ if (typeof window === "undefined") return null;
22
+ try {
23
+ return window.localStorage?.getItem(key) ?? null;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+ function safeWriteLocalStorage(key, value) {
29
+ if (typeof window === "undefined") return;
30
+ try {
31
+ window.localStorage?.setItem(key, value);
32
+ } catch {
33
+ }
34
+ }
35
+ function getOrMintAnonymousId() {
36
+ if (cachedAnonymousId) return cachedAnonymousId;
37
+ const existing = safeReadLocalStorage(ANON_STORAGE_KEY);
38
+ if (existing && /^[a-z0-9-]{8,}$/i.test(existing)) {
39
+ cachedAnonymousId = existing;
40
+ return existing;
41
+ }
42
+ const id = generateRandomId();
43
+ safeWriteLocalStorage(ANON_STORAGE_KEY, id);
44
+ cachedAnonymousId = id;
45
+ return id;
46
+ }
47
+
3
48
  // src/plugins/session-replay.ts
4
- var DEFAULT_OPTIONS = {
5
- bufferSeconds: 30,
49
+ var DEFAULTS = {
6
50
  startAfterMs: 0,
7
51
  sampleRate: 1,
8
52
  sampling: { mousemove: 50, scroll: 100 },
9
53
  maskAllInputs: true,
10
54
  maskTextSelector: "[data-usero-mask]",
11
55
  inlineStylesheet: true,
12
- blockSelector: "[data-usero-block]"
56
+ blockSelector: "[data-usero-block]",
57
+ chunkSeconds: 10,
58
+ chunkMaxEvents: 5e3,
59
+ chunkMaxAttempts: 5,
60
+ apiUrl: ""
13
61
  };
14
- function evictOldEvents(events, bufferSeconds, now) {
15
- if (events.length === 0) return;
16
- const cutoff = now - bufferSeconds * 1e3;
17
- let dropCount = 0;
18
- for (const e of events) {
19
- if (e.timestamp >= cutoff) break;
20
- dropCount++;
21
- }
22
- if (dropCount > 0) events.splice(0, dropCount);
23
- }
62
+ var SDK_SESSION_STORAGE_KEY = "usero:session-replay:sdk-session-id";
63
+ var HARD_CHUNK_BYTE_CAP = 4 * 1024 * 1024;
24
64
  function uint8ToBase64(bytes) {
25
65
  let binary = "";
26
66
  const chunkSize = 32768;
27
67
  for (let i = 0; i < bytes.length; i += chunkSize) {
28
- const chunk = bytes.subarray(i, i + chunkSize);
29
- binary += String.fromCharCode.apply(null, Array.from(chunk));
68
+ const slice = bytes.subarray(i, i + chunkSize);
69
+ binary += String.fromCharCode.apply(null, Array.from(slice));
30
70
  }
31
71
  return typeof btoa === "function" ? btoa(binary) : "";
32
72
  }
33
- async function gzipString(input) {
73
+ async function gzipBytes(input) {
34
74
  if (typeof CompressionStream === "undefined") {
35
- const bytes = new TextEncoder().encode(input);
36
- return uint8ToBase64(bytes);
75
+ return new TextEncoder().encode(input);
37
76
  }
38
77
  const stream = new Blob([input]).stream().pipeThrough(new CompressionStream("gzip"));
39
- const compressed = await new Response(stream).arrayBuffer();
40
- return uint8ToBase64(new Uint8Array(compressed));
78
+ const buf = await new Response(stream).arrayBuffer();
79
+ return new Uint8Array(buf);
80
+ }
81
+ function generateRandomId2() {
82
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
83
+ return crypto.randomUUID();
84
+ }
85
+ const bytes = new Uint8Array(16);
86
+ if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
87
+ crypto.getRandomValues(bytes);
88
+ } else {
89
+ for (let i = 0; i < bytes.length; i += 1) bytes[i] = Math.floor(Math.random() * 256);
90
+ }
91
+ let out = "";
92
+ for (const b of bytes) out += b.toString(16).padStart(2, "0");
93
+ return out;
94
+ }
95
+ function mintSdkSessionId() {
96
+ try {
97
+ const existing = window.sessionStorage?.getItem(SDK_SESSION_STORAGE_KEY);
98
+ if (existing && /^[a-z0-9-]{8,}$/i.test(existing)) return existing;
99
+ } catch {
100
+ }
101
+ const id = generateRandomId2();
102
+ try {
103
+ window.sessionStorage?.setItem(SDK_SESSION_STORAGE_KEY, id);
104
+ } catch {
105
+ }
106
+ return id;
107
+ }
108
+ function joinUrl(apiUrl, path) {
109
+ return `${apiUrl.replace(/\/$/, "")}${path}`;
110
+ }
111
+ async function createSession(apiUrl, clientId, sdkSessionId, anonymousId) {
112
+ try {
113
+ const startUrl = typeof window !== "undefined" && window.location ? window.location.href : void 0;
114
+ const userAgent = typeof navigator !== "undefined" && navigator.userAgent ? navigator.userAgent : void 0;
115
+ const res = await fetch(joinUrl(apiUrl, "/api/replay-sessions"), {
116
+ method: "POST",
117
+ headers: { "Content-Type": "application/json" },
118
+ body: JSON.stringify({
119
+ clientId,
120
+ sdkSessionId,
121
+ anonymousId,
122
+ startUrl,
123
+ userAgent,
124
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
125
+ })
126
+ });
127
+ if (!res.ok) return null;
128
+ const json = await res.json();
129
+ if (typeof json.accepted !== "boolean") return null;
130
+ const result = { accepted: json.accepted };
131
+ if (typeof json.sessionReplayId === "string") result.sessionReplayId = json.sessionReplayId;
132
+ if (typeof json.dropReason === "string") result.dropReason = json.dropReason;
133
+ return result;
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+ async function uploadChunk(apiUrl, sessionReplayId, clientId, seq, bytes, eventCount, durationMs, logger, maxAttempts) {
139
+ const url = joinUrl(
140
+ apiUrl,
141
+ `/api/replay-sessions/${encodeURIComponent(sessionReplayId)}/chunks/${seq}`
142
+ );
143
+ let attempt = 0;
144
+ while (attempt < maxAttempts) {
145
+ try {
146
+ const buffer = bytes.buffer.slice(
147
+ bytes.byteOffset,
148
+ bytes.byteOffset + bytes.byteLength
149
+ );
150
+ const blob = new Blob([buffer], { type: "application/octet-stream" });
151
+ const res = await fetch(url, {
152
+ method: "PUT",
153
+ body: blob,
154
+ headers: {
155
+ "Content-Type": "application/octet-stream",
156
+ "X-Usero-Client-Id": clientId,
157
+ "X-Usero-Event-Count": String(eventCount),
158
+ "X-Usero-Duration-Ms": String(Math.max(0, Math.round(durationMs)))
159
+ }
160
+ });
161
+ if (res.ok) return { ok: true, stopSession: false };
162
+ if (res.status === 409) {
163
+ logger.warn(`chunk ${seq} rejected with 409, stopping session`);
164
+ return { ok: false, stopSession: true };
165
+ }
166
+ if (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429) {
167
+ logger.error(`chunk ${seq} rejected with ${res.status}`);
168
+ return { ok: false, stopSession: false };
169
+ }
170
+ } catch (err) {
171
+ logger.warn(`chunk ${seq} attempt ${attempt + 1} failed`, err);
172
+ }
173
+ attempt += 1;
174
+ const backoff = Math.min(15e3, 500 * 2 ** attempt) + Math.floor(Math.random() * 250);
175
+ await new Promise((resolve) => setTimeout(resolve, backoff));
176
+ }
177
+ logger.error(`chunk ${seq} dropped after ${maxAttempts} attempts`);
178
+ return { ok: false, stopSession: false };
41
179
  }
42
180
  async function loadRrwebRecord() {
43
181
  try {
@@ -53,20 +191,100 @@ async function loadRrwebRecord() {
53
191
  return null;
54
192
  }
55
193
  }
194
+ function scheduleChunkUpload(store, ctx) {
195
+ if (!store.sessionReplayId) return;
196
+ if (store.pendingEvents.length === 0) return;
197
+ try {
198
+ ctx.resolveUser?.();
199
+ } catch (err) {
200
+ ctx.logger.warn("resolveUser threw at chunk boundary", err);
201
+ }
202
+ const events = store.pendingEvents;
203
+ const eventCount = events.length;
204
+ const firstTs = store.pendingFirstTs ?? 0;
205
+ const lastTs = store.pendingLastTs ?? firstTs;
206
+ const durationMs = Math.max(0, lastTs - firstTs);
207
+ const seq = store.nextChunkSeq;
208
+ store.nextChunkSeq += 1;
209
+ store.pendingEvents = [];
210
+ store.pendingFirstTs = null;
211
+ store.pendingLastTs = null;
212
+ const sessionReplayId = store.sessionReplayId;
213
+ const apiUrl = store.options.apiUrl;
214
+ const clientId = store.clientId;
215
+ const maxAttempts = store.options.chunkMaxAttempts;
216
+ store.pendingUploads += 1;
217
+ store.uploadQueue = store.uploadQueue.then(async () => {
218
+ try {
219
+ if (store.cancelled) return;
220
+ const json = JSON.stringify(events);
221
+ const bytes = await gzipBytes(json);
222
+ if (bytes.byteLength > HARD_CHUNK_BYTE_CAP) {
223
+ ctx.logger.error(
224
+ `chunk ${seq} exceeds 4MB hard cap (${bytes.byteLength} bytes), dropping`
225
+ );
226
+ return;
227
+ }
228
+ const result = await uploadChunk(
229
+ apiUrl,
230
+ sessionReplayId,
231
+ clientId,
232
+ seq,
233
+ bytes,
234
+ eventCount,
235
+ durationMs,
236
+ ctx.logger,
237
+ maxAttempts
238
+ );
239
+ if (result.stopSession) {
240
+ store.stopped = true;
241
+ stopRrweb(store);
242
+ }
243
+ } catch (err) {
244
+ ctx.logger.error(`chunk ${seq} encode failed`, err);
245
+ } finally {
246
+ store.pendingUploads -= 1;
247
+ }
248
+ });
249
+ }
250
+ function flushPendingChunk(store, ctx) {
251
+ if (store.stopped || store.cancelled) return;
252
+ if (store.pendingEvents.length === 0) return;
253
+ scheduleChunkUpload(store, ctx);
254
+ }
255
+ function stopRrweb(store) {
256
+ if (store.stopRecording) {
257
+ try {
258
+ store.stopRecording();
259
+ } catch {
260
+ }
261
+ store.stopRecording = null;
262
+ }
263
+ if (store.chunkFlushTimer) {
264
+ clearInterval(store.chunkFlushTimer);
265
+ store.chunkFlushTimer = null;
266
+ }
267
+ }
56
268
  function startRecording(store, ctx) {
57
- if (store.cancelled || store.stopRecording || store.loadInProgress) return;
269
+ if (store.cancelled || store.stopped || store.stopRecording || store.loadInProgress) return;
58
270
  store.loadInProgress = true;
59
271
  void loadRrwebRecord().then((record) => {
60
272
  store.loadInProgress = false;
61
- if (store.cancelled || !record) {
273
+ if (store.cancelled || store.stopped || !record) {
62
274
  if (!record) ctx.logger.warn("rrweb failed to load, replay disabled");
63
275
  return;
64
276
  }
65
277
  try {
66
278
  const stop = record({
67
279
  emit: (event) => {
68
- store.events.push(event);
69
- evictOldEvents(store.events, store.options.bufferSeconds, event.timestamp);
280
+ if (store.stopped || store.cancelled) return;
281
+ if (store.recordingStartedAt === null) store.recordingStartedAt = event.timestamp;
282
+ store.pendingEvents.push(event);
283
+ if (store.pendingFirstTs === null) store.pendingFirstTs = event.timestamp;
284
+ store.pendingLastTs = event.timestamp;
285
+ if (store.pendingEvents.length >= store.options.chunkMaxEvents) {
286
+ scheduleChunkUpload(store, ctx);
287
+ }
70
288
  },
71
289
  maskAllInputs: store.options.maskAllInputs,
72
290
  maskTextSelector: store.options.maskTextSelector || void 0,
@@ -75,45 +293,130 @@ function startRecording(store, ctx) {
75
293
  sampling: store.options.sampling
76
294
  });
77
295
  store.stopRecording = stop;
296
+ store.record = record;
297
+ scheduleShadowSnapshot(store, ctx);
298
+ store.chunkFlushTimer = setInterval(
299
+ () => flushPendingChunk(store, ctx),
300
+ store.options.chunkSeconds * 1e3
301
+ );
78
302
  } catch (err) {
79
303
  ctx.logger.error("rrweb record() threw", err);
80
304
  }
81
305
  });
82
306
  }
307
+ function scheduleShadowSnapshot(store, ctx) {
308
+ if (store.cancelled || store.stopped || !store.record || !store.stopRecording) return;
309
+ const fn = store.record.takeFullSnapshot;
310
+ if (typeof fn !== "function") return;
311
+ try {
312
+ fn(true);
313
+ } catch (err) {
314
+ ctx.logger.warn("takeFullSnapshot threw", err);
315
+ }
316
+ }
317
+ function finalise(store, ctx, opts) {
318
+ if (!store.sessionReplayId) return;
319
+ if (store.pendingEvents.length > 0) flushPendingChunk(store, ctx);
320
+ const url = joinUrl(
321
+ store.options.apiUrl,
322
+ `/api/replay-sessions/${encodeURIComponent(store.sessionReplayId)}/finalise`
323
+ );
324
+ const body = JSON.stringify({ clientId: store.clientId, endedAt: (/* @__PURE__ */ new Date()).toISOString() });
325
+ if (opts.useBeacon && typeof navigator !== "undefined" && navigator.sendBeacon) {
326
+ try {
327
+ const blob = new Blob([body], { type: "application/json" });
328
+ navigator.sendBeacon(url, blob);
329
+ return;
330
+ } catch (err) {
331
+ ctx.logger.warn("finalise sendBeacon threw", err);
332
+ }
333
+ }
334
+ void fetch(url, {
335
+ method: "POST",
336
+ body,
337
+ headers: { "Content-Type": "application/json" },
338
+ keepalive: true
339
+ }).catch((err) => ctx.logger.warn("finalise fetch failed", err));
340
+ }
83
341
  function sessionReplay(options = {}) {
84
342
  const merged = {
85
- ...DEFAULT_OPTIONS,
343
+ ...DEFAULTS,
86
344
  ...options,
87
- sampling: { ...DEFAULT_OPTIONS.sampling, ...options.sampling ?? {} }
345
+ sampling: { ...DEFAULTS.sampling, ...options.sampling ?? {} }
88
346
  };
89
347
  return {
90
348
  name: "session-replay",
91
349
  onInit(ctx) {
350
+ if (typeof window === "undefined") return;
92
351
  if (merged.sampleRate < 1 && Math.random() >= merged.sampleRate) {
93
352
  ctx.logger.debug("skipped by sampleRate");
94
353
  return;
95
354
  }
96
- if (typeof window === "undefined") return;
355
+ const apiUrl = merged.apiUrl || ctx.baseUrl;
356
+ if (!apiUrl) {
357
+ ctx.logger.error("session-replay needs an apiUrl (via options or PluginContext)");
358
+ return;
359
+ }
360
+ const sdkSessionId = mintSdkSessionId();
361
+ const anonymousId = getOrMintAnonymousId();
97
362
  const store = {
98
- events: [],
99
- stopRecording: null,
363
+ options: { ...merged, apiUrl },
364
+ clientId: ctx.clientId,
365
+ sdkSessionId,
366
+ sessionReplayId: null,
367
+ recordingStartedAt: null,
368
+ pendingEvents: [],
369
+ pendingFirstTs: null,
370
+ pendingLastTs: null,
371
+ nextChunkSeq: 0,
372
+ uploadQueue: Promise.resolve(),
373
+ pendingUploads: 0,
374
+ chunkFlushTimer: null,
100
375
  startTimer: null,
101
376
  pageHideHandler: null,
377
+ shadowUpdateHandler: null,
378
+ record: null,
379
+ stopRecording: null,
102
380
  loadInProgress: false,
103
381
  cancelled: false,
104
- options: merged
382
+ stopped: false
105
383
  };
106
384
  ctx.setStore(store);
107
- const begin = () => {
108
- if (store.startTimer) {
109
- clearTimeout(store.startTimer);
110
- store.startTimer = null;
385
+ const onShadowUpdate = () => scheduleShadowSnapshot(store, ctx);
386
+ store.shadowUpdateHandler = onShadowUpdate;
387
+ window.addEventListener("usero:shadow-update", onShadowUpdate);
388
+ const onPageHide = () => {
389
+ finalise(store, ctx, { useBeacon: true });
390
+ store.stopped = true;
391
+ stopRrweb(store);
392
+ };
393
+ store.pageHideHandler = onPageHide;
394
+ window.addEventListener("pagehide", onPageHide);
395
+ const begin = async () => {
396
+ if (store.cancelled) return;
397
+ try {
398
+ ctx.resolveUser?.();
399
+ } catch (err) {
400
+ ctx.logger.warn("resolveUser threw at session start", err);
111
401
  }
112
- if (store.pageHideHandler) {
113
- window.removeEventListener("pagehide", store.pageHideHandler);
114
- window.removeEventListener("beforeunload", store.pageHideHandler);
115
- store.pageHideHandler = null;
402
+ const created = await createSession(apiUrl, ctx.clientId, sdkSessionId, anonymousId);
403
+ if (!created) {
404
+ ctx.logger.warn("session create failed, replay disabled");
405
+ store.stopped = true;
406
+ return;
116
407
  }
408
+ if (!created.accepted) {
409
+ ctx.logger.info(`session-replay declined: ${created.dropReason ?? "unknown"}`);
410
+ store.stopped = true;
411
+ return;
412
+ }
413
+ if (!created.sessionReplayId) {
414
+ ctx.logger.error("server accepted but returned no sessionReplayId");
415
+ store.stopped = true;
416
+ return;
417
+ }
418
+ store.sessionReplayId = created.sessionReplayId;
419
+ store.recordingStartedAt = Date.now();
117
420
  startRecording(store, ctx);
118
421
  };
119
422
  if (merged.startAfterMs > 0) {
@@ -123,56 +426,67 @@ function sessionReplay(options = {}) {
123
426
  clearTimeout(store.startTimer);
124
427
  store.startTimer = null;
125
428
  }
126
- if (store.pageHideHandler) {
127
- window.removeEventListener("pagehide", store.pageHideHandler);
128
- window.removeEventListener("beforeunload", store.pageHideHandler);
129
- store.pageHideHandler = null;
130
- }
131
429
  };
132
- store.pageHideHandler = cancelOnExit;
133
430
  window.addEventListener("pagehide", cancelOnExit, { once: true });
134
431
  window.addEventListener("beforeunload", cancelOnExit, { once: true });
135
- store.startTimer = setTimeout(begin, merged.startAfterMs);
432
+ store.startTimer = setTimeout(() => {
433
+ void begin();
434
+ }, merged.startAfterMs);
136
435
  } else {
137
- begin();
436
+ void begin();
138
437
  }
139
438
  },
140
- async onFeedbackSubmit(ctx) {
439
+ onFeedbackSubmit(ctx) {
141
440
  const store = ctx.getStore();
142
- if (!store || store.cancelled || store.events.length === 0) return void 0;
143
- const snapshot = store.events.slice();
144
- try {
145
- const json = JSON.stringify(snapshot);
146
- const replayEvents = await gzipString(json);
147
- return { replayEvents };
148
- } catch (err) {
149
- ctx.logger.error("failed to encode replay buffer", err);
150
- return void 0;
151
- }
441
+ if (!store || store.cancelled || store.stopped) return void 0;
442
+ if (!store.sessionReplayId) return void 0;
443
+ const offsetMs = store.recordingStartedAt !== null ? Math.max(0, Date.now() - store.recordingStartedAt) : 0;
444
+ return { sessionReplayId: store.sessionReplayId, replayOffsetMs: offsetMs };
152
445
  },
153
446
  onDestroy(ctx) {
154
447
  const store = ctx.getStore();
155
448
  if (!store) return;
156
449
  store.cancelled = true;
157
- if (store.startTimer) clearTimeout(store.startTimer);
450
+ if (store.startTimer) {
451
+ clearTimeout(store.startTimer);
452
+ store.startTimer = null;
453
+ }
158
454
  if (store.pageHideHandler) {
159
455
  window.removeEventListener("pagehide", store.pageHideHandler);
160
- window.removeEventListener("beforeunload", store.pageHideHandler);
456
+ store.pageHideHandler = null;
161
457
  }
162
- if (store.stopRecording) {
163
- try {
164
- store.stopRecording();
165
- } catch (err) {
166
- ctx.logger.warn("rrweb stop threw", err);
167
- }
458
+ if (store.shadowUpdateHandler) {
459
+ window.removeEventListener("usero:shadow-update", store.shadowUpdateHandler);
460
+ store.shadowUpdateHandler = null;
461
+ }
462
+ if (store.sessionReplayId && !store.stopped) {
463
+ finalise(store, ctx, { useBeacon: false });
168
464
  }
169
- store.events.length = 0;
465
+ store.stopped = true;
466
+ stopRrweb(store);
467
+ store.pendingEvents.length = 0;
170
468
  }
171
469
  };
172
470
  }
173
- var __test__ = { evictOldEvents, gzipString, uint8ToBase64 };
471
+ function getCurrentSession(ctx) {
472
+ const store = ctx.getStore();
473
+ if (!store || store.cancelled || store.stopped || !store.sessionReplayId) return null;
474
+ const offsetMs = store.recordingStartedAt !== null ? Math.max(0, Date.now() - store.recordingStartedAt) : 0;
475
+ return { id: store.sessionReplayId, offsetMs };
476
+ }
477
+ var __test__ = {
478
+ uint8ToBase64,
479
+ gzipBytes,
480
+ mintSdkSessionId,
481
+ uploadChunk,
482
+ createSession,
483
+ joinUrl,
484
+ HARD_CHUNK_BYTE_CAP,
485
+ SDK_SESSION_STORAGE_KEY
486
+ };
174
487
 
175
488
  exports.__test__ = __test__;
489
+ exports.getCurrentSession = getCurrentSession;
176
490
  exports.sessionReplay = sessionReplay;
177
491
  //# sourceMappingURL=session-replay.cjs.map
178
492
  //# sourceMappingURL=session-replay.cjs.map