@noego/app 0.0.11 โ†’ 0.0.12

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.
@@ -0,0 +1,17 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(cat:*)",
5
+ "Bash(curl:*)",
6
+ "Bash(noego dev)",
7
+ "WebSearch",
8
+ "WebFetch(domain:mattkentzia.com)",
9
+ "Bash(node --test:*)",
10
+ "WebFetch(domain:svelte.dev)",
11
+ "WebFetch(domain:github.com)",
12
+ "Bash(tree:*)"
13
+ ],
14
+ "deny": [],
15
+ "ask": []
16
+ }
17
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noego/app",
3
- "version": "0.0.11",
3
+ "version": "0.0.12",
4
4
  "description": "Production build tool for Dinner/Forge apps.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/args.js CHANGED
@@ -27,11 +27,17 @@ const FLAG_MAP = new Map([
27
27
  ['mode', 'mode'],
28
28
  ['verbose', 'verbose'],
29
29
  ['help', 'help'],
30
- ['version', 'version']
30
+ ['version', 'version'],
31
+ ['ci-server', 'testServer'],
32
+ ['ci-test', 'testCommand'],
33
+ ['ci-status', 'testStatus'],
34
+ ['ci-port', 'testPort'],
35
+ ['ci-timeout', 'testTimeout'],
36
+ ['ci-visible', 'testVisible']
31
37
  ]);
32
38
 
33
39
  const MULTI_VALUE_FLAGS = new Set(['sqlGlob', 'assets', 'clientExclude', 'watchPath']);
34
- const BOOLEAN_FLAGS = new Set(['watch', 'splitServe', 'verbose']);
40
+ const BOOLEAN_FLAGS = new Set(['watch', 'splitServe', 'verbose', 'testVisible']);
35
41
 
