@noego/app 0.0.16 → 0.0.17

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/cjs/client.cjs ADDED
@@ -0,0 +1,35 @@
1
+ 'use strict';
2
+
3
+ // CJS wrapper for ESM client module
4
+
5
+ let modulePromise = null;
6
+
7
+ function getModule() {
8
+ if (!modulePromise) {
9
+ modulePromise = import('../src/client.js');
10
+ }
11
+ return modulePromise;
12
+ }
13
+
14
+ // Export functions that return promises
15
+ module.exports = {
16
+ setContext: async (...args) => {
17
+ const mod = await getModule();
18
+ return mod.setContext(...args);
19
+ },
20
+ boot: async (...args) => {
21
+ const mod = await getModule();
22
+ return mod.boot(...args);
23
+ },
24
+ clientBoot: async (...args) => {
25
+ const mod = await getModule();
26
+ return mod.clientBoot(...args);
27
+ },
28
+ clientInit: async (...args) => {
29
+ const mod = await getModule();
30
+ return mod.clientInit(...args);
31
+ },
32
+ get client() {
33
+ return getModule().then(mod => mod.client);
34
+ }
35
+ };
package/cjs/config.cjs ADDED
@@ -0,0 +1,24 @@
1
+ 'use strict';
2
+
3
+ // CJS wrapper for ESM config module
4
+
5
+ let modulePromise = null;
6
+
7
+ function getModule() {
8
+ if (!modulePromise) {
9
+ modulePromise = import('../src/runtime/config.js');
10
+ }
11
+ return modulePromise;
12
+ }
13
+
14
+ // Export functions that return promises
15
+ module.exports = {
16
+ buildConfig: async (...args) => {
17
+ const mod = await getModule();
18
+ return mod.buildConfig(...args);
19
+ },
20
+ getConfig: async (...args) => {
21
+ const mod = await getModule();
22
+ return mod.getConfig(...args);
23
+ }
24
+ };
package/cjs/index.cjs ADDED
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+
3
+ // CJS wrapper for ESM module
4
+ // Uses dynamic import to load ESM and caches the result
5
+
6
+ let modulePromise = null;
7
+
8
+ function getModule() {
9
+ if (!modulePromise) {
10
+ modulePromise = import('../src/index.js');
11
+ }
12
+ return modulePromise;
13
+ }
14
+
15
+ module.exports = new Proxy({}, {
16
+ get(_, prop) {
17
+ if (prop === 'then' || prop === 'catch' || prop === 'finally') {
18
+ return undefined; // Prevent treating as thenable
19
+ }
20
+ return async (...args) => {
21
+ const mod = await getModule();
22
+ const fn = mod[prop];
23
+ if (typeof fn === 'function') {
24
+ return fn(...args);
25
+ }
26
+ return fn;
27
+ };
28
+ }
29
+ });
30
+
31
+ // Also export named exports as promises for destructuring
32
+ Object.defineProperties(module.exports, {
33
+ runCombinedServices: {
34
+ get() {
35
+ return async (...args) => {
36
+ const mod = await getModule();
37
+ return mod.runCombinedServices(...args);
38
+ };
39
+ }
40
+ },
41
+ boot: {
42
+ get() {
43
+ return async (...args) => {
44
+ const mod = await getModule();
45
+ return mod.boot(...args);
46
+ };
47
+ }
48
+ },
49
+ client: {
50
+ get() {
51
+ return getModule().then(mod => mod.client);
52
+ }
53
+ }
54
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noego/app",
3
- "version": "0.0.16",
3
+ "version": "0.0.17",
4
4
  "description": "Production build tool for Dinner/Forge apps.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,14 +9,17 @@
9
9
  },
10
10
  "exports": {
11
11
  ".": {
12
- "import": "./src/index.js"
12
+ "import": "./src/index.js",
13
+ "require": "./cjs/index.cjs"
13
14
  },
14
15
  "./client": {
15
16
  "import": "./src/client.js",
17
+ "require": "./cjs/client.cjs",
16
18
  "types": "./types/client.d.ts"
17
19
  },
18
20
  "./config": {
19
21
  "import": "./src/runtime/config.js",
22
+ "require": "./cjs/config.cjs",
20
23
  "types": "./types/config.d.ts"
21
24
  }
22
25
  },
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { fileURLToPath } from 'node:url';
3
- import { spawn } from 'node:child_process';
3
+ import { spawn, execSync } from 'node:child_process';
4
4
  import { createBuildContext } from '../build/context.js';
5
5
  import { findConfigFile } from '../runtime/index.js';
6
6
  import { loadConfig } from '../runtime/config-loader.js';
@@ -28,6 +28,90 @@ function killProcessTree(pid, signal = 'SIGKILL') {
28
28
  }
29
29
  }
