@lunardinos/browser 0.2.3 → 0.2.5

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.
@@ -11,6 +11,13 @@ interface RecorderConfig {
11
11
  batchInterval?: number;
12
12
  /** Initial persistence mode: 'cookie' (default) or 'cookieless'. Can be changed at runtime via setPersistence(). */
13
13
  defaultPersistence?: PersistenceMode;
14
+ /**
15
+ * BCP-47 language the app renders in (e.g. "de"). Static fallback used when a page
16
+ * event doesn't carry its own locale and `<html lang>` isn't set. Prefer passing the
17
+ * current locale per navigation via the framework SDK's page metadata, since the app
18
+ * knows the shown language and it can change at runtime.
19
+ */
20
+ locale?: string;
14
21
  }
15
22
  interface PageEventPayload {
16
23
  href: string;
@@ -18,6 +25,8 @@ interface PageEventPayload {
18
25
  routePattern: string | null;
19
26
  params: Record<string, string> | null;
20
27
  referrer?: string | null;
28
+ /** BCP-47 language this page was rendered in (e.g. "de"). The app knows this. */
29
+ locale?: string | null;
21
30
  }
22
31
  export interface IdentifyPayload {
23
32
  user: {
@@ -1 +1 @@
1
- {"version":3,"file":"recorder-core.d.ts","sourceRoot":"","sources":["../../src/recorder-core.ts"],"names":[],"mappings":"AACA,OAAO,EAA8C,KAAK,eAAe,EAAE,MAAM,eAAe,CAAA;AAEhG,YAAY,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AAIpD,UAAU,cAAc;IACtB,iCAAiC;IACjC,MAAM,EAAE,MAAM,CAAA;IACd,qEAAqE;IACrE,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,kEAAkE;IAClE,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACtC,6CAA6C;IAC7C,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,oHAAoH;IACpH,kBAAkB,CAAC,EAAE,eAAe,CAAA;CACrC;AA4BD,UAAU,gBAAgB;IACxB,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;IACb,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAAA;IACrC,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACzB;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE;QACJ,EAAE,EAAE,MAAM,CAAA;QACV,KAAK,CAAC,EAAE,MAAM,CAAA;QACd,IAAI,CAAC,EAAE,MAAM,CAAA;QACb,IAAI,CAAC,EAAE,MAAM,CAAA;QACb,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;KACvB,CAAA;IACD,OAAO,CAAC,EAAE;QACR,EAAE,EAAE,MAAM,CAAA;QACV,IAAI,CAAC,EAAE,MAAM,CAAA;QACb,SAAS,CAAC,EAAE,OAAO,CAAA;QACnB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;KACvB,CAAA;IACD,IAAI,CAAC,EAAE;QACL,EAAE,EAAE,MAAM,CAAA;QACV,IAAI,CAAC,EAAE,MAAM,CAAA;QACb,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;KACvB,CAAA;CACF;AAuCD,MAAM,WAAW,YAAY;IAC3B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,8FAA8F;IAC9F,YAAY,CAAC,EAAE,gBAAgB,CAAA;CAChC;AAED,MAAM,WAAW,QAAQ;IACvB,KAAK,EAAE,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,YAAY,KAAK,MAAM,CAAA;IAClD,IAAI,EAAE,MAAM,IAAI,CAAA;IAChB,+FAA+F;IAC/F,KAAK,EAAE,CAAC,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,CAAA;IAC/C,YAAY,EAAE,MAAM,MAAM,GAAG,IAAI,CAAA;IACjC,YAAY,EAAE,MAAM,MAAM,GAAG,IAAI,CAAA;IACjC,WAAW,EAAE,MAAM,OAAO,CAAA;IAC1B,iFAAiF;IACjF,aAAa,EAAE,CAAC,OAAO,EAAE,gBAAgB,KAAK,IAAI,CAAA;IAClD,4EAA4E;IAC5E,QAAQ,EAAE,CAAC,OAAO,EAAE,eAAe,KAAK,IAAI,CAAA;IAC5C,2EAA2E;IAC3E,cAAc,EAAE,CAAC,IAAI,EAAE,eAAe,KAAK,IAAI,CAAA;CAChD;AAED;;;;;;GAMG;AACH,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,cAAc,GAAG,QAAQ,CA+bzE"}
1
+ {"version":3,"file":"recorder-core.d.ts","sourceRoot":"","sources":["../../src/recorder-core.ts"],"names":[],"mappings":"AACA,OAAO,EAA8C,KAAK,eAAe,EAAE,MAAM,eAAe,CAAA;AAShG,YAAY,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AAuBpD,UAAU,cAAc;IACtB,iCAAiC;IACjC,MAAM,EAAE,MAAM,CAAA;IACd,qEAAqE;IACrE,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,kEAAkE;IAClE,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACtC,6CAA6C;IAC7C,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,oHAAoH;IACpH,kBAAkB,CAAC,EAAE,eAAe,CAAA;IACpC;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AA4BD,UAAU,gBAAgB;IACxB,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;IACb,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAAA;IACrC,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,iFAAiF;IACjF,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACvB;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE;QACJ,EAAE,EAAE,MAAM,CAAA;QACV,KAAK,CAAC,EAAE,MAAM,CAAA;QACd,IAAI,CAAC,EAAE,MAAM,CAAA;QACb,IAAI,CAAC,EAAE,MAAM,CAAA;QACb,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;KACvB,CAAA;IACD,OAAO,CAAC,EAAE;QACR,EAAE,EAAE,MAAM,CAAA;QACV,IAAI,CAAC,EAAE,MAAM,CAAA;QACb,SAAS,CAAC,EAAE,OAAO,CAAA;QACnB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;KACvB,CAAA;IACD,IAAI,CAAC,EAAE;QACL,EAAE,EAAE,MAAM,CAAA;QACV,IAAI,CAAC,EAAE,MAAM,CAAA;QACb,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;KACvB,CAAA;CACF;AAuED,MAAM,WAAW,YAAY;IAC3B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,8FAA8F;IAC9F,YAAY,CAAC,EAAE,gBAAgB,CAAA;CAChC;AAED,MAAM,WAAW,QAAQ;IACvB,KAAK,EAAE,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,YAAY,KAAK,MAAM,CAAA;IAClD,IAAI,EAAE,MAAM,IAAI,CAAA;IAChB,+FAA+F;IAC/F,KAAK,EAAE,CAAC,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,CAAA;IAC/C,YAAY,EAAE,MAAM,MAAM,GAAG,IAAI,CAAA;IACjC,YAAY,EAAE,MAAM,MAAM,GAAG,IAAI,CAAA;IACjC,WAAW,EAAE,MAAM,OAAO,CAAA;IAC1B,iFAAiF;IACjF,aAAa,EAAE,CAAC,OAAO,EAAE,gBAAgB,KAAK,IAAI,CAAA;IAClD,4EAA4E;IAC5E,QAAQ,EAAE,CAAC,OAAO,EAAE,eAAe,KAAK,IAAI,CAAA;IAC5C,2EAA2E;IAC3E,cAAc,EAAE,CAAC,IAAI,EAAE,eAAe,KAAK,IAAI,CAAA;CAChD;AAED;;;;;;GAMG;AACH,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,cAAc,GAAG,QAAQ,CAmjBzE"}
@@ -1,6 +1,24 @@
1
- import record from "@lunardinos/rrweb-record";
1
+ import record, {} from "@lunardinos/rrweb-record";
2
2
  import { createPersistence } from "./persistence";
3
+ import { isNavigation, isUserActivity, SESSION_TIMEOUT_MS, sessionExpired, storedSessionUsable, } from "./session-lifecycle";
3
4
  const DEFAULT_ENDPOINT = "https://ingest.lunardinos.com";
5
+ /** Timestamp of the last user activity, alongside the session id in sessionStorage. */
6
+ const SESSION_ACTIVITY_KEY = "__ld_sid_at";
7
+ /**
8
+ * How often to force a full snapshot.
9
+ *
10
+ * These are the replay's keyframes: the player can only start rendering from a
11
+ * snapshot, so seeking to an arbitrary point costs replaying every mutation
12
+ * since the preceding one. That makes this interval the ceiling on seek latency
13
+ * — at five minutes, jumping around a long recording meant fast-forwarding
14
+ * through up to five minutes of DOM mutations per jump.
15
+ *
16
+ * The tradeoff is upload size: each snapshot is the whole DOM. Stylesheets and
17
+ * images are content-addressed and deduplicated across the session, so the
18
+ * marginal cost of an extra snapshot is mostly the node tree, which compresses
19
+ * well — worth paying for a 5x cut in seek latency.
20
+ */
21
+ const FULL_SNAPSHOT_INTERVAL_MS = 60 * 1000;
4
22
  /** Compress a string via the CompressionStream API (gzip). Returns null if unavailable or on error. */
5
23
  async function gzipCompress(input) {
6
24
  try {
@@ -37,6 +55,38 @@ function truncateText(text) {
37
55
  return null;
38
56
  return text.length > 40 ? `${text.slice(0, 37)}...` : text;
39
57
  }
58
+ // Hard filter for auth tokens / secrets apps leak into URLs — notably Supabase,
59
+ // which puts `#access_token=…&refresh_token=…` in the location hash after an OAuth
60
+ // callback. Strip them here so live tokens never leave the browser. The server
61
+ // re-applies the same filter on ingest (defense-in-depth). Kept in sync with
62
+ // packages/shared/src/redact-url.ts (the SDK bundle is standalone, so it can't
63
+ // import the shared server module).
64
+ const AUTH_URL_KEYS = "access[_-]?token|refresh[_-]?token|provider[_-]?token|provider[_-]?refresh[_-]?token|id[_-]?token|token|token[_-]?type|expires[_-]?at|expires[_-]?in|code|client[_-]?secret|session[_-]?token|jwt|api[_-]?key|password|secret";
65
+ const AUTH_PAIR = new RegExp(`\\b(${AUTH_URL_KEYS})=[^&#\\s"']*`, "gi");
66
+ const AUTH_FRAG = new RegExp(`#[^#\\s]*\\b(?:${AUTH_URL_KEYS})=`, "i");
67
+ function redactAuthFromUrl(value) {
68
+ if (value == null)
69
+ return value;
70
+ if (value.indexOf("=") === -1)
71
+ return value;
72
+ let v = value;
73
+ if (AUTH_FRAG.test(v)) {
74
+ const h = v.indexOf("#");
75
+ if (h >= 0)
76
+ v = v.slice(0, h);
77
+ }
78
+ return v.replace(AUTH_PAIR, (m) => `${m.slice(0, m.indexOf("=") + 1)}REDACTED`);
79
+ }
80
+ /** Read the page's rendered language from `<html lang>`, lowercased, or null. */
81
+ function readHtmlLang() {
82
+ try {
83
+ const lang = document.documentElement.getAttribute("lang");
84
+ return lang?.trim() ? lang.trim().toLowerCase() : null;
85
+ }
86
+ catch {
87
+ return null;
88
+ }
89
+ }
40
90
  /** Extract UTM parameters from the current URL. */
41
91
  function extractUtmParams() {
42
92
  const params = new URLSearchParams(window.location.search);
@@ -57,6 +107,13 @@ function extractUtmParams() {
57
107
  */
58
108
  export function createLunarDinosRecorder(config) {
59
109
  const { apiKey, endpoint = DEFAULT_ENDPOINT, rrwebOptions = {}, batchInterval = 2000 } = config;
110
+ const configLocale = config.locale ?? null;
111
+ /**
112
+ * Resolve the locale to stamp on a page event: an explicit per-page value wins
113
+ * (the app told us), then the live `<html lang>` (authoritative and updates on
114
+ * navigation), then the static config default.
115
+ */
116
+ const resolveLocale = (explicit) => explicit ?? readHtmlLang() ?? configLocale;
60
117
  // Always inline images so they display correctly in replay (the replay
61
118
  // iframe is on a different origin and can't fetch original URLs).
62
119
  const defaultRrwebOptions = {
@@ -85,7 +142,18 @@ export function createLunarDinosRecorder(config) {
85
142
  method: "POST",
86
143
  headers: reqHeaders,
87
144
  body,
88
- }).catch(() => { });
145
+ })
146
+ .then((res) => {
147
+ // Fire-and-forget by design (recording must never block the page), but a
148
+ // failed delivery should at least be visible in devtools/error monitoring
149
+ // instead of vanishing — this batch's events are gone either way.
150
+ if (!res.ok) {
151
+ console.error(`lunardinos: ingest request failed with status ${res.status}`);
152
+ }
153
+ })
154
+ .catch(() => {
155
+ console.error("lunardinos: ingest request failed (network error)");
156
+ });
89
157
  }
90
158
  /** Send via navigator.sendBeacon — reliable during page unload, no custom headers. */
91
159
  function sendViaBeacon(json) {
@@ -120,7 +188,17 @@ export function createLunarDinosRecorder(config) {
120
188
  let visitorId = null;
121
189
  let stopFn = null;
122
190
  let intervalId = null;
191
+ // Periodic full-snapshot ("checkout") timer. Bounds segment size for long
192
+ // single-page sessions: each snapshot becomes an independently-replayable
193
+ // segment boundary, so the player never has to load one giant blob.
194
+ let checkoutIntervalId = null;
123
195
  let recording = false;
196
+ /** Last user activity, for {@link SESSION_TIMEOUT_MS}. */
197
+ let lastActivityAt = 0;
198
+ /** Set while rotating, since taking the new session's snapshot re-enters emit. */
199
+ let rotating = false;
200
+ /** The page the app last reported, so a rotated session opens on it. */
201
+ let currentPage = {};
124
202
  // Click enrichment state
125
203
  let pendingClickContext = null;
126
204
  // Input enrichment state
@@ -185,11 +263,55 @@ export function createLunarDinosRecorder(config) {
185
263
  truncateText(getDirectText(clicked)) ??
186
264
  clicked.querySelector("[aria-label]")?.getAttribute("aria-label") ??
187
265
  null;
188
- const href = tag === "a"
266
+ const href = redactAuthFromUrl(tag === "a"
189
267
  ? clicked.getAttribute("href")
190
- : (clicked.closest("a")?.getAttribute("href") ?? null);
268
+ : (clicked.closest("a")?.getAttribute("href") ?? null));
191
269
  pendingClickContext = { clickTag: tag, clickRole: role, clickName: name, clickHref: href };
192
270
  }
271
+ /**
272
+ * End the current session and open a new one.
273
+ *
274
+ * Order matters: pending events belong to the session that produced them, so
275
+ * they go out first, and the new session opens on a navigation and a full
276
+ * snapshot — without one it starts mid-stream against a DOM the player has
277
+ * never seen and can't be replayed at all, and has no landing page.
278
+ *
279
+ * `snapshot: false` when a navigation triggered the rotation: its own
280
+ * snapshot follows and opens the new session.
281
+ */
282
+ function rotateSession(snapshot) {
283
+ if (rotating)
284
+ return;
285
+ rotating = true;
286
+ try {
287
+ flush();
288
+ sessionId = crypto.randomUUID();
289
+ persistence.setSessionId(sessionId);
290
+ if (typeof sessionStorage !== "undefined") {
291
+ sessionStorage.setItem("__ld_sid", sessionId);
292
+ }
293
+ lastActivityAt = Date.now();
294
+ markActivity();
295
+ if (snapshot) {
296
+ try {
297
+ record.takeNavigationSnapshot(currentPage);
298
+ }
299
+ catch {
300
+ // Recording already stopped; the next start() will snapshot anyway.
301
+ }
302
+ }
303
+ }
304
+ finally {
305
+ rotating = false;
306
+ }
307
+ }
308
+ /** Persist the activity clock so a reload in the same tab can honour it. */
309
+ function markActivity() {
310
+ lastActivityAt = Date.now();
311
+ if (typeof sessionStorage !== "undefined") {
312
+ sessionStorage.setItem(SESSION_ACTIVITY_KEY, String(lastActivityAt));
313
+ }
314
+ }
193
315
  function flush(options) {
194
316
  if (events.length === 0 || !sessionId)
195
317
  return;
@@ -232,16 +354,24 @@ export function createLunarDinosRecorder(config) {
232
354
  }
233
355
  flush();
234
356
  }
357
+ /** What a navigation reports about a page, with auth tokens stripped from its URL. */
358
+ function pageMetadata(payload) {
359
+ return {
360
+ href: redactAuthFromUrl(payload.href) ?? payload.href,
361
+ title: payload.title,
362
+ routePattern: payload.routePattern ?? undefined,
363
+ params: payload.params,
364
+ locale: resolveLocale(payload.locale),
365
+ };
366
+ }
235
367
  function emitPageEvent(payload) {
236
368
  if (!recording)
237
369
  return;
370
+ currentPage = pageMetadata(payload);
238
371
  try {
239
- // Single Pageview event carries both DOM snapshot and route metadata
240
- record.takePageviewSnapshot({
241
- href: payload.href,
242
- title: payload.title,
243
- routePattern: payload.routePattern ?? undefined,
244
- params: payload.params,
372
+ // The navigation event, then Meta + FullSnapshot of the new page
373
+ record.takeNavigationSnapshot({
374
+ ...currentPage,
245
375
  referrer: payload.referrer ?? externalReferrer(),
246
376
  });
247
377
  }
@@ -324,7 +454,15 @@ export function createLunarDinosRecorder(config) {
324
454
  let isNewSession = false;
325
455
  if (persistence.mode === "cookie") {
326
456
  const persistedSession = persistence.getSessionId();
327
- const storedSession = typeof sessionStorage !== "undefined" ? sessionStorage.getItem(SESSION_STORAGE_KEY) : null;
457
+ // sessionStorage outlives the session cookie — it's scoped to the tab, not
458
+ // to 30 minutes — so reusing it unconditionally resurrected sessions the
459
+ // cookie had already expired. Only honour it while it's still fresh.
460
+ const storedAt = typeof sessionStorage !== "undefined"
461
+ ? Number(sessionStorage.getItem(SESSION_ACTIVITY_KEY))
462
+ : Number.NaN;
463
+ const storedSession = storedSessionUsable(storedAt, Date.now()) && typeof sessionStorage !== "undefined"
464
+ ? sessionStorage.getItem(SESSION_STORAGE_KEY)
465
+ : null;
328
466
  isNewSession = !persistedSession && !storedSession;
329
467
  sessionId = persistedSession || storedSession || existingSessionId || crypto.randomUUID();
330
468
  persistence.setSessionId(sessionId);
@@ -345,6 +483,7 @@ export function createLunarDinosRecorder(config) {
345
483
  }
346
484
  events = [];
347
485
  recording = true;
486
+ markActivity();
348
487
  // Listen for clicks/inputs in capture phase to grab context before rrweb sees it
349
488
  document.addEventListener("click", captureClickContext, true);
350
489
  document.addEventListener("input", captureInputContext, true);
@@ -352,20 +491,24 @@ export function createLunarDinosRecorder(config) {
352
491
  document.addEventListener("submit", captureSubmit, true);
353
492
  window.addEventListener("error", captureError);
354
493
  window.addEventListener("unhandledrejection", captureRejection);
494
+ currentPage = startOpts.pageMetadata ? pageMetadata(startOpts.pageMetadata) : {};
355
495
  stopFn = record({
356
496
  ...defaultRrwebOptions,
357
497
  ...rrwebOptions,
358
498
  initialPageMetadata: startOpts.pageMetadata
359
- ? {
360
- href: startOpts.pageMetadata.href,
361
- title: startOpts.pageMetadata.title,
362
- routePattern: startOpts.pageMetadata.routePattern ?? undefined,
363
- params: startOpts.pageMetadata.params,
364
- referrer: startOpts.pageMetadata.referrer ?? undefined,
365
- }
499
+ ? { ...currentPage, referrer: startOpts.pageMetadata.referrer ?? undefined }
366
500
  : undefined,
367
501
  emit(event) {
368
502
  const ev = event;
503
+ // A navigation, or anything a human did, keeps the session alive; if the
504
+ // gap since the last one is too long this is a new sitting, so close the
505
+ // old session before the event is recorded against it.
506
+ if (isUserActivity(ev) && !rotating) {
507
+ if (sessionExpired(lastActivityAt, Date.now()))
508
+ rotateSession(!isNavigation(ev));
509
+ else
510
+ markActivity();
511
+ }
369
512
  // Attach click context to rrweb click events
370
513
  if (ev.type === 3 && ev.data?.source === 2 && ev.data?.type === 2 && pendingClickContext) {
371
514
  const d = ev.data;
@@ -391,10 +534,20 @@ export function createLunarDinosRecorder(config) {
391
534
  if (persistence.mode === "cookieless" || isNewSession) {
392
535
  emitSessionMeta();
393
536
  }
394
- // Flush immediately so the pageview snapshot (which can exceed sendBeacon's
537
+ // Flush immediately so the first snapshot (which can exceed sendBeacon's
395
538
  // ~64 KB limit) is delivered via regular fetch before the user navigates away.
396
539
  flush();
397
540
  intervalId = setInterval(flush, batchInterval);
541
+ // Take a periodic full snapshot so long single-page sessions are split into
542
+ // bounded, independently-replayable segments (matches PostHog's 5-min cadence).
543
+ checkoutIntervalId = setInterval(() => {
544
+ try {
545
+ record.takeFullSnapshot(true);
546
+ }
547
+ catch {
548
+ /* not recording / mid-teardown */
549
+ }
550
+ }, FULL_SNAPSHOT_INTERVAL_MS);
398
551
  return sessionId;
399
552
  }
400
553
  function stop() {
@@ -417,6 +570,10 @@ export function createLunarDinosRecorder(config) {
417
570
  clearInterval(intervalId);
418
571
  intervalId = null;
419
572
  }
573
+ if (checkoutIntervalId) {
574
+ clearInterval(checkoutIntervalId);
575
+ checkoutIntervalId = null;
576
+ }
420
577
  stopFn?.();
421
578
  stopFn = null;
422
579
  recording = false;
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=recorder.browser.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"recorder.browser.test.d.ts","sourceRoot":"","sources":["../../src/recorder.browser.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,144 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { chromium } from "playwright";
3
+ import { buildRecorderBundle } from "./testing/recorder-bundle";
4
+ /**
5
+ * What the recorder uploads around navigations, keyframes and session
6
+ * rotation, captured off the wire from the production recorder in Chromium on
7
+ * a fake clock.
8
+ *
9
+ * A navigation is a Custom "navigation" event followed by Meta + FullSnapshot;
10
+ * a keyframe is Meta + FullSnapshot alone. Keyframes are the recorder's own
11
+ * doing, so they must not keep an idle session alive, and a session rotated
12
+ * after inactivity must open on a navigation so it has a landing page.
13
+ */
14
+ const PAGE = `<!DOCTYPE html><html lang="de"><head><title>App</title></head><body>
15
+ <div id="app"><h1>Shifts</h1><button id="b">Apply</button></div>
16
+ <script src="/recorder.js"></script></body></html>`;
17
+ let browser;
18
+ let bundle;
19
+ beforeAll(async () => {
20
+ bundle = await buildRecorderBundle();
21
+ browser = await chromium.launch();
22
+ });
23
+ afterAll(() => browser.close());
24
+ async function openPage() {
25
+ const uploads = [];
26
+ const page = await browser.newPage();
27
+ await page.route("https://app.test/**", (route) => {
28
+ const { pathname } = new URL(route.request().url());
29
+ if (pathname === "/recorder.js")
30
+ return route.fulfill({ contentType: "application/javascript", body: bundle });
31
+ return route.fulfill({ contentType: "text/html", body: PAGE });
32
+ });
33
+ await page.route("https://ingest.test/**", (route) => {
34
+ const req = route.request();
35
+ const body = new Uint8Array(req.postDataBuffer() ?? []);
36
+ const json = req.headers()["content-encoding"] === "gzip" ? Bun.gunzipSync(body) : body;
37
+ uploads.push(JSON.parse(new TextDecoder().decode(json)));
38
+ return route.fulfill({ status: 200, body: "{}" });
39
+ });
40
+ await page.clock.install({ time: new Date("2026-01-01T10:00:00Z") });
41
+ await page.goto("https://app.test/shifts?access_token=secret");
42
+ await page.evaluate(() => {
43
+ const g = globalThis;
44
+ g.__recorder = g.LunarDinos.createLunarDinosRecorder({
45
+ apiKey: "k",
46
+ endpoint: "https://ingest.test",
47
+ });
48
+ g.__recorder.start({
49
+ pageMetadata: {
50
+ href: location.href,
51
+ title: "App",
52
+ routePattern: "/shifts",
53
+ params: null,
54
+ },
55
+ });
56
+ });
57
+ await settle(page);
58
+ return { page, uploads };
59
+ }
60
+ /** Run the fake clock forward, then let the async gzip + fetch land on the real one. */
61
+ async function settle(page, ms = 2_100) {
62
+ await page.clock.runFor(ms);
63
+ await new Promise((r) => setTimeout(r, 150));
64
+ }
65
+ const all = (uploads) => uploads.flatMap((u) => u.events);
66
+ const kinds = (events) => events
67
+ .filter((e) => e.type !== 3 && e.type !== 0 && e.type !== 1)
68
+ .map((e) => (e.type === 5 ? `5:${e.data.tag}` : String(e.type)));
69
+ describe("recorder uploads", () => {
70
+ test("a recording opens on a navigation with the page metadata, then Meta + FullSnapshot", async () => {
71
+ const { page, uploads } = await openPage();
72
+ const events = all(uploads);
73
+ expect(kinds(events).slice(0, 3)).toEqual(["5:navigation", "4", "2"]);
74
+ const [nav, meta] = events.filter((e) => e.type === 5 || e.type === 4);
75
+ expect(nav.data.payload).toMatchObject({
76
+ href: "https://app.test/shifts?access_token=REDACTED",
77
+ title: "App",
78
+ routePattern: "/shifts",
79
+ params: null,
80
+ locale: "de",
81
+ });
82
+ // Tokens are stripped from the snapshot's href too, not only the page view's.
83
+ expect(meta.data.href).toBe("https://app.test/shifts?access_token=REDACTED");
84
+ expect(events.map((e) => e.type)).not.toContain(7);
85
+ await page.close();
86
+ });
87
+ test("an SPA navigation is a navigation, then Meta + FullSnapshot", async () => {
88
+ const { page, uploads } = await openPage();
89
+ const before = all(uploads).length;
90
+ await page.evaluate(() => {
91
+ document.getElementById("app").innerHTML = "<h1>Profile</h1>";
92
+ globalThis.__recorder.emitPageEvent({
93
+ href: "https://app.test/profile",
94
+ title: "Profile",
95
+ routePattern: "/profile",
96
+ params: null,
97
+ });
98
+ });
99
+ await settle(page);
100
+ const events = all(uploads).slice(before);
101
+ expect(kinds(events)).toEqual(["5:navigation", "4", "2"]);
102
+ expect(events[0].data.payload.routePattern).toBe("/profile");
103
+ await page.close();
104
+ });
105
+ test("a periodic keyframe is Meta + FullSnapshot without a navigation", async () => {
106
+ const { page, uploads } = await openPage();
107
+ const before = all(uploads).length;
108
+ await settle(page, 60_000);
109
+ expect(kinds(all(uploads).slice(before))).toEqual(["4", "2"]);
110
+ await page.close();
111
+ });
112
+ test("keyframes don't keep an idle session alive; it rotates onto a navigation", async () => {
113
+ const { page, uploads } = await openPage();
114
+ const first = uploads[0].sessionId;
115
+ // 31 idle minutes: only keyframes are recorded meanwhile.
116
+ await settle(page, 31 * 60_000);
117
+ expect(new Set(uploads.map((u) => u.sessionId))).toEqual(new Set([first]));
118
+ await page.click("#b");
119
+ await settle(page);
120
+ const next = uploads.filter((u) => u.sessionId !== first);
121
+ expect(next.length).toBeGreaterThan(0);
122
+ const events = all(next);
123
+ // The new session has a landing page: the page the app last reported.
124
+ expect(kinds(events).slice(0, 3)).toEqual(["5:navigation", "4", "2"]);
125
+ expect(events.find((e) => e.type === 5).data.payload.routePattern).toBe("/shifts");
126
+ await page.close();
127
+ });
128
+ test("a navigation after the timeout opens the new session with its own snapshot only", async () => {
129
+ const { page, uploads } = await openPage();
130
+ const first = uploads[0].sessionId;
131
+ await settle(page, 31 * 60_000);
132
+ await page.evaluate(() => globalThis.__recorder.emitPageEvent({
133
+ href: "https://app.test/profile",
134
+ title: "Profile",
135
+ routePattern: "/profile",
136
+ params: null,
137
+ }));
138
+ await settle(page);
139
+ const events = all(uploads.filter((u) => u.sessionId !== first));
140
+ expect(kinds(events)).toEqual(["5:navigation", "4", "2"]);
141
+ expect(events[0].data.payload.routePattern).toBe("/profile");
142
+ await page.close();
143
+ });
144
+ });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * When a recorded session ends.
3
+ *
4
+ * A session is one sitting. The recorder used to resolve its id once at start
5
+ * and never revisit it, so a tab left open stayed a single session for as long
6
+ * as the tab lived — production accumulated recordings of 20 days and 14k
7
+ * segments, which are neither watchable nor meaningful as analytics.
8
+ */
9
+ /**
10
+ * User inactivity that ends a session. Matched to the session cookie's max-age
11
+ * so the in-tab and cross-navigation rules agree on when a session ends.
12
+ */
13
+ export declare const SESSION_TIMEOUT_MS: number;
14
+ /** rrweb event types, narrowed to what this module needs. */
15
+ interface ActivityEvent {
16
+ type: number;
17
+ data?: {
18
+ source?: number;
19
+ tag?: string;
20
+ };
21
+ }
22
+ /** A Custom (5) event tagged "navigation": the app moved to another page. */
23
+ export declare function isNavigation(event: ActivityEvent): boolean;
24
+ /**
25
+ * Whether this event is evidence a person is still there.
26
+ *
27
+ * Keyframes (Meta + FullSnapshot) are not: the recorder takes them on a timer.
28
+ */
29
+ export declare function isUserActivity(event: ActivityEvent): boolean;
30
+ /**
31
+ * Whether the gap since `lastActivityAt` has ended the session.
32
+ *
33
+ * `lastActivityAt` of 0 means nothing has happened yet, which is the start of a
34
+ * session rather than the end of one.
35
+ */
36
+ export declare function sessionExpired(lastActivityAt: number, now: number): boolean;
37
+ /**
38
+ * Whether a session id cached in sessionStorage may still be used.
39
+ *
40
+ * sessionStorage is scoped to the tab, not to 30 minutes, so without this check
41
+ * a reload resurrects a session the cookie had already expired.
42
+ */
43
+ export declare function storedSessionUsable(storedAt: number, now: number): boolean;
44
+ export {};
45
+ //# sourceMappingURL=session-lifecycle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-lifecycle.d.ts","sourceRoot":"","sources":["../../src/session-lifecycle.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH;;;GAGG;AACH,eAAO,MAAM,kBAAkB,QAAiB,CAAA;AAoBhD,6DAA6D;AAC7D,UAAU,aAAa;IACrB,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CACzC;AAED,6EAA6E;AAC7E,wBAAgB,YAAY,CAAC,KAAK,EAAE,aAAa,GAAG,OAAO,CAE1D;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,aAAa,GAAG,OAAO,CAM5D;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,cAAc,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAG3E;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAG1E"}
@@ -0,0 +1,70 @@
1
+ /**
2
+ * When a recorded session ends.
3
+ *
4
+ * A session is one sitting. The recorder used to resolve its id once at start
5
+ * and never revisit it, so a tab left open stayed a single session for as long
6
+ * as the tab lived — production accumulated recordings of 20 days and 14k
7
+ * segments, which are neither watchable nor meaningful as analytics.
8
+ */
9
+ /**
10
+ * User inactivity that ends a session. Matched to the session cookie's max-age
11
+ * so the in-tab and cross-navigation rules agree on when a session ends.
12
+ */
13
+ export const SESSION_TIMEOUT_MS = 30 * 60 * 1000;
14
+ /**
15
+ * rrweb incremental sources that mean a human did something.
16
+ *
17
+ * Deliberately excludes mutations (source 0): a page that polls, animates or
18
+ * re-renders on a timer emits those forever with nobody watching, and counting
19
+ * them as activity is exactly what keeps an abandoned tab alive.
20
+ */
21
+ const USER_ACTIVITY_SOURCES = new Set([
22
+ 1, // MouseMove
23
+ 2, // MouseInteraction
24
+ 3, // Scroll
25
+ 5, // Input
26
+ 6, // TouchMove
27
+ 7, // MediaInteraction
28
+ 12, // Drag
29
+ 14, // Selection
30
+ ]);
31
+ /** A Custom (5) event tagged "navigation": the app moved to another page. */
32
+ export function isNavigation(event) {
33
+ return event.type === 5 && event.data?.tag === "navigation";
34
+ }
35
+ /**
36
+ * Whether this event is evidence a person is still there.
37
+ *
38
+ * Keyframes (Meta + FullSnapshot) are not: the recorder takes them on a timer.
39
+ */
40
+ export function isUserActivity(event) {
41
+ // A navigation is always a person doing something.
42
+ if (isNavigation(event))
43
+ return true;
44
+ // IncrementalSnapshot (3) — only the interaction sources count.
45
+ if (event.type === 3)
46
+ return USER_ACTIVITY_SOURCES.has(event.data?.source ?? -1);
47
+ return false;
48
+ }
49
+ /**
50
+ * Whether the gap since `lastActivityAt` has ended the session.
51
+ *
52
+ * `lastActivityAt` of 0 means nothing has happened yet, which is the start of a
53
+ * session rather than the end of one.
54
+ */
55
+ export function sessionExpired(lastActivityAt, now) {
56
+ if (lastActivityAt <= 0)
57
+ return false;
58
+ return now - lastActivityAt > SESSION_TIMEOUT_MS;
59
+ }
60
+ /**
61
+ * Whether a session id cached in sessionStorage may still be used.
62
+ *
63
+ * sessionStorage is scoped to the tab, not to 30 minutes, so without this check
64
+ * a reload resurrects a session the cookie had already expired.
65
+ */
66
+ export function storedSessionUsable(storedAt, now) {
67
+ if (!Number.isFinite(storedAt) || storedAt <= 0)
68
+ return false;
69
+ return now - storedAt < SESSION_TIMEOUT_MS;
70
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=session-lifecycle.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-lifecycle.test.d.ts","sourceRoot":"","sources":["../../src/session-lifecycle.test.ts"],"names":[],"mappings":""}