unitup 0.3.0 → 0.4.1

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/CHANGELOG.md CHANGED
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.1] - 2026-10-18
11
+
12
+ ### Added
13
+ - **Supervisor Active Generation TCP Health Verification & Self-Healing**: Supervisor now validates TCP connectivity on the internal port (`active.internalPort`) of persisted active generations at startup. If the target generation is dead or refusing connections, supervisor cleans up stale state and automatically triggers a fresh deployment, eliminating stale `502 Bad Gateway (ECONNREFUSED)` loops.
14
+ - **Systemd Crash Loop Circuit Breaker (`RestartPreventExitStatus=78`)**: Added `RestartPreventExitStatus=78` (POSIX `EX_CONFIG`) to generated systemd unit files, paired with `StartLimitIntervalSec=30s` and `StartLimitBurst=5`. When supervisor encounters unrecoverable configuration errors (such as public port collision / `EADDRINUSE`), it exits with code 78, preventing systemd from endlessly restarting and spamming journald logs.
15
+ - **Enhanced Connection Diagnostics in Router**: Detailed internal port and error context (`connect ECONNREFUSED 127.0.0.1:<port>`) reported on proxy failures to clarify application startup crashes.
16
+ - **Exported `IPCClient` Type Definitions**: Exported `IPCClient` class and interface in `index.d.ts` for clean downstream programmatic integration by orchestrators.
17
+
18
+ ### Fixed
19
+ - **Systemd Unit Parser & Directives**: Added extraction and preservation of `RestartPreventExitStatus` in `parseUnitContent` to maintain parity across read/write cycles.
20
+ - **Router In-Flight Request Timing**: Resolved timing race condition in `test/router.test.js` in-flight request tracking test.
21
+
22
+ ## [0.4.0] - 2026-10-18
23
+
24
+ ### Added
25
+ - **Multi-Port Concurrent Routing**: Enhanced `Router` to listen on multiple distinct ports concurrently using an internal server map (`servers: Map<port, { server, port, host }>`), enabling multiple zero-downtime services to operate simultaneously without port collisions or premature listener termination.
26
+ - **Port-Scoped Traffic Isolation**: Added strict port-to-service mapping (`portServices: Map<port, Set<serviceName>>`) ensuring requests on port `A` are strictly isolated from services listening on port `B`.
27
+ - **Domain Normalization in Router**: Added hostname normalization (`hostNormalized`) to resolve incoming Host headers seamlessly against registered service names (e.g., `supanote.app` accurately resolves to service `supanoteapp`).
28
+ - **Automated GitHub Actions Release Pipeline**: Added `.github/workflows/release.yml` using Litepacks reusable release workflow with OIDC Trusted Publishing and provenance.
29
+ - **TypeScript Typecheck Script**: Added `npm run typecheck` (`tsc --noEmit`) to scripts for automated type validation in CI.
30
+ - **Package Distribution Optimization**: Configured `"files"` in `package.json` to publish only essential production files, reducing package size from 2.4 MB to 85 KB.
31
+
32
+ ### Changed
33
+ - **Eliminated Blind Cross-Service Fallback**: Removed indiscriminate fallback to other active backends when a Host header does not match; the router now safely returns `503 Service Unavailable` instead of cross-routing traffic to an unrelated backend.
34
+ - **Service Metadata Persistence**: `writeUnitFile` now explicitly preserves `port` and `deploy` configurations in `saveAppMetadata`, allowing systemd supervisors to retain their target public ports across daemon restarts.
35
+ - **Supervisor Public Port Resolution**: Updated `Supervisor` to reliably resolve public ports across `options.publicPort`, `config.port`, and `config.deploy?.port`.
36
+ - **Connection Draining on Shutdown**: Enhanced `Router.close()` to invoke `server.closeAllConnections()` for immediate and clean termination of idle keep-alive sockets.
37
+
38
+
10
39
  ## [0.1.5] - 2026-07-31
11
40
 
12
41
  ### Fixed
