@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.mjs CHANGED
@@ -23,19 +23,21 @@
23
23
  */
24
24
 
25
25
  import express from 'express';
26
+ import http from 'http';
26
27
  import https from 'https';
27
28
  import { HttpStatusCodes } from '@catbee/utils/http-status-codes';
28
- import { createFinalErrorResponse, SuccessResponse } from '@catbee/utils/response';
29
+ import { createFinalErrorResponse } from '@catbee/utils/response';
29
30
  import { requestId, setupRequestContext, timeout, responseTime, errorHandler } from '@catbee/utils/middleware';
30
31
  import { Env } from '@catbee/utils/env';
31
32
  import { getLogger } from '@catbee/utils/logger';
32
- import { ServiceUnavailableException, InternalServerErrorException, NotFoundException } from '@catbee/utils/exception';
33
+ import { ServiceUnavailableException, NotFoundException } from '@catbee/utils/exception';
33
34
  import { getCatbeeServerGlobalConfig } from '@catbee/utils/config';
34
- import { deepObjMerge, isPlainObject, deepClone } from '@catbee/utils/object';
35
+ import { isPlainObject, deepClone, deepObjMerge } from '@catbee/utils/object';
35
36
  import { fileExists, readFile, readFileSync } from '@catbee/utils/fs';
36
37
  import { isPort, isHostname } from '@catbee/utils/validation';
37
38
  import { optionalRequire } from '@catbee/utils/async';
38
39
  import { uuid } from '@catbee/utils/id';
40
+ import { HealthzServer } from '@catbee/utils/healthz-server';
39
41
 
40
42
  var __defProp = Object.defineProperty;
41
43
  var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
@@ -298,36 +300,53 @@ var ServerConfigBuilder = class {
298
300
  return this.setEnabled("requestLogging", false);
299
301
  }
300
302
  /**
301
- * Configures server health check endpoint.
303
+ * Configures the dedicated Healthz probe HTTP server for Kubernetes.
302
304
  *
303
- * @param opts - Health check configuration options
305
+ * @param opts - Healthz server configuration options or boolean toggle
304
306
  * @returns The builder instance for chaining
305
- * @default - { path: '/healthz', detailed: true, withGlobalPrefix: false }
306
307
  *
307
308
  * @example
308
309
  * ```typescript
309
- * builder.withHealthCheck({
310
- * path: '/health',
311
- * detailed: true
310
+ * builder.withHealthzServer({
311
+ * port: 8282,
312
+ * shutdownDelayMs: 5000,
313
+ * readinessChecks: [
314
+ * { name: 'db', check: () => checkDb() }
315
+ * ]
312
316
  * })
313
317
  * ```
314
318
  */
315
- withHealthCheck(opts) {
316
- 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
+ }
317
329
  return this;
318
330
  }
319
331
  /**
320
- * Enables health check endpoint with default or custom settings
321
- * @param opts - Optional health check configuration
332
+ * Enables the dedicated Healthz probe HTTP server.
333
+ *
334
+ * @param opts - Optional Healthz server configuration options
322
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.
323
345
  *
324
- * @example
325
- * ```typescript
326
- * builder.disableHealthCheck()
327
- * ```
346
+ * @returns The builder instance for chaining
328
347
  */
