CDP WebSocket

HE
henuwangkai
Community
👁 6·💰 ~0.06 USD·📅 Published Mar 24, 2026·📖 1 min read

Chrome DevTools Protocol WebSocket

1. CDP WebSocket 代理(多 Agent 共享浏览器)

Chrome DevTools Protocol WebSocket 代理,支持多个 AI Agent 共享同一浏览器实例

Prompt

#!/usr/bin/env node
/**
 * CDP Background Proxy v3 — 持久连接 + 多 Agent 隔离
 *
 * 1. 持久 WebSocket 连接到 Chrome(弹窗只出现一次)
 * 2. 拦截 Target.activateTarget / Page.bringToFront(防抢焦点)
 * 3. 强制 createTarget background=true
 * 4. 请求 ID 重映射(防多客户端 ID 冲突)
 * 5. 事件按 sessionId 路由到对应客户端(防多 Agent 互扰)
 * 6. 自动重连
 *
 * 用法: node cdp-proxy.mjs [--port 9401] [--chrome-port 9222]
 */

import http from 'http';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { WebSocketServer, WebSocket } from 'ws';

const args = process.argv.slice(2);
const getArg = (name, def) => {
    const i = args.indexOf(name);
    return i >= 0 ? args[i + 1] : def;
};

const PROXY_PORT = parseInt(getArg('--port', '9401'));
const CHROME_PORT = parseInt(getArg('--chrome-port', '9222'));
const CHROME_HOST = getArg('--chrome-host', '127.0.0.1');

const DEVTOOLS_PORT_FILE = getArg('--devtools-port-file',
    path.join(os.homedir(), 'Library/Application Support/Google/Chrome/DevToolsActivePort'));

// ═══════════════════════════════════════════
// 持久 Chrome 连接
// ═══════════════════════════════════════════

let chromeWs = null;
let chromeReady = false;
let reconnecting = false;

const BLOCKED_METHODS = new Set([
    'Target.activateTarget',
    'Page.bringToFront',
]);

function safeSend(ws, data) {
    if (ws && ws.readyState === WebSocket.OPEN) {
        ws.send(typeof data === 'string' ? data : JSON.stringify(data));
    }
}

function readDevToolsActivePort() {
    try {
        const content = fs.readFileSync(DEVTOOLS_PORT_FILE, 'utf-8').trim();
        const lines = content.split('\n');
        if (lines.length >= 2) {
            return { port: parseInt(lines[0]), wsPath: lines[1] };
        }
    } catch (e) { /* ignore */ }
    return null;
}

function getChromeWsUrl() {
    const portInfo = readDevToolsActivePort();
    if (portInfo) {
        return `ws://${CHROME_HOST}:${portInfo.port}${portInfo.wsPath}`;
    }
    return null;
}

// ═══════════════════════════════════════════
// 多客户端隔离
// ═══════════════════════════════════════════

// 全局自增 ID,保证唯一
let globalIdCounter = 1;

// proxyId → { clientWs, originalId, method }
const pendingRequests = new Map();

// sessionId → clientWs(哪个客户端 attach 了这个 session)
const sessionOwners = new Map();

// clientWs → { tabs: Set, sessions: Set, proxyIds: Set }
const clientState = new Map();

function getOrCreateState(clientWs) {
    if (!clientState.has(clientWs)) {
        clientState.set(clientWs, { tabs: new Set(), sessions: new Set(), proxyIds: new Set() });
    }
    return clientState.get(clientWs);
}

// ═══════════════════════════════════════════
// Chrome 连接管理
// ═══════════════════════════════════════════

