Skip to content

Commit a4ca430

Browse files
authored
Add browse cdp command for streaming CDP events (#1905)
1 parent 7e2fc26 commit a4ca430

4 files changed

Lines changed: 265 additions & 0 deletions

File tree

.changeset/add-cdp-tailing.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@browserbasehq/browse-cli": patch
3+
---
4+
5+
Add `browse cdp <url|port>` command to attach to any CDP target and stream DevTools protocol events as NDJSON. Supports `--domain` filtering, `--pretty` mode for human-readable output, and clean piping to files or jq.

packages/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
},
6565
"devDependencies": {
6666
"@types/node": "^20.11.30",
67+
"@types/ws": "^8.5.13",
6768
"devtools-protocol": "^0.0.1464554",
6869
"eslint": "^10.0.2",
6970
"tsup": "^8.2.1",

packages/cli/src/index.ts

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import * as net from "net";
1717
import { spawn } from "child_process";
1818
import * as readline from "readline";
1919
import type { Protocol } from "devtools-protocol";
20+
import WebSocket from "ws";
2021
import { version as VERSION } from "../package.json";
2122
import {
2223
DEFAULT_LOCAL_CONFIG,
@@ -2921,6 +2922,261 @@ networkCmd
29212922
}
29222923
});
29232924

2925+
// ==================== CDP TAILING ====================
2926+
2927+
interface CDPMessage {
2928+
id?: number;
2929+
method?: string;
2930+
params?: unknown;
2931+
result?: unknown;
2932+
error?: { code: number; message: string };
2933+
sessionId?: string;
2934+
}
2935+
2936+
const CDP_DEFAULT_DOMAINS = ["Network", "Console", "Runtime", "Log", "Page"];
2937+
2938+
program
2939+
.command("cdp <url|port>")
2940+
.description(
2941+
"Attach to a CDP target and stream DevTools protocol events as NDJSON.\n" +
2942+
"Accepts a WebSocket URL (ws://...) or a bare port number (e.g. 9222).\n" +
2943+
"Output is one JSON object per line, suitable for piping to files or jq.",
2944+
)
2945+
.option(
2946+
"--domain <domains...>",
2947+
`CDP domains to enable (repeatable). Default: ${CDP_DEFAULT_DOMAINS.join(",")}`,
2948+
)
2949+
.option("--pretty", "Human-readable output instead of JSON")
2950+
.action(
2951+
async (
2952+
target: string,
2953+
cmdOpts: { domain?: string[]; pretty?: boolean },
2954+
) => {
2955+
const wsUrl = await resolveWsTarget(target);
2956+
const domains = cmdOpts.domain ?? CDP_DEFAULT_DOMAINS;
2957+
const usePretty = cmdOpts.pretty ?? process.stdout.isTTY ?? false;
2958+
2959+
let messageId = 1;
2960+
const pendingIds = new Set<number>();
2961+
const targetSessionMap = new Map<string, string>();
2962+
2963+
function sendCDP(
2964+
ws: WebSocket,
2965+
method: string,
2966+
params: Record<string, unknown> = {},
2967+
sessionId?: string,
2968+
): number {
2969+
const id = messageId++;
2970+
pendingIds.add(id);
2971+
const msg: Record<string, unknown> = { id, method, params };
2972+
if (sessionId) msg.sessionId = sessionId;
2973+
ws.send(JSON.stringify(msg));
2974+
return id;
2975+
}
2976+
2977+
function enableDomainsForSession(ws: WebSocket, sessionId: string): void {
2978+
for (const domain of domains) {
2979+
if (domain === "Network") {
2980+
sendCDP(
2981+
ws,
2982+
"Network.enable",
2983+
{ maxTotalBufferSize: 1000000, maxResourceBufferSize: 100000 },
2984+
sessionId,
2985+
);
2986+
} else {
2987+
sendCDP(ws, `${domain}.enable`, {}, sessionId);
2988+
}
2989+
}
2990+
}
2991+
2992+
function writeEvent(message: CDPMessage): void {
2993+
try {
2994+
process.stdout.write(JSON.stringify(message) + "\n");
2995+
} catch (err: unknown) {
2996+
if ((err as NodeJS.ErrnoException).code === "EPIPE") process.exit(0);
2997+
throw err;
2998+
}
2999+
}
3000+
3001+
function writePrettyEvent(message: CDPMessage): void {
3002+
if (!message.method) return;
3003+
const params = message.params as Record<string, unknown> | undefined;
3004+
let line = `[${message.method}]`;
3005+
3006+
try {
3007+
switch (message.method) {
3008+
case "Network.requestWillBeSent": {
3009+
const req = params?.request as
3010+
| { method?: string; url?: string }
3011+
| undefined;
3012+
if (req) line += ` ${req.method ?? "?"} ${req.url ?? ""}`;
3013+
break;
3014+
}
3015+
case "Network.responseReceived": {
3016+
const resp = params?.response as
3017+
| { status?: number; url?: string }
3018+
| undefined;
3019+
if (resp) line += ` ${resp.status ?? "?"} ${resp.url ?? ""}`;
3020+
break;
3021+
}
3022+
case "Network.loadingFailed": {
3023+
const errorText =
3024+
(params?.errorText as string) ??
3025+
(params?.canceled ? "Canceled" : "Unknown");
3026+
line += ` ${errorText}`;
3027+
break;
3028+
}
3029+
case "Runtime.consoleAPICalled": {
3030+
const type = (params?.type as string) ?? "log";
3031+
const args =
3032+
(params?.args as Array<{
3033+
value?: unknown;
3034+
description?: string;
3035+
}>) ?? [];
3036+
const text = args
3037+
.map((a) => a.description ?? a.value ?? "")
3038+
.join(" ");
3039+
line += ` [${type}] ${text}`;
3040+
break;
3041+
}
3042+
case "Runtime.exceptionThrown": {
3043+
const detail = params?.exceptionDetails as
3044+
| {
3045+
text?: string;
3046+
exception?: { description?: string };
3047+
}
3048+
| undefined;
3049+
line += ` ${detail?.exception?.description ?? detail?.text ?? "Unknown exception"}`;
3050+
break;
3051+
}
3052+
case "Page.frameNavigated": {
3053+
const url = (params?.frame as { url?: string })?.url ?? "";
3054+
if (url) line += ` ${url}`;
3055+
break;
3056+
}
3057+
case "Target.attachedToTarget": {
3058+
const info = params?.targetInfo as
3059+
| { type?: string; url?: string }
3060+
| undefined;
3061+
if (info) line += ` [${info.type ?? "?"}] ${info.url ?? ""}`;
3062+
break;
3063+
}
3064+
default:
3065+
break;
3066+
}
3067+
} catch {
3068+
// Formatting failed — use method name only
3069+
}
3070+
3071+
try {
3072+
process.stdout.write(line + "\n");
3073+
} catch (err: unknown) {
3074+
if ((err as NodeJS.ErrnoException).code === "EPIPE") process.exit(0);
3075+
throw err;
3076+
}
3077+
}
3078+
3079+
const emit = usePretty ? writePrettyEvent : writeEvent;
3080+
3081+
await new Promise<void>((resolve) => {
3082+
const ws = new WebSocket(wsUrl);
3083+
let closed = false;
3084+
3085+
function cleanup(): void {
3086+
if (closed) return;
3087+
closed = true;
3088+
if (
3089+
ws.readyState === WebSocket.OPEN ||
3090+
ws.readyState === WebSocket.CONNECTING
3091+
) {
3092+
ws.close();
3093+
}
3094+
resolve();
3095+
}
3096+
3097+
process.on("SIGINT", cleanup);
3098+
process.on("SIGTERM", cleanup);
3099+
3100+
ws.on("open", () => {
3101+
if (usePretty) {
3102+
process.stderr.write(`Connected to ${wsUrl}\n`);
3103+
}
3104+
3105+
// Auto-attach to page targets
3106+
sendCDP(ws, "Target.setAutoAttach", {
3107+
autoAttach: true,
3108+
flatten: true,
3109+
waitForDebuggerOnStart: false,
3110+
filter: [{ type: "page" }],
3111+
});
3112+
3113+
sendCDP(ws, "Target.setDiscoverTargets", {
3114+
discover: true,
3115+
filter: [{ type: "page" }],
3116+
});
3117+
});
3118+
3119+
ws.on("message", (raw: WebSocket.RawData) => {
3120+
let data: CDPMessage;
3121+
try {
3122+
data = JSON.parse(raw.toString()) as CDPMessage;
3123+
} catch {
3124+
return;
3125+
}
3126+
3127+
// Filter out responses to our own commands
3128+
if (data.id !== undefined && pendingIds.has(data.id)) {
3129+
pendingIds.delete(data.id);
3130+
if (data.error) {
3131+
process.stderr.write(
3132+
`CDP error (id=${data.id}): ${data.error.message}\n`,
3133+
);
3134+
}
3135+
return;
3136+
}
3137+
3138+
// Track page targets and enable domains
3139+
if (data.method === "Target.attachedToTarget" && data.params) {
3140+
const p = data.params as {
3141+
sessionId: string;
3142+
targetInfo: { targetId: string; type: string };
3143+
};
3144+
if (p.targetInfo?.type === "page") {
3145+
targetSessionMap.set(p.targetInfo.targetId, p.sessionId);
3146+
enableDomainsForSession(ws, p.sessionId);
3147+
}
3148+
}
3149+
3150+
if (data.method === "Target.detachedFromTarget" && data.params) {
3151+
const p = data.params as {
3152+
sessionId: string;
3153+
targetId?: string;
3154+
};
3155+
const targetId =
3156+
p.targetId ??
3157+
[...targetSessionMap.entries()].find(
3158+
([, sid]) => sid === p.sessionId,
3159+
)?.[0];
3160+
if (targetId) targetSessionMap.delete(targetId);
3161+
}
3162+
3163+
emit(data);
3164+
});
3165+
3166+
ws.on("error", (err: Error) => {
3167+
process.stderr.write(`Error: ${err.message}\n`);
3168+
});
3169+
3170+
ws.on("close", () => {
3171+
if (!closed && usePretty) {
3172+
process.stderr.write("Disconnected.\n");
3173+
}
3174+
cleanup();
3175+
});
3176+
});
3177+
},
3178+
);
3179+
29243180
// ==================== RUN ====================
29253181

29263182
program.parse();

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)