@catbee/utils 2.2.0 → 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,15 +86,30 @@ var HealthzServer = class _HealthzServer {
67
86
  server;
68
87
  config;
69
88
  startedAt = Date.now();
70
- /** Whether the health server has successfully started listening (for `/startupz`) */
71
- started = false;
89
+ /** Running address info for the Healthz probe server */
90
+ addressInfo = null;
91
+ /** Whether the health HTTP server is currently running and listening on its port */
92
+ running = false;
93
+ /** Whether application startup has completed (for `/startupz`) */
94
+ startupComplete = false;
72
95
  /** Whether the service is ready to receive traffic (for `/readyz`) */
73
96
  ready = false;
74
97
  shuttingDown = false;
98
+ livenessChecks;
99
+ readinessChecks;
100
+ stoppingPromise;
75
101
  onSigterm = /* @__PURE__ */ __name(() => this.initiateShutdown(), "onSigterm");
76
102
  onSigint = /* @__PURE__ */ __name(() => this.initiateShutdown(), "onSigint");
77
103
  constructor(config) {
78
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
+ ];
79
113
  this.server = http.createServer((req, res) => this.handleRequest(req, res));
80
114
  if (this.config.handleSignals !== false) {
81
115
  process.once("SIGTERM", this.onSigterm);
@@ -97,68 +131,181 @@ var HealthzServer = class _HealthzServer {
97
131
  const config = resolveConfig(opts);
98
132
  const instance = new _HealthzServer(config);
99
133
  _global[SINGLETON_KEY] = instance;
134
+ const bindHost = config.host.startsWith("[") && config.host.endsWith("]") ? config.host.slice(1, -1) : config.host;
100
135
  return new Promise((resolve, reject) => {
101
136
  const onError = /* @__PURE__ */ __name((err) => {
102
137
  instance.cleanup();
138
+ instance.running = false;
139
+ instance.shuttingDown = false;
140
+ instance.addressInfo = null;
103
141
  delete _global[SINGLETON_KEY];
104
142
  reject(err);
105
143
  }, "onError");
106
144
  instance.server.once("error", onError);
107
145
  instance.server.listen({
108
- host: config.host,
146
+ host: bindHost,
109
147
  port: config.port
110
148
  }, () => {
111
149
  instance.server.off("error", onError);
112
- instance.started = true;
150
+ instance.server.on("error", () => {
151
+ });
152
+ instance.running = true;
113
153
  const addr = instance.server.address();
114
154
  if (!addr || typeof addr === "string") {
115
155
  instance.cleanup();
156
+ instance.running = false;
157
+ instance.shuttingDown = false;
158
+ instance.addressInfo = null;
116
159
  delete _global[SINGLETON_KEY];
117
160
  reject(new Error("Failed to resolve health server address"));
118
161
  return;
119
162
  }
120
- resolve({
163
+ instance.addressInfo = {
121
164
  address: addr.address,
122
165
  family: addr.family,
123
166
  port: addr.port
167
+ };
168
+ resolve({
169
+ ...instance.addressInfo
124
170
  });
125
171
  });
126
172
  });
127
173
  }
128
- /** Whether the health-check server is currently running and started. */
129
- static isStarted() {
130
- return _global[SINGLETON_KEY]?.started ?? false;
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. */
179
+ static isRunning() {
180
+ return _global[SINGLETON_KEY]?.running ?? false;
181
+ }
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). */
188
+ static markStartupComplete() {
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;
203
+ }
204
+ /** Whether application startup has completed (for `/startupz`). */
205
+ static isStartupComplete() {
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;
131
212
  }
132
213
  /** Mark the service as ready / not-ready for traffic. */
133
214
  static setReady(ready) {
134
- const instance = _global[SINGLETON_KEY];
135
- if (!instance) return;
136
- 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;
137
220
  }
138
221
  /** Whether the service is currently marked as ready. */
139
222
  static isReady() {
140
- 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();
141
262
  }
142
263
  /** Gracefully stop the health-check server. */
143
264
  static async stop() {
144
265
  const instance = _global[SINGLETON_KEY];
145
266
  if (!instance) return;
267
+ if (instance.stoppingPromise) {
268
+ return instance.stoppingPromise;
269
+ }
270
+ instance.shuttingDown = true;
271
+ instance.ready = false;
146
272
  instance.cleanup();
147
- 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");
148
287
  if (typeof instance.server.closeIdleConnections === "function") {
149
288
  instance.server.closeIdleConnections();
150
289
  }
151
- instance.server.close(() => {
152
- delete _global[SINGLETON_KEY];
153
- resolve();
154
- });
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));
155
300
  });
301
+ return instance.stoppingPromise;
156
302
  }
