unitup 0.2.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 +183 -0
- package/package.json +5 -3
- package/src/cli.js +374 -2
- 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 +244 -0
- package/src/core/router.js +507 -0
- package/src/core/supervisor.js +222 -0
- package/src/deploy/deployment-manager.js +388 -0
- package/src/deploy/index.js +9 -0
- package/src/deploy/rollback-manager.js +187 -0
- package/src/index.js +54 -0
- package/src/platform/macos.js +4 -1
- package/src/platform/windows-host.js +1 -1
- package/src/platform/windows.js +4 -1
- package/src/service/config.js +8 -0
- package/src/service/normalize.js +30 -1
- package/src/unit.js +29 -3
- package/src/utils.js +80 -0
- 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
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Router owns stable public ports and reverse-proxies HTTP & WebSocket traffic
|
|
6
|
+
* to whichever internal generation is marked active.
|
|
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
|
+
*
|
|
13
|
+
* It contains NO deployment sequencing decisions.
|
|
14
|
+
*/
|
|
15
|
+
export class Router {
|
|
16
|
+
constructor(options = {}) {
|
|
17
|
+
this.activeBackends = new Map(); // serviceName -> generation { id, internalPort }
|
|
18
|
+
this.canaryBackends = new Map(); // serviceName -> { generation, weight }
|
|
19
|
+
this.inFlightRequests = new Map(); // generationId -> number
|
|
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
|
|
26
|
+
this.closing = false;
|
|
27
|
+
}
|
|
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
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Sets a canary backend with a specific traffic weight (0 to 1, or 1 to 100).
|
|
47
|
+
*
|
|
48
|
+
* @param {string} serviceName
|
|
49
|
+
* @param {object} generation
|
|
50
|
+
* @param {number} [weight=0.1] - Probability (0 to 1) or percentage (1 to 100)
|
|
51
|
+
* @param {number} [publicPort]
|
|
52
|
+
*/
|
|
53
|
+
setCanary(serviceName, generation, weight = 0.1, publicPort = null) {
|
|
54
|
+
const parsed = typeof weight === 'string' ? Number.parseFloat(weight.replace('%', '')) : Number(weight);
|
|
55
|
+
const num = Number.isNaN(parsed) ? 0.1 : parsed;
|
|
56
|
+
const normalizedWeight = num > 1 ? num / 100 : num;
|
|
57
|
+
this.canaryBackends.set(serviceName, {
|
|
58
|
+
generation,
|
|
59
|
+
weight: Math.max(0, Math.min(1, normalizedWeight))
|
|
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
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Clears the canary backend for a service.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} serviceName
|
|
73
|
+
*/
|
|
74
|
+
clearCanary(serviceName) {
|
|
75
|
+
this.canaryBackends.delete(serviceName);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Promotes the canary generation to the active primary generation,
|
|
80
|
+
* clearing the canary state.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} serviceName
|
|
83
|
+
* @returns {object|null} The promoted generation
|
|
84
|
+
*/
|
|
85
|
+
promoteCanary(serviceName) {
|
|
86
|
+
const canary = this.canaryBackends.get(serviceName);
|
|
87
|
+
if (!canary || !canary.generation) return null;
|
|
88
|
+
const port = this.servicePorts.get(serviceName);
|
|
89
|
+
this.switchBackend(serviceName, canary.generation, port);
|
|
90
|
+
this.canaryBackends.delete(serviceName);
|
|
91
|
+
return canary.generation;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Gets the canary config for a service.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} serviceName
|
|
98
|
+
* @returns {{ generation: object, weight: number }|null}
|
|
99
|
+
*/
|
|
100
|
+
getCanary(serviceName) {
|
|
101
|
+
return this.canaryBackends.get(serviceName) || null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Resolves the target backend generation for an incoming request.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} serviceName
|
|
108
|
+
* @param {import('node:http').IncomingMessage} [req]
|
|
109
|
+
* @returns {object|null}
|
|
110
|
+
*/
|
|
111
|
+
resolveBackend(serviceName, req = null) {
|
|
112
|
+
const primary = this.activeBackends.get(serviceName);
|
|
113
|
+
const canary = this.canaryBackends.get(serviceName);
|
|
114
|
+
|
|
115
|
+
if (!canary || !canary.generation) {
|
|
116
|
+
return primary || null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!primary) {
|
|
120
|
+
return canary.generation;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Optional header-based override (e.g. x-canary: true or x-canary: always)
|
|
124
|
+
if (req?.headers) {
|
|
125
|
+
const canaryHeader = req.headers['x-canary'];
|
|
126
|
+
if (canaryHeader === 'true' || canaryHeader === 'always' || canaryHeader === '1') {
|
|
127
|
+
return canary.generation;
|
|
128
|
+
}
|
|
129
|
+
if (canaryHeader === 'false' || canaryHeader === 'never' || canaryHeader === '0') {
|
|
130
|
+
return primary;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Probabilistic routing based on weight
|
|
135
|
+
if (canary.weight > 0 && Math.random() < canary.weight) {
|
|
136
|
+
return canary.generation;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return primary;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Helper to find the service name matching the request.
|
|
144
|
+
* STRICT: Scoped to the listening port. Never returns an unrelated service!
|
|
145
|
+
*
|
|
146
|
+
* @param {import('node:http').IncomingMessage} [req]
|
|
147
|
+
* @param {number} [listeningPort]
|
|
148
|
+
* @returns {string|null}
|
|
149
|
+
*/
|
|
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;
|
|
162
|
+
}
|
|
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)) {
|
|
185
|
+
return srv;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// STRICT: Return null if host does not match any service on this port.
|
|
191
|
+
// NEVER fall back to another random service.
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Increments in-flight request counter for a generation.
|
|
197
|
+
* @param {number|string} generationId
|
|
198
|
+
*/
|
|
199
|
+
_incrementInFlight(generationId) {
|
|
200
|
+
const id = String(generationId);
|
|
201
|
+
const count = this.inFlightRequests.get(id) || 0;
|
|
202
|
+
this.inFlightRequests.set(id, count + 1);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Decrements in-flight request counter for a generation.
|
|
207
|
+
* @param {number|string} generationId
|
|
208
|
+
*/
|
|
209
|
+
_decrementInFlight(generationId) {
|
|
210
|
+
const id = String(generationId);
|
|
211
|
+
const count = this.inFlightRequests.get(id) || 0;
|
|
212
|
+
if (count <= 1) {
|
|
213
|
+
this.inFlightRequests.delete(id);
|
|
214
|
+
} else {
|
|
215
|
+
this.inFlightRequests.set(id, count - 1);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Gets the current number of in-flight requests for a generation.
|
|
221
|
+
*
|
|
222
|
+
* @param {number|string} generationId
|
|
223
|
+
* @returns {number}
|
|
224
|
+
*/
|
|
225
|
+
getInFlightCount(generationId) {
|
|
226
|
+
return this.inFlightRequests.get(String(generationId)) || 0;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Switches the active backend generation for a service.
|
|
231
|
+
* Switching is atomic and lock-free for incoming requests.
|
|
232
|
+
*
|
|
233
|
+
* @param {string} serviceName
|
|
234
|
+
* @param {object} generation - { id, internalPort, service }
|
|
235
|
+
* @param {number} [publicPort]
|
|
236
|
+
*/
|
|
237
|
+
switchBackend(serviceName, generation, publicPort = null) {
|
|
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
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Gets the active backend generation for a service.
|
|
248
|
+
*
|
|
249
|
+
* @param {string} serviceName
|
|
250
|
+
* @returns {object|null}
|
|
251
|
+
*/
|
|
252
|
+
getActiveBackend(serviceName) {
|
|
253
|
+
return this.activeBackends.get(serviceName) || null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Starts listening on the public port.
|
|
258
|
+
* Supports multiple distinct ports concurrently without closing existing ones.
|
|
259
|
+
*
|
|
260
|
+
* @param {number} publicPort
|
|
261
|
+
* @param {string} [host='0.0.0.0']
|
|
262
|
+
* @param {string} [serviceName=null]
|
|
263
|
+
* @returns {Promise<{ port: number, host: string }>}
|
|
264
|
+
*/
|
|
265
|
+
async listen(publicPort, host = '0.0.0.0', serviceName = null) {
|
|
266
|
+
const portNum = Number(publicPort);
|
|
267
|
+
if (serviceName) {
|
|
268
|
+
this.registerServicePort(serviceName, portNum);
|
|
269
|
+
}
|
|
270
|
+
|
|
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 };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
this.closing = false;
|
|
280
|
+
|
|
281
|
+
return new Promise((resolve, reject) => {
|
|
282
|
+
const server = http.createServer((req, res) => {
|
|
283
|
+
this._handleHttpRequest(req, res, portNum);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
server.on('upgrade', (req, clientSocket, head) => {
|
|
287
|
+
this._handleWebSocketUpgrade(req, clientSocket, head, portNum);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
server.once('error', (err) => {
|
|
291
|
+
if (err.code === 'EADDRINUSE') {
|
|
292
|
+
reject(new Error(`Public port ${portNum} is already occupied by another process.`));
|
|
293
|
+
} else {
|
|
294
|
+
reject(err);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
server.listen(portNum, host, () => {
|
|
299
|
+
this.servers.set(portNum, { server, port: portNum, host });
|
|
300
|
+
const actualPort = server.address() ? server.address().port : portNum;
|
|
301
|
+
this.listeningPort = actualPort;
|
|
302
|
+
this.listeningHost = host;
|
|
303
|
+
this.server = server;
|
|
304
|
+
resolve({ port: actualPort, host });
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Dispatches and proxies an HTTP request to the active backend.
|
|
311
|
+
*
|
|
312
|
+
* @param {import('node:http').IncomingMessage} req
|
|
313
|
+
* @param {import('node:http').ServerResponse} res
|
|
314
|
+
* @param {number} [listeningPort]
|
|
315
|
+
*/
|
|
316
|
+
_handleHttpRequest(req, res, listeningPort = null) {
|
|
317
|
+
const effectivePort = listeningPort || this.listeningPort;
|
|
318
|
+
const serviceName = this._resolveServiceName(req, effectivePort);
|
|
319
|
+
const backend = serviceName ? this.resolveBackend(serviceName, req) : null;
|
|
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
|
+
|
|
332
|
+
if (!backend || !backend.internalPort) {
|
|
333
|
+
res.writeHead(503, {
|
|
334
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
335
|
+
'Retry-After': '1'
|
|
336
|
+
});
|
|
337
|
+
res.end(`503 Service Unavailable: No active generation backend available for service "${serviceName}".`);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const genId = backend.id;
|
|
342
|
+
this._incrementInFlight(genId);
|
|
343
|
+
|
|
344
|
+
let decremented = false;
|
|
345
|
+
const cleanupInFlight = () => {
|
|
346
|
+
if (!decremented) {
|
|
347
|
+
decremented = true;
|
|
348
|
+
this._decrementInFlight(genId);
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
res.once('finish', cleanupInFlight);
|
|
353
|
+
res.once('close', cleanupInFlight);
|
|
354
|
+
|
|
355
|
+
// Forward headers
|
|
356
|
+
const forwardedHeaders = { ...req.headers };
|
|
357
|
+
const clientIp = req.socket?.remoteAddress || '127.0.0.1';
|
|
358
|
+
forwardedHeaders['x-forwarded-for'] = forwardedHeaders['x-forwarded-for']
|
|
359
|
+
? `${forwardedHeaders['x-forwarded-for']}, ${clientIp}`
|
|
360
|
+
: clientIp;
|
|
361
|
+
forwardedHeaders['x-forwarded-proto'] = forwardedHeaders['x-forwarded-proto'] || 'http';
|
|
362
|
+
forwardedHeaders['x-forwarded-host'] = req.headers.host || `localhost:${effectivePort}`;
|
|
363
|
+
forwardedHeaders['x-forwarded-port'] = String(effectivePort);
|
|
364
|
+
|
|
365
|
+
const proxyReq = http.request(
|
|
366
|
+
{
|
|
367
|
+
hostname: '127.0.0.1',
|
|
368
|
+
port: backend.internalPort,
|
|
369
|
+
path: req.url,
|
|
370
|
+
method: req.method,
|
|
371
|
+
headers: forwardedHeaders
|
|
372
|
+
},
|
|
373
|
+
(proxyRes) => {
|
|
374
|
+
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
|
375
|
+
proxyRes.pipe(res);
|
|
376
|
+
}
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
req.on('error', () => {
|
|
380
|
+
cleanupInFlight();
|
|
381
|
+
proxyReq.destroy();
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
proxyReq.on('error', (err) => {
|
|
385
|
+
cleanupInFlight();
|
|
386
|
+
if (!res.headersSent) {
|
|
387
|
+
res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
388
|
+
res.end(`502 Bad Gateway: Error communicating with generation #${genId} (${err.message}).`);
|
|
389
|
+
} else {
|
|
390
|
+
res.destroy();
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
req.pipe(proxyReq);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Dispatches and proxies WebSocket / Upgrade connections to the active backend.
|
|
399
|
+
*/
|
|
400
|
+
_handleWebSocketUpgrade(req, clientSocket, head, listeningPort = null) {
|
|
401
|
+
const effectivePort = listeningPort || this.listeningPort;
|
|
402
|
+
const serviceName = this._resolveServiceName(req, effectivePort);
|
|
403
|
+
const backend = serviceName ? this.resolveBackend(serviceName, req) : null;
|
|
404
|
+
if (!serviceName || !backend || !backend.internalPort) {
|
|
405
|
+
clientSocket.destroy();
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const genId = backend.id;
|
|
410
|
+
this._incrementInFlight(genId);
|
|
411
|
+
|
|
412
|
+
let decremented = false;
|
|
413
|
+
const cleanupInFlight = () => {
|
|
414
|
+
if (!decremented) {
|
|
415
|
+
decremented = true;
|
|
416
|
+
this._decrementInFlight(genId);
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
let backendSocket = null;
|
|
421
|
+
|
|
422
|
+
const onClientEnded = () => {
|
|
423
|
+
cleanupInFlight();
|
|
424
|
+
if (backendSocket && !backendSocket.destroyed) {
|
|
425
|
+
backendSocket.destroy();
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
clientSocket.once('close', onClientEnded);
|
|
430
|
+
clientSocket.once('end', onClientEnded);
|
|
431
|
+
clientSocket.once('error', onClientEnded);
|
|
432
|
+
|
|
433
|
+
backendSocket = net.connect(backend.internalPort, '127.0.0.1', () => {
|
|
434
|
+
backendSocket.write(
|
|
435
|
+
`${req.method} ${req.url} HTTP/${req.httpVersion}\r\n` +
|
|
436
|
+
Object.entries(req.headers)
|
|
437
|
+
.map(([k, v]) => `${k}: ${v}\r\n`)
|
|
438
|
+
.join('') +
|
|
439
|
+
'\r\n'
|
|
440
|
+
);
|
|
441
|
+
if (head && head.length > 0) {
|
|
442
|
+
backendSocket.write(head);
|
|
443
|
+
}
|
|
444
|
+
backendSocket.pipe(clientSocket);
|
|
445
|
+
clientSocket.pipe(backendSocket);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
const onBackendEnded = () => {
|
|
449
|
+
cleanupInFlight();
|
|
450
|
+
if (!clientSocket.destroyed) {
|
|
451
|
+
clientSocket.destroy();
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
backendSocket.once('close', onBackendEnded);
|
|
456
|
+
backendSocket.once('end', onBackendEnded);
|
|
457
|
+
backendSocket.on('error', onBackendEnded);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Closes the public router HTTP listener(s).
|
|
462
|
+
*
|
|
463
|
+
* @param {number} [port=null] - Specific port to close, or null to close all.
|
|
464
|
+
* @returns {Promise<void>}
|
|
465
|
+
*/
|
|
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
|
+
});
|
|
484
|
+
});
|
|
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;
|
|
506
|
+
}
|
|
507
|
+
}
|