@noego/app 0.0.3 → 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.
@@ -0,0 +1,749 @@
1
+ import path from 'node:path';
2
+ import http from 'node:http';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { createRequire } from 'node:module';
5
+ import { loadConfig } from './config-loader.js';
6
+ import { setContext } from '../client.js';
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+
10
+ function toFileUrl(filePath) {
11
+ return `file://${filePath}`;
12
+ }
13
+
14
+ function parseCookieHeader(header) {
15
+ const cookies = Object.create(null);
16
+ if (typeof header !== 'string' || header.length === 0) {
17
+ return cookies;
18
+ }
19
+
20
+ const segments = header.split(';');
21
+ for (const segment of segments) {
22
+ if (!segment) continue;
23
+ const separatorIndex = segment.indexOf('=');
24
+ if (separatorIndex === -1) continue;
25
+
26
+ const name = segment.slice(0, separatorIndex).trim();
27
+ if (!name) continue;
28
+
29
+ const rawValue = segment.slice(separatorIndex + 1).trim();
30
+ if (!rawValue) {
31
+ cookies[name] = '';
32
+ continue;
33
+ }
34
+
35
+ try {
36
+ cookies[name] = decodeURIComponent(rawValue);
37
+ } catch {
38
+ cookies[name] = rawValue;
39
+ }
40
+ }
41
+
42
+ return cookies;
43
+ }
44
+
45
+ function attachCookiePolyfill(app) {
46
+ if (!app || typeof app.use !== 'function') {
47
+ return;
48
+ }
49
+
50
+ app.use((req, res, next) => {
51
+ try {
52
+ if (req && (req.cookies == null) && req.headers && typeof req.headers.cookie === 'string') {
53
+ req.cookies = parseCookieHeader(req.headers.cookie);
54
+ }
55
+ } catch {
56
+ // Ignore cookie parsing errors; leave req.cookies undefined.
57
+ }
58
+ next();
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Setup proxy middleware that runs BEFORE Vite/Forge
64
+ * This dynamically checks if the backend can handle each route
65
+ */
66
+ async function setupProxyFirst(app, backendPort, config) {
67
+ try {
68
+ const { createRequire } = await import('node:module');
69
+ const requireFromRoot = createRequire(path.join(config.root, 'package.json'));
70
+ const http = await import('http');
71
+
72
+ // Cache backend route availability to avoid repeated HEAD requests
73
+ const routeCache = new Map();
74
+ const CACHE_TTL = 60000; // Cache for 60 seconds in dev mode
75
+
76
+ // Patterns that should NOT be proxied (frontend assets)
77
+ const isFrontendAsset = (pathname) => {
78
+ return (
79
+ pathname.startsWith('/@') || // Vite internals
80
+ pathname.startsWith('/node_modules') ||
81
+ pathname.startsWith('/src') ||
82
+ pathname.includes('.hot-update.') ||
83
+ // Static file extensions
84
+ /\.(js|mjs|jsx|ts|tsx|css|scss|sass|less|json|xml|html|htm|vue|svelte)$/.test(pathname) ||
85
+ /\.(png|jpg|jpeg|gif|svg|ico|webp|avif)$/.test(pathname) ||
86
+ /\.(woff|woff2|ttf|otf|eot)$/.test(pathname) ||
87
+ /\.(mp4|webm|ogg|mp3|wav|flac|aac)$/.test(pathname) ||
88
+ /\.(pdf|doc|docx|xls|xlsx|ppt|pptx|zip|tar|gz)$/.test(pathname)
89
+ );
90
+ };
91
+
92
+ // Check if backend can handle a route by making a HEAD request
93
+ const checkBackend = (pathname) => new Promise((resolve) => {
94
+ // Check cache first
95
+ const cached = routeCache.get(pathname);
96
+ if (cached && (Date.now() - cached.timestamp < CACHE_TTL)) {
97
+ return resolve(cached.canHandle);
98
+ }
99
+
100
+ const options = {
101
+ hostname: 'localhost',
102
+ port: backendPort,
103
+ path: pathname,
104
+ method: 'GET', // Use GET instead of HEAD since some backends don't support HEAD
105
+ timeout: 50, // Very short timeout for dev mode
106
+ headers: {
107
+ 'X-Proxy-Check': 'true' // Indicate this is just a check
108
+ }
109
+ };
110
+
111
+ const req = http.request(options, (res) => {
112
+ // If backend responds with anything other than 404, it can handle it
113
+ const canHandle = res.statusCode !== 404;
114
+ routeCache.set(pathname, { canHandle, timestamp: Date.now() });
115
+
116
+ // Immediately destroy the response to avoid consuming data
117
+ res.destroy();
118
+ resolve(canHandle);
119
+ });
120
+
121
+ req.on('error', () => {
122
+ // Backend not available or error
123
+ routeCache.set(pathname, { canHandle: false, timestamp: Date.now() });
124
+ resolve(false);
125
+ });
126
+
127
+ req.on('timeout', () => {
128
+ req.destroy();
129
+ // On timeout, assume backend can't handle it
130
+ routeCache.set(pathname, { canHandle: false, timestamp: Date.now() });
131
+ resolve(false);
132
+ });
133
+
134
+ req.end();
135
+ });
136
+
137
+ try {
138
+ const { createProxyMiddleware } = requireFromRoot('http-proxy-middleware');
139
+
140
+ // Create proxy middleware
141
+ const proxyMiddleware = createProxyMiddleware({
142
+ target: `http://localhost:${backendPort}`,
143
+ changeOrigin: true,
144
+ ws: true,
145
+ logLevel: 'silent', // We'll do our own logging
146
+ onProxyReq: (proxyReq, req, res) => {
147
+ if (config.mode !== 'production') {
148
+ console.log(`[proxy] ${req.method} ${req.url} -> backend:${backendPort}`);
149
+ }
150
+ }
151
+ });
152
+
153
+ // Install selective proxy middleware that checks backend first
154
+ app.use(async (req, res, next) => {
155
+ const pathname = req.path || req.url || '';
156
+
157
+ // Skip if it's a frontend asset
158
+ if (isFrontendAsset(pathname)) {
159
+ return next();
160
+ }
161
+
162
+ // For non-asset paths, check if backend can handle it
163
+ try {
164
+ const backendCanHandle = await checkBackend(pathname);
165
+
166
+ if (backendCanHandle) {
167
+ // Backend can handle this route, proxy it
168
+ proxyMiddleware(req, res, next);
169
+ } else {
170
+ // Backend returned 404 or unavailable, let Vite/Forge handle it
171
+ next();
172
+ }
173
+ } catch (err) {
174
+ // On any error, fall back to Vite/Forge
175
+ console.error('[proxy] Error checking backend:', err);
176
+ next();
177
+ }
178
+ });
179
+
180
+ console.log(`[app] Dynamic proxy configured: checking backend at http://localhost:${backendPort} for non-asset routes`);
181
+ return;
182
+ } catch {
183
+ console.warn('http-proxy-middleware not available, skipping proxy setup');
184
+ }
185
+ } catch (error) {
186
+ console.error('[app] Failed to setup proxy:', error);
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Setup Express proxy middleware to forward API requests to backend
192
+ * (LEGACY - kept for backward compatibility)
193
+ */
194
+ async function setupProxy(app, backendPort, config) {
195
+ try {
196
+ const { createRequire } = await import('node:module');
197
+ const requireFromRoot = createRequire(path.join(config.root, 'package.json'));
198
+
199
+ // Skip proxy for known frontend assets
200
+ const skipProxy = (req) => {
201
+ const pathname = req.path || req.url || '';
202
+ if (!pathname) return true;
203
+ // Skip Vite internal routes and static assets
204
+ return (
205
+ pathname.startsWith('/@') ||
206
+ pathname.startsWith('/node_modules') ||
207
+ pathname.startsWith('/src') ||
208
+ pathname.includes('.hot-update.') ||
209
+ pathname.includes('.js') ||
210
+ pathname.includes('.css') ||
211
+ pathname.includes('.svg') ||
212
+ pathname.includes('.png') ||
213
+ pathname.includes('.jpg') ||
214
+ pathname.includes('.jpeg') ||
215
+ pathname.includes('.gif') ||
216
+ pathname.includes('.ico')
217
+ );
218
+ };
219
+
220
+ const installFallback = (handler) => {
221
+ // Install middleware that runs AFTER Vite
222
+ // This will only be reached if Vite doesn't handle the request
223
+ app.use((req, res, next) => {
224
+ if (skipProxy(req)) {
225
+ return next();
226
+ }
227
+ // If we reach here and response hasn't been sent, proxy to backend
228
+ if (!res.headersSent) {
229
+ return handler(req, res, next);
230
+ }
231
+ next();
232
+ });
233
+
234
+ // Also add a 404 handler as the very last middleware
235
+ app.use((req, res, next) => {
236
+ if (!res.headersSent && !skipProxy(req)) {
237
+ // No route handled this, proxy to backend
238
+ return handler(req, res, next);
239
+ }
240
+ // If still not handled, send 404
241
+ if (!res.headersSent) {
242
+ res.status(404).send('Not Found');
243
+ }
244
+ });
245
+ };
246
+
247
+ // Try to use http-proxy-middleware if available
248
+ try {
249
+ const { createProxyMiddleware } = requireFromRoot('http-proxy-middleware');
250
+ const proxyMiddleware = createProxyMiddleware({
251
+ target: `http://localhost:${backendPort}`,
252
+ changeOrigin: true,
253
+ ws: true,
254
+ logLevel: config.mode === 'production' ? 'error' : 'info',
255
+ onProxyReq: (proxyReq, req, res) => {
256
+ if (config.mode !== 'production') {
257
+ console.log(`[proxy] ${req.method} ${req.url} -> backend:${backendPort}`);
258
+ }
259
+ }
260
+ });
261
+ installFallback(proxyMiddleware);
262
+ console.log(`[app] Proxy fallback configured: unhandled requests will be forwarded to http://localhost:${backendPort}`);
263
+ return;
264
+ } catch {
265
+ // Fallback to express-http-proxy if http-proxy-middleware not available
266
+ }
267
+
268
+ try {
269
+ const proxy = requireFromRoot('express-http-proxy');
270
+ const proxyMiddleware = proxy(`http://localhost:${backendPort}`, {
271
+ proxyReqPathResolver: (req) => req.url,
272
+ proxyReqOptDecorator: (proxyReqOpts, req) => {
273
+ proxyReqOpts.headers = proxyReqOpts.headers || {};
274
+ proxyReqOpts.headers['x-forwarded-host'] = req.headers.host;
275
+ return proxyReqOpts;
276
+ }
277
+ });
278
+ installFallback(proxyMiddleware);
279
+ return;
280
+ } catch {
281
+ console.warn('Proxy dependencies not found; falling back to built-in proxy handler.');
282
+ }
283
+
284
+ const nativeProxy = (req, res, next) => {
285
+ const targetPath = req.originalUrl || req.url || '/';
286
+ if (config.mode !== 'production') {
287
+ console.log(`[app][proxy] Forwarding ${req.method} ${targetPath} -> http://localhost:${backendPort}`);
288
+ }
289
+ const requestHeaders = {
290
+ ...req.headers,
291
+ host: `localhost:${backendPort}`,
292
+ 'x-forwarded-host': req.headers.host,
293
+ 'x-forwarded-proto': req.protocol || 'http'
294
+ };
295
+
296
+ const backendRequest = http.request(
297
+ {
298
+ hostname: '127.0.0.1',
299
+ port: backendPort,
300
+ path: targetPath,
301
+ method: req.method,
302
+ headers: requestHeaders
303
+ },
304
+ (backendResponse) => {
305
+ res.status(backendResponse.statusCode || 500);
306
+ for (const [key, value] of Object.entries(backendResponse.headers)) {
307
+ if (value !== undefined) {
308
+ res.setHeader(key, value);
309
+ }
310
+ }
311
+ backendResponse.on('error', (err) => {
312
+ console.error('[app][proxy] Response error:', err.message);
313
+ if (!res.headersSent) {
314
+ res.status(502).end();
315
+ }
316
+ });
317
+ backendResponse.pipe(res);
318
+ }
319
+ );
320
+
321
+ backendRequest.on('error', (err) => {
322
+ console.error('[app][proxy] Request error:', err.message);
323
+ if (!res.headersSent) {
324
+ res.status(502).end();
325
+ }
326
+ next();
327
+ });
328
+
329
+ req.on('aborted', () => {
330
+ backendRequest.destroy();
331
+ });
332
+
333
+ if (!req.readableEnded && typeof req.pipe === 'function') {
334
+ req.pipe(backendRequest);
335
+ } else if (req.body !== undefined && req.body !== null) {
336
+ let payload;
337
+ if (Buffer.isBuffer(req.body)) {
338
+ payload = req.body;
339
+ } else if (typeof req.body === 'string') {
340
+ payload = Buffer.from(req.body);
341
+ } else {
342
+ try {
343
+ payload = Buffer.from(JSON.stringify(req.body));
344
+ backendRequest.setHeader('content-type', backendRequest.getHeader('content-type') || 'application/json');
345
+ } catch {
346
+ payload = Buffer.from('');
347
+ }
348
+ }
349
+ if (payload) {
350
+ backendRequest.setHeader('content-length', String(payload.length));
351
+ backendRequest.write(payload);
352
+ }
353
+ backendRequest.end();
354
+ } else {
355
+ backendRequest.end();
356
+ }
357
+ };
358
+
359
+ installFallback(nativeProxy);
360
+ return;
361
+ } catch (err) {
362
+ console.error(`Failed to setup proxy: ${err.message}`);
363
+ throw err;
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Run backend service only (Dinner API)
369
+ */
370
+ async function runBackendService(config) {
371
+ const appBootModule = await import(toFileUrl(config.app.boot_abs));
372
+ const backendApp = appBootModule.default(config);
373
+
374
+ attachCookiePolyfill(backendApp);
375
+
376
+ setContext(backendApp, config);
377
+
378
+ const serverMainModule = await import(toFileUrl(config.server.main_abs));
379
+ await serverMainModule.default(backendApp, config);
380
+
381
+ // Use NOEGO_PORT if running as a separate service, otherwise use config
382
+ const backendPort = process.env.NOEGO_SERVICE === 'backend' ?
383
+ parseInt(process.env.NOEGO_PORT) : config.dev.backendPort;
384
+
385
+ console.log('[backend] NOEGO_SERVICE:', process.env.NOEGO_SERVICE);
386
+ console.log('[backend] NOEGO_PORT:', process.env.NOEGO_PORT);
387
+ console.log('[backend] config.dev.backendPort:', config.dev.backendPort);
388
+ console.log('[backend] Using port:', backendPort);
389
+
390
+ backendApp.listen(backendPort, '0.0.0.0', () => {
391
+ console.log(`Backend server running on http://localhost:${backendPort}`);
392
+ });
393
+
394
+ return backendApp;
395
+ }
396
+
397
+ /**
398
+ * Run frontend service only (Forge SSR + Vite + proxy)
399
+ */
400
+ async function runFrontendService(config) {
401
+ const appBootModule = await import(toFileUrl(config.app.boot_abs));
402
+ const frontendApp = appBootModule.default(config);
403
+
404
+ attachCookiePolyfill(frontendApp);
405
+
406
+ if (config.mode !== 'production' && config.client?.assetDirs) {
407
+ const require = createRequire(path.join(config.root, 'package.json'));
408
+ const express = require('express');
409
+ for (const { mountPath, absolutePath } of config.client.assetDirs) {
410
+ if (mountPath && absolutePath) {
411
+ frontendApp.use(mountPath, express.static(absolutePath));
412
+ }
413
+ }
414
+ }
415
+
416
+ setContext(frontendApp, config);
417
+
418
+ // Only setup proxy if NOT running as a separate frontend service
419
+ // In split-serve mode, the router handles proxying
420
+ if (process.env.NOEGO_SERVICE !== 'frontend') {
421
+ // Setup proxy BEFORE Vite/Forge middleware, but as a selective passthrough
422
+ const backendPort = config.dev.backendPort;
423
+ await setupProxyFirst(frontendApp, backendPort, config);
424
+ }
425
+
426
+ const clientMainModule = await import(toFileUrl(config.client.main_abs));
427
+ await clientMainModule.default(frontendApp, config);
428
+
429
+ // Use NOEGO_PORT if running as a separate service, otherwise use config
430
+ const frontendPort = process.env.NOEGO_SERVICE === 'frontend' ?
431
+ parseInt(process.env.NOEGO_PORT) : config.dev.port;
432
+
433
+ console.log('[frontend] NOEGO_SERVICE:', process.env.NOEGO_SERVICE);
434
+ console.log('[frontend] NOEGO_PORT:', process.env.NOEGO_PORT);
435
+ console.log('[frontend] config.dev.port:', config.dev.port);
436
+ console.log('[frontend] Using port:', frontendPort);
437
+
438
+ frontendApp.listen(frontendPort, '0.0.0.0', () => {
439
+ console.log(`Frontend server running on http://localhost:${frontendPort}`);
440
+ });
441
+
442
+ return frontendApp;
443
+ }
444
+
445
+ /**
446
+ * Run router service that proxies to frontend/backend
447
+ */
448
+ async function runRouterService(config) {
449
+ const appBootModule = await import(toFileUrl(config.app.boot_abs));
450
+ const routerApp = appBootModule.default(config);
451
+
452
+ attachCookiePolyfill(routerApp);
453
+
454
+ const routerPort = config.dev.port;
455
+ const frontendPort = parseInt(process.env.NOEGO_FRONTEND_PORT) || (routerPort + 1);
456
+ const backendPort = parseInt(process.env.NOEGO_BACKEND_PORT) || (routerPort + 2);
457
+
458
+ setContext(routerApp, config);
459
+
460
+ // Simple native proxy implementation that tries frontend first, then backend on 404
461
+ routerApp.use(async (req, res, next) => {
462
+ const originalUrl = req.url;
463
+ const method = req.method;
464
+
465
+ // Collect the request body
466
+ const chunks = [];
467
+ req.on('data', chunk => chunks.push(chunk));
468
+
469
+ req.on('end', async () => {
470
+ const bodyBuffer = Buffer.concat(chunks);
471
+
472
+ // First try frontend
473
+ const tryService = (port, serviceName) => {
474
+ return new Promise((resolve) => {
475
+ // Hop-by-hop headers that shouldn't be forwarded
476
+ const hopByHopHeaders = [
477
+ 'connection',
478
+ 'keep-alive',
479
+ 'proxy-authenticate',
480
+ 'proxy-authorization',
481
+ 'te',
482
+ 'trailers',
483
+ 'transfer-encoding',
484
+ 'upgrade'
485
+ ];
486
+
487
+ // Copy headers, excluding hop-by-hop headers
488
+ const headers = {};
489
+ Object.keys(req.headers).forEach(key => {
490
+ if (!hopByHopHeaders.includes(key.toLowerCase())) {
491
+ headers[key] = req.headers[key];
492
+ }
493
+ });
494
+
495
+ // If there's a body, ensure content-length is correct
496
+ if (bodyBuffer.length > 0) {
497
+ headers['content-length'] = bodyBuffer.length;
498
+ }
499
+
500
+ // Preserve original host information for downstream auth/cookie logic
501
+ const originalHost = req.headers.host;
502
+ if (originalHost) {
503
+ if (!headers['x-forwarded-host']) {
504
+ headers['x-forwarded-host'] = originalHost;
505
+ }
506
+ if (!headers['x-forwarded-proto']) {
507
+ headers['x-forwarded-proto'] = req.protocol || 'http';
508
+ }
509
+ if (!headers['x-forwarded-port']) {
510
+ const hostPort = originalHost.split(':')[1];
511
+ headers['x-forwarded-port'] = hostPort || (headers['x-forwarded-proto'] === 'https' ? '443' : '80');
512
+ }
513
+ }
514
+
515
+ // Debug logging for authentication-related headers
516
+ if (headers.cookie || headers.authorization) {
517
+ console.log(`[router] Forwarding auth headers to ${serviceName}:`, {
518
+ cookie: headers.cookie ? 'present' : 'absent',
519
+ authorization: headers.authorization ? 'present' : 'absent'
520
+ });
521
+ }
522
+
523
+ const options = {
524
+ hostname: 'localhost',
525
+ port: port,
526
+ path: originalUrl,
527
+ method: method,
528
+ headers: headers
529
+ };
530
+
531
+ const proxyReq = http.request(options, (proxyRes) => {
532
+ const responseChunks = [];
533
+
534
+ // Debug logging for authentication response headers
535
+ if (proxyRes.headers['set-cookie'] || proxyRes.statusCode === 401 || proxyRes.statusCode === 403) {
536
+ console.log(`[router] Response from ${serviceName}:`, {
537
+ status: proxyRes.statusCode,
538
+ 'set-cookie': proxyRes.headers['set-cookie'] ? 'present' : 'absent'
539
+ });
540
+ }
541
+
542
+ proxyRes.on('data', chunk => responseChunks.push(chunk));
543
+ proxyRes.on('end', () => {
544
+ resolve({
545
+ statusCode: proxyRes.statusCode,
546
+ headers: proxyRes.headers,
547
+ body: Buffer.concat(responseChunks),
548
+ serviceName
549
+ });
550
+ });
551
+ });
552
+
553
+ proxyReq.on('error', (err) => {
554
+ console.error(`[router] Error proxying to ${serviceName}: ${err.message}`);
555
+ resolve({ statusCode: 502, error: err, serviceName });
556
+ });
557
+
558
+ if (bodyBuffer.length > 0) {
559
+ proxyReq.write(bodyBuffer);
560
+ }
561
+ proxyReq.end();
562
+ });
563
+ };
564
+
565
+ // Try frontend first
566
+ const frontendResult = await tryService(frontendPort, 'frontend');
567
+
568
+ // Helper to forward response headers, excluding hop-by-hop headers
569
+ const forwardResponseHeaders = (headers, res) => {
570
+ const hopByHopHeaders = [
571
+ 'connection',
572
+ 'keep-alive',
573
+ 'proxy-authenticate',
574
+ 'proxy-authorization',
575
+ 'te',
576
+ 'trailers',
577
+ 'transfer-encoding',
578
+ 'upgrade'
579
+ ];
580
+
581
+ if (headers) {
582
+ Object.entries(headers).forEach(([key, value]) => {
583
+ const keyLower = key.toLowerCase();
584
+ if (hopByHopHeaders.includes(keyLower)) {
585
+ return;
586
+ }
587
+
588
+ if (keyLower === 'set-cookie') {
589
+ const cookies = Array.isArray(value) ? value : [value];
590
+ if (typeof res.append === 'function') {
591
+ cookies.forEach((cookie) => res.append('set-cookie', cookie));
592
+ } else {
593
+ res.setHeader('set-cookie', cookies);
594
+ }
595
+ return;
596
+ }
597
+
598
+ res.setHeader(key, value);
599
+ });
600
+ }
601
+ };
602
+
603
+ if (frontendResult.statusCode === 404) {
604
+ // Frontend returned 404, try backend
605
+ console.log(`[router] Frontend returned 404 for ${method} ${originalUrl}, trying backend...`);
606
+ const backendResult = await tryService(backendPort, 'backend');
607
+
608
+ // Send backend response
609
+ res.statusCode = backendResult.statusCode || 502;
610
+ forwardResponseHeaders(backendResult.headers, res);
611
+ if (backendResult.body) {
612
+ res.end(backendResult.body);
613
+ } else {
614
+ res.end(backendResult.error ? 'Backend proxy error' : '');
615
+ }
616
+ } else {
617
+ // Frontend handled it or returned non-404 error
618
+ res.statusCode = frontendResult.statusCode || 502;
619
+ forwardResponseHeaders(frontendResult.headers, res);
620
+ if (frontendResult.body) {
621
+ res.end(frontendResult.body);
622
+ } else {
623
+ res.end(frontendResult.error ? 'Frontend proxy error' : '');
624
+ }
625
+ }
626
+ });
627
+ });
628
+
629
+ routerApp.listen(routerPort, '0.0.0.0', () => {
630
+ console.log(`Router server running on http://localhost:${routerPort}`);
631
+ console.log(` Proxying to frontend on port ${frontendPort}`);
632
+ console.log(` Proxying to backend on port ${backendPort}`);
633
+ });
634
+
635
+ return routerApp;
636
+ }
637
+
638
+ /**
639
+ * Run combined services in one process (backend and/or frontend based on configuration)
640
+ */
641
+ export async function runCombinedServices(config, options = {}) {
642
+ const { hasBackend = true, hasFrontend = true } = options;
643
+
644
+ const appBootModule = await import(toFileUrl(config.app.boot_abs));
645
+ const app = appBootModule.default(config);
646
+
647
+ attachCookiePolyfill(app);
648
+
649
+ if (config.mode !== 'production' && config.client?.assetDirs) {
650
+ const require = createRequire(path.join(config.root, 'package.json'));
651
+ const express = require('express');
652
+ for (const { mountPath, absolutePath } of config.client.assetDirs) {
653
+ if (mountPath && absolutePath) {
654
+ app.use(mountPath, express.static(absolutePath));
655
+ }
656
+ }
657
+ }
658
+
659
+ if (config.mode === 'production' && hasFrontend && config.outDir_abs) {
660
+ const require = createRequire(path.join(config.root, 'package.json'));
661
+ const express = require('express');
662
+ const clientStaticPath = path.join(config.outDir_abs, '.app', 'assets');
663
+ app.use('/client', express.static(clientStaticPath));
664
+ }
665
+
666
+ const httpServer = http.createServer(app);
667
+ setContext(app, config, httpServer);
668
+
669
+ if (hasBackend && config.server?.main_abs) {
670
+ const serverMainModule = await import(toFileUrl(config.server.main_abs));
671
+ await serverMainModule.default(app, config);
672
+ }
673
+
674
+ if (hasFrontend && config.client?.main_abs) {
675
+ const clientMainModule = await import(toFileUrl(config.client.main_abs));
676
+ await clientMainModule.default(app, config);
677
+ }
678
+
679
+ // Allow PORT env var to override config
680
+ const port = process.env.PORT ? parseInt(process.env.PORT, 10) : (config.dev?.port || 3000);
681
+ httpServer.listen(port, '0.0.0.0', () => {
682
+ const services = [];
683
+ if (hasBackend && config.server?.main_abs) services.push('Backend');
684
+ if (hasFrontend && config.client?.main_abs) services.push('Frontend');
685
+ const servicesStr = services.length > 0 ? ` (${services.join(' + ')})` : '';
686
+ console.log(`Server running on http://localhost:${port}${servicesStr}`);
687
+ });
688
+
689
+ return app;
690
+ }
691
+
692
+ export async function runRuntime(configFilePath, cliRoot) {
693
+ let config;
694
+
695
+ // Priority: Use env config if NOEGO_CONFIGURATION is set and no explicit config file
696
+ if (process.env.NOEGO_CONFIGURATION && !configFilePath) {
697
+ const { loadConfigFromEnv } = await import('./config-loader.js');
698
+ const result = loadConfigFromEnv();
699
+ config = result.config;
700
+ } else {
701
+ // Traditional path: load from YAML file
702
+ const result = await loadConfig(configFilePath, cliRoot);
703
+ config = result.config;
704
+ }
705
+
706
+ // Force development mode when running dev command (NODE_ENV is set to development)
707
+ // Override YAML config mode to ensure Vite runs in dev mode
708
+ if (process.env.NODE_ENV !== 'production') {
709
+ config.mode = 'development';
710
+ } else if (!config.mode) {
711
+ config.mode = 'production';
712
+ }
713
+
714
+ // Validate critical configuration
715
+ if (!config.app?.boot) {
716
+ throw new Error('app.boot is required in configuration');
717
+ }
718
+
719
+ // Check if running as a specific service (split-serve mode with separate processes)
720
+ const serviceMode = process.env.NOEGO_SERVICE; // 'router', 'backend', 'frontend', or undefined
721
+
722
+ if (serviceMode === 'router') {
723
+ // Router service runs in the main process
724
+ return await runRouterService(config);
725
+ } else if (serviceMode === 'backend') {
726
+ if (!config.server?.main_abs) {
727
+ throw new Error('server.main is required when NOEGO_SERVICE=backend');
728
+ }
729
+ return await runBackendService(config);
730
+ } else if (serviceMode === 'frontend') {
731
+ if (!config.client?.main_abs) {
732
+ throw new Error('client.main is required when NOEGO_SERVICE=frontend');
733
+ }
734
+ return await runFrontendService(config);
735
+ }
736
+
737
+ const splitServe = config.dev?.splitServe || false;
738
+ const hasBackend = !!config.server?.main_abs;
739
+ const hasFrontend = !!config.client?.main_abs;
740
+
741
+ if (splitServe) {
742
+ if (!hasBackend || !hasFrontend) {
743
+ throw new Error('splitServe requires both server.main and client.main to be configured');
744
+ }
745
+ throw new Error('Split-serve should be handled by dev.js spawning separate processes');
746
+ }
747
+
748
+ return await runCombinedServices(config, { hasBackend, hasFrontend });
749
+ }