@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,25 @@
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
+ export interface ParentInheritance {
8
+ tabId?: string;
9
+ sessionId?: string;
10
+ parentProjectId?: string;
11
+ }
12
+ /**
13
+ * Reach across to a same-origin parent window to inherit the harness-fe
14
+ * identity triple (tabId, sessionId, parentProjectId). Cross-origin parents
15
+ * throw on property access; we catch and return {} so the child runtime
16
+ * silently falls back to generating its own ids.
17
+ *
18
+ * Resolution order for each field:
19
+ * tabId ← parent.__harness_fe_client__.tabId
20
+ * ← parent.sessionStorage.getItem('__hfe_tab_id__')
21
+ * sessionId ← parent.__hfe_session_id__
22
+ * ← parent.__harness_fe_client__.sessionId
23
+ * parentProjectId ← parent.__HARNESS_FE__.projectId
24
+ */
25
+ export declare function tryInheritFromParent(): ParentInheritance;
@@ -0,0 +1,43 @@
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
+ const TAB_ID_KEY = '__hfe_tab_id__';
8
+ /**
9
+ * Reach across to a same-origin parent window to inherit the harness-fe
10
+ * identity triple (tabId, sessionId, parentProjectId). Cross-origin parents
11
+ * throw on property access; we catch and return {} so the child runtime
12
+ * silently falls back to generating its own ids.
13
+ *
14
+ * Resolution order for each field:
15
+ * tabId ← parent.__harness_fe_client__.tabId
16
+ * ← parent.sessionStorage.getItem('__hfe_tab_id__')
17
+ * sessionId ← parent.__hfe_session_id__
18
+ * ← parent.__harness_fe_client__.sessionId
19
+ * parentProjectId ← parent.__HARNESS_FE__.projectId
20
+ */
21
+ export function tryInheritFromParent() {
22
+ if (typeof window === 'undefined')
23
+ return {};
24
+ if (window.parent === window)
25
+ return {};
26
+ try {
27
+ const p = window.parent;
28
+ const parentTabId = p.__harness_fe_client__?.tabId ??
29
+ p.sessionStorage?.getItem(TAB_ID_KEY) ??
30
+ undefined;
31
+ const parentSessionId = p.__hfe_session_id__ ??
32
+ p.__harness_fe_client__?.sessionId ??
33
+ undefined;
34
+ return {
35
+ tabId: parentTabId ?? undefined,
36
+ sessionId: parentSessionId ?? undefined,
37
+ parentProjectId: p.__HARNESS_FE__?.projectId,
38
+ };
39
+ }
40
+ catch {
41
+ return {};
42
+ }
43
+ }
@@ -0,0 +1,27 @@
1
+ import type { RrwebChunkPayload } from '@harness-fe/protocol';
2
+ export { RRWEB_FULL_SNAPSHOT_TYPE, chunkHasFullSnapshot } from './rrweb-types.js';
3
+ export declare class RrwebRecorder {
4
+ private readonly onChunk;
5
+ private stopRecording?;
6
+ private flushTimer?;
7
+ private chunkSeq;
8
+ private buffer;
9
+ constructor(onChunk: (chunk: RrwebChunkPayload) => void);
10
+ start(): void;
11
+ stop(): void;
12
+ /**
13
+ * Force rrweb to emit a fresh Meta + FullSnapshot pair right now.
14
+ *
15
+ * Used by the client on every ws hello-ack so each new connection has its
16
+ * own baseline. Without this, the only FullSnapshot for the session is
17
+ * the one rrweb emits at `start()`; if that chunk gets evicted from the
18
+ * outbox (FIFO overflow) or lost because the daemon was down at the
19
+ * critical moment, the session is unreplayable for the rest of its life.
20
+ *
21
+ * Safe to call repeatedly — rrweb just emits another type:2 each time.
22
+ * No-op if the recorder hasn't been started.
23
+ */
24
+ takeFullSnapshot(): void;
25
+ private push;
26
+ private flush;
27
+ }
@@ -0,0 +1,86 @@
1
+ import { record } from 'rrweb';
2
+ export { RRWEB_FULL_SNAPSHOT_TYPE, chunkHasFullSnapshot } from './rrweb-types.js';
3
+ const FLUSH_MS = 5_000;
4
+ const MAX_EVENTS = 200;
5
+ export class RrwebRecorder {
6
+ onChunk;
7
+ stopRecording;
8
+ flushTimer;
9
+ chunkSeq = 0;
10
+ buffer = [];
11
+ constructor(onChunk) {
12
+ this.onChunk = onChunk;
13
+ }
14
+ start() {
15
+ if (this.stopRecording)
16
+ return;
17
+ this.stopRecording = record({
18
+ emit: (event) => this.push(event),
19
+ inlineImages: false,
20
+ recordCanvas: false,
21
+ collectFonts: false,
22
+ maskAllInputs: false,
23
+ });
24
+ this.flushTimer = window.setInterval(() => this.flush(), FLUSH_MS);
25
+ }
26
+ stop() {
27
+ if (this.flushTimer !== undefined) {
28
+ window.clearInterval(this.flushTimer);
29
+ this.flushTimer = undefined;
30
+ }
31
+ this.stopRecording?.();
32
+ this.stopRecording = undefined;
33
+ this.flush();
34
+ }
35
+ /**
36
+ * Force rrweb to emit a fresh Meta + FullSnapshot pair right now.
37
+ *
38
+ * Used by the client on every ws hello-ack so each new connection has its
39
+ * own baseline. Without this, the only FullSnapshot for the session is
40
+ * the one rrweb emits at `start()`; if that chunk gets evicted from the
41
+ * outbox (FIFO overflow) or lost because the daemon was down at the
42
+ * critical moment, the session is unreplayable for the rest of its life.
43
+ *
44
+ * Safe to call repeatedly — rrweb just emits another type:2 each time.
45
+ * No-op if the recorder hasn't been started.
46
+ */
47
+ takeFullSnapshot() {
48
+ if (!this.stopRecording)
49
+ return;
50
+ try {
51
+ record.takeFullSnapshot(true);
52
+ }
53
+ catch {
54
+ // rrweb may throw if DOM is in an unexpected state — never let
55
+ // that bubble up and break the host page.
56
+ }
57
+ }
58
+ push(event) {
59
+ this.buffer.push(event);
60
+ if (this.buffer.length >= MAX_EVENTS)
61
+ this.flush();
62
+ }
63
+ flush() {
64
+ if (this.buffer.length === 0)
65
+ return;
66
+ const events = this.buffer.splice(0, this.buffer.length);
67
+ const startTs = getEventTimestamp(events[0]) ?? Date.now();
68
+ const endTs = getEventTimestamp(events[events.length - 1]) ?? startTs;
69
+ this.chunkSeq += 1;
70
+ this.onChunk({
71
+ chunkId: `rrc_${this.chunkSeq.toString().padStart(6, '0')}`,
72
+ startTs,
73
+ endTs,
74
+ eventCount: events.length,
75
+ events,
76
+ });
77
+ }
78
+ }
79
+ function getEventTimestamp(event) {
80
+ if (event && typeof event === 'object' && 'timestamp' in event) {
81
+ const ts = event.timestamp;
82
+ if (typeof ts === 'number')
83
+ return ts;
84
+ }
85
+ return undefined;
86
+ }
@@ -0,0 +1,13 @@
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
+ /** rrweb event types: 2 = FullSnapshot baseline. */
9
+ export declare const RRWEB_FULL_SNAPSHOT_TYPE = 2;
10
+ /** True if any event in the chunk is an rrweb FullSnapshot (type:2). */
11
+ export declare function chunkHasFullSnapshot(chunk: {
12
+ events: readonly unknown[];
13
+ }): boolean;
@@ -0,0 +1,20 @@
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
+ /** rrweb event types: 2 = FullSnapshot baseline. */
9
+ export const RRWEB_FULL_SNAPSHOT_TYPE = 2;
10
+ /** True if any event in the chunk is an rrweb FullSnapshot (type:2). */
11
+ export function chunkHasFullSnapshot(chunk) {
12
+ for (const ev of chunk.events) {
13
+ if (ev &&
14
+ typeof ev === 'object' &&
15
+ ev.type === RRWEB_FULL_SNAPSHOT_TYPE) {
16
+ return true;
17
+ }
18
+ }
19
+ return false;
20
+ }
@@ -0,0 +1,14 @@
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
+ import type { Selector } from '@harness-fe/protocol';
7
+ export interface ResolveResult {
8
+ element: Element | null;
9
+ /** Index used when multiple matched (for diagnostics). */
10
+ index: number;
11
+ /** How we found it (for diagnostics). */
12
+ via: 'css' | 'aria' | 'role-text' | 'component-attr' | 'file' | 'none';
13
+ }
14
+ export declare function resolveSelector(selector: Selector): ResolveResult;
@@ -0,0 +1,91 @@
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
+ export function resolveSelector(selector) {
7
+ const nth = selector.nth ?? 0;
8
+ if (selector.css) {
9
+ const list = document.querySelectorAll(selector.css);
10
+ if (list[nth])
11
+ return { element: list[nth], index: nth, via: 'css' };
12
+ }
13
+ if (selector.ariaLabel) {
14
+ const list = document.querySelectorAll(`[aria-label="${escapeCss(selector.ariaLabel)}"]`);
15
+ if (list[nth])
16
+ return { element: list[nth], index: nth, via: 'aria' };
17
+ }
18
+ if (selector.role || selector.text) {
19
+ const candidates = matchByRoleText(selector.role, selector.text);
20
+ if (candidates[nth]) {
21
+ return { element: candidates[nth], index: nth, via: 'role-text' };
22
+ }
23
+ }
24
+ if (selector.component) {
25
+ const list = document.querySelectorAll(`[data-morphix-comp="${escapeCss(selector.component)}"]`);
26
+ if (list[nth]) {
27
+ return { element: list[nth], index: nth, via: 'component-attr' };
28
+ }
29
+ }
30
+ if (selector.file) {
31
+ const lineSuffix = selector.line ? `:${selector.line}` : '';
32
+ const list = document.querySelectorAll(`[data-morphix-loc^="${escapeCss(selector.file)}${lineSuffix}"]`);
33
+ if (list[nth])
34
+ return { element: list[nth], index: nth, via: 'file' };
35
+ }
36
+ return { element: null, index: -1, via: 'none' };
37
+ }
38
+ function escapeCss(value) {
39
+ return value.replace(/(["\\])/g, '\\$1');
40
+ }
41
+ function matchByRoleText(role, text) {
42
+ const all = Array.from(document.querySelectorAll('*'));
43
+ return all.filter((el) => {
44
+ if (role) {
45
+ const elRole = el.getAttribute('role') ?? implicitRole(el);
46
+ if (elRole !== role)
47
+ return false;
48
+ }
49
+ if (text) {
50
+ // Use the element's own direct text (child text nodes only) for an
51
+ // exact match first, then fall back to full textContent for a
52
+ // contains-match. This prevents parent elements (e.g. <nav>,
53
+ // <ul>) from being returned ahead of the actual target element
54
+ // just because their textContent happens to include the search
55
+ // string via a descendant.
56
+ const directText = Array.from(el.childNodes)
57
+ .filter((n) => n.nodeType === Node.TEXT_NODE)
58
+ .map((n) => n.textContent ?? '')
59
+ .join('')
60
+ .trim();
61
+ const fullText = (el.textContent ?? '').trim();
62
+ const exactMatch = directText === text || fullText === text;
63
+ const containsMatch = directText.includes(text) || fullText.includes(text);
64
+ if (!exactMatch && !containsMatch)
65
+ return false;
66
+ }
67
+ return true;
68
+ });
69
+ }
70
+ function implicitRole(el) {
71
+ const tag = el.tagName.toLowerCase();
72
+ if (tag === 'button')
73
+ return 'button';
74
+ if (tag === 'a' && el.hasAttribute('href'))
75
+ return 'link';
76
+ if (tag === 'input') {
77
+ const type = el.type;
78
+ if (type === 'checkbox')
79
+ return 'checkbox';
80
+ if (type === 'radio')
81
+ return 'radio';
82
+ if (type === 'button' || type === 'submit')
83
+ return 'button';
84
+ return 'textbox';
85
+ }
86
+ if (tag === 'textarea')
87
+ return 'textbox';
88
+ if (tag === 'select')
89
+ return 'combobox';
90
+ return undefined;
91
+ }
@@ -0,0 +1,12 @@
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
+ import type { PageLoadPayload } from '@harness-fe/protocol';
12
+ export declare function collectPageLoadSnapshot(sessionId: string): PageLoadPayload;
@@ -0,0 +1,111 @@
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
+ const VALUE_CAP = 32 * 1024;
12
+ const TOTAL_CAP = 256 * 1024;
13
+ function readStorage(storage) {
14
+ if (!storage)
15
+ return { data: {}, truncated: false };
16
+ const out = {};
17
+ let total = 0;
18
+ let truncated = false;
19
+ let i = 0;
20
+ try {
21
+ for (i = 0; i < storage.length; i++) {
22
+ const key = storage.key(i);
23
+ if (key === null)
24
+ continue;
25
+ let value = null;
26
+ try {
27
+ value = storage.getItem(key);
28
+ }
29
+ catch {
30
+ continue;
31
+ }
32
+ if (value === null)
33
+ continue;
34
+ let trimmed = value;
35
+ if (trimmed.length > VALUE_CAP) {
36
+ trimmed = trimmed.slice(0, VALUE_CAP);
37
+ truncated = true;
38
+ }
39
+ if (total + trimmed.length > TOTAL_CAP) {
40
+ truncated = true;
41
+ break;
42
+ }
43
+ out[key] = trimmed;
44
+ total += trimmed.length;
45
+ }
46
+ }
47
+ catch {
48
+ // Storage access can throw (e.g. blocked third-party cookies).
49
+ truncated = true;
50
+ }
51
+ return { data: out, truncated };
52
+ }
53
+ function readViewport() {
54
+ if (typeof window === 'undefined')
55
+ return undefined;
56
+ try {
57
+ return {
58
+ w: Math.floor(window.innerWidth ?? 0),
59
+ h: Math.floor(window.innerHeight ?? 0),
60
+ dpr: window.devicePixelRatio ?? 1,
61
+ };
62
+ }
63
+ catch {
64
+ return undefined;
65
+ }
66
+ }
67
+ function readPerformance() {
68
+ if (typeof performance === 'undefined')
69
+ return undefined;
70
+ try {
71
+ const nav = performance.getEntriesByType?.('navigation')[0];
72
+ if (!nav)
73
+ return undefined;
74
+ return {
75
+ navigationStart: nav.startTime,
76
+ domContentLoaded: nav.domContentLoadedEventEnd,
77
+ loadEventEnd: nav.loadEventEnd,
78
+ };
79
+ }
80
+ catch {
81
+ return undefined;
82
+ }
83
+ }
84
+ export function collectPageLoadSnapshot(sessionId) {
85
+ const local = readStorage(typeof localStorage !== 'undefined' ? localStorage : null);
86
+ const session = readStorage(typeof sessionStorage !== 'undefined' ? sessionStorage : null);
87
+ let cookie = '';
88
+ try {
89
+ cookie = typeof document !== 'undefined' ? document.cookie : '';
90
+ }
91
+ catch {
92
+ cookie = '';
93
+ }
94
+ return {
95
+ sessionId,
96
+ page: {
97
+ url: typeof location !== 'undefined' ? location.href : undefined,
98
+ title: typeof document !== 'undefined' ? document.title : undefined,
99
+ referrer: typeof document !== 'undefined' ? document.referrer : undefined,
100
+ userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined,
101
+ },
102
+ viewport: readViewport(),
103
+ storage: {
104
+ local: local.data,
105
+ session: session.data,
106
+ cookie,
107
+ truncated: local.truncated || session.truncated,
108
+ },
109
+ performance: readPerformance(),
110
+ };
111
+ }
@@ -0,0 +1,28 @@
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
+ import type { VisitorEnv } from '@harness-fe/protocol';
14
+ /** Read or lazily create the visitorId. localStorage scope = per-origin. */
15
+ export declare function getOrCreateVisitorId(): string;
16
+ /**
17
+ * Same-origin iframe child inherits the parent's visitorId so the journey
18
+ * stitches across micro-frontends. Cross-origin children fall back to their
19
+ * own (a different visitorId — expected, browsers isolate localStorage).
20
+ */
21
+ export declare function tryInheritVisitorFromParent(): string | undefined;
22
+ /**
23
+ * Snapshot the browser environment for this pageload. Cheap (all sync
24
+ * reads); safe to call on every hello.
25
+ */
26
+ export declare function collectEnv(): VisitorEnv;
27
+ /** Expose to same-origin iframes; called by client.ts after resolving id. */
28
+ export declare function publishVisitorIdToWindow(id: string): void;
@@ -0,0 +1,107 @@
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
+ const VISITOR_ID_KEY = '__hfe_visitor_id__';
14
+ /** Read or lazily create the visitorId. localStorage scope = per-origin. */
15
+ export function getOrCreateVisitorId() {
16
+ try {
17
+ const existing = localStorage.getItem(VISITOR_ID_KEY);
18
+ if (existing && existing.length > 0)
19
+ return existing;
20
+ const id = generateVisitorId();
21
+ localStorage.setItem(VISITOR_ID_KEY, id);
22
+ return id;
23
+ }
24
+ catch {
25
+ // localStorage can throw on iOS Safari private mode / disabled storage.
26
+ // Fall back to an in-memory ephemeral id so events still flow.
27
+ return generateVisitorId();
28
+ }
29
+ }
30
+ function generateVisitorId() {
31
+ try {
32
+ return `v_${crypto.randomUUID()}`;
33
+ }
34
+ catch {
35
+ return `v_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
36
+ }
37
+ }
38
+ /**
39
+ * Same-origin iframe child inherits the parent's visitorId so the journey
40
+ * stitches across micro-frontends. Cross-origin children fall back to their
41
+ * own (a different visitorId — expected, browsers isolate localStorage).
42
+ */
43
+ export function tryInheritVisitorFromParent() {
44
+ if (typeof window === 'undefined' || window.parent === window)
45
+ return undefined;
46
+ try {
47
+ const p = window.parent;
48
+ // Prefer the runtime-exposed global; fall back to parent's localStorage
49
+ // when the parent's runtime hasn't yet exposed the cache (race).
50
+ if (p.__harness_fe_visitor_id__)
51
+ return p.__harness_fe_visitor_id__;
52
+ const fromLs = p.localStorage?.getItem(VISITOR_ID_KEY);
53
+ return fromLs ?? undefined;
54
+ }
55
+ catch {
56
+ // SecurityError when cross-origin — expected, swallow.
57
+ return undefined;
58
+ }
59
+ }
60
+ /**
61
+ * Snapshot the browser environment for this pageload. Cheap (all sync
62
+ * reads); safe to call on every hello.
63
+ */
64
+ export function collectEnv() {
65
+ const screenObj = (typeof screen !== 'undefined' ? screen : undefined);
66
+ const dprNow = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
67
+ const colorScheme = typeof window !== 'undefined' && typeof window.matchMedia === 'function'
68
+ ? window.matchMedia('(prefers-color-scheme: dark)').matches
69
+ ? 'dark'
70
+ : 'light'
71
+ : 'unknown';
72
+ const reducedMotion = typeof window !== 'undefined' && typeof window.matchMedia === 'function'
73
+ ? window.matchMedia('(prefers-reduced-motion: reduce)').matches
74
+ : false;
75
+ return {
76
+ userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
77
+ language: typeof navigator !== 'undefined' ? navigator.language : 'en',
78
+ languages: typeof navigator !== 'undefined' && navigator.languages
79
+ ? Array.from(navigator.languages)
80
+ : [],
81
+ timezone: typeof Intl !== 'undefined' && Intl.DateTimeFormat
82
+ ? (Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC')
83
+ : 'UTC',
84
+ timezoneOffsetMin: new Date().getTimezoneOffset(),
85
+ screen: {
86
+ width: screenObj?.width ?? 0,
87
+ height: screenObj?.height ?? 0,
88
+ dpr: dprNow,
89
+ colorDepth: screenObj?.colorDepth,
90
+ },
91
+ viewport: {
92
+ width: typeof window !== 'undefined' ? window.innerWidth : 0,
93
+ height: typeof window !== 'undefined' ? window.innerHeight : 0,
94
+ },
95
+ colorScheme,
96
+ reducedMotion,
97
+ platform: typeof navigator !== 'undefined' && 'platform' in navigator
98
+ ? navigator.platform
99
+ : undefined,
100
+ };
101
+ }
102
+ /** Expose to same-origin iframes; called by client.ts after resolving id. */
103
+ export function publishVisitorIdToWindow(id) {
104
+ if (typeof window === 'undefined')
105
+ return;
106
+ window.__harness_fe_visitor_id__ = id;
107
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * XMLHttpRequest monkey-patch — captures URL/method/headers/body for
3
+ * request and response WITHOUT replacing the XMLHttpRequest constructor.
4
+ *
5
+ * The previous implementation wrapped `window.XMLHttpRequest` with a new
6
+ * constructor, which broke `xhr instanceof XMLHttpRequest` checks in
7
+ * business code. This patch attaches per-instance metadata via a
8
+ * non-enumerable Symbol key and overrides only prototype methods, leaving
9
+ * the constructor and prototype chain native.
10
+ *
11
+ * Capture rules mirror fetchPatch.ts:
12
+ * - 256 KB body cap with content-type routing (json / text / binary)
13
+ * - Sensitive header redaction (Authorization / Cookie / x-api-key / x-auth-*)
14
+ * - Two events per request keyed by a shared `id` (`phase: 'req' | 'res'`)
15
+ * - Errors inside capture are swallowed via `safeEmit`
16
+ * - `__hfeInternal` opt-out via a magic header `x-hfe-internal: 1`
17
+ * (XHR has no init-style options bag like fetch)
18
+ * - Idempotent install + dispose() restores original prototype methods
19
+ */
20
+ import type { NetworkEntry } from '@harness-fe/protocol';
21
+ export interface XhrPatchOptions {
22
+ onEntry: (entry: NetworkEntry) => void;
23
+ bodyCap?: number;
24
+ denylist?: RegExp[];
25
+ }
26
+ export declare function installXhrPatch(opts: XhrPatchOptions): () => void;