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,244 @@
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 =
174
+ typeof this.processManager.getProcess === 'function' ? this.processManager.getProcess(generation.pid) : null;
175
+ const stderr = procInfo?.recentStderr || '';
176
+
177
+ if (stderr.includes('EADDRINUSE') || stderr.includes('address already in use')) {
178
+ const portHint = options.configuredPort ? ` (such as configured port :${options.configuredPort})` : '';
179
+ throw new ProcessStartupError(
180
+ `Generation #${generation.id} (PID ${generation.pid}) crashed with EADDRINUSE during startup. ` +
181
+ `The application likely attempted to bind to a hardcoded port${portHint} instead of reading process.env.PORT (${generation.internalPort}).` +
182
+ `\nDetails:\n${stderr.trim()}`,
183
+ generation
184
+ );
185
+ }
186
+
187
+ throw new ProcessStartupError(
188
+ `Generation #${generation.id} (PID ${generation.pid}) process exited unexpectedly during startup.` +
189
+ (stderr ? `\nDetails:\n${stderr.trim()}` : ''),
190
+ generation
191
+ );
192
+ }
193
+
194
+ // 2. Check TCP port
195
+ const tcpReady = await this.checkTcp(generation.internalPort);
196
+ if (tcpReady) {
197
+ // 3. If HTTP health check path is configured, verify HTTP 2xx
198
+ if (healthPath) {
199
+ const httpReady = await this.checkHttp(generation.internalPort, healthPath);
200
+ if (httpReady) {
201
+ return true;
202
+ }
203
+ } else {
204
+ return true;
205
+ }
206
+ } else {
207
+ // Port mismatch fast-detection: If application is listening on configured public port instead of internalPort
208
+ const isRouterOnConfiguredPort =
209
+ options.router && options.router.server && options.router.listeningPort === options.configuredPort;
210
+
211
+ if (!isRouterOnConfiguredPort && options.configuredPort && options.configuredPort !== generation.internalPort) {
212
+ const isConfiguredPortOpen = await this.checkTcp(options.configuredPort);
213
+ if (isConfiguredPortOpen) {
214
+ throw new PortMismatchError(
215
+ `Port mismatch detected: Generation #${generation.id} (PID ${generation.pid}) is listening on configured public port ${options.configuredPort} ` +
216
+ `instead of the dynamically allocated process.env.PORT (${generation.internalPort}). ` +
217
+ `Please ensure your application code reads process.env.PORT (e.g. app.listen(process.env.PORT || ${options.configuredPort})).`,
218
+ generation,
219
+ options.configuredPort
220
+ );
221
+ }
222
+ }
223
+ }
224
+
225
+ // 4. Check timeout
226
+ if (Date.now() - startTime >= timeout) {
227
+ if (generation.pid && this.processManager && this.processManager.isAlive(generation.pid)) {
228
+ throw new ReadinessTimeoutError(
229
+ `Generation #${generation.id} (PID ${generation.pid}) is still running, but did not accept connections on process.env.PORT (${generation.internalPort}) within ${timeout}ms. ` +
230
+ `Ensure your application listens on process.env.PORT (e.g. app.listen(process.env.PORT)).`,
231
+ generation
232
+ );
233
+ }
234
+
235
+ throw new ReadinessTimeoutError(
236
+ `Generation #${generation.id} on port ${generation.internalPort} failed readiness check within ${timeout}ms.`,
237
+ generation
238
+ );
239
+ }
240
+
241
+ await new Promise((resolve) => setTimeout(resolve, interval));
242
+ }
243
+ }
244
+ }