@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.
- package/dist/recorder-core.js +112 -6
- package/dist/src/recorder-core.d.ts +9 -0
- package/dist/src/recorder-core.d.ts.map +1 -1
- package/dist/src/recorder-core.js +143 -5
- package/dist/src/session-lifecycle.d.ts +38 -0
- package/dist/src/session-lifecycle.d.ts.map +1 -0
- package/dist/src/session-lifecycle.js +62 -0
- package/dist/src/session-lifecycle.test.d.ts +2 -0
- package/dist/src/session-lifecycle.test.d.ts.map +1 -0
- package/dist/src/session-lifecycle.test.js +85 -0
- package/package.json +1 -1
package/dist/recorder-core.js
CHANGED
|
@@ -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
|
|
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;
|
|
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
|
-
|
|
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 @@
|
|
|
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
|
+
});
|