unitup 0.3.0 → 0.4.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/CHANGELOG.md +17 -0
- package/index.d.ts +55 -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 +167 -50
- package/src/core/supervisor.js +5 -5
- 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 +24 -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,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0] - 2026-10-18
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **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.
|
|
14
|
+
- **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`.
|
|
15
|
+
- **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`).
|
|
16
|
+
- **Automated GitHub Actions Release Pipeline**: Added `.github/workflows/release.yml` using Litepacks reusable release workflow with OIDC Trusted Publishing and provenance.
|
|
17
|
+
- **TypeScript Typecheck Script**: Added `npm run typecheck` (`tsc --noEmit`) to scripts for automated type validation in CI.
|
|
18
|
+
- **Package Distribution Optimization**: Configured `"files"` in `package.json` to publish only essential production files, reducing package size from 2.4 MB to 85 KB.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- **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.
|
|
22
|
+
- **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.
|
|
23
|
+
- **Supervisor Public Port Resolution**: Updated `Supervisor` to reliably resolve public ports across `options.publicPort`, `config.port`, and `config.deploy?.port`.
|
|
24
|
+
- **Connection Draining on Shutdown**: Enhanced `Router.close()` to invoke `server.closeAllConnections()` for immediate and clean termination of idle keep-alive sockets.
|
|
25
|
+
|
|
26
|
+
|
|
10
27
|
## [0.1.5] - 2026-07-31
|
|
11
28
|
|
|
12
29
|
### 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,25 @@ 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>;
|
|
642
689
|
}
|
|
643
690
|
|
|
644
691
|
export class Supervisor {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unitup",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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
|
{
|
|
@@ -310,10 +397,11 @@ export class Router {
|
|
|
310
397
|
/**
|
|
311
398
|
* Dispatches and proxies WebSocket / Upgrade connections to the active backend.
|
|
312
399
|
*/
|
|
313
|
-
_handleWebSocketUpgrade(req, clientSocket, head) {
|
|
314
|
-
const
|
|
400
|
+
_handleWebSocketUpgrade(req, clientSocket, head, listeningPort = null) {
|
|
401
|
+
const effectivePort = listeningPort || this.listeningPort;
|
|
402
|
+
const serviceName = this._resolveServiceName(req, effectivePort);
|
|
315
403
|
const backend = serviceName ? this.resolveBackend(serviceName, req) : null;
|
|
316
|
-
if (!backend || !backend.internalPort) {
|
|
404
|
+
if (!serviceName || !backend || !backend.internalPort) {
|
|
317
405
|
clientSocket.destroy();
|
|
318
406
|
return;
|
|
319
407
|
}
|
|
@@ -370,21 +458,50 @@ export class Router {
|
|
|
370
458
|
}
|
|
371
459
|
|
|
372
460
|
/**
|
|
373
|
-
* Closes the public router HTTP listener.
|
|
461
|
+
* Closes the public router HTTP listener(s).
|
|
374
462
|
*
|
|
463
|
+
* @param {number} [port=null] - Specific port to close, or null to close all.
|
|
375
464
|
* @returns {Promise<void>}
|
|
376
465
|
*/
|
|
377
|
-
async close() {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
466
|
+
async close(port = null) {
|
|
467
|
+
if (port !== null) {
|
|
468
|
+
const portNum = Number(port);
|
|
469
|
+
const entry = this.servers.get(portNum);
|
|
470
|
+
if (!entry || !entry.server) return;
|
|
471
|
+
return new Promise((resolve) => {
|
|
472
|
+
if (typeof entry.server.closeAllConnections === 'function') entry.server.closeAllConnections();
|
|
473
|
+
entry.server.close(() => {
|
|
474
|
+
this.servers.delete(portNum);
|
|
475
|
+
this.portServices.delete(portNum);
|
|
476
|
+
if (this.listeningPort === portNum) {
|
|
477
|
+
const nextEntry = this.servers.values().next().value;
|
|
478
|
+
this.server = nextEntry ? nextEntry.server : null;
|
|
479
|
+
this.listeningPort = nextEntry ? nextEntry.port : null;
|
|
480
|
+
this.listeningHost = nextEntry ? nextEntry.host : null;
|
|
481
|
+
}
|
|
482
|
+
resolve();
|
|
483
|
+
});
|
|
387
484
|
});
|
|
388
|
-
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
this.closing = true;
|
|
488
|
+
const closePromises = [];
|
|
489
|
+
for (const [, entry] of this.servers.entries()) {
|
|
490
|
+
if (entry.server) {
|
|
491
|
+
closePromises.push(
|
|
492
|
+
new Promise((resolve) => {
|
|
493
|
+
if (typeof entry.server.closeAllConnections === 'function') entry.server.closeAllConnections();
|
|
494
|
+
entry.server.close(() => resolve());
|
|
495
|
+
})
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
await Promise.all(closePromises);
|
|
500
|
+
this.servers.clear();
|
|
501
|
+
this.portServices.clear();
|
|
502
|
+
this.servicePorts.clear();
|
|
503
|
+
this.server = null;
|
|
504
|
+
this.listeningPort = null;
|
|
505
|
+
this.listeningHost = null;
|
|
389
506
|
}
|
|
390
507
|
}
|
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,15 +142,15 @@ 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
149
|
if (active && this.processManager.isAlive(active.pid)) {
|
|
150
150
|
if (publicPort) {
|
|
151
|
-
await this.router.listen(publicPort);
|
|
151
|
+
await this.router.listen(publicPort, '0.0.0.0', this.serviceName);
|
|
152
152
|
}
|
|
153
|
-
this.router.switchBackend(this.serviceName, active);
|
|
153
|
+
this.router.switchBackend(this.serviceName, active, publicPort);
|
|
154
154
|
} else {
|
|
155
155
|
// Start initial generation
|
|
156
156
|
await this.deploymentManager.deploy(this.serviceName, this.config, {
|