329
- disableHealthCheck() {
330
- return this.setEnabled("healthCheck", false);
348
+ disableHealthzServer() {
349
+ return this.withHealthzServer(false);
331
350
  }
332
351
  /**
333
352
  * Configures OpenAPI/Swagger documentation for the API.
@@ -682,6 +701,15 @@ var DependencyErrors = {
682
701
  "cookie-parser": getDependencyErrorMessage("cookie-parser"),
683
702
  "@scalar/express-api-reference": getDependencyErrorMessage("@scalar/express-api-reference")
684
703
  };
704
+ var SUPPORTED_HTTP_METHODS = /* @__PURE__ */ new Set([
705
+ "get",
706
+ "post",
707
+ "put",
708
+ "delete",
709
+ "patch",
710
+ "options",
711
+ "head"
712
+ ]);
685
713
  var ExpressServer = class {
686
714
  static {
687
715
  __name(this, "ExpressServer");
@@ -694,11 +722,11 @@ var ExpressServer = class {
694
722
  hooks;
695
723
  /** Global API prefix (from config) */
696
724
  globalPrefix;
697
- /** Internal fallback router */
725
+ /** Primary root router mounted to the application */
698
726
  rootRouter;
699
- /** User-supplied router */
700
- externalRouter;
701
- /** Internal Express app instance */
727
+ /** Set of registered sub-routers to prevent duplicate mounting */
728
+ mountedRouters = /* @__PURE__ */ new Set();
729
+ /** Express app instance */
702
730
  app;
703
731
  /** Set of active WebSocket connections */
704
732
  connections = /* @__PURE__ */ new Set();
@@ -706,13 +734,18 @@ var ExpressServer = class {
706
734
  isShuttingDown = false;
707
735
  /** Flag indicating if graceful shutdown handlers are registered */
708
736
  gracefulShutdownRegistered = false;
709
- /**
710
- * Collection of registered health check functions.
711
- * These are executed when the health check endpoint is accessed.
712
- */
713
- healthChecks = [];
737
+ /** Map of registered signal listeners for clean teardown */
738
+ signalListeners = /* @__PURE__ */ new Map();
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 = [];
714
745
  /** Promise that resolves when initialization (middleware + routes) is complete */
715
746
  initPromise;
747
+ /** In-flight start promise to protect against concurrent start() calls */
748
+ startPromise;
716
749
  /**
717
750
  * Initializes server with intelligent defaults and security best practices.
718
751
  * All settings can be customized via config and hooks.
@@ -743,8 +776,19 @@ var ExpressServer = class {
743
776
  getLogger().error(msg);
744
777
  throw new Error(msg);
745
778
  }
746
- if (config?.healthCheck?.checks) {
747
- 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
+ }
748
792
  }
749
793
  this.globalPrefix = this.normalizePath(this.config.globalPrefix ?? "", false);
750
794
  this.hooks = hooks;
@@ -815,6 +859,7 @@ var ExpressServer = class {
815
859
  this.setupBodyParsingMiddleware();
816
860
  this.setupCookieParsingMiddleware();
817
861
  await this.setupOpenApiMiddleware();
862
+ this.setupResponseHook();
818
863
  }
819
864
  /**
820
865
  * Set up basic middleware (trust proxy, request ID, context).
@@ -869,10 +914,15 @@ var ExpressServer = class {
869
914
  * Set up global headers middleware.
870
915
  */
871
916
  setupGlobalHeaders() {
917
+ const hasCustomHeaders = Boolean(this.config.globalHeaders && Object.keys(this.config.globalHeaders).length > 0);
918
+ const isMicroservice = Boolean(this.config.isMicroservice);
919
+ const hasServiceVersion = Boolean(this.config.serviceVersion?.enable);
920
+ if (!hasCustomHeaders && !isMicroservice && !hasServiceVersion) {
921
+ return;
922
+ }
872
923
  this.app.use((_req, res, next) => {
873
924
  if (this.config.globalHeaders) {
874
- for (const key in this.config.globalHeaders) {
875
- const value = this.config.globalHeaders[key];
925
+ for (const [key, value] of Object.entries(this.config.globalHeaders)) {
876
926
  res.setHeader(key, typeof value === "function" ? value() : value);
877
927
  }
878
928
  }
@@ -1067,6 +1117,11 @@ var ExpressServer = class {
1067
1117
  }, "Failed to mount OpenAPI docs");
1068
1118
  }
1069
1119
  }
1120
+ }
1121
+ /**
1122
+ * Set up response preprocessing hook (applies global prefix if set).
1123
+ */
1124
+ setupResponseHook() {
1070
1125
  if (this.hooks.onResponse) {
1071
1126
  this.app.use(this.globalPrefix, this.hooks.onResponse);
1072
1127
  }
@@ -1081,12 +1136,7 @@ var ExpressServer = class {
1081
1136
  * 4. Error handler
1082
1137
  */
1083
1138
  async setupRoutes() {
1084
- const healthCheckPath = this.normalizePath(this.config.healthCheck?.path || "/healthz", this.config.healthCheck?.withGlobalPrefix);
1085
- this.app.get(healthCheckPath, async (_req, res) => {
1086
- return this.handleHealthCheckRequest(res);
1087
- });
1088
- const routerToUse = this.externalRouter || this.rootRouter;
1089
- this.app.use(this.globalPrefix, routerToUse);
1139
+ this.app.use(this.globalPrefix, this.rootRouter);
1090
1140
  await this.runHook("afterRoutes", this.app);
1091
1141
  this.app.use((req, res) => {
1092
1142
  const status = HttpStatusCodes.NOT_FOUND;
@@ -1109,60 +1159,25 @@ var ExpressServer = class {
1109
1159
  });
1110
1160
  }
1111
1161
  /**
1112
- * Execute health check and return response.
1162
+ * Whether the Healthz probe server is enabled.
1113
1163
  */
1114
- async handleHealthCheckRequest(res) {
1115
- try {
1116
- if (!this.healthChecks.length || getCatbeeServerGlobalConfig().skipHealthzChecksValidation) {
1117
- return res.status(HttpStatusCodes.OK).json(new SuccessResponse("OK"));
1118
- }
1119
- const results = await this.executeHealthChecks();
1120
- const allOk = results.every((r) => r.status);
1121
- const status = allOk ? HttpStatusCodes.OK : HttpStatusCodes.SERVICE_UNAVAILABLE;
1122
- const response = new SuccessResponse(allOk ? "OK" : "Service unavailable");
1123
- if (!allOk) response.error = true;
1124
- if (this.config.healthCheck?.detailed) response.data = {
1125
- checks: results
1126
- };
1127
- return res.status(status).json(response);
1128
- } catch {
1129
- 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;
1130
1167
  }
1168
+ return this.config.healthzServer?.enable === true;
1131
1169
  }
1132
1170
  /**
1133
- * Execute all registered health checks and return results.
1171
+ * Get the graceful shutdown delay in milliseconds configured for HealthzServer.
1134
1172
  */
1135
- async executeHealthChecks() {
1136
- const checkResults = await Promise.allSettled(this.healthChecks.map(async ({ name, check }) => {
1137
- try {
1138
- const status = await Promise.resolve(check());
1139
- return {
1140
- name,
1141
- status,
1142
- error: null
1143
- };
1144
- } catch (error) {
1145
- return {
1146
- name,
1147
- status: false,
1148
- error: error.message
1149
- };
1150
- }
1151
- }));
1152
- return checkResults.map((result) => {
1153
- if (result.status === "fulfilled") return result.value;
1154
- return {
1155
- name: "unknown",
1156
- status: false,
1157
- error: result.reason
1158
- };
1159
- });
1173
+ getHealthzShutdownDelay() {
1174
+ return typeof this.config.healthzServer === "object" ? this.config.healthzServer.shutdownDelayMs ?? 0 : 0;
1160
1175
  }
1161
1176
  /**
1162
- * 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.
1163
1178
  *
1164
- * Health checks are executed when the health endpoint is accessed and
1165
- * 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`.
1166
1181
  *
1167
1182
  * Examples:
1168
1183
  * - Database connectivity
@@ -1171,33 +1186,61 @@ var ExpressServer = class {
1171
1186
  * - Memory/CPU usage checks
1172
1187
  *
1173
1188
  * @param name Unique identifier for the check (used in detailed responses)
1174
- * @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
1175
1191
  * @returns This instance for method chaining
1176
1192
  */
1177
- registerHealthCheck(name, check) {
1178
- this.healthChecks.push({
1193
+ registerHealthCheck(name, check, options) {
1194
+ const probeType = typeof options === "string" ? options : options?.type ?? "readiness";
1195
+ const namedCheck = {
1179
1196
  name,
1180
1197
  check
1181
- });
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);
1182
1206
  return this;
1183
1207
  }
1184
1208
  /**
1185
- * Run registered health checks and return whether the service is ready.
1186
- * Useful for readiness probes in deployment tooling.
1209
+ * Mark the service as ready / not-ready for traffic on the Healthz probe server.
1187
1210
  *
1188
- * @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
1189
1213
  */
1190
- async ready() {
1191
- try {
1192
- if (!this.healthChecks.length || getCatbeeServerGlobalConfig().skipHealthzChecksValidation) return true;
1193
- const results = await this.executeHealthChecks();
1194
- return results.every((r) => r.status === true);
1195
- } catch (err) {
1196
- getLogger().error({
1197
- err
1198
- }, "Error while running readiness checks");
1199
- return false;
1200
- }
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
+ * Get the running HealthzServer instance (if started).
1226
+ */
1227
+ getHealthzServer() {
1228
+ return HealthzServer.getInstance();
1229
+ }
1230
+ /**
1231
+ * Get the address info of the running HealthzServer (if started).
1232
+ */
1233
+ getHealthzAddress() {
1234
+ return this.healthzAddress;
1235
+ }
1236
+ /**
1237
+ * Returns whether the service is currently marked as ready for traffic on the Healthz probe server.
1238
+ * Useful for readiness checks in deployment tooling.
1239
+ *
1240
+ * @returns `true` when ready, otherwise `false`.
1241
+ */
1242
+ ready() {
1243
+ return HealthzServer.isReady();
1201
1244
  }
1202
1245
  /**
1203
1246
  * Get the underlying Express application instance.
@@ -1221,11 +1264,15 @@ var ExpressServer = class {
1221
1264
  * Start the HTTP server and begin listening for requests.
1222
1265
  *
1223
1266
  * This method:
1267
+ * - Protects against concurrent start() invocations
1268
+ * - Awaits server initialization (middleware + routes)
1224
1269
  * - Executes beforeStart hooks
1270
+ * - Creates the HTTP/HTTPS server instance
1271
+ * - Sets up error handling and connection tracking BEFORE listening
1272
+ * - Executes onServerCreated hook BEFORE listening
1225
1273
  * - Binds to the configured host/port
1226
- * - Sets up error handling for startup failures
1227
- * - Executes afterStart hooks on success
1228
- * - Logs startup information
1274
+ * - Executes afterStart hooks on successful listen
1275
+ * - Cleans up server reference and listeners on startup failure
1229
1276
  *
1230
1277
  * @returns Promise resolving to the running HTTP server instance
1231
1278
  * @throws Error if server fails to start or port is already in use
@@ -1235,33 +1282,112 @@ var ExpressServer = class {
1235
1282
  getLogger().warn("Server is already running, returning existing instance");
1236
1283
  return this.server;
1237
1284
  }
1285
+ if (this.startPromise) {
1286
+ return this.startPromise;
1287
+ }
1288
+ this.startPromise = this.doStart();
1289
+ try {
1290
+ return await this.startPromise;
1291
+ } finally {
1292
+ this.startPromise = void 0;
1293
+ }
1294
+ }
1295
+ /**
1296
+ * Internal implementation of server startup.
1297
+ */
1298
+ async doStart() {
1238
1299
  await this.initPromise;
1239
1300
  await this.runHook("beforeStart", this.app);
1301
+ const server = this.createServerInstance();
1302
+ this.server = server;
1240
1303
  return new Promise((resolve, reject) => {
1241
- try {
1304
+ let isListening = false;
1305
+ server.on("error", (err) => {
1306
+ if (!isListening) {
1307
+ getLogger().error({
1308
+ err
1309
+ }, "Server failed to start");
1310
+ try {
1311
+ server.removeAllListeners();
1312
+ server.close();
1313
+ if (HealthzServer.isStarted()) {
1314
+ HealthzServer.stop().catch(() => {
1315
+ });
1316
+ }
1317
+ } catch {
1318
+ }
1319
+ this.server = null;
1320
+ this.connections.clear();
1321
+ reject(err);
1322
+ } else {
1323
+ getLogger().error({
1324
+ err
1325
+ }, "Server runtime error");
1326
+ }
1327
+ });
1328
+ this.setupConnectionTracking();
1329
+ Promise.resolve(this.runHook("onServerCreated", server)).then(() => {
1242
1330
  const onListening = /* @__PURE__ */ __name(async () => {
1243
- this.logServerStartInfo();
1244
- if (this.server) await this.runHook("afterStart", this.server);
1245
- resolve(this.server);
1331
+ try {
1332
+ if (this.isHealthzServerEnabled()) {
1333
+ const healthzConfig = {
1334
+ ...typeof this.config.healthzServer === "object" ? this.config.healthzServer : {},
1335
+ handleSignals: false,
1336
+ checks: [
1337
+ ...this.healthzChecks
1338
+ ],
1339
+ readinessChecks: [
1340
+ ...this.healthzReadinessChecks
1341
+ ]
1342
+ };
1343
+ const addr = await HealthzServer.start(healthzConfig);
1344
+ if (!addr) {
1345
+ throw new Error("Healthz probe server failed to start (already running in this process)");
1346
+ }
1347
+ this.healthzAddress = addr;
1348
+ }
1349
+ this.logServerStartInfo();
1350
+ await this.runHook("afterStart", server);
1351
+ if (this.isHealthzServerEnabled()) {
1352
+ HealthzServer.setReady(true);
1353
+ }
1354
+ isListening = true;
1355
+ resolve(server);
1356
+ } catch (err) {
1357
+ const error = err instanceof Error ? err : new Error(String(err));
1358
+ getLogger().error({
1359
+ err: error
1360
+ }, "Server startup failed");
1361
+ if (HealthzServer.isStarted()) {
1362
+ await HealthzServer.stop().catch(() => {
1363
+ });
1364
+ }
1365
+ try {
1366
+ server.removeAllListeners();
1367
+ server.close();
1368
+ } catch {
1369
+ }
1370
+ this.server = null;
1371
+ this.healthzAddress = null;
1372
+ this.connections.clear();
1373
+ reject(error);
1374
+ }
1246
1375
  }, "onListening");
1247
- this.server = this.createServerInstance(onListening);
1248
- this.runHook("onServerCreated", this.server);
1249
- this.setupConnectionTracking();
1250
- this.setupServerErrorHandling(reject);
1251
- } catch (error) {
1252
- reject(error);
1253
- }
1376
+ const listenArgs = [
1377
+ this.config.port,
1378
+ this.config.host,
1379
+ onListening
1380
+ ];
1381
+ server.listen(...listenArgs);
1382
+ }).catch((err) => {
1383
+ server.emit("error", err instanceof Error ? err : new Error(String(err)));
1384
+ });
1254
1385
  });
1255
1386
  }
1256
1387
  /**
1257
- * Create HTTP or HTTPS server instance.
1388
+ * Create HTTP or HTTPS server instance (without listening).
1258
1389
  */
1259
- createServerInstance(onListening) {
1260
- const listenArgs = [
1261
- this.config.port,
1262
- this.config.host,
1263
- onListening
1264
- ];
1390
+ createServerInstance() {
1265
1391
  if (this.config.https) {
1266
1392
  const httpsOptions = {
1267
1393
  ...this.config.https,
@@ -1274,9 +1400,9 @@ var ExpressServer = class {
1274
1400
  if (this.config.https.passphrase) {
1275
1401
  httpsOptions.passphrase = this.config.https.passphrase;
1276
1402
  }
1277
- return https.createServer(httpsOptions, this.app).listen(...listenArgs);
1403
+ return https.createServer(httpsOptions, this.app);
1278
1404
  }
1279
- return this.app.listen(...listenArgs);
1405
+ return http.createServer(this.app);
1280
1406
  }
1281
1407
  /**
1282
1408
  * Set up connection tracking for graceful shutdown.
@@ -1288,17 +1414,6 @@ var ExpressServer = class {
1288
1414
  });
1289
1415
  }
1290
1416
  /**
1291
- * Set up error handling for server startup.
1292
- */
1293
- setupServerErrorHandling(reject) {
1294
- this.server.on("error", (err) => {
1295
- getLogger().error({
1296
- err
1297
- }, "Server failed to start");
1298
- reject(err);
1299
- });
1300
- }
1301
- /**
1302
1417
  * Log server startup information.
1303
1418
  */
1304
1419
  logServerStartInfo() {
@@ -1307,8 +1422,8 @@ var ExpressServer = class {
1307
1422
  const host = this.formatHostForUrl(this.config.host || "localhost");
1308
1423
  const url = `${protocol}://${host}:${port}`;
1309
1424
  getLogger().info(`Server running on ${url}`);
1310
- if (this.config.healthCheck && this.config.healthCheck?.path) {
1311
- getLogger().info(`Health check available at ${url}${this.normalizePath(this.config.healthCheck.path, this.config.healthCheck.withGlobalPrefix)}`);
1425
+ if (this.healthzAddress) {
1426
+ getLogger().info(`Healthz probe server running on http://${this.healthzAddress.address}:${this.healthzAddress.port}`);
1312
1427
  }
1313
1428
  if (this.config.openApi?.enable) {
1314
1429
  getLogger().info(`API docs available at ${url}${this.normalizePath(this.config.openApi.mountPath, this.config.openApi.withGlobalPrefix)}`);
@@ -1331,7 +1446,7 @@ var ExpressServer = class {
1331
1446
  * - Monitoring systems are notified
1332
1447
  */
1333
1448
  async stop(force = false) {
1334
- if (!this.server) {
1449
+ if (!this.server && !HealthzServer.isStarted()) {
1335
1450
  getLogger().warn("Stop called but server is not running");
1336
1451
  return;
1337
1452
  }
@@ -1340,10 +1455,26 @@ var ExpressServer = class {
1340
1455
  return;
1341
1456
  }
1342
1457
  this.isShuttingDown = true;
1343
- await this.runHook("beforeStop", this.server);
1458
+ if (HealthzServer.isStarted()) {
1459
+ HealthzServer.setReady(false);
1460
+ }
1461
+ if (this.server) {
1462
+ await this.runHook("beforeStop", this.server);
1463
+ }
1344
1464
  try {
1345
- await this.gracefulShutdown(force);
1465
+ const shutdownDelay = this.getHealthzShutdownDelay();
1466
+ if (shutdownDelay > 0 && !force && HealthzServer.isStarted()) {
1467
+ getLogger().info(`Waiting ${shutdownDelay}ms for load balancer to drain traffic...`);
1468
+ await new Promise((resolve) => setTimeout(resolve, shutdownDelay));
1469
+ }
1470
+ if (this.server) {
1471
+ await this.gracefulShutdown(force);
1472
+ }
1346
1473
  } finally {
1474
+ if (HealthzServer.isStarted()) {
1475
+ await HealthzServer.stop();
1476
+ this.healthzAddress = null;
1477
+ }
1347
1478
  this.isShuttingDown = false;
1348
1479
  }
1349
1480
  }
@@ -1360,6 +1491,7 @@ var ExpressServer = class {
1360
1491
  afterStopCalled = true;
1361
1492
  await this.runHook("afterStop");
1362
1493
  }, "runAfterStop");
1494
+ server.closeIdleConnections?.();
1363
1495
  const serverClosePromise = new Promise((resolve, reject) => {
1364
1496
  server.close(async (err) => {
1365
1497
  if (timer) clearTimeout(timer);
@@ -1422,7 +1554,7 @@ var ExpressServer = class {
1422
1554
  }
1423
1555
  let signalHandled = false;
1424
1556
  signals.forEach((signal) => {
1425
- process.on(signal, async () => {
1557
+ const handler = /* @__PURE__ */ __name(async () => {
1426
1558
  if (signalHandled) {
1427
1559
  getLogger().warn(`Ignoring duplicate ${signal}`);
1428
1560
  return;
@@ -1430,7 +1562,8 @@ var ExpressServer = class {
1430
1562
  signalHandled = true;
1431
1563
  getLogger().info(`Received ${signal}, initiating graceful shutdown...`);
1432
1564
  try {
1433
- await this.stop(true);
1565
+ this.disableGracefulShutdown();
1566
+ await this.stop(false);
1434
1567
  process.exit(0);
1435
1568
  } catch (err) {
1436
1569
  getLogger().fatal({
@@ -1438,12 +1571,29 @@ var ExpressServer = class {
1438
1571
  }, "Shutdown failed");
1439
1572
  process.exit(1);
1440
1573
  }
1441
- });
1574
+ }, "handler");
1575
+ this.signalListeners.set(signal, handler);
1576
+ process.on(signal, handler);
1442
1577
  });
1443
1578
  this.gracefulShutdownRegistered = true;
1444
1579
  return this;
1445
1580
  }
1446
1581
  /**
1582
+ * Unregister graceful shutdown signal listeners.
1583
+ * Useful for testing and dynamic server lifecycles to prevent memory and listener leaks.
1584
+ */
1585
+ disableGracefulShutdown() {
1586
+ if (!this.gracefulShutdownRegistered) {
1587
+ return this;
1588
+ }
1589
+ for (const [signal, handler] of this.signalListeners.entries()) {
1590
+ process.removeListener(signal, handler);
1591
+ }
1592
+ this.signalListeners.clear();
1593
+ this.gracefulShutdownRegistered = false;
1594
+ return this;
1595
+ }
1596
+ /**
1447
1597
  * Destroy all active connections (gracefully if possible).
1448
1598
  * If a connection does not close cleanly, it will be force-destroyed.
1449
1599
  */
@@ -1456,32 +1606,56 @@ var ExpressServer = class {
1456
1606
  ];
1457
1607
  await Promise.allSettled(sockets.map((socket) => new Promise((resolve) => {
1458
1608
  socket.end();
1459
- const timer = setTimeout(() => {
1460
- socket.destroy();
1461
- resolve();
1462
- }, 1e3);
1463
- socket.once("close", () => {
1464
- clearTimeout(timer);
1609
+ let timer;
1610
+ const cleanup = /* @__PURE__ */ __name(() => {
1611
+ if (timer) clearTimeout(timer);
1612
+ socket.removeListener("close", onClose);
1613
+ socket.removeListener("error", onError);
1465
1614
  resolve();
1466
- });
1467
- socket.once("error", () => {
1468
- clearTimeout(timer);
1615
+ }, "cleanup");
1616
+ const onClose = /* @__PURE__ */ __name(() => cleanup(), "onClose");
1617
+ const onError = /* @__PURE__ */ __name(() => {
1469
1618
  socket.destroy();
1470
- resolve();
1471
- });
1619
+ cleanup();
1620
+ }, "onError");
1621
+ timer = setTimeout(() => {
1622
+ socket.destroy();
1623
+ cleanup();
1624
+ }, 1e3);
1625
+ socket.once("close", onClose);
1626
+ socket.once("error", onError);
1472
1627
  })));
1473
1628
  this.connections.clear();
1474
1629
  getLogger().info(`Closed ${sockets.length} active connection(s)`);
1475
1630
  }
1476
1631
  /**
1477
- * Set an externally created base router.
1478
- * This will override the internal rootRouter.
1632
+ * Mount a base router onto the server's root router.
1633
+ *
1634
+ * Note: This attaches the supplied router to the root router pipeline.
1635
+ * Duplicate mounting of the same router instance is ignored.
1636
+ *
1637
+ * @param router The Express router instance to mount
1638
+ * @returns This instance for method chaining
1479
1639
  */
1480
- setBaseRouter(router) {
1481
- this.externalRouter = router;
1640
+ addBaseRouter(router) {
1641
+ if (this.mountedRouters.has(router)) {
1642
+ return this;
1643
+ }
1644
+ this.mountedRouters.add(router);
1645
+ this.rootRouter.use(router);
1482
1646
  return this;
1483
1647
  }
1484
1648
  /**
1649
+ * Alias for `addBaseRouter` (maintained for backward compatibility).
1650
+ * Mounts the supplied router onto the server's root router.
1651
+ *
1652
+ * @param router The Express router instance to mount
1653
+ * @returns This instance for method chaining
1654
+ */
1655
+ setBaseRouter(router) {
1656
+ return this.addBaseRouter(router);
1657
+ }
1658
+ /**
1485
1659
  * Create and register a new router (only used if not injecting one externally - use `setBaseRouter` instead).
1486
1660
  */
1487
1661
  createRouter(prefix = "") {
@@ -1501,27 +1675,73 @@ var ExpressServer = class {
1501
1675
  */
1502
1676
  registerRoute(methods, path, ...handlers) {
1503
1677
  const fullPath = this.normalizePath(path, true);
1504
- const routerToUse = this.externalRouter || this.rootRouter;
1505
- const methodMap = {
1506
- get: routerToUse.get.bind(routerToUse),
1507
- post: routerToUse.post.bind(routerToUse),
1508
- put: routerToUse.put.bind(routerToUse),
1509
- delete: routerToUse.delete.bind(routerToUse),
1510
- patch: routerToUse.patch.bind(routerToUse),
1511
- options: routerToUse.options.bind(routerToUse),
1512
- head: routerToUse.head.bind(routerToUse)
1513
- };
1678
+ const routerToUse = this.rootRouter;
1514
1679
  methods.forEach((m) => {
1515
- const fn = methodMap[m];
1516
- if (fn) {
1517
- fn(fullPath, ...handlers);
1518
- } else {
1680
+ const method = m.toLowerCase();
1681
+ if (!SUPPORTED_HTTP_METHODS.has(method) || typeof routerToUse[method] !== "function") {
1519
1682
  throw new Error(`Unsupported HTTP method: ${m}`);
1520
1683
  }
1684
+ routerToUse[method](fullPath, ...handlers);
1521
1685
  });
1522
1686
  return this;
1523
1687
  }
1524
1688
  /**
1689
+ * Register a GET route handler.
1690
+ */
1691
+ get(path, ...handlers) {
1692
+ return this.registerRoute([
1693
+ "get"
1694
+ ], path, ...handlers);
1695
+ }
1696
+ /**
1697
+ * Register a POST route handler.
1698
+ */
1699
+ post(path, ...handlers) {
1700
+ return this.registerRoute([
1701
+ "post"
1702
+ ], path, ...handlers);
1703
+ }
1704
+ /**
1705
+ * Register a PUT route handler.
1706
+ */
1707
+ put(path, ...handlers) {
1708
+ return this.registerRoute([
1709
+ "put"
1710
+ ], path, ...handlers);
1711
+ }
1712
+ /**
1713
+ * Register a DELETE route handler.
1714
+ */
1715
+ delete(path, ...handlers) {
1716
+ return this.registerRoute([
1717
+ "delete"
1718
+ ], path, ...handlers);
1719
+ }
1720
+ /**
1721
+ * Register a PATCH route handler.
1722
+ */
1723
+ patch(path, ...handlers) {
1724
+ return this.registerRoute([
1725
+ "patch"
1726
+ ], path, ...handlers);
1727
+ }
1728
+ /**
1729
+ * Register an OPTIONS route handler.
1730
+ */
1731
+ options(path, ...handlers) {
1732
+ return this.registerRoute([
1733
+ "options"
1734
+ ], path, ...handlers);
1735
+ }
1736
+ /**
1737
+ * Register a HEAD route handler.
1738
+ */
1739
+ head(path, ...handlers) {
1740
+ return this.registerRoute([
1741
+ "head"
1742
+ ], path, ...handlers);
1743
+ }
1744
+ /**
1525
1745
  * Register custom middleware with optional path restriction.
1526
1746
  *
1527
1747
  * Use this for:
@@ -1535,7 +1755,7 @@ var ExpressServer = class {
1535
1755
  * @returns This instance for method chaining
1536
1756
  */
1537
1757
  registerMiddleware(path, middleware) {
1538
- const routerToUse = this.externalRouter || this.rootRouter;
1758
+ const routerToUse = this.rootRouter;
1539
1759
  if (typeof path === "string") {
1540
1760
  const normalizedPath = this.normalizePath(path);
1541
1761
  if (normalizedPath) {
@@ -1558,7 +1778,7 @@ var ExpressServer = class {
1558
1778
  */
1559
1779
  useMiddleware(...middlewares) {
1560
1780
  middlewares.forEach((middleware) => {
1561
- (this.externalRouter || this.rootRouter).use(middleware);
1781
+ this.rootRouter.use(middleware);
1562
1782
  });
1563
1783
  return this;
1564
1784
  }