@lunardinos/browser 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/recorder-core.js +408 -382
- 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 +176 -19
- package/dist/src/recorder.browser.test.d.ts +2 -0
- package/dist/src/recorder.browser.test.d.ts.map +1 -0
- package/dist/src/recorder.browser.test.js +144 -0
- package/dist/src/session-lifecycle.d.ts +45 -0
- package/dist/src/session-lifecycle.d.ts.map +1 -0
- package/dist/src/session-lifecycle.js +70 -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 +93 -0
- package/dist/src/testing/measure-bytes.d.ts +2 -0
- package/dist/src/testing/measure-bytes.d.ts.map +1 -0
- package/dist/src/testing/measure-bytes.js +151 -0
- package/dist/src/testing/recorder-bundle.d.ts +6 -0
- package/dist/src/testing/recorder-bundle.d.ts.map +1 -0
- package/dist/src/testing/recorder-bundle.js +19 -0
- package/package.json +3 -2
|
@@ -0,0 +1,93 @@
|
|
|
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: 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);
|
|
31
|
+
});
|
|
32
|
+
test("does NOT count DOM mutations", () => {
|
|
33
|
+
// The whole point: a polling or animating page emits these forever with
|
|
34
|
+
// nobody watching. Counting them is what created the 20-day recordings.
|
|
35
|
+
expect(isUserActivity(inc(0))).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
test("does not count other machine-driven sources", () => {
|
|
38
|
+
expect(isUserActivity(inc(4))).toBe(false); // viewport resize
|
|
39
|
+
expect(isUserActivity(inc(8))).toBe(false); // stylesheet rule
|
|
40
|
+
expect(isUserActivity(inc(9))).toBe(false); // canvas mutation
|
|
41
|
+
expect(isUserActivity(inc(11))).toBe(false); // log
|
|
42
|
+
expect(isUserActivity(inc(15))).toBe(false); // adopted stylesheet
|
|
43
|
+
});
|
|
44
|
+
test("ignores non-incremental event types", () => {
|
|
45
|
+
expect(isUserActivity({ type: 2 })).toBe(false); // full snapshot
|
|
46
|
+
expect(isUserActivity({ type: 4 })).toBe(false); // meta
|
|
47
|
+
expect(isUserActivity({ type: 5 })).toBe(false); // custom
|
|
48
|
+
});
|
|
49
|
+
test("tolerates a missing source", () => {
|
|
50
|
+
expect(isUserActivity({ type: 3 })).toBe(false);
|
|
51
|
+
expect(isUserActivity({ type: 3, data: {} })).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
describe("sessionExpired", () => {
|
|
55
|
+
test("holds the session open right up to the timeout", () => {
|
|
56
|
+
expect(sessionExpired(NOW, NOW + SESSION_TIMEOUT_MS - 1)).toBe(false);
|
|
57
|
+
expect(sessionExpired(NOW, NOW + SESSION_TIMEOUT_MS)).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
test("ends it past the timeout", () => {
|
|
60
|
+
expect(sessionExpired(NOW, NOW + SESSION_TIMEOUT_MS + 1)).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
test("a long sitting stays one session while activity continues", () => {
|
|
63
|
+
// Activity every 25 minutes for 8 hours never expires — the gap is what
|
|
64
|
+
// matters, not the total duration.
|
|
65
|
+
let last = NOW;
|
|
66
|
+
for (let i = 0; i < 20; i++) {
|
|
67
|
+
const next = last + 25 * 60_000;
|
|
68
|
+
expect(sessionExpired(last, next)).toBe(false);
|
|
69
|
+
last = next;
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
test("an idle tab expires once, not repeatedly", () => {
|
|
73
|
+
// 20 days of silence is a single expiry, and the caller resets the clock.
|
|
74
|
+
expect(sessionExpired(NOW, NOW + 20 * 24 * 60 * 60_000)).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
test("no activity yet is not an expiry", () => {
|
|
77
|
+
expect(sessionExpired(0, NOW)).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
describe("storedSessionUsable", () => {
|
|
81
|
+
test("reuses a session the tab touched recently", () => {
|
|
82
|
+
expect(storedSessionUsable(NOW, NOW + 60_000)).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
test("refuses one the cookie would already have expired", () => {
|
|
85
|
+
expect(storedSessionUsable(NOW, NOW + SESSION_TIMEOUT_MS)).toBe(false);
|
|
86
|
+
expect(storedSessionUsable(NOW, NOW + SESSION_TIMEOUT_MS + 1)).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
test("refuses a missing or unparseable timestamp", () => {
|
|
89
|
+
// An SDK upgrade leaves tabs with no stored timestamp; those start fresh.
|
|
90
|
+
expect(storedSessionUsable(Number.NaN, NOW)).toBe(false);
|
|
91
|
+
expect(storedSessionUsable(0, NOW)).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -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 @@
|
|
|
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.
|
|
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
|
}
|