@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/.claude/settings.local.json +11 -4
- package/DEVELOPING.md +73 -0
- package/docs/asset-serving-fix.md +381 -0
- package/package.json +6 -2
- package/scripts/watch-harness.mjs +246 -0
- package/src/args.js +3 -1
- package/src/build/bootstrap.js +107 -5
- package/src/build/html.js +4 -2
- package/src/build/runtime-manifest.js +1 -1
- package/src/build/server.js +14 -4
- package/src/build/ssr.js +130 -13
- package/src/build/ui-common.js +19 -2
- package/src/client.js +14 -2
- package/src/commands/dev.js +239 -40
- package/src/config.js +10 -0
- package/src/runtime/runtime.js +49 -6
- package/test/asset-mounting.test.js +211 -0
- package/test/config-pipeline.test.js +353 -0
- package/test/path-resolution.test.js +164 -0
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
|
-
|
|
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
|
-
|
package/src/commands/dev.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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.
|
|
636
|
+
logger.info(`[WATCHER] File add event: ${p}`);
|
|
484
637
|
handleFileChange('add', p);
|
|
485
638
|
})
|
|
486
639
|
.on('change', (p) => {
|
|
487
|
-
logger.
|
|
640
|
+
logger.info(`[WATCHER] File change event: ${p}`);
|
|
488
641
|
handleFileChange('change', p);
|
|
489
642
|
})
|
|
490
643
|
.on('unlink', (p) => {
|
|
491
|
-
logger.
|
|
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',
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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,
|
package/src/runtime/runtime.js
CHANGED
|
@@ -412,8 +412,11 @@ async function runFrontendService(config) {
|
|
|
412
412
|
}
|
|
413
413
|
}
|
|
414
414
|
}
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
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
|
-
|
|
450
|
-
|
|
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
|
-
|
|
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);
|