@olimsaidov/icdp 0.1.1 → 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 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
@@ -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(win?: WindowLike);
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 };
@@ -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(win = window) {
15
- this.win = win;
16
- win.addEventListener("message", this.onWindowMessage);
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({
@@ -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
- type HostToRelayMessage = BridgeReady | BridgeTargetCreated | BridgeTargetDestroyed | BridgeTargetInfoChanged | BridgeResponse | BridgeEvent;
99
- type RelayToHostMessage = BridgeCommand | BridgeDetached;
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 };
@@ -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;
@@ -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.1.1",
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(private readonly win: WindowLike = window) {
97
- win.addEventListener("message", this.onWindowMessage);
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({ kind: "ready", v: PROTOCOL_VERSION, targets: this.host.targets() });
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 = { kind: "ready"; v: number; targets: TargetSummary[] };
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":