@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,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
|
+
}
|