@catbee/utils 2.0.5 → 2.1.1
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/README.md +1 -0
- package/context-store/index.cjs +97 -76
- package/context-store/index.d.ts +74 -57
- package/context-store/index.mjs +97 -77
- package/healthz-server/index.cjs +356 -0
- package/healthz-server/index.d.ts +291 -0
- package/healthz-server/index.mjs +352 -0
- package/index.cjs +7 -0
- package/index.d.ts +1 -0
- package/index.mjs +1 -0
- package/logger/index.cjs +174 -76
- package/logger/index.d.ts +29 -18
- package/logger/index.mjs +174 -77
- package/package.json +12 -7
- package/server/index.cjs +216 -74
- package/server/index.d.ts +72 -14
- package/server/index.mjs +215 -74
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The MIT License
|
|
3
|
+
*
|
|
4
|
+
* Copyright (c) 2026 Catbee Technologies. https://catbee.in/license
|
|
5
|
+
*
|
|
6
|
+
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
* of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
* in the Software without restriction, including without limitation the rights
|
|
9
|
+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
* copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
* furnished to do so, subject to the following conditions:
|
|
12
|
+
*
|
|
13
|
+
* The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
* copies or substantial portions of the Software.
|
|
15
|
+
*
|
|
16
|
+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
* SOFTWARE.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
var http = require('http');
|
|
28
|
+
var env = require('@catbee/utils/env');
|
|
29
|
+
|
|
30
|
+
var __defProp = Object.defineProperty;
|
|
31
|
+
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
32
|
+
function getDefaultHealthzConfig() {
|
|
33
|
+
return {
|
|
34
|
+
host: env.Env.get("HEALTHZ_HOST", "") || env.Env.get("SERVER_HEALTHZ_HOST", "") || env.Env.get("SERVER_HOST", "") || env.Env.get("HOST", "0.0.0.0"),
|
|
35
|
+
port: env.Env.getPort("HEALTHZ_PORT", env.Env.getPort("SERVER_HEALTHZ_PORT", 8282)),
|
|
36
|
+
healthzPath: env.Env.get("HEALTHZ_PATH", "") || env.Env.get("SERVER_HEALTHZ_PATH", "") || env.Env.get("SERVER_HEALTH_CHECK_PATH", "/healthz"),
|
|
37
|
+
readyzPath: env.Env.get("HEALTHZ_READYZ_PATH", "") || env.Env.get("SERVER_READYZ_PATH", "/readyz"),
|
|
38
|
+
startupzPath: env.Env.get("HEALTHZ_STARTUPZ_PATH", "") || env.Env.get("SERVER_STARTUPZ_PATH", "/startupz"),
|
|
39
|
+
detailed: env.Env.getBoolean("HEALTHZ_DETAILED", env.Env.getBoolean("SERVER_HEALTH_CHECK_DETAILED_OUTPUT", true)),
|
|
40
|
+
checks: [],
|
|
41
|
+
checkTimeoutMs: env.Env.getDuration("HEALTHZ_CHECK_TIMEOUT_MS", 5e3),
|
|
42
|
+
shutdownDelayMs: env.Env.getDuration("HEALTHZ_SHUTDOWN_DELAY_MS", 5e3)
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
__name(getDefaultHealthzConfig, "getDefaultHealthzConfig");
|
|
46
|
+
function resolveConfig(userConfig) {
|
|
47
|
+
const defaults = getDefaultHealthzConfig();
|
|
48
|
+
if (!userConfig) return {
|
|
49
|
+
...defaults
|
|
50
|
+
};
|
|
51
|
+
const cleaned = Object.fromEntries(Object.entries(userConfig).filter(([, v]) => v !== void 0));
|
|
52
|
+
return {
|
|
53
|
+
...defaults,
|
|
54
|
+
...cleaned
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
__name(resolveConfig, "resolveConfig");
|
|
58
|
+
|
|
59
|
+
// src/healthz-server/healthz-server.ts
|
|
60
|
+
var SINGLETON_KEY = Symbol.for("CatbeeHealthzServer");
|
|
61
|
+
var _global = globalThis;
|
|
62
|
+
var HealthzServer = class _HealthzServer {
|
|
63
|
+
static {
|
|
64
|
+
__name(this, "HealthzServer");
|
|
65
|
+
}
|
|
66
|
+
server;
|
|
67
|
+
config;
|
|
68
|
+
startedAt = Date.now();
|
|
69
|
+
/** Whether the health server has successfully started listening (for `/startupz`) */
|
|
70
|
+
started = false;
|
|
71
|
+
/** Whether the service is ready to receive traffic (for `/readyz`) */
|
|
72
|
+
ready = false;
|
|
73
|
+
shuttingDown = false;
|
|
74
|
+
onSigterm = /* @__PURE__ */ __name(() => this.initiateShutdown(), "onSigterm");
|
|
75
|
+
onSigint = /* @__PURE__ */ __name(() => this.initiateShutdown(), "onSigint");
|
|
76
|
+
constructor(config) {
|
|
77
|
+
this.config = config;
|
|
78
|
+
this.server = http.createServer((req, res) => this.handleRequest(req, res));
|
|
79
|
+
process.once("SIGTERM", this.onSigterm);
|
|
80
|
+
process.once("SIGINT", this.onSigint);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Boot the health-check server.
|
|
84
|
+
* Returns `null` if a server is already running in this process.
|
|
85
|
+
*/
|
|
86
|
+
static async start(opts) {
|
|
87
|
+
if (_global[SINGLETON_KEY]) return null;
|
|
88
|
+
const config = resolveConfig(opts);
|
|
89
|
+
const instance = new _HealthzServer(config);
|
|
90
|
+
_global[SINGLETON_KEY] = instance;
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
const onError = /* @__PURE__ */ __name((err) => {
|
|
93
|
+
instance.cleanup();
|
|
94
|
+
delete _global[SINGLETON_KEY];
|
|
95
|
+
reject(err);
|
|
96
|
+
}, "onError");
|
|
97
|
+
instance.server.once("error", onError);
|
|
98
|
+
instance.server.listen({
|
|
99
|
+
host: config.host,
|
|
100
|
+
port: config.port
|
|
101
|
+
}, () => {
|
|
102
|
+
instance.server.off("error", onError);
|
|
103
|
+
instance.started = true;
|
|
104
|
+
const addr = instance.server.address();
|
|
105
|
+
if (!addr || typeof addr === "string") {
|
|
106
|
+
instance.cleanup();
|
|
107
|
+
delete _global[SINGLETON_KEY];
|
|
108
|
+
reject(new Error("Failed to resolve health server address"));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
resolve({
|
|
112
|
+
address: addr.address,
|
|
113
|
+
family: addr.family,
|
|
114
|
+
port: addr.port
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/** Whether the health-check server is currently running and started. */
|
|
120
|
+
static isStarted() {
|
|
121
|
+
return _global[SINGLETON_KEY]?.started ?? false;
|
|
122
|
+
}
|
|
123
|
+
/** Mark the service as ready / not-ready for traffic. */
|
|
124
|
+
static setReady(ready) {
|
|
125
|
+
const instance = _global[SINGLETON_KEY];
|
|
126
|
+
if (!instance) return;
|
|
127
|
+
instance.ready = ready;
|
|
128
|
+
}
|
|
129
|
+
/** Whether the service is currently marked as ready. */
|
|
130
|
+
static isReady() {
|
|
131
|
+
return _global[SINGLETON_KEY]?.ready ?? false;
|
|
132
|
+
}
|
|
133
|
+
/** Gracefully stop the health-check server. */
|
|
134
|
+
static async stop() {
|
|
135
|
+
const instance = _global[SINGLETON_KEY];
|
|
136
|
+
if (!instance) return;
|
|
137
|
+
instance.cleanup();
|
|
138
|
+
return new Promise((resolve) => {
|
|
139
|
+
if (typeof instance.server.closeIdleConnections === "function") {
|
|
140
|
+
instance.server.closeIdleConnections();
|
|
141
|
+
}
|
|
142
|
+
instance.server.close(() => {
|
|
143
|
+
delete _global[SINGLETON_KEY];
|
|
144
|
+
resolve();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
static getInstance() {
|
|
149
|
+
return _global[SINGLETON_KEY];
|
|
150
|
+
}
|
|
151
|
+
async handleRequest(req, res) {
|
|
152
|
+
const isHead = req.method === "HEAD";
|
|
153
|
+
if (req.method !== "GET" && !isHead) {
|
|
154
|
+
this.sendJson(res, 405, {
|
|
155
|
+
error: "Method Not Allowed"
|
|
156
|
+
}, isHead);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const url = req.url ?? "/";
|
|
160
|
+
try {
|
|
161
|
+
if (url === this.config.healthzPath) {
|
|
162
|
+
await this.handleLiveness(res, isHead);
|
|
163
|
+
} else if (url === this.config.readyzPath) {
|
|
164
|
+
await this.handleReadiness(res, isHead);
|
|
165
|
+
} else if (url === this.config.startupzPath) {
|
|
166
|
+
this.handleStartup(res, isHead);
|
|
167
|
+
} else {
|
|
168
|
+
this.sendJson(res, 404, {
|
|
169
|
+
error: "Not Found"
|
|
170
|
+
}, isHead);
|
|
171
|
+
}
|
|
172
|
+
} catch (err) {
|
|
173
|
+
const message = err instanceof Error ? err.message : "Internal Server Error";
|
|
174
|
+
this.sendJson(res, 500, {
|
|
175
|
+
error: message
|
|
176
|
+
}, isHead);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/** `/healthz` — Liveness probe */
|
|
180
|
+
async handleLiveness(res, isHead = false) {
|
|
181
|
+
const results = await this.runChecks(this.config.checks);
|
|
182
|
+
if (this.config.onHealthCheck) {
|
|
183
|
+
const start = Date.now();
|
|
184
|
+
try {
|
|
185
|
+
const ok = await this.executeCheck(this.config.onHealthCheck, this.config.checkTimeoutMs);
|
|
186
|
+
results.push({
|
|
187
|
+
name: "custom",
|
|
188
|
+
ok: !!ok,
|
|
189
|
+
durationMs: Date.now() - start,
|
|
190
|
+
...!ok ? {
|
|
191
|
+
error: "Health check returned false"
|
|
192
|
+
} : {}
|
|
193
|
+
});
|
|
194
|
+
} catch (err) {
|
|
195
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
196
|
+
results.push({
|
|
197
|
+
name: "custom",
|
|
198
|
+
ok: false,
|
|
199
|
+
durationMs: Date.now() - start,
|
|
200
|
+
error: message
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const allOk = results.every((r) => r.ok);
|
|
205
|
+
const status = allOk ? "ok" : "unhealthy";
|
|
206
|
+
const httpStatus = allOk ? 200 : 503;
|
|
207
|
+
this.sendProbe(res, httpStatus, status, results, isHead);
|
|
208
|
+
}
|
|
209
|
+
/** `/readyz` — Readiness probe */
|
|
210
|
+
async handleReadiness(res, isHead = false) {
|
|
211
|
+
if (this.shuttingDown || !this.ready) {
|
|
212
|
+
const status2 = "unhealthy";
|
|
213
|
+
this.sendProbe(res, 503, status2, [
|
|
214
|
+
{
|
|
215
|
+
name: "readiness",
|
|
216
|
+
ok: false,
|
|
217
|
+
durationMs: 0,
|
|
218
|
+
error: this.shuttingDown ? "Shutting down" : "Not ready"
|
|
219
|
+
}
|
|
220
|
+
], isHead);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const checksToRun = this.config.readinessChecks ?? this.config.checks;
|
|
224
|
+
const results = await this.runChecks(checksToRun);
|
|
225
|
+
if (this.config.onReadinessCheck) {
|
|
226
|
+
const start = Date.now();
|
|
227
|
+
try {
|
|
228
|
+
const ready = await this.executeCheck(this.config.onReadinessCheck, this.config.checkTimeoutMs);
|
|
229
|
+
results.push({
|
|
230
|
+
name: "custom-readiness",
|
|
231
|
+
ok: !!ready,
|
|
232
|
+
durationMs: Date.now() - start,
|
|
233
|
+
...!ready ? {
|
|
234
|
+
error: "Readiness check returned false"
|
|
235
|
+
} : {}
|
|
236
|
+
});
|
|
237
|
+
} catch (err) {
|
|
238
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
239
|
+
results.push({
|
|
240
|
+
name: "custom-readiness",
|
|
241
|
+
ok: false,
|
|
242
|
+
durationMs: Date.now() - start,
|
|
243
|
+
error: message
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const allOk = results.every((r) => r.ok);
|
|
248
|
+
const status = allOk ? "ok" : "unhealthy";
|
|
249
|
+
const httpStatus = allOk ? 200 : 503;
|
|
250
|
+
this.sendProbe(res, httpStatus, status, results, isHead);
|
|
251
|
+
}
|
|
252
|
+
/** `/startupz` — Startup probe */
|
|
253
|
+
handleStartup(res, isHead = false) {
|
|
254
|
+
if (this.started) {
|
|
255
|
+
this.sendProbe(res, 200, "ok", [], isHead);
|
|
256
|
+
} else {
|
|
257
|
+
this.sendProbe(res, 503, "unhealthy", [
|
|
258
|
+
{
|
|
259
|
+
name: "startup",
|
|
260
|
+
ok: false,
|
|
261
|
+
durationMs: 0,
|
|
262
|
+
error: "Service has not started yet"
|
|
263
|
+
}
|
|
264
|
+
], isHead);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
async runChecks(checks) {
|
|
268
|
+
if (checks.length === 0) return [];
|
|
269
|
+
return Promise.all(checks.map(async ({ name, check }) => {
|
|
270
|
+
const start = Date.now();
|
|
271
|
+
try {
|
|
272
|
+
const result = await this.executeCheck(check, this.config.checkTimeoutMs);
|
|
273
|
+
return {
|
|
274
|
+
name,
|
|
275
|
+
ok: !!result,
|
|
276
|
+
durationMs: Date.now() - start
|
|
277
|
+
};
|
|
278
|
+
} catch (err) {
|
|
279
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
280
|
+
return {
|
|
281
|
+
name,
|
|
282
|
+
ok: false,
|
|
283
|
+
durationMs: Date.now() - start,
|
|
284
|
+
error: message
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
}));
|
|
288
|
+
}
|
|
289
|
+
sendProbe(res, httpStatus, status, checks, isHead = false) {
|
|
290
|
+
const body = {
|
|
291
|
+
status,
|
|
292
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
293
|
+
uptimeSeconds: Math.floor((Date.now() - this.startedAt) / 1e3)
|
|
294
|
+
};
|
|
295
|
+
if (this.config.detailed && checks.length > 0) {
|
|
296
|
+
body.checks = checks;
|
|
297
|
+
}
|
|
298
|
+
this.sendJson(res, httpStatus, body, isHead);
|
|
299
|
+
}
|
|
300
|
+
sendJson(res, status, body, isHead = false) {
|
|
301
|
+
const payload = JSON.stringify(body);
|
|
302
|
+
res.writeHead(status, {
|
|
303
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
304
|
+
"Content-Length": Buffer.byteLength(payload),
|
|
305
|
+
"Cache-Control": "no-cache, no-store, must-revalidate",
|
|
306
|
+
"X-Content-Type-Options": "nosniff"
|
|
307
|
+
});
|
|
308
|
+
if (isHead) {
|
|
309
|
+
res.end();
|
|
310
|
+
} else {
|
|
311
|
+
res.end(payload);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
async executeCheck(fn, ms) {
|
|
315
|
+
const controller = new AbortController();
|
|
316
|
+
let timer;
|
|
317
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
318
|
+
timer = setTimeout(() => {
|
|
319
|
+
const error = new Error(`Check timed out after ${ms}ms`);
|
|
320
|
+
controller.abort(error);
|
|
321
|
+
reject(error);
|
|
322
|
+
}, ms);
|
|
323
|
+
});
|
|
324
|
+
try {
|
|
325
|
+
const checkPromise = Promise.resolve().then(() => fn(controller.signal));
|
|
326
|
+
checkPromise.catch(() => {
|
|
327
|
+
});
|
|
328
|
+
return await Promise.race([
|
|
329
|
+
checkPromise,
|
|
330
|
+
timeoutPromise
|
|
331
|
+
]);
|
|
332
|
+
} finally {
|
|
333
|
+
if (timer) clearTimeout(timer);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
async initiateShutdown() {
|
|
337
|
+
if (this.shuttingDown) return;
|
|
338
|
+
this.shuttingDown = true;
|
|
339
|
+
this.ready = false;
|
|
340
|
+
if (this.config.shutdownDelayMs > 0) {
|
|
341
|
+
await new Promise((r) => setTimeout(r, this.config.shutdownDelayMs));
|
|
342
|
+
}
|
|
343
|
+
await _HealthzServer.stop();
|
|
344
|
+
}
|
|
345
|
+
cleanup() {
|
|
346
|
+
process.off("SIGTERM", this.onSigterm);
|
|
347
|
+
process.off("SIGINT", this.onSigint);
|
|
348
|
+
this.ready = false;
|
|
349
|
+
this.started = false;
|
|
350
|
+
this.shuttingDown = false;
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
exports.HealthzServer = HealthzServer;
|
|
355
|
+
exports.getDefaultHealthzConfig = getDefaultHealthzConfig;
|
|
356
|
+
exports.resolveConfig = resolveConfig;
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The MIT License
|
|
3
|
+
*
|
|
4
|
+
* Copyright (c) 2026 Catbee Technologies. https://catbee.in/license
|
|
5
|
+
*
|
|
6
|
+
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
* of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
* in the Software without restriction, including without limitation the rights
|
|
9
|
+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
* copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
* furnished to do so, subject to the following conditions:
|
|
12
|
+
*
|
|
13
|
+
* The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
* copies or substantial portions of the Software.
|
|
15
|
+
*
|
|
16
|
+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
* SOFTWARE.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A health check function that can be synchronous or asynchronous.
|
|
27
|
+
* Receives an AbortSignal that is triggered when `checkTimeoutMs` is exceeded.
|
|
28
|
+
*
|
|
29
|
+
* > **Cancellation Note**: Cancellation is cooperative. The check function must listen to
|
|
30
|
+
* > `signal.aborted` or pass `signal` to underlying asynchronous APIs (e.g. database drivers, `fetch`).
|
|
31
|
+
* > Synchronous CPU-bound loops or operations that ignore `signal` cannot be forcibly stopped by JavaScript.
|
|
32
|
+
*
|
|
33
|
+
* - Return `true` (or resolve to true) to signal healthy.
|
|
34
|
+
* - Return `false` (or resolve to false) to signal unhealthy.
|
|
35
|
+
* - Throw an error (or reject) to signal unhealthy with an error message.
|
|
36
|
+
*/
|
|
37
|
+
type HealthCheckFn = (signal?: AbortSignal) => boolean | Promise<boolean>;
|
|
38
|
+
/**
|
|
39
|
+
* A readiness check function.
|
|
40
|
+
* Receives an AbortSignal that is triggered when `checkTimeoutMs` is exceeded.
|
|
41
|
+
*
|
|
42
|
+
* > **Cancellation Note**: Cancellation is cooperative. The check function must listen to
|
|
43
|
+
* > `signal.aborted` or pass `signal` to underlying asynchronous APIs (e.g. database drivers, `fetch`).
|
|
44
|
+
* > Synchronous CPU-bound loops or operations that ignore `signal` cannot be forcibly stopped by JavaScript.
|
|
45
|
+
*
|
|
46
|
+
* - Return `true` (or resolve to true) to signal ready.
|
|
47
|
+
* - Return `false` (or resolve to false) to signal not ready.
|
|
48
|
+
* - Throw an error (or reject) to signal not ready with an error.
|
|
49
|
+
*/
|
|
50
|
+
type ReadinessCheckFn = (signal?: AbortSignal) => boolean | Promise<boolean>;
|
|
51
|
+
/**
|
|
52
|
+
* A named health check with an associated check function.
|
|
53
|
+
*/
|
|
54
|
+
interface NamedCheck {
|
|
55
|
+
/** Human-readable name for this check (e.g. 'database', 'redis', 'disk') */
|
|
56
|
+
name: string;
|
|
57
|
+
/**
|
|
58
|
+
* Check function — return false or throw to indicate failure.
|
|
59
|
+
* Receives an AbortSignal that is triggered when the check times out.
|
|
60
|
+
* Cancellation via the signal is cooperative.
|
|
61
|
+
*/
|
|
62
|
+
check: (signal?: AbortSignal) => boolean | Promise<boolean>;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Result of a single named health check.
|
|
66
|
+
*/
|
|
67
|
+
interface CheckResult {
|
|
68
|
+
/** Name of the check */
|
|
69
|
+
name: string;
|
|
70
|
+
/** Whether the check passed */
|
|
71
|
+
ok: boolean;
|
|
72
|
+
/** Duration of the check in milliseconds */
|
|
73
|
+
durationMs: number;
|
|
74
|
+
/** Error message if the check failed */
|
|
75
|
+
error?: string;
|
|
76
|
+
}
|
|
77
|
+
/** Aggregate status string used in JSON probe responses */
|
|
78
|
+
type ProbeStatus = 'ok' | 'unhealthy';
|
|
79
|
+
/**
|
|
80
|
+
* Structured JSON response returned by probe endpoints.
|
|
81
|
+
*/
|
|
82
|
+
interface ProbeResponse {
|
|
83
|
+
/** Overall status */
|
|
84
|
+
status: ProbeStatus;
|
|
85
|
+
/** ISO-8601 timestamp of the check */
|
|
86
|
+
timestamp: string;
|
|
87
|
+
/** Server uptime in seconds */
|
|
88
|
+
uptimeSeconds: number;
|
|
89
|
+
/** Individual check results (only when `detailed` is enabled) */
|
|
90
|
+
checks?: CheckResult[];
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Configuration for the standalone healthz HTTP server.
|
|
94
|
+
*/
|
|
95
|
+
interface CatbeeHealthzServerConfig {
|
|
96
|
+
/** Hostname / IP to bind to
|
|
97
|
+
* - **default**: `'0.0.0.0'`
|
|
98
|
+
* - **env**: `HEALTHZ_HOST` (fallback: `SERVER_HOST`, `HOST`)
|
|
99
|
+
*/
|
|
100
|
+
host?: string;
|
|
101
|
+
/** Port to listen on
|
|
102
|
+
* - **default**: `8282`
|
|
103
|
+
* - **env**: `HEALTHZ_PORT` (fallback: `SERVER_HEALTHZ_PORT`)
|
|
104
|
+
*/
|
|
105
|
+
port?: number;
|
|
106
|
+
/** Liveness probe path — Kubernetes `livenessProbe.httpGet.path`
|
|
107
|
+
* - **default**: `'/healthz'`
|
|
108
|
+
* - **env**: `HEALTHZ_PATH` (fallback: `SERVER_HEALTH_CHECK_PATH`)
|
|
109
|
+
*/
|
|
110
|
+
healthzPath?: string;
|
|
111
|
+
/** Readiness probe path — Kubernetes `readinessProbe.httpGet.path`
|
|
112
|
+
* - **default**: `'/readyz'`
|
|
113
|
+
* - **env**: `HEALTHZ_READYZ_PATH` (fallback: `SERVER_READYZ_PATH`)
|
|
114
|
+
*/
|
|
115
|
+
readyzPath?: string;
|
|
116
|
+
/** Startup probe path — Kubernetes `startupProbe.httpGet.path`
|
|
117
|
+
* - **default**: `'/startupz'`
|
|
118
|
+
* - **env**: `HEALTHZ_STARTUPZ_PATH` (fallback: `SERVER_STARTUPZ_PATH`)
|
|
119
|
+
*/
|
|
120
|
+
startupzPath?: string;
|
|
121
|
+
/** Include individual check results in the JSON response
|
|
122
|
+
* - **default**: `true`
|
|
123
|
+
* - **env**: `HEALTHZ_DETAILED` (fallback: `SERVER_HEALTH_CHECK_DETAILED_OUTPUT`)
|
|
124
|
+
*/
|
|
125
|
+
detailed?: boolean;
|
|
126
|
+
/**
|
|
127
|
+
* Named checks to run on the liveness endpoint (`/healthz`).
|
|
128
|
+
*
|
|
129
|
+
* > **Kubernetes Best Practice**: Keep liveness checks very lightweight (e.g. process is responsive,
|
|
130
|
+
* > event loop not blocked). Avoid placing external dependencies (DB, Redis, downstream APIs) here;
|
|
131
|
+
* > if a shared dependency encounters transient downtime, failing liveness causes Kubernetes to restart
|
|
132
|
+
* > the container, risking cascading restart storms.
|
|
133
|
+
* >
|
|
134
|
+
* > Place external dependency checks in `readinessChecks` instead.
|
|
135
|
+
*/
|
|
136
|
+
checks?: NamedCheck[];
|
|
137
|
+
/**
|
|
138
|
+
* Named checks to run on the readiness endpoint (`/readyz`).
|
|
139
|
+
*
|
|
140
|
+
* Use this for external dependencies (DB, Redis, cache, message broker).
|
|
141
|
+
* If a dependency goes down, Kubernetes will temporarily remove the pod from traffic rotation
|
|
142
|
+
* without killing/restarting the container, allowing it to recover cleanly.
|
|
143
|
+
*
|
|
144
|
+
* - If **omitted** (`undefined`): falls back to `checks`.
|
|
145
|
+
* - If **explicitly empty** (`[]`): no readiness checks are run (traffic gated purely by `setReady(true)`).
|
|
146
|
+
*/
|
|
147
|
+
readinessChecks?: NamedCheck[];
|
|
148
|
+
/**
|
|
149
|
+
* Custom liveness check function. Runs *in addition to* `checks`.
|
|
150
|
+
* Return `false` or throw to indicate unhealthy.
|
|
151
|
+
*/
|
|
152
|
+
onHealthCheck?: HealthCheckFn;
|
|
153
|
+
/**
|
|
154
|
+
* Custom readiness check function. Runs *in addition to* `readinessChecks`.
|
|
155
|
+
* Return `false` or throw to indicate not ready.
|
|
156
|
+
*/
|
|
157
|
+
onReadinessCheck?: ReadinessCheckFn;
|
|
158
|
+
/** Timeout (ms) per individual check before it's considered failed
|
|
159
|
+
* - **default**: `5000`
|
|
160
|
+
* - **env**: `HEALTHZ_CHECK_TIMEOUT_MS`
|
|
161
|
+
*/
|
|
162
|
+
checkTimeoutMs?: number;
|
|
163
|
+
/**
|
|
164
|
+
* Graceful shutdown delay in milliseconds.
|
|
165
|
+
* After receiving SIGTERM/SIGINT the server immediately flips readiness
|
|
166
|
+
* to `false` and waits this many ms before closing — giving the load
|
|
167
|
+
* balancer time to drain traffic.
|
|
168
|
+
* - **default**: `5000`
|
|
169
|
+
* - **env**: `HEALTHZ_SHUTDOWN_DELAY_MS`
|
|
170
|
+
*/
|
|
171
|
+
shutdownDelayMs?: number;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Address information returned after the server starts listening.
|
|
175
|
+
*/
|
|
176
|
+
interface HealthzAddressInfo {
|
|
177
|
+
address: string;
|
|
178
|
+
family: string;
|
|
179
|
+
port: number;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Standalone HTTP health-check server designed for Kubernetes probes
|
|
184
|
+
* and microservice orchestration.
|
|
185
|
+
*
|
|
186
|
+
* Exposes three probe endpoints (paths are configurable):
|
|
187
|
+
*
|
|
188
|
+
* | Probe | Default path | K8s probe type | Behaviour |
|
|
189
|
+
* |-----------|-------------|------------------|-----------|
|
|
190
|
+
* | Liveness | `/healthz` | `livenessProbe` | Runs configured checks; 200 = alive, 503 = unhealthy |
|
|
191
|
+
* | Readiness | `/readyz` | `readinessProbe` | Checks readiness flag + readiness checks; 503 while not ready |
|
|
192
|
+
* | Startup | `/startupz` | `startupProbe` | 200 once the server has successfully started listening; 503 before that |
|
|
193
|
+
*
|
|
194
|
+
* ### Kubernetes Probe Best Practices:
|
|
195
|
+
* - **Liveness (`/healthz`)**: Keep these checks extremely lightweight (e.g. process is responsive,
|
|
196
|
+
* event loop not blocked). Avoid external dependencies (DB/Redis) here; if a shared database has
|
|
197
|
+
* a transient outage, failing liveness causes Kubernetes to restart the container, which does not
|
|
198
|
+
* fix external outages and can cause cascading restart storms.
|
|
199
|
+
* - **Readiness (`/readyz`)**: Place external dependency checks (DB, Redis, downstream APIs) here via
|
|
200
|
+
* `readinessChecks`. If a dependency fails, Kubernetes temporarily pulls the pod from service endpoints
|
|
201
|
+
* without restarting the container, allowing it to recover gracefully.
|
|
202
|
+
* - **Startup (`/startupz`)**: Verifies the health server process has started listening.
|
|
203
|
+
*
|
|
204
|
+
* @example
|
|
205
|
+
* ```ts
|
|
206
|
+
* import { HealthzServer } from '@catbee/utils/healthz-server';
|
|
207
|
+
*
|
|
208
|
+
* const addr = await HealthzServer.start({
|
|
209
|
+
* port: 8282,
|
|
210
|
+
* // Keep liveness lightweight:
|
|
211
|
+
* checks: [
|
|
212
|
+
* { name: 'process', check: () => true },
|
|
213
|
+
* ],
|
|
214
|
+
* // Place dependency checks on readiness:
|
|
215
|
+
* readinessChecks: [
|
|
216
|
+
* { name: 'database', check: (signal) => db.ping({ signal }) },
|
|
217
|
+
* { name: 'redis', check: (signal) => redis.ping({ signal }) },
|
|
218
|
+
* ],
|
|
219
|
+
* shutdownDelayMs: 10_000,
|
|
220
|
+
* });
|
|
221
|
+
*
|
|
222
|
+
* // Signal readiness after all background services and migrations are ready:
|
|
223
|
+
* HealthzServer.setReady(true);
|
|
224
|
+
* ```
|
|
225
|
+
*/
|
|
226
|
+
declare class HealthzServer {
|
|
227
|
+
private readonly server;
|
|
228
|
+
private readonly config;
|
|
229
|
+
private readonly startedAt;
|
|
230
|
+
/** Whether the health server has successfully started listening (for `/startupz`) */
|
|
231
|
+
private started;
|
|
232
|
+
/** Whether the service is ready to receive traffic (for `/readyz`) */
|
|
233
|
+
private ready;
|
|
234
|
+
private shuttingDown;
|
|
235
|
+
private readonly onSigterm;
|
|
236
|
+
private readonly onSigint;
|
|
237
|
+
private constructor();
|
|
238
|
+
/**
|
|
239
|
+
* Boot the health-check server.
|
|
240
|
+
* Returns `null` if a server is already running in this process.
|
|
241
|
+
*/
|
|
242
|
+
static start(opts?: CatbeeHealthzServerConfig): Promise<HealthzAddressInfo | null>;
|
|
243
|
+
/** Whether the health-check server is currently running and started. */
|
|
244
|
+
static isStarted(): boolean;
|
|
245
|
+
/** Mark the service as ready / not-ready for traffic. */
|
|
246
|
+
static setReady(ready: boolean): void;
|
|
247
|
+
/** Whether the service is currently marked as ready. */
|
|
248
|
+
static isReady(): boolean;
|
|
249
|
+
/** Gracefully stop the health-check server. */
|
|
250
|
+
static stop(): Promise<void>;
|
|
251
|
+
static getInstance(): HealthzServer | undefined;
|
|
252
|
+
private handleRequest;
|
|
253
|
+
/** `/healthz` — Liveness probe */
|
|
254
|
+
private handleLiveness;
|
|
255
|
+
/** `/readyz` — Readiness probe */
|
|
256
|
+
private handleReadiness;
|
|
257
|
+
/** `/startupz` — Startup probe */
|
|
258
|
+
private handleStartup;
|
|
259
|
+
private runChecks;
|
|
260
|
+
private sendProbe;
|
|
261
|
+
private sendJson;
|
|
262
|
+
private executeCheck;
|
|
263
|
+
private initiateShutdown;
|
|
264
|
+
private cleanup;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Resolved configuration with all defaults applied.
|
|
269
|
+
* Internal-only — consumers interact with `CatbeeHealthzServerConfig`.
|
|
270
|
+
*
|
|
271
|
+
* `readinessChecks` is kept as `NamedCheck[] | undefined` so that
|
|
272
|
+
* "omitted" (-> fall back to `checks`) is distinguishable from
|
|
273
|
+
* "explicitly empty" (-> run nothing).
|
|
274
|
+
*/
|
|
275
|
+
interface ResolvedHealthzConfig extends Required<Omit<CatbeeHealthzServerConfig, 'onHealthCheck' | 'onReadinessCheck' | 'readinessChecks'>> {
|
|
276
|
+
onHealthCheck?: CatbeeHealthzServerConfig['onHealthCheck'];
|
|
277
|
+
onReadinessCheck?: CatbeeHealthzServerConfig['onReadinessCheck'];
|
|
278
|
+
readinessChecks?: CatbeeHealthzServerConfig['readinessChecks'];
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Loads default Healthz server configuration from environment variables.
|
|
282
|
+
*/
|
|
283
|
+
declare function getDefaultHealthzConfig(): ResolvedHealthzConfig;
|
|
284
|
+
/**
|
|
285
|
+
* Merge user-supplied configuration with environment-resolved defaults.
|
|
286
|
+
* Undefined user values do not override resolved defaults.
|
|
287
|
+
*/
|
|
288
|
+
declare function resolveConfig(userConfig?: CatbeeHealthzServerConfig): ResolvedHealthzConfig;
|
|
289
|
+
|
|
290
|
+
export { HealthzServer, getDefaultHealthzConfig, resolveConfig };
|
|
291
|
+
export type { CatbeeHealthzServerConfig, CheckResult, HealthCheckFn, HealthzAddressInfo, NamedCheck, ProbeResponse, ProbeStatus, ReadinessCheckFn, ResolvedHealthzConfig };
|