@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.
@@ -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,6 +84,8 @@ var HealthzServer = class _HealthzServer {
65
84
  server;
66
85
  config;
67
86
  startedAt = Date.now();
87
+ /** Running address info for the Healthz probe server */
88
+ addressInfo = null;
68
89
  /** Whether the health HTTP server is currently running and listening on its port */
69
90
  running = false;
70
91
  /** Whether application startup has completed (for `/startupz`) */
@@ -72,10 +93,21 @@ var HealthzServer = class _HealthzServer {
72
93
  /** Whether the service is ready to receive traffic (for `/readyz`) */
73
94
  ready = false;
74
95
  shuttingDown = false;
96
+ livenessChecks;
97
+ readinessChecks;
98
+ stoppingPromise;
75
99
  onSigterm = /* @__PURE__ */ __name(() => this.initiateShutdown(), "onSigterm");
76
100
  onSigint = /* @__PURE__ */ __name(() => this.initiateShutdown(), "onSigint");
77
101
  constructor(config) {
78
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
+ ];
79
111
  this.server = createServer((req, res) => this.handleRequest(req, res));
80
112
  if (this.config.handleSignals !== false) {
81
113
  process.once("SIGTERM", this.onSigterm);
@@ -97,78 +129,181 @@ var HealthzServer = class _HealthzServer {
97
129
  const config = resolveConfig(opts);
98
130
  const instance = new _HealthzServer(config);
99
131
  _global[SINGLETON_KEY] = instance;
132
+ const bindHost = config.host.startsWith("[") && config.host.endsWith("]") ? config.host.slice(1, -1) : config.host;
100
133
  return new Promise((resolve, reject) => {
101
134
  const onError = /* @__PURE__ */ __name((err) => {
102
135
  instance.cleanup();
136
+ instance.running = false;
137
+ instance.shuttingDown = false;
138
+ instance.addressInfo = null;
103
139
  delete _global[SINGLETON_KEY];
104
140
  reject(err);
105
141
  }, "onError");
106
142
  instance.server.once("error", onError);
107
143
  instance.server.listen({
108
- host: config.host,
144
+ host: bindHost,
109
145
  port: config.port
110
146
  }, () => {
111
147
  instance.server.off("error", onError);
148
+ instance.server.on("error", () => {
149
+ });
112
150
  instance.running = true;
113
151
  const addr = instance.server.address();
114
152
  if (!addr || typeof addr === "string") {
115
153
  instance.cleanup();
154
+ instance.running = false;
155
+ instance.shuttingDown = false;
156
+ instance.addressInfo = null;
116
157
  delete _global[SINGLETON_KEY];
117
158
  reject(new Error("Failed to resolve health server address"));
118
159
  return;
119
160
  }
120
- resolve({
161
+ instance.addressInfo = {
121
162
  address: addr.address,
122
163
  family: addr.family,
123
164
  port: addr.port
165
+ };
166
+ resolve({
167
+ ...instance.addressInfo
124
168
  });
125
169
  });
126
170
  });
127
171
  }
128
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. */
129
177
  static isRunning() {
130
178
  return _global[SINGLETON_KEY]?.running ?? false;
131
179
  }
132
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). */
133
186
  static markStartupComplete() {
134
- const instance = _global[SINGLETON_KEY];
135
- if (!instance) return;
136
- instance.startupComplete = true;
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;
137
201
  }
138
202
  /** Whether application startup has completed (for `/startupz`). */
139
203
  static isStartupComplete() {
140
- return _global[SINGLETON_KEY]?.startupComplete ?? false;
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;
141
210
  }
142
211
  /** Mark the service as ready / not-ready for traffic. */
143
212
  static setReady(ready) {
144
- const instance = _global[SINGLETON_KEY];
145
- if (!instance) return;
146
- 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;
147
218
  }
148
219
  /** Whether the service is currently marked as ready. */
149
220
  static isReady() {
150
- 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();
151
260
  }
152
261
  /** Gracefully stop the health-check server. */
153
262
  static async stop() {
154
263
  const instance = _global[SINGLETON_KEY];
155
264
  if (!instance) return;
265
+ if (instance.stoppingPromise) {
266
+ return instance.stoppingPromise;
267
+ }
268
+ instance.shuttingDown = true;
269
+ instance.ready = false;
156
270
  instance.cleanup();
157
- 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");
158
285
  if (typeof instance.server.closeIdleConnections === "function") {
159
286
  instance.server.closeIdleConnections();
160
287
  }
161
- instance.server.close(() => {
162
- delete _global[SINGLETON_KEY];
163
- resolve();
164
- });
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));
165
298
  });
299
+ return instance.stoppingPromise;
166
300
  }
