@lopatnov/express-reverse-proxy 2.0.0 → 3.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 +158 -0
- package/ecosystem.config.cjs +24 -24
- package/hot-reload-client.js +31 -0
- package/package.json +4 -2
- package/server.js +83 -5
package/README.md
CHANGED
|
@@ -218,6 +218,55 @@ The port the server listens on. Defaults to `8000`. Can also be set via the `POR
|
|
|
218
218
|
}
|
|
219
219
|
```
|
|
220
220
|
|
|
221
|
+
### logging
|
|
222
|
+
|
|
223
|
+
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.
|
|
224
|
+
|
|
225
|
+
```json
|
|
226
|
+
{
|
|
227
|
+
"port": 8080,
|
|
228
|
+
"logging": false,
|
|
229
|
+
"folders": "www"
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### hotReload
|
|
234
|
+
|
|
235
|
+
Watches the `folders` directories for file changes and automatically reloads connected browser tabs. Uses Server-Sent Events (SSE). Intended for local development only.
|
|
236
|
+
|
|
237
|
+
```json
|
|
238
|
+
{
|
|
239
|
+
"port": 8080,
|
|
240
|
+
"hotReload": true,
|
|
241
|
+
"folders": "www"
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
The server exposes two endpoints when hot reload is enabled:
|
|
246
|
+
|
|
247
|
+
| Endpoint | Description |
|
|
248
|
+
| --------------------------------- | ------------------------------------------ |
|
|
249
|
+
| `GET /__hot-reload__` | SSE stream — browsers subscribe here |
|
|
250
|
+
| `GET /__hot-reload__/client.js` | Ready-to-use client script |
|
|
251
|
+
|
|
252
|
+
#### Connecting the client
|
|
253
|
+
|
|
254
|
+
**Option A — plain HTML project**: add a script tag to your page. The file is served directly by the dev server, no installation needed:
|
|
255
|
+
|
|
256
|
+
```html
|
|
257
|
+
<script src="/__hot-reload__/client.js"></script>
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
**Option B — bundled project** (Vite, webpack, etc.): import the client module. The bundler resolves it through the package `exports` field:
|
|
261
|
+
|
|
262
|
+
```js
|
|
263
|
+
import '@lopatnov/express-reverse-proxy/hot-reload-client';
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
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.
|
|
267
|
+
|
|
268
|
+
> **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.
|
|
269
|
+
|
|
221
270
|
### headers
|
|
222
271
|
|
|
223
272
|
Add headers to every response — useful for CORS in development.
|
|
@@ -385,6 +434,34 @@ To use multi-site mode, make the config file an **array** instead of an object.
|
|
|
385
434
|
>
|
|
386
435
|
> Two entries with the same `host` **and** `port` cause a startup error. The same `host` on different ports is allowed.
|
|
387
436
|
|
|
437
|
+
### ssl
|
|
438
|
+
|
|
439
|
+
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.
|
|
440
|
+
|
|
441
|
+
| Field | Type | Description |
|
|
442
|
+
| ------ | -------- | -------------------------------------------------------- |
|
|
443
|
+
| `key` | `string` | Path to the private key file (PEM format) |
|
|
444
|
+
| `cert` | `string` | Path to the certificate file (PEM format) |
|
|
445
|
+
| `ca` | `string` | *(optional)* Path to the CA bundle for client validation |
|
|
446
|
+
|
|
447
|
+
Paths are resolved **relative to the config file**, not the current working directory.
|
|
448
|
+
|
|
449
|
+
```json
|
|
450
|
+
{
|
|
451
|
+
"port": 443,
|
|
452
|
+
"ssl": {
|
|
453
|
+
"key": "./certs/key.pem",
|
|
454
|
+
"cert": "./certs/cert.pem"
|
|
455
|
+
},
|
|
456
|
+
"folders": "./public",
|
|
457
|
+
"proxy": {
|
|
458
|
+
"/api": "http://localhost:4000"
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
> All site configs on the same port must either all have `ssl` or none — mixing is a startup error.
|
|
464
|
+
|
|
388
465
|
---
|
|
389
466
|
|
|
390
467
|
## Configuration Recipes
|
|
@@ -422,6 +499,37 @@ Only `/api/*` requests go to the back-end; everything else stays local.
|
|
|
422
499
|
- `GET /api/users` → proxied to `http://localhost:4000/users`
|
|
423
500
|
- `GET /missing` → 404 Not Found
|
|
424
501
|
|
|
502
|
+
### HTTPS with a self-signed certificate (local dev)
|
|
503
|
+
|
|
504
|
+
```shell
|
|
505
|
+
mkdir certs
|
|
506
|
+
openssl req -x509 -newkey rsa:2048 -keyout certs/key.pem -out certs/cert.pem \
|
|
507
|
+
-days 365 -nodes -subj "/CN=localhost"
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
```json
|
|
511
|
+
{
|
|
512
|
+
"port": 8443,
|
|
513
|
+
"ssl": {
|
|
514
|
+
"key": "./certs/key.pem",
|
|
515
|
+
"cert": "./certs/cert.pem"
|
|
516
|
+
},
|
|
517
|
+
"folders": "www",
|
|
518
|
+
"proxy": {
|
|
519
|
+
"/api": "http://localhost:4000"
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
Start and open in browser (accept the self-signed cert warning):
|
|
525
|
+
|
|
526
|
+
```shell
|
|
527
|
+
node server.js --config server-config.json
|
|
528
|
+
# [listen] https://localhost:8443
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
---
|
|
532
|
+
|
|
425
533
|
### CORS headers + rich error responses
|
|
426
534
|
|
|
427
535
|
```json
|
|
@@ -519,6 +627,56 @@ express-reverse-proxy --cluster status
|
|
|
519
627
|
express-reverse-proxy --cluster stop
|
|
520
628
|
```
|
|
521
629
|
|
|
630
|
+
### Behind a reverse proxy
|
|
631
|
+
|
|
632
|
+
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:
|
|
633
|
+
|
|
634
|
+
```
|
|
635
|
+
Internet (HTTPS / HTTP/2)
|
|
636
|
+
↓
|
|
637
|
+
Nginx or Caddy — TLS, HTTP/2, gzip, rate limiting
|
|
638
|
+
↓ HTTP/1.1 (localhost)
|
|
639
|
+
express-reverse-proxy — PM2 cluster, routing, static files, API proxy
|
|
640
|
+
↓
|
|
641
|
+
Backend API servers
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
**No `ssl` config needed** in `server-config.json` when the outer proxy handles TLS.
|
|
645
|
+
|
|
646
|
+
#### Nginx
|
|
647
|
+
|
|
648
|
+
```nginx
|
|
649
|
+
server {
|
|
650
|
+
listen 443 ssl;
|
|
651
|
+
server_name example.com;
|
|
652
|
+
|
|
653
|
+
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
|
|
654
|
+
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
|
|
655
|
+
|
|
656
|
+
location / {
|
|
657
|
+
proxy_pass http://127.0.0.1:8080;
|
|
658
|
+
proxy_set_header Host $host;
|
|
659
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
660
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
661
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
Free certificates can be obtained with [Certbot](https://certbot.eff.org/): `certbot --nginx -d example.com`.
|
|
667
|
+
|
|
668
|
+
#### Caddy
|
|
669
|
+
|
|
670
|
+
[Caddy](https://caddyserver.com/) provisions and renews Let's Encrypt certificates automatically — no extra tooling needed:
|
|
671
|
+
|
|
672
|
+
```
|
|
673
|
+
example.com {
|
|
674
|
+
reverse_proxy 127.0.0.1:8080
|
|
675
|
+
}
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
Start with `caddy run --config Caddyfile`.
|
|
679
|
+
|
|
522
680
|
---
|
|
523
681
|
|
|
524
682
|
## Testing
|
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,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lopatnov/express-reverse-proxy",
|
|
3
3
|
"email": "oleksandr@lopatnov.cv.ua",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "3.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
14
|
"express-reverse-proxy": "./server.js"
|
|
@@ -57,6 +58,7 @@
|
|
|
57
58
|
"homepage": "https://lopatnov.github.io/express-reverse-proxy/",
|
|
58
59
|
"files": [
|
|
59
60
|
"server.js",
|
|
61
|
+
"hot-reload-client.js",
|
|
60
62
|
"ecosystem.config.cjs",
|
|
61
63
|
"README.md",
|
|
62
64
|
"LICENSE"
|
package/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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';
|
|
6
7
|
import express from 'express';
|
|
@@ -151,6 +152,7 @@ if (serverArgs['--config']) {
|
|
|
151
152
|
configFile = path.join(configFile, './server-config.json');
|
|
152
153
|
}
|
|
153
154
|
}
|
|
155
|
+
const configDir = path.dirname(path.resolve(configFile));
|
|
154
156
|
|
|
155
157
|
const DEFAULT_CONFIG = { port: 8000, folders: '.' };
|
|
156
158
|
|
|
@@ -194,6 +196,21 @@ configs.forEach((c) => {
|
|
|
194
196
|
configsByPort.get(p).push(c);
|
|
195
197
|
});
|
|
196
198
|
|
|
199
|
+
// Validate: cannot mix SSL and non-SSL site configs on the same port
|
|
200
|
+
for (const [p, group] of configsByPort) {
|
|
201
|
+
const sslCount = group.filter((c) => c.ssl).length;
|
|
202
|
+
if (sslCount > 0 && sslCount < group.length) {
|
|
203
|
+
exitError(`Port ${p}: cannot mix SSL and non-SSL site configs on the same port.`, 1);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function collectFolderPaths(folders) {
|
|
208
|
+
if (typeof folders === 'string') return [folders];
|
|
209
|
+
if (Array.isArray(folders)) return folders.flatMap(collectFolderPaths);
|
|
210
|
+
if (folders instanceof Object) return Object.values(folders).flatMap(collectFolderPaths);
|
|
211
|
+
return [];
|
|
212
|
+
}
|
|
213
|
+
|
|
197
214
|
function addStaticFolderByName(router, port, urlPath, folder) {
|
|
198
215
|
let folderPath = folder;
|
|
199
216
|
if (!path.isAbsolute(folder)) {
|
|
@@ -290,7 +307,47 @@ const servers = [];
|
|
|
290
307
|
|
|
291
308
|
configsByPort.forEach((portConfigs, p) => {
|
|
292
309
|
const app = express();
|
|
293
|
-
|
|
310
|
+
const loggingEnabled = portConfigs.every((c) => c.logging !== false);
|
|
311
|
+
if (loggingEnabled) {
|
|
312
|
+
app.use(morgan('combined'));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Hot reload via SSE
|
|
316
|
+
const hotReloadEnabled = portConfigs.some((c) => c.hotReload === true);
|
|
317
|
+
if (hotReloadEnabled) {
|
|
318
|
+
const sseClients = new Set();
|
|
319
|
+
let reloadTimer = null;
|
|
320
|
+
|
|
321
|
+
app.get('/__hot-reload__', (req, res) => {
|
|
322
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
323
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
324
|
+
res.setHeader('Connection', 'keep-alive');
|
|
325
|
+
res.flushHeaders();
|
|
326
|
+
sseClients.add(res);
|
|
327
|
+
req.on('close', () => sseClients.delete(res));
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
app.get('/__hot-reload__/client.js', (_req, res) => {
|
|
331
|
+
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
|
332
|
+
res.sendFile(path.join(__dirname, 'hot-reload-client.js'));
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const watchPaths = [
|
|
336
|
+
...new Set(portConfigs.flatMap((c) => collectFolderPaths(c.folders || []))),
|
|
337
|
+
];
|
|
338
|
+
for (const folder of watchPaths) {
|
|
339
|
+
const absPath = path.isAbsolute(folder) ? folder : path.join(process.cwd(), folder);
|
|
340
|
+
if (fs.existsSync(absPath)) {
|
|
341
|
+
fs.watch(absPath, { recursive: true }, () => {
|
|
342
|
+
clearTimeout(reloadTimer);
|
|
343
|
+
reloadTimer = setTimeout(() => {
|
|
344
|
+
for (const client of sseClients) client.write('data: reload\n\n');
|
|
345
|
+
}, 100);
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
console.log(`[hot-reload] watching ${watchPaths.length} folder(s) on port ${p}`);
|
|
350
|
+
}
|
|
294
351
|
|
|
295
352
|
// Specific hosts first, catch-all last
|
|
296
353
|
const sorted = [
|
|
@@ -341,10 +398,31 @@ configsByPort.forEach((portConfigs, p) => {
|
|
|
341
398
|
}
|
|
342
399
|
});
|
|
343
400
|
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
401
|
+
const sslConfig = portConfigs.find((c) => c.ssl)?.ssl;
|
|
402
|
+
let server;
|
|
403
|
+
if (sslConfig) {
|
|
404
|
+
let sslOptions;
|
|
405
|
+
try {
|
|
406
|
+
sslOptions = {
|
|
407
|
+
key: fs.readFileSync(path.resolve(configDir, sslConfig.key)),
|
|
408
|
+
cert: fs.readFileSync(path.resolve(configDir, sslConfig.cert)),
|
|
409
|
+
};
|
|
410
|
+
if (sslConfig.ca) {
|
|
411
|
+
sslOptions.ca = fs.readFileSync(path.resolve(configDir, sslConfig.ca));
|
|
412
|
+
}
|
|
413
|
+
} catch (err) {
|
|
414
|
+
exitError(`SSL cert/key error on port ${p}: ${err.message}`, 1);
|
|
415
|
+
}
|
|
416
|
+
server = https.createServer(sslOptions, app).listen(p, () => {
|
|
417
|
+
console.log(`[listen] https://localhost:${p}`);
|
|
418
|
+
if (process.send) process.send('ready');
|
|
419
|
+
});
|
|
420
|
+
} else {
|
|
421
|
+
server = app.listen(p, () => {
|
|
422
|
+
console.log(`[listen] http://localhost:${p}`);
|
|
423
|
+
if (process.send) process.send('ready');
|
|
424
|
+
});
|
|
425
|
+
}
|
|
348
426
|
server.on('error', (err) => {
|
|
349
427
|
if (err.code === 'EADDRINUSE') {
|
|
350
428
|
exitError(`Port ${p} is already in use`, 1);
|