browse
Version:
Unified Browserbase CLI for browser automation and cloud APIs.
232 lines (231 loc) • 7.95 kB
JavaScript
import WebSocket from "ws";
import { DriverError } from "./errors.js";
/**
* A CLI-owned browser-level CDP connection used only for V3-compatible
* network capture. The connection is intentionally kept alive across
* `network off`/`network on`: Browserbase treats closing an auxiliary browser
* WebSocket as a browser-session disconnect. It is closed only with the Browse
* driver session.
*/
export class NetworkCdpSidecar {
createWebSocket;
connecting = null;
nextId = 1;
pending = new Map();
sessions = new Map();
socket = null;
websocketUrl = null;
constructor(createWebSocket = (url) => new WebSocket(url)) {
this.createWebSocket = createWebSocket;
}
async attach(websocketUrl, targetId) {
await this.ensureConnected(websocketUrl);
const { sessionId } = await this.sendCommand("Target.attachToTarget", { flatten: true, targetId });
this.sessions.set(sessionId, new Map());
return new AttachedNetworkCdpSession(this, sessionId);
}
close() {
const socket = this.socket;
this.disconnect(socket, new DriverError("Network capture CDP sidecar closed.", {
code: "network_sidecar_closed",
}));
if (socket &&
(socket.readyState === WebSocket.OPEN ||
socket.readyState === WebSocket.CONNECTING)) {
socket.close();
}
}
hasSession(sessionId) {
return (this.socket?.readyState === WebSocket.OPEN && this.sessions.has(sessionId));
}
on(sessionId, event, listener) {
const listeners = this.sessions.get(sessionId);
if (!listeners)
return;
const eventListeners = listeners.get(event) ?? new Set();
eventListeners.add(listener);
listeners.set(event, eventListeners);
}
off(sessionId, event, listener) {
const listeners = this.sessions.get(sessionId);
const eventListeners = listeners?.get(event);
eventListeners?.delete(listener);
if (eventListeners?.size === 0)
listeners?.delete(event);
}
async sendToSession(sessionId, method, params = {}) {
if (!this.sessions.has(sessionId)) {
throw new DriverError("Network capture CDP session is detached.", {
code: "network_sidecar_detached",
});
}
return this.sendCommand(method, params, sessionId);
}
async detach(sessionId) {
if (!this.sessions.has(sessionId))
return;
this.sessions.delete(sessionId);
await this.sendCommand("Target.detachFromTarget", { sessionId }).catch(() => undefined);
}
async ensureConnected(websocketUrl) {
if (this.socket?.readyState === WebSocket.OPEN) {
if (this.websocketUrl !== websocketUrl) {
throw new DriverError("The browser CDP endpoint changed during the Browse session.", { code: "network_sidecar_endpoint_changed" });
}
return;
}
if (this.connecting) {
await this.connecting;
return;
}
const connecting = this.open(websocketUrl);
this.connecting = connecting;
try {
await connecting;
}
finally {
if (this.connecting === connecting)
this.connecting = null;
}
}
async open(websocketUrl) {
let socket;
try {
socket = this.createWebSocket(websocketUrl);
}
catch (error) {
throw connectionError(error);
}
this.socket = socket;
this.websocketUrl = websocketUrl;
await new Promise((resolve, reject) => {
let opened = false;
socket.on("message", (raw) => {
this.handleMessage(socket, raw);
});
socket.on("error", (error) => {
const failure = connectionError(error);
this.disconnect(socket, failure);
if (!opened)
reject(failure);
});
socket.on("close", () => {
const error = new DriverError("Network capture CDP sidecar disconnected.", { code: "network_sidecar_disconnected" });
this.disconnect(socket, error);
if (!opened)
reject(connectionError(error));
});
socket.once("open", () => {
opened = true;
resolve();
});
});
}
sendCommand(method, params = {}, sessionId) {
const socket = this.socket;
if (!socket || socket.readyState !== WebSocket.OPEN) {
return Promise.reject(new DriverError("Network capture CDP sidecar is not connected.", {
code: "network_sidecar_disconnected",
}));
}
const id = this.nextId++;
const message = sessionId
? { id, method, params, sessionId }
: { id, method, params };
return new Promise((resolve, reject) => {
this.pending.set(id, {
method,
reject,
resolve: (result) => resolve(result),
});
try {
socket.send(JSON.stringify(message));
}
catch (error) {
this.pending.delete(id);
reject(commandError(method, error));
}
});
}
handleMessage(socket, raw) {
if (this.socket !== socket)
return;
let message;
try {
message = JSON.parse(raw.toString());
}
catch {
return;
}
if (message.id !== undefined) {
const pending = this.pending.get(message.id);
if (!pending)
return;
this.pending.delete(message.id);
if (message.error) {
pending.reject(commandError(pending.method, message.error.message));
}
else {
pending.resolve(message.result);
}
return;
}
if (message.method === "Target.detachedFromTarget") {
const detachedSessionId = message.params
?.sessionId;
if (detachedSessionId)
this.sessions.delete(detachedSessionId);
return;
}
if (!message.sessionId || !message.method)
return;
const listeners = this.sessions.get(message.sessionId)?.get(message.method);
for (const listener of listeners ?? [])
listener(message.params);
}
disconnect(socket, error) {
if (!socket || this.socket !== socket)
return;
this.socket = null;
this.websocketUrl = null;
this.sessions.clear();
for (const pending of this.pending.values())
pending.reject(error);
this.pending.clear();
}
}
class AttachedNetworkCdpSession {
sidecar;
sessionId;
constructor(sidecar, sessionId) {
this.sidecar = sidecar;
this.sessionId = sessionId;
}
get connected() {
return this.sidecar.hasSession(this.sessionId);
}
detach() {
return this.sidecar.detach(this.sessionId);
}
off(event, listener) {
this.sidecar.off(this.sessionId, event, listener);
}
on(event, listener) {
this.sidecar.on(this.sessionId, event, listener);
}
send(method, params) {
return this.sidecar.sendToSession(this.sessionId, method, params);
}
}
function connectionError(cause) {
return new DriverError("Failed to connect the network capture CDP sidecar.", {
cause,
code: "network_sidecar_connect_failed",
});
}
function commandError(method, cause) {
return new DriverError(`Network capture CDP command ${method} failed.`, {
cause,
code: "network_sidecar_command_failed",
});
}