30
30
 
31
+ /**
32
+ * Recovery utility for EADDRINUSE errors
33
+ * Uses lsof to detect what process is using a port, kills it, and waits for the port to be free
34
+ *
35
+ * @param {number} port - The port to recover
36
+ * @param {object} logger - Logger instance for output
37
+ * @returns {boolean} - True if recovery was successful, false otherwise
38
+ */
39
+ function recoverFromPortInUse(port, logger) {
40
+ try {
41
+ logger.warn(`[PORT RECOVERY] Attempting to recover port ${port}...`);
42
+
43
+ // Run lsof to find what's using the port
44
+ let lsofOutput;
45
+ try {
46
+ lsofOutput = execSync(`lsof -i :${port}`, { encoding: 'utf-8' });
47
+ } catch (err) {
48
+ // lsof returns exit code 1 if no processes found
49
+ if (err.status === 1) {
50
+ logger.info(`[PORT RECOVERY] Port ${port} appears to be free (no process found)`);
51
+ return true;
52
+ }
53
+ throw err;
54
+ }
55
+
56
+ // Parse lsof output to extract PIDs
57
+ // Output format: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
58
+ // Skip header line, extract second column (PID)
59
+ const lines = lsofOutput.trim().split('\n');
60
+ const pidsToKill = new Set();
61
+
62
+ for (let i = 1; i < lines.length; i++) { // Skip header
63
+ const columns = lines[i].split(/\s+/);
64
+ if (columns.length >= 2) {
65
+ const pid = parseInt(columns[1], 10);
66
+ if (!isNaN(pid) && pid !== process.pid) { // Don't kill ourselves
67
+ pidsToKill.add(pid);
68
+ }
69
+ }
70
+ }
71
+
72
+ if (pidsToKill.size === 0) {
73
+ logger.info(`[PORT RECOVERY] No killable processes found on port ${port}`);
74
+ return true;
75
+ }
76
+
77
+ // Kill each process holding the port
78
+ for (const pid of pidsToKill) {
79
+ try {
80
+ logger.warn(`[PORT RECOVERY] Killing stale process PID ${pid} holding port ${port}`);
81
+ execSync(`kill -9 ${pid}`, { encoding: 'utf-8' });
82
+ } catch (killErr) {
83
+ // Process might already be dead
84
+ logger.debug(`[PORT RECOVERY] Could not kill PID ${pid}: ${killErr.message}`);
85
+ }
86
+ }
87
+
88
+ // Brief wait for OS to release the port
89
+ // execSync is synchronous so we use a simple busy-wait
90
+ const waitStart = Date.now();
91
+ const maxWait = 2000; // 2 seconds max
92
+ while (Date.now() - waitStart < maxWait) {
93
+ try {
94
+ // Check if port is now free by running lsof again
95
+ execSync(`lsof -i :${port}`, { encoding: 'utf-8' });
96
+ // If lsof succeeds, port is still in use - keep waiting
97
+ // Small synchronous delay
98
+ const spinStart = Date.now();
99
+ while (Date.now() - spinStart < 100) { /* busy wait 100ms */ }
100
+ } catch {
101
+ // lsof failed = port is free
102
+ logger.info(`[PORT RECOVERY] Port ${port} is now free`);
103
+ return true;
104
+ }
105
+ }
106
+
107
+ logger.warn(`[PORT RECOVERY] Port ${port} may still be in use after recovery attempt`);
108
+ return false;
109
+ } catch (error) {
110
+ logger.error(`[PORT RECOVERY] Failed to recover port ${port}: ${error.message}`);
111
+ return false;
112
+ }
113
+ }
114
+
31
115
  export async function runDev(config) {
32
116
  const context = createBuildContext(config);
33
117
  const { logger } = context;
@@ -448,7 +532,25 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
448
532
  let backendStartTime = 0;
449
533
  let frontendStartTime = 0;
450
534
 
451
- const startBackend = () => {
535
+ // Port recovery retry tracking
536
+ const MAX_PORT_RECOVERY_RETRIES = 3;
537
+ let backendPortRetries = 0;
538
+ let frontendPortRetries = 0;
539
+
540
+ const startBackend = (retryAfterRecovery = false) => {
541
+ if (retryAfterRecovery) {
542
+ backendPortRetries++;
543
+ if (backendPortRetries > MAX_PORT_RECOVERY_RETRIES) {
544
+ logger.error(`[BACKEND] Exceeded max port recovery retries (${MAX_PORT_RECOVERY_RETRIES}). Shutting down...`);
545
+ shutdown('backend-port-recovery-exceeded', 1, 'backend-port-recovery');
546
+ return;
547
+ }
548
+ logger.warn(`[BACKEND] Port recovery retry ${backendPortRetries}/${MAX_PORT_RECOVERY_RETRIES}...`);
549
+ } else {
550
+ // Reset retry counter on normal start
551
+ backendPortRetries = 0;
552
+ }
553
+
452
554
  backendRestartCount++;
453
555
  backendStartTime = Date.now();
454
556
  logger.info(`🚀 [RESTART #${backendRestartCount}] Starting backend on port ${backendPort}...`);
@@ -458,13 +560,23 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
458
560
  NOEGO_PORT: String(backendPort)
459
561
  };
460
562
 
563
+ // Capture stderr to differentiate between port errors and boot errors
564
+ let stderrBuffer = '';
565
+
461
566
  backendProc = spawn(tsxExecutable, tsxArgs, {
462
567
  cwd: context.config.rootDir,
463
568
  env: backendEnv,
464
- stdio: 'inherit',
569
+ stdio: ['inherit', 'inherit', 'pipe'], // Pipe stderr to capture errors
465
570
  detached: false
466
571
  });
467
572
 
573
+ // Capture stderr while also logging it to console
574
+ backendProc.stderr.on('data', (data) => {
575
+ const text = data.toString();
576
+ stderrBuffer += text;
577
+ process.stderr.write(text); // Still show errors to user
578
+ });
579
+
468
580
  backendProc.on('exit', (code, signal) => {
469
581
  if (isShuttingDown) return;
470
582
 
@@ -472,12 +584,33 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
472
584
  const runDuration = Date.now() - backendStartTime;
473
585
  if (runDuration > STABILITY_THRESHOLD) {
474
586
  backendCrashRestarts = 0;
587
+ backendPortRetries = 0; // Also reset port retries on stability
475
588
  }
476
589
 
590
+ // Quick exit (< 5 seconds) might be port conflict OR boot error
591
+ const isQuickExit = runDuration < 5000;
592
+
593
+ // Check stderr for EADDRINUSE to determine error type
594
+ const isPortError = stderrBuffer.includes('EADDRINUSE') ||
595
+ stderrBuffer.includes('address already in use') ||
596
+ stderrBuffer.includes('listen EADDRINUSE');
597
+
477
598
  if (code !== null && code !== 0) {
478
- logger.error(`[BACKEND] Exited with code ${code}, signal ${signal}`);
599
+ logger.error(`[BACKEND] Exited with code ${code}, signal ${signal}, ran for ${runDuration}ms`);
479
600
 
480
- // Auto-restart on crash if under limit
601
+ // Differentiate between port errors and other boot errors
602
+ if (isQuickExit && !isPortError && stderrBuffer.length > 0) {
603
+ // Boot error (syntax error, missing module, etc.) - don't retry
604
+ logger.error(`[BACKEND] Boot error detected (not a port conflict). Not retrying.`);
605
+ logger.error(`[BACKEND] Fix the error and save a file to trigger a new restart attempt.`);
606
+ backendProc = null;
607
+ // Reset crash counter so next file change gets a fresh attempt
608
+ backendCrashRestarts = 0;
609
+ backendPortRetries = 0;
610
+ return;
611
+ }
612
+
613
+ // Auto-restart on crash if under limit (for port errors or runtime crashes)
481
614
  if (backendCrashRestarts < MAX_CRASH_RESTARTS) {
482
615
  backendCrashRestarts++;
483
616
  logger.warn(`[BACKEND] Crash detected. Auto-restart ${backendCrashRestarts}/${MAX_CRASH_RESTARTS}...`);
@@ -492,7 +625,24 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
492
625
  }
493
626
  },
494
627
  error: (err) => {
495
- logger.warn(`[BACKEND] Port wait warning: ${err.message}. Attempting restart anyway...`);
628
+ logger.warn(`[BACKEND] Port wait timed out: ${err.message}`);
629
+
630
+ // Quick exit + port not free + port error = likely EADDRINUSE from stale process
631
+ if (isQuickExit && isPortError) {
632
+ logger.warn(`[BACKEND] Port conflict detected - attempting port recovery...`);
633
+ const recovered = recoverFromPortInUse(backendPort, logger);
634
+ if (recovered && !isShuttingDown) {
635
+ // Wait a moment for OS to fully release the port
636
+ setTimeout(() => {
637
+ if (!isShuttingDown) {
638
+ startBackend(true); // Mark as recovery retry
639
+ }
640
+ }, 500);
641
+ return;
642
+ }
643
+ }
644
+
645
+ // Fallback: try restart anyway
496
646
  if (!isShuttingDown) {
497
647
  startBackend();
498
648
  }
@@ -507,10 +657,36 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
507
657
 
508
658
  backendProc.on('error', (err) => {
509
659
  logger.error(`[BACKEND] Spawn error: ${err.message}`);
660
+
661
+ // Check if this is an EADDRINUSE-like error at spawn level
662
+ if (err.code === 'EADDRINUSE' || err.message.includes('EADDRINUSE')) {
663
+ logger.warn(`[BACKEND] EADDRINUSE detected at spawn - attempting port recovery...`);
664
+ const recovered = recoverFromPortInUse(backendPort, logger);
665
+ if (recovered && backendPortRetries < MAX_PORT_RECOVERY_RETRIES && !isShuttingDown) {
666
+ setTimeout(() => {
667
+ if (!isShuttingDown) {
668
+ startBackend(true);
669
+ }
670
+ }, 500);
671
+ }
672
+ }
510
673
  });
511
674
  };
512
675
 
513
- const startFrontend = () => {
676
+ const startFrontend = (retryAfterRecovery = false) => {
677
+ if (retryAfterRecovery) {
678
+ frontendPortRetries++;
679
+ if (frontendPortRetries > MAX_PORT_RECOVERY_RETRIES) {
680
+ logger.error(`[FRONTEND] Exceeded max port recovery retries (${MAX_PORT_RECOVERY_RETRIES}). Shutting down...`);
681
+ shutdown('frontend-port-recovery-exceeded', 1, 'frontend-port-recovery');
682
+ return;
683
+ }
684
+ logger.warn(`[FRONTEND] Port recovery retry ${frontendPortRetries}/${MAX_PORT_RECOVERY_RETRIES}...`);
685
+ } else {
686
+ // Reset retry counter on normal start
687
+ frontendPortRetries = 0;
688
+ }
689
+
514
690
  frontendRestartCount++;
515
691
  frontendStartTime = Date.now();
516
692
  logger.info(`🚀 [RESTART #${frontendRestartCount}] Starting frontend on port ${frontendPort}...`);
@@ -520,13 +696,23 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
520
696
  NOEGO_PORT: String(frontendPort)
521
697
  };
522
698
 
699
+ // Capture stderr to differentiate between port errors and boot errors
700
+ let stderrBuffer = '';
701
+
523
702
  frontendProc = spawn(tsxExecutable, tsxArgs, {
524
703
  cwd: context.config.rootDir,
525
704
  env: frontendEnv,
526
- stdio: 'inherit',
705
+ stdio: ['inherit', 'inherit', 'pipe'], // Pipe stderr to capture errors
527
706
  detached: false
528
707
  });
529
708
 
709
+ // Capture stderr while also logging it to console
710
+ frontendProc.stderr.on('data', (data) => {
711
+ const text = data.toString();
712
+ stderrBuffer += text;
713
+ process.stderr.write(text); // Still show errors to user
714
+ });
715
+
530
716
  frontendProc.on('exit', (code, signal) => {
531
717
  if (isShuttingDown) return;
532
718
 
@@ -534,12 +720,33 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
534
720
  const runDuration = Date.now() - frontendStartTime;
535
721
  if (runDuration > STABILITY_THRESHOLD) {
536
722
  frontendCrashRestarts = 0;
723
+ frontendPortRetries = 0; // Also reset port retries on stability
537
724
  }
538
725
 
726
+ // Quick exit (< 5 seconds) might be port conflict OR boot error
727
+ const isQuickExit = runDuration < 5000;
728
+
729
+ // Check stderr for EADDRINUSE to determine error type
730
+ const isPortError = stderrBuffer.includes('EADDRINUSE') ||
731
+ stderrBuffer.includes('address already in use') ||
732
+ stderrBuffer.includes('listen EADDRINUSE');
733
+
539
734
  if (code !== null && code !== 0) {
540
- logger.error(`[FRONTEND] Exited with code ${code}, signal ${signal}`);
735
+ logger.error(`[FRONTEND] Exited with code ${code}, signal ${signal}, ran for ${runDuration}ms`);
736
+
737
+ // Differentiate between port errors and other boot errors
738
+ if (isQuickExit && !isPortError && stderrBuffer.length > 0) {
739
+ // Boot error (syntax error, missing module, etc.) - don't retry
740
+ logger.error(`[FRONTEND] Boot error detected (not a port conflict). Not retrying.`);
741
+ logger.error(`[FRONTEND] Fix the error and save a file to trigger a new restart attempt.`);
742
+ frontendProc = null;
743
+ // Reset crash counter so next file change gets a fresh attempt
744
+ frontendCrashRestarts = 0;
745
+ frontendPortRetries = 0;
746
+ return;
747
+ }
541
748
 
542
- // Auto-restart on crash if under limit
749
+ // Auto-restart on crash if under limit (for port errors or runtime crashes)
543
750
  if (frontendCrashRestarts < MAX_CRASH_RESTARTS) {
544
751
  frontendCrashRestarts++;
545
752
  logger.warn(`[FRONTEND] Crash detected. Auto-restart ${frontendCrashRestarts}/${MAX_CRASH_RESTARTS}...`);
@@ -554,7 +761,24 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
554
761
  }
555
762
  },
556
763
  error: (err) => {
557
- logger.warn(`[FRONTEND] Port wait warning: ${err.message}. Attempting restart anyway...`);
764
+ logger.warn(`[FRONTEND] Port wait timed out: ${err.message}`);
765
+
766
+ // Quick exit + port not free + port error = likely EADDRINUSE from stale process
767
+ if (isQuickExit && isPortError) {
768
+ logger.warn(`[FRONTEND] Port conflict detected - attempting port recovery...`);
769
+ const recovered = recoverFromPortInUse(frontendPort, logger);
770
+ if (recovered && !isShuttingDown) {
771
+ // Wait a moment for OS to fully release the port
772
+ setTimeout(() => {
773
+ if (!isShuttingDown) {
774
+ startFrontend(true); // Mark as recovery retry
775
+ }
776
+ }, 500);
777
+ return;
778
+ }
779
+ }
780
+
781
+ // Fallback: try restart anyway
558
782
  if (!isShuttingDown) {
559
783
  startFrontend();
560
784
  }
@@ -569,6 +793,19 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
569
793
 
570
794
  frontendProc.on('error', (err) => {
571
795
  logger.error(`[FRONTEND] Spawn error: ${err.message}`);
796
+
797
+ // Check if this is an EADDRINUSE-like error at spawn level
798
+ if (err.code === 'EADDRINUSE' || err.message.includes('EADDRINUSE')) {
799
+ logger.warn(`[FRONTEND] EADDRINUSE detected at spawn - attempting port recovery...`);
800
+ const recovered = recoverFromPortInUse(frontendPort, logger);
801
+ if (recovered && frontendPortRetries < MAX_PORT_RECOVERY_RETRIES && !isShuttingDown) {
802
+ setTimeout(() => {
803
+ if (!isShuttingDown) {
804
+ startFrontend(true);
805
+ }
806
+ }, 500);
807
+ }
808
+ }
572
809
  });
573
810
  };
574
811
 
@@ -893,6 +1130,12 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
893
1130
  })
894
1131
  .on('error', handleWatcherError);
895
1132
 
1133
+ // Proactive port recovery before initial startup
1134
+ // This handles the case where stale processes from a previous dev session are holding the ports
1135
+ logger.info(`[STARTUP] Checking ports ${backendPort} and ${frontendPort} for stale processes...`);
1136
+ recoverFromPortInUse(backendPort, logger);
1137
+ recoverFromPortInUse(frontendPort, logger);
1138
+
896
1139
  // Start initial processes
897
1140
  startBackend();
898
1141
  startFrontend();
@@ -390,10 +390,23 @@ async function runBackendService(config) {
390
390
  console.log('[backend] config.dev.backendPort:', config.dev.backendPort);
391
391
  console.log('[backend] Using port:', backendPort);
392
392
 
393
- backendApp.listen(backendPort, '0.0.0.0', () => {
393
+ const httpServer = http.createServer(backendApp);
394
+
395
+ httpServer.on('error', (err) => {
396
+ if (err.code === 'EADDRINUSE') {
397
+ console.error(`[ERROR] Port ${backendPort} is already in use (EADDRINUSE)`);
398
+ console.error(`[ERROR] This likely means a stale process is still running.`);
399
+ console.error(`[ERROR] The dev server will attempt to recover...`);
400
+ } else {
401
+ console.error(`[ERROR] Server error on port ${backendPort}:`, err.message);
402
+ }
403
+ process.exit(1);
404
+ });
405
+
406
+ httpServer.listen(backendPort, '0.0.0.0', () => {
394
407
  console.log(`Backend server running on http://localhost:${backendPort}`);
395
408
  });
396
-
409
+
397
410
  return backendApp;
398
411
  }
399
412
 
@@ -441,6 +454,17 @@ async function runFrontendService(config) {
441
454
  console.log('[frontend] config.dev.port:', config.dev.port);
442
455
  console.log('[frontend] Using port:', frontendPort);
443
456
 
457
+ httpServer.on('error', (err) => {
458
+ if (err.code === 'EADDRINUSE') {
459
+ console.error(`[ERROR] Port ${frontendPort} is already in use (EADDRINUSE)`);
460
+ console.error(`[ERROR] This likely means a stale process is still running.`);
461
+ console.error(`[ERROR] The dev server will attempt to recover...`);
462
+ } else {
463
+ console.error(`[ERROR] Server error on port ${frontendPort}:`, err.message);
464
+ }
465
+ process.exit(1);
466
+ });
467
+
444
468
  httpServer.listen(frontendPort, '0.0.0.0', () => {
445
469
  console.log(`Frontend server running on http://localhost:${frontendPort}`);
446
470
  });
@@ -669,6 +693,17 @@ async function runRouterService(config) {
669
693
  try { socket?.destroy?.(); } catch {}
670
694
  });
671
695
 
696
+ httpServer.on('error', (err) => {
697
+ if (err.code === 'EADDRINUSE') {
698
+ console.error(`[ERROR] Port ${routerPort} is already in use (EADDRINUSE)`);
699
+ console.error(`[ERROR] This likely means a stale process is still running.`);
700
+ console.error(`[ERROR] The dev server will attempt to recover...`);
701
+ } else {
702
+ console.error(`[ERROR] Server error on port ${routerPort}:`, err.message);
703
+ }
704
+ process.exit(1);
705
+ });
706
+
672
707
  httpServer.listen(routerPort, '0.0.0.0', () => {
673
708
  console.log(`Router server running on http://localhost:${routerPort}`);
674
709
  console.log(` Proxying to frontend on port ${frontendPort}`);
@@ -724,6 +759,18 @@ export async function runCombinedServices(config, options = {}) {
724
759
 
725
760
  // Allow PORT env var to override config
726
761
  const port = process.env.PORT ? parseInt(process.env.PORT, 10) : (config.dev?.port || 3000);
762
+
763
+ httpServer.on('error', (err) => {
764
+ if (err.code === 'EADDRINUSE') {
765
+ console.error(`[ERROR] Port ${port} is already in use (EADDRINUSE)`);
766
+ console.error(`[ERROR] This likely means a stale process is still running.`);
767
+ console.error(`[ERROR] The dev server will attempt to recover...`);
768
+ } else {
769
+ console.error(`[ERROR] Server error on port ${port}:`, err.message);
770
+ }
771
+ process.exit(1);
772
+ });
773
+
727
774
  httpServer.listen(port, '0.0.0.0', () => {
728
775
  const services = [];
729
776
  if (hasBackend && config.server?.main_abs) services.push('Backend');