function connectToChrome() {
    if (reconnecting) return;
    reconnecting = true;

    const wsUrl = getChromeWsUrl();
    if (!wsUrl) {
        console.error('[Proxy] Chrome 未运行,5秒后重试...');
        setTimeout(() => { reconnecting = false; connectToChrome(); }, 5000);
        return;
    }

    console.log(`[Proxy] 连接 Chrome: ${wsUrl}`);
    chromeWs = new WebSocket(wsUrl);

    chromeWs.on('open', () => {
        console.log('[Proxy] ✓ Chrome 已连接');
        chromeReady = true;
        reconnecting = false;
    });

    chromeWs.on('message', (rawData) => {
        try {
            const msg = JSON.parse(rawData.toString());

            // ── 响应消息(有 id)→ 路由到发起者 ──
            if (msg.id !== undefined && pendingRequests.has(msg.id)) {
                const { clientWs, originalId, method } = pendingRequests.get(msg.id);
                pendingRequests.delete(msg.id);

                const state = clientState.get(clientWs);

                // 追踪 createTarget
                if (method === 'Target.createTarget' && msg.result?.targetId) {
                    if (state) state.tabs.add(msg.result.targetId);
                    console.log(`[Proxy] 新 tab: ${msg.result.targetId}`);
                }

                // 追踪 attachToTarget → 记录 session 归属
                if (method === 'Target.attachToTarget' && msg.result?.sessionId) {
                    const sid = msg.result.sessionId;
                    sessionOwners.set(sid, clientWs);
                    if (state) state.sessions.add(sid);
                    console.log(`[Proxy] session ${sid.slice(0, 8)}... → 客户端`);
                }

                // 还原原始 ID
                msg.id = originalId;
                safeSend(clientWs, msg);
                return;
            }

            // ── 事件消息(无 id)→ 按 sessionId 路由 ──
            if (msg.method) {
                // 带 sessionId 的事件:发给对应客户端
                if (msg.sessionId && sessionOwners.has(msg.sessionId)) {
                    safeSend(sessionOwners.get(msg.sessionId), msg);
                    return;
                }

                // Target 域的全局事件:按 targetId 路由或广播
                if (msg.method.startsWith('Target.')) {
                    const targetId = msg.params?.targetInfo?.targetId || msg.params?.targetId;
                    if (targetId) {
                        // 找到拥有这个 tab 的客户端
                        for (const [ws, state] of clientState) {
                            if (state.tabs.has(targetId)) {
                                safeSend(ws, msg);
                                return;
                            }
                        }
                    }
                    // 找不到归属,广播
                    for (const [ws] of clientState) {
                        safeSend(ws, msg);
                    }
                    return;
                }

                // 其他无 sessionId 的事件,广播
                for (const [ws] of clientState) {
                    safeSend(ws, msg);
                }
                return;
            }

            // 其他消息,广播
            for (const [ws] of clientState) {
                safeSend(ws, rawData);
            }
        } catch (e) {
            for (const [ws] of clientState) {
                safeSend(ws, rawData);
            }
        }
    });

    chromeWs.on('close', () => {
        console.log('[Proxy] Chrome 断开,5秒后重连...');
        chromeReady = false;
        chromeWs = null;
        reconnecting = false;
        // 清理所有 session 归属(Chrome 重启后 session 失效)
        sessionOwners.clear();
        setTimeout(() => connectToChrome(), 5000);
    });

    chromeWs.on('error', (err) => {
        console.error('[Proxy] Chrome 错误:', err.message);
        chromeReady = false;
        chromeWs = null;
        reconnecting = false;
        setTimeout(() => connectToChrome(), 5000);
    });
}

// ═══════════════════════════════════════════
// HTTP 端点
// ═══════════════════════════════════════════

function cdpRequest(method, params = {}) {
    return new Promise((resolve, reject) => {
        if (!chromeReady) return reject(new Error('Chrome not connected'));
        const proxyId = globalIdCounter++;
        const timeout = setTimeout(() => {
            pendingRequests.delete(proxyId);
            reject(new Error('timeout'));
        }, 5000);
        const fakeClient = {
            send: (data) => {
                clearTimeout(timeout);
                resolve(typeof data === 'string' ? JSON.parse(data) : data);
            },
            readyState: WebSocket.OPEN,
        };
        pendingRequests.set(proxyId, { clientWs: fakeClient, originalId: proxyId, method });
        chromeWs.send(JSON.stringify({ id: proxyId, method, params }));
    });
}

const server = http.createServer(async (req, res) => {
    try {
        if (req.url === '/json/version') {
            const portInfo = readDevToolsActivePort();
            const wsUrl = portInfo
                ? `ws://${CHROME_HOST}:${PROXY_PORT}${portInfo.wsPath}`
                : `ws://${CHROME_HOST}:${PROXY_PORT}/devtools/browser/proxy`;
            res.writeHead(200, { 'Content-Type': 'application/json' });
            res.end(JSON.stringify({ Browser: 'Chrome (via CDP Proxy v3)', webSocketDebuggerUrl: wsUrl }));
            return;
        }

        if (req.url === '/json/list' || req.url === '/json') {
            if (!chromeReady) { res.writeHead(503); res.end('Chrome not connected'); return; }
            try {
                const result = await cdpRequest('Target.getTargets');
                const targets = (result.result?.targetInfos || []).map(t => ({
                    ...t,
                    webSocketDebuggerUrl: `ws://${CHROME_HOST}:${PROXY_PORT}/devtools/page/${t.targetId}`,
                }));
                res.writeHead(200, { 'Content-Type': 'application/json' });
                res.end(JSON.stringify(targets));
            } catch (e) { res.writeHead(500); res.end(e.message); }
            return;
        }

        if (req.url === '/json/new') {
            if (!chromeReady) { res.writeHead(503); res.end('Chrome not connected'); return; }
            try {
                const result = await cdpRequest('Target.createTarget', { url: 'about:blank', background: true });
                res.writeHead(200, { 'Content-Type': 'application/json' });
                res.end(JSON.stringify(result.result || {}));
            } catch (e) { res.writeHead(500); res.end(e.message); }
            return;
        }

        if (req.url === '/proxy/status') {
            const clientList = [];
            for (const [, state] of clientState) {
                clientList.push({ tabs: state.tabs.size, sessions: state.sessions.size });
            }
            res.writeHead(200, { 'Content-Type': 'application/json' });
            res.end(JSON.stringify({
                chromeConnected: chromeReady,
                clients: clientState.size,
                sessions: sessionOwners.size,
                pendingRequests: pendingRequests.size,
                clientDetails: clientList,
            }, null, 2));
            return;
        }

        res.writeHead(404);
        res.end('Not found');
    } catch (e) { res.writeHead(500); res.end(e.message); }
});

