@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 +73 -0
- package/package.json +5 -2
- package/scripts/watch-harness.mjs +246 -0
- package/src/commands/dev.js +239 -40
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.
|
|
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
|
+
});
|
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
|
|