@catbee/utils 2.0.5 → 2.2.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.
package/server/index.cjs CHANGED
@@ -25,6 +25,7 @@
25
25
  'use strict';
26
26
 
27
27
  var express = require('express');
28
+ var http = require('http');
28
29
  var https = require('https');
29
30
  var httpStatusCodes = require('@catbee/utils/http-status-codes');
30
31
  var response = require('@catbee/utils/response');
@@ -38,10 +39,12 @@ var fs = require('@catbee/utils/fs');
38
39
  var validation = require('@catbee/utils/validation');
39
40
  var async = require('@catbee/utils/async');
40
41
  var id = require('@catbee/utils/id');
42
+ var healthzServer = require('@catbee/utils/healthz-server');
41
43
 
42
44
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
43
45
 
44
46
  var express__default = /*#__PURE__*/_interopDefault(express);
47
+ var http__default = /*#__PURE__*/_interopDefault(http);
45
48
  var https__default = /*#__PURE__*/_interopDefault(https);
46
49
 
47
50
  var __defProp = Object.defineProperty;
@@ -305,36 +308,53 @@ var ServerConfigBuilder = class {
305
308
  return this.setEnabled("requestLogging", false);
306
309
  }
307
310
  /**
308
- * Configures server health check endpoint.
311
+ * Configures the dedicated Healthz probe HTTP server for Kubernetes.
309
312
  *
310
- * @param opts - Health check configuration options
313
+ * @param opts - Healthz server configuration options or boolean toggle
311
314
  * @returns The builder instance for chaining
312
- * @default - { path: '/healthz', detailed: true, withGlobalPrefix: false }
313
315
  *
314
316
  * @example
315
317
  * ```typescript
316
- * builder.withHealthCheck({
317
- * path: '/health',
318
- * detailed: true
318
+ * builder.withHealthzServer({
319
+ * port: 8282,
320
+ * shutdownDelayMs: 5000,
321
+ * readinessChecks: [
322
+ * { name: 'db', check: () => checkDb() }
323
+ * ]
319
324
  * })
320
325
  * ```
321
326
  */
