@lopatnov/express-reverse-proxy 5.0.5 → 5.0.7

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 (3) hide show
  1. package/README.md +98 -81
  2. package/package.json +4 -4
  3. package/server.js +316 -299
package/README.md CHANGED
@@ -269,14 +269,14 @@ If `server-config.json` already exists, the command asks before overwriting.
269
269
 
270
270
  Manage the PM2 process cluster. Action defaults to `start` when omitted.
271
271
 
272
- | Action | Description |
273
- | --------- | ------------------------------------------------------ |
274
- | `start` | Start the cluster (default when action is omitted) |
275
- | `stop` | Stop all cluster instances |
276
- | `restart` | Restart all cluster instances |
277
- | `status` | Show PM2 process status table |
278
- | `logs` | Stream the last 200 log lines |
279
- | `monitor` | Open the PM2 real-time monitor |
272
+ | Action | Description |
273
+ | --------- | -------------------------------------------------- |
274
+ | `start` | Start the cluster (default when action is omitted) |
275
+ | `stop` | Stop all cluster instances |
276
+ | `restart` | Restart all cluster instances |
277
+ | `status` | Show PM2 process status table |
278
+ | `logs` | Stream the last 200 log lines |
279
+ | `monitor` | Open the PM2 real-time monitor |
280
280
 
281
281
  ```shell
282
282
  express-reverse-proxy --cluster # same as --cluster start
@@ -325,10 +325,10 @@ Add a `$schema` reference to your config file to get property autocomplete, desc
325
325
 
326
326
  ### Environment variables
327
327
 
328
- | Variable | Default | Description |
329
- |----------|---------|-------------|
330
- | `PORT` | `8000` | Overrides the port when it is not set in the config file |
331
- | `NODE_ENV` | — | Passed through to PM2 env profiles (`env` / `env_development`) |
328
+ | Variable | Default | Description |
329
+ | ---------- | ------- | -------------------------------------------------------------- |
330
+ | `PORT` | `8000` | Overrides the port when it is not set in the config file |
331
+ | `NODE_ENV` | — | Passed through to PM2 env profiles (`env` / `env_development`) |
332
332
 
333
333
  ### port
334
334
 
@@ -383,10 +383,10 @@ Watches the `folders` directories for file changes and automatically reloads con
383
383
 
384
384
  The server exposes two endpoints when hot reload is enabled:
385
385
 
386
- | Endpoint | Description |
387
- | --------------------------------- | ------------------------------------------ |
388
- | `GET /__hot-reload__` | SSE stream — browsers subscribe here |
389
- | `GET /__hot-reload__/client.js` | Ready-to-use client script |
386
+ | Endpoint | Description |
387
+ | ------------------------------- | ------------------------------------ |
388
+ | `GET /__hot-reload__` | SSE stream — browsers subscribe here |
389
+ | `GET /__hot-reload__/client.js` | Ready-to-use client script |
390
390
 
391
391
  #### Connecting the client
392
392
 
@@ -399,7 +399,7 @@ The server exposes two endpoints when hot reload is enabled:
399
399
  **Option B — bundled project** (Vite, webpack, etc.): import the client module. The bundler resolves it through the package `exports` field:
400
400
 
401
401
  ```js
402
- import '@lopatnov/express-reverse-proxy/hot-reload-client';
402
+ import "@lopatnov/express-reverse-proxy/hot-reload-client";
403
403
  ```
404
404
 
405
405
  Both options connect to `/__hot-reload__` and call `location.reload()` when a file change is detected. The connection is re-established automatically after 3 seconds if the server restarts.
@@ -446,11 +446,11 @@ Permanently or temporarily redirect URL paths to new destinations. Redirects are
446
446
  }
447
447
  ```
448
448
 
449
- | Field | Default | Description |
450
- | -------- | ------- | -------------------------------------------------------- |
451
- | `from` | — | Source URL path *(array form only, required)* |
452
- | `to` | — | Destination path or full URL *(required)* |
453
- | `status` | `301` | HTTP redirect status: `301`, `302`, `307`, or `308` |
449
+ | Field | Default | Description |
450
+ | -------- | ------- | --------------------------------------------------- |
451
+ | `from` | — | Source URL path _(array form only, required)_ |
452
+ | `to` | — | Destination path or full URL _(required)_ |
453
+ | `status` | `301` | HTTP redirect status: `301`, `302`, `307`, or `308` |
454
454
 
