@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,411 @@
1
+ import { CDP_METHOD_NOT_FOUND, CDP_SERVER_ERROR, parseJson } from "../protocol.mjs";
2
+ //#region src/relay/core.ts
3
+ /** Sentinel: the method was handled and a response was already sent. */
4
+ const RESPONDED = Symbol("responded");
5
+ var RelayCore = class {
6
+ product;
7
+ browserWsUrl;
8
+ hostSocket = null;
9
+ clients = /* @__PURE__ */ new Map();
10
+ sessions = /* @__PURE__ */ new Map();
11
+ targets = /* @__PURE__ */ new Map();
12
+ pending = /* @__PURE__ */ new Map();
13
+ nextBridgeId = 1;
14
+ nextSessionId = 1;
15
+ constructor(options = {}) {
16
+ this.product = options.product ?? "icdp/0.1";
17
+ this.browserWsUrl = options.browserWsUrl ?? "";
18
+ }
19
+ /** A Host bridge connected. New-wins: any previous Host is dropped. */
20
+ hostConnected(socket) {
21
+ if (this.hostSocket && this.hostSocket !== socket) {
22
+ const previous = this.hostSocket;
23
+ this.hostSocket = null;
24
+ this.dropAllTargets("Host replaced by a newer connection");
25
+ try {
26
+ previous.close(1008, "replaced by a newer host");
27
+ } catch {}
28
+ }
29
+ this.hostSocket = socket;
30
+ }
31
+ hostDisconnected(socket) {
32
+ if (this.hostSocket !== socket) return;
33
+ this.hostSocket = null;
34
+ this.dropAllTargets("Host disconnected");
35
+ }
36
+ hostMessage(socket, raw) {
37
+ if (this.hostSocket !== socket) return;
38
+ const message = parseJson(raw);
39
+ if (!message) return;
40
+ switch (message.kind) {
41
+ case "ready":
42
+ this.dropAllTargets("Host re-announced");
43
+ for (const target of message.targets) this.addTarget(target);
44
+ return;
45
+ case "targetCreated":
46
+ this.addTarget(message.target);
47
+ return;
48
+ case "targetDestroyed":
49
+ this.removeTarget(message.targetId, "Target destroyed");
50
+ return;
51
+ case "targetInfoChanged":
52
+ this.targets.set(message.target.targetId, message.target);
53
+ this.broadcastTargetEvent("Target.targetInfoChanged", { targetInfo: this.targetInfo(message.target) });
54
+ return;
55
+ case "response": {
56
+ const id = Number(message.id);
57
+ const call = this.pending.get(id);
58
+ if (!call) return;
59
+ this.pending.delete(id);
60
+ this.sendToClient(call.client, {
61
+ id: call.clientId,
62
+ sessionId: call.sessionId,
63
+ ...message.error ? { error: message.error } : { result: message.result ?? {} }
64
+ });
65
+ return;
66
+ }
67
+ case "event":
68
+ for (const session of this.sessions.values()) {
69
+ if (session.targetId !== message.targetId) continue;
70
+ this.sendToClient(session.client, {
71
+ method: message.method,
72
+ params: message.params,
73
+ sessionId: session.sessionId
74
+ });
75
+ }
76
+ return;
77
+ }
78
+ }
79
+ clientConnected(socket) {
80
+ this.clients.set(socket, {
81
+ socket,
82
+ autoAttach: false,
83
+ discoverTargets: false,
84
+ sessions: /* @__PURE__ */ new Set()
85
+ });
86
+ }
87
+ clientDisconnected(socket) {
88
+ const client = this.clients.get(socket);
89
+ if (!client) return;
90
+ this.clients.delete(socket);
91
+ for (const sessionId of client.sessions) this.endSession(sessionId, { notifyClient: false });
92
+ }
93
+ clientMessage(socket, raw) {
94
+ const client = this.clients.get(socket);
95
+ if (!client) return;
96
+ const message = parseJson(raw);
97
+ if (!message) return;
98
+ if (message.sessionId) {
99
+ this.routeSessionCommand(client, message);
100
+ return;
101
+ }
102
+ const local = this.browserLevelResult(client, message);
103
+ if (local === RESPONDED) return;
104
+ if (local !== void 0) {
105
+ this.sendToClient(client, {
106
+ id: message.id,
107
+ result: local
108
+ });
109
+ return;
110
+ }
111
+ this.sendToClient(client, {
112
+ id: message.id,
113
+ error: {
114
+ code: CDP_METHOD_NOT_FOUND,
115
+ message: `Method not available on the browser target: ${message.method ?? "<missing>"}. Attach to a target and send it with a sessionId.`
116
+ }
117
+ });
118
+ }
119
+ jsonVersion() {
120
+ return {
121
+ Browser: this.product,
122
+ "Protocol-Version": "1.3",
123
+ "User-Agent": this.product,
124
+ "V8-Version": "synthetic",
125
+ "WebKit-Version": "synthetic",
126
+ webSocketDebuggerUrl: this.browserWsUrl
127
+ };
128
+ }
129
+ jsonList() {
130
+ return Array.from(this.targets.values(), (target) => ({
131
+ description: "icdp iframe target",
132
+ devtoolsFrontendUrl: "",
133
+ id: target.targetId,
134
+ title: target.title,
135
+ type: "page",
136
+ url: target.url,
137
+ webSocketDebuggerUrl: this.browserWsUrl
138
+ }));
139
+ }
140
+ status() {
141
+ return {
142
+ hostConnected: this.hostSocket !== null,
143
+ targets: Array.from(this.targets.values()),
144
+ clients: this.clients.size
145
+ };
146
+ }
147
+ targetInfo(target) {
148
+ return {
149
+ targetId: target.targetId,
150
+ type: "page",
151
+ title: target.title,
152
+ url: target.url,
153
+ attached: Array.from(this.sessions.values()).some((session) => session.targetId === target.targetId),
154
+ canAccessOpener: false
155
+ };
156
+ }
157
+ sendToClient(client, message) {
158
+ try {
159
+ client.socket.send(JSON.stringify(message));
160
+ } catch {}
161
+ }
162
+ sendToHost(message) {
163
+ try {
164
+ this.hostSocket?.send(JSON.stringify(message));
165
+ } catch {}
166
+ }
167
+ broadcastTargetEvent(method, params) {
168
+ for (const client of this.clients.values()) {
169
+ if (!client.discoverTargets) continue;
170
+ this.sendToClient(client, {
171
+ method,
172
+ params
173
+ });
174
+ }
175
+ }
176
+ addTarget(target) {
177
+ this.targets.set(target.targetId, target);
178
+ this.broadcastTargetEvent("Target.targetCreated", { targetInfo: this.targetInfo(target) });
179
+ for (const client of this.clients.values()) if (client.autoAttach) this.startSession(client, target.targetId);
180
+ }
181
+ removeTarget(targetId, reason) {
182
+ if (!this.targets.delete(targetId)) return;
183
+ for (const session of Array.from(this.sessions.values())) if (session.targetId === targetId) this.endSession(session.sessionId, {
184
+ notifyClient: true,
185
+ failReason: reason
186
+ });
187
+ this.broadcastTargetEvent("Target.targetDestroyed", { targetId });
188
+ }
189
+ dropAllTargets(reason) {
190
+ for (const targetId of Array.from(this.targets.keys())) this.removeTarget(targetId, reason);
191
+ }
192
+ startSession(client, targetId) {
193
+ const session = {
194
+ sessionId: `icdp-session-${this.nextSessionId++}`,
195
+ targetId,
196
+ client
197
+ };
198
+ this.sessions.set(session.sessionId, session);
199
+ client.sessions.add(session.sessionId);
200
+ const target = this.targets.get(targetId);
201
+ if (target) this.sendToClient(client, {
202
+ method: "Target.attachedToTarget",
203
+ params: {
204
+ sessionId: session.sessionId,
205
+ targetInfo: this.targetInfo(target),
206
+ waitingForDebugger: false
207
+ }
208
+ });
209
+ return session;
210
+ }
211
+ endSession(sessionId, options) {
212
+ const session = this.sessions.get(sessionId);
213
+ if (!session) return;
214
+ this.sessions.delete(sessionId);
215
+ session.client.sessions.delete(sessionId);
216
+ for (const [bridgeId, call] of this.pending) {
217
+ if (call.sessionId !== sessionId) continue;
218
+ this.pending.delete(bridgeId);
219
+ if (options.notifyClient) this.sendToClient(session.client, {
220
+ id: call.clientId,
221
+ sessionId,
222
+ error: {
223
+ code: CDP_SERVER_ERROR,
224
+ message: options.failReason ?? "Session detached"
225
+ }
226
+ });
227
+ }
228
+ if (options.notifyClient) this.sendToClient(session.client, {
229
+ method: "Target.detachedFromTarget",
230
+ params: {
231
+ sessionId,
232
+ targetId: session.targetId
233
+ }
234
+ });
235
+ this.sendToHost({
236
+ kind: "detached",
237
+ sessionId,
238
+ targetId: session.targetId
239
+ });
240
+ }
241
+ routeSessionCommand(client, message) {
242
+ const sessionId = message.sessionId;
243
+ const session = this.sessions.get(sessionId);
244
+ if (!session || session.client !== client) {
245
+ this.sendToClient(client, {
246
+ id: message.id,
247
+ sessionId,
248
+ error: {
249
+ code: CDP_SERVER_ERROR,
250
+ message: `Session not found: ${sessionId}`
251
+ }
252
+ });
253
+ return;
254
+ }
255
+ if (!message.method) {
256
+ this.sendToClient(client, {
257
+ id: message.id,
258
+ sessionId,
259
+ error: {
260
+ code: CDP_METHOD_NOT_FOUND,
261
+ message: "Method not found: <missing>"
262
+ }
263
+ });
264
+ return;
265
+ }
266
+ const local = this.sessionLevelResult(session, message);
267
+ if (local !== void 0) {
268
+ this.sendToClient(client, {
269
+ id: message.id,
270
+ sessionId,
271
+ result: local
272
+ });
273
+ return;
274
+ }
275
+ if (!this.hostSocket) {
276
+ this.sendToClient(client, {
277
+ id: message.id,
278
+ sessionId,
279
+ error: {
280
+ code: CDP_SERVER_ERROR,
281
+ message: "Host is not connected"
282
+ }
283
+ });
284
+ return;
285
+ }
286
+ const bridgeId = this.nextBridgeId++;
287
+ this.pending.set(bridgeId, {
288
+ client,
289
+ clientId: message.id,
290
+ sessionId
291
+ });
292
+ this.sendToHost({
293
+ kind: "command",
294
+ sessionId,
295
+ targetId: session.targetId,
296
+ id: bridgeId,
297
+ method: message.method,
298
+ params: message.params ?? {}
299
+ });
300
+ }
301
+ /** Session-scoped methods the Relay answers itself; undefined = forward to the frame. */
302
+ sessionLevelResult(session, message) {
303
+ switch (message.method) {
304
+ case "Browser.getVersion": return {
305
+ protocolVersion: "1.3",
306
+ product: this.product,
307
+ revision: `icdp-v1`,
308
+ userAgent: this.product,
309
+ jsVersion: "synthetic"
310
+ };
311
+ case "Schema.getDomains": return { domains: [] };
312
+ case "Target.setAutoAttach":
313
+ case "Target.setDiscoverTargets":
314
+ case "Target.setRemoteLocations":
315
+ case "Target.activateTarget": return {};
316
+ case "Target.getTargetInfo": {
317
+ const target = this.targets.get(session.targetId);
318
+ if (!target) return { targetInfo: {
319
+ targetId: session.targetId,
320
+ type: "page",
321
+ title: "",
322
+ url: "",
323
+ attached: true
324
+ } };
325
+ return { targetInfo: this.targetInfo(target) };
326
+ }
327
+ default: return;
328
+ }
329
+ }
330
+ /** Browser-level methods the Relay answers itself; undefined = not local. */
331
+ browserLevelResult(client, message) {
332
+ switch (message.method) {
333
+ case "Browser.getVersion": return {
334
+ protocolVersion: "1.3",
335
+ product: this.product,
336
+ revision: `icdp-v1`,
337
+ userAgent: this.product,
338
+ jsVersion: "synthetic"
339
+ };
340
+ case "Browser.close":
341
+ case "Browser.setDownloadBehavior":
342
+ case "Browser.setWindowBounds":
343
+ case "Security.setIgnoreCertificateErrors":
344
+ case "Target.setRemoteLocations":
345
+ case "Target.activateTarget":
346
+ case "Target.closeTarget": return {};
347
+ case "Schema.getDomains": return { domains: [] };
348
+ case "Target.getTargets": return { targetInfos: Array.from(this.targets.values(), (target) => this.targetInfo(target)) };
349
+ case "Target.getTargetInfo": {
350
+ const targetId = String(message.params?.targetId ?? "");
351
+ const target = this.targets.get(targetId);
352
+ if (!target) return { targetInfo: {
353
+ targetId,
354
+ type: "page",
355
+ title: "",
356
+ url: "",
357
+ attached: false
358
+ } };
359
+ return { targetInfo: this.targetInfo(target) };
360
+ }
361
+ case "Target.setDiscoverTargets": {
362
+ const discover = Boolean(message.params?.discover);
363
+ client.discoverTargets = discover;
364
+ if (discover) for (const target of this.targets.values()) this.sendToClient(client, {
365
+ method: "Target.targetCreated",
366
+ params: { targetInfo: this.targetInfo(target) }
367
+ });
368
+ return {};
369
+ }
370
+ case "Target.setAutoAttach": {
371
+ const autoAttach = Boolean(message.params?.autoAttach);
372
+ client.autoAttach = autoAttach;
373
+ if (autoAttach) {
374
+ for (const target of this.targets.values()) if (!Array.from(client.sessions).some((sessionId) => this.sessions.get(sessionId)?.targetId === target.targetId)) this.startSession(client, target.targetId);
375
+ }
376
+ return {};
377
+ }
378
+ case "Target.attachToTarget": {
379
+ const targetId = String(message.params?.targetId ?? "");
380
+ if (!this.targets.has(targetId)) {
381
+ this.sendToClient(client, {
382
+ id: message.id,
383
+ error: {
384
+ code: CDP_SERVER_ERROR,
385
+ message: `No target with given id found: ${targetId}`
386
+ }
387
+ });
388
+ return RESPONDED;
389
+ }
390
+ return { sessionId: this.startSession(client, targetId).sessionId };
391
+ }
392
+ case "Target.detachFromTarget": {
393
+ const sessionId = String(message.params?.sessionId ?? "");
394
+ this.endSession(sessionId, { notifyClient: false });
395
+ return {};
396
+ }
397
+ case "Target.createTarget":
398
+ this.sendToClient(client, {
399
+ id: message.id,
400
+ error: {
401
+ code: CDP_SERVER_ERROR,
402
+ message: "Target.createTarget is not supported: icdp targets are iframes paired by the Host."
403
+ }
404
+ });
405
+ return RESPONDED;
406
+ default: return;
407
+ }
408
+ }
409
+ };
410
+ //#endregion
411
+ export { RelayCore };
@@ -0,0 +1,24 @@
1
+ import { RelayCore } from "./core.mjs";
2
+ import { IncomingMessage, Server, ServerResponse } from "node:http";
3
+
4
+ //#region src/relay/node.d.ts
5
+ type ServeRelayOptions = {
6
+ port?: number;
7
+ hostname?: string;
8
+ product?: string; /** Path Clients connect to. Advertised by /json/version. */
9
+ browserPath?: string; /** Path the Host bridge connects to. */
10
+ hostPath?: string; /** Handles requests the relay doesn't own (anything but its WS + /json + /icdp paths). */
11
+ fallback?: (request: IncomingMessage, response: ServerResponse) => void;
12
+ };
13
+ type RelayServer = {
14
+ core: RelayCore;
15
+ server: Server;
16
+ port: number;
17
+ browserWsUrl: string;
18
+ hostWsUrl: string;
19
+ stop(): Promise<void>;
20
+ };
21
+ /** Serve a Relay on Node. One Host, many Clients, flat-session protocol only. */
22
+ declare function serveRelay(options?: ServeRelayOptions): Promise<RelayServer>;
23
+ //#endregion
24
+ export { RelayServer, ServeRelayOptions, serveRelay };
@@ -0,0 +1,99 @@
1
+ import { RelayCore } from "./core.mjs";
2
+ import { createServer } from "node:http";
3
+ import { WebSocketServer } from "ws";
4
+ //#region src/relay/node.ts
5
+ function asSocketLike(ws) {
6
+ return {
7
+ send: (data) => {
8
+ try {
9
+ ws.send(data);
10
+ } catch {}
11
+ },
12
+ close: (code, reason) => {
13
+ try {
14
+ ws.close(code, reason);
15
+ } catch {}
16
+ }
17
+ };
18
+ }
19
+ function sendJson(response, payload) {
20
+ response.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
21
+ response.end(JSON.stringify(payload));
22
+ }
23
+ /** Serve a Relay on Node. One Host, many Clients, flat-session protocol only. */
24
+ async function serveRelay(options = {}) {
25
+ const hostname = options.hostname ?? "127.0.0.1";
26
+ const browserPath = options.browserPath ?? "/devtools/browser";
27
+ const hostPath = options.hostPath ?? "/icdp/host";
28
+ const server = createServer((request, response) => {
29
+ const url = new URL(request.url ?? "/", `http://${hostname}`);
30
+ if (process.env.ICDP_DEBUG === "1") console.log(`[icdp:http] ${request.method} ${url.pathname}`);
31
+ if (url.pathname === "/json/version") return sendJson(response, core.jsonVersion());
32
+ if (url.pathname === "/json" || url.pathname === "/json/list") return sendJson(response, core.jsonList());
33
+ if (url.pathname === "/icdp/status") return sendJson(response, core.status());
34
+ if (options.fallback) return options.fallback(request, response);
35
+ response.writeHead(404);
36
+ response.end("not found");
37
+ });
38
+ const wss = new WebSocketServer({ noServer: true });
39
+ const socketLikes = /* @__PURE__ */ new WeakMap();
40
+ const wrap = (ws) => {
41
+ let like = socketLikes.get(ws);
42
+ if (!like) {
43
+ like = asSocketLike(ws);
44
+ socketLikes.set(ws, like);
45
+ }
46
+ return like;
47
+ };
48
+ server.on("upgrade", (request, socket, head) => {
49
+ const url = new URL(request.url ?? "/", `http://${hostname}`);
50
+ const kind = url.pathname === browserPath ? "client" : url.pathname === hostPath ? "host" : null;
51
+ if (process.env.ICDP_DEBUG === "1") console.log(`[icdp:http] UPGRADE ${url.pathname} -> ${kind ?? "reject"}`);
52
+ if (!kind) {
53
+ socket.destroy();
54
+ return;
55
+ }
56
+ wss.handleUpgrade(request, socket, head, (ws) => {
57
+ if (kind === "host") core.hostConnected(wrap(ws));
58
+ else core.clientConnected(wrap(ws));
59
+ ws.on("message", (data) => {
60
+ const raw = data.toString();
61
+ if (process.env.ICDP_DEBUG === "1") console.log(`[icdp:${kind}]`, raw.slice(0, 400));
62
+ if (kind === "host") core.hostMessage(wrap(ws), raw);
63
+ else core.clientMessage(wrap(ws), raw);
64
+ });
65
+ ws.on("close", () => {
66
+ if (kind === "host") core.hostDisconnected(wrap(ws));
67
+ else core.clientDisconnected(wrap(ws));
68
+ });
69
+ ws.on("error", () => ws.close());
70
+ });
71
+ });
72
+ await new Promise((resolve, reject) => {
73
+ server.once("error", reject);
74
+ server.listen(options.port ?? 0, hostname, resolve);
75
+ });
76
+ const address = server.address();
77
+ if (address === null || typeof address === "string") throw new Error("relay server has no TCP address");
78
+ const port = address.port;
79
+ const browserWsUrl = `ws://${hostname}:${port}${browserPath}`;
80
+ const core = new RelayCore({
81
+ product: options.product,
82
+ browserWsUrl
83
+ });
84
+ return {
85
+ core,
86
+ server,
87
+ port,
88
+ browserWsUrl,
89
+ hostWsUrl: `ws://${hostname}:${port}${hostPath}`,
90
+ stop: () => new Promise((resolve) => {
91
+ for (const ws of wss.clients) ws.terminate();
92
+ wss.close();
93
+ server.closeAllConnections();
94
+ server.close(() => resolve());
95
+ })
96
+ };
97
+ }
98
+ //#endregion
99
+ export { serveRelay };
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "@olimsaidov/icdp",
3
+ "version": "0.1.0",
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
+ "homepage": "https://github.com/olimsaidov/icdp#readme",
6
+ "bugs": "https://github.com/olimsaidov/icdp/issues",
7
+ "license": "MIT",
8
+ "author": "Olim Saidov",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/olimsaidov/icdp.git"
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "src",
16
+ "README.md"
17
+ ],
18
+ "type": "module",
19
+ "exports": {
20
+ "./frame": {
21
+ "types": "./dist/frame/index.d.mts",
22
+ "default": "./dist/frame/index.mjs"
23
+ },
24
+ "./host": {
25
+ "types": "./dist/host/index.d.mts",
26
+ "default": "./dist/host/index.mjs"
27
+ },
28
+ "./relay": {
29
+ "types": "./dist/relay/core.d.mts",
30
+ "default": "./dist/relay/core.mjs"
31
+ },
32
+ "./relay/node": {
33
+ "types": "./dist/relay/node.d.mts",
34
+ "default": "./dist/relay/node.mjs"
35
+ },
36
+ "./protocol": {
37
+ "types": "./dist/protocol.d.mts",
38
+ "default": "./dist/protocol.mjs"
39
+ }
40
+ },
41
+ "publishConfig": {
42
+ "access": "public",
43
+ "registry": "https://registry.npmjs.org/"
44
+ },
45
+ "scripts": {
46
+ "build": "tsdown",
47
+ "check": "tsc --noEmit && oxlint && oxfmt --check .",
48
+ "fmt": "oxfmt .",
49
+ "gen:conformance": "node scripts/gen-conformance.ts",
50
+ "playground": "node playground/server.ts",
51
+ "test": "vitest run --exclude '**/*.e2e.test.ts'",
52
+ "test:e2e": "vitest run tests/agent-browser.e2e.test.ts",
53
+ "test:all": "vitest run"
54
+ },
55
+ "dependencies": {
56
+ "aria-query": "^5.3.2",
57
+ "chobitsu": "1.8.6",
58
+ "devtools-protocol": "^0.0.1624250",
59
+ "dom-accessibility-api": "^0.7.1",
60
+ "ws": "^8.21.0"
61
+ },
62
+ "devDependencies": {
63
+ "@types/aria-query": "^5.0.4",
64
+ "@types/node": "^24",
65
+ "@types/ws": "^8",
66
+ "jsdom": "^29.1.1",
67
+ "oxfmt": "^0.54.0",
68
+ "oxlint": "^1.69.0",
69
+ "rolldown": "^1.1.1",
70
+ "tsdown": "^0.22.2",
71
+ "typescript": "^5",
72
+ "vitest": "^4.1.8"
73
+ },
74
+ "engines": {
75
+ "node": ">=22"
76
+ }
77
+ }