@lopatnov/express-reverse-proxy 5.0.4 → 5.0.6
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/package.json +4 -4
- package/server.js +307 -298
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lopatnov/express-reverse-proxy",
|
|
3
3
|
"email": "oleksandr@lopatnov.cv.ua",
|
|
4
|
-
"version": "5.0.
|
|
4
|
+
"version": "5.0.6",
|
|
5
5
|
"description": "Node.js CLI tool to serve static front-end files with a reverse proxy for back-end APIs",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"author": "lopatnov",
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
"express": "^5.2.1",
|
|
76
76
|
"express-basic-auth": "^1.2.1",
|
|
77
77
|
"express-http-proxy": "^2.1.2",
|
|
78
|
-
"express-rate-limit": "^8.3.
|
|
78
|
+
"express-rate-limit": "^8.3.2",
|
|
79
79
|
"helmet": "^8.1.0",
|
|
80
80
|
"morgan": "^1.10.1",
|
|
81
81
|
"multer": "^2.1.1",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"serve-favicon": "^2.5.1"
|
|
85
85
|
},
|
|
86
86
|
"devDependencies": {
|
|
87
|
-
"@biomejs/biome": "^2.4.
|
|
88
|
-
"cypress": "^15.
|
|
87
|
+
"@biomejs/biome": "^2.4.11",
|
|
88
|
+
"cypress": "^15.13.1"
|
|
89
89
|
}
|
|
90
90
|
}
|
package/server.js
CHANGED
|
@@ -69,7 +69,7 @@ function exitError(msg, code = -1) {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
function parseArguments(args) {
|
|
72
|
-
const argsNames = args.map((a) => a.name);
|
|
72
|
+
const argsNames = new Set(args.map((a) => a.name));
|
|
73
73
|
return args.reduce((res, arg) => {
|
|
74
74
|
const argIndex = process.argv.indexOf(arg.name);
|
|
75
75
|
if (argIndex > -1) {
|
|
@@ -77,10 +77,7 @@ function parseArguments(args) {
|
|
|
77
77
|
if (arg.subArgs) {
|
|
78
78
|
arg.subArgs.forEach((subArg, index) => {
|
|
79
79
|
const subArgIndex = argIndex + index + 1;
|
|
80
|
-
if (
|
|
81
|
-
process.argv.length <= subArgIndex ||
|
|
82
|
-
argsNames.indexOf(process.argv[subArgIndex]) > -1
|
|
83
|
-
) {
|
|
80
|
+
if (process.argv.length <= subArgIndex || argsNames.has(process.argv[subArgIndex])) {
|
|
84
81
|
exitError(`Invalid argument ${arg.name}. Missing <${subArg}>.`, 16);
|
|
85
82
|
}
|
|
86
83
|
res[arg.name].args.push(process.argv[subArgIndex]);
|
|
@@ -99,10 +96,10 @@ function help(app, args) {
|
|
|
99
96
|
|
|
100
97
|
args.forEach((arg) => {
|
|
101
98
|
const tabIndentLength = 3;
|
|
102
|
-
const tabIndent =
|
|
103
|
-
const argTabIndent =
|
|
104
|
-
.
|
|
105
|
-
|
|
99
|
+
const tabIndent = '\t'.repeat(tabIndentLength);
|
|
100
|
+
const argTabIndent = '\t'.repeat(
|
|
101
|
+
Math.max(1, tabIndentLength - Math.trunc(arg.name.length / 4)),
|
|
102
|
+
);
|
|
106
103
|
console.log(`\t\x1b[1m${arg.name}\x1b[0m${argTabIndent}${arg.description}`);
|
|
107
104
|
if (arg.subArgs) {
|
|
108
105
|
const subArgs = arg.subArgs.map((subArg) => `<${subArg}>`).join(' ');
|
|
@@ -174,7 +171,7 @@ if (serverArgs['--init']) {
|
|
|
174
171
|
if (proxyPath) proxyTarget = (await rl.question(`Proxy target for ${proxyPath}: `)).trim();
|
|
175
172
|
const hotReload = (await rl.question('Hot reload? [y/N]: ')).trim().toLowerCase() === 'y';
|
|
176
173
|
rl.close();
|
|
177
|
-
const cfg = { port: parseInt(port, 10), folders: folder };
|
|
174
|
+
const cfg = { port: Number.parseInt(port, 10), folders: folder };
|
|
178
175
|
if (proxyPath && proxyTarget) cfg.proxy = { [proxyPath]: proxyTarget };
|
|
179
176
|
if (hotReload) cfg.hotReload = true;
|
|
180
177
|
fs.writeFileSync(configOut, `${JSON.stringify(cfg, null, 2)}\n`);
|
|
@@ -194,7 +191,14 @@ const configDir = path.dirname(path.resolve(configFile));
|
|
|
194
191
|
const DEFAULT_CONFIG = { port: 8000, folders: '.' };
|
|
195
192
|
|
|
196
193
|
let rawConfig;
|
|
197
|
-
if (
|
|
194
|
+
if (fs.existsSync(configFile)) {
|
|
195
|
+
console.log(`[config] ${configFile}`);
|
|
196
|
+
try {
|
|
197
|
+
rawConfig = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
|
198
|
+
} catch (err) {
|
|
199
|
+
exitError(`Failed to parse "${configFile}": ${err.message}`, 1);
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
198
202
|
if (serverArgs['--config']) {
|
|
199
203
|
exitError(`Configuration file not found: "${configFile}"`, 404);
|
|
200
204
|
}
|
|
@@ -202,13 +206,6 @@ if (!fs.existsSync(configFile)) {
|
|
|
202
206
|
`\x1b[33m[config] "${configFile}" not found — using defaults (port: 8000, folders: ".")\x1b[0m`,
|
|
203
207
|
);
|
|
204
208
|
rawConfig = DEFAULT_CONFIG;
|
|
205
|
-
} else {
|
|
206
|
-
console.log(`[config] ${configFile}`);
|
|
207
|
-
try {
|
|
208
|
-
rawConfig = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
|
209
|
-
} catch (err) {
|
|
210
|
-
exitError(`Failed to parse "${configFile}": ${err.message}`, 1);
|
|
211
|
-
}
|
|
212
209
|
}
|
|
213
210
|
|
|
214
211
|
const configs = Array.isArray(rawConfig) ? rawConfig : [rawConfig];
|
|
@@ -216,7 +213,7 @@ const configs = Array.isArray(rawConfig) ? rawConfig : [rawConfig];
|
|
|
216
213
|
// Validate: same host on same port is an error; same host on different ports is OK
|
|
217
214
|
const seen = new Set();
|
|
218
215
|
configs.forEach((c) => {
|
|
219
|
-
const p = parseInt(c.port || process.env.PORT || 8000, 10);
|
|
216
|
+
const p = Number.parseInt(c.port || process.env.PORT || 8000, 10);
|
|
220
217
|
if (p < 1 || p > 65535) exitError(`Invalid port: ${p}`, 1);
|
|
221
218
|
const key = `${p}:${c.host || '*'}`;
|
|
222
219
|
if (seen.has(key)) {
|
|
@@ -228,7 +225,7 @@ configs.forEach((c) => {
|
|
|
228
225
|
// Group configs by port
|
|
229
226
|
const configsByPort = new Map();
|
|
230
227
|
configs.forEach((c) => {
|
|
231
|
-
const p = parseInt(c.port || process.env.PORT || 8000, 10);
|
|
228
|
+
const p = Number.parseInt(c.port || process.env.PORT || 8000, 10);
|
|
232
229
|
if (!configsByPort.has(p)) configsByPort.set(p, []);
|
|
233
230
|
configsByPort.get(p).push(c);
|
|
234
231
|
});
|
|
@@ -333,6 +330,275 @@ function addProxy(router, port, localRootPath, remoteProxy) {
|
|
|
333
330
|
}
|
|
334
331
|
}
|
|
335
332
|
|
|
333
|
+
function configureLogging(router, siteConfig, configDir) {
|
|
334
|
+
if (siteConfig.logging === false) return;
|
|
335
|
+
const lc = siteConfig.logging;
|
|
336
|
+
if (typeof lc === 'object' && lc.file) {
|
|
337
|
+
const stream = fs.createWriteStream(path.resolve(configDir, lc.file), { flags: 'a' });
|
|
338
|
+
router.use(morgan(lc.format || 'combined', { stream }));
|
|
339
|
+
} else {
|
|
340
|
+
router.use(morgan((typeof lc === 'object' ? lc.format : null) || 'dev'));
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function toOpts(val) {
|
|
345
|
+
return typeof val === 'object' ? val : {};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function configureMiddleware(router, siteConfig) {
|
|
349
|
+
if (siteConfig.responseTime) router.use(responseTime(toOpts(siteConfig.responseTime)));
|
|
350
|
+
if (siteConfig.cors) router.use(cors(toOpts(siteConfig.cors)));
|
|
351
|
+
if (siteConfig.compression) router.use(compression(toOpts(siteConfig.compression)));
|
|
352
|
+
if (siteConfig.helmet) router.use(helmet(toOpts(siteConfig.helmet)));
|
|
353
|
+
if (siteConfig.favicon) router.use(favicon(path.resolve(configDir, siteConfig.favicon)));
|
|
354
|
+
if (siteConfig.healthCheck) {
|
|
355
|
+
const hcPath =
|
|
356
|
+
(typeof siteConfig.healthCheck === 'object' && siteConfig.healthCheck.path) || '/__health__';
|
|
357
|
+
router.get(hcPath, (_req, res) => {
|
|
358
|
+
res.json({ status: 'ok', uptime: process.uptime(), timestamp: new Date().toISOString() });
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
if (siteConfig.rateLimit) router.use(rateLimit(toOpts(siteConfig.rateLimit)));
|
|
362
|
+
if (siteConfig.basicAuth) router.use(basicAuth(toOpts(siteConfig.basicAuth)));
|
|
363
|
+
if (siteConfig.headers) {
|
|
364
|
+
router.use((_req, res, next) => {
|
|
365
|
+
for (const h of Object.keys(siteConfig.headers)) res.setHeader(h, siteConfig.headers[h]);
|
|
366
|
+
next();
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function setupRedirects(router, redirects) {
|
|
372
|
+
if (Array.isArray(redirects)) {
|
|
373
|
+
for (const r of redirects) {
|
|
374
|
+
router.all(r.from, (_req, res) => res.redirect(r.status || 301, r.to));
|
|
375
|
+
}
|
|
376
|
+
} else {
|
|
377
|
+
for (const [from, to] of Object.entries(redirects)) {
|
|
378
|
+
const dest = typeof to === 'string' ? to : to.to;
|
|
379
|
+
const status = typeof to === 'object' ? to.status || 301 : 301;
|
|
380
|
+
router.all(from, (_req, res) => res.redirect(status, dest));
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function buildCgiEnv(req, scriptPath, cgiUrlPath, p) {
|
|
386
|
+
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
387
|
+
const env = {
|
|
388
|
+
...process.env,
|
|
389
|
+
GATEWAY_INTERFACE: 'CGI/1.1',
|
|
390
|
+
SERVER_PROTOCOL: 'HTTP/1.1',
|
|
391
|
+
SERVER_SOFTWARE: 'express-reverse-proxy',
|
|
392
|
+
REQUEST_METHOD: req.method.toUpperCase(),
|
|
393
|
+
SCRIPT_FILENAME: scriptPath,
|
|
394
|
+
SCRIPT_NAME: cgiUrlPath + req.path,
|
|
395
|
+
PATH_INFO: '',
|
|
396
|
+
QUERY_STRING: url.search ? url.search.slice(1) : '',
|
|
397
|
+
REMOTE_ADDR: req.ip || '127.0.0.1',
|
|
398
|
+
CONTENT_TYPE: req.headers['content-type'] || '',
|
|
399
|
+
CONTENT_LENGTH: req.headers['content-length'] || '0',
|
|
400
|
+
SERVER_NAME: req.hostname || 'localhost',
|
|
401
|
+
SERVER_PORT: String(p),
|
|
402
|
+
};
|
|
403
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
404
|
+
env[`HTTP_${k.toUpperCase().replaceAll('-', '_')}`] = Array.isArray(v) ? v.join(', ') : v;
|
|
405
|
+
}
|
|
406
|
+
return env;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function applyCgiHeaders(rawHeaders, res) {
|
|
410
|
+
let statusCode = 200;
|
|
411
|
+
for (const line of rawHeaders.split(/\r?\n/)) {
|
|
412
|
+
const colon = line.indexOf(':');
|
|
413
|
+
if (colon === -1) continue;
|
|
414
|
+
const name = line.substring(0, colon).trim();
|
|
415
|
+
const value = line.substring(colon + 1).trim();
|
|
416
|
+
if (name.toLowerCase() === 'status') {
|
|
417
|
+
statusCode = Number.parseInt(value, 10) || 200;
|
|
418
|
+
} else {
|
|
419
|
+
res.setHeader(name, value);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return statusCode;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function setupCgi(router, siteConfig, p, configDir) {
|
|
426
|
+
if (!siteConfig.cgi) return;
|
|
427
|
+
const cgiRaw = siteConfig.cgi;
|
|
428
|
+
const cgiConfigs = Array.isArray(cgiRaw)
|
|
429
|
+
? cgiRaw
|
|
430
|
+
: [typeof cgiRaw === 'string' ? { dir: cgiRaw } : cgiRaw];
|
|
431
|
+
|
|
432
|
+
for (const cgiConfig of cgiConfigs) {
|
|
433
|
+
const cgiUrlPath = cgiConfig.path || '/cgi-bin';
|
|
434
|
+
const cgiDirResolved = path.resolve(configDir, cgiConfig.dir || './cgi-bin');
|
|
435
|
+
const cgiExts = new Set(cgiConfig.extensions || ['.cgi', '.pl', '.py', '.sh']);
|
|
436
|
+
const interps = cgiConfig.interpreters || {};
|
|
437
|
+
|
|
438
|
+
router.use(cgiUrlPath, (req, res, next) => {
|
|
439
|
+
const scriptPath = path.resolve(path.join(cgiDirResolved, req.path));
|
|
440
|
+
if (!scriptPath.startsWith(cgiDirResolved + path.sep)) return next();
|
|
441
|
+
|
|
442
|
+
const ext = path.extname(scriptPath);
|
|
443
|
+
if (!cgiExts.has(ext)) return next();
|
|
444
|
+
let scriptStat;
|
|
445
|
+
try {
|
|
446
|
+
scriptStat = fs.lstatSync(scriptPath);
|
|
447
|
+
} catch {
|
|
448
|
+
return next();
|
|
449
|
+
}
|
|
450
|
+
if (!scriptStat.isFile() || scriptStat.isSymbolicLink()) return next();
|
|
451
|
+
|
|
452
|
+
const env = buildCgiEnv(req, scriptPath, cgiUrlPath, p);
|
|
453
|
+
const interpreter = interps[ext];
|
|
454
|
+
const command = interpreter || scriptPath;
|
|
455
|
+
const args = interpreter ? [scriptPath] : [];
|
|
456
|
+
const child = spawn(command, args, { env, cwd: path.dirname(scriptPath), shell: false });
|
|
457
|
+
|
|
458
|
+
child.stdin.on('error', (_err) => {});
|
|
459
|
+
req.pipe(child.stdin);
|
|
460
|
+
|
|
461
|
+
res.on('drain', () => child.stdout.resume());
|
|
462
|
+
|
|
463
|
+
let headersParsed = false;
|
|
464
|
+
let rawBuf = '';
|
|
465
|
+
child.stdout.on('data', (chunk) => {
|
|
466
|
+
if (headersParsed) {
|
|
467
|
+
if (!res.write(chunk)) child.stdout.pause();
|
|
468
|
+
} else {
|
|
469
|
+
rawBuf += chunk.toString('binary');
|
|
470
|
+
if (rawBuf.length > 65536) {
|
|
471
|
+
child.stdout.destroy();
|
|
472
|
+
child.kill();
|
|
473
|
+
res.status(500).send('CGI headers too large');
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const m = /\r?\n\r?\n/.exec(rawBuf);
|
|
477
|
+
if (m) {
|
|
478
|
+
const rawHeaders = rawBuf.substring(0, m.index);
|
|
479
|
+
const bodyStart = Buffer.from(rawBuf.substring(m.index + m[0].length), 'binary');
|
|
480
|
+
headersParsed = true;
|
|
481
|
+
res.status(applyCgiHeaders(rawHeaders, res));
|
|
482
|
+
if (bodyStart.length && !res.write(bodyStart)) child.stdout.pause();
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
child.stdout.on('end', () => {
|
|
487
|
+
if (headersParsed) {
|
|
488
|
+
res.end();
|
|
489
|
+
} else if (!res.headersSent) {
|
|
490
|
+
res.status(500).send('CGI script produced no output');
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
child.stderr.on('data', (data) => console.error(`[cgi] ${scriptPath}: ${data}`));
|
|
494
|
+
child.on('error', (err) => {
|
|
495
|
+
console.error(`[cgi] spawn error for ${scriptPath}: ${err.message}`);
|
|
496
|
+
if (!res.headersSent) res.status(500).send(`CGI error: ${err.message}`);
|
|
497
|
+
});
|
|
498
|
+
console.log(`[cgi] ${req.method} ${cgiUrlPath}${req.path} → ${scriptPath}`);
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function buildFileFilter(allowedTypes) {
|
|
504
|
+
if (!allowedTypes) return undefined;
|
|
505
|
+
return (_req, file, cb) => {
|
|
506
|
+
if (allowedTypes.has(file.mimetype)) cb(null, true);
|
|
507
|
+
else cb(Object.assign(new Error(`File type not allowed: ${file.mimetype}`), { status: 400 }));
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function handleUploadResponse(req, res) {
|
|
512
|
+
if (!req.files?.length) return res.status(400).json({ error: 'No files uploaded' });
|
|
513
|
+
res.json({
|
|
514
|
+
files: req.files.map((f) => ({ file: f.filename, size: f.size, originalName: f.originalname })),
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function setupUpload(router, siteConfig, configDir) {
|
|
519
|
+
if (!siteConfig.upload) return;
|
|
520
|
+
const uploadRaw = siteConfig.upload;
|
|
521
|
+
const uploadConfigs = Array.isArray(uploadRaw)
|
|
522
|
+
? uploadRaw
|
|
523
|
+
: [typeof uploadRaw === 'string' ? { dir: uploadRaw } : uploadRaw];
|
|
524
|
+
|
|
525
|
+
for (const uploadConfig of uploadConfigs) {
|
|
526
|
+
const uploadUrlPath = uploadConfig.path || '/upload';
|
|
527
|
+
const uploadDir = path.resolve(configDir, uploadConfig.dir || './uploads');
|
|
528
|
+
const allowedTypes = uploadConfig.allowedTypes ? new Set(uploadConfig.allowedTypes) : null;
|
|
529
|
+
|
|
530
|
+
fs.mkdirSync(uploadDir, { recursive: true });
|
|
531
|
+
|
|
532
|
+
const storage = multer.diskStorage({
|
|
533
|
+
destination: uploadDir,
|
|
534
|
+
filename: (_req, file, cb) => {
|
|
535
|
+
const ext = path.extname(file.originalname);
|
|
536
|
+
const base =
|
|
537
|
+
path.basename(file.originalname, ext).replaceAll(/[^a-zA-Z0-9_.-]/g, '_') || 'file';
|
|
538
|
+
cb(null, `${base}-${Date.now()}-${Math.round(Math.random() * 1e9)}${ext}`);
|
|
539
|
+
},
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
const limits = {};
|
|
543
|
+
if (uploadConfig.maxFileSize) limits.fileSize = uploadConfig.maxFileSize;
|
|
544
|
+
if (uploadConfig.maxFiles) limits.files = uploadConfig.maxFiles;
|
|
545
|
+
|
|
546
|
+
const uploader = multer({ storage, limits, fileFilter: buildFileFilter(allowedTypes) });
|
|
547
|
+
const multerMiddleware = uploadConfig.fieldName
|
|
548
|
+
? uploader.array(uploadConfig.fieldName)
|
|
549
|
+
: uploader.any();
|
|
550
|
+
|
|
551
|
+
router.post(uploadUrlPath, multerMiddleware, handleUploadResponse);
|
|
552
|
+
router.use(uploadUrlPath, express.static(uploadDir));
|
|
553
|
+
console.log(`[upload] POST ${uploadUrlPath} → ${uploadDir}`);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function setupHotReload(app, portConfigs, p) {
|
|
558
|
+
const hotReloadEnabled = portConfigs.some((c) => c.hotReload === true);
|
|
559
|
+
if (!hotReloadEnabled) return;
|
|
560
|
+
|
|
561
|
+
const hotReloadClientJs = fs.readFileSync(path.join(__dirname, 'hot-reload-client.js'), 'utf8');
|
|
562
|
+
const sseClients = new Set();
|
|
563
|
+
let reloadTimer = null;
|
|
564
|
+
|
|
565
|
+
app.get('/__hot-reload__', (req, res) => {
|
|
566
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
567
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
568
|
+
res.setHeader('Connection', 'keep-alive');
|
|
569
|
+
res.flushHeaders();
|
|
570
|
+
sseClients.add(res);
|
|
571
|
+
req.on('close', () => sseClients.delete(res));
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
app.get('/__hot-reload__/client.js', (_req, res) => {
|
|
575
|
+
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
|
576
|
+
res.send(hotReloadClientJs);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
const watchPaths = [...new Set(portConfigs.flatMap((c) => collectFolderPaths(c.folders || [])))];
|
|
580
|
+
for (const folder of watchPaths) {
|
|
581
|
+
const absPath = path.isAbsolute(folder) ? folder : path.join(process.cwd(), folder);
|
|
582
|
+
if (fs.existsSync(absPath)) {
|
|
583
|
+
const onChange = () => {
|
|
584
|
+
clearTimeout(reloadTimer);
|
|
585
|
+
reloadTimer = setTimeout(() => {
|
|
586
|
+
for (const client of sseClients) client.write('data: reload\n\n');
|
|
587
|
+
}, 100);
|
|
588
|
+
};
|
|
589
|
+
try {
|
|
590
|
+
fs.watch(absPath, { recursive: true }, onChange);
|
|
591
|
+
} catch {
|
|
592
|
+
console.warn(
|
|
593
|
+
`[hot-reload] recursive watch not supported on this platform, falling back for ${absPath}`,
|
|
594
|
+
);
|
|
595
|
+
fs.watch(absPath, onChange);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
console.log(`[hot-reload] watching ${watchPaths.length} folder(s) on port ${p}`);
|
|
600
|
+
}
|
|
601
|
+
|
|
336
602
|
function unhandled(res, acceptConfig) {
|
|
337
603
|
const headers = (acceptConfig.headers && Object.keys(acceptConfig.headers)) || [];
|
|
338
604
|
for (const header of headers) res.setHeader(header, acceptConfig.headers[header]);
|
|
@@ -358,42 +624,7 @@ const servers = [];
|
|
|
358
624
|
|
|
359
625
|
configsByPort.forEach((portConfigs, p) => {
|
|
360
626
|
const app = express();
|
|
361
|
-
|
|
362
|
-
const hotReloadEnabled = portConfigs.some((c) => c.hotReload === true);
|
|
363
|
-
if (hotReloadEnabled) {
|
|
364
|
-
const sseClients = new Set();
|
|
365
|
-
let reloadTimer = null;
|
|
366
|
-
|
|
367
|
-
app.get('/__hot-reload__', (req, res) => {
|
|
368
|
-
res.setHeader('Content-Type', 'text/event-stream');
|
|
369
|
-
res.setHeader('Cache-Control', 'no-cache');
|
|
370
|
-
res.setHeader('Connection', 'keep-alive');
|
|
371
|
-
res.flushHeaders();
|
|
372
|
-
sseClients.add(res);
|
|
373
|
-
req.on('close', () => sseClients.delete(res));
|
|
374
|
-
});
|
|
375
|
-
|
|
376
|
-
app.get('/__hot-reload__/client.js', (_req, res) => {
|
|
377
|
-
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
|
378
|
-
res.sendFile(path.join(__dirname, 'hot-reload-client.js'));
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
const watchPaths = [
|
|
382
|
-
...new Set(portConfigs.flatMap((c) => collectFolderPaths(c.folders || []))),
|
|
383
|
-
];
|
|
384
|
-
for (const folder of watchPaths) {
|
|
385
|
-
const absPath = path.isAbsolute(folder) ? folder : path.join(process.cwd(), folder);
|
|
386
|
-
if (fs.existsSync(absPath)) {
|
|
387
|
-
fs.watch(absPath, { recursive: true }, () => {
|
|
388
|
-
clearTimeout(reloadTimer);
|
|
389
|
-
reloadTimer = setTimeout(() => {
|
|
390
|
-
for (const client of sseClients) client.write('data: reload\n\n');
|
|
391
|
-
}, 100);
|
|
392
|
-
});
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
console.log(`[hot-reload] watching ${watchPaths.length} folder(s) on port ${p}`);
|
|
396
|
-
}
|
|
627
|
+
setupHotReload(app, portConfigs, p);
|
|
397
628
|
|
|
398
629
|
// Specific hosts first, catch-all last
|
|
399
630
|
const sorted = [
|
|
@@ -404,254 +635,32 @@ configsByPort.forEach((portConfigs, p) => {
|
|
|
404
635
|
sorted.forEach((siteConfig) => {
|
|
405
636
|
const siteHost = siteConfig.host || '*';
|
|
406
637
|
const router = express.Router();
|
|
407
|
-
|
|
408
638
|
console.log(`[host] ${siteHost} → :${p}`);
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
router.use(morgan((typeof lc === 'object' ? lc.format : null) || 'dev'));
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
if (siteConfig.responseTime) {
|
|
421
|
-
const opts = typeof siteConfig.responseTime === 'object' ? siteConfig.responseTime : {};
|
|
422
|
-
router.use(responseTime(opts));
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
if (siteConfig.cors) {
|
|
426
|
-
const opts = typeof siteConfig.cors === 'object' ? siteConfig.cors : {};
|
|
427
|
-
router.use(cors(opts));
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
if (siteConfig.compression) {
|
|
431
|
-
const opts = typeof siteConfig.compression === 'object' ? siteConfig.compression : {};
|
|
432
|
-
router.use(compression(opts));
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
if (siteConfig.helmet) {
|
|
436
|
-
const opts = typeof siteConfig.helmet === 'object' ? siteConfig.helmet : {};
|
|
437
|
-
router.use(helmet(opts));
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
if (siteConfig.favicon) {
|
|
441
|
-
router.use(favicon(path.resolve(configDir, siteConfig.favicon)));
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
if (siteConfig.healthCheck) {
|
|
445
|
-
const hcPath =
|
|
446
|
-
(typeof siteConfig.healthCheck === 'object' && siteConfig.healthCheck.path) ||
|
|
447
|
-
'/__health__';
|
|
448
|
-
router.get(hcPath, (_req, res) => {
|
|
449
|
-
res.json({ status: 'ok', uptime: process.uptime(), timestamp: new Date().toISOString() });
|
|
450
|
-
});
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
if (siteConfig.rateLimit) {
|
|
454
|
-
const opts = typeof siteConfig.rateLimit === 'object' ? siteConfig.rateLimit : {};
|
|
455
|
-
router.use(rateLimit(opts));
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
if (siteConfig.basicAuth) {
|
|
459
|
-
const opts = typeof siteConfig.basicAuth === 'object' ? siteConfig.basicAuth : {};
|
|
460
|
-
router.use(basicAuth(opts));
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
if (siteConfig.headers) {
|
|
464
|
-
router.use((_req, res, next) => {
|
|
465
|
-
for (const h of Object.keys(siteConfig.headers)) res.setHeader(h, siteConfig.headers[h]);
|
|
466
|
-
next();
|
|
467
|
-
});
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
if (siteConfig.redirects) {
|
|
471
|
-
const redirects = siteConfig.redirects;
|
|
472
|
-
if (Array.isArray(redirects)) {
|
|
473
|
-
for (const r of redirects) {
|
|
474
|
-
router.all(r.from, (_req, res) => res.redirect(r.status || 301, r.to));
|
|
475
|
-
}
|
|
476
|
-
} else {
|
|
477
|
-
for (const [from, to] of Object.entries(redirects)) {
|
|
478
|
-
const dest = typeof to === 'string' ? to : to.to;
|
|
479
|
-
const status = typeof to === 'object' ? to.status || 301 : 301;
|
|
480
|
-
router.all(from, (_req, res) => res.redirect(status, dest));
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
if (siteConfig.folders) {
|
|
486
|
-
addStaticFolder(router, p, null, siteConfig.folders);
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
if (siteConfig.cgi) {
|
|
490
|
-
const cgiRaw = siteConfig.cgi;
|
|
491
|
-
const cgiConfigs = Array.isArray(cgiRaw)
|
|
492
|
-
? cgiRaw
|
|
493
|
-
: [typeof cgiRaw === 'string' ? { dir: cgiRaw } : cgiRaw];
|
|
494
|
-
|
|
495
|
-
for (const cgiConfig of cgiConfigs) {
|
|
496
|
-
const cgiUrlPath = cgiConfig.path || '/cgi-bin';
|
|
497
|
-
const cgiDirResolved = path.resolve(configDir, cgiConfig.dir || './cgi-bin');
|
|
498
|
-
const cgiExts = new Set(cgiConfig.extensions || ['.cgi', '.pl', '.py', '.sh']);
|
|
499
|
-
const interps = cgiConfig.interpreters || {};
|
|
500
|
-
|
|
501
|
-
router.use(cgiUrlPath, (req, res, next) => {
|
|
502
|
-
const scriptPath = path.resolve(path.join(cgiDirResolved, req.path));
|
|
503
|
-
if (!scriptPath.startsWith(cgiDirResolved + path.sep)) return next();
|
|
504
|
-
|
|
505
|
-
const ext = path.extname(scriptPath);
|
|
506
|
-
if (!cgiExts.has(ext) || !fs.existsSync(scriptPath)) return next();
|
|
507
|
-
|
|
508
|
-
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
509
|
-
const env = {
|
|
510
|
-
...process.env,
|
|
511
|
-
GATEWAY_INTERFACE: 'CGI/1.1',
|
|
512
|
-
SERVER_PROTOCOL: 'HTTP/1.1',
|
|
513
|
-
SERVER_SOFTWARE: 'express-reverse-proxy',
|
|
514
|
-
REQUEST_METHOD: req.method.toUpperCase(),
|
|
515
|
-
SCRIPT_FILENAME: scriptPath,
|
|
516
|
-
SCRIPT_NAME: cgiUrlPath + req.path,
|
|
517
|
-
PATH_INFO: '',
|
|
518
|
-
QUERY_STRING: url.search ? url.search.slice(1) : '',
|
|
519
|
-
REMOTE_ADDR: req.ip || '127.0.0.1',
|
|
520
|
-
CONTENT_TYPE: req.headers['content-type'] || '',
|
|
521
|
-
CONTENT_LENGTH: req.headers['content-length'] || '0',
|
|
522
|
-
SERVER_NAME: req.hostname || 'localhost',
|
|
523
|
-
SERVER_PORT: String(p),
|
|
524
|
-
};
|
|
525
|
-
for (const [k, v] of Object.entries(req.headers)) {
|
|
526
|
-
env[`HTTP_${k.toUpperCase().replace(/-/g, '_')}`] = Array.isArray(v) ? v.join(', ') : v;
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
const interpreter = interps[ext];
|
|
530
|
-
const command = interpreter || scriptPath;
|
|
531
|
-
const args = interpreter ? [scriptPath] : [];
|
|
532
|
-
const child = spawn(command, args, { env, cwd: path.dirname(scriptPath) });
|
|
533
|
-
|
|
534
|
-
child.stdin.on('error', (_err) => {});
|
|
535
|
-
req.pipe(child.stdin);
|
|
536
|
-
|
|
537
|
-
let headersParsed = false;
|
|
538
|
-
let rawBuf = '';
|
|
539
|
-
child.stdout.on('data', (chunk) => {
|
|
540
|
-
if (!headersParsed) {
|
|
541
|
-
rawBuf += chunk.toString('binary');
|
|
542
|
-
const m = /\r?\n\r?\n/.exec(rawBuf);
|
|
543
|
-
if (m) {
|
|
544
|
-
const rawHeaders = rawBuf.substring(0, m.index);
|
|
545
|
-
const bodyStart = Buffer.from(rawBuf.substring(m.index + m[0].length), 'binary');
|
|
546
|
-
headersParsed = true;
|
|
547
|
-
let statusCode = 200;
|
|
548
|
-
for (const line of rawHeaders.split(/\r?\n/)) {
|
|
549
|
-
const colon = line.indexOf(':');
|
|
550
|
-
if (colon === -1) continue;
|
|
551
|
-
const name = line.substring(0, colon).trim();
|
|
552
|
-
const value = line.substring(colon + 1).trim();
|
|
553
|
-
if (name.toLowerCase() === 'status') {
|
|
554
|
-
statusCode = Number.parseInt(value, 10) || 200;
|
|
555
|
-
} else {
|
|
556
|
-
res.setHeader(name, value);
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
res.status(statusCode);
|
|
560
|
-
if (bodyStart.length) res.write(bodyStart);
|
|
561
|
-
}
|
|
562
|
-
} else {
|
|
563
|
-
res.write(chunk);
|
|
564
|
-
}
|
|
565
|
-
});
|
|
566
|
-
child.stdout.on('end', () => {
|
|
567
|
-
if (!headersParsed) res.status(500).send('CGI script produced no output');
|
|
568
|
-
else res.end();
|
|
569
|
-
});
|
|
570
|
-
child.stderr.on('data', (data) => console.error(`[cgi] ${scriptPath}: ${data}`));
|
|
571
|
-
child.on('error', (err) => {
|
|
572
|
-
console.error(`[cgi] spawn error for ${scriptPath}: ${err.message}`);
|
|
573
|
-
if (!res.headersSent) res.status(500).send(`CGI error: ${err.message}`);
|
|
574
|
-
});
|
|
575
|
-
console.log(`[cgi] ${req.method} ${cgiUrlPath}${req.path} → ${scriptPath}`);
|
|
576
|
-
});
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
if (siteConfig.upload) {
|
|
581
|
-
const uploadRaw = siteConfig.upload;
|
|
582
|
-
const uploadConfigs = Array.isArray(uploadRaw)
|
|
583
|
-
? uploadRaw
|
|
584
|
-
: [typeof uploadRaw === 'string' ? { dir: uploadRaw } : uploadRaw];
|
|
585
|
-
|
|
586
|
-
for (const uploadConfig of uploadConfigs) {
|
|
587
|
-
const uploadUrlPath = uploadConfig.path || '/upload';
|
|
588
|
-
const uploadDir = path.resolve(configDir, uploadConfig.dir || './uploads');
|
|
589
|
-
const allowedTypes = uploadConfig.allowedTypes ? new Set(uploadConfig.allowedTypes) : null;
|
|
590
|
-
|
|
591
|
-
fs.mkdirSync(uploadDir, { recursive: true });
|
|
592
|
-
|
|
593
|
-
const storage = multer.diskStorage({
|
|
594
|
-
destination: uploadDir,
|
|
595
|
-
filename: (_req, file, cb) => {
|
|
596
|
-
const ext = path.extname(file.originalname);
|
|
597
|
-
const base = path.basename(file.originalname, ext).replace(/[^a-zA-Z0-9_.-]/g, '_');
|
|
598
|
-
cb(null, `${base}-${Date.now()}-${Math.round(Math.random() * 1e9)}${ext}`);
|
|
599
|
-
},
|
|
600
|
-
});
|
|
601
|
-
|
|
602
|
-
const fileFilter = allowedTypes
|
|
603
|
-
? (_req, file, cb) => {
|
|
604
|
-
if (allowedTypes.has(file.mimetype)) cb(null, true);
|
|
605
|
-
else
|
|
606
|
-
cb(
|
|
607
|
-
Object.assign(new Error(`File type not allowed: ${file.mimetype}`), {
|
|
608
|
-
status: 400,
|
|
609
|
-
}),
|
|
610
|
-
);
|
|
611
|
-
}
|
|
612
|
-
: undefined;
|
|
613
|
-
|
|
614
|
-
const limits = {};
|
|
615
|
-
if (uploadConfig.maxFileSize) limits.fileSize = uploadConfig.maxFileSize;
|
|
616
|
-
if (uploadConfig.maxFiles) limits.files = uploadConfig.maxFiles;
|
|
617
|
-
|
|
618
|
-
const uploader = multer({ storage, limits, fileFilter });
|
|
619
|
-
const multerMiddleware = uploadConfig.fieldName
|
|
620
|
-
? uploader.array(uploadConfig.fieldName)
|
|
621
|
-
: uploader.any();
|
|
622
|
-
|
|
623
|
-
router.post(uploadUrlPath, multerMiddleware, (req, res) => {
|
|
624
|
-
if (!req.files?.length) return res.status(400).json({ error: 'No files uploaded' });
|
|
625
|
-
res.json({
|
|
626
|
-
files: req.files.map((f) => ({
|
|
627
|
-
file: f.filename,
|
|
628
|
-
size: f.size,
|
|
629
|
-
originalName: f.originalname,
|
|
630
|
-
})),
|
|
631
|
-
});
|
|
632
|
-
});
|
|
633
|
-
|
|
634
|
-
router.use(uploadUrlPath, express.static(uploadDir));
|
|
635
|
-
console.log(`[upload] POST ${uploadUrlPath} → ${uploadDir}`);
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
if (siteConfig.proxy) {
|
|
640
|
-
addProxy(router, p, null, siteConfig.proxy);
|
|
641
|
-
}
|
|
642
|
-
|
|
639
|
+
configureLogging(router, siteConfig, configDir);
|
|
640
|
+
configureMiddleware(router, siteConfig);
|
|
641
|
+
if (siteConfig.redirects) setupRedirects(router, siteConfig.redirects);
|
|
642
|
+
if (siteConfig.folders) addStaticFolder(router, p, null, siteConfig.folders);
|
|
643
|
+
setupCgi(router, siteConfig, p, configDir);
|
|
644
|
+
setupUpload(router, siteConfig, configDir);
|
|
645
|
+
if (siteConfig.proxy) addProxy(router, p, null, siteConfig.proxy);
|
|
643
646
|
if (siteConfig.unhandled) {
|
|
644
|
-
router.use((req, res,
|
|
645
|
-
Object.
|
|
647
|
+
router.use((req, res, next) => {
|
|
648
|
+
const entries = Object.entries(siteConfig.unhandled);
|
|
649
|
+
for (const [acceptName, acceptConfig] of entries) {
|
|
650
|
+
if (acceptName && acceptName !== '*' && acceptName !== '**' && req.accepts(acceptName)) {
|
|
651
|
+
unhandled(res, acceptConfig);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
for (const [acceptName, acceptConfig] of entries) {
|
|
646
656
|
if (!acceptName || acceptName === '*' || acceptName === '**') {
|
|
647
|
-
unhandled(res,
|
|
648
|
-
|
|
649
|
-
unhandled(res, siteConfig.unhandled[acceptName]);
|
|
657
|
+
unhandled(res, acceptConfig);
|
|
658
|
+
return;
|
|
650
659
|
}
|
|
651
|
-
}
|
|
660
|
+
}
|
|
661
|
+
next();
|
|
652
662
|
});
|
|
653
663
|
}
|
|
654
|
-
|
|
655
664
|
if (siteHost === '*') {
|
|
656
665
|
app.use(router);
|
|
657
666
|
} else {
|