@harness-fe/runtime 3.0.1

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.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +48 -0
  3. package/dist/buffer.d.ts +13 -0
  4. package/dist/buffer.js +26 -0
  5. package/dist/capture.d.ts +47 -0
  6. package/dist/capture.js +112 -0
  7. package/dist/client.d.ts +82 -0
  8. package/dist/client.js +364 -0
  9. package/dist/commands.d.ts +10 -0
  10. package/dist/commands.js +304 -0
  11. package/dist/dashboardUrl.d.ts +18 -0
  12. package/dist/dashboardUrl.js +20 -0
  13. package/dist/fetchPatch.d.ts +39 -0
  14. package/dist/fetchPatch.js +311 -0
  15. package/dist/index.d.ts +8 -0
  16. package/dist/index.js +23 -0
  17. package/dist/outbox.d.ts +37 -0
  18. package/dist/outbox.js +80 -0
  19. package/dist/overlay.d.ts +68 -0
  20. package/dist/overlay.js +1946 -0
  21. package/dist/parent-inherit.d.ts +25 -0
  22. package/dist/parent-inherit.js +43 -0
  23. package/dist/recording.d.ts +27 -0
  24. package/dist/recording.js +86 -0
  25. package/dist/rrweb-types.d.ts +13 -0
  26. package/dist/rrweb-types.js +20 -0
  27. package/dist/selectors.d.ts +14 -0
  28. package/dist/selectors.js +91 -0
  29. package/dist/snapshot.d.ts +12 -0
  30. package/dist/snapshot.js +111 -0
  31. package/dist/visitor.d.ts +28 -0
  32. package/dist/visitor.js +107 -0
  33. package/dist/xhrPatch.d.ts +26 -0
  34. package/dist/xhrPatch.js +269 -0
  35. package/package.json +50 -0
  36. package/src/buffer.test.ts +26 -0
  37. package/src/buffer.ts +29 -0
  38. package/src/capture.ts +126 -0
  39. package/src/client.test.ts +89 -0
  40. package/src/client.ts +423 -0
  41. package/src/commands.test.ts +128 -0
  42. package/src/commands.ts +335 -0
  43. package/src/dashboardUrl.test.ts +59 -0
  44. package/src/dashboardUrl.ts +36 -0
  45. package/src/fetchPatch.test.ts +203 -0
  46. package/src/fetchPatch.ts +371 -0
  47. package/src/index.ts +32 -0
  48. package/src/outbox.test.ts +115 -0
  49. package/src/outbox.ts +84 -0
  50. package/src/overlay.test.ts +319 -0
  51. package/src/overlay.ts +2070 -0
  52. package/src/parent-inherit.ts +54 -0
  53. package/src/recording.ts +88 -0
  54. package/src/rrweb-types.test.ts +40 -0
  55. package/src/rrweb-types.ts +24 -0
  56. package/src/selectors.test.ts +50 -0
  57. package/src/selectors.ts +103 -0
  58. package/src/snapshot.ts +112 -0
  59. package/src/visitor.ts +116 -0
  60. package/src/xhrPatch.test.ts +191 -0
  61. package/src/xhrPatch.ts +314 -0
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Same-origin iframe identity inheritance.
3
+ *
4
+ * Extracted from `client.ts` so unit tests don't have to load the rrweb
5
+ * recorder (rrweb has a CJS/ESM dual-shape that breaks happy-dom test envs).
6
+ */
7
+
8
+ export interface ParentInheritance {
9
+ tabId?: string;
10
+ sessionId?: string;
11
+ parentProjectId?: string;
12
+ }
13
+
14
+ const TAB_ID_KEY = '__hfe_tab_id__';
15
+
16
+ /**
17
+ * Reach across to a same-origin parent window to inherit the harness-fe
18
+ * identity triple (tabId, sessionId, parentProjectId). Cross-origin parents
19
+ * throw on property access; we catch and return {} so the child runtime
20
+ * silently falls back to generating its own ids.
21
+ *
22
+ * Resolution order for each field:
23
+ * tabId ← parent.__harness_fe_client__.tabId
24
+ * ← parent.sessionStorage.getItem('__hfe_tab_id__')
25
+ * sessionId ← parent.__hfe_session_id__
26
+ * ← parent.__harness_fe_client__.sessionId
27
+ * parentProjectId ← parent.__HARNESS_FE__.projectId
28
+ */
29
+ export function tryInheritFromParent(): ParentInheritance {
30
+ if (typeof window === 'undefined') return {};
31
+ if (window.parent === window) return {};
32
+ try {
33
+ const p = window.parent as Window & {
34
+ __hfe_session_id__?: string;
35
+ __harness_fe_client__?: { tabId?: string; sessionId?: string };
36
+ __HARNESS_FE__?: { projectId?: string };
37
+ };
38
+ const parentTabId =
39
+ p.__harness_fe_client__?.tabId ??
40
+ p.sessionStorage?.getItem(TAB_ID_KEY) ??
41
+ undefined;
42
+ const parentSessionId =
43
+ p.__hfe_session_id__ ??
44
+ p.__harness_fe_client__?.sessionId ??
45
+ undefined;
46
+ return {
47
+ tabId: parentTabId ?? undefined,
48
+ sessionId: parentSessionId ?? undefined,
49
+ parentProjectId: p.__HARNESS_FE__?.projectId,
50
+ };
51
+ } catch {
52
+ return {};
53
+ }
54
+ }
@@ -0,0 +1,88 @@
1
+ import { record } from 'rrweb';
2
+ import type { RrwebChunkPayload } from '@harness-fe/protocol';
3
+
4
+ export { RRWEB_FULL_SNAPSHOT_TYPE, chunkHasFullSnapshot } from './rrweb-types.js';
5
+
6
+ const FLUSH_MS = 5_000;
7
+ const MAX_EVENTS = 200;
8
+
9
+ export class RrwebRecorder {
10
+ private stopRecording?: () => void;
11
+ private flushTimer?: number;
12
+ private chunkSeq = 0;
13
+ private buffer: unknown[] = [];
14
+
15
+ constructor(private readonly onChunk: (chunk: RrwebChunkPayload) => void) {}
16
+
17
+ start(): void {
18
+ if (this.stopRecording) return;
19
+ this.stopRecording = record({
20
+ emit: (event: unknown) => this.push(event),
21
+ inlineImages: false,
22
+ recordCanvas: false,
23
+ collectFonts: false,
24
+ maskAllInputs: false,
25
+ });
26
+ this.flushTimer = window.setInterval(() => this.flush(), FLUSH_MS);
27
+ }
28
+
29
+ stop(): void {
30
+ if (this.flushTimer !== undefined) {
31
+ window.clearInterval(this.flushTimer);
32
+ this.flushTimer = undefined;
33
+ }
34
+ this.stopRecording?.();
35
+ this.stopRecording = undefined;
36
+ this.flush();
37
+ }
38
+
39
+ /**
40
+ * Force rrweb to emit a fresh Meta + FullSnapshot pair right now.
41
+ *
42
+ * Used by the client on every ws hello-ack so each new connection has its
43
+ * own baseline. Without this, the only FullSnapshot for the session is
44
+ * the one rrweb emits at `start()`; if that chunk gets evicted from the
45
+ * outbox (FIFO overflow) or lost because the daemon was down at the
46
+ * critical moment, the session is unreplayable for the rest of its life.
47
+ *
48
+ * Safe to call repeatedly — rrweb just emits another type:2 each time.
49
+ * No-op if the recorder hasn't been started.
50
+ */
51
+ takeFullSnapshot(): void {
52
+ if (!this.stopRecording) return;
53
+ try {
54
+ record.takeFullSnapshot(true);
55
+ } catch {
56
+ // rrweb may throw if DOM is in an unexpected state — never let
57
+ // that bubble up and break the host page.
58
+ }
59
+ }
60
+
61
+ private push(event: unknown): void {
62
+ this.buffer.push(event);
63
+ if (this.buffer.length >= MAX_EVENTS) this.flush();
64
+ }
65
+
66
+ private flush(): void {
67
+ if (this.buffer.length === 0) return;
68
+ const events = this.buffer.splice(0, this.buffer.length);
69
+ const startTs = getEventTimestamp(events[0]) ?? Date.now();
70
+ const endTs = getEventTimestamp(events[events.length - 1]) ?? startTs;
71
+ this.chunkSeq += 1;
72
+ this.onChunk({
73
+ chunkId: `rrc_${this.chunkSeq.toString().padStart(6, '0')}`,
74
+ startTs,
75
+ endTs,
76
+ eventCount: events.length,
77
+ events,
78
+ });
79
+ }
80
+ }
81
+
82
+ function getEventTimestamp(event: unknown): number | undefined {
83
+ if (event && typeof event === 'object' && 'timestamp' in event) {
84
+ const ts = (event as { timestamp?: unknown }).timestamp;
85
+ if (typeof ts === 'number') return ts;
86
+ }
87
+ return undefined;
88
+ }
@@ -0,0 +1,40 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { chunkHasFullSnapshot, RRWEB_FULL_SNAPSHOT_TYPE } from './rrweb-types.js';
3
+
4
+ describe('chunkHasFullSnapshot', () => {
5
+ it('returns true when any event has type 2 (FullSnapshot)', () => {
6
+ const chunk = {
7
+ events: [
8
+ { type: 4, timestamp: 1, data: {} }, // Meta
9
+ { type: 2, timestamp: 2, data: {} }, // FullSnapshot
10
+ { type: 3, timestamp: 3, data: {} }, // Incremental
11
+ ],
12
+ };
13
+ expect(chunkHasFullSnapshot(chunk)).toBe(true);
14
+ });
15
+
16
+ it('returns false when only incremental events present', () => {
17
+ const chunk = {
18
+ events: [
19
+ { type: 3, timestamp: 1, data: {} },
20
+ { type: 3, timestamp: 2, data: {} },
21
+ ],
22
+ };
23
+ expect(chunkHasFullSnapshot(chunk)).toBe(false);
24
+ });
25
+
26
+ it('returns false for empty events array', () => {
27
+ expect(chunkHasFullSnapshot({ events: [] })).toBe(false);
28
+ });
29
+
30
+ it('tolerates malformed events without throwing', () => {
31
+ const chunk = {
32
+ events: [null, undefined, 42, 'string', { noType: true }],
33
+ };
34
+ expect(chunkHasFullSnapshot(chunk)).toBe(false);
35
+ });
36
+
37
+ it('exports the canonical FullSnapshot type constant', () => {
38
+ expect(RRWEB_FULL_SNAPSHOT_TYPE).toBe(2);
39
+ });
40
+ });
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Pure helpers around rrweb event types — no rrweb import.
3
+ *
4
+ * rrweb itself is browser-only and dies on import in Node test contexts
5
+ * (transitively requires `document`/CommonJS shenanigans), so we keep
6
+ * anything that needs to be unit-testable in a separate module.
7
+ */
8
+
9
+ /** rrweb event types: 2 = FullSnapshot baseline. */
10
+ export const RRWEB_FULL_SNAPSHOT_TYPE = 2;
11
+
12
+ /** True if any event in the chunk is an rrweb FullSnapshot (type:2). */
13
+ export function chunkHasFullSnapshot(chunk: { events: readonly unknown[] }): boolean {
14
+ for (const ev of chunk.events) {
15
+ if (
16
+ ev &&
17
+ typeof ev === 'object' &&
18
+ (ev as { type?: unknown }).type === RRWEB_FULL_SNAPSHOT_TYPE
19
+ ) {
20
+ return true;
21
+ }
22
+ }
23
+ return false;
24
+ }
@@ -0,0 +1,50 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it, beforeEach } from 'vitest';
3
+ import { resolveSelector } from './selectors.js';
4
+
5
+ beforeEach(() => {
6
+ document.body.innerHTML = `
7
+ <div>
8
+ <button class="primary" aria-label="submit">Submit</button>
9
+ <a href="/x">Link</a>
10
+ <input type="text" />
11
+ <span data-morphix-comp="SubmitButton" data-morphix-loc="src/Login.tsx:42:8">component target</span>
12
+ </div>
13
+ `;
14
+ });
15
+
16
+ describe('resolveSelector', () => {
17
+ it('resolves by css', () => {
18
+ const r = resolveSelector({ css: 'button.primary' });
19
+ expect(r.via).toBe('css');
20
+ expect((r.element as HTMLElement).textContent).toBe('Submit');
21
+ });
22
+
23
+ it('resolves by aria-label', () => {
24
+ const r = resolveSelector({ ariaLabel: 'submit' });
25
+ expect(r.via).toBe('aria');
26
+ expect((r.element as HTMLElement).tagName.toLowerCase()).toBe('button');
27
+ });
28
+
29
+ it('resolves by role + text', () => {
30
+ const r = resolveSelector({ role: 'button', text: 'Submit' });
31
+ expect(r.via).toBe('role-text');
32
+ });
33
+
34
+ it('resolves by component data attribute', () => {
35
+ const r = resolveSelector({ component: 'SubmitButton' });
36
+ expect(r.via).toBe('component-attr');
37
+ expect((r.element as HTMLElement).textContent).toBe('component target');
38
+ });
39
+
40
+ it('resolves by file:line', () => {
41
+ const r = resolveSelector({ file: 'src/Login.tsx', line: 42 });
42
+ expect(r.via).toBe('file');
43
+ });
44
+
45
+ it('returns null for no match', () => {
46
+ const r = resolveSelector({ css: '.does-not-exist' });
47
+ expect(r.element).toBeNull();
48
+ expect(r.via).toBe('none');
49
+ });
50
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Selector resolution in the page. Phase A: CSS / role+text / ariaLabel /
3
+ * compile-time data-morphix-comp attribute (set by future vite-plugin AST
4
+ * transform — Phase B). file:line + runtime fiber lookup land in Phase B/C.
5
+ */
6
+
7
+ import type { Selector } from '@harness-fe/protocol';
8
+
9
+ export interface ResolveResult {
10
+ element: Element | null;
11
+ /** Index used when multiple matched (for diagnostics). */
12
+ index: number;
13
+ /** How we found it (for diagnostics). */
14
+ via: 'css' | 'aria' | 'role-text' | 'component-attr' | 'file' | 'none';
15
+ }
16
+
17
+ export function resolveSelector(selector: Selector): ResolveResult {
18
+ const nth = selector.nth ?? 0;
19
+
20
+ if (selector.css) {
21
+ const list = document.querySelectorAll(selector.css);
22
+ if (list[nth]) return { element: list[nth] as Element, index: nth, via: 'css' };
23
+ }
24
+
25
+ if (selector.ariaLabel) {
26
+ const list = document.querySelectorAll(`[aria-label="${escapeCss(selector.ariaLabel)}"]`);
27
+ if (list[nth]) return { element: list[nth] as Element, index: nth, via: 'aria' };
28
+ }
29
+
30
+ if (selector.role || selector.text) {
31
+ const candidates = matchByRoleText(selector.role, selector.text);
32
+ if (candidates[nth]) {
33
+ return { element: candidates[nth], index: nth, via: 'role-text' };
34
+ }
35
+ }
36
+
37
+ if (selector.component) {
38
+ const list = document.querySelectorAll(
39
+ `[data-morphix-comp="${escapeCss(selector.component)}"]`,
40
+ );
41
+ if (list[nth]) {
42
+ return { element: list[nth] as Element, index: nth, via: 'component-attr' };
43
+ }
44
+ }
45
+
46
+ if (selector.file) {
47
+ const lineSuffix = selector.line ? `:${selector.line}` : '';
48
+ const list = document.querySelectorAll(
49
+ `[data-morphix-loc^="${escapeCss(selector.file)}${lineSuffix}"]`,
50
+ );
51
+ if (list[nth]) return { element: list[nth] as Element, index: nth, via: 'file' };
52
+ }
53
+
54
+ return { element: null, index: -1, via: 'none' };
55
+ }
56
+
57
+ function escapeCss(value: string): string {
58
+ return value.replace(/(["\\])/g, '\\$1');
59
+ }
60
+
61
+ function matchByRoleText(role?: string, text?: string): Element[] {
62
+ const all = Array.from(document.querySelectorAll<HTMLElement>('*'));
63
+ return all.filter((el) => {
64
+ if (role) {
65
+ const elRole = el.getAttribute('role') ?? implicitRole(el);
66
+ if (elRole !== role) return false;
67
+ }
68
+ if (text) {
69
+ // Use the element's own direct text (child text nodes only) for an
70
+ // exact match first, then fall back to full textContent for a
71
+ // contains-match. This prevents parent elements (e.g. <nav>,
72
+ // <ul>) from being returned ahead of the actual target element
73
+ // just because their textContent happens to include the search
74
+ // string via a descendant.
75
+ const directText = Array.from(el.childNodes)
76
+ .filter((n) => n.nodeType === Node.TEXT_NODE)
77
+ .map((n) => n.textContent ?? '')
78
+ .join('')
79
+ .trim();
80
+ const fullText = (el.textContent ?? '').trim();
81
+ const exactMatch = directText === text || fullText === text;
82
+ const containsMatch = directText.includes(text) || fullText.includes(text);
83
+ if (!exactMatch && !containsMatch) return false;
84
+ }
85
+ return true;
86
+ });
87
+ }
88
+
89
+ function implicitRole(el: HTMLElement): string | undefined {
90
+ const tag = el.tagName.toLowerCase();
91
+ if (tag === 'button') return 'button';
92
+ if (tag === 'a' && el.hasAttribute('href')) return 'link';
93
+ if (tag === 'input') {
94
+ const type = (el as HTMLInputElement).type;
95
+ if (type === 'checkbox') return 'checkbox';
96
+ if (type === 'radio') return 'radio';
97
+ if (type === 'button' || type === 'submit') return 'button';
98
+ return 'textbox';
99
+ }
100
+ if (tag === 'textarea') return 'textbox';
101
+ if (tag === 'select') return 'combobox';
102
+ return undefined;
103
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Initial-state snapshot for a single page load. Collected once after
3
+ * `hello.ack` and emitted as the first event of the load.
4
+ *
5
+ * Caps:
6
+ * - Single value: 32 KB → truncated with a placeholder
7
+ * - Entire snapshot: 256 KB → marked truncated, late keys dropped
8
+ *
9
+ * No business code is modified by this helper.
10
+ */
11
+
12
+ import type { PageLoadPayload } from '@harness-fe/protocol';
13
+
14
+ const VALUE_CAP = 32 * 1024;
15
+ const TOTAL_CAP = 256 * 1024;
16
+
17
+ function readStorage(storage: Storage | null | undefined): {
18
+ data: Record<string, string>;
19
+ truncated: boolean;
20
+ } {
21
+ if (!storage) return { data: {}, truncated: false };
22
+ const out: Record<string, string> = {};
23
+ let total = 0;
24
+ let truncated = false;
25
+ let i = 0;
26
+ try {
27
+ for (i = 0; i < storage.length; i++) {
28
+ const key = storage.key(i);
29
+ if (key === null) continue;
30
+ let value: string | null = null;
31
+ try {
32
+ value = storage.getItem(key);
33
+ } catch {
34
+ continue;
35
+ }
36
+ if (value === null) continue;
37
+ let trimmed = value;
38
+ if (trimmed.length > VALUE_CAP) {
39
+ trimmed = trimmed.slice(0, VALUE_CAP);
40
+ truncated = true;
41
+ }
42
+ if (total + trimmed.length > TOTAL_CAP) {
43
+ truncated = true;
44
+ break;
45
+ }
46
+ out[key] = trimmed;
47
+ total += trimmed.length;
48
+ }
49
+ } catch {
50
+ // Storage access can throw (e.g. blocked third-party cookies).
51
+ truncated = true;
52
+ }
53
+ return { data: out, truncated };
54
+ }
55
+
56
+ function readViewport(): { w: number; h: number; dpr: number } | undefined {
57
+ if (typeof window === 'undefined') return undefined;
58
+ try {
59
+ return {
60
+ w: Math.floor(window.innerWidth ?? 0),
61
+ h: Math.floor(window.innerHeight ?? 0),
62
+ dpr: window.devicePixelRatio ?? 1,
63
+ };
64
+ } catch {
65
+ return undefined;
66
+ }
67
+ }
68
+
69
+ function readPerformance(): PageLoadPayload['performance'] {
70
+ if (typeof performance === 'undefined') return undefined;
71
+ try {
72
+ const nav = performance.getEntriesByType?.('navigation')[0] as
73
+ | PerformanceNavigationTiming
74
+ | undefined;
75
+ if (!nav) return undefined;
76
+ return {
77
+ navigationStart: nav.startTime,
78
+ domContentLoaded: nav.domContentLoadedEventEnd,
79
+ loadEventEnd: nav.loadEventEnd,
80
+ };
81
+ } catch {
82
+ return undefined;
83
+ }
84
+ }
85
+
86
+ export function collectPageLoadSnapshot(sessionId: string): PageLoadPayload {
87
+ const local = readStorage(typeof localStorage !== 'undefined' ? localStorage : null);
88
+ const session = readStorage(typeof sessionStorage !== 'undefined' ? sessionStorage : null);
89
+ let cookie = '';
90
+ try {
91
+ cookie = typeof document !== 'undefined' ? document.cookie : '';
92
+ } catch {
93
+ cookie = '';
94
+ }
95
+ return {
96
+ sessionId,
97
+ page: {
98
+ url: typeof location !== 'undefined' ? location.href : undefined,
99
+ title: typeof document !== 'undefined' ? document.title : undefined,
100
+ referrer: typeof document !== 'undefined' ? document.referrer : undefined,
101
+ userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined,
102
+ },
103
+ viewport: readViewport(),
104
+ storage: {
105
+ local: local.data,
106
+ session: session.data,
107
+ cookie,
108
+ truncated: local.truncated || session.truncated,
109
+ },
110
+ performance: readPerformance(),
111
+ };
112
+ }
package/src/visitor.ts ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Visitor identity + environment capture.
3
+ *
4
+ * `visitorId` is an opaque UUID persisted in `localStorage.__hfe_visitor_id__`
5
+ * (per-origin, per-browser). It stitches a user's activity across pageloads,
6
+ * refreshes, and tabs so the daemon can build a coherent user journey.
7
+ *
8
+ * Privacy: anonymous by default. No canvas / WebGL / AudioContext
9
+ * fingerprinting. The app may pass `userId` via `HarnessScript userId=…`
10
+ * which the daemon attaches to `VisitorMeta.userId` — that field is only
11
+ * meaningful when the host app explicitly opts in.
12
+ */
13
+
14
+ import type { VisitorEnv } from '@harness-fe/protocol';
15
+
16
+ const VISITOR_ID_KEY = '__hfe_visitor_id__';
17
+
18
+ /** Read or lazily create the visitorId. localStorage scope = per-origin. */
19
+ export function getOrCreateVisitorId(): string {
20
+ try {
21
+ const existing = localStorage.getItem(VISITOR_ID_KEY);
22
+ if (existing && existing.length > 0) return existing;
23
+ const id = generateVisitorId();
24
+ localStorage.setItem(VISITOR_ID_KEY, id);
25
+ return id;
26
+ } catch {
27
+ // localStorage can throw on iOS Safari private mode / disabled storage.
28
+ // Fall back to an in-memory ephemeral id so events still flow.
29
+ return generateVisitorId();
30
+ }
31
+ }
32
+
33
+ function generateVisitorId(): string {
34
+ try {
35
+ return `v_${crypto.randomUUID()}`;
36
+ } catch {
37
+ return `v_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Same-origin iframe child inherits the parent's visitorId so the journey
43
+ * stitches across micro-frontends. Cross-origin children fall back to their
44
+ * own (a different visitorId — expected, browsers isolate localStorage).
45
+ */
46
+ export function tryInheritVisitorFromParent(): string | undefined {
47
+ if (typeof window === 'undefined' || window.parent === window) return undefined;
48
+ try {
49
+ const p = window.parent as Window & {
50
+ localStorage?: Storage;
51
+ __harness_fe_visitor_id__?: string;
52
+ };
53
+ // Prefer the runtime-exposed global; fall back to parent's localStorage
54
+ // when the parent's runtime hasn't yet exposed the cache (race).
55
+ if (p.__harness_fe_visitor_id__) return p.__harness_fe_visitor_id__;
56
+ const fromLs = p.localStorage?.getItem(VISITOR_ID_KEY);
57
+ return fromLs ?? undefined;
58
+ } catch {
59
+ // SecurityError when cross-origin — expected, swallow.
60
+ return undefined;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Snapshot the browser environment for this pageload. Cheap (all sync
66
+ * reads); safe to call on every hello.
67
+ */
68
+ export function collectEnv(): VisitorEnv {
69
+ const screenObj = (typeof screen !== 'undefined' ? screen : undefined) as Screen | undefined;
70
+ const dprNow = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
71
+ const colorScheme: 'light' | 'dark' | 'unknown' =
72
+ typeof window !== 'undefined' && typeof window.matchMedia === 'function'
73
+ ? window.matchMedia('(prefers-color-scheme: dark)').matches
74
+ ? 'dark'
75
+ : 'light'
76
+ : 'unknown';
77
+ const reducedMotion =
78
+ typeof window !== 'undefined' && typeof window.matchMedia === 'function'
79
+ ? window.matchMedia('(prefers-reduced-motion: reduce)').matches
80
+ : false;
81
+ return {
82
+ userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
83
+ language: typeof navigator !== 'undefined' ? navigator.language : 'en',
84
+ languages:
85
+ typeof navigator !== 'undefined' && navigator.languages
86
+ ? Array.from(navigator.languages)
87
+ : [],
88
+ timezone:
89
+ typeof Intl !== 'undefined' && Intl.DateTimeFormat
90
+ ? (Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC')
91
+ : 'UTC',
92
+ timezoneOffsetMin: new Date().getTimezoneOffset(),
93
+ screen: {
94
+ width: screenObj?.width ?? 0,
95
+ height: screenObj?.height ?? 0,
96
+ dpr: dprNow,
97
+ colorDepth: screenObj?.colorDepth,
98
+ },
99
+ viewport: {
100
+ width: typeof window !== 'undefined' ? window.innerWidth : 0,
101
+ height: typeof window !== 'undefined' ? window.innerHeight : 0,
102
+ },
103
+ colorScheme,
104
+ reducedMotion,
105
+ platform:
106
+ typeof navigator !== 'undefined' && 'platform' in navigator
107
+ ? (navigator as Navigator & { platform: string }).platform
108
+ : undefined,
109
+ };
110
+ }
111
+
112
+ /** Expose to same-origin iframes; called by client.ts after resolving id. */
113
+ export function publishVisitorIdToWindow(id: string): void {
114
+ if (typeof window === 'undefined') return;
115
+ (window as Window & { __harness_fe_visitor_id__?: string }).__harness_fe_visitor_id__ = id;
116
+ }