mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 16:19:27 +00:00
fix(browser): harden browser MCP websocket startup
- fall back across globalThis.WebSocket, undici, and ws for installed runtimes - normalize socket event handling for both WHATWG and ws implementations - add an installed-copy regression test for missing ws in Bun global layouts
This commit is contained in:
+119
-27
@@ -1,6 +1,34 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
const { WebSocket } = require('ws');
|
function loadWebSocketImplementation() {
|
||||||
|
if (typeof globalThis.WebSocket === 'function') {
|
||||||
|
return globalThis.WebSocket;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { WebSocket } = require('undici');
|
||||||
|
if (typeof WebSocket === 'function') {
|
||||||
|
return WebSocket;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to the legacy ws dependency when available.
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { WebSocket } = require('ws');
|
||||||
|
if (typeof WebSocket === 'function') {
|
||||||
|
return WebSocket;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Surface a dedicated error below if no implementation is available.
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
'Browser MCP could not find a WebSocket implementation. Tried globalThis.WebSocket, undici, and ws.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const WebSocket = loadWebSocketImplementation();
|
||||||
|
|
||||||
const PROTOCOL_VERSION = '2024-11-05';
|
const PROTOCOL_VERSION = '2024-11-05';
|
||||||
const SERVER_NAME = 'ccs-browser';
|
const SERVER_NAME = 'ccs-browser';
|
||||||
@@ -29,6 +57,66 @@ const NAVIGATION_POLL_INTERVAL_MS = 100;
|
|||||||
let inputBuffer = Buffer.alloc(0);
|
let inputBuffer = Buffer.alloc(0);
|
||||||
let requestCounter = 0;
|
let requestCounter = 0;
|
||||||
|
|
||||||
|
function addSocketListener(socket, eventName, handler) {
|
||||||
|
if (typeof socket.addEventListener === 'function') {
|
||||||
|
socket.addEventListener(eventName, handler);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof socket.on === 'function') {
|
||||||
|
socket.on(eventName, handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSocketMessageText(message) {
|
||||||
|
const data = message && typeof message === 'object' && 'data' in message ? message.data : message;
|
||||||
|
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Buffer.isBuffer(data)) {
|
||||||
|
return data.toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data instanceof ArrayBuffer) {
|
||||||
|
return Buffer.from(data).toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ArrayBuffer.isView(data)) {
|
||||||
|
return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data && typeof data.text === 'function') {
|
||||||
|
return await data.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSocket(socket) {
|
||||||
|
if (typeof socket.close === 'function') {
|
||||||
|
socket.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function abortSocket(socket) {
|
||||||
|
if (typeof socket.terminate === 'function') {
|
||||||
|
socket.terminate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeSocket(socket);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSocketError(error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Error('Browser MCP lost the DevTools websocket connection.');
|
||||||
|
}
|
||||||
|
|
||||||
function shouldExposeTools() {
|
function shouldExposeTools() {
|
||||||
return Boolean(process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL);
|
return Boolean(process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL);
|
||||||
}
|
}
|
||||||
@@ -298,7 +386,7 @@ async function sendCdpCommand(page, method, params = {}) {
|
|||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (!settled) {
|
if (!settled) {
|
||||||
settled = true;
|
settled = true;
|
||||||
ws.terminate();
|
abortSocket(ws);
|
||||||
reject(new Error('Browser MCP timed out waiting for a DevTools response.'));
|
reject(new Error('Browser MCP timed out waiting for a DevTools response.'));
|
||||||
}
|
}
|
||||||
}, CDP_TIMEOUT_MS);
|
}, CDP_TIMEOUT_MS);
|
||||||
@@ -309,10 +397,10 @@ async function sendCdpCommand(page, method, params = {}) {
|
|||||||
}
|
}
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
settled = true;
|
settled = true;
|
||||||
reject(error);
|
reject(toSocketError(error));
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.on('open', () => {
|
addSocketListener(ws, 'open', () => {
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
id: requestId,
|
id: requestId,
|
||||||
@@ -322,39 +410,43 @@ async function sendCdpCommand(page, method, params = {}) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('message', (data) => {
|
addSocketListener(ws, 'message', (data) => {
|
||||||
if (settled) {
|
void (async () => {
|
||||||
return;
|
const raw = await getSocketMessageText(data);
|
||||||
}
|
|
||||||
|
|
||||||
let message;
|
if (settled) {
|
||||||
try {
|
return;
|
||||||
message = JSON.parse(data.toString());
|
}
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.id !== requestId) {
|
let message;
|
||||||
return;
|
try {
|
||||||
}
|
message = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
clearTimeout(timer);
|
if (message.id !== requestId) {
|
||||||
settled = true;
|
return;
|
||||||
ws.close();
|
}
|
||||||
|
|
||||||
if (message.error) {
|
clearTimeout(timer);
|
||||||
reject(new Error(message.error.message || 'DevTools request failed.'));
|
settled = true;
|
||||||
return;
|
closeSocket(ws);
|
||||||
}
|
|
||||||
|
|
||||||
resolve(message.result || null);
|
if (message.error) {
|
||||||
|
reject(new Error(message.error.message || 'DevTools request failed.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(message.result || null);
|
||||||
|
})().catch(settleError);
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('error', (error) => {
|
addSocketListener(ws, 'error', (error) => {
|
||||||
settleError(error);
|
settleError(error);
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('close', () => {
|
addSocketListener(ws, 'close', () => {
|
||||||
if (settled) {
|
if (settled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, describe, expect, it } from 'bun:test';
|
import { afterEach, describe, expect, it } from 'bun:test';
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { mkdtempSync, rmSync } from 'node:fs';
|
import { cpSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
||||||
import * as http from 'node:http';
|
import * as http from 'node:http';
|
||||||
import { WebSocketServer } from 'ws';
|
import { WebSocketServer } from 'ws';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
@@ -50,7 +50,12 @@ type MockPageState = {
|
|||||||
>;
|
>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const serverPath = join(process.cwd(), 'lib', 'mcp', 'ccs-browser-server.cjs');
|
const bundledServerPath = join(process.cwd(), 'lib', 'mcp', 'ccs-browser-server.cjs');
|
||||||
|
|
||||||
|
type RunMcpRequestsOptions = {
|
||||||
|
serverPath?: string;
|
||||||
|
childEnv?: NodeJS.ProcessEnv;
|
||||||
|
};
|
||||||
|
|
||||||
function encodeMessage(message: unknown): string {
|
function encodeMessage(message: unknown): string {
|
||||||
return `${JSON.stringify(message)}\n`;
|
return `${JSON.stringify(message)}\n`;
|
||||||
@@ -162,7 +167,9 @@ function createMockBrowser(pagesInput: MockPageState[]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function start() {
|
async function start(options: RunMcpRequestsOptions = {}) {
|
||||||
|
const entryServerPath = options.serverPath || bundledServerPath;
|
||||||
|
const childEnv = options.childEnv || {};
|
||||||
tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-mcp-server-'));
|
tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-mcp-server-'));
|
||||||
|
|
||||||
const port = await new Promise<number>((resolve, reject) => {
|
const port = await new Promise<number>((resolve, reject) => {
|
||||||
@@ -404,10 +411,11 @@ function createMockBrowser(pagesInput: MockPageState[]) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const child = spawn('node', [serverPath], {
|
const child = spawn('node', [entryServerPath], {
|
||||||
cwd: tempDir,
|
cwd: tempDir,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
|
...childEnv,
|
||||||
CCS_BROWSER_DEVTOOLS_HTTP_URL: `http://127.0.0.1:${port}`,
|
CCS_BROWSER_DEVTOOLS_HTTP_URL: `http://127.0.0.1:${port}`,
|
||||||
},
|
},
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
@@ -438,9 +446,13 @@ function createMockBrowser(pagesInput: MockPageState[]) {
|
|||||||
return { start, stop };
|
return { start, stop };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runMcpRequests(pages: MockPageState[], requests: JsonRpcMessage[]) {
|
async function runMcpRequests(
|
||||||
|
pages: MockPageState[],
|
||||||
|
requests: JsonRpcMessage[],
|
||||||
|
options: RunMcpRequestsOptions = {}
|
||||||
|
) {
|
||||||
const browser = createMockBrowser(pages);
|
const browser = createMockBrowser(pages);
|
||||||
const child = await browser.start();
|
const child = await browser.start(options);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const responsesPromise = collectResponses(child, requests.length + 1);
|
const responsesPromise = collectResponses(child, requests.length + 1);
|
||||||
@@ -510,6 +522,45 @@ describe('ccs-browser MCP server', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('works from an installed copy when ws is absent but undici is available via NODE_PATH', async () => {
|
||||||
|
const installDir = mkdtempSync(join(tmpdir(), 'ccs-browser-installed-copy-'));
|
||||||
|
const installedServerPath = join(installDir, 'ccs-browser-server.cjs');
|
||||||
|
const nodeModulesDir = join(installDir, 'node_modules');
|
||||||
|
|
||||||
|
try {
|
||||||
|
cpSync(bundledServerPath, installedServerPath);
|
||||||
|
mkdirSync(nodeModulesDir, { recursive: true });
|
||||||
|
cpSync(join(process.cwd(), 'node_modules', 'undici'), join(nodeModulesDir, 'undici'), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const responses = await runMcpRequests(
|
||||||
|
[{ id: 'page-1', title: 'Installed Copy', currentUrl: 'https://example.com/' }],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 2,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'browser_get_url_and_title', arguments: {} },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
serverPath: installedServerPath,
|
||||||
|
childEnv: {
|
||||||
|
NODE_PATH: nodeModulesDir,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = responses.find((message) => message.id === 2);
|
||||||
|
expect((response?.result as { isError?: boolean }).isError).not.toBe(true);
|
||||||
|
expect(getResponseText(response)).toContain('title: Installed Copy');
|
||||||
|
expect(getResponseText(response)).toContain('url: https://example.com/');
|
||||||
|
} finally {
|
||||||
|
rmSync(installDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('navigates successfully after readiness polling', async () => {
|
it('navigates successfully after readiness polling', async () => {
|
||||||
const responses = await runMcpRequests(
|
const responses = await runMcpRequests(
|
||||||
[
|
[
|
||||||
|
|||||||
Reference in New Issue
Block a user