36
42
  export function parseCliArgs(argv) {
37
43
  const result = {
@@ -129,6 +135,15 @@ export function printHelpAndExit({ stdout = process.stdout } = {}) {
129
135
  app dev [options]
130
136
  app serve [options]
131
137
  app preview [options]
138
+ app ci [options]
139
+
140
+ CI Testing Options:
141
+ --ci-server <cmd> Server start command (default: npm run dev)
142
+ --ci-test <cmd> Test command (default: npm run test:live)
143
+ --ci-status <path> Health check endpoint (default: /api/status)
144
+ --ci-port <number> Server port (default: random 4000-8000)
145
+ --ci-timeout <seconds> Health check timeout (default: 60)
146
+ --ci-visible Run browser in visible mode
132
147
 
133
148
  Options (shared):
134
149
  --root <dir> Project root (default: .)
package/src/cli.js CHANGED
@@ -8,6 +8,7 @@ import { runBuild } from './commands/build.js';
8
8
  import { runServe } from './commands/serve.js';
9
9
  import { runPreview } from './commands/preview.js';
10
10
  import { runDev } from './commands/dev.js';
11
+ import { runTestLive } from './commands/test-live.js';
11
12
 
12
13
  export async function runCli(argv = process.argv.slice(2)) {
13
14
  try {
@@ -43,6 +44,11 @@ export async function runCli(argv = process.argv.slice(2)) {
43
44
  await runPreview(config);
44
45
  break;
45
46
  }
47
+ case 'ci': {
48
+ const config = await loadBuildConfig(options, { cwd: process.cwd() });
49
+ await runTestLive(config);
50
+ break;
51
+ }
46
52
  default:
47
53
  throw cliError(`Unknown command "${command}"`);
48
54
  }
@@ -449,52 +449,115 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
449
449
  let frontendRestartCount = 0;
450
450
  let isShuttingDown = false;
451
451
 
452
+ // Crash restart tracking
453
+ const MAX_CRASH_RESTARTS = 3;
454
+ const CRASH_RESTART_DELAY = 2000; // 2 seconds between crash restarts
455
+ const STABILITY_THRESHOLD = 30000; // Reset crash counter if running 30+ seconds
456
+ let backendCrashRestarts = 0;
457
+ let frontendCrashRestarts = 0;
458
+ let backendStartTime = 0;
459
+ let frontendStartTime = 0;
460
+
452
461
  const startBackend = () => {
453
462
  backendRestartCount++;
463
+ backendStartTime = Date.now();
454
464
  logger.info(`๐Ÿš€ [RESTART #${backendRestartCount}] Starting backend on port ${backendPort}...`);
455
465
  const backendEnv = {
456
466
  ...baseEnv,
457
467
  NOEGO_SERVICE: 'backend',
458
468
  NOEGO_PORT: String(backendPort)
459
469
  };
460
-
470
+
461
471
  backendProc = spawn(tsxExecutable, tsxArgs, {
462
472
  cwd: context.config.rootDir,
463
473
  env: backendEnv,
464
474
  stdio: 'inherit',
465
475
  detached: false
466
476
  });
467
-
468
- backendProc.on('exit', (code) => {
477
+
478
+ backendProc.on('exit', (code, signal) => {
479
+ if (isShuttingDown) return;
480
+
481
+ // Check if process was stable (ran for a while) - reset crash counter
482
+ const runDuration = Date.now() - backendStartTime;
483
+ if (runDuration > STABILITY_THRESHOLD) {
484
+ backendCrashRestarts = 0;
485
+ }
486
+
469
487
  if (code !== null && code !== 0) {
470
- logger.error(`Backend exited with code ${code}`);
488
+ logger.error(`[BACKEND] Exited with code ${code}, signal ${signal}`);
489
+
490
+ // Auto-restart on crash if under limit
491
+ if (backendCrashRestarts < MAX_CRASH_RESTARTS) {
492
+ backendCrashRestarts++;
493
+ logger.warn(`[BACKEND] Crash detected. Auto-restart ${backendCrashRestarts}/${MAX_CRASH_RESTARTS} in ${CRASH_RESTART_DELAY}ms...`);
494
+ setTimeout(() => {
495
+ if (!isShuttingDown) {
496
+ startBackend();
497
+ }
498
+ }, CRASH_RESTART_DELAY);
499
+ } else {
500
+ logger.error(`[BACKEND] Exceeded max crash restarts (${MAX_CRASH_RESTARTS}). Shutting down...`);
501
+ shutdown('backend-exceeded-restarts', 1, 'backend-crash');
502
+ }
471
503
  }
472
504
  });
505
+
506
+ backendProc.on('error', (err) => {
507
+ logger.error(`[BACKEND] Spawn error: ${err.message}`);
508
+ });
473
509
  };
474
-
510
+
475
511
  const startFrontend = () => {
476
512
  frontendRestartCount++;
513
+ frontendStartTime = Date.now();
477
514
  logger.info(`๐Ÿš€ [RESTART #${frontendRestartCount}] Starting frontend on port ${frontendPort}...`);
478
515
  const frontendEnv = {
479
516
  ...baseEnv,
480
517
  NOEGO_SERVICE: 'frontend',
481
518
  NOEGO_PORT: String(frontendPort)
482
519
  };
483
-
520
+
484
521
  frontendProc = spawn(tsxExecutable, tsxArgs, {
485
522
  cwd: context.config.rootDir,
486
523
  env: frontendEnv,
487
524
  stdio: 'inherit',
488
525
  detached: false
489
526
  });
490
-
491
- frontendProc.on('exit', (code) => {
527
+
528
+ frontendProc.on('exit', (code, signal) => {
529
+ if (isShuttingDown) return;
530
+
531
+ // Check if process was stable (ran for a while) - reset crash counter
532
+ const runDuration = Date.now() - frontendStartTime;
533
+ if (runDuration > STABILITY_THRESHOLD) {
534
+ frontendCrashRestarts = 0;
535
+ }
536
+
492
537
  if (code !== null && code !== 0) {
493
- logger.error(`Frontend exited with code ${code}`);
538
+ logger.error(`[FRONTEND] Exited with code ${code}, signal ${signal}`);
539
+
540
+ // Auto-restart on crash if under limit
541
+ if (frontendCrashRestarts < MAX_CRASH_RESTARTS) {
542
+ frontendCrashRestarts++;
543
+ logger.warn(`[FRONTEND] Crash detected. Auto-restart ${frontendCrashRestarts}/${MAX_CRASH_RESTARTS} in ${CRASH_RESTART_DELAY}ms...`);
544
+ setTimeout(() => {
545
+ if (!isShuttingDown) {
546
+ startFrontend();
547
+ }
548
+ }, CRASH_RESTART_DELAY);
549
+ } else {
550
+ logger.error(`[FRONTEND] Exceeded max crash restarts (${MAX_CRASH_RESTARTS}). Shutting down...`);
551
+ shutdown('frontend-exceeded-restarts', 1, 'frontend-crash');
552
+ }
494
553
  }
495
554
  });
555
+
556
+ frontendProc.on('error', (err) => {
557
+ logger.error(`[FRONTEND] Spawn error: ${err.message}`);
558
+ });
496
559
  };
497
-
560
+
498
561
  const stopBackend = () =>
499
562
  new Promise((resolve) => {
500
563
  if (!backendProc) return resolve();
@@ -549,10 +612,11 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
549
612
  }
550
613
  });
551
614
 
552
- async function shutdown(signal = 'SIGTERM', exitCode = 0) {
615
+ async function shutdown(signal = 'SIGTERM', exitCode = 0, source = 'unknown') {
553
616
  if (isShuttingDown) return;
554
617
  isShuttingDown = true;
555
618
 
619
+ logger.info(`[SHUTDOWN] source=${source}, signal=${signal}, exitCode=${exitCode}`);
556
620
  logger.info(`Shutting down split-serve processes (signal: ${signal})...`);
557
621
 
558
622
  try {
@@ -577,7 +641,7 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
577
641
  }
578
642
  logger.error('Watcher error:', error);
579
643
  logger.error('File watching failed; shutting down dev server.');
580
- await shutdown('watcherError', 1);
644
+ await shutdown('watcherError', 1, 'watcher-error');
581
645
  }
582
646
 
583
647
  const handleFileChange = async (reason, file) => {
@@ -699,8 +763,8 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
699
763
  await import(runtimeEntryPath);
700
764
 
701
765
  // Handle graceful shutdown signals
702
- process.on('SIGINT', () => shutdown('SIGINT', 0));
703
- process.on('SIGTERM', () => shutdown('SIGTERM', 0));
766
+ process.on('SIGINT', () => shutdown('SIGINT', 0, 'signal-handler'));
767
+ process.on('SIGTERM', () => shutdown('SIGTERM', 0, 'signal-handler'));
704
768
 
705
769
  // Handle process exit - this ensures children are killed even if parent crashes
706
770
  process.on('exit', () => {
@@ -717,13 +781,13 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
717
781
  // Handle uncaught exceptions
718
782
  process.on('uncaughtException', async (error) => {
719
783
  logger.error('Uncaught exception:', error);
720
- await shutdown('uncaughtException', 1);
784
+ await shutdown('uncaughtException', 1, 'uncaught-exception');
721
785
  });
722
786
 
723
787
  // Handle unhandled promise rejections
724
788
  process.on('unhandledRejection', async (reason, promise) => {
725
789
  logger.error('Unhandled rejection at:', promise, 'reason:', reason);
726
- await shutdown('unhandledRejection', 1);
790
+ await shutdown('unhandledRejection', 1, 'unhandled-rejection');
727
791
  });
728
792
 
729
793
  // Keep process alive for file watching
@@ -0,0 +1,187 @@
1
+ import { spawn } from 'child_process';
2
+ import { createBuildContext } from '../build/context.js';
3
+
4
+ /**
5
+ * Polls health check endpoint until server is ready
6
+ */
7
+ async function waitForServer(port, statusPath, timeoutSec, logger) {
8
+ const url = `http://localhost:${port}${statusPath}`;
9
+ const maxAttempts = timeoutSec;
10
+
11
+ logger.info(`Waiting for server at ${url}...`);
12
+
13
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
14
+ try {
15
+ const response = await fetch(url, { signal: AbortSignal.timeout(2000) });
16
+
17
+ if (response.ok) {
18
+ const data = await response.json();
19
+
20
+ // For /status/deep - check both status and database
21
+ if (statusPath.includes('deep')) {
22
+ if (data.status === 'OK' && data.database === 'connected') {
23
+ logger.info('Server is ready!');
24
+ await new Promise(r => setTimeout(r, 2000)); // Extra 2s for full init
25
+ return true;
26
+ }
27
+ } else if (data.status === 'OK' || data.status === 'ok') {
28
+ logger.info('Server is ready!');
29
+ await new Promise(r => setTimeout(r, 2000));
30
+ return true;
31
+ }
32
+ }
33
+ } catch (err) {
34
+ // Connection refused or timeout - server not ready yet
35
+ }
36
+
37
+ if (attempt < maxAttempts) {
38
+ await new Promise(r => setTimeout(r, 1000)); // Wait 1s between attempts
39
+ if (attempt % 5 === 0) {
40
+ logger.info(`Still waiting... (${attempt}/${maxAttempts})`);
41
+ }
42
+ }
43
+ }
44
+
45
+ return false;
46
+ }
47
+
48
+ /**
49
+ * Spawns a command and returns a process handle
50
+ */
51
+ function spawnCommand(command, env, logger) {
52
+ logger.info(`Spawning: ${command}`);
53
+
54
+ const child = spawn(command, {
55
+ shell: true,
56
+ stdio: 'inherit',
57
+ env: { ...process.env, ...env },
58
+ detached: process.platform !== 'win32', // Process group for Unix
59
+ });
60
+
61
+ return child;
62
+ }
63
+
64
+ /**
65
+ * Kills process tree (cross-platform)
66
+ */
67
+ function killProcessTree(pid, logger) {
68
+ try {
69
+ if (process.platform === 'win32') {
70
+ spawn('taskkill', ['/pid', pid.toString(), '/T', '/F'], { stdio: 'ignore' });
71
+ } else {
72
+ process.kill(-pid, 'SIGKILL'); // Negative PID kills process group
73
+ }
74
+ } catch (err) {
75
+ logger.debug(`Process ${pid} already terminated`);
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Main test:live command implementation
81
+ */
82
+ export async function runTestLive(config) {
83
+ const context = createBuildContext(config);
84
+ const { logger } = context;
85
+
86
+ // Parse options - no defaults, must be specified
87
+ const serverCmd = config.testServer;
88
+ const testCmd = config.testCommand;
89
+ const statusPath = config.testStatus;
90
+ const port = config.testPort || Math.floor(Math.random() * 4000) + 4000;
91
+ const timeout = config.testTimeout || 60;
92
+ const visible = config.testVisible || false;
93
+
94
+ // Validate required options
95
+ if (!serverCmd) {
96
+ logger.error('โŒ Missing required option: --ci-server');
97
+ logger.info('Example: noego ci --ci-server "npm run dev" --ci-test "npm run test:live" --ci-status "/api/status"');
98
+ process.exit(1);
99
+ }
100
+
101
+ if (!testCmd) {
102
+ logger.error('โŒ Missing required option: --ci-test');
103
+ logger.info('Example: noego ci --ci-server "npm run dev" --ci-test "npm run test:live" --ci-status "/api/status"');
104
+ process.exit(1);
105
+ }
106
+
107
+ if (!statusPath) {
108
+ logger.error('โŒ Missing required option: --ci-status');
109
+ logger.info('Example: noego ci --ci-server "npm run dev" --ci-test "npm run test:live" --ci-status "/api/status"');
110
+ process.exit(1);
111
+ }
112
+
113
+ logger.info('๐Ÿงช Live Test Runner');
114
+ logger.info(` Server: ${serverCmd}`);
115
+ logger.info(` Tests: ${testCmd}`);
116
+ logger.info(` Port: ${port}`);
117
+ logger.info(` Health: ${statusPath}`);
118
+
119
+ let serverProcess = null;
120
+ let exitCode = 1;
121
+
122
+ const cleanup = async () => {
123
+ if (serverProcess && !serverProcess.killed) {
124
+ logger.info('Shutting down server...');
125
+ killProcessTree(serverProcess.pid, logger);
126
+ await new Promise(r => setTimeout(r, 2000));
127
+ }
128
+ };
129
+
130
+ // Handle interrupts
131
+ process.on('SIGINT', async () => {
132
+ logger.info('Interrupted by user');
133
+ await cleanup();
134
+ process.exit(130);
135
+ });
136
+
137
+ process.on('SIGTERM', async () => {
138
+ await cleanup();
139
+ process.exit(143);
140
+ });
141
+
142
+ try {
143
+ // Start server
144
+ serverProcess = spawnCommand(serverCmd, { PORT: port.toString() }, logger);
145
+
146
+ // Wait for health check
147
+ logger.info('Waiting for server to be ready...');
148
+ const ready = await waitForServer(port, statusPath, timeout, logger);
149
+
150
+ if (!ready) {
151
+ logger.error(`โŒ Server failed to become healthy within ${timeout} seconds`);
152
+ await cleanup();
153
+ process.exit(1);
154
+ }
155
+
156
+ // Run tests
157
+ logger.info('โœ… Server ready, running tests...');
158
+ const testEnv = {
159
+ PORT: port.toString(),
160
+ HEADLESS: visible ? 'false' : 'true',
161
+ };
162
+
163
+ const testProcess = spawnCommand(testCmd, testEnv, logger);
164
+
165
+ // Wait for tests to complete
166
+ exitCode = await new Promise((resolve) => {
167
+ testProcess.on('exit', (code) => resolve(code || 0));
168
+ });
169
+
170
+ if (exitCode === 0) {
171
+ logger.info('โœ… Tests passed!');
172
+ } else {
173
+ logger.error(`โŒ Tests failed with exit code ${exitCode}`);
174
+ }
175
+
176
+ } catch (err) {
177
+ logger.error('Test runner failed:', err.message);
178
+ if (err.stack) {
179
+ logger.debug(err.stack);
180
+ }
181
+ exitCode = 1;
182
+
183
+ } finally {
184
+ await cleanup();
185
+ process.exit(exitCode);
186
+ }
187
+ }
package/src/config.js CHANGED
@@ -71,6 +71,13 @@ export async function loadBuildConfig(cliOptions = {}, { cwd = process.cwd() } =
71
71
  ...config,
72
72
  rootDir: config.root,
73
73
  verbose: cliOptions.verbose || false,
74
+ // CI test options from CLI
75
+ testServer: cliOptions.testServer,
76
+ testCommand: cliOptions.testCommand,
77
+ testStatus: cliOptions.testStatus,
78
+ testPort: cliOptions.testPort,
79
+ testTimeout: cliOptions.testTimeout,
80
+ testVisible: cliOptions.testVisible,
74
81
  layout,
75
82
  server: config.server ? {
76
83
  rootDir: config.server.main_abs ? path.dirname(config.server.main_abs) : config.root,
@@ -97,12 +97,15 @@ async function setupProxyFirst(app, backendPort, config) {
97
97
  return resolve(cached.canHandle);
98
98
  }
99
99
 
100
+ // Configurable timeout - default 500ms is enough for GC pauses and startup
101
+ const checkTimeout = parseInt(process.env.NOEGO_BACKEND_CHECK_TIMEOUT) || 500;
102
+
100
103
  const options = {
101
104
  hostname: 'localhost',
102
105
  port: backendPort,
103
106
  path: pathname,
104
107
  method: 'GET', // Use GET instead of HEAD since some backends don't support HEAD
105
- timeout: 50, // Very short timeout for dev mode
108
+ timeout: checkTimeout,
106
109
  headers: {
107
110
  'X-Proxy-Check': 'true' // Indicate this is just a check
108
111
  }