455
455
  > `301` — Moved Permanently. `302` — Found (temporary). Use `301` for permanent URL changes and `302` for temporary ones.
456
456
 
@@ -538,7 +538,11 @@ Forward requests to a back-end server. Supports three forms:
538
538
  ```json
539
539
  {
540
540
  "proxy": {
541
- "/api": ["http://backend1:3000", "http://backend2:3000", "http://backend3:3000"]
541
+ "/api": [
542
+ "http://backend1:3000",
543
+ "http://backend2:3000",
544
+ "http://backend3:3000"
545
+ ]
542
546
  }
543
547
  }
544
548
  ```
@@ -629,8 +633,8 @@ Enable HTTPS on a port by adding an `ssl` object to any site config for that por
629
633
  | ---------- | --------- | -------------------------------------------------------- |
630
634
  | `key` | `string` | Path to the private key file (PEM format) |
631
635
  | `cert` | `string` | Path to the certificate file (PEM format) |
632
- | `ca` | `string` | *(optional)* Path to the CA bundle for client validation |
633
- | `redirect` | `integer` | *(optional)* HTTP port to redirect (301) to HTTPS |
636
+ | `ca` | `string` | _(optional)_ Path to the CA bundle for client validation |
637
+ | `redirect` | `integer` | _(optional)_ HTTP port to redirect (301) to HTTPS |
634
638
 
635
639
  Paths are resolved **relative to the config file**, not the current working directory.
636
640
 
@@ -780,11 +784,11 @@ Limit the number of requests a client can make in a time window. Responds with `
780
784
  }
781
785
  ```
782
786
 
783
- | Option | Default | Description |
784
- | ----------- | -------- | ------------------------------------------------ |
785
- | `windowMs` | `60000` | Time window in milliseconds |
786
- | `limit` | `5` | Maximum requests per client per window |
787
- | `message` | built-in | Response body when limit is exceeded |
787
+ | Option | Default | Description |
788
+ | ---------- | -------- | -------------------------------------- |
789
+ | `windowMs` | `60000` | Time window in milliseconds |
790
+ | `limit` | `5` | Maximum requests per client per window |
791
+ | `message` | built-in | Response body when limit is exceeded |
788
792
 
789
793
  See [express-rate-limit docs](https://express-rate-limit.mintlify.app/reference/configuration) for all options.
790
794
 
@@ -805,11 +809,11 @@ Protect the site with HTTP Basic Authentication. All requests must include valid
805
809
  }
806
810
  ```
807
811
 
808
- | Option | Default | Description |
809
- | ----------- | ------- | ------------------------------------------------------------ |
810
- | `users` | — | Object mapping username → password *(required)* |
812
+ | Option | Default | Description |
813
+ | ----------- | ------- | -------------------------------------------------------------- |
814
+ | `users` | — | Object mapping username → password _(required)_ |
811
815
  | `challenge` | `false` | Send `WWW-Authenticate` header to trigger browser login dialog |
812
- | `realm` | — | Realm string shown in the browser login dialog |
816
+ | `realm` | — | Realm string shown in the browser login dialog |
813
817
 
814
818
  See [express-basic-auth docs](https://github.com/LionC/express-basic-auth#options) for all options.
815
819
 
@@ -841,8 +845,8 @@ Custom path:
841
845
  }
842
846
  ```
843
847
 
844
- | Option | Default | Description |
845
- | ------ | -------------- | -------------------------------- |
848
+ | Option | Default | Description |
849
+ | ------ | --------------- | ------------------------------- |
846
850
  | `path` | `"/__health__"` | URL path of the health endpoint |
847
851
 
848
852
  > The health check endpoint is placed before rate limiting and basic auth — it is always publicly accessible regardless of other authentication settings.
@@ -867,12 +871,12 @@ Execute server-side scripts using the CGI (Common Gateway Interface) protocol. W
867
871
  }