157
303
  static getInstance() {
158
304
  return _global[SINGLETON_KEY];
159
305
  }
160
306
  /**
161
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.
162
309
  *
163
310
  * @param check - The named check to register
164
311
  * @param type - Which probe to attach this check to ('readiness', 'liveness', or 'both')
@@ -166,18 +313,57 @@ var HealthzServer = class _HealthzServer {
166
313
  */
167
314
  registerCheck(check, type = "readiness") {
168
315
  if (type === "liveness" || type === "both") {
169
- 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);
170
344
  }
171
345
  if (type === "readiness" || type === "both") {
172
- this.config.readinessChecks ??= [
173
- ...this.config.checks
174
- ];
175
- this.config.readinessChecks.push(check);
346
+ const idx = this.readinessChecks.findIndex((c) => c.name === name);
347
+ if (idx !== -1) this.readinessChecks.splice(idx, 1);
176
348
  }
177
349
  return this;
178
350
  }
179
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
+ /**
180
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.
181
367
  *
182
368
  * @param check - The named check to register
183
369
  * @param type - Which probe to attach this check to ('readiness', 'liveness', or 'both')
@@ -185,51 +371,73 @@ var HealthzServer = class _HealthzServer {
185
371
  static registerCheck(check, type = "readiness") {
186
372
  _global[SINGLETON_KEY]?.registerCheck(check, type);
187
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
+ }
188
392
  async handleRequest(req, res) {
189
393
  const isHead = req.method === "HEAD";
190
394
  if (req.method !== "GET" && !isHead) {
395
+ res.setHeader("Allow", "GET, HEAD");
191
396
  this.sendJson(res, 405, {
192
397
  error: "Method Not Allowed"
193
398
  }, isHead);
194
399
  return;
195
400
  }
196
- 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");
197
404
  try {
198
- if (url === this.config.healthzPath) {
405
+ if (matchPath(this.config.healthzPath)) {
199
406
  await this.handleLiveness(res, isHead);
200
- } else if (url === this.config.readyzPath) {
407
+ } else if (matchPath(this.config.readyzPath)) {
201
408
  await this.handleReadiness(res, isHead);
202
- } else if (url === this.config.startupzPath) {
409
+ } else if (matchPath(this.config.startupzPath)) {
203
410
  this.handleStartup(res, isHead);
204
411
  } else {
205
412
  this.sendJson(res, 404, {
206
413
  error: "Not Found"
207
414
  }, isHead);
208
415
  }
209
- } catch (err) {
210
- const message = err instanceof Error ? err.message : "Internal Server Error";
416
+ } catch (_err) {
211
417
  this.sendJson(res, 500, {
212
- error: message
418
+ error: "Internal Server Error"
213
419
  }, isHead);
214
420
  }
215
421
  }
216
422
  /** `/healthz` — Liveness probe */
