@noego/app 0.0.7 → 0.0.10

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/src/client.js CHANGED
@@ -70,6 +70,7 @@ export async function clientBoot() {
70
70
  const root = config.root || process.cwd();
71
71
  const require = createRequire(path.join(root, 'package.json'));
72
72
  const { createServer: createForgeServer } = require('@noego/forge/server');
73
+ const { assets } = require('@noego/forge/assets');
73
74
 
74
75
  // Setup Forge (SSR)
75
76
  // Forge expects paths relative to viteOptions.root
@@ -88,11 +89,23 @@ export async function clientBoot() {
88
89
  }
89
90
  : undefined
90
91
  },
91
- component_dir: config.client?.component_dir,
92
+ // IMPORTANT: Forge uses component_dir for BOTH SSR and client
93
+ // - SSR (server-side): Loads from filesystem using this path directly
94
+ // - Client (browser): Forge injects window.__COMPONENT_DIR__ which browser uses for imports
95
+ // So we pass the SSR path here, and Forge will handle client path injection
96
+ component_dir: config.client?.component_dir_ssr || config.client?.component_dir || '.app/ssr',
92
97
  open_api_path: config.client?.openapi_path,
93
98
  renderer: config.client?.shell_path,
94
99
  middleware_path: config.server?.middleware_path,
95
100
  development: config.mode !== 'production',
101
+ assets: assets((() => {
102
+ if (config.mode === 'production') {
103
+ const route = config.client?.component_base_path || '/assets';
104
+ const dir = config.client?.assets_build_dir || '.app/assets';
105
+ return { [route]: [dir] };
106
+ }
107
+ return {};
108
+ })()),
96
109
  };
97
110
 
98
111
  await createForgeServer(app, forgeOptions);
@@ -138,4 +151,3 @@ export const client = {
138
151
  boot: clientBoot,
139
152
  init: clientInit // Browser-side initialization
140
153
  };
141
-
@@ -1,12 +1,48 @@
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';
7
+ import globParent from 'glob-parent';
7
8
 
8
9
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
10
 
11
+ /**
12
+ * Kill a process and all its descendants (entire process tree)
13
+ * This ensures no orphaned processes remain when killing a parent
14
+ */
15
+ function killProcessTree(pid, signal = 'SIGKILL') {
16
+ if (!pid) return;
17
+
18
+ try {
19
+ // On Unix systems, use pkill to kill all descendants
20
+ if (process.platform !== 'win32') {
21
+ // Kill all child processes first
22
+ try {
23
+ execSync(`pkill -P ${pid}`, { stdio: 'ignore' });
24
+ } catch (e) {
25
+ // No children or already dead, that's fine
26
+ }
27
+ // Then kill the parent
28
+ try {
29
+ process.kill(pid, signal);
30
+ } catch (e) {
31
+ // Process may already be dead
32
+ }
33
+ } else {
34
+ // On Windows, use taskkill with /T flag to kill process tree
35
+ try {
36
+ execSync(`taskkill /pid ${pid} /T /F`, { stdio: 'ignore' });
37
+ } catch (e) {
38
+ // Process may already be dead
39
+ }
40
+ }
41
+ } catch (error) {
42
+ // Ignore errors - process might already be dead
43
+ }
44
+ }
45
+
10
46
  export async function runDev(config) {
11
47
  const context = createBuildContext(config);
12
48
  const { logger } = context;
@@ -222,8 +258,14 @@ async function runSplitServeNoWatch(tsxExecutable, tsxArgs, baseEnv, routerPort,
222
258
  detached: false
223
259
  });
224
260
 
261
+ // Track if we're already shutting down
262
+ let isShuttingDown = false;
263
+
225
264
  // Handle shutdown