868
872
  ```
869
873
 
870
- | Option | Default | Description |
871
- | -------------- | ------------------------------------- | -------------------------------------------------------------------- |
872
- | `path` | `"/cgi-bin"` | URL prefix that triggers CGI dispatch |
873
- | `dir` | `"./cgi-bin"` | Local directory containing scripts (resolved relative to config file)|
874
- | `extensions` | `[".cgi", ".pl", ".py", ".sh"]` | File extensions treated as executable CGI scripts |
875
- | `interpreters` | `{}` | Map of file extension → interpreter command |
874
+ | Option | Default | Description |
875
+ | -------------- | ------------------------------- | --------------------------------------------------------------------- |
876
+ | `path` | `"/cgi-bin"` | URL prefix that triggers CGI dispatch |
877
+ | `dir` | `"./cgi-bin"` | Local directory containing scripts (resolved relative to config file) |
878
+ | `extensions` | `[".cgi", ".pl", ".py", ".sh"]` | File extensions treated as executable CGI scripts |
879
+ | `interpreters` | `{}` | Map of file extension → interpreter command |
876
880
 
877
881
  Shorthand — point directly to the script directory (all defaults apply):
878
882
 
@@ -884,18 +888,18 @@ Shorthand — point directly to the script directory (all defaults apply):
884
888
 
885
889
  CGI environment variables set for every request:
886
890
 
887
- | Variable | Value |
888
- | ----------------- | -------------------------------------------------------- |
889
- | `REQUEST_METHOD` | HTTP method (`GET`, `POST`, …) |
890
- | `QUERY_STRING` | URL query string (without `?`) |
891
- | `CONTENT_TYPE` | `Content-Type` request header |
892
- | `CONTENT_LENGTH` | `Content-Length` request header |
893
- | `SCRIPT_FILENAME` | Absolute path to the script file |
894
- | `SCRIPT_NAME` | URL path to the script (e.g. `/cgi-bin/hello.py`) |
895
- | `SERVER_NAME` | Requested hostname |
896
- | `SERVER_PORT` | Server listen port |
897
- | `REMOTE_ADDR` | Client IP address |
898
- | `HTTP_*` | All request headers (e.g. `HTTP_ACCEPT`, `HTTP_HOST`) |
891
+ | Variable | Value |
892
+ | ----------------- | ----------------------------------------------------- |
893
+ | `REQUEST_METHOD` | HTTP method (`GET`, `POST`, …) |
894
+ | `QUERY_STRING` | URL query string (without `?`) |
895
+ | `CONTENT_TYPE` | `Content-Type` request header |
896
+ | `CONTENT_LENGTH` | `Content-Length` request header |
897
+ | `SCRIPT_FILENAME` | Absolute path to the script file |
898
+ | `SCRIPT_NAME` | URL path to the script (e.g. `/cgi-bin/hello.py`) |
899
+ | `SERVER_NAME` | Requested hostname |
900
+ | `SERVER_PORT` | Server listen port |
901
+ | `REMOTE_ADDR` | Client IP address |
902
+ | `HTTP_*` | All request headers (e.g. `HTTP_ACCEPT`, `HTTP_HOST`) |
899
903
 
900
904
  A minimal Python example (`cgi-bin/hello.py`):
901
905
 
@@ -960,39 +964,52 @@ Shorthand — directory only (all defaults apply):
960
964
  }
961
965
  ```
962
966
 
963
- | Option | Default | Description |
964
- | --------------- | -------------- | ------------------------------------------------------------------------ |
965
- | `path` | `"/upload"` | URL prefix for the upload endpoint |
966
- | `dir` | `"./uploads"` | Save directory (resolved relative to the config file) |
967
- | `maxFileSize` | none | Maximum file size in bytes; responds with `413` when exceeded |
968
- | `maxFiles` | none | Maximum number of files per request; responds with `400` when exceeded |
969
- | `allowedTypes` | none | MIME type whitelist; responds with `400` when the type is not in the list|
970
- | `fieldName` | any field | Accept only files uploaded in this specific form field |
967
+ | Option | Default | Description |
968
+ | -------------- | ------------- | ------------------------------------------------------------------------- |
969
+ | `path` | `"/upload"` | URL prefix for the upload endpoint |
970
+ | `dir` | `"./uploads"` | Save directory (resolved relative to the config file) |
971
+ | `maxFileSize` | none | Maximum file size in bytes; responds with `413` when exceeded |
972
+ | `maxFiles` | none | Maximum number of files per request; responds with `400` when exceeded |
973
+ | `allowedTypes` | none | MIME type whitelist; responds with `400` when the type is not in the list |
974
+ | `fieldName` | any field | Accept only files uploaded in this specific form field |
971
975
 
