@lunardinos/browser 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -0
- package/dist/recorder-core.js +4892 -0
- package/dist/src/persistence.d.ts +16 -0
- package/dist/src/persistence.d.ts.map +1 -0
- package/dist/src/persistence.js +62 -0
- package/dist/src/recorder-core.d.ts +72 -0
- package/dist/src/recorder-core.d.ts.map +1 -0
- package/dist/src/recorder-core.js +470 -0
- package/package.json +24 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type PersistenceMode = "cookie" | "cookieless";
|
|
2
|
+
export interface PersistenceAdapter {
|
|
3
|
+
getVisitorId(): string | null;
|
|
4
|
+
getSessionId(): string | null;
|
|
5
|
+
setVisitorId(id: string): void;
|
|
6
|
+
setSessionId(id: string): void;
|
|
7
|
+
/** Touch the session cookie to extend its rolling expiry. */
|
|
8
|
+
touchSession(): void;
|
|
9
|
+
/** Delete all persisted state (cookies/storage). */
|
|
10
|
+
clear(): void;
|
|
11
|
+
readonly mode: PersistenceMode;
|
|
12
|
+
}
|
|
13
|
+
export declare function createCookiePersistence(): PersistenceAdapter;
|
|
14
|
+
export declare function createCookielessPersistence(): PersistenceAdapter;
|
|
15
|
+
export declare function createPersistence(mode: PersistenceMode): PersistenceAdapter;
|
|
16
|
+
//# sourceMappingURL=persistence.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"persistence.d.ts","sourceRoot":"","sources":["../../src/persistence.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,eAAe,GAAG,QAAQ,GAAG,YAAY,CAAA;AAErD,MAAM,WAAW,kBAAkB;IACjC,YAAY,IAAI,MAAM,GAAG,IAAI,CAAA;IAC7B,YAAY,IAAI,MAAM,GAAG,IAAI,CAAA;IAC7B,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,6DAA6D;IAC7D,YAAY,IAAI,IAAI,CAAA;IACpB,oDAAoD;IACpD,KAAK,IAAI,IAAI,CAAA;IACb,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAA;CAC/B;AA6BD,wBAAgB,uBAAuB,IAAI,kBAAkB,CA8B5D;AAID,wBAAgB,2BAA2B,IAAI,kBAAkB,CAUhE;AAID,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,eAAe,GAAG,kBAAkB,CAE3E"}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// ── Cookie helpers ──────────────────────────────────────────────────
|
|
2
|
+
const VISITOR_COOKIE = "__ld_vid";
|
|
3
|
+
const SESSION_COOKIE = "__ld_sid";
|
|
4
|
+
/** 1 year in seconds */
|
|
5
|
+
const VISITOR_MAX_AGE = 365 * 24 * 60 * 60;
|
|
6
|
+
/** 30 minutes in seconds */
|
|
7
|
+
const SESSION_MAX_AGE = 30 * 60;
|
|
8
|
+
function getCookie(name) {
|
|
9
|
+
const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
|
|
10
|
+
return match?.[1] ? decodeURIComponent(match[1]) : null;
|
|
11
|
+
}
|
|
12
|
+
function setCookie(name, value, maxAge) {
|
|
13
|
+
// biome-ignore lint/suspicious/noDocumentCookie: intentional cookie API usage for session tracking
|
|
14
|
+
document.cookie = `${name}=${encodeURIComponent(value)};path=/;max-age=${maxAge};SameSite=Lax`;
|
|
15
|
+
}
|
|
16
|
+
function deleteCookie(name) {
|
|
17
|
+
// biome-ignore lint/suspicious/noDocumentCookie: intentional cookie API usage for session tracking
|
|
18
|
+
document.cookie = `${name}=;path=/;max-age=0;SameSite=Lax`;
|
|
19
|
+
}
|
|
20
|
+
// ── Cookie Persistence ──────────────────────────────────────────────
|
|
21
|
+
export function createCookiePersistence() {
|
|
22
|
+
return {
|
|
23
|
+
mode: "cookie",
|
|
24
|
+
getVisitorId() {
|
|
25
|
+
return getCookie(VISITOR_COOKIE);
|
|
26
|
+
},
|
|
27
|
+
getSessionId() {
|
|
28
|
+
return getCookie(SESSION_COOKIE);
|
|
29
|
+
},
|
|
30
|
+
setVisitorId(id) {
|
|
31
|
+
setCookie(VISITOR_COOKIE, id, VISITOR_MAX_AGE);
|
|
32
|
+
},
|
|
33
|
+
setSessionId(id) {
|
|
34
|
+
setCookie(SESSION_COOKIE, id, SESSION_MAX_AGE);
|
|
35
|
+
},
|
|
36
|
+
touchSession() {
|
|
37
|
+
const sid = getCookie(SESSION_COOKIE);
|
|
38
|
+
if (sid)
|
|
39
|
+
setCookie(SESSION_COOKIE, sid, SESSION_MAX_AGE);
|
|
40
|
+
},
|
|
41
|
+
clear() {
|
|
42
|
+
deleteCookie(VISITOR_COOKIE);
|
|
43
|
+
deleteCookie(SESSION_COOKIE);
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
// ── Cookieless Persistence (no-op, in-memory only) ──────────────────
|
|
48
|
+
export function createCookielessPersistence() {
|
|
49
|
+
return {
|
|
50
|
+
mode: "cookieless",
|
|
51
|
+
getVisitorId: () => null,
|
|
52
|
+
getSessionId: () => null,
|
|
53
|
+
setVisitorId: () => { },
|
|
54
|
+
setSessionId: () => { },
|
|
55
|
+
touchSession: () => { },
|
|
56
|
+
clear: () => { },
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
// ── Factory ─────────────────────────────────────────────────────────
|
|
60
|
+
export function createPersistence(mode) {
|
|
61
|
+
return mode === "cookie" ? createCookiePersistence() : createCookielessPersistence();
|
|
62
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { type PersistenceMode } from "./persistence";
|
|
2
|
+
export type { PersistenceMode } from "./persistence";
|
|
3
|
+
interface RecorderConfig {
|
|
4
|
+
/** API key for authentication */
|
|
5
|
+
apiKey: string;
|
|
6
|
+
/** Ingestion endpoint (defaults to https://ingest.lunardinos.com) */
|
|
7
|
+
endpoint?: string;
|
|
8
|
+
/** rrweb record options (inlineStylesheet, inlineImages, etc.) */
|
|
9
|
+
rrwebOptions?: Record<string, unknown>;
|
|
10
|
+
/** Batching interval in ms (default 2000) */
|
|
11
|
+
batchInterval?: number;
|
|
12
|
+
/** Initial persistence mode: 'cookie' (default) or 'cookieless'. Can be changed at runtime via setPersistence(). */
|
|
13
|
+
defaultPersistence?: PersistenceMode;
|
|
14
|
+
}
|
|
15
|
+
interface PageEventPayload {
|
|
16
|
+
href: string;
|
|
17
|
+
title: string;
|
|
18
|
+
routePattern: string | null;
|
|
19
|
+
params: Record<string, string> | null;
|
|
20
|
+
referrer?: string | null;
|
|
21
|
+
}
|
|
22
|
+
export interface IdentifyPayload {
|
|
23
|
+
user: {
|
|
24
|
+
id: string;
|
|
25
|
+
email?: string;
|
|
26
|
+
name?: string;
|
|
27
|
+
role?: string;
|
|
28
|
+
[key: string]: unknown;
|
|
29
|
+
};
|
|
30
|
+
account?: {
|
|
31
|
+
id: string;
|
|
32
|
+
name?: string;
|
|
33
|
+
onboarded?: boolean;
|
|
34
|
+
[key: string]: unknown;
|
|
35
|
+
};
|
|
36
|
+
plan?: {
|
|
37
|
+
id: string;
|
|
38
|
+
name?: string;
|
|
39
|
+
[key: string]: unknown;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export interface StartOptions {
|
|
43
|
+
sessionId?: string;
|
|
44
|
+
/** Page metadata to attach to the initial snapshot (avoids a separate emitPageEvent call). */
|
|
45
|
+
pageMetadata?: PageEventPayload;
|
|
46
|
+
}
|
|
47
|
+
export interface Recorder {
|
|
48
|
+
start: (options?: string | StartOptions) => string;
|
|
49
|
+
stop: () => void;
|
|
50
|
+
/** Flush buffered events. Pass `{ beacon: true }` during page unload for reliable delivery. */
|
|
51
|
+
flush: (options?: {
|
|
52
|
+
beacon?: boolean;
|
|
53
|
+
}) => void;
|
|
54
|
+
getSessionId: () => string | null;
|
|
55
|
+
getVisitorId: () => string | null;
|
|
56
|
+
isRecording: () => boolean;
|
|
57
|
+
/** Emit a page event with route metadata (called by the SDK, not internally). */
|
|
58
|
+
emitPageEvent: (payload: PageEventPayload) => void;
|
|
59
|
+
/** Tag the current session with account/user identity for B2B analytics. */
|
|
60
|
+
identify: (payload: IdentifyPayload) => void;
|
|
61
|
+
/** Switch persistence mode at runtime (for cookie consent integration). */
|
|
62
|
+
setPersistence: (mode: PersistenceMode) => void;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Creates a recorder instance that wraps rrweb's `record()`.
|
|
66
|
+
*
|
|
67
|
+
* Handles event buffering and batching. Navigation detection is delegated
|
|
68
|
+
* to the framework SDK (e.g. recorder-next). Trigger attribution is done
|
|
69
|
+
* server-side by correlating click events with page views.
|
|
70
|
+
*/
|
|
71
|
+
export declare function createLunarDinosRecorder(config: RecorderConfig): Recorder;
|
|
72
|
+
//# sourceMappingURL=recorder-core.d.ts.map
|
|
@@ -0,0 +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,CA0bzE"}
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
import record from "@lunardinos/rrweb-record";
|
|
2
|
+
import { createPersistence } from "./persistence";
|
|
3
|
+
const DEFAULT_ENDPOINT = "https://ingest.lunardinos.com";
|
|
4
|
+
/** Compress a string via the CompressionStream API (gzip). Returns null if unavailable or on error. */
|
|
5
|
+
async function gzipCompress(input) {
|
|
6
|
+
try {
|
|
7
|
+
if (typeof CompressionStream === "undefined")
|
|
8
|
+
return null;
|
|
9
|
+
const blob = new Blob([input]);
|
|
10
|
+
const stream = blob.stream().pipeThrough(new CompressionStream("gzip"));
|
|
11
|
+
return await new Response(stream).blob();
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const INTERACTIVE_TAGS = new Set(["a", "button", "input", "select", "textarea"]);
|
|
18
|
+
/** Walk up the DOM to find the nearest interactive ancestor (or the target itself). */
|
|
19
|
+
function findInteractiveElement(target) {
|
|
20
|
+
let el = target;
|
|
21
|
+
while (el && el !== document.body) {
|
|
22
|
+
if (INTERACTIVE_TAGS.has(el.tagName.toLowerCase()) || el.getAttribute("role")) {
|
|
23
|
+
return el;
|
|
24
|
+
}
|
|
25
|
+
el = el.parentElement;
|
|
26
|
+
}
|
|
27
|
+
return target;
|
|
28
|
+
}
|
|
29
|
+
/** Get direct/shallow text content, avoiding concatenation of all descendant text. */
|
|
30
|
+
function getDirectText(el) {
|
|
31
|
+
const text = el.innerText?.trim().slice(0, 80);
|
|
32
|
+
return text || null;
|
|
33
|
+
}
|
|
34
|
+
/** Truncate text to a reasonable label length. */
|
|
35
|
+
function truncateText(text) {
|
|
36
|
+
if (!text)
|
|
37
|
+
return null;
|
|
38
|
+
return text.length > 40 ? `${text.slice(0, 37)}...` : text;
|
|
39
|
+
}
|
|
40
|
+
/** Extract UTM parameters from the current URL. */
|
|
41
|
+
function extractUtmParams() {
|
|
42
|
+
const params = new URLSearchParams(window.location.search);
|
|
43
|
+
const utm = {};
|
|
44
|
+
for (const key of ["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content"]) {
|
|
45
|
+
const val = params.get(key);
|
|
46
|
+
if (val)
|
|
47
|
+
utm[key] = val;
|
|
48
|
+
}
|
|
49
|
+
return utm;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Creates a recorder instance that wraps rrweb's `record()`.
|
|
53
|
+
*
|
|
54
|
+
* Handles event buffering and batching. Navigation detection is delegated
|
|
55
|
+
* to the framework SDK (e.g. recorder-next). Trigger attribution is done
|
|
56
|
+
* server-side by correlating click events with page views.
|
|
57
|
+
*/
|
|
58
|
+
export function createLunarDinosRecorder(config) {
|
|
59
|
+
const { apiKey, endpoint = DEFAULT_ENDPOINT, rrwebOptions = {}, batchInterval = 2000 } = config;
|
|
60
|
+
// Always inline images so they display correctly in replay (the replay
|
|
61
|
+
// iframe is on a different origin and can't fetch original URLs).
|
|
62
|
+
const defaultRrwebOptions = {
|
|
63
|
+
inlineImages: true,
|
|
64
|
+
};
|
|
65
|
+
const url = `${endpoint}/api/events`;
|
|
66
|
+
let persistence = createPersistence(config.defaultPersistence ?? "cookie");
|
|
67
|
+
/** Build the beacon URL with auth + mode encoded as query params. */
|
|
68
|
+
function beaconUrl() {
|
|
69
|
+
const u = new URL(url);
|
|
70
|
+
u.searchParams.set("key", apiKey);
|
|
71
|
+
if (persistence.mode === "cookieless")
|
|
72
|
+
u.searchParams.set("cookieless", "1");
|
|
73
|
+
return u.toString();
|
|
74
|
+
}
|
|
75
|
+
function send(body, headers) {
|
|
76
|
+
const reqHeaders = {
|
|
77
|
+
...headers,
|
|
78
|
+
Authorization: `Bearer ${apiKey}`,
|
|
79
|
+
};
|
|
80
|
+
// Signal cookieless mode to the server so it can generate server-side identity
|
|
81
|
+
if (persistence.mode === "cookieless") {
|
|
82
|
+
reqHeaders["X-LD-Cookieless"] = "1";
|
|
83
|
+
}
|
|
84
|
+
fetch(url, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers: reqHeaders,
|
|
87
|
+
body,
|
|
88
|
+
}).catch(() => { });
|
|
89
|
+
}
|
|
90
|
+
/** Send via navigator.sendBeacon — reliable during page unload, no custom headers. */
|
|
91
|
+
function sendViaBeacon(json) {
|
|
92
|
+
try {
|
|
93
|
+
// Use text/plain to avoid CORS preflight during page unload.
|
|
94
|
+
// application/json triggers an OPTIONS preflight that the browser may not
|
|
95
|
+
// complete before the page is torn down, causing silent delivery failure.
|
|
96
|
+
const blob = new Blob([json], { type: "text/plain" });
|
|
97
|
+
const sent = navigator.sendBeacon(beaconUrl(), blob);
|
|
98
|
+
if (sent)
|
|
99
|
+
return;
|
|
100
|
+
// sendBeacon rejected (payload too large) — fall through to fetch+keepalive
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// sendBeacon threw — fall through to fetch+keepalive
|
|
104
|
+
}
|
|
105
|
+
// Fallback: try fetch with keepalive
|
|
106
|
+
try {
|
|
107
|
+
fetch(beaconUrl(), {
|
|
108
|
+
method: "POST",
|
|
109
|
+
body: json,
|
|
110
|
+
headers: { "Content-Type": "application/json" },
|
|
111
|
+
keepalive: true,
|
|
112
|
+
}).catch(() => { });
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
/* truly lost — accept it */
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
let events = [];
|
|
119
|
+
let sessionId = null;
|
|
120
|
+
let visitorId = null;
|
|
121
|
+
let stopFn = null;
|
|
122
|
+
let intervalId = null;
|
|
123
|
+
let recording = false;
|
|
124
|
+
// Click enrichment state
|
|
125
|
+
let pendingClickContext = null;
|
|
126
|
+
// Input enrichment state
|
|
127
|
+
let pendingInputContext = null;
|
|
128
|
+
/** Capture form submit events for analytics. */
|
|
129
|
+
function captureSubmit(e) {
|
|
130
|
+
if (!recording)
|
|
131
|
+
return;
|
|
132
|
+
const form = e.target;
|
|
133
|
+
const formName = form.getAttribute("name") ??
|
|
134
|
+
form.getAttribute("id") ??
|
|
135
|
+
form.getAttribute("aria-label") ??
|
|
136
|
+
null;
|
|
137
|
+
const formAction = form.getAttribute("action") ?? null;
|
|
138
|
+
try {
|
|
139
|
+
record.addCustomEvent("submit", { formName, formAction });
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
/* not recording yet */
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** Capture tag/name from input/select/textarea elements for analytics. */
|
|
146
|
+
function captureInputContext(e) {
|
|
147
|
+
const el = e.target;
|
|
148
|
+
const tag = el.tagName?.toLowerCase();
|
|
149
|
+
if (tag !== "input" && tag !== "select" && tag !== "textarea")
|
|
150
|
+
return;
|
|
151
|
+
const inputEl = el;
|
|
152
|
+
const inputName = inputEl.getAttribute("name") ??
|
|
153
|
+
inputEl.getAttribute("id") ??
|
|
154
|
+
inputEl.getAttribute("aria-label") ??
|
|
155
|
+
(tag !== "select" ? inputEl.getAttribute("placeholder") : null) ??
|
|
156
|
+
(() => {
|
|
157
|
+
const id = inputEl.id;
|
|
158
|
+
if (id) {
|
|
159
|
+
const label = document.querySelector(`label[for="${id}"]`);
|
|
160
|
+
if (label)
|
|
161
|
+
return truncateText(label.textContent?.trim() ?? null);
|
|
162
|
+
}
|
|
163
|
+
const parentLabel = inputEl.closest("label");
|
|
164
|
+
return truncateText(parentLabel?.textContent?.trim() ?? null);
|
|
165
|
+
})() ??
|
|
166
|
+
null;
|
|
167
|
+
pendingInputContext = { inputTag: tag, inputName };
|
|
168
|
+
}
|
|
169
|
+
/** Capture tag/role/name from clicked element for analytics. */
|
|
170
|
+
function captureClickContext(e) {
|
|
171
|
+
const raw = e.target;
|
|
172
|
+
const clicked = findInteractiveElement(raw);
|
|
173
|
+
const tag = clicked.tagName?.toLowerCase() ?? "unknown";
|
|
174
|
+
const TAG_ROLES = {
|
|
175
|
+
button: "button",
|
|
176
|
+
a: "link",
|
|
177
|
+
input: "textbox",
|
|
178
|
+
select: "combobox",
|
|
179
|
+
textarea: "textbox",
|
|
180
|
+
};
|
|
181
|
+
const role = clicked.getAttribute("role") ?? TAG_ROLES[tag] ?? null;
|
|
182
|
+
const name = clicked.getAttribute("aria-label") ??
|
|
183
|
+
clicked.getAttribute("name") ??
|
|
184
|
+
clicked.getAttribute("title") ??
|
|
185
|
+
truncateText(getDirectText(clicked)) ??
|
|
186
|
+
clicked.querySelector("[aria-label]")?.getAttribute("aria-label") ??
|
|
187
|
+
null;
|
|
188
|
+
const href = tag === "a"
|
|
189
|
+
? clicked.getAttribute("href")
|
|
190
|
+
: (clicked.closest("a")?.getAttribute("href") ?? null);
|
|
191
|
+
pendingClickContext = { clickTag: tag, clickRole: role, clickName: name, clickHref: href };
|
|
192
|
+
}
|
|
193
|
+
function flush(options) {
|
|
194
|
+
if (events.length === 0 || !sessionId)
|
|
195
|
+
return;
|
|
196
|
+
const batch = events.splice(0);
|
|
197
|
+
const id = sessionId;
|
|
198
|
+
const vid = visitorId;
|
|
199
|
+
const payload = { sessionId: id, events: batch };
|
|
200
|
+
if (vid)
|
|
201
|
+
payload.visitorId = vid;
|
|
202
|
+
const json = JSON.stringify(payload);
|
|
203
|
+
// Extend rolling session cookie on each flush
|
|
204
|
+
persistence.touchSession();
|
|
205
|
+
// During page unload (beforeunload / MPA navigation), use sendBeacon
|
|
206
|
+
// for guaranteed delivery. No async compression — page may tear down.
|
|
207
|
+
if (options?.beacon) {
|
|
208
|
+
sendViaBeacon(json);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
// Normal flush: try async gzip compression, fall back to uncompressed.
|
|
212
|
+
gzipCompress(json).then((compressed) => {
|
|
213
|
+
if (compressed && compressed.size < json.length) {
|
|
214
|
+
send(compressed, {
|
|
215
|
+
"Content-Type": "application/json",
|
|
216
|
+
"Content-Encoding": "gzip",
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
send(json, { "Content-Type": "application/json" });
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
function identify(payload) {
|
|
225
|
+
if (!recording)
|
|
226
|
+
return;
|
|
227
|
+
try {
|
|
228
|
+
record.addCustomEvent("identify", payload);
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
/* not recording yet */
|
|
232
|
+
}
|
|
233
|
+
flush();
|
|
234
|
+
}
|
|
235
|
+
function emitPageEvent(payload) {
|
|
236
|
+
if (!recording)
|
|
237
|
+
return;
|
|
238
|
+
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,
|
|
245
|
+
referrer: payload.referrer ?? externalReferrer(),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
/* recorder may not be fully initialized yet */
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/** Capture unhandled JS errors for analytics. */
|
|
253
|
+
function captureError(e) {
|
|
254
|
+
if (!recording)
|
|
255
|
+
return;
|
|
256
|
+
try {
|
|
257
|
+
record.addCustomEvent("error", {
|
|
258
|
+
message: e.message ?? "Unknown error",
|
|
259
|
+
source: e.filename ?? null,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
/* not recording yet */
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/** Capture unhandled promise rejections for analytics. */
|
|
267
|
+
function captureRejection(e) {
|
|
268
|
+
if (!recording)
|
|
269
|
+
return;
|
|
270
|
+
try {
|
|
271
|
+
const message = e.reason instanceof Error ? e.reason.message : String(e.reason ?? "Unhandled rejection");
|
|
272
|
+
record.addCustomEvent("error", {
|
|
273
|
+
message,
|
|
274
|
+
source: null,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
/* not recording yet */
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
/** Return the external referrer, or null if it's same-origin (internal navigation). */
|
|
282
|
+
function externalReferrer() {
|
|
283
|
+
const ref = document.referrer;
|
|
284
|
+
if (!ref)
|
|
285
|
+
return null;
|
|
286
|
+
try {
|
|
287
|
+
return new URL(ref).origin === window.location.origin ? null : ref;
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return ref;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/** Emit a session:meta event with referrer and UTM params (once per session start). */
|
|
294
|
+
function emitSessionMeta() {
|
|
295
|
+
try {
|
|
296
|
+
const utm = extractUtmParams();
|
|
297
|
+
record.addCustomEvent("session:meta", {
|
|
298
|
+
referrer: externalReferrer(),
|
|
299
|
+
landingPage: window.location.pathname,
|
|
300
|
+
...utm,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
/* not recording yet */
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
function start(optionsOrSessionId) {
|
|
308
|
+
if (recording)
|
|
309
|
+
return sessionId;
|
|
310
|
+
const startOpts = typeof optionsOrSessionId === "string"
|
|
311
|
+
? { sessionId: optionsOrSessionId }
|
|
312
|
+
: (optionsOrSessionId ?? {});
|
|
313
|
+
const existingSessionId = startOpts.sessionId;
|
|
314
|
+
// Resolve session ID.
|
|
315
|
+
// Cookie mode: persisted cookie > sessionStorage (MPA fallback) > provided arg > new UUID
|
|
316
|
+
// Cookieless mode: always a fresh per-page-load ID (no client storage = consent-free)
|
|
317
|
+
const SESSION_STORAGE_KEY = "__ld_sid";
|
|
318
|
+
let isNewSession = false;
|
|
319
|
+
if (persistence.mode === "cookie") {
|
|
320
|
+
const persistedSession = persistence.getSessionId();
|
|
321
|
+
const storedSession = typeof sessionStorage !== "undefined" ? sessionStorage.getItem(SESSION_STORAGE_KEY) : null;
|
|
322
|
+
isNewSession = !persistedSession && !storedSession;
|
|
323
|
+
sessionId = persistedSession || storedSession || existingSessionId || crypto.randomUUID();
|
|
324
|
+
persistence.setSessionId(sessionId);
|
|
325
|
+
if (typeof sessionStorage !== "undefined") {
|
|
326
|
+
sessionStorage.setItem(SESSION_STORAGE_KEY, sessionId);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
// Cookieless: each page load is an independent recording.
|
|
331
|
+
// The server stitches page recordings into sessions using visitor hash + referrer + timing.
|
|
332
|
+
sessionId = existingSessionId || crypto.randomUUID();
|
|
333
|
+
}
|
|
334
|
+
// Resolve visitor ID from cookie (only set in cookie mode)
|
|
335
|
+
visitorId = persistence.getVisitorId();
|
|
336
|
+
if (!visitorId && persistence.mode === "cookie") {
|
|
337
|
+
visitorId = crypto.randomUUID();
|
|
338
|
+
persistence.setVisitorId(visitorId);
|
|
339
|
+
}
|
|
340
|
+
events = [];
|
|
341
|
+
recording = true;
|
|
342
|
+
// Listen for clicks/inputs in capture phase to grab context before rrweb sees it
|
|
343
|
+
document.addEventListener("click", captureClickContext, true);
|
|
344
|
+
document.addEventListener("input", captureInputContext, true);
|
|
345
|
+
document.addEventListener("change", captureInputContext, true);
|
|
346
|
+
document.addEventListener("submit", captureSubmit, true);
|
|
347
|
+
window.addEventListener("error", captureError);
|
|
348
|
+
window.addEventListener("unhandledrejection", captureRejection);
|
|
349
|
+
stopFn = record({
|
|
350
|
+
...defaultRrwebOptions,
|
|
351
|
+
...rrwebOptions,
|
|
352
|
+
initialPageMetadata: startOpts.pageMetadata
|
|
353
|
+
? {
|
|
354
|
+
href: startOpts.pageMetadata.href,
|
|
355
|
+
title: startOpts.pageMetadata.title,
|
|
356
|
+
routePattern: startOpts.pageMetadata.routePattern ?? undefined,
|
|
357
|
+
params: startOpts.pageMetadata.params,
|
|
358
|
+
referrer: startOpts.pageMetadata.referrer ?? undefined,
|
|
359
|
+
}
|
|
360
|
+
: undefined,
|
|
361
|
+
emit(event) {
|
|
362
|
+
const ev = event;
|
|
363
|
+
// Attach click context to rrweb click events
|
|
364
|
+
if (ev.type === 3 && ev.data?.source === 2 && ev.data?.type === 2 && pendingClickContext) {
|
|
365
|
+
const d = ev.data;
|
|
366
|
+
d.clickTag = pendingClickContext.clickTag;
|
|
367
|
+
d.clickRole = pendingClickContext.clickRole;
|
|
368
|
+
d.clickName = pendingClickContext.clickName;
|
|
369
|
+
d.clickHref = pendingClickContext.clickHref;
|
|
370
|
+
pendingClickContext = null;
|
|
371
|
+
}
|
|
372
|
+
// Attach input context to rrweb input events (type=3, source=5)
|
|
373
|
+
if (ev.type === 3 && ev.data?.source === 5 && pendingInputContext) {
|
|
374
|
+
const d = ev.data;
|
|
375
|
+
d.inputTag = pendingInputContext.inputTag;
|
|
376
|
+
d.inputName = pendingInputContext.inputName;
|
|
377
|
+
pendingInputContext = null;
|
|
378
|
+
}
|
|
379
|
+
events.push(event);
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
// Emit session metadata (referrer, UTM).
|
|
383
|
+
// In cookieless mode: every page load is a new recording, always emit.
|
|
384
|
+
// In cookie mode: only for genuinely new sessions (not MPA reloads with existing session).
|
|
385
|
+
if (persistence.mode === "cookieless" || isNewSession) {
|
|
386
|
+
emitSessionMeta();
|
|
387
|
+
}
|
|
388
|
+
// Flush immediately so the pageview snapshot (which can exceed sendBeacon's
|
|
389
|
+
// ~64 KB limit) is delivered via regular fetch before the user navigates away.
|
|
390
|
+
flush();
|
|
391
|
+
intervalId = setInterval(flush, batchInterval);
|
|
392
|
+
return sessionId;
|
|
393
|
+
}
|
|
394
|
+
function stop() {
|
|
395
|
+
if (!recording)
|
|
396
|
+
return;
|
|
397
|
+
document.removeEventListener("click", captureClickContext, true);
|
|
398
|
+
document.removeEventListener("input", captureInputContext, true);
|
|
399
|
+
document.removeEventListener("change", captureInputContext, true);
|
|
400
|
+
document.removeEventListener("submit", captureSubmit, true);
|
|
401
|
+
window.removeEventListener("error", captureError);
|
|
402
|
+
window.removeEventListener("unhandledrejection", captureRejection);
|
|
403
|
+
try {
|
|
404
|
+
record.addCustomEvent("session:end", {});
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
/* not recording */
|
|
408
|
+
}
|
|
409
|
+
flush();
|
|
410
|
+
if (intervalId) {
|
|
411
|
+
clearInterval(intervalId);
|
|
412
|
+
intervalId = null;
|
|
413
|
+
}
|
|
414
|
+
stopFn?.();
|
|
415
|
+
stopFn = null;
|
|
416
|
+
recording = false;
|
|
417
|
+
sessionId = null;
|
|
418
|
+
visitorId = null;
|
|
419
|
+
events = [];
|
|
420
|
+
pendingClickContext = null;
|
|
421
|
+
pendingInputContext = null;
|
|
422
|
+
}
|
|
423
|
+
function setPersistence(mode) {
|
|
424
|
+
const oldMode = persistence.mode;
|
|
425
|
+
if (oldMode === mode)
|
|
426
|
+
return;
|
|
427
|
+
if (oldMode === "cookieless" && mode === "cookie") {
|
|
428
|
+
// Transitioning from cookieless → cookie: generate cookie-based visitor ID
|
|
429
|
+
// and emit a link event so the server can merge the server-hash identity
|
|
430
|
+
persistence.clear();
|
|
431
|
+
persistence = createPersistence("cookie");
|
|
432
|
+
const newVisitorId = crypto.randomUUID();
|
|
433
|
+
persistence.setVisitorId(newVisitorId);
|
|
434
|
+
if (sessionId)
|
|
435
|
+
persistence.setSessionId(sessionId);
|
|
436
|
+
const oldVisitorId = visitorId;
|
|
437
|
+
visitorId = newVisitorId;
|
|
438
|
+
// Tell the server to link the old (server-generated) identity to the new cookie identity
|
|
439
|
+
if (recording && oldVisitorId !== newVisitorId) {
|
|
440
|
+
try {
|
|
441
|
+
record.addCustomEvent("visitor:link", {
|
|
442
|
+
previousVisitorId: oldVisitorId,
|
|
443
|
+
newVisitorId,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
/* not recording */
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
else if (oldMode === "cookie" && mode === "cookieless") {
|
|
452
|
+
// Transitioning from cookie → cookieless: delete cookies, keep in-memory session
|
|
453
|
+
persistence.clear();
|
|
454
|
+
persistence = createPersistence("cookieless");
|
|
455
|
+
// visitorId becomes null — server will assign hash-based ID
|
|
456
|
+
visitorId = null;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return {
|
|
460
|
+
start,
|
|
461
|
+
stop,
|
|
462
|
+
flush,
|
|
463
|
+
getSessionId: () => sessionId,
|
|
464
|
+
getVisitorId: () => visitorId,
|
|
465
|
+
isRecording: () => recording,
|
|
466
|
+
emitPageEvent,
|
|
467
|
+
identify,
|
|
468
|
+
setPersistence,
|
|
469
|
+
};
|
|
470
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lunardinos/browser",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"import": "./dist/recorder-core.js",
|
|
8
|
+
"types": "./dist/src/recorder-core.d.ts"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"!dist/*.tsbuildinfo"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "rm -rf dist && bun build src/recorder-core.ts --outdir dist --format esm --external react && rm -f dist/*.css && bunx tsc -b tsconfig.json"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@lunardinos/rrweb-record": "0.1.0"
|
|
23
|
+
}
|
|
24
|
+
}
|