@treelocator/runtime 0.4.7 → 0.6.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.
Files changed (166) hide show
  1. package/.eslintignore +1 -0
  2. package/dist/_generated_styles.d.ts +1 -1
  3. package/dist/_generated_styles.js +20 -0
  4. package/dist/_generated_tree_icon.d.ts +1 -1
  5. package/dist/adapters/HtmlElementTreeNode.d.ts +2 -2
  6. package/dist/adapters/HtmlElementTreeNode.js +4 -6
  7. package/dist/adapters/createTreeNode.js +17 -44
  8. package/dist/adapters/detectFramework.d.ts +8 -0
  9. package/dist/adapters/detectFramework.js +25 -0
  10. package/dist/adapters/detectFramework.test.d.ts +1 -0
  11. package/dist/adapters/detectFramework.test.js +60 -0
  12. package/dist/adapters/jsx/jsxAdapter.js +54 -89
  13. package/dist/adapters/jsx/jsxAdapter.test.d.ts +1 -0
  14. package/dist/adapters/jsx/jsxAdapter.test.js +273 -0
  15. package/dist/adapters/nextjs/parseNextjsDataAttributes.js +1 -1
  16. package/dist/adapters/nextjs/parseNextjsDataAttributes.test.d.ts +1 -0
  17. package/dist/adapters/nextjs/parseNextjsDataAttributes.test.js +158 -0
  18. package/dist/adapters/react/findFiberByHtmlElement.d.ts +1 -1
  19. package/dist/adapters/react/findFiberByHtmlElement.js +1 -1
  20. package/dist/adapters/react/getAllParentsElementsAndRootComponent.js +4 -0
  21. package/dist/adapters/resolveAdapter.d.ts +1 -1
  22. package/dist/adapters/resolveAdapter.js +4 -8
  23. package/dist/adapters/svelte/svelteAdapter.test.d.ts +1 -0
  24. package/dist/adapters/svelte/svelteAdapter.test.js +280 -0
  25. package/dist/adapters/vue/vueAdapter.test.d.ts +1 -0
  26. package/dist/adapters/vue/vueAdapter.test.js +222 -0
  27. package/dist/browserApi.d.ts +148 -0
  28. package/dist/browserApi.js +146 -5
  29. package/dist/browserApi.test.d.ts +1 -0
  30. package/dist/browserApi.test.js +287 -0
  31. package/dist/components/RecordingPillButton.d.ts +11 -0
  32. package/dist/components/RecordingPillButton.js +202 -0
  33. package/dist/components/RecordingResults.d.ts +2 -0
  34. package/dist/components/RecordingResults.js +213 -78
  35. package/dist/components/Runtime.js +161 -554
  36. package/dist/components/SettingsPanel.d.ts +5 -0
  37. package/dist/components/SettingsPanel.js +312 -0
  38. package/dist/consoleCapture.d.ts +9 -0
  39. package/dist/consoleCapture.js +95 -0
  40. package/dist/dejitter/recorder.d.ts +7 -1
  41. package/dist/dejitter/recorder.js +64 -1
  42. package/dist/functions/cssRuleInspector.d.ts +83 -0
  43. package/dist/functions/cssRuleInspector.js +608 -0
  44. package/dist/functions/cssRuleInspector.test.d.ts +1 -0
  45. package/dist/functions/cssRuleInspector.test.js +439 -0
  46. package/dist/functions/deduplicateLabels.test.d.ts +1 -0
  47. package/dist/functions/deduplicateLabels.test.js +178 -0
  48. package/dist/functions/enrichAncestrySourceMaps.js +0 -1
  49. package/dist/functions/extractComputedStyles.d.ts +51 -0
  50. package/dist/functions/extractComputedStyles.js +447 -0
  51. package/dist/functions/extractComputedStyles.test.d.ts +1 -0
  52. package/dist/functions/extractComputedStyles.test.js +549 -0
  53. package/dist/functions/formatAncestryChain.d.ts +8 -0
  54. package/dist/functions/formatAncestryChain.js +21 -1
  55. package/dist/functions/formatAncestryChain.test.js +18 -0
  56. package/dist/functions/getUsableName.test.d.ts +1 -0
  57. package/dist/functions/getUsableName.test.js +219 -0
  58. package/dist/functions/isCombinationModifiersPressed.test.d.ts +1 -0
  59. package/dist/functions/isCombinationModifiersPressed.test.js +192 -0
  60. package/dist/functions/mergeRects.test.js +210 -1
  61. package/dist/functions/namedSnapshots.d.ts +52 -0
  62. package/dist/functions/namedSnapshots.js +161 -0
  63. package/dist/functions/namedSnapshots.test.d.ts +1 -0
  64. package/dist/functions/namedSnapshots.test.js +85 -0
  65. package/dist/functions/normalizeFilePath.test.d.ts +1 -0
  66. package/dist/functions/normalizeFilePath.test.js +66 -0
  67. package/dist/functions/parseDataId.test.d.ts +1 -0
  68. package/dist/functions/parseDataId.test.js +101 -0
  69. package/dist/hooks/getStorage.d.ts +3 -0
  70. package/dist/hooks/getStorage.js +17 -0
  71. package/dist/hooks/useEventListeners.d.ts +15 -0
  72. package/dist/hooks/useEventListeners.js +56 -0
  73. package/dist/hooks/useLocatorStorage.d.ts +18 -0
  74. package/dist/hooks/useLocatorStorage.js +41 -0
  75. package/dist/hooks/useLocatorStorage.test.d.ts +1 -0
  76. package/dist/hooks/useLocatorStorage.test.js +124 -0
  77. package/dist/hooks/useRecordingState.d.ts +43 -0
  78. package/dist/hooks/useRecordingState.js +387 -0
  79. package/dist/hooks/useSettings.d.ts +13 -0
  80. package/dist/hooks/useSettings.js +66 -0
  81. package/dist/index.d.ts +5 -2
  82. package/dist/index.js +4 -2
  83. package/dist/initRuntime.d.ts +3 -1
  84. package/dist/initRuntime.js +4 -1
  85. package/dist/mcpBridge.d.ts +61 -0
  86. package/dist/mcpBridge.js +534 -0
  87. package/dist/mcpBridge.test.d.ts +1 -0
  88. package/dist/mcpBridge.test.js +248 -0
  89. package/dist/output.css +20 -0
  90. package/dist/visualDiff/diff.d.ts +9 -0
  91. package/dist/visualDiff/diff.js +209 -0
  92. package/dist/visualDiff/diff.test.d.ts +1 -0
  93. package/dist/visualDiff/diff.test.js +253 -0
  94. package/dist/visualDiff/settle.d.ts +3 -0
  95. package/dist/visualDiff/settle.js +50 -0
  96. package/dist/visualDiff/settle.test.d.ts +1 -0
  97. package/dist/visualDiff/settle.test.js +65 -0
  98. package/dist/visualDiff/snapshot.d.ts +4 -0
  99. package/dist/visualDiff/snapshot.js +84 -0
  100. package/dist/visualDiff/snapshot.test.d.ts +1 -0
  101. package/dist/visualDiff/snapshot.test.js +245 -0
  102. package/dist/visualDiff/types.d.ts +37 -0
  103. package/dist/visualDiff/types.js +1 -0
  104. package/package.json +2 -2
  105. package/scripts/wrapCSS.js +1 -1
  106. package/scripts/wrapImage.js +1 -1
  107. package/src/_generated_styles.ts +21 -1
  108. package/src/_generated_tree_icon.ts +1 -1
  109. package/src/adapters/HtmlElementTreeNode.ts +10 -7
  110. package/src/adapters/createTreeNode.ts +12 -51
  111. package/src/adapters/detectFramework.test.ts +73 -0
  112. package/src/adapters/detectFramework.ts +28 -0
  113. package/src/adapters/jsx/jsxAdapter.test.ts +240 -0
  114. package/src/adapters/jsx/jsxAdapter.ts +53 -106
  115. package/src/adapters/nextjs/parseNextjsDataAttributes.test.ts +212 -0
  116. package/src/adapters/nextjs/parseNextjsDataAttributes.ts +1 -1
  117. package/src/adapters/react/findDebugSource.ts +5 -6
  118. package/src/adapters/react/findFiberByHtmlElement.ts +3 -3
  119. package/src/adapters/react/getAllParentsElementsAndRootComponent.ts +3 -0
  120. package/src/adapters/react/reactAdapter.ts +1 -2
  121. package/src/adapters/resolveAdapter.ts +4 -14
  122. package/src/adapters/svelte/svelteAdapter.test.ts +334 -0
  123. package/src/adapters/vue/vueAdapter.test.ts +259 -0
  124. package/src/browserApi.test.ts +329 -0
  125. package/src/browserApi.ts +351 -4
  126. package/src/components/RecordingPillButton.tsx +301 -0
  127. package/src/components/RecordingResults.tsx +114 -13
  128. package/src/components/Runtime.tsx +176 -621
  129. package/src/components/SettingsPanel.tsx +339 -0
  130. package/src/consoleCapture.ts +113 -0
  131. package/src/dejitter/recorder.ts +67 -3
  132. package/src/functions/cssRuleInspector.test.ts +517 -0
  133. package/src/functions/cssRuleInspector.ts +708 -0
  134. package/src/functions/deduplicateLabels.test.ts +115 -0
  135. package/src/functions/enrichAncestrySourceMaps.ts +6 -3
  136. package/src/functions/extractComputedStyles.test.ts +681 -0
  137. package/src/functions/extractComputedStyles.ts +768 -0
  138. package/src/functions/formatAncestryChain.test.ts +23 -1
  139. package/src/functions/formatAncestryChain.ts +22 -1
  140. package/src/functions/getUsableName.test.ts +242 -0
  141. package/src/functions/isCombinationModifiersPressed.test.ts +156 -0
  142. package/src/functions/mergeRects.test.ts +111 -1
  143. package/src/functions/namedSnapshots.test.ts +106 -0
  144. package/src/functions/namedSnapshots.ts +232 -0
  145. package/src/functions/normalizeFilePath.test.ts +80 -0
  146. package/src/functions/parseDataId.test.ts +125 -0
  147. package/src/hooks/getStorage.ts +26 -0
  148. package/src/hooks/useEventListeners.ts +97 -0
  149. package/src/hooks/useLocatorStorage.test.ts +127 -0
  150. package/src/hooks/useLocatorStorage.ts +60 -0
  151. package/src/hooks/useRecordingState.ts +516 -0
  152. package/src/hooks/useSettings.ts +83 -0
  153. package/src/index.ts +10 -5
  154. package/src/initRuntime.ts +5 -0
  155. package/src/mcpBridge.test.ts +260 -0
  156. package/src/mcpBridge.ts +677 -0
  157. package/src/visualDiff/diff.test.ts +167 -0
  158. package/src/visualDiff/diff.ts +242 -0
  159. package/src/visualDiff/settle.test.ts +77 -0
  160. package/src/visualDiff/settle.ts +62 -0
  161. package/src/visualDiff/snapshot.test.ts +200 -0
  162. package/src/visualDiff/snapshot.ts +119 -0
  163. package/src/visualDiff/types.ts +40 -0
  164. package/tsconfig.json +3 -1
  165. package/vitest.config.ts +18 -0
  166. package/jest.config.ts +0 -195
