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.
@@ -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
+ }