@lunardinos/browser 0.2.2 → 0.2.4

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.
@@ -4527,8 +4527,40 @@ function createPersistence(mode) {
4527
4527
  return mode === "cookie" ? createCookiePersistence() : createCookielessPersistence();
4528
4528
  }
4529
4529
 
4530
+ // src/session-lifecycle.ts
4531
+ var SESSION_TIMEOUT_MS = 30 * 60 * 1000;
4532
+ var USER_ACTIVITY_SOURCES = new Set([
4533
+ 1,
4534
+ 2,
4535
+ 3,
4536
+ 5,
4537
+ 6,
4538
+ 7,
4539
+ 12,
4540
+ 14
4541
+ ]);
4542
+ function isUserActivity(event) {
4543
+ if (event.type === 7)
4544
+ return true;
4545
+ if (event.type === 3)
4546
+ return USER_ACTIVITY_SOURCES.has(event.data?.source ?? -1);
4547
+ return false;
4548
+ }
4549
+ function sessionExpired(lastActivityAt, now) {
4550
+ if (lastActivityAt <= 0)
4551
+ return false;
4552
+ return now - lastActivityAt > SESSION_TIMEOUT_MS;
4553
+ }
4554
+ function storedSessionUsable(storedAt, now) {
4555
+ if (!Number.isFinite(storedAt) || storedAt <= 0)
4556
+ return false;
4557
+ return now - storedAt < SESSION_TIMEOUT_MS;
4558
+ }
4559
+
4530
4560
  // src/recorder-core.ts
4531
4561
  var DEFAULT_ENDPOINT = "https://ingest.lunardinos.com";
