@catbee/utils 2.1.1 → 2.2.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/server/index.mjs CHANGED
@@ -26,17 +26,18 @@ import express from 'express';
26
26
  import http from 'http';
27
27
  import https from 'https';
28
28
  import { HttpStatusCodes } from '@catbee/utils/http-status-codes';
29
- import { createFinalErrorResponse, SuccessResponse } from '@catbee/utils/response';
29
+ import { createFinalErrorResponse } from '@catbee/utils/response';
30
30
  import { requestId, setupRequestContext, timeout, responseTime, errorHandler } from '@catbee/utils/middleware';
31
31
  import { Env } from '@catbee/utils/env';
32
32
  import { getLogger } from '@catbee/utils/logger';
33
- import { ServiceUnavailableException, InternalServerErrorException, NotFoundException } from '@catbee/utils/exception';
33
+ import { ServiceUnavailableException, NotFoundException } from '@catbee/utils/exception';
34
34
  import { getCatbeeServerGlobalConfig } from '@catbee/utils/config';
35
- import { deepObjMerge, isPlainObject, deepClone } from '@catbee/utils/object';
35
+ import { isPlainObject, deepClone, deepObjMerge } from '@catbee/utils/object';
36
36
  import { fileExists, readFile, readFileSync } from '@catbee/utils/fs';
37
37
  import { isPort, isHostname } from '@catbee/utils/validation';
38
38
  import { optionalRequire } from '@catbee/utils/async';
39
39
  import { uuid } from '@catbee/utils/id';
40
+ import { HealthzServer } from '@catbee/utils/healthz-server';
40
41
 
41
42
  var __defProp = Object.defineProperty;
42
43
  var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
@@ -299,36 +300,53 @@ var ServerConfigBuilder = class {
299
300
  return this.setEnabled("requestLogging", false);
300
301
  }
301
302
  /**
302
- * Configures server health check endpoint.
303
+ * Configures the dedicated Healthz probe HTTP server for Kubernetes.
303
304
  *
304
- * @param opts - Health check configuration options
305
+ * @param opts - Healthz server configuration options or boolean toggle
305
306
  * @returns The builder instance for chaining
306
- * @default - { path: '/healthz', detailed: true, withGlobalPrefix: false }
307
307
  *
308
308
  * @example
309
309
  * ```typescript
310
- * builder.withHealthCheck({
311
- * path: '/health',
312
- * detailed: true
310
+ * builder.withHealthzServer({
311
+ * port: 8282,
312
+ * shutdownDelayMs: 5000,
313
+ * readinessChecks: [
314
+ * { name: 'db', check: () => checkDb() }
315
+ * ]
313
316
  * })
314
317
  * ```
315
318
  */
