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