167
301
  static getInstance() {
168
302
  return _global[SINGLETON_KEY];
169
303
  }
170
304
  /**
171
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.
172
307
  *
173
308
  * @param check - The named check to register
174
309
  * @param type - Which probe to attach this check to ('readiness', 'liveness', or 'both')
@@ -176,18 +311,57 @@ var HealthzServer = class _HealthzServer {
176
311
  */
177
312
  registerCheck(check, type = "readiness") {
178
313
  if (type === "liveness" || type === "both") {
179
- 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);
180
342
  }
181
343
  if (type === "readiness" || type === "both") {
182
- this.config.readinessChecks ??= [
183
- ...this.config.checks
184
- ];
185
- this.config.readinessChecks.push(check);
344
+ const idx = this.readinessChecks.findIndex((c) => c.name === name);
345
+ if (idx !== -1) this.readinessChecks.splice(idx, 1);
186
346
  }
187
347
  return this;
188
348
  }
189
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
+ /**
190
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.
191
365
  *
192
366
  * @param check - The named check to register
193
367
  * @param type - Which probe to attach this check to ('readiness', 'liveness', or 'both')
@@ -195,51 +369,73 @@ var HealthzServer = class _HealthzServer {
195
369
  static registerCheck(check, type = "readiness") {
196
370
  _global[SINGLETON_KEY]?.registerCheck(check, type);
197
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
+ }
198
390
  async handleRequest(req, res) {
199
391
  const isHead = req.method === "HEAD";
200
392
  if (req.method !== "GET" && !isHead) {
393
+ res.setHeader("Allow", "GET, HEAD");
201
394
  this.sendJson(res, 405, {
202
395
  error: "Method Not Allowed"
203
396
  }, isHead);
204
397
  return;
205
398
  }
206
- 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");
207
402
  try {
208
- if (url === this.config.healthzPath) {
403
+ if (matchPath(this.config.healthzPath)) {
209
404
  await this.handleLiveness(res, isHead);
210
- } else if (url === this.config.readyzPath) {
405
+ } else if (matchPath(this.config.readyzPath)) {
211
406
  await this.handleReadiness(res, isHead);
212
- } else if (url === this.config.startupzPath) {
407
+ } else if (matchPath(this.config.startupzPath)) {
213
408
  this.handleStartup(res, isHead);
214
409
  } else {
215
410
  this.sendJson(res, 404, {
216
411
  error: "Not Found"
217
412
  }, isHead);
218
413
  }
219
- } catch (err) {
220
- const message = err instanceof Error ? err.message : "Internal Server Error";
414
+ } catch (_err) {
221
415
  this.sendJson(res, 500, {
222
- error: message
416
+ error: "Internal Server Error"
223
417
  }, isHead);
224
418
  }
225
419
  }
226
420
  /** `/healthz` — Liveness probe */
