@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 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
@@ -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": "2.0.0",
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
- app.use(morgan('combined'));
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 server = app.listen(p, () => {
345
- console.log(`[listen] http://localhost:${p}`);
346
- if (process.send) process.send('ready');
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);