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