@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.
- package/LICENSE +21 -0
- package/README.md +104 -0
- package/dist/frame/ax-tree.mjs +2186 -0
- package/dist/frame/index.d.mts +17 -0
- package/dist/frame/index.mjs +782 -0
- package/dist/host/index.d.mts +99 -0
- package/dist/host/index.mjs +328 -0
- package/dist/protocol.d.mts +102 -0
- package/dist/protocol.mjs +16 -0
- package/dist/relay/core.d.mts +54 -0
- package/dist/relay/core.mjs +411 -0
- package/dist/relay/node.d.mts +24 -0
- package/dist/relay/node.mjs +99 -0
- package/package.json +77 -0
- package/src/frame/ax-tree.ts +2393 -0
- package/src/frame/index.ts +1048 -0
- package/src/host/index.ts +422 -0
- package/src/protocol.ts +125 -0
- package/src/relay/core.ts +499 -0
- package/src/relay/node.ts +135 -0
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CDP_METHOD_NOT_FOUND,
|
|
3
|
+
CDP_SERVER_ERROR,
|
|
4
|
+
type CdpId,
|
|
5
|
+
type CdpMessage,
|
|
6
|
+
type HostToRelayMessage,
|
|
7
|
+
PROTOCOL_VERSION,
|
|
8
|
+
parseJson,
|
|
9
|
+
type RelayToHostMessage,
|
|
10
|
+
type TargetSummary,
|
|
11
|
+
} from "../protocol.ts";
|
|
12
|
+
|
|
13
|
+
/** Minimal socket surface the adapter must provide for each connection. */
|
|
14
|
+
export type SocketLike = {
|
|
15
|
+
send(data: string): void;
|
|
16
|
+
close(code?: number, reason?: string): void;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type RelayCoreOptions = {
|
|
20
|
+
/** Reported by Browser.getVersion and /json/version. */
|
|
21
|
+
product?: string;
|
|
22
|
+
/** Absolute WebSocket URL of the browser endpoint, for /json payloads. */
|
|
23
|
+
browserWsUrl?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type ClientState = {
|
|
27
|
+
socket: SocketLike;
|
|
28
|
+
autoAttach: boolean;
|
|
29
|
+
discoverTargets: boolean;
|
|
30
|
+
sessions: Set<string>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type SessionState = {
|
|
34
|
+
sessionId: string;
|
|
35
|
+
targetId: string;
|
|
36
|
+
client: ClientState;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type PendingCommand = {
|
|
40
|
+
client: ClientState;
|
|
41
|
+
clientId: CdpId | undefined;
|
|
42
|
+
sessionId: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Sentinel: the method was handled and a response was already sent. */
|
|
46
|
+
const RESPONDED = Symbol("responded");
|
|
47
|
+
|
|
48
|
+
export class RelayCore {
|
|
49
|
+
private readonly product: string;
|
|
50
|
+
private readonly browserWsUrl: string;
|
|
51
|
+
private hostSocket: SocketLike | null = null;
|
|
52
|
+
private readonly clients = new Map<SocketLike, ClientState>();
|
|
53
|
+
private readonly sessions = new Map<string, SessionState>();
|
|
54
|
+
private readonly targets = new Map<string, TargetSummary>();
|
|
55
|
+
private readonly pending = new Map<number, PendingCommand>();
|
|
56
|
+
private nextBridgeId = 1;
|
|
57
|
+
private nextSessionId = 1;
|
|
58
|
+
|
|
59
|
+
constructor(options: RelayCoreOptions = {}) {
|
|
60
|
+
this.product = options.product ?? "icdp/0.1";
|
|
61
|
+
this.browserWsUrl = options.browserWsUrl ?? "";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// -- adapter wiring ---------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
/** A Host bridge connected. New-wins: any previous Host is dropped. */
|
|
67
|
+
hostConnected(socket: SocketLike): void {
|
|
68
|
+
if (this.hostSocket && this.hostSocket !== socket) {
|
|
69
|
+
const previous = this.hostSocket;
|
|
70
|
+
this.hostSocket = null;
|
|
71
|
+
this.dropAllTargets("Host replaced by a newer connection");
|
|
72
|
+
try {
|
|
73
|
+
previous.close(1008, "replaced by a newer host");
|
|
74
|
+
} catch {}
|
|
75
|
+
}
|
|
76
|
+
this.hostSocket = socket;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
hostDisconnected(socket: SocketLike): void {
|
|
80
|
+
if (this.hostSocket !== socket) return;
|
|
81
|
+
this.hostSocket = null;
|
|
82
|
+
this.dropAllTargets("Host disconnected");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
hostMessage(socket: SocketLike, raw: string): void {
|
|
86
|
+
if (this.hostSocket !== socket) return;
|
|
87
|
+
const message = parseJson<HostToRelayMessage>(raw);
|
|
88
|
+
if (!message) return;
|
|
89
|
+
switch (message.kind) {
|
|
90
|
+
case "ready":
|
|
91
|
+
this.dropAllTargets("Host re-announced");
|
|
92
|
+
for (const target of message.targets) this.addTarget(target);
|
|
93
|
+
return;
|
|
94
|
+
case "targetCreated":
|
|
95
|
+
this.addTarget(message.target);
|
|
96
|
+
return;
|
|
97
|
+
case "targetDestroyed":
|
|
98
|
+
this.removeTarget(message.targetId, "Target destroyed");
|
|
99
|
+
return;
|
|
100
|
+
case "targetInfoChanged": {
|
|
101
|
+
this.targets.set(message.target.targetId, message.target);
|
|
102
|
+
this.broadcastTargetEvent("Target.targetInfoChanged", {
|
|
103
|
+
targetInfo: this.targetInfo(message.target),
|
|
104
|
+
});
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
case "response": {
|
|
108
|
+
const id = Number(message.id);
|
|
109
|
+
const call = this.pending.get(id);
|
|
110
|
+
if (!call) return;
|
|
111
|
+
this.pending.delete(id);
|
|
112
|
+
this.sendToClient(call.client, {
|
|
113
|
+
id: call.clientId,
|
|
114
|
+
sessionId: call.sessionId,
|
|
115
|
+
...(message.error ? { error: message.error } : { result: message.result ?? {} }),
|
|
116
|
+
});
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
case "event": {
|
|
120
|
+
for (const session of this.sessions.values()) {
|
|
121
|
+
if (session.targetId !== message.targetId) continue;
|
|
122
|
+
this.sendToClient(session.client, {
|
|
123
|
+
method: message.method,
|
|
124
|
+
params: message.params,
|
|
125
|
+
sessionId: session.sessionId,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
clientConnected(socket: SocketLike): void {
|
|
134
|
+
this.clients.set(socket, {
|
|
135
|
+
socket,
|
|
136
|
+
autoAttach: false,
|
|
137
|
+
discoverTargets: false,
|
|
138
|
+
sessions: new Set(),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
clientDisconnected(socket: SocketLike): void {
|
|
143
|
+
const client = this.clients.get(socket);
|
|
144
|
+
if (!client) return;
|
|
145
|
+
this.clients.delete(socket);
|
|
146
|
+
for (const sessionId of client.sessions) this.endSession(sessionId, { notifyClient: false });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
clientMessage(socket: SocketLike, raw: string): void {
|
|
150
|
+
const client = this.clients.get(socket);
|
|
151
|
+
if (!client) return;
|
|
152
|
+
const message = parseJson<CdpMessage>(raw);
|
|
153
|
+
if (!message) return;
|
|
154
|
+
|
|
155
|
+
if (message.sessionId) {
|
|
156
|
+
this.routeSessionCommand(client, message);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const local = this.browserLevelResult(client, message);
|
|
161
|
+
if (local === RESPONDED) return;
|
|
162
|
+
if (local !== undefined) {
|
|
163
|
+
this.sendToClient(client, { id: message.id, result: local });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
this.sendToClient(client, {
|
|
168
|
+
id: message.id,
|
|
169
|
+
error: {
|
|
170
|
+
code: CDP_METHOD_NOT_FOUND,
|
|
171
|
+
message: `Method not available on the browser target: ${message.method ?? "<missing>"}. Attach to a target and send it with a sessionId.`,
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// -- HTTP discovery payloads --------------------------------------------------
|
|
177
|
+
|
|
178
|
+
jsonVersion(): Record<string, unknown> {
|
|
179
|
+
return {
|
|
180
|
+
Browser: this.product,
|
|
181
|
+
"Protocol-Version": "1.3",
|
|
182
|
+
"User-Agent": this.product,
|
|
183
|
+
"V8-Version": "synthetic",
|
|
184
|
+
"WebKit-Version": "synthetic",
|
|
185
|
+
webSocketDebuggerUrl: this.browserWsUrl,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
jsonList(): Array<Record<string, unknown>> {
|
|
190
|
+
return Array.from(this.targets.values(), (target) => ({
|
|
191
|
+
description: "icdp iframe target",
|
|
192
|
+
devtoolsFrontendUrl: "",
|
|
193
|
+
id: target.targetId,
|
|
194
|
+
title: target.title,
|
|
195
|
+
type: "page",
|
|
196
|
+
url: target.url,
|
|
197
|
+
// Flat-session protocol only: attach via Target.attachToTarget on the browser endpoint.
|
|
198
|
+
webSocketDebuggerUrl: this.browserWsUrl,
|
|
199
|
+
}));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
status(): { hostConnected: boolean; targets: TargetSummary[]; clients: number } {
|
|
203
|
+
return {
|
|
204
|
+
hostConnected: this.hostSocket !== null,
|
|
205
|
+
targets: Array.from(this.targets.values()),
|
|
206
|
+
clients: this.clients.size,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// -- internals ------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
private targetInfo(target: TargetSummary) {
|
|
213
|
+
return {
|
|
214
|
+
targetId: target.targetId,
|
|
215
|
+
type: "page",
|
|
216
|
+
title: target.title,
|
|
217
|
+
url: target.url,
|
|
218
|
+
attached: Array.from(this.sessions.values()).some(
|
|
219
|
+
(session) => session.targetId === target.targetId,
|
|
220
|
+
),
|
|
221
|
+
canAccessOpener: false,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private sendToClient(client: ClientState, message: CdpMessage): void {
|
|
226
|
+
try {
|
|
227
|
+
client.socket.send(JSON.stringify(message));
|
|
228
|
+
} catch {}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private sendToHost(message: RelayToHostMessage): void {
|
|
232
|
+
try {
|
|
233
|
+
this.hostSocket?.send(JSON.stringify(message));
|
|
234
|
+
} catch {}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private broadcastTargetEvent(method: string, params: Record<string, unknown>): void {
|
|
238
|
+
for (const client of this.clients.values()) {
|
|
239
|
+
if (!client.discoverTargets) continue;
|
|
240
|
+
this.sendToClient(client, { method, params });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private addTarget(target: TargetSummary): void {
|
|
245
|
+
this.targets.set(target.targetId, target);
|
|
246
|
+
this.broadcastTargetEvent("Target.targetCreated", { targetInfo: this.targetInfo(target) });
|
|
247
|
+
for (const client of this.clients.values()) {
|
|
248
|
+
if (client.autoAttach) this.startSession(client, target.targetId);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private removeTarget(targetId: string, reason: string): void {
|
|
253
|
+
if (!this.targets.delete(targetId)) return;
|
|
254
|
+
for (const session of Array.from(this.sessions.values())) {
|
|
255
|
+
if (session.targetId === targetId)
|
|
256
|
+
this.endSession(session.sessionId, { notifyClient: true, failReason: reason });
|
|
257
|
+
}
|
|
258
|
+
this.broadcastTargetEvent("Target.targetDestroyed", { targetId });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private dropAllTargets(reason: string): void {
|
|
262
|
+
for (const targetId of Array.from(this.targets.keys())) this.removeTarget(targetId, reason);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private startSession(client: ClientState, targetId: string): SessionState {
|
|
266
|
+
const session: SessionState = {
|
|
267
|
+
sessionId: `icdp-session-${this.nextSessionId++}`,
|
|
268
|
+
targetId,
|
|
269
|
+
client,
|
|
270
|
+
};
|
|
271
|
+
this.sessions.set(session.sessionId, session);
|
|
272
|
+
client.sessions.add(session.sessionId);
|
|
273
|
+
const target = this.targets.get(targetId);
|
|
274
|
+
if (target) {
|
|
275
|
+
this.sendToClient(client, {
|
|
276
|
+
method: "Target.attachedToTarget",
|
|
277
|
+
params: {
|
|
278
|
+
sessionId: session.sessionId,
|
|
279
|
+
targetInfo: this.targetInfo(target),
|
|
280
|
+
waitingForDebugger: false,
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
return session;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private endSession(
|
|
288
|
+
sessionId: string,
|
|
289
|
+
options: { notifyClient: boolean; failReason?: string },
|
|
290
|
+
): void {
|
|
291
|
+
const session = this.sessions.get(sessionId);
|
|
292
|
+
if (!session) return;
|
|
293
|
+
this.sessions.delete(sessionId);
|
|
294
|
+
session.client.sessions.delete(sessionId);
|
|
295
|
+
|
|
296
|
+
for (const [bridgeId, call] of this.pending) {
|
|
297
|
+
if (call.sessionId !== sessionId) continue;
|
|
298
|
+
this.pending.delete(bridgeId);
|
|
299
|
+
if (options.notifyClient) {
|
|
300
|
+
this.sendToClient(session.client, {
|
|
301
|
+
id: call.clientId,
|
|
302
|
+
sessionId,
|
|
303
|
+
error: { code: CDP_SERVER_ERROR, message: options.failReason ?? "Session detached" },
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (options.notifyClient) {
|
|
309
|
+
this.sendToClient(session.client, {
|
|
310
|
+
method: "Target.detachedFromTarget",
|
|
311
|
+
params: { sessionId, targetId: session.targetId },
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
this.sendToHost({ kind: "detached", sessionId, targetId: session.targetId });
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private routeSessionCommand(client: ClientState, message: CdpMessage): void {
|
|
318
|
+
const sessionId = message.sessionId as string;
|
|
319
|
+
const session = this.sessions.get(sessionId);
|
|
320
|
+
if (!session || session.client !== client) {
|
|
321
|
+
this.sendToClient(client, {
|
|
322
|
+
id: message.id,
|
|
323
|
+
sessionId,
|
|
324
|
+
error: { code: CDP_SERVER_ERROR, message: `Session not found: ${sessionId}` },
|
|
325
|
+
});
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (!message.method) {
|
|
329
|
+
this.sendToClient(client, {
|
|
330
|
+
id: message.id,
|
|
331
|
+
sessionId,
|
|
332
|
+
error: { code: CDP_METHOD_NOT_FOUND, message: "Method not found: <missing>" },
|
|
333
|
+
});
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Target/Browser housekeeping arrives session-scoped from real clients
|
|
338
|
+
// (e.g. agent-browser sends Target.setAutoAttach inside the session); the
|
|
339
|
+
// Frame Agent knows nothing about targets, so answer here.
|
|
340
|
+
const local = this.sessionLevelResult(session, message);
|
|
341
|
+
if (local !== undefined) {
|
|
342
|
+
this.sendToClient(client, { id: message.id, sessionId, result: local });
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (!this.hostSocket) {
|
|
347
|
+
this.sendToClient(client, {
|
|
348
|
+
id: message.id,
|
|
349
|
+
sessionId,
|
|
350
|
+
error: { code: CDP_SERVER_ERROR, message: "Host is not connected" },
|
|
351
|
+
});
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const bridgeId = this.nextBridgeId++;
|
|
356
|
+
this.pending.set(bridgeId, { client, clientId: message.id, sessionId });
|
|
357
|
+
this.sendToHost({
|
|
358
|
+
kind: "command",
|
|
359
|
+
sessionId,
|
|
360
|
+
targetId: session.targetId,
|
|
361
|
+
id: bridgeId,
|
|
362
|
+
method: message.method,
|
|
363
|
+
params: message.params ?? {},
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Session-scoped methods the Relay answers itself; undefined = forward to the frame. */
|
|
368
|
+
private sessionLevelResult(session: SessionState, message: CdpMessage): unknown | undefined {
|
|
369
|
+
switch (message.method) {
|
|
370
|
+
case "Browser.getVersion":
|
|
371
|
+
return {
|
|
372
|
+
protocolVersion: "1.3",
|
|
373
|
+
product: this.product,
|
|
374
|
+
revision: `icdp-v${PROTOCOL_VERSION}`,
|
|
375
|
+
userAgent: this.product,
|
|
376
|
+
jsVersion: "synthetic",
|
|
377
|
+
};
|
|
378
|
+
case "Schema.getDomains":
|
|
379
|
+
return { domains: [] };
|
|
380
|
+
case "Target.setAutoAttach":
|
|
381
|
+
case "Target.setDiscoverTargets":
|
|
382
|
+
case "Target.setRemoteLocations":
|
|
383
|
+
case "Target.activateTarget":
|
|
384
|
+
return {};
|
|
385
|
+
case "Target.getTargetInfo": {
|
|
386
|
+
const target = this.targets.get(session.targetId);
|
|
387
|
+
if (!target)
|
|
388
|
+
return {
|
|
389
|
+
targetInfo: {
|
|
390
|
+
targetId: session.targetId,
|
|
391
|
+
type: "page",
|
|
392
|
+
title: "",
|
|
393
|
+
url: "",
|
|
394
|
+
attached: true,
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
return { targetInfo: this.targetInfo(target) };
|
|
398
|
+
}
|
|
399
|
+
default:
|
|
400
|
+
return undefined;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Browser-level methods the Relay answers itself; undefined = not local. */
|
|
405
|
+
private browserLevelResult(
|
|
406
|
+
client: ClientState,
|
|
407
|
+
message: CdpMessage,
|
|
408
|
+
): unknown | typeof RESPONDED | undefined {
|
|
409
|
+
switch (message.method) {
|
|
410
|
+
case "Browser.getVersion":
|
|
411
|
+
return {
|
|
412
|
+
protocolVersion: "1.3",
|
|
413
|
+
product: this.product,
|
|
414
|
+
revision: `icdp-v${PROTOCOL_VERSION}`,
|
|
415
|
+
userAgent: this.product,
|
|
416
|
+
jsVersion: "synthetic",
|
|
417
|
+
};
|
|
418
|
+
case "Browser.close":
|
|
419
|
+
case "Browser.setDownloadBehavior":
|
|
420
|
+
case "Browser.setWindowBounds":
|
|
421
|
+
case "Security.setIgnoreCertificateErrors":
|
|
422
|
+
case "Target.setRemoteLocations":
|
|
423
|
+
case "Target.activateTarget":
|
|
424
|
+
case "Target.closeTarget":
|
|
425
|
+
return {};
|
|
426
|
+
case "Schema.getDomains":
|
|
427
|
+
return { domains: [] };
|
|
428
|
+
case "Target.getTargets":
|
|
429
|
+
return {
|
|
430
|
+
targetInfos: Array.from(this.targets.values(), (target) => this.targetInfo(target)),
|
|
431
|
+
};
|
|
432
|
+
case "Target.getTargetInfo": {
|
|
433
|
+
const targetId = String(message.params?.targetId ?? "");
|
|
434
|
+
const target = this.targets.get(targetId);
|
|
435
|
+
if (!target)
|
|
436
|
+
return { targetInfo: { targetId, type: "page", title: "", url: "", attached: false } };
|
|
437
|
+
return { targetInfo: this.targetInfo(target) };
|
|
438
|
+
}
|
|
439
|
+
case "Target.setDiscoverTargets": {
|
|
440
|
+
const discover = Boolean(message.params?.discover);
|
|
441
|
+
client.discoverTargets = discover;
|
|
442
|
+
if (discover) {
|
|
443
|
+
for (const target of this.targets.values()) {
|
|
444
|
+
this.sendToClient(client, {
|
|
445
|
+
method: "Target.targetCreated",
|
|
446
|
+
params: { targetInfo: this.targetInfo(target) },
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return {};
|
|
451
|
+
}
|
|
452
|
+
case "Target.setAutoAttach": {
|
|
453
|
+
const autoAttach = Boolean(message.params?.autoAttach);
|
|
454
|
+
client.autoAttach = autoAttach;
|
|
455
|
+
if (autoAttach) {
|
|
456
|
+
for (const target of this.targets.values()) {
|
|
457
|
+
const attachedHere = Array.from(client.sessions).some(
|
|
458
|
+
(sessionId) => this.sessions.get(sessionId)?.targetId === target.targetId,
|
|
459
|
+
);
|
|
460
|
+
if (!attachedHere) this.startSession(client, target.targetId);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return {};
|
|
464
|
+
}
|
|
465
|
+
case "Target.attachToTarget": {
|
|
466
|
+
const targetId = String(message.params?.targetId ?? "");
|
|
467
|
+
if (!this.targets.has(targetId)) {
|
|
468
|
+
this.sendToClient(client, {
|
|
469
|
+
id: message.id,
|
|
470
|
+
error: {
|
|
471
|
+
code: CDP_SERVER_ERROR,
|
|
472
|
+
message: `No target with given id found: ${targetId}`,
|
|
473
|
+
},
|
|
474
|
+
});
|
|
475
|
+
return RESPONDED;
|
|
476
|
+
}
|
|
477
|
+
const session = this.startSession(client, targetId);
|
|
478
|
+
return { sessionId: session.sessionId };
|
|
479
|
+
}
|
|
480
|
+
case "Target.detachFromTarget": {
|
|
481
|
+
const sessionId = String(message.params?.sessionId ?? "");
|
|
482
|
+
this.endSession(sessionId, { notifyClient: false });
|
|
483
|
+
return {};
|
|
484
|
+
}
|
|
485
|
+
case "Target.createTarget":
|
|
486
|
+
this.sendToClient(client, {
|
|
487
|
+
id: message.id,
|
|
488
|
+
error: {
|
|
489
|
+
code: CDP_SERVER_ERROR,
|
|
490
|
+
message:
|
|
491
|
+
"Target.createTarget is not supported: icdp targets are iframes paired by the Host.",
|
|
492
|
+
},
|
|
493
|
+
});
|
|
494
|
+
return RESPONDED;
|
|
495
|
+
default:
|
|
496
|
+
return undefined;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
2
|
+
import type { Socket } from "node:net";
|
|
3
|
+
|
|
4
|
+
import { type WebSocket, WebSocketServer } from "ws";
|
|
5
|
+
|
|
6
|
+
import { RelayCore, type SocketLike } from "./core.ts";
|
|
7
|
+
|
|
8
|
+
export type ServeRelayOptions = {
|
|
9
|
+
port?: number;
|
|
10
|
+
hostname?: string;
|
|
11
|
+
product?: string;
|
|
12
|
+
/** Path Clients connect to. Advertised by /json/version. */
|
|
13
|
+
browserPath?: string;
|
|
14
|
+
/** Path the Host bridge connects to. */
|
|
15
|
+
hostPath?: string;
|
|
16
|
+
/** Handles requests the relay doesn't own (anything but its WS + /json + /icdp paths). */
|
|
17
|
+
fallback?: (request: IncomingMessage, response: ServerResponse) => void;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type RelayServer = {
|
|
21
|
+
core: RelayCore;
|
|
22
|
+
server: Server;
|
|
23
|
+
port: number;
|
|
24
|
+
browserWsUrl: string;
|
|
25
|
+
hostWsUrl: string;
|
|
26
|
+
stop(): Promise<void>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function asSocketLike(ws: WebSocket): SocketLike {
|
|
30
|
+
return {
|
|
31
|
+
send: (data) => {
|
|
32
|
+
try {
|
|
33
|
+
ws.send(data);
|
|
34
|
+
} catch {}
|
|
35
|
+
},
|
|
36
|
+
close: (code, reason) => {
|
|
37
|
+
try {
|
|
38
|
+
ws.close(code, reason);
|
|
39
|
+
} catch {}
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function sendJson(response: ServerResponse, payload: unknown): void {
|
|
45
|
+
response.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
46
|
+
response.end(JSON.stringify(payload));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Serve a Relay on Node. One Host, many Clients, flat-session protocol only. */
|
|
50
|
+
export async function serveRelay(options: ServeRelayOptions = {}): Promise<RelayServer> {
|
|
51
|
+
const hostname = options.hostname ?? "127.0.0.1";
|
|
52
|
+
const browserPath = options.browserPath ?? "/devtools/browser";
|
|
53
|
+
const hostPath = options.hostPath ?? "/icdp/host";
|
|
54
|
+
|
|
55
|
+
const server = createServer((request, response) => {
|
|
56
|
+
const url = new URL(request.url ?? "/", `http://${hostname}`);
|
|
57
|
+
if (process.env.ICDP_DEBUG === "1")
|
|
58
|
+
console.log(`[icdp:http] ${request.method} ${url.pathname}`);
|
|
59
|
+
|
|
60
|
+
if (url.pathname === "/json/version") return sendJson(response, core.jsonVersion());
|
|
61
|
+
if (url.pathname === "/json" || url.pathname === "/json/list")
|
|
62
|
+
return sendJson(response, core.jsonList());
|
|
63
|
+
if (url.pathname === "/icdp/status") return sendJson(response, core.status());
|
|
64
|
+
if (options.fallback) return options.fallback(request, response);
|
|
65
|
+
response.writeHead(404);
|
|
66
|
+
response.end("not found");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
70
|
+
// The SocketLike for each ws is created once and reused via this map, so the
|
|
71
|
+
// core can compare connection identities.
|
|
72
|
+
const socketLikes = new WeakMap<WebSocket, SocketLike>();
|
|
73
|
+
const wrap = (ws: WebSocket): SocketLike => {
|
|
74
|
+
let like = socketLikes.get(ws);
|
|
75
|
+
if (!like) {
|
|
76
|
+
like = asSocketLike(ws);
|
|
77
|
+
socketLikes.set(ws, like);
|
|
78
|
+
}
|
|
79
|
+
return like;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
server.on("upgrade", (request, socket, head) => {
|
|
83
|
+
const url = new URL(request.url ?? "/", `http://${hostname}`);
|
|
84
|
+
const kind =
|
|
85
|
+
url.pathname === browserPath ? "client" : url.pathname === hostPath ? "host" : null;
|
|
86
|
+
if (process.env.ICDP_DEBUG === "1")
|
|
87
|
+
console.log(`[icdp:http] UPGRADE ${url.pathname} -> ${kind ?? "reject"}`);
|
|
88
|
+
if (!kind) {
|
|
89
|
+
socket.destroy();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
wss.handleUpgrade(request, socket as Socket, head, (ws) => {
|
|
93
|
+
if (kind === "host") core.hostConnected(wrap(ws));
|
|
94
|
+
else core.clientConnected(wrap(ws));
|
|
95
|
+
ws.on("message", (data) => {
|
|
96
|
+
const raw = data.toString();
|
|
97
|
+
if (process.env.ICDP_DEBUG === "1") console.log(`[icdp:${kind}]`, raw.slice(0, 400));
|
|
98
|
+
if (kind === "host") core.hostMessage(wrap(ws), raw);
|
|
99
|
+
else core.clientMessage(wrap(ws), raw);
|
|
100
|
+
});
|
|
101
|
+
ws.on("close", () => {
|
|
102
|
+
if (kind === "host") core.hostDisconnected(wrap(ws));
|
|
103
|
+
else core.clientDisconnected(wrap(ws));
|
|
104
|
+
});
|
|
105
|
+
ws.on("error", () => ws.close());
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await new Promise<void>((resolve, reject) => {
|
|
110
|
+
server.once("error", reject);
|
|
111
|
+
server.listen(options.port ?? 0, hostname, resolve);
|
|
112
|
+
});
|
|
113
|
+
const address = server.address();
|
|
114
|
+
if (address === null || typeof address === "string")
|
|
115
|
+
throw new Error("relay server has no TCP address");
|
|
116
|
+
const port = address.port;
|
|
117
|
+
|
|
118
|
+
const browserWsUrl = `ws://${hostname}:${port}${browserPath}`;
|
|
119
|
+
const core = new RelayCore({ product: options.product, browserWsUrl });
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
core,
|
|
123
|
+
server,
|
|
124
|
+
port,
|
|
125
|
+
browserWsUrl,
|
|
126
|
+
hostWsUrl: `ws://${hostname}:${port}${hostPath}`,
|
|
127
|
+
stop: () =>
|
|
128
|
+
new Promise<void>((resolve) => {
|
|
129
|
+
for (const ws of wss.clients) ws.terminate();
|
|
130
|
+
wss.close();
|
|
131
|
+
server.closeAllConnections();
|
|
132
|
+
server.close(() => resolve());
|
|
133
|
+
}),
|
|
134
|
+
};
|
|
135
|
+
}
|