227
421
  async handleLiveness(res, isHead = false) {
228
- const results = await this.runChecks(this.config.checks);
229
- if (this.config.onHealthCheck) {
422
+ const results = await this.runChecks(this.livenessChecks);
423
+ const livenessCheck = this.config.onLivenessCheck ?? this.config.onHealthCheck;
424
+ if (livenessCheck) {
230
425
  const start = Date.now();
231
426
  try {
232
- 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;
233
429
  results.push({
234
430
  name: "custom",
235
- ok: !!ok,
431
+ ok,
236
432
  durationMs: Date.now() - start,
237
433
  ...!ok ? {
238
434
  error: "Health check returned false"
239
435
  } : {}
240
436
  });
241
437
  } catch (err) {
242
- const message = err instanceof Error ? err.message : "Unknown error";
438
+ const message = getErrorMessage(err);
243
439
  results.push({
244
440
  name: "custom",
245
441
  ok: false,
@@ -267,22 +463,22 @@ var HealthzServer = class _HealthzServer {
267
463
  ], isHead);
268
464
  return;
269
465
  }
270
- const checksToRun = this.config.readinessChecks ?? this.config.checks;
271
- const results = await this.runChecks(checksToRun);
466
+ const results = await this.runChecks(this.readinessChecks);
272
467
  if (this.config.onReadinessCheck) {
273
468
  const start = Date.now();
274
469
  try {
275
- 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;
276
472
  results.push({
277
473
  name: "custom-readiness",
278
- ok: !!ready,
474
+ ok: ready,
279
475
  durationMs: Date.now() - start,
280
476
  ...!ready ? {
281
477
  error: "Readiness check returned false"
282
478
  } : {}
283
479
  });
284
480
  } catch (err) {
285
- const message = err instanceof Error ? err.message : "Unknown error";
481
+ const message = getErrorMessage(err);
286
482
  results.push({
287
483
  name: "custom-readiness",
288
484
  ok: false,
@@ -317,13 +513,17 @@ var HealthzServer = class _HealthzServer {
317
513
  const start = Date.now();
318
514
  try {
319
515
  const result = await this.executeCheck(check, this.config.checkTimeoutMs);
516
+ const ok = result === void 0 ? true : !!result;
320
517
  return {
321
518
  name,
322
- ok: !!result,
323
- durationMs: Date.now() - start
519
+ ok,
520
+ durationMs: Date.now() - start,
521
+ ...!ok ? {
522
+ error: "Check returned false"
523
+ } : {}
324
524
  };
325
525
  } catch (err) {
326
- const message = err instanceof Error ? err.message : "Unknown error";
526
+ const message = getErrorMessage(err);
327
527
  return {
328
528
  name,
329
529
  ok: false,
@@ -334,6 +534,9 @@ var HealthzServer = class _HealthzServer {
334
534
  }));
335
535
  }
336
536
  sendProbe(res, httpStatus, status, checks, isHead = false) {
537
+ if (this.shuttingDown) {
538
+ res.setHeader("Connection", "close");
539
+ }
337
540
  const body = {
338
541
  status,
339
542
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
@@ -345,6 +548,7 @@ var HealthzServer = class _HealthzServer {
345
548
  this.sendJson(res, httpStatus, body, isHead);
346
549
  }
347
550
  sendJson(res, status, body, isHead = false) {
551
+ if (res.headersSent) return;
348
552
  const payload = JSON.stringify(body);
349
553
  res.writeHead(status, {
350
554
  "Content-Type": "application/json; charset=utf-8",
@@ -358,27 +562,37 @@ var HealthzServer = class _HealthzServer {
358
562
  res.end(payload);
359
563
  }
360
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
+ */
361
572
  async executeCheck(fn, ms) {
362
573
  const controller = new AbortController();
363
574
  let timer;
364
- const timeoutPromise = new Promise((_, reject) => {
365
- timer = setTimeout(() => {
366
- const error = new Error(`Check timed out after ${ms}ms`);
367
- controller.abort(error);
368
- reject(error);
369
- }, ms);
370
- });
371
- try {
372
- const checkPromise = Promise.resolve().then(() => fn(controller.signal));
373
- 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);
374
582
  });
375
- return await Promise.race([
376
- checkPromise,
377
- timeoutPromise
378
- ]);
379
- } finally {
380
- 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
+ }
381
594
  }
595
+ return await Promise.resolve().then(() => fn(controller.signal));
382
596
  }
383
597
  async initiateShutdown() {
384
598
  if (this.shuttingDown) return;
@@ -394,8 +608,6 @@ var HealthzServer = class _HealthzServer {
394
608
  process.off("SIGINT", this.onSigint);
395
609
  this.ready = false;
396
610
  this.startupComplete = false;
397
- this.running = false;
398
- this.shuttingDown = false;
399
611
  }
400
612
  };
401
613
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@catbee/utils",
3
- "version": "2.2.1",
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"