// ═══════════════════════════════════════════
// WebSocket 客户端处理
// ═══════════════════════════════════════════

const wss = new WebSocketServer({ server });

wss.on('connection', (clientWs, req) => {
    const state = getOrCreateState(clientWs);
    console.log(`[Proxy] 客户端连接 (共 ${clientState.size} 个)`);

    clientWs.on('message', (data) => {
        if (!chromeReady) {
            try {
                const msg = JSON.parse(data.toString());
                if (msg.id !== undefined) {
                    safeSend(clientWs, { id: msg.id, error: { code: -1, message: 'Chrome not connected' } });
                }
            } catch (e) { /* ignore */ }
            return;
        }

        try {
            const msg = JSON.parse(data.toString());

            // 拦截抢焦点命令
            if (BLOCKED_METHODS.has(msg.method)) {
                console.log(`[Proxy] 拦截: ${msg.method}`);
                const reply = { id: msg.id, result: {} };
                if (msg.sessionId) reply.sessionId = msg.sessionId;
                safeSend(clientWs, reply);
                return;
            }

            // 强制后台创建 tab
            if (msg.method === 'Target.createTarget') {
                if (!msg.params) msg.params = {};
                msg.params.background = true;
                console.log(`[Proxy] 后台创建 tab: ${msg.params.url || 'about:blank'}`);
            }

            // ── ID 重映射 ──
            if (msg.id !== undefined) {
                const proxyId = globalIdCounter++;
                pendingRequests.set(proxyId, {
                    clientWs,
                    originalId: msg.id,
                    method: msg.method,
                });
                state.proxyIds.add(proxyId);
                msg.id = proxyId;
            }

            safeSend(chromeWs, msg);
        } catch (e) {
            safeSend(chromeWs, data);
        }
    });

    const cleanup = () => {
        // 清理这个客户端的 session 归属
        for (const sid of state.sessions) {
            sessionOwners.delete(sid);
        }
        // 清理未完成的请求
        for (const pid of state.proxyIds) {
            pendingRequests.delete(pid);
        }
        clientState.delete(clientWs);
        console.log(`[Proxy] 客户端断开 (剩 ${clientState.size} 个, 释放 ${state.tabs.size} tab, ${state.sessions.size} session)`);
    };

    clientWs.on('close', cleanup);
    clientWs.on('error', cleanup);
});

// ═══════════════════════════════════════════
// 启动
// ═══════════════════════════════════════════

process.on('uncaughtException', (err) => {
    console.error('[Proxy] 异常(已恢复):', err.message);
});
process.on('unhandledRejection', (reason) => {
    console.error('[Proxy] Promise 拒绝(已恢复):', reason);
});

server.listen(PROXY_PORT, () => {
    console.log(`
╔══════════════════════════════════════════════════════╗
║   CDP Background Proxy v3 — 持久连接 + 多Agent隔离  ║
║                                                      ║
║   Proxy:  http://127.0.0.1:${PROXY_PORT}                    ║
║                                                      ║
║   ✓ 持久连接(弹窗只出现一次)                       ║
║   ✓ 拦截 activateTarget / bringToFront               ║
║   ✓ 强制 createTarget background=true                ║
║   ✓ 请求 ID 重映射(防多客户端冲突)                 ║
║   ✓ 事件按 sessionId 路由(防多 Agent 互扰)         ║
║   ✓ 自动重连                                         ║
║                                                      ║
║   状态: http://127.0.0.1:${PROXY_PORT}/proxy/status          ║
╚══════════════════════════════════════════════════════╝
`);
    connectToChrome();
});

Discussion

Discussion

Sign in to join the discussion.
MC
Maya Chen·2 hours ago

Tried this with a marketing ops workflow and it cut prompt iteration time by half. The Prompt section is especially reusable.

LW
Leo Wang·Yesterday

Would love a follow-up showing how you adapted this for team use.

  • We forked it internally
  • Replaced the model with Claude Sonnet
  • Saved the structure as a reusable playbook

Related Assets

Related Assets

Other assets published by the same creator.

Back to home