@lunardinos/browser 0.2.4 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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;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
+ {"version":3,"file":"recorder-core.d.ts","sourceRoot":"","sources":["../../src/recorder-core.ts"],"names":[],"mappings":"AACA,OAAO,EAA8C,KAAK,eAAe,EAAE,MAAM,eAAe,CAAA;AAShG,YAAY,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AAuBpD,UAAU,cAAc;IACtB,iCAAiC;IACjC,MAAM,EAAE,MAAM,CAAA;IACd,qEAAqE;IACrE,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,kEAAkE;IAClE,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACtC,6CAA6C;IAC7C,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,oHAAoH;IACpH,kBAAkB,CAAC,EAAE,eAAe,CAAA;IACpC;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AA4BD,UAAU,gBAAgB;IACxB,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;IACb,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAAA;IACrC,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,iFAAiF;IACjF,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACvB;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE;QACJ,EAAE,EAAE,MAAM,CAAA;QACV,KAAK,CAAC,EAAE,MAAM,CAAA;QACd,IAAI,CAAC,EAAE,MAAM,CAAA;QACb,IAAI,CAAC,EAAE,MAAM,CAAA;QACb,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;KACvB,CAAA;IACD,OAAO,CAAC,EAAE;QACR,EAAE,EAAE,MAAM,CAAA;QACV,IAAI,CAAC,EAAE,MAAM,CAAA;QACb,SAAS,CAAC,EAAE,OAAO,CAAA;QACnB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;KACvB,CAAA;IACD,IAAI,CAAC,EAAE;QACL,EAAE,EAAE,MAAM,CAAA;QACV,IAAI,CAAC,EAAE,MAAM,CAAA;QACb,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;KACvB,CAAA;CACF;AAuED,MAAM,WAAW,YAAY;IAC3B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,8FAA8F;IAC9F,YAAY,CAAC,EAAE,gBAAgB,CAAA;CAChC;AAED,MAAM,WAAW,QAAQ;IACvB,KAAK,EAAE,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,YAAY,KAAK,MAAM,CAAA;IAClD,IAAI,EAAE,MAAM,IAAI,CAAA;IAChB,+FAA+F;IAC/F,KAAK,EAAE,CAAC,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,CAAA;IAC/C,YAAY,EAAE,MAAM,MAAM,GAAG,IAAI,CAAA;IACjC,YAAY,EAAE,MAAM,MAAM,GAAG,IAAI,CAAA;IACjC,WAAW,EAAE,MAAM,OAAO,CAAA;IAC1B,iFAAiF;IACjF,aAAa,EAAE,CAAC,OAAO,EAAE,gBAAgB,KAAK,IAAI,CAAA;IAClD,4EAA4E;IAC5E,QAAQ,EAAE,CAAC,OAAO,EAAE,eAAe,KAAK,IAAI,CAAA;IAC5C,2EAA2E;IAC3E,cAAc,EAAE,CAAC,IAAI,EAAE,eAAe,KAAK,IAAI,CAAA;CAChD;AAED;;;;;;GAMG;AACH,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,cAAc,GAAG,QAAQ,CAmjBzE"}
@@ -1,6 +1,6 @@
1
- import record from "@lunardinos/rrweb-record";
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
+ import { isNavigation, isUserActivity, SESSION_TIMEOUT_MS, sessionExpired, storedSessionUsable, } from "./session-lifecycle";
4
4
  const DEFAULT_ENDPOINT = "https://ingest.lunardinos.com";
5
5
  /** Timestamp of the last user activity, alongside the session id in sessionStorage. */
6
6
  const SESSION_ACTIVITY_KEY = "__ld_sid_at";
@@ -142,7 +142,18 @@ export function createLunarDinosRecorder(config) {
142
142
  method: "POST",
143
143
  headers: reqHeaders,
144
144
  body,
145
- }).catch(() => { });
145
+ })
146
+ .then((res) => {
147
+ // Fire-and-forget by design (recording must never block the page), but a
148
+ // failed delivery should at least be visible in devtools/error monitoring
149
+ // instead of vanishing — this batch's events are gone either way.
150
+ if (!res.ok) {
151
+ console.error(`lunardinos: ingest request failed with status ${res.status}`);
152
+ }
153
+ })
154
+ .catch(() => {
155
+ console.error("lunardinos: ingest request failed (network error)");
156
+ });
146
157
  }