@@ -0,0 +1,677 @@
1
+ import type { LocatorJSAPI } from "./browserApi";
2
+ import { getConsoleEntries, installConsoleCapture } from "./consoleCapture";
3
+
4
+ export const MCP_BRIDGE_DEFAULT_URL = "wss://127.0.0.1:7463/treelocator";
5
+ export const MCP_BRIDGE_FALLBACK_URL = "wss://localhost:7463/treelocator";
6
+
7
+ const HEARTBEAT_MS = 20_000;
8
+ const MAX_RECONNECT_MS = 5 * 60_000;
9
+ const QUIET_RETRY_AFTER_FAILURES = 2;
10
+ const QUIET_RETRY_MS = 5 * 60_000;
11
+
12
+ export type BridgeCommandName =
13
+ | "get_path"
14
+ | "get_ancestry"
15
+ | "get_path_data"
16
+ | "get_styles"
17
+ | "get_css_rules"
18
+ | "get_css_report"
19
+ | "take_snapshot"
20
+ | "get_snapshot_diff"
21
+ | "clear_snapshot"
22
+ | "click"
23
+ | "hover"
24
+ | "type"
25
+ | "execute_js"
26
+ | "get_console";
27
+
28
+ export interface HelloMessage {
29
+ type: "hello";
30
+ sessionId: string;
31
+ url: string;
32
+ title: string;
33
+ runtimeVersion: string;
34
+ capabilities: BridgeCommandName[];
35
+ connectedAt: string;
36
+ }
37
+
38
+ export interface CommandRequest {
39
+ type: "command";
40
+ id: string;
41
+ command: BridgeCommandName;
42
+ args?: Record<string, unknown>;
43
+ }
44
+
45
+ export interface CommandResponse {
46
+ type: "response";
47
+ id: string;
48
+ ok: boolean;
49
+ result?: unknown;
50
+ error?: {
51
+ code: string;
52
+ message: string;
53
+ details?: unknown;
54
+ };
55
+ }
56
+
57
+ interface PingMessage {
58
+ type: "ping";
59
+ timestamp: number;
60
+ }
61
+
62
+ interface PongMessage {
63
+ type: "pong";
64
+ timestamp: number;
65
+ }
66
+
67
+ type BridgeInboundMessage = CommandRequest | PongMessage;
68
+ type BridgeOutboundMessage = HelloMessage | CommandResponse | PingMessage;
69
+
70
+ export interface MCPBridgeConfig {
71
+ enabled?: boolean;
72
+ bridgeUrl?: string;
73
+ reconnectMs?: number;
74
+ }
75
+
76
+ class BridgeRuntimeError extends Error {
77
+ readonly code: string;
78
+ readonly details?: unknown;
79
+
80
+ constructor(code: string, message: string, details?: unknown) {
81
+ super(message);
82
+ this.name = "BridgeRuntimeError";
83
+ this.code = code;
84
+ this.details = details;
85
+ }
86
+ }
87
+
88
+ const SESSION_ID_STORAGE_KEY = "treelocator:sessionId";
89
+
90
+ function generateSessionId(): string {
91
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
92
+ return crypto.randomUUID();
93
+ }
94
+ return `tl-${Date.now()}-${Math.random().toString(36).slice(2)}`;
95
+ }
96
+
97
+ function createSessionId(): string {
98
+ // Persist per tab via sessionStorage so reloads keep the same id — the
99
+ // broker accepts rehello on an existing id and just swaps the socket, so
100
+ // MCP callers don't need to re-run connect_session after a page refresh.
101
+ let storage: Storage | null = null;
102
+ try {
103
+ storage = typeof sessionStorage !== "undefined" ? sessionStorage : null;
104
+ } catch {
105
+ storage = null;
106
+ }
107
+
108
+ if (storage) {
109
+ try {
110
+ const existing = storage.getItem(SESSION_ID_STORAGE_KEY);
111
+ if (existing) return existing;
112
+ } catch {
113
+ // Access can throw under strict privacy settings — fall through.
114
+ }
115
+ }
116
+
117
+ const fresh = generateSessionId();
118
+ if (storage) {
119
+ try {
120
+ storage.setItem(SESSION_ID_STORAGE_KEY, fresh);
121
+ } catch {
122
+ // Quota or privacy mode — not fatal, we just won't persist.
123
+ }
124
+ }
125
+ return fresh;
126
+ }
127
+
128
+ function parseMessage(raw: unknown): BridgeInboundMessage | null {
129
+ if (typeof raw !== "string") return null;
130
+ try {
131
+ const parsed = JSON.parse(raw) as BridgeInboundMessage;
132
+ if (!parsed || typeof parsed !== "object") return null;
133
+ if (
134
+ "type" in parsed &&
135
+ (parsed.type === "command" || parsed.type === "pong")
136
+ ) {
137
+ return parsed;
138
+ }
139
+ } catch {
140
+ return null;
141
+ }
142
+ return null;
143
+ }
144
+
145
+ function resolveElement(selector: string, index = 0): HTMLElement {
146
+ let elements: NodeListOf<Element>;
147
+ try {
148
+ elements = document.querySelectorAll(selector);
149
+ } catch {
150
+ throw new BridgeRuntimeError("invalid_selector", `Invalid selector: ${selector}`);
151
+ }
152
+
153
+ const element = elements.item(index);
154
+ if (!(element instanceof HTMLElement)) {
155
+ throw new BridgeRuntimeError(
156
+ "element_not_found",
157
+ `No HTMLElement found for selector "${selector}" at index ${index}`
158
+ );
159
+ }
160
+
161
+ return element;
162
+ }
163
+
164
+ function getTargetArgs(args?: Record<string, unknown>): {
165
+ selector: string;
166
+ index: number;
167
+ } {
168
+ const selector = typeof args?.selector === "string" ? args.selector : "";
169
+ if (!selector) {
170
+ throw new BridgeRuntimeError("invalid_args", "selector is required");
171
+ }
172
+
173
+ const rawIndex = args?.index;
174
+ const index =
175
+ typeof rawIndex === "number" && Number.isInteger(rawIndex) && rawIndex >= 0
176
+ ? rawIndex
177
+ : 0;
178
+
179
+ return { selector, index };
180
+ }
181
+
182
+ function dispatchHover(element: HTMLElement): void {
183
+ const eventWindow = element.ownerDocument.defaultView;
184
+ const MouseEventCtor = eventWindow?.MouseEvent || MouseEvent;
185
+ const eventInit: MouseEventInit = {
186
+ bubbles: true,
187
+ cancelable: true,
188
+ composed: true,
189
+ };
190
+ element.dispatchEvent(new MouseEventCtor("mouseenter", eventInit));
191
+ element.dispatchEvent(new MouseEventCtor("mouseover", eventInit));
192
+ element.dispatchEvent(new MouseEventCtor("mousemove", eventInit));
193
+ }
194
+
195
+ function dispatchType(
196
+ element: HTMLElement,
197
+ text: string,
198
+ submit: boolean
199
+ ): { value: string } {
200
+ if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
201
+ element.focus();
202
+ element.value = text;
203
+ element.dispatchEvent(new Event("input", { bubbles: true, cancelable: true }));
204
+ element.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
205
+
206
+ if (submit) {
207
+ element.dispatchEvent(
208
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true })
209
+ );
210
+ element.dispatchEvent(
211
+ new KeyboardEvent("keyup", { key: "Enter", bubbles: true })
212
+ );
213
+ if (element.form && typeof element.form.requestSubmit === "function") {
214
+ element.form.requestSubmit();
215
+ }
216
+ }
217
+
218
+ return { value: element.value };
219
+ }
220
+
221
+ if (element.isContentEditable) {
222
+ element.focus();
223
+ element.textContent = text;
224
+ element.dispatchEvent(new Event("input", { bubbles: true, cancelable: true }));
225
+ return { value: element.textContent || "" };
226
+ }
227
+
228
+ throw new BridgeRuntimeError(
229
+ "unsupported_target",
230
+ "type target must be input, textarea, or contenteditable"
231
+ );
232
+ }
233
+
234
+ function safeSerialize(value: unknown): unknown {
235
+ const seen = new WeakSet<object>();
236
+ const walk = (v: unknown): unknown => {
237
+ if (v === null || v === undefined) return v ?? null;
238
+ const t = typeof v;
239
+ if (t === "string" || t === "number" || t === "boolean") return v;
240
+ if (t === "bigint") return (v as bigint).toString();
241
+ if (t === "function") {
242
+ const name = (v as { name?: string }).name || "anonymous";
243
+ return `[Function: ${name}]`;
244
+ }
245
+ if (t === "symbol") return (v as symbol).toString();
246
+ if (v instanceof Error) {
247
+ return { name: v.name, message: v.message, stack: v.stack };
248
+ }
249
+ if (typeof Element !== "undefined" && v instanceof Element) {
250
+ const el = v as Element;
251
+ return `[Element: <${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ""}>]`;
252
+ }
253
+ if (t === "object") {
254
+ if (seen.has(v as object)) return "[Circular]";
255
+ seen.add(v as object);
256
+ if (Array.isArray(v)) return v.map(walk);
257
+ const out: Record<string, unknown> = {};
258
+ for (const key of Object.keys(v as object)) {
259
+ try {
260
+ out[key] = walk((v as Record<string, unknown>)[key]);
261
+ } catch (err) {
262
+ out[key] = `[Unreadable: ${(err as Error).message}]`;
263
+ }
264
+ }
265
+ return out;
266
+ }
267
+ return String(v);
268
+ };
269
+ return walk(value);
270
+ }
271
+
272
+ const AsyncFunctionCtor = Object.getPrototypeOf(async function () {}).constructor as {
273
+ new (...args: string[]): (...fnArgs: unknown[]) => Promise<unknown>;
274
+ };
275
+
276
+ async function runUserCode(code: string): Promise<unknown> {
277
+ let fn: (...a: unknown[]) => Promise<unknown>;
278
+ try {
279
+ fn = new AsyncFunctionCtor(code);
280
+ } catch (err) {
281
+ throw new BridgeRuntimeError(
282
+ "compile_error",
283
+ err instanceof Error ? err.message : "Failed to compile code"
284
+ );
285
+ }
286
+ try {
287
+ return await fn();
288
+ } catch (err) {
289
+ throw new BridgeRuntimeError(
290
+ "runtime_error",
291
+ err instanceof Error ? err.message : "Execution failed",
292
+ err instanceof Error ? { stack: err.stack } : undefined
293
+ );
294
+ }
295
+ }
296
+
297
+ export async function executeBridgeCommand(
298
+ api: LocatorJSAPI,
299
+ command: BridgeCommandName,
300
+ args?: Record<string, unknown>
301
+ ): Promise<unknown> {
302
+ switch (command) {
303
+ case "get_path": {
304
+ const { selector } = getTargetArgs(args);
305
+ return await api.getPath(selector);
306
+ }
307
+ case "get_ancestry": {
308
+ const { selector } = getTargetArgs(args);
309
+ return await api.getAncestry(selector);
310
+ }
311
+ case "get_path_data": {
312
+ const { selector } = getTargetArgs(args);
313
+ return await api.getPathData(selector);
314
+ }
315
+ case "get_styles": {
316
+ const { selector } = getTargetArgs(args);
317
+ const options =
318
+ args && typeof args.options === "object" && args.options !== null
319
+ ? (args.options as Record<string, unknown>)
320
+ : undefined;
321
+ return api.getStyles(selector, {
322
+ includeDefaults:
323
+ typeof options?.includeDefaults === "boolean"
324
+ ? options.includeDefaults
325
+ : undefined,
326
+ });
327
+ }
328
+ case "get_css_rules": {
329
+ const { selector } = getTargetArgs(args);
330
+ return api.getCSSRules(selector);
331
+ }
332
+ case "get_css_report": {
333
+ const { selector } = getTargetArgs(args);
334
+ const properties = Array.isArray(args?.properties)
335
+ ? args?.properties.filter((item): item is string => typeof item === "string")
336
+ : undefined;
337
+ return api.getCSSReport(selector, properties ? { properties } : undefined);
338
+ }
339
+ case "take_snapshot": {
340
+ const selector = typeof args?.selector === "string" ? args.selector : "";
341
+ const snapshotId =
342
+ typeof args?.snapshotId === "string" ? args.snapshotId : "";
343
+ if (!selector) {
344
+ throw new BridgeRuntimeError("invalid_args", "selector is required");
345
+ }
346
+ if (!snapshotId) {
347
+ throw new BridgeRuntimeError("invalid_args", "snapshotId is required");
348
+ }
349
+ const index =
350
+ typeof args?.index === "number" && Number.isInteger(args.index) && args.index >= 0
351
+ ? args.index
352
+ : 0;
353
+ const label = typeof args?.label === "string" ? args.label : undefined;
354
+ return api.takeSnapshot(selector, snapshotId, { index, label });
355
+ }
356
+ case "get_snapshot_diff": {
357
+ const snapshotId =
358
+ typeof args?.snapshotId === "string" ? args.snapshotId : "";
359
+ if (!snapshotId) {
360
+ throw new BridgeRuntimeError("invalid_args", "snapshotId is required");
361
+ }
362
+ return api.getSnapshotDiff(snapshotId);
363
+ }
364
+ case "clear_snapshot": {
365
+ const snapshotId =
366
+ typeof args?.snapshotId === "string" ? args.snapshotId : "";
367
+ if (!snapshotId) {
368
+ throw new BridgeRuntimeError("invalid_args", "snapshotId is required");
369
+ }
370
+ api.clearSnapshot(snapshotId);
371
+ return { ok: true };
372
+ }
373
+ case "click": {
374
+ const { selector, index } = getTargetArgs(args);
375
+ const element = resolveElement(selector, index);
376
+ if (typeof element.click === "function") {
377
+ element.click();
378
+ } else {
379
+ const eventWindow = element.ownerDocument.defaultView;
380
+ const MouseEventCtor = eventWindow?.MouseEvent || MouseEvent;
381
+ element.dispatchEvent(
382
+ new MouseEventCtor("click", {
383
+ bubbles: true,
384
+ cancelable: true,
385
+ composed: true,
386
+ })
387
+ );
388
+ }
389
+ return { ok: true };
390
+ }
391
+ case "hover": {
392
+ const { selector, index } = getTargetArgs(args);
393
+ const element = resolveElement(selector, index);
394
+ dispatchHover(element);
395
+ return { ok: true };
396
+ }
397
+ case "type": {
398
+ const { selector, index } = getTargetArgs(args);
399
+ const element = resolveElement(selector, index);
400
+ const text = typeof args?.text === "string" ? args.text : "";
401
+ if (!text) {
402
+ throw new BridgeRuntimeError("invalid_args", "text is required for type");
403
+ }
404
+ const submit = typeof args?.submit === "boolean" ? args.submit : false;
405
+ return dispatchType(element, text, submit);
406
+ }
407
+ case "execute_js": {
408
+ const code = typeof args?.code === "string" ? args.code : "";
409
+ if (!code) {
410
+ throw new BridgeRuntimeError("invalid_args", "code is required for execute_js");
411
+ }
412
+ const result = await runUserCode(code);
413
+ return {
414
+ type: result === null ? "null" : typeof result,
415
+ value: safeSerialize(result),
416
+ };
417
+ }
418
+ case "get_console": {
419
+ const rawLast = args?.last;
420
+ const last =
421
+ typeof rawLast === "number" && Number.isFinite(rawLast) && rawLast > 0
422
+ ? Math.floor(rawLast)
423
+ : undefined;
424
+ const captured = getConsoleEntries(last);
425
+ return { count: captured.length, entries: captured };
426
+ }
427
+ default:
428
+ throw new BridgeRuntimeError("unsupported_command", `Unsupported command: ${command}`);
429
+ }
430
+ }
431
+
432
+ function getBridgeUrls(config?: MCPBridgeConfig): string[] {
433
+ const urls: string[] = [];
434
+
435
+ if (!config?.bridgeUrl) {
436
+ urls.push(MCP_BRIDGE_DEFAULT_URL, MCP_BRIDGE_FALLBACK_URL);
437
+ return urls;
438
+ }
439
+
440
+ urls.push(config.bridgeUrl);
441
+ try {
442
+ const parsed = new URL(config.bridgeUrl);
443
+ if (parsed.hostname === "127.0.0.1") {
444
+ parsed.hostname = "localhost";
445
+ urls.push(parsed.toString());
446
+ } else if (parsed.hostname === "localhost") {
447
+ parsed.hostname = "127.0.0.1";
448
+ urls.push(parsed.toString());
449
+ }
450
+ } catch {
451
+ // Keep the configured URL only when parsing fails.
452
+ }
453
+
454
+ return urls;
455
+ }
456
+
457
+ export class TreeLocatorMCPBridgeClient {
458
+ private readonly config: MCPBridgeConfig;
459
+ private readonly sessionId: string;
460
+ private readonly runtimeVersion: string;
461
+ private readonly getApi: () => LocatorJSAPI | undefined;
462
+
463
+ private socket: WebSocket | null = null;
464
+ private heartbeatTimer: number | null = null;
465
+ private reconnectTimer: number | null = null;
466
+ private reconnectAttempts = 0;
467
+ private consecutiveFailures = 0;
468
+ private urlIndex = 0;
469
+ private stopped = false;
470
+
471
+ constructor(
472
+ getApi: () => LocatorJSAPI | undefined,
473
+ config?: MCPBridgeConfig,
474
+ runtimeVersion = "unknown"
475
+ ) {
476
+ this.getApi = getApi;
477
+ this.config = config || {};
478
+ this.sessionId = createSessionId();
479
+ this.runtimeVersion = runtimeVersion;
480
+ }
481
+
482
+ start(): void {
483
+ if (this.config.enabled === false) return;
484
+ if (typeof window === "undefined" || typeof WebSocket === "undefined") return;
485
+ this.connect();
486
+ }
487
+
488
+ stop(): void {
489
+ this.stopped = true;
490
+ this.clearTimers();
491
+ if (this.socket) {
492
+ this.socket.close();
493
+ this.socket = null;
494
+ }
495
+ }
496
+
497
+ private connect(): void {
498
+ if (this.stopped || this.config.enabled === false || this.socket) return;
499
+
500
+ const urls = getBridgeUrls(this.config);
501
+ const url = urls[this.urlIndex % urls.length];
502
+ if (!url) return;
503
+
504
+ const socket = new WebSocket(url);
505
+ this.socket = socket;
506
+
507
+ socket.addEventListener("open", () => {
508
+ this.reconnectAttempts = 0;
509
+ this.consecutiveFailures = 0;
510
+ this.sendHello();
511
+ this.startHeartbeat();
512
+ });
513
+
514
+ socket.addEventListener("message", (event) => {
515
+ void this.handleMessage(event.data);
516
+ });
517
+
518
+ socket.addEventListener("close", () => {
519
+ this.socket = null;
520
+ this.clearHeartbeat();
521
+ this.scheduleReconnect();
522
+ });
523
+
524
+ socket.addEventListener("error", () => {
525
+ socket.close();
526
+ });
527
+ }
528
+
529
+ private sendMessage(message: BridgeOutboundMessage): void {
530
+ if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return;
531
+ this.socket.send(JSON.stringify(message));
532
+ }
533
+
534
+ private sendHello(): void {
535
+ const hello: HelloMessage = {
536
+ type: "hello",
537
+ sessionId: this.sessionId,
538
+ url: window.location.href,
539
+ title: document.title || "",
540
+ runtimeVersion: this.runtimeVersion,
541
+ capabilities: [
542
+ "get_path",
543
+ "get_ancestry",
544
+ "get_path_data",
545
+ "get_styles",
546
+ "get_css_rules",
547
+ "get_css_report",
548
+ "take_snapshot",
549
+ "get_snapshot_diff",
550
+ "clear_snapshot",
551
+ "click",
552
+ "hover",
553
+ "type",
554
+ "execute_js",
555
+ "get_console",
556
+ ],
557
+ connectedAt: new Date().toISOString(),
558
+ };
559
+ this.sendMessage(hello);
560
+ }
561
+
562
+ private async handleMessage(data: unknown): Promise<void> {
563
+ const message = parseMessage(data);
564
+ if (!message) return;
565
+
566
+ if (message.type === "pong") {
567
+ return;
568
+ }
569
+
570
+ const api = this.getApi();
571
+ if (!api) {
572
+ this.sendMessage({
573
+ type: "response",
574
+ id: message.id,
575
+ ok: false,
576
+ error: {
577
+ code: "api_unavailable",
578
+ message: "window.__treelocator__ is not available",
579
+ },
580
+ });
581
+ return;
582
+ }
583
+
584
+ try {
585
+ const result = await executeBridgeCommand(api, message.command, message.args);
586
+ this.sendMessage({
587
+ type: "response",
588
+ id: message.id,
589
+ ok: true,
590
+ result,
591
+ });
592
+ } catch (error) {
593
+ const bridgeError =
594
+ error instanceof BridgeRuntimeError
595
+ ? error
596
+ : new BridgeRuntimeError(
597
+ "runtime_error",
598
+ error instanceof Error ? error.message : "Unknown bridge error"
599
+ );
600
+
601
+ this.sendMessage({
602
+ type: "response",
603
+ id: message.id,
604
+ ok: false,
605
+ error: {
606
+ code: bridgeError.code,
607
+ message: bridgeError.message,
608
+ details: bridgeError.details,
609
+ },
610
+ });
611
+ }
612
+ }
613
+
614
+ private startHeartbeat(): void {
615
+ this.clearHeartbeat();
616
+ this.heartbeatTimer = window.setInterval(() => {
617
+ this.sendMessage({
618
+ type: "ping",
619
+ timestamp: Date.now(),
620
+ });
621
+ }, HEARTBEAT_MS);
622
+ }
623
+
624
+ private clearHeartbeat(): void {
625
+ if (this.heartbeatTimer !== null) {
626
+ window.clearInterval(this.heartbeatTimer);
627
+ this.heartbeatTimer = null;
628
+ }
629
+ }
630
+
631
+ private scheduleReconnect(): void {
632
+ if (this.stopped || this.config.enabled === false || this.reconnectTimer !== null) {
633
+ return;
634
+ }
635
+
636
+ const baseReconnect = this.config.reconnectMs && this.config.reconnectMs > 0
637
+ ? this.config.reconnectMs
638
+ : 1_000;
639
+ const delay =
640
+ this.consecutiveFailures >= QUIET_RETRY_AFTER_FAILURES
641
+ ? QUIET_RETRY_MS
642
+ : Math.min(
643
+ baseReconnect * Math.pow(2, this.reconnectAttempts),
644
+ MAX_RECONNECT_MS
645
+ );
646
+ this.reconnectAttempts += 1;
647
+ this.consecutiveFailures += 1;
648
+ this.urlIndex += 1;
649
+
650
+ this.reconnectTimer = window.setTimeout(() => {
651
+ this.reconnectTimer = null;
652
+ this.connect();
653
+ }, delay);
654
+ }
655
+
656
+ private clearTimers(): void {
657
+ this.clearHeartbeat();
658
+ if (this.reconnectTimer !== null) {
659
+ window.clearTimeout(this.reconnectTimer);
660
+ this.reconnectTimer = null;
661
+ }
662
+ }
663
+ }
664
+
665
+ export function startMCPBridge(config?: MCPBridgeConfig): TreeLocatorMCPBridgeClient | null {
666
+ if (config?.enabled === false) return null;
667
+ if (typeof window === "undefined") return null;
668
+
669
+ installConsoleCapture();
670
+
671
+ const client = new TreeLocatorMCPBridgeClient(
672
+ () => (window as Window & { __treelocator__?: LocatorJSAPI }).__treelocator__,
673
+ config
674
+ );
675
+ client.start();
676
+ return client;
677
+ }