4562
+ var SESSION_ACTIVITY_KEY = "__ld_sid_at";
4563
+ var FULL_SNAPSHOT_INTERVAL_MS = 60 * 1000;
4532
4564
  async function gzipCompress(input) {
4533
4565
  try {
4534
4566
  if (typeof CompressionStream === "undefined")
@@ -4560,6 +4592,30 @@ function truncateText(text) {
4560
4592
  return null;
4561
4593
  return text.length > 40 ? `${text.slice(0, 37)}...` : text;
4562
4594
  }
4595
+ var 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";
4596
+ var AUTH_PAIR = new RegExp(`\\b(${AUTH_URL_KEYS})=[^&#\\s"']*`, "gi");
4597
+ var AUTH_FRAG = new RegExp(`#[^#\\s]*\\b(?:${AUTH_URL_KEYS})=`, "i");
4598
+ function redactAuthFromUrl(value) {
4599
+ if (value == null)
4600
+ return value;
4601
+ if (value.indexOf("=") === -1)
4602
+ return value;
4603
+ let v = value;
4604
+ if (AUTH_FRAG.test(v)) {
4605
+ const h = v.indexOf("#");
4606
+ if (h >= 0)
4607
+ v = v.slice(0, h);
4608
+ }
4609
+ return v.replace(AUTH_PAIR, (m) => `${m.slice(0, m.indexOf("=") + 1)}REDACTED`);
4610
+ }
4611
+ function readHtmlLang() {
4612
+ try {
4613
+ const lang = document.documentElement.getAttribute("lang");
4614
+ return lang?.trim() ? lang.trim().toLowerCase() : null;
4615
+ } catch {
4616
+ return null;
4617
+ }
4618
+ }
4563
4619
  function extractUtmParams() {
4564
4620
  const params = new URLSearchParams(window.location.search);
4565
4621
  const utm = {};
@@ -4572,6 +4628,8 @@ function extractUtmParams() {
4572
4628
  }
4573
4629
  function createLunarDinosRecorder(config) {
4574
4630
  const { apiKey, endpoint = DEFAULT_ENDPOINT, rrwebOptions = {}, batchInterval = 2000 } = config;
4631
+ const configLocale = config.locale ?? null;
4632
+ const resolveLocale = (explicit) => explicit ?? readHtmlLang() ?? configLocale;
4575
4633
  const defaultRrwebOptions = {
4576
4634
  inlineImages: true
4577
4635
  };
@@ -4619,7 +4677,10 @@ function createLunarDinosRecorder(config) {
4619
4677
  let visitorId = null;
4620
4678
  let stopFn = null;
4621
4679
  let intervalId = null;
4680
+ let checkoutIntervalId = null;
4622
4681
  let recording2 = false;
4682
+ let lastActivityAt = 0;
4683
+ let rotating = false;
4623
4684
  let pendingClickContext = null;
4624
4685
  let pendingInputContext = null;
4625
4686
  function captureSubmit(e) {
@@ -4663,9 +4724,35 @@ function createLunarDinosRecorder(config) {
4663
4724
  };
4664
4725
  const role = clicked.getAttribute("role") ?? TAG_ROLES[tag] ?? null;
4665
4726
  const name = clicked.getAttribute("aria-label") ?? clicked.getAttribute("name") ?? clicked.getAttribute("title") ?? truncateText(getDirectText(clicked)) ?? clicked.querySelector("[aria-label]")?.getAttribute("aria-label") ?? null;
4666
- const href = tag === "a" ? clicked.getAttribute("href") : clicked.closest("a")?.getAttribute("href") ?? null;
4727
+ const href = redactAuthFromUrl(tag === "a" ? clicked.getAttribute("href") : clicked.closest("a")?.getAttribute("href") ?? null);
4667
4728
  pendingClickContext = { clickTag: tag, clickRole: role, clickName: name, clickHref: href };
4668
4729
  }
4730
+ function rotateSession() {
4731
+ if (rotating)
4732
+ return;
4733
+ rotating = true;
4734
+ try {
4735
+ flush();
4736
+ sessionId = crypto.randomUUID();
4737
+ persistence.setSessionId(sessionId);
4738
+ if (typeof sessionStorage !== "undefined") {
4739
+ sessionStorage.setItem("__ld_sid", sessionId);
4740
+ }
4741
+ lastActivityAt = Date.now();
4742
+ markActivity();
4743
+ try {
4744
+ record_default.takeFullSnapshot(true);
4745
+ } catch {}
4746
+ } finally {
4747
+ rotating = false;
4748
+ }
4749
+ }
4750
+ function markActivity() {
4751
+ lastActivityAt = Date.now();
4752
+ if (typeof sessionStorage !== "undefined") {
4753
+ sessionStorage.setItem(SESSION_ACTIVITY_KEY, String(lastActivityAt));
4754
+ }
4755
+ }
4669
4756
  function flush(options) {
4670
4757
  if (events.length === 0 || !sessionId)
4671
4758
  return;
@@ -4705,11 +4792,12 @@ function createLunarDinosRecorder(config) {
4705
4792
  return;
4706
4793
  try {
4707
4794
  record_default.takePageviewSnapshot({
4708
- href: payload.href,
4795
+ href: redactAuthFromUrl(payload.href) ?? payload.href,
4709
4796
  title: payload.title,
4710
4797
  routePattern: payload.routePattern ?? undefined,
4711
4798
  params: payload.params,
4712
- referrer: payload.referrer ?? externalReferrer()
4799
+ referrer: payload.referrer ?? externalReferrer(),
4800
+ locale: resolveLocale(payload.locale)
4713
4801
  });
4714
4802
  } catch {}
4715
4803
  }
@@ -4769,7 +4857,8 @@ function createLunarDinosRecorder(config) {
4769
4857
  let isNewSession = false;
4770
4858
  if (persistence.mode === "cookie") {
4771
4859
  const persistedSession = persistence.getSessionId();
4772
- const storedSession = typeof sessionStorage !== "undefined" ? sessionStorage.getItem(SESSION_STORAGE_KEY) : null;
4860
+ const storedAt = typeof sessionStorage !== "undefined" ? Number(sessionStorage.getItem(SESSION_ACTIVITY_KEY)) : Number.NaN;
4861
+ const storedSession = storedSessionUsable(storedAt, Date.now()) && typeof sessionStorage !== "undefined" ? sessionStorage.getItem(SESSION_STORAGE_KEY) : null;
4773
4862
  isNewSession = !persistedSession && !storedSession;
4774
4863
  sessionId = persistedSession || storedSession || existingSessionId || crypto.randomUUID();
4775
4864
  persistence.setSessionId(sessionId);
@@ -4786,6 +4875,7 @@ function createLunarDinosRecorder(config) {
4786
4875
  }
4787
4876
  events = [];
4788
4877
  recording2 = true;
4878
+ markActivity();
4789
4879
  document.addEventListener("click", captureClickContext, true);
4790
4880
  document.addEventListener("input", captureInputContext, true);
4791
4881
  document.addEventListener("change", captureInputContext, true);
@@ -4796,14 +4886,21 @@ function createLunarDinosRecorder(config) {
4796
4886
  ...defaultRrwebOptions,
4797
4887
  ...rrwebOptions,
4798
4888
  initialPageMetadata: startOpts.pageMetadata ? {
4799
- href: startOpts.pageMetadata.href,
4889
+ href: redactAuthFromUrl(startOpts.pageMetadata.href) ?? startOpts.pageMetadata.href,
4800
4890
  title: startOpts.pageMetadata.title,
4801
4891
  routePattern: startOpts.pageMetadata.routePattern ?? undefined,
4802
4892
  params: startOpts.pageMetadata.params,
4803
- referrer: startOpts.pageMetadata.referrer ?? undefined
4893
+ referrer: startOpts.pageMetadata.referrer ?? undefined,
4894
+ locale: resolveLocale(startOpts.pageMetadata.locale)
4804
4895
  } : undefined,
4805
4896
  emit(event) {
4806
4897
  const ev = event;
4898
+ if (isUserActivity(ev) && !rotating) {
4899
+ if (sessionExpired(lastActivityAt, Date.now()))
4900
+ rotateSession();
4901
+ else
4902
+ markActivity();
4903
+ }
4807
4904
  if (ev.type === 3 && ev.data?.source === 2 && ev.data?.type === 2 && pendingClickContext) {
4808
4905
  const d = ev.data;
4809
4906
  d.clickTag = pendingClickContext.clickTag;
@@ -4826,6 +4923,11 @@ function createLunarDinosRecorder(config) {
4826
4923
  }
4827
4924
  flush();
4828
4925
  intervalId = setInterval(flush, batchInterval);
4926
+ checkoutIntervalId = setInterval(() => {
4927
+ try {
4928
+ record_default.takeFullSnapshot(true);
4929
+ } catch {}
4930
+ }, FULL_SNAPSHOT_INTERVAL_MS);
4829
4931
  return sessionId;
4830
4932
  }
4831
4933
  function stop() {
@@ -4845,6 +4947,10 @@ function createLunarDinosRecorder(config) {
4845
4947
  clearInterval(intervalId);
4846
4948
  intervalId = null;
4847
4949
  }
4950
+ if (checkoutIntervalId) {
4951
+ clearInterval(checkoutIntervalId);
4952
+ checkoutIntervalId = null;
4953
+ }
4848
4954
  stopFn?.();
4849
4955
  stopFn = null;
4850
4956
  recording2 = false;
@@ -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;AAQhG,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,CA4hBzE"}
@@ -1,6 +1,24 @@
1
1
  import record from "@lunardinos/rrweb-record";
2
2
  import { createPersistence } from "./persistence";
3
+ import { 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 = {
@@ -120,7 +177,15 @@ export function createLunarDinosRecorder(config) {
120
177
  let visitorId = null;
121
178
  let stopFn = null;
122
179
  let intervalId = null;
180
+ // Periodic full-snapshot ("checkout") timer. Bounds segment size for long
181
+ // single-page sessions: each snapshot becomes an independently-replayable
182
+ // segment boundary, so the player never has to load one giant blob.
183
+ let checkoutIntervalId = null;
123
184
  let recording = false;
185
+ /** Last user activity, for {@link SESSION_TIMEOUT_MS}. */
186
+ let lastActivityAt = 0;
187
+ /** Set while rotating, since taking the new session's snapshot re-enters emit. */
188
+ let rotating = false;
124
189
  // Click enrichment state
125
190
  let pendingClickContext = null;
126
191
  // Input enrichment state
@@ -185,11 +250,50 @@ export function createLunarDinosRecorder(config) {
185
250
  truncateText(getDirectText(clicked)) ??
186
251
  clicked.querySelector("[aria-label]")?.getAttribute("aria-label") ??
187
252
  null;
188
- const href = tag === "a"
253
+ const href = redactAuthFromUrl(tag === "a"
189
254
  ? clicked.getAttribute("href")
190
- : (clicked.closest("a")?.getAttribute("href") ?? null);
255
+ : (clicked.closest("a")?.getAttribute("href") ?? null));
191
256
  pendingClickContext = { clickTag: tag, clickRole: role, clickName: name, clickHref: href };
192
257
  }
258
+ /**
259
+ * End the current session and open a new one.
260
+ *
261
+ * Order matters: pending events belong to the session that produced them, so
262
+ * they go out first, and the new session opens on a full snapshot — without
263
+ * one it starts mid-stream against a DOM the player has never seen and can't
264
+ * be replayed at all.
265
+ */
266
+ function rotateSession() {
267
+ if (rotating)
268
+ return;
269
+ rotating = true;
270
+ try {
271
+ flush();
272
+ sessionId = crypto.randomUUID();
273
+ persistence.setSessionId(sessionId);
274
+ if (typeof sessionStorage !== "undefined") {
275
+ sessionStorage.setItem("__ld_sid", sessionId);
276
+ }
277
+ lastActivityAt = Date.now();
278
+ markActivity();
279
+ try {
280
+ record.takeFullSnapshot(true);
281
+ }
282
+ catch {
283
+ // Recording already stopped; the next start() will snapshot anyway.
284
+ }
285
+ }
286
+ finally {
287
+ rotating = false;
288
+ }
289
+ }
290
+ /** Persist the activity clock so a reload in the same tab can honour it. */
291
+ function markActivity() {
292
+ lastActivityAt = Date.now();
293
+ if (typeof sessionStorage !== "undefined") {
294
+ sessionStorage.setItem(SESSION_ACTIVITY_KEY, String(lastActivityAt));
295
+ }
296
+ }
193
297
  function flush(options) {
194
298
  if (events.length === 0 || !sessionId)
195
299
  return;
@@ -238,11 +342,12 @@ export function createLunarDinosRecorder(config) {
238
342
  try {
239
343
  // Single Pageview event carries both DOM snapshot and route metadata
240
344
  record.takePageviewSnapshot({
241
- href: payload.href,
345
+ href: redactAuthFromUrl(payload.href) ?? payload.href,
242
346
  title: payload.title,
243
347
  routePattern: payload.routePattern ?? undefined,
244
348
  params: payload.params,
245
349
  referrer: payload.referrer ?? externalReferrer(),
350
+ locale: resolveLocale(payload.locale),
246
351
  });
247
352
  }
248
353
  catch {
@@ -324,7 +429,15 @@ export function createLunarDinosRecorder(config) {
324
429
  let isNewSession = false;
325
430
  if (persistence.mode === "cookie") {
326
431
  const persistedSession = persistence.getSessionId();
327
- const storedSession = typeof sessionStorage !== "undefined" ? sessionStorage.getItem(SESSION_STORAGE_KEY) : null;
432
+ // sessionStorage outlives the session cookie — it's scoped to the tab, not
433
+ // to 30 minutes — so reusing it unconditionally resurrected sessions the
434
+ // cookie had already expired. Only honour it while it's still fresh.
435
+ const storedAt = typeof sessionStorage !== "undefined"
436
+ ? Number(sessionStorage.getItem(SESSION_ACTIVITY_KEY))
437
+ : Number.NaN;
438
+ const storedSession = storedSessionUsable(storedAt, Date.now()) && typeof sessionStorage !== "undefined"
439
+ ? sessionStorage.getItem(SESSION_STORAGE_KEY)
440
+ : null;
328
441
  isNewSession = !persistedSession && !storedSession;
329
442
  sessionId = persistedSession || storedSession || existingSessionId || crypto.randomUUID();
330
443
  persistence.setSessionId(sessionId);
@@ -345,6 +458,7 @@ export function createLunarDinosRecorder(config) {
345
458
  }
346
459
  events = [];
347
460
  recording = true;
461
+ markActivity();
348
462
  // Listen for clicks/inputs in capture phase to grab context before rrweb sees it
349
463
  document.addEventListener("click", captureClickContext, true);
350
464
  document.addEventListener("input", captureInputContext, true);
@@ -357,15 +471,25 @@ export function createLunarDinosRecorder(config) {
357
471
  ...rrwebOptions,
358
472
  initialPageMetadata: startOpts.pageMetadata
359
473
  ? {
360
- href: startOpts.pageMetadata.href,
474
+ href: redactAuthFromUrl(startOpts.pageMetadata.href) ?? startOpts.pageMetadata.href,
361
475
  title: startOpts.pageMetadata.title,
362
476
  routePattern: startOpts.pageMetadata.routePattern ?? undefined,
363
477
  params: startOpts.pageMetadata.params,
364
478
  referrer: startOpts.pageMetadata.referrer ?? undefined,
479
+ locale: resolveLocale(startOpts.pageMetadata.locale),
365
480
  }
366
481
  : undefined,
367
482
  emit(event) {
368
483
  const ev = event;
484
+ // A navigation, or anything a human did, keeps the session alive; if the
485
+ // gap since the last one is too long this is a new sitting, so close the
486
+ // old session before the event is recorded against it.
487
+ if (isUserActivity(ev) && !rotating) {
488
+ if (sessionExpired(lastActivityAt, Date.now()))
489
+ rotateSession();
490
+ else
491
+ markActivity();
492
+ }
369
493
  // Attach click context to rrweb click events
370
494
  if (ev.type === 3 && ev.data?.source === 2 && ev.data?.type === 2 && pendingClickContext) {
371
495
  const d = ev.data;
@@ -395,6 +519,16 @@ export function createLunarDinosRecorder(config) {
395
519
  // ~64 KB limit) is delivered via regular fetch before the user navigates away.
396
520
  flush();
397
521
  intervalId = setInterval(flush, batchInterval);
522
+ // Take a periodic full snapshot so long single-page sessions are split into
523
+ // bounded, independently-replayable segments (matches PostHog's 5-min cadence).
524
+ checkoutIntervalId = setInterval(() => {
525
+ try {
526
+ record.takeFullSnapshot(true);
527
+ }
528
+ catch {
529
+ /* not recording / mid-teardown */
530
+ }
531
+ }, FULL_SNAPSHOT_INTERVAL_MS);
398
532
  return sessionId;
399
533
  }
400
534
  function stop() {
@@ -417,6 +551,10 @@ export function createLunarDinosRecorder(config) {
417
551
  clearInterval(intervalId);
418
552
  intervalId = null;
419
553
  }
554
+ if (checkoutIntervalId) {
555
+ clearInterval(checkoutIntervalId);
556
+ checkoutIntervalId = null;
557
+ }
420
558
  stopFn?.();
421
559
  stopFn = null;
422
560
  recording = false;
@@ -0,0 +1,38 @@
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
+ };
20
+ }
21
+ /** Whether this event is evidence a person is still there. */
22
+ export declare function isUserActivity(event: ActivityEvent): boolean;
23
+ /**
24
+ * Whether the gap since `lastActivityAt` has ended the session.
25
+ *
26
+ * `lastActivityAt` of 0 means nothing has happened yet, which is the start of a
27
+ * session rather than the end of one.
28
+ */
29
+ export declare function sessionExpired(lastActivityAt: number, now: number): boolean;
30
+ /**
31
+ * Whether a session id cached in sessionStorage may still be used.
32
+ *
33
+ * sessionStorage is scoped to the tab, not to 30 minutes, so without this check
34
+ * a reload resurrects a session the cookie had already expired.
35
+ */
36
+ export declare function storedSessionUsable(storedAt: number, now: number): boolean;
37
+ export {};
38
+ //# 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,CAAA;KAAE,CAAA;CAC3B;AAED,8DAA8D;AAC9D,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,62 @@
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
+ /** Whether this event is evidence a person is still there. */
32
+ export function isUserActivity(event) {
33
+ // Pageview (7) — a navigation is always a person doing something.
34
+ if (event.type === 7)
35
+ return true;
36
+ // IncrementalSnapshot (3) — only the interaction sources count.
37
+ if (event.type === 3)
38
+ return USER_ACTIVITY_SOURCES.has(event.data?.source ?? -1);
39
+ return false;
40
+ }
41
+ /**
42
+ * Whether the gap since `lastActivityAt` has ended the session.
43
+ *
44
+ * `lastActivityAt` of 0 means nothing has happened yet, which is the start of a
45
+ * session rather than the end of one.
46
+ */
47
+ export function sessionExpired(lastActivityAt, now) {
48
+ if (lastActivityAt <= 0)
49
+ return false;
50
+ return now - lastActivityAt > SESSION_TIMEOUT_MS;
51
+ }
52
+ /**
53
+ * Whether a session id cached in sessionStorage may still be used.
54
+ *
55
+ * sessionStorage is scoped to the tab, not to 30 minutes, so without this check
56
+ * a reload resurrects a session the cookie had already expired.
57
+ */
58
+ export function storedSessionUsable(storedAt, now) {
59
+ if (!Number.isFinite(storedAt) || storedAt <= 0)
60
+ return false;
61
+ return now - storedAt < SESSION_TIMEOUT_MS;
62
+ }
@@ -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":""}
@@ -0,0 +1,85 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { isUserActivity, SESSION_TIMEOUT_MS, sessionExpired, storedSessionUsable, } from "./session-lifecycle";
3
+ /**
4
+ * These rules decide how a visitor's time is cut into sessions, and both failure
5
+ * directions are expensive: too permissive and a tab left open becomes a 20-day
6
+ * recording, too strict and one sitting fragments into dozens of stubs.
7
+ */
8
+ const NOW = 1_700_000_000_000;
9
+ /** rrweb IncrementalSnapshot with the given source. */
10
+ const inc = (source) => ({ type: 3, data: { source } });
11
+ describe("isUserActivity", () => {
12
+ test("counts the things a person does", () => {
13
+ expect(isUserActivity(inc(1))).toBe(true); // mouse move
14
+ expect(isUserActivity(inc(2))).toBe(true); // click
15
+ expect(isUserActivity(inc(3))).toBe(true); // scroll
16
+ expect(isUserActivity(inc(5))).toBe(true); // input
17
+ expect(isUserActivity(inc(6))).toBe(true); // touch
18
+ expect(isUserActivity(inc(12))).toBe(true); // drag
19
+ expect(isUserActivity(inc(14))).toBe(true); // selection
20
+ });
21
+ test("a navigation is always activity", () => {
22
+ expect(isUserActivity({ type: 7 })).toBe(true);
23
+ });
24
+ test("does NOT count DOM mutations", () => {
25
+ // The whole point: a polling or animating page emits these forever with
26
+ // nobody watching. Counting them is what created the 20-day recordings.
27
+ expect(isUserActivity(inc(0))).toBe(false);
28
+ });
29
+ test("does not count other machine-driven sources", () => {
30
+ expect(isUserActivity(inc(4))).toBe(false); // viewport resize
31
+ expect(isUserActivity(inc(8))).toBe(false); // stylesheet rule
32
+ expect(isUserActivity(inc(9))).toBe(false); // canvas mutation
33
+ expect(isUserActivity(inc(11))).toBe(false); // log
34
+ expect(isUserActivity(inc(15))).toBe(false); // adopted stylesheet
35
+ });
36
+ test("ignores non-incremental event types", () => {
37
+ expect(isUserActivity({ type: 2 })).toBe(false); // full snapshot
38
+ expect(isUserActivity({ type: 4 })).toBe(false); // meta
39
+ expect(isUserActivity({ type: 5 })).toBe(false); // custom
40
+ });
41
+ test("tolerates a missing source", () => {
42
+ expect(isUserActivity({ type: 3 })).toBe(false);
43
+ expect(isUserActivity({ type: 3, data: {} })).toBe(false);
44
+ });
45
+ });
46
+ describe("sessionExpired", () => {
47
+ test("holds the session open right up to the timeout", () => {
48
+ expect(sessionExpired(NOW, NOW + SESSION_TIMEOUT_MS - 1)).toBe(false);
49
+ expect(sessionExpired(NOW, NOW + SESSION_TIMEOUT_MS)).toBe(false);
50
+ });
51
+ test("ends it past the timeout", () => {
52
+ expect(sessionExpired(NOW, NOW + SESSION_TIMEOUT_MS + 1)).toBe(true);
53
+ });
54
+ test("a long sitting stays one session while activity continues", () => {
55
+ // Activity every 25 minutes for 8 hours never expires — the gap is what
56
+ // matters, not the total duration.
57
+ let last = NOW;
58
+ for (let i = 0; i < 20; i++) {
59
+ const next = last + 25 * 60_000;
60
+ expect(sessionExpired(last, next)).toBe(false);
61
+ last = next;
62
+ }
63
+ });
64
+ test("an idle tab expires once, not repeatedly", () => {
65
+ // 20 days of silence is a single expiry, and the caller resets the clock.
66
+ expect(sessionExpired(NOW, NOW + 20 * 24 * 60 * 60_000)).toBe(true);
67
+ });
68
+ test("no activity yet is not an expiry", () => {
69
+ expect(sessionExpired(0, NOW)).toBe(false);
70
+ });
71
+ });
72
+ describe("storedSessionUsable", () => {
73
+ test("reuses a session the tab touched recently", () => {
74
+ expect(storedSessionUsable(NOW, NOW + 60_000)).toBe(true);
75
+ });
76
+ test("refuses one the cookie would already have expired", () => {
77
+ expect(storedSessionUsable(NOW, NOW + SESSION_TIMEOUT_MS)).toBe(false);
78
+ expect(storedSessionUsable(NOW, NOW + SESSION_TIMEOUT_MS + 1)).toBe(false);
79
+ });
80
+ test("refuses a missing or unparseable timestamp", () => {
81
+ // An SDK upgrade leaves tabs with no stored timestamp; those start fresh.
82
+ expect(storedSessionUsable(Number.NaN, NOW)).toBe(false);
83
+ expect(storedSessionUsable(0, NOW)).toBe(false);
84
+ });
85
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunardinos/browser",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {