@noego/app 0.0.7 → 0.0.9

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/DEVELOPING.md ADDED
@@ -0,0 +1,73 @@
1
+ **Purpose**
2
+ - Run and test dev watchers without blocking your terminal.
3
+ - Verify backend/frontend restarts on file changes using short, backgrounded runs with logs and PIDs.
4
+
5
+ **Prerequisites**
6
+ - Node 20+.
7
+ - This repo installed (run `npm install` once).
8
+
9
+ **Local Watch Harness (Fast, Backgrounded)**
10
+ - Start (runs ~10–15s, then exits on its own):
11
+ - `Timestamp=$(date +%s); LOG=watch-harness-$Timestamp.log; node scripts/watch-harness.mjs > $LOG 2>&1 & echo $! > watch-harness.pid`
12
+ - Observe:
13
+ - `tail -f $LOG`
14
+ - Look for lines like:
15
+ - `🔄 change detected (change): server/stitch.yaml` (BACKEND)
16
+ - `🔄 change detected (change): hammer.config.yml` (SHARED)
17
+ - `🚀 [backend restart #X]` / `🚀 [frontend restart #X]`
18
+ - `✅ restart sequence complete`
19
+ - Stop (if ever needed):
20
+ - `kill $(cat watch-harness.pid)`
21
+ - What it does
22
+ - Seeds a temp tree under `.watch-harness/`.
23
+ - Watches directories (server/ui/middleware/openapi/repo) and classifies events using your globs.
24
+ - Spawns mock backend/frontend child processes, simulates backend/frontend/shared changes, restarts the right process(es), then shuts down.
25
+
26
+ **Real App Dev (noblelaw) in Background**
27
+ - Start dev for the noblelaw project in the background with logs:
28
+ - `NO_COLOR=1 node ./bin/app.js dev --root /Users/shavauhngabay/dev/noblelaw > noblelaw-dev.log 2>&1 & echo $! > noblelaw-dev.pid`
29
+ - Observe:
30
+ - `tail -f noblelaw-dev.log`
31
+ - Expect on startup: backend on port 3002, frontend (Vite) on 3001, router on 3000.
32
+ - Make edits to trigger restarts:
33
+ - Backend restart: edit `server/services/*.ts`, `middleware/**/*.ts`, `server/openapi/**/*.yaml`, or `server/repo/**/*.sql`.
34
+ - Frontend restart: edit `ui/**/*.ts`, `ui/openapi/**/*.yaml`.
35
+ - Shared restart: edit `index.ts` or `hammer.config.yml`.
36
+ - Note: `.svelte` changes do not restart the server; Vite HMR handles them.
37
+ - Look for logs:
38
+ - `🔄 FILE CHANGE DETECTED: …`
39
+ - `Type: BACKEND | FRONTEND | SHARED`
40
+ - `🚀 [RESTART #…] Starting …`
41
+ - `✅ Restart complete`
42
+ - Stop:
43
+ - `kill $(cat noblelaw-dev.pid)`
44
+ - Free busy ports (if you see EADDRINUSE):
45
+ - `for p in 3000 3001 3002; do P=$(lsof -t -nP -iTCP:$p -sTCP:LISTEN 2>/dev/null); [ -n "$P" ] && kill -9 $P; done`
46
+
47
+ **How The Watcher Works**
48
+ - Chokidar now watches concrete directories/files so it always attaches:
49
+ - Directories: `server/`, `ui/`, `middleware/`, `server/openapi/`, `server/repo/`, `ui/openapi/`.
50
+ - Files: `index.ts`, `hammer.config.yml`, `server/stitch.yaml`, `ui/stitch.yaml`.
51
+ - Classification still uses your original globs (picomatch):
52
+ - Backend: `server/**/*.ts`, `middleware/**/*.ts`, `server/stitch.yaml`, `server/openapi/**/*.yaml`, `server/repo/**/*.sql`.
53
+ - Frontend: `ui/**/*.ts`, `ui/stitch.yaml`, `ui/openapi/**/*.yaml`.
54
+ - Shared: `index.ts`, `hammer.config.yml` (plus any app-level watch entries).
55
+ - This avoids the “ready but no watchers” problem when globs don’t match initial files.
56
+
57
+ **Troubleshooting**
58
+ - No restart logs after editing a file:
59
+ - Confirm the file lives under a watched directory and matches one of the classification globs above.
60
+ - `.svelte` isn’t supposed to restart—use Vite HMR in the browser.
61
+ - Watcher error (EMFILE or similar):
62
+ - The CLI now fails fast on watcher errors with a clear log and exit.
63
+ - Ensure you’re not watching the entire repo root or `node_modules/`; keep to `server/`, `ui/`, `middleware/` and the specific YAML/SQL roots.
64
+ - Ports in use (EADDRINUSE):
65
+ - Free 3000/3001/3002 using the snippet above and restart dev.
66
+ - Clean up stray mock processes from the harness (if any):
67
+ - `pkill -f "\[backend\]"; pkill -f "\[frontend\]"`
68
+
69
+ **Operational Tips**
70
+ - Always background long-running scripts and write the PID to a file.
71
+ - Tail logs for verification and kill by PID when done.
72
+ - Keep a hard timeout in any custom test harness so it self-exits if something goes wrong.
73
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noego/app",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
4
4
  "description": "Production build tool for Dinner/Forge apps.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -29,6 +29,7 @@
29
29
  "license": "MIT",
30
30
  "author": "App Build CLI",
31
31
  "dependencies": {
32
+ "glob-parent": "^6.0.2",
32
33
  "deepmerge": "^4.3.1",
33
34
  "picomatch": "^2.3.1",
34
35
  "yaml": "^2.6.0"
@@ -38,5 +39,7 @@
38
39
  "@noego/forge": "*",
39
40
  "express": "*"
40
41
  },
41
- "devDependencies": {}
42
+ "devDependencies": {
43
+ "chokidar": "^4.0.3"
44
+ }
42
45
  }
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import chokidar from 'chokidar';
6
+ import picomatch from 'picomatch';
7
+
8
+ const PLAYGROUND_DIR = path.join(process.cwd(), '.watch-harness');
9
+ const sharedPatterns = ['index.ts', 'hammer.config.yml'];
10
+ const backendPatterns = [
11
+ 'server/**/*.ts',
12
+ 'middleware/**/*.ts',
13
+ 'server/stitch.yaml',
14
+ 'server/openapi/**/*.yaml',
15
+ 'server/repo/**/*.sql'
16
+ ];
17
+ const frontendPatterns = [
18
+ 'ui/**/*.ts',
19
+ 'ui/stitch.yaml',
20
+ 'ui/openapi/**/*.yaml'
21
+ ];
22
+ const allPatterns = [...sharedPatterns, ...backendPatterns, ...frontendPatterns];
23
+
24
+ const seedFiles = {
25
+ 'index.ts': '// root entry\n',
26
+ 'hammer.config.yml': '# config\n',
27
+ 'server/server.ts': '// server bootstrap\n',
28
+ 'server/services/admin_service.ts': '// admin service seed\n',
29
+ 'middleware/logger.ts': '// middleware seed\n',
30
+ 'server/stitch.yaml': 'openapi: 3.0.0\n',
31
+ 'server/openapi/routes.yaml': 'paths: {}\n',
32
+ 'server/repo/example/query.sql': 'select 1;\n',
33
+ 'ui/frontend.ts': '// frontend entry\n',
34
+ 'ui/stitch.yaml': 'paths: {}\n',
35
+ 'ui/openapi/page.yaml': 'paths: {}\n',
36
+ 'ui/pages/home.ts': 'export const home = true;\n'
37
+ };
38
+
39
+ const changeSequence = [
40
+ { label: 'backend', file: 'server/services/admin_service.ts' },
41
+ { label: 'frontend', file: 'ui/pages/home.ts' },
42
+ { label: 'shared', file: 'hammer.config.yml' }
43
+ ];
44
+
45
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
46
+
47
+ let backendProc = null;
48
+ let frontendProc = null;
49
+ let watcher = null;
50
+ let shuttingDown = false;
51
+ let pendingRestart = false;
52
+ let backendRestartCount = 0;
53
+ let frontendRestartCount = 0;
54
+ let readyHandled = false;
55
+
56
+ async function preparePlayground() {
57
+ await fs.rm(PLAYGROUND_DIR, { recursive: true, force: true }).catch(() => {});
58
+ await fs.mkdir(PLAYGROUND_DIR, { recursive: true });
59
+ await Promise.all(
60
+ Object.entries(seedFiles).map(async ([relative, contents]) => {
61
+ const absolute = path.join(PLAYGROUND_DIR, relative);
62
+ await fs.mkdir(path.dirname(absolute), { recursive: true });
63
+ await fs.writeFile(absolute, contents);
64
+ })
65
+ );
66
+ }
67
+
68
+ function spawnChild(label) {
69
+ const child = spawn(process.execPath, ['-e', `
70
+ console.log('[${label}] booting (pid', process.pid, ')');
71
+ let counter = 0;
72
+ setInterval(() => {
73
+ counter += 1;
74
+ console.log('[${label}] heartbeat #' + counter);
75
+ }, 750);
76
+ `], {
77
+ stdio: ['ignore', 'inherit', 'inherit']
78
+ });
79
+ child.on('exit', (code, signal) => {
80
+ console.log(`[${label}] exited`, { code, signal });
81
+ });
82
+ return child;
83
+ }
84
+
85
+ function startBackend() {
86
+ backendRestartCount += 1;
87
+ console.log(`🚀 [backend restart #${backendRestartCount}] starting mock backend`);
88
+ backendProc = spawnChild('backend');
89
+ }
90
+
91
+ function startFrontend() {
92
+ frontendRestartCount += 1;
93
+ console.log(`🚀 [frontend restart #${frontendRestartCount}] starting mock frontend`);
94
+ frontendProc = spawnChild('frontend');
95
+ }
96
+
97
+ function stopProcess(proc, label) {
98
+ return new Promise((resolve) => {
99
+ if (!proc || proc.killed) {
100
+ return resolve();
101
+ }
102
+ const timeout = setTimeout(() => {
103
+ console.warn(`[${label}] did not exit in time, forcing SIGKILL`);
104
+ try {
105
+ proc.kill('SIGKILL');
106
+ } catch {}
107
+ resolve();
108
+ }, 2000);
109
+ proc.once('exit', () => {
110
+ clearTimeout(timeout);
111
+ resolve();
112
+ });
113
+ try {
114
+ proc.kill('SIGTERM');
115
+ } catch {
116
+ clearTimeout(timeout);
117
+ resolve();
118
+ }
119
+ });
120
+ }
121
+
122
+ async function shutdown(exitCode = 0) {
123
+ if (shuttingDown) return;
124
+ shuttingDown = true;
125
+ console.log('[harness] shutting down...');
126
+ try {
127
+ if (watcher) {
128
+ await watcher.close();
129
+ }
130
+ } catch (error) {
131
+ console.error('[harness] watcher close error', error);
132
+ }
133
+ await stopProcess(backendProc, 'backend');
134
+ await stopProcess(frontendProc, 'frontend');
135
+ await fs.rm(PLAYGROUND_DIR, { recursive: true, force: true }).catch(() => {});
136
+ process.exit(exitCode);
137
+ }
138
+
139
+ async function handleFileChange(reason, relativePath, matchers) {
140
+ if (pendingRestart) return;
141
+ pendingRestart = true;
142
+ const normalized = relativePath.replace(/\\/g, '/');
143
+ const { sharedMatcher, backendMatcher, frontendMatcher } = matchers;
144
+ let restartBackend = false;
145
+ let restartFrontend = false;
146
+
147
+ if (sharedMatcher && sharedMatcher(normalized)) {
148
+ console.log(`\n${'='.repeat(60)}`);
149
+ console.log(`🔄 change detected (${reason}): ${normalized}`);
150
+ console.log(' type: SHARED -> restarting backend + frontend');
151
+ console.log(`${'='.repeat(60)}\n`);
152
+ restartBackend = true;
153
+ restartFrontend = true;
154
+ } else if (backendMatcher && backendMatcher(normalized)) {
155
+ console.log(`\n${'='.repeat(60)}`);
156
+ console.log(`🔄 change detected (${reason}): ${normalized}`);
157
+ console.log(' type: BACKEND -> restarting backend only');
158
+ console.log(`${'='.repeat(60)}\n`);
159
+ restartBackend = true;
160
+ } else if (frontendMatcher && frontendMatcher(normalized)) {
161
+ console.log(`\n${'='.repeat(60)}`);
162
+ console.log(`🔄 change detected (${reason}): ${normalized}`);
163
+ console.log(' type: FRONTEND -> restarting frontend only');
164
+ console.log(`${'='.repeat(60)}\n`);
165
+ restartFrontend = true;
166
+ } else {
167
+ console.log(`[harness] change ignored (no matcher): ${normalized}`);
168
+ }
169
+
170
+ if (restartBackend) {
171
+ await stopProcess(backendProc, 'backend');
172
+ startBackend();
173
+ }
174
+ if (restartFrontend) {
175
+ await stopProcess(frontendProc, 'frontend');
176
+ startFrontend();
177
+ }
178
+ if (restartBackend || restartFrontend) {
179
+ console.log('✅ restart sequence complete\n');
180
+ }
181
+ pendingRestart = false;
182
+ }
183
+
184
+ async function simulateChanges() {
185
+ for (const step of changeSequence) {
186
+ await delay(500);
187
+ const target = path.join(PLAYGROUND_DIR, step.file);
188
+ console.log(`[harness] simulating ${step.label} change: ${step.file}`);
189
+ await fs.appendFile(target, `\n// touched ${Date.now()}`);
190
+ }
191
+ console.log('[harness] change simulation complete');
192
+ }
193
+
194
+ async function runHarness() {
195
+ await preparePlayground();
196
+
197
+ const normalizePattern = (pattern) => pattern;
198
+ const sharedMatcher = picomatch(sharedPatterns.map(normalizePattern));
199
+ const backendMatcher = picomatch(backendPatterns.map(normalizePattern));
200
+ const frontendMatcher = picomatch(frontendPatterns.map(normalizePattern));
201
+
202
+ watcher = chokidar.watch(allPatterns, {
203
+ cwd: PLAYGROUND_DIR,
204
+ ignoreInitial: true
205
+ });
206
+
207
+ const autoTimeout = setTimeout(() => {
208
+ console.error('[harness] timeout waiting for restarts');
209
+ shutdown(1);
210
+ }, 15000);
211
+
212
+ watcher.on('ready', async () => {
213
+ if (readyHandled) {
214
+ return;
215
+ }
216
+ readyHandled = true;
217
+ console.log('[harness] watcher ready');
218
+ startBackend();
219
+ startFrontend();
220
+ await simulateChanges();
221
+ setTimeout(async () => {
222
+ clearTimeout(autoTimeout);
223
+ await shutdown(0);
224
+ }, 1500);
225
+ });
226
+
227
+ watcher.on('all', (event, file) => {
228
+ if (!['add', 'change', 'unlink'].includes(event)) {
229
+ return;
230
+ }
231
+ handleFileChange(event, file, { sharedMatcher, backendMatcher, frontendMatcher });
232
+ });
233
+
234
+ watcher.on('error', async (error) => {
235
+ console.error('[harness] watcher error', error);
236
+ await shutdown(1);
237
+ });
238
+ }
239
+
240
+ process.on('SIGINT', () => shutdown(0));
241
+ process.on('SIGTERM', () => shutdown(0));
242
+
243
+ runHarness().catch((error) => {
244
+ console.error('[harness] fatal error', error);
245
+ shutdown(1);
246
+ });
@@ -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