@lopatnov/express-reverse-proxy 4.0.0 → 5.0.0

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/server.js CHANGED
@@ -1,15 +1,19 @@
1
1
  #!/usr/bin/env node
2
- import { spawnSync } from 'node:child_process';
2
+ import { spawn, spawnSync } from 'node:child_process';
3
3
  import fs from 'node:fs';
4
4
  import https from 'node:https';
5
5
  import path from 'node:path';
6
+ import readline from 'node:readline/promises';
6
7
  import { fileURLToPath } from 'node:url';
7
8
  import compression from 'compression';
8
9
  import cors from 'cors';
9
10
  import express from 'express';
11
+ import basicAuth from 'express-basic-auth';
10
12
  import proxy from 'express-http-proxy';
13
+ import rateLimit from 'express-rate-limit';
11
14
  import helmet from 'helmet';
12
15
  import morgan from 'morgan';
16
+ import multer from 'multer';
13
17
  import responseTime from 'response-time';
14
18
  import favicon from 'serve-favicon';
15
19
 
@@ -52,6 +56,10 @@ const possibleServerArgs = [
52
56
  '--cluster restart --cluster-config /etc/myapp/ecosystem.config.cjs',
53
57
  ],
54
58
  },
59
+ {
60
+ name: '--init',
61
+ description: 'interactively creates a server-config.json in the current directory',
62
+ },
55
63
  ];
56
64
 
57
65
  function exitError(msg, code = -1) {
@@ -150,6 +158,30 @@ if (serverArgs['--cluster']) {
150
158
  process.exit(result.status ?? 0);
151
159
  }
152
160
 
161
+ if (serverArgs['--init']) {
162
+ const configOut = path.resolve(process.cwd(), 'server-config.json');
163
+ if (fs.existsSync(configOut)) {
164
+ const rl2 = readline.createInterface({ input: process.stdin, output: process.stdout });
165
+ const ans = await rl2.question(`${configOut} already exists. Overwrite? [y/N]: `);
166
+ rl2.close();
167
+ if (ans.trim().toLowerCase() !== 'y') process.exit(0);
168
+ }
169
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
170
+ const port = (await rl.question('Port [8000]: ')).trim() || '8000';
171
+ const folder = (await rl.question('Static folder [.]: ')).trim() || '.';
172
+ const proxyPath = (await rl.question('Proxy path (e.g. /api) [skip]: ')).trim();
173
+ let proxyTarget = '';
174
+ if (proxyPath) proxyTarget = (await rl.question(`Proxy target for ${proxyPath}: `)).trim();
175
+ const hotReload = (await rl.question('Hot reload? [y/N]: ')).trim().toLowerCase() === 'y';
176
+ rl.close();
177
+ const cfg = { port: parseInt(port, 10), folders: folder };
178
+ if (proxyPath && proxyTarget) cfg.proxy = { [proxyPath]: proxyTarget };
179
+ if (hotReload) cfg.hotReload = true;
180
+ fs.writeFileSync(configOut, `${JSON.stringify(cfg, null, 2)}\n`);
181
+ console.log(`[init] Created ${configOut}`);
182
+ process.exit(0);
183
+ }
184
+
153
185
  let configFile = './server-config.json';
154
186
  if (serverArgs['--config']) {
155
187
  [configFile] = serverArgs['--config'].args;
@@ -255,12 +287,26 @@ function addStaticFolder(router, port, rootPath, folder) {
255
287
  }
256
288
 
257
289
  function addRemoteProxy(router, port, urlPath, proxyServer) {
258
- if (urlPath) {
259
- router.use(urlPath, proxy(proxyServer));
290
+ if (Array.isArray(proxyServer)) {
291
+ let i = 0;
292
+ const targets = proxyServer;
293
+ const balancedProxy = proxy((_req) => targets[i++ % targets.length]);
294
+ if (urlPath) {
295
+ router.use(urlPath, balancedProxy);
296
+ } else {
297
+ router.use(balancedProxy);
298
+ }
299
+ console.log(
300
+ `[proxy] http://localhost:${port}${urlPath || ''} <===> [${targets.join(', ')}] (round-robin)`,
301
+ );
260
302
  } else {
261
- router.use(proxy(proxyServer));
303
+ if (urlPath) {
304
+ router.use(urlPath, proxy(proxyServer));
305
+ } else {
306
+ router.use(proxy(proxyServer));
307
+ }
308
+ console.log(`[proxy] http://localhost:${port}${urlPath || ''} <===> ${proxyServer}`);
262
309
  }
263
- console.log(`[proxy] http://localhost:${port}${urlPath || ''} <===> ${proxyServer}`);
264
310
  }
265
311
 
266
312
  function addMappedProxy(router, port, localRootPath, pathPairs) {
@@ -312,11 +358,6 @@ const servers = [];
312
358
 
313
359
  configsByPort.forEach((portConfigs, p) => {
314
360
  const app = express();
315
- const loggingEnabled = portConfigs.every((c) => c.logging !== false);
316
- if (loggingEnabled) {
317
- app.use(morgan('combined'));
318
- }
319
-
320
361
  // Hot reload via SSE
321
362
  const hotReloadEnabled = portConfigs.some((c) => c.hotReload === true);
322
363
  if (hotReloadEnabled) {
@@ -366,6 +407,16 @@ configsByPort.forEach((portConfigs, p) => {
366
407
 
367
408
  console.log(`[host] ${siteHost} → :${p}`);
368
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
+
369
420
  if (siteConfig.responseTime) {
370
421
  const opts = typeof siteConfig.responseTime === 'object' ? siteConfig.responseTime : {};
371
422
  router.use(responseTime(opts));
@@ -390,6 +441,25 @@ configsByPort.forEach((portConfigs, p) => {
390
441
  router.use(favicon(path.resolve(configDir, siteConfig.favicon)));
391
442
  }
392
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
+
393
463
  if (siteConfig.headers) {
394
464
  router.use((_req, res, next) => {
395
465
  for (const h of Object.keys(siteConfig.headers)) res.setHeader(h, siteConfig.headers[h]);
@@ -397,10 +467,175 @@ configsByPort.forEach((portConfigs, p) => {
397
467
  });
398
468
  }
399
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
+
400
485
  if (siteConfig.folders) {
401
486
  addStaticFolder(router, p, null, siteConfig.folders);
402
487
  }
403
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
+
404
639
  if (siteConfig.proxy) {
405
640
  addProxy(router, p, null, siteConfig.proxy);
406
641
  }
@@ -446,6 +681,16 @@ configsByPort.forEach((portConfigs, p) => {
446
681
  console.log(`[listen] https://localhost:${p}`);
447
682
  if (process.send) process.send('ready');
448
683
  });
684
+ if (sslConfig.redirect) {
685
+ const redirectPort = sslConfig.redirect;
686
+ const redirectApp = express();
687
+ redirectApp.use((req, res) => {
688
+ res.redirect(301, `https://${req.hostname}:${p}${req.url}`);
689
+ });
690
+ redirectApp.listen(redirectPort, () => {
691
+ console.log(`[listen] http redirect :${redirectPort} → https :${p}`);
692
+ });
693
+ }
449
694
  } else {
450
695
  server = app.listen(p, () => {
451
696
  console.log(`[listen] http://localhost:${p}`);