@lopatnov/express-reverse-proxy 2.0.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +302 -0
- package/ecosystem.config.cjs +24 -24
- package/hot-reload-client.js +31 -0
- package/package.json +12 -4
- package/server.js +112 -5
package/README.md
CHANGED
|
@@ -20,11 +20,19 @@
|
|
|
20
20
|
- [CLI Options](#cli-options)
|
|
21
21
|
- [Configuration](#configuration)
|
|
22
22
|
- [port](#port)
|
|
23
|
+
- [logging](#logging)
|
|
24
|
+
- [hotReload](#hotreload)
|
|
25
|
+
- [compression](#compression)
|
|
26
|
+
- [helmet](#helmet)
|
|
27
|
+
- [cors](#cors)
|
|
28
|
+
- [favicon](#favicon)
|
|
29
|
+
- [responseTime](#responsetime)
|
|
23
30
|
- [headers](#headers)
|
|
24
31
|
- [folders](#folders)
|
|
25
32
|
- [proxy](#proxy)
|
|
26
33
|
- [unhandled](#unhandled)
|
|
27
34
|
- [host](#host)
|
|
35
|
+
- [ssl](#ssl)
|
|
28
36
|
- [Configuration Recipes](#configuration-recipes)
|
|
29
37
|
- [Docker & PM2](#docker--pm2)
|
|
30
38
|
- [Testing](#testing)
|
|
@@ -154,6 +162,15 @@ Static files always take priority over proxy rules. Proxies are checked only whe
|
|
|
154
162
|
|
|
155
163
|
## CLI Options
|
|
156
164
|
|
|
165
|
+
The package installs two equivalent commands — use whichever you prefer:
|
|
166
|
+
|
|
167
|
+
```shell
|
|
168
|
+
express-reverse-proxy [options]
|
|
169
|
+
lerp [options]
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`lerp` is a short alias for **L**opatnov **E**xpress **R**everse **P**roxy.
|
|
173
|
+
|
|
157
174
|
| Option | Description |
|
|
158
175
|
| ------------------------- | ----------------------------------------------------------------------------------------------- |
|
|
159
176
|
| `--help` | Print help and exit |
|
|
@@ -218,6 +235,55 @@ The port the server listens on. Defaults to `8000`. Can also be set via the `POR
|
|
|
218
235
|
}
|
|
219
236
|
```
|
|
220
237
|
|
|
238
|
+
### logging
|
|
239
|
+
|
|
240
|
+
Controls HTTP request logging (Morgan). Enabled by default. Set to `false` to silence per-request log lines — useful in production behind another proxy, or to keep console output clean.
|
|
241
|
+
|
|
242
|
+
```json
|
|
243
|
+
{
|
|
244
|
+
"port": 8080,
|
|
245
|
+
"logging": false,
|
|
246
|
+
"folders": "www"
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### hotReload
|
|
251
|
+
|
|
252
|
+
Watches the `folders` directories for file changes and automatically reloads connected browser tabs. Uses Server-Sent Events (SSE). Intended for local development only.
|
|
253
|
+
|
|
254
|
+
```json
|
|
255
|
+
{
|
|
256
|
+
"port": 8080,
|
|
257
|
+
"hotReload": true,
|
|
258
|
+
"folders": "www"
|
|
259
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
The server exposes two endpoints when hot reload is enabled:
|
|
263
|
+
|
|
264
|
+
| Endpoint | Description |
|
|
265
|
+
| --------------------------------- | ------------------------------------------ |
|
|
266
|
+
| `GET /__hot-reload__` | SSE stream — browsers subscribe here |
|
|
267
|
+
| `GET /__hot-reload__/client.js` | Ready-to-use client script |
|
|
268
|
+
|
|
269
|
+
#### Connecting the client
|
|
270
|
+
|
|
271
|
+
**Option A — plain HTML project**: add a script tag to your page. The file is served directly by the dev server, no installation needed:
|
|
272
|
+
|
|
273
|
+
```html
|
|
274
|
+
<script src="/__hot-reload__/client.js"></script>
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
**Option B — bundled project** (Vite, webpack, etc.): import the client module. The bundler resolves it through the package `exports` field:
|
|
278
|
+
|
|
279
|
+
```js
|
|
280
|
+
import '@lopatnov/express-reverse-proxy/hot-reload-client';
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
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.
|
|
284
|
+
|
|
285
|
+
> **PM2 note:** hot reload works best with a single process (`node server.js`). If using PM2, set `instances: 1` in your ecosystem config — each worker maintains its own file watcher and SSE client list independently.
|
|
286
|
+
|
|
221
287
|
### headers
|
|
222
288
|
|
|
223
289
|
Add headers to every response — useful for CORS in development.
|
|
@@ -385,6 +451,136 @@ To use multi-site mode, make the config file an **array** instead of an object.
|
|
|
385
451
|
>
|
|
386
452
|
> Two entries with the same `host` **and** `port` cause a startup error. The same `host` on different ports is allowed.
|
|
387
453
|
|
|
454
|
+
### ssl
|
|
455
|
+
|
|
456
|
+
Enable HTTPS on a port by adding an `ssl` object to any site config for that port. All sites sharing the same port use the same certificate.
|
|
457
|
+
|
|
458
|
+
| Field | Type | Description |
|
|
459
|
+
| ------ | -------- | -------------------------------------------------------- |
|
|
460
|
+
| `key` | `string` | Path to the private key file (PEM format) |
|
|
461
|
+
| `cert` | `string` | Path to the certificate file (PEM format) |
|
|
462
|
+
| `ca` | `string` | *(optional)* Path to the CA bundle for client validation |
|
|
463
|
+
|
|
464
|
+
Paths are resolved **relative to the config file**, not the current working directory.
|
|
465
|
+
|
|
466
|
+
```json
|
|
467
|
+
{
|
|
468
|
+
"port": 443,
|
|
469
|
+
"ssl": {
|
|
470
|
+
"key": "./certs/key.pem",
|
|
471
|
+
"cert": "./certs/cert.pem"
|
|
472
|
+
},
|
|
473
|
+
"folders": "./public",
|
|
474
|
+
"proxy": {
|
|
475
|
+
"/api": "http://localhost:4000"
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
> All site configs on the same port must either all have `ssl` or none — mixing is a startup error.
|
|
481
|
+
|
|
482
|
+
### compression
|
|
483
|
+
|
|
484
|
+
Enable gzip/deflate response compression. Reduces the size of HTML, CSS, JS, and JSON responses sent to the browser. Set to `true` for defaults, or pass an options object.
|
|
485
|
+
|
|
486
|
+
```json
|
|
487
|
+
{
|
|
488
|
+
"port": 8080,
|
|
489
|
+
"compression": true,
|
|
490
|
+
"folders": "www"
|
|
491
|
+
}
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
With custom options (see [compression docs](https://github.com/expressjs/compression#options)):
|
|
495
|
+
|
|
496
|
+
```json
|
|
497
|
+
{
|
|
498
|
+
"compression": { "level": 6, "threshold": 1024 }
|
|
499
|
+
}
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
> Compression is applied per-site. Assets that are already compressed (images, fonts, video) are not affected — the browser signals it accepts compressed responses via the `Accept-Encoding` header.
|
|
503
|
+
|
|
504
|
+
### helmet
|
|
505
|
+
|
|
506
|
+
Set security-related HTTP response headers. Protects against common web vulnerabilities by configuring headers such as `Content-Security-Policy`, `X-Frame-Options`, `Strict-Transport-Security`, and others.
|
|
507
|
+
|
|
508
|
+
```json
|
|
509
|
+
{
|
|
510
|
+
"port": 8080,
|
|
511
|
+
"helmet": true,
|
|
512
|
+
"folders": "www"
|
|
513
|
+
}
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
Disable a specific header (see [helmet docs](https://helmetjs.github.io/) for all options):
|
|
517
|
+
|
|
518
|
+
```json
|
|
519
|
+
{
|
|
520
|
+
"helmet": { "contentSecurityPolicy": false }
|
|
521
|
+
}
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
> When `helmet: true` is set, the default helmet configuration is applied. This may block inline scripts and cross-origin resources. Adjust `contentSecurityPolicy` or other options as needed for your project.
|
|
525
|
+
|
|
526
|
+
### cors
|
|
527
|
+
|
|
528
|
+
Enable CORS (Cross-Origin Resource Sharing) headers and handle preflight `OPTIONS` requests automatically. Useful when your front-end on one origin calls an API on a different origin.
|
|
529
|
+
|
|
530
|
+
```json
|
|
531
|
+
{
|
|
532
|
+
"port": 8080,
|
|
533
|
+
"cors": true,
|
|
534
|
+
"proxy": { "/api": "http://localhost:4000" }
|
|
535
|
+
}
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
Restrict to a specific origin (see [cors docs](https://github.com/expressjs/cors#configuration-options)):
|
|
539
|
+
|
|
540
|
+
```json
|
|
541
|
+
{
|
|
542
|
+
"cors": { "origin": "https://app.example.com" }
|
|
543
|
+
}
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
> The `cors` middleware handles `OPTIONS` preflight requests that the `headers` option cannot respond to. Use `cors` when you need to allow requests from JavaScript on a different domain — for example a React app calling this proxy's API routes.
|
|
547
|
+
|
|
548
|
+
### favicon
|
|
549
|
+
|
|
550
|
+
Serve a favicon file efficiently. The file is read into memory at startup and served from there on every `/favicon.ico` request — before static folder scanning or proxy rules run.
|
|
551
|
+
|
|
552
|
+
```json
|
|
553
|
+
{
|
|
554
|
+
"port": 8080,
|
|
555
|
+
"favicon": "./public/favicon.ico",
|
|
556
|
+
"folders": "www"
|
|
557
|
+
}
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
The path is resolved **relative to the config file**, consistent with the `ssl` option. Absolute paths are also accepted.
|
|
561
|
+
|
|
562
|
+
> If your favicon already lives inside a directory listed in `folders`, this option is not needed — `express.static` will serve it automatically.
|
|
563
|
+
|
|
564
|
+
### responseTime
|
|
565
|
+
|
|
566
|
+
Add an `X-Response-Time` header to every response, recording how long the server took to handle the request. Useful for performance monitoring and debugging.
|
|
567
|
+
|
|
568
|
+
```json
|
|
569
|
+
{
|
|
570
|
+
"port": 8080,
|
|
571
|
+
"responseTime": true,
|
|
572
|
+
"folders": "www"
|
|
573
|
+
}
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
With custom precision (see [response-time docs](https://github.com/expressjs/response-time#options)):
|
|
577
|
+
|
|
578
|
+
```json
|
|
579
|
+
{
|
|
580
|
+
"responseTime": { "digits": 0, "suffix": false }
|
|
581
|
+
}
|
|
582
|
+
```
|
|
583
|
+
|
|
388
584
|
---
|
|
389
585
|
|
|
390
586
|
## Configuration Recipes
|
|
@@ -422,6 +618,57 @@ Only `/api/*` requests go to the back-end; everything else stays local.
|
|
|
422
618
|
- `GET /api/users` → proxied to `http://localhost:4000/users`
|
|
423
619
|
- `GET /missing` → 404 Not Found
|
|
424
620
|
|
|
621
|
+
### HTTPS with a self-signed certificate (local dev)
|
|
622
|
+
|
|
623
|
+
```shell
|
|
624
|
+
mkdir certs
|
|
625
|
+
openssl req -x509 -newkey rsa:2048 -keyout certs/key.pem -out certs/cert.pem \
|
|
626
|
+
-days 365 -nodes -subj "/CN=localhost"
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
```json
|
|
630
|
+
{
|
|
631
|
+
"port": 8443,
|
|
632
|
+
"ssl": {
|
|
633
|
+
"key": "./certs/key.pem",
|
|
634
|
+
"cert": "./certs/cert.pem"
|
|
635
|
+
},
|
|
636
|
+
"folders": "www",
|
|
637
|
+
"proxy": {
|
|
638
|
+
"/api": "http://localhost:4000"
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
Start and open in browser (accept the self-signed cert warning):
|
|
644
|
+
|
|
645
|
+
```shell
|
|
646
|
+
node server.js --config server-config.json
|
|
647
|
+
# [listen] https://localhost:8443
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
---
|
|
651
|
+
|
|
652
|
+
### Production hardening (helmet + cors + compression)
|
|
653
|
+
|
|
654
|
+
Enable security headers, CORS, and response compression in one config:
|
|
655
|
+
|
|
656
|
+
```json
|
|
657
|
+
{
|
|
658
|
+
"port": 8080,
|
|
659
|
+
"compression": true,
|
|
660
|
+
"helmet": true,
|
|
661
|
+
"cors": { "origin": "https://app.example.com" },
|
|
662
|
+
"responseTime": true,
|
|
663
|
+
"folders": "www",
|
|
664
|
+
"proxy": {
|
|
665
|
+
"/api": "http://localhost:4000"
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
---
|
|
671
|
+
|
|
425
672
|
### CORS headers + rich error responses
|
|
426
673
|
|
|
427
674
|
```json
|
|
@@ -519,6 +766,56 @@ express-reverse-proxy --cluster status
|
|
|
519
766
|
express-reverse-proxy --cluster stop
|
|
520
767
|
```
|
|
521
768
|
|
|
769
|
+
### Behind a reverse proxy
|
|
770
|
+
|
|
771
|
+
For production deployments it is common to place a dedicated reverse proxy in front of `express-reverse-proxy` to handle TLS termination, HTTP/2, gzip compression, and rate limiting. In this setup the Node.js server listens on a local port over plain HTTP, while the outer proxy terminates HTTPS connections from the internet:
|
|
772
|
+
|
|
773
|
+
```
|
|
774
|
+
Internet (HTTPS / HTTP/2)
|
|
775
|
+
↓
|
|
776
|
+
Nginx or Caddy — TLS, HTTP/2, gzip, rate limiting
|
|
777
|
+
↓ HTTP/1.1 (localhost)
|
|
778
|
+
express-reverse-proxy — PM2 cluster, routing, static files, API proxy
|
|
779
|
+
↓
|
|
780
|
+
Backend API servers
|
|
781
|
+
```
|
|
782
|
+
|
|
783
|
+
**No `ssl` config needed** in `server-config.json` when the outer proxy handles TLS.
|
|
784
|
+
|
|
785
|
+
#### Nginx
|
|
786
|
+
|
|
787
|
+
```nginx
|
|
788
|
+
server {
|
|
789
|
+
listen 443 ssl;
|
|
790
|
+
server_name example.com;
|
|
791
|
+
|
|
792
|
+
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
|
|
793
|
+
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
|
|
794
|
+
|
|
795
|
+
location / {
|
|
796
|
+
proxy_pass http://127.0.0.1:8080;
|
|
797
|
+
proxy_set_header Host $host;
|
|
798
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
799
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
800
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
Free certificates can be obtained with [Certbot](https://certbot.eff.org/): `certbot --nginx -d example.com`.
|
|
806
|
+
|
|
807
|
+
#### Caddy
|
|
808
|
+
|
|
809
|
+
[Caddy](https://caddyserver.com/) provisions and renews Let's Encrypt certificates automatically — no extra tooling needed:
|
|
810
|
+
|
|
811
|
+
```
|
|
812
|
+
example.com {
|
|
813
|
+
reverse_proxy 127.0.0.1:8080
|
|
814
|
+
}
|
|
815
|
+
```
|
|
816
|
+
|
|
817
|
+
Start with `caddy run --config Caddyfile`.
|
|
818
|
+
|
|
522
819
|
---
|
|
523
820
|
|
|
524
821
|
## Testing
|
|
@@ -649,6 +946,11 @@ Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) before
|
|
|
649
946
|
- [Express](https://expressjs.com/) — HTTP server framework
|
|
650
947
|
- [express-http-proxy](https://github.com/villadora/express-http-proxy) — reverse proxy middleware
|
|
651
948
|
- [Morgan](https://github.com/expressjs/morgan) — HTTP request logger
|
|
949
|
+
- [compression](https://github.com/expressjs/compression) — gzip/deflate response compression
|
|
950
|
+
- [helmet](https://helmetjs.github.io/) — security HTTP headers
|
|
951
|
+
- [cors](https://github.com/expressjs/cors) — CORS headers and preflight handling
|
|
952
|
+
- [serve-favicon](https://github.com/expressjs/serve-favicon) — efficient favicon serving
|
|
953
|
+
- [response-time](https://github.com/expressjs/response-time) — X-Response-Time header
|
|
652
954
|
- [PM2](https://pm2.keymetrics.io/) — production process manager with clustering
|
|
653
955
|
- [Biome](https://biomejs.dev/) — fast linter and formatter (Rust-based)
|
|
654
956
|
- [Cypress](https://www.cypress.io/) — E2E testing framework
|
package/ecosystem.config.cjs
CHANGED
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
const path = require('node:path');
|
|
2
|
-
|
|
3
|
-
module.exports = {
|
|
4
|
-
apps: [
|
|
5
|
-
{
|
|
6
|
-
name: 'express-reverse-proxy',
|
|
7
|
-
script: path.join(__dirname, 'server.js'),
|
|
8
|
-
instances: 'max',
|
|
9
|
-
exec_mode: 'cluster',
|
|
10
|
-
wait_ready: true,
|
|
11
|
-
listen_timeout: 30000,
|
|
12
|
-
kill_timeout: 5000,
|
|
13
|
-
shutdown_with_message: true,
|
|
14
|
-
vizion: false, // disable git metadata collection
|
|
15
|
-
pmx: false, // disable PM2 metrics (avoids wmic ENOENT on Windows 11)
|
|
16
|
-
env: {
|
|
17
|
-
NODE_ENV: 'production',
|
|
18
|
-
},
|
|
19
|
-
env_development: {
|
|
20
|
-
NODE_ENV: 'development',
|
|
21
|
-
},
|
|
22
|
-
},
|
|
23
|
-
],
|
|
24
|
-
};
|
|
1
|
+
const path = require('node:path');
|
|
2
|
+
|
|
3
|
+
module.exports = {
|
|
4
|
+
apps: [
|
|
5
|
+
{
|
|
6
|
+
name: 'express-reverse-proxy',
|
|
7
|
+
script: path.join(__dirname, 'server.js'),
|
|
8
|
+
instances: 'max',
|
|
9
|
+
exec_mode: 'cluster',
|
|
10
|
+
wait_ready: true,
|
|
11
|
+
listen_timeout: 30000,
|
|
12
|
+
kill_timeout: 5000,
|
|
13
|
+
shutdown_with_message: true,
|
|
14
|
+
vizion: false, // disable git metadata collection
|
|
15
|
+
pmx: false, // disable PM2 metrics (avoids wmic ENOENT on Windows 11)
|
|
16
|
+
env: {
|
|
17
|
+
NODE_ENV: 'production',
|
|
18
|
+
},
|
|
19
|
+
env_development: {
|
|
20
|
+
NODE_ENV: 'development',
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hot Reload Client — connect via SSE and reload the page when files change.
|
|
3
|
+
*
|
|
4
|
+
* Usage (choose one):
|
|
5
|
+
*
|
|
6
|
+
* <!-- as a plain script tag -->
|
|
7
|
+
* <script src="/__hot-reload__/client.js"></script>
|
|
8
|
+
*
|
|
9
|
+
* <!-- as an ES module (bundler resolves via package.json exports) -->
|
|
10
|
+
* import '@lopatnov/express-reverse-proxy/hot-reload-client';
|
|
11
|
+
*
|
|
12
|
+
* The server must have `"hotReload": true` in its server-config.json.
|
|
13
|
+
*/
|
|
14
|
+
(function hotReloadClient() {
|
|
15
|
+
var url = '/__hot-reload__';
|
|
16
|
+
|
|
17
|
+
function connect() {
|
|
18
|
+
var es = new EventSource(url);
|
|
19
|
+
|
|
20
|
+
es.onmessage = () => {
|
|
21
|
+
location.reload();
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
es.onerror = () => {
|
|
25
|
+
es.close();
|
|
26
|
+
setTimeout(connect, 3000);
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
connect();
|
|
31
|
+
})();
|
package/package.json
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lopatnov/express-reverse-proxy",
|
|
3
3
|
"email": "oleksandr@lopatnov.cv.ua",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "4.0.0",
|
|
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",
|
|
8
8
|
"main": "server.js",
|
|
9
9
|
"exports": {
|
|
10
|
-
".": "./server.js"
|
|
10
|
+
".": "./server.js",
|
|
11
|
+
"./hot-reload-client": "./hot-reload-client.js"
|
|
11
12
|
},
|
|
12
13
|
"bin": {
|
|
13
|
-
"express-reverse-proxy": "./server.js"
|
|
14
|
+
"express-reverse-proxy": "./server.js",
|
|
15
|
+
"lerp": "./server.js"
|
|
14
16
|
},
|
|
15
17
|
"engines": {
|
|
16
18
|
"node": ">=18.0.0"
|
|
@@ -57,6 +59,7 @@
|
|
|
57
59
|
"homepage": "https://lopatnov.github.io/express-reverse-proxy/",
|
|
58
60
|
"files": [
|
|
59
61
|
"server.js",
|
|
62
|
+
"hot-reload-client.js",
|
|
60
63
|
"ecosystem.config.cjs",
|
|
61
64
|
"README.md",
|
|
62
65
|
"LICENSE"
|
|
@@ -65,10 +68,15 @@
|
|
|
65
68
|
"access": "public"
|
|
66
69
|
},
|
|
67
70
|
"dependencies": {
|
|
71
|
+
"compression": "^1.7.5",
|
|
72
|
+
"cors": "^2.8.5",
|
|
68
73
|
"express": "^5.2.1",
|
|
69
74
|
"express-http-proxy": "^2.1.2",
|
|
75
|
+
"helmet": "^8.0.0",
|
|
70
76
|
"morgan": "^1.10.1",
|
|
71
|
-
"pm2": "^6.0.14"
|
|
77
|
+
"pm2": "^6.0.14",
|
|
78
|
+
"response-time": "^2.3.2",
|
|
79
|
+
"serve-favicon": "^2.5.0"
|
|
72
80
|
},
|
|
73
81
|
"devDependencies": {
|
|
74
82
|
"@biomejs/biome": "^2.4.2",
|
package/server.js
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawnSync } from 'node:child_process';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
|
+
import https from 'node:https';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import compression from 'compression';
|
|
8
|
+
import cors from 'cors';
|
|
6
9
|
import express from 'express';
|
|
7
10
|
import proxy from 'express-http-proxy';
|
|
11
|
+
import helmet from 'helmet';
|
|
8
12
|
import morgan from 'morgan';
|
|
13
|
+
import responseTime from 'response-time';
|
|
14
|
+
import favicon from 'serve-favicon';
|
|
9
15
|
|
|
10
16
|
const __filename = fileURLToPath(import.meta.url);
|
|
11
17
|
const __dirname = path.dirname(__filename);
|
|
@@ -151,6 +157,7 @@ if (serverArgs['--config']) {
|
|
|
151
157
|
configFile = path.join(configFile, './server-config.json');
|
|
152
158
|
}
|
|
153
159
|
}
|
|
160
|
+
const configDir = path.dirname(path.resolve(configFile));
|
|
154
161
|
|
|
155
162
|
const DEFAULT_CONFIG = { port: 8000, folders: '.' };
|
|
156
163
|
|
|
@@ -194,6 +201,21 @@ configs.forEach((c) => {
|
|
|
194
201
|
configsByPort.get(p).push(c);
|
|
195
202
|
});
|
|
196
203
|
|
|
204
|
+
// Validate: cannot mix SSL and non-SSL site configs on the same port
|
|
205
|
+
for (const [p, group] of configsByPort) {
|
|
206
|
+
const sslCount = group.filter((c) => c.ssl).length;
|
|
207
|
+
if (sslCount > 0 && sslCount < group.length) {
|
|
208
|
+
exitError(`Port ${p}: cannot mix SSL and non-SSL site configs on the same port.`, 1);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function collectFolderPaths(folders) {
|
|
213
|
+
if (typeof folders === 'string') return [folders];
|
|
214
|
+
if (Array.isArray(folders)) return folders.flatMap(collectFolderPaths);
|
|
215
|
+
if (folders instanceof Object) return Object.values(folders).flatMap(collectFolderPaths);
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
|
|
197
219
|
function addStaticFolderByName(router, port, urlPath, folder) {
|
|
198
220
|
let folderPath = folder;
|
|
199
221
|
if (!path.isAbsolute(folder)) {
|
|
@@ -290,7 +312,47 @@ const servers = [];
|
|
|
290
312
|
|
|
291
313
|
configsByPort.forEach((portConfigs, p) => {
|
|
292
314
|
const app = express();
|
|
293
|
-
|
|
315
|
+
const loggingEnabled = portConfigs.every((c) => c.logging !== false);
|
|
316
|
+
if (loggingEnabled) {
|
|
317
|
+
app.use(morgan('combined'));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Hot reload via SSE
|
|
321
|
+
const hotReloadEnabled = portConfigs.some((c) => c.hotReload === true);
|
|
322
|
+
if (hotReloadEnabled) {
|
|
323
|
+
const sseClients = new Set();
|
|
324
|
+
let reloadTimer = null;
|
|
325
|
+
|
|
326
|
+
app.get('/__hot-reload__', (req, res) => {
|
|
327
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
328
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
329
|
+
res.setHeader('Connection', 'keep-alive');
|
|
330
|
+
res.flushHeaders();
|
|
331
|
+
sseClients.add(res);
|
|
332
|
+
req.on('close', () => sseClients.delete(res));
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
app.get('/__hot-reload__/client.js', (_req, res) => {
|
|
336
|
+
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
|
337
|
+
res.sendFile(path.join(__dirname, 'hot-reload-client.js'));
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
const watchPaths = [
|
|
341
|
+
...new Set(portConfigs.flatMap((c) => collectFolderPaths(c.folders || []))),
|
|
342
|
+
];
|
|
343
|
+
for (const folder of watchPaths) {
|
|
344
|
+
const absPath = path.isAbsolute(folder) ? folder : path.join(process.cwd(), folder);
|
|
345
|
+
if (fs.existsSync(absPath)) {
|
|
346
|
+
fs.watch(absPath, { recursive: true }, () => {
|
|
347
|
+
clearTimeout(reloadTimer);
|
|
348
|
+
reloadTimer = setTimeout(() => {
|
|
349
|
+
for (const client of sseClients) client.write('data: reload\n\n');
|
|
350
|
+
}, 100);
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
console.log(`[hot-reload] watching ${watchPaths.length} folder(s) on port ${p}`);
|
|
355
|
+
}
|
|
294
356
|
|
|
295
357
|
// Specific hosts first, catch-all last
|
|
296
358
|
const sorted = [
|
|
@@ -304,6 +366,30 @@ configsByPort.forEach((portConfigs, p) => {
|
|
|
304
366
|
|
|
305
367
|
console.log(`[host] ${siteHost} → :${p}`);
|
|
306
368
|
|
|
369
|
+
if (siteConfig.responseTime) {
|
|
370
|
+
const opts = typeof siteConfig.responseTime === 'object' ? siteConfig.responseTime : {};
|
|
371
|
+
router.use(responseTime(opts));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (siteConfig.cors) {
|
|
375
|
+
const opts = typeof siteConfig.cors === 'object' ? siteConfig.cors : {};
|
|
376
|
+
router.use(cors(opts));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (siteConfig.compression) {
|
|
380
|
+
const opts = typeof siteConfig.compression === 'object' ? siteConfig.compression : {};
|
|
381
|
+
router.use(compression(opts));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (siteConfig.helmet) {
|
|
385
|
+
const opts = typeof siteConfig.helmet === 'object' ? siteConfig.helmet : {};
|
|
386
|
+
router.use(helmet(opts));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (siteConfig.favicon) {
|
|
390
|
+
router.use(favicon(path.resolve(configDir, siteConfig.favicon)));
|
|
391
|
+
}
|
|
392
|
+
|
|
307
393
|
if (siteConfig.headers) {
|
|
308
394
|
router.use((_req, res, next) => {
|
|
309
395
|
for (const h of Object.keys(siteConfig.headers)) res.setHeader(h, siteConfig.headers[h]);
|
|
@@ -341,10 +427,31 @@ configsByPort.forEach((portConfigs, p) => {
|
|
|
341
427
|
}
|
|
342
428
|
});
|
|
343
429
|
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
430
|
+
const sslConfig = portConfigs.find((c) => c.ssl)?.ssl;
|
|
431
|
+
let server;
|
|
432
|
+
if (sslConfig) {
|
|
433
|
+
let sslOptions;
|
|
434
|
+
try {
|
|
435
|
+
sslOptions = {
|
|
436
|
+
key: fs.readFileSync(path.resolve(configDir, sslConfig.key)),
|
|
437
|
+
cert: fs.readFileSync(path.resolve(configDir, sslConfig.cert)),
|
|
438
|
+
};
|
|
439
|
+
if (sslConfig.ca) {
|
|
440
|
+
sslOptions.ca = fs.readFileSync(path.resolve(configDir, sslConfig.ca));
|
|
441
|
+
}
|
|
442
|
+
} catch (err) {
|
|
443
|
+
exitError(`SSL cert/key error on port ${p}: ${err.message}`, 1);
|
|
444
|
+
}
|
|
445
|
+
server = https.createServer(sslOptions, app).listen(p, () => {
|
|
446
|
+
console.log(`[listen] https://localhost:${p}`);
|
|
447
|
+
if (process.send) process.send('ready');
|
|
448
|
+
});
|
|
449
|
+
} else {
|
|
450
|
+
server = app.listen(p, () => {
|
|
451
|
+
console.log(`[listen] http://localhost:${p}`);
|
|
452
|
+
if (process.send) process.send('ready');
|
|
453
|
+
});
|
|
454
|
+
}
|
|
348
455
|
server.on('error', (err) => {
|
|
349
456
|
if (err.code === 'EADDRINUSE') {
|
|
350
457
|
exitError(`Port ${p} is already in use`, 1);
|