chrome-ai-bridge 2.3.2 → 2.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/src/config.js +23 -0
- package/build/src/main.js +147 -26
- package/package.json +1 -1
- package/scripts/cli.mjs +13 -37
package/build/src/config.js
CHANGED
|
@@ -62,3 +62,26 @@ export function getSessionConfig() {
|
|
|
62
62
|
cleanupIntervalMinutes: raw.interval > 0 ? raw.interval : 5,
|
|
63
63
|
};
|
|
64
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Get IPC overload protection settings from environment variables or defaults.
|
|
67
|
+
*/
|
|
68
|
+
export function getIpcGuardConfig() {
|
|
69
|
+
const raw = {
|
|
70
|
+
maxSessions: Number(process.env.CAI_IPC_MAX_SESSIONS),
|
|
71
|
+
maxQueue: Number(process.env.CAI_IPC_MAX_QUEUE),
|
|
72
|
+
queueWaitTimeoutMs: Number(process.env.CAI_IPC_QUEUE_WAIT_TIMEOUT_MS),
|
|
73
|
+
sessionIdleMs: Number(process.env.CAI_IPC_SESSION_IDLE_MS),
|
|
74
|
+
startupDelayJitterMs: Number(process.env.CAI_STARTUP_DELAY_JITTER_MS),
|
|
75
|
+
startupProcessThreshold: Number(process.env.CAI_STARTUP_PROCESS_THRESHOLD),
|
|
76
|
+
};
|
|
77
|
+
return {
|
|
78
|
+
maxSessions: raw.maxSessions > 0 ? Math.floor(raw.maxSessions) : 16,
|
|
79
|
+
maxQueue: raw.maxQueue > 0 ? Math.floor(raw.maxQueue) : 64,
|
|
80
|
+
queueWaitTimeoutMs: raw.queueWaitTimeoutMs > 0 ? Math.floor(raw.queueWaitTimeoutMs) : 10_000,
|
|
81
|
+
sessionIdleMs: raw.sessionIdleMs > 0 ? Math.floor(raw.sessionIdleMs) : 300_000,
|
|
82
|
+
startupDelayJitterMs: raw.startupDelayJitterMs > 0 ? Math.floor(raw.startupDelayJitterMs) : 1_500,
|
|
83
|
+
startupProcessThreshold: raw.startupProcessThreshold > 0
|
|
84
|
+
? Math.floor(raw.startupProcessThreshold)
|
|
85
|
+
: 8,
|
|
86
|
+
};
|
|
87
|
+
}
|
package/build/src/main.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import assert from 'node:assert';
|
|
16
16
|
import fs from 'node:fs';
|
|
17
17
|
import path from 'node:path';
|
|
18
|
+
import { execFileSync } from 'node:child_process';
|
|
18
19
|
import { randomUUID } from 'node:crypto';
|
|
19
20
|
import http from 'node:http';
|
|
20
21
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
@@ -31,7 +32,7 @@ import { getFastContext } from './fast-cdp/fast-context.js';
|
|
|
31
32
|
import { cleanupAllConnections } from './fast-cdp/fast-chat.js';
|
|
32
33
|
import { generateAgentId, setAgentId } from './fast-cdp/agent-context.js';
|
|
33
34
|
import { cleanupStaleSessions } from './fast-cdp/session-manager.js';
|
|
34
|
-
import { getSessionConfig, IPC_CONFIG } from './config.js';
|
|
35
|
+
import { getIpcGuardConfig, getSessionConfig, IPC_CONFIG } from './config.js';
|
|
35
36
|
import { releaseLock, tryAcquireLockSafe, checkExistingPrimary, updateLockPort } from './process-lock.js';
|
|
36
37
|
import { checkPrimaryHealth, startProxyMode } from './stdio-http-proxy.js';
|
|
37
38
|
function readPackageJson() {
|
|
@@ -64,8 +65,33 @@ const MAX_STARTUP_ATTEMPTS = 5;
|
|
|
64
65
|
const BASE_DELAY_MS = 300;
|
|
65
66
|
const HEALTH_CHECK_RETRIES = 3;
|
|
66
67
|
const HEALTH_CHECK_INTERVAL_MS = 500;
|
|
68
|
+
const ipcGuardConfig = getIpcGuardConfig();
|
|
67
69
|
const instanceId = randomUUID();
|
|
68
70
|
let becamePrimary = false;
|
|
71
|
+
function countLocalBridgeInstances() {
|
|
72
|
+
try {
|
|
73
|
+
const output = execFileSync('ps', ['-axo', 'command'], {
|
|
74
|
+
encoding: 'utf8',
|
|
75
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
76
|
+
});
|
|
77
|
+
const lines = output.split('\n').filter(Boolean);
|
|
78
|
+
return lines.filter(line => line.includes('chrome-ai-bridge') &&
|
|
79
|
+
(line.includes('build/src/main.js') || line.includes('scripts/cli.mjs'))).length;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async function applyStartupJitterIfNeeded() {
|
|
86
|
+
const instanceCount = countLocalBridgeInstances();
|
|
87
|
+
if (instanceCount < ipcGuardConfig.startupProcessThreshold) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const delayMs = Math.floor(Math.random() * ipcGuardConfig.startupDelayJitterMs);
|
|
91
|
+
logger(`[main] High startup concurrency detected (${instanceCount} processes). Applying jitter=${delayMs}ms.`);
|
|
92
|
+
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
93
|
+
}
|
|
94
|
+
await applyStartupJitterIfNeeded();
|
|
69
95
|
for (let attempt = 0; attempt < MAX_STARTUP_ATTEMPTS; attempt++) {
|
|
70
96
|
// 1. Try to become Primary (non-throwing)
|
|
71
97
|
const lockAcquired = await tryAcquireLockSafe(IPC_CONFIG.port, instanceId);
|
|
@@ -225,6 +251,75 @@ logDisclaimers();
|
|
|
225
251
|
// ─── IPC HTTP server (for proxy clients) ───
|
|
226
252
|
{
|
|
227
253
|
const ipcTransports = {};
|
|
254
|
+
const ipcSessionLastActivity = new Map();
|
|
255
|
+
const initQueue = [];
|
|
256
|
+
let initializingCount = 0;
|
|
257
|
+
const getSessionLoad = () => Object.keys(ipcTransports).length + initializingCount;
|
|
258
|
+
const touchIpcSession = (sessionId) => {
|
|
259
|
+
ipcSessionLastActivity.set(sessionId, Date.now());
|
|
260
|
+
};
|
|
261
|
+
const cleanupIpcSession = (sessionId) => {
|
|
262
|
+
if (ipcTransports[sessionId]) {
|
|
263
|
+
delete ipcTransports[sessionId];
|
|
264
|
+
}
|
|
265
|
+
ipcSessionLastActivity.delete(sessionId);
|
|
266
|
+
drainInitQueue();
|
|
267
|
+
};
|
|
268
|
+
function sendJsonRpcError(res, code, message, id = null, statusCode = 400) {
|
|
269
|
+
res.writeHead(statusCode).end(JSON.stringify({
|
|
270
|
+
jsonrpc: '2.0',
|
|
271
|
+
error: { code, message },
|
|
272
|
+
id,
|
|
273
|
+
}));
|
|
274
|
+
}
|
|
275
|
+
function drainInitQueue() {
|
|
276
|
+
while (initQueue.length > 0 && getSessionLoad() < ipcGuardConfig.maxSessions) {
|
|
277
|
+
const waiter = initQueue.shift();
|
|
278
|
+
if (!waiter)
|
|
279
|
+
break;
|
|
280
|
+
clearTimeout(waiter.timeout);
|
|
281
|
+
waiter.resolve();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
async function waitForInitCapacity() {
|
|
285
|
+
if (getSessionLoad() < ipcGuardConfig.maxSessions) {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (initQueue.length >= ipcGuardConfig.maxQueue) {
|
|
289
|
+
throw new Error('SERVER_QUEUE_FULL');
|
|
290
|
+
}
|
|
291
|
+
await new Promise((resolve, reject) => {
|
|
292
|
+
const timeout = setTimeout(() => {
|
|
293
|
+
const index = initQueue.findIndex(item => item.resolve === resolve);
|
|
294
|
+
if (index >= 0) {
|
|
295
|
+
initQueue.splice(index, 1);
|
|
296
|
+
}
|
|
297
|
+
reject(new Error('SERVER_BUSY_TIMEOUT'));
|
|
298
|
+
}, ipcGuardConfig.queueWaitTimeoutMs);
|
|
299
|
+
timeout.unref();
|
|
300
|
+
initQueue.push({ resolve, reject, timeout });
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
const idleCleanupTimer = setInterval(async () => {
|
|
304
|
+
const now = Date.now();
|
|
305
|
+
const staleSessionIds = Array.from(ipcSessionLastActivity.entries())
|
|
306
|
+
.filter(([, lastActivity]) => now - lastActivity > ipcGuardConfig.sessionIdleMs)
|
|
307
|
+
.map(([sessionId]) => sessionId);
|
|
308
|
+
if (staleSessionIds.length === 0) {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
logger(`[ipc] Closing ${staleSessionIds.length} idle session(s) older than ${ipcGuardConfig.sessionIdleMs}ms.`);
|
|
312
|
+
for (const staleSessionId of staleSessionIds) {
|
|
313
|
+
try {
|
|
314
|
+
await ipcTransports[staleSessionId]?.close();
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
// Ignore transport close errors and continue cleanup.
|
|
318
|
+
}
|
|
319
|
+
cleanupIpcSession(staleSessionId);
|
|
320
|
+
}
|
|
321
|
+
}, Math.max(10_000, Math.min(60_000, Math.floor(ipcGuardConfig.sessionIdleMs / 2))));
|
|
322
|
+
idleCleanupTimer.unref();
|
|
228
323
|
const ipcServer = http.createServer(async (req, res) => {
|
|
229
324
|
if (!req.url || !req.method) {
|
|
230
325
|
res.writeHead(400).end();
|
|
@@ -233,7 +328,15 @@ logDisclaimers();
|
|
|
233
328
|
const url = new URL(req.url, `http://${IPC_CONFIG.host}:${IPC_CONFIG.port}`);
|
|
234
329
|
// Health endpoint
|
|
235
330
|
if (url.pathname === IPC_CONFIG.healthPath) {
|
|
236
|
-
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({
|
|
331
|
+
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({
|
|
332
|
+
status: 'ok',
|
|
333
|
+
pid: process.pid,
|
|
334
|
+
version,
|
|
335
|
+
instanceId,
|
|
336
|
+
activeSessions: Object.keys(ipcTransports).length,
|
|
337
|
+
queuedInitializations: initQueue.length,
|
|
338
|
+
sessionCapacity: ipcGuardConfig.maxSessions,
|
|
339
|
+
}));
|
|
237
340
|
return;
|
|
238
341
|
}
|
|
239
342
|
// MCP endpoint
|
|
@@ -261,40 +364,54 @@ logDisclaimers();
|
|
|
261
364
|
json = body ? JSON.parse(body) : null;
|
|
262
365
|
}
|
|
263
366
|
catch {
|
|
264
|
-
res
|
|
265
|
-
jsonrpc: '2.0',
|
|
266
|
-
error: { code: -32700, message: 'Parse error' },
|
|
267
|
-
id: null,
|
|
268
|
-
}));
|
|
367
|
+
sendJsonRpcError(res, -32700, 'Parse error');
|
|
269
368
|
return;
|
|
270
369
|
}
|
|
271
370
|
let ipcTransport;
|
|
272
371
|
if (sessionId && ipcTransports[sessionId]) {
|
|
273
372
|
ipcTransport = ipcTransports[sessionId];
|
|
373
|
+
touchIpcSession(sessionId);
|
|
274
374
|
}
|
|
275
375
|
else if (!sessionId && isInitializeRequest(json)) {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
if (ipcTransport?.sessionId) {
|
|
284
|
-
delete ipcTransports[ipcTransport.sessionId];
|
|
376
|
+
try {
|
|
377
|
+
await waitForInitCapacity();
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
const message = error instanceof Error ? error.message : 'SERVER_BUSY_TIMEOUT';
|
|
381
|
+
if (message === 'SERVER_QUEUE_FULL') {
|
|
382
|
+
sendJsonRpcError(res, -32002, message, null, 503);
|
|
285
383
|
}
|
|
286
|
-
|
|
287
|
-
|
|
384
|
+
else {
|
|
385
|
+
sendJsonRpcError(res, -32001, message, null, 503);
|
|
386
|
+
}
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
initializingCount++;
|
|
390
|
+
try {
|
|
391
|
+
ipcTransport = new StreamableHTTPServerTransport({
|
|
392
|
+
sessionIdGenerator: () => randomUUID(),
|
|
393
|
+
onsessioninitialized: newSessionId => {
|
|
394
|
+
ipcTransports[newSessionId] = ipcTransport;
|
|
395
|
+
touchIpcSession(newSessionId);
|
|
396
|
+
},
|
|
397
|
+
onsessionclosed: closedSessionId => {
|
|
398
|
+
cleanupIpcSession(closedSessionId);
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
ipcTransport.onclose = () => {
|
|
402
|
+
if (ipcTransport?.sessionId) {
|
|
403
|
+
cleanupIpcSession(ipcTransport.sessionId);
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
await server.connect(ipcTransport);
|
|
407
|
+
}
|
|
408
|
+
finally {
|
|
409
|
+
initializingCount = Math.max(0, initializingCount - 1);
|
|
410
|
+
drainInitQueue();
|
|
411
|
+
}
|
|
288
412
|
}
|
|
289
413
|
else {
|
|
290
|
-
res
|
|
291
|
-
jsonrpc: '2.0',
|
|
292
|
-
error: {
|
|
293
|
-
code: -32000,
|
|
294
|
-
message: 'Bad Request: No valid session ID provided',
|
|
295
|
-
},
|
|
296
|
-
id: null,
|
|
297
|
-
}));
|
|
414
|
+
sendJsonRpcError(res, -32000, 'Bad Request: No valid session ID provided');
|
|
298
415
|
return;
|
|
299
416
|
}
|
|
300
417
|
try {
|
|
@@ -322,8 +439,12 @@ logDisclaimers();
|
|
|
322
439
|
res.writeHead(400).end('Invalid or missing session ID');
|
|
323
440
|
return;
|
|
324
441
|
}
|
|
442
|
+
touchIpcSession(sessionId);
|
|
325
443
|
try {
|
|
326
444
|
await ipcTransports[sessionId].handleRequest(req, res);
|
|
445
|
+
if (req.method === 'DELETE') {
|
|
446
|
+
cleanupIpcSession(sessionId);
|
|
447
|
+
}
|
|
327
448
|
}
|
|
328
449
|
catch (error) {
|
|
329
450
|
if (!res.headersSent) {
|
package/package.json
CHANGED
package/scripts/cli.mjs
CHANGED
|
@@ -2,48 +2,24 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* CLI Entry Point for chrome-ai-bridge
|
|
4
4
|
*
|
|
5
|
-
* This
|
|
6
|
-
*
|
|
7
|
-
* chrome-ai-bridge (if globally installed)
|
|
8
|
-
*
|
|
9
|
-
* Launches the MCP server with browser globals mock:
|
|
10
|
-
* - Loads browser-globals-mock.mjs BEFORE main.js
|
|
11
|
-
* - Ensures chrome-devtools-frontend modules work in Node.js
|
|
12
|
-
* - Simple execution: no wrapper, no hot-reload
|
|
5
|
+
* This entrypoint runs the MCP server in-process to avoid spawning an extra
|
|
6
|
+
* wrapper process per client (important for multi-pane usage).
|
|
13
7
|
*/
|
|
14
8
|
|
|
15
|
-
import {spawn} from 'node:child_process';
|
|
16
|
-
import process from 'node:process';
|
|
17
|
-
import {fileURLToPath} from 'node:url';
|
|
18
9
|
import path from 'node:path';
|
|
10
|
+
import process from 'node:process';
|
|
11
|
+
import {fileURLToPath, pathToFileURL} from 'node:url';
|
|
19
12
|
|
|
20
|
-
// Resolve paths
|
|
21
13
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
22
14
|
const mockPath = path.join(__dirname, 'browser-globals-mock.mjs');
|
|
23
15
|
const mainPath = path.join(__dirname, '..', 'build', 'src', 'main.js');
|
|
24
16
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
{
|
|
35
|
-
stdio: 'inherit',
|
|
36
|
-
env: process.env,
|
|
37
|
-
},
|
|
38
|
-
);
|
|
39
|
-
|
|
40
|
-
child.on('exit', (code, signal) => {
|
|
41
|
-
if (signal) {
|
|
42
|
-
process.exit(1);
|
|
43
|
-
}
|
|
44
|
-
process.exit(code ?? 0);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
// Forward signals
|
|
48
|
-
process.on('SIGTERM', () => child?.kill('SIGTERM'));
|
|
49
|
-
process.on('SIGINT', () => child?.kill('SIGINT'));
|
|
17
|
+
try {
|
|
18
|
+
// Ensure browser globals are defined before loading main server modules.
|
|
19
|
+
await import(pathToFileURL(mockPath).href);
|
|
20
|
+
await import(pathToFileURL(mainPath).href);
|
|
21
|
+
} catch (error) {
|
|
22
|
+
const message = error instanceof Error ? error.stack || error.message : String(error);
|
|
23
|
+
console.error(`[cli] Failed to start chrome-ai-bridge: ${message}`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|