@xnng/browser-relay 1.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 (79) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +445 -0
  3. package/docs/README.zh-CN.md +421 -0
  4. package/docs/benchmarks/browser-gaps-codex-ax.json +418 -0
  5. package/docs/benchmarks/browser-gaps-relay-after.json +655 -0
  6. package/docs/benchmarks/browser-gaps-relay-baseline.json +679 -0
  7. package/docs/benchmarks/browser-readiness-cost.json +106 -0
  8. package/docs/benchmarks/browser-runtime-balanced-headed.json +412 -0
  9. package/docs/benchmarks/browser-runtime-balanced-rtt50.json +412 -0
  10. package/docs/benchmarks/browser-runtime-balanced.json +412 -0
  11. package/docs/benchmarks/browser-runtime-rtt50.json +194 -0
  12. package/docs/benchmarks/browser-runtime.json +254 -0
  13. package/docs/benchmarks/browser-use-parity.json +54 -0
  14. package/docs/benchmarks/codex-extension-audit.json +131 -0
  15. package/docs/benchmarks/codex-native-protocol.md +69 -0
  16. package/docs/benchmarks/codex-native-replay.js +116 -0
  17. package/docs/benchmarks/codex-native-status.json +81 -0
  18. package/docs/benchmarks/codex-native.json +1003 -0
  19. package/docs/benchmarks/extension-sessions.png +0 -0
  20. package/docs/benchmarks/extension-tasks.png +0 -0
  21. package/docs/benchmarks/iframe-routing-regression.json +37 -0
  22. package/docs/browser-use-comparison.md +331 -0
  23. package/docs/browser-use-gap-audit.md +141 -0
  24. package/docs/browser-use-parity.md +105 -0
  25. package/docs/demo/intranet.html +103 -0
  26. package/docs/releases/v1.5.0.md +59 -0
  27. package/docs/releases/v1.5.1.md +16 -0
  28. package/docs/releases/v1.5.2.md +42 -0
  29. package/docs/releases/v1.5.3.md +33 -0
  30. package/docs/releases/v1.5.4.md +42 -0
  31. package/docs/releases/v1.6.0.md +16 -0
  32. package/docs/remote-control-hub.md +523 -0
  33. package/extension/activity.js +328 -0
  34. package/extension/automation.js +1839 -0
  35. package/extension/background.js +1854 -0
  36. package/extension/i18n.js +149 -0
  37. package/extension/icons/icon128.png +0 -0
  38. package/extension/icons/icon16.png +0 -0
  39. package/extension/icons/icon32.png +0 -0
  40. package/extension/icons/icon48.png +0 -0
  41. package/extension/manifest.json +47 -0
  42. package/extension/observations.js +109 -0
  43. package/extension/options.html +289 -0
  44. package/extension/options.js +269 -0
  45. package/extension/popup.html +74 -0
  46. package/extension/popup.js +105 -0
  47. package/extension/protocol.js +45 -0
  48. package/extension/remote-auth.js +18 -0
  49. package/extension/sessions.js +134 -0
  50. package/extension/snapshot.js +161 -0
  51. package/extension/task-groups.js +108 -0
  52. package/extension/tasks.js +186 -0
  53. package/extension/wait.js +89 -0
  54. package/hub/README.md +42 -0
  55. package/hub/package-lock.json +1544 -0
  56. package/hub/package.json +13 -0
  57. package/hub/src/rpc.js +41 -0
  58. package/hub/src/worker.js +322 -0
  59. package/hub/wrangler.example.toml +19 -0
  60. package/package.json +83 -0
  61. package/server/cdp-bridge.js +200 -0
  62. package/server/cli.js +1798 -0
  63. package/server/hub-server.js +258 -0
  64. package/server/install.js +250 -0
  65. package/server/mcp-server.js +504 -0
  66. package/server/npx-runner.js +96 -0
  67. package/server/relay-server.js +1356 -0
  68. package/server/remote-protocol.js +76 -0
  69. package/server/runtime-worker.js +166 -0
  70. package/server/script-runtime.js +189 -0
  71. package/server/sdk.js +307 -0
  72. package/server/service-state.js +103 -0
  73. package/server/snapshot.js +161 -0
  74. package/server/uninstall.js +61 -0
  75. package/server/windows-service-entry.js +58 -0
  76. package/server/windows-service.js +360 -0
  77. package/skills/browser-relay/SKILL.md +192 -0
  78. package/skills/browser-relay/references/legacy-api.md +163 -0
  79. package/skills/browser-relay/references/runtime.md +240 -0