package/index.d.ts CHANGED
@@ -85,6 +85,24 @@ export interface CreateServiceOptions {
85
85
  resetAfter?: number | null;
86
86
  };
87
87
 
88
+ /**
89
+ * Maximum log level written to systemd journal.
90
+ * @default "info"
91
+ */
92
+ logLevelMax?: string;
93
+
94
+ /**
95
+ * Rate limit interval for systemd journal logging.
96
+ * @default "10s"
97
+ */
98
+ logRateLimitIntervalSec?: string;
99
+
100
+ /**
101
+ * Maximum number of logs per interval before rate limiting kicks in.
102
+ * @default 200
103
+ */
104
+ logRateLimitBurst?: number;
105
+
88
106
  /**
89
107
  * Positional arguments to pass to the script/command execution line.
90
108
  */
@@ -575,16 +593,27 @@ export function generations(name: string, options?: Record<string, unknown>): Ge
575
593
 
576
594
  export class ProcessManager {
577
595
  constructor(options?: Record<string, unknown>);
578
- start(command: string, args?: string[], options?: Record<string, unknown>): { pid: number; child: any; exitPromise: Promise<any> };
596
+ start(
597
+ command: string,
598
+ args?: string[],
599
+ options?: Record<string, unknown>
600
+ ): { pid: number; child: any; exitPromise: Promise<any> };
579
601
  stop(processOrPid: number | object, options?: { timeout?: number; signal?: string }): Promise<boolean>;
580
602
  signal(processOrPid: number | object, signal?: string): boolean;
581
603
  isAlive(processOrPid: number | object): boolean;
582
- waitForExit(processOrPid: number | object, timeout?: number): Promise<{ exited: boolean; code?: number | null; signal?: string | null }>;
604
+ waitForExit(
605
+ processOrPid: number | object,
606
+ timeout?: number
607
+ ): Promise<{ exited: boolean; code?: number | null; signal?: string | null }>;
583
608
  }
584
609
 
