chrome-ai-bridge 2.3.2 → 2.3.4

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,28 @@ 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
+ primaryIdleMs: Number(process.env.CAI_PRIMARY_IDLE_MS),
77
+ };
78
+ return {
79
+ maxSessions: raw.maxSessions > 0 ? Math.floor(raw.maxSessions) : 6,
80
+ maxQueue: raw.maxQueue > 0 ? Math.floor(raw.maxQueue) : 64,
81
+ queueWaitTimeoutMs: raw.queueWaitTimeoutMs > 0 ? Math.floor(raw.queueWaitTimeoutMs) : 10_000,
82
+ sessionIdleMs: raw.sessionIdleMs > 0 ? Math.floor(raw.sessionIdleMs) : 300_000,
83
+ startupDelayJitterMs: raw.startupDelayJitterMs > 0 ? Math.floor(raw.startupDelayJitterMs) : 1_500,
84
+ startupProcessThreshold: raw.startupProcessThreshold > 0
85
+ ? Math.floor(raw.startupProcessThreshold)
86
+ : 8,
87
+ primaryIdleMs: raw.primaryIdleMs > 0 ? Math.floor(raw.primaryIdleMs) : 300_000,
88
+ };
89
+ }
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);
@@ -112,6 +138,11 @@ if (!becamePrimary) {
112
138
  process.exit(1);
113
139
  }
114
140
  // ─── Primary mode ───
141
+ // Idle auto-exit tracking for Primary process
142
+ let primaryLastActivityAt = Date.now();
143
+ const touchPrimaryActivity = () => {
144
+ primaryLastActivityAt = Date.now();
145
+ };
115
146
  // Start session cleanup timer
116
147
  const sessionConfig = getSessionConfig();
117
148
  const cleanupTimer = setInterval(async () => {
@@ -146,6 +177,7 @@ function registerTool(tool) {
146
177
  inputSchema: tool.schema,
147
178
  annotations: tool.annotations,
148
179
  }, async (params) => {
180
+ touchPrimaryActivity();
149
181
  const guard = await toolMutex.acquire();
150
182
  try {
151
183
  logger(`${tool.name} request: ${JSON.stringify(params, null, ' ')}`);
@@ -225,6 +257,75 @@ logDisclaimers();
225
257
  // ─── IPC HTTP server (for proxy clients) ───
226
258
  {
227
259
  const ipcTransports = {};
260
+ const ipcSessionLastActivity = new Map();
261
+ const initQueue = [];
262
+ let initializingCount = 0;
263
+ const getSessionLoad = () => Object.keys(ipcTransports).length + initializingCount;
264
+ const touchIpcSession = (sessionId) => {
265
+ ipcSessionLastActivity.set(sessionId, Date.now());
266
+ };
267
+ const cleanupIpcSession = (sessionId) => {
268
+ if (ipcTransports[sessionId]) {
269
+ delete ipcTransports[sessionId];
270
+ }
271
+ ipcSessionLastActivity.delete(sessionId);
272
+ drainInitQueue();
273
+ };
274
+ function sendJsonRpcError(res, code, message, id = null, statusCode = 400) {
275
+ res.writeHead(statusCode).end(JSON.stringify({
276
+ jsonrpc: '2.0',
277
+ error: { code, message },
278
+ id,
279
+ }));
280
+ }
281
+ function drainInitQueue() {
282
+ while (initQueue.length > 0 && getSessionLoad() < ipcGuardConfig.maxSessions) {
283
+ const waiter = initQueue.shift();
284
+ if (!waiter)
285
+ break;
286
+ clearTimeout(waiter.timeout);
287
+ waiter.resolve();
288
+ }
289
+ }
290
+ async function waitForInitCapacity() {
291
+ if (getSessionLoad() < ipcGuardConfig.maxSessions) {
292
+ return;
293
+ }
294
+ if (initQueue.length >= ipcGuardConfig.maxQueue) {
295
+ throw new Error('SERVER_QUEUE_FULL');
296
+ }
297
+ await new Promise((resolve, reject) => {
298
+ const timeout = setTimeout(() => {
299
+ const index = initQueue.findIndex(item => item.resolve === resolve);
300
+ if (index >= 0) {
301
+ initQueue.splice(index, 1);
302
+ }
303
+ reject(new Error('SERVER_BUSY_TIMEOUT'));
304
+ }, ipcGuardConfig.queueWaitTimeoutMs);
305
+ timeout.unref();
306
+ initQueue.push({ resolve, reject, timeout });
307
+ });
308
+ }
309
+ const idleCleanupTimer = setInterval(async () => {
310
+ const now = Date.now();
311
+ const staleSessionIds = Array.from(ipcSessionLastActivity.entries())
312
+ .filter(([, lastActivity]) => now - lastActivity > ipcGuardConfig.sessionIdleMs)
313
+ .map(([sessionId]) => sessionId);
314
+ if (staleSessionIds.length === 0) {
315
+ return;
316
+ }
317
+ logger(`[ipc] Closing ${staleSessionIds.length} idle session(s) older than ${ipcGuardConfig.sessionIdleMs}ms.`);
318
+ for (const staleSessionId of staleSessionIds) {
319
+ try {
320
+ await ipcTransports[staleSessionId]?.close();
321
+ }
322
+ catch {
323
+ // Ignore transport close errors and continue cleanup.
324
+ }
325
+ cleanupIpcSession(staleSessionId);
326
+ }
327
+ }, Math.max(10_000, Math.min(60_000, Math.floor(ipcGuardConfig.sessionIdleMs / 2))));
328
+ idleCleanupTimer.unref();
228
329
  const ipcServer = http.createServer(async (req, res) => {
229
330
  if (!req.url || !req.method) {
230
331
  res.writeHead(400).end();
@@ -233,7 +334,15 @@ logDisclaimers();
233
334
  const url = new URL(req.url, `http://${IPC_CONFIG.host}:${IPC_CONFIG.port}`);
234
335
  // Health endpoint
235
336
  if (url.pathname === IPC_CONFIG.healthPath) {
236
- res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ status: 'ok', pid: process.pid, version, instanceId }));
337
+ res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({
338
+ status: 'ok',
339
+ pid: process.pid,
340
+ version,
341
+ instanceId,
342
+ activeSessions: Object.keys(ipcTransports).length,
343
+ queuedInitializations: initQueue.length,
344
+ sessionCapacity: ipcGuardConfig.maxSessions,
345
+ }));
237
346
  return;
238
347
  }
239
348
  // MCP endpoint
@@ -261,40 +370,55 @@ logDisclaimers();
261
370
  json = body ? JSON.parse(body) : null;
262
371
  }
263
372
  catch {
264
- res.writeHead(400).end(JSON.stringify({
265
- jsonrpc: '2.0',
266
- error: { code: -32700, message: 'Parse error' },
267
- id: null,
268
- }));
373
+ sendJsonRpcError(res, -32700, 'Parse error');
269
374
  return;
270
375
  }
271
376
  let ipcTransport;
377
+ touchPrimaryActivity();
272
378
  if (sessionId && ipcTransports[sessionId]) {
273
379
  ipcTransport = ipcTransports[sessionId];
380
+ touchIpcSession(sessionId);
274
381
  }
275
382
  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];
383
+ try {
384
+ await waitForInitCapacity();
385
+ }
386
+ catch (error) {
387
+ const message = error instanceof Error ? error.message : 'SERVER_BUSY_TIMEOUT';
388
+ if (message === 'SERVER_QUEUE_FULL') {
389
+ sendJsonRpcError(res, -32002, message, null, 503);
285
390
  }
286
- };
287
- await server.connect(ipcTransport);
391
+ else {
392
+ sendJsonRpcError(res, -32001, message, null, 503);
393
+ }
394
+ return;
395
+ }
396
+ initializingCount++;
397
+ try {
398
+ ipcTransport = new StreamableHTTPServerTransport({
399
+ sessionIdGenerator: () => randomUUID(),
400
+ onsessioninitialized: newSessionId => {
401
+ ipcTransports[newSessionId] = ipcTransport;
402
+ touchIpcSession(newSessionId);
403
+ },
404
+ onsessionclosed: closedSessionId => {
405
+ cleanupIpcSession(closedSessionId);
406
+ },
407
+ });
408
+ ipcTransport.onclose = () => {
409
+ if (ipcTransport?.sessionId) {
410
+ cleanupIpcSession(ipcTransport.sessionId);
411
+ }
412
+ };
413
+ await server.connect(ipcTransport);
414
+ }
415
+ finally {
416
+ initializingCount = Math.max(0, initializingCount - 1);
417
+ drainInitQueue();
418
+ }
288
419
  }