217
423
  async handleLiveness(res, isHead = false) {
218
- const results = await this.runChecks(this.config.checks);
219
- if (this.config.onHealthCheck) {
424
+ const results = await this.runChecks(this.livenessChecks);
425
+ const livenessCheck = this.config.onLivenessCheck ?? this.config.onHealthCheck;
426
+ if (livenessCheck) {
220
427
  const start = Date.now();
221
428
  try {
222
- 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;
223
431
  results.push({
224
432
  name: "custom",
225
- ok: !!ok,
433
+ ok,
226
434
  durationMs: Date.now() - start,
227
435
  ...!ok ? {
228
436
  error: "Health check returned false"
229
437
  } : {}
230
438
  });
231
439
  } catch (err) {
232
- const message = err instanceof Error ? err.message : "Unknown error";
440
+ const message = getErrorMessage(err);
233
441
  results.push({
234
442
  name: "custom",
235
443
  ok: false,
@@ -257,22 +465,22 @@ var HealthzServer = class _HealthzServer {
257
465
  ], isHead);
258
466
  return;
259
467
  }
260
- const checksToRun = this.config.readinessChecks ?? this.config.checks;
261
- const results = await this.runChecks(checksToRun);
468
+ const results = await this.runChecks(this.readinessChecks);
262
469
  if (this.config.onReadinessCheck) {
263
470
  const start = Date.now();
264
471
  try {
265
- 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;
266
474
  results.push({
267
475
  name: "custom-readiness",
268
- ok: !!ready,
476
+ ok: ready,
269
477
  durationMs: Date.now() - start,
270
478
  ...!ready ? {
271
479
  error: "Readiness check returned false"
272
480
  } : {}
273
481
  });
274
482
  } catch (err) {
275
- const message = err instanceof Error ? err.message : "Unknown error";
483
+ const message = getErrorMessage(err);
276
484
  results.push({
277
485
  name: "custom-readiness",
278
486
  ok: false,
@@ -288,7 +496,7 @@ var HealthzServer = class _HealthzServer {
288
496
  }
289
497
  /** `/startupz` — Startup probe */
290
498
  handleStartup(res, isHead = false) {
291
- if (this.started) {
499
+ if (this.startupComplete) {
292
500
  this.sendProbe(res, 200, "ok", [], isHead);
293
501
  } else {
294
502
  this.sendProbe(res, 503, "unhealthy", [
@@ -296,7 +504,7 @@ var HealthzServer = class _HealthzServer {
296
504
  name: "startup",
297
505
  ok: false,
298
506
  durationMs: 0,
299
- error: "Service has not started yet"
507
+ error: "Application startup not complete"
300
508
  }
301
509
  ], isHead);
302
510
  }
@@ -307,13 +515,17 @@ var HealthzServer = class _HealthzServer {
307
515
  const start = Date.now();
308
516
  try {
309
517
  const result = await this.executeCheck(check, this.config.checkTimeoutMs);
518
+ const ok = result === void 0 ? true : !!result;
310
519
  return {
311
520
  name,
312
- ok: !!result,
313
- durationMs: Date.now() - start
521
+ ok,
522
+ durationMs: Date.now() - start,
523
+ ...!ok ? {
524
+ error: "Check returned false"
525
+ } : {}
314
526
  };
315
527
  } catch (err) {
316
- const message = err instanceof Error ? err.message : "Unknown error";
528
+ const message = getErrorMessage(err);
317
529
  return {
318
530
  name,
319
531
  ok: false,
@@ -324,6 +536,9 @@ var HealthzServer = class _HealthzServer {
324
536
  }));
325
537
  }
326
538
  sendProbe(res, httpStatus, status, checks, isHead = false) {
539
+ if (this.shuttingDown) {
540
+ res.setHeader("Connection", "close");
541
+ }
327
542
  const body = {
328
543
  status,
329
544
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
@@ -335,6 +550,7 @@ var HealthzServer = class _HealthzServer {
335
550
  this.sendJson(res, httpStatus, body, isHead);
336
551
  }
337
552
  sendJson(res, status, body, isHead = false) {
553
+ if (res.headersSent) return;
338
554
  const payload = JSON.stringify(body);
339
555
  res.writeHead(status, {
340
556
  "Content-Type": "application/json; charset=utf-8",
@@ -348,27 +564,37 @@ var HealthzServer = class _HealthzServer {
348
564
  res.end(payload);
349
565
  }
350
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
+ */
351
574
  async executeCheck(fn, ms) {
352
575
  const controller = new AbortController();
353
576
  let timer;
354
- const timeoutPromise = new Promise((_, reject) => {
355
- timer = setTimeout(() => {
356
- const error = new Error(`Check timed out after ${ms}ms`);
357
- controller.abort(error);
358
- reject(error);
359
- }, ms);
360
- });
361
- try {
362
- const checkPromise = Promise.resolve().then(() => fn(controller.signal));
363
- 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);
364
584
  });
365
- return await Promise.race([
366
- checkPromise,
367
- timeoutPromise
368
- ]);
369
- } finally {
370
- 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
+ }
371
596
  }
597
+ return await Promise.resolve().then(() => fn(controller.signal));
372
598
  }
373
599
  async initiateShutdown() {
374
600
  if (this.shuttingDown) return;
@@ -383,8 +609,7 @@ var HealthzServer = class _HealthzServer {
383
609
  process.off("SIGTERM", this.onSigterm);
384
610
  process.off("SIGINT", this.onSigint);
385
611
  this.ready = false;
386
- this.started = false;
387
- this.shuttingDown = false;
612
+ this.startupComplete = false;
388
613
  }
389
614
  };
390
615