@crouton-kit/capture 1.3.6 → 1.3.7
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/bin/capture +1384 -891
- package/package.json +1 -1
- package/src/capture.ts +20 -0
- package/src/cdp/args.ts +14 -0
- package/src/cdp/bridge/client.ts +49 -0
- package/src/cdp/bridge/protocol.ts +38 -0
- package/src/cdp/bridge/server.ts +179 -0
- package/src/cdp/bridge/spawn.ts +65 -0
- package/src/cdp/client.ts +7 -1
- package/src/cdp/commands/bridge-serve.ts +18 -0
- package/src/cdp/commands/cdp.ts +175 -0
- package/src/cdp/dispatch.ts +4 -0
- package/src/cdp/targets.ts +8 -1
- package/src/cdp/types.ts +5 -0
- package/src/cdp/usage.ts +7 -0
- package/src/session/commands.ts +64 -5
- package/src/session-context.ts +2 -0
package/package.json
CHANGED
package/src/capture.ts
CHANGED
|
@@ -72,6 +72,8 @@ async function main(): Promise<void> {
|
|
|
72
72
|
case "lib":
|
|
73
73
|
case "network":
|
|
74
74
|
case "list":
|
|
75
|
+
case "cdp":
|
|
76
|
+
case "__bridge-serve":
|
|
75
77
|
return cdpMain();
|
|
76
78
|
|
|
77
79
|
default:
|
|
@@ -126,6 +128,24 @@ DIAGNOSTICS & ONE-OFFS (no session needed)
|
|
|
126
128
|
network <offline|online> Toggle connectivity for a tab
|
|
127
129
|
har create | read [id] | delete <id> Manage standalone HAR recordings
|
|
128
130
|
|
|
131
|
+
RAW CDP ESCAPE HATCH
|
|
132
|
+
|
|
133
|
+
cdp <Domain.method> [--params json] Send any CDP command capture doesn't wrap
|
|
134
|
+
[--browser] Target the browser connection (Browser.*, ServiceWorker.*)
|
|
135
|
+
[--wait-event name] Wait for a CDP event, e.g. after ServiceWorker.enable
|
|
136
|
+
|
|
137
|
+
Browser-level state (permission grants, ServiceWorker enablement) reverts
|
|
138
|
+
the instant its connection closes. session start --hold keeps ONE browser
|
|
139
|
+
connection open across commands so that state survives between them:
|
|
140
|
+
|
|
141
|
+
capture session start --url http://localhost:3000 --hold
|
|
142
|
+
capture cdp Browser.grantPermissions --browser --params '{"origin":"...","permissions":["notifications"]}'
|
|
143
|
+
capture cdp ServiceWorker.enable --browser --target <pageTabId>
|
|
144
|
+
capture cdp ServiceWorker.deliverPushMessage --browser --target <pageTabId> --params '{...}'
|
|
145
|
+
capture session stop <session-id>
|
|
146
|
+
|
|
147
|
+
capture cdp --help Full reference
|
|
148
|
+
|
|
129
149
|
LIBRARY (vault libs — dev checkout only)
|
|
130
150
|
|
|
131
151
|
lib list List available libs
|
package/src/cdp/args.ts
CHANGED
|
@@ -94,6 +94,20 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
|
|
|
94
94
|
} else if (arg === '--limit' && next) {
|
|
95
95
|
parsed.limit = parseInt(next, 10);
|
|
96
96
|
i++;
|
|
97
|
+
} else if (arg === '--browser') {
|
|
98
|
+
parsed.browser = true;
|
|
99
|
+
} else if (arg === '--params' && next) {
|
|
100
|
+
parsed.params = next;
|
|
101
|
+
i++;
|
|
102
|
+
} else if (arg === '--wait-event' && next) {
|
|
103
|
+
parsed.waitEvent = next;
|
|
104
|
+
i++;
|
|
105
|
+
} else if (arg === '--timeout' && next) {
|
|
106
|
+
parsed.timeoutMs = parseInt(next, 10);
|
|
107
|
+
i++;
|
|
108
|
+
} else if (arg === '--socket' && next) {
|
|
109
|
+
parsed.socket = next;
|
|
110
|
+
i++;
|
|
97
111
|
} else if (arg === '--help' || arg === '-h') {
|
|
98
112
|
parsed.help = true;
|
|
99
113
|
} else if (arg.startsWith('--')) {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client side of the bridge protocol \u2014 used by `capture cdp --browser` to
|
|
3
|
+
* relay one request to an already-running bridge server over its socket.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as net from 'net';
|
|
7
|
+
import { type BridgeRequest, type BridgeResponse } from './protocol.js';
|
|
8
|
+
|
|
9
|
+
let reqCounter = 0;
|
|
10
|
+
|
|
11
|
+
export function sendBridgeRequest(
|
|
12
|
+
socketPath: string,
|
|
13
|
+
req: Omit<BridgeRequest, 'reqId'>,
|
|
14
|
+
): Promise<BridgeResponse> {
|
|
15
|
+
const wireTimeoutMs = (req.timeoutMs ?? 10000) + 5000;
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
const reqId = ++reqCounter;
|
|
18
|
+
const socket = net.createConnection(socketPath);
|
|
19
|
+
let buffer = '';
|
|
20
|
+
|
|
21
|
+
const timer = setTimeout(() => {
|
|
22
|
+
socket.destroy();
|
|
23
|
+
reject(new Error(`Bridge request timed out after ${wireTimeoutMs}ms (socket: ${socketPath})`));
|
|
24
|
+
}, wireTimeoutMs);
|
|
25
|
+
|
|
26
|
+
socket.on('connect', () => {
|
|
27
|
+
socket.write(JSON.stringify({ ...req, reqId } satisfies BridgeRequest) + '\n');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
socket.on('data', (chunk) => {
|
|
31
|
+
buffer += chunk.toString('utf-8');
|
|
32
|
+
const idx = buffer.indexOf('\n');
|
|
33
|
+
if (idx < 0) return;
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
const line = buffer.slice(0, idx);
|
|
36
|
+
socket.end();
|
|
37
|
+
try {
|
|
38
|
+
resolve(JSON.parse(line) as BridgeResponse);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
socket.on('error', (err) => {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
reject(err);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire protocol between `capture cdp --browser` and the held bridge process
|
|
3
|
+
* (`capture __bridge-serve`). Newline-delimited JSON over a Unix domain
|
|
4
|
+
* socket — one request per connection, one response, then the socket closes.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface BridgeRequest {
|
|
8
|
+
reqId: number;
|
|
9
|
+
/** CDP method, e.g. "Browser.grantPermissions". Omit when only waiting for an event. */
|
|
10
|
+
method?: string;
|
|
11
|
+
params?: Record<string, unknown>;
|
|
12
|
+
/**
|
|
13
|
+
* Target (page or worker) to scope this call to, via a flattened
|
|
14
|
+
* `Target.attachToTarget` session on the SAME held browser websocket.
|
|
15
|
+
* Required for domains that live on a target rather than the browser
|
|
16
|
+
* itself (`ServiceWorker.*`, `Page.*`, ...) — omit for pure browser-level
|
|
17
|
+
* domains (`Browser.*`, `Target.*`).
|
|
18
|
+
*/
|
|
19
|
+
targetId?: string;
|
|
20
|
+
/** CDP event name to wait for (consumes the next occurrence, FIFO, buffered if it already fired). */
|
|
21
|
+
waitEvent?: string;
|
|
22
|
+
timeoutMs?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface BridgeResponseOk {
|
|
26
|
+
reqId: number;
|
|
27
|
+
ok: true;
|
|
28
|
+
result?: unknown;
|
|
29
|
+
event?: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface BridgeResponseErr {
|
|
33
|
+
reqId: number;
|
|
34
|
+
ok: false;
|
|
35
|
+
error: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type BridgeResponse = BridgeResponseOk | BridgeResponseErr;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bridge server: holds ONE browser-level CDP connection open for the
|
|
3
|
+
* lifetime of a held session and exposes it to (many, sequential) `capture
|
|
4
|
+
* cdp --browser` invocations over a Unix socket.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists: `Browser.grantPermissions`, `ServiceWorker.enable`, and
|
|
7
|
+
* other browser-scoped CDP state are per-CLIENT and revert the instant the
|
|
8
|
+
* granting websocket disconnects. `capture` normally opens/closes a fresh
|
|
9
|
+
* websocket per command, so that state was gone by the next command. Running
|
|
10
|
+
* this as a long-lived detached process is what keeps ONE websocket (and
|
|
11
|
+
* therefore that state) alive across many separate `capture` invocations.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from 'fs';
|
|
15
|
+
import * as net from 'net';
|
|
16
|
+
import * as path from 'path';
|
|
17
|
+
import { getBrowserClient } from '../targets.js';
|
|
18
|
+
import { type CDPClient } from '../client.js';
|
|
19
|
+
import { type BridgeRequest, type BridgeResponse } from './protocol.js';
|
|
20
|
+
|
|
21
|
+
interface EventWaiter {
|
|
22
|
+
resolve: (v: unknown) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class EventBroker {
|
|
26
|
+
private queues = new Map<string, unknown[]>();
|
|
27
|
+
private waiters = new Map<string, EventWaiter[]>();
|
|
28
|
+
private listening = new Set<string>();
|
|
29
|
+
|
|
30
|
+
constructor(private client: CDPClient) {}
|
|
31
|
+
|
|
32
|
+
private ensureListening(eventName: string): void {
|
|
33
|
+
if (this.listening.has(eventName)) return;
|
|
34
|
+
this.listening.add(eventName);
|
|
35
|
+
this.client.on(eventName, (params) => {
|
|
36
|
+
const waiters = this.waiters.get(eventName);
|
|
37
|
+
if (waiters && waiters.length > 0) {
|
|
38
|
+
waiters.shift()!.resolve(params);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const q = this.queues.get(eventName) ?? [];
|
|
42
|
+
q.push(params);
|
|
43
|
+
// Cap the buffer so an event nobody ever asks for can't leak memory.
|
|
44
|
+
if (q.length > 50) q.shift();
|
|
45
|
+
this.queues.set(eventName, q);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
wait(eventName: string, timeoutMs: number): Promise<unknown> {
|
|
50
|
+
this.ensureListening(eventName);
|
|
51
|
+
const q = this.queues.get(eventName);
|
|
52
|
+
if (q && q.length > 0) {
|
|
53
|
+
return Promise.resolve(q.shift());
|
|
54
|
+
}
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
const list = this.waiters.get(eventName) ?? [];
|
|
57
|
+
const entry: EventWaiter = {
|
|
58
|
+
resolve: (v) => {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
resolve(v);
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
const timer = setTimeout(() => {
|
|
64
|
+
const current = this.waiters.get(eventName);
|
|
65
|
+
if (current) {
|
|
66
|
+
const idx = current.indexOf(entry);
|
|
67
|
+
if (idx >= 0) current.splice(idx, 1);
|
|
68
|
+
}
|
|
69
|
+
reject(new Error(`Timed out after ${timeoutMs}ms waiting for event "${eventName}"`));
|
|
70
|
+
}, timeoutMs);
|
|
71
|
+
list.push(entry);
|
|
72
|
+
this.waiters.set(eventName, list);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function runBridgeServer(socketPath: string, port?: number): Promise<void> {
|
|
78
|
+
const { client } = await getBrowserClient(await resolvePort(port));
|
|
79
|
+
const events = new EventBroker(client);
|
|
80
|
+
|
|
81
|
+
// Cache flattened Target.attachToTarget sessions so repeated requests
|
|
82
|
+
// against the same target (e.g. ServiceWorker.enable then
|
|
83
|
+
// ServiceWorker.deliverPushMessage) reuse one attach instead of piling up.
|
|
84
|
+
const targetSessions = new Map<string, string>();
|
|
85
|
+
async function attach(targetId: string): Promise<string> {
|
|
86
|
+
const cached = targetSessions.get(targetId);
|
|
87
|
+
if (cached) return cached;
|
|
88
|
+
const result = (await client.send('Target.attachToTarget', { targetId, flatten: true })) as {
|
|
89
|
+
sessionId: string;
|
|
90
|
+
};
|
|
91
|
+
targetSessions.set(targetId, result.sessionId);
|
|
92
|
+
return result.sessionId;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
fs.mkdirSync(path.dirname(socketPath), { recursive: true });
|
|
96
|
+
try {
|
|
97
|
+
fs.unlinkSync(socketPath);
|
|
98
|
+
} catch {
|
|
99
|
+
// No stale socket to remove.
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const server = net.createServer((socket) => {
|
|
103
|
+
let buffer = '';
|
|
104
|
+
socket.on('data', (chunk) => {
|
|
105
|
+
buffer += chunk.toString('utf-8');
|
|
106
|
+
let idx: number;
|
|
107
|
+
while ((idx = buffer.indexOf('\n')) >= 0) {
|
|
108
|
+
const line = buffer.slice(0, idx);
|
|
109
|
+
buffer = buffer.slice(idx + 1);
|
|
110
|
+
if (line.trim()) void handleLine(line, socket);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
async function handleLine(line: string, socket: net.Socket): Promise<void> {
|
|
116
|
+
let req: BridgeRequest;
|
|
117
|
+
try {
|
|
118
|
+
req = JSON.parse(line);
|
|
119
|
+
} catch {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
let resp: BridgeResponse;
|
|
123
|
+
try {
|
|
124
|
+
const sessionId = req.targetId ? await attach(req.targetId) : undefined;
|
|
125
|
+
const eventPromise = req.waitEvent ? events.wait(req.waitEvent, req.timeoutMs ?? 10000) : undefined;
|
|
126
|
+
const result = req.method ? await client.send(req.method, req.params ?? {}, 60000, sessionId) : undefined;
|
|
127
|
+
const event = eventPromise ? await eventPromise : undefined;
|
|
128
|
+
resp = { reqId: req.reqId, ok: true, result, event };
|
|
129
|
+
} catch (err) {
|
|
130
|
+
resp = { reqId: req.reqId, ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
131
|
+
}
|
|
132
|
+
socket.write(JSON.stringify(resp) + '\n');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const cleanup = (): void => {
|
|
136
|
+
try {
|
|
137
|
+
server.close();
|
|
138
|
+
} catch {
|
|
139
|
+
// Already closed.
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
client.close();
|
|
143
|
+
} catch {
|
|
144
|
+
// Already closed.
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
fs.unlinkSync(socketPath);
|
|
148
|
+
} catch {
|
|
149
|
+
// Already gone.
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
process.on('SIGTERM', () => {
|
|
153
|
+
cleanup();
|
|
154
|
+
process.exit(0);
|
|
155
|
+
});
|
|
156
|
+
process.on('SIGINT', () => {
|
|
157
|
+
cleanup();
|
|
158
|
+
process.exit(0);
|
|
159
|
+
});
|
|
160
|
+
client.onDisconnect(() => {
|
|
161
|
+
// The browser (or its CDP endpoint) went away — nothing left to bridge.
|
|
162
|
+
cleanup();
|
|
163
|
+
process.exit(0);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await new Promise<void>((resolve, reject) => {
|
|
167
|
+
server.on('error', reject);
|
|
168
|
+
server.listen(socketPath, () => resolve());
|
|
169
|
+
});
|
|
170
|
+
// Intentionally does not resolve further work here: the open server and
|
|
171
|
+
// the live websocket keep the event loop (and this detached process) alive
|
|
172
|
+
// until `stopBridge()` sends SIGTERM.
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function resolvePort(port?: number): Promise<number> {
|
|
176
|
+
if (port) return port;
|
|
177
|
+
const { detectCdpPort } = await import('../detect.js');
|
|
178
|
+
return detectCdpPort();
|
|
179
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spawns/stops the detached bridge process for a held session
|
|
3
|
+
* (`capture session start --hold`). Mirrors the existing detached-child +
|
|
4
|
+
* pid-tracking pattern used for session log tailers (`session/commands.ts`).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { spawn } from 'child_process';
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
|
|
11
|
+
export function bridgeSocketPath(sessionDir: string): string {
|
|
12
|
+
return path.join(sessionDir, 'bridge.sock');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function startBridge(
|
|
16
|
+
sessionDir: string,
|
|
17
|
+
port: number,
|
|
18
|
+
timeoutMs = 5000,
|
|
19
|
+
): Promise<{ socketPath: string; pid: number }> {
|
|
20
|
+
const socketPath = bridgeSocketPath(sessionDir);
|
|
21
|
+
// Re-invoke the currently-running capture entrypoint in a hidden mode.
|
|
22
|
+
// Only works against the built bin (a bundled, plain-node-runnable JS
|
|
23
|
+
// file) \u2014 running `npm run dev` (tsx) can't self-spawn this way.
|
|
24
|
+
const scriptPath = process.argv[1];
|
|
25
|
+
const child = spawn(
|
|
26
|
+
process.execPath,
|
|
27
|
+
[scriptPath, '__bridge-serve', '--socket', socketPath, '--port', String(port)],
|
|
28
|
+
{ detached: true, stdio: 'ignore' },
|
|
29
|
+
);
|
|
30
|
+
child.unref();
|
|
31
|
+
const pid = child.pid;
|
|
32
|
+
if (!pid) {
|
|
33
|
+
throw new Error('Failed to spawn CDP bridge process.');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const start = Date.now();
|
|
37
|
+
while (!fs.existsSync(socketPath)) {
|
|
38
|
+
if (Date.now() - start > timeoutMs) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`CDP bridge (pid ${pid}) did not come up within ${timeoutMs}ms. ` +
|
|
41
|
+
`Check that a browser is reachable on port ${port} (capture detect).`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { socketPath, pid };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function stopBridge(pid: number | null | undefined, socketPath: string | null | undefined): void {
|
|
51
|
+
if (pid) {
|
|
52
|
+
try {
|
|
53
|
+
process.kill(pid, 'SIGTERM');
|
|
54
|
+
} catch {
|
|
55
|
+
// Already dead.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (socketPath) {
|
|
59
|
+
try {
|
|
60
|
+
fs.unlinkSync(socketPath);
|
|
61
|
+
} catch {
|
|
62
|
+
// Already gone.
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
package/src/cdp/client.ts
CHANGED
|
@@ -60,13 +60,14 @@ export class CDPClient {
|
|
|
60
60
|
method: string,
|
|
61
61
|
params: Record<string, unknown> = {},
|
|
62
62
|
timeout = 60000,
|
|
63
|
+
sessionId?: string,
|
|
63
64
|
): Promise<unknown> {
|
|
64
65
|
await this.ready;
|
|
65
66
|
const id = ++this.messageId;
|
|
66
67
|
|
|
67
68
|
return new Promise((resolve, reject) => {
|
|
68
69
|
this.pendingRequests.set(id, { resolve, reject });
|
|
69
|
-
this.ws.send(JSON.stringify({ id, method, params }));
|
|
70
|
+
this.ws.send(JSON.stringify(sessionId ? { id, method, params, sessionId } : { id, method, params }));
|
|
70
71
|
|
|
71
72
|
const timer = setTimeout(() => {
|
|
72
73
|
if (this.pendingRequests.has(id)) {
|
|
@@ -86,6 +87,11 @@ export class CDPClient {
|
|
|
86
87
|
this.eventHandlers.get(event)!.push(handler);
|
|
87
88
|
}
|
|
88
89
|
|
|
90
|
+
/** Fires when the underlying websocket goes away (browser closed, crashed, CDP endpoint dropped). */
|
|
91
|
+
onDisconnect(handler: () => void): void {
|
|
92
|
+
this.ws.on('close', handler);
|
|
93
|
+
}
|
|
94
|
+
|
|
89
95
|
close(): void {
|
|
90
96
|
// Clear all pending timeouts to allow process to exit
|
|
91
97
|
for (const timer of this.pendingTimeouts.values()) {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { runBridgeServer } from '../bridge/server.js';
|
|
2
|
+
import { type ParsedArgs } from '../types.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Hidden entrypoint spawned detached by `capture session start --hold`
|
|
6
|
+
* (see `bridge/spawn.ts`). Not documented in `capture --help` \u2014 nothing
|
|
7
|
+
* calls this directly.
|
|
8
|
+
*/
|
|
9
|
+
export async function cmdBridgeServe(parsed: ParsedArgs, _args: string[]): Promise<void> {
|
|
10
|
+
if (!parsed.socket) {
|
|
11
|
+
console.error('Usage: capture __bridge-serve --socket <path> [--port <cdpPort>]');
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
await runBridgeServer(parsed.socket, parsed.port);
|
|
15
|
+
// Deliberately does not exit: the open Unix socket server and the live
|
|
16
|
+
// browser websocket keep this detached process alive until `session stop`
|
|
17
|
+
// sends it SIGTERM.
|
|
18
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raw CDP passthrough \u2014 the escape hatch for domains no other capture
|
|
3
|
+
* command wraps (`Browser.grantPermissions`, `ServiceWorker.*`, etc.).
|
|
4
|
+
*
|
|
5
|
+
* `--browser` routes through the HELD connection (see `session start
|
|
6
|
+
* --hold` / `src/cdp/bridge/`) instead of a fresh page/tab websocket.
|
|
7
|
+
* Browser-scoped state (permission grants) and target-scoped state that was
|
|
8
|
+
* enabled on that same connection (`ServiceWorker.enable`, ...) is
|
|
9
|
+
* per-CLIENT and reverts the instant its websocket disconnects, so a plain
|
|
10
|
+
* one-shot call here only lasts for that single command \u2014 running inside a
|
|
11
|
+
* held session is what keeps it alive across commands.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from 'fs';
|
|
15
|
+
import { getActiveSession } from '../../session-context.js';
|
|
16
|
+
import { connectForCommand } from '../connection.js';
|
|
17
|
+
import { getBrowserClient } from '../targets.js';
|
|
18
|
+
import { detectCdpPort } from '../detect.js';
|
|
19
|
+
import { sendBridgeRequest } from '../bridge/client.js';
|
|
20
|
+
import { type CDPClient } from '../client.js';
|
|
21
|
+
import { type ParsedArgs } from '../types.js';
|
|
22
|
+
|
|
23
|
+
const DEFAULT_TIMEOUT_MS = 10000;
|
|
24
|
+
|
|
25
|
+
function printHelp(): void {
|
|
26
|
+
console.log(
|
|
27
|
+
`Usage: capture cdp <Domain.method> [--params '<json>'] [--browser] [--target <id>] [--wait-event <Domain.event>] [--timeout <ms>]
|
|
28
|
+
|
|
29
|
+
Send a raw CDP command \u2014 the escape hatch for domains no other capture
|
|
30
|
+
command wraps (Browser.grantPermissions, ServiceWorker.*, Target.*, ...).
|
|
31
|
+
|
|
32
|
+
--browser Route through the held connection (session --hold)
|
|
33
|
+
instead of a fresh page/tab websocket. Required for
|
|
34
|
+
state that must survive across separate commands.
|
|
35
|
+
--target <id> With --browser: attach to this target (a flattened CDP
|
|
36
|
+
session on the SAME held connection) for target-scoped
|
|
37
|
+
domains, e.g. ServiceWorker.enable / .deliverPushMessage.
|
|
38
|
+
Without --browser: the page target to run against, as
|
|
39
|
+
with every other capture command.
|
|
40
|
+
--params '<json>' JSON-encoded params object for the method.
|
|
41
|
+
--wait-event <name> Wait for (and return) the next occurrence of a CDP
|
|
42
|
+
event, e.g. ServiceWorker.workerRegistrationUpdated.
|
|
43
|
+
Can be combined with a method (sent first) or alone.
|
|
44
|
+
--timeout <ms> Wait timeout (default: ${DEFAULT_TIMEOUT_MS}ms).
|
|
45
|
+
|
|
46
|
+
Browser-level and target-enablement state reverts the instant its connection
|
|
47
|
+
disconnects \u2014 a one-shot call here only lasts for that single command. Run
|
|
48
|
+
inside a held session to keep it alive across commands:
|
|
49
|
+
|
|
50
|
+
capture session start --url http://localhost:3000 --hold
|
|
51
|
+
capture cdp Browser.grantPermissions --browser \\
|
|
52
|
+
--params '{"origin":"http://localhost:3000","permissions":["notifications"]}'
|
|
53
|
+
capture cdp ServiceWorker.enable --browser --target <pageTabId>
|
|
54
|
+
capture cdp --browser --target <pageTabId> --wait-event ServiceWorker.workerRegistrationUpdated
|
|
55
|
+
capture cdp ServiceWorker.deliverPushMessage --browser --target <pageTabId> \\
|
|
56
|
+
--params '{"origin":"...","registrationId":"...","data":"..."}'
|
|
57
|
+
capture session stop <session-id>
|
|
58
|
+
|
|
59
|
+
Without --browser, the method runs against the current page target (session
|
|
60
|
+
context or --target), like any other capture command \u2014 e.g. DOM.* or
|
|
61
|
+
Accessibility.* calls beyond what \`capture a11y\` exposes.`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function cmdCdp(parsed: ParsedArgs, _args: string[]): Promise<void> {
|
|
66
|
+
if (parsed.help) {
|
|
67
|
+
printHelp();
|
|
68
|
+
process.exit(0);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const method = parsed.positional[0];
|
|
72
|
+
if (!method && !parsed.waitEvent) {
|
|
73
|
+
console.error('ERROR: Provide a CDP method (e.g. Browser.grantPermissions) or --wait-event.\n');
|
|
74
|
+
printHelp();
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let params: Record<string, unknown> | undefined;
|
|
79
|
+
if (parsed.params) {
|
|
80
|
+
try {
|
|
81
|
+
params = JSON.parse(parsed.params);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
console.error(`ERROR: --params is not valid JSON: ${err instanceof Error ? err.message : err}`);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const timeoutMs = parsed.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
89
|
+
|
|
90
|
+
if (parsed.browser) {
|
|
91
|
+
await runBrowserScope(method, params, parsed, timeoutMs);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
await runPageScope(method, params, parsed, timeoutMs);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function runBrowserScope(
|
|
99
|
+
method: string | undefined,
|
|
100
|
+
params: Record<string, unknown> | undefined,
|
|
101
|
+
parsed: ParsedArgs,
|
|
102
|
+
timeoutMs: number,
|
|
103
|
+
): Promise<void> {
|
|
104
|
+
const session = getActiveSession();
|
|
105
|
+
if (session?.bridgeSocket && fs.existsSync(session.bridgeSocket)) {
|
|
106
|
+
const resp = await sendBridgeRequest(session.bridgeSocket, {
|
|
107
|
+
method,
|
|
108
|
+
params,
|
|
109
|
+
targetId: parsed.target,
|
|
110
|
+
waitEvent: parsed.waitEvent,
|
|
111
|
+
timeoutMs,
|
|
112
|
+
});
|
|
113
|
+
if (!resp.ok) {
|
|
114
|
+
console.error(`ERROR: ${resp.error}`);
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
console.log(JSON.stringify({ result: resp.result, event: resp.event }, null, 2));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
console.error(
|
|
122
|
+
'No held session bridge active \u2014 this connection closes when the command exits, ' +
|
|
123
|
+
'so any browser-level grant or target enablement made here reverts immediately after.\n' +
|
|
124
|
+
'For state that must survive multiple commands: capture session start --hold\n',
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const port = parsed.port ?? (await detectCdpPort());
|
|
128
|
+
const { client } = await getBrowserClient(port);
|
|
129
|
+
try {
|
|
130
|
+
let sessionId: string | undefined;
|
|
131
|
+
if (parsed.target) {
|
|
132
|
+
const attached = (await client.send('Target.attachToTarget', {
|
|
133
|
+
targetId: parsed.target,
|
|
134
|
+
flatten: true,
|
|
135
|
+
})) as { sessionId: string };
|
|
136
|
+
sessionId = attached.sessionId;
|
|
137
|
+
}
|
|
138
|
+
const eventPromise = parsed.waitEvent ? waitForEventOnce(client, parsed.waitEvent, timeoutMs) : undefined;
|
|
139
|
+
const result = method ? await client.send(method, params ?? {}, 60000, sessionId) : undefined;
|
|
140
|
+
const event = eventPromise ? await eventPromise : undefined;
|
|
141
|
+
console.log(JSON.stringify({ result, event }, null, 2));
|
|
142
|
+
} finally {
|
|
143
|
+
client.close();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function runPageScope(
|
|
148
|
+
method: string | undefined,
|
|
149
|
+
params: Record<string, unknown> | undefined,
|
|
150
|
+
parsed: ParsedArgs,
|
|
151
|
+
timeoutMs: number,
|
|
152
|
+
): Promise<void> {
|
|
153
|
+
const { client } = await connectForCommand(parsed);
|
|
154
|
+
try {
|
|
155
|
+
const eventPromise = parsed.waitEvent ? waitForEventOnce(client, parsed.waitEvent, timeoutMs) : undefined;
|
|
156
|
+
const result = method ? await client.send(method, params ?? {}) : undefined;
|
|
157
|
+
const event = eventPromise ? await eventPromise : undefined;
|
|
158
|
+
console.log(JSON.stringify({ result, event }, null, 2));
|
|
159
|
+
} finally {
|
|
160
|
+
client.close();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function waitForEventOnce(client: CDPClient, eventName: string, timeoutMs: number): Promise<unknown> {
|
|
165
|
+
return new Promise((resolve, reject) => {
|
|
166
|
+
const timer = setTimeout(
|
|
167
|
+
() => reject(new Error(`Timed out after ${timeoutMs}ms waiting for event "${eventName}"`)),
|
|
168
|
+
timeoutMs,
|
|
169
|
+
);
|
|
170
|
+
client.on(eventName, (params) => {
|
|
171
|
+
clearTimeout(timer);
|
|
172
|
+
resolve(params);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
}
|
package/src/cdp/dispatch.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { cmdScreenshot, cmdClick, cmdType, cmdA11y } from './commands/ui.js';
|
|
|
6
6
|
import { cmdRecord, cmdNavigate, cmdNetwork } from './commands/traffic.js';
|
|
7
7
|
import { cmdHar } from './commands/har.js';
|
|
8
8
|
import { cmdLib } from './commands/lib.js';
|
|
9
|
+
import { cmdCdp } from './commands/cdp.js';
|
|
10
|
+
import { cmdBridgeServe } from './commands/bridge-serve.js';
|
|
9
11
|
|
|
10
12
|
export async function cdpMain(): Promise<void> {
|
|
11
13
|
const args = process.argv.slice(2);
|
|
@@ -26,6 +28,8 @@ export async function cdpMain(): Promise<void> {
|
|
|
26
28
|
case 'network': return cmdNetwork(parsed, args);
|
|
27
29
|
case 'har': return cmdHar(parsed, args);
|
|
28
30
|
case 'lib': return cmdLib(parsed, args);
|
|
31
|
+
case 'cdp': return cmdCdp(parsed, args);
|
|
32
|
+
case '__bridge-serve': return cmdBridgeServe(parsed, args);
|
|
29
33
|
default:
|
|
30
34
|
console.log(CDP_USAGE);
|
|
31
35
|
}
|
package/src/cdp/targets.ts
CHANGED
|
@@ -2,7 +2,14 @@ import { CDPClient } from './client.js';
|
|
|
2
2
|
import { detectCdpPortsAsync } from './detect.js';
|
|
3
3
|
import { type CDPTarget } from './types.js';
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Connects to the BROWSER-level CDP websocket (not a page/tab target).
|
|
7
|
+
* Browser-scoped domains (`Browser.grantPermissions`, `ServiceWorker.*`,
|
|
8
|
+
* `Target.*`) only take effect on this connection — and only for as long as
|
|
9
|
+
* it stays open, which is why `capture cdp --browser` routes through the
|
|
10
|
+
* session bridge (`src/cdp/bridge/`) instead of opening a fresh one per call.
|
|
11
|
+
*/
|
|
12
|
+
export async function getBrowserClient(
|
|
6
13
|
port: number,
|
|
7
14
|
): Promise<{ client: CDPClient; browserWsUrl: string }> {
|
|
8
15
|
const versionResp = await fetch(`http://localhost:${port}/json/version`);
|