@monoes/monobrowse 1.0.13 → 1.0.14
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/dist/src/__tests__/bridge-instruments.test.d.ts +2 -0
- package/dist/src/__tests__/bridge-instruments.test.d.ts.map +1 -0
- package/dist/src/__tests__/bridge-instruments.test.js +132 -0
- package/dist/src/__tests__/bridge-instruments.test.js.map +1 -0
- package/dist/src/__tests__/bridge-transport.test.d.ts +2 -0
- package/dist/src/__tests__/bridge-transport.test.d.ts.map +1 -0
- package/dist/src/__tests__/bridge-transport.test.js +287 -0
- package/dist/src/__tests__/bridge-transport.test.js.map +1 -0
- package/dist/src/__tests__/cdp-transport.test.d.ts +2 -0
- package/dist/src/__tests__/cdp-transport.test.d.ts.map +1 -0
- package/dist/src/__tests__/cdp-transport.test.js +167 -0
- package/dist/src/__tests__/cdp-transport.test.js.map +1 -0
- package/dist/src/browser/bridge.d.ts +119 -0
- package/dist/src/browser/bridge.d.ts.map +1 -0
- package/dist/src/browser/bridge.js +264 -0
- package/dist/src/browser/bridge.js.map +1 -0
- package/dist/src/browser/cdp.d.ts +11 -1
- package/dist/src/browser/cdp.d.ts.map +1 -1
- package/dist/src/browser/cdp.js +60 -56
- package/dist/src/browser/cdp.js.map +1 -1
- package/dist/src/browser/index.d.ts +2 -0
- package/dist/src/browser/index.d.ts.map +1 -1
- package/dist/src/browser/index.js +2 -0
- package/dist/src/browser/index.js.map +1 -1
- package/dist/src/browser/transport.d.ts +62 -0
- package/dist/src/browser/transport.d.ts.map +1 -0
- package/dist/src/browser/transport.js +65 -0
- package/dist/src/browser/transport.js.map +1 -0
- package/dist/src/cli/commander-adapter.d.ts +16 -0
- package/dist/src/cli/commander-adapter.d.ts.map +1 -0
- package/dist/src/cli/commander-adapter.js +68 -0
- package/dist/src/cli/commander-adapter.js.map +1 -0
- package/dist/src/cli/commands-context.d.ts +14 -0
- package/dist/src/cli/commands-context.d.ts.map +1 -0
- package/dist/src/cli/commands-context.js +351 -0
- package/dist/src/cli/commands-context.js.map +1 -0
- package/dist/src/cli/commands-data.d.ts +12 -0
- package/dist/src/cli/commands-data.d.ts.map +1 -0
- package/dist/src/cli/commands-data.js +246 -0
- package/dist/src/cli/commands-data.js.map +1 -0
- package/dist/src/cli/commands-element.d.ts +15 -0
- package/dist/src/cli/commands-element.d.ts.map +1 -0
- package/dist/src/cli/commands-element.js +262 -0
- package/dist/src/cli/commands-element.js.map +1 -0
- package/dist/src/cli/commands-files.d.ts +9 -0
- package/dist/src/cli/commands-files.d.ts.map +1 -0
- package/dist/src/cli/commands-files.js +207 -0
- package/dist/src/cli/commands-files.js.map +1 -0
- package/dist/src/cli/commands-input.d.ts +26 -0
- package/dist/src/cli/commands-input.d.ts.map +1 -0
- package/dist/src/cli/commands-input.js +399 -0
- package/dist/src/cli/commands-input.js.map +1 -0
- package/dist/src/cli/commands-navigate.d.ts +12 -0
- package/dist/src/cli/commands-navigate.d.ts.map +1 -0
- package/dist/src/cli/commands-navigate.js +389 -0
- package/dist/src/cli/commands-navigate.js.map +1 -0
- package/dist/src/cli/commands-page.d.ts +25 -0
- package/dist/src/cli/commands-page.d.ts.map +1 -0
- package/dist/src/cli/commands-page.js +377 -0
- package/dist/src/cli/commands-page.js.map +1 -0
- package/dist/src/cli/commands-report.d.ts +8 -0
- package/dist/src/cli/commands-report.d.ts.map +1 -0
- package/dist/src/cli/commands-report.js +256 -0
- package/dist/src/cli/commands-report.js.map +1 -0
- package/dist/src/cli/commands-script.d.ts +43 -0
- package/dist/src/cli/commands-script.d.ts.map +1 -0
- package/dist/src/cli/commands-script.js +168 -0
- package/dist/src/cli/commands-script.js.map +1 -0
- package/dist/src/cli/commands-session.d.ts +15 -0
- package/dist/src/cli/commands-session.d.ts.map +1 -0
- package/dist/src/cli/commands-session.js +357 -0
- package/dist/src/cli/commands-session.js.map +1 -0
- package/dist/src/cli/commands-trace.d.ts +12 -0
- package/dist/src/cli/commands-trace.d.ts.map +1 -0
- package/dist/src/cli/commands-trace.js +347 -0
- package/dist/src/cli/commands-trace.js.map +1 -0
- package/dist/src/cli/commands.d.ts +22 -42
- package/dist/src/cli/commands.d.ts.map +1 -1
- package/dist/src/cli/commands.js +42 -3648
- package/dist/src/cli/commands.js.map +1 -1
- package/dist/src/cli/session.d.ts +47 -0
- package/dist/src/cli/session.d.ts.map +1 -0
- package/dist/src/cli/session.js +343 -0
- package/dist/src/cli/session.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/__tests__/bridge-instruments.test.ts +180 -0
- package/src/__tests__/bridge-transport.test.ts +365 -0
- package/src/__tests__/cdp-transport.test.ts +198 -0
- package/src/browser/bridge.ts +354 -0
- package/src/browser/cdp.ts +57 -52
- package/src/browser/index.ts +2 -0
- package/src/browser/transport.ts +111 -0
- package/src/cli/commander-adapter.ts +65 -0
- package/src/cli/commands-context.ts +367 -0
- package/src/cli/commands-data.ts +248 -0
- package/src/cli/commands-element.ts +305 -0
- package/src/cli/commands-files.ts +248 -0
- package/src/cli/commands-input.ts +434 -0
- package/src/cli/commands-navigate.ts +442 -0
- package/src/cli/commands-page.ts +420 -0
- package/src/cli/commands-report.ts +275 -0
- package/src/cli/commands-script.ts +183 -0
- package/src/cli/commands-session.ts +376 -0
- package/src/cli/commands-trace.ts +349 -0
- package/src/cli/commands.ts +95 -3943
- package/src/cli/session.ts +412 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transport seam under CdpClient.
|
|
3
|
+
*
|
|
4
|
+
* Two halves: WebSocketTransport must behave exactly as the inlined `ws`
|
|
5
|
+
* handling it replaced (that is what keeps the 474 existing tests honest),
|
|
6
|
+
* and CdpClient must drive *any* transport — the whole point of the seam,
|
|
7
|
+
* since the bridge-backed one is not a socket to a local Chrome at all.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
10
|
+
|
|
11
|
+
// --- fake `ws` -------------------------------------------------------------
|
|
12
|
+
let lastSocket: FakeWs | null = null;
|
|
13
|
+
|
|
14
|
+
class FakeWs {
|
|
15
|
+
handlers = new Map<string, Array<(...a: unknown[]) => void>>();
|
|
16
|
+
sent: string[] = [];
|
|
17
|
+
closed = false;
|
|
18
|
+
sendError: Error | null = null;
|
|
19
|
+
|
|
20
|
+
constructor(public url: string) {
|
|
21
|
+
lastSocket = this;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
on(event: string, fn: (...a: unknown[]) => void): void {
|
|
25
|
+
if (!this.handlers.has(event)) this.handlers.set(event, []);
|
|
26
|
+
this.handlers.get(event)!.push(fn);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
emit(event: string, ...args: unknown[]): void {
|
|
30
|
+
for (const fn of [...(this.handlers.get(event) ?? [])]) fn(...args);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
send(data: string, cb?: (err?: Error) => void): void {
|
|
34
|
+
this.sent.push(data);
|
|
35
|
+
cb?.(this.sendError ?? undefined);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
close(): void {
|
|
39
|
+
this.closed = true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
vi.mock('ws', () => ({ WebSocket: FakeWs }));
|
|
44
|
+
|
|
45
|
+
const { WebSocketTransport } = await import('../browser/transport.js');
|
|
46
|
+
const { CdpClient } = await import('../browser/cdp.js');
|
|
47
|
+
|
|
48
|
+
import type { CdpTransport, CdpTransportHandlers } from '../browser/transport.js';
|
|
49
|
+
|
|
50
|
+
/** Collects what a transport hands back up to its owner. */
|
|
51
|
+
function recorder(): CdpTransportHandlers & {
|
|
52
|
+
messages: string[];
|
|
53
|
+
closes: Array<Error | undefined>;
|
|
54
|
+
} {
|
|
55
|
+
const messages: string[] = [];
|
|
56
|
+
const closes: Array<Error | undefined> = [];
|
|
57
|
+
const errors: Error[] = [];
|
|
58
|
+
return {
|
|
59
|
+
messages,
|
|
60
|
+
closes,
|
|
61
|
+
message: (d: string) => void messages.push(d),
|
|
62
|
+
close: (e?: Error) => void closes.push(e),
|
|
63
|
+
error: (e: Error) => void errors.push(e),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe('WebSocketTransport', () => {
|
|
68
|
+
it('opens on the socket "open" event and reports the url it dialed', async () => {
|
|
69
|
+
const t = new WebSocketTransport('ws://127.0.0.1:9222/devtools/page/ABC');
|
|
70
|
+
const p = t.open(recorder());
|
|
71
|
+
lastSocket!.emit('open');
|
|
72
|
+
await p;
|
|
73
|
+
expect(lastSocket!.url).toBe('ws://127.0.0.1:9222/devtools/page/ABC');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('rejects open() when the socket errors before it ever opened', async () => {
|
|
77
|
+
const t = new WebSocketTransport('ws://127.0.0.1:1/devtools');
|
|
78
|
+
const p = t.open(recorder());
|
|
79
|
+
lastSocket!.emit('error', new Error('ECONNREFUSED'));
|
|
80
|
+
await expect(p).rejects.toThrow('ECONNREFUSED');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('routes a post-open error to error(), not to a rejected open()', async () => {
|
|
84
|
+
const h = recorder();
|
|
85
|
+
const errors: Error[] = [];
|
|
86
|
+
h.error = (e: Error) => void errors.push(e);
|
|
87
|
+
const t = new WebSocketTransport('ws://x');
|
|
88
|
+
const p = t.open(h);
|
|
89
|
+
lastSocket!.emit('open');
|
|
90
|
+
await p;
|
|
91
|
+
lastSocket!.emit('error', new Error('EPIPE'));
|
|
92
|
+
expect(errors.map((e) => e.message)).toEqual(['EPIPE']);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('delivers messages as strings and reports close', async () => {
|
|
96
|
+
const h = recorder();
|
|
97
|
+
const t = new WebSocketTransport('ws://x');
|
|
98
|
+
const p = t.open(h);
|
|
99
|
+
lastSocket!.emit('open');
|
|
100
|
+
await p;
|
|
101
|
+
lastSocket!.emit('message', Buffer.from('{"id":1}'));
|
|
102
|
+
expect(h.messages).toEqual(['{"id":1}']);
|
|
103
|
+
lastSocket!.emit('close');
|
|
104
|
+
expect(h.closes).toHaveLength(1);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('rejects send() with the socket error and closes the socket on close()', async () => {
|
|
108
|
+
const t = new WebSocketTransport('ws://x');
|
|
109
|
+
const p = t.open(recorder());
|
|
110
|
+
lastSocket!.emit('open');
|
|
111
|
+
await p;
|
|
112
|
+
await expect(t.send('{"id":1}')).resolves.toBeUndefined();
|
|
113
|
+
expect(lastSocket!.sent).toEqual(['{"id":1}']);
|
|
114
|
+
lastSocket!.sendError = new Error('socket gone');
|
|
115
|
+
await expect(t.send('{"id":2}')).rejects.toThrow('socket gone');
|
|
116
|
+
t.close();
|
|
117
|
+
expect(lastSocket!.closed).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// --- CdpClient over an arbitrary transport ---------------------------------
|
|
122
|
+
|
|
123
|
+
/** A transport with no socket at all: the test *is* the other end. */
|
|
124
|
+
class ScriptedTransport implements CdpTransport {
|
|
125
|
+
sent: string[] = [];
|
|
126
|
+
closed = false;
|
|
127
|
+
private handlers: CdpTransportHandlers | null = null;
|
|
128
|
+
|
|
129
|
+
async open(handlers: CdpTransportHandlers): Promise<void> {
|
|
130
|
+
this.handlers = handlers;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async send(frame: string): Promise<void> {
|
|
134
|
+
this.sent.push(frame);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
close(): void {
|
|
138
|
+
this.closed = true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Push a frame up as if it had arrived from the other side. */
|
|
142
|
+
deliver(msg: unknown): void {
|
|
143
|
+
this.handlers!.message(JSON.stringify(msg));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
drop(err?: Error): void {
|
|
147
|
+
this.handlers!.close(err);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
describe('CdpClient.connectTransport', () => {
|
|
152
|
+
it('round-trips a command through a transport that is not a socket', async () => {
|
|
153
|
+
const t = new ScriptedTransport();
|
|
154
|
+
const client = new CdpClient();
|
|
155
|
+
await client.connectTransport(t);
|
|
156
|
+
expect(client.isConnected()).toBe(true);
|
|
157
|
+
|
|
158
|
+
const pending = client.send<{ frameId: string }>('Page.navigate', { url: 'https://x.test' });
|
|
159
|
+
const sent = JSON.parse(t.sent[0]);
|
|
160
|
+
expect(sent.method).toBe('Page.navigate');
|
|
161
|
+
expect(sent.params).toEqual({ url: 'https://x.test' });
|
|
162
|
+
|
|
163
|
+
t.deliver({ id: sent.id, result: { frameId: 'F1' } });
|
|
164
|
+
expect(await pending).toEqual({ frameId: 'F1' });
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('dispatches events with their sessionId to listeners', async () => {
|
|
168
|
+
const t = new ScriptedTransport();
|
|
169
|
+
const client = new CdpClient();
|
|
170
|
+
await client.connectTransport(t);
|
|
171
|
+
|
|
172
|
+
const seen: Array<[Record<string, unknown>, string | undefined]> = [];
|
|
173
|
+
client.on('Network.responseReceived', (params, sid) => void seen.push([params, sid]));
|
|
174
|
+
t.deliver({ method: 'Network.responseReceived', params: { requestId: 'R1' }, sessionId: 'S1' });
|
|
175
|
+
|
|
176
|
+
expect(seen).toEqual([[{ requestId: 'R1' }, 'S1']]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('flushes in-flight commands when the transport drops', async () => {
|
|
180
|
+
const t = new ScriptedTransport();
|
|
181
|
+
const client = new CdpClient();
|
|
182
|
+
await client.connectTransport(t);
|
|
183
|
+
|
|
184
|
+
const pending = client.send('Page.enable');
|
|
185
|
+
t.drop(new Error('bridge disconnected'));
|
|
186
|
+
await expect(pending).rejects.toThrow('bridge disconnected');
|
|
187
|
+
expect(client.isConnected()).toBe(false);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('closes the transport when the client closes', async () => {
|
|
191
|
+
const t = new ScriptedTransport();
|
|
192
|
+
const client = new CdpClient();
|
|
193
|
+
await client.connectTransport(t);
|
|
194
|
+
client.close();
|
|
195
|
+
expect(t.closed).toBe(true);
|
|
196
|
+
await expect(client.send('Page.enable')).rejects.toThrow('CDP not connected');
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { enableSessionDomains } from './browser.js';
|
|
2
|
+
import { CdpClient } from './cdp.js';
|
|
3
|
+
import type { CdpTransport, CdpTransportHandlers } from './transport.js';
|
|
4
|
+
import { WebSocketTransport } from './transport.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* CDP over the MonoAgent extension bridge.
|
|
8
|
+
*
|
|
9
|
+
* The extension already holds Chrome's `debugger` permission and attaches to
|
|
10
|
+
* tabs with `chrome.debugger.attach`; mono-agent's Go server already owns the
|
|
11
|
+
* WebSocket to it. This transport puts CDP frames on that path — out through
|
|
12
|
+
* the relay's `/monoagent/cdp` socket, into `chrome.debugger.sendCommand`,
|
|
13
|
+
* and back — so a CdpClient can drive the browser the user is actually
|
|
14
|
+
* signed into rather than a headless Chrome we launched. Every instrument
|
|
15
|
+
* built on CdpClient (console, network, HAR, vitals, trace, CPU profiler,
|
|
16
|
+
* snapshot, screenshot, emulation) comes along unchanged.
|
|
17
|
+
*
|
|
18
|
+
* Two mismatches have to be reconciled here, and they are the whole job:
|
|
19
|
+
*
|
|
20
|
+
* **Sessions.** Against a raw socket, monobrowse attaches to a target and
|
|
21
|
+
* carries the resulting `sessionId` on every command and every event.
|
|
22
|
+
* `chrome.debugger` has no such id for the tab: the debuggee *is* the page
|
|
23
|
+
* session, and a `sessionId` on the wire means a *child* session (an
|
|
24
|
+
* out-of-process iframe). So this transport mints a stable synthetic id for
|
|
25
|
+
* the tab, strips it on the way out, stamps it onto relayed events on the
|
|
26
|
+
* way in, and passes any other id through untouched for real child sessions.
|
|
27
|
+
*
|
|
28
|
+
* **Attachment.** A socket is opened; a debuggee is attached. open() does the
|
|
29
|
+
* attach and fails if it cannot, rather than handing back a transport whose
|
|
30
|
+
* first command would fail. close() detaches, which is what takes Chrome's
|
|
31
|
+
* "…started debugging this browser" banner off the user's tab.
|
|
32
|
+
*
|
|
33
|
+
* Known gaps, which are Chrome's and not this file's: `chrome.debugger`
|
|
34
|
+
* exposes a fixed set of domains, and `HeapProfiler` and `Browser` are not
|
|
35
|
+
* among them, so heap snapshots and `Browser.close` fail over the bridge.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** Header the Go relay authenticates with; mirrors internal/extension/token.go. */
|
|
39
|
+
export const BRIDGE_TOKEN_HEADER = 'X-Monoagent-Extension-Token';
|
|
40
|
+
|
|
41
|
+
/** Default relay endpoint; mirrors the extension server's default port. */
|
|
42
|
+
export const DEFAULT_BRIDGE_URL = 'ws://127.0.0.1:9222/monoagent/cdp';
|
|
43
|
+
|
|
44
|
+
/** How long the attach handshake may take before open() gives up. */
|
|
45
|
+
const DEFAULT_ATTACH_TIMEOUT_MS = 15_000;
|
|
46
|
+
|
|
47
|
+
/** The synthetic target id standing in for a Chrome tab. */
|
|
48
|
+
export function bridgeTargetId(tabId: number): string {
|
|
49
|
+
return `bridge-tab-${tabId}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The synthetic session id standing in for a tab's page session. */
|
|
53
|
+
export function bridgeSessionId(tabId: number): string {
|
|
54
|
+
return `bridge-session-${tabId}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** True for ids this transport minted, as opposed to real CDP child sessions. */
|
|
58
|
+
export function isBridgeSessionId(sessionId: string | undefined): boolean {
|
|
59
|
+
return typeof sessionId === 'string' && sessionId.startsWith('bridge-session-');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface BridgeTransportOptions {
|
|
63
|
+
/**
|
|
64
|
+
* The Chrome tab to drive. Omit it to drive whichever tab the user is
|
|
65
|
+
* looking at: the extension resolves the active tab and reports the id
|
|
66
|
+
* back on attach, which is the normal way in — the caller has no tab ids
|
|
67
|
+
* to hand, only a browser with the user's session in it.
|
|
68
|
+
*/
|
|
69
|
+
tabId?: number;
|
|
70
|
+
/** Relay endpoint. Defaults to DEFAULT_BRIDGE_URL. */
|
|
71
|
+
url?: string;
|
|
72
|
+
/** Relay token, from ~/.monoagent (the Go side writes it). */
|
|
73
|
+
token?: string;
|
|
74
|
+
/** Attach-handshake budget in ms. */
|
|
75
|
+
attachTimeoutMs?: number;
|
|
76
|
+
/**
|
|
77
|
+
* The byte pipe underneath. Defaults to a WebSocket to `url`; tests inject
|
|
78
|
+
* a scripted one so they can be the relay.
|
|
79
|
+
*/
|
|
80
|
+
socket?: CdpTransport;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One bridge-level envelope, as the Go relay and the extension speak it. */
|
|
84
|
+
interface BridgeEnvelope {
|
|
85
|
+
id: string;
|
|
86
|
+
type: 'cdp' | 'cdp_attach' | 'cdp_detach';
|
|
87
|
+
/** Omitted when the extension should resolve the active tab itself. */
|
|
88
|
+
tabId?: number;
|
|
89
|
+
params?: Record<string, unknown>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface BridgeReply {
|
|
93
|
+
id?: string;
|
|
94
|
+
type?: string;
|
|
95
|
+
success?: boolean;
|
|
96
|
+
data?: {
|
|
97
|
+
result?: unknown;
|
|
98
|
+
tabId?: number;
|
|
99
|
+
method?: string;
|
|
100
|
+
params?: unknown;
|
|
101
|
+
sessionId?: string;
|
|
102
|
+
};
|
|
103
|
+
error?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A CDP frame on its way out of CdpClient. */
|
|
107
|
+
interface OutboundCdp {
|
|
108
|
+
id: number;
|
|
109
|
+
method: string;
|
|
110
|
+
params?: Record<string, unknown>;
|
|
111
|
+
sessionId?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export class BridgeTransport implements CdpTransport {
|
|
115
|
+
/** 0 until the extension resolves the active tab (see open()). */
|
|
116
|
+
private tabId: number;
|
|
117
|
+
private readonly socket: CdpTransport;
|
|
118
|
+
private readonly attachTimeoutMs: number;
|
|
119
|
+
private handlers: CdpTransportHandlers | null = null;
|
|
120
|
+
private nextEnvelopeId = 1;
|
|
121
|
+
private readonly pending = new Map<string, (reply: BridgeReply) => void>();
|
|
122
|
+
private closed = false;
|
|
123
|
+
|
|
124
|
+
constructor(opts: BridgeTransportOptions) {
|
|
125
|
+
this.tabId = opts.tabId ?? 0;
|
|
126
|
+
this.attachTimeoutMs = opts.attachTimeoutMs ?? DEFAULT_ATTACH_TIMEOUT_MS;
|
|
127
|
+
this.socket =
|
|
128
|
+
opts.socket ??
|
|
129
|
+
new WebSocketTransport(
|
|
130
|
+
opts.url ?? DEFAULT_BRIDGE_URL,
|
|
131
|
+
opts.token ? { [BRIDGE_TOKEN_HEADER]: opts.token } : undefined,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async open(handlers: CdpTransportHandlers): Promise<void> {
|
|
136
|
+
this.handlers = handlers;
|
|
137
|
+
await this.socket.open({
|
|
138
|
+
message: (data) => this.onBridgeMessage(data),
|
|
139
|
+
close: (err) => this.onGone(err ?? new Error('extension bridge closed')),
|
|
140
|
+
error: (err) => this.onGone(err),
|
|
141
|
+
});
|
|
142
|
+
const reply = await this.command('cdp_attach', undefined, this.attachTimeoutMs);
|
|
143
|
+
if (reply.success === false) {
|
|
144
|
+
this.socket.close();
|
|
145
|
+
const where = this.tabId ? `tab ${this.tabId}` : 'the active tab';
|
|
146
|
+
throw new Error(`bridge attach to ${where} failed: ${reply.error ?? 'unknown'}`);
|
|
147
|
+
}
|
|
148
|
+
// The extension answers with the tab it actually attached to, which is
|
|
149
|
+
// the only way to learn the id when we asked for "whatever is active".
|
|
150
|
+
if (typeof reply.data?.tabId === 'number') this.tabId = reply.data.tabId;
|
|
151
|
+
if (!this.tabId) {
|
|
152
|
+
this.socket.close();
|
|
153
|
+
throw new Error('bridge attach did not report a tab id');
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The tab this transport is driving; valid once open() has resolved. */
|
|
158
|
+
get attachedTabId(): number {
|
|
159
|
+
return this.tabId;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The session id every instrument should use against this transport. */
|
|
163
|
+
get sessionId(): string {
|
|
164
|
+
return bridgeSessionId(this.tabId);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async send(frame: string): Promise<void> {
|
|
168
|
+
const cdp = JSON.parse(frame) as OutboundCdp;
|
|
169
|
+
if (this.answerLocally(cdp)) return;
|
|
170
|
+
|
|
171
|
+
const params: Record<string, unknown> = {
|
|
172
|
+
method: cdp.method,
|
|
173
|
+
params: cdp.params ?? {},
|
|
174
|
+
};
|
|
175
|
+
// A synthetic id means "the tab itself", which chrome.debugger addresses
|
|
176
|
+
// by its debuggee alone. Anything else is a real child session id and
|
|
177
|
+
// has to travel, or the command lands in the wrong frame.
|
|
178
|
+
if (cdp.sessionId !== undefined && !isBridgeSessionId(cdp.sessionId)) {
|
|
179
|
+
params.sessionId = cdp.sessionId;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const reply = await this.command('cdp', params);
|
|
183
|
+
if (reply.success === false) {
|
|
184
|
+
this.deliver({
|
|
185
|
+
id: cdp.id,
|
|
186
|
+
// -32000 is CDP's own "server error" code; the bridge has no richer
|
|
187
|
+
// one to offer, and the message is what a caller actually reads.
|
|
188
|
+
error: { code: -32000, message: reply.error ?? 'bridge command failed' },
|
|
189
|
+
});
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
this.deliver({ id: cdp.id, result: reply.data?.result ?? {} });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
close(): void {
|
|
196
|
+
if (this.closed) return;
|
|
197
|
+
this.closed = true;
|
|
198
|
+
// Best effort, and deliberately not awaited: leaving the debugger
|
|
199
|
+
// attached leaves Chrome's debugging banner on the user's tab. The
|
|
200
|
+
// extension's idle sweep would eventually detach anyway.
|
|
201
|
+
void this.command('cdp_detach').catch(() => {});
|
|
202
|
+
this.socket.close();
|
|
203
|
+
this.failPending(new Error('extension bridge closed'));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// --- internals -----------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Commands this transport answers itself. `Target.attachToTarget` against
|
|
210
|
+
* the tab is the one that matters: monobrowse's connect path asks for a
|
|
211
|
+
* session, and over the bridge there is nothing to ask — the debuggee is
|
|
212
|
+
* already the session. Any *other* target id is a genuine child target and
|
|
213
|
+
* goes to Chrome.
|
|
214
|
+
*/
|
|
215
|
+
private answerLocally(cdp: OutboundCdp): boolean {
|
|
216
|
+
const targetId = cdp.params?.targetId;
|
|
217
|
+
if (cdp.method === 'Target.attachToTarget' && targetId === bridgeTargetId(this.tabId)) {
|
|
218
|
+
this.deliver({ id: cdp.id, result: { sessionId: bridgeSessionId(this.tabId) } });
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
if (
|
|
222
|
+
cdp.method === 'Target.detachFromTarget' &&
|
|
223
|
+
isBridgeSessionId(cdp.params?.sessionId as string | undefined)
|
|
224
|
+
) {
|
|
225
|
+
this.deliver({ id: cdp.id, result: {} });
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private command(
|
|
232
|
+
type: BridgeEnvelope['type'],
|
|
233
|
+
params?: Record<string, unknown>,
|
|
234
|
+
timeoutMs = 0,
|
|
235
|
+
): Promise<BridgeReply> {
|
|
236
|
+
const id = `cdp-${this.nextEnvelopeId++}`;
|
|
237
|
+
const envelope: BridgeEnvelope = { id, type, params };
|
|
238
|
+
if (this.tabId) envelope.tabId = this.tabId;
|
|
239
|
+
return new Promise<BridgeReply>((resolve, reject) => {
|
|
240
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
241
|
+
if (timeoutMs > 0) {
|
|
242
|
+
timer = setTimeout(() => {
|
|
243
|
+
this.pending.delete(id);
|
|
244
|
+
reject(new Error(`bridge ${type} timed out after ${timeoutMs}ms`));
|
|
245
|
+
}, timeoutMs);
|
|
246
|
+
timer.unref?.();
|
|
247
|
+
}
|
|
248
|
+
this.pending.set(id, (reply) => {
|
|
249
|
+
if (timer) clearTimeout(timer);
|
|
250
|
+
resolve(reply);
|
|
251
|
+
});
|
|
252
|
+
this.socket.send(JSON.stringify(envelope)).catch((err) => {
|
|
253
|
+
if (timer) clearTimeout(timer);
|
|
254
|
+
this.pending.delete(id);
|
|
255
|
+
reject(err);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private onBridgeMessage(data: string): void {
|
|
261
|
+
let msg: BridgeReply;
|
|
262
|
+
try {
|
|
263
|
+
msg = JSON.parse(data) as BridgeReply;
|
|
264
|
+
} catch {
|
|
265
|
+
return; // malformed frames are ignored, as on a raw CDP socket
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (msg.type === 'cdp_event') {
|
|
269
|
+
this.relayEvent(msg);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (msg.id !== undefined) {
|
|
273
|
+
const settle = this.pending.get(msg.id);
|
|
274
|
+
if (settle) {
|
|
275
|
+
this.pending.delete(msg.id);
|
|
276
|
+
settle(msg);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private relayEvent(msg: BridgeReply): void {
|
|
282
|
+
const ev = msg.data;
|
|
283
|
+
if (!ev?.method) return;
|
|
284
|
+
// One relay socket can carry several tabs; take only our own.
|
|
285
|
+
if (ev.tabId !== undefined && ev.tabId !== this.tabId) return;
|
|
286
|
+
|
|
287
|
+
// The debugger went away — the user hit Cancel on the banner, DevTools
|
|
288
|
+
// opened on the tab, or the tab closed. Nothing else will arrive, and a
|
|
289
|
+
// caller waiting on a command should hear that now rather than time out.
|
|
290
|
+
if (ev.method === 'Inspector.detached') {
|
|
291
|
+
const reason = (ev.params as { reason?: string } | undefined)?.reason ?? 'unknown';
|
|
292
|
+
this.onGone(new Error(`extension bridge detached from tab ${this.tabId}: ${reason}`));
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
this.deliver({
|
|
297
|
+
method: ev.method,
|
|
298
|
+
params: (ev.params as Record<string, unknown>) ?? {},
|
|
299
|
+
// A real child session id wins; otherwise this is the tab's own
|
|
300
|
+
// session, which every instrument filters its events by.
|
|
301
|
+
sessionId: ev.sessionId ?? bridgeSessionId(this.tabId),
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private deliver(frame: Record<string, unknown>): void {
|
|
306
|
+
this.handlers?.message(JSON.stringify(frame));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private onGone(err: Error): void {
|
|
310
|
+
this.failPending(err);
|
|
311
|
+
this.handlers?.close(err);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private failPending(err: Error): void {
|
|
315
|
+
for (const settle of this.pending.values()) {
|
|
316
|
+
settle({ success: false, error: err.message });
|
|
317
|
+
}
|
|
318
|
+
this.pending.clear();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** What a bridge connection hands back: the same shape connectToTarget gives. */
|
|
323
|
+
export interface BridgeConnection {
|
|
324
|
+
client: CdpClient;
|
|
325
|
+
sessionId: string;
|
|
326
|
+
tabId: number;
|
|
327
|
+
transport: BridgeTransport;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Connect a CdpClient to the user's real Chrome through the extension
|
|
332
|
+
* bridge, with the same domains enabled that a local session gets. This is
|
|
333
|
+
* the whole of the "point the instruments at the logged-in browser" story
|
|
334
|
+
* at the call site:
|
|
335
|
+
*
|
|
336
|
+
* const { client, sessionId } = await connectBridge({ token });
|
|
337
|
+
* const vitals = await collectVitals(client, sessionId);
|
|
338
|
+
*
|
|
339
|
+
* `enableDomains` is on by default for parity with connectToTarget. Turn it
|
|
340
|
+
* off for a caller that wants to choose its own domains (a bridge attach is
|
|
341
|
+
* visible to the user, so a lighter footprint is sometimes the point).
|
|
342
|
+
*/
|
|
343
|
+
export async function connectBridge(
|
|
344
|
+
opts: BridgeTransportOptions & { enableDomains?: boolean } = {},
|
|
345
|
+
): Promise<BridgeConnection> {
|
|
346
|
+
const transport = new BridgeTransport(opts);
|
|
347
|
+
const client = new CdpClient();
|
|
348
|
+
await client.connectTransport(transport);
|
|
349
|
+
const sessionId = transport.sessionId;
|
|
350
|
+
if (opts.enableDomains !== false) {
|
|
351
|
+
await enableSessionDomains(client, sessionId);
|
|
352
|
+
}
|
|
353
|
+
return { client, sessionId, tabId: transport.attachedTabId, transport };
|
|
354
|
+
}
|