322
- withHealthCheck(opts) {
323
- this.mergeConfig("healthCheck", opts);
327
+ withHealthzServer(opts) {
328
+ if (typeof opts === "boolean") {
329
+ this.config.healthzServer = opts;
330
+ } else {
331
+ const current = object.isPlainObject(this.config.healthzServer) ? object.deepClone(this.config.healthzServer) : {};
332
+ this.config.healthzServer = object.deepObjMerge({}, current, {
333
+ enable: true,
334
+ ...opts
335
+ });
336
+ }
324
337
  return this;
325
338
  }
326
339
  /**
327
- * Enables health check endpoint with default or custom settings
328
- * @param opts - Optional health check configuration
340
+ * Enables the dedicated Healthz probe HTTP server.
341
+ *
342
+ * @param opts - Optional Healthz server configuration options
329
343
  * @returns The builder instance for chaining
344
+ */
345
+ enableHealthzServer(opts = {}) {
346
+ return this.withHealthzServer({
347
+ ...opts,
348
+ enable: true
349
+ });
350
+ }
351
+ /**
352
+ * Disables the dedicated Healthz probe HTTP server.
330
353
  *
331
- * @example
332
- * ```typescript
333
- * builder.disableHealthCheck()
334
- * ```
354
+ * @returns The builder instance for chaining
335
355
  */
336
- disableHealthCheck() {
337
- return this.setEnabled("healthCheck", false);
356
+ disableHealthzServer() {
357
+ return this.withHealthzServer(false);
338
358
  }
339
359
  /**
340
360
  * Configures OpenAPI/Swagger documentation for the API.
@@ -689,6 +709,15 @@ var DependencyErrors = {
689
709
  "cookie-parser": getDependencyErrorMessage("cookie-parser"),
690
710
  "@scalar/express-api-reference": getDependencyErrorMessage("@scalar/express-api-reference")
691
711
  };
712
+ var SUPPORTED_HTTP_METHODS = /* @__PURE__ */ new Set([
713
+ "get",
714
+ "post",
715
+ "put",
716
+ "delete",
717
+ "patch",
718
+ "options",
719
+ "head"
720
+ ]);
692
721
  var ExpressServer = class {
693
722
  static {
694
723
  __name(this, "ExpressServer");
@@ -701,11 +730,11 @@ var ExpressServer = class {
701
730
  hooks;
702
731
  /** Global API prefix (from config) */
703
732
  globalPrefix;
704
- /** Internal fallback router */
733
+ /** Primary root router mounted to the application */
705
734
  rootRouter;
706
- /** User-supplied router */
707
- externalRouter;
708
- /** Internal Express app instance */
735
+ /** Set of registered sub-routers to prevent duplicate mounting */
736
+ mountedRouters = /* @__PURE__ */ new Set();
737
+ /** Express app instance */
709
738
  app;
710
739
  /** Set of active WebSocket connections */
711
740
  connections = /* @__PURE__ */ new Set();
@@ -713,13 +742,18 @@ var ExpressServer = class {
713
742
  isShuttingDown = false;
714
743
  /** Flag indicating if graceful shutdown handlers are registered */
715
744
  gracefulShutdownRegistered = false;
716
- /**
717
- * Collection of registered health check functions.
718
- * These are executed when the health check endpoint is accessed.
719
- */
720
- healthChecks = [];
745
+ /** Map of registered signal listeners for clean teardown */
746
+ signalListeners = /* @__PURE__ */ new Map();
747
+ /** Running address info for the Healthz probe server */
748
+ healthzAddress;
749
+ /** Named checks queued for Healthz liveness probe */
750
+ healthzChecks = [];
751
+ /** Named checks queued for Healthz readiness probe */
752
+ healthzReadinessChecks = [];
721
753
  /** Promise that resolves when initialization (middleware + routes) is complete */
722
754
  initPromise;
755
+ /** In-flight start promise to protect against concurrent start() calls */
756
+ startPromise;
723
757
  /**
724
758
  * Initializes server with intelligent defaults and security best practices.
725
759
  * All settings can be customized via config and hooks.
@@ -750,8 +784,19 @@ var ExpressServer = class {
750
784
  logger.getLogger().error(msg);
751
785
  throw new Error(msg);
752
786
  }
753
- if (config$1?.healthCheck?.checks) {
754
- this.healthChecks.push(...config$1.healthCheck.checks);
787
+ if (typeof this.config.healthzServer === "boolean") {
788
+ this.config.healthzServer = {
789
+ ...healthzServer.HealthzServer.getDefaultConfig(),
790
+ enable: this.config.healthzServer
791
+ };
792
+ }
793
+ if (this.config.healthzServer && typeof this.config.healthzServer === "object") {
794
+ if (this.config.healthzServer.checks) {
795
+ this.healthzChecks.push(...this.config.healthzServer.checks);
796
+ }
797
+ if (this.config.healthzServer.readinessChecks) {
798
+ this.healthzReadinessChecks.push(...this.config.healthzServer.readinessChecks);
799
+ }
755
800
  }
756
801
  this.globalPrefix = this.normalizePath(this.config.globalPrefix ?? "", false);
757
802
  this.hooks = hooks;
@@ -822,6 +867,7 @@ var ExpressServer = class {
822
867
  this.setupBodyParsingMiddleware();
823
868
  this.setupCookieParsingMiddleware();
824
869
  await this.setupOpenApiMiddleware();
870
+ this.setupResponseHook();
825
871
  }
826
872
  /**
827
873
  * Set up basic middleware (trust proxy, request ID, context).
@@ -876,10 +922,15 @@ var ExpressServer = class {
876
922
  * Set up global headers middleware.
877
923
  */
878
924
  setupGlobalHeaders() {
925
+ const hasCustomHeaders = Boolean(this.config.globalHeaders && Object.keys(this.config.globalHeaders).length > 0);
926
+ const isMicroservice = Boolean(this.config.isMicroservice);
927
+ const hasServiceVersion = Boolean(this.config.serviceVersion?.enable);
928
+ if (!hasCustomHeaders && !isMicroservice && !hasServiceVersion) {
929
+ return;
930
+ }
879
931
  this.app.use((_req, res, next) => {
880
932
  if (this.config.globalHeaders) {
881
- for (const key in this.config.globalHeaders) {
882
- const value = this.config.globalHeaders[key];
933
+ for (const [key, value] of Object.entries(this.config.globalHeaders)) {
883
934
  res.setHeader(key, typeof value === "function" ? value() : value);
884
935
  }
885
936
  }
@@ -1074,6 +1125,11 @@ var ExpressServer = class {
1074
1125
  }, "Failed to mount OpenAPI docs");
1075
1126
  }
1076
1127
  }
1128
+ }
1129
+ /**
1130
+ * Set up response preprocessing hook (applies global prefix if set).
1131
+ */
1132
+ setupResponseHook() {
1077
1133
  if (this.hooks.onResponse) {
1078
1134
  this.app.use(this.globalPrefix, this.hooks.onResponse);
1079
1135
  }
@@ -1088,12 +1144,7 @@ var ExpressServer = class {
1088
1144
  * 4. Error handler
1089
1145
  */
1090
1146
  async setupRoutes() {
1091
- const healthCheckPath = this.normalizePath(this.config.healthCheck?.path || "/healthz", this.config.healthCheck?.withGlobalPrefix);
1092
- this.app.get(healthCheckPath, async (_req, res) => {
1093
- return this.handleHealthCheckRequest(res);
1094
- });
1095
- const routerToUse = this.externalRouter || this.rootRouter;
1096
- this.app.use(this.globalPrefix, routerToUse);
1147
+ this.app.use(this.globalPrefix, this.rootRouter);
1097
1148
  await this.runHook("afterRoutes", this.app);
1098
1149
  this.app.use((req, res) => {
1099
1150
  const status = httpStatusCodes.HttpStatusCodes.NOT_FOUND;
@@ -1116,60 +1167,25 @@ var ExpressServer = class {
1116
1167
  });
1117
1168
  }
1118
1169
  /**
1119
- * Execute health check and return response.
1170
+ * Whether the Healthz probe server is enabled.
1120
1171
  */
1121
- async handleHealthCheckRequest(res) {
1122
- try {
1123
- if (!this.healthChecks.length || config.getCatbeeServerGlobalConfig().skipHealthzChecksValidation) {
1124
- return res.status(httpStatusCodes.HttpStatusCodes.OK).json(new response.SuccessResponse("OK"));
1125
- }
1126
- const results = await this.executeHealthChecks();
1127
- const allOk = results.every((r) => r.status);
1128
- const status = allOk ? httpStatusCodes.HttpStatusCodes.OK : httpStatusCodes.HttpStatusCodes.SERVICE_UNAVAILABLE;
1129
- const response$1 = new response.SuccessResponse(allOk ? "OK" : "Service unavailable");
1130
- if (!allOk) response$1.error = true;
1131
- if (this.config.healthCheck?.detailed) response$1.data = {
1132
- checks: results
1133
- };
1134
- return res.status(status).json(response$1);
1135
- } catch {
1136
- return res.status(httpStatusCodes.HttpStatusCodes.INTERNAL_SERVER_ERROR).json(new exception.InternalServerErrorException("Health check failed"));
1172
+ isHealthzServerEnabled() {
1173
+ if (typeof this.config.healthzServer === "boolean") {
1174
+ return this.config.healthzServer;
1137
1175
  }
1176
+ return this.config.healthzServer?.enable === true;
1138
1177
  }
1139
1178
  /**
1140
- * Execute all registered health checks and return results.
1179
+ * Get the graceful shutdown delay in milliseconds configured for HealthzServer.
1141
1180
  */
1142
- async executeHealthChecks() {
1143
- const checkResults = await Promise.allSettled(this.healthChecks.map(async ({ name, check }) => {
1144
- try {
1145
- const status = await Promise.resolve(check());
1146
- return {
1147
- name,
1148
- status,
1149
- error: null
1150
- };
1151
- } catch (error) {
1152
- return {
1153
- name,
1154
- status: false,
1155
- error: error.message
1156
- };
1157
- }
1158
- }));
1159
- return checkResults.map((result) => {
1160
- if (result.status === "fulfilled") return result.value;
1161
- return {
1162
- name: "unknown",
1163
- status: false,
1164
- error: result.reason
1165
- };
1166
- });
1181
+ getHealthzShutdownDelay() {
1182
+ return typeof this.config.healthzServer === "object" ? this.config.healthzServer.shutdownDelayMs ?? 0 : 0;
1167
1183
  }
1168
1184
  /**
1169
- * Register a new health check function for monitoring service dependencies.
1185
+ * Register a new health check function for monitoring service dependencies on the Healthz probe server.
1170
1186
  *
1171
- * Health checks are executed when the health endpoint is accessed and
1172
- * help determine if the service is ready to handle requests.
1187
+ * By default, external dependencies (DB, Redis, etc.) are registered as `readiness` checks.
1188
+ * Can also be registered as `liveness` or `both`.
1173
1189
  *
1174
1190
  * Examples:
1175
1191
  * - Database connectivity
@@ -1178,33 +1194,61 @@ var ExpressServer = class {
1178
1194
  * - Memory/CPU usage checks
1179
1195
  *
1180
1196
  * @param name Unique identifier for the check (used in detailed responses)
1181
- * @param check Function returning boolean or Promise<boolean> indicating health
1197
+ * @param check Function returning boolean or Promise<boolean> indicating health (supports optional AbortSignal)
1198
+ * @param options Target probe type ('readiness' | 'liveness' | 'both') or options object
1182
1199
  * @returns This instance for method chaining
1183
1200
  */
1184
- registerHealthCheck(name, check) {
1185
- this.healthChecks.push({
1201
+ registerHealthCheck(name, check, options) {
1202
+ const probeType = typeof options === "string" ? options : options?.type ?? "readiness";
1203
+ const namedCheck = {
1186
1204
  name,
1187
1205
  check
1188
- });
1206
+ };
1207
+ if (probeType === "liveness" || probeType === "both") {
1208
+ this.healthzChecks.push(namedCheck);
1209
+ }
1210
+ if (probeType === "readiness" || probeType === "both") {
1211
+ this.healthzReadinessChecks.push(namedCheck);
1212
+ }
1213
+ healthzServer.HealthzServer.registerCheck(namedCheck, probeType);
1189
1214
  return this;
1190
1215
  }
1191
1216
  /**
1192
- * Run registered health checks and return whether the service is ready.
1193
- * Useful for readiness probes in deployment tooling.
1217
+ * Mark the service as ready / not-ready for traffic on the Healthz probe server.
1194
1218
  *
1195
- * @returns Promise resolving to `true` when all checks pass, otherwise `false`.
1219
+ * @param ready Whether the service is ready to receive traffic
1220
+ * @returns This instance for method chaining
1196
1221
  */
1197
- async ready() {
1198
- try {
1199
- if (!this.healthChecks.length || config.getCatbeeServerGlobalConfig().skipHealthzChecksValidation) return true;
1200
- const results = await this.executeHealthChecks();
1201
- return results.every((r) => r.status === true);
1202
- } catch (err) {
1203
- logger.getLogger().error({
1204
- err
1205
- }, "Error while running readiness checks");
1206
- return false;
1207
- }
1222
+ setReady(ready) {
1223
+ healthzServer.HealthzServer.setReady(ready);
1224
+ return this;
1225
+ }
1226
+ /**
1227
+ * Whether the service is currently marked as ready for traffic on the Healthz probe server.
1228
+ */
1229
+ isReady() {
1230
+ return healthzServer.HealthzServer.isReady();
1231
+ }
1232
+ /**
1233
+ * Get the running HealthzServer instance (if started).
1234
+ */
1235
+ getHealthzServer() {
1236
+ return healthzServer.HealthzServer.getInstance();
1237
+ }
1238
+ /**
1239
+ * Get the address info of the running HealthzServer (if started).
1240
+ */
1241
+ getHealthzAddress() {
1242
+ return this.healthzAddress;
1243
+ }
1244
+ /**
1245
+ * Returns whether the service is currently marked as ready for traffic on the Healthz probe server.
1246
+ * Useful for readiness checks in deployment tooling.
1247
+ *
1248
+ * @returns `true` when ready, otherwise `false`.
1249
+ */
1250
+ ready() {
1251
+ return healthzServer.HealthzServer.isReady();
1208
1252
  }
1209
1253
  /**
1210
1254
  * Get the underlying Express application instance.
@@ -1228,11 +1272,15 @@ var ExpressServer = class {
1228
1272
  * Start the HTTP server and begin listening for requests.
1229
1273
  *
1230
1274
  * This method:
1275
+ * - Protects against concurrent start() invocations
1276
+ * - Awaits server initialization (middleware + routes)
1231
1277
  * - Executes beforeStart hooks
1278
+ * - Creates the HTTP/HTTPS server instance
1279
+ * - Sets up error handling and connection tracking BEFORE listening
1280
+ * - Executes onServerCreated hook BEFORE listening
1232
1281
  * - Binds to the configured host/port
1233
- * - Sets up error handling for startup failures
1234
- * - Executes afterStart hooks on success
1235
- * - Logs startup information
1282
+ * - Executes afterStart hooks on successful listen
1283
+ * - Cleans up server reference and listeners on startup failure
1236
1284
  *
1237
1285
  * @returns Promise resolving to the running HTTP server instance
1238
1286
  * @throws Error if server fails to start or port is already in use
@@ -1242,33 +1290,112 @@ var ExpressServer = class {
1242
1290
  logger.getLogger().warn("Server is already running, returning existing instance");
1243
1291
  return this.server;
1244
1292
  }
1293
+ if (this.startPromise) {
1294
+ return this.startPromise;
1295
+ }
1296
+ this.startPromise = this.doStart();
1297
+ try {
1298
+ return await this.startPromise;
1299
+ } finally {
1300
+ this.startPromise = void 0;
1301
+ }
1302
+ }
1303
+ /**
1304
+ * Internal implementation of server startup.
1305
+ */
1306
+ async doStart() {
1245
1307
  await this.initPromise;
1246
1308
  await this.runHook("beforeStart", this.app);
1309
+ const server = this.createServerInstance();
1310
+ this.server = server;
1247
1311
  return new Promise((resolve, reject) => {
1248
- try {
1312
+ let isListening = false;
1313
+ server.on("error", (err) => {
1314
+ if (!isListening) {
1315
+ logger.getLogger().error({
1316
+ err
1317
+ }, "Server failed to start");
1318
+ try {
1319
+ server.removeAllListeners();
1320
+ server.close();
1321
+ if (healthzServer.HealthzServer.isStarted()) {
1322
+ healthzServer.HealthzServer.stop().catch(() => {
1323
+ });
1324
+ }
1325
+ } catch {
1326
+ }
1327
+ this.server = null;
1328
+ this.connections.clear();
1329
+ reject(err);
1330
+ } else {
1331
+ logger.getLogger().error({
1332
+ err
1333
+ }, "Server runtime error");
1334
+ }
1335
+ });
1336
+ this.setupConnectionTracking();
1337
+ Promise.resolve(this.runHook("onServerCreated", server)).then(() => {
1249
1338
  const onListening = /* @__PURE__ */ __name(async () => {
1250
- this.logServerStartInfo();
1251
- if (this.server) await this.runHook("afterStart", this.server);
1252
- resolve(this.server);
1339
+ try {
1340
+ if (this.isHealthzServerEnabled()) {
1341
+ const healthzConfig = {
1342
+ ...typeof this.config.healthzServer === "object" ? this.config.healthzServer : {},
1343
+ handleSignals: false,
1344
+ checks: [
1345
+ ...this.healthzChecks
1346
+ ],
1347
+ readinessChecks: [
1348
+ ...this.healthzReadinessChecks
1349
+ ]
1350
+ };
1351
+ const addr = await healthzServer.HealthzServer.start(healthzConfig);
1352
+ if (!addr) {
1353
+ throw new Error("Healthz probe server failed to start (already running in this process)");
1354
+ }
1355
+ this.healthzAddress = addr;
1356
+ }
1357
+ this.logServerStartInfo();
1358
+ await this.runHook("afterStart", server);
1359
+ if (this.isHealthzServerEnabled()) {
1360
+ healthzServer.HealthzServer.setReady(true);
1361
+ }
1362
+ isListening = true;
1363
+ resolve(server);
1364
+ } catch (err) {
1365
+ const error = err instanceof Error ? err : new Error(String(err));
1366
+ logger.getLogger().error({
1367
+ err: error
1368
+ }, "Server startup failed");
1369
+ if (healthzServer.HealthzServer.isStarted()) {
1370
+ await healthzServer.HealthzServer.stop().catch(() => {
1371
+ });
1372
+ }
1373
+ try {
1374
+ server.removeAllListeners();
1375
+ server.close();
1376
+ } catch {
1377
+ }
1378
+ this.server = null;
1379
+ this.healthzAddress = null;
1380
+ this.connections.clear();
1381
+ reject(error);
1382
+ }
1253
1383
  }, "onListening");
1254
- this.server = this.createServerInstance(onListening);
1255
- this.runHook("onServerCreated", this.server);
1256
- this.setupConnectionTracking();
1257
- this.setupServerErrorHandling(reject);
1258
- } catch (error) {
1259
- reject(error);
1260
- }
1384
+ const listenArgs = [
1385
+ this.config.port,
1386
+ this.config.host,
1387
+ onListening
1388
+ ];
1389
+ server.listen(...listenArgs);
1390
+ }).catch((err) => {
1391
+ server.emit("error", err instanceof Error ? err : new Error(String(err)));
1392
+ });
1261
1393
  });
1262
1394
  }
1263
1395
  /**
1264
- * Create HTTP or HTTPS server instance.
1396
+ * Create HTTP or HTTPS server instance (without listening).
1265
1397
  */
1266
- createServerInstance(onListening) {
1267
- const listenArgs = [
1268
- this.config.port,
1269
- this.config.host,
1270
- onListening
1271
- ];
1398
+ createServerInstance() {
1272
1399
  if (this.config.https) {
1273
1400
  const httpsOptions = {
1274
1401
  ...this.config.https,
@@ -1281,9 +1408,9 @@ var ExpressServer = class {
1281
1408
  if (this.config.https.passphrase) {
1282
1409
  httpsOptions.passphrase = this.config.https.passphrase;
1283
1410
  }
1284
- return https__default.default.createServer(httpsOptions, this.app).listen(...listenArgs);
1411
+ return https__default.default.createServer(httpsOptions, this.app);
1285
1412
  }
1286
- return this.app.listen(...listenArgs);
1413
+ return http__default.default.createServer(this.app);
1287
1414
  }
1288
1415
  /**
1289
1416
  * Set up connection tracking for graceful shutdown.
@@ -1295,17 +1422,6 @@ var ExpressServer = class {
1295
1422
  });
1296
1423
  }
1297
1424
  /**
1298
- * Set up error handling for server startup.
1299
- */
1300
- setupServerErrorHandling(reject) {
1301
- this.server.on("error", (err) => {
1302
- logger.getLogger().error({
1303
- err
1304
- }, "Server failed to start");
1305
- reject(err);
1306
- });
1307
- }
1308
- /**
1309
1425
  * Log server startup information.
1310
1426
  */
1311
1427
  logServerStartInfo() {
@@ -1314,8 +1430,8 @@ var ExpressServer = class {
1314
1430
  const host = this.formatHostForUrl(this.config.host || "localhost");
1315
1431
  const url = `${protocol}://${host}:${port}`;
1316
1432
  logger.getLogger().info(`Server running on ${url}`);
1317
- if (this.config.healthCheck && this.config.healthCheck?.path) {
1318
- logger.getLogger().info(`Health check available at ${url}${this.normalizePath(this.config.healthCheck.path, this.config.healthCheck.withGlobalPrefix)}`);
1433
+ if (this.healthzAddress) {
1434
+ logger.getLogger().info(`Healthz probe server running on http://${this.healthzAddress.address}:${this.healthzAddress.port}`);
1319
1435
  }
1320
1436
  if (this.config.openApi?.enable) {
1321
1437
  logger.getLogger().info(`API docs available at ${url}${this.normalizePath(this.config.openApi.mountPath, this.config.openApi.withGlobalPrefix)}`);
@@ -1338,7 +1454,7 @@ var ExpressServer = class {
1338
1454
  * - Monitoring systems are notified
1339
1455
  */
1340
1456
  async stop(force = false) {
1341
- if (!this.server) {
1457
+ if (!this.server && !healthzServer.HealthzServer.isStarted()) {
1342
1458
  logger.getLogger().warn("Stop called but server is not running");
1343
1459
  return;
1344
1460
  }
@@ -1347,10 +1463,26 @@ var ExpressServer = class {
1347
1463
  return;
1348
1464
  }
1349
1465
  this.isShuttingDown = true;
1350
- await this.runHook("beforeStop", this.server);
1466
+ if (healthzServer.HealthzServer.isStarted()) {
1467
+ healthzServer.HealthzServer.setReady(false);
1468
+ }
1469
+ if (this.server) {
1470
+ await this.runHook("beforeStop", this.server);
1471
+ }
1351
1472
  try {
1352
- await this.gracefulShutdown(force);
1473
+ const shutdownDelay = this.getHealthzShutdownDelay();
1474
+ if (shutdownDelay > 0 && !force && healthzServer.HealthzServer.isStarted()) {
1475
+ logger.getLogger().info(`Waiting ${shutdownDelay}ms for load balancer to drain traffic...`);
1476
+ await new Promise((resolve) => setTimeout(resolve, shutdownDelay));
1477
+ }
1478
+ if (this.server) {
1479
+ await this.gracefulShutdown(force);
1480
+ }
1353
1481
  } finally {
1482
+ if (healthzServer.HealthzServer.isStarted()) {
1483
+ await healthzServer.HealthzServer.stop();
1484
+ this.healthzAddress = null;
1485
+ }
1354
1486
  this.isShuttingDown = false;
1355
1487
  }
1356
1488
  }
@@ -1367,6 +1499,7 @@ var ExpressServer = class {
1367
1499
  afterStopCalled = true;
1368
1500
  await this.runHook("afterStop");
1369
1501
  }, "runAfterStop");
1502
+ server.closeIdleConnections?.();
1370
1503
  const serverClosePromise = new Promise((resolve, reject) => {
1371
1504
  server.close(async (err) => {
1372
1505
  if (timer) clearTimeout(timer);
@@ -1429,7 +1562,7 @@ var ExpressServer = class {
1429
1562
  }
1430
1563
  let signalHandled = false;
1431
1564
  signals.forEach((signal) => {
1432
- process.on(signal, async () => {
1565
+ const handler = /* @__PURE__ */ __name(async () => {
1433
1566
  if (signalHandled) {
1434
1567
  logger.getLogger().warn(`Ignoring duplicate ${signal}`);
1435
1568
  return;
@@ -1437,7 +1570,8 @@ var ExpressServer = class {
1437
1570
  signalHandled = true;
1438
1571
  logger.getLogger().info(`Received ${signal}, initiating graceful shutdown...`);
1439
1572
  try {
1440
- await this.stop(true);
1573
+ this.disableGracefulShutdown();
1574
+ await this.stop(false);
1441
1575
  process.exit(0);
1442
1576
  } catch (err) {
1443
1577
  logger.getLogger().fatal({
@@ -1445,12 +1579,29 @@ var ExpressServer = class {
1445
1579
  }, "Shutdown failed");
1446
1580
  process.exit(1);
1447
1581
  }
1448
- });
1582
+ }, "handler");
1583
+ this.signalListeners.set(signal, handler);
1584
+ process.on(signal, handler);
1449
1585
  });
1450
1586
  this.gracefulShutdownRegistered = true;
1451
1587
  return this;
1452
1588
  }
1453
1589
  /**
1590
+ * Unregister graceful shutdown signal listeners.
1591
+ * Useful for testing and dynamic server lifecycles to prevent memory and listener leaks.
1592
+ */
1593
+ disableGracefulShutdown() {
1594
+ if (!this.gracefulShutdownRegistered) {
1595
+ return this;
1596
+ }
1597
+ for (const [signal, handler] of this.signalListeners.entries()) {
1598
+ process.removeListener(signal, handler);
1599
+ }
1600
+ this.signalListeners.clear();
1601
+ this.gracefulShutdownRegistered = false;
1602
+ return this;
1603
+ }
1604
+ /**
1454
1605
  * Destroy all active connections (gracefully if possible).
1455
1606
  * If a connection does not close cleanly, it will be force-destroyed.
1456
1607
  */
@@ -1463,32 +1614,56 @@ var ExpressServer = class {
1463
1614
  ];
1464
1615
  await Promise.allSettled(sockets.map((socket) => new Promise((resolve) => {
1465
1616
  socket.end();
1466
- const timer = setTimeout(() => {
1467
- socket.destroy();
1468
- resolve();
1469
- }, 1e3);
1470
- socket.once("close", () => {
1471
- clearTimeout(timer);
1617
+ let timer;
1618
+ const cleanup = /* @__PURE__ */ __name(() => {
1619
+ if (timer) clearTimeout(timer);
1620
+ socket.removeListener("close", onClose);
1621
+ socket.removeListener("error", onError);
1472
1622
  resolve();
1473
- });
1474
- socket.once("error", () => {
1475
- clearTimeout(timer);
1623
+ }, "cleanup");
1624
+ const onClose = /* @__PURE__ */ __name(() => cleanup(), "onClose");
1625
+ const onError = /* @__PURE__ */ __name(() => {
1476
1626
  socket.destroy();
1477
- resolve();
1478
- });
1627
+ cleanup();
1628
+ }, "onError");
1629
+ timer = setTimeout(() => {
1630
+ socket.destroy();
1631
+ cleanup();
1632
+ }, 1e3);
1633
+ socket.once("close", onClose);
1634
+ socket.once("error", onError);
1479
1635
  })));
1480
1636
  this.connections.clear();
1481
1637
  logger.getLogger().info(`Closed ${sockets.length} active connection(s)`);
1482
1638
  }
1483
1639
  /**
1484
- * Set an externally created base router.
1485
- * This will override the internal rootRouter.
1640
+ * Mount a base router onto the server's root router.
1641
+ *
1642
+ * Note: This attaches the supplied router to the root router pipeline.
1643
+ * Duplicate mounting of the same router instance is ignored.
1644
+ *
1645
+ * @param router The Express router instance to mount
1646
+ * @returns This instance for method chaining
1486
1647
  */
1487
- setBaseRouter(router) {
1488
- this.externalRouter = router;
1648
+ addBaseRouter(router) {
1649
+ if (this.mountedRouters.has(router)) {
1650
+ return this;
1651
+ }
1652
+ this.mountedRouters.add(router);
1653
+ this.rootRouter.use(router);
1489
1654
  return this;
1490
1655
  }
1491
1656
  /**
1657
+ * Alias for `addBaseRouter` (maintained for backward compatibility).
1658
+ * Mounts the supplied router onto the server's root router.
1659
+ *
1660
+ * @param router The Express router instance to mount
1661
+ * @returns This instance for method chaining
1662
+ */
1663
+ setBaseRouter(router) {
1664
+ return this.addBaseRouter(router);
1665
+ }
1666
+ /**
1492
1667
  * Create and register a new router (only used if not injecting one externally - use `setBaseRouter` instead).
1493
1668
  */
1494
1669
  createRouter(prefix = "") {
@@ -1508,27 +1683,73 @@ var ExpressServer = class {
1508
1683
  */
1509
1684
  registerRoute(methods, path, ...handlers) {
1510
1685
  const fullPath = this.normalizePath(path, true);
1511
- const routerToUse = this.externalRouter || this.rootRouter;
1512
- const methodMap = {
1513
- get: routerToUse.get.bind(routerToUse),
1514
- post: routerToUse.post.bind(routerToUse),
1515
- put: routerToUse.put.bind(routerToUse),
1516
- delete: routerToUse.delete.bind(routerToUse),
1517
- patch: routerToUse.patch.bind(routerToUse),
1518
- options: routerToUse.options.bind(routerToUse),
1519
- head: routerToUse.head.bind(routerToUse)
1520
- };
1686
+ const routerToUse = this.rootRouter;
1521
1687
  methods.forEach((m) => {
1522
- const fn = methodMap[m];
1523
- if (fn) {
1524
- fn(fullPath, ...handlers);
1525
- } else {
1688
+ const method = m.toLowerCase();
1689
+ if (!SUPPORTED_HTTP_METHODS.has(method) || typeof routerToUse[method] !== "function") {
1526
1690
  throw new Error(`Unsupported HTTP method: ${m}`);
1527
1691
  }
1692
+ routerToUse[method](fullPath, ...handlers);
1528
1693
  });
1529
1694
  return this;
1530
1695
  }
1531
1696
  /**
1697
+ * Register a GET route handler.
1698
+ */
1699
+ get(path, ...handlers) {
1700
+ return this.registerRoute([
1701
+ "get"
1702
+ ], path, ...handlers);
1703
+ }
1704
+ /**
1705
+ * Register a POST route handler.
1706
+ */
1707
+ post(path, ...handlers) {
1708
+ return this.registerRoute([
1709
+ "post"
1710
+ ], path, ...handlers);
1711
+ }
1712
+ /**
1713
+ * Register a PUT route handler.
1714
+ */
1715
+ put(path, ...handlers) {
1716
+ return this.registerRoute([
1717
+ "put"
1718
+ ], path, ...handlers);
1719
+ }
1720
+ /**
1721
+ * Register a DELETE route handler.
1722
+ */
1723
+ delete(path, ...handlers) {
1724
+ return this.registerRoute([
1725
+ "delete"
1726
+ ], path, ...handlers);
1727
+ }
1728
+ /**
1729
+ * Register a PATCH route handler.
1730
+ */
1731
+ patch(path, ...handlers) {
1732
+ return this.registerRoute([
1733
+ "patch"
1734
+ ], path, ...handlers);
1735
+ }
1736
+ /**
1737
+ * Register an OPTIONS route handler.
1738
+ */
1739
+ options(path, ...handlers) {
1740
+ return this.registerRoute([
1741
+ "options"
1742
+ ], path, ...handlers);
1743
+ }
1744
+ /**
1745
+ * Register a HEAD route handler.
1746
+ */
1747
+ head(path, ...handlers) {
1748
+ return this.registerRoute([
1749
+ "head"
1750
+ ], path, ...handlers);
1751
+ }
1752
+ /**
1532
1753
  * Register custom middleware with optional path restriction.
1533
1754
  *
1534
1755
  * Use this for:
@@ -1542,7 +1763,7 @@ var ExpressServer = class {
1542
1763
  * @returns This instance for method chaining
1543
1764
  */
1544
1765
  registerMiddleware(path, middleware) {
1545
- const routerToUse = this.externalRouter || this.rootRouter;
1766
+ const routerToUse = this.rootRouter;
1546
1767
  if (typeof path === "string") {
1547
1768
  const normalizedPath = this.normalizePath(path);
1548
1769
  if (normalizedPath) {
@@ -1565,7 +1786,7 @@ var ExpressServer = class {
1565
1786
  */
1566
1787
  useMiddleware(...middlewares) {
1567
1788
  middlewares.forEach((middleware) => {
1568
- (this.externalRouter || this.rootRouter).use(middleware);
1789
+ this.rootRouter.use(middleware);
1569
1790
  });
1570
1791
  return this;
1571
1792
  }