@lopatnov/express-reverse-proxy 5.0.5 → 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.
Files changed (2) hide show
  1. package/package.json +4 -4
  2. 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.5",
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.0",
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.6",
88
- "cypress": "^15.11.0"
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 = Array(tabIndentLength).fill('\t').join('');
103
- const argTabIndent = Array(tabIndentLength - Math.trunc(arg.name.length / 4))
104
- .fill('\t')
105
- .join('');
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 (!fs.existsSync(configFile)) {
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
- // Hot reload via SSE
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
- if (siteConfig.logging !== false) {
411
- const lc = siteConfig.logging;
412
- if (typeof lc === 'object' && lc.file) {
413
- const stream = fs.createWriteStream(path.resolve(configDir, lc.file), { flags: 'a' });
414
- router.use(morgan(lc.format || 'combined', { stream }));
415
- } else {
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, _next) => {
645
- Object.keys(siteConfig.unhandled).forEach((acceptName) => {
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, siteConfig.unhandled[acceptName]);
648
- } else if (req.accepts(acceptName)) {
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 {