147
158
  /** Send via navigator.sendBeacon — reliable during page unload, no custom headers. */
148
159
  function sendViaBeacon(json) {
@@ -186,6 +197,8 @@ export function createLunarDinosRecorder(config) {
186
197
  let lastActivityAt = 0;
187
198
  /** Set while rotating, since taking the new session's snapshot re-enters emit. */
188
199
  let rotating = false;
200
+ /** The page the app last reported, so a rotated session opens on it. */
201
+ let currentPage = {};
189
202
  // Click enrichment state
190
203
  let pendingClickContext = null;
191
204
  // Input enrichment state
@@ -259,11 +272,14 @@ export function createLunarDinosRecorder(config) {
259
272
  * End the current session and open a new one.
260
273
  *
261
274
  * 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.
275
+ * they go out first, and the new session opens on a navigation and a full
276
+ * snapshot — without one it starts mid-stream against a DOM the player has
277
+ * never seen and can't be replayed at all, and has no landing page.
278
+ *
279
+ * `snapshot: false` when a navigation triggered the rotation: its own
280
+ * snapshot follows and opens the new session.
265
281
  */
266
- function rotateSession() {
282
+ function rotateSession(snapshot) {
267
283
  if (rotating)
268
284
  return;
269
285
  rotating = true;
@@ -276,11 +292,13 @@ export function createLunarDinosRecorder(config) {
276
292
  }
277
293
  lastActivityAt = Date.now();
278
294
  markActivity();
279
- try {
280
- record.takeFullSnapshot(true);
281
- }
282
- catch {
283
- // Recording already stopped; the next start() will snapshot anyway.
295
+ if (snapshot) {
296
+ try {
297
+ record.takeNavigationSnapshot(currentPage);
298
+ }
299
+ catch {
300
+ // Recording already stopped; the next start() will snapshot anyway.
301
+ }
284
302
  }
285
303
  }
286
304
  finally {
@@ -336,18 +354,25 @@ export function createLunarDinosRecorder(config) {
336
354
  }
337
355
  flush();
338
356
  }
357
+ /** What a navigation reports about a page, with auth tokens stripped from its URL. */
358
+ function pageMetadata(payload) {
359
+ return {
360
+ href: redactAuthFromUrl(payload.href) ?? payload.href,
361
+ title: payload.title,
362
+ routePattern: payload.routePattern ?? undefined,
363
+ params: payload.params,
364
+ locale: resolveLocale(payload.locale),
365
+ };
366
+ }
339
367
  function emitPageEvent(payload) {
340
368
  if (!recording)
341
369
  return;
370
+ currentPage = pageMetadata(payload);
342
371
  try {
343
- // Single Pageview event carries both DOM snapshot and route metadata
344
- record.takePageviewSnapshot({
345
- href: redactAuthFromUrl(payload.href) ?? payload.href,
346
- title: payload.title,
347
- routePattern: payload.routePattern ?? undefined,
348
- params: payload.params,
372
+ // The navigation event, then Meta + FullSnapshot of the new page
373
+ record.takeNavigationSnapshot({
374
+ ...currentPage,
349
375
  referrer: payload.referrer ?? externalReferrer(),
350
- locale: resolveLocale(payload.locale),
351
376
  });
352
377
  }
353
378
  catch {
@@ -466,18 +491,12 @@ export function createLunarDinosRecorder(config) {
466
491
  document.addEventListener("submit", captureSubmit, true);
467
492
  window.addEventListener("error", captureError);
468
493
  window.addEventListener("unhandledrejection", captureRejection);
494
+ currentPage = startOpts.pageMetadata ? pageMetadata(startOpts.pageMetadata) : {};
469
495
  stopFn = record({
470
496
  ...defaultRrwebOptions,
471
497
  ...rrwebOptions,
472
498
  initialPageMetadata: startOpts.pageMetadata
473
- ? {
474
- href: redactAuthFromUrl(startOpts.pageMetadata.href) ?? startOpts.pageMetadata.href,
475
- title: startOpts.pageMetadata.title,
476
- routePattern: startOpts.pageMetadata.routePattern ?? undefined,
477
- params: startOpts.pageMetadata.params,
478
- referrer: startOpts.pageMetadata.referrer ?? undefined,
479
- locale: resolveLocale(startOpts.pageMetadata.locale),
480
- }
499
+ ? { ...currentPage, referrer: startOpts.pageMetadata.referrer ?? undefined }
481
500
  : undefined,
482
501
  emit(event) {
483
502
  const ev = event;
@@ -486,7 +505,7 @@ export function createLunarDinosRecorder(config) {
486
505
  // old session before the event is recorded against it.
487
506
  if (isUserActivity(ev) && !rotating) {
488
507
  if (sessionExpired(lastActivityAt, Date.now()))
489
- rotateSession();
508
+ rotateSession(!isNavigation(ev));
490
509
  else
491
510
  markActivity();
492
511
  }
@@ -515,7 +534,7 @@ export function createLunarDinosRecorder(config) {
515
534
  if (persistence.mode === "cookieless" || isNewSession) {
516
535
  emitSessionMeta();
517
536
  }
518
- // Flush immediately so the pageview snapshot (which can exceed sendBeacon's
537
+ // Flush immediately so the first snapshot (which can exceed sendBeacon's
519
538
  // ~64 KB limit) is delivered via regular fetch before the user navigates away.
520
539
  flush();
521
540
  intervalId = setInterval(flush, batchInterval);
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=recorder.browser.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"recorder.browser.test.d.ts","sourceRoot":"","sources":["../../src/recorder.browser.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,144 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { chromium } from "playwright";
3
+ import { buildRecorderBundle } from "./testing/recorder-bundle";
4
+ /**
5
+ * What the recorder uploads around navigations, keyframes and session
6
+ * rotation, captured off the wire from the production recorder in Chromium on
7
+ * a fake clock.
8
+ *
9
+ * A navigation is a Custom "navigation" event followed by Meta + FullSnapshot;
10
+ * a keyframe is Meta + FullSnapshot alone. Keyframes are the recorder's own
11
+ * doing, so they must not keep an idle session alive, and a session rotated
12
+ * after inactivity must open on a navigation so it has a landing page.
13
+ */
14
+ const PAGE = `<!DOCTYPE html><html lang="de"><head><title>App</title></head><body>
15
+ <div id="app"><h1>Shifts</h1><button id="b">Apply</button></div>
16
+ <script src="/recorder.js"></script></body></html>`;
17
+ let browser;
18
+ let bundle;
19
+ beforeAll(async () => {
20
+ bundle = await buildRecorderBundle();
21
+ browser = await chromium.launch();
22
+ });
23
+ afterAll(() => browser.close());
24
+ async function openPage() {
25
+ const uploads = [];
26
+ const page = await browser.newPage();
27
+ await page.route("https://app.test/**", (route) => {
28
+ const { pathname } = new URL(route.request().url());
29
+ if (pathname === "/recorder.js")
30
+ return route.fulfill({ contentType: "application/javascript", body: bundle });
31
+ return route.fulfill({ contentType: "text/html", body: PAGE });
32
+ });
33
+ await page.route("https://ingest.test/**", (route) => {
34
+ const req = route.request();
35
+ const body = new Uint8Array(req.postDataBuffer() ?? []);
36
+ const json = req.headers()["content-encoding"] === "gzip" ? Bun.gunzipSync(body) : body;
37
+ uploads.push(JSON.parse(new TextDecoder().decode(json)));
38
+ return route.fulfill({ status: 200, body: "{}" });
39
+ });
40
+ await page.clock.install({ time: new Date("2026-01-01T10:00:00Z") });
41
+ await page.goto("https://app.test/shifts?access_token=secret");
42
+ await page.evaluate(() => {
43
+ const g = globalThis;
44
+ g.__recorder = g.LunarDinos.createLunarDinosRecorder({
45
+ apiKey: "k",
46
+ endpoint: "https://ingest.test",
47
+ });
48
+ g.__recorder.start({
49
+ pageMetadata: {
50
+ href: location.href,
51
+ title: "App",
52
+ routePattern: "/shifts",
53
+ params: null,
54
+ },
55
+ });
56
+ });
57
+ await settle(page);
58
+ return { page, uploads };
59
+ }
60
+ /** Run the fake clock forward, then let the async gzip + fetch land on the real one. */
61
+ async function settle(page, ms = 2_100) {
62
+ await page.clock.runFor(ms);
63
+ await new Promise((r) => setTimeout(r, 150));
64
+ }
65
+ const all = (uploads) => uploads.flatMap((u) => u.events);
66
+ const kinds = (events) => events
67
+ .filter((e) => e.type !== 3 && e.type !== 0 && e.type !== 1)
68
+ .map((e) => (e.type === 5 ? `5:${e.data.tag}` : String(e.type)));
69
+ describe("recorder uploads", () => {
70
+ test("a recording opens on a navigation with the page metadata, then Meta + FullSnapshot", async () => {
71
+ const { page, uploads } = await openPage();
72
+ const events = all(uploads);
73
+ expect(kinds(events).slice(0, 3)).toEqual(["5:navigation", "4", "2"]);
74
+ const [nav, meta] = events.filter((e) => e.type === 5 || e.type === 4);
75
+ expect(nav.data.payload).toMatchObject({
76
+ href: "https://app.test/shifts?access_token=REDACTED",
77
+ title: "App",
78
+ routePattern: "/shifts",
79
+ params: null,
80
+ locale: "de",
81
+ });
82
+ // Tokens are stripped from the snapshot's href too, not only the page view's.
83
+ expect(meta.data.href).toBe("https://app.test/shifts?access_token=REDACTED");
84
+ expect(events.map((e) => e.type)).not.toContain(7);
85
+ await page.close();
86
+ });
87
+ test("an SPA navigation is a navigation, then Meta + FullSnapshot", async () => {
88
+ const { page, uploads } = await openPage();
89
+ const before = all(uploads).length;
90
+ await page.evaluate(() => {
91
+ document.getElementById("app").innerHTML = "<h1>Profile</h1>";
92
+ globalThis.__recorder.emitPageEvent({
93
+ href: "https://app.test/profile",
94
+ title: "Profile",
95
+ routePattern: "/profile",
96
+ params: null,
97
+ });
98
+ });
99
+ await settle(page);
100
+ const events = all(uploads).slice(before);
101
+ expect(kinds(events)).toEqual(["5:navigation", "4", "2"]);
102
+ expect(events[0].data.payload.routePattern).toBe("/profile");
103
+ await page.close();
104
+ });
105
+ test("a periodic keyframe is Meta + FullSnapshot without a navigation", async () => {
106
+ const { page, uploads } = await openPage();
107
+ const before = all(uploads).length;
108
+ await settle(page, 60_000);
109
+ expect(kinds(all(uploads).slice(before))).toEqual(["4", "2"]);
110
+ await page.close();
111
+ });
112
+ test("keyframes don't keep an idle session alive; it rotates onto a navigation", async () => {
113
+ const { page, uploads } = await openPage();
114
+ const first = uploads[0].sessionId;
115
+ // 31 idle minutes: only keyframes are recorded meanwhile.
116
+ await settle(page, 31 * 60_000);
117
+ expect(new Set(uploads.map((u) => u.sessionId))).toEqual(new Set([first]));
118
+ await page.click("#b");
119
+ await settle(page);
120
+ const next = uploads.filter((u) => u.sessionId !== first);
121
+ expect(next.length).toBeGreaterThan(0);
122
+ const events = all(next);
123
+ // The new session has a landing page: the page the app last reported.
124
+ expect(kinds(events).slice(0, 3)).toEqual(["5:navigation", "4", "2"]);
125
+ expect(events.find((e) => e.type === 5).data.payload.routePattern).toBe("/shifts");
126
+ await page.close();
127
+ });
128
+ test("a navigation after the timeout opens the new session with its own snapshot only", async () => {
129
+ const { page, uploads } = await openPage();
130
+ const first = uploads[0].sessionId;
131
+ await settle(page, 31 * 60_000);
132
+ await page.evaluate(() => globalThis.__recorder.emitPageEvent({
133
+ href: "https://app.test/profile",
134
+ title: "Profile",
135
+ routePattern: "/profile",
136
+ params: null,
137
+ }));
138
+ await settle(page);
139
+ const events = all(uploads.filter((u) => u.sessionId !== first));
140
+ expect(kinds(events)).toEqual(["5:navigation", "4", "2"]);
141
+ expect(events[0].data.payload.routePattern).toBe("/profile");
142
+ await page.close();
143
+ });
144
+ });
@@ -16,9 +16,16 @@ interface ActivityEvent {
16
16
  type: number;
17
17
  data?: {
18
18
  source?: number;
19
+ tag?: string;
19
20
  };
20
21
  }
21
- /** Whether this event is evidence a person is still there. */
22
+ /** A Custom (5) event tagged "navigation": the app moved to another page. */
23
+ export declare function isNavigation(event: ActivityEvent): boolean;
24
+ /**
25
+ * Whether this event is evidence a person is still there.
26
+ *
27
+ * Keyframes (Meta + FullSnapshot) are not: the recorder takes them on a timer.
28
+ */
22
29
  export declare function isUserActivity(event: ActivityEvent): boolean;
23
30
  /**
24
31
  * Whether the gap since `lastActivityAt` has ended the session.
@@ -1 +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"}
1
+ {"version":3,"file":"session-lifecycle.d.ts","sourceRoot":"","sources":["../../src/session-lifecycle.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH;;;GAGG;AACH,eAAO,MAAM,kBAAkB,QAAiB,CAAA;AAoBhD,6DAA6D;AAC7D,UAAU,aAAa;IACrB,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CACzC;AAED,6EAA6E;AAC7E,wBAAgB,YAAY,CAAC,KAAK,EAAE,aAAa,GAAG,OAAO,CAE1D;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,aAAa,GAAG,OAAO,CAM5D;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,cAAc,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAG3E;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAG1E"}
@@ -28,10 +28,18 @@ const USER_ACTIVITY_SOURCES = new Set([
28
28
  12, // Drag
29
29
  14, // Selection
30
30
  ]);
31
- /** Whether this event is evidence a person is still there. */
31
+ /** A Custom (5) event tagged "navigation": the app moved to another page. */
32
+ export function isNavigation(event) {
33
+ return event.type === 5 && event.data?.tag === "navigation";
34
+ }
35
+ /**
36
+ * Whether this event is evidence a person is still there.
37
+ *
38
+ * Keyframes (Meta + FullSnapshot) are not: the recorder takes them on a timer.
39
+ */
32
40
  export function isUserActivity(event) {
33
- // Pageview (7) — a navigation is always a person doing something.
34
- if (event.type === 7)
41
+ // A navigation is always a person doing something.
42
+ if (isNavigation(event))
35
43
  return true;
36
44
  // IncrementalSnapshot (3) — only the interaction sources count.
37
45
  if (event.type === 3)
@@ -19,7 +19,15 @@ describe("isUserActivity", () => {
19
19
  expect(isUserActivity(inc(14))).toBe(true); // selection
20
20
  });
21
21
  test("a navigation is always activity", () => {
22
- expect(isUserActivity({ type: 7 })).toBe(true);
22
+ expect(isUserActivity({ type: 5, data: { tag: "navigation" } })).toBe(true);
23
+ });
24
+ test("a keyframe is not activity", () => {
25
+ // The periodic Meta + FullSnapshot is the recorder's doing, not a person's.
26
+ expect(isUserActivity({ type: 4 })).toBe(false);
27
+ expect(isUserActivity({ type: 2 })).toBe(false);
28
+ });
29
+ test("other custom events are not activity", () => {
30
+ expect(isUserActivity({ type: 5, data: { tag: "session:meta" } })).toBe(false);
23
31
  });
24
32
  test("does NOT count DOM mutations", () => {
25
33
  // The whole point: a polling or animating page emits these forever with
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=measure-bytes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"measure-bytes.d.ts","sourceRoot":"","sources":["../../../src/testing/measure-bytes.ts"],"names":[],"mappings":""}
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Measure what the recorder uploads per minute of an active user.
3
+ *
4
+ * bun packages/browser/src/testing/measure-bytes.ts [--minutes 5] [--seed 1]
5
+ *
6
+ * Runs the production recorder (`createLunarDinosRecorder`) in real Chromium on
7
+ * a fake clock, against an app-like page: a shift list with cards and avatars,
8
+ * a detail view and a form, switched by SPA navigations the way the framework
9
+ * SDKs report them. The user is active the whole time — mouse, scroll, clicks,
10
+ * typing, a navigation every 20 s. Every upload is intercepted and counted as it
11
+ * goes over the wire (gzip where the recorder compresses).
12
+ *
13
+ * The page is synthetic, so the absolute number is a baseline, not a forecast:
14
+ * compare runs of this script before and after a recorder change.
15
+ */
16
+ import { chromium } from "playwright";
17
+ import { buildRecorderBundle } from "./recorder-bundle";
18
+ const args = process.argv.slice(2);
19
+ const opt = (n, d) => {
20
+ const i = args.indexOf(`--${n}`);
21
+ return i === -1 ? d : args[i + 1];
22
+ };
23
+ const minutes = Number(opt("minutes", "5"));
24
+ const seed = Number(opt("seed", "1"));
25
+ const CSS = `
26
+ body { font-family: system-ui, sans-serif; margin: 0; color: #222; background: #fafafa; }
27
+ header { display: flex; gap: 16px; padding: 12px 24px; background: #1e1e2e; color: #fff; }
28
+ header a { color: inherit; text-decoration: none; }
29
+ main { padding: 24px; max-width: 960px; margin: 0 auto; }
30
+ .card { display: flex; gap: 12px; align-items: center; padding: 12px; margin: 8px 0; background: #fff; border: 1px solid #ddd; border-radius: 8px; }
31
+ .card img { width: 32px; height: 32px; border-radius: 50%; }
32
+ .card .title { font-weight: 600; }
33
+ .card .meta { color: #666; font-size: 13px; }
34
+ .badge { padding: 2px 8px; border-radius: 999px; background: #e0f2e9; color: #14532d; font-size: 12px; }
35
+ form label { display: block; margin: 12px 0 4px; }
36
+ form input, form textarea, form select { width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 6px; }
37
+ button { padding: 8px 16px; border-radius: 6px; border: 0; background: #4f46e5; color: #fff; }
38
+ `;
39
+ // An avatar of the kind an app serves: same-origin, so the recorder inlines it.
40
+ const AVATAR = `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256"><rect width="256" height="256" fill="#89b4fa"/><circle cx="128" cy="100" r="48" fill="#fff"/><rect x="48" y="170" width="160" height="80" rx="40" fill="#fff"/></svg>`;
41
+ const HTML = `<!DOCTYPE html><html lang="de"><head><meta charset="utf-8"><title>Schichten</title><style>${CSS}</style></head>
42
+ <body><header><a href="#" data-route="/shifts">Schichten</a><a href="#" data-route="/profile">Profil</a><span id="clock"></span></header>
43
+ <main id="app"></main>
44
+ <script src="/recorder.js"></script>
45
+ <script>{
46
+ let s = ${seed};
47
+ const rnd = () => ((s = (s * 1103515245 + 12345) % 2147483648) / 2147483648);
48
+ const app = document.getElementById('app');
49
+ const views = {
50
+ '/shifts': () => {
51
+ let h = '<h1>Offene Schichten</h1><input id="search" placeholder="Suchen">';
52
+ for (let i = 0; i < 40; i++) h += '<div class="card" data-id="' + i + '"><img src="/avatar.svg?u=' + (i % 8) + '"><div><div class="title">Frühdienst Station ' + (i % 7 + 1) + '</div><div class="meta">Klinikum Nord · 06:00–14:00 · ' + (20 + i) + ' €/h</div></div><span class="badge">offen</span></div>';
53
+ return h;
54
+ },
55
+ '/shifts/[id]': (id) => '<h1>Schicht ' + id + '</h1><div class="card"><img src="/avatar.svg?u=1"><div><div class="title">Frühdienst</div><div class="meta">Klinikum Nord</div></div></div><p>' + 'Beschreibung der Schicht. '.repeat(30) + '</p><button id="apply">Bewerben</button>',
56
+ '/profile': () => '<h1>Profil</h1><form><label>Name</label><input id="name"><label>Qualifikation</label><select id="qual"><option>Pflegefachkraft</option><option>Pflegehelfer</option></select><label>Notizen</label><textarea id="notes"></textarea><button type="button">Speichern</button></form>',
57
+ };
58
+ const recorder = LunarDinos.createLunarDinosRecorder({ apiKey: 'measure', endpoint: 'https://ingest.test' });
59
+ function render(pattern, params) {
60
+ app.innerHTML = views[pattern](params && params.id);
61
+ }
62
+ function meta(pattern, params) {
63
+ const href = pattern === '/shifts/[id]' ? '/shifts/' + params.id : pattern;
64
+ return { href: location.origin + href, title: document.title, routePattern: pattern, params };
65
+ }
66
+ window.__navigate = (pattern, params = null) => {
67
+ render(pattern, params);
68
+ recorder.emitPageEvent(meta(pattern, params));
69
+ };
70
+ render('/shifts');
71
+ recorder.start({ pageMetadata: meta('/shifts', null) });
72
+ setInterval(() => {
73
+ document.getElementById('clock').textContent = new Date().toLocaleTimeString();
74
+ const card = app.querySelector('.card .badge');
75
+ if (card && rnd() < 0.3) card.textContent = rnd() < 0.5 ? 'offen' : 'vergeben';
76
+ }, 1000);
77
+ window.__flush = () => recorder.flush();
78
+ }</script></body></html>`;
79
+ const recorderJs = await buildRecorderBundle();
80
+ let wireBytes = 0;
81
+ let jsonBytes = 0;
82
+ let requests = 0;
83
+ const eventTypes = {};
84
+ const browser = await chromium.launch();
85
+ const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
86
+ await page.route("https://app.test/**", (route) => {
87
+ const u = new URL(route.request().url());
88
+ if (u.pathname === "/")
89
+ return route.fulfill({ contentType: "text/html", body: HTML });
90
+ if (u.pathname === "/recorder.js")
91
+ return route.fulfill({ contentType: "application/javascript", body: recorderJs });
92
+ if (u.pathname === "/avatar.svg")
93
+ return route.fulfill({ contentType: "image/svg+xml", body: AVATAR });
94
+ return route.fulfill({ status: 404, body: "" });
95
+ });
96
+ await page.route("https://ingest.test/**", async (route) => {
97
+ const req = route.request();
98
+ if (req.method() !== "POST")
99
+ return route.fulfill({ status: 204, body: "" });
100
+ const body = req.postDataBuffer() ?? Buffer.alloc(0);
101
+ wireBytes += body.length;
102
+ requests++;
103
+ const gzipped = req.headers()["content-encoding"] === "gzip";
104
+ const json = gzipped ? Bun.gunzipSync(new Uint8Array(body)) : body;
105
+ jsonBytes += json.length;
106
+ const { events } = JSON.parse(new TextDecoder().decode(json));
107
+ for (const e of events) {
108
+ const k = e.type === 3 ? `3.${e.data?.source}` : e.type === 5 ? `5.${e.data?.tag}` : String(e.type);
109
+ eventTypes[k] = (eventTypes[k] ?? 0) + 1;
110
+ }
111
+ return route.fulfill({ status: 200, body: "{}" });
112
+ });
113
+ await page.clock.install({ time: new Date("2026-01-01T10:00:00Z") });
114
+ page.on("pageerror", (e) => console.error("pageerror", e.message));
115
+ await page.goto("https://app.test/");
116
+ /** Let the recorder's async gzip and fetch settle on the real clock. */
117
+ const settle = () => new Promise((r) => setTimeout(r, 30));
118
+ const routes = [
119
+ ["/shifts", null],
120
+ ["/shifts/[id]", { id: "7" }],
121
+ ["/profile", null],
122
+ ["/shifts/[id]", { id: "12" }],
123
+ ];
124
+ let navs = 0;
125
+ for (let sec = 0; sec < minutes * 60; sec++) {
126
+ await page.mouse.move(200 + ((sec * 37) % 800), 150 + ((sec * 53) % 500), { steps: 4 });
127
+ if (sec % 3 === 0)
128
+ await page.mouse.wheel(0, sec % 6 === 0 ? 300 : -200);
129
+ if (sec % 20 === 19) {
130
+ const [pattern, params] = routes[++navs % routes.length];
131
+ await page.evaluate(([p, q]) => window.__navigate(p, q), [pattern, params]);
132
+ }
133
+ if (sec % 7 === 0) {
134
+ const input = page.locator("#search, #name").first();
135
+ if (await input.count())
136
+ await input.fill(`eingabe ${sec}`);
137
+ }
138
+ if (sec % 9 === 0)
139
+ await page.mouse.click(300, 300);
140
+ await page.clock.runFor(1000);
141
+ await settle();
142
+ }
143
+ await page.evaluate(() => window.__flush());
144
+ await page.clock.runFor(100);
145
+ await new Promise((r) => setTimeout(r, 300));
146
+ await browser.close();
147
+ const perMin = (n) => `${(n / minutes / 1024).toFixed(1)} KB`;
148
+ console.log(`${minutes} active minutes, ${navs} navigations, ${requests} uploads`);
149
+ console.log(`wire: ${perMin(wireBytes)} per active minute (${(wireBytes / 1024).toFixed(0)} KB total)`);
150
+ console.log(`json: ${perMin(jsonBytes)} per active minute (${(jsonBytes / 1024).toFixed(0)} KB total)`);
151
+ console.log("events:", eventTypes);
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The production recorder built for the browser, exposing
3
+ * `LunarDinos.createLunarDinosRecorder` on the page.
4
+ */
5
+ export declare function buildRecorderBundle(): Promise<string>;
6
+ //# sourceMappingURL=recorder-bundle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"recorder-bundle.d.ts","sourceRoot":"","sources":["../../../src/testing/recorder-bundle.ts"],"names":[],"mappings":"AAIA;;;GAGG;AACH,wBAAsB,mBAAmB,IAAI,OAAO,CAAC,MAAM,CAAC,CAa3D"}
@@ -0,0 +1,19 @@
1
+ import { mkdtempSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ /**
5
+ * The production recorder built for the browser, exposing
6
+ * `LunarDinos.createLunarDinosRecorder` on the page.
7
+ */
8
+ export async function buildRecorderBundle() {
9
+ const outdir = mkdtempSync(join(tmpdir(), "recorder-bundle-"));
10
+ const entry = join(outdir, "entry.ts");
11
+ await Bun.write(entry, `import { createLunarDinosRecorder } from "${import.meta.dir}/../recorder-core";\n(globalThis as any).LunarDinos = { createLunarDinosRecorder };\n`);
12
+ // Via the CLI: Bun.build inside `bun test` can't resolve workspace packages.
13
+ const build = Bun.spawnSync(["bun", "build", entry, "--target=browser", `--outdir=${outdir}`], {
14
+ cwd: import.meta.dir,
15
+ });
16
+ if (build.exitCode !== 0)
17
+ throw new Error(`bundle failed: ${build.stderr}`);
18
+ return Bun.file(join(outdir, "entry.js")).text();
19
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunardinos/browser",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -19,6 +19,7 @@
19
19
  "access": "public"
20
20
  },
21
21
  "devDependencies": {
22
- "@lunardinos/rrweb-record": "0.1.0"
22
+ "@lunardinos/rrweb-record": "0.1.0",
23
+ "playwright": "^1.50.0"
23
24
  }
24
25
  }