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 +29 -0
- package/index.d.ts +61 -8
- package/package.json +5 -3
- package/src/cli.js +7 -7
- package/src/core/process-manager.js +1 -1
- package/src/core/readiness.js +2 -1
- package/src/core/router.js +172 -51
- package/src/core/supervisor.js +25 -8
- package/src/deploy/deployment-manager.js +15 -34
- package/src/deploy/rollback-manager.js +2 -1
- package/src/index.js +23 -23
- package/src/platform/windows-host.js +1 -1
- package/src/unit.js +29 -2
- package/.agents/AGENTS.md +0 -35
- package/.agents/skills/multi-runtime-management/SKILL.md +0 -80
- package/.agents/skills/platform-service-adapters/SKILL.md +0 -108
- package/.agents/skills/unitup-development-and-qa/SKILL.md +0 -88
- package/.agents/skills/zero-downtime-deployment/SKILL.md +0 -135
- package/SECURITY.md +0 -21
- package/biome.json +0 -44
- package/docs/index.html +0 -587
- package/tsconfig.json +0 -13
- package/types-test/index.ts +0 -158
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
636
|
-
|
|
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(
|
|
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
|
+
"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.
|
package/src/core/readiness.js
CHANGED
|
@@ -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 =
|
|
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')) {
|
package/src/core/router.js
CHANGED
|
@@ -2,9 +2,14 @@ import http from 'node:http';
|
|
|
2
2
|
import net from 'node:net';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Router owns
|
|
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.
|
|
16
|
-
this.
|
|
17
|
-
this.
|
|
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.
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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 ${
|
|
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(
|
|
226
|
-
this.server
|
|
227
|
-
const actualPort = server.address() ? server.address().port :
|
|
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
|
|
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(
|
|
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:${
|
|
276
|
-
forwardedHeaders['x-forwarded-port'] = String(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
}
|
package/src/core/supervisor.js
CHANGED
|
@@ -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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
}
|