@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,422 @@
1
+ import {
2
+ CDP_SERVER_ERROR,
3
+ type CdpError,
4
+ type CdpMessage,
5
+ type FrameInfo,
6
+ type HostToRelayMessage,
7
+ isHandshakeMessage,
8
+ PROTOCOL_VERSION,
9
+ parseJson,
10
+ type RelayToHostMessage,
11
+ type TargetSummary,
12
+ type WelcomeMessage,
13
+ } from "../protocol.ts";
14
+
15
+ /** Minimal structural view of an iframe, so tests can fake it. */
16
+ export type FrameElementLike = {
17
+ contentWindow: {
18
+ postMessage: (message: unknown, targetOrigin: string, transfer?: Transferable[]) => void;
19
+ } | null;
20
+ addEventListener: (type: "load", listener: () => void) => void;
21
+ removeEventListener: (type: "load", listener: () => void) => void;
22
+ };
23
+
24
+ /** Minimal structural view of the parent window, so tests can fake it. */
25
+ export type WindowLike = {
26
+ addEventListener: (type: "message", listener: (event: MessageEvent) => void) => void;
27
+ removeEventListener: (type: "message", listener: (event: MessageEvent) => void) => void;
28
+ };
29
+
30
+ export type PairOptions = {
31
+ /** Stable Target id for this Pairing. Survives reloads and navigations. */
32
+ targetId: string;
33
+ /**
34
+ * Frame origins allowed to pair into this slot, or "*" to accept whatever
35
+ * the iframe element currently hosts.
36
+ */
37
+ origins: string[] | "*";
38
+ };
39
+
40
+ export type LocalSession = {
41
+ send(method: string, params?: Record<string, unknown>): Promise<unknown>;
42
+ onEvent(listener: (method: string, params: Record<string, unknown>) => void): () => void;
43
+ detach(): void;
44
+ };
45
+
46
+ export type TargetEvent =
47
+ | { kind: "targetCreated"; target: TargetSummary }
48
+ | { kind: "targetDestroyed"; targetId: string }
49
+ | { kind: "targetInfoChanged"; target: TargetSummary };
50
+
51
+ type PendingCall = {
52
+ consumerKey: string;
53
+ settle: (result: unknown, error?: CdpError) => void;
54
+ };
55
+
56
+ type Pairing = {
57
+ targetId: string;
58
+ iframe: FrameElementLike;
59
+ origins: string[] | "*";
60
+ port: MessagePort | null;
61
+ connected: boolean;
62
+ info: FrameInfo;
63
+ nextCommandId: number;
64
+ pending: Map<number, PendingCall>;
65
+ /** domain -> consumer keys that currently have it enabled */
66
+ enables: Map<string, Set<string>>;
67
+ localSessions: Map<string, LocalSessionState>;
68
+ onLoad: () => void;
69
+ };
70
+
71
+ type LocalSessionState = {
72
+ key: string;
73
+ listeners: Set<(method: string, params: Record<string, unknown>) => void>;
74
+ };
75
+
76
+ export type RelayUplinkOptions = {
77
+ /** Bridge WebSocket URL on the Relay, e.g. ws://host/icdp/host */
78
+ url: string;
79
+ reconnectDelayMs?: number;
80
+ webSocketFactory?: (url: string) => WebSocket;
81
+ };
82
+
83
+ const TARGET_NOT_CONNECTED = "Target is not connected: the Frame Agent has not paired yet.";
84
+
85
+ function methodDomain(method: string): string {
86
+ return method.split(".")[0] ?? method;
87
+ }
88
+
89
+ export class IcdpHost {
90
+ private readonly pairings = new Map<string, Pairing>();
91
+ private readonly targetListeners = new Set<(event: TargetEvent) => void>();
92
+ private nextLocalSession = 1;
93
+ private uplink: RelayUplink | null = null;
94
+ private readonly onWindowMessage = (event: MessageEvent) => this.handleWindowMessage(event);
95
+
96
+ constructor(private readonly win: WindowLike = window) {
97
+ win.addEventListener("message", this.onWindowMessage);
98
+ }
99
+
100
+ /** Register an iframe slot as a Target. The Pairing owns target identity. */
101
+ pair(iframe: FrameElementLike, options: PairOptions): void {
102
+ if (this.pairings.has(options.targetId)) {
103
+ throw new Error(`Target "${options.targetId}" is already paired`);
104
+ }
105
+ const pairing: Pairing = {
106
+ targetId: options.targetId,
107
+ iframe,
108
+ origins: options.origins,
109
+ port: null,
110
+ connected: false,
111
+ info: { title: options.targetId, url: "" },
112
+ nextCommandId: 1,
113
+ pending: new Map(),
114
+ enables: new Map(),
115
+ localSessions: new Map(),
116
+ onLoad: () => this.probe(pairing),
117
+ };
118
+ this.pairings.set(options.targetId, pairing);
119
+ iframe.addEventListener("load", pairing.onLoad);
120
+ this.probe(pairing);
121
+ this.emitTargetEvent({ kind: "targetCreated", target: this.summary(pairing) });
122
+ }
123
+
124
+ /** Destroy a Pairing. This is the only way a Target dies. */
125
+ unpair(targetId: string): void {
126
+ const pairing = this.pairings.get(targetId);
127
+ if (!pairing) return;
128
+ this.pairings.delete(targetId);
129
+ pairing.iframe.removeEventListener("load", pairing.onLoad);
130
+ this.failPending(pairing, "Target destroyed");
131
+ pairing.port?.close();
132
+ pairing.port = null;
133
+ this.emitTargetEvent({ kind: "targetDestroyed", targetId });
134
+ }
135
+
136
+ targets(): TargetSummary[] {
137
+ return Array.from(this.pairings.values(), (pairing) => this.summary(pairing));
138
+ }
139
+
140
+ onTargets(listener: (event: TargetEvent) => void): () => void {
141
+ this.targetListeners.add(listener);
142
+ return () => this.targetListeners.delete(listener);
143
+ }
144
+
145
+ /** Attach a local consumer (e.g. a console panel) to a Target — no server involved. */
146
+ attach(targetId: string): LocalSession {
147
+ const pairing = this.pairings.get(targetId);
148
+ if (!pairing) throw new Error(`Unknown target "${targetId}"`);
149
+ const state: LocalSessionState = {
150
+ key: `local-${this.nextLocalSession++}`,
151
+ listeners: new Set(),
152
+ };
153
+ pairing.localSessions.set(state.key, state);
154
+
155
+ return {
156
+ send: (method, params = {}) =>
157
+ new Promise((resolve, reject) => {
158
+ this.dispatch(pairing, state.key, method, params, (result, error) => {
159
+ if (error) reject(Object.assign(new Error(error.message), { code: error.code }));
160
+ else resolve(result);
161
+ });
162
+ }),
163
+ onEvent: (listener) => {
164
+ state.listeners.add(listener);
165
+ return () => state.listeners.delete(listener);
166
+ },
167
+ detach: () => {
168
+ pairing.localSessions.delete(state.key);
169
+ this.releaseEnables(pairing, state.key);
170
+ },
171
+ };
172
+ }
173
+
174
+ /** Connect the Relay uplink. Structurally just another consumer of this hub. */
175
+ connectRelay(options: RelayUplinkOptions): () => void {
176
+ this.uplink?.close();
177
+ this.uplink = new RelayUplink(this, options);
178
+ return () => {
179
+ this.uplink?.close();
180
+ this.uplink = null;
181
+ };
182
+ }
183
+
184
+ destroy(): void {
185
+ this.uplink?.close();
186
+ this.uplink = null;
187
+ for (const targetId of Array.from(this.pairings.keys())) this.unpair(targetId);
188
+ this.win.removeEventListener("message", this.onWindowMessage);
189
+ }
190
+
191
+ // -- internals ------------------------------------------------------------
192
+
193
+ private summary(pairing: Pairing): TargetSummary {
194
+ return { targetId: pairing.targetId, ...pairing.info };
195
+ }
196
+
197
+ private emitTargetEvent(event: TargetEvent): void {
198
+ for (const listener of this.targetListeners) listener(event);
199
+ this.uplink?.handleTargetEvent(event);
200
+ }
201
+
202
+ private probe(pairing: Pairing): void {
203
+ // The probe carries nothing sensitive, so "*" is safe here; the security
204
+ // gate is the welcome (origin-checked) and the agent's own allowlist.
205
+ pairing.iframe.contentWindow?.postMessage({ icdp: "probe", v: PROTOCOL_VERSION }, "*");
206
+ }
207
+
208
+ private handleWindowMessage(event: MessageEvent): void {
209
+ if (!isHandshakeMessage(event.data) || event.data.icdp !== "hello") return;
210
+ const pairing = Array.from(this.pairings.values()).find(
211
+ (candidate) =>
212
+ candidate.iframe.contentWindow !== null && candidate.iframe.contentWindow === event.source,
213
+ );
214
+ if (!pairing) return;
215
+ if (pairing.origins !== "*" && !pairing.origins.includes(event.origin)) return;
216
+
217
+ if (pairing.port) {
218
+ this.failPending(pairing, "Target reloaded");
219
+ pairing.port.close();
220
+ }
221
+
222
+ const channel = new MessageChannel();
223
+ pairing.port = channel.port1;
224
+ pairing.connected = true;
225
+ pairing.info = { title: event.data.title, url: event.data.url };
226
+ pairing.enables.clear();
227
+ channel.port1.onmessage = (portEvent) =>
228
+ this.handleFrameMessage(pairing, String(portEvent.data));
229
+ pairing.iframe.contentWindow?.postMessage(
230
+ { icdp: "welcome", v: PROTOCOL_VERSION } satisfies WelcomeMessage,
231
+ event.origin === "null" ? "*" : event.origin,
232
+ [channel.port2],
233
+ );
234
+
235
+ this.emitTargetEvent({ kind: "targetInfoChanged", target: this.summary(pairing) });
236
+ }
237
+
238
+ private handleFrameMessage(pairing: Pairing, raw: string): void {
239
+ const message = parseJson<CdpMessage>(raw);
240
+ if (!message) return;
241
+
242
+ if (message.id != null) {
243
+ const call = pairing.pending.get(Number(message.id));
244
+ if (!call) return;
245
+ pairing.pending.delete(Number(message.id));
246
+ call.settle(message.result ?? {}, message.error);
247
+ return;
248
+ }
249
+
250
+ if (!message.method) return;
251
+ const params = message.params ?? {};
252
+ for (const session of pairing.localSessions.values()) {
253
+ for (const listener of session.listeners) listener(message.method, params);
254
+ }
255
+ this.uplink?.handleFrameEvent(pairing.targetId, message.method, params);
256
+ }
257
+
258
+ private failPending(pairing: Pairing, reason: string): void {
259
+ for (const [id, call] of pairing.pending) {
260
+ pairing.pending.delete(id);
261
+ call.settle(undefined, { code: CDP_SERVER_ERROR, message: reason });
262
+ }
263
+ }
264
+
265
+ /** Route one command from a consumer to the Frame Agent, with enable ref-counting. */
266
+ dispatch(
267
+ pairing: Pairing,
268
+ consumerKey: string,
269
+ method: string,
270
+ params: Record<string, unknown>,
271
+ settle: (result: unknown, error?: CdpError) => void,
272
+ ): void {
273
+ const domain = methodDomain(method);
274
+
275
+ if (method.endsWith(".disable")) {
276
+ const holders = pairing.enables.get(domain);
277
+ holders?.delete(consumerKey);
278
+ if (holders && holders.size > 0) {
279
+ // Another consumer still has this domain enabled — swallow the disable.
280
+ settle({});
281
+ return;
282
+ }
283
+ }
284
+
285
+ if (!pairing.port) {
286
+ settle(undefined, { code: CDP_SERVER_ERROR, message: TARGET_NOT_CONNECTED });
287
+ return;
288
+ }
289
+
290
+ if (method.endsWith(".enable")) {
291
+ let holders = pairing.enables.get(domain);
292
+ if (!holders) {
293
+ holders = new Set();
294
+ pairing.enables.set(domain, holders);
295
+ }
296
+ holders.add(consumerKey);
297
+ }
298
+
299
+ const commandId = pairing.nextCommandId++;
300
+ pairing.pending.set(commandId, { consumerKey, settle });
301
+ pairing.port.postMessage(JSON.stringify({ id: commandId, method, params }));
302
+ }
303
+
304
+ dispatchTo(
305
+ targetId: string,
306
+ consumerKey: string,
307
+ method: string,
308
+ params: Record<string, unknown>,
309
+ settle: (result: unknown, error?: CdpError) => void,
310
+ ): void {
311
+ const pairing = this.pairings.get(targetId);
312
+ if (!pairing) {
313
+ settle(undefined, { code: CDP_SERVER_ERROR, message: `Unknown target "${targetId}"` });
314
+ return;
315
+ }
316
+ this.dispatch(pairing, consumerKey, method, params, settle);
317
+ }
318
+
319
+ /** Drop a consumer's enable refs; send disables to the frame for domains it held last. */
320
+ releaseEnables(pairing: Pairing, consumerKey: string): void {
321
+ for (const [domain, holders] of pairing.enables) {
322
+ if (!holders.delete(consumerKey)) continue;
323
+ if (holders.size === 0 && pairing.port) {
324
+ const commandId = pairing.nextCommandId++;
325
+ pairing.pending.set(commandId, { consumerKey, settle: () => {} });
326
+ pairing.port.postMessage(
327
+ JSON.stringify({ id: commandId, method: `${domain}.disable`, params: {} }),
328
+ );
329
+ }
330
+ }
331
+ }
332
+
333
+ releaseEnablesFor(targetId: string, consumerKey: string): void {
334
+ const pairing = this.pairings.get(targetId);
335
+ if (pairing) this.releaseEnables(pairing, consumerKey);
336
+ }
337
+ }
338
+
339
+ class RelayUplink {
340
+ private socket: WebSocket | null = null;
341
+ private closed = false;
342
+ private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
343
+
344
+ constructor(
345
+ private readonly host: IcdpHost,
346
+ private readonly options: RelayUplinkOptions,
347
+ ) {
348
+ this.connect();
349
+ }
350
+
351
+ private connect(): void {
352
+ if (this.closed) return;
353
+ const factory = this.options.webSocketFactory ?? ((url: string) => new WebSocket(url));
354
+ const socket = factory(this.options.url);
355
+ this.socket = socket;
356
+ socket.addEventListener("open", () => {
357
+ this.send({ kind: "ready", v: PROTOCOL_VERSION, targets: this.host.targets() });
358
+ });
359
+ socket.addEventListener("message", (event) => {
360
+ const message = parseJson<RelayToHostMessage>(String(event.data));
361
+ if (message) this.handleRelayMessage(message);
362
+ });
363
+ socket.addEventListener("close", () => {
364
+ if (this.socket === socket) this.socket = null;
365
+ this.scheduleReconnect();
366
+ });
367
+ socket.addEventListener("error", () => socket.close());
368
+ }
369
+
370
+ private scheduleReconnect(): void {
371
+ if (this.closed || this.reconnectTimer !== undefined) return;
372
+ this.reconnectTimer = setTimeout(() => {
373
+ this.reconnectTimer = undefined;
374
+ this.connect();
375
+ }, this.options.reconnectDelayMs ?? 500);
376
+ }
377
+
378
+ close(): void {
379
+ this.closed = true;
380
+ if (this.reconnectTimer !== undefined) clearTimeout(this.reconnectTimer);
381
+ this.socket?.close();
382
+ this.socket = null;
383
+ }
384
+
385
+ private send(message: HostToRelayMessage): void {
386
+ if (this.socket?.readyState === WebSocket.OPEN) {
387
+ this.socket.send(JSON.stringify(message));
388
+ }
389
+ }
390
+
391
+ private handleRelayMessage(message: RelayToHostMessage): void {
392
+ if (message.kind === "command") {
393
+ this.host.dispatchTo(
394
+ message.targetId,
395
+ `relay-${message.sessionId}`,
396
+ message.method,
397
+ message.params,
398
+ (result, error) => {
399
+ this.send({
400
+ kind: "response",
401
+ sessionId: message.sessionId,
402
+ id: message.id,
403
+ ...(error ? { error } : { result }),
404
+ });
405
+ },
406
+ );
407
+ } else if (message.kind === "detached") {
408
+ this.host.releaseEnablesFor(message.targetId, `relay-${message.sessionId}`);
409
+ }
410
+ }
411
+
412
+ handleFrameEvent(targetId: string, method: string, params: Record<string, unknown>): void {
413
+ this.send({ kind: "event", targetId, method, params });
414
+ }
415
+
416
+ handleTargetEvent(event: TargetEvent): void {
417
+ if (event.kind === "targetCreated") this.send({ kind: "targetCreated", target: event.target });
418
+ else if (event.kind === "targetDestroyed")
419
+ this.send({ kind: "targetDestroyed", targetId: event.targetId });
420
+ else this.send({ kind: "targetInfoChanged", target: event.target });
421
+ }
422
+ }
@@ -0,0 +1,125 @@
1
+ import type Protocol from "devtools-protocol";
2
+
3
+ export const PROTOCOL_VERSION = 1;
4
+
5
+ export type CdpId = Protocol.integer | string;
6
+
7
+ /** A raw CDP message: command, response, or event. */
8
+ export type CdpMessage = {
9
+ id?: CdpId;
10
+ method?: string;
11
+ params?: Record<string, unknown>;
12
+ sessionId?: string;
13
+ result?: unknown;
14
+ error?: CdpError;
15
+ };
16
+
17
+ export type CdpError = { code: number; message: string };
18
+
19
+ export const CDP_SERVER_ERROR = -32000;
20
+ export const CDP_METHOD_NOT_FOUND = -32601;
21
+
22
+ /** Metadata a Frame Agent reports about its document. */
23
+ export type FrameInfo = {
24
+ title: string;
25
+ url: string;
26
+ };
27
+
28
+ /** Target metadata as the Host reports it to the Relay. */
29
+ export type TargetSummary = FrameInfo & {
30
+ targetId: string;
31
+ };
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Frame Agent <-> Host handshake (window.postMessage, then a MessagePort)
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /** Sent by the Frame Agent to window.parent when it boots (and on probe). */
38
+ export type HelloMessage = {
39
+ icdp: "hello";
40
+ v: number;
41
+ } & FrameInfo;
42
+
43
+ /** Sent by the Host to an iframe it doesn't yet have a channel for. */
44
+ export type ProbeMessage = {
45
+ icdp: "probe";
46
+ v: number;
47
+ };
48
+
49
+ /** Sent by the Host in reply to hello, transferring a MessagePort. */
50
+ export type WelcomeMessage = {
51
+ icdp: "welcome";
52
+ v: number;
53
+ };
54
+
55
+ export type HandshakeMessage = HelloMessage | ProbeMessage | WelcomeMessage;
56
+
57
+ export function isHandshakeMessage(data: unknown): data is HandshakeMessage {
58
+ return (
59
+ typeof data === "object" &&
60
+ data !== null &&
61
+ "icdp" in data &&
62
+ ((data as { icdp: unknown }).icdp === "hello" ||
63
+ (data as { icdp: unknown }).icdp === "probe" ||
64
+ (data as { icdp: unknown }).icdp === "welcome")
65
+ );
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Host <-> Relay bridge protocol (WebSocket, JSON frames)
70
+ // ---------------------------------------------------------------------------
71
+
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[] };
74
+ /** Host -> Relay: a Pairing appeared. */
75
+ export type BridgeTargetCreated = { kind: "targetCreated"; target: TargetSummary };
76
+ /** Host -> Relay: a Pairing was destroyed by the Host. */
77
+ export type BridgeTargetDestroyed = { kind: "targetDestroyed"; targetId: string };
78
+ /** Host -> Relay: a Target's document changed (reload / navigation under a stable targetId). */
79
+ export type BridgeTargetInfoChanged = { kind: "targetInfoChanged"; target: TargetSummary };
80
+ /** Relay -> Host: a Client command routed to one session. */
81
+ export type BridgeCommand = {
82
+ kind: "command";
83
+ sessionId: string;
84
+ targetId: string;
85
+ id: number;
86
+ method: string;
87
+ params: Record<string, unknown>;
88
+ };
89
+ /** Host -> Relay: the response to a BridgeCommand. */
90
+ export type BridgeResponse = {
91
+ kind: "response";
92
+ sessionId: string;
93
+ id: number;
94
+ result?: unknown;
95
+ error?: CdpError;
96
+ };
97
+ /** Host -> Relay: a CDP event from a Target; the Relay fans it out to every session attached to it. */
98
+ export type BridgeEvent = {
99
+ kind: "event";
100
+ targetId: string;
101
+ method: string;
102
+ params: Record<string, unknown>;
103
+ };
104
+ /** Relay -> Host: a session detached (Client disconnected or detached explicitly). */
105
+ export type BridgeDetached = { kind: "detached"; sessionId: string; targetId: string };
106
+
107
+ export type HostToRelayMessage =
108
+ | BridgeReady
109
+ | BridgeTargetCreated
110
+ | BridgeTargetDestroyed
111
+ | BridgeTargetInfoChanged
112
+ | BridgeResponse
113
+ | BridgeEvent;
114
+
115
+ export type RelayToHostMessage = BridgeCommand | BridgeDetached;
116
+
117
+ export function parseJson<T>(raw: string | Buffer | ArrayBuffer | Uint8Array): T | null {
118
+ try {
119
+ return JSON.parse(
120
+ typeof raw === "string" ? raw : new TextDecoder().decode(raw as Uint8Array),
121
+ ) as T;
122
+ } catch {
123
+ return null;
124
+ }
125
+ }