@noego/app 0.0.2 → 0.0.4
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 +3 -11
- package/package.json +15 -1
- package/src/args.js +1 -0
- package/src/build/bootstrap.js +115 -8
- package/src/build/context.js +2 -2
- package/src/build/helpers.js +10 -0
- package/src/build/openapi.js +9 -4
- package/src/build/runtime-manifest.js +22 -30
- package/src/build/server.js +34 -4
- package/src/cli.js +10 -5
- package/src/client.js +141 -0
- package/src/commands/build.js +66 -21
- package/src/commands/dev.js +624 -0
- package/src/commands/runtime-entry.ts +16 -0
- package/src/config.js +73 -553
- package/src/index.js +7 -0
- package/src/runtime/config-loader.js +203 -0
- package/src/runtime/html-parser.js +47 -0
- package/src/runtime/index.js +4 -0
- package/src/runtime/runtime.js +749 -0
- package/types/client.d.ts +23 -0
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { createBuildContext } from '../build/context.js';
|
|
5
|
+
import { findConfigFile } from '../runtime/index.js';
|
|
6
|
+
import { loadConfig } from '../runtime/config-loader.js';
|
|
7
|
+
|
|
8
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
|
|
10
|
+
export async function runDev(config) {
|
|
11
|
+
const context = createBuildContext(config);
|
|
12
|
+
const { logger } = context;
|
|
13
|
+
|
|
14
|
+
// Find config file
|
|
15
|
+
const configFilePath = await findConfigFile(config.rootDir, config.sources?.configFile);
|
|
16
|
+
const cliRoot = config.sources?.rootDir;
|
|
17
|
+
|
|
18
|
+
// Load config to get dev settings
|
|
19
|
+
const { config: yamlConfig, root: yamlRoot } = await loadConfig(configFilePath, cliRoot);
|
|
20
|
+
|
|
21
|
+
logger.info(`Loading config from ${path.relative(cliRoot || config.rootDir, configFilePath)}`);
|
|
22
|
+
|
|
23
|
+
// Create requireFromRoot using YAML root (user's project root)
|
|
24
|
+
const { createRequire } = await import('node:module');
|
|
25
|
+
const requireFromRoot = createRequire(path.join(yamlRoot, 'package.json'));
|
|
26
|
+
|
|
27
|
+
// Resolve tsx runner
|
|
28
|
+
let tsxExecutable;
|
|
29
|
+
let tsxArgs = [];
|
|
30
|
+
try {
|
|
31
|
+
// Try to find tsx binary in node_modules/.bin first
|
|
32
|
+
const fs = await import('node:fs/promises');
|
|
33
|
+
const tsxBin = path.join(yamlRoot, 'node_modules', '.bin', 'tsx');
|
|
34
|
+
if (await fs.access(tsxBin).then(() => true).catch(() => false)) {
|
|
35
|
+
tsxExecutable = tsxBin;
|
|
36
|
+
} else {
|
|
37
|
+
// Fallback to resolving tsx package.json to get package root, then find CLI
|
|
38
|
+
const tsxPackageJson = requireFromRoot.resolve('tsx/package.json');
|
|
39
|
+
const tsxPackageDir = path.dirname(tsxPackageJson);
|
|
40
|
+
const cliMjs = path.join(tsxPackageDir, 'dist', 'cli.mjs');
|
|
41
|
+
const cliJs = path.join(tsxPackageDir, 'dist', 'cli.js');
|
|
42
|
+
if (await fs.access(cliMjs).then(() => true).catch(() => false)) {
|
|
43
|
+
tsxExecutable = process.execPath;
|
|
44
|
+
tsxArgs = [cliMjs];
|
|
45
|
+
} else if (await fs.access(cliJs).then(() => true).catch(() => false)) {
|
|
46
|
+
tsxExecutable = process.execPath;
|
|
47
|
+
tsxArgs = [cliJs];
|
|
48
|
+
} else {
|
|
49
|
+
throw new Error('tsx CLI not found');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
} catch (err) {
|
|
53
|
+
logger.error(`Failed to resolve tsx: ${err.message}`);
|
|
54
|
+
throw new Error('tsx not found. Please install "tsx" as a dev dependency.');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// If Forge dev loader is available, attach it
|
|
58
|
+
// For Node.js loaders, we need to resolve the actual file path
|
|
59
|
+
const fs = await import('node:fs/promises');
|
|
60
|
+
let forgeLoaderPath;
|
|
61
|
+
|
|
62
|
+
// Try to find loader.mjs directly in node_modules
|
|
63
|
+
const forgeLoader = path.join(yamlRoot, 'node_modules', '@noego', 'forge', 'loader.mjs');
|
|
64
|
+
if (await fs.access(forgeLoader).then(() => true).catch(() => false)) {
|
|
65
|
+
forgeLoaderPath = forgeLoader;
|
|
66
|
+
} else {
|
|
67
|
+
// Try to find @noego/forge package and look for loader.mjs
|
|
68
|
+
try {
|
|
69
|
+
const forgePackageDir = path.dirname(requireFromRoot.resolve('@noego/forge'));
|
|
70
|
+
const loaderMjs = path.join(forgePackageDir, 'loader.mjs');
|
|
71
|
+
if (await fs.access(loaderMjs).then(() => true).catch(() => false)) {
|
|
72
|
+
forgeLoaderPath = loaderMjs;
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// Package not found or not accessible
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (forgeLoaderPath) {
|
|
80
|
+
tsxArgs.push('--loader', forgeLoaderPath);
|
|
81
|
+
logger.info(`Using @noego/forge/loader for Svelte file support: ${forgeLoaderPath}`);
|
|
82
|
+
} else {
|
|
83
|
+
// Fallback to package name resolution (tsx will resolve it)
|
|
84
|
+
tsxArgs.push('--loader', '@noego/forge/loader');
|
|
85
|
+
logger.info('Using @noego/forge/loader for Svelte file support (package name)');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Runtime entry file (TypeScript)
|
|
89
|
+
const runtimeEntryPath = path.resolve(__dirname, 'runtime-entry.ts');
|
|
90
|
+
tsxArgs.push(runtimeEntryPath);
|
|
91
|
+
|
|
92
|
+
// Set environment variables
|
|
93
|
+
const env = {
|
|
94
|
+
...process.env,
|
|
95
|
+
NODE_ENV: 'development',
|
|
96
|
+
NOEGO_CONFIG_FILE: configFilePath,
|
|
97
|
+
NOEGO_CLI_ROOT: cliRoot || config.rootDir
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// Check if split-serve mode
|
|
101
|
+
const splitServe = yamlConfig.dev?.splitServe || false;
|
|
102
|
+
|
|
103
|
+
if (splitServe) {
|
|
104
|
+
// Validate configuration for split-serve
|
|
105
|
+
if (!yamlConfig.server?.main) {
|
|
106
|
+
logger.error('splitServe requires server.main to be configured');
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
if (!yamlConfig.client?.main) {
|
|
110
|
+
logger.error('splitServe requires client.main to be configured');
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Split-serve mode: run router in main process, spawn frontend and backend
|
|
115
|
+
logger.info('Running in split-serve mode with router architecture...');
|
|
116
|
+
|
|
117
|
+
// Port assignment: router on main port, frontend on +1, backend on +2
|
|
118
|
+
const routerPort = yamlConfig.dev?.port || 3000;
|
|
119
|
+
const frontendPort = routerPort + 1;
|
|
120
|
+
const backendPort = routerPort + 2;
|
|
121
|
+
|
|
122
|
+
if (yamlConfig.dev?.watch) {
|
|
123
|
+
// With watching: monitor files and restart appropriate process
|
|
124
|
+
await runSplitServeWithWatch(context, tsxExecutable, tsxArgs, env, yamlConfig, configFilePath, routerPort, frontendPort, backendPort, logger);
|
|
125
|
+
} else {
|
|
126
|
+
// Without watching: run router and spawn processes
|
|
127
|
+
await runSplitServeNoWatch(tsxExecutable, tsxArgs, env, routerPort, frontendPort, backendPort, config.rootDir, logger);
|
|
128
|
+
}
|
|
129
|
+
} else if (yamlConfig.dev?.watch) {
|
|
130
|
+
// Non-split mode with watching
|
|
131
|
+
const watcher = await createWatcher(context, yamlConfig, configFilePath);
|
|
132
|
+
await runWithRestart(context, tsxExecutable, tsxArgs, env, watcher, logger);
|
|
133
|
+
} else {
|
|
134
|
+
// No watching: run server in background (don't wait for exit)
|
|
135
|
+
logger.info('Starting dev server...');
|
|
136
|
+
logger.debug(`Running: ${tsxExecutable} ${tsxArgs.join(' ')}`);
|
|
137
|
+
const child = spawn(tsxExecutable, tsxArgs, {
|
|
138
|
+
cwd: config.rootDir,
|
|
139
|
+
env,
|
|
140
|
+
stdio: 'inherit',
|
|
141
|
+
detached: false // Keep attached so signals propagate correctly
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Handle shutdown gracefully
|
|
145
|
+
const shutdown = () => {
|
|
146
|
+
if (child && !child.killed) {
|
|
147
|
+
child.kill('SIGTERM');
|
|
148
|
+
}
|
|
149
|
+
process.exit(0);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
child.on('exit', (code) => {
|
|
153
|
+
if (code !== null && code !== 0) {
|
|
154
|
+
logger.info(`Dev server exited with code ${code}`);
|
|
155
|
+
}
|
|
156
|
+
// Only exit if there was an error or explicit exit
|
|
157
|
+
if (code !== null) {
|
|
158
|
+
process.exit(code || 0);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
child.on('error', (err) => {
|
|
163
|
+
logger.error(`Failed to start dev server: ${err.message}`);
|
|
164
|
+
process.exit(1);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Set up signal handlers to clean up on termination
|
|
168
|
+
process.on('SIGINT', shutdown);
|
|
169
|
+
process.on('SIGTERM', shutdown);
|
|
170
|
+
|
|
171
|
+
// Keep process alive - don't return immediately
|
|
172
|
+
// The process will stay alive as long as child processes are running
|
|
173
|
+
// Event handlers above will handle cleanup
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Run split-serve without watching: spawn backend and frontend processes
|
|
179
|
+
*/
|
|
180
|
+
async function runSplitServeNoWatch(tsxExecutable, tsxArgs, baseEnv, routerPort, frontendPort, backendPort, rootDir, logger) {
|
|
181
|
+
logger.info(`Starting router on port ${routerPort}...`);
|
|
182
|
+
logger.info(`Starting frontend on port ${frontendPort}...`);
|
|
183
|
+
logger.info(`Starting backend on port ${backendPort}...`);
|
|
184
|
+
|
|
185
|
+
console.log('[dev.js] Port assignments:');
|
|
186
|
+
console.log(' routerPort:', routerPort);
|
|
187
|
+
console.log(' frontendPort:', frontendPort);
|
|
188
|
+
console.log(' backendPort:', backendPort);
|
|
189
|
+
console.log('[dev.js] baseEnv NOEGO_PORT:', baseEnv.NOEGO_PORT);
|
|
190
|
+
console.log('[dev.js] baseEnv NOEGO_BACKEND_PORT:', baseEnv.NOEGO_BACKEND_PORT);
|
|
191
|
+
|
|
192
|
+
// Spawn backend process
|
|
193
|
+
const backendEnv = {
|
|
194
|
+
...baseEnv,
|
|
195
|
+
NOEGO_SERVICE: 'backend',
|
|
196
|
+
NOEGO_PORT: String(backendPort)
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
console.log('[dev.js] Backend env NOEGO_PORT:', backendEnv.NOEGO_PORT);
|
|
200
|
+
|
|
201
|
+
const backendProc = spawn(tsxExecutable, tsxArgs, {
|
|
202
|
+
cwd: rootDir,
|
|
203
|
+
env: backendEnv,
|
|
204
|
+
stdio: 'inherit',
|
|
205
|
+
detached: false
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Spawn frontend process
|
|
209
|
+
const frontendEnv = {
|
|
210
|
+
...baseEnv,
|
|
211
|
+
NOEGO_SERVICE: 'frontend',
|
|
212
|
+
NOEGO_PORT: String(frontendPort),
|
|
213
|
+
// Frontend doesn't proxy to backend anymore - router handles that
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
console.log('[dev.js] Frontend env NOEGO_PORT:', frontendEnv.NOEGO_PORT);
|
|
217
|
+
|
|
218
|
+
const frontendProc = spawn(tsxExecutable, tsxArgs, {
|
|
219
|
+
cwd: rootDir,
|
|
220
|
+
env: frontendEnv,
|
|
221
|
+
stdio: 'inherit',
|
|
222
|
+
detached: false
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// Handle shutdown
|
|
226
|
+
const shutdown = (exitCode = 0) => {
|
|
227
|
+
logger.info('Shutting down split-serve processes...');
|
|
228
|
+
if (backendProc && !backendProc.killed) {
|
|
229
|
+
backendProc.kill('SIGTERM');
|
|
230
|
+
}
|
|
231
|
+
if (frontendProc && !frontendProc.killed) {
|
|
232
|
+
frontendProc.kill('SIGTERM');
|
|
233
|
+
}
|
|
234
|
+
process.exit(exitCode);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// If either spawned process crashes, kill both and exit
|
|
238
|
+
backendProc.on('exit', (code) => {
|
|
239
|
+
if (code !== null && code !== 0) {
|
|
240
|
+
logger.error(`Backend exited with code ${code}. Shutting down...`);
|
|
241
|
+
shutdown(code);
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
frontendProc.on('exit', (code) => {
|
|
246
|
+
if (code !== null && code !== 0) {
|
|
247
|
+
logger.error(`Frontend exited with code ${code}. Shutting down...`);
|
|
248
|
+
shutdown(code);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
backendProc.on('error', (err) => {
|
|
253
|
+
logger.error(`Backend error: ${err.message}. Shutting down...`);
|
|
254
|
+
shutdown(1);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
frontendProc.on('error', (err) => {
|
|
258
|
+
logger.error(`Frontend error: ${err.message}. Shutting down...`);
|
|
259
|
+
shutdown(1);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// Set up signal handlers to clean up on termination
|
|
263
|
+
process.on('SIGINT', () => shutdown(0));
|
|
264
|
+
process.on('SIGTERM', () => shutdown(0));
|
|
265
|
+
|
|
266
|
+
// Wait a moment for spawned processes to start
|
|
267
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
268
|
+
|
|
269
|
+
// Now run the router in the main process
|
|
270
|
+
// Set environment for router
|
|
271
|
+
const routerEnv = {
|
|
272
|
+
...baseEnv,
|
|
273
|
+
NOEGO_SERVICE: 'router',
|
|
274
|
+
NOEGO_PORT: String(routerPort),
|
|
275
|
+
NOEGO_FRONTEND_PORT: String(frontendPort),
|
|
276
|
+
NOEGO_BACKEND_PORT: String(backendPort)
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
// Merge router env into process.env
|
|
280
|
+
Object.assign(process.env, routerEnv);
|
|
281
|
+
|
|
282
|
+
// Execute the router runtime in the current process
|
|
283
|
+
logger.info('Starting router service in main process...');
|
|
284
|
+
const runtimeEntryPath = tsxArgs[tsxArgs.length - 1];
|
|
285
|
+
// Use dynamic import instead of require for ES module compatibility
|
|
286
|
+
await import(runtimeEntryPath);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Run split-serve with watching: spawn backend and frontend processes, restart on file changes
|
|
291
|
+
*/
|
|
292
|
+
async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv, yamlConfig, configFilePath, routerPort, frontendPort, backendPort, logger) {
|
|
293
|
+
const { createRequire } = await import('node:module');
|
|
294
|
+
const root = yamlConfig.root || path.dirname(configFilePath);
|
|
295
|
+
const requireFromRoot = createRequire(path.join(root, 'package.json'));
|
|
296
|
+
const chokidar = requireFromRoot('chokidar');
|
|
297
|
+
const picomatch = (await import('picomatch')).default;
|
|
298
|
+
|
|
299
|
+
const resolvePattern = (pattern) => {
|
|
300
|
+
if (path.isAbsolute(pattern)) return pattern;
|
|
301
|
+
return path.resolve(root, pattern);
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
// Collect watch patterns
|
|
305
|
+
const backendPatterns = [];
|
|
306
|
+
const frontendPatterns = [];
|
|
307
|
+
const sharedPatterns = [];
|
|
308
|
+
|
|
309
|
+
// App watch patterns (restart both)
|
|
310
|
+
if (yamlConfig.app?.watch) {
|
|
311
|
+
for (const pattern of yamlConfig.app.watch) {
|
|
312
|
+
sharedPatterns.push(resolvePattern(pattern));
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Server watch patterns (restart backend only)
|
|
317
|
+
if (yamlConfig.server?.watch) {
|
|
318
|
+
for (const pattern of yamlConfig.server.watch) {
|
|
319
|
+
backendPatterns.push(resolvePattern(pattern));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Client watch patterns (restart frontend only)
|
|
324
|
+
if (yamlConfig.client?.watch) {
|
|
325
|
+
for (const pattern of yamlConfig.client.watch) {
|
|
326
|
+
frontendPatterns.push(resolvePattern(pattern));
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Combine all patterns for chokidar
|
|
331
|
+
const allPatterns = [...sharedPatterns, ...backendPatterns, ...frontendPatterns];
|
|
332
|
+
|
|
333
|
+
if (allPatterns.length === 0) {
|
|
334
|
+
logger.warn('No watch patterns configured. Watching disabled.');
|
|
335
|
+
return await runSplitServeNoWatch(tsxExecutable, tsxArgs, baseEnv, routerPort, frontendPort, backendPort, context.config.rootDir, logger);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
logger.info('Watching for changes...');
|
|
339
|
+
logger.info(` Shared: ${sharedPatterns.length} patterns`);
|
|
340
|
+
logger.info(` Backend: ${backendPatterns.length} patterns`);
|
|
341
|
+
logger.info(` Frontend: ${frontendPatterns.length} patterns`);
|
|
342
|
+
|
|
343
|
+
// Create matchers
|
|
344
|
+
const sharedMatcher = sharedPatterns.length > 0 ? picomatch(sharedPatterns) : null;
|
|
345
|
+
const backendMatcher = backendPatterns.length > 0 ? picomatch(backendPatterns) : null;
|
|
346
|
+
const frontendMatcher = frontendPatterns.length > 0 ? picomatch(frontendPatterns) : null;
|
|
347
|
+
|
|
348
|
+
let backendProc = null;
|
|
349
|
+
let frontendProc = null;
|
|
350
|
+
let pending = false;
|
|
351
|
+
|
|
352
|
+
const startBackend = () => {
|
|
353
|
+
logger.info(`Starting backend on port ${backendPort}...`);
|
|
354
|
+
const backendEnv = {
|
|
355
|
+
...baseEnv,
|
|
356
|
+
NOEGO_SERVICE: 'backend',
|
|
357
|
+
NOEGO_PORT: String(backendPort)
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
backendProc = spawn(tsxExecutable, tsxArgs, {
|
|
361
|
+
cwd: context.config.rootDir,
|
|
362
|
+
env: backendEnv,
|
|
363
|
+
stdio: 'inherit',
|
|
364
|
+
detached: false
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
backendProc.on('exit', (code) => {
|
|
368
|
+
if (code !== null && code !== 0) {
|
|
369
|
+
logger.error(`Backend exited with code ${code}`);
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const startFrontend = () => {
|
|
375
|
+
logger.info(`Starting frontend on port ${frontendPort}...`);
|
|
376
|
+
const frontendEnv = {
|
|
377
|
+
...baseEnv,
|
|
378
|
+
NOEGO_SERVICE: 'frontend',
|
|
379
|
+
NOEGO_PORT: String(frontendPort)
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
frontendProc = spawn(tsxExecutable, tsxArgs, {
|
|
383
|
+
cwd: context.config.rootDir,
|
|
384
|
+
env: frontendEnv,
|
|
385
|
+
stdio: 'inherit',
|
|
386
|
+
detached: false
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
frontendProc.on('exit', (code) => {
|
|
390
|
+
if (code !== null && code !== 0) {
|
|
391
|
+
logger.error(`Frontend exited with code ${code}`);
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
const stopBackend = () =>
|
|
397
|
+
new Promise((resolve) => {
|
|
398
|
+
if (!backendProc) return resolve();
|
|
399
|
+
const to = setTimeout(resolve, 2000);
|
|
400
|
+
backendProc.once('exit', () => {
|
|
401
|
+
clearTimeout(to);
|
|
402
|
+
resolve();
|
|
403
|
+
});
|
|
404
|
+
try {
|
|
405
|
+
backendProc.kill('SIGTERM');
|
|
406
|
+
} catch {
|
|
407
|
+
resolve();
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
const stopFrontend = () =>
|
|
412
|
+
new Promise((resolve) => {
|
|
413
|
+
if (!frontendProc) return resolve();
|
|
414
|
+
const to = setTimeout(resolve, 2000);
|
|
415
|
+
frontendProc.once('exit', () => {
|
|
416
|
+
clearTimeout(to);
|
|
417
|
+
resolve();
|
|
418
|
+
});
|
|
419
|
+
try {
|
|
420
|
+
frontendProc.kill('SIGTERM');
|
|
421
|
+
} catch {
|
|
422
|
+
resolve();
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
const handleFileChange = async (reason, file) => {
|
|
427
|
+
if (pending) return;
|
|
428
|
+
pending = true;
|
|
429
|
+
|
|
430
|
+
const absPath = path.isAbsolute(file) ? file : path.resolve(root, file);
|
|
431
|
+
|
|
432
|
+
// Determine which service(s) to restart
|
|
433
|
+
let restartBackend = false;
|
|
434
|
+
let restartFrontend = false;
|
|
435
|
+
|
|
436
|
+
if (sharedMatcher && sharedMatcher(absPath)) {
|
|
437
|
+
// Shared file changed - restart both
|
|
438
|
+
logger.info(`Shared file changed (${reason}): ${file}`);
|
|
439
|
+
restartBackend = true;
|
|
440
|
+
restartFrontend = true;
|
|
441
|
+
} else if (backendMatcher && backendMatcher(absPath)) {
|
|
442
|
+
// Backend file changed
|
|
443
|
+
logger.info(`Backend file changed (${reason}): ${file}`);
|
|
444
|
+
restartBackend = true;
|
|
445
|
+
} else if (frontendMatcher && frontendMatcher(absPath)) {
|
|
446
|
+
// Frontend file changed
|
|
447
|
+
logger.info(`Frontend file changed (${reason}): ${file}`);
|
|
448
|
+
restartFrontend = true;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Restart appropriate service(s)
|
|
452
|
+
if (restartBackend) {
|
|
453
|
+
await stopBackend();
|
|
454
|
+
startBackend();
|
|
455
|
+
}
|
|
456
|
+
if (restartFrontend) {
|
|
457
|
+
await stopFrontend();
|
|
458
|
+
startFrontend();
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
pending = false;
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
// Create watcher
|
|
465
|
+
const watcher = chokidar.watch(allPatterns, {
|
|
466
|
+
ignoreInitial: true,
|
|
467
|
+
cwd: root
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
watcher
|
|
471
|
+
.on('add', (p) => handleFileChange('add', p))
|
|
472
|
+
.on('change', (p) => handleFileChange('change', p))
|
|
473
|
+
.on('unlink', (p) => handleFileChange('unlink', p));
|
|
474
|
+
|
|
475
|
+
// Start initial processes
|
|
476
|
+
startBackend();
|
|
477
|
+
startFrontend();
|
|
478
|
+
|
|
479
|
+
// Wait a moment for spawned processes to start
|
|
480
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
481
|
+
|
|
482
|
+
// Now run the router in the main process
|
|
483
|
+
const routerEnv = {
|
|
484
|
+
...baseEnv,
|
|
485
|
+
NOEGO_SERVICE: 'router',
|
|
486
|
+
NOEGO_PORT: String(routerPort),
|
|
487
|
+
NOEGO_FRONTEND_PORT: String(frontendPort),
|
|
488
|
+
NOEGO_BACKEND_PORT: String(backendPort)
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
// Merge router env into process.env
|
|
492
|
+
Object.assign(process.env, routerEnv);
|
|
493
|
+
|
|
494
|
+
// Execute the router runtime in the current process
|
|
495
|
+
logger.info('Starting router service in main process...');
|
|
496
|
+
const runtimeEntryPath = tsxArgs[tsxArgs.length - 1];
|
|
497
|
+
// Use dynamic import instead of require for ES module compatibility
|
|
498
|
+
await import(runtimeEntryPath);
|
|
499
|
+
|
|
500
|
+
// Handle shutdown
|
|
501
|
+
const shutdown = async () => {
|
|
502
|
+
logger.info('Shutting down split-serve processes...');
|
|
503
|
+
await stopBackend();
|
|
504
|
+
await stopFrontend();
|
|
505
|
+
await watcher.close();
|
|
506
|
+
process.exit(0);
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
process.on('SIGINT', shutdown);
|
|
510
|
+
process.on('SIGTERM', shutdown);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async function createWatcher(context, yamlConfig, configFilePath) {
|
|
514
|
+
const { logger } = context;
|
|
515
|
+
const { createRequire } = await import('node:module');
|
|
516
|
+
const root = yamlConfig.root || path.dirname(configFilePath);
|
|
517
|
+
const requireFromRoot = createRequire(path.join(root, 'package.json'));
|
|
518
|
+
const chokidar = requireFromRoot('chokidar');
|
|
519
|
+
|
|
520
|
+
const patterns = new Set();
|
|
521
|
+
|
|
522
|
+
const resolvePattern = (pattern) => {
|
|
523
|
+
if (path.isAbsolute(pattern)) return pattern;
|
|
524
|
+
return path.resolve(root, pattern);
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
// User-supplied watch paths
|
|
528
|
+
if (yamlConfig.dev?.watchPaths) {
|
|
529
|
+
for (const pattern of yamlConfig.dev.watchPaths) {
|
|
530
|
+
patterns.add(resolvePattern(pattern));
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Default watch patterns from config
|
|
535
|
+
if (yamlConfig.server?.controllers) {
|
|
536
|
+
patterns.add(resolvePattern(`${yamlConfig.server.controllers}/**/*.{ts,js}`));
|
|
537
|
+
}
|
|
538
|
+
if (yamlConfig.server?.middleware) {
|
|
539
|
+
patterns.add(resolvePattern(`${yamlConfig.server.middleware}/**/*.{ts,js}`));
|
|
540
|
+
}
|
|
541
|
+
if (yamlConfig.server?.openapi) {
|
|
542
|
+
patterns.add(resolvePattern(yamlConfig.server.openapi));
|
|
543
|
+
const openapiDir = path.dirname(resolvePattern(yamlConfig.server.openapi));
|
|
544
|
+
patterns.add(path.join(openapiDir, 'openapi/**/*.yaml'));
|
|
545
|
+
}
|
|
546
|
+
if (yamlConfig.client?.openapi) {
|
|
547
|
+
patterns.add(resolvePattern(yamlConfig.client.openapi));
|
|
548
|
+
const openapiDir = path.dirname(resolvePattern(yamlConfig.client.openapi));
|
|
549
|
+
patterns.add(path.join(openapiDir, 'openapi/**/*.yaml'));
|
|
550
|
+
}
|
|
551
|
+
if (yamlConfig.assets) {
|
|
552
|
+
for (const asset of yamlConfig.assets) {
|
|
553
|
+
if (asset.includes('*.sql')) {
|
|
554
|
+
patterns.add(resolvePattern(asset));
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
logger.info('Watching for changes to restart server...');
|
|
560
|
+
const watcher = chokidar.watch(Array.from(patterns), {
|
|
561
|
+
ignoreInitial: true,
|
|
562
|
+
cwd: root
|
|
563
|
+
});
|
|
564
|
+
return watcher;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
async function runWithRestart(context, tsxExecutable, tsxArgs, env, watcher, logger) {
|
|
568
|
+
let child = null;
|
|
569
|
+
|
|
570
|
+
const start = () => {
|
|
571
|
+
child = spawn(tsxExecutable, tsxArgs, {
|
|
572
|
+
cwd: context.config.rootDir,
|
|
573
|
+
env,
|
|
574
|
+
stdio: 'inherit'
|
|
575
|
+
});
|
|
576
|
+
child.on('exit', (code) => {
|
|
577
|
+
if (code !== null && code !== 0) {
|
|
578
|
+
logger.info(`Dev server exited with code ${code}`);
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
const stop = () =>
|
|
584
|
+
new Promise((resolve) => {
|
|
585
|
+
if (!child) return resolve();
|
|
586
|
+
const to = setTimeout(resolve, 2000);
|
|
587
|
+
child.once('exit', () => {
|
|
588
|
+
clearTimeout(to);
|
|
589
|
+
resolve();
|
|
590
|
+
});
|
|
591
|
+
try {
|
|
592
|
+
child.kill('SIGTERM');
|
|
593
|
+
} catch {
|
|
594
|
+
resolve();
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
let pending = false;
|
|
599
|
+
const scheduleRestart = async (reason, file) => {
|
|
600
|
+
if (pending) return;
|
|
601
|
+
pending = true;
|
|
602
|
+
logger.info(`Change detected (${reason}): ${file}. Restarting...`);
|
|
603
|
+
await stop();
|
|
604
|
+
start();
|
|
605
|
+
pending = false;
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
watcher
|
|
609
|
+
.on('add', (p) => scheduleRestart('add', p))
|
|
610
|
+
.on('change', (p) => scheduleRestart('change', p))
|
|
611
|
+
.on('unlink', (p) => scheduleRestart('unlink', p));
|
|
612
|
+
|
|
613
|
+
// Start initial server
|
|
614
|
+
start();
|
|
615
|
+
|
|
616
|
+
// Keep process alive until SIGINT/SIGTERM
|
|
617
|
+
const shutdown = async () => {
|
|
618
|
+
await stop();
|
|
619
|
+
await watcher.close();
|
|
620
|
+
process.exit(0);
|
|
621
|
+
};
|
|
622
|
+
process.on('SIGINT', shutdown);
|
|
623
|
+
process.on('SIGTERM', shutdown);
|
|
624
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { runRuntime } from '../runtime/runtime.js';
|
|
2
|
+
|
|
3
|
+
// This file is run via tsx, so it can import TypeScript files
|
|
4
|
+
const configFilePath = process.env.NOEGO_CONFIG_FILE;
|
|
5
|
+
const cliRoot = process.env.NOEGO_CLI_ROOT;
|
|
6
|
+
|
|
7
|
+
if (!configFilePath) {
|
|
8
|
+
console.error('NOEGO_CONFIG_FILE environment variable is required');
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
runRuntime(configFilePath, cliRoot || undefined).catch((error) => {
|
|
13
|
+
console.error('Runtime error:', error);
|
|
14
|
+
process.exit(1);
|
|
15
|
+
});
|
|
16
|
+
|