585
610
  export class GenerationManager {
586
611
  constructor(options?: Record<string, unknown>);
587
- create(serviceName: string, config?: Record<string, unknown>, options?: Record<string, unknown>): Promise<GenerationRecord>;
612
+ create(
613
+ serviceName: string,
614
+ config?: Record<string, unknown>,
615
+ options?: Record<string, unknown>
616
+ ): Promise<GenerationRecord>;
588
617
  getActive(serviceName: string): GenerationRecord | null;
589
618
  getPrevious(serviceName: string): GenerationRecord | null;
590
619
  getCanary(serviceName: string): GenerationRecord | null;
@@ -616,12 +645,18 @@ export class ReadinessChecker {
616
645
  checkTcp(port: number, host?: string, timeout?: number): Promise<boolean>;
617
646
  checkHttp(port: number, path?: string, timeout?: number): Promise<boolean>;
618
647
  ensure(generation: GenerationRecord, options?: { path?: string }): Promise<boolean>;
619
- wait(generation: GenerationRecord, options?: { timeout?: number; interval?: number; path?: string }): Promise<boolean>;
648
+ wait(
649
+ generation: GenerationRecord,
650
+ options?: { timeout?: number; interval?: number; path?: string }
651
+ ): Promise<boolean>;
620
652
  }
621
653
 
622
654
  export class DrainManager {
623
655
  constructor(options?: Record<string, unknown>);
624
- drain(generation: GenerationRecord, options?: { timeout?: number; pollInterval?: number; stopProcess?: boolean }): Promise<{ drained: boolean; inFlightRemaining: number; timedOut: boolean }>;
656
+ drain(
657
+ generation: GenerationRecord,
658
+ options?: { timeout?: number; pollInterval?: number; stopProcess?: boolean }
659
+ ): Promise<{ drained: boolean; inFlightRemaining: number; timedOut: boolean }>;
625
660
  }
626
661
 
627
662
  export class DeploymentLock {
@@ -632,13 +667,31 @@ export class DeploymentLock {
632
667
 
633
668
  export class DeploymentManager {
634
669
  constructor(options?: Record<string, unknown>);
635
- deploy(serviceName: string, serviceConfig?: Record<string, unknown>, options?: Record<string, unknown>): Promise<DeploymentResult>;
636
- promote(serviceName: string, serviceConfig?: Record<string, unknown>, options?: Record<string, unknown>): Promise<PromoteResult>;
670
+ deploy(
671
+ serviceName: string,
672
+ serviceConfig?: Record<string, unknown>,
673
+ options?: Record<string, unknown>
674
+ ): Promise<DeploymentResult>;
675
+ promote(
676
+ serviceName: string,
677
+ serviceConfig?: Record<string, unknown>,
678
+ options?: Record<string, unknown>
679
+ ): Promise<PromoteResult>;
637
680
  }
638
681
 
639
682
  export class RollbackManager {
640
683
  constructor(options?: Record<string, unknown>);
641
- rollback(serviceName: string, serviceConfig?: Record<string, unknown>, options?: Record<string, unknown>): Promise<RollbackResult>;
684
+ rollback(
685
+ serviceName: string,
686
+ serviceConfig?: Record<string, unknown>,
687
+ options?: Record<string, unknown>
688
+ ): Promise<RollbackResult>;
689
+ }
690
+
691
+ export class IPCClient {
692
+ constructor(serviceName: string, ipcDir?: string);
693
+ isAlive(): Promise<boolean>;
694
+ send(message: Record<string, unknown>, onProgress?: (msg: unknown) => void, timeoutMs?: number): Promise<unknown>;
642
695
  }
643
696
 
644
697
  export class Supervisor {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unitup",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Minimal, zero-dependency cross-platform native service manager (systemd, launchd, Windows Services) for any runtime, script, or executable",
5
5
  "main": "src/index.js",
6
6
  "types": "./index.d.ts",
@@ -12,7 +12,8 @@
12
12
  "test": "node --test",
13
13
  "lint": "biome check .",
14
14
  "format": "biome format --write .",
15
- "check": "biome check ."
15
+ "check": "biome check .",
16
+ "typecheck": "tsc --noEmit"
16
17
  },
17
18
  "devDependencies": {
18
19
  "@biomejs/biome": "^1.9.4",
@@ -56,5 +57,6 @@
56
57
  "homepage": "https://github.com/litepacks/unitup#readme",
57
58
  "engines": {
58
59
  "node": ">=20.0.0"
59
- }
60
+ },
61
+ "files": ["bin", "src", "index.d.ts", "README.md", "LICENSE", "CHANGELOG.md"]
60
62
  }
package/src/cli.js CHANGED
@@ -1,6 +1,13 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import readline from 'node:readline';
4
+ import { GenerationManager } from './core/generation-manager.js';
5
+ import { IPCClient } from './core/ipc.js';
6
+ import { Supervisor } from './core/supervisor.js';
7
+ import {
8
+ defaultDeploymentManager as deploymentManager,
9
+ defaultRollbackManager as rollbackManager
10
+ } from './deploy/index.js';
4
11
  import { runDoctor } from './doctor.js';
5
12
  import { getAdapter } from './platform/index.js';
6
13
  import {
@@ -47,13 +54,6 @@ import {
47
54
  saveProjectConfig,
48
55
  validateMemorySize
49
56
  } from './utils.js';
50
- import { GenerationManager } from './core/generation-manager.js';
51
- import { IPCClient } from './core/ipc.js';
52
- import { Supervisor } from './core/supervisor.js';
53
- import {
54
- defaultDeploymentManager as deploymentManager,
55
- defaultRollbackManager as rollbackManager
56
- } from './deploy/index.js';
57
57
 
58
58
  /**
59
59
  * Custom light CLI argument parser without runtime dependencies.
@@ -1,4 +1,4 @@
1
- import { spawn, execFile } from 'node:child_process';
1
+ import { execFile, spawn } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
 
@@ -170,7 +170,8 @@ export class ReadinessChecker {
170
170
  while (true) {
171
171
  // 1. Fail fast if process has crashed during startup
172
172
  if (generation.pid && this.processManager && !this.processManager.isAlive(generation.pid)) {
173
- const procInfo = typeof this.processManager.getProcess === 'function' ? this.processManager.getProcess(generation.pid) : null;
173
+ const procInfo =
174
+ typeof this.processManager.getProcess === 'function' ? this.processManager.getProcess(generation.pid) : null;
174
175
  const stderr = procInfo?.recentStderr || '';
175
176
 
176
177
  if (stderr.includes('EADDRINUSE') || stderr.includes('address already in use')) {
@@ -2,9 +2,14 @@ import http from 'node:http';
2
2
  import net from 'node:net';
3
3
 
4
4
  /**
5
- * Router owns a stable public port and reverse-proxies HTTP & WebSocket traffic
5
+ * Router owns stable public ports and reverse-proxies HTTP & WebSocket traffic
6
6
  * to whichever internal generation is marked active.
7
7
  *
8
+ * Supports multi-port listening with STRICT port-level isolation:
9
+ * - A request arriving on port A can ONLY EVER route to backends registered for port A.
10
+ * - Under NO circumstances will requests cross port boundaries.
11
+ * - Opening port B will NEVER close or disrupt port A.
12
+ *
8
13
  * It contains NO deployment sequencing decisions.
9
14
  */
10
15
  export class Router {
@@ -12,20 +17,40 @@ export class Router {
12
17
  this.activeBackends = new Map(); // serviceName -> generation { id, internalPort }
13
18
  this.canaryBackends = new Map(); // serviceName -> { generation, weight }
14
19
  this.inFlightRequests = new Map(); // generationId -> number
15
- this.server = null;
16
- this.listeningPort = null;
17
- this.listeningHost = null;
20
+ this.servers = new Map(); // port -> { server, port, host }
21
+ this.portServices = new Map(); // port -> Set<serviceName>
22
+ this.servicePorts = new Map(); // serviceName -> port
23
+ this.server = null; // backward compatibility: primary/latest server
24
+ this.listeningPort = null; // backward compatibility
25
+ this.listeningHost = null; // backward compatibility
18
26
  this.closing = false;
19
27
  }
20
28
 
29
+ /**
30
+ * Associates a service name with a public port.
31
+ *
32
+ * @param {string} serviceName
33
+ * @param {number|string} port
34
+ */
35
+ registerServicePort(serviceName, port) {
36
+ if (!serviceName || !port) return;
37
+ const numPort = Number(port);
38
+ this.servicePorts.set(serviceName, numPort);
39
+ if (!this.portServices.has(numPort)) {
40
+ this.portServices.set(numPort, new Set());
41
+ }
42
+ this.portServices.get(numPort).add(serviceName);
43
+ }
44
+
21
45
  /**
22
46
  * Sets a canary backend with a specific traffic weight (0 to 1, or 1 to 100).
23
47
  *
24
48
  * @param {string} serviceName
25
49
  * @param {object} generation
26
50
  * @param {number} [weight=0.1] - Probability (0 to 1) or percentage (1 to 100)
51
+ * @param {number} [publicPort]
27
52
  */
28
- setCanary(serviceName, generation, weight = 0.1) {
53
+ setCanary(serviceName, generation, weight = 0.1, publicPort = null) {
29
54
  const parsed = typeof weight === 'string' ? Number.parseFloat(weight.replace('%', '')) : Number(weight);
30
55
  const num = Number.isNaN(parsed) ? 0.1 : parsed;
31
56
  const normalizedWeight = num > 1 ? num / 100 : num;
@@ -33,6 +58,12 @@ export class Router {
33
58
  generation,
34
59
  weight: Math.max(0, Math.min(1, normalizedWeight))
35
60
  });
61
+
62
+ const targetPort =
63
+ publicPort || generation?.publicPort || (this.servers.size === 1 ? this.servers.keys().next().value : null);
64
+ if (targetPort) {
65
+ this.registerServicePort(serviceName, targetPort);
66
+ }
36
67
  }
37
68
 
38
69
  /**
@@ -54,7 +85,8 @@ export class Router {
54
85
  promoteCanary(serviceName) {
55
86
  const canary = this.canaryBackends.get(serviceName);
56
87
  if (!canary || !canary.generation) return null;
57
- this.switchBackend(serviceName, canary.generation);
88
+ const port = this.servicePorts.get(serviceName);
89
+ this.switchBackend(serviceName, canary.generation, port);
58
90
  this.canaryBackends.delete(serviceName);
59
91
  return canary.generation;
60
92
  }
@@ -109,26 +141,54 @@ export class Router {
109
141
 
110
142
  /**
111
143
  * Helper to find the service name matching the request.
144
+ * STRICT: Scoped to the listening port. Never returns an unrelated service!
145
+ *
112
146
  * @param {import('node:http').IncomingMessage} [req]
147
+ * @param {number} [listeningPort]
113
148
  * @returns {string|null}
114
149
  */
115
- _resolveServiceName(req) {
116
- if (this.activeBackends.size === 1) {
117
- return this.activeBackends.keys().next().value;
150
+ _resolveServiceName(req, listeningPort = null) {
151
+ const numPort = listeningPort ? Number(listeningPort) : null;
152
+ let candidateNames = null;
153
+
154
+ if (numPort && this.portServices.has(numPort)) {
155
+ candidateNames = Array.from(this.portServices.get(numPort));
156
+ } else if (this.activeBackends.size > 0 || this.canaryBackends.size > 0) {
157
+ candidateNames = Array.from(new Set([...this.activeBackends.keys(), ...this.canaryBackends.keys()]));
158
+ }
159
+
160
+ if (!candidateNames || candidateNames.length === 0) {
161
+ return null;
118
162
  }
119
- if (this.activeBackends.size > 1 && req?.headers?.host) {
120
- for (const srv of this.activeBackends.keys()) {
121
- if (req.headers.host.includes(srv)) {
163
+
164
+ // Single service registered on this port: unconditionally routes to it
165
+ if (candidateNames.length === 1) {
166
+ return candidateNames[0];
167
+ }
168
+
169
+ // Multiple services registered on this port: match by Host header
170
+ if (req?.headers?.host) {
171
+ const hostRaw = req.headers.host.split(':')[0].toLowerCase();
172
+ const hostNormalized = hostRaw.replace(/[^a-z0-9]/g, '');
173
+
174
+ // 1. Exact match
175
+ for (const srv of candidateNames) {
176
+ if (hostRaw === srv.toLowerCase()) {
177
+ return srv;
178
+ }
179
+ }
180
+
181
+ // 2. Normalized match (e.g. "supanote.app" matches service "supanoteapp")
182
+ for (const srv of candidateNames) {
183
+ const srvNorm = srv.toLowerCase().replace(/[^a-z0-9]/g, '');
184
+ if (hostNormalized === srvNorm || hostNormalized.includes(srvNorm) || srvNorm.includes(hostNormalized)) {
122
185
  return srv;
123
186
  }
124
187
  }
125
188
  }
126
- if (this.activeBackends.size > 0) {
127
- return this.activeBackends.keys().next().value;
128
- }
129
- if (this.canaryBackends.size > 0) {
130
- return this.canaryBackends.keys().next().value;
131
- }
189
+
190
+ // STRICT: Return null if host does not match any service on this port.
191
+ // NEVER fall back to another random service.
132
192
  return null;
133
193
  }
134
194
 
@@ -172,9 +232,15 @@ export class Router {
172
232
  *
173
233
  * @param {string} serviceName
174
234
  * @param {object} generation - { id, internalPort, service }
235
+ * @param {number} [publicPort]
175
236
  */
176
- switchBackend(serviceName, generation) {
237
+ switchBackend(serviceName, generation, publicPort = null) {
177
238
  this.activeBackends.set(serviceName, generation);
239
+ const targetPort =
240
+ publicPort || generation?.publicPort || (this.servers.size === 1 ? this.servers.keys().next().value : null);
241
+ if (targetPort) {
242
+ this.registerServicePort(serviceName, targetPort);
243
+ }
178
244
  }
179
245
 
180
246
  /**
@@ -189,44 +255,52 @@ export class Router {
189
255
 
190
256
  /**
191
257
  * Starts listening on the public port.
258
+ * Supports multiple distinct ports concurrently without closing existing ones.
192
259
  *
193
260
  * @param {number} publicPort
194
261
  * @param {string} [host='0.0.0.0']
262
+ * @param {string} [serviceName=null]
195
263
  * @returns {Promise<{ port: number, host: string }>}
196
264
  */
197
- async listen(publicPort, host = '0.0.0.0') {
198
- if (this.server && this.listeningPort === publicPort) {
199
- return { port: this.listeningPort, host: this.listeningHost };
265
+ async listen(publicPort, host = '0.0.0.0', serviceName = null) {
266
+ const portNum = Number(publicPort);
267
+ if (serviceName) {
268
+ this.registerServicePort(serviceName, portNum);
200
269
  }
201
270
 
202
- if (this.server) {
203
- await this.close();
271
+ const existing = this.servers.get(portNum);
272
+ if (existing && existing.server) {
273
+ this.listeningPort = portNum;
274
+ this.listeningHost = existing.host;
275
+ this.server = existing.server;
276
+ return { port: portNum, host: existing.host };
204
277
  }
205
278
 
206
279
  this.closing = false;
207
280
 
208
281
  return new Promise((resolve, reject) => {
209
282
  const server = http.createServer((req, res) => {
210
- this._handleHttpRequest(req, res);
283
+ this._handleHttpRequest(req, res, portNum);
211
284
  });
212
285
 
213
286
  server.on('upgrade', (req, clientSocket, head) => {
214
- this._handleWebSocketUpgrade(req, clientSocket, head);
287
+ this._handleWebSocketUpgrade(req, clientSocket, head, portNum);
215
288
  });
216
289
 
217
290
  server.once('error', (err) => {
218
291
  if (err.code === 'EADDRINUSE') {
219
- reject(new Error(`Public port ${publicPort} is already occupied by another process.`));
292
+ reject(new Error(`Public port ${portNum} is already occupied by another process.`));
220
293
  } else {
221
294
  reject(err);
222
295
  }
223
296
  });
224
297
 
225
- server.listen(publicPort, host, () => {
226
- this.server = server;
227
- const actualPort = server.address() ? server.address().port : publicPort;
298
+ server.listen(portNum, host, () => {
299
+ this.servers.set(portNum, { server, port: portNum, host });
300
+ const actualPort = server.address() ? server.address().port : portNum;
228
301
  this.listeningPort = actualPort;
229
302
  this.listeningHost = host;
303
+ this.server = server;
230
304
  resolve({ port: actualPort, host });
231
305
  });
232
306
  });
@@ -237,17 +311,30 @@ export class Router {
237
311
  *
238
312
  * @param {import('node:http').IncomingMessage} req
239
313
  * @param {import('node:http').ServerResponse} res
314
+ * @param {number} [listeningPort]
240
315
  */
241
- _handleHttpRequest(req, res) {
242
- const serviceName = this._resolveServiceName(req);
316
+ _handleHttpRequest(req, res, listeningPort = null) {
317
+ const effectivePort = listeningPort || this.listeningPort;
318
+ const serviceName = this._resolveServiceName(req, effectivePort);
243
319
  const backend = serviceName ? this.resolveBackend(serviceName, req) : null;
244
320
 
321
+ if (!serviceName) {
322
+ res.writeHead(503, {
323
+ 'Content-Type': 'text/plain; charset=utf-8',
324
+ 'Retry-After': '1'
325
+ });
326
+ res.end(
327
+ `503 Service Unavailable: No service matching host "${req?.headers?.host || 'unknown'}" on port ${effectivePort}.`
328
+ );
329
+ return;
330
+ }
331
+
245
332
  if (!backend || !backend.internalPort) {
246
333
  res.writeHead(503, {
247
334
  'Content-Type': 'text/plain; charset=utf-8',
248
335
  'Retry-After': '1'
249
336
  });
250
- res.end('503 Service Unavailable: No active generation backend available.');
337
+ res.end(`503 Service Unavailable: No active generation backend available for service "${serviceName}".`);
251
338
  return;
252
339
  }
253
340
 
@@ -272,8 +359,8 @@ export class Router {
272
359
  ? `${forwardedHeaders['x-forwarded-for']}, ${clientIp}`
273
360
  : clientIp;
274
361
  forwardedHeaders['x-forwarded-proto'] = forwardedHeaders['x-forwarded-proto'] || 'http';
275
- forwardedHeaders['x-forwarded-host'] = req.headers.host || `localhost:${this.listeningPort}`;
276
- forwardedHeaders['x-forwarded-port'] = String(this.listeningPort);
362
+ forwardedHeaders['x-forwarded-host'] = req.headers.host || `localhost:${effectivePort}`;
363
+ forwardedHeaders['x-forwarded-port'] = String(effectivePort);
277
364
 
278
365
  const proxyReq = http.request(
279
366
  {
@@ -298,7 +385,11 @@ export class Router {
298
385
  cleanupInFlight();
299
386
  if (!res.headersSent) {
300
387
  res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' });
301
- res.end(`502 Bad Gateway: Error communicating with generation #${genId} (${err.message}).`);
388
+ const errorDetail =
389
+ err.code === 'ECONNREFUSED'
390
+ ? `Connection refused at 127.0.0.1:${backend.internalPort}. The application process may have crashed or not bound to process.env.PORT.`
391
+ : err.message;
392
+ res.end(`502 Bad Gateway: Error communicating with generation #${genId} (${errorDetail}).`);
302
393
  } else {
303
394
  res.destroy();
304
395
  }
@@ -310,10 +401,11 @@ export class Router {
310
401
  /**
311
402
  * Dispatches and proxies WebSocket / Upgrade connections to the active backend.
312
403
  */
313
- _handleWebSocketUpgrade(req, clientSocket, head) {
314
- const serviceName = this._resolveServiceName(req);
404
+ _handleWebSocketUpgrade(req, clientSocket, head, listeningPort = null) {
405
+ const effectivePort = listeningPort || this.listeningPort;
406
+ const serviceName = this._resolveServiceName(req, effectivePort);
315
407
  const backend = serviceName ? this.resolveBackend(serviceName, req) : null;
316
- if (!backend || !backend.internalPort) {
408
+ if (!serviceName || !backend || !backend.internalPort) {
317
409
  clientSocket.destroy();
318
410
  return;
319
411
  }
@@ -370,21 +462,50 @@ export class Router {
370
462
  }
371
463
 
372
464
  /**
373
- * Closes the public router HTTP listener.
465
+ * Closes the public router HTTP listener(s).
374
466
  *
467
+ * @param {number} [port=null] - Specific port to close, or null to close all.
375
468
  * @returns {Promise<void>}
376
469
  */
377
- async close() {
378
- this.closing = true;
379
- if (!this.server) return;
380
-
381
- return new Promise((resolve) => {
382
- this.server.close(() => {
383
- this.server = null;
384
- this.listeningPort = null;
385
- this.listeningHost = null;
386
- resolve();
470
+ async close(port = null) {
471
+ if (port !== null) {
472
+ const portNum = Number(port);
473
+ const entry = this.servers.get(portNum);
474
+ if (!entry || !entry.server) return;
475
+ return new Promise((resolve) => {
476
+ if (typeof entry.server.closeAllConnections === 'function') entry.server.closeAllConnections();
477
+ entry.server.close(() => {
478
+ this.servers.delete(portNum);
479
+ this.portServices.delete(portNum);
480
+ if (this.listeningPort === portNum) {
481
+ const nextEntry = this.servers.values().next().value;
482
+ this.server = nextEntry ? nextEntry.server : null;
483
+ this.listeningPort = nextEntry ? nextEntry.port : null;
484
+ this.listeningHost = nextEntry ? nextEntry.host : null;
485
+ }
486
+ resolve();
487
+ });
387
488
  });
388
- });
489
+ }
490
+
491
+ this.closing = true;
492
+ const closePromises = [];
493
+ for (const [, entry] of this.servers.entries()) {
494
+ if (entry.server) {
495
+ closePromises.push(
496
+ new Promise((resolve) => {
497
+ if (typeof entry.server.closeAllConnections === 'function') entry.server.closeAllConnections();
498
+ entry.server.close(() => resolve());
499
+ })
500
+ );
501
+ }
502
+ }
503
+ await Promise.all(closePromises);
504
+ this.servers.clear();
505
+ this.portServices.clear();
506
+ this.servicePorts.clear();
507
+ this.server = null;
508
+ this.listeningPort = null;
509
+ this.listeningHost = null;
389
510
  }
390
511
  }
@@ -1,3 +1,5 @@
1
+ import { DeploymentManager } from '../deploy/deployment-manager.js';
2
+ import { RollbackManager } from '../deploy/rollback-manager.js';
1
3
  import { readAppMetadata, sanitizeServiceName } from '../utils.js';
2
4
  import { DrainManager } from './drain-manager.js';
3
5
  import { GenerationManager } from './generation-manager.js';
@@ -6,8 +8,6 @@ import { DeploymentLock } from './lock.js';
6
8
  import { ProcessManager } from './process-manager.js';
7
9
  import { ReadinessChecker } from './readiness.js';
8
10
  import { Router } from './router.js';
9
- import { DeploymentManager } from '../deploy/deployment-manager.js';
10
- import { RollbackManager } from '../deploy/rollback-manager.js';
11
11
 
12
12
  /**
13
13
  * Supervisor is the long-running host managed by the OS service manager (unitupd).
@@ -142,16 +142,29 @@ export class Supervisor {
142
142
  this.generationManager.loadState(this.serviceName);
143
143
  this.generationManager.cleanupStale(this.serviceName);
144
144
 
145
- const publicPort = this.options.publicPort || this.config.port;
145
+ const publicPort = this.options.publicPort || this.config.port || this.config.deploy?.port;
146
146
 
147
147
  // 3. Check if there is an active running generation from persisted state
148
148
  const active = this.generationManager.getActive(this.serviceName);
149
+ let activeHealthy = false;
149
150
  if (active && this.processManager.isAlive(active.pid)) {
150
- if (publicPort) {
151
- await this.router.listen(publicPort);
151
+ // Verify internal port is actually listening to prevent 502 Bad Gateway
152
+ activeHealthy = await this.readiness.checkTcp(active.internalPort, '127.0.0.1', 1000);
153
+ if (activeHealthy) {
154
+ if (publicPort) {
155
+ await this.router.listen(publicPort, '0.0.0.0', this.serviceName);
156
+ }
157
+ this.router.switchBackend(this.serviceName, active, publicPort);
158
+ }
159
+ }
160
+
161
+ if (!activeHealthy) {
162
+ if (active) {
163
+ process.stderr.write(
164
+ `[unitup supervisor] Persisted active generation #${active.id} (PID ${active.pid}) is not listening on internal port :${active.internalPort}. Deploying fresh instance...\n`
165
+ );
166
+ this.generationManager.remove(active, false);
152
167
  }
153
- this.router.switchBackend(this.serviceName, active);
154
- } else {
155
168
  // Start initial generation
156
169
  await this.deploymentManager.deploy(this.serviceName, this.config, {
157
170
  publicPort,
@@ -216,7 +229,11 @@ export class Supervisor {
216
229
  await supervisor.start();
217
230
  } catch (err) {
218
231
  process.stderr.write(`[unitup supervisor] Failed to start: ${err.message}\n`);
219
- process.exit(1);
232
+ const isUnrecoverableConfigError =
233
+ err.message?.includes('already occupied') ||
234
+ err.message?.includes('EADDRINUSE') ||
235
+ err.name === 'PortMismatchError';
236
+ process.exit(isUnrecoverableConfigError ? 78 : 1);
220
237
  }
221
238
  }
222
239
  }