@olimsaidov/icdp 0.1.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.
@@ -0,0 +1,1048 @@
1
+ import chobitsu from "chobitsu";
2
+ import type Protocol from "devtools-protocol";
3
+ import type { ProtocolMapping } from "devtools-protocol/types/protocol-mapping";
4
+
5
+ import {
6
+ CDP_SERVER_ERROR,
7
+ type CdpId,
8
+ type HandshakeMessage,
9
+ isHandshakeMessage,
10
+ PROTOCOL_VERSION,
11
+ } from "../protocol.ts";
12
+ import {
13
+ createDomRegistry,
14
+ getAXNodeAndAncestors,
15
+ getChildAXNodes,
16
+ getFullAXTree,
17
+ getPartialAXTree,
18
+ getRootAXNode,
19
+ queryAXTree,
20
+ } from "./ax-tree.ts";
21
+
22
+ type CdpMethod = keyof ProtocolMapping.Commands;
23
+ type CdpParams<Method extends CdpMethod> = NonNullable<
24
+ ProtocolMapping.Commands[Method]["paramsType"][0]
25
+ >;
26
+
27
+ type CdpRequest = {
28
+ id?: CdpId;
29
+ method: CdpMethod | string;
30
+ params?: Record<string, any>;
31
+ };
32
+
33
+ type CdpResponse = {
34
+ id?: CdpId;
35
+ result?: unknown;
36
+ error?: { code?: number; message?: string };
37
+ method?: string;
38
+ params?: Record<string, unknown>;
39
+ };
40
+ type CdpHandler = (params?: Record<string, any>) => unknown | Promise<unknown>;
41
+
42
+ // chobitsu's published .d.ts omits register(), but the runtime exposes it.
43
+ const cdp = chobitsu as typeof chobitsu & {
44
+ register(domain: string, handlers: Record<string, (params: any) => unknown>): void;
45
+ };
46
+
47
+ export type FrameAgentOptions = {
48
+ /**
49
+ * Origins allowed to act as Host. The agent stays dormant unless the parent
50
+ * matches. "*" hands full DOM read/write/eval to ANY embedder — only use it
51
+ * for pages that are themselves sandboxed or throwaway.
52
+ */
53
+ allowedParents: string[] | "*";
54
+ };
55
+
56
+ const frameId: Protocol.Page.FrameId = "icdp-frame";
57
+ const registry = createDomRegistry();
58
+ const noop: CdpHandler = () => ({});
59
+ let port: MessagePort | null = null;
60
+ let nextScriptIdentifier = 1;
61
+ let nextSearchId = 1;
62
+ const outboundMethods = new Map<CdpId, string>();
63
+ const searchResults = new Map<string, Protocol.DOM.BackendNodeId[]>();
64
+ let runtimeEnabled = false;
65
+ const queuedRuntimeEvents: CdpResponse[] = [];
66
+ const consoleWrapped = Symbol("icdp-console-wrapped");
67
+ type ConsoleMethod = (...args: unknown[]) => unknown;
68
+ let pressedElement: Element | null = null;
69
+ let hoveredElement: Element | null = null;
70
+ let lastClickElement: Element | null = null;
71
+ let lastClickTime = 0;
72
+
73
+ function sendToHost(message: CdpResponse): void {
74
+ port?.postMessage(JSON.stringify(message));
75
+ }
76
+
77
+ function sendRuntimeEvent(message: CdpResponse): void {
78
+ if (runtimeEnabled) {
79
+ sendToHost(message);
80
+ } else {
81
+ queuedRuntimeEvents.push(message);
82
+ if (queuedRuntimeEvents.length > 200) queuedRuntimeEvents.shift();
83
+ }
84
+ }
85
+
86
+ chobitsu.setOnMessage((raw) => {
87
+ let message: CdpResponse;
88
+ try {
89
+ message = JSON.parse(raw) as CdpResponse;
90
+ } catch {
91
+ return;
92
+ }
93
+
94
+ if (message.id != null) {
95
+ const method = outboundMethods.get(message.id);
96
+ outboundMethods.delete(message.id);
97
+ if (method && message.error?.message === `${method} unimplemented`) {
98
+ message.error = { code: CDP_SERVER_ERROR, message: `Method not found: ${method}` };
99
+ } else if (message.error && message.error.code == null) {
100
+ message.error = { ...message.error, code: CDP_SERVER_ERROR };
101
+ }
102
+ }
103
+
104
+ sendToHost(message);
105
+ });
106
+
107
+ function elementForBackendId(id: Protocol.DOM.BackendNodeId): Element {
108
+ const node = registry.nodeForBackendId(id);
109
+ const element = node instanceof Element ? node : node?.parentElement;
110
+ if (!element) throw new Error(`No element for backendDOMNodeId=${id}`);
111
+ return element;
112
+ }
113
+
114
+ function boxModel(id: Protocol.DOM.BackendNodeId): Protocol.DOM.GetBoxModelResponse {
115
+ const rect = elementForBackendId(id).getBoundingClientRect();
116
+ const quad = [
117
+ rect.left,
118
+ rect.top,
119
+ rect.right,
120
+ rect.top,
121
+ rect.right,
122
+ rect.bottom,
123
+ rect.left,
124
+ rect.bottom,
125
+ ].map(Math.round);
126
+ return {
127
+ model: {
128
+ content: quad,
129
+ padding: quad,
130
+ border: quad,
131
+ margin: quad,
132
+ width: Math.round(rect.width),
133
+ height: Math.round(rect.height),
134
+ },
135
+ };
136
+ }
137
+
138
+ function contentQuads(id: Protocol.DOM.BackendNodeId): Protocol.DOM.GetContentQuadsResponse {
139
+ return { quads: [boxModel(id).model.content] };
140
+ }
141
+
142
+ function describeNode(id: Protocol.DOM.BackendNodeId): Protocol.DOM.DescribeNodeResponse {
143
+ const node = registry.nodeForBackendId(id);
144
+ if (!node) throw new Error(`No element for backendDOMNodeId=${id}`);
145
+ return { node: domNode(node, 0) };
146
+ }
147
+
148
+ function attributesFor(el: Element): string[] {
149
+ const attributes: string[] = [];
150
+ for (const attr of el.attributes) attributes.push(attr.name, attr.value);
151
+ return attributes;
152
+ }
153
+
154
+ function childNodesFor(node: Node, depth: number): Protocol.DOM.Node[] {
155
+ if (depth === 0) return [];
156
+ const nextDepth = depth < 0 ? -1 : depth - 1;
157
+ return Array.from(node.childNodes).map((child) => domNode(child, nextDepth));
158
+ }
159
+
160
+ function domNode(node: Node, depth: number): Protocol.DOM.Node {
161
+ const nodeId = registry.backendIdFor(node);
162
+ const children = childNodesFor(node, depth);
163
+ const nodeName =
164
+ node instanceof Element && node.namespaceURI === "http://www.w3.org/1999/xhtml"
165
+ ? node.nodeName.toUpperCase()
166
+ : node.nodeName;
167
+ const result: Protocol.DOM.Node = {
168
+ backendNodeId: nodeId,
169
+ localName: node instanceof Element ? node.localName : "",
170
+ nodeId,
171
+ nodeName,
172
+ nodeType: node.nodeType,
173
+ nodeValue: node.nodeValue ?? "",
174
+ };
175
+
176
+ if (node.hasChildNodes()) {
177
+ result.childNodeCount = node.childNodes.length;
178
+ if (depth !== 0) result.children = children;
179
+ }
180
+
181
+ if (node instanceof Document) {
182
+ result.documentURL = location.href;
183
+ result.baseURL = document.baseURI;
184
+ result.xmlVersion = "";
185
+ } else if (node instanceof DocumentType) {
186
+ result.publicId = node.publicId;
187
+ result.systemId = node.systemId;
188
+ result.internalSubset = (node as DocumentType & { internalSubset?: string }).internalSubset;
189
+ } else if (node instanceof Element) {
190
+ result.attributes = attributesFor(node);
191
+ if (node instanceof HTMLIFrameElement && node.contentDocument) {
192
+ result.frameId = `${frameId}:${nodeId}`;
193
+ result.contentDocument = domNode(node.contentDocument, depth);
194
+ }
195
+ }
196
+
197
+ return result;
198
+ }
199
+
200
+ function runtimeValue(value: unknown): Protocol.Runtime.RemoteObject {
201
+ if (value === undefined) return { type: "undefined" };
202
+ if (value === null) return { type: "object", subtype: "null", value: null };
203
+ if (value instanceof Node) return nodeRuntimeValue(value);
204
+ if (typeof value === "bigint")
205
+ return { type: "bigint", unserializableValue: `${value}n`, description: `${value}n` };
206
+ if (typeof value === "symbol") return { type: "symbol", description: String(value) };
207
+ if (typeof value === "function") return { type: "function", description: String(value) };
208
+ if (typeof value === "number" && !Number.isFinite(value))
209
+ return { type: "number", unserializableValue: String(value), description: String(value) };
210
+ if (typeof value === "object")
211
+ return {
212
+ type: "object",
213
+ value: JSON.parse(JSON.stringify(value)),
214
+ description: Object.prototype.toString.call(value),
215
+ };
216
+ return { type: typeof value, value };
217
+ }
218
+
219
+ function nodeRuntimeValue(node: Node): Protocol.Runtime.RemoteObject {
220
+ const element = node instanceof Element ? node : node.parentElement;
221
+ const objectId = `backend:${registry.backendIdFor(element || node)}`;
222
+ return {
223
+ type: "object",
224
+ subtype: "node",
225
+ className: node instanceof Element ? node.constructor.name : "Node",
226
+ description: node instanceof Element ? node.outerHTML : node.nodeName,
227
+ objectId,
228
+ };
229
+ }
230
+
231
+ function cloneConsoleObject(value: unknown, seen = new WeakSet<object>(), depth = 0): unknown {
232
+ if (value === null || typeof value !== "object") return value;
233
+ if (seen.has(value)) return "[Circular]";
234
+ if (depth >= 4) return Object.prototype.toString.call(value);
235
+ seen.add(value);
236
+
237
+ if (value instanceof Error) {
238
+ return {
239
+ name: value.name,
240
+ message: value.message,
241
+ stack: value.stack,
242
+ };
243
+ }
244
+ if (value instanceof Element) {
245
+ return {
246
+ tagName: value.tagName.toLowerCase(),
247
+ id: value.id || undefined,
248
+ className:
249
+ typeof value.className === "string" && value.className ? value.className : undefined,
250
+ textContent: (value.textContent || "").trim().slice(0, 120) || undefined,
251
+ };
252
+ }
253
+ if (Array.isArray(value)) {
254
+ return value.slice(0, 100).map((item) => cloneConsoleObject(item, seen, depth + 1));
255
+ }
256
+ if (value instanceof Map) {
257
+ return {
258
+ entries: Array.from(value.entries())
259
+ .slice(0, 100)
260
+ .map(([key, entryValue]) => [
261
+ cloneConsoleObject(key, seen, depth + 1),
262
+ cloneConsoleObject(entryValue, seen, depth + 1),
263
+ ]),
264
+ };
265
+ }
266
+ if (value instanceof Set) {
267
+ return {
268
+ values: Array.from(value.values())
269
+ .slice(0, 100)
270
+ .map((item) => cloneConsoleObject(item, seen, depth + 1)),
271
+ };
272
+ }
273
+ if (value instanceof Date) return value.toISOString();
274
+ if (value instanceof RegExp) return String(value);
275
+
276
+ return Object.fromEntries(
277
+ Object.entries(value)
278
+ .slice(0, 100)
279
+ .map(([key, entryValue]) => [key, cloneConsoleObject(entryValue, seen, depth + 1)]),
280
+ );
281
+ }
282
+
283
+ function consoleRuntimeValue(value: unknown): Protocol.Runtime.RemoteObject {
284
+ if (value === undefined || value === null || typeof value !== "object")
285
+ return runtimeValue(value);
286
+
287
+ const description =
288
+ value instanceof Error
289
+ ? value.stack || `${value.name}: ${value.message}`
290
+ : value instanceof Element
291
+ ? value.outerHTML
292
+ : Object.prototype.toString.call(value);
293
+
294
+ return {
295
+ type: "object",
296
+ subtype:
297
+ value instanceof Error
298
+ ? "error"
299
+ : Array.isArray(value)
300
+ ? "array"
301
+ : value instanceof Element
302
+ ? "node"
303
+ : undefined,
304
+ className: value.constructor?.name,
305
+ description,
306
+ value: cloneConsoleObject(value),
307
+ };
308
+ }
309
+
310
+ function flushQueuedRuntimeEvents(): void {
311
+ while (queuedRuntimeEvents.length) {
312
+ const event = queuedRuntimeEvents.shift();
313
+ if (event) sendToHost(event);
314
+ }
315
+ }
316
+
317
+ function enableRuntime(): Record<string, never> {
318
+ runtimeEnabled = true;
319
+ sendToHost({
320
+ method: "Runtime.executionContextCreated",
321
+ params: {
322
+ context: {
323
+ id: 1,
324
+ name: "top",
325
+ origin: location.origin,
326
+ },
327
+ },
328
+ });
329
+ flushQueuedRuntimeEvents();
330
+ return {};
331
+ }
332
+
333
+ function emitConsole(type: string, args: unknown[]): void {
334
+ sendRuntimeEvent({
335
+ method: "Runtime.consoleAPICalled",
336
+ params: {
337
+ type,
338
+ args: args.map(consoleRuntimeValue),
339
+ executionContextId: 1,
340
+ timestamp: Date.now(),
341
+ stackTrace: { callFrames: [] },
342
+ },
343
+ });
344
+ }
345
+
346
+ function installConsoleBridge(): void {
347
+ const methods: Record<string, string> = {
348
+ clear: "clear",
349
+ debug: "debug",
350
+ dir: "dir",
351
+ error: "error",
352
+ group: "startGroup",
353
+ groupCollapsed: "startGroupCollapsed",
354
+ groupEnd: "endGroup",
355
+ info: "info",
356
+ log: "log",
357
+ table: "table",
358
+ warn: "warning",
359
+ };
360
+
361
+ for (const [name, type] of Object.entries(methods)) {
362
+ const original = (console as unknown as Record<string, unknown>)[name];
363
+ if (typeof original !== "function") continue;
364
+ let current = wrapConsoleMethod(original as ConsoleMethod, type);
365
+ Object.defineProperty(console, name, {
366
+ configurable: true,
367
+ get: () => current,
368
+ set: (next) => {
369
+ current = typeof next === "function" ? wrapConsoleMethod(next, type) : next;
370
+ },
371
+ });
372
+ }
373
+ }
374
+
375
+ function wrapConsoleMethod(fn: ConsoleMethod, type: string): ConsoleMethod {
376
+ if ((fn as ConsoleMethod & { [consoleWrapped]?: true })[consoleWrapped]) return fn;
377
+ const wrapped = function (this: unknown, ...args: unknown[]) {
378
+ fn.apply(console, args);
379
+ emitConsole(type, args);
380
+ } as ConsoleMethod;
381
+ Object.defineProperty(wrapped, consoleWrapped, { value: true });
382
+ return wrapped;
383
+ }
384
+
385
+ function highlight(el: Element): void {
386
+ const rect = el.getBoundingClientRect();
387
+ const marker = document.createElement("div");
388
+ marker.style.cssText = [
389
+ "position:fixed",
390
+ `left:${rect.left}px`,
391
+ `top:${rect.top}px`,
392
+ `width:${rect.width}px`,
393
+ `height:${rect.height}px`,
394
+ "z-index:2147483647",
395
+ "pointer-events:none",
396
+ "outline:2px solid #005fb8",
397
+ "background:rgba(0,95,184,.08)",
398
+ ].join(";");
399
+ document.documentElement.appendChild(marker);
400
+ setTimeout(() => marker.remove(), 500);
401
+ }
402
+
403
+ function setNativeValue(el: HTMLInputElement | HTMLTextAreaElement, value: string): void {
404
+ const proto =
405
+ el instanceof HTMLInputElement ? HTMLInputElement.prototype : HTMLTextAreaElement.prototype;
406
+ const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set;
407
+ setter?.call(el, value);
408
+ }
409
+
410
+ function canSelectText(el: HTMLInputElement): boolean {
411
+ return ["", "text", "search", "tel", "url", "password"].includes(el.type);
412
+ }
413
+
414
+ function insertText(text: string): void {
415
+ const el = document.activeElement as HTMLElement | null;
416
+ if (!el) return;
417
+ highlight(el);
418
+ if (el instanceof HTMLInputElement && !canSelectText(el)) {
419
+ setNativeValue(el, text);
420
+ el.dispatchEvent(
421
+ new InputEvent("input", { bubbles: true, data: text, inputType: "insertText" }),
422
+ );
423
+ el.dispatchEvent(new Event("change", { bubbles: true }));
424
+ } else if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
425
+ const start = el.selectionStart ?? el.value.length;
426
+ const end = el.selectionEnd ?? el.value.length;
427
+ const nextValue = `${el.value.slice(0, start)}${text}${el.value.slice(end)}`;
428
+ setNativeValue(el, nextValue);
429
+ try {
430
+ el.setSelectionRange(start + text.length, start + text.length);
431
+ } catch {}
432
+ el.dispatchEvent(
433
+ new InputEvent("input", { bubbles: true, data: text, inputType: "insertText" }),
434
+ );
435
+ el.dispatchEvent(new Event("change", { bubbles: true }));
436
+ } else if (el.isContentEditable) {
437
+ document.execCommand("insertText", false, text);
438
+ }
439
+ }
440
+
441
+ function deleteBackward(): void {
442
+ const el = document.activeElement as HTMLElement | null;
443
+ if (!el) return;
444
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
445
+ const start = el.selectionStart ?? el.value.length;
446
+ const end = el.selectionEnd ?? el.value.length;
447
+ if (start === 0 && end === 0) return;
448
+ const nextStart = start === end ? start - 1 : start;
449
+ const nextValue = `${el.value.slice(0, nextStart)}${el.value.slice(end)}`;
450
+ setNativeValue(el, nextValue);
451
+ try {
452
+ el.setSelectionRange(nextStart, nextStart);
453
+ } catch {}
454
+ el.dispatchEvent(
455
+ new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward" }),
456
+ );
457
+ el.dispatchEvent(new Event("change", { bubbles: true }));
458
+ } else if (el.isContentEditable) {
459
+ document.execCommand("delete", false);
460
+ }
461
+ }
462
+
463
+ function pageFrame(): Protocol.Page.Frame {
464
+ return {
465
+ id: frameId,
466
+ loaderId: "icdp-loader",
467
+ domainAndRegistry: "",
468
+ mimeType: document.contentType || "text/html",
469
+ securityOrigin: location.origin,
470
+ secureContextType: window.isSecureContext ? "Secure" : "InsecureScheme",
471
+ crossOriginIsolatedContextType: "NotIsolated",
472
+ gatedAPIFeatures: [],
473
+ url: location.href,
474
+ };
475
+ }
476
+
477
+ function emulateNetworkConditionsByRule(
478
+ params: CdpParams<"Network.emulateNetworkConditionsByRule"> = {} as CdpParams<"Network.emulateNetworkConditionsByRule">,
479
+ ): Protocol.Network.EmulateNetworkConditionsByRuleResponse {
480
+ return {
481
+ ruleIds: (params.matchedNetworkConditions ?? []).map((_, index) => `icdp-rule-${index}`),
482
+ };
483
+ }
484
+
485
+ function addScriptToEvaluateOnNewDocument(): Protocol.Page.AddScriptToEvaluateOnNewDocumentResponse {
486
+ return { identifier: `icdp-script-${nextScriptIdentifier++}` };
487
+ }
488
+
489
+ function getFrameTree(): Protocol.Page.GetFrameTreeResponse {
490
+ return {
491
+ frameTree: { frame: pageFrame() },
492
+ };
493
+ }
494
+
495
+ function getResourceTree(): Protocol.Page.GetResourceTreeResponse {
496
+ return {
497
+ frameTree: {
498
+ frame: pageFrame(),
499
+ resources: [],
500
+ },
501
+ };
502
+ }
503
+
504
+ function getStorageKey(): Protocol.Storage.GetStorageKeyResponse {
505
+ return { storageKey: location.origin };
506
+ }
507
+
508
+ function axOptions() {
509
+ return { document, frameId, registry };
510
+ }
511
+
512
+ /** Our DOM domain unifies nodeId with backendNodeId, so either resolves here. */
513
+ function axTargetBackendId(params: {
514
+ backendNodeId?: number;
515
+ nodeId?: number;
516
+ }): Protocol.DOM.BackendNodeId | undefined {
517
+ const id = Number(params.backendNodeId ?? params.nodeId);
518
+ return Number.isFinite(id) && id > 0 ? id : undefined;
519
+ }
520
+
521
+ /** Strict target resolution with Chromium's AssertNode error messages. */
522
+ function assertAXTarget(params: {
523
+ backendNodeId?: number;
524
+ nodeId?: number;
525
+ objectId?: string;
526
+ }): Protocol.DOM.BackendNodeId {
527
+ if (params.nodeId == null && params.backendNodeId == null && params.objectId == null)
528
+ throw new Error("Either nodeId, backendNodeId or objectId must be specified");
529
+ if (params.nodeId != null) {
530
+ const id = Number(params.nodeId);
531
+ if (registry.nodeForBackendId(id)) return id;
532
+ throw new Error("Could not find node with given id");
533
+ }
534
+ if (params.backendNodeId != null) {
535
+ const id = Number(params.backendNodeId);
536
+ if (registry.nodeForBackendId(id)) return id;
537
+ throw new Error("No node found for given backend id");
538
+ }
539
+ const raw = String(params.objectId);
540
+ if (raw.startsWith("backend:")) {
541
+ const id = Number(raw.slice("backend:".length));
542
+ if (registry.nodeForBackendId(id)) return id;
543
+ }
544
+ throw new Error("Invalid remote object id");
545
+ }
546
+
547
+ function getFullAccessibilityTree(
548
+ params: CdpParams<"Accessibility.getFullAXTree"> = {} as CdpParams<"Accessibility.getFullAXTree">,
549
+ ): Protocol.Accessibility.GetFullAXTreeResponse {
550
+ return getFullAXTree(axOptions(), params.depth);
551
+ }
552
+
553
+ function getPartialAccessibilityTree(
554
+ params: CdpParams<"Accessibility.getPartialAXTree"> = {} as CdpParams<"Accessibility.getPartialAXTree">,
555
+ ): Protocol.Accessibility.GetPartialAXTreeResponse {
556
+ return getPartialAXTree(axOptions(), axTargetBackendId(params), params.fetchRelatives ?? true);
557
+ }
558
+
559
+ function getRootAccessibilityNode(): Protocol.Accessibility.GetRootAXNodeResponse {
560
+ return getRootAXNode(axOptions());
561
+ }
562
+
563
+ function getChildAccessibilityNodes(
564
+ params: CdpParams<"Accessibility.getChildAXNodes">,
565
+ ): Protocol.Accessibility.GetChildAXNodesResponse {
566
+ return getChildAXNodes(axOptions(), String(params.id));
567
+ }
568
+
569
+ function getAccessibilityNodeAndAncestors(
570
+ params: CdpParams<"Accessibility.getAXNodeAndAncestors"> = {} as CdpParams<"Accessibility.getAXNodeAndAncestors">,
571
+ ): Protocol.Accessibility.GetAXNodeAndAncestorsResponse {
572
+ const target = axTargetBackendId(params);
573
+ if (target == null) throw new Error("getAXNodeAndAncestors requires a nodeId or backendNodeId");
574
+ return getAXNodeAndAncestors(axOptions(), target);
575
+ }
576
+
577
+ function queryAccessibilityTree(
578
+ params: CdpParams<"Accessibility.queryAXTree"> = {} as CdpParams<"Accessibility.queryAXTree">,
579
+ ): Protocol.Accessibility.QueryAXTreeResponse {
580
+ return queryAXTree(axOptions(), {
581
+ target: assertAXTarget(params),
582
+ accessibleName: params.accessibleName,
583
+ role: params.role,
584
+ });
585
+ }
586
+
587
+ function getDocument(
588
+ params: CdpParams<"DOM.getDocument"> = {} as CdpParams<"DOM.getDocument">,
589
+ ): Protocol.DOM.GetDocumentResponse {
590
+ return {
591
+ root: domNode(document, Number(params.depth ?? 1)),
592
+ };
593
+ }
594
+
595
+ function queryRoot(nodeId: unknown): Document | Element {
596
+ if (nodeId == null || Number(nodeId) === 0) return document;
597
+ const node = registry.nodeForBackendId(Number(nodeId));
598
+ if (node instanceof Document) return node;
599
+ if (node instanceof Element) return node;
600
+ return node?.parentElement ?? document;
601
+ }
602
+
603
+ function querySelector(params: CdpParams<"DOM.querySelector">): Protocol.DOM.QuerySelectorResponse {
604
+ const root = queryRoot(params.nodeId);
605
+ const element = root.querySelector(params.selector);
606
+ return { nodeId: element ? registry.backendIdFor(element) : 0 };
607
+ }
608
+
609
+ function querySelectorAll(
610
+ params: CdpParams<"DOM.querySelectorAll">,
611
+ ): Protocol.DOM.QuerySelectorAllResponse {
612
+ const root = queryRoot(params.nodeId);
613
+ return {
614
+ nodeIds: Array.from(root.querySelectorAll(params.selector)).map((element) =>
615
+ registry.backendIdFor(element),
616
+ ),
617
+ };
618
+ }
619
+
620
+ function matchingSearchNodes(query: string): Protocol.DOM.BackendNodeId[] {
621
+ try {
622
+ return Array.from(document.querySelectorAll(query)).map((element) =>
623
+ registry.backendIdFor(element),
624
+ );
625
+ } catch {}
626
+
627
+ const text = query.toLowerCase();
628
+ return Array.from(document.querySelectorAll("*"))
629
+ .filter((element) => (element.textContent || "").toLowerCase().includes(text))
630
+ .map((element) => registry.backendIdFor(element));
631
+ }
632
+
633
+ function performSearch(params: CdpParams<"DOM.performSearch">): Protocol.DOM.PerformSearchResponse {
634
+ const searchId = `icdp-search-${nextSearchId++}`;
635
+ const nodes = matchingSearchNodes(String(params.query || ""));
636
+ searchResults.set(searchId, nodes);
637
+ return { searchId, resultCount: nodes.length };
638
+ }
639
+
640
+ function getSearchResults(
641
+ params: CdpParams<"DOM.getSearchResults">,
642
+ ): Protocol.DOM.GetSearchResultsResponse {
643
+ const nodes = searchResults.get(params.searchId) ?? [];
644
+ return { nodeIds: nodes.slice(params.fromIndex, params.toIndex) };
645
+ }
646
+
647
+ function discardSearchResults(
648
+ params: CdpParams<"DOM.discardSearchResults">,
649
+ ): Record<string, never> {
650
+ searchResults.delete(params.searchId);
651
+ return {};
652
+ }
653
+
654
+ function getAttributes(params: CdpParams<"DOM.getAttributes">): Protocol.DOM.GetAttributesResponse {
655
+ const node = registry.nodeForBackendId(Number(params.nodeId));
656
+ return { attributes: node instanceof Element ? attributesFor(node) : [] };
657
+ }
658
+
659
+ function getOuterHTML(params: CdpParams<"DOM.getOuterHTML">): Protocol.DOM.GetOuterHTMLResponse {
660
+ const node = registry.nodeForBackendId(Number(params.backendNodeId ?? params.nodeId));
661
+ if (node instanceof Element) return { outerHTML: node.outerHTML };
662
+ if (node instanceof Document) return { outerHTML: node.documentElement.outerHTML };
663
+ return { outerHTML: "" };
664
+ }
665
+
666
+ function focusNode(params: CdpParams<"DOM.focus">): Record<string, never> {
667
+ const element = elementForBackendId(Number(params.backendNodeId ?? params.nodeId));
668
+ if (element instanceof HTMLElement) element.focus();
669
+ return {};
670
+ }
671
+
672
+ function scrollIntoViewIfNeeded(
673
+ params: CdpParams<"DOM.scrollIntoViewIfNeeded">,
674
+ ): Record<string, never> {
675
+ elementForBackendId(Number(params.backendNodeId ?? params.nodeId)).scrollIntoView({
676
+ block: "center",
677
+ inline: "center",
678
+ });
679
+ return {};
680
+ }
681
+
682
+ function requestChildNodes(params: CdpParams<"DOM.requestChildNodes">): Record<string, never> {
683
+ const node = registry.nodeForBackendId(Number(params.nodeId));
684
+ if (!node) throw new Error(`No element for backendDOMNodeId=${params.nodeId}`);
685
+ sendToHost({
686
+ method: "DOM.setChildNodes",
687
+ params: {
688
+ parentId: Number(params.nodeId),
689
+ nodes: childNodesFor(node, Number(params.depth ?? 1)),
690
+ },
691
+ });
692
+ return {};
693
+ }
694
+
695
+ function describeDomNode(
696
+ params: CdpParams<"DOM.describeNode"> = {} as CdpParams<"DOM.describeNode">,
697
+ ): Protocol.DOM.DescribeNodeResponse {
698
+ return describeNode(Number(params.backendNodeId ?? params.nodeId ?? 1));
699
+ }
700
+
701
+ function resolveNode(
702
+ params: CdpParams<"DOM.resolveNode"> = {} as CdpParams<"DOM.resolveNode">,
703
+ ): Protocol.DOM.ResolveNodeResponse {
704
+ return {
705
+ object: {
706
+ objectId: `backend:${params.backendNodeId ?? params.nodeId}`,
707
+ type: "object",
708
+ className: "Element",
709
+ },
710
+ };
711
+ }
712
+
713
+ function pushNodesByBackendIdsToFrontend(
714
+ params: CdpParams<"DOM.pushNodesByBackendIdsToFrontend"> = {} as CdpParams<"DOM.pushNodesByBackendIdsToFrontend">,
715
+ ): Protocol.DOM.PushNodesByBackendIdsToFrontendResponse {
716
+ return { nodeIds: (params.backendNodeIds ?? []).map((id) => id) };
717
+ }
718
+
719
+ function getBoxModel(params: CdpParams<"DOM.getBoxModel">): Protocol.DOM.GetBoxModelResponse {
720
+ return boxModel(Number(params.backendNodeId ?? params.nodeId));
721
+ }
722
+
723
+ function getContentQuads(
724
+ params: CdpParams<"DOM.getContentQuads">,
725
+ ): Protocol.DOM.GetContentQuadsResponse {
726
+ return contentQuads(Number(params.backendNodeId ?? params.nodeId));
727
+ }
728
+
729
+ function getComputedStyleForNode(params: CdpParams<"CSS.getComputedStyleForNode">): {
730
+ computedStyle: Protocol.CSS.CSSComputedStyleProperty[];
731
+ } {
732
+ const style = getComputedStyle(elementForBackendId(Number(params.nodeId)));
733
+ return {
734
+ computedStyle: Array.from(style).map((name) => ({
735
+ name,
736
+ value: style.getPropertyValue(name),
737
+ })),
738
+ };
739
+ }
740
+
741
+ function mouseEvent(type: string, params: CdpParams<"Input.dispatchMouseEvent">): MouseEvent {
742
+ return new MouseEvent(type, {
743
+ bubbles: true,
744
+ cancelable: true,
745
+ button: params.button === "right" ? 2 : params.button === "middle" ? 1 : 0,
746
+ buttons: Number(params.buttons ?? 0),
747
+ clientX: Number(params.x ?? 0),
748
+ clientY: Number(params.y ?? 0),
749
+ });
750
+ }
751
+
752
+ function wheelEvent(params: CdpParams<"Input.dispatchMouseEvent">): WheelEvent {
753
+ return new WheelEvent("wheel", {
754
+ bubbles: true,
755
+ cancelable: true,
756
+ clientX: Number(params.x ?? 0),
757
+ clientY: Number(params.y ?? 0),
758
+ deltaX: Number(params.deltaX ?? 0),
759
+ deltaY: Number(params.deltaY ?? 0),
760
+ });
761
+ }
762
+
763
+ function scrollableAncestor(el: Element | null): Element | null {
764
+ for (let current = el; current; current = current.parentElement) {
765
+ const style = getComputedStyle(current);
766
+ if (/(auto|scroll)/.test(`${style.overflow}${style.overflowX}${style.overflowY}`))
767
+ return current;
768
+ }
769
+ return document.scrollingElement;
770
+ }
771
+
772
+ function dispatchMouseEvent(params: CdpParams<"Input.dispatchMouseEvent">): Record<string, never> {
773
+ const target =
774
+ document.elementFromPoint(Number(params.x ?? 0), Number(params.y ?? 0)) ||
775
+ document.documentElement;
776
+ if (!(target instanceof Element)) return {};
777
+
778
+ if (params.type === "mouseMoved") {
779
+ if (hoveredElement !== target) {
780
+ hoveredElement?.dispatchEvent(mouseEvent("mouseout", params));
781
+ target.dispatchEvent(mouseEvent("mouseover", params));
782
+ target.dispatchEvent(mouseEvent("mouseenter", params));
783
+ hoveredElement = target;
784
+ }
785
+ target.dispatchEvent(mouseEvent("mousemove", params));
786
+ } else if (params.type === "mousePressed") {
787
+ pressedElement = target;
788
+ target.dispatchEvent(mouseEvent("mousedown", params));
789
+ } else if (params.type === "mouseReleased") {
790
+ target.dispatchEvent(mouseEvent("mouseup", params));
791
+ if (pressedElement === target) {
792
+ (target as HTMLElement).click();
793
+ const now = Date.now();
794
+ if (
795
+ Number(params.clickCount ?? 1) > 1 ||
796
+ (lastClickElement === target && now - lastClickTime < 500)
797
+ ) {
798
+ target.dispatchEvent(mouseEvent("dblclick", params));
799
+ }
800
+ lastClickElement = target;
801
+ lastClickTime = now;
802
+ }
803
+ pressedElement = null;
804
+ } else if (params.type === "mouseWheel") {
805
+ target.dispatchEvent(wheelEvent(params));
806
+ const scroller = scrollableAncestor(target);
807
+ scroller?.scrollBy(Number(params.deltaX ?? 0), Number(params.deltaY ?? 0));
808
+ }
809
+ return {};
810
+ }
811
+
812
+ function dispatchKeyEvent(params: CdpParams<"Input.dispatchKeyEvent">): Record<string, never> {
813
+ const key = String(params.key || params.code || params.text || "");
814
+ const target = document.activeElement || document.body;
815
+ if (params.type === "keyDown" || params.type === "rawKeyDown") {
816
+ target?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key }));
817
+ if (key === "Backspace") deleteBackward();
818
+ } else if (params.type === "keyUp") {
819
+ target?.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true, cancelable: true, key }));
820
+ }
821
+ if (typeof params.text === "string" && params.text) insertText(params.text);
822
+ return {};
823
+ }
824
+
825
+ function inputInsertText(params: CdpParams<"Input.insertText">): Record<string, never> {
826
+ if (typeof params.text === "string" && params.text) insertText(params.text);
827
+ return {};
828
+ }
829
+
830
+ function navigate(params: CdpParams<"Page.navigate">): Protocol.Page.NavigateResponse {
831
+ const next = new URL(String(params.url || "/"), location.href);
832
+ if (next.origin !== location.origin)
833
+ throw new Error("Navigation outside the embedded app's origin is not allowed");
834
+ location.href = next.href;
835
+ return { frameId };
836
+ }
837
+
838
+ async function evaluate(
839
+ params: CdpParams<"Runtime.evaluate">,
840
+ ): Promise<Protocol.Runtime.EvaluateResponse> {
841
+ // biome-ignore lint/security/noGlobalEval: CDP Runtime.evaluate intentionally executes page expressions.
842
+ const indirectEval = globalThis.eval;
843
+ const value = indirectEval(String(params.expression || ""));
844
+ return {
845
+ result: runtimeValue(params.awaitPromise && value instanceof Promise ? await value : value),
846
+ };
847
+ }
848
+
849
+ async function callFunctionOn(
850
+ params: CdpParams<"Runtime.callFunctionOn">,
851
+ ): Promise<Protocol.Runtime.CallFunctionOnResponse> {
852
+ const id = String(params.objectId || "").startsWith("backend:")
853
+ ? Number(String(params.objectId).slice(8))
854
+ : NaN;
855
+ const target = Number.isFinite(id) ? elementForBackendId(id) : window;
856
+ // biome-ignore lint/security/noGlobalEval: CDP Runtime.callFunctionOn intentionally executes page functions.
857
+ const indirectEval = globalThis.eval;
858
+ const fn = indirectEval(`(${params.functionDeclaration})`) as (
859
+ this: unknown,
860
+ ...args: unknown[]
861
+ ) => unknown;
862
+ const value = fn.call(
863
+ target,
864
+ ...((params.arguments || []) as Array<{ value?: unknown; objectId?: string }>).map((arg) => {
865
+ if (arg.objectId?.startsWith("backend:"))
866
+ return elementForBackendId(Number(arg.objectId.slice(8)));
867
+ return arg.value;
868
+ }),
869
+ );
870
+ return {
871
+ result: runtimeValue(params.awaitPromise && value instanceof Promise ? await value : value),
872
+ };
873
+ }
874
+
875
+ cdp.register("Accessibility", {
876
+ disable: noop,
877
+ enable: noop,
878
+ getFullAXTree: getFullAccessibilityTree,
879
+ getPartialAXTree: getPartialAccessibilityTree,
880
+ getRootAXNode: getRootAccessibilityNode,
881
+ getChildAXNodes: getChildAccessibilityNodes,
882
+ getAXNodeAndAncestors: getAccessibilityNodeAndAncestors,
883
+ queryAXTree: queryAccessibilityTree,
884
+ });
885
+
886
+ cdp.register("Animation", {
887
+ enable: noop,
888
+ });
889
+
890
+ cdp.register("Autofill", {
891
+ setAddresses: noop,
892
+ });
893
+
894
+ cdp.register("CSS", {
895
+ disable: noop,
896
+ enable: noop,
897
+ getComputedStyleForNode,
898
+ });
899
+
900
+ cdp.register("DOM", {
901
+ discardSearchResults,
902
+ describeNode: describeDomNode,
903
+ enable: noop,
904
+ focus: focusNode,
905
+ getAttributes,
906
+ getBoxModel,
907
+ getContentQuads,
908
+ getDocument,
909
+ getOuterHTML,
910
+ getSearchResults,
911
+ performSearch,
912
+ pushNodesByBackendIdsToFrontend,
913
+ querySelector,
914
+ querySelectorAll,
915
+ requestChildNodes,
916
+ resolveNode,
917
+ scrollIntoViewIfNeeded,
918
+ });
919
+
920
+ cdp.register("Input", {
921
+ dispatchKeyEvent,
922
+ dispatchMouseEvent,
923
+ insertText: inputInsertText,
924
+ });
925
+
926
+ cdp.register("Network", {
927
+ emulateNetworkConditionsByRule,
928
+ overrideNetworkState: noop,
929
+ setBlockedURLs: noop,
930
+ });
931
+
932
+ cdp.register("Page", {
933
+ addScriptToEvaluateOnNewDocument,
934
+ getFrameTree,
935
+ getResourceTree,
936
+ navigate,
937
+ });
938
+
939
+ cdp.register("Runtime", {
940
+ addBinding: noop,
941
+ callFunctionOn,
942
+ enable: enableRuntime,
943
+ evaluate,
944
+ runIfWaitingForDebugger: noop,
945
+ });
946
+
947
+ cdp.register("Storage", {
948
+ getStorageKey,
949
+ });
950
+
951
+ // ---------------------------------------------------------------------------
952
+ // Handshake: announce to the parent, adopt the MessagePort the Host transfers.
953
+ // ---------------------------------------------------------------------------
954
+
955
+ const ANNOUNCE_RETRIES = 10;
956
+ const ANNOUNCE_INTERVAL_MS = 300;
957
+
958
+ let started = false;
959
+
960
+ function parentAllowed(origin: string, allowed: string[] | "*"): boolean {
961
+ return allowed === "*" || allowed.includes(origin);
962
+ }
963
+
964
+ async function handleCommand(raw: string): Promise<void> {
965
+ const request = JSON.parse(raw) as CdpRequest;
966
+ if (request.id != null) outboundMethods.set(request.id, request.method);
967
+ try {
968
+ await chobitsu.sendRawMessage(raw);
969
+ } catch (error) {
970
+ if (request.id != null) outboundMethods.delete(request.id);
971
+ sendToHost({
972
+ id: request.id,
973
+ error: {
974
+ code: CDP_SERVER_ERROR,
975
+ message: error instanceof Error ? error.message : String(error),
976
+ },
977
+ });
978
+ }
979
+ }
980
+
981
+ function adoptPort(next: MessagePort): void {
982
+ port?.close();
983
+ port = next;
984
+ next.onmessage = (event) => {
985
+ void handleCommand(String(event.data));
986
+ };
987
+ sendToHost({
988
+ method: "Page.frameNavigated",
989
+ params: { frame: pageFrame() },
990
+ });
991
+ sendToHost({
992
+ method: "Page.domContentEventFired",
993
+ params: { timestamp: performance.now() / 1000 },
994
+ });
995
+ sendToHost({
996
+ method: "Page.loadEventFired",
997
+ params: { timestamp: performance.now() / 1000 },
998
+ });
999
+ }
1000
+
1001
+ function announce(allowed: string[] | "*"): void {
1002
+ const hello = {
1003
+ icdp: "hello",
1004
+ v: PROTOCOL_VERSION,
1005
+ title: document.title || location.href,
1006
+ url: location.href,
1007
+ } satisfies HandshakeMessage;
1008
+ const targetOrigins = allowed === "*" ? ["*"] : allowed;
1009
+ for (const origin of targetOrigins) {
1010
+ try {
1011
+ window.parent.postMessage(hello, origin);
1012
+ } catch {}
1013
+ }
1014
+ }
1015
+
1016
+ /**
1017
+ * Boot the Frame Agent. No-op when the page is not embedded. The agent stays
1018
+ * dormant (announces, but never adopts a channel) unless a parent on the
1019
+ * allowlist answers with a welcome.
1020
+ */
1021
+ export function startFrameAgent(options: FrameAgentOptions): void {
1022
+ if (started || window.parent === window) return;
1023
+ started = true;
1024
+
1025
+ const allowed = options.allowedParents;
1026
+
1027
+ window.addEventListener("message", (event) => {
1028
+ if (event.source !== window.parent || !isHandshakeMessage(event.data)) return;
1029
+ if (!parentAllowed(event.origin, allowed)) return;
1030
+ if (event.data.icdp === "probe") {
1031
+ announce(allowed);
1032
+ } else if (event.data.icdp === "welcome" && event.ports[0]) {
1033
+ adoptPort(event.ports[0]);
1034
+ }
1035
+ });
1036
+
1037
+ installConsoleBridge();
1038
+ announce(allowed);
1039
+
1040
+ let attempts = 0;
1041
+ const retry = window.setInterval(() => {
1042
+ if (port || ++attempts >= ANNOUNCE_RETRIES) {
1043
+ window.clearInterval(retry);
1044
+ return;
1045
+ }
1046
+ announce(allowed);
1047
+ }, ANNOUNCE_INTERVAL_MS);
1048
+ }