972
976
  **Array form** — multiple upload endpoints on the same site:
973
977
 
974
978
  ```json
975
979
  {
976
980
  "upload": [
977
- { "path": "/photos", "dir": "./photos", "allowedTypes": ["image/jpeg", "image/png"] },
978
- { "path": "/docs", "dir": "./documents", "allowedTypes": ["application/pdf"], "maxFileSize": 5242880 }
981
+ {
982
+ "path": "/photos",
983
+ "dir": "./photos",
984
+ "allowedTypes": ["image/jpeg", "image/png"]
985
+ },
986
+ {
987
+ "path": "/docs",
988
+ "dir": "./documents",
989
+ "allowedTypes": ["application/pdf"],
990
+ "maxFileSize": 5242880
991
+ }
979
992
  ]
980
993
  }
981
994
  ```
982
995
 
983
996
  **HTTP interface:**
984
997
 
985
- | Method | URL | Description |
986
- | ------ | ---------------- | ---------------------------------------- |
987
- | `POST` | `<path>` | Upload files via `multipart/form-data` |
988
- | `GET` | `<path>/<name>` | Retrieve a previously uploaded file |
998
+ | Method | URL | Description |
999
+ | ------ | --------------- | -------------------------------------- |
1000
+ | `POST` | `<path>` | Upload files via `multipart/form-data` |
1001
+ | `GET` | `<path>/<name>` | Retrieve a previously uploaded file |
989
1002
 
990
1003
  `POST` success response (`200`):
991
1004
 
992
1005
  ```json
993
1006
  {
994
1007
  "files": [
995
- { "file": "photo-1700000000000-123456789.jpg", "size": 45678, "originalName": "photo.jpg" }
1008
+ {
1009
+ "file": "photo-1700000000000-123456789.jpg",
1010
+ "size": 45678,
1011
+ "originalName": "photo.jpg"
1012
+ }
996
1013
  ]
997
1014
  }
998
1015
  ```
@@ -1186,9 +1203,9 @@ Redirect old URLs to new ones after a site restructure, without breaking existin
1186
1203
  {
1187
1204
  "port": 8080,
1188
1205
  "redirects": [
1189
- { "from": "/about.html", "to": "/about", "status": 301 },
1190
- { "from": "/products.html", "to": "/products", "status": 301 },
1191
- { "from": "/blog/:slug", "to": "/posts/:slug", "status": 301 }
1206
+ { "from": "/about.html", "to": "/about", "status": 301 },
1207
+ { "from": "/products.html", "to": "/products", "status": 301 },
1208
+ { "from": "/blog/:slug", "to": "/posts/:slug", "status": 301 }
1192
1209
  ],
1193
1210
  "folders": "./public"
1194
1211
  }
