@catbee/utils 2.2.1 → 2.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.
@@ -25,6 +25,7 @@
25
25
  'use strict';
26
26
 
27
27
  var http = require('http');
28
+ var string = require('@catbee/utils/string');
28
29
  var env = require('@catbee/utils/env');
29
30
 
30
31
  var __defProp = Object.defineProperty;
@@ -60,6 +61,24 @@ __name(resolveConfig, "resolveConfig");
60
61
  // src/healthz-server/healthz-server.ts
61
62
  var SINGLETON_KEY = Symbol.for("CatbeeHealthzServer");
62
63
  var _global = globalThis;
64
+ function formatHostForUrl(host) {
65
+ if (host.includes(":")) {
66
+ return `[${host}]`;
67
+ }
68
+ return host;
69
+ }
70
+ __name(formatHostForUrl, "formatHostForUrl");
71
+ function getErrorMessage(err) {
72
+ if (err instanceof Error) return err.message;
73
+ if (typeof err === "string" && err.trim().length > 0) return err;
74
+ return "Unknown error";
75
+ }
76
+ __name(getErrorMessage, "getErrorMessage");
77
+ function normalizeProbePath(p) {
78
+ const trimmed = string.trimChars(p, "/");
79
+ return trimmed ? `/${trimmed}` : "/";
80
+ }
81
+ __name(normalizeProbePath, "normalizeProbePath");
63
82
  var HealthzServer = class _HealthzServer {
64
83
  static {
65
84
  __name(this, "HealthzServer");
@@ -67,6 +86,8 @@ var HealthzServer = class _HealthzServer {
67
86
  server;
68
87
  config;
69
88
  startedAt = Date.now();
89
+ /** Running address info for the Healthz probe server */
90
+ addressInfo = null;
70
91
  /** Whether the health HTTP server is currently running and listening on its port */
71
92
  running = false;
72
93
  /** Whether application startup has completed (for `/startupz`) */
@@ -74,10 +95,21 @@ var HealthzServer = class _HealthzServer {
74
95
  /** Whether the service is ready to receive traffic (for `/readyz`) */
75
96
  ready = false;
76
97
  shuttingDown = false;
98
+ livenessChecks;
99
+ readinessChecks;
100
+ stoppingPromise;
77
101
  onSigterm = /* @__PURE__ */ __name(() => this.initiateShutdown(), "onSigterm");
78
102
  onSigint = /* @__PURE__ */ __name(() => this.initiateShutdown(), "onSigint");
79
103
  constructor(config) {
80
104
  this.config = config;
105
+ this.livenessChecks = [
106
+ ...config.checks
107
+ ];
108
+ this.readinessChecks = config.readinessChecks !== void 0 ? [
109
+ ...config.readinessChecks
110
+ ] : [
111
+ ...config.checks
112
+ ];
81
113
  this.server = http.createServer((req, res) => this.handleRequest(req, res));
82
114
  if (this.config.handleSignals !== false) {
83
115
  process.once("SIGTERM", this.onSigterm);
@@ -99,78 +131,181 @@ var HealthzServer = class _HealthzServer {
99
131
  const config = resolveConfig(opts);
100
132
  const instance = new _HealthzServer(config);
101
133
  _global[SINGLETON_KEY] = instance;
134
+ const bindHost = config.host.startsWith("[") && config.host.endsWith("]") ? config.host.slice(1, -1) : config.host;
102
135
  return new Promise((resolve, reject) => {
103
136
  const onError = /* @__PURE__ */ __name((err) => {
104
137
  instance.cleanup();
138
+ instance.running = false;
139
+ instance.shuttingDown = false;
140
+ instance.addressInfo = null;
105
141
  delete _global[SINGLETON_KEY];
106
142
  reject(err);
107
143
  }, "onError");
108
144
  instance.server.once("error", onError);
109
145
  instance.server.listen({
110
- host: config.host,
146
+ host: bindHost,
111
147
  port: config.port
112
148
  }, () => {
113
149
  instance.server.off("error", onError);
150
+ instance.server.on("error", () => {
151
+ });
114
152
  instance.running = true;
115
153
  const addr = instance.server.address();
116
154
  if (!addr || typeof addr === "string") {
117
155
  instance.cleanup();
156
+ instance.running = false;
157
+ instance.shuttingDown = false;
158
+ instance.addressInfo = null;
118
159
  delete _global[SINGLETON_KEY];
119
160
  reject(new Error("Failed to resolve health server address"));
120
161
  return;
121
162
  }
122
- resolve({
163
+ instance.addressInfo = {
123
164
  address: addr.address,
124
165
  family: addr.family,
125
166
  port: addr.port
167
+ };
168
+ resolve({
169
+ ...instance.addressInfo
126
170
  });
127
171
  });
128
172
  });
129
173
  }
130
174
  /** Whether the Healthz HTTP probe server is currently running and listening on its port. */
175
+ isRunning() {
176
+ return this.running;
177
+ }
178
+ /** Whether the Healthz HTTP probe server is currently running and listening on its port. */
131
179
  static isRunning() {
132
180
  return _global[SINGLETON_KEY]?.running ?? false;
133
181
  }
134
182
  /** Mark application startup as completed (switches `/startupz` to 200). */
183
+ markStartupComplete() {
184
+ this.startupComplete = true;
185
+ return this;
186
+ }
187
+ /** Mark application startup as completed (switches `/startupz` to 200). */
135
188
  static markStartupComplete() {
136
- const instance = _global[SINGLETON_KEY];
137
- if (!instance) return;
138
- instance.startupComplete = true;
189
+ _global[SINGLETON_KEY]?.markStartupComplete();
190
+ }
191
+ /** Set application startup completion status. */
192
+ setStartupComplete(complete) {
193
+ this.startupComplete = complete;
194
+ return this;
195
+ }
196
+ /** Set application startup completion status. */
197
+ static setStartupComplete(complete) {
198
+ _global[SINGLETON_KEY]?.setStartupComplete(complete);
199
+ }
200
+ /** Whether application startup has completed (for `/startupz`). */
201
+ isStartupComplete() {
202
+ return this.startupComplete;
139
203
  }
140
204
  /** Whether application startup has completed (for `/startupz`). */
141
205
  static isStartupComplete() {
142
- return _global[SINGLETON_KEY]?.startupComplete ?? false;
206
+ return _global[SINGLETON_KEY]?.isStartupComplete() ?? false;
207
+ }
208
+ /** Mark the service as ready / not-ready for traffic. */
209
+ setReady(ready) {
210
+ this.ready = ready;
211
+ return this;
143
212
  }
144
213
  /** Mark the service as ready / not-ready for traffic. */
145
214
  static setReady(ready) {
146
- const instance = _global[SINGLETON_KEY];
147
- if (!instance) return;
148
- instance.ready = ready;
215
+ _global[SINGLETON_KEY]?.setReady(ready);
216
+ }
217
+ /** Whether the service is currently marked as ready. */
218
+ isReady() {
219
+ return this.ready;
149
220
  }
150
221
  /** Whether the service is currently marked as ready. */
151
222
  static isReady() {
152
- return _global[SINGLETON_KEY]?.ready ?? false;
223
+ return _global[SINGLETON_KEY]?.isReady() ?? false;
224
+ }
225
+ /** Get address info of this health server instance. */
226
+ getAddress() {
227
+ return this.addressInfo;
228
+ }
229
+ /** Get address info of the active singleton health server. */
230
+ static getAddress() {
231
+ return _global[SINGLETON_KEY]?.getAddress() ?? null;
232
+ }
233
+ /** Get port the health server is listening on. */
234
+ getPort() {
235
+ return this.addressInfo?.port;
236
+ }
237
+ /** Get port the active singleton health server is listening on. */
238
+ static getPort() {
239
+ return _global[SINGLETON_KEY]?.getPort();
240
+ }
241
+ /** Get configured host. */
242
+ getHost() {
243
+ return this.config.host;
244
+ }
245
+ /** Get configured host of the active singleton health server. */
246
+ static getHost() {
247
+ return _global[SINGLETON_KEY]?.getHost();
248
+ }
249
+ /** Get full URL for this health server instance. */
250
+ getUrl() {
251
+ if (!this.addressInfo) return void 0;
252
+ const host = formatHostForUrl(this.addressInfo.address);
253
+ return `http://${host}:${this.addressInfo.port}`;
254
+ }
255
+ /** Get full URL for the active singleton health server. */
256
+ static getUrl() {
257
+ return _global[SINGLETON_KEY]?.getUrl();
258
+ }
259
+ /** Gracefully stop this health server instance. */
260
+ async stop() {
261
+ return _HealthzServer.stop();
153
262
  }
154
263
  /** Gracefully stop the health-check server. */
155
264
  static async stop() {
156
265
  const instance = _global[SINGLETON_KEY];
157
266
  if (!instance) return;
267
+ if (instance.stoppingPromise) {
268
+ return instance.stoppingPromise;
269
+ }
270
+ instance.shuttingDown = true;
271
+ instance.ready = false;
158
272
  instance.cleanup();
159
- return new Promise((resolve) => {
273
+ instance.stoppingPromise = new Promise((resolve, reject) => {
274
+ let forceTimer;
275
+ const onDone = /* @__PURE__ */ __name((err) => {
276
+ if (forceTimer) clearTimeout(forceTimer);
277
+ instance.running = false;
278
+ instance.shuttingDown = false;
279
+ instance.addressInfo = null;
280
+ delete _global[SINGLETON_KEY];
281
+ if (err && err.code !== "ERR_SERVER_NOT_RUNNING") {
282
+ reject(err);
283
+ } else {
284
+ resolve();
285
+ }
286
+ }, "onDone");
160
287
  if (typeof instance.server.closeIdleConnections === "function") {
161
288
  instance.server.closeIdleConnections();
162
289
  }
163
- instance.server.close(() => {
164
- delete _global[SINGLETON_KEY];
165
- resolve();
166
- });
290
+ if (typeof instance.server.closeAllConnections === "function") {
291
+ instance.server.closeAllConnections();
292
+ }
293
+ forceTimer = setTimeout(() => {
294
+ if (typeof instance.server.closeAllConnections === "function") {
295
+ instance.server.closeAllConnections();
296
+ }
297
+ onDone();
298
+ }, 5e3);
299
+ instance.server.close((err) => onDone(err ?? void 0));
167
300
  });
301
+ return instance.stoppingPromise;
168
302
  }
169
303
  static getInstance() {
170
304
  return _global[SINGLETON_KEY];
171
305
  }
172
306
  /**
173
307
  * Register a named check on this HealthzServer instance dynamically.
308
+ * If a check with the same name already exists in the target probe, it is updated in-place.
174
309
  *
175
310
  * @param check - The named check to register
176
311
  * @param type - Which probe to attach this check to ('readiness', 'liveness', or 'both')
@@ -178,18 +313,57 @@ var HealthzServer = class _HealthzServer {
178
313
  */
179
314
  registerCheck(check, type = "readiness") {
180
315
  if (type === "liveness" || type === "both") {
181
- this.config.checks.push(check);
316
+ const idx = this.livenessChecks.findIndex((c) => c.name === check.name);
317
+ if (idx !== -1) {
318
+ this.livenessChecks[idx] = check;
319
+ } else {
320
+ this.livenessChecks.push(check);
321
+ }
322
+ }
323
+ if (type === "readiness" || type === "both") {
324
+ const idx = this.readinessChecks.findIndex((c) => c.name === check.name);
325
+ if (idx !== -1) {
326
+ this.readinessChecks[idx] = check;
327
+ } else {
328
+ this.readinessChecks.push(check);
329
+ }
330
+ }
331
+ return this;
332
+ }
333
+ /**
334
+ * Unregister a named check on this HealthzServer instance.
335
+ *
336
+ * @param name - The name of the check to remove
337
+ * @param type - Which probe to remove from ('liveness', 'readiness', or 'both')
338
+ * @returns This instance for chaining
339
+ */
340
+ unregisterCheck(name, type = "both") {
341
+ if (type === "liveness" || type === "both") {
342
+ const idx = this.livenessChecks.findIndex((c) => c.name === name);
343
+ if (idx !== -1) this.livenessChecks.splice(idx, 1);
182
344
  }
183
345
  if (type === "readiness" || type === "both") {
184
- this.config.readinessChecks ??= [
185
- ...this.config.checks
186
- ];
187
- this.config.readinessChecks.push(check);
346
+ const idx = this.readinessChecks.findIndex((c) => c.name === name);
347
+ if (idx !== -1) this.readinessChecks.splice(idx, 1);
188
348
  }
189
349
  return this;
190
350
  }
191
351
  /**
352
+ * Get all registered checks on this instance.
353
+ */
354
+ getChecks() {
355
+ return {
356
+ liveness: [
357
+ ...this.livenessChecks
358
+ ],
359
+ readiness: [
360
+ ...this.readinessChecks
361
+ ]
362
+ };
363
+ }
364
+ /**
192
365
  * Register a named check on the active singleton HealthzServer instance (if started).
366
+ * If a check with the same name already exists in the target probe, it is updated in-place.
193
367
  *
194
368
  * @param check - The named check to register
195
369
  * @param type - Which probe to attach this check to ('readiness', 'liveness', or 'both')
@@ -197,51 +371,73 @@ var HealthzServer = class _HealthzServer {
197
371
  static registerCheck(check, type = "readiness") {
198
372
  _global[SINGLETON_KEY]?.registerCheck(check, type);
199
373
  }
374
+ /**
375
+ * Unregister a named check on the active singleton HealthzServer instance (if started).
376
+ *
377
+ * @param name - Name of the check to remove
378
+ * @param type - Which probe to remove from ('liveness', 'readiness', or 'both')
379
+ */
380
+ static unregisterCheck(name, type = "both") {
381
+ _global[SINGLETON_KEY]?.unregisterCheck(name, type);
382
+ }
383
+ /**
384
+ * Get all registered checks on the active singleton HealthzServer instance.
385
+ */
386
+ static getChecks() {
387
+ return _global[SINGLETON_KEY]?.getChecks() ?? {
388
+ liveness: [],
389
+ readiness: []
390
+ };
391
+ }
200
392
  async handleRequest(req, res) {
201
393
  const isHead = req.method === "HEAD";
202
394
  if (req.method !== "GET" && !isHead) {
395
+ res.setHeader("Allow", "GET, HEAD");
203
396
  this.sendJson(res, 405, {
204
397
  error: "Method Not Allowed"
205
398
  }, isHead);
206
399
  return;
207
400
  }
208
- const url = req.url ?? "/";
401
+ const rawUrl = req.url ?? "/";
402
+ const pathname = normalizeProbePath(rawUrl.split("?")[0]);
403
+ const matchPath = /* @__PURE__ */ __name((configured) => pathname === normalizeProbePath(configured), "matchPath");
209
404
  try {
210
- if (url === this.config.healthzPath) {
405
+ if (matchPath(this.config.healthzPath)) {
211
406
  await this.handleLiveness(res, isHead);
212
- } else if (url === this.config.readyzPath) {
407
+ } else if (matchPath(this.config.readyzPath)) {
213
408
  await this.handleReadiness(res, isHead);
214
- } else if (url === this.config.startupzPath) {
409
+ } else if (matchPath(this.config.startupzPath)) {
215
410
  this.handleStartup(res, isHead);
216
411
  } else {
217
412
  this.sendJson(res, 404, {
218
413
  error: "Not Found"
219
414
  }, isHead);
220
415
  }
221
- } catch (err) {
222
- const message = err instanceof Error ? err.message : "Internal Server Error";
416
+ } catch (_err) {
223
417
  this.sendJson(res, 500, {
224
- error: message
418
+ error: "Internal Server Error"
225
419
  }, isHead);
226
420
  }
227
421
  }
228
422
  /** `/healthz` — Liveness probe */
229
423
  async handleLiveness(res, isHead = false) {
230
- const results = await this.runChecks(this.config.checks);
231
- if (this.config.onHealthCheck) {
424
+ const results = await this.runChecks(this.livenessChecks);
425
+ const livenessCheck = this.config.onLivenessCheck ?? this.config.onHealthCheck;
426
+ if (livenessCheck) {
232
427
  const start = Date.now();
233
428
  try {
234
- const ok = await this.executeCheck(this.config.onHealthCheck, this.config.checkTimeoutMs);
429
+ const result = await this.executeCheck(livenessCheck, this.config.checkTimeoutMs);
430
+ const ok = result === void 0 ? true : !!result;
235
431
  results.push({
236
432
  name: "custom",
237
- ok: !!ok,
433
+ ok,
238
434
  durationMs: Date.now() - start,
239
435
  ...!ok ? {
240
436
  error: "Health check returned false"
241
437
  } : {}
242
438
  });
243
439
  } catch (err) {
244
- const message = err instanceof Error ? err.message : "Unknown error";
440
+ const message = getErrorMessage(err);
245
441
  results.push({
246
442
  name: "custom",
247
443
  ok: false,
@@ -269,22 +465,22 @@ var HealthzServer = class _HealthzServer {
269
465
  ], isHead);
270
466
  return;
271
467
  }
272
- const checksToRun = this.config.readinessChecks ?? this.config.checks;
273
- const results = await this.runChecks(checksToRun);
468
+ const results = await this.runChecks(this.readinessChecks);
274
469
  if (this.config.onReadinessCheck) {
275
470
  const start = Date.now();
276
471
  try {
277
- const ready = await this.executeCheck(this.config.onReadinessCheck, this.config.checkTimeoutMs);
472
+ const result = await this.executeCheck(this.config.onReadinessCheck, this.config.checkTimeoutMs);
473
+ const ready = result === void 0 ? true : !!result;
278
474
  results.push({
279
475
  name: "custom-readiness",
280
- ok: !!ready,
476
+ ok: ready,
281
477
  durationMs: Date.now() - start,
282
478
  ...!ready ? {
283
479
  error: "Readiness check returned false"
284
480
  } : {}
285
481
  });
286
482
  } catch (err) {
287
- const message = err instanceof Error ? err.message : "Unknown error";
483
+ const message = getErrorMessage(err);
288
484
  results.push({
289
485
  name: "custom-readiness",
290
486
  ok: false,
@@ -319,13 +515,17 @@ var HealthzServer = class _HealthzServer {
319
515
  const start = Date.now();
320
516
  try {
321
517
  const result = await this.executeCheck(check, this.config.checkTimeoutMs);
518
+ const ok = result === void 0 ? true : !!result;
322
519
  return {
323
520
  name,
324
- ok: !!result,
325
- durationMs: Date.now() - start
521
+ ok,
522
+ durationMs: Date.now() - start,
523
+ ...!ok ? {
524
+ error: "Check returned false"
525
+ } : {}
326
526
  };
327
527
  } catch (err) {
328
- const message = err instanceof Error ? err.message : "Unknown error";
528
+ const message = getErrorMessage(err);
329
529
  return {
330
530
  name,
331
531
  ok: false,
@@ -336,6 +536,9 @@ var HealthzServer = class _HealthzServer {
336
536
  }));
337
537
  }
338
538
  sendProbe(res, httpStatus, status, checks, isHead = false) {
539
+ if (this.shuttingDown) {
540
+ res.setHeader("Connection", "close");
541
+ }
339
542
  const body = {
340
543
  status,
341
544
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
@@ -347,6 +550,7 @@ var HealthzServer = class _HealthzServer {
347
550
  this.sendJson(res, httpStatus, body, isHead);
348
551
  }
349
552
  sendJson(res, status, body, isHead = false) {
553
+ if (res.headersSent) return;
350
554
  const payload = JSON.stringify(body);
351
555
  res.writeHead(status, {
352
556
  "Content-Type": "application/json; charset=utf-8",
@@ -360,27 +564,37 @@ var HealthzServer = class _HealthzServer {
360
564
  res.end(payload);
361
565
  }
362
566
  }
567
+ /**
568
+ * Executes a check function with timeout and cooperative cancellation.
569
+ *
570
+ * Note on cancellation: The AbortController aborts when `ms` expires, which signals cooperative
571
+ * consumers (e.g., fetch, pg, ioredis) to terminate their work. The attached .catch() on checkPromise
572
+ * prevents unhandled rejections if the check promise rejects after the timeout has already resolved.
573
+ */
363
574
  async executeCheck(fn, ms) {
364
575
  const controller = new AbortController();
365
576
  let timer;
366
- const timeoutPromise = new Promise((_, reject) => {
367
- timer = setTimeout(() => {
368
- const error = new Error(`Check timed out after ${ms}ms`);
369
- controller.abort(error);
370
- reject(error);
371
- }, ms);
372
- });
373
- try {
374
- const checkPromise = Promise.resolve().then(() => fn(controller.signal));
375
- checkPromise.catch(() => {
577
+ if (ms > 0) {
578
+ const timeoutPromise = new Promise((_, reject) => {
579
+ timer = setTimeout(() => {
580
+ const error = new Error(`Check timed out after ${ms}ms`);
581
+ controller.abort(error);
582
+ reject(error);
583
+ }, ms);
376
584
  });
377
- return await Promise.race([
378
- checkPromise,
379
- timeoutPromise
380
- ]);
381
- } finally {
382
- if (timer) clearTimeout(timer);
585
+ try {
586
+ const checkPromise = Promise.resolve().then(() => fn(controller.signal));
587
+ checkPromise.catch(() => {
588
+ });
589
+ return await Promise.race([
590
+ checkPromise,
591
+ timeoutPromise
592
+ ]);
593
+ } finally {
594
+ if (timer) clearTimeout(timer);
595
+ }
383
596
  }
597
+ return await Promise.resolve().then(() => fn(controller.signal));
384
598
  }
385
599
  async initiateShutdown() {
386
600
  if (this.shuttingDown) return;
@@ -396,8 +610,6 @@ var HealthzServer = class _HealthzServer {
396
610
  process.off("SIGINT", this.onSigint);
397
611
  this.ready = false;
398
612
  this.startupComplete = false;
399
- this.running = false;
400
- this.shuttingDown = false;
401
613
  }
402
614
  };
403
615