316
- withHealthCheck(opts) {
317
- this.mergeConfig("healthCheck", opts);
319
+ withHealthzServer(opts) {
320
+ if (typeof opts === "boolean") {
321
+ this.config.healthzServer = opts;
322
+ } else {
323
+ const current = isPlainObject(this.config.healthzServer) ? deepClone(this.config.healthzServer) : {};
324
+ this.config.healthzServer = deepObjMerge({}, current, {
325
+ enable: true,
326
+ ...opts
327
+ });
328
+ }
318
329
  return this;
319
330
  }
320
331
  /**
321
- * Enables health check endpoint with default or custom settings
322
- * @param opts - Optional health check configuration
332
+ * Enables the dedicated Healthz probe HTTP server.
333
+ *
334
+ * @param opts - Optional Healthz server configuration options
323
335
  * @returns The builder instance for chaining
336
+ */
337
+ enableHealthzServer(opts = {}) {
338
+ return this.withHealthzServer({
339
+ ...opts,
340
+ enable: true
341
+ });
342
+ }
343
+ /**
344
+ * Disables the dedicated Healthz probe HTTP server.
324
345
  *
325
- * @example
326
- * ```typescript
327
- * builder.disableHealthCheck()
328
- * ```
346
+ * @returns The builder instance for chaining
329
347
  */
330
- disableHealthCheck() {
331
- return this.setEnabled("healthCheck", false);
348
+ disableHealthzServer() {
349
+ return this.withHealthzServer(false);
332
350
  }
333
351
  /**
334
352
  * Configures OpenAPI/Swagger documentation for the API.
@@ -718,11 +736,12 @@ var ExpressServer = class {
718
736
  gracefulShutdownRegistered = false;
719
737
  /** Map of registered signal listeners for clean teardown */
720
738
  signalListeners = /* @__PURE__ */ new Map();
721
- /**
722
- * Collection of registered health check functions.
723
- * These are executed when the health check endpoint is accessed.
724
- */
725
- healthChecks = [];
739
+ /** Running address info for the Healthz probe server */
740
+ healthzAddress;
741
+ /** Named checks queued for Healthz liveness probe */
742
+ healthzChecks = [];
743
+ /** Named checks queued for Healthz readiness probe */
744
+ healthzReadinessChecks = [];
726
745
  /** Promise that resolves when initialization (middleware + routes) is complete */
727
746
  initPromise;
728
747
  /** In-flight start promise to protect against concurrent start() calls */
@@ -757,8 +776,19 @@ var ExpressServer = class {
757
776
  getLogger().error(msg);
758
777
  throw new Error(msg);
759
778
  }
760
- if (config?.healthCheck?.checks) {
761
- this.healthChecks.push(...config.healthCheck.checks);
779
+ if (typeof this.config.healthzServer === "boolean") {
780
+ this.config.healthzServer = {
781
+ ...HealthzServer.getDefaultConfig(),
782
+ enable: this.config.healthzServer
783
+ };
784
+ }
785
+ if (this.config.healthzServer && typeof this.config.healthzServer === "object") {
786
+ if (this.config.healthzServer.checks) {
787
+ this.healthzChecks.push(...this.config.healthzServer.checks);
788
+ }
789
+ if (this.config.healthzServer.readinessChecks) {
790
+ this.healthzReadinessChecks.push(...this.config.healthzServer.readinessChecks);
791
+ }
762
792
  }
763
793
  this.globalPrefix = this.normalizePath(this.config.globalPrefix ?? "", false);
764
794
  this.hooks = hooks;
@@ -1106,10 +1136,6 @@ var ExpressServer = class {
1106
1136
  * 4. Error handler
1107
1137
  */
1108
1138
  async setupRoutes() {
1109
- const healthCheckPath = this.normalizePath(this.config.healthCheck?.path || "/healthz", this.config.healthCheck?.withGlobalPrefix);
1110
- this.app.get(healthCheckPath, async (_req, res) => {
1111
- return this.handleHealthCheckRequest(res);
1112
- });
1113
1139
  this.app.use(this.globalPrefix, this.rootRouter);
1114
1140
  await this.runHook("afterRoutes", this.app);
1115
1141
  this.app.use((req, res) => {
@@ -1133,60 +1159,25 @@ var ExpressServer = class {
1133
1159
  });
1134
1160
  }
1135
1161
  /**
1136
- * Execute health check and return response.
1162
+ * Whether the Healthz probe server is enabled.
1137
1163
  */
1138
- async handleHealthCheckRequest(res) {
1139
- try {
1140
- if (!this.healthChecks.length || getCatbeeServerGlobalConfig().skipHealthzChecksValidation) {
1141
- return res.status(HttpStatusCodes.OK).json(new SuccessResponse("OK"));
1142
- }
1143
- const results = await this.executeHealthChecks();
1144
- const allOk = results.every((r) => r.status);
1145
- const status = allOk ? HttpStatusCodes.OK : HttpStatusCodes.SERVICE_UNAVAILABLE;
1146
- const response = new SuccessResponse(allOk ? "OK" : "Service unavailable");
1147
- if (!allOk) response.error = true;
1148
- if (this.config.healthCheck?.detailed) response.data = {
1149
- checks: results
1150
- };
1151
- return res.status(status).json(response);
1152
- } catch {
1153
- return res.status(HttpStatusCodes.INTERNAL_SERVER_ERROR).json(new InternalServerErrorException("Health check failed"));
1164
+ isHealthzServerEnabled() {
1165
+ if (typeof this.config.healthzServer === "boolean") {
1166
+ return this.config.healthzServer;
1154
1167
  }
1168
+ return this.config.healthzServer?.enable === true;
1155
1169
  }
1156
1170
  /**
1157
- * Execute all registered health checks and return results.
1171
+ * Get the graceful shutdown delay in milliseconds configured for HealthzServer.
1158
1172
  */
1159
- async executeHealthChecks() {
1160
- const checkResults = await Promise.allSettled(this.healthChecks.map(async ({ name, check }) => {
1161
- try {
1162
- const status = await Promise.resolve(check());
1163
- return {
1164
- name,
1165
- status,
1166
- error: null
1167
- };
1168
- } catch (error) {
1169
- return {
1170
- name,
1171
- status: false,
1172
- error: error.message
1173
- };
1174
- }
1175
- }));
1176
- return checkResults.map((result) => {
1177
- if (result.status === "fulfilled") return result.value;
1178
- return {
1179
- name: "unknown",
1180
- status: false,
1181
- error: result.reason
1182
- };
1183
- });
1173
+ getHealthzShutdownDelay() {
1174
+ return typeof this.config.healthzServer === "object" ? this.config.healthzServer.shutdownDelayMs ?? 0 : 0;
1184
1175
  }
1185
1176
  /**
1186
- * Register a new health check function for monitoring service dependencies.
1177
+ * Register a new health check function for monitoring service dependencies on the Healthz probe server.
1187
1178
  *
1188
- * Health checks are executed when the health endpoint is accessed and
1189
- * help determine if the service is ready to handle requests.
1179
+ * By default, external dependencies (DB, Redis, etc.) are registered as `readiness` checks.
1180
+ * Can also be registered as `liveness` or `both`.
1190
1181
  *
1191
1182
  * Examples:
1192
1183
  * - Database connectivity
@@ -1195,33 +1186,76 @@ var ExpressServer = class {
1195
1186
  * - Memory/CPU usage checks
1196
1187
  *
1197
1188
  * @param name Unique identifier for the check (used in detailed responses)
1198
- * @param check Function returning boolean or Promise<boolean> indicating health
1189
+ * @param check Function returning boolean or Promise<boolean> indicating health (supports optional AbortSignal)
1190
+ * @param options Target probe type ('readiness' | 'liveness' | 'both') or options object
1199
1191
  * @returns This instance for method chaining
1200
1192
  */
1201
- registerHealthCheck(name, check) {
1202
- this.healthChecks.push({
1193
+ registerHealthCheck(name, check, options) {
1194
+ const probeType = typeof options === "string" ? options : options?.type ?? "readiness";
1195
+ const namedCheck = {
1203
1196
  name,
1204
1197
  check
1205
- });
1198
+ };
1199
+ if (probeType === "liveness" || probeType === "both") {
1200
+ this.healthzChecks.push(namedCheck);
1201
+ }
1202
+ if (probeType === "readiness" || probeType === "both") {
1203
+ this.healthzReadinessChecks.push(namedCheck);
1204
+ }
1205
+ HealthzServer.registerCheck(namedCheck, probeType);
1206
1206
  return this;
1207
1207
  }
1208
1208
  /**
1209
- * Run registered health checks and return whether the service is ready.
1210
- * Useful for readiness probes in deployment tooling.
1209
+ * Mark the service as ready / not-ready for traffic on the Healthz probe server.
1211
1210
  *
1212
- * @returns Promise resolving to `true` when all checks pass, otherwise `false`.
1211
+ * @param ready Whether the service is ready to receive traffic
1212
+ * @returns This instance for method chaining
1213
1213
  */
1214
- async ready() {
1215
- try {
1216
- if (!this.healthChecks.length || getCatbeeServerGlobalConfig().skipHealthzChecksValidation) return true;
1217
- const results = await this.executeHealthChecks();
1218
- return results.every((r) => r.status === true);
1219
- } catch (err) {
1220
- getLogger().error({
1221
- err
1222
- }, "Error while running readiness checks");
1223
- return false;
1224
- }
1214
+ setReady(ready) {
1215
+ HealthzServer.setReady(ready);
1216
+ return this;
1217
+ }
1218
+ /**
1219
+ * Whether the service is currently marked as ready for traffic on the Healthz probe server.
1220
+ */
1221
+ isReady() {
1222
+ return HealthzServer.isReady();
1223
+ }
1224
+ /**
1225
+ * Mark application startup as completed on the Healthz probe server (switches `/startupz` to 200).
1226
+ *
1227
+ * @returns This instance for method chaining
1228
+ */
1229
+ markStartupComplete() {
1230
+ HealthzServer.markStartupComplete();
1231
+ return this;
1232
+ }
1233
+ /**
1234
+ * Whether application startup has completed on the Healthz probe server.
1235
+ */
1236
+ isStartupComplete() {
1237
+ return HealthzServer.isStartupComplete();
1238
+ }
1239
+ /**
1240
+ * Get the running HealthzServer instance (if started).
1241
+ */
1242
+ getHealthzServer() {
1243
+ return HealthzServer.getInstance();
1244
+ }
1245
+ /**
1246
+ * Get the address info of the running HealthzServer (if started).
1247
+ */
1248
+ getHealthzAddress() {
1249
+ return this.healthzAddress;
1250
+ }
1251
+ /**
1252
+ * Returns whether the service is currently marked as ready for traffic on the Healthz probe server.
1253
+ * Useful for readiness checks in deployment tooling.
1254
+ *
1255
+ * @returns `true` when ready, otherwise `false`.
1256
+ */
1257
+ ready() {
1258
+ return HealthzServer.isReady();
1225
1259
  }
1226
1260
  /**
1227
1261
  * Get the underlying Express application instance.
@@ -1278,48 +1312,105 @@ var ExpressServer = class {
1278
1312
  */
1279
1313
  async doStart() {
1280
1314
  await this.initPromise;
1281
- await this.runHook("beforeStart", this.app);
1282
- const server = this.createServerInstance();
1283
- this.server = server;
1284
- return new Promise((resolve, reject) => {
1285
- let isListening = false;
1286
- server.on("error", (err) => {
1287
- if (!isListening) {
1288
- getLogger().error({
1289
- err
1290
- }, "Server failed to start");
1291
- try {
1292
- server.removeAllListeners();
1293
- server.close();
1294
- } catch {
1315
+ if (this.isHealthzServerEnabled()) {
1316
+ const healthzConfig = {
1317
+ ...typeof this.config.healthzServer === "object" ? this.config.healthzServer : {},
1318
+ handleSignals: false,
1319
+ checks: [
1320
+ ...this.healthzChecks
1321
+ ],
1322
+ readinessChecks: [
1323
+ ...this.healthzReadinessChecks
1324
+ ]
1325
+ };
1326
+ const addr = await HealthzServer.start(healthzConfig);
1327
+ if (!addr) {
1328
+ throw new Error("Healthz probe server failed to start (already running in this process)");
1329
+ }
1330
+ this.healthzAddress = addr;
1331
+ }
1332
+ try {
1333
+ await this.runHook("beforeStart", this.app);
1334
+ const server = this.createServerInstance();
1335
+ this.server = server;
1336
+ return await new Promise((resolve, reject) => {
1337
+ let isListening = false;
1338
+ server.on("error", async (err) => {
1339
+ if (!isListening) {
1340
+ getLogger().error({
1341
+ err
1342
+ }, "Server failed to start");
1343
+ try {
1344
+ server.removeAllListeners();
1345
+ server.close();
1346
+ if (HealthzServer.isRunning()) {
1347
+ await HealthzServer.stop().catch(() => {
1348
+ });
1349
+ }
1350
+ } catch {
1351
+ }
1352
+ this.server = null;
1353
+ this.healthzAddress = null;
1354
+ this.connections.clear();
1355
+ reject(err);
1356
+ } else {
1357
+ getLogger().error({
1358
+ err
1359
+ }, "Server runtime error");
1295
1360
  }
1296
- this.server = null;
1297
- this.connections.clear();
1298
- reject(err);
1299
- } else {
1300
- getLogger().error({
1301
- err
1302
- }, "Server runtime error");
1303
- }
1304
- });
1305
- this.setupConnectionTracking();
1306
- Promise.resolve(this.runHook("onServerCreated", server)).then(() => {
1307
- const onListening = /* @__PURE__ */ __name(async () => {
1308
- isListening = true;
1309
- this.logServerStartInfo();
1310
- await this.runHook("afterStart", server);
1311
- resolve(server);
1312
- }, "onListening");
1313
- const listenArgs = [
1314
- this.config.port,
1315
- this.config.host,
1316
- onListening
1317
- ];
1318
- server.listen(...listenArgs);
1319
- }).catch((err) => {
1320
- server.emit("error", err instanceof Error ? err : new Error(String(err)));
1361
+ });
1362
+ this.setupConnectionTracking();
1363
+ Promise.resolve(this.runHook("onServerCreated", server)).then(() => {
1364
+ const onListening = /* @__PURE__ */ __name(async () => {
1365
+ try {
1366
+ this.logServerStartInfo();
1367
+ await this.runHook("afterStart", server);
1368
+ if (this.isHealthzServerEnabled()) {
1369
+ HealthzServer.markStartupComplete();
1370
+ HealthzServer.setReady(true);
1371
+ }
1372
+ isListening = true;
1373
+ resolve(server);
1374
+ } catch (err) {
1375
+ const error = err instanceof Error ? err : new Error(String(err));
1376
+ getLogger().error({
1377
+ err: error
1378
+ }, "Server startup failed");
1379
+ if (HealthzServer.isRunning()) {
1380
+ await HealthzServer.stop().catch(() => {
1381
+ });
1382
+ }
1383
+ try {
1384
+ server.removeAllListeners();
1385
+ server.close();
1386
+ } catch {
1387
+ }
1388
+ this.server = null;
1389
+ this.healthzAddress = null;
1390
+ this.connections.clear();
1391
+ reject(error);
1392
+ }
1393
+ }, "onListening");
1394
+ const listenArgs = [
1395
+ this.config.port,
1396
+ this.config.host,
1397
+ onListening
1398
+ ];
1399
+ server.listen(...listenArgs);
1400
+ }).catch((err) => {
1401
+ server.emit("error", err instanceof Error ? err : new Error(String(err)));
1402
+ });
1321
1403
  });
1322
- });
1404
+ } catch (err) {
1405
+ if (HealthzServer.isRunning()) {
1406
+ await HealthzServer.stop().catch(() => {
1407
+ });
1408
+ }
1409
+ this.healthzAddress = null;
1410
+ this.server = null;
1411
+ this.connections.clear();
1412
+ throw err;
1413
+ }
1323
1414
  }
1324
1415
  /**
1325
1416
  * Create HTTP or HTTPS server instance (without listening).
@@ -1359,8 +1450,8 @@ var ExpressServer = class {
1359
1450
  const host = this.formatHostForUrl(this.config.host || "localhost");
1360
1451
  const url = `${protocol}://${host}:${port}`;
1361
1452
  getLogger().info(`Server running on ${url}`);
1362
- if (this.config.healthCheck && this.config.healthCheck?.path) {
1363
- getLogger().info(`Health check available at ${url}${this.normalizePath(this.config.healthCheck.path, this.config.healthCheck.withGlobalPrefix)}`);
1453
+ if (this.healthzAddress) {
1454
+ getLogger().info(`Healthz probe server running on http://${this.healthzAddress.address}:${this.healthzAddress.port}`);
1364
1455
  }
1365
1456
  if (this.config.openApi?.enable) {
1366
1457
  getLogger().info(`API docs available at ${url}${this.normalizePath(this.config.openApi.mountPath, this.config.openApi.withGlobalPrefix)}`);
@@ -1383,7 +1474,7 @@ var ExpressServer = class {
1383
1474
  * - Monitoring systems are notified
1384
1475
  */
1385
1476
  async stop(force = false) {
1386
- if (!this.server) {
1477
+ if (!this.server && !HealthzServer.isRunning()) {
1387
1478
  getLogger().warn("Stop called but server is not running");
1388
1479
  return;
1389
1480
  }
@@ -1392,10 +1483,26 @@ var ExpressServer = class {
1392
1483
  return;
1393
1484
  }
1394
1485
  this.isShuttingDown = true;
1395
- await this.runHook("beforeStop", this.server);
1486
+ if (HealthzServer.isRunning()) {
1487
+ HealthzServer.setReady(false);
1488
+ }
1489
+ if (this.server) {
1490
+ await this.runHook("beforeStop", this.server);
1491
+ }
1396
1492
  try {
1397
- await this.gracefulShutdown(force);
1493
+ const shutdownDelay = this.getHealthzShutdownDelay();
1494
+ if (shutdownDelay > 0 && !force && HealthzServer.isRunning()) {
1495
+ getLogger().info(`Waiting ${shutdownDelay}ms for load balancer to drain traffic...`);
1496
+ await new Promise((resolve) => setTimeout(resolve, shutdownDelay));
1497
+ }
1498
+ if (this.server) {
1499
+ await this.gracefulShutdown(force);
1500
+ }
1398
1501
  } finally {
1502
+ if (HealthzServer.isRunning()) {
1503
+ await HealthzServer.stop();
1504
+ this.healthzAddress = null;
1505
+ }
1399
1506
  this.isShuttingDown = false;
1400
1507
  }
1401
1508
  }
@@ -1484,7 +1591,7 @@ var ExpressServer = class {
1484
1591
  getLogger().info(`Received ${signal}, initiating graceful shutdown...`);
1485
1592
  try {
1486
1593
  this.disableGracefulShutdown();
1487
- await this.stop(true);
1594
+ await this.stop(false);
1488
1595
  process.exit(0);
1489
1596
  } catch (err) {
1490
1597
  getLogger().fatal({