UNPKG

browse

Version:

Unified Browserbase CLI for browser automation and cloud APIs.

232 lines (231 loc) • 7.95 kB
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", }); }