@obsrviq/tracker 0.3.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/src/network.ts ADDED
@@ -0,0 +1,439 @@
1
+ import type { NetworkEvent, NetworkResourceType, NetworkTiming } from '@obsrviq/types';
2
+ import type { InstrumentCtx, Teardown } from './context.js';
3
+ import { maskString } from './mask.js';
4
+ import { uuid } from './util.js';
5
+
6
+ const BODY_CAP = 4 * 1024; // 4 KB body preview cap
7
+ // Response/request header allowlist — everything else is dropped.
8
+ const HEADER_ALLOW = new Set([
9
+ 'content-type',
10
+ 'content-length',
11
+ 'accept',
12
+ 'accept-encoding',
13
+ 'cache-control',
14
+ 'etag',
15
+ 'last-modified',
16
+ 'content-encoding',
17
+ 'x-request-id',
18
+ 'x-correlation-id',
19
+ 'server',
20
+ 'vary',
21
+ ]);
22
+
23
+ function redactUrl(raw: string): string {
24
+ try {
25
+ const u = new URL(raw, location.href);
26
+ if (u.search) u.search = '?…';
27
+ return u.toString();
28
+ } catch {
29
+ return raw.split('?')[0] ?? raw;
30
+ }
31
+ }
32
+
33
+ function classifyHttp(status: number): string | undefined {
34
+ if (status === 0) return 'network';
35
+ if (status >= 500) return 'http_5xx';
36
+ if (status >= 400) return 'http_4xx';
37
+ return undefined;
38
+ }
39
+
40
+ function pickHeaders(
41
+ redact: string[],
42
+ entries: Iterable<[string, string]>,
43
+ ): Record<string, string> {
44
+ const out: Record<string, string> = {};
45
+ const redactSet = new Set(redact.map((h) => h.toLowerCase()));
46
+ for (const [k, v] of entries) {
47
+ const key = k.toLowerCase();
48
+ if (redactSet.has(key)) out[key] = 'redacted';
49
+ else if (HEADER_ALLOW.has(key)) out[key] = v;
50
+ }
51
+ return out;
52
+ }
53
+
54
+ function bodyPreview(
55
+ text: string | undefined,
56
+ contentType: string | undefined,
57
+ ): NetworkEvent['requestBody'] {
58
+ if (text == null) return undefined;
59
+ const masked = maskString(text);
60
+ const truncated = masked.length > BODY_CAP;
61
+ return {
62
+ contentType,
63
+ preview: truncated ? masked.slice(0, BODY_CAP) : masked,
64
+ truncated,
65
+ redacted: masked !== text,
66
+ };
67
+ }
68
+
69
+ function enrichTiming(ctx: InstrumentCtx, url: string, base: NetworkTiming): NetworkTiming {
70
+ try {
71
+ const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
72
+ // newest matching entry by URL
73
+ const match = entries.filter((e) => e.name === url).pop();
74
+ if (!match) return base;
75
+ const t: NetworkTiming = { ...base };
76
+ if (match.domainLookupEnd && match.domainLookupStart)
77
+ t.dns = Math.max(0, match.domainLookupEnd - match.domainLookupStart);
78
+ if (match.connectEnd && match.connectStart)
79
+ t.tcp = Math.max(0, match.connectEnd - match.connectStart);
80
+ if (match.secureConnectionStart)
81
+ t.tls = Math.max(0, match.connectEnd - match.secureConnectionStart);
82
+ if (match.responseStart && match.requestStart)
83
+ t.ttfb = Math.max(0, match.responseStart - match.requestStart);
84
+ if (match.responseEnd && match.responseStart)
85
+ t.download = Math.max(0, match.responseEnd - match.responseStart);
86
+ return t;
87
+ } catch {
88
+ return base;
89
+ }
90
+ }
91
+
92
+ export function instrumentNetwork(ctx: InstrumentCtx): Teardown {
93
+ const teardowns: Teardown[] = [];
94
+ teardowns.push(wrapFetch(ctx));
95
+ teardowns.push(wrapXhr(ctx));
96
+ teardowns.push(wrapBeacon(ctx));
97
+ teardowns.push(observeResources(ctx));
98
+ return () => teardowns.forEach((t) => t());
99
+ }
100
+
101
+ // ───────────────────────────── fetch ────────────────────────────────
102
+ function wrapFetch(ctx: InstrumentCtx): Teardown {
103
+ const orig = window.fetch;
104
+ if (!orig) return () => {};
105
+ window.fetch = async function (input: RequestInfo | URL, init?: RequestInit) {
106
+ const rawUrl = input instanceof Request ? input.url : String(input);
107
+ // Never record our own telemetry round-trips to the ingest endpoint.
108
+ if (rawUrl.includes('/v1/batch')) return orig.call(window, input as RequestInfo, init);
109
+ const startT = ctx.clock.now();
110
+ const method = (init?.method || (input instanceof Request ? input.method : 'GET')).toUpperCase();
111
+ const url = redactUrl(rawUrl);
112
+ const reqHeaders = pickHeaders(
113
+ ctx.config.redactHeaders,
114
+ headerEntries(init?.headers ?? (input instanceof Request ? input.headers : undefined)),
115
+ );
116
+
117
+ const emit = (partial: Partial<NetworkEvent>) => {
118
+ const endT = ctx.clock.now();
119
+ const timing = enrichTiming(ctx, rawUrl, { startT, endT, duration: endT - startT });
120
+ const ev: NetworkEvent = {
121
+ id: uuid(),
122
+ sessionId: ctx.sessionId,
123
+ type: 'network',
124
+ t: startT,
125
+ ts: ctx.clock.wall(),
126
+ initiator: 'fetch',
127
+ method,
128
+ url,
129
+ status: 0,
130
+ ok: false,
131
+ resourceType: 'fetch',
132
+ requestHeaders: reqHeaders,
133
+ timing,
134
+ ...partial,
135
+ };
136
+ ctx.emit(ev);
137
+ ctx.markActivity();
138
+ };
139
+
140
+ try {
141
+ const res = await orig.call(window, input as RequestInfo, init);
142
+ const status = res.status;
143
+ const respHeaders = pickHeaders(ctx.config.redactHeaders, res.headers.entries());
144
+ const len = res.headers.get('content-length');
145
+ let responseBody: NetworkEvent['responseBody'];
146
+ if (ctx.config.captureNetworkBodies && isTexty(res.headers.get('content-type'))) {
147
+ try {
148
+ const text = await res.clone().text();
149
+ responseBody = bodyPreview(text, res.headers.get('content-type') || undefined);
150
+ } catch {
151
+ /* ignore */
152
+ }
153
+ }
154
+ emit({
155
+ status,
156
+ ok: res.ok,
157
+ responseSize: len ? Number(len) : undefined,
158
+ responseHeaders: respHeaders,
159
+ requestBody: ctx.config.captureNetworkBodies
160
+ ? bodyPreview(stringifyBody(init?.body), undefined)
161
+ : undefined,
162
+ responseBody,
163
+ error: classifyHttp(status),
164
+ });
165
+ return res;
166
+ } catch (err) {
167
+ emit({ status: 0, ok: false, error: classifyFetchError(err) });
168
+ throw err;
169
+ }
170
+ } as typeof window.fetch;
171
+ return () => {
172
+ window.fetch = orig;
173
+ };
174
+ }
175
+
176
+ function classifyFetchError(err: unknown): string {
177
+ const name = (err as { name?: string })?.name;
178
+ if (name === 'AbortError') return 'aborted';
179
+ const msg = String((err as { message?: string })?.message || '').toLowerCase();
180
+ if (msg.includes('cors')) return 'cors';
181
+ if (msg.includes('timeout')) return 'timeout';
182
+ return 'network';
183
+ }
184
+
185
+ // ────────────────────────────── XHR ─────────────────────────────────
186
+ interface XhrMeta {
187
+ method: string;
188
+ url: string;
189
+ rawUrl: string;
190
+ startT: number;
191
+ reqHeaders: Record<string, string>;
192
+ body?: string;
193
+ }
194
+
195
+ function wrapXhr(ctx: InstrumentCtx): Teardown {
196
+ const XHR = XMLHttpRequest.prototype;
197
+ const origOpen = XHR.open;
198
+ const origSend = XHR.send;
199
+ const origSetHeader = XHR.setRequestHeader;
200
+ const META = new WeakMap<XMLHttpRequest, XhrMeta>();
201
+
202
+ XHR.open = function (this: XMLHttpRequest, method: string, url: string | URL, ...rest: unknown[]) {
203
+ const rawUrl = String(url);
204
+ META.set(this, {
205
+ method: String(method).toUpperCase(),
206
+ rawUrl,
207
+ url: redactUrl(rawUrl),
208
+ startT: 0,
209
+ reqHeaders: {},
210
+ });
211
+ return origOpen.apply(this, [method, url, ...rest] as never);
212
+ } as typeof XHR.open;
213
+
214
+ XHR.setRequestHeader = function (this: XMLHttpRequest, name: string, value: string) {
215
+ const meta = META.get(this);
216
+ if (meta) {
217
+ const key = name.toLowerCase();
218
+ if (ctx.config.redactHeaders.map((h) => h.toLowerCase()).includes(key))
219
+ meta.reqHeaders[key] = 'redacted';
220
+ else if (HEADER_ALLOW.has(key)) meta.reqHeaders[key] = value;
221
+ }
222
+ return origSetHeader.apply(this, [name, value] as never);
223
+ } as typeof XHR.setRequestHeader;
224
+
225
+ XHR.send = function (this: XMLHttpRequest, body?: Document | XMLHttpRequestBodyInit | null) {
226
+ const meta = META.get(this);
227
+ if (meta) {
228
+ meta.startT = ctx.clock.now();
229
+ if (ctx.config.captureNetworkBodies) meta.body = stringifyBody(body);
230
+ const finish = (error?: string) => {
231
+ const endT = ctx.clock.now();
232
+ const timing = enrichTiming(ctx, meta.rawUrl, {
233
+ startT: meta.startT,
234
+ endT,
235
+ duration: endT - meta.startT,
236
+ });
237
+ const status = this.status;
238
+ const respHeaders = parseRawHeaders(ctx, this.getAllResponseHeaders());
239
+ // Response body (texty + readable responseType only), parallel to fetch.
240
+ let responseBody: NetworkEvent['responseBody'];
241
+ if (ctx.config.captureNetworkBodies && isTexty(this.getResponseHeader('content-type'))) {
242
+ try {
243
+ if (this.responseType === '' || this.responseType === 'text') {
244
+ responseBody = bodyPreview(this.responseText, this.getResponseHeader('content-type') || undefined);
245
+ }
246
+ } catch {
247
+ /* opaque/cross-origin response — skip the body */
248
+ }
249
+ }
250
+ const ev: NetworkEvent = {
251
+ id: uuid(),
252
+ sessionId: ctx.sessionId,
253
+ type: 'network',
254
+ t: meta.startT,
255
+ ts: ctx.clock.wall(),
256
+ initiator: 'xhr',
257
+ method: meta.method,
258
+ url: meta.url,
259
+ status,
260
+ ok: status >= 200 && status < 400,
261
+ resourceType: 'xhr',
262
+ requestHeaders: meta.reqHeaders,
263
+ responseHeaders: respHeaders,
264
+ responseSize: safeLen(this),
265
+ requestBody: ctx.config.captureNetworkBodies ? bodyPreview(meta.body, undefined) : undefined,
266
+ responseBody,
267
+ timing,
268
+ error: error || classifyHttp(status),
269
+ };
270
+ ctx.emit(ev);
271
+ ctx.markActivity();
272
+ };
273
+ this.addEventListener('loadend', () => finish());
274
+ this.addEventListener('error', () => finish('network'));
275
+ this.addEventListener('timeout', () => finish('timeout'));
276
+ this.addEventListener('abort', () => finish('aborted'));
277
+ }
278
+ return origSend.apply(this, [body] as never);
279
+ } as typeof XHR.send;
280
+
281
+ return () => {
282
+ XHR.open = origOpen;
283
+ XHR.send = origSend;
284
+ XHR.setRequestHeader = origSetHeader;
285
+ };
286
+ }
287
+
288
+ function safeLen(xhr: XMLHttpRequest): number | undefined {
289
+ try {
290
+ if (xhr.responseType === '' || xhr.responseType === 'text')
291
+ return new Blob([xhr.responseText]).size;
292
+ } catch {
293
+ /* opaque */
294
+ }
295
+ const len = xhr.getResponseHeader('content-length');
296
+ return len ? Number(len) : undefined;
297
+ }
298
+
299
+ function parseRawHeaders(ctx: InstrumentCtx, raw: string): Record<string, string> {
300
+ const entries: [string, string][] = [];
301
+ for (const line of raw.trim().split(/[\r\n]+/)) {
302
+ const idx = line.indexOf(':');
303
+ if (idx > 0) entries.push([line.slice(0, idx).trim(), line.slice(idx + 1).trim()]);
304
+ }
305
+ return pickHeaders(ctx.config.redactHeaders, entries);
306
+ }
307
+
308
+ // ──────────────────────────── sendBeacon ────────────────────────────
309
+ function wrapBeacon(ctx: InstrumentCtx): Teardown {
310
+ const orig = navigator.sendBeacon;
311
+ if (!orig) return () => {};
312
+ navigator.sendBeacon = function (url: string | URL, data?: BodyInit | null): boolean {
313
+ const startT = ctx.clock.now();
314
+ let ok = false;
315
+ try {
316
+ ok = orig.call(navigator, url, data);
317
+ } finally {
318
+ // Don't record beacons to our own ingest endpoint.
319
+ const u = String(url);
320
+ if (!u.includes('/v1/batch')) {
321
+ const ev: NetworkEvent = {
322
+ id: uuid(),
323
+ sessionId: ctx.sessionId,
324
+ type: 'network',
325
+ t: startT,
326
+ ts: ctx.clock.wall(),
327
+ initiator: 'beacon',
328
+ method: 'POST',
329
+ url: redactUrl(u),
330
+ status: ok ? 202 : 0,
331
+ ok,
332
+ resourceType: 'other',
333
+ timing: { startT, endT: ctx.clock.now(), duration: 0 },
334
+ error: ok ? undefined : 'network',
335
+ };
336
+ ctx.emit(ev);
337
+ }
338
+ }
339
+ return ok;
340
+ };
341
+ return () => {
342
+ navigator.sendBeacon = orig;
343
+ };
344
+ }
345
+
346
+ // ─────────────────── PerformanceObserver (resources) ────────────────
347
+ const RESOURCE_TYPE_MAP: Record<string, NetworkResourceType> = {
348
+ script: 'script',
349
+ link: 'stylesheet',
350
+ css: 'stylesheet',
351
+ img: 'image',
352
+ image: 'image',
353
+ font: 'font',
354
+ audio: 'media',
355
+ video: 'media',
356
+ track: 'media',
357
+ };
358
+
359
+ function observeResources(ctx: InstrumentCtx): Teardown {
360
+ if (typeof PerformanceObserver === 'undefined') return () => {};
361
+ let obs: PerformanceObserver | undefined;
362
+ try {
363
+ obs = new PerformanceObserver((list) => {
364
+ for (const entry of list.getEntries() as PerformanceResourceTiming[]) {
365
+ const it = entry.initiatorType;
366
+ // fetch/XHR are captured by our wrappers — skip to avoid duplicates.
367
+ if (it === 'fetch' || it === 'xmlhttprequest' || it === 'beacon') continue;
368
+ const resourceType = RESOURCE_TYPE_MAP[it] || 'other';
369
+ const startT = ctx.clock.fromPerf(entry.startTime);
370
+ const endT = ctx.clock.fromPerf(entry.responseEnd || entry.startTime);
371
+ const timing: NetworkTiming = {
372
+ startT,
373
+ endT,
374
+ duration: Math.max(0, endT - startT),
375
+ dns: nz(entry.domainLookupEnd - entry.domainLookupStart),
376
+ tcp: nz(entry.connectEnd - entry.connectStart),
377
+ tls: entry.secureConnectionStart ? nz(entry.connectEnd - entry.secureConnectionStart) : undefined,
378
+ ttfb: nz(entry.responseStart - entry.requestStart),
379
+ download: nz(entry.responseEnd - entry.responseStart),
380
+ };
381
+ const ev: NetworkEvent = {
382
+ id: uuid(),
383
+ sessionId: ctx.sessionId,
384
+ type: 'network',
385
+ t: startT,
386
+ ts: ctx.clock.wall(),
387
+ initiator: 'resource',
388
+ method: 'GET',
389
+ url: redactUrl(entry.name),
390
+ status: entry.responseStatus || 200,
391
+ ok: !entry.responseStatus || entry.responseStatus < 400,
392
+ resourceType,
393
+ responseSize: entry.transferSize || entry.encodedBodySize || undefined,
394
+ timing,
395
+ };
396
+ ctx.emit(ev);
397
+ }
398
+ });
399
+ obs.observe({ type: 'resource', buffered: true });
400
+ } catch {
401
+ return () => {};
402
+ }
403
+ return () => obs?.disconnect();
404
+ }
405
+
406
+ // ────────────────────────────── helpers ─────────────────────────────
407
+ function nz(n: number): number | undefined {
408
+ return Number.isFinite(n) && n > 0 ? Math.round(n) : undefined;
409
+ }
410
+
411
+ function isTexty(contentType: string | null): boolean {
412
+ if (!contentType) return false;
413
+ return /json|text|xml|x-www-form-urlencoded/i.test(contentType);
414
+ }
415
+
416
+ function stringifyBody(body: unknown): string | undefined {
417
+ if (body == null) return undefined;
418
+ if (typeof body === 'string') return body;
419
+ if (body instanceof URLSearchParams) return body.toString();
420
+ if (body instanceof FormData) {
421
+ const obj: Record<string, string> = {};
422
+ body.forEach((v, k) => (obj[k] = typeof v === 'string' ? v : '«file»'));
423
+ return JSON.stringify(obj);
424
+ }
425
+ try {
426
+ return JSON.stringify(body);
427
+ } catch {
428
+ return undefined;
429
+ }
430
+ }
431
+
432
+ function headerEntries(
433
+ headers: HeadersInit | Headers | undefined,
434
+ ): Iterable<[string, string]> {
435
+ if (!headers) return [];
436
+ if (headers instanceof Headers) return headers.entries();
437
+ if (Array.isArray(headers)) return headers as [string, string][];
438
+ return Object.entries(headers);
439
+ }
@@ -0,0 +1,67 @@
1
+ import type { PageviewEvent } from '@obsrviq/types';
2
+ import type { InstrumentCtx, Teardown } from './context.js';
3
+ import { uuid } from './util.js';
4
+
5
+ /**
6
+ * Auto-captures a `pageview` event on the initial load and on every SPA route
7
+ * change — history.pushState / replaceState (patched, since neither fires a
8
+ * native event), popstate (back/forward), and hashchange.
9
+ *
10
+ * The "page" identity is the pathname, plus a hash-router segment when the hash
11
+ * looks like a route (`#/checkout`) but NOT a plain anchor (`#section`) — so
12
+ * anchor jumps don't masquerade as page views. Same-path repeats are de-duped.
13
+ * Each view records the previous in-app path as its `referrer`: the "from" edge
14
+ * that Path / Journey analysis chains.
15
+ */
16
+ export function instrumentPageviews(ctx: InstrumentCtx): Teardown {
17
+ let lastPath: string | null = null;
18
+
19
+ const currentPath = (): string => {
20
+ const hash = location.hash;
21
+ return location.pathname + (hash.startsWith('#/') ? hash : '');
22
+ };
23
+
24
+ const fire = () => {
25
+ const path = currentPath();
26
+ if (path === lastPath) return; // de-dupe no-op navigations / anchor jumps
27
+ const referrer = lastPath ?? '';
28
+ lastPath = path;
29
+ const e: PageviewEvent = {
30
+ id: uuid(),
31
+ sessionId: ctx.sessionId,
32
+ type: 'pageview',
33
+ t: ctx.clock.now(),
34
+ ts: ctx.clock.wall(),
35
+ url: location.href,
36
+ path,
37
+ title: document.title || undefined,
38
+ referrer: referrer || undefined,
39
+ };
40
+ ctx.emit(e);
41
+ ctx.markActivity();
42
+ };
43
+
44
+ // Patch history so SPA navigations (which fire no native event) are observed.
45
+ const origPush = history.pushState;
46
+ const origReplace = history.replaceState;
47
+ const wrap = (orig: History['pushState']): History['pushState'] =>
48
+ function patched(this: History, data, unused, url) {
49
+ const ret = orig.call(this, data, unused, url);
50
+ // Defer one microtask so location.* already reflects the new URL.
51
+ queueMicrotask(fire);
52
+ return ret;
53
+ };
54
+ history.pushState = wrap(origPush);
55
+ history.replaceState = wrap(origReplace);
56
+ window.addEventListener('popstate', fire);
57
+ window.addEventListener('hashchange', fire);
58
+
59
+ fire(); // the entry view
60
+
61
+ return () => {
62
+ history.pushState = origPush;
63
+ history.replaceState = origReplace;
64
+ window.removeEventListener('popstate', fire);
65
+ window.removeEventListener('hashchange', fire);
66
+ };
67
+ }
@@ -0,0 +1,53 @@
1
+ import { record } from 'rrweb';
2
+ import type { eventWithTime } from '@rrweb/types';
3
+ import type { DomEvent } from '@obsrviq/types';
4
+ import type { InstrumentCtx, Teardown } from './context.js';
5
+ import { uuid } from './util.js';
6
+
7
+ // rrweb EventType.IncrementalSnapshot === 3 (a real DOM mutation/interaction).
8
+ const INCREMENTAL = 3;
9
+
10
+ export function instrumentDom(ctx: InstrumentCtx): Teardown {
11
+ const stop = record({
12
+ emit(event: eventWithTime) {
13
+ const dom: DomEvent = {
14
+ id: uuid(),
15
+ sessionId: ctx.sessionId,
16
+ type: 'dom',
17
+ t: ctx.clock.fromPerf(performanceNow()),
18
+ ts: ctx.clock.wall(),
19
+ data: event,
20
+ };
21
+ ctx.emit(dom);
22
+ if (event.type === INCREMENTAL) ctx.markActivity();
23
+ },
24
+ maskAllInputs: ctx.config.maskAllInputs,
25
+ // Password inputs stay masked even in no-privacy mode (maskAllInputs:false) —
26
+ // a hard floor so recordings never store plaintext passwords.
27
+ maskInputOptions: { password: true },
28
+ maskTextSelector: ctx.config.maskTextSelector,
29
+ blockSelector: ctx.config.blockSelector,
30
+ recordCanvas: ctx.config.recordCanvas,
31
+ // Periodic full snapshots → the player can start replay from the nearest
32
+ // checkpoint, enabling progressive buffering + reliable seeking (§8.6).
33
+ checkoutEveryNms: 30_000,
34
+ sampling: {
35
+ // Don't record every mousemove point; sample for a smaller payload.
36
+ mousemove: 50,
37
+ scroll: 150,
38
+ input: 'last',
39
+ },
40
+ });
41
+
42
+ return () => {
43
+ try {
44
+ stop?.();
45
+ } catch {
46
+ /* already stopped */
47
+ }
48
+ };
49
+ }
50
+
51
+ function performanceNow(): number {
52
+ return typeof performance !== 'undefined' ? performance.now() : Date.now();
53
+ }
package/src/scroll.ts ADDED
@@ -0,0 +1,65 @@
1
+ import type { CustomEvent as ObsrviqCustomEvent } from '@obsrviq/types';
2
+ import type { InstrumentCtx, Teardown } from './context.js';
3
+ import { uuid } from './util.js';
4
+
5
+ /**
6
+ * Scroll-depth capture, per page path. Emits a `_scroll` event whenever the
7
+ * visitor reaches a new, deeper 10% band of the page, so the server can take
8
+ * each session's max depth per page. Cheap (a handful of events per page at
9
+ * most) and value-free. The page identity is the pathname; depth resets on SPA
10
+ * route changes.
11
+ */
12
+ export function instrumentScrollDepth(ctx: InstrumentCtx): Teardown {
13
+ let path = location.pathname;
14
+ let maxBand = 0; // deepest 10% band reached on the current path
15
+ let scheduled = false;
16
+
17
+ const emit = (depth: number) => {
18
+ const e: ObsrviqCustomEvent = {
19
+ id: uuid(),
20
+ sessionId: ctx.sessionId,
21
+ type: 'custom',
22
+ t: ctx.clock.now(),
23
+ ts: ctx.clock.wall(),
24
+ name: '_scroll',
25
+ props: { path, depth },
26
+ };
27
+ ctx.emit(e);
28
+ };
29
+
30
+ const docHeight = (): number => {
31
+ const d = document.documentElement;
32
+ const b = document.body;
33
+ return Math.max(d.scrollHeight, b ? b.scrollHeight : 0, d.clientHeight) || 1;
34
+ };
35
+
36
+ const measure = () => {
37
+ scheduled = false;
38
+ // Reset depth tracking when the SPA route changes.
39
+ if (location.pathname !== path) {
40
+ path = location.pathname;
41
+ maxBand = 0;
42
+ }
43
+ const vh = window.innerHeight || document.documentElement.clientHeight || 1;
44
+ const reached = Math.min(1, (window.scrollY + vh) / docHeight());
45
+ const band = Math.min(100, Math.round(reached * 10) * 10);
46
+ if (band > maxBand) {
47
+ maxBand = band;
48
+ emit(band);
49
+ }
50
+ };
51
+
52
+ const onScroll = () => {
53
+ if (scheduled) return;
54
+ scheduled = true;
55
+ requestAnimationFrame(measure);
56
+ };
57
+
58
+ window.addEventListener('scroll', onScroll, { passive: true, capture: true });
59
+ // Capture the entry fold immediately (a session that never scrolls still has a depth).
60
+ measure();
61
+
62
+ return () => {
63
+ window.removeEventListener('scroll', onScroll, true);
64
+ };
65
+ }