289
420
  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
- }));
421
+ sendJsonRpcError(res, -32000, 'Bad Request: No valid session ID provided');
298
422
  return;
299
423
  }
300
424
  try {
@@ -322,8 +446,13 @@ logDisclaimers();
322
446
  res.writeHead(400).end('Invalid or missing session ID');
323
447
  return;
324
448
  }
449
+ touchPrimaryActivity();
450
+ touchIpcSession(sessionId);
325
451
  try {
326
452
  await ipcTransports[sessionId].handleRequest(req, res);
453
+ if (req.method === 'DELETE') {
454
+ cleanupIpcSession(sessionId);
455
+ }
327
456
  }
328
457
  catch (error) {
329
458
  if (!res.headersSent) {
@@ -360,6 +489,16 @@ logDisclaimers();
360
489
  }
361
490
  });
362
491
  ipcServer.listen(IPC_CONFIG.port, IPC_CONFIG.host, onListening);
492
+ // Primary idle auto-exit: exit when no activity and no active IPC sessions
493
+ const primaryIdleCheckTimer = setInterval(() => {
494
+ const activeSessionCount = Object.keys(ipcTransports).length;
495
+ if (Date.now() - primaryLastActivityAt > ipcGuardConfig.primaryIdleMs &&
496
+ activeSessionCount === 0) {
497
+ logger(`[main] Primary idle for ${Math.round((Date.now() - primaryLastActivityAt) / 1000)}s with 0 active sessions. Auto-exiting.`);
498
+ shutdown('idle timeout');
499
+ }
500
+ }, 30_000);
501
+ primaryIdleCheckTimer.unref();
363
502
  }
364
503
  // Graceful shutdown handler with timeout
365
504
  // Based on review: タイムアウト必須、強制終了タイマー必要
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.4",
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
+ }