226
265
  const shutdown = (exitCode = 0) => {
266
+ if (isShuttingDown) return;
267
+ isShuttingDown = true;
268
+
227
269
  logger.info('Shutting down split-serve processes...');
228
270
  if (backendProc && !backendProc.killed) {
229
271
  backendProc.kill('SIGTERM');
@@ -259,10 +301,34 @@ async function runSplitServeNoWatch(tsxExecutable, tsxArgs, baseEnv, routerPort,
259
301
  shutdown(1);
260
302
  });
261
303
 
262
- // Set up signal handlers to clean up on termination
304
+ // Handle graceful shutdown signals
263
305
  process.on('SIGINT', () => shutdown(0));
264
306
  process.on('SIGTERM', () => shutdown(0));
265
307
 
308
+ // Handle process exit - ensures children are killed even if parent crashes
309
+ process.on('exit', () => {
310
+ // Synchronous cleanup only - exit event doesn't allow async
311
+ // Kill entire process tree (including tsx wrappers and their children)
312
+ if (backendProc && backendProc.pid && !backendProc.killed) {
313
+ killProcessTree(backendProc.pid);
314
+ }
315
+ if (frontendProc && frontendProc.pid && !frontendProc.killed) {
316
+ killProcessTree(frontendProc.pid);
317
+ }
318
+ });
319
+
320
+ // Handle uncaught exceptions
321
+ process.on('uncaughtException', (error) => {
322
+ logger.error('Uncaught exception:', error);
323
+ shutdown(1);
324
+ });
325
+
326
+ // Handle unhandled promise rejections
327
+ process.on('unhandledRejection', (reason, promise) => {
328
+ logger.error('Unhandled rejection at:', promise, 'reason:', reason);
329
+ shutdown(1);
330
+ });
331
+
266
332
  // Wait a moment for spawned processes to start
267
333
  await new Promise(resolve => setTimeout(resolve, 2000));
268
334
 
@@ -291,7 +357,9 @@ async function runSplitServeNoWatch(tsxExecutable, tsxArgs, baseEnv, routerPort,
291
357
  */
292
358
  async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv, yamlConfig, configFilePath, routerPort, frontendPort, backendPort, logger) {
293
359
  const { createRequire } = await import('node:module');
294
- const root = yamlConfig.root || path.dirname(configFilePath);
360
+ // Use context.config.rootDir for consistency with spawned processes
361
+ const root = context.config.rootDir;
362
+ logger.debug(`Using root directory for file watching: ${root}`);
295
363
  const requireFromRoot = createRequire(path.join(root, 'package.json'));
296
364
  const chokidar = requireFromRoot('chokidar');
297
365
  const picomatch = (await import('picomatch')).default;
@@ -301,8 +369,11 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
301
369
  const normalizePattern = (pattern) => {
302
370
  // If pattern is absolute, make it relative to root
303
371
  if (path.isAbsolute(pattern)) {
304
- return path.relative(root, pattern);
372
+ const normalized = path.relative(root, pattern);
373
+ logger.debug(`Normalized pattern: ${pattern} -> ${normalized}`);
374
+ return normalized;
305
375
  }
376
+ logger.debug(`Pattern already relative: ${pattern}`);
306
377
  return pattern;
307
378
  };
308
379
 
@@ -332,8 +403,28 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
332
403
  }
333
404
  }
334
405
 
335
- // Combine all patterns for chokidar
406
+ // Combine all patterns (for classification)
336
407
  const allPatterns = [...sharedPatterns, ...backendPatterns, ...frontendPatterns];
408
+
409
+ // Derive concrete watch targets (directories/files) from the patterns
410
+ // We watch directories; we use picomatch on the original patterns to classify events.
411
+ const watchTargets = new Set();
412
+ const GLOB_CHARS = /[\\*?\[\]\{\}\(\)!+@]/; // simple glob detector
413
+ const addWatchTarget = (pattern) => {
414
+ if (!pattern) return;
415
+ const rel = normalizePattern(pattern);
416
+ // Negative patterns are for filtering; don't watch them directly
417
+ if (rel.startsWith('!')) return;
418
+ const isGlob = GLOB_CHARS.test(rel);
419
+ if (!isGlob) {
420
+ // Literal path; watch the file itself
421
+ watchTargets.add(rel);
422
+ return;
423
+ }
424
+ const base = globParent(rel);
425
+ watchTargets.add(base && base.length > 0 ? base : '.');
426
+ };
427
+ for (const p of allPatterns) addWatchTarget(p);
337
428
 
