@catbee/utils 2.0.5 → 2.1.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
@@ -23,6 +23,7 @@
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
29
  import { createFinalErrorResponse, SuccessResponse } from '@catbee/utils/response';
@@ -682,6 +683,15 @@ var DependencyErrors = {
682
683
  "cookie-parser": getDependencyErrorMessage("cookie-parser"),
683
684
  "@scalar/express-api-reference": getDependencyErrorMessage("@scalar/express-api-reference")
684
685
  };
686
+ var SUPPORTED_HTTP_METHODS = /* @__PURE__ */ new Set([
687
+ "get",
688
+ "post",
689
+ "put",
690
+ "delete",
691
+ "patch",
692
+ "options",
693
+ "head"
694
+ ]);
685
695
  var ExpressServer = class {
686
696
  static {
687
697
  __name(this, "ExpressServer");
@@ -694,11 +704,11 @@ var ExpressServer = class {
694
704
  hooks;
695
705
  /** Global API prefix (from config) */
696
706
  globalPrefix;
697
- /** Internal fallback router */
707
+ /** Primary root router mounted to the application */
698
708
  rootRouter;
699
- /** User-supplied router */
700
- externalRouter;
701
- /** Internal Express app instance */
709
+ /** Set of registered sub-routers to prevent duplicate mounting */
710
+ mountedRouters = /* @__PURE__ */ new Set();
711
+ /** Express app instance */
702
712
  app;
703
713
  /** Set of active WebSocket connections */
704
714
  connections = /* @__PURE__ */ new Set();
@@ -706,6 +716,8 @@ var ExpressServer = class {
706
716
  isShuttingDown = false;
707
717
  /** Flag indicating if graceful shutdown handlers are registered */
708
718
  gracefulShutdownRegistered = false;
719
+ /** Map of registered signal listeners for clean teardown */
720
+ signalListeners = /* @__PURE__ */ new Map();
709
721
  /**
710
722
  * Collection of registered health check functions.
711
723
  * These are executed when the health check endpoint is accessed.
@@ -713,6 +725,8 @@ var ExpressServer = class {
713
725
  healthChecks = [];
714
726
  /** Promise that resolves when initialization (middleware + routes) is complete */
715
727
  initPromise;
728
+ /** In-flight start promise to protect against concurrent start() calls */
729
+ startPromise;
716
730
  /**
717
731
  * Initializes server with intelligent defaults and security best practices.
718
732
  * All settings can be customized via config and hooks.
@@ -815,6 +829,7 @@ var ExpressServer = class {
815
829
  this.setupBodyParsingMiddleware();
816
830
  this.setupCookieParsingMiddleware();
817
831
  await this.setupOpenApiMiddleware();
832
+ this.setupResponseHook();
818
833
  }
819
834
  /**
820
835
  * Set up basic middleware (trust proxy, request ID, context).
@@ -869,10 +884,15 @@ var ExpressServer = class {
869
884
  * Set up global headers middleware.
870
885
  */
871
886
  setupGlobalHeaders() {
887
+ const hasCustomHeaders = Boolean(this.config.globalHeaders && Object.keys(this.config.globalHeaders).length > 0);
888
+ const isMicroservice = Boolean(this.config.isMicroservice);
889
+ const hasServiceVersion = Boolean(this.config.serviceVersion?.enable);
890
+ if (!hasCustomHeaders && !isMicroservice && !hasServiceVersion) {
891
+ return;
892
+ }
872
893
  this.app.use((_req, res, next) => {
873
894
  if (this.config.globalHeaders) {
874
- for (const key in this.config.globalHeaders) {
875
- const value = this.config.globalHeaders[key];
895
+ for (const [key, value] of Object.entries(this.config.globalHeaders)) {
876
896
  res.setHeader(key, typeof value === "function" ? value() : value);
877
897
  }
878
898
  }
@@ -1067,6 +1087,11 @@ var ExpressServer = class {
1067
1087
  }, "Failed to mount OpenAPI docs");
1068
1088
  }
1069
1089
  }
1090
+ }
1091
+ /**
1092
+ * Set up response preprocessing hook (applies global prefix if set).
1093
+ */
1094
+ setupResponseHook() {
1070
1095
  if (this.hooks.onResponse) {
1071
1096
  this.app.use(this.globalPrefix, this.hooks.onResponse);
1072
1097
  }
@@ -1085,8 +1110,7 @@ var ExpressServer = class {
1085
1110
  this.app.get(healthCheckPath, async (_req, res) => {
1086
1111
  return this.handleHealthCheckRequest(res);
1087
1112
  });
1088
- const routerToUse = this.externalRouter || this.rootRouter;
1089
- this.app.use(this.globalPrefix, routerToUse);
1113
+ this.app.use(this.globalPrefix, this.rootRouter);
1090
1114
  await this.runHook("afterRoutes", this.app);
1091
1115
  this.app.use((req, res) => {
1092
1116
  const status = HttpStatusCodes.NOT_FOUND;
@@ -1221,11 +1245,15 @@ var ExpressServer = class {
1221
1245
  * Start the HTTP server and begin listening for requests.
1222
1246
  *
1223
1247
  * This method:
1248
+ * - Protects against concurrent start() invocations
1249
+ * - Awaits server initialization (middleware + routes)
1224
1250
  * - Executes beforeStart hooks
1251
+ * - Creates the HTTP/HTTPS server instance
1252
+ * - Sets up error handling and connection tracking BEFORE listening
1253
+ * - Executes onServerCreated hook BEFORE listening
1225
1254
  * - Binds to the configured host/port
1226
- * - Sets up error handling for startup failures
1227
- * - Executes afterStart hooks on success
1228
- * - Logs startup information
1255
+ * - Executes afterStart hooks on successful listen
1256
+ * - Cleans up server reference and listeners on startup failure
1229
1257
  *
1230
1258
  * @returns Promise resolving to the running HTTP server instance
1231
1259
  * @throws Error if server fails to start or port is already in use
@@ -1235,33 +1263,68 @@ var ExpressServer = class {
1235
1263
  getLogger().warn("Server is already running, returning existing instance");
1236
1264
  return this.server;
1237
1265
  }
1266
+ if (this.startPromise) {
1267
+ return this.startPromise;
1268
+ }
1269
+ this.startPromise = this.doStart();
1270
+ try {
1271
+ return await this.startPromise;
1272
+ } finally {
1273
+ this.startPromise = void 0;
1274
+ }
1275
+ }
1276
+ /**
1277
+ * Internal implementation of server startup.
1278
+ */
1279
+ async doStart() {
1238
1280
  await this.initPromise;
1239
1281
  await this.runHook("beforeStart", this.app);
1282
+ const server = this.createServerInstance();
1283
+ this.server = server;
1240
1284
  return new Promise((resolve, reject) => {
1241
- try {
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 {
1295
+ }
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(() => {
1242
1307
  const onListening = /* @__PURE__ */ __name(async () => {
1308
+ isListening = true;
1243
1309
  this.logServerStartInfo();
1244
- if (this.server) await this.runHook("afterStart", this.server);
1245
- resolve(this.server);
1310
+ await this.runHook("afterStart", server);
1311
+ resolve(server);
1246
1312
  }, "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
- }
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)));
1321
+ });
1254
1322
  });
1255
1323
  }
1256
1324
  /**
1257
- * Create HTTP or HTTPS server instance.
1325
+ * Create HTTP or HTTPS server instance (without listening).
1258
1326
  */
1259
- createServerInstance(onListening) {
1260
- const listenArgs = [
1261
- this.config.port,
1262
- this.config.host,
1263
- onListening
1264
- ];
1327
+ createServerInstance() {
1265
1328
  if (this.config.https) {
1266
1329
  const httpsOptions = {
1267
1330
  ...this.config.https,
@@ -1274,9 +1337,9 @@ var ExpressServer = class {
1274
1337
  if (this.config.https.passphrase) {
1275
1338
  httpsOptions.passphrase = this.config.https.passphrase;
1276
1339
  }
1277
- return https.createServer(httpsOptions, this.app).listen(...listenArgs);
1340
+ return https.createServer(httpsOptions, this.app);
1278
1341
  }
1279
- return this.app.listen(...listenArgs);
1342
+ return http.createServer(this.app);
1280
1343
  }
1281
1344
  /**
1282
1345
  * Set up connection tracking for graceful shutdown.
@@ -1288,17 +1351,6 @@ var ExpressServer = class {
1288
1351
  });
1289
1352
  }
1290
1353
  /**
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
1354
  * Log server startup information.
1303
1355
  */
1304
1356
  logServerStartInfo() {
@@ -1360,6 +1412,7 @@ var ExpressServer = class {
1360
1412
  afterStopCalled = true;
1361
1413
  await this.runHook("afterStop");
1362
1414
  }, "runAfterStop");
1415
+ server.closeIdleConnections?.();
1363
1416
  const serverClosePromise = new Promise((resolve, reject) => {
1364
1417
  server.close(async (err) => {
1365
1418
  if (timer) clearTimeout(timer);
@@ -1422,7 +1475,7 @@ var ExpressServer = class {
1422
1475
  }
1423
1476
  let signalHandled = false;
1424
1477
  signals.forEach((signal) => {
1425
- process.on(signal, async () => {
1478
+ const handler = /* @__PURE__ */ __name(async () => {
1426
1479
  if (signalHandled) {
1427
1480
  getLogger().warn(`Ignoring duplicate ${signal}`);
1428
1481
  return;
@@ -1430,6 +1483,7 @@ var ExpressServer = class {
1430
1483
  signalHandled = true;
1431
1484
  getLogger().info(`Received ${signal}, initiating graceful shutdown...`);
1432
1485
  try {
1486
+ this.disableGracefulShutdown();
1433
1487
  await this.stop(true);
1434
1488
  process.exit(0);
1435
1489
  } catch (err) {
@@ -1438,12 +1492,29 @@ var ExpressServer = class {
1438
1492
  }, "Shutdown failed");
1439
1493
  process.exit(1);
1440
1494
  }
1441
- });
1495
+ }, "handler");
1496
+ this.signalListeners.set(signal, handler);
1497
+ process.on(signal, handler);
1442
1498
  });
1443
1499
  this.gracefulShutdownRegistered = true;
1444
1500
  return this;
1445
1501
  }
1446
1502
  /**
1503
+ * Unregister graceful shutdown signal listeners.
1504
+ * Useful for testing and dynamic server lifecycles to prevent memory and listener leaks.
1505
+ */
1506
+ disableGracefulShutdown() {
1507
+ if (!this.gracefulShutdownRegistered) {
1508
+ return this;
1509
+ }
1510
+ for (const [signal, handler] of this.signalListeners.entries()) {
1511
+ process.removeListener(signal, handler);
1512
+ }
1513
+ this.signalListeners.clear();
1514
+ this.gracefulShutdownRegistered = false;
1515
+ return this;
1516
+ }
1517
+ /**
1447
1518
  * Destroy all active connections (gracefully if possible).
1448
1519
  * If a connection does not close cleanly, it will be force-destroyed.
1449
1520
  */
@@ -1456,32 +1527,56 @@ var ExpressServer = class {
1456
1527
  ];
1457
1528
  await Promise.allSettled(sockets.map((socket) => new Promise((resolve) => {
1458
1529
  socket.end();
1459
- const timer = setTimeout(() => {
1460
- socket.destroy();
1461
- resolve();
1462
- }, 1e3);
1463
- socket.once("close", () => {
1464
- clearTimeout(timer);
1530
+ let timer;
1531
+ const cleanup = /* @__PURE__ */ __name(() => {
1532
+ if (timer) clearTimeout(timer);
1533
+ socket.removeListener("close", onClose);
1534
+ socket.removeListener("error", onError);
1465
1535
  resolve();
1466
- });
1467
- socket.once("error", () => {
1468
- clearTimeout(timer);
1536
+ }, "cleanup");
1537
+ const onClose = /* @__PURE__ */ __name(() => cleanup(), "onClose");
1538
+ const onError = /* @__PURE__ */ __name(() => {
1469
1539
  socket.destroy();
1470
- resolve();
1471
- });
1540
+ cleanup();
1541
+ }, "onError");
1542
+ timer = setTimeout(() => {
1543
+ socket.destroy();
1544
+ cleanup();
1545
+ }, 1e3);
1546
+ socket.once("close", onClose);
1547
+ socket.once("error", onError);
1472
1548
  })));
1473
1549
  this.connections.clear();
1474
1550
  getLogger().info(`Closed ${sockets.length} active connection(s)`);
1475
1551
  }
1476
1552
  /**
1477
- * Set an externally created base router.
1478
- * This will override the internal rootRouter.
1553
+ * Mount a base router onto the server's root router.
1554
+ *
1555
+ * Note: This attaches the supplied router to the root router pipeline.
1556
+ * Duplicate mounting of the same router instance is ignored.
1557
+ *
1558
+ * @param router The Express router instance to mount
1559
+ * @returns This instance for method chaining
1479
1560
  */
1480
- setBaseRouter(router) {
1481
- this.externalRouter = router;
1561
+ addBaseRouter(router) {
1562
+ if (this.mountedRouters.has(router)) {
1563
+ return this;
1564
+ }
1565
+ this.mountedRouters.add(router);
1566
+ this.rootRouter.use(router);
1482
1567
  return this;
1483
1568
  }
1484
1569
  /**
1570
+ * Alias for `addBaseRouter` (maintained for backward compatibility).
1571
+ * Mounts the supplied router onto the server's root router.
1572
+ *
1573
+ * @param router The Express router instance to mount
1574
+ * @returns This instance for method chaining
1575
+ */
1576
+ setBaseRouter(router) {
1577
+ return this.addBaseRouter(router);
1578
+ }
1579
+ /**
1485
1580
  * Create and register a new router (only used if not injecting one externally - use `setBaseRouter` instead).
1486
1581
  */
1487
1582
  createRouter(prefix = "") {
@@ -1501,27 +1596,73 @@ var ExpressServer = class {
1501
1596
  */
1502
1597
  registerRoute(methods, path, ...handlers) {
1503
1598
  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
- };
1599
+ const routerToUse = this.rootRouter;
1514
1600
  methods.forEach((m) => {
1515
- const fn = methodMap[m];
1516
- if (fn) {
1517
- fn(fullPath, ...handlers);
1518
- } else {
1601
+ const method = m.toLowerCase();
1602
+ if (!SUPPORTED_HTTP_METHODS.has(method) || typeof routerToUse[method] !== "function") {
1519
1603
  throw new Error(`Unsupported HTTP method: ${m}`);
1520
1604
  }
1605
+ routerToUse[method](fullPath, ...handlers);
1521
1606
  });
1522
1607
  return this;
1523
1608
  }
1524
1609
  /**
1610
+ * Register a GET route handler.
1611
+ */
1612
+ get(path, ...handlers) {
1613
+ return this.registerRoute([
1614
+ "get"
1615
+ ], path, ...handlers);
1616
+ }
1617
+ /**
1618
+ * Register a POST route handler.
1619
+ */
1620
+ post(path, ...handlers) {
1621
+ return this.registerRoute([
1622
+ "post"
1623
+ ], path, ...handlers);
1624
+ }
1625
+ /**
1626
+ * Register a PUT route handler.
1627
+ */
1628
+ put(path, ...handlers) {
1629
+ return this.registerRoute([
1630
+ "put"
1631
+ ], path, ...handlers);
1632
+ }
1633
+ /**
1634
+ * Register a DELETE route handler.
1635
+ */
1636
+ delete(path, ...handlers) {
1637
+ return this.registerRoute([
1638
+ "delete"
1639
+ ], path, ...handlers);
1640
+ }
1641
+ /**
1642
+ * Register a PATCH route handler.
1643
+ */
1644
+ patch(path, ...handlers) {
1645
+ return this.registerRoute([
1646
+ "patch"
1647
+ ], path, ...handlers);
1648
+ }
1649
+ /**
1650
+ * Register an OPTIONS route handler.
1651
+ */
1652
+ options(path, ...handlers) {
1653
+ return this.registerRoute([
1654
+ "options"
1655
+ ], path, ...handlers);
1656
+ }
1657
+ /**
1658
+ * Register a HEAD route handler.
1659
+ */
1660
+ head(path, ...handlers) {
1661
+ return this.registerRoute([
1662
+ "head"
1663
+ ], path, ...handlers);
1664
+ }
1665
+ /**
1525
1666
  * Register custom middleware with optional path restriction.
1526
1667
  *
1527
1668
  * Use this for:
@@ -1535,7 +1676,7 @@ var ExpressServer = class {
1535
1676
  * @returns This instance for method chaining
1536
1677
  */
1537
1678
  registerMiddleware(path, middleware) {
1538
- const routerToUse = this.externalRouter || this.rootRouter;
1679
+ const routerToUse = this.rootRouter;
1539
1680
  if (typeof path === "string") {
1540
1681
  const normalizedPath = this.normalizePath(path);
1541
1682
  if (normalizedPath) {
@@ -1558,7 +1699,7 @@ var ExpressServer = class {
1558
1699
  */
1559
1700
  useMiddleware(...middlewares) {
1560
1701
  middlewares.forEach((middleware) => {
1561
- (this.externalRouter || this.rootRouter).use(middleware);
1702
+ this.rootRouter.use(middleware);
1562
1703
  });
1563
1704
  return this;
1564
1705
  }