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.
@@ -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({ status: 'ok', pid: process.pid, version, instanceId }));
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.writeHead(400).end(JSON.stringify({
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
- ipcTransport = new StreamableHTTPServerTransport({
277
- sessionIdGenerator: () => randomUUID(),
278
- onsessioninitialized: newSessionId => {
279
- ipcTransports[newSessionId] = ipcTransport;
280
- },
281
- });
282
- ipcTransport.onclose = () => {
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
- await server.connect(ipcTransport);
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.writeHead(400).end(JSON.stringify({
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chrome-ai-bridge",
3
- "version": "2.3.2",
3
+ "version": "2.3.3",
4
4
  "description": "MCP server bridging Chrome extension and AI assistants (ChatGPT, Gemini). Extension-only mode - no Puppeteer.",
5
5
  "type": "module",
6
6
  "bin": "./scripts/cli.mjs",
package/scripts/cli.mjs CHANGED
@@ -2,48 +2,24 @@
2
2
  /**
3
3
  * CLI Entry Point for chrome-ai-bridge
4
4
  *
5
- * This is the entry point when users run:
6
- * npx chrome-ai-bridge
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
- // Launch MCP server with --import flag
26
- const child = spawn(
27
- process.execPath,
28
- [
29
- '--import',
30
- mockPath,
31
- mainPath,
32
- ...process.argv.slice(2), // Forward CLI arguments
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
+ }