@@ -0,0 +1,1839 @@
1
+ import { createTaskQueue, TaskError, checkCancelled, pause } from "./tasks.js";
2
+ import { observationRecords, textPage } from "./observations.js";
3
+ import { createSessions } from "./sessions.js";
4
+ import { PROTOCOL_VERSION, FEATURES } from "./protocol.js";
5
+
6
+ const valueOf = (v) => v?.value;
7
+ const compact = (s, n = 180) =>
8
+ String(s ?? "")
9
+ .replace(/\s+/g, " ")
10
+ .trim()
11
+ .slice(0, n);
12
+ const integer = (n, fallback, min, max) => {
13
+ if (n === undefined) return fallback;
14
+ if (!Number.isInteger(n) || n < min || n > max)
15
+ throw new TaskError("invalid_request", `Expected integer ${min}–${max}`);
16
+ return n;
17
+ };
18
+ const fail = (code, message, status) => {
19
+ throw new TaskError(code, message, status);
20
+ };
21
+ const ACTIONS = new Set([
22
+ "click",
23
+ "double_click",
24
+ "hover",
25
+ "move",
26
+ "drag",
27
+ "type",
28
+ "fill",
29
+ "key",
30
+ "scroll",
31
+ "wait",
32
+ "select",
33
+ "check",
34
+ "navigate",
35
+ "focus",
36
+ ]);
37
+ const NODE_FUNCTION = `function(operation, args) {
38
+ if (!this.isConnected) return {error:'stale_ref'};
39
+ const view = this.ownerDocument.defaultView;
40
+ const element = this.nodeType===1 ? this : this.parentElement;
41
+ const hitTest = (x,y) => {
42
+ let target=element;
43
+ for(;;){
44
+ const root=target.getRootNode();
45
+ const hit=root.elementFromPoint?.(x,y);
46
+ if(!hit || (hit!==target && !target.contains(hit)))return false;
47
+ if(!root.host)return true;
48
+ target=root.host;
49
+ }
50
+ };
51
+ if (operation === 'hitTest') return {hittable:hitTest(args.x,args.y)};
52
+ if (operation === 'prepare') {
53
+ const chain=[];
54
+ for(let n=element;n;n=n.parentElement||n.getRootNode().host)chain.push([n,n.scrollLeft,n.scrollTop]);
55
+ const beforeX=view.scrollX, beforeY=view.scrollY;
56
+ element.scrollIntoView({block:'center', inline:'center', behavior:'instant'});
57
+ const r = element.getBoundingClientRect(), s = view.getComputedStyle(element);
58
+ const visible = r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none';
59
+ return {visible, disabled:element.matches(':disabled') || !!element.closest('[aria-disabled="true"]'),
60
+ obscured:!hitTest(r.x+r.width/2,r.y+r.height/2), x:r.x+r.width/2, y:r.y+r.height/2, width:r.width, height:r.height, clientLeft:element.clientLeft, clientTop:element.clientTop,
61
+ scrolled:beforeX!==view.scrollX||beforeY!==view.scrollY||chain.some(([n,x,y])=>n.scrollLeft!==x||n.scrollTop!==y),
62
+ background:view.document.visibilityState==='hidden'};
63
+ }
64
+ if (operation === 'inspect') {
65
+ const r=element.getBoundingClientRect(), s=view.getComputedStyle(element);
66
+ return {visible:r.width>0 && r.height>0 && s.display!=='none' && s.visibility!=='hidden',
67
+ enabled:!element.matches(':disabled') && !element.closest('[aria-disabled="true"]'),
68
+ value:this.type==='password'?'[redacted]':this.value, text:(this.innerText||'').slice(0,400)};
69
+ }
70
+ if (operation === 'click') { if(typeof element.click!=='function')return false;element.click(); return true; }
71
+ if (operation === 'scroll') {element.scrollBy({left:args.deltaX||0,top:args.deltaY||0,behavior:'instant'});return {scrollLeft:element.scrollLeft,scrollTop:element.scrollTop};}
72
+ if (operation === 'focus') {
73
+ const editable=this.isContentEditable || this.tagName==='TEXTAREA' || (this.tagName==='INPUT' && ['text','search','email','tel','url','password','number'].includes(this.type));
74
+ if(!editable || this.readOnly) return {error:'not_editable'};
75
+ this.focus();
76
+ const active=this.getRootNode().activeElement;
77
+ if(active!==this && !this.contains(active)) return {error:'focus_failed'};
78
+ if (args.clear) {
79
+ if (typeof this.select==='function') this.select();
80
+ else if (this.isContentEditable) { const r=view.document.createRange(); r.selectNodeContents(this); const s=view.getSelection();s.removeAllRanges();s.addRange(r); }
81
+ else return {error:'not_editable'};
82
+ }
83
+ return true;
84
+ }
85
+ if (operation === 'select') {
86
+ if (this.tagName!=='SELECT') return {error:'not_select'};
87
+ const values=Array.isArray(args.value)?args.value:[args.value];
88
+ if (values.some(v=>!Array.from(this.options).some(o=>o.value===v))) return {error:'option_not_found'};
89
+ if (!this.multiple && values.length>1) return {error:'not_multiple_select'};
90
+ if (Array.from(this.options).some(o=>values.includes(o.value) && (o.disabled||o.parentElement?.tagName==='OPTGROUP'&&o.parentElement.disabled))) return {error:'option_disabled'};
91
+ for (const option of this.options) option.selected=values.includes(option.value);
92
+ this.dispatchEvent(new view.Event('input',{bubbles:true}));this.dispatchEvent(new view.Event('change',{bubbles:true}));
93
+ return {selected:Array.from(this.selectedOptions).map(o=>o.value)};
94
+ }
95
+ if (operation === 'checkState') {
96
+ if (['checkbox','radio'].includes(this.type)) return {checked:this.checked,radio:this.type==='radio'};
97
+ const role=this.getAttribute('role'), checked=this.getAttribute('aria-checked');
98
+ if (!['checkbox','radio','switch'].includes(role)||!['true','false','mixed'].includes(checked)) return {error:'not_checkable'};
99
+ return {checked:checked==='mixed'?null:checked==='true',radio:role==='radio'};
100
+ }
101
+ }`;
102
+
103
+ // A hidden tab can scroll its DOM without activating the Chrome tab/window.
104
+ // Resolve the container under the pointer so nested panels keep their meaning.
105
+ function scrollAtPoint({ x, y, deltaX = 0, deltaY = 0 }) {
106
+ let element = document.elementFromPoint(x, y);
107
+ while (element?.shadowRoot) {
108
+ const inner = element.shadowRoot.elementFromPoint(x, y);
109
+ if (!inner || inner === element) break;
110
+ element = inner;
111
+ }
112
+ if (element?.matches('iframe, frame'))
113
+ return { error: 'frame_target_required' };
114
+ const root = document.scrollingElement;
115
+ for (let node = element; node && node !== root; node = node.parentElement || node.getRootNode().host) {
116
+ const style = getComputedStyle(node);
117
+ const scrollY = deltaY && node.scrollHeight > node.clientHeight && /auto|scroll|overlay/.test(style.overflowY);
118
+ const scrollX = deltaX && node.scrollWidth > node.clientWidth && /auto|scroll|overlay/.test(style.overflowX);
119
+ if (scrollX || scrollY) {
120
+ node.scrollBy({ left: deltaX, top: deltaY, behavior: 'instant' });
121
+ return { scrollLeft: node.scrollLeft, scrollTop: node.scrollTop };
122
+ }
123
+ }
124
+ window.scrollBy({ left: deltaX, top: deltaY, behavior: 'instant' });
125
+ return { scrollLeft: window.scrollX, scrollTop: window.scrollY };
126
+ }
127
+
128
+ /** Browser-side executor. Local HTTP and remote hub both reach this instance. */
129
+ export function createAutomation({
130
+ send,
131
+ resolveTab,
132
+ createTab,
133
+ closeTab,
134
+ listTabs,
135
+ taskGroups,
136
+ focusTab,
137
+ prepareTab = async () => {},
138
+ runtimeInfo = () => ({}),
139
+ publicTabId = (id) => id,
140
+ onActivity = () => () => {},
141
+ }) {
142
+ const states = new Map(),
143
+ children = new Map(),
144
+ queue = createTaskQueue();
145
+ const sessions = createSessions({
146
+ cancelTab: queue.cancelTab,
147
+ cancelSession: queue.cancelSession,
148
+ active: queue.active,
149
+ });
150
+ const heldInputs = new Map();
151
+ const signals = new Map(),
152
+ jobOrigins = new Map(),
153
+ sessionOrigins = new Map();
154
+ function state(tabId) {
155
+ if (!states.has(tabId))
156
+ states.set(tabId, {
157
+ refs: new Map(),
158
+ nodes: new Map(),
159
+ ancestors: new Map(),
160
+ next: 0,
161
+ prefix: crypto.randomUUID().slice(0, 8),
162
+ baselines: new Map(),
163
+ shots: new Map(),
164
+ sessions: new Map(),
165
+ documents: new Map(),
166
+ });
167
+ return states.get(tabId);
168
+ }
169
+ function invalidate(tabId) {
170
+ states.delete(tabId);
171
+ }
172
+ async function cdp(tabId, method, params = {}, sessionId) {
173
+ const signal = signals.get(tabId);
174
+ checkCancelled(signal);
175
+ if (
176
+ signal &&
177
+ ["Input.dispatchMouseEvent", "Input.dispatchKeyEvent"].includes(method)
178
+ ) {
179
+ if (!heldInputs.has(tabId)) heldInputs.set(tabId, new Map());
180
+ const held = heldInputs.get(tabId),
181
+ key =
182
+ method === "Input.dispatchMouseEvent"
183
+ ? `mouse:${params.button}`
184
+ : `key:${params.key}`;
185
+ if (["mousePressed", "keyDown", "rawKeyDown"].includes(params.type))
186
+ held.set(key, { method, params });
187
+ }
188
+ if (!signal) return send(tabId, method, params, sessionId);
189
+ const held = heldInputs.get(tabId);
190
+ return new Promise((resolve, reject) => {
191
+ const abort = () =>
192
+ reject(
193
+ new TaskError(
194
+ "task_cancelled",
195
+ "Task cancelled; dispatched input is not replayed",
196
+ 409,
197
+ ),
198
+ );
199
+ signal.addEventListener("abort", abort, { once: true });
200
+ Promise.resolve()
201
+ .then(() => {
202
+ checkCancelled(signal);
203
+ return send(tabId, method, params, sessionId);
204
+ })
205
+ .then((result) => {
206
+ if (["mouseReleased", "keyUp"].includes(params.type))
207
+ held?.delete(
208
+ method === "Input.dispatchMouseEvent"
209
+ ? `mouse:${params.button}`
210
+ : `key:${params.key}`,
211
+ );
212
+ resolve(result);
213
+ }, reject)
214
+ .finally(() => signal.removeEventListener("abort", abort));
215
+ });
216
+ }
217
+ async function releaseInputs(tabId) {
218
+ const held = heldInputs.get(tabId);
219
+ heldInputs.delete(tabId);
220
+ if (!held?.size) return;
221
+ await Promise.allSettled(
222
+ [...held.values()].reverse().map(({ method, params }) => {
223
+ const { text, ...rest } = params;
224
+ return send(tabId, method, {
225
+ ...rest,
226
+ type:
227
+ method === "Input.dispatchMouseEvent" ? "mouseReleased" : "keyUp",
228
+ });
229
+ }),
230
+ );
231
+ }
232
+ async function evaluate(tabId, expression, sessionId) {
233
+ const r = await cdp(
234
+ tabId,
235
+ "Runtime.evaluate",
236
+ { expression, returnByValue: true, awaitPromise: true },
237
+ sessionId,
238
+ );
239
+ if (r.exceptionDetails)
240
+ fail(
241
+ "evaluation_failed",
242
+ r.exceptionDetails.text || "Page evaluation failed",
243
+ 422,
244
+ );
245
+ return r.result?.value;
246
+ }
247
+ const metadataExpression = `({url:location.href,title:document.title,viewport:{width:innerWidth,height:innerHeight,scrollX,scrollY,dpr:devicePixelRatio},readyState:document.readyState,background:document.visibilityState==='hidden'})`;
248
+ async function frameSession(tabId, frameId) {
249
+ const st = state(tabId);
250
+ const child = children.get(tabId)?.get(frameId);
251
+ if (child) return child.sessionId;
252
+ if (st.sessions.has(frameId)) return st.sessions.get(frameId);
253
+ const attached = await cdp(tabId, "Target.attachToTarget", {
254
+ targetId: frameId,
255
+ flatten: true,
256
+ });
257
+ st.sessions.set(frameId, attached.sessionId);
258
+ return attached.sessionId;
259
+ }
260
+ async function tree(tabId, includeTarget) {
261
+ const st = state(tabId);
262
+ {
263
+ await cdp(tabId, "Target.setAutoAttach", {
264
+ autoAttach: true,
265
+ waitForDebuggerOnStart: false,
266
+ flatten: true,
267
+ });
268
+ }
269
+ const [meta, frames] = await Promise.all([
270
+ evaluate(tabId, metadataExpression),
271
+ cdp(tabId, "Page.getFrameTree"),
272
+ ]);
273
+ const nodes = [],
274
+ warnings = [],
275
+ frameInfo = [];
276
+ const redundantRefs = new Set();
277
+ const rendererDOM = new Map();
278
+ async function urlsFor(sessionId) {
279
+ const key = sessionId || "main";
280
+ if (!rendererDOM.has(key))
281
+ rendererDOM.set(
282
+ key,
283
+ (async () => {
284
+ const result = await cdp(
285
+ tabId,
286
+ "DOMSnapshot.captureSnapshot",
287
+ { computedStyles: [] },
288
+ sessionId,
289
+ );
290
+ const urls = new Map(),
291
+ strings = result.strings;
292
+ for (const doc of result.documents) {
293
+ const base = strings[doc.baseURL] || strings[doc.documentURL];
294
+ for (let i = 0; i < doc.nodes.backendNodeId.length; i++) {
295
+ const attrs = doc.nodes.attributes[i] || [];
296
+ for (let j = 0; j < attrs.length; j += 2)
297
+ if (strings[attrs[j]] === "href") {
298
+ try {
299
+ const url = new URL(strings[attrs[j + 1]], base);
300
+ if (
301
+ ["http:", "https:", "mailto:", "tel:"].includes(
302
+ url.protocol,
303
+ )
304
+ )
305
+ urls.set(doc.nodes.backendNodeId[i], url.href);
306
+ } catch {}
307
+ }
308
+ }
309
+ }
310
+ return urls;
311
+ })().catch((error) => {
312
+ warnings.push({
313
+ code: "link_metadata_unavailable",
314
+ message: error.message,
315
+ });
316
+ return new Map();
317
+ }),
318
+ );
319
+ return rendererDOM.get(key);
320
+ }
321
+ const visit = async (entry, parent, forcedSession) => {
322
+ const frameId = entry.frame.id;
323
+ let result,
324
+ sessionId = forcedSession;
325
+ try {
326
+ result = await cdp(
327
+ tabId,
328
+ "Accessibility.getFullAXTree",
329
+ { frameId },
330
+ sessionId,
331
+ );
332
+ } catch (error) {
333
+ try {
334
+ sessionId = await frameSession(tabId, frameId);
335
+ result = await cdp(
336
+ tabId,
337
+ "Accessibility.getFullAXTree",
338
+ {},
339
+ sessionId,
340
+ );
341
+ } catch (inner) {
342
+ warnings.push({
343
+ frameId,
344
+ code: "frame_unavailable",
345
+ message: inner.message,
346
+ });
347
+ }
348
+ }
349
+ frameInfo.push({
350
+ id: frameId,
351
+ parentId: parent,
352
+ url: entry.frame.url,
353
+ sessionId,
354
+ });
355
+ const urls = (result?.nodes || []).some((n) => valueOf(n.role) === "link")
356
+ ? await urlsFor(sessionId)
357
+ : new Map();
358
+ const axNodes = new Map(
359
+ (result?.nodes || []).map((node) => [node.nodeId, node]),
360
+ );
361
+ // CDP returns nodes breadth first. Flatten only after walking childIds,
362
+ // otherwise children of omitted paragraphs move behind later siblings.
363
+ const ordered = [], seen = new Set();
364
+ const walk = (node) => {
365
+ if (!node || seen.has(node.nodeId)) return;
366
+ seen.add(node.nodeId);
367
+ ordered.push(node);
368
+ for (const id of node.childIds || []) walk(axNodes.get(id));
369
+ };
370
+ for (const node of axNodes.values())
371
+ if (!axNodes.has(node.parentId)) walk(node);
372
+ for (const node of axNodes.values()) walk(node);
373
+ for (const node of ordered) {
374
+ const selectedRoot = includeTarget && includeTarget.backendId === node.backendDOMNodeId &&
375
+ frameId === (includeTarget.frameId || frames.frameTree.frame.id);
376
+ if (node.ignored && !selectedRoot) continue;
377
+ const role = valueOf(node.role),
378
+ name = String(valueOf(node.name) || "")
379
+ .replace(/\s+/g, " ")
380
+ .trim()
381
+ .replace(role === "RootWebArea" ? /^[🔵⚪]+\s*/u : /$^/, "");
382
+ if (
383
+ [
384
+ "none",
385
+ "generic",
386
+ "InlineTextBox",
387
+ "LabelText",
388
+ "paragraph",
389
+ "Legend",
390
+ "MenuListPopup",
391
+ ].includes(role) &&
392
+ !name && !selectedRoot
393
+ )
394
+ continue;
395
+ if (role === "InlineTextBox") continue;
396
+ const backendId = node.backendDOMNodeId;
397
+ const key = `${frameId}:${backendId}`;
398
+ const ancestry = [];
399
+ for (
400
+ let parent = axNodes.get(node.parentId), depth = 0;
401
+ parent && depth++ < 100;
402
+ parent = axNodes.get(parent.parentId)
403
+ )
404
+ if (parent.backendDOMNodeId)
405
+ ancestry.push(`${frameId}:${parent.backendDOMNodeId}`);
406
+ st.ancestors.set(key, ancestry);
407
+ let ref;
408
+ if (backendId) {
409
+ ref = st.nodes.get(key);
410
+ if (!ref) {
411
+ ref = `e${st.prefix}_${++st.next}`;
412
+ st.nodes.set(key, ref);
413
+ }
414
+ st.refs.set(ref, { backendId, frameId, sessionId });
415
+ }
416
+ if (role === "StaticText" && ref) {
417
+ let ancestor = axNodes.get(node.parentId),
418
+ depth = 0;
419
+ while (ancestor && depth++ < 100) {
420
+ if (
421
+ ["button", "link", "heading", "option"].includes(
422
+ valueOf(ancestor.role),
423
+ ) &&
424
+ String(valueOf(ancestor.name) || "").replace(/\s+/g, " ").trim() === name
425
+ ) {
426
+ redundantRefs.add(ref);
427
+ break;
428
+ }
429
+ ancestor = axNodes.get(ancestor.parentId);
430
+ }
431
+ }
432
+ const properties = Object.fromEntries(
433
+ (node.properties || [])
434
+ .filter((p) =>
435
+ [
436
+ "disabled",
437
+ "checked",
438
+ "selected",
439
+ "expanded",
440
+ "required",
441
+ "focused",
442
+ "level",
443
+ ].includes(p.name),
444
+ )
445
+ .map((p) => [p.name, valueOf(p.value)]),
446
+ );
447
+ // AX may expose password values depending on browser/platform; never emit them.
448
+ const val =
449
+ role === "textbox" || role === "searchbox"
450
+ ? undefined
451
+ : valueOf(node.value);
452
+ nodes.push({
453
+ ref,
454
+ role,
455
+ name,
456
+ ...(val !== undefined && val !== "" ? { value: val } : {}),
457
+ ...(urls.has(backendId) ? { url: urls.get(backendId) } : {}),
458
+ ...properties,
459
+ frameId,
460
+ });
461
+ }
462
+ for (const child of entry.childFrames || [])
463
+ await visit(child, frameId, sessionId);
464
+ };
465
+ await visit(frames.frameTree);
466
+ for (const child of children.get(tabId)?.values() || []) {
467
+ if (
468
+ child.targetInfo.type !== "iframe" ||
469
+ frameInfo.some((f) => f.id === child.targetInfo.targetId)
470
+ )
471
+ continue;
472
+ try {
473
+ const childTree = await cdp(
474
+ tabId,
475
+ "Page.getFrameTree",
476
+ {},
477
+ child.sessionId,
478
+ );
479
+ await visit(
480
+ childTree.frameTree,
481
+ childTree.frameTree.frame.parentId || frames.frameTree.frame.id,
482
+ child.sessionId,
483
+ );
484
+ } catch (error) {
485
+ warnings.push({
486
+ frameId: child.targetInfo.targetId,
487
+ code: "frame_unavailable",
488
+ message: error.message,
489
+ });
490
+ }
491
+ }
492
+ const active = new Set(nodes.map((n) => n.ref).filter(Boolean));
493
+ for (const [ref, node] of st.refs)
494
+ if (!active.has(ref)) {
495
+ st.refs.delete(ref);
496
+ st.nodes.delete(`${node.frameId}:${node.backendId}`);
497
+ st.ancestors.delete(`${node.frameId}:${node.backendId}`);
498
+ }
499
+ const byRef = new Map(nodes.filter((n) => n.ref).map((n) => [n.ref, n]));
500
+ for (const node of nodes) {
501
+ const backend = st.refs.get(node.ref);
502
+ if (!backend) continue;
503
+ const ancestors = (
504
+ st.ancestors.get(`${node.frameId}:${backend.backendId}`) || []
505
+ )
506
+ .map((key) => byRef.get(st.nodes.get(key)))
507
+ .filter(Boolean);
508
+ if (ancestors[0]) node.parentRef = ancestors[0].ref;
509
+ const scope = ancestors.find(
510
+ (n) =>
511
+ n.name &&
512
+ ["group", "form", "region", "navigation", "dialog"].includes(n.role),
513
+ );
514
+ if (scope) node.within = scope.ref;
515
+ }
516
+ // Each frame has its own AX root. Join it to the embedding element so a
517
+ // main/article subtree includes its frames at their actual document position.
518
+ for (const frame of frameInfo) {
519
+ if (!frame.parentId) continue;
520
+ try {
521
+ const parent = frameInfo.find((f) => f.id === frame.parentId);
522
+ const owner = await cdp(tabId, "DOM.getFrameOwner", { frameId: frame.id }, parent?.sessionId);
523
+ const ownerRef = st.nodes.get(`${frame.parentId}:${owner.backendNodeId}`);
524
+ const root = nodes.find((n) => n.frameId === frame.id && n.role === "RootWebArea");
525
+ if (!ownerRef || !root) throw new Error("Frame has no accessible embedding element or root");
526
+ root.parentRef = ownerRef;
527
+ const ownerKey = `${frame.parentId}:${owner.backendNodeId}`;
528
+ const outerAncestors = [ownerKey, ...(st.ancestors.get(ownerKey) || [])];
529
+ for (const node of nodes) {
530
+ if (node.frameId !== frame.id || !node.ref) continue;
531
+ const key = `${frame.id}:${st.refs.get(node.ref).backendId}`;
532
+ st.ancestors.set(key, [...(st.ancestors.get(key) || []), ...outerAncestors]);
533
+ }
534
+ } catch (error) {
535
+ warnings.push({ frameId: frame.id, code: "frame_unattached", message: error.message });
536
+ }
537
+ }
538
+ st.frames = frameInfo;
539
+ return {
540
+ ...meta,
541
+ nodes,
542
+ redundantRefs,
543
+ frames: frameInfo.map(({ sessionId, ...f }) => f),
544
+ warnings,
545
+ };
546
+ }
547
+ async function awaitPaint(tabId) {
548
+ // 给输入/滚动后的合成留出与原兜底相同的 100ms。计时必须在扩展侧:
549
+ // 收起分组会暂停页面的 rAF 和定时器,页面内的 setTimeout 不能作为超时兜底。
550
+ // 沿用任务信号,取消时立即结束等待;无需展开分组或切换用户当前页面。
551
+ await pause(100, signals.get(tabId));
552
+ }
553
+ async function observe(tabId, options = {}) {
554
+ if (
555
+ !["snapshot", "read", "both", "screenshot"].includes(
556
+ options.mode || "snapshot",
557
+ )
558
+ )
559
+ fail("invalid_request", "Unknown observation mode");
560
+ const st = state(tabId);
561
+ const maxLength = integer(options.maxLength, 20000, 100, 100000);
562
+ const saveBaseline = (key, value) => {
563
+ if ((st.baselines.get(key)?.capturedAt || 0) > value.capturedAt) return;
564
+ if (st.baselines.size >= 50 && !st.baselines.has(key))
565
+ st.baselines.delete(st.baselines.keys().next().value);
566
+ st.baselines.set(key, value);
567
+ };
568
+ if (options.cursor) {
569
+ const match = /^(obs_[\w-]+):(\d+)$/.exec(String(options.cursor));
570
+ const saved = match && st.documents.get(match[1]);
571
+ if (!saved)
572
+ fail(
573
+ "stale_observation",
574
+ "Observation expired or navigation invalidated it; read the page again",
575
+ 409,
576
+ );
577
+ const offset = Number(match[2]);
578
+ if (
579
+ !Number.isSafeInteger(offset) ||
580
+ offset >= saved.text.length ||
581
+ offset > saved.seenThrough
582
+ )
583
+ fail("invalid_cursor", "Invalid observation cursor");
584
+ const page = textPage(saved.text, offset, maxLength);
585
+ saved.seenThrough = Math.max(saved.seenThrough, page.end);
586
+ if (saved.seenThrough === saved.text.length)
587
+ saveBaseline(saved.baselineKey, {
588
+ url: saved.meta.url,
589
+ records: saved.records,
590
+ capturedAt: saved.meta.capturedAt,
591
+ });
592
+ return {
593
+ ...saved.meta,
594
+ ...page,
595
+ observationId: match[1],
596
+ nextCursor: page.truncated ? `${match[1]}:${page.end}` : null,
597
+ };
598
+ }
599
+ await awaitPaint(tabId);
600
+ if (options.mode === "screenshot") return screenshot(tabId, options);
601
+ let root;
602
+ if (options.target) root = await resolveNode(tabId, options.target);
603
+ const data = await tree(tabId, root);
604
+ const capturedAt = Date.now();
605
+ const rootRef = root && st.nodes.get(`${root.frameId || data.frames[0]?.id}:${root.backendId}`);
606
+ if (root && !rootRef)
607
+ fail("stale_ref", "Requested subtree disappeared; observe again", 409);
608
+ const records = observationRecords(data, { mode: options.mode, rootRef });
609
+ const session = String(options.sessionId || "default").slice(0, 128);
610
+ const baselineKey = JSON.stringify([
611
+ session,
612
+ options.mode || "snapshot",
613
+ rootRef || "page",
614
+ ]);
615
+ const previous = st.baselines.get(baselineKey);
616
+ let lines = [...records.values()],
617
+ diff = false;
618
+ if (options.diff === true && previous?.url === data.url) {
619
+ diff = true;
620
+ lines = [];
621
+ for (const [key, line] of previous.records)
622
+ if (!records.has(key)) lines.push(`- ${line}`);
623
+ for (const [key, line] of records)
624
+ if (previous.records.get(key) !== line)
625
+ lines.push(`${previous.records.has(key) ? "~" : "+"} ${line}`);
626
+ if (!lines.length) lines.push("(no changes)");
627
+ }
628
+ const text = lines.join("\n"),
629
+ page = textPage(text, 0, maxLength);
630
+ if (!page.truncated)
631
+ saveBaseline(baselineKey, { url: data.url, records, capturedAt });
632
+ const warnings = [...data.warnings];
633
+ if (data.background)
634
+ warnings.push({
635
+ code: "background_tab",
636
+ message:
637
+ "Background pages may defer rendering. Focus explicitly if content does not advance.",
638
+ });
639
+ if (data.readyState === "loading")
640
+ warnings.push({
641
+ code: "page_loading",
642
+ message:
643
+ "Document is still loading; wait for the expected content before reading it as complete.",
644
+ });
645
+ const observationId = `obs_${crypto.randomUUID()}`;
646
+ const meta = {
647
+ ok: true,
648
+ url: data.url,
649
+ title: data.title,
650
+ viewport: data.viewport,
651
+ readyState: data.readyState,
652
+ background: data.background,
653
+ diff,
654
+ frames: data.frames,
655
+ warnings,
656
+ capturedAt,
657
+ totalCharacters: text.length,
658
+ scope: rootRef || (options.mode === "read" && data.nodes.some((n) => n.role === "main" && n.frameId === data.frames[0]?.id) ? "main" : "page"),
659
+ };
660
+ if (page.truncated && text.length <= 4_000_000) {
661
+ while (st.documents.size >= 3)
662
+ st.documents.delete(st.documents.keys().next().value);
663
+ st.documents.set(observationId, {
664
+ meta,
665
+ text,
666
+ records,
667
+ baselineKey,
668
+ seenThrough: page.end,
669
+ });
670
+ } else if (page.truncated)
671
+ warnings.push({
672
+ code: "observation_too_large",
673
+ message:
674
+ "Read a smaller subtree using target; the page exceeds the continuation cache limit.",
675
+ });
676
+ const result = {
677
+ ...meta,
678
+ ...page,
679
+ observationId,
680
+ nextCursor:
681
+ page.truncated && st.documents.has(observationId)
682
+ ? `${observationId}:${page.end}`
683
+ : null,
684
+ ...(options.includeNodes ? { nodes: data.nodes } : {}),
685
+ };
686
+ if (options.mode === "both")
687
+ result.screenshot = await screenshot(tabId, options);
688
+ return result;
689
+ }
690
+ async function nodeCall(tabId, node, operation, args = {}) {
691
+ let objectId;
692
+ try {
693
+ objectId = (
694
+ await cdp(
695
+ tabId,
696
+ "DOM.resolveNode",
697
+ { backendNodeId: node.backendId },
698
+ node.sessionId,
699
+ )
700
+ ).object.objectId;
701
+ const result = await cdp(
702
+ tabId,
703
+ "Runtime.callFunctionOn",
704
+ {
705
+ objectId,
706
+ functionDeclaration: NODE_FUNCTION,
707
+ arguments: [{ value: operation }, { value: args }],
708
+ returnByValue: true,
709
+ },
710
+ node.sessionId,
711
+ );
712
+ if (result.exceptionDetails)
713
+ fail(
714
+ "element_action_failed",
715
+ result.exceptionDetails.exception?.description ||
716
+ result.exceptionDetails.text ||
717
+ "Element action failed",
718
+ 422,
719
+ );
720
+ const value = result.result?.value;
721
+ if (value?.error) fail(value.error, value.error, 409);
722
+ return value;
723
+ } catch (error) {
724
+ if (error.code) throw error;
725
+ fail(
726
+ "stale_ref",
727
+ "Element no longer exists; observe the page again",
728
+ 409,
729
+ );
730
+ } finally {
731
+ if (objectId)
732
+ await cdp(
733
+ tabId,
734
+ "Runtime.releaseObject",
735
+ { objectId },
736
+ node.sessionId,
737
+ ).catch(() => {});
738
+ }
739
+ }
740
+ async function resolveNode(tabId, target, depth = 0) {
741
+ if (depth > 5) fail("invalid_target", "Scope nesting is too deep");
742
+ if (typeof target === "string")
743
+ target =
744
+ target.startsWith("e") && /^e[\w]+_\d+$/.test(target)
745
+ ? { ref: target }
746
+ : { selector: target };
747
+ if (!target || typeof target !== "object")
748
+ fail("invalid_target", "Use a ref, selector, or role/name target");
749
+ if (target.scope && !(target.role || target.name))
750
+ fail("invalid_target", "scope requires a role/name target");
751
+ const st = state(tabId);
752
+ if (target.ref) {
753
+ const node = st.refs.get(target.ref);
754
+ if (!node)
755
+ fail(
756
+ "stale_ref",
757
+ "Unknown or expired reference; observe the page again",
758
+ 409,
759
+ );
760
+ return node;
761
+ }
762
+ if (target.role || target.name) {
763
+ const scope = target.scope
764
+ ? await resolveNode(tabId, target.scope, depth + 1)
765
+ : null;
766
+ const data = await tree(tabId);
767
+ const matches = data.nodes.filter(
768
+ (n) =>
769
+ n.ref &&
770
+ (!target.frameId || n.frameId === target.frameId) &&
771
+ (!scope ||
772
+ (
773
+ st.ancestors.get(
774
+ `${n.frameId}:${st.refs.get(n.ref)?.backendId}`,
775
+ ) || []
776
+ ).includes(
777
+ `${scope.frameId || data.frames[0]?.id}:${scope.backendId}`,
778
+ )) &&
779
+ (!target.role || n.role === target.role) &&
780
+ (target.name === undefined ||
781
+ (target.exact === false
782
+ ? n.name.includes(target.name)
783
+ : n.name === target.name)),
784
+ );
785
+ if (!matches.length)
786
+ fail("element_not_found", "No matching accessible element", 404);
787
+ if (matches.length !== 1)
788
+ fail(
789
+ "ambiguous_target",
790
+ `Target matches ${matches.length} elements; use a ref, scope, or frameId`,
791
+ 409,
792
+ );
793
+ return st.refs.get(matches[0].ref);
794
+ }
795
+ if (typeof target.selector !== "string")
796
+ fail("invalid_target", "selector is required");
797
+ let contextId, sessionId;
798
+ if (target.frameId) {
799
+ try {
800
+ contextId = (
801
+ await cdp(tabId, "Page.createIsolatedWorld", {
802
+ frameId: target.frameId,
803
+ worldName: "browser-relay-locator",
804
+ })
805
+ ).executionContextId;
806
+ } catch {
807
+ sessionId = await frameSession(tabId, target.frameId);
808
+ contextId = (
809
+ await cdp(
810
+ tabId,
811
+ "Page.createIsolatedWorld",
812
+ { frameId: target.frameId, worldName: "browser-relay-locator" },
813
+ sessionId,
814
+ )
815
+ ).executionContextId;
816
+ }
817
+ }
818
+ const expression = `(() => { const found=[]; const walk=root=>{found.push(...root.querySelectorAll(${JSON.stringify(target.selector)}));for(const el of root.querySelectorAll('*'))if(el.shadowRoot)walk(el.shadowRoot);};walk(document);if(found.length!==1)throw new Error('Expected one element; found '+found.length);return found[0];})()`;
819
+ const r = await cdp(
820
+ tabId,
821
+ "Runtime.evaluate",
822
+ { expression, contextId },
823
+ sessionId,
824
+ );
825
+ if (r.exceptionDetails)
826
+ fail(
827
+ "invalid_target",
828
+ r.exceptionDetails.exception?.description || r.exceptionDetails.text,
829
+ 409,
830
+ );
831
+ try {
832
+ const { node } = await cdp(
833
+ tabId,
834
+ "DOM.describeNode",
835
+ { objectId: r.result.objectId },
836
+ sessionId,
837
+ );
838
+ return {
839
+ backendId: node.backendNodeId,
840
+ frameId: target.frameId,
841
+ sessionId,
842
+ };
843
+ } finally {
844
+ if (r.result?.objectId)
845
+ await cdp(
846
+ tabId,
847
+ "Runtime.releaseObject",
848
+ { objectId: r.result.objectId },
849
+ sessionId,
850
+ ).catch(() => {});
851
+ }
852
+ }
853
+ async function frameOffset(tabId, frameId, point) {
854
+ const st = state(tabId);
855
+ if (!st.frames) await tree(tabId);
856
+ let x = 0,
857
+ y = 0,
858
+ frame = st.frames.find((f) => f.id === frameId);
859
+ while (frame?.parentId) {
860
+ const parent = st.frames.find((f) => f.id === frame.parentId);
861
+ const owner = await cdp(
862
+ tabId,
863
+ "DOM.getFrameOwner",
864
+ { frameId: frame.id },
865
+ parent?.sessionId,
866
+ );
867
+ const rect = await nodeCall(
868
+ tabId,
869
+ { backendId: owner.backendNodeId, sessionId: parent?.sessionId },
870
+ "prepare",
871
+ );
872
+ // A scroll can update DOM geometry before Chromium's compositor routes
873
+ // pointer events to an iframe. Otherwise down/up may hit different frames.
874
+ if (rect.scrolled && !rect.background)
875
+ await awaitPaint(tabId);
876
+ x += rect.x - rect.width / 2 + (rect.clientLeft || 0);
877
+ y += rect.y - rect.height / 2 + (rect.clientTop || 0);
878
+ if (point) {
879
+ const hit = await nodeCall(
880
+ tabId,
881
+ { backendId: owner.backendNodeId, sessionId: parent?.sessionId },
882
+ "hitTest",
883
+ { x: point.x + x, y: point.y + y },
884
+ );
885
+ if (!rect.visible || !hit.hittable)
886
+ fail(
887
+ "element_obscured",
888
+ "Target iframe is clipped or covered at the action point",
889
+ 409,
890
+ );
891
+ }
892
+ frame = parent;
893
+ }
894
+ return { x, y };
895
+ }
896
+ async function screenshot(tabId, options = {}) {
897
+ const meta = await evaluate(tabId, metadataExpression);
898
+ let clip = options.clip;
899
+ if (options.fullPage) {
900
+ const metrics = await cdp(tabId, "Page.getLayoutMetrics");
901
+ const size = metrics.cssContentSize || metrics.contentSize;
902
+ clip = {
903
+ x: 0,
904
+ y: 0,
905
+ width: Math.ceil(size.width),
906
+ height: Math.ceil(size.height),
907
+ scale: 1,
908
+ };
909
+ }
910
+ if (clip) {
911
+ for (const k of ["x", "y", "width", "height"])
912
+ if (
913
+ !Number.isFinite(clip[k]) ||
914
+ clip[k] < (k === "x" || k === "y" ? 0 : 1)
915
+ )
916
+ fail("invalid_clip", "Invalid screenshot clip");
917
+ if (clip.width * clip.height > 40_000_000)
918
+ fail(
919
+ "screenshot_too_large",
920
+ "Use a viewport screenshot or a smaller clip",
921
+ );
922
+ clip = { ...clip, scale: 1 };
923
+ }
924
+ const { data } = await cdp(tabId, "Page.captureScreenshot", {
925
+ format: "png",
926
+ captureBeyondViewport: !!clip,
927
+ ...(clip ? { clip } : {}),
928
+ });
929
+ const bytes = atob(data.slice(0, 44)),
930
+ uint = (i) =>
931
+ bytes.charCodeAt(i) * 2 ** 24 +
932
+ (bytes.charCodeAt(i + 1) << 16) +
933
+ (bytes.charCodeAt(i + 2) << 8) +
934
+ bytes.charCodeAt(i + 3);
935
+ const width = uint(16),
936
+ height = uint(20),
937
+ screenshotId = `shot_${crypto.randomUUID()}`;
938
+ const mapping = {
939
+ scaleX: (clip?.width || meta.viewport.width) / width,
940
+ scaleY: (clip?.height || meta.viewport.height) / height,
941
+ offsetX: clip ? clip.x - meta.viewport.scrollX : 0,
942
+ offsetY: clip ? clip.y - meta.viewport.scrollY : 0,
943
+ };
944
+ const shot = {
945
+ ok: true,
946
+ data,
947
+ format: "png",
948
+ width,
949
+ height,
950
+ screenshotId,
951
+ viewport: meta.viewport,
952
+ url: meta.url,
953
+ fullPage: !!options.fullPage,
954
+ imageToViewport: mapping,
955
+ };
956
+ const shots = state(tabId).shots;
957
+ if (shots.size >= 5) shots.delete(shots.keys().next().value);
958
+ shots.set(screenshotId, { ...shot, data: undefined });
959
+ return shot;
960
+ }
961
+ async function point(tabId, action, { allowBackground = false } = {}) {
962
+ let { x, y } = action;
963
+ if (!Number.isFinite(x) || !Number.isFinite(y))
964
+ fail("invalid_coordinates", "Finite x and y are required");
965
+ if (action.screenshotId) {
966
+ const shot = state(tabId).shots.get(action.screenshotId);
967
+ if (!shot)
968
+ fail("stale_screenshot", "Screenshot expired; capture a new one", 409);
969
+ const meta = await evaluate(tabId, metadataExpression);
970
+ if (
971
+ meta.url !== shot.url ||
972
+ JSON.stringify(meta.viewport) !== JSON.stringify(shot.viewport)
973
+ )
974
+ fail(
975
+ "stale_screenshot",
976
+ "Viewport changed; capture a new screenshot",
977
+ 409,
978
+ );
979
+ if (x < 0 || y < 0 || x >= shot.width || y >= shot.height)
980
+ fail("invalid_coordinates", "Point is outside screenshot");
981
+ x = x * shot.imageToViewport.scaleX + shot.imageToViewport.offsetX;
982
+ y = y * shot.imageToViewport.scaleY + shot.imageToViewport.offsetY;
983
+ }
984
+ const meta = await evaluate(tabId, metadataExpression);
985
+ if (x < 0 || y < 0 || x >= meta.viewport.width || y >= meta.viewport.height)
986
+ fail(
987
+ "invalid_coordinates",
988
+ "Point is outside current viewport; scroll first",
989
+ );
990
+ if (meta.background && !allowBackground) {
991
+ if (action.allowFocus && focusTab) await focusTab(tabId);
992
+ else
993
+ fail(
994
+ "needs_foreground",
995
+ "Visual input requires a visible tab; use a semantic target in the background. Only focus when the user has requested foreground operation",
996
+ 409,
997
+ );
998
+ }
999
+ return { x, y };
1000
+ }
1001
+ async function key(tabId, combo) {
1002
+ const aliases = {
1003
+ Return: "Enter",
1004
+ Esc: "Escape",
1005
+ Up: "ArrowUp",
1006
+ Down: "ArrowDown",
1007
+ Left: "ArrowLeft",
1008
+ Right: "ArrowRight",
1009
+ space: " ",
1010
+ };
1011
+ const parts = combo.split("+"),
1012
+ last = parts.pop(),
1013
+ k = aliases[last] || last;
1014
+ let modifiers = 0;
1015
+ for (const part of parts) {
1016
+ const m = {
1017
+ Control: 2,
1018
+ Ctrl: 2,
1019
+ Alt: 1,
1020
+ Shift: 8,
1021
+ Meta: 4,
1022
+ Command: 4,
1023
+ super: 4,
1024
+ }[part];
1025
+ if (!m) fail("invalid_key", `Unknown modifier ${part}`);
1026
+ modifiers |= m;
1027
+ }
1028
+ const virtual = {
1029
+ Enter: 13,
1030
+ Tab: 9,
1031
+ Escape: 27,
1032
+ Backspace: 8,
1033
+ Delete: 46,
1034
+ ArrowLeft: 37,
1035
+ ArrowUp: 38,
1036
+ ArrowRight: 39,
1037
+ ArrowDown: 40,
1038
+ Home: 36,
1039
+ End: 35,
1040
+ PageUp: 33,
1041
+ PageDown: 34,
1042
+ " ": 32,
1043
+ };
1044
+ if (k.length !== 1 && !virtual[k])
1045
+ fail("invalid_key", `Unsupported key ${k}`);
1046
+ const vk = virtual[k] || k.toUpperCase().charCodeAt(0),
1047
+ base = {
1048
+ key: k,
1049
+ modifiers,
1050
+ windowsVirtualKeyCode: vk,
1051
+ nativeVirtualKeyCode: vk,
1052
+ };
1053
+ await cdp(tabId, "Input.dispatchKeyEvent", {
1054
+ type: "keyDown",
1055
+ ...base,
1056
+ ...(!modifiers && (k.length === 1 || k === "Enter")
1057
+ ? { text: k === "Enter" ? "\r" : k }
1058
+ : {}),
1059
+ });
1060
+ await cdp(tabId, "Input.dispatchKeyEvent", { type: "keyUp", ...base });
1061
+ }
1062
+ async function perform(tabId, action, signal) {
1063
+ checkCancelled(signal);
1064
+ const type = action.type;
1065
+ if (type === "focus") {
1066
+ await focusTab(tabId);
1067
+ await awaitPaint(tabId);
1068
+ return { focused: true };
1069
+ }
1070
+ if (type === "navigate") {
1071
+ const before = await cdp(tabId, "Page.getFrameTree");
1072
+ const result = await cdp(tabId, "Page.navigate", { url: action.url });
1073
+ if (result.errorText) fail("navigation_failed", result.errorText, 422);
1074
+ invalidate(tabId);
1075
+ const deadline = Date.now() + (action.timeoutMs || 10000);
1076
+ while (result.loaderId && action.waitUntil !== "commit") {
1077
+ checkCancelled(signal);
1078
+ try {
1079
+ const frames = await cdp(tabId, "Page.getFrameTree");
1080
+ const meta = await evaluate(tabId, metadataExpression);
1081
+ if (
1082
+ frames.frameTree.frame.loaderId !==
1083
+ before.frameTree.frame.loaderId &&
1084
+ meta.readyState !== "loading"
1085
+ )
1086
+ break;
1087
+ } catch (error) {
1088
+ if (error.code === "task_cancelled") throw error;
1089
+ }
1090
+ if (Date.now() >= deadline)
1091
+ fail(
1092
+ "navigation_timeout",
1093
+ "Navigation started but document readiness timed out; inspect current state before continuing",
1094
+ 408,
1095
+ );
1096
+ await pause(50, signal);
1097
+ }
1098
+ return { navigated: true, waitUntil: action.waitUntil || "interactive" };
1099
+ }
1100
+ if (type === "key") {
1101
+ await key(tabId, action.key);
1102
+ return { pressed: true };
1103
+ }
1104
+ if (type === "wait") {
1105
+ const deadline = Date.now() + integer(action.timeoutMs, 5000, 1, 20000),
1106
+ stateName = action.state || "visible";
1107
+ do {
1108
+ checkCancelled(signal);
1109
+ try {
1110
+ const node = await resolveNode(tabId, action.target),
1111
+ details = await nodeCall(tabId, node, "inspect");
1112
+ if (
1113
+ stateName === "attached" ||
1114
+ (stateName === "visible" && details.visible) ||
1115
+ (stateName === "enabled" && details.visible && details.enabled) ||
1116
+ (stateName === "hidden" && !details.visible)
1117
+ )
1118
+ return { matched: true };
1119
+ } catch (error) {
1120
+ if (
1121
+ ["hidden", "detached"].includes(stateName) &&
1122
+ ["element_not_found", "stale_ref"].includes(error.code)
1123
+ )
1124
+ return { matched: true };
1125
+ // CSS missing nodes are distinct from invalid/ambiguous selectors.
1126
+ if (
1127
+ error.code === "invalid_target" &&
1128
+ /found 0\b/.test(error.message)
1129
+ ) {
1130
+ if (["hidden", "detached"].includes(stateName))
1131
+ return { matched: true };
1132
+ } else if (!["element_not_found", "stale_ref"].includes(error.code))
1133
+ throw error;
1134
+ }
1135
+ if (Date.now() >= deadline) break;
1136
+ await pause(Math.min(100, deadline - Date.now()), signal);
1137
+ } while (true);
1138
+ fail("wait_timeout", "Target did not reach the requested state", 408);
1139
+ }
1140
+ let node, rect, targetOffset;
1141
+ if (action.target) {
1142
+ const deadline = Date.now() + (action.timeoutMs || 0);
1143
+ let previousRect;
1144
+ for (;;) {
1145
+ checkCancelled(signal);
1146
+ try {
1147
+ node = await resolveNode(tabId, action.target);
1148
+ rect = await nodeCall(tabId, node, "prepare");
1149
+ if (!rect.visible)
1150
+ fail("element_not_visible", "Target is not visible", 409);
1151
+ if (rect.disabled)
1152
+ fail("element_disabled", "Target is disabled", 409);
1153
+ if (rect.obscured && type !== "scroll")
1154
+ fail(
1155
+ "element_obscured",
1156
+ "Target is covered by another element",
1157
+ 409,
1158
+ );
1159
+ if (type !== "scroll")
1160
+ targetOffset = await frameOffset(tabId, node.frameId, rect);
1161
+ if (
1162
+ action.timeoutMs &&
1163
+ ["click", "double_click", "hover", "drag", "check"].includes(type)
1164
+ ) {
1165
+ const position = [
1166
+ rect.x + targetOffset.x,
1167
+ rect.y + targetOffset.y,
1168
+ rect.width,
1169
+ rect.height,
1170
+ ];
1171
+ if (
1172
+ !previousRect ||
1173
+ position.some((n, i) => Math.abs(n - previousRect[i]) > 0.5)
1174
+ ) {
1175
+ previousRect = position;
1176
+ fail("element_unstable", "Target is still moving", 409);
1177
+ }
1178
+ }
1179
+ break;
1180
+ } catch (error) {
1181
+ const retryable =
1182
+ [
1183
+ "element_not_found",
1184
+ "element_not_visible",
1185
+ "element_disabled",
1186
+ "element_obscured",
1187
+ "element_unstable",
1188
+ ].includes(error.code) ||
1189
+ (error.code === "invalid_target" &&
1190
+ /found 0\b/.test(error.message));
1191
+ if (!retryable || Date.now() >= deadline) throw error;
1192
+ if (error.code !== "element_unstable") previousRect = undefined;
1193
+ await pause(Math.min(50, deadline - Date.now()), signal);
1194
+ }
1195
+ }
1196
+ checkCancelled(signal);
1197
+ }
1198
+ if (["fill", "type"].includes(type)) {
1199
+ if (node)
1200
+ await nodeCall(tabId, node, "focus", {
1201
+ clear: type === "fill" || action.clear === true,
1202
+ });
1203
+ else if (type === "fill")
1204
+ fail("invalid_target", "fill requires an editable target");
1205
+ if (action.text === "") {
1206
+ if (node && (type === "fill" || action.clear))
1207
+ await key(tabId, "Backspace");
1208
+ } else await cdp(tabId, "Input.insertText", { text: action.text });
1209
+ if (action.submit) await key(tabId, "Enter");
1210
+ return { typed: true };
1211
+ }
1212
+ if (type === "select") {
1213
+ if (!node) fail("invalid_target", `${type} requires a target`);
1214
+ return nodeCall(tabId, node, type, action);
1215
+ }
1216
+ if (type === "check") {
1217
+ if (!node) fail("invalid_target", "check requires a target");
1218
+ const current = await nodeCall(tabId, node, "checkState");
1219
+ if (current.checked === action.checked)
1220
+ return { checked: action.checked, changed: false };
1221
+ if (current.radio && !action.checked)
1222
+ fail(
1223
+ "not_checkable",
1224
+ "Radio controls cannot be unchecked directly",
1225
+ 409,
1226
+ );
1227
+ }
1228
+ if (type === "scroll") {
1229
+ const before = await evaluate(tabId, metadataExpression);
1230
+ const beforeText = action.waitForChange
1231
+ ? await evaluate(
1232
+ tabId,
1233
+ "(document.querySelector('main')||document.body).innerText",
1234
+ )
1235
+ : undefined;
1236
+ if (node) await nodeCall(tabId, node, "scroll", action);
1237
+ else if (before.background) {
1238
+ const at = await point(tabId, action, { allowBackground: true });
1239
+ const result = await evaluate(tabId, `(${scrollAtPoint.toString()})(${JSON.stringify({ ...at, deltaX: action.deltaX || 0, deltaY: action.deltaY || 0 })})`);
1240
+ if (result?.error)
1241
+ fail(result.error, "Use a target with frameId to scroll inside a background iframe", 409);
1242
+ } else {
1243
+ // The user may switch tabs during preparation; never reactivate it.
1244
+ const at = await point(tabId, { ...action, allowFocus: false });
1245
+ await cdp(tabId, "Input.dispatchMouseEvent", {
1246
+ type: "mouseWheel",
1247
+ x: at.x,
1248
+ y: at.y,
1249
+ deltaX: action.deltaX || 0,
1250
+ deltaY: action.deltaY || 0,
1251
+ });
1252
+ }
1253
+ await awaitPaint(tabId);
1254
+ let contentChanged;
1255
+ if (action.waitForChange) {
1256
+ const deadline = Date.now() + (action.timeoutMs || 1500);
1257
+ do {
1258
+ contentChanged =
1259
+ (await evaluate(
1260
+ tabId,
1261
+ "(document.querySelector('main')||document.body).innerText",
1262
+ )) !== beforeText;
1263
+ if (contentChanged || Date.now() >= deadline) break;
1264
+ await pause(50, signal);
1265
+ } while (true);
1266
+ }
1267
+ const after = await evaluate(tabId, metadataExpression);
1268
+ return {
1269
+ scrolled: true,
1270
+ strategy: node || before.background ? "dom" : "wheel",
1271
+ viewportMoved:
1272
+ before.viewport.scrollY !== after.viewport.scrollY ||
1273
+ before.viewport.scrollX !== after.viewport.scrollX,
1274
+ ...(contentChanged === undefined ? {} : { contentChanged }),
1275
+ ...(contentChanged === false
1276
+ ? {
1277
+ warning:
1278
+ before.background
1279
+ ? "No text change observed; background rendering may be deferred or the feed may be at its end. Do not count this as new content."
1280
+ : "No text change observed; the feed may be unchanged or at its end. Do not count this as new content.",
1281
+ }
1282
+ : {}),
1283
+ };
1284
+ }
1285
+ if (
1286
+ node &&
1287
+ rect.background &&
1288
+ ["click", "check"].includes(type) &&
1289
+ (!action.button || action.button === "left")
1290
+ ) {
1291
+ if (await nodeCall(tabId, node, "click")) {
1292
+ if (type === "check") {
1293
+ const after = await nodeCall(tabId, node, "checkState");
1294
+ if (after.checked !== action.checked)
1295
+ fail(
1296
+ "checked_state_mismatch",
1297
+ "Control did not reach requested checked state",
1298
+ 409,
1299
+ );
1300
+ return { checked: after.checked, changed: true, strategy: "dom" };
1301
+ }
1302
+ return { clicked: true, strategy: "dom" };
1303
+ }
1304
+ }
1305
+ const offset = node
1306
+ ? targetOffset || (await frameOffset(tabId, node.frameId, rect))
1307
+ : { x: 0, y: 0 };
1308
+ const at = await point(
1309
+ tabId,
1310
+ node
1311
+ ? {
1312
+ ...action,
1313
+ x: rect.x + offset.x,
1314
+ y: rect.y + offset.y,
1315
+ screenshotId: undefined,
1316
+ }
1317
+ : action,
1318
+ );
1319
+ if (type === "move" || type === "hover") {
1320
+ await cdp(tabId, "Input.dispatchMouseEvent", {
1321
+ type: "mouseMoved",
1322
+ ...at,
1323
+ });
1324
+ return { moved: true };
1325
+ }
1326
+ const button = action.button || "left",
1327
+ clickCount = type === "double_click" ? 2 : 1;
1328
+ await cdp(tabId, "Input.dispatchMouseEvent", { type: "mouseMoved", ...at });
1329
+ if (type === "drag") {
1330
+ const path = action.path || [action.to],
1331
+ end = await point(tabId, {
1332
+ ...path.at(-1),
1333
+ screenshotId: action.screenshotId,
1334
+ allowFocus: action.allowFocus,
1335
+ });
1336
+ let current = at,
1337
+ pressed = false;
1338
+ try {
1339
+ await cdp(tabId, "Input.dispatchMouseEvent", {
1340
+ type: "mousePressed",
1341
+ ...at,
1342
+ button: "left",
1343
+ buttons: 1,
1344
+ clickCount: 1,
1345
+ });
1346
+ pressed = true;
1347
+ for (const dest of path) {
1348
+ const goal = await point(tabId, {
1349
+ ...dest,
1350
+ screenshotId: action.screenshotId,
1351
+ allowFocus: action.allowFocus,
1352
+ });
1353
+ const from = current;
1354
+ for (let i = 1; i <= 8; i++) {
1355
+ checkCancelled(signal);
1356
+ current = {
1357
+ x: from.x + ((goal.x - from.x) * i) / 8,
1358
+ y: from.y + ((goal.y - from.y) * i) / 8,
1359
+ };
1360
+ await cdp(tabId, "Input.dispatchMouseEvent", {
1361
+ type: "mouseMoved",
1362
+ ...current,
1363
+ button: "left",
1364
+ buttons: 1,
1365
+ });
1366
+ }
1367
+ }
1368
+ } finally {
1369
+ if (pressed)
1370
+ await cdp(tabId, "Input.dispatchMouseEvent", {
1371
+ type: "mouseReleased",
1372
+ ...current,
1373
+ button: "left",
1374
+ buttons: 0,
1375
+ clickCount: 1,
1376
+ });
1377
+ }
1378
+ return { dragged: true, end };
1379
+ }
1380
+ for (let i = 1; i <= clickCount; i++) {
1381
+ await cdp(tabId, "Input.dispatchMouseEvent", {
1382
+ type: "mousePressed",
1383
+ ...at,
1384
+ button,
1385
+ clickCount: i,
1386
+ });
1387
+ await cdp(tabId, "Input.dispatchMouseEvent", {
1388
+ type: "mouseReleased",
1389
+ ...at,
1390
+ button,
1391
+ clickCount: i,
1392
+ });
1393
+ }
1394
+ if (type === "check") {
1395
+ const after = await nodeCall(tabId, node, "checkState");
1396
+ if (after.checked !== action.checked)
1397
+ fail(
1398
+ "checked_state_mismatch",
1399
+ "Control did not reach requested checked state",
1400
+ 409,
1401
+ );
1402
+ return { checked: after.checked, changed: true, strategy: "mouse" };
1403
+ }
1404
+ return { clicked: true, strategy: "mouse" };
1405
+ }
1406
+ function validate(actions) {
1407
+ if (!Array.isArray(actions) || !actions.length || actions.length > 100)
1408
+ fail("invalid_request", "actions must contain 1–100 operations");
1409
+ for (const action of actions) {
1410
+ if (!action || !ACTIONS.has(action.type))
1411
+ fail("invalid_action", `Unsupported action: ${action?.type}`);
1412
+ if (action.timeoutMs !== undefined)
1413
+ integer(action.timeoutMs, 0, 1, 20000);
1414
+ if (
1415
+ ["type", "fill"].includes(action.type) &&
1416
+ typeof action.text !== "string"
1417
+ )
1418
+ fail("invalid_action", "text is required");
1419
+ if (action.type === "key" && typeof action.key !== "string")
1420
+ fail("invalid_action", "key is required");
1421
+ if (action.type === "navigate") {
1422
+ let u;
1423
+ try {
1424
+ u = new URL(action.url);
1425
+ } catch {
1426
+ fail("invalid_url", "Invalid URL");
1427
+ }
1428
+ if (
1429
+ !["http:", "https:"].includes(u.protocol) &&
1430
+ action.url !== "about:blank"
1431
+ )
1432
+ fail(
1433
+ "invalid_url",
1434
+ "Only HTTP(S) and about:blank navigation is supported",
1435
+ );
1436
+ }
1437
+ if (
1438
+ action.type === "wait" &&
1439
+ !["attached", "visible", "hidden", "detached", "enabled"].includes(
1440
+ action.state || "visible",
1441
+ )
1442
+ )
1443
+ fail("invalid_action", "Invalid wait state");
1444
+ if (action.type === "check" && typeof action.checked !== "boolean")
1445
+ fail("invalid_action", "checked must be boolean");
1446
+ if (
1447
+ action.type === "select" &&
1448
+ typeof action.value !== "string" &&
1449
+ !(
1450
+ Array.isArray(action.value) &&
1451
+ action.value.every((v) => typeof v === "string")
1452
+ )
1453
+ )
1454
+ fail("invalid_action", "value must be a string or string array");
1455
+ if (action.button && !["left", "middle", "right"].includes(action.button))
1456
+ fail("invalid_action", "Invalid mouse button");
1457
+ if (
1458
+ action.type === "drag" &&
1459
+ !(
1460
+ action.to ||
1461
+ (Array.isArray(action.path) &&
1462
+ action.path.length &&
1463
+ action.path.length <= 100)
1464
+ )
1465
+ )
1466
+ fail("invalid_action", "drag requires to or a bounded path");
1467
+ for (const k of ["deltaX", "deltaY"])
1468
+ if (
1469
+ action[k] !== undefined &&
1470
+ (!Number.isFinite(action[k]) || Math.abs(action[k]) > 10000)
1471
+ )
1472
+ fail("invalid_action", "Scroll delta must be within ±10000");
1473
+ }
1474
+ }
1475
+ let lifecycle = Promise.resolve();
1476
+ function request(method, path, body = {}, transport = "local") {
1477
+ const p = path.split('?')[0];
1478
+ if (p === '/api/sessions' || /^\/api\/tabs\/(create|close|claim|release|handoff)$/.test(p)) {
1479
+ const result = lifecycle.then(() => executeRequest(method, path, body, transport));
1480
+ lifecycle = result.catch(() => {});
1481
+ return result;
1482
+ }
1483
+ return executeRequest(method, path, body, transport);
1484
+ }
1485
+ async function executeRequest(method, path, body = {}, transport = "local") {
1486
+ const startJob = (tabId, run, ...args) => {
1487
+ const job = queue.start(tabId, async (task, signal) => {
1488
+ const finish = onActivity(tabId);
1489
+ const abort = () => finish(true);
1490
+ signal.addEventListener('abort', abort, { once: true });
1491
+ try {
1492
+ checkCancelled(signal);
1493
+ await prepareTab(tabId);
1494
+ checkCancelled(signal);
1495
+ return await run(task, signal);
1496
+ }
1497
+ finally {
1498
+ signal.removeEventListener('abort', abort);
1499
+ finish(signal.aborted);
1500
+ }
1501
+ }, ...args);
1502
+ jobOrigins.set(job.id, transport);
1503
+ job.done.finally(() => jobOrigins.delete(job.id));
1504
+ return job;
1505
+ };
1506
+ const u = new URL(path, "http://relay.local"),
1507
+ p = u.pathname,
1508
+ options = method === "GET" ? Object.fromEntries(u.searchParams) : body;
1509
+ const sessionId = options.sessionId;
1510
+ if (sessionId) {
1511
+ if (typeof sessionId !== "string" || !/^[\w.-]{1,128}$/.test(sessionId))
1512
+ fail("invalid_session", "Invalid session id");
1513
+ if (!sessionOrigins.has(sessionId) && sessionOrigins.size >= 500)
1514
+ sessionOrigins.delete(sessionOrigins.keys().next().value);
1515
+ sessionOrigins.set(sessionId, transport);
1516
+ }
1517
+ const publicClaim = (c) => c && { ...c, tabId: publicTabId(c.tabId) };
1518
+ const own = async (tabId) => {
1519
+ sessions.check(tabId, sessionId);
1520
+ if (sessionId) {
1521
+ sessions.claim(tabId, sessionId);
1522
+ if (taskGroups && await taskGroups.claim(tabId, sessionId))
1523
+ sessions.claim(tabId, sessionId, { created: true });
1524
+ }
1525
+ };
1526
+ if (method === "GET" && p === "/api/capabilities")
1527
+ return {
1528
+ ok: true,
1529
+ protocolVersion: PROTOCOL_VERSION,
1530
+ features: FEATURES,
1531
+ maxActions: 100,
1532
+ ...runtimeInfo(),
1533
+ };
1534
+ if (p === "/api/sessions") {
1535
+ if (method === "GET")
1536
+ return { ok: true, claims: sessions.list().map(publicClaim), taskGroups: taskGroups ?
1537
+ (await taskGroups.list()).map(s => ({ ...s, groups: s.groups.map(g => ({ ...g, tabIds: g.tabIds.map(publicTabId) })) })) : [] };
1538
+ if (body.action === "start") {
1539
+ if (!taskGroups) fail('unsupported_feature', 'Task groups are unavailable');
1540
+ sessions.touch(sessionId);
1541
+ await taskGroups.start(sessionId, body.label);
1542
+ return { ok: true, sessionId };
1543
+ }
1544
+ if (body.action === "complete") {
1545
+ if (!taskGroups) fail('unsupported_feature', 'Task groups are unavailable');
1546
+ if (queue.active().some(job => job.sessionId === sessionId))
1547
+ fail('session_busy', 'Await or cancel pending tasks before completing the session', 409);
1548
+ // 先封住本 session 的新动作,再按资源记录清理;过期/停止后仍能显式完成。
1549
+ const released = sessions.stop(sessionId).map(publicClaim);
1550
+ const result = await taskGroups.complete(sessionId, tabId => sessions.list().find(c => c.tabId === tabId)?.sessionId, publicTabId);
1551
+ sessionOrigins.delete(sessionId);
1552
+ return { ok: result.errors.length === 0, ...(result.errors.length ? { code: 'cleanup_failed', message: 'Some task tabs could not be closed; retry complete', } : {}),
1553
+ sessionId, released, closed: result.closed, preserved: result.preserved, errors: result.errors };
1554
+ }
1555
+ if (body.action === "heartbeat")
1556
+ return { ok: true, ...sessions.touch(sessionId) };
1557
+ if (body.action === "stop") {
1558
+ sessionOrigins.delete(sessionId);
1559
+ return {
1560
+ ok: true,
1561
+ released: sessions.stop(sessionId).map(publicClaim),
1562
+ };
1563
+ }
1564
+ fail("invalid_request", "Session action must be start, heartbeat, stop or complete");
1565
+ }
1566
+ if (p === "/api/session/check") {
1567
+ sessions.check(await resolveTab(options.tabId), sessionId);
1568
+ return { ok: true };
1569
+ }
1570
+ if (
1571
+ ["/api/tabs/claim", "/api/tabs/release", "/api/tabs/handoff"].includes(
1572
+ p,
1573
+ ) &&
1574
+ method === "POST"
1575
+ ) {
1576
+ const tabId = await resolveTab(body.tabId);
1577
+ if (taskGroups && !p.endsWith('/claim')) {
1578
+ const current = sessions.check(tabId, sessionId);
1579
+ if (queue.active().some(job => job.tabId === tabId)) fail('tab_busy', 'Await pending tasks before releasing or handing off', 409);
1580
+ if (p.endsWith('/handoff')) {
1581
+ if (!current) fail('tab_unclaimed', 'Claim the tab before handing it off', 409);
1582
+ sessions.touch(body.toSessionId);
1583
+ if (current.created) await taskGroups.add(tabId, body.toSessionId);
1584
+ } else await taskGroups.release(tabId, sessionId);
1585
+ }
1586
+ if (p.endsWith('/claim')) await own(tabId);
1587
+ const claim = p.endsWith("/claim")
1588
+ ? sessions.claim(tabId, sessionId, { label: body.label })
1589
+ : p.endsWith("/release")
1590
+ ? sessions.release(tabId, sessionId)
1591
+ : sessions.handoff(tabId, sessionId, body.toSessionId);
1592
+ return { ok: true, claim: publicClaim(claim) };
1593
+ }
1594
+ const taskMatch = p.match(/^\/api\/tasks\/([^/]+)(\/cancel)?$/);
1595
+ if (taskMatch) {
1596
+ let task;
1597
+ try {
1598
+ task = queue.get(taskMatch[1]);
1599
+ } catch (error) {
1600
+ if (
1601
+ error.code === "task_not_found" &&
1602
+ method === "POST" &&
1603
+ taskMatch[2]
1604
+ )
1605
+ return {
1606
+ ok: true,
1607
+ task: queue.cancel(taskMatch[1], "task_cancelled", sessionId),
1608
+ };
1609
+ throw error;
1610
+ }
1611
+ if (task.sessionId && task.sessionId !== sessionId)
1612
+ fail("task_owned", "Task belongs to another session", 409);
1613
+ return {
1614
+ ok: true,
1615
+ task:
1616
+ method === "POST" && taskMatch[2] ? queue.cancel(taskMatch[1]) : task,
1617
+ };
1618
+ }
1619
+ if (p === "/api/tabs/create" && method === "POST") {
1620
+ if (sessionId) sessions.touch(sessionId);
1621
+ const created = await createTab(body.url || "about:blank");
1622
+ if (sessionId) {
1623
+ const tabId = await resolveTab(created.tabId);
1624
+ sessions.claim(tabId, sessionId, { created: true });
1625
+ if (taskGroups) {
1626
+ try { created.groupId = await taskGroups.add(tabId, sessionId); }
1627
+ catch (error) {
1628
+ // 新页面尚未交付调用方;建组失败就回收,避免遗留无法定位的后台页。
1629
+ await closeTab(tabId);
1630
+ sessions.forget(tabId);
1631
+ throw error;
1632
+ }
1633
+ }
1634
+ }
1635
+ return { ok: true, ...created };
1636
+ }
1637
+ if (p === "/api/tabs/focus" && method === "POST")
1638
+ return request("POST", "/api/actions", {
1639
+ ...body,
1640
+ actions: [{ type: "focus" }],
1641
+ }, transport);
1642
+ if (p === "/api/evaluate" && method === "POST") {
1643
+ if (typeof body.expression !== "string")
1644
+ fail("invalid_request", "expression is required");
1645
+ const tabId = await resolveTab(body.tabId);
1646
+ await own(tabId);
1647
+ const job = startJob(
1648
+ tabId,
1649
+ async (_, signal) => {
1650
+ signals.set(tabId, signal);
1651
+ try {
1652
+ return { ok: true, value: await evaluate(tabId, body.expression) };
1653
+ } finally {
1654
+ signals.delete(tabId);
1655
+ }
1656
+ },
1657
+ 20000,
1658
+ body.taskId,
1659
+ sessionId,
1660
+ );
1661
+ const result = await job.done;
1662
+ if (result.error)
1663
+ throw new TaskError(
1664
+ result.error.code,
1665
+ result.error.message,
1666
+ result.error.status,
1667
+ );
1668
+ return result.observation;
1669
+ }
1670
+ if (p === "/api/tabs/close" && method === "POST") {
1671
+ const tabId = await resolveTab(body.tabId);
1672
+ await own(tabId);
1673
+ if (queue.active().some((job) => job.tabId === tabId))
1674
+ fail("tab_busy", "Cancel and await tasks before closing the tab", 409);
1675
+ invalidate(tabId);
1676
+ await closeTab(tabId);
1677
+ sessions.forget(tabId);
1678
+ return { ok: true };
1679
+ }
1680
+ if (p === "/api/observe" || p === "/api/read") {
1681
+ const tabId = await resolveTab(options.tabId);
1682
+ await own(tabId);
1683
+ const job = startJob(
1684
+ tabId,
1685
+ async (_, signal) => {
1686
+ signals.set(tabId, signal);
1687
+ try {
1688
+ return await observe(tabId, {
1689
+ ...options,
1690
+ mode: p === "/api/read" ? "read" : options.mode,
1691
+ diff: options.diff === true || options.diff === "true",
1692
+ includeNodes:
1693
+ options.includeNodes === true ||
1694
+ options.includeNodes === "true",
1695
+ maxLength:
1696
+ options.maxLength === undefined
1697
+ ? undefined
1698
+ : Number(options.maxLength),
1699
+ });
1700
+ } finally {
1701
+ signals.delete(tabId);
1702
+ }
1703
+ },
1704
+ 20000,
1705
+ options.taskId,
1706
+ sessionId,
1707
+ );
1708
+ const result = await job.done;
1709
+ if (result.error)
1710
+ throw new TaskError(
1711
+ result.error.code,
1712
+ result.error.message,
1713
+ result.error.status,
1714
+ );
1715
+ return result.observation;
1716
+ }
1717
+ if (p === "/api/actions" && method === "POST") {
1718
+ validate(body.actions);
1719
+ if (
1720
+ !["none", "snapshot", "read", "screenshot", "both"].includes(
1721
+ body.observe || "snapshot",
1722
+ )
1723
+ )
1724
+ fail(
1725
+ "invalid_request",
1726
+ "observe must be none, snapshot, read, screenshot, or both",
1727
+ );
1728
+ if (!body.tabId)
1729
+ fail("invalid_request", "Explicit tabId is required for actions");
1730
+ const tabId = await resolveTab(body.tabId),
1731
+ timeoutMs = integer(body.timeoutMs, 20000, 1, 120000);
1732
+ await own(tabId);
1733
+ if (timeoutMs > 20000 && body.async !== true)
1734
+ fail(
1735
+ "async_required",
1736
+ "Tasks longer than 20 seconds must use async:true",
1737
+ );
1738
+ const { id, done } = startJob(
1739
+ tabId,
1740
+ async (job, signal) => {
1741
+ sessions.check(tabId, sessionId);
1742
+ signals.set(tabId, signal);
1743
+ try {
1744
+ for (const [index, action] of body.actions.entries()) {
1745
+ checkCancelled(signal);
1746
+ job.currentAction = { index, type: action.type };
1747
+ const start = Date.now(),
1748
+ result = await perform(tabId, action, signal);
1749
+ job.results.push({
1750
+ type: action.type,
1751
+ elapsedMs: Date.now() - start,
1752
+ ...result,
1753
+ });
1754
+ job.currentAction = undefined;
1755
+ }
1756
+ checkCancelled(signal);
1757
+ return body.observe === "none"
1758
+ ? undefined
1759
+ : await observe(tabId, {
1760
+ mode: body.observe,
1761
+ sessionId: body.sessionId,
1762
+ diff: body.diff !== false,
1763
+ maxLength: body.maxLength,
1764
+ });
1765
+ } finally {
1766
+ await releaseInputs(tabId);
1767
+ signals.delete(tabId);
1768
+ }
1769
+ },
1770
+ timeoutMs,
1771
+ body.taskId,
1772
+ sessionId,
1773
+ );
1774
+ if (body.async === true) return { ok: true, task: queue.get(id) };
1775
+ const task = await done;
1776
+ return {
1777
+ ok: task.status === "completed",
1778
+ task,
1779
+ ...(task.error
1780
+ ? {
1781
+ code: task.error.code,
1782
+ message: task.error.message,
1783
+ error: task.error.message,
1784
+ status: task.error.status,
1785
+ }
1786
+ : {}),
1787
+ };
1788
+ }
1789
+ fail(
1790
+ "endpoint_not_found",
1791
+ `Unknown automation endpoint: ${method} ${p}`,
1792
+ 404,
1793
+ );
1794
+ }
1795
+ return {
1796
+ request,
1797
+ observe,
1798
+ screenshot,
1799
+ invalidate,
1800
+ activeTasks: queue.active,
1801
+ // 页面脚本/target=_blank 新建的页面继承发起页当前任务;不用 URL 猜归属。
1802
+ adoptTab: (tabId, sourceTabId) => {
1803
+ const result = lifecycle.then(async () => {
1804
+ const owner = sessions.list().find(c => c.tabId === sourceTabId);
1805
+ if (!owner || !taskGroups) return;
1806
+ sessions.claim(tabId, owner.sessionId, { created: true });
1807
+ await taskGroups.add(tabId, owner.sessionId);
1808
+ });
1809
+ lifecycle = result.catch(() => {});
1810
+ return result;
1811
+ },
1812
+ sessions,
1813
+ disconnect: (transport = "local") => {
1814
+ for (const job of queue.active())
1815
+ if (jobOrigins.get(job.id) === transport)
1816
+ queue.cancel(job.id, "extension_disconnected");
1817
+ for (const [id, origin] of sessionOrigins)
1818
+ if (origin === transport) {
1819
+ sessions.stop(id, "extension_disconnected");
1820
+ sessionOrigins.delete(id);
1821
+ }
1822
+ },
1823
+ cancelAll: queue.cancelAll,
1824
+ attachChild: (tabId, entry) => {
1825
+ if (!children.has(tabId)) children.set(tabId, new Map());
1826
+ children.get(tabId).set(entry.targetInfo.targetId, entry);
1827
+ },
1828
+ detachChild: (tabId, sessionId) => {
1829
+ for (const [id, c] of children.get(tabId) || [])
1830
+ if (c.sessionId === sessionId) children.get(tabId).delete(id);
1831
+ },
1832
+ close: (tabId) => {
1833
+ invalidate(tabId);
1834
+ sessions.forget(tabId);
1835
+ children.delete(tabId);
1836
+ queue.cancelTab(tabId);
1837
+ },
1838
+ };
1839
+ }