@@ -1199,7 +1216,7 @@ Or as an object map for simple path-to-path redirects:
1199
1216
  ```json
1200
1217
  {
1201
1218
  "redirects": {
1202
- "/old-home": "/",
1219
+ "/old-home": "/",
1203
1220
  "/old-about": "/about",
1204
1221
  "/legacy-api": "https://api.example.com"
1205
1222
  }
@@ -1386,14 +1403,14 @@ express-reverse-proxy --cluster start --cluster-config ./my-ecosystem.config.cjs
1386
1403
 
1387
1404
  Run via npm scripts:
1388
1405
 
1389
- | Script | Description |
1390
- | ----------------------- | ------------------------------------------------------------------ |
1391
- | `npm run pm2-start` | Start cluster (max CPU cores); reads `server-config.json` from cwd |
1392
- | `npm run pm2-restart` | Restart all instances |
1393
- | `npm run pm2-stop` | Stop all instances |
1394
- | `npm run pm2-status` | Show process status |
1395
- | `npm run pm2-logs` | Show last 200 log lines |
1396
- | `npm run pm2-monitor` | Open real-time monitor |
1406
+ | Script | Description |
1407
+ | --------------------- | ------------------------------------------------------------------ |
1408
+ | `npm run pm2-start` | Start cluster (max CPU cores); reads `server-config.json` from cwd |
1409
+ | `npm run pm2-restart` | Restart all instances |
1410
+ | `npm run pm2-stop` | Stop all instances |
1411
+ | `npm run pm2-status` | Show process status |
1412
+ | `npm run pm2-logs` | Show last 200 log lines |
1413
+ | `npm run pm2-monitor` | Open real-time monitor |
1397
1414
 
1398
1415
  Or use the CLI directly:
1399
1416
 
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.7",
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
  });
@@ -319,7 +316,15 @@ function addMappedProxy(router, port, localRootPath, pathPairs) {
319
316
 
320
317
  function addProxies(router, port, localRootPath, proxies) {
321
318
  proxies.forEach((proxyUrl) => {
322
- addRemoteProxy(router, port, localRootPath, proxyUrl);
319
+ if (
320
+ Array.isArray(proxyUrl) &&
321
+ proxyUrl.length > 0 &&
322
+ proxyUrl.every((i) => typeof i === 'string')
323
+ ) {
324
+ addRemoteProxy(router, port, localRootPath, proxyUrl);
325
+ } else {
326
+ addProxy(router, port, localRootPath, proxyUrl);
327
+ }
323
328
  });
324
329
  }
325
330
 
@@ -333,6 +338,275 @@ function addProxy(router, port, localRootPath, remoteProxy) {
333
338
  }
334
339
  }
335
340
 
341
+ function configureLogging(router, siteConfig, configDir) {
342
+ if (siteConfig.logging === false) return;
343
+ const lc = siteConfig.logging;
344
+ if (typeof lc === 'object' && lc.file) {
345
+ const stream = fs.createWriteStream(path.resolve(configDir, lc.file), { flags: 'a' });
346
+ router.use(morgan(lc.format || 'combined', { stream }));
347
+ } else {
348
+ router.use(morgan((typeof lc === 'object' ? lc.format : null) || 'dev'));
349
+ }
350
+ }
351
+
352
+ function toOpts(val) {
353
+ return typeof val === 'object' ? val : {};
354
+ }
355
+
356
+ function configureMiddleware(router, siteConfig) {
357
+ if (siteConfig.responseTime) router.use(responseTime(toOpts(siteConfig.responseTime)));
358
+ if (siteConfig.cors) router.use(cors(toOpts(siteConfig.cors)));
359
+ if (siteConfig.compression) router.use(compression(toOpts(siteConfig.compression)));
360
+ if (siteConfig.helmet) router.use(helmet(toOpts(siteConfig.helmet)));
361
+ if (siteConfig.favicon) router.use(favicon(path.resolve(configDir, siteConfig.favicon)));
362
+ if (siteConfig.healthCheck) {
363
+ const hcPath =
364
+ (typeof siteConfig.healthCheck === 'object' && siteConfig.healthCheck.path) || '/__health__';
365
+ router.get(hcPath, (_req, res) => {
366
+ res.json({ status: 'ok', uptime: process.uptime(), timestamp: new Date().toISOString() });
367
+ });
368
+ }
369
+ if (siteConfig.rateLimit) router.use(rateLimit(toOpts(siteConfig.rateLimit)));
370
+ if (siteConfig.basicAuth) router.use(basicAuth(toOpts(siteConfig.basicAuth)));
371
+ if (siteConfig.headers) {
372
+ router.use((_req, res, next) => {
373
+ for (const h of Object.keys(siteConfig.headers)) res.setHeader(h, siteConfig.headers[h]);
374
+ next();
375
+ });
376
+ }
377
+ }
378
+
379
+ function setupRedirects(router, redirects) {
380
+ if (Array.isArray(redirects)) {
381
+ for (const r of redirects) {
382
+ router.all(r.from, (_req, res) => res.redirect(r.status || 301, r.to));
383
+ }
384
+ } else {
385
+ for (const [from, to] of Object.entries(redirects)) {
386
+ const dest = typeof to === 'string' ? to : to.to;
387
+ const status = typeof to === 'object' ? to.status || 301 : 301;
388
+ router.all(from, (_req, res) => res.redirect(status, dest));
389
+ }
390
+ }
391
+ }
392
+
393
+ function buildCgiEnv(req, scriptPath, cgiUrlPath, p) {
394
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
395
+ const env = {
396
+ ...process.env,
397
+ GATEWAY_INTERFACE: 'CGI/1.1',
398
+ SERVER_PROTOCOL: 'HTTP/1.1',
399
+ SERVER_SOFTWARE: 'express-reverse-proxy',
400
+ REQUEST_METHOD: req.method.toUpperCase(),
401
+ SCRIPT_FILENAME: scriptPath,
402
+ SCRIPT_NAME: cgiUrlPath + req.path,
403
+ PATH_INFO: '',
404
+ QUERY_STRING: url.search ? url.search.slice(1) : '',
405
+ REMOTE_ADDR: req.ip || '127.0.0.1',
406
+ CONTENT_TYPE: req.headers['content-type'] || '',
407
+ CONTENT_LENGTH: req.headers['content-length'] || '0',
408
+ SERVER_NAME: req.hostname || 'localhost',
409
+ SERVER_PORT: String(p),
410
+ };
411
+ for (const [k, v] of Object.entries(req.headers)) {
412
+ env[`HTTP_${k.toUpperCase().replaceAll('-', '_')}`] = Array.isArray(v) ? v.join(', ') : v;
413
+ }
414
+ return env;
415
+ }
416
+
417
+ function applyCgiHeaders(rawHeaders, res) {
418
+ let statusCode = 200;
419
+ for (const line of rawHeaders.split(/\r?\n/)) {
420
+ const colon = line.indexOf(':');
421
+ if (colon === -1) continue;
422
+ const name = line.substring(0, colon).trim();
423
+ const value = line.substring(colon + 1).trim();
424
+ if (name.toLowerCase() === 'status') {
425
+ statusCode = Number.parseInt(value, 10) || 200;
426
+ } else {
427
+ res.setHeader(name, value);
428
+ }
429
+ }
430
+ return statusCode;
431
+ }
432
+
433
+ function setupCgi(router, siteConfig, p, configDir) {
434
+ if (!siteConfig.cgi) return;
435
+ const cgiRaw = siteConfig.cgi;
436
+ const cgiConfigs = Array.isArray(cgiRaw)
437
+ ? cgiRaw
438
+ : [typeof cgiRaw === 'string' ? { dir: cgiRaw } : cgiRaw];
439
+
440
+ for (const cgiConfig of cgiConfigs) {
441
+ const cgiUrlPath = cgiConfig.path || '/cgi-bin';
442
+ const cgiDirResolved = path.resolve(configDir, cgiConfig.dir || './cgi-bin');
443
+ const cgiExts = new Set(cgiConfig.extensions || ['.cgi', '.pl', '.py', '.sh']);
444
+ const interps = cgiConfig.interpreters || {};
445
+
446
+ router.use(cgiUrlPath, (req, res, next) => {
447
+ const scriptPath = path.resolve(path.join(cgiDirResolved, req.path));
448
+ if (!scriptPath.startsWith(cgiDirResolved + path.sep)) return next();
449
+
450
+ const ext = path.extname(scriptPath);
451
+ if (!cgiExts.has(ext)) return next();
452
+ let scriptStat;
453
+ try {
454
+ scriptStat = fs.lstatSync(scriptPath);
455
+ } catch {
456
+ return next();
457
+ }
458
+ if (!scriptStat.isFile() || scriptStat.isSymbolicLink()) return next();
459
+
460
+ const env = buildCgiEnv(req, scriptPath, cgiUrlPath, p);
461
+ const interpreter = interps[ext];
462
+ const command = interpreter || scriptPath;
463
+ const args = interpreter ? [scriptPath] : [];
464
+ const child = spawn(command, args, { env, cwd: path.dirname(scriptPath), shell: false });
465
+
466
+ child.stdin.on('error', (_err) => {});
467
+ req.pipe(child.stdin);
468
+
469
+ res.on('drain', () => child.stdout.resume());
470
+
471
+ let headersParsed = false;
472
+ let rawBuf = '';
473
+ child.stdout.on('data', (chunk) => {
474
+ if (headersParsed) {
475
+ if (!res.write(chunk)) child.stdout.pause();
476
+ } else {
477
+ rawBuf += chunk.toString('binary');
478
+ if (rawBuf.length > 65536) {
479
+ child.stdout.destroy();
480
+ child.kill();
481
+ res.status(500).send('CGI headers too large');
482
+ return;
483
+ }
484
+ const m = /\r?\n\r?\n/.exec(rawBuf);
485
+ if (m) {
486
+ const rawHeaders = rawBuf.substring(0, m.index);
487
+ const bodyStart = Buffer.from(rawBuf.substring(m.index + m[0].length), 'binary');
488
+ headersParsed = true;
489
+ res.status(applyCgiHeaders(rawHeaders, res));
490
+ if (bodyStart.length && !res.write(bodyStart)) child.stdout.pause();
491
+ }
492
+ }
493
+ });
494
+ child.stdout.on('end', () => {
495
+ if (headersParsed) {
496
+ res.end();
497
+ } else if (!res.headersSent) {
498
+ res.status(500).send('CGI script produced no output');
499
+ }
500
+ });
501
+ child.stderr.on('data', (data) => console.error(`[cgi] ${scriptPath}: ${data}`));
502
+ child.on('error', (err) => {
503
+ console.error(`[cgi] spawn error for ${scriptPath}: ${err.message}`);
504
+ if (!res.headersSent) res.status(500).send(`CGI error: ${err.message}`);
505
+ });
506
+ console.log(`[cgi] ${req.method} ${cgiUrlPath}${req.path} → ${scriptPath}`);
507
+ });
508
+ }
509
+ }
510
+
511
+ function buildFileFilter(allowedTypes) {
512
+ if (!allowedTypes) return undefined;
513
+ return (_req, file, cb) => {
514
+ if (allowedTypes.has(file.mimetype)) cb(null, true);
515
+ else cb(Object.assign(new Error(`File type not allowed: ${file.mimetype}`), { status: 400 }));
516
+ };
517
+ }
518
+
519
+ function handleUploadResponse(req, res) {
520
+ if (!req.files?.length) return res.status(400).json({ error: 'No files uploaded' });
521
+ res.json({
522
+ files: req.files.map((f) => ({ file: f.filename, size: f.size, originalName: f.originalname })),
523
+ });
524
+ }
525
+
526
+ function setupUpload(router, siteConfig, configDir) {
527
+ if (!siteConfig.upload) return;
528
+ const uploadRaw = siteConfig.upload;
529
+ const uploadConfigs = Array.isArray(uploadRaw)
530
+ ? uploadRaw
531
+ : [typeof uploadRaw === 'string' ? { dir: uploadRaw } : uploadRaw];
532
+
533
+ for (const uploadConfig of uploadConfigs) {
534
+ const uploadUrlPath = uploadConfig.path || '/upload';
535
+ const uploadDir = path.resolve(configDir, uploadConfig.dir || './uploads');
536
+ const allowedTypes = uploadConfig.allowedTypes ? new Set(uploadConfig.allowedTypes) : null;
537
+
538
+ fs.mkdirSync(uploadDir, { recursive: true });
539
+
540
+ const storage = multer.diskStorage({
541
+ destination: uploadDir,
542
+ filename: (_req, file, cb) => {
543
+ const ext = path.extname(file.originalname);
544
+ const base =
545
+ path.basename(file.originalname, ext).replaceAll(/[^a-zA-Z0-9_.-]/g, '_') || 'file';
546
+ cb(null, `${base}-${Date.now()}-${Math.round(Math.random() * 1e9)}${ext}`);
547
+ },
548
+ });
549
+
550
+ const limits = {};
551
+ if (uploadConfig.maxFileSize) limits.fileSize = uploadConfig.maxFileSize;
552
+ if (uploadConfig.maxFiles) limits.files = uploadConfig.maxFiles;
553
+
554
+ const uploader = multer({ storage, limits, fileFilter: buildFileFilter(allowedTypes) });
555
+ const multerMiddleware = uploadConfig.fieldName
556
+ ? uploader.array(uploadConfig.fieldName)
557
+ : uploader.any();
558
+
559
+ router.post(uploadUrlPath, multerMiddleware, handleUploadResponse);
560
+ router.use(uploadUrlPath, express.static(uploadDir));
561
+ console.log(`[upload] POST ${uploadUrlPath} → ${uploadDir}`);
562
+ }
563
+ }
564
+
565
+ function setupHotReload(app, portConfigs, p) {
566
+ const hotReloadEnabled = portConfigs.some((c) => c.hotReload === true);
567
+ if (!hotReloadEnabled) return;
568
+
569
+ const hotReloadClientJs = fs.readFileSync(path.join(__dirname, 'hot-reload-client.js'), 'utf8');
570
+ const sseClients = new Set();
571
+ let reloadTimer = null;
572
+
573
+ app.get('/__hot-reload__', (req, res) => {
574
+ res.setHeader('Content-Type', 'text/event-stream');
575
+ res.setHeader('Cache-Control', 'no-cache');
576
+ res.setHeader('Connection', 'keep-alive');
577
+ res.flushHeaders();
578
+ sseClients.add(res);
579
+ req.on('close', () => sseClients.delete(res));
580
+ });
581
+
582
+ app.get('/__hot-reload__/client.js', (_req, res) => {
583
+ res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
584
+ res.send(hotReloadClientJs);
585
+ });
586
+
587
+ const watchPaths = [...new Set(portConfigs.flatMap((c) => collectFolderPaths(c.folders || [])))];
588
+ for (const folder of watchPaths) {
589
+ const absPath = path.isAbsolute(folder) ? folder : path.join(process.cwd(), folder);
590
+ if (fs.existsSync(absPath)) {
591
+ const onChange = () => {
592
+ clearTimeout(reloadTimer);
593
+ reloadTimer = setTimeout(() => {
594
+ for (const client of sseClients) client.write('data: reload\n\n');
595
+ }, 100);
596
+ };
597
+ try {
598
+ fs.watch(absPath, { recursive: true }, onChange);
599
+ } catch {
600
+ console.warn(
601
+ `[hot-reload] recursive watch not supported on this platform, falling back for ${absPath}`,
602
+ );
603
+ fs.watch(absPath, onChange);
604
+ }
605
+ }
606
+ }
607
+ console.log(`[hot-reload] watching ${watchPaths.length} folder(s) on port ${p}`);
608
+ }
609
+
336
610
  function unhandled(res, acceptConfig) {
337
611
  const headers = (acceptConfig.headers && Object.keys(acceptConfig.headers)) || [];
338
612
  for (const header of headers) res.setHeader(header, acceptConfig.headers[header]);
@@ -358,42 +632,7 @@ const servers = [];
358
632
 
359
633
  configsByPort.forEach((portConfigs, p) => {
360
634
  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
- }
635
+ setupHotReload(app, portConfigs, p);
397
636
 
398
637
  // Specific hosts first, catch-all last
399
638
  const sorted = [
@@ -404,254 +643,32 @@ configsByPort.forEach((portConfigs, p) => {
404
643
  sorted.forEach((siteConfig) => {
405
644
  const siteHost = siteConfig.host || '*';
406
645
  const router = express.Router();
407
-
408
646
  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
-
647
+ configureLogging(router, siteConfig, configDir);
648
+ configureMiddleware(router, siteConfig);
649
+ if (siteConfig.redirects) setupRedirects(router, siteConfig.redirects);
650
+ if (siteConfig.folders) addStaticFolder(router, p, null, siteConfig.folders);
651
+ setupCgi(router, siteConfig, p, configDir);
652
+ setupUpload(router, siteConfig, configDir);
653
+ if (siteConfig.proxy) addProxy(router, p, null, siteConfig.proxy);
643
654
  if (siteConfig.unhandled) {
644
- router.use((req, res, _next) => {
645
- Object.keys(siteConfig.unhandled).forEach((acceptName) => {
655
+ router.use((req, res, next) => {
656
+ const entries = Object.entries(siteConfig.unhandled);
657
+ for (const [acceptName, acceptConfig] of entries) {
658
+ if (acceptName && acceptName !== '*' && acceptName !== '**' && req.accepts(acceptName)) {
659
+ unhandled(res, acceptConfig);
660
+ return;
661
+ }
662
+ }
663
+ for (const [acceptName, acceptConfig] of entries) {
646
664
  if (!acceptName || acceptName === '*' || acceptName === '**') {
647
- unhandled(res, siteConfig.unhandled[acceptName]);
648
- } else if (req.accepts(acceptName)) {
649
- unhandled(res, siteConfig.unhandled[acceptName]);
665
+ unhandled(res, acceptConfig);
666
+ return;
650
667
  }
651
- });
668
+ }
669
+ next();
652
670
  });
653
671
  }
654
-
655
672
  if (siteHost === '*') {
656
673
  app.use(router);
657
674
  } else {