browser-debug-mcp-bridge 1.5.0 → 1.9.0

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.
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  const { spawn, spawnSync } = require('node:child_process');
3
- const { existsSync } = require('node:fs');
3
+ const { existsSync, mkdirSync, openSync, writeFileSync, closeSync, readFileSync, unlinkSync } = require('node:fs');
4
4
  const { dirname, join, resolve } = require('node:path');
5
5
  const { createRequire } = require('node:module');
6
6
  const net = require('node:net');
@@ -22,6 +22,9 @@ const localRequire = createRequire(join(repoRoot, 'package.json'));
22
22
  const supportsColor = Boolean(process.stderr.isTTY) && !process.env.NO_COLOR;
23
23
  const greenBackground = '\x1b[42m\x1b[30m';
24
24
  const ansiReset = '\x1b[0m';
25
+ const launchLockPath = join(process.env.DATA_DIR ? resolve(process.env.DATA_DIR) : join(repoRoot, 'data'), '.mcp-start.lock');
26
+
27
+ let launchLockHeld = false;
25
28
 
26
29
  function resolveRuntimePath(specifier) {
27
30
  try {
@@ -75,6 +78,117 @@ function delay(ms) {
75
78
  return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
76
79
  }
77
80
 
81
+ function isProcessAlive(pid) {
82
+ if (!Number.isInteger(pid) || pid <= 0) {
83
+ return false;
84
+ }
85
+
86
+ try {
87
+ process.kill(pid, 0);
88
+ return true;
89
+ } catch (error) {
90
+ return Boolean(error && error.code === 'EPERM');
91
+ }
92
+ }
93
+
94
+ function parseLockPayload(raw) {
95
+ try {
96
+ const parsed = JSON.parse(raw);
97
+ return parsed && typeof parsed === 'object' ? parsed : null;
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ function readLaunchLock(lockPath) {
104
+ try {
105
+ const raw = readFileSync(lockPath, 'utf8');
106
+ return parseLockPayload(raw);
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+
112
+ function acquireLaunchLock(lockPath) {
113
+ mkdirSync(dirname(lockPath), { recursive: true });
114
+ const payload = JSON.stringify({
115
+ pid: process.pid,
116
+ createdAt: new Date().toISOString(),
117
+ command: process.argv.join(' '),
118
+ });
119
+
120
+ for (let attempt = 0; attempt < 2; attempt++) {
121
+ try {
122
+ const fd = openSync(lockPath, 'wx');
123
+ try {
124
+ writeFileSync(fd, payload, 'utf8');
125
+ } finally {
126
+ closeSync(fd);
127
+ }
128
+ launchLockHeld = true;
129
+ return;
130
+ } catch (error) {
131
+ if (!error || error.code !== 'EEXIST') {
132
+ throw error;
133
+ }
134
+
135
+ const existing = readLaunchLock(lockPath);
136
+ const lockPid = Number(existing && existing.pid);
137
+ if (Number.isInteger(lockPid) && lockPid > 0 && lockPid !== process.pid && isProcessAlive(lockPid)) {
138
+ process.stderr.write(
139
+ `[mcp-start] MCP_STARTUP_LOCKED: another launcher instance (pid ${lockPid}) is already running.\n`,
140
+ );
141
+ process.stderr.write('[mcp-start] If startup appears stuck, run: node scripts/mcp-start.cjs --stop\n');
142
+ process.exit(1);
143
+ }
144
+
145
+ try {
146
+ unlinkSync(lockPath);
147
+ } catch (unlinkError) {
148
+ if (!unlinkError || unlinkError.code !== 'ENOENT') {
149
+ process.stderr.write(
150
+ `[mcp-start] MCP_STARTUP_LOCK_FAILED: unable to clear stale lock at ${lockPath}.\n`,
151
+ );
152
+ process.exit(1);
153
+ }
154
+ }
155
+ }
156
+ }
157
+
158
+ process.stderr.write(`[mcp-start] MCP_STARTUP_LOCK_FAILED: unable to acquire startup lock at ${lockPath}.\n`);
159
+ process.exit(1);
160
+ }
161
+
162
+ function releaseLaunchLock(lockPath) {
163
+ if (!launchLockHeld) {
164
+ return;
165
+ }
166
+
167
+ try {
168
+ const existing = readLaunchLock(lockPath);
169
+ const lockPid = Number(existing && existing.pid);
170
+ if (Number.isInteger(lockPid) && lockPid > 0 && lockPid !== process.pid) {
171
+ launchLockHeld = false;
172
+ return;
173
+ }
174
+ unlinkSync(lockPath);
175
+ } catch (error) {
176
+ if (error && error.code !== 'ENOENT') {
177
+ process.stderr.write(`[mcp-start] Warning: failed to release startup lock ${lockPath}.\n`);
178
+ }
179
+ } finally {
180
+ launchLockHeld = false;
181
+ }
182
+ }
183
+
184
+ function getStartupTimeoutMs() {
185
+ const timeoutMs = Number(process.env.MCP_STARTUP_TIMEOUT_MS || '15000');
186
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 1000) {
187
+ return 15000;
188
+ }
189
+ return Math.floor(timeoutMs);
190
+ }
191
+
78
192
  function fetchJson(pathname, port, timeoutMs = 1000) {
79
193
  return new Promise((resolveJson) => {
80
194
  const request = http.request(
@@ -261,6 +375,32 @@ async function tryRecoverStaleBridgeOnWindowsPort(port) {
261
375
  return false;
262
376
  }
263
377
 
378
+ async function waitForBridgeReady(port, child) {
379
+ const timeoutMs = getStartupTimeoutMs();
380
+ const deadline = Date.now() + timeoutMs;
381
+
382
+ while (Date.now() < deadline) {
383
+ if (child.exitCode !== null) {
384
+ return {
385
+ ok: false,
386
+ reason: `MCP bridge exited during startup with code ${child.exitCode}.`,
387
+ };
388
+ }
389
+
390
+ const health = await fetchJson('/health', port, 1000);
391
+ if (health && typeof health === 'object' && health.status === 'ok' && health.websocket) {
392
+ return { ok: true };
393
+ }
394
+
395
+ await delay(200);
396
+ }
397
+
398
+ return {
399
+ ok: false,
400
+ reason: `Timed out after ${timeoutMs}ms waiting for /health on 127.0.0.1:${port}.`,
401
+ };
402
+ }
403
+
264
404
  async function stopBridgeOnPort(port) {
265
405
  const listenerPids = getListeningPids(port).filter((pid) => pid !== process.pid);
266
406
  if (listenerPids.length === 0) {
@@ -336,7 +476,7 @@ if (!existsSync(packageJson)) {
336
476
  process.exit(1);
337
477
  }
338
478
 
339
- function spawnRuntime(runtime) {
479
+ async function spawnRuntime(runtime, port) {
340
480
  const nxTarget = standalone ? 'mcp-server:serve' : 'mcp-server:serve-mcp';
341
481
  const entryScript =
342
482
  runtime === 'dist'
@@ -359,17 +499,6 @@ function spawnRuntime(runtime) {
359
499
  process.exit(0);
360
500
  }
361
501
 
362
- const startedMessage = standalone
363
- ? `[mcp-start] Started Browser Debug MCP Bridge (runtime: ${runtime}, mode: standalone). Keep this terminal open.`
364
- : `[mcp-start] Started Browser Debug MCP Bridge (runtime: ${runtime}, mode: mcp-stdio).`;
365
- process.stderr.write(`${supportsColor ? `${greenBackground}${startedMessage}${ansiReset}` : startedMessage}\n`);
366
- if (!standalone && process.stdin.isTTY) {
367
- process.stderr.write(
368
- '[mcp-start] Running from interactive terminal without MCP host. ' +
369
- 'Use --standalone for manual keep-alive testing.\n',
370
- );
371
- }
372
-
373
502
  const child =
374
503
  runtime === 'nx'
375
504
  ? spawn(
@@ -397,6 +526,8 @@ function spawnRuntime(runtime) {
397
526
  },
398
527
  );
399
528
 
529
+ let startupFinished = false;
530
+
400
531
  const forwardSignalToChild = (signal) => {
401
532
  if (child.exitCode !== null || child.killed) {
402
533
  return;
@@ -441,6 +572,11 @@ function spawnRuntime(runtime) {
441
572
  process.kill(process.pid, signal);
442
573
  return;
443
574
  }
575
+ if (!startupFinished) {
576
+ process.stderr.write(
577
+ `[mcp-start] MCP_STARTUP_FAILED: MCP bridge exited before readiness check completed (code ${code ?? 0}).\n`,
578
+ );
579
+ }
444
580
  if (!standalone && process.stdin.isTTY && (code ?? 0) === 0) {
445
581
  process.stderr.write(
446
582
  '[mcp-start] MCP stdio process exited (no MCP host attached). ' +
@@ -457,6 +593,25 @@ function spawnRuntime(runtime) {
457
593
  );
458
594
  process.exit(1);
459
595
  });
596
+
597
+ const readiness = await waitForBridgeReady(port, child);
598
+ if (!readiness.ok) {
599
+ process.stderr.write(`[mcp-start] MCP_STARTUP_FAILED: ${readiness.reason}\n`);
600
+ forwardSignalToChild('SIGTERM');
601
+ process.exit(1);
602
+ }
603
+
604
+ startupFinished = true;
605
+ const startedMessage = standalone
606
+ ? `[mcp-start] Started Browser Debug MCP Bridge (runtime: ${runtime}, mode: standalone). Keep this terminal open.`
607
+ : `[mcp-start] Started Browser Debug MCP Bridge (runtime: ${runtime}, mode: mcp-stdio).`;
608
+ process.stderr.write(`${supportsColor ? `${greenBackground}${startedMessage}${ansiReset}` : startedMessage}\n`);
609
+ if (!standalone && process.stdin.isTTY) {
610
+ process.stderr.write(
611
+ '[mcp-start] Running from interactive terminal without MCP host. ' +
612
+ 'Use --standalone for manual keep-alive testing.\n',
613
+ );
614
+ }
460
615
  }
461
616
 
462
617
  async function main() {
@@ -471,6 +626,11 @@ async function main() {
471
626
  return;
472
627
  }
473
628
 
629
+ if (!dryRun) {
630
+ acquireLaunchLock(launchLockPath);
631
+ process.on('exit', () => releaseLaunchLock(launchLockPath));
632
+ }
633
+
474
634
  if (Number.isFinite(port)) {
475
635
  let inUse = await isPortInUse(port);
476
636
  if (inUse) {
@@ -506,7 +666,7 @@ async function main() {
506
666
  );
507
667
  process.exit(1);
508
668
  }
509
- spawnRuntime('dist');
669
+ await spawnRuntime('dist', port);
510
670
  return;
511
671
  }
512
672
 
@@ -515,17 +675,17 @@ async function main() {
515
675
  process.stderr.write('[mcp-start] Missing tsx runtime. Run npm install/pnpm install first.\n');
516
676
  process.exit(1);
517
677
  }
518
- spawnRuntime('tsx');
678
+ await spawnRuntime('tsx', port);
519
679
  return;
520
680
  }
521
681
 
522
682
  if (existsSync(mcpBridgeDistEntry) && existsSync(mainServerDistEntry)) {
523
- spawnRuntime('dist');
683
+ await spawnRuntime('dist', port);
524
684
  return;
525
685
  }
526
686
 
527
687
  if (existsSync(nxBin)) {
528
- spawnRuntime('nx');
688
+ await spawnRuntime('nx', port);
529
689
  return;
530
690
  }
531
691
 
@@ -538,7 +698,7 @@ async function main() {
538
698
  }
539
699
 
540
700
  process.stderr.write('[mcp-start] nx runtime not found, using tsx fallback runtime.\n');
541
- spawnRuntime('tsx');
701
+ await spawnRuntime('tsx', port);
542
702
  }
543
703
 
544
704
  main().catch((error) => {