unitup 0.1.5 → 0.3.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/.agents/AGENTS.md +35 -0
- package/.agents/skills/multi-runtime-management/SKILL.md +80 -0
- package/.agents/skills/platform-service-adapters/SKILL.md +108 -0
- package/.agents/skills/unitup-development-and-qa/SKILL.md +88 -0
- package/.agents/skills/zero-downtime-deployment/SKILL.md +135 -0
- package/CHANGELOG.md +5 -3
- package/README.md +89 -8
- package/biome.json +0 -1
- package/docs/index.html +188 -234
- package/index.d.ts +467 -440
- package/package.json +9 -3
- package/src/cli.js +561 -103
- package/src/core/drain-manager.js +70 -0
- package/src/core/generation-manager.js +414 -0
- package/src/core/index.js +8 -0
- package/src/core/ipc.js +236 -0
- package/src/core/lock.js +133 -0
- package/src/core/process-manager.js +313 -0
- package/src/core/readiness.js +243 -0
- package/src/core/router.js +390 -0
- package/src/core/supervisor.js +222 -0
- package/src/deploy/deployment-manager.js +407 -0
- package/src/deploy/index.js +9 -0
- package/src/deploy/rollback-manager.js +186 -0
- package/src/doctor.js +123 -56
- package/src/errors.js +131 -0
- package/src/index.js +186 -59
- package/src/logs.js +148 -0
- package/src/platform/base.js +211 -0
- package/src/platform/index.js +43 -0
- package/src/platform/linux.js +155 -0
- package/src/platform/macos.js +726 -0
- package/src/platform/windows-host.js +246 -0
- package/src/platform/windows.js +498 -0
- package/src/runtimes/bun.js +1 -1
- package/src/runtimes/common.js +24 -6
- package/src/runtimes/deno.js +1 -1
- package/src/runtimes/elixir.js +1 -1
- package/src/runtimes/go.js +1 -1
- package/src/runtimes/index.js +7 -7
- package/src/runtimes/native.js +2 -4
- package/src/runtimes/node.js +1 -1
- package/src/runtimes/php.js +1 -1
- package/src/runtimes/python.js +1 -1
- package/src/runtimes/ruby.js +1 -1
- package/src/runtimes/shell.js +1 -1
- package/src/schedule.js +31 -24
- package/src/service/config.js +63 -0
- package/src/service/manager.js +233 -0
- package/src/service/normalize.js +245 -0
- package/src/systemd.js +84 -38
- package/src/unit.js +17 -19
- package/src/utils.js +112 -18
- package/tsconfig.json +1 -1
- package/types-test/index.ts +158 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Custom error thrown when a generation fails readiness within the timeout.
|
|
6
|
+
*/
|
|
7
|
+
export class ReadinessTimeoutError extends Error {
|
|
8
|
+
constructor(message, generation) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = 'ReadinessTimeoutError';
|
|
11
|
+
this.generation = generation;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Custom error thrown when a generation's process exits unexpectedly during startup.
|
|
17
|
+
*/
|
|
18
|
+
export class ProcessStartupError extends Error {
|
|
19
|
+
constructor(message, generation) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = 'ProcessStartupError';
|
|
22
|
+
this.generation = generation;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Custom error thrown when an application listens on a hardcoded port instead of process.env.PORT.
|
|
28
|
+
*/
|
|
29
|
+
export class PortMismatchError extends Error {
|
|
30
|
+
constructor(message, generation, detectedPort) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = 'PortMismatchError';
|
|
33
|
+
this.generation = generation;
|
|
34
|
+
this.detectedPort = detectedPort;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* ReadinessChecker provides reusable TCP and HTTP readiness verification.
|
|
40
|
+
*/
|
|
41
|
+
export class ReadinessChecker {
|
|
42
|
+
/**
|
|
43
|
+
* @param {object} options
|
|
44
|
+
* @param {import('./process-manager.js').ProcessManager} options.processManager
|
|
45
|
+
*/
|
|
46
|
+
constructor(options = {}) {
|
|
47
|
+
this.processManager = options.processManager;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Checks if an internal TCP port is accepting connections.
|
|
52
|
+
*
|
|
53
|
+
* @param {number} port
|
|
54
|
+
* @param {string} [host='127.0.0.1']
|
|
55
|
+
* @param {number} [timeout=1000]
|
|
56
|
+
* @returns {Promise<boolean>}
|
|
57
|
+
*/
|
|
58
|
+
checkTcp(port, host = '127.0.0.1', timeout = 1000) {
|
|
59
|
+
return new Promise((resolve) => {
|
|
60
|
+
const socket = net.connect({ port, host, timeout });
|
|
61
|
+
let connected = false;
|
|
62
|
+
|
|
63
|
+
socket.once('connect', () => {
|
|
64
|
+
connected = true;
|
|
65
|
+
socket.end();
|
|
66
|
+
resolve(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
socket.once('timeout', () => {
|
|
70
|
+
socket.destroy();
|
|
71
|
+
resolve(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
socket.once('error', () => {
|
|
75
|
+
socket.destroy();
|
|
76
|
+
resolve(false);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Checks if an HTTP readiness endpoint returns a healthy status (2xx).
|
|
83
|
+
*
|
|
84
|
+
* @param {number} port
|
|
85
|
+
* @param {string} [path='/']
|
|
86
|
+
* @param {number} [timeout=1500]
|
|
87
|
+
* @returns {Promise<boolean>}
|
|
88
|
+
*/
|
|
89
|
+
checkHttp(port, path = '/', timeout = 1500) {
|
|
90
|
+
return new Promise((resolve) => {
|
|
91
|
+
const req = http.request(
|
|
92
|
+
{
|
|
93
|
+
hostname: '127.0.0.1',
|
|
94
|
+
port,
|
|
95
|
+
path,
|
|
96
|
+
method: 'GET',
|
|
97
|
+
timeout
|
|
98
|
+
},
|
|
99
|
+
(res) => {
|
|
100
|
+
// Read response data to avoid socket leak
|
|
101
|
+
res.resume();
|
|
102
|
+
const is2xx = res.statusCode >= 200 && res.statusCode < 300;
|
|
103
|
+
resolve(is2xx);
|
|
104
|
+
}
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
req.on('timeout', () => {
|
|
108
|
+
req.destroy();
|
|
109
|
+
resolve(false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
req.on('error', () => {
|
|
113
|
+
resolve(false);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
req.end();
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Performs an immediate one-shot readiness check on a generation.
|
|
122
|
+
*
|
|
123
|
+
* @param {object} generation - { id, pid, internalPort }
|
|
124
|
+
* @param {object} [options]
|
|
125
|
+
* @param {string} [options.path]
|
|
126
|
+
* @returns {Promise<boolean>}
|
|
127
|
+
*/
|
|
128
|
+
async ensure(generation, options = {}) {
|
|
129
|
+
if (!generation) return false;
|
|
130
|
+
|
|
131
|
+
// 1. Process liveness
|
|
132
|
+
if (generation.pid && this.processManager && !this.processManager.isAlive(generation.pid)) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 2. TCP connectivity
|
|
137
|
+
const tcpOk = await this.checkTcp(generation.internalPort);
|
|
138
|
+
if (!tcpOk) return false;
|
|
139
|
+
|
|
140
|
+
// 3. Optional HTTP endpoint
|
|
141
|
+
if (options.path) {
|
|
142
|
+
const httpOk = await this.checkHttp(generation.internalPort, options.path);
|
|
143
|
+
if (!httpOk) return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Polls readiness until the generation is ready, process crashes, or timeout is reached.
|
|
151
|
+
*
|
|
152
|
+
* @param {object} generation - { id, pid, internalPort }
|
|
153
|
+
* @param {object} [options]
|
|
154
|
+
* @param {number} [options.timeout=15000] - Timeout in milliseconds
|
|
155
|
+
* @param {number} [options.interval=50] - Polling interval in milliseconds
|
|
156
|
+
* @param {string} [options.path] - Optional HTTP health check path (e.g. '/health')
|
|
157
|
+
* @returns {Promise<boolean>} Resolves true when ready
|
|
158
|
+
*/
|
|
159
|
+
async wait(generation, options = {}) {
|
|
160
|
+
if (!generation || !generation.internalPort) {
|
|
161
|
+
throw new Error('Valid generation with internalPort is required for readiness check.');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const timeout = options.timeout !== undefined ? options.timeout : 15000;
|
|
165
|
+
const interval = options.interval !== undefined ? options.interval : 50;
|
|
166
|
+
const healthPath = options.path;
|
|
167
|
+
|
|
168
|
+
const startTime = Date.now();
|
|
169
|
+
|
|
170
|
+
while (true) {
|
|
171
|
+
// 1. Fail fast if process has crashed during startup
|
|
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;
|
|
174
|
+
const stderr = procInfo?.recentStderr || '';
|
|
175
|
+
|
|
176
|
+
if (stderr.includes('EADDRINUSE') || stderr.includes('address already in use')) {
|
|
177
|
+
const portHint = options.configuredPort ? ` (such as configured port :${options.configuredPort})` : '';
|
|
178
|
+
throw new ProcessStartupError(
|
|
179
|
+
`Generation #${generation.id} (PID ${generation.pid}) crashed with EADDRINUSE during startup. ` +
|
|
180
|
+
`The application likely attempted to bind to a hardcoded port${portHint} instead of reading process.env.PORT (${generation.internalPort}).` +
|
|
181
|
+
`\nDetails:\n${stderr.trim()}`,
|
|
182
|
+
generation
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
throw new ProcessStartupError(
|
|
187
|
+
`Generation #${generation.id} (PID ${generation.pid}) process exited unexpectedly during startup.` +
|
|
188
|
+
(stderr ? `\nDetails:\n${stderr.trim()}` : ''),
|
|
189
|
+
generation
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 2. Check TCP port
|
|
194
|
+
const tcpReady = await this.checkTcp(generation.internalPort);
|
|
195
|
+
if (tcpReady) {
|
|
196
|
+
// 3. If HTTP health check path is configured, verify HTTP 2xx
|
|
197
|
+
if (healthPath) {
|
|
198
|
+
const httpReady = await this.checkHttp(generation.internalPort, healthPath);
|
|
199
|
+
if (httpReady) {
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
} else {
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
} else {
|
|
206
|
+
// Port mismatch fast-detection: If application is listening on configured public port instead of internalPort
|
|
207
|
+
const isRouterOnConfiguredPort =
|
|
208
|
+
options.router && options.router.server && options.router.listeningPort === options.configuredPort;
|
|
209
|
+
|
|
210
|
+
if (!isRouterOnConfiguredPort && options.configuredPort && options.configuredPort !== generation.internalPort) {
|
|
211
|
+
const isConfiguredPortOpen = await this.checkTcp(options.configuredPort);
|
|
212
|
+
if (isConfiguredPortOpen) {
|
|
213
|
+
throw new PortMismatchError(
|
|
214
|
+
`Port mismatch detected: Generation #${generation.id} (PID ${generation.pid}) is listening on configured public port ${options.configuredPort} ` +
|
|
215
|
+
`instead of the dynamically allocated process.env.PORT (${generation.internalPort}). ` +
|
|
216
|
+
`Please ensure your application code reads process.env.PORT (e.g. app.listen(process.env.PORT || ${options.configuredPort})).`,
|
|
217
|
+
generation,
|
|
218
|
+
options.configuredPort
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// 4. Check timeout
|
|
225
|
+
if (Date.now() - startTime >= timeout) {
|
|
226
|
+
if (generation.pid && this.processManager && this.processManager.isAlive(generation.pid)) {
|
|
227
|
+
throw new ReadinessTimeoutError(
|
|
228
|
+
`Generation #${generation.id} (PID ${generation.pid}) is still running, but did not accept connections on process.env.PORT (${generation.internalPort}) within ${timeout}ms. ` +
|
|
229
|
+
`Ensure your application listens on process.env.PORT (e.g. app.listen(process.env.PORT)).`,
|
|
230
|
+
generation
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
throw new ReadinessTimeoutError(
|
|
235
|
+
`Generation #${generation.id} on port ${generation.internalPort} failed readiness check within ${timeout}ms.`,
|
|
236
|
+
generation
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Router owns a stable public port and reverse-proxies HTTP & WebSocket traffic
|
|
6
|
+
* to whichever internal generation is marked active.
|
|
7
|
+
*
|
|
8
|
+
* It contains NO deployment sequencing decisions.
|
|
9
|
+
*/
|
|
10
|
+
export class Router {
|
|
11
|
+
constructor(options = {}) {
|
|
12
|
+
this.activeBackends = new Map(); // serviceName -> generation { id, internalPort }
|
|
13
|
+
this.canaryBackends = new Map(); // serviceName -> { generation, weight }
|
|
14
|
+
this.inFlightRequests = new Map(); // generationId -> number
|
|
15
|
+
this.server = null;
|
|
16
|
+
this.listeningPort = null;
|
|
17
|
+
this.listeningHost = null;
|
|
18
|
+
this.closing = false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Sets a canary backend with a specific traffic weight (0 to 1, or 1 to 100).
|
|
23
|
+
*
|
|
24
|
+
* @param {string} serviceName
|
|
25
|
+
* @param {object} generation
|
|
26
|
+
* @param {number} [weight=0.1] - Probability (0 to 1) or percentage (1 to 100)
|
|
27
|
+
*/
|
|
28
|
+
setCanary(serviceName, generation, weight = 0.1) {
|
|
29
|
+
const parsed = typeof weight === 'string' ? Number.parseFloat(weight.replace('%', '')) : Number(weight);
|
|
30
|
+
const num = Number.isNaN(parsed) ? 0.1 : parsed;
|
|
31
|
+
const normalizedWeight = num > 1 ? num / 100 : num;
|
|
32
|
+
this.canaryBackends.set(serviceName, {
|
|
33
|
+
generation,
|
|
34
|
+
weight: Math.max(0, Math.min(1, normalizedWeight))
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Clears the canary backend for a service.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} serviceName
|
|
42
|
+
*/
|
|
43
|
+
clearCanary(serviceName) {
|
|
44
|
+
this.canaryBackends.delete(serviceName);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Promotes the canary generation to the active primary generation,
|
|
49
|
+
* clearing the canary state.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} serviceName
|
|
52
|
+
* @returns {object|null} The promoted generation
|
|
53
|
+
*/
|
|
54
|
+
promoteCanary(serviceName) {
|
|
55
|
+
const canary = this.canaryBackends.get(serviceName);
|
|
56
|
+
if (!canary || !canary.generation) return null;
|
|
57
|
+
this.switchBackend(serviceName, canary.generation);
|
|
58
|
+
this.canaryBackends.delete(serviceName);
|
|
59
|
+
return canary.generation;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Gets the canary config for a service.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} serviceName
|
|
66
|
+
* @returns {{ generation: object, weight: number }|null}
|
|
67
|
+
*/
|
|
68
|
+
getCanary(serviceName) {
|
|
69
|
+
return this.canaryBackends.get(serviceName) || null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolves the target backend generation for an incoming request.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} serviceName
|
|
76
|
+
* @param {import('node:http').IncomingMessage} [req]
|
|
77
|
+
* @returns {object|null}
|
|
78
|
+
*/
|
|
79
|
+
resolveBackend(serviceName, req = null) {
|
|
80
|
+
const primary = this.activeBackends.get(serviceName);
|
|
81
|
+
const canary = this.canaryBackends.get(serviceName);
|
|
82
|
+
|
|
83
|
+
if (!canary || !canary.generation) {
|
|
84
|
+
return primary || null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!primary) {
|
|
88
|
+
return canary.generation;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Optional header-based override (e.g. x-canary: true or x-canary: always)
|
|
92
|
+
if (req?.headers) {
|
|
93
|
+
const canaryHeader = req.headers['x-canary'];
|
|
94
|
+
if (canaryHeader === 'true' || canaryHeader === 'always' || canaryHeader === '1') {
|
|
95
|
+
return canary.generation;
|
|
96
|
+
}
|
|
97
|
+
if (canaryHeader === 'false' || canaryHeader === 'never' || canaryHeader === '0') {
|
|
98
|
+
return primary;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Probabilistic routing based on weight
|
|
103
|
+
if (canary.weight > 0 && Math.random() < canary.weight) {
|
|
104
|
+
return canary.generation;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return primary;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Helper to find the service name matching the request.
|
|
112
|
+
* @param {import('node:http').IncomingMessage} [req]
|
|
113
|
+
* @returns {string|null}
|
|
114
|
+
*/
|
|
115
|
+
_resolveServiceName(req) {
|
|
116
|
+
if (this.activeBackends.size === 1) {
|
|
117
|
+
return this.activeBackends.keys().next().value;
|
|
118
|
+
}
|
|
119
|
+
if (this.activeBackends.size > 1 && req?.headers?.host) {
|
|
120
|
+
for (const srv of this.activeBackends.keys()) {
|
|
121
|
+
if (req.headers.host.includes(srv)) {
|
|
122
|
+
return srv;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
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
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Increments in-flight request counter for a generation.
|
|
137
|
+
* @param {number|string} generationId
|
|
138
|
+
*/
|
|
139
|
+
_incrementInFlight(generationId) {
|
|
140
|
+
const id = String(generationId);
|
|
141
|
+
const count = this.inFlightRequests.get(id) || 0;
|
|
142
|
+
this.inFlightRequests.set(id, count + 1);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Decrements in-flight request counter for a generation.
|
|
147
|
+
* @param {number|string} generationId
|
|
148
|
+
*/
|
|
149
|
+
_decrementInFlight(generationId) {
|
|
150
|
+
const id = String(generationId);
|
|
151
|
+
const count = this.inFlightRequests.get(id) || 0;
|
|
152
|
+
if (count <= 1) {
|
|
153
|
+
this.inFlightRequests.delete(id);
|
|
154
|
+
} else {
|
|
155
|
+
this.inFlightRequests.set(id, count - 1);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Gets the current number of in-flight requests for a generation.
|
|
161
|
+
*
|
|
162
|
+
* @param {number|string} generationId
|
|
163
|
+
* @returns {number}
|
|
164
|
+
*/
|
|
165
|
+
getInFlightCount(generationId) {
|
|
166
|
+
return this.inFlightRequests.get(String(generationId)) || 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Switches the active backend generation for a service.
|
|
171
|
+
* Switching is atomic and lock-free for incoming requests.
|
|
172
|
+
*
|
|
173
|
+
* @param {string} serviceName
|
|
174
|
+
* @param {object} generation - { id, internalPort, service }
|
|
175
|
+
*/
|
|
176
|
+
switchBackend(serviceName, generation) {
|
|
177
|
+
this.activeBackends.set(serviceName, generation);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Gets the active backend generation for a service.
|
|
182
|
+
*
|
|
183
|
+
* @param {string} serviceName
|
|
184
|
+
* @returns {object|null}
|
|
185
|
+
*/
|
|
186
|
+
getActiveBackend(serviceName) {
|
|
187
|
+
return this.activeBackends.get(serviceName) || null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Starts listening on the public port.
|
|
192
|
+
*
|
|
193
|
+
* @param {number} publicPort
|
|
194
|
+
* @param {string} [host='0.0.0.0']
|
|
195
|
+
* @returns {Promise<{ port: number, host: string }>}
|
|
196
|
+
*/
|
|
197
|
+
async listen(publicPort, host = '0.0.0.0') {
|
|
198
|
+
if (this.server && this.listeningPort === publicPort) {
|
|
199
|
+
return { port: this.listeningPort, host: this.listeningHost };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (this.server) {
|
|
203
|
+
await this.close();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
this.closing = false;
|
|
207
|
+
|
|
208
|
+
return new Promise((resolve, reject) => {
|
|
209
|
+
const server = http.createServer((req, res) => {
|
|
210
|
+
this._handleHttpRequest(req, res);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
server.on('upgrade', (req, clientSocket, head) => {
|
|
214
|
+
this._handleWebSocketUpgrade(req, clientSocket, head);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
server.once('error', (err) => {
|
|
218
|
+
if (err.code === 'EADDRINUSE') {
|
|
219
|
+
reject(new Error(`Public port ${publicPort} is already occupied by another process.`));
|
|
220
|
+
} else {
|
|
221
|
+
reject(err);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
server.listen(publicPort, host, () => {
|
|
226
|
+
this.server = server;
|
|
227
|
+
const actualPort = server.address() ? server.address().port : publicPort;
|
|
228
|
+
this.listeningPort = actualPort;
|
|
229
|
+
this.listeningHost = host;
|
|
230
|
+
resolve({ port: actualPort, host });
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Dispatches and proxies an HTTP request to the active backend.
|
|
237
|
+
*
|
|
238
|
+
* @param {import('node:http').IncomingMessage} req
|
|
239
|
+
* @param {import('node:http').ServerResponse} res
|
|
240
|
+
*/
|
|
241
|
+
_handleHttpRequest(req, res) {
|
|
242
|
+
const serviceName = this._resolveServiceName(req);
|
|
243
|
+
const backend = serviceName ? this.resolveBackend(serviceName, req) : null;
|
|
244
|
+
|
|
245
|
+
if (!backend || !backend.internalPort) {
|
|
246
|
+
res.writeHead(503, {
|
|
247
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
248
|
+
'Retry-After': '1'
|
|
249
|
+
});
|
|
250
|
+
res.end('503 Service Unavailable: No active generation backend available.');
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const genId = backend.id;
|
|
255
|
+
this._incrementInFlight(genId);
|
|
256
|
+
|
|
257
|
+
let decremented = false;
|
|
258
|
+
const cleanupInFlight = () => {
|
|
259
|
+
if (!decremented) {
|
|
260
|
+
decremented = true;
|
|
261
|
+
this._decrementInFlight(genId);
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
res.once('finish', cleanupInFlight);
|
|
266
|
+
res.once('close', cleanupInFlight);
|
|
267
|
+
|
|
268
|
+
// Forward headers
|
|
269
|
+
const forwardedHeaders = { ...req.headers };
|
|
270
|
+
const clientIp = req.socket?.remoteAddress || '127.0.0.1';
|
|
271
|
+
forwardedHeaders['x-forwarded-for'] = forwardedHeaders['x-forwarded-for']
|
|
272
|
+
? `${forwardedHeaders['x-forwarded-for']}, ${clientIp}`
|
|
273
|
+
: clientIp;
|
|
274
|
+
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);
|
|
277
|
+
|
|
278
|
+
const proxyReq = http.request(
|
|
279
|
+
{
|
|
280
|
+
hostname: '127.0.0.1',
|
|
281
|
+
port: backend.internalPort,
|
|
282
|
+
path: req.url,
|
|
283
|
+
method: req.method,
|
|
284
|
+
headers: forwardedHeaders
|
|
285
|
+
},
|
|
286
|
+
(proxyRes) => {
|
|
287
|
+
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
|
288
|
+
proxyRes.pipe(res);
|
|
289
|
+
}
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
req.on('error', () => {
|
|
293
|
+
cleanupInFlight();
|
|
294
|
+
proxyReq.destroy();
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
proxyReq.on('error', (err) => {
|
|
298
|
+
cleanupInFlight();
|
|
299
|
+
if (!res.headersSent) {
|
|
300
|
+
res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
301
|
+
res.end(`502 Bad Gateway: Error communicating with generation #${genId} (${err.message}).`);
|
|
302
|
+
} else {
|
|
303
|
+
res.destroy();
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
req.pipe(proxyReq);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Dispatches and proxies WebSocket / Upgrade connections to the active backend.
|
|
312
|
+
*/
|
|
313
|
+
_handleWebSocketUpgrade(req, clientSocket, head) {
|
|
314
|
+
const serviceName = this._resolveServiceName(req);
|
|
315
|
+
const backend = serviceName ? this.resolveBackend(serviceName, req) : null;
|
|
316
|
+
if (!backend || !backend.internalPort) {
|
|
317
|
+
clientSocket.destroy();
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const genId = backend.id;
|
|
322
|
+
this._incrementInFlight(genId);
|
|
323
|
+
|
|
324
|
+
let decremented = false;
|
|
325
|
+
const cleanupInFlight = () => {
|
|
326
|
+
if (!decremented) {
|
|
327
|
+
decremented = true;
|
|
328
|
+
this._decrementInFlight(genId);
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
let backendSocket = null;
|
|
333
|
+
|
|
334
|
+
const onClientEnded = () => {
|
|
335
|
+
cleanupInFlight();
|
|
336
|
+
if (backendSocket && !backendSocket.destroyed) {
|
|
337
|
+
backendSocket.destroy();
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
clientSocket.once('close', onClientEnded);
|
|
342
|
+
clientSocket.once('end', onClientEnded);
|
|
343
|
+
clientSocket.once('error', onClientEnded);
|
|
344
|
+
|
|
345
|
+
backendSocket = net.connect(backend.internalPort, '127.0.0.1', () => {
|
|
346
|
+
backendSocket.write(
|
|
347
|
+
`${req.method} ${req.url} HTTP/${req.httpVersion}\r\n` +
|
|
348
|
+
Object.entries(req.headers)
|
|
349
|
+
.map(([k, v]) => `${k}: ${v}\r\n`)
|
|
350
|
+
.join('') +
|
|
351
|
+
'\r\n'
|
|
352
|
+
);
|
|
353
|
+
if (head && head.length > 0) {
|
|
354
|
+
backendSocket.write(head);
|
|
355
|
+
}
|
|
356
|
+
backendSocket.pipe(clientSocket);
|
|
357
|
+
clientSocket.pipe(backendSocket);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const onBackendEnded = () => {
|
|
361
|
+
cleanupInFlight();
|
|
362
|
+
if (!clientSocket.destroyed) {
|
|
363
|
+
clientSocket.destroy();
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
backendSocket.once('close', onBackendEnded);
|
|
368
|
+
backendSocket.once('end', onBackendEnded);
|
|
369
|
+
backendSocket.on('error', onBackendEnded);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Closes the public router HTTP listener.
|
|
374
|
+
*
|
|
375
|
+
* @returns {Promise<void>}
|
|
376
|
+
*/
|
|
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();
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
}
|