338
429
  if (allPatterns.length === 0) {
339
430
  logger.warn('No watch patterns configured. Watching disabled.');
@@ -344,18 +435,23 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
344
435
  logger.info(` Shared: ${sharedPatterns.length} patterns`);
345
436
  logger.info(` Backend: ${backendPatterns.length} patterns`);
346
437
  logger.info(` Frontend: ${frontendPatterns.length} patterns`);
438
+ logger.info(` Watch targets (${watchTargets.size}): ${JSON.stringify(Array.from(watchTargets), null, 2)}`);
347
439
 
348
440
  // Create matchers
349
441
  const sharedMatcher = sharedPatterns.length > 0 ? picomatch(sharedPatterns) : null;
350
442
  const backendMatcher = backendPatterns.length > 0 ? picomatch(backendPatterns) : null;
351
443
  const frontendMatcher = frontendPatterns.length > 0 ? picomatch(frontendPatterns) : null;
352
-
444
+
353
445
  let backendProc = null;
354
446
  let frontendProc = null;
355
447
  let pending = false;
356
-
448
+ let backendRestartCount = 0;
449
+ let frontendRestartCount = 0;
450
+ let isShuttingDown = false;
451
+
357
452
  const startBackend = () => {
358
- logger.info(`Starting backend on port ${backendPort}...`);
453
+ backendRestartCount++;
454
+ logger.info(`🚀 [RESTART #${backendRestartCount}] Starting backend on port ${backendPort}...`);
359
455
  const backendEnv = {
360
456
  ...baseEnv,
361
457
  NOEGO_SERVICE: 'backend',
@@ -377,7 +473,8 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
377
473
  };
378
474
 
379
475
  const startFrontend = () => {
380
- logger.info(`Starting frontend on port ${frontendPort}...`);
476
+ frontendRestartCount++;
477
+ logger.info(`🚀 [RESTART #${frontendRestartCount}] Starting frontend on port ${frontendPort}...`);
381
478
  const frontendEnv = {
382
479
  ...baseEnv,
383
480
  NOEGO_SERVICE: 'frontend',
@@ -401,6 +498,7 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
401
498
  const stopBackend = () =>
402
499
  new Promise((resolve) => {
403
500
  if (!backendProc) return resolve();
501
+ logger.info(`⏹️ Stopping backend (preparing for restart #${backendRestartCount + 1})...`);
404
502
  const to = setTimeout(resolve, 2000);
405
503
  backendProc.once('exit', () => {
406
504
  clearTimeout(to);
@@ -416,6 +514,7 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
416
514
  const stopFrontend = () =>
417
515
  new Promise((resolve) => {
418
516
  if (!frontendProc) return resolve();
517
+ logger.info(`⏹️ Stopping frontend (preparing for restart #${frontendRestartCount + 1})...`);
419
518
  const to = setTimeout(resolve, 2000);
420
519
  frontendProc.once('exit', () => {
421
520
  clearTimeout(to);
@@ -427,7 +526,38 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
427
526
  resolve();
428
527
  }
429
528
  });
430
-
529
+
530
+ async function shutdown(signal = 'SIGTERM', exitCode = 0) {
531
+ if (isShuttingDown) return;
532
+ isShuttingDown = true;
533
+
534
+ logger.info(`Shutting down split-serve processes (signal: ${signal})...`);
535
+
536
+ try {
537
+ if (watcher) {
538
+ await watcher.close();
539
+ }
540
+
541
+ await stopBackend();
542
+ await stopFrontend();
543
+
544
+ logger.info('All processes shut down successfully');
545
+ } catch (error) {
546
+ logger.error('Error during shutdown:', error);
547
+ }
548
+
549
+ process.exit(exitCode);
550
+ }
551
+
552
+ async function handleWatcherError(error) {
553
+ if (isShuttingDown) {
554
+ return;
555
+ }
556
+ logger.error('Watcher error:', error);
557
+ logger.error('File watching failed; shutting down dev server.');
558
+ await shutdown('watcherError', 1);
559
+ }
560
+
431
561
  const handleFileChange = async (reason, file) => {
432
562
  if (pending) return;
433
563
  pending = true;
@@ -442,19 +572,28 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
442
572
 
443
573
  if (sharedMatcher && sharedMatcher(relativePath)) {
444
574
  // Shared file changed - restart both
445
- logger.info(`Shared file changed (${reason}): ${relativePath}`);
575
+ logger.info(`\n${'='.repeat(80)}`);
576
+ logger.info(`🔄 FILE CHANGE DETECTED: ${relativePath} (${reason})`);
577
+ logger.info(` Type: SHARED - Will restart BOTH backend and frontend`);
578
+ logger.info('='.repeat(80));
446
579
  restartBackend = true;
447
580
  restartFrontend = true;
448
581
  } else if (backendMatcher && backendMatcher(relativePath)) {
449
582
  // Backend file changed
450
- logger.info(`Backend file changed (${reason}): ${relativePath}`);
583
+ logger.info(`\n${'='.repeat(80)}`);
584
+ logger.info(`🔄 FILE CHANGE DETECTED: ${relativePath} (${reason})`);
585
+ logger.info(` Type: BACKEND - Will restart backend only`);
586
+ logger.info('='.repeat(80));
451
587
  restartBackend = true;
452
588
  } else if (frontendMatcher && frontendMatcher(relativePath)) {
453
589
  // Frontend file changed
454
- logger.info(`Frontend file changed (${reason}): ${relativePath}`);
590
+ logger.info(`\n${'='.repeat(80)}`);
591
+ logger.info(`🔄 FILE CHANGE DETECTED: ${relativePath} (${reason})`);
592
+ logger.info(` Type: FRONTEND - Will restart frontend only`);
593
+ logger.info('='.repeat(80));
455
594
  restartFrontend = true;
456
595
  }
457
-
596
+
458
597
  // Restart appropriate service(s)
459
598
  if (restartBackend) {
460
599
  await stopBackend();
@@ -464,39 +603,51 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
464
603
  await stopFrontend();
465
604
  startFrontend();
466
605
  }
467
-
606
+
607
+ if (restartBackend || restartFrontend) {
608
+ logger.info(`✅ Restart complete\n`);
609
+ }
610
+
468
611
  pending = false;
469
612
  };
470
613
 
471
614
  // Create watcher
472
615
  // Use cwd with relative patterns for proper glob support
473
- const watcher = chokidar.watch(allPatterns, {
474
- cwd: root,
475
- ignoreInitial: true
476
- });
616
+ logger.info(`[WATCHER] Creating watcher with cwd: ${root}`);
617
+ logger.info(`[WATCHER] Patterns (classification only): ${JSON.stringify(allPatterns, null, 2)}`);
618
+ logger.info(`[WATCHER] Concrete targets: ${JSON.stringify(Array.from(watchTargets), null, 2)}`);
619
+
620
+ let watcher;
621
+ try {
622
+ watcher = chokidar.watch(Array.from(watchTargets), {
623
+ cwd: root,
624
+ ignoreInitial: true
625
+ });
626
+ } catch (error) {
627
+ await handleWatcherError(error);
628
+ return;
629
+ }
477
630
 
478
631
  // Debug: Log what patterns we're actually watching
479
632
  logger.info('Watch patterns:', allPatterns);
480
633
 
481
634
  watcher
482
635
  .on('add', (p) => {
483
- logger.debug(`File add event: ${p}`);
636
+ logger.info(`[WATCHER] File add event: ${p}`);
484
637
  handleFileChange('add', p);
485
638
  })
486
639
  .on('change', (p) => {
487
- logger.debug(`File change event: ${p}`);
640
+ logger.info(`[WATCHER] File change event: ${p}`);
488
641
  handleFileChange('change', p);
489
642
  })
490
643
  .on('unlink', (p) => {
491
- logger.debug(`File unlink event: ${p}`);
644
+ logger.info(`[WATCHER] File unlink event: ${p}`);
492
645
  handleFileChange('unlink', p);
493
646
  })
494
647
  .on('ready', () => {
495
648
  logger.info('File watcher is ready');
496
649
  })
497
- .on('error', (error) => {
498
- logger.error('Watcher error:', error);
499
- });
650
+ .on('error', handleWatcherError);
500
651
 
501
652
  // Start initial processes
502
653
  startBackend();
@@ -523,17 +674,38 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
523
674
  // Use dynamic import instead of require for ES module compatibility
524
675
  await import(runtimeEntryPath);
525
676
 
526
- // Handle shutdown
527
- const shutdown = async () => {
528
- logger.info('Shutting down split-serve processes...');
529
- await stopBackend();
530
- await stopFrontend();
531
- await watcher.close();
532
- process.exit(0);
533
- };
534
-
535
- process.on('SIGINT', shutdown);
536
- process.on('SIGTERM', shutdown);
677
+ // Handle graceful shutdown signals
678
+ process.on('SIGINT', () => shutdown('SIGINT', 0));
679
+ process.on('SIGTERM', () => shutdown('SIGTERM', 0));
680
+
681
+ // Handle process exit - this ensures children are killed even if parent crashes
682
+ process.on('exit', () => {
683
+ // Synchronous cleanup only - exit event doesn't allow async
684
+ // Kill entire process tree (including tsx wrappers and their children)
685
+ if (backendProc && backendProc.pid && !backendProc.killed) {
686
+ killProcessTree(backendProc.pid);
687
+ }
688
+ if (frontendProc && frontendProc.pid && !frontendProc.killed) {
689
+ killProcessTree(frontendProc.pid);
690
+ }
691
+ });
692
+
693
+ // Handle uncaught exceptions
694
+ process.on('uncaughtException', async (error) => {
695
+ logger.error('Uncaught exception:', error);
696
+ await shutdown('uncaughtException', 1);
697
+ });
698
+
699
+ // Handle unhandled promise rejections
700
+ process.on('unhandledRejection', async (reason, promise) => {
701
+ logger.error('Unhandled rejection at:', promise, 'reason:', reason);
702
+ await shutdown('unhandledRejection', 1);
703
+ });
704
+
705
+ // Keep process alive for file watching
706
+ // This promise never resolves, keeping the event loop running
707
+ // The process will only exit via the signal handlers above
708
+ await new Promise(() => {});
537
709
  }
538
710
 
539
711
  async function createWatcher(context, yamlConfig, configFilePath) {
@@ -580,12 +752,39 @@ async function createWatcher(context, yamlConfig, configFilePath) {
580
752
  return null;
581
753
  }
582
754
 
755
+ // Convert patterns to concrete watch targets (directories/files)
756
+ const GLOB_CHARS = /[\\*?\[\]\{\}\(\)!+@]/;
757
+ const watchTargets = new Set();
758
+ for (const p of patterns) {
759
+ const isGlob = GLOB_CHARS.test(p);
760
+ if (!isGlob) {
761
+ watchTargets.add(p);
762
+ } else {
763
+ const base = globParent(p);
764
+ watchTargets.add(base && base.length > 0 ? base : '.');
765
+ }
766
+ }
767
+
583
768
  logger.info('Watching for changes to restart server...');
584
- // Use cwd with relative patterns for proper glob support
585
- const watcher = chokidar.watch(Array.from(patterns), {
586
- cwd: root,
587
- ignoreInitial: true
769
+ logger.info(`[WATCHER] Concrete targets: ${JSON.stringify(Array.from(watchTargets), null, 2)}`);
770
+ // Use cwd with relative targets for proper glob support
771
+ let watcher;
772
+ try {
773
+ watcher = chokidar.watch(Array.from(watchTargets), {
774
+ cwd: root,
775
+ ignoreInitial: true
776
+ });
777
+ } catch (error) {
778
+ logger.error('Failed to start file watcher:', error);
779
+ throw error;
780
+ }
781
+
782
+ watcher.on('error', (error) => {
783
+ logger.error('Watcher error:', error);
784
+ logger.error('File watching is required for dev mode; exiting.');
785
+ process.exit(1);
588
786
  });
787
+
589
788
  return watcher;
590
789
  }
591
790
 
package/src/config.js CHANGED
@@ -47,6 +47,15 @@ export async function loadBuildConfig(cliOptions = {}, { cwd = process.cwd() } =
47
47
  const uiRootDir = config.client?.main_abs ? path.dirname(config.client.main_abs) : null;
48
48
  const uiRelRoot = uiRootDir ? path.relative(config.root, uiRootDir) : null;
49
49
 
50
+ if (cliOptions.verbose) {
51
+ console.log('[config] uiRootDir calculation:', {
52
+ 'config.client': config.client,
53
+ 'config.client.main_abs': config.client?.main_abs,
54
+ 'uiRootDir': uiRootDir,
55
+ 'uiRelRoot': uiRelRoot
56
+ });
57
+ }
58
+
50
59
  const layout = {
51
60
  outDir,
52
61
  serverOutDir: path.join(outDir, 'server'),
@@ -61,6 +70,7 @@ export async function loadBuildConfig(cliOptions = {}, { cwd = process.cwd() } =
61
70
  const buildConfig = {
62
71
  ...config,
63
72
  rootDir: config.root,
73
+ verbose: cliOptions.verbose || false,
64
74
  layout,
65
75
  server: config.server ? {
66
76
  rootDir: config.server.main_abs ? path.dirname(config.server.main_abs) : config.root,
@@ -412,8 +412,11 @@ async function runFrontendService(config) {
412
412
  }
413
413
  }
414
414
  }
415
-
416
- setContext(frontendApp, config);
415
+
416
+ // Create HTTP server for WebSocket handling (Bug 1 fix)
417
+ const httpServer = http.createServer(frontendApp);
418
+
419
+ setContext(frontendApp, config, httpServer);
417
420
 
418
421
  // Only setup proxy if NOT running as a separate frontend service
419
422
  // In split-serve mode, the router handles proxying
@@ -435,7 +438,7 @@ async function runFrontendService(config) {
435
438
  console.log('[frontend] config.dev.port:', config.dev.port);
436
439
  console.log('[frontend] Using port:', frontendPort);
437
440
 
438
- frontendApp.listen(frontendPort, '0.0.0.0', () => {
441
+ httpServer.listen(frontendPort, '0.0.0.0', () => {
439
442
  console.log(`Frontend server running on http://localhost:${frontendPort}`);
440
443
  });
441
444
 
@@ -446,8 +449,10 @@ async function runFrontendService(config) {
446
449
  * Run router service that proxies to frontend/backend
447
450
  */
448
451
  async function runRouterService(config) {
449
- const appBootModule = await import(toFileUrl(config.app.boot_abs));
450
- const routerApp = appBootModule.default(config);
452
+ // Router runs in main process without TypeScript support.
453
+ // Create a plain Express app instead of importing the user's boot file.
454
+ const express = (await import('express')).default;
455
+ const routerApp = express();
451
456
 
452
457
  attachCookiePolyfill(routerApp);
453
458
 
@@ -626,10 +631,46 @@ async function runRouterService(config) {
626
631
  });
627
632
  });
628
633
 
629
- routerApp.listen(routerPort, '0.0.0.0', () => {
634
+ // Create HTTP server for WebSocket handling (Bug 2 fix)
635
+ const httpServer = http.createServer(routerApp);
636
+
637
+ // Set up WebSocket proxy
638
+ const httpProxy = await import('http-proxy');
639
+ const wsProxy = httpProxy.default.createProxyServer({ changeOrigin: true });
640
+
641
+ // Handle WebSocket upgrade requests
642
+ httpServer.on('upgrade', (req, socket, head) => {
643
+ const url = req.url || '';
644
+
645
+ // Path-based routing for WebSocket connections
646
+ // Vite HMR uses /__vite_hmr by default, but can be configured to /vite-hmr
647
+ const isHmr = url.startsWith('/__vite_hmr') || url.startsWith('/vite-hmr') ||
648
+ String(req.headers['sec-websocket-protocol'] || '').includes('vite-hmr');
649
+
650
+ // Route both HMR and app WebSockets to frontend by default
651
+ // The frontend handles both Vite HMR and app WebSocket connections
652
+ // Only route to backend if explicitly needed (currently none)
653
+ const target = `ws://localhost:${frontendPort}`;
654
+
655
+ console.log(`[router][ws] Routing WebSocket ${url} to ${target}`);
656
+
657
+ wsProxy.ws(req, socket, head, { target }, (err) => {
658
+ console.error('[router][ws] proxy error:', err?.message);
659
+ try { socket.destroy(); } catch {}
660
+ });
661
+ });
662
+
663
+ // Optional: observe proxy-level errors
664
+ wsProxy.on('error', (err, req, socket) => {
665
+ console.error('[router][ws] proxy error (global):', err?.message);
666
+ try { socket?.destroy?.(); } catch {}
667
+ });
668
+
669
+ httpServer.listen(routerPort, '0.0.0.0', () => {
630
670
  console.log(`Router server running on http://localhost:${routerPort}`);
631
671
  console.log(` Proxying to frontend on port ${frontendPort}`);
632
672
  console.log(` Proxying to backend on port ${backendPort}`);
673
+ console.log(` WebSocket support enabled via http-proxy`);
633
674
  });
634
675
 
635
676
  return routerApp;
@@ -660,7 +701,9 @@ export async function runCombinedServices(config, options = {}) {
660
701
  const require = createRequire(path.join(config.root, 'package.json'));
661
702
  const express = require('express');
662
703
  const clientStaticPath = path.join(config.outDir_abs, '.app', 'assets');
704
+ const chunksStaticPath = path.join(config.outDir_abs, '.app', 'ssr', 'chunks');
663
705
  app.use('/client', express.static(clientStaticPath));
706
+ app.use('/chunks', express.static(chunksStaticPath));
664
707
  }
665
708
 
666
709
  const httpServer = http.createServer(app);