@olimsaidov/icdp 0.1.0 → 0.2.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.
- package/README.md +21 -1
- package/dist/host/index.d.mts +32 -3
- package/dist/host/index.mjs +75 -5
- package/dist/protocol.d.mts +22 -3
- package/dist/relay/core.d.mts +13 -0
- package/dist/relay/core.mjs +83 -2
- package/package.json +1 -1
- package/src/host/index.ts +107 -3
- package/src/protocol.ts +27 -3
- package/src/relay/core.ts +109 -1
package/README.md
CHANGED
|
@@ -52,6 +52,26 @@ const disconnect = host.connectRelay({ url: "ws://localhost:9222/icdp/host" });
|
|
|
52
52
|
|
|
53
53
|
Target identity belongs to the Pairing: reloads and navigations keep the same `targetId` (Clients see `Page.frameNavigated`); commands in flight when a document dies fail fast with `-32000`. The Relay uplink is just another consumer of the same hub — events broadcast to all attached sessions, domain enables are ref-counted.
|
|
54
54
|
|
|
55
|
+
#### Client-driven target lifecycle (optional)
|
|
56
|
+
|
|
57
|
+
By default only the Host creates Targets (via `pair()`), so a Client's `Target.createTarget` is rejected and `Target.closeTarget` is a no-op. Pass `onCreateTarget` / `onCloseTarget` to let a Client open and close Targets itself:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
const host = new IcdpHost({
|
|
61
|
+
onCreateTarget: ({ url }) => {
|
|
62
|
+
const iframe = document.createElement("iframe");
|
|
63
|
+
iframe.src = url ?? "about:blank";
|
|
64
|
+
document.body.append(iframe);
|
|
65
|
+
const targetId = crypto.randomUUID();
|
|
66
|
+
host.pair(iframe, { targetId, origins: ["https://app.example.com"] });
|
|
67
|
+
return targetId; // string | Promise<string>
|
|
68
|
+
},
|
|
69
|
+
onCloseTarget: (targetId) => host.unpair(targetId), // + remove the iframe you made
|
|
70
|
+
});
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The Host advertises which of these it handles, so the Relay forwards only those and keeps its defaults for any hook you leave unset (existing Hosts are unaffected). `createTarget` resolves **only once the new Target completes its handshake**, so the Client's first command can't race the not-connected gate; a Target that never connects (timeout or early destroy) is torn down rather than left as a zombie. A bare `new IcdpHost(window)` is still accepted for back-compat.
|
|
74
|
+
|
|
55
75
|
### Relay — the server
|
|
56
76
|
|
|
57
77
|
```ts
|
|
@@ -68,7 +88,7 @@ One Host per Relay, new-wins: a newly connecting Host replaces a stale one, with
|
|
|
68
88
|
|
|
69
89
|
## Protocol shape
|
|
70
90
|
|
|
71
|
-
- **Flat sessions only.** Clients connect to the single browser-level endpoint and use `Target.getTargets` / `Target.attachToTarget` (or `Target.setAutoAttach`) + `sessionId` routing. There are no per-target WebSocket URLs. Session-scoped `Target.*`/`Browser.*` housekeeping (e.g. agent-browser's session-scoped `Target.setAutoAttach`) is answered by the Relay; the Frame Agent never sees it.
|
|
91
|
+
- **Flat sessions only.** Clients connect to the single browser-level endpoint and use `Target.getTargets` / `Target.attachToTarget` (or `Target.setAutoAttach`) + `sessionId` routing. There are no per-target WebSocket URLs. Session-scoped `Target.*`/`Browser.*` housekeeping (e.g. agent-browser's session-scoped `Target.setAutoAttach`) is answered by the Relay; the Frame Agent never sees it. Registry methods (`getTargets`/`attachToTarget`/`setAutoAttach`) are always Relay-owned; only the `Target.createTarget`/`Target.closeTarget` lifecycle can be delegated to the Host (see [Client-driven target lifecycle](#client-driven-target-lifecycle-optional)).
|
|
72
92
|
- **Compatibility bar: agent-browser.** The supported command surface is the prior art's support matrix (AX-tree snapshots, semantic locators, click/fill/type, eval, waits, console, SPA history). Screenshots, PDF, file uploads, drag-and-drop, dialogs, and real network interception are intentionally out — page JavaScript cannot provide them. Raw Playwright over `connectOverCDP` is best-effort, not promised.
|
|
73
93
|
|
|
74
94
|
## Driving an icdp target with agent-browser
|
package/dist/host/index.d.mts
CHANGED
|
@@ -63,14 +63,33 @@ type RelayUplinkOptions = {
|
|
|
63
63
|
reconnectDelayMs?: number;
|
|
64
64
|
webSocketFactory?: (url: string) => WebSocket;
|
|
65
65
|
};
|
|
66
|
+
/** Params of a CDP Target.createTarget request handed to onCreateTarget. */
|
|
67
|
+
type CreateTargetParams = {
|
|
68
|
+
url?: string;
|
|
69
|
+
} & Record<string, unknown>;
|
|
70
|
+
type IcdpHostOptions = {
|
|
71
|
+
/** The window to listen on (defaults to the global `window`). */window?: WindowLike;
|
|
72
|
+
/**
|
|
73
|
+
* Handle a Client's `Target.createTarget`: create + `pair()` an iframe and
|
|
74
|
+
* return its `targetId`. The Relay's response resolves only after the new
|
|
75
|
+
* Target connects, so the Client's first commands land. Throw to reject.
|
|
76
|
+
*/
|
|
77
|
+
onCreateTarget?: (params: CreateTargetParams) => string | Promise<string>;
|
|
78
|
+
/**
|
|
79
|
+
* Handle a Client's `Target.closeTarget`: tear the Target down (e.g.
|
|
80
|
+
* `unpair()` + remove the iframe). Throw to reject.
|
|
81
|
+
*/
|
|
82
|
+
onCloseTarget?: (targetId: string) => void | Promise<void>;
|
|
83
|
+
};
|
|
66
84
|
declare class IcdpHost {
|
|
67
|
-
private readonly win;
|
|
68
85
|
private readonly pairings;
|
|
69
86
|
private readonly targetListeners;
|
|
70
87
|
private nextLocalSession;
|
|
71
88
|
private uplink;
|
|
89
|
+
private readonly win;
|
|
90
|
+
private readonly options;
|
|
72
91
|
private readonly onWindowMessage;
|
|
73
|
-
constructor(
|
|
92
|
+
constructor(optionsOrWindow?: IcdpHostOptions | WindowLike);
|
|
74
93
|
/** Register an iframe slot as a Target. The Pairing owns target identity. */
|
|
75
94
|
pair(iframe: FrameElementLike, options: PairOptions): void;
|
|
76
95
|
/** Destroy a Pairing. This is the only way a Target dies. */
|
|
@@ -81,6 +100,16 @@ declare class IcdpHost {
|
|
|
81
100
|
attach(targetId: string): LocalSession;
|
|
82
101
|
/** Connect the Relay uplink. Structurally just another consumer of this hub. */
|
|
83
102
|
connectRelay(options: RelayUplinkOptions): () => void;
|
|
103
|
+
/** Browser-level methods this Host handles, advertised to the Relay so it
|
|
104
|
+
* forwards them instead of using its built-in default. */
|
|
105
|
+
handledMethods(): string[];
|
|
106
|
+
/** Run a browser-level method the Host advertised (invoked by the Relay
|
|
107
|
+
* uplink). The resolved value, or a thrown error, becomes the Client's
|
|
108
|
+
* response. createTarget resolves only once the new Target connects. */
|
|
109
|
+
handleBrowserRequest(method: string, params: Record<string, unknown>): Promise<unknown>;
|
|
110
|
+
/** Resolve once a paired Target completes its handshake (targetInfoChanged);
|
|
111
|
+
* reject if it is destroyed first or does not connect within the timeout. */
|
|
112
|
+
private whenConnected;
|
|
84
113
|
destroy(): void;
|
|
85
114
|
private summary;
|
|
86
115
|
private emitTargetEvent;
|
|
@@ -96,4 +125,4 @@ declare class IcdpHost {
|
|
|
96
125
|
releaseEnablesFor(targetId: string, consumerKey: string): void;
|
|
97
126
|
}
|
|
98
127
|
//#endregion
|
|
99
|
-
export { FrameElementLike, IcdpHost, LocalSession, PairOptions, RelayUplinkOptions, TargetEvent, WindowLike };
|
|
128
|
+
export { CreateTargetParams, FrameElementLike, IcdpHost, IcdpHostOptions, LocalSession, PairOptions, RelayUplinkOptions, TargetEvent, WindowLike };
|
package/dist/host/index.mjs
CHANGED
|
@@ -5,15 +5,17 @@ function methodDomain(method) {
|
|
|
5
5
|
return method.split(".")[0] ?? method;
|
|
6
6
|
}
|
|
7
7
|
var IcdpHost = class {
|
|
8
|
-
win;
|
|
9
8
|
pairings = /* @__PURE__ */ new Map();
|
|
10
9
|
targetListeners = /* @__PURE__ */ new Set();
|
|
11
10
|
nextLocalSession = 1;
|
|
12
11
|
uplink = null;
|
|
12
|
+
win;
|
|
13
|
+
options;
|
|
13
14
|
onWindowMessage = (event) => this.handleWindowMessage(event);
|
|
14
|
-
constructor(
|
|
15
|
-
this.
|
|
16
|
-
win
|
|
15
|
+
constructor(optionsOrWindow = {}) {
|
|
16
|
+
this.options = "addEventListener" in optionsOrWindow ? { window: optionsOrWindow } : optionsOrWindow;
|
|
17
|
+
this.win = this.options.window ?? window;
|
|
18
|
+
this.win.addEventListener("message", this.onWindowMessage);
|
|
17
19
|
}
|
|
18
20
|
/** Register an iframe slot as a Target. The Pairing owns target identity. */
|
|
19
21
|
pair(iframe, options) {
|
|
@@ -98,6 +100,61 @@ var IcdpHost = class {
|
|
|
98
100
|
this.uplink = null;
|
|
99
101
|
};
|
|
100
102
|
}
|
|
103
|
+
/** Browser-level methods this Host handles, advertised to the Relay so it
|
|
104
|
+
* forwards them instead of using its built-in default. */
|
|
105
|
+
handledMethods() {
|
|
106
|
+
const methods = [];
|
|
107
|
+
if (this.options.onCreateTarget) methods.push("Target.createTarget");
|
|
108
|
+
if (this.options.onCloseTarget) methods.push("Target.closeTarget");
|
|
109
|
+
return methods;
|
|
110
|
+
}
|
|
111
|
+
/** Run a browser-level method the Host advertised (invoked by the Relay
|
|
112
|
+
* uplink). The resolved value, or a thrown error, becomes the Client's
|
|
113
|
+
* response. createTarget resolves only once the new Target connects. */
|
|
114
|
+
async handleBrowserRequest(method, params) {
|
|
115
|
+
if (method === "Target.createTarget") {
|
|
116
|
+
if (!this.options.onCreateTarget) throw new Error(`${method} is not handled by this Host`);
|
|
117
|
+
const targetId = await this.options.onCreateTarget(params);
|
|
118
|
+
try {
|
|
119
|
+
await this.whenConnected(targetId);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
this.unpair(targetId);
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
return { targetId };
|
|
125
|
+
}
|
|
126
|
+
if (method === "Target.closeTarget") {
|
|
127
|
+
if (!this.options.onCloseTarget) throw new Error(`${method} is not handled by this Host`);
|
|
128
|
+
await this.options.onCloseTarget(String(params.targetId ?? ""));
|
|
129
|
+
return { success: true };
|
|
130
|
+
}
|
|
131
|
+
throw new Error(`Unhandled browser method: ${method}`);
|
|
132
|
+
}
|
|
133
|
+
/** Resolve once a paired Target completes its handshake (targetInfoChanged);
|
|
134
|
+
* reject if it is destroyed first or does not connect within the timeout. */
|
|
135
|
+
whenConnected(targetId, timeoutMs = 1e4) {
|
|
136
|
+
const pairing = this.pairings.get(targetId);
|
|
137
|
+
if (!pairing) return Promise.reject(/* @__PURE__ */ new Error(`Unknown target "${targetId}"`));
|
|
138
|
+
if (pairing.connected) return Promise.resolve();
|
|
139
|
+
return new Promise((resolve, reject) => {
|
|
140
|
+
let timer;
|
|
141
|
+
const off = this.onTargets((event) => {
|
|
142
|
+
if (event.kind === "targetInfoChanged" && event.target.targetId === targetId) {
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
off();
|
|
145
|
+
resolve();
|
|
146
|
+
} else if (event.kind === "targetDestroyed" && event.targetId === targetId) {
|
|
147
|
+
clearTimeout(timer);
|
|
148
|
+
off();
|
|
149
|
+
reject(/* @__PURE__ */ new Error(`Target "${targetId}" was destroyed before connecting`));
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
timer = setTimeout(() => {
|
|
153
|
+
off();
|
|
154
|
+
reject(/* @__PURE__ */ new Error(`Target "${targetId}" did not connect within ${timeoutMs}ms`));
|
|
155
|
+
}, timeoutMs);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
101
158
|
destroy() {
|
|
102
159
|
this.uplink?.close();
|
|
103
160
|
this.uplink = null;
|
|
@@ -261,7 +318,8 @@ var RelayUplink = class {
|
|
|
261
318
|
this.send({
|
|
262
319
|
kind: "ready",
|
|
263
320
|
v: 1,
|
|
264
|
-
targets: this.host.targets()
|
|
321
|
+
targets: this.host.targets(),
|
|
322
|
+
handles: this.host.handledMethods()
|
|
265
323
|
});
|
|
266
324
|
});
|
|
267
325
|
socket.addEventListener("message", (event) => {
|
|
@@ -300,6 +358,18 @@ var RelayUplink = class {
|
|
|
300
358
|
});
|
|
301
359
|
});
|
|
302
360
|
else if (message.kind === "detached") this.host.releaseEnablesFor(message.targetId, `relay-${message.sessionId}`);
|
|
361
|
+
else if (message.kind === "browserRequest") this.host.handleBrowserRequest(message.method, message.params).then((result) => this.send({
|
|
362
|
+
kind: "browserResult",
|
|
363
|
+
id: message.id,
|
|
364
|
+
result
|
|
365
|
+
}), (error) => this.send({
|
|
366
|
+
kind: "browserResult",
|
|
367
|
+
id: message.id,
|
|
368
|
+
error: {
|
|
369
|
+
code: CDP_SERVER_ERROR,
|
|
370
|
+
message: error instanceof Error ? error.message : String(error)
|
|
371
|
+
}
|
|
372
|
+
}));
|
|
303
373
|
}
|
|
304
374
|
handleFrameEvent(targetId, method, params) {
|
|
305
375
|
this.send({
|
package/dist/protocol.d.mts
CHANGED
|
@@ -49,6 +49,10 @@ type BridgeReady = {
|
|
|
49
49
|
kind: "ready";
|
|
50
50
|
v: number;
|
|
51
51
|
targets: TargetSummary[];
|
|
52
|
+
/** Browser-level methods (e.g. "Target.createTarget") the Host handles itself.
|
|
53
|
+
* The Relay forwards these as a BridgeBrowserRequest instead of using its
|
|
54
|
+
* built-in default; omitted/empty means the Relay keeps its defaults. */
|
|
55
|
+
handles?: string[];
|
|
52
56
|
};
|
|
53
57
|
/** Host -> Relay: a Pairing appeared. */
|
|
54
58
|
type BridgeTargetCreated = {
|
|
@@ -95,8 +99,23 @@ type BridgeDetached = {
|
|
|
95
99
|
sessionId: string;
|
|
96
100
|
targetId: string;
|
|
97
101
|
};
|
|
98
|
-
|
|
99
|
-
|
|
102
|
+
/** Relay -> Host: a browser-level method the Host advertised it handles
|
|
103
|
+
* (e.g. Target.createTarget / Target.closeTarget). Not session-scoped. */
|
|
104
|
+
type BridgeBrowserRequest = {
|
|
105
|
+
kind: "browserRequest";
|
|
106
|
+
id: number;
|
|
107
|
+
method: string;
|
|
108
|
+
params: Record<string, unknown>;
|
|
109
|
+
};
|
|
110
|
+
/** Host -> Relay: the response to a BridgeBrowserRequest. */
|
|
111
|
+
type BridgeBrowserResult = {
|
|
112
|
+
kind: "browserResult";
|
|
113
|
+
id: number;
|
|
114
|
+
result?: unknown;
|
|
115
|
+
error?: CdpError;
|
|
116
|
+
};
|
|
117
|
+
type HostToRelayMessage = BridgeReady | BridgeTargetCreated | BridgeTargetDestroyed | BridgeTargetInfoChanged | BridgeResponse | BridgeEvent | BridgeBrowserResult;
|
|
118
|
+
type RelayToHostMessage = BridgeCommand | BridgeDetached | BridgeBrowserRequest;
|
|
100
119
|
declare function parseJson<T>(raw: string | Buffer | ArrayBuffer | Uint8Array): T | null;
|
|
101
120
|
//#endregion
|
|
102
|
-
export { BridgeCommand, BridgeDetached, BridgeEvent, BridgeReady, BridgeResponse, BridgeTargetCreated, BridgeTargetDestroyed, BridgeTargetInfoChanged, CDP_METHOD_NOT_FOUND, CDP_SERVER_ERROR, CdpError, CdpId, CdpMessage, FrameInfo, HandshakeMessage, HelloMessage, HostToRelayMessage, PROTOCOL_VERSION, ProbeMessage, RelayToHostMessage, TargetSummary, WelcomeMessage, isHandshakeMessage, parseJson };
|
|
121
|
+
export { BridgeBrowserRequest, BridgeBrowserResult, BridgeCommand, BridgeDetached, BridgeEvent, BridgeReady, BridgeResponse, BridgeTargetCreated, BridgeTargetDestroyed, BridgeTargetInfoChanged, CDP_METHOD_NOT_FOUND, CDP_SERVER_ERROR, CdpError, CdpId, CdpMessage, FrameInfo, HandshakeMessage, HelloMessage, HostToRelayMessage, PROTOCOL_VERSION, ProbeMessage, RelayToHostMessage, TargetSummary, WelcomeMessage, isHandshakeMessage, parseJson };
|
package/dist/relay/core.d.mts
CHANGED
|
@@ -9,6 +9,9 @@ type SocketLike = {
|
|
|
9
9
|
type RelayCoreOptions = {
|
|
10
10
|
/** Reported by Browser.getVersion and /json/version. */product?: string; /** Absolute WebSocket URL of the browser endpoint, for /json payloads. */
|
|
11
11
|
browserWsUrl?: string;
|
|
12
|
+
/** How long to wait for the Host to answer a forwarded browser-level request
|
|
13
|
+
* before failing the Client. Backstops a silent or hung Host. */
|
|
14
|
+
browserRequestTimeoutMs?: number;
|
|
12
15
|
};
|
|
13
16
|
declare class RelayCore {
|
|
14
17
|
private readonly product;
|
|
@@ -18,6 +21,11 @@ declare class RelayCore {
|
|
|
18
21
|
private readonly sessions;
|
|
19
22
|
private readonly targets;
|
|
20
23
|
private readonly pending;
|
|
24
|
+
/** Browser-level requests forwarded to the Host, awaiting a result. */
|
|
25
|
+
private readonly browserPending;
|
|
26
|
+
/** Browser-level methods the Host advertised it handles (from the ready message). */
|
|
27
|
+
private readonly hostHandles;
|
|
28
|
+
private readonly browserRequestTimeoutMs;
|
|
21
29
|
private nextBridgeId;
|
|
22
30
|
private nextSessionId;
|
|
23
31
|
constructor(options?: RelayCoreOptions);
|
|
@@ -38,6 +46,11 @@ declare class RelayCore {
|
|
|
38
46
|
private targetInfo;
|
|
39
47
|
private sendToClient;
|
|
40
48
|
private sendToHost;
|
|
49
|
+
private failBrowserPending;
|
|
50
|
+
/** Bound a forwarded browser request so a silent or hung Host can't pin a
|
|
51
|
+
* Client's command open forever (and leak the pending entry). */
|
|
52
|
+
private armBrowserTimeout;
|
|
53
|
+
private expireBrowserPending;
|
|
41
54
|
private broadcastTargetEvent;
|
|
42
55
|
private addTarget;
|
|
43
56
|
private removeTarget;
|
package/dist/relay/core.mjs
CHANGED
|
@@ -2,6 +2,10 @@ import { CDP_METHOD_NOT_FOUND, CDP_SERVER_ERROR, parseJson } from "../protocol.m
|
|
|
2
2
|
//#region src/relay/core.ts
|
|
3
3
|
/** Sentinel: the method was handled and a response was already sent. */
|
|
4
4
|
const RESPONDED = Symbol("responded");
|
|
5
|
+
/** Browser-domain methods a Host may take ownership of via the ready `handles`.
|
|
6
|
+
* Registry methods (getTargets/attachToTarget/setAutoAttach/...) stay relay-owned:
|
|
7
|
+
* they read the Relay's own session/target state, so the Host can't answer them. */
|
|
8
|
+
const FORWARDABLE_BROWSER_METHODS = new Set(["Target.createTarget", "Target.closeTarget"]);
|
|
5
9
|
var RelayCore = class {
|
|
6
10
|
product;
|
|
7
11
|
browserWsUrl;
|
|
@@ -10,11 +14,17 @@ var RelayCore = class {
|
|
|
10
14
|
sessions = /* @__PURE__ */ new Map();
|
|
11
15
|
targets = /* @__PURE__ */ new Map();
|
|
12
16
|
pending = /* @__PURE__ */ new Map();
|
|
17
|
+
/** Browser-level requests forwarded to the Host, awaiting a result. */
|
|
18
|
+
browserPending = /* @__PURE__ */ new Map();
|
|
19
|
+
/** Browser-level methods the Host advertised it handles (from the ready message). */
|
|
20
|
+
hostHandles = /* @__PURE__ */ new Set();
|
|
21
|
+
browserRequestTimeoutMs;
|
|
13
22
|
nextBridgeId = 1;
|
|
14
23
|
nextSessionId = 1;
|
|
15
24
|
constructor(options = {}) {
|
|
16
25
|
this.product = options.product ?? "icdp/0.1";
|
|
17
26
|
this.browserWsUrl = options.browserWsUrl ?? "";
|
|
27
|
+
this.browserRequestTimeoutMs = options.browserRequestTimeoutMs ?? 3e4;
|
|
18
28
|
}
|
|
19
29
|
/** A Host bridge connected. New-wins: any previous Host is dropped. */
|
|
20
30
|
hostConnected(socket) {
|
|
@@ -31,6 +41,8 @@ var RelayCore = class {
|
|
|
31
41
|
hostDisconnected(socket) {
|
|
32
42
|
if (this.hostSocket !== socket) return;
|
|
33
43
|
this.hostSocket = null;
|
|
44
|
+
this.hostHandles.clear();
|
|
45
|
+
this.failBrowserPending("Host disconnected");
|
|
34
46
|
this.dropAllTargets("Host disconnected");
|
|
35
47
|
}
|
|
36
48
|
hostMessage(socket, raw) {
|
|
@@ -40,6 +52,8 @@ var RelayCore = class {
|
|
|
40
52
|
switch (message.kind) {
|
|
41
53
|
case "ready":
|
|
42
54
|
this.dropAllTargets("Host re-announced");
|
|
55
|
+
this.hostHandles.clear();
|
|
56
|
+
for (const handled of message.handles ?? []) if (FORWARDABLE_BROWSER_METHODS.has(handled)) this.hostHandles.add(handled);
|
|
43
57
|
for (const target of message.targets) this.addTarget(target);
|
|
44
58
|
return;
|
|
45
59
|
case "targetCreated":
|
|
@@ -74,6 +88,18 @@ var RelayCore = class {
|
|
|
74
88
|
});
|
|
75
89
|
}
|
|
76
90
|
return;
|
|
91
|
+
case "browserResult": {
|
|
92
|
+
const call = this.browserPending.get(message.id);
|
|
93
|
+
if (!call) return;
|
|
94
|
+
this.browserPending.delete(message.id);
|
|
95
|
+
clearTimeout(call.timer);
|
|
96
|
+
this.sendToClient(call.client, {
|
|
97
|
+
id: call.clientId,
|
|
98
|
+
...call.sessionId ? { sessionId: call.sessionId } : {},
|
|
99
|
+
...message.error ? { error: message.error } : { result: message.result ?? {} }
|
|
100
|
+
});
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
77
103
|
}
|
|
78
104
|
}
|
|
79
105
|
clientConnected(socket) {
|
|
@@ -89,12 +115,33 @@ var RelayCore = class {
|
|
|
89
115
|
if (!client) return;
|
|
90
116
|
this.clients.delete(socket);
|
|
91
117
|
for (const sessionId of client.sessions) this.endSession(sessionId, { notifyClient: false });
|
|
118
|
+
for (const [id, call] of this.browserPending) {
|
|
119
|
+
if (call.client !== client) continue;
|
|
120
|
+
this.browserPending.delete(id);
|
|
121
|
+
clearTimeout(call.timer);
|
|
122
|
+
}
|
|
92
123
|
}
|
|
93
124
|
clientMessage(socket, raw) {
|
|
94
125
|
const client = this.clients.get(socket);
|
|
95
126
|
if (!client) return;
|
|
96
127
|
const message = parseJson(raw);
|
|
97
128
|
if (!message) return;
|
|
129
|
+
if (message.method && this.hostHandles.has(message.method) && this.hostSocket) {
|
|
130
|
+
const bridgeId = this.nextBridgeId++;
|
|
131
|
+
this.browserPending.set(bridgeId, {
|
|
132
|
+
client,
|
|
133
|
+
clientId: message.id,
|
|
134
|
+
sessionId: message.sessionId,
|
|
135
|
+
timer: this.armBrowserTimeout(bridgeId)
|
|
136
|
+
});
|
|
137
|
+
this.sendToHost({
|
|
138
|
+
kind: "browserRequest",
|
|
139
|
+
id: bridgeId,
|
|
140
|
+
method: message.method,
|
|
141
|
+
params: message.params ?? {}
|
|
142
|
+
});
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
98
145
|
if (message.sessionId) {
|
|
99
146
|
this.routeSessionCommand(client, message);
|
|
100
147
|
return;
|
|
@@ -164,6 +211,40 @@ var RelayCore = class {
|
|
|
164
211
|
this.hostSocket?.send(JSON.stringify(message));
|
|
165
212
|
} catch {}
|
|
166
213
|
}
|
|
214
|
+
failBrowserPending(reason) {
|
|
215
|
+
for (const [id, call] of this.browserPending) {
|
|
216
|
+
this.browserPending.delete(id);
|
|
217
|
+
clearTimeout(call.timer);
|
|
218
|
+
this.sendToClient(call.client, {
|
|
219
|
+
id: call.clientId,
|
|
220
|
+
...call.sessionId ? { sessionId: call.sessionId } : {},
|
|
221
|
+
error: {
|
|
222
|
+
code: CDP_SERVER_ERROR,
|
|
223
|
+
message: reason
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/** Bound a forwarded browser request so a silent or hung Host can't pin a
|
|
229
|
+
* Client's command open forever (and leak the pending entry). */
|
|
230
|
+
armBrowserTimeout(bridgeId) {
|
|
231
|
+
const timer = setTimeout(() => this.expireBrowserPending(bridgeId), this.browserRequestTimeoutMs);
|
|
232
|
+
timer.unref?.();
|
|
233
|
+
return timer;
|
|
234
|
+
}
|
|
235
|
+
expireBrowserPending(bridgeId) {
|
|
236
|
+
const call = this.browserPending.get(bridgeId);
|
|
237
|
+
if (!call) return;
|
|
238
|
+
this.browserPending.delete(bridgeId);
|
|
239
|
+
this.sendToClient(call.client, {
|
|
240
|
+
id: call.clientId,
|
|
241
|
+
...call.sessionId ? { sessionId: call.sessionId } : {},
|
|
242
|
+
error: {
|
|
243
|
+
code: CDP_SERVER_ERROR,
|
|
244
|
+
message: "Host did not respond to the browser-level request in time"
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
}
|
|
167
248
|
broadcastTargetEvent(method, params) {
|
|
168
249
|
for (const client of this.clients.values()) {
|
|
169
250
|
if (!client.discoverTargets) continue;
|
|
@@ -342,8 +423,8 @@ var RelayCore = class {
|
|
|
342
423
|
case "Browser.setWindowBounds":
|
|
343
424
|
case "Security.setIgnoreCertificateErrors":
|
|
344
425
|
case "Target.setRemoteLocations":
|
|
345
|
-
case "Target.activateTarget":
|
|
346
|
-
case "Target.closeTarget": return {};
|
|
426
|
+
case "Target.activateTarget": return {};
|
|
427
|
+
case "Target.closeTarget": return { success: true };
|
|
347
428
|
case "Schema.getDomains": return { domains: [] };
|
|
348
429
|
case "Target.getTargets": return { targetInfos: Array.from(this.targets.values(), (target) => this.targetInfo(target)) };
|
|
349
430
|
case "Target.getTargetInfo": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@olimsaidov/icdp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Chrome DevTools Protocol over an iframe boundary: drive and inspect embedded (including cross-origin) apps with CDP tools, without a real browser debugging session.",
|
|
5
5
|
"homepage": "https://github.com/olimsaidov/icdp#readme",
|
|
6
6
|
"bugs": "https://github.com/olimsaidov/icdp/issues",
|
package/src/host/index.ts
CHANGED
|
@@ -86,15 +86,40 @@ function methodDomain(method: string): string {
|
|
|
86
86
|
return method.split(".")[0] ?? method;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/** Params of a CDP Target.createTarget request handed to onCreateTarget. */
|
|
90
|
+
export type CreateTargetParams = { url?: string } & Record<string, unknown>;
|
|
91
|
+
|
|
92
|
+
export type IcdpHostOptions = {
|
|
93
|
+
/** The window to listen on (defaults to the global `window`). */
|
|
94
|
+
window?: WindowLike;
|
|
95
|
+
/**
|
|
96
|
+
* Handle a Client's `Target.createTarget`: create + `pair()` an iframe and
|
|
97
|
+
* return its `targetId`. The Relay's response resolves only after the new
|
|
98
|
+
* Target connects, so the Client's first commands land. Throw to reject.
|
|
99
|
+
*/
|
|
100
|
+
onCreateTarget?: (params: CreateTargetParams) => string | Promise<string>;
|
|
101
|
+
/**
|
|
102
|
+
* Handle a Client's `Target.closeTarget`: tear the Target down (e.g.
|
|
103
|
+
* `unpair()` + remove the iframe). Throw to reject.
|
|
104
|
+
*/
|
|
105
|
+
onCloseTarget?: (targetId: string) => void | Promise<void>;
|
|
106
|
+
};
|
|
107
|
+
|
|
89
108
|
export class IcdpHost {
|
|
90
109
|
private readonly pairings = new Map<string, Pairing>();
|
|
91
110
|
private readonly targetListeners = new Set<(event: TargetEvent) => void>();
|
|
92
111
|
private nextLocalSession = 1;
|
|
93
112
|
private uplink: RelayUplink | null = null;
|
|
113
|
+
private readonly win: WindowLike;
|
|
114
|
+
private readonly options: IcdpHostOptions;
|
|
94
115
|
private readonly onWindowMessage = (event: MessageEvent) => this.handleWindowMessage(event);
|
|
95
116
|
|
|
96
|
-
constructor(
|
|
97
|
-
|
|
117
|
+
constructor(optionsOrWindow: IcdpHostOptions | WindowLike = {}) {
|
|
118
|
+
// Back-compat: a bare WindowLike (has addEventListener) is still accepted.
|
|
119
|
+
this.options =
|
|
120
|
+
"addEventListener" in optionsOrWindow ? { window: optionsOrWindow } : optionsOrWindow;
|
|
121
|
+
this.win = this.options.window ?? window;
|
|
122
|
+
this.win.addEventListener("message", this.onWindowMessage);
|
|
98
123
|
}
|
|
99
124
|
|
|
100
125
|
/** Register an iframe slot as a Target. The Pairing owns target identity. */
|
|
@@ -181,6 +206,67 @@ export class IcdpHost {
|
|
|
181
206
|
};
|
|
182
207
|
}
|
|
183
208
|
|
|
209
|
+
/** Browser-level methods this Host handles, advertised to the Relay so it
|
|
210
|
+
* forwards them instead of using its built-in default. */
|
|
211
|
+
handledMethods(): string[] {
|
|
212
|
+
const methods: string[] = [];
|
|
213
|
+
if (this.options.onCreateTarget) methods.push("Target.createTarget");
|
|
214
|
+
if (this.options.onCloseTarget) methods.push("Target.closeTarget");
|
|
215
|
+
return methods;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Run a browser-level method the Host advertised (invoked by the Relay
|
|
219
|
+
* uplink). The resolved value, or a thrown error, becomes the Client's
|
|
220
|
+
* response. createTarget resolves only once the new Target connects. */
|
|
221
|
+
async handleBrowserRequest(method: string, params: Record<string, unknown>): Promise<unknown> {
|
|
222
|
+
if (method === "Target.createTarget") {
|
|
223
|
+
if (!this.options.onCreateTarget) throw new Error(`${method} is not handled by this Host`);
|
|
224
|
+
const targetId = await this.options.onCreateTarget(params as CreateTargetParams);
|
|
225
|
+
try {
|
|
226
|
+
await this.whenConnected(targetId);
|
|
227
|
+
} catch (error) {
|
|
228
|
+
// The Target never materialised (timed out, or destroyed mid-handshake).
|
|
229
|
+
// Tear down the half-created Pairing so it doesn't linger as a zombie in
|
|
230
|
+
// the Host, the Relay, and Target.getTargets. unpair() is idempotent.
|
|
231
|
+
this.unpair(targetId);
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
return { targetId };
|
|
235
|
+
}
|
|
236
|
+
if (method === "Target.closeTarget") {
|
|
237
|
+
if (!this.options.onCloseTarget) throw new Error(`${method} is not handled by this Host`);
|
|
238
|
+
await this.options.onCloseTarget(String(params.targetId ?? ""));
|
|
239
|
+
return { success: true };
|
|
240
|
+
}
|
|
241
|
+
throw new Error(`Unhandled browser method: ${method}`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Resolve once a paired Target completes its handshake (targetInfoChanged);
|
|
245
|
+
* reject if it is destroyed first or does not connect within the timeout. */
|
|
246
|
+
private whenConnected(targetId: string, timeoutMs = 10_000): Promise<void> {
|
|
247
|
+
const pairing = this.pairings.get(targetId);
|
|
248
|
+
if (!pairing) return Promise.reject(new Error(`Unknown target "${targetId}"`));
|
|
249
|
+
if (pairing.connected) return Promise.resolve();
|
|
250
|
+
return new Promise<void>((resolve, reject) => {
|
|
251
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
252
|
+
const off = this.onTargets((event) => {
|
|
253
|
+
if (event.kind === "targetInfoChanged" && event.target.targetId === targetId) {
|
|
254
|
+
clearTimeout(timer);
|
|
255
|
+
off();
|
|
256
|
+
resolve();
|
|
257
|
+
} else if (event.kind === "targetDestroyed" && event.targetId === targetId) {
|
|
258
|
+
clearTimeout(timer);
|
|
259
|
+
off();
|
|
260
|
+
reject(new Error(`Target "${targetId}" was destroyed before connecting`));
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
timer = setTimeout(() => {
|
|
264
|
+
off();
|
|
265
|
+
reject(new Error(`Target "${targetId}" did not connect within ${timeoutMs}ms`));
|
|
266
|
+
}, timeoutMs);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
184
270
|
destroy(): void {
|
|
185
271
|
this.uplink?.close();
|
|
186
272
|
this.uplink = null;
|
|
@@ -354,7 +440,12 @@ class RelayUplink {
|
|
|
354
440
|
const socket = factory(this.options.url);
|
|
355
441
|
this.socket = socket;
|
|
356
442
|
socket.addEventListener("open", () => {
|
|
357
|
-
this.send({
|
|
443
|
+
this.send({
|
|
444
|
+
kind: "ready",
|
|
445
|
+
v: PROTOCOL_VERSION,
|
|
446
|
+
targets: this.host.targets(),
|
|
447
|
+
handles: this.host.handledMethods(),
|
|
448
|
+
});
|
|
358
449
|
});
|
|
359
450
|
socket.addEventListener("message", (event) => {
|
|
360
451
|
const message = parseJson<RelayToHostMessage>(String(event.data));
|
|
@@ -406,6 +497,19 @@ class RelayUplink {
|
|
|
406
497
|
);
|
|
407
498
|
} else if (message.kind === "detached") {
|
|
408
499
|
this.host.releaseEnablesFor(message.targetId, `relay-${message.sessionId}`);
|
|
500
|
+
} else if (message.kind === "browserRequest") {
|
|
501
|
+
this.host.handleBrowserRequest(message.method, message.params).then(
|
|
502
|
+
(result) => this.send({ kind: "browserResult", id: message.id, result }),
|
|
503
|
+
(error: unknown) =>
|
|
504
|
+
this.send({
|
|
505
|
+
kind: "browserResult",
|
|
506
|
+
id: message.id,
|
|
507
|
+
error: {
|
|
508
|
+
code: CDP_SERVER_ERROR,
|
|
509
|
+
message: error instanceof Error ? error.message : String(error),
|
|
510
|
+
},
|
|
511
|
+
}),
|
|
512
|
+
);
|
|
409
513
|
}
|
|
410
514
|
}
|
|
411
515
|
|
package/src/protocol.ts
CHANGED
|
@@ -70,7 +70,15 @@ export function isHandshakeMessage(data: unknown): data is HandshakeMessage {
|
|
|
70
70
|
// ---------------------------------------------------------------------------
|
|
71
71
|
|
|
72
72
|
/** Host -> Relay: announces itself and its current targets. New-wins: the Relay drops any previous Host. */
|
|
73
|
-
export type BridgeReady = {
|
|
73
|
+
export type BridgeReady = {
|
|
74
|
+
kind: "ready";
|
|
75
|
+
v: number;
|
|
76
|
+
targets: TargetSummary[];
|
|
77
|
+
/** Browser-level methods (e.g. "Target.createTarget") the Host handles itself.
|
|
78
|
+
* The Relay forwards these as a BridgeBrowserRequest instead of using its
|
|
79
|
+
* built-in default; omitted/empty means the Relay keeps its defaults. */
|
|
80
|
+
handles?: string[];
|
|
81
|
+
};
|
|
74
82
|
/** Host -> Relay: a Pairing appeared. */
|
|
75
83
|
export type BridgeTargetCreated = { kind: "targetCreated"; target: TargetSummary };
|
|
76
84
|
/** Host -> Relay: a Pairing was destroyed by the Host. */
|
|
@@ -103,6 +111,21 @@ export type BridgeEvent = {
|
|
|
103
111
|
};
|
|
104
112
|
/** Relay -> Host: a session detached (Client disconnected or detached explicitly). */
|
|
105
113
|
export type BridgeDetached = { kind: "detached"; sessionId: string; targetId: string };
|
|
114
|
+
/** Relay -> Host: a browser-level method the Host advertised it handles
|
|
115
|
+
* (e.g. Target.createTarget / Target.closeTarget). Not session-scoped. */
|
|
116
|
+
export type BridgeBrowserRequest = {
|
|
117
|
+
kind: "browserRequest";
|
|
118
|
+
id: number;
|
|
119
|
+
method: string;
|
|
120
|
+
params: Record<string, unknown>;
|
|
121
|
+
};
|
|
122
|
+
/** Host -> Relay: the response to a BridgeBrowserRequest. */
|
|
123
|
+
export type BridgeBrowserResult = {
|
|
124
|
+
kind: "browserResult";
|
|
125
|
+
id: number;
|
|
126
|
+
result?: unknown;
|
|
127
|
+
error?: CdpError;
|
|
128
|
+
};
|
|
106
129
|
|
|
107
130
|
export type HostToRelayMessage =
|
|
108
131
|
| BridgeReady
|
|
@@ -110,9 +133,10 @@ export type HostToRelayMessage =
|
|
|
110
133
|
| BridgeTargetDestroyed
|
|
111
134
|
| BridgeTargetInfoChanged
|
|
112
135
|
| BridgeResponse
|
|
113
|
-
| BridgeEvent
|
|
136
|
+
| BridgeEvent
|
|
137
|
+
| BridgeBrowserResult;
|
|
114
138
|
|
|
115
|
-
export type RelayToHostMessage = BridgeCommand | BridgeDetached;
|
|
139
|
+
export type RelayToHostMessage = BridgeCommand | BridgeDetached | BridgeBrowserRequest;
|
|
116
140
|
|
|
117
141
|
export function parseJson<T>(raw: string | Buffer | ArrayBuffer | Uint8Array): T | null {
|
|
118
142
|
try {
|
package/src/relay/core.ts
CHANGED
|
@@ -21,6 +21,9 @@ export type RelayCoreOptions = {
|
|
|
21
21
|
product?: string;
|
|
22
22
|
/** Absolute WebSocket URL of the browser endpoint, for /json payloads. */
|
|
23
23
|
browserWsUrl?: string;
|
|
24
|
+
/** How long to wait for the Host to answer a forwarded browser-level request
|
|
25
|
+
* before failing the Client. Backstops a silent or hung Host. */
|
|
26
|
+
browserRequestTimeoutMs?: number;
|
|
24
27
|
};
|
|
25
28
|
|
|
26
29
|
type ClientState = {
|
|
@@ -45,6 +48,11 @@ type PendingCommand = {
|
|
|
45
48
|
/** Sentinel: the method was handled and a response was already sent. */
|
|
46
49
|
const RESPONDED = Symbol("responded");
|
|
47
50
|
|
|
51
|
+
/** Browser-domain methods a Host may take ownership of via the ready `handles`.
|
|
52
|
+
* Registry methods (getTargets/attachToTarget/setAutoAttach/...) stay relay-owned:
|
|
53
|
+
* they read the Relay's own session/target state, so the Host can't answer them. */
|
|
54
|
+
const FORWARDABLE_BROWSER_METHODS = new Set(["Target.createTarget", "Target.closeTarget"]);
|
|
55
|
+
|
|
48
56
|
export class RelayCore {
|
|
49
57
|
private readonly product: string;
|
|
50
58
|
private readonly browserWsUrl: string;
|
|
@@ -53,12 +61,27 @@ export class RelayCore {
|
|
|
53
61
|
private readonly sessions = new Map<string, SessionState>();
|
|
54
62
|
private readonly targets = new Map<string, TargetSummary>();
|
|
55
63
|
private readonly pending = new Map<number, PendingCommand>();
|
|
64
|
+
/** Browser-level requests forwarded to the Host, awaiting a result. */
|
|
65
|
+
private readonly browserPending = new Map<
|
|
66
|
+
number,
|
|
67
|
+
{
|
|
68
|
+
client: ClientState;
|
|
69
|
+
clientId: CdpId | undefined;
|
|
70
|
+
/** Echoed back if the Client scoped the request to a session. */
|
|
71
|
+
sessionId: string | undefined;
|
|
72
|
+
timer: ReturnType<typeof setTimeout>;
|
|
73
|
+
}
|
|
74
|
+
>();
|
|
75
|
+
/** Browser-level methods the Host advertised it handles (from the ready message). */
|
|
76
|
+
private readonly hostHandles = new Set<string>();
|
|
77
|
+
private readonly browserRequestTimeoutMs: number;
|
|
56
78
|
private nextBridgeId = 1;
|
|
57
79
|
private nextSessionId = 1;
|
|
58
80
|
|
|
59
81
|
constructor(options: RelayCoreOptions = {}) {
|
|
60
82
|
this.product = options.product ?? "icdp/0.1";
|
|
61
83
|
this.browserWsUrl = options.browserWsUrl ?? "";
|
|
84
|
+
this.browserRequestTimeoutMs = options.browserRequestTimeoutMs ?? 30_000;
|
|
62
85
|
}
|
|
63
86
|
|
|
64
87
|
// -- adapter wiring ---------------------------------------------------------
|
|
@@ -79,6 +102,8 @@ export class RelayCore {
|
|
|
79
102
|
hostDisconnected(socket: SocketLike): void {
|
|
80
103
|
if (this.hostSocket !== socket) return;
|
|
81
104
|
this.hostSocket = null;
|
|
105
|
+
this.hostHandles.clear();
|
|
106
|
+
this.failBrowserPending("Host disconnected");
|
|
82
107
|
this.dropAllTargets("Host disconnected");
|
|
83
108
|
}
|
|
84
109
|
|
|
@@ -89,6 +114,9 @@ export class RelayCore {
|
|
|
89
114
|
switch (message.kind) {
|
|
90
115
|
case "ready":
|
|
91
116
|
this.dropAllTargets("Host re-announced");
|
|
117
|
+
this.hostHandles.clear();
|
|
118
|
+
for (const handled of message.handles ?? [])
|
|
119
|
+
if (FORWARDABLE_BROWSER_METHODS.has(handled)) this.hostHandles.add(handled);
|
|
92
120
|
for (const target of message.targets) this.addTarget(target);
|
|
93
121
|
return;
|
|
94
122
|
case "targetCreated":
|
|
@@ -127,6 +155,18 @@ export class RelayCore {
|
|
|
127
155
|
}
|
|
128
156
|
return;
|
|
129
157
|
}
|
|
158
|
+
case "browserResult": {
|
|
159
|
+
const call = this.browserPending.get(message.id);
|
|
160
|
+
if (!call) return;
|
|
161
|
+
this.browserPending.delete(message.id);
|
|
162
|
+
clearTimeout(call.timer);
|
|
163
|
+
this.sendToClient(call.client, {
|
|
164
|
+
id: call.clientId,
|
|
165
|
+
...(call.sessionId ? { sessionId: call.sessionId } : {}),
|
|
166
|
+
...(message.error ? { error: message.error } : { result: message.result ?? {} }),
|
|
167
|
+
});
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
130
170
|
}
|
|
131
171
|
}
|
|
132
172
|
|
|
@@ -144,6 +184,12 @@ export class RelayCore {
|
|
|
144
184
|
if (!client) return;
|
|
145
185
|
this.clients.delete(socket);
|
|
146
186
|
for (const sessionId of client.sessions) this.endSession(sessionId, { notifyClient: false });
|
|
187
|
+
// Drop any browser-level request still in flight for this gone Client.
|
|
188
|
+
for (const [id, call] of this.browserPending) {
|
|
189
|
+
if (call.client !== client) continue;
|
|
190
|
+
this.browserPending.delete(id);
|
|
191
|
+
clearTimeout(call.timer);
|
|
192
|
+
}
|
|
147
193
|
}
|
|
148
194
|
|
|
149
195
|
clientMessage(socket: SocketLike, raw: string): void {
|
|
@@ -152,6 +198,27 @@ export class RelayCore {
|
|
|
152
198
|
const message = parseJson<CdpMessage>(raw);
|
|
153
199
|
if (!message) return;
|
|
154
200
|
|
|
201
|
+
// Browser-domain lifecycle methods the Host advertised it handles → forward
|
|
202
|
+
// and await its result, instead of using the relay's built-in default below.
|
|
203
|
+
// These are not session-scoped, so we honour them whether or not the Client
|
|
204
|
+
// attached a sessionId (any sessionId is only echoed back on the response).
|
|
205
|
+
if (message.method && this.hostHandles.has(message.method) && this.hostSocket) {
|
|
206
|
+
const bridgeId = this.nextBridgeId++;
|
|
207
|
+
this.browserPending.set(bridgeId, {
|
|
208
|
+
client,
|
|
209
|
+
clientId: message.id,
|
|
210
|
+
sessionId: message.sessionId,
|
|
211
|
+
timer: this.armBrowserTimeout(bridgeId),
|
|
212
|
+
});
|
|
213
|
+
this.sendToHost({
|
|
214
|
+
kind: "browserRequest",
|
|
215
|
+
id: bridgeId,
|
|
216
|
+
method: message.method,
|
|
217
|
+
params: message.params ?? {},
|
|
218
|
+
});
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
155
222
|
if (message.sessionId) {
|
|
156
223
|
this.routeSessionCommand(client, message);
|
|
157
224
|
return;
|
|
@@ -234,6 +301,44 @@ export class RelayCore {
|
|
|
234
301
|
} catch {}
|
|
235
302
|
}
|
|
236
303
|
|
|
304
|
+
private failBrowserPending(reason: string): void {
|
|
305
|
+
for (const [id, call] of this.browserPending) {
|
|
306
|
+
this.browserPending.delete(id);
|
|
307
|
+
clearTimeout(call.timer);
|
|
308
|
+
this.sendToClient(call.client, {
|
|
309
|
+
id: call.clientId,
|
|
310
|
+
...(call.sessionId ? { sessionId: call.sessionId } : {}),
|
|
311
|
+
error: { code: CDP_SERVER_ERROR, message: reason },
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Bound a forwarded browser request so a silent or hung Host can't pin a
|
|
317
|
+
* Client's command open forever (and leak the pending entry). */
|
|
318
|
+
private armBrowserTimeout(bridgeId: number): ReturnType<typeof setTimeout> {
|
|
319
|
+
const timer = setTimeout(
|
|
320
|
+
() => this.expireBrowserPending(bridgeId),
|
|
321
|
+
this.browserRequestTimeoutMs,
|
|
322
|
+
);
|
|
323
|
+
// Don't keep a Node event loop alive just for this backstop.
|
|
324
|
+
(timer as { unref?: () => void }).unref?.();
|
|
325
|
+
return timer;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private expireBrowserPending(bridgeId: number): void {
|
|
329
|
+
const call = this.browserPending.get(bridgeId);
|
|
330
|
+
if (!call) return;
|
|
331
|
+
this.browserPending.delete(bridgeId);
|
|
332
|
+
this.sendToClient(call.client, {
|
|
333
|
+
id: call.clientId,
|
|
334
|
+
...(call.sessionId ? { sessionId: call.sessionId } : {}),
|
|
335
|
+
error: {
|
|
336
|
+
code: CDP_SERVER_ERROR,
|
|
337
|
+
message: "Host did not respond to the browser-level request in time",
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
237
342
|
private broadcastTargetEvent(method: string, params: Record<string, unknown>): void {
|
|
238
343
|
for (const client of this.clients.values()) {
|
|
239
344
|
if (!client.discoverTargets) continue;
|
|
@@ -421,8 +526,11 @@ export class RelayCore {
|
|
|
421
526
|
case "Security.setIgnoreCertificateErrors":
|
|
422
527
|
case "Target.setRemoteLocations":
|
|
423
528
|
case "Target.activateTarget":
|
|
424
|
-
case "Target.closeTarget":
|
|
425
529
|
return {};
|
|
530
|
+
// CDP's Target.closeTarget returns { success }. This default applies only
|
|
531
|
+
// when no Host advertised the method (otherwise it's forwarded above).
|
|
532
|
+
case "Target.closeTarget":
|
|
533
|
+
return { success: true };
|
|
426
534
|
case "Schema.getDomains":
|
|
427
535
|
return { domains: [] };
|
|
428
536
|
case "Target.getTargets":
|