@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.
- package/README.md +98 -81
- package/package.json +4 -4
- 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
|
|
329
|
-
|
|
330
|
-
| `PORT`
|
|
331
|
-
| `NODE_ENV` | —
|
|
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
|
|
387
|
-
|
|
|
388
|
-
| `GET /__hot-reload__`
|
|
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
|
|
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
|
|
452
|
-
| `to` | — | Destination path or full URL
|
|
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": [
|
|
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` |
|
|
633
|
-
| `redirect` | `integer` |
|
|
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
|
|
784
|
-
|
|
|
785
|
-
| `windowMs`
|
|
786
|
-
| `limit`
|
|
787
|
-
| `message`
|
|
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
|
|
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
|
|
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
|
|
871
|
-
| -------------- |
|
|
872
|
-
| `path` | `"/cgi-bin"`
|
|
873
|
-
| `dir` | `"./cgi-bin"`
|
|
874
|
-
| `extensions` | `[".cgi", ".pl", ".py", ".sh"]`
|
|
875
|
-
| `interpreters` | `{}`
|
|
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
|
|
964
|
-
|
|
|
965
|
-
| `path`
|
|
966
|
-
| `dir`
|
|
967
|
-
| `maxFileSize`
|
|
968
|
-
| `maxFiles`
|
|
969
|
-
| `allowedTypes`
|
|
970
|
-
| `fieldName`
|
|
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
|
-
{
|
|
978
|
-
|
|
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
|
|
986
|
-
| ------ |
|
|
987
|
-
| `POST` | `<path>`
|
|
988
|
-
| `GET` | `<path>/<name>`
|
|
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
|
-
{
|
|
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",
|
|
1190
|
-
{ "from": "/products.html", "to": "/products",
|
|
1191
|
-
{ "from": "/blog/:slug",
|
|
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
|
|
1390
|
-
|
|
|
1391
|
-
| `npm run pm2-start`
|
|
1392
|
-
| `npm run pm2-restart`
|
|
1393
|
-
| `npm run pm2-stop`
|
|
1394
|
-
| `npm run pm2-status`
|
|
1395
|
-
| `npm run pm2-logs`
|
|
1396
|
-
| `npm run pm2-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.
|
|
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.
|
|
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
|
});
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
645
|
-
Object.
|
|
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,
|
|
648
|
-
|
|
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 {
|