@catbee/utils 2.2.1 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/healthz-server/index.cjs +270 -58
- package/healthz-server/index.d.ts +118 -14
- package/healthz-server/index.mjs +270 -58
- package/package.json +1 -1
- package/server/index.cjs +131 -17
- package/server/index.d.ts +56 -3
- package/server/index.mjs +131 -17
- package/string/index.cjs +44 -2
- package/string/index.d.ts +36 -1
- package/string/index.mjs +42 -3
- package/types/index.d.ts +43 -15
- package/url/index.cjs +5 -4
- package/url/index.mjs +5 -4
package/server/index.cjs
CHANGED
|
@@ -39,6 +39,7 @@ var fs = require('@catbee/utils/fs');
|
|
|
39
39
|
var validation = require('@catbee/utils/validation');
|
|
40
40
|
var async = require('@catbee/utils/async');
|
|
41
41
|
var id = require('@catbee/utils/id');
|
|
42
|
+
var string = require('@catbee/utils/string');
|
|
42
43
|
var healthzServer = require('@catbee/utils/healthz-server');
|
|
43
44
|
|
|
44
45
|
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
@@ -738,8 +739,12 @@ var ExpressServer = class {
|
|
|
738
739
|
app;
|
|
739
740
|
/** Set of active WebSocket connections */
|
|
740
741
|
connections = /* @__PURE__ */ new Set();
|
|
741
|
-
/** Flag indicating if the server is shutting down */
|
|
742
|
+
/** Flag indicating if the server is shutting down (rejects traffic with 503) */
|
|
742
743
|
isShuttingDown = false;
|
|
744
|
+
/** Flag indicating if the server is in graceful drain delay (sets Connection: close while servicing traffic) */
|
|
745
|
+
isDraining = false;
|
|
746
|
+
/** Internal readiness state (used when HealthzServer is not enabled) */
|
|
747
|
+
internalReady = false;
|
|
743
748
|
/** Flag indicating if graceful shutdown handlers are registered */
|
|
744
749
|
gracefulShutdownRegistered = false;
|
|
745
750
|
/** Map of registered signal listeners for clean teardown */
|
|
@@ -750,10 +755,14 @@ var ExpressServer = class {
|
|
|
750
755
|
healthzChecks = [];
|
|
751
756
|
/** Named checks queued for Healthz readiness probe */
|
|
752
757
|
healthzReadinessChecks = [];
|
|
758
|
+
/** Whether readiness checks were explicitly provided or registered */
|
|
759
|
+
hasExplicitReadinessChecks = false;
|
|
753
760
|
/** Promise that resolves when initialization (middleware + routes) is complete */
|
|
754
761
|
initPromise;
|
|
755
762
|
/** In-flight start promise to protect against concurrent start() calls */
|
|
756
763
|
startPromise;
|
|
764
|
+
/** In-flight stop promise to protect against concurrent stop() calls */
|
|
765
|
+
stopPromise;
|
|
757
766
|
/**
|
|
758
767
|
* Initializes server with intelligent defaults and security best practices.
|
|
759
768
|
* All settings can be customized via config and hooks.
|
|
@@ -794,7 +803,8 @@ var ExpressServer = class {
|
|
|
794
803
|
if (this.config.healthzServer.checks) {
|
|
795
804
|
this.healthzChecks.push(...this.config.healthzServer.checks);
|
|
796
805
|
}
|
|
797
|
-
if (this.config.healthzServer.readinessChecks) {
|
|
806
|
+
if (this.config.healthzServer.readinessChecks !== void 0) {
|
|
807
|
+
this.hasExplicitReadinessChecks = true;
|
|
798
808
|
this.healthzReadinessChecks.push(...this.config.healthzServer.readinessChecks);
|
|
799
809
|
}
|
|
800
810
|
}
|
|
@@ -892,6 +902,9 @@ var ExpressServer = class {
|
|
|
892
902
|
res.status(httpStatusCodes.HttpStatusCodes.SERVICE_UNAVAILABLE).json(new exception.ServiceUnavailableException("Server is shutting down"));
|
|
893
903
|
return;
|
|
894
904
|
}
|
|
905
|
+
if (this.isDraining) {
|
|
906
|
+
res.setHeader("Connection", "close");
|
|
907
|
+
}
|
|
895
908
|
next();
|
|
896
909
|
});
|
|
897
910
|
}
|
|
@@ -1205,21 +1218,96 @@ var ExpressServer = class {
|
|
|
1205
1218
|
check
|
|
1206
1219
|
};
|
|
1207
1220
|
if (probeType === "liveness" || probeType === "both") {
|
|
1208
|
-
this.healthzChecks.
|
|
1221
|
+
const idx = this.healthzChecks.findIndex((c) => c.name === name);
|
|
1222
|
+
if (idx !== -1) {
|
|
1223
|
+
this.healthzChecks[idx] = namedCheck;
|
|
1224
|
+
} else {
|
|
1225
|
+
this.healthzChecks.push(namedCheck);
|
|
1226
|
+
}
|
|
1209
1227
|
}
|
|
1210
1228
|
if (probeType === "readiness" || probeType === "both") {
|
|
1211
|
-
this.
|
|
1229
|
+
this.hasExplicitReadinessChecks = true;
|
|
1230
|
+
const idx = this.healthzReadinessChecks.findIndex((c) => c.name === name);
|
|
1231
|
+
if (idx !== -1) {
|
|
1232
|
+
this.healthzReadinessChecks[idx] = namedCheck;
|
|
1233
|
+
} else {
|
|
1234
|
+
this.healthzReadinessChecks.push(namedCheck);
|
|
1235
|
+
}
|
|
1212
1236
|
}
|
|
1213
1237
|
healthzServer.HealthzServer.registerCheck(namedCheck, probeType);
|
|
1214
1238
|
return this;
|
|
1215
1239
|
}
|
|
1216
1240
|
/**
|
|
1241
|
+
* Unregister a health check by name.
|
|
1242
|
+
*
|
|
1243
|
+
* @param name Name of the check to remove
|
|
1244
|
+
* @param options Target probe type ('readiness' | 'liveness' | 'both') or options object
|
|
1245
|
+
* @returns This instance for method chaining
|
|
1246
|
+
*/
|
|
1247
|
+
unregisterHealthCheck(name, options) {
|
|
1248
|
+
const probeType = typeof options === "string" ? options : options?.type ?? "both";
|
|
1249
|
+
if (probeType === "liveness" || probeType === "both") {
|
|
1250
|
+
const idx = this.healthzChecks.findIndex((c) => c.name === name);
|
|
1251
|
+
if (idx !== -1) this.healthzChecks.splice(idx, 1);
|
|
1252
|
+
}
|
|
1253
|
+
if (probeType === "readiness" || probeType === "both") {
|
|
1254
|
+
const idx = this.healthzReadinessChecks.findIndex((c) => c.name === name);
|
|
1255
|
+
if (idx !== -1) this.healthzReadinessChecks.splice(idx, 1);
|
|
1256
|
+
}
|
|
1257
|
+
healthzServer.HealthzServer.unregisterCheck(name, probeType);
|
|
1258
|
+
return this;
|
|
1259
|
+
}
|
|
1260
|
+
/**
|
|
1261
|
+
* Get all registered Healthz checks queued for this Express server.
|
|
1262
|
+
*/
|
|
1263
|
+
getHealthzChecks() {
|
|
1264
|
+
return {
|
|
1265
|
+
liveness: [
|
|
1266
|
+
...this.healthzChecks
|
|
1267
|
+
],
|
|
1268
|
+
readiness: [
|
|
1269
|
+
...this.healthzReadinessChecks
|
|
1270
|
+
]
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
/**
|
|
1274
|
+
* Register a readiness health check.
|
|
1275
|
+
*
|
|
1276
|
+
* @param name Unique identifier for the check (used in detailed responses)
|
|
1277
|
+
* @param check Function returning boolean or Promise<boolean> indicating health (supports optional AbortSignal)
|
|
1278
|
+
* @returns This instance for method chaining
|
|
1279
|
+
*/
|
|
1280
|
+
registerReadinessCheck(name, check) {
|
|
1281
|
+
return this.registerHealthCheck(name, check, "readiness");
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* Register a liveness health check.
|
|
1285
|
+
*
|
|
1286
|
+
* @param name Unique identifier for the check (used in detailed responses)
|
|
1287
|
+
* @param check Function returning boolean or Promise<boolean> indicating health (supports optional AbortSignal)
|
|
1288
|
+
* @returns This instance for method chaining
|
|
1289
|
+
*/
|
|
1290
|
+
registerLivenessCheck(name, check) {
|
|
1291
|
+
return this.registerHealthCheck(name, check, "liveness");
|
|
1292
|
+
}
|
|
1293
|
+
/**
|
|
1294
|
+
* Register a readiness and liveness health check.
|
|
1295
|
+
*
|
|
1296
|
+
* @param name Unique identifier for the check (used in detailed responses)
|
|
1297
|
+
* @param check Function returning boolean or Promise<boolean> indicating health (supports optional AbortSignal)
|
|
1298
|
+
* @returns This instance for method chaining
|
|
1299
|
+
*/
|
|
1300
|
+
registerReadinessAndLivenessChecks(name, check) {
|
|
1301
|
+
return this.registerHealthCheck(name, check, "both");
|
|
1302
|
+
}
|
|
1303
|
+
/**
|
|
1217
1304
|
* Mark the service as ready / not-ready for traffic on the Healthz probe server.
|
|
1218
1305
|
*
|
|
1219
1306
|
* @param ready Whether the service is ready to receive traffic
|
|
1220
1307
|
* @returns This instance for method chaining
|
|
1221
1308
|
*/
|
|
1222
1309
|
setReady(ready) {
|
|
1310
|
+
this.internalReady = ready;
|
|
1223
1311
|
healthzServer.HealthzServer.setReady(ready);
|
|
1224
1312
|
return this;
|
|
1225
1313
|
}
|
|
@@ -1227,7 +1315,10 @@ var ExpressServer = class {
|
|
|
1227
1315
|
* Whether the service is currently marked as ready for traffic on the Healthz probe server.
|
|
1228
1316
|
*/
|
|
1229
1317
|
isReady() {
|
|
1230
|
-
|
|
1318
|
+
if (this.isHealthzServerEnabled()) {
|
|
1319
|
+
return healthzServer.HealthzServer.isReady();
|
|
1320
|
+
}
|
|
1321
|
+
return this.internalReady && this.isRunning();
|
|
1231
1322
|
}
|
|
1232
1323
|
/**
|
|
1233
1324
|
* Mark application startup as completed on the Healthz probe server (switches `/startupz` to 200).
|
|
@@ -1242,7 +1333,10 @@ var ExpressServer = class {
|
|
|
1242
1333
|
* Whether application startup has completed on the Healthz probe server.
|
|
1243
1334
|
*/
|
|
1244
1335
|
isStartupComplete() {
|
|
1245
|
-
|
|
1336
|
+
if (this.isHealthzServerEnabled()) {
|
|
1337
|
+
return healthzServer.HealthzServer.isStartupComplete();
|
|
1338
|
+
}
|
|
1339
|
+
return this.isRunning();
|
|
1246
1340
|
}
|
|
1247
1341
|
/**
|
|
1248
1342
|
* Get the running HealthzServer instance (if started).
|
|
@@ -1263,7 +1357,7 @@ var ExpressServer = class {
|
|
|
1263
1357
|
* @returns `true` when ready, otherwise `false`.
|
|
1264
1358
|
*/
|
|
1265
1359
|
ready() {
|
|
1266
|
-
return
|
|
1360
|
+
return this.isReady();
|
|
1267
1361
|
}
|
|
1268
1362
|
/**
|
|
1269
1363
|
* Get the underlying Express application instance.
|
|
@@ -1327,9 +1421,11 @@ var ExpressServer = class {
|
|
|
1327
1421
|
checks: [
|
|
1328
1422
|
...this.healthzChecks
|
|
1329
1423
|
],
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1424
|
+
...this.hasExplicitReadinessChecks ? {
|
|
1425
|
+
readinessChecks: [
|
|
1426
|
+
...this.healthzReadinessChecks
|
|
1427
|
+
]
|
|
1428
|
+
} : {}
|
|
1333
1429
|
};
|
|
1334
1430
|
const addr = await healthzServer.HealthzServer.start(healthzConfig);
|
|
1335
1431
|
if (!addr) {
|
|
@@ -1373,6 +1469,7 @@ var ExpressServer = class {
|
|
|
1373
1469
|
try {
|
|
1374
1470
|
this.logServerStartInfo();
|
|
1375
1471
|
await this.runHook("afterStart", server);
|
|
1472
|
+
this.internalReady = true;
|
|
1376
1473
|
if (this.isHealthzServerEnabled()) {
|
|
1377
1474
|
healthzServer.HealthzServer.markStartupComplete();
|
|
1378
1475
|
healthzServer.HealthzServer.setReady(true);
|
|
@@ -1459,7 +1556,8 @@ var ExpressServer = class {
|
|
|
1459
1556
|
const url = `${protocol}://${host}:${port}`;
|
|
1460
1557
|
logger.getLogger().info(`Server running on ${url}`);
|
|
1461
1558
|
if (this.healthzAddress) {
|
|
1462
|
-
|
|
1559
|
+
const healthzHost = this.formatHostForUrl(this.healthzAddress.address);
|
|
1560
|
+
logger.getLogger().info(`Healthz server running on http://${healthzHost}:${this.healthzAddress.port}`);
|
|
1463
1561
|
}
|
|
1464
1562
|
if (this.config.openApi?.enable) {
|
|
1465
1563
|
logger.getLogger().info(`API docs available at ${url}${this.normalizePath(this.config.openApi.mountPath, this.config.openApi.withGlobalPrefix)}`);
|
|
@@ -1486,11 +1584,21 @@ var ExpressServer = class {
|
|
|
1486
1584
|
logger.getLogger().warn("Stop called but server is not running");
|
|
1487
1585
|
return;
|
|
1488
1586
|
}
|
|
1489
|
-
if (this.
|
|
1490
|
-
|
|
1491
|
-
|
|
1587
|
+
if (this.stopPromise) {
|
|
1588
|
+
return this.stopPromise;
|
|
1589
|
+
}
|
|
1590
|
+
this.stopPromise = this.doStop(force);
|
|
1591
|
+
try {
|
|
1592
|
+
await this.stopPromise;
|
|
1593
|
+
} finally {
|
|
1594
|
+
this.stopPromise = void 0;
|
|
1492
1595
|
}
|
|
1493
|
-
|
|
1596
|
+
}
|
|
1597
|
+
/**
|
|
1598
|
+
* Internal implementation of server shutdown.
|
|
1599
|
+
*/
|
|
1600
|
+
async doStop(force = false) {
|
|
1601
|
+
this.internalReady = false;
|
|
1494
1602
|
if (healthzServer.HealthzServer.isRunning()) {
|
|
1495
1603
|
healthzServer.HealthzServer.setReady(false);
|
|
1496
1604
|
}
|
|
@@ -1501,8 +1609,11 @@ var ExpressServer = class {
|
|
|
1501
1609
|
const shutdownDelay = this.getHealthzShutdownDelay();
|
|
1502
1610
|
if (shutdownDelay > 0 && !force && healthzServer.HealthzServer.isRunning()) {
|
|
1503
1611
|
logger.getLogger().info(`Waiting ${shutdownDelay}ms for load balancer to drain traffic...`);
|
|
1612
|
+
this.isDraining = true;
|
|
1504
1613
|
await new Promise((resolve) => setTimeout(resolve, shutdownDelay));
|
|
1505
1614
|
}
|
|
1615
|
+
this.isDraining = false;
|
|
1616
|
+
this.isShuttingDown = true;
|
|
1506
1617
|
if (this.server) {
|
|
1507
1618
|
await this.gracefulShutdown(force);
|
|
1508
1619
|
}
|
|
@@ -1512,6 +1623,7 @@ var ExpressServer = class {
|
|
|
1512
1623
|
this.healthzAddress = null;
|
|
1513
1624
|
}
|
|
1514
1625
|
this.isShuttingDown = false;
|
|
1626
|
+
this.isDraining = false;
|
|
1515
1627
|
}
|
|
1516
1628
|
}
|
|
1517
1629
|
/**
|
|
@@ -1598,10 +1710,11 @@ var ExpressServer = class {
|
|
|
1598
1710
|
signalHandled = true;
|
|
1599
1711
|
logger.getLogger().info(`Received ${signal}, initiating graceful shutdown...`);
|
|
1600
1712
|
try {
|
|
1601
|
-
this.disableGracefulShutdown();
|
|
1602
1713
|
await this.stop(false);
|
|
1714
|
+
this.disableGracefulShutdown();
|
|
1603
1715
|
process.exit(0);
|
|
1604
1716
|
} catch (err) {
|
|
1717
|
+
this.disableGracefulShutdown();
|
|
1605
1718
|
logger.getLogger().fatal({
|
|
1606
1719
|
err
|
|
1607
1720
|
}, "Shutdown failed");
|
|
@@ -1959,7 +2072,8 @@ var ExpressServer = class {
|
|
|
1959
2072
|
}
|
|
1960
2073
|
normalizePath(path, withGlobalPrefix = false) {
|
|
1961
2074
|
const sanitize = /* @__PURE__ */ __name((p) => {
|
|
1962
|
-
|
|
2075
|
+
const inner = string.trimChars(p.trim(), "/");
|
|
2076
|
+
return inner ? "/" + inner.replace(/\/{2,}/g, "/") : "/";
|
|
1963
2077
|
}, "sanitize");
|
|
1964
2078
|
const prefix = withGlobalPrefix && this.globalPrefix ? sanitize(this.globalPrefix) : "";
|
|
1965
2079
|
if (typeof path !== "string" || !path.trim()) {
|
package/server/index.d.ts
CHANGED
|
@@ -26,7 +26,7 @@ import express, { Express, Router } from 'express';
|
|
|
26
26
|
import http from 'node:http';
|
|
27
27
|
import https from 'node:https';
|
|
28
28
|
import { CatbeeServerConfig, CatbeeServerHooks } from '@catbee/utils/types';
|
|
29
|
-
import { HealthzServer, HealthzAddressInfo, CatbeeHealthzServerConfig } from '@catbee/utils/healthz-server';
|
|
29
|
+
import { NamedCheck, HealthzServer, HealthzAddressInfo, CatbeeHealthzServerConfig } from '@catbee/utils/healthz-server';
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* Map of critical dependencies to their error messages.
|
|
@@ -71,8 +71,12 @@ declare class ExpressServer {
|
|
|
71
71
|
private readonly app;
|
|
72
72
|
/** Set of active WebSocket connections */
|
|
73
73
|
private readonly connections;
|
|
74
|
-
/** Flag indicating if the server is shutting down */
|
|
74
|
+
/** Flag indicating if the server is shutting down (rejects traffic with 503) */
|
|
75
75
|
private isShuttingDown;
|
|
76
|
+
/** Flag indicating if the server is in graceful drain delay (sets Connection: close while servicing traffic) */
|
|
77
|
+
private isDraining;
|
|
78
|
+
/** Internal readiness state (used when HealthzServer is not enabled) */
|
|
79
|
+
private internalReady;
|
|
76
80
|
/** Flag indicating if graceful shutdown handlers are registered */
|
|
77
81
|
private gracefulShutdownRegistered;
|
|
78
82
|
/** Map of registered signal listeners for clean teardown */
|
|
@@ -83,10 +87,14 @@ declare class ExpressServer {
|
|
|
83
87
|
private readonly healthzChecks;
|
|
84
88
|
/** Named checks queued for Healthz readiness probe */
|
|
85
89
|
private readonly healthzReadinessChecks;
|
|
90
|
+
/** Whether readiness checks were explicitly provided or registered */
|
|
91
|
+
private hasExplicitReadinessChecks;
|
|
86
92
|
/** Promise that resolves when initialization (middleware + routes) is complete */
|
|
87
93
|
private readonly initPromise;
|
|
88
94
|
/** In-flight start promise to protect against concurrent start() calls */
|
|
89
95
|
private startPromise?;
|
|
96
|
+
/** In-flight stop promise to protect against concurrent stop() calls */
|
|
97
|
+
private stopPromise?;
|
|
90
98
|
/**
|
|
91
99
|
* Initializes server with intelligent defaults and security best practices.
|
|
92
100
|
* All settings can be customized via config and hooks.
|
|
@@ -223,9 +231,50 @@ declare class ExpressServer {
|
|
|
223
231
|
* @param options Target probe type ('readiness' | 'liveness' | 'both') or options object
|
|
224
232
|
* @returns This instance for method chaining
|
|
225
233
|
*/
|
|
226
|
-
registerHealthCheck(name: string, check: (signal?: AbortSignal) => Promise<boolean> | boolean, options?: 'readiness' | 'liveness' | 'both' | {
|
|
234
|
+
registerHealthCheck(name: string, check: (signal?: AbortSignal) => Promise<boolean | void> | boolean | void, options?: 'readiness' | 'liveness' | 'both' | {
|
|
227
235
|
type?: 'readiness' | 'liveness' | 'both';
|
|
228
236
|
}): this;
|
|
237
|
+
/**
|
|
238
|
+
* Unregister a health check by name.
|
|
239
|
+
*
|
|
240
|
+
* @param name Name of the check to remove
|
|
241
|
+
* @param options Target probe type ('readiness' | 'liveness' | 'both') or options object
|
|
242
|
+
* @returns This instance for method chaining
|
|
243
|
+
*/
|
|
244
|
+
unregisterHealthCheck(name: string, options?: 'readiness' | 'liveness' | 'both' | {
|
|
245
|
+
type?: 'readiness' | 'liveness' | 'both';
|
|
246
|
+
}): this;
|
|
247
|
+
/**
|
|
248
|
+
* Get all registered Healthz checks queued for this Express server.
|
|
249
|
+
*/
|
|
250
|
+
getHealthzChecks(): {
|
|
251
|
+
liveness: NamedCheck[];
|
|
252
|
+
readiness: NamedCheck[];
|
|
253
|
+
};
|
|
254
|
+
/**
|
|
255
|
+
* Register a readiness health check.
|
|
256
|
+
*
|
|
257
|
+
* @param name Unique identifier for the check (used in detailed responses)
|
|
258
|
+
* @param check Function returning boolean or Promise<boolean> indicating health (supports optional AbortSignal)
|
|
259
|
+
* @returns This instance for method chaining
|
|
260
|
+
*/
|
|
261
|
+
registerReadinessCheck(name: string, check: (signal?: AbortSignal) => Promise<boolean | void> | boolean | void): this;
|
|
262
|
+
/**
|
|
263
|
+
* Register a liveness health check.
|
|
264
|
+
*
|
|
265
|
+
* @param name Unique identifier for the check (used in detailed responses)
|
|
266
|
+
* @param check Function returning boolean or Promise<boolean> indicating health (supports optional AbortSignal)
|
|
267
|
+
* @returns This instance for method chaining
|
|
268
|
+
*/
|
|
269
|
+
registerLivenessCheck(name: string, check: (signal?: AbortSignal) => Promise<boolean | void> | boolean | void): this;
|
|
270
|
+
/**
|
|
271
|
+
* Register a readiness and liveness health check.
|
|
272
|
+
*
|
|
273
|
+
* @param name Unique identifier for the check (used in detailed responses)
|
|
274
|
+
* @param check Function returning boolean or Promise<boolean> indicating health (supports optional AbortSignal)
|
|
275
|
+
* @returns This instance for method chaining
|
|
276
|
+
*/
|
|
277
|
+
registerReadinessAndLivenessChecks(name: string, check: (signal?: AbortSignal) => Promise<boolean | void> | boolean | void): this;
|
|
229
278
|
/**
|
|
230
279
|
* Mark the service as ready / not-ready for traffic on the Healthz probe server.
|
|
231
280
|
*
|
|
@@ -327,6 +376,10 @@ declare class ExpressServer {
|
|
|
327
376
|
* - Monitoring systems are notified
|
|
328
377
|
*/
|
|
329
378
|
stop(force?: boolean): Promise<void>;
|
|
379
|
+
/**
|
|
380
|
+
* Internal implementation of server shutdown.
|
|
381
|
+
*/
|
|
382
|
+
private doStop;
|
|
330
383
|
/**
|
|
331
384
|
* Perform graceful server shutdown with timeout.
|
|
332
385
|
*/
|
package/server/index.mjs
CHANGED
|
@@ -37,6 +37,7 @@ 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 { trimChars } from '@catbee/utils/string';
|
|
40
41
|
import { HealthzServer } from '@catbee/utils/healthz-server';
|
|
41
42
|
|
|
42
43
|
var __defProp = Object.defineProperty;
|
|
@@ -730,8 +731,12 @@ var ExpressServer = class {
|
|
|
730
731
|
app;
|
|
731
732
|
/** Set of active WebSocket connections */
|
|
732
733
|
connections = /* @__PURE__ */ new Set();
|
|
733
|
-
/** Flag indicating if the server is shutting down */
|
|
734
|
+
/** Flag indicating if the server is shutting down (rejects traffic with 503) */
|
|
734
735
|
isShuttingDown = false;
|
|
736
|
+
/** Flag indicating if the server is in graceful drain delay (sets Connection: close while servicing traffic) */
|
|
737
|
+
isDraining = false;
|
|
738
|
+
/** Internal readiness state (used when HealthzServer is not enabled) */
|
|
739
|
+
internalReady = false;
|
|
735
740
|
/** Flag indicating if graceful shutdown handlers are registered */
|
|
736
741
|
gracefulShutdownRegistered = false;
|
|
737
742
|
/** Map of registered signal listeners for clean teardown */
|
|
@@ -742,10 +747,14 @@ var ExpressServer = class {
|
|
|
742
747
|
healthzChecks = [];
|
|
743
748
|
/** Named checks queued for Healthz readiness probe */
|
|
744
749
|
healthzReadinessChecks = [];
|
|
750
|
+
/** Whether readiness checks were explicitly provided or registered */
|
|
751
|
+
hasExplicitReadinessChecks = false;
|
|
745
752
|
/** Promise that resolves when initialization (middleware + routes) is complete */
|
|
746
753
|
initPromise;
|
|
747
754
|
/** In-flight start promise to protect against concurrent start() calls */
|
|
748
755
|
startPromise;
|
|
756
|
+
/** In-flight stop promise to protect against concurrent stop() calls */
|
|
757
|
+
stopPromise;
|
|
749
758
|
/**
|
|
750
759
|
* Initializes server with intelligent defaults and security best practices.
|
|
751
760
|
* All settings can be customized via config and hooks.
|
|
@@ -786,7 +795,8 @@ var ExpressServer = class {
|
|
|
786
795
|
if (this.config.healthzServer.checks) {
|
|
787
796
|
this.healthzChecks.push(...this.config.healthzServer.checks);
|
|
788
797
|
}
|
|
789
|
-
if (this.config.healthzServer.readinessChecks) {
|
|
798
|
+
if (this.config.healthzServer.readinessChecks !== void 0) {
|
|
799
|
+
this.hasExplicitReadinessChecks = true;
|
|
790
800
|
this.healthzReadinessChecks.push(...this.config.healthzServer.readinessChecks);
|
|
791
801
|
}
|
|
792
802
|
}
|
|
@@ -884,6 +894,9 @@ var ExpressServer = class {
|
|
|
884
894
|
res.status(HttpStatusCodes.SERVICE_UNAVAILABLE).json(new ServiceUnavailableException("Server is shutting down"));
|
|
885
895
|
return;
|
|
886
896
|
}
|
|
897
|
+
if (this.isDraining) {
|
|
898
|
+
res.setHeader("Connection", "close");
|
|
899
|
+
}
|
|
887
900
|
next();
|
|
888
901
|
});
|
|
889
902
|
}
|
|
@@ -1197,21 +1210,96 @@ var ExpressServer = class {
|
|
|
1197
1210
|
check
|
|
1198
1211
|
};
|
|
1199
1212
|
if (probeType === "liveness" || probeType === "both") {
|
|
1200
|
-
this.healthzChecks.
|
|
1213
|
+
const idx = this.healthzChecks.findIndex((c) => c.name === name);
|
|
1214
|
+
if (idx !== -1) {
|
|
1215
|
+
this.healthzChecks[idx] = namedCheck;
|
|
1216
|
+
} else {
|
|
1217
|
+
this.healthzChecks.push(namedCheck);
|
|
1218
|
+
}
|
|
1201
1219
|
}
|
|
1202
1220
|
if (probeType === "readiness" || probeType === "both") {
|
|
1203
|
-
this.
|
|
1221
|
+
this.hasExplicitReadinessChecks = true;
|
|
1222
|
+
const idx = this.healthzReadinessChecks.findIndex((c) => c.name === name);
|
|
1223
|
+
if (idx !== -1) {
|
|
1224
|
+
this.healthzReadinessChecks[idx] = namedCheck;
|
|
1225
|
+
} else {
|
|
1226
|
+
this.healthzReadinessChecks.push(namedCheck);
|
|
1227
|
+
}
|
|
1204
1228
|
}
|
|
1205
1229
|
HealthzServer.registerCheck(namedCheck, probeType);
|
|
1206
1230
|
return this;
|
|
1207
1231
|
}
|
|
1208
1232
|
/**
|
|
1233
|
+
* Unregister a health check by name.
|
|
1234
|
+
*
|
|
1235
|
+
* @param name Name of the check to remove
|
|
1236
|
+
* @param options Target probe type ('readiness' | 'liveness' | 'both') or options object
|
|
1237
|
+
* @returns This instance for method chaining
|
|
1238
|
+
*/
|
|
1239
|
+
unregisterHealthCheck(name, options) {
|
|
1240
|
+
const probeType = typeof options === "string" ? options : options?.type ?? "both";
|
|
1241
|
+
if (probeType === "liveness" || probeType === "both") {
|
|
1242
|
+
const idx = this.healthzChecks.findIndex((c) => c.name === name);
|
|
1243
|
+
if (idx !== -1) this.healthzChecks.splice(idx, 1);
|
|
1244
|
+
}
|
|
1245
|
+
if (probeType === "readiness" || probeType === "both") {
|
|
1246
|
+
const idx = this.healthzReadinessChecks.findIndex((c) => c.name === name);
|
|
1247
|
+
if (idx !== -1) this.healthzReadinessChecks.splice(idx, 1);
|
|
1248
|
+
}
|
|
1249
|
+
HealthzServer.unregisterCheck(name, probeType);
|
|
1250
|
+
return this;
|
|
1251
|
+
}
|
|
1252
|
+
/**
|
|
1253
|
+
* Get all registered Healthz checks queued for this Express server.
|
|
1254
|
+
*/
|
|
1255
|
+
getHealthzChecks() {
|
|
1256
|
+
return {
|
|
1257
|
+
liveness: [
|
|
1258
|
+
...this.healthzChecks
|
|
1259
|
+
],
|
|
1260
|
+
readiness: [
|
|
1261
|
+
...this.healthzReadinessChecks
|
|
1262
|
+
]
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
/**
|
|
1266
|
+
* Register a readiness health check.
|
|
1267
|
+
*
|
|
1268
|
+
* @param name Unique identifier for the check (used in detailed responses)
|
|
1269
|
+
* @param check Function returning boolean or Promise<boolean> indicating health (supports optional AbortSignal)
|
|
1270
|
+
* @returns This instance for method chaining
|
|
1271
|
+
*/
|
|
1272
|
+
registerReadinessCheck(name, check) {
|
|
1273
|
+
return this.registerHealthCheck(name, check, "readiness");
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* Register a liveness health check.
|
|
1277
|
+
*
|
|
1278
|
+
* @param name Unique identifier for the check (used in detailed responses)
|
|
1279
|
+
* @param check Function returning boolean or Promise<boolean> indicating health (supports optional AbortSignal)
|
|
1280
|
+
* @returns This instance for method chaining
|
|
1281
|
+
*/
|
|
1282
|
+
registerLivenessCheck(name, check) {
|
|
1283
|
+
return this.registerHealthCheck(name, check, "liveness");
|
|
1284
|
+
}
|
|
1285
|
+
/**
|
|
1286
|
+
* Register a readiness and liveness health check.
|
|
1287
|
+
*
|
|
1288
|
+
* @param name Unique identifier for the check (used in detailed responses)
|
|
1289
|
+
* @param check Function returning boolean or Promise<boolean> indicating health (supports optional AbortSignal)
|
|
1290
|
+
* @returns This instance for method chaining
|
|
1291
|
+
*/
|
|
1292
|
+
registerReadinessAndLivenessChecks(name, check) {
|
|
1293
|
+
return this.registerHealthCheck(name, check, "both");
|
|
1294
|
+
}
|
|
1295
|
+
/**
|
|
1209
1296
|
* Mark the service as ready / not-ready for traffic on the Healthz probe server.
|
|
1210
1297
|
*
|
|
1211
1298
|
* @param ready Whether the service is ready to receive traffic
|
|
1212
1299
|
* @returns This instance for method chaining
|
|
1213
1300
|
*/
|
|
1214
1301
|
setReady(ready) {
|
|
1302
|
+
this.internalReady = ready;
|
|
1215
1303
|
HealthzServer.setReady(ready);
|
|
1216
1304
|
return this;
|
|
1217
1305
|
}
|
|
@@ -1219,7 +1307,10 @@ var ExpressServer = class {
|
|
|
1219
1307
|
* Whether the service is currently marked as ready for traffic on the Healthz probe server.
|
|
1220
1308
|
*/
|
|
1221
1309
|
isReady() {
|
|
1222
|
-
|
|
1310
|
+
if (this.isHealthzServerEnabled()) {
|
|
1311
|
+
return HealthzServer.isReady();
|
|
1312
|
+
}
|
|
1313
|
+
return this.internalReady && this.isRunning();
|
|
1223
1314
|
}
|
|
1224
1315
|
/**
|
|
1225
1316
|
* Mark application startup as completed on the Healthz probe server (switches `/startupz` to 200).
|
|
@@ -1234,7 +1325,10 @@ var ExpressServer = class {
|
|
|
1234
1325
|
* Whether application startup has completed on the Healthz probe server.
|
|
1235
1326
|
*/
|
|
1236
1327
|
isStartupComplete() {
|
|
1237
|
-
|
|
1328
|
+
if (this.isHealthzServerEnabled()) {
|
|
1329
|
+
return HealthzServer.isStartupComplete();
|
|
1330
|
+
}
|
|
1331
|
+
return this.isRunning();
|
|
1238
1332
|
}
|
|
1239
1333
|
/**
|
|
1240
1334
|
* Get the running HealthzServer instance (if started).
|
|
@@ -1255,7 +1349,7 @@ var ExpressServer = class {
|
|
|
1255
1349
|
* @returns `true` when ready, otherwise `false`.
|
|
1256
1350
|
*/
|
|
1257
1351
|
ready() {
|
|
1258
|
-
return
|
|
1352
|
+
return this.isReady();
|
|
1259
1353
|
}
|
|
1260
1354
|
/**
|
|
1261
1355
|
* Get the underlying Express application instance.
|
|
@@ -1319,9 +1413,11 @@ var ExpressServer = class {
|
|
|
1319
1413
|
checks: [
|
|
1320
1414
|
...this.healthzChecks
|
|
1321
1415
|
],
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1416
|
+
...this.hasExplicitReadinessChecks ? {
|
|
1417
|
+
readinessChecks: [
|
|
1418
|
+
...this.healthzReadinessChecks
|
|
1419
|
+
]
|
|
1420
|
+
} : {}
|
|
1325
1421
|
};
|
|
1326
1422
|
const addr = await HealthzServer.start(healthzConfig);
|
|
1327
1423
|
if (!addr) {
|
|
@@ -1365,6 +1461,7 @@ var ExpressServer = class {
|
|
|
1365
1461
|
try {
|
|
1366
1462
|
this.logServerStartInfo();
|
|
1367
1463
|
await this.runHook("afterStart", server);
|
|
1464
|
+
this.internalReady = true;
|
|
1368
1465
|
if (this.isHealthzServerEnabled()) {
|
|
1369
1466
|
HealthzServer.markStartupComplete();
|
|
1370
1467
|
HealthzServer.setReady(true);
|
|
@@ -1451,7 +1548,8 @@ var ExpressServer = class {
|
|
|
1451
1548
|
const url = `${protocol}://${host}:${port}`;
|
|
1452
1549
|
getLogger().info(`Server running on ${url}`);
|
|
1453
1550
|
if (this.healthzAddress) {
|
|
1454
|
-
|
|
1551
|
+
const healthzHost = this.formatHostForUrl(this.healthzAddress.address);
|
|
1552
|
+
getLogger().info(`Healthz server running on http://${healthzHost}:${this.healthzAddress.port}`);
|
|
1455
1553
|
}
|
|
1456
1554
|
if (this.config.openApi?.enable) {
|
|
1457
1555
|
getLogger().info(`API docs available at ${url}${this.normalizePath(this.config.openApi.mountPath, this.config.openApi.withGlobalPrefix)}`);
|
|
@@ -1478,11 +1576,21 @@ var ExpressServer = class {
|
|
|
1478
1576
|
getLogger().warn("Stop called but server is not running");
|
|
1479
1577
|
return;
|
|
1480
1578
|
}
|
|
1481
|
-
if (this.
|
|
1482
|
-
|
|
1483
|
-
|
|
1579
|
+
if (this.stopPromise) {
|
|
1580
|
+
return this.stopPromise;
|
|
1581
|
+
}
|
|
1582
|
+
this.stopPromise = this.doStop(force);
|
|
1583
|
+
try {
|
|
1584
|
+
await this.stopPromise;
|
|
1585
|
+
} finally {
|
|
1586
|
+
this.stopPromise = void 0;
|
|
1484
1587
|
}
|
|
1485
|
-
|
|
1588
|
+
}
|
|
1589
|
+
/**
|
|
1590
|
+
* Internal implementation of server shutdown.
|
|
1591
|
+
*/
|
|
1592
|
+
async doStop(force = false) {
|
|
1593
|
+
this.internalReady = false;
|
|
1486
1594
|
if (HealthzServer.isRunning()) {
|
|
1487
1595
|
HealthzServer.setReady(false);
|
|
1488
1596
|
}
|
|
@@ -1493,8 +1601,11 @@ var ExpressServer = class {
|
|
|
1493
1601
|
const shutdownDelay = this.getHealthzShutdownDelay();
|
|
1494
1602
|
if (shutdownDelay > 0 && !force && HealthzServer.isRunning()) {
|
|
1495
1603
|
getLogger().info(`Waiting ${shutdownDelay}ms for load balancer to drain traffic...`);
|
|
1604
|
+
this.isDraining = true;
|
|
1496
1605
|
await new Promise((resolve) => setTimeout(resolve, shutdownDelay));
|
|
1497
1606
|
}
|
|
1607
|
+
this.isDraining = false;
|
|
1608
|
+
this.isShuttingDown = true;
|
|
1498
1609
|
if (this.server) {
|
|
1499
1610
|
await this.gracefulShutdown(force);
|
|
1500
1611
|
}
|
|
@@ -1504,6 +1615,7 @@ var ExpressServer = class {
|
|
|
1504
1615
|
this.healthzAddress = null;
|
|
1505
1616
|
}
|
|
1506
1617
|
this.isShuttingDown = false;
|
|
1618
|
+
this.isDraining = false;
|
|
1507
1619
|
}
|
|
1508
1620
|
}
|
|
1509
1621
|
/**
|
|
@@ -1590,10 +1702,11 @@ var ExpressServer = class {
|
|
|
1590
1702
|
signalHandled = true;
|
|
1591
1703
|
getLogger().info(`Received ${signal}, initiating graceful shutdown...`);
|
|
1592
1704
|
try {
|
|
1593
|
-
this.disableGracefulShutdown();
|
|
1594
1705
|
await this.stop(false);
|
|
1706
|
+
this.disableGracefulShutdown();
|
|
1595
1707
|
process.exit(0);
|
|
1596
1708
|
} catch (err) {
|
|
1709
|
+
this.disableGracefulShutdown();
|
|
1597
1710
|
getLogger().fatal({
|
|
1598
1711
|
err
|
|
1599
1712
|
}, "Shutdown failed");
|
|
@@ -1951,7 +2064,8 @@ var ExpressServer = class {
|
|
|
1951
2064
|
}
|
|
1952
2065
|
normalizePath(path, withGlobalPrefix = false) {
|
|
1953
2066
|
const sanitize = /* @__PURE__ */ __name((p) => {
|
|
1954
|
-
|
|
2067
|
+
const inner = trimChars(p.trim(), "/");
|
|
2068
|
+
return inner ? "/" + inner.replace(/\/{2,}/g, "/") : "/";
|
|
1955
2069
|
}, "sanitize");
|
|
1956
2070
|
const prefix = withGlobalPrefix && this.globalPrefix ? sanitize(this.globalPrefix) : "";
|
|
1957
2071
|
if (typeof path !== "string" || !path.trim()) {
|