@noego/app 0.0.6 → 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/.claude/settings.local.json +2 -1
- package/DEVELOPING.md +73 -0
- package/package.json +9 -2
- package/scripts/watch-harness.mjs +246 -0
- package/src/commands/dev.js +319 -91
- package/src/commands/serve.js +49 -26
- package/src/config.js +4 -2
- package/src/runtime/config.js +82 -0
- package/types/config.d.ts +23 -0
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": {
|
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
"./client": {
|
|
15
15
|
"import": "./src/client.js",
|
|
16
16
|
"types": "./types/client.d.ts"
|
|
17
|
+
},
|
|
18
|
+
"./config": {
|
|
19
|
+
"import": "./src/runtime/config.js",
|
|
20
|
+
"types": "./types/config.d.ts"
|
|
17
21
|
}
|
|
18
22
|
},
|
|
19
23
|
"scripts": {
|
|
@@ -25,6 +29,7 @@
|
|
|
25
29
|
"license": "MIT",
|
|
26
30
|
"author": "App Build CLI",
|
|
27
31
|
"dependencies": {
|
|
32
|
+
"glob-parent": "^6.0.2",
|
|
28
33
|
"deepmerge": "^4.3.1",
|
|
29
34
|
"picomatch": "^2.3.1",
|
|
30
35
|
"yaml": "^2.6.0"
|
|
@@ -34,5 +39,7 @@
|
|
|
34
39
|
"@noego/forge": "*",
|
|
35
40
|
"express": "*"
|
|
36
41
|
},
|
|
37
|
-
"devDependencies": {
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"chokidar": "^4.0.3"
|
|
44
|
+
}
|
|
38
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,44 +357,74 @@ 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;
|
|
298
366
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
367
|
+
// Keep patterns relative for chokidar to work properly with globs
|
|
368
|
+
// Absolute patterns with globs don't work well, especially in chokidar v4
|
|
369
|
+
const normalizePattern = (pattern) => {
|
|
370
|
+
// If pattern is absolute, make it relative to root
|
|
371
|
+
if (path.isAbsolute(pattern)) {
|
|
372
|
+
const normalized = path.relative(root, pattern);
|
|
373
|
+
logger.debug(`Normalized pattern: ${pattern} -> ${normalized}`);
|
|
374
|
+
return normalized;
|
|
375
|
+
}
|
|
376
|
+
logger.debug(`Pattern already relative: ${pattern}`);
|
|
377
|
+
return pattern;
|
|
302
378
|
};
|
|
303
|
-
|
|
379
|
+
|
|
304
380
|
// Collect watch patterns
|
|
305
381
|
const backendPatterns = [];
|
|
306
382
|
const frontendPatterns = [];
|
|
307
383
|
const sharedPatterns = [];
|
|
308
|
-
|
|
384
|
+
|
|
309
385
|
// App watch patterns (restart both)
|
|
310
386
|
if (yamlConfig.app?.watch) {
|
|
311
387
|
for (const pattern of yamlConfig.app.watch) {
|
|
312
|
-
sharedPatterns.push(
|
|
388
|
+
sharedPatterns.push(normalizePattern(pattern));
|
|
313
389
|
}
|
|
314
390
|
}
|
|
315
|
-
|
|
391
|
+
|
|
316
392
|
// Server watch patterns (restart backend only)
|
|
317
393
|
if (yamlConfig.server?.watch) {
|
|
318
394
|
for (const pattern of yamlConfig.server.watch) {
|
|
319
|
-
backendPatterns.push(
|
|
395
|
+
backendPatterns.push(normalizePattern(pattern));
|
|
320
396
|
}
|
|
321
397
|
}
|
|
322
|
-
|
|
398
|
+
|
|
323
399
|
// Client watch patterns (restart frontend only)
|
|
324
400
|
if (yamlConfig.client?.watch) {
|
|
325
401
|
for (const pattern of yamlConfig.client.watch) {
|
|
326
|
-
frontendPatterns.push(
|
|
402
|
+
frontendPatterns.push(normalizePattern(pattern));
|
|
327
403
|
}
|
|
328
404
|
}
|
|
329
405
|
|
|
330
|
-
// Combine all patterns for
|
|
406
|
+
// Combine all patterns (for classification)
|
|
331
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);
|
|
332
428
|
|
|
333
429
|
if (allPatterns.length === 0) {
|
|
334
430
|
logger.warn('No watch patterns configured. Watching disabled.');
|
|
@@ -339,18 +435,23 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
|
|
|
339
435
|
logger.info(` Shared: ${sharedPatterns.length} patterns`);
|
|
340
436
|
logger.info(` Backend: ${backendPatterns.length} patterns`);
|
|
341
437
|
logger.info(` Frontend: ${frontendPatterns.length} patterns`);
|
|
438
|
+
logger.info(` Watch targets (${watchTargets.size}): ${JSON.stringify(Array.from(watchTargets), null, 2)}`);
|
|
342
439
|
|
|
343
440
|
// Create matchers
|
|
344
441
|
const sharedMatcher = sharedPatterns.length > 0 ? picomatch(sharedPatterns) : null;
|
|
345
442
|
const backendMatcher = backendPatterns.length > 0 ? picomatch(backendPatterns) : null;
|
|
346
443
|
const frontendMatcher = frontendPatterns.length > 0 ? picomatch(frontendPatterns) : null;
|
|
347
|
-
|
|
444
|
+
|
|
348
445
|
let backendProc = null;
|
|
349
446
|
let frontendProc = null;
|
|
350
447
|
let pending = false;
|
|
351
|
-
|
|
448
|
+
let backendRestartCount = 0;
|
|
449
|
+
let frontendRestartCount = 0;
|
|
450
|
+
let isShuttingDown = false;
|
|
451
|
+
|
|
352
452
|
const startBackend = () => {
|
|
353
|
-
|
|
453
|
+
backendRestartCount++;
|
|
454
|
+
logger.info(`🚀 [RESTART #${backendRestartCount}] Starting backend on port ${backendPort}...`);
|
|
354
455
|
const backendEnv = {
|
|
355
456
|
...baseEnv,
|
|
356
457
|
NOEGO_SERVICE: 'backend',
|
|
@@ -372,7 +473,8 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
|
|
|
372
473
|
};
|
|
373
474
|
|
|
374
475
|
const startFrontend = () => {
|
|
375
|
-
|
|
476
|
+
frontendRestartCount++;
|
|
477
|
+
logger.info(`🚀 [RESTART #${frontendRestartCount}] Starting frontend on port ${frontendPort}...`);
|
|
376
478
|
const frontendEnv = {
|
|
377
479
|
...baseEnv,
|
|
378
480
|
NOEGO_SERVICE: 'frontend',
|
|
@@ -396,6 +498,7 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
|
|
|
396
498
|
const stopBackend = () =>
|
|
397
499
|
new Promise((resolve) => {
|
|
398
500
|
if (!backendProc) return resolve();
|
|
501
|
+
logger.info(`⏹️ Stopping backend (preparing for restart #${backendRestartCount + 1})...`);
|
|
399
502
|
const to = setTimeout(resolve, 2000);
|
|
400
503
|
backendProc.once('exit', () => {
|
|
401
504
|
clearTimeout(to);
|
|
@@ -411,6 +514,7 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
|
|
|
411
514
|
const stopFrontend = () =>
|
|
412
515
|
new Promise((resolve) => {
|
|
413
516
|
if (!frontendProc) return resolve();
|
|
517
|
+
logger.info(`⏹️ Stopping frontend (preparing for restart #${frontendRestartCount + 1})...`);
|
|
414
518
|
const to = setTimeout(resolve, 2000);
|
|
415
519
|
frontendProc.once('exit', () => {
|
|
416
520
|
clearTimeout(to);
|
|
@@ -422,32 +526,74 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
|
|
|
422
526
|
resolve();
|
|
423
527
|
}
|
|
424
528
|
});
|
|
425
|
-
|
|
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
|
+
|
|
426
561
|
const handleFileChange = async (reason, file) => {
|
|
427
562
|
if (pending) return;
|
|
428
563
|
pending = true;
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
564
|
+
|
|
565
|
+
// With cwd set, chokidar reports relative paths
|
|
566
|
+
// Our patterns are now relative, so match against the relative path
|
|
567
|
+
const relativePath = path.isAbsolute(file) ? path.relative(root, file) : file;
|
|
568
|
+
|
|
432
569
|
// Determine which service(s) to restart
|
|
433
570
|
let restartBackend = false;
|
|
434
571
|
let restartFrontend = false;
|
|
435
|
-
|
|
436
|
-
if (sharedMatcher && sharedMatcher(
|
|
572
|
+
|
|
573
|
+
if (sharedMatcher && sharedMatcher(relativePath)) {
|
|
437
574
|
// Shared file changed - restart both
|
|
438
|
-
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));
|
|
439
579
|
restartBackend = true;
|
|
440
580
|
restartFrontend = true;
|
|
441
|
-
} else if (backendMatcher && backendMatcher(
|
|
581
|
+
} else if (backendMatcher && backendMatcher(relativePath)) {
|
|
442
582
|
// Backend file changed
|
|
443
|
-
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));
|
|
444
587
|
restartBackend = true;
|
|
445
|
-
} else if (frontendMatcher && frontendMatcher(
|
|
588
|
+
} else if (frontendMatcher && frontendMatcher(relativePath)) {
|
|
446
589
|
// Frontend file changed
|
|
447
|
-
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));
|
|
448
594
|
restartFrontend = true;
|
|
449
595
|
}
|
|
450
|
-
|
|
596
|
+
|
|
451
597
|
// Restart appropriate service(s)
|
|
452
598
|
if (restartBackend) {
|
|
453
599
|
await stopBackend();
|
|
@@ -457,20 +603,51 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
|
|
|
457
603
|
await stopFrontend();
|
|
458
604
|
startFrontend();
|
|
459
605
|
}
|
|
460
|
-
|
|
606
|
+
|
|
607
|
+
if (restartBackend || restartFrontend) {
|
|
608
|
+
logger.info(`✅ Restart complete\n`);
|
|
609
|
+
}
|
|
610
|
+
|
|
461
611
|
pending = false;
|
|
462
612
|
};
|
|
463
613
|
|
|
464
614
|
// Create watcher
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
});
|
|
469
|
-
|
|
615
|
+
// Use cwd with relative patterns for proper glob support
|
|
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
|
+
}
|
|
630
|
+
|
|
631
|
+
// Debug: Log what patterns we're actually watching
|
|
632
|
+
logger.info('Watch patterns:', allPatterns);
|
|
633
|
+
|
|
470
634
|
watcher
|
|
471
|
-
.on('add', (p) =>
|
|
472
|
-
|
|
473
|
-
|
|
635
|
+
.on('add', (p) => {
|
|
636
|
+
logger.info(`[WATCHER] File add event: ${p}`);
|
|
637
|
+
handleFileChange('add', p);
|
|
638
|
+
})
|
|
639
|
+
.on('change', (p) => {
|
|
640
|
+
logger.info(`[WATCHER] File change event: ${p}`);
|
|
641
|
+
handleFileChange('change', p);
|
|
642
|
+
})
|
|
643
|
+
.on('unlink', (p) => {
|
|
644
|
+
logger.info(`[WATCHER] File unlink event: ${p}`);
|
|
645
|
+
handleFileChange('unlink', p);
|
|
646
|
+
})
|
|
647
|
+
.on('ready', () => {
|
|
648
|
+
logger.info('File watcher is ready');
|
|
649
|
+
})
|
|
650
|
+
.on('error', handleWatcherError);
|
|
474
651
|
|
|
475
652
|
// Start initial processes
|
|
476
653
|
startBackend();
|
|
@@ -497,17 +674,38 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
|
|
|
497
674
|
// Use dynamic import instead of require for ES module compatibility
|
|
498
675
|
await import(runtimeEntryPath);
|
|
499
676
|
|
|
500
|
-
// Handle shutdown
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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(() => {});
|
|
511
709
|
}
|
|
512
710
|
|
|
513
711
|
async function createWatcher(context, yamlConfig, configFilePath) {
|
|
@@ -518,49 +716,75 @@ async function createWatcher(context, yamlConfig, configFilePath) {
|
|
|
518
716
|
const chokidar = requireFromRoot('chokidar');
|
|
519
717
|
|
|
520
718
|
const patterns = new Set();
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
719
|
+
|
|
720
|
+
// Keep patterns relative for chokidar to work properly with globs
|
|
721
|
+
const normalizePattern = (pattern) => {
|
|
722
|
+
// If pattern is absolute, make it relative to root
|
|
723
|
+
if (path.isAbsolute(pattern)) {
|
|
724
|
+
return path.relative(root, pattern);
|
|
725
|
+
}
|
|
726
|
+
return pattern;
|
|
525
727
|
};
|
|
526
|
-
|
|
527
|
-
//
|
|
528
|
-
if (yamlConfig.
|
|
529
|
-
for (const pattern of yamlConfig.
|
|
530
|
-
patterns.add(
|
|
728
|
+
|
|
729
|
+
// App watch patterns
|
|
730
|
+
if (yamlConfig.app?.watch) {
|
|
731
|
+
for (const pattern of yamlConfig.app.watch) {
|
|
732
|
+
patterns.add(normalizePattern(pattern));
|
|
531
733
|
}
|
|
532
734
|
}
|
|
533
|
-
|
|
534
|
-
//
|
|
535
|
-
if (yamlConfig.server?.
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
patterns.add(resolvePattern(`${yamlConfig.server.middleware}/**/*.{ts,js}`));
|
|
735
|
+
|
|
736
|
+
// Server watch patterns
|
|
737
|
+
if (yamlConfig.server?.watch) {
|
|
738
|
+
for (const pattern of yamlConfig.server.watch) {
|
|
739
|
+
patterns.add(normalizePattern(pattern));
|
|
740
|
+
}
|
|
540
741
|
}
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
742
|
+
|
|
743
|
+
// Client watch patterns
|
|
744
|
+
if (yamlConfig.client?.watch) {
|
|
745
|
+
for (const pattern of yamlConfig.client.watch) {
|
|
746
|
+
patterns.add(normalizePattern(pattern));
|
|
747
|
+
}
|
|
545
748
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
749
|
+
|
|
750
|
+
if (patterns.size === 0) {
|
|
751
|
+
logger.warn('No watch patterns configured. Watching disabled.');
|
|
752
|
+
return null;
|
|
550
753
|
}
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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 : '.');
|
|
556
765
|
}
|
|
557
766
|
}
|
|
558
|
-
|
|
767
|
+
|
|
559
768
|
logger.info('Watching for changes to restart server...');
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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);
|
|
563
786
|
});
|
|
787
|
+
|
|
564
788
|
return watcher;
|
|
565
789
|
}
|
|
566
790
|
|
|
@@ -604,19 +828,23 @@ async function runWithRestart(context, tsxExecutable, tsxArgs, env, watcher, log
|
|
|
604
828
|
start();
|
|
605
829
|
pending = false;
|
|
606
830
|
};
|
|
607
|
-
|
|
608
|
-
watcher
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
831
|
+
|
|
832
|
+
if (watcher) {
|
|
833
|
+
watcher
|
|
834
|
+
.on('add', (p) => scheduleRestart('add', p))
|
|
835
|
+
.on('change', (p) => scheduleRestart('change', p))
|
|
836
|
+
.on('unlink', (p) => scheduleRestart('unlink', p));
|
|
837
|
+
}
|
|
838
|
+
|
|
613
839
|
// Start initial server
|
|
614
840
|
start();
|
|
615
|
-
|
|
841
|
+
|
|
616
842
|
// Keep process alive until SIGINT/SIGTERM
|
|
617
843
|
const shutdown = async () => {
|
|
618
844
|
await stop();
|
|
619
|
-
|
|
845
|
+
if (watcher) {
|
|
846
|
+
await watcher.close();
|
|
847
|
+
}
|
|
620
848
|
process.exit(0);
|
|
621
849
|
};
|
|
622
850
|
process.on('SIGINT', shutdown);
|
package/src/commands/serve.js
CHANGED
|
@@ -74,39 +74,52 @@ async function createWatcher(context) {
|
|
|
74
74
|
patterns.add(value);
|
|
75
75
|
};
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
// Keep patterns relative for chokidar to work properly with globs
|
|
78
|
+
const normalizePattern = (entry) => {
|
|
78
79
|
if (!entry) return null;
|
|
79
80
|
if (typeof entry === 'string') {
|
|
80
|
-
|
|
81
|
+
// If pattern is absolute, make it relative to rootDir
|
|
82
|
+
if (path.isAbsolute(entry)) {
|
|
83
|
+
return path.relative(config.rootDir, entry);
|
|
84
|
+
}
|
|
85
|
+
return entry;
|
|
81
86
|
}
|
|
87
|
+
// Handle object-style patterns
|
|
82
88
|
if (entry.isAbsolute) {
|
|
83
|
-
return entry.pattern;
|
|
89
|
+
return path.relative(config.rootDir, entry.pattern);
|
|
84
90
|
}
|
|
85
91
|
if (entry.cwd) {
|
|
86
|
-
|
|
92
|
+
const absolute = path.join(entry.cwd, entry.pattern);
|
|
93
|
+
return path.relative(config.rootDir, absolute);
|
|
87
94
|
}
|
|
88
95
|
return entry.pattern;
|
|
89
96
|
};
|
|
90
97
|
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
98
|
+
// App watch patterns - restart server
|
|
99
|
+
if (config.app?.watch) {
|
|
100
|
+
for (const entry of config.app.watch) {
|
|
101
|
+
const pattern = normalizePattern(entry);
|
|
102
|
+
if (pattern) addPattern(pattern);
|
|
103
|
+
}
|
|
95
104
|
}
|
|
96
105
|
|
|
97
|
-
// Server-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const pattern = resolveGlob(entry);
|
|
104
|
-
if (pattern) addPattern(pattern);
|
|
106
|
+
// Server watch patterns - restart server
|
|
107
|
+
if (config.server?.watch) {
|
|
108
|
+
for (const entry of config.server.watch) {
|
|
109
|
+
const pattern = normalizePattern(entry);
|
|
110
|
+
if (pattern) addPattern(pattern);
|
|
111
|
+
}
|
|
105
112
|
}
|
|
106
113
|
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
114
|
+
// Client watch patterns - restart server (in non-split mode)
|
|
115
|
+
// Check both ui.watch (from buildConfig) and client.watch (from spread)
|
|
116
|
+
const clientWatch = config.ui?.watch || config.client?.watch;
|
|
117
|
+
if (clientWatch) {
|
|
118
|
+
for (const entry of clientWatch) {
|
|
119
|
+
const pattern = normalizePattern(entry);
|
|
120
|
+
if (pattern) addPattern(pattern);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
110
123
|
|
|
111
124
|
// Ignore Svelte/client files to let Vite HMR handle them
|
|
112
125
|
const uiIgnores = [
|
|
@@ -114,11 +127,17 @@ async function createWatcher(context) {
|
|
|
114
127
|
path.join(config.ui.rootDir, '**/*.{ts,tsx,css,scss,html}')
|
|
115
128
|
];
|
|
116
129
|
|
|
130
|
+
if (patterns.size === 0) {
|
|
131
|
+
logger.warn('No watch patterns configured. Watching disabled.');
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
117
135
|
logger.info('Watching for changes to restart server...');
|
|
136
|
+
// Use cwd with relative patterns for proper glob support
|
|
118
137
|
const watcher = chokidar.watch(Array.from(patterns), {
|
|
138
|
+
cwd: config.rootDir,
|
|
119
139
|
ignoreInitial: true,
|
|
120
|
-
ignored: uiIgnores
|
|
121
|
-
cwd: config.rootDir
|
|
140
|
+
ignored: uiIgnores
|
|
122
141
|
});
|
|
123
142
|
return watcher;
|
|
124
143
|
}
|
|
@@ -166,10 +185,12 @@ async function runWithRestart(context, runner, watcher, frontendProc) {
|
|
|
166
185
|
pending = false;
|
|
167
186
|
};
|
|
168
187
|
|
|
169
|
-
watcher
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
188
|
+
if (watcher) {
|
|
189
|
+
watcher
|
|
190
|
+
.on('add', (p) => scheduleRestart('add', p))
|
|
191
|
+
.on('change', (p) => scheduleRestart('change', p))
|
|
192
|
+
.on('unlink', (p) => scheduleRestart('unlink', p));
|
|
193
|
+
}
|
|
173
194
|
|
|
174
195
|
// Start initial server
|
|
175
196
|
start();
|
|
@@ -177,7 +198,9 @@ async function runWithRestart(context, runner, watcher, frontendProc) {
|
|
|
177
198
|
// Keep process alive until SIGINT/SIGTERM
|
|
178
199
|
const shutdown = async () => {
|
|
179
200
|
await stop();
|
|
180
|
-
|
|
201
|
+
if (watcher) {
|
|
202
|
+
await watcher.close();
|
|
203
|
+
}
|
|
181
204
|
if (frontendProc) {
|
|
182
205
|
try { frontendProc.kill('SIGTERM'); } catch {}
|
|
183
206
|
}
|
package/src/config.js
CHANGED
|
@@ -73,7 +73,8 @@ export async function loadBuildConfig(cliOptions = {}, { cwd = process.cwd() } =
|
|
|
73
73
|
controllersDir: config.server.controllers_abs,
|
|
74
74
|
middlewareDir: config.server.middleware_abs,
|
|
75
75
|
openapiFile: config.server.openapi_abs,
|
|
76
|
-
sqlGlobs: serverSqlGlobs
|
|
76
|
+
sqlGlobs: serverSqlGlobs,
|
|
77
|
+
watch: config.server.watch // Preserve watch property
|
|
77
78
|
} : null,
|
|
78
79
|
ui: config.client ? {
|
|
79
80
|
rootDir: uiRootDir,
|
|
@@ -85,7 +86,8 @@ export async function loadBuildConfig(cliOptions = {}, { cwd = process.cwd() } =
|
|
|
85
86
|
options: {}, // This seems to be deprecated
|
|
86
87
|
openapiFile: config.client.openapi_abs,
|
|
87
88
|
assets: assetSpecs,
|
|
88
|
-
clientExclude: clientExcludeSpecs
|
|
89
|
+
clientExclude: clientExcludeSpecs,
|
|
90
|
+
watch: config.client.watch // Preserve watch property
|
|
89
91
|
} : null,
|
|
90
92
|
assets: assetSpecs,
|
|
91
93
|
vite: {
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { loadConfig, findConfigFile, loadConfigFromEnv } from './config-loader.js';
|
|
3
|
+
import {setContext} from "../client";
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Loads and applies the resolved project configuration to the application's runtime context.
|
|
10
|
+
*
|
|
11
|
+
* @param {(config:any) => any} getAppFunction - A function that returns the application instance (e.g., Express app).
|
|
12
|
+
* @returns {Promise<object>} - The fully resolved and normalized configuration object.
|
|
13
|
+
*/
|
|
14
|
+
export async function buildConfig(getAppFunction) {
|
|
15
|
+
// Retrieve the application instance from the provided function.
|
|
16
|
+
/** @type {any} */
|
|
17
|
+
const app = getAppFunction();
|
|
18
|
+
// Load and resolve the configuration via getConfig (auto-detects config file or env).
|
|
19
|
+
const { config } = await getConfig();
|
|
20
|
+
// Attach the loaded config to the runtime context for downstream usage.
|
|
21
|
+
setContext(app, config);
|
|
22
|
+
// Return the config for consumers (generates env and internal path invariants).
|
|
23
|
+
return {config,app};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Automatically load configuration from environment variable or config file.
|
|
28
|
+
*
|
|
29
|
+
* This function:
|
|
30
|
+
* 1. First checks NOEGO_CONFIGURATION env var (uses loadConfigFromEnv if present)
|
|
31
|
+
* 2. Otherwise searches for config file starting from rootDir (or process.cwd())
|
|
32
|
+
* and walks up directories until a config file is found
|
|
33
|
+
* 3. Sets all environment variables automatically
|
|
34
|
+
* 4. Returns the fully resolved config object
|
|
35
|
+
*
|
|
36
|
+
* @param {string} [rootDir] - Optional starting directory for config file search. Defaults to process.cwd()
|
|
37
|
+
* @returns {Promise<{root: string, config: object, configFilePath: string|null}>}
|
|
38
|
+
*/
|
|
39
|
+
export async function getConfig(rootDir) {
|
|
40
|
+
// First priority: check NOEGO_CONFIGURATION env var
|
|
41
|
+
if (process.env.NOEGO_CONFIGURATION) {
|
|
42
|
+
return loadConfigFromEnv();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Second priority: find and load config file
|
|
46
|
+
const startDir = rootDir ? path.resolve(rootDir) : process.cwd();
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
// Walk up directories to find config file
|
|
50
|
+
let currentDir = startDir;
|
|
51
|
+
let lastDir = '';
|
|
52
|
+
|
|
53
|
+
while (currentDir !== lastDir) {
|
|
54
|
+
try {
|
|
55
|
+
const configFilePath = await findConfigFile(currentDir);
|
|
56
|
+
// Found config file, load it
|
|
57
|
+
// Pass rootDir if provided, otherwise let loadConfig determine root from config file
|
|
58
|
+
return await loadConfig(configFilePath, rootDir);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
// Config file not found in this directory, try parent
|
|
61
|
+
lastDir = currentDir;
|
|
62
|
+
currentDir = path.dirname(currentDir);
|
|
63
|
+
|
|
64
|
+
// Stop if we've reached filesystem root
|
|
65
|
+
if (currentDir === lastDir) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`No configuration file found starting from ${startDir}.\n` +
|
|
68
|
+
`Please create hammer.config.yml (or a similar named file) in your project root.`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
// Should never reach here, but just in case
|
|
77
|
+
throw new Error(
|
|
78
|
+
`No configuration file found starting from ${startDir}.\n` +
|
|
79
|
+
`Please create hammer.config.yml (or a similar named file) in your project root.`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Express } from 'express';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Result returned by buildConfig
|
|
5
|
+
*/
|
|
6
|
+
export interface BuildConfigResult {
|
|
7
|
+
config: Record<string, unknown>;
|
|
8
|
+
app: Express;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Loads and applies the resolved project configuration to the application's runtime context.
|
|
13
|
+
*
|
|
14
|
+
* This function:
|
|
15
|
+
* 1. Retrieves the application instance from the provided function
|
|
16
|
+
* 2. Loads configuration via getConfig (auto-detects config file or env)
|
|
17
|
+
* 3. Attaches the loaded config to the runtime context via setContext
|
|
18
|
+
* 4. Returns both the config object and app instance for consumers
|
|
19
|
+
*
|
|
20
|
+
* @param getAppFunction - A function that returns the application instance (e.g., Express app)
|
|
21
|
+
* @returns Promise resolving to an object containing both the config and app
|
|
22
|
+
*/
|
|
23
|
+
export function buildConfig(getAppFunction: (config:any) => Express): Promise<BuildConfigResult>;
|