@bymax-one/nest-core 1.6.7 → 1.6.8

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.
@@ -49,8 +49,9 @@ interface HealthIndicatorResult {
49
49
  /** Whether the checked dependency is reachable and healthy. */
50
50
  status: 'up' | 'down';
51
51
  /**
52
- * Optional diagnostic detail. Must never include secrets, connection
53
- * strings, credentials, or a raw internal error object: only safe,
52
+ * Optional diagnostic detail, served in the readiness response only when
53
+ * `health.exposeIndicatorErrors` is on. Must never include secrets,
54
+ * connection strings, credentials, or a raw internal error object: only safe,
54
55
  * human-readable fields belong here.
55
56
  */
56
57
  details?: Record<string, unknown>;
@@ -66,11 +67,13 @@ interface IHealthIndicator {
66
67
  /**
67
68
  * Perform the check.
68
69
  *
70
+ * @param signal - Aborted when the aggregator stops waiting for this check. An indicator that
71
+ * can cancel its query should pass it on; one that cannot may ignore it.
69
72
  * @returns The indicator's outcome.
70
73
  * @throws Any error; a rejection is converted to a `down` entry by the
71
74
  * aggregator and never propagates to the caller.
72
75
  */
73
- check(): Promise<HealthIndicatorResult>;
76
+ check(signal?: AbortSignal): Promise<HealthIndicatorResult>;
74
77
  }
75
78
  /**
76
79
  * One entry in a {@link HealthResponse.checks} array: an indicator's result,
@@ -49,8 +49,9 @@ interface HealthIndicatorResult {
49
49
  /** Whether the checked dependency is reachable and healthy. */
50
50
  status: 'up' | 'down';
51
51
  /**
52
- * Optional diagnostic detail. Must never include secrets, connection
53
- * strings, credentials, or a raw internal error object: only safe,
52
+ * Optional diagnostic detail, served in the readiness response only when
53
+ * `health.exposeIndicatorErrors` is on. Must never include secrets,
54
+ * connection strings, credentials, or a raw internal error object: only safe,
54
55
  * human-readable fields belong here.
55
56
  */
56
57
  details?: Record<string, unknown>;
@@ -66,11 +67,13 @@ interface IHealthIndicator {
66
67
  /**
67
68
  * Perform the check.
68
69
  *
70
+ * @param signal - Aborted when the aggregator stops waiting for this check. An indicator that
71
+ * can cancel its query should pass it on; one that cannot may ignore it.
69
72
  * @returns The indicator's outcome.
70
73
  * @throws Any error; a rejection is converted to a `down` entry by the
71
74
  * aggregator and never propagates to the caller.
72
75
  */
73
- check(): Promise<HealthIndicatorResult>;
76
+ check(signal?: AbortSignal): Promise<HealthIndicatorResult>;
74
77
  }
75
78
  /**
76
79
  * One entry in a {@link HealthResponse.checks} array: an indicator's result,
@@ -29,6 +29,21 @@ function assertPositiveInteger(name, value) {
29
29
  throw new Error(`${name} must be a positive integer, received ${String(value)}`);
30
30
  }
31
31
  }
32
+ function assertBootstrapState(express) {
33
+ if (express.getHttpAdapter().getType?.() !== "express") {
34
+ throw new Error("applyHttpFloors supports the Express adapter only");
35
+ }
36
+ const server = express.getHttpServer();
37
+ if (server.listening) {
38
+ throw new Error("applyHttpFloors must run before listen(): the server is already listening");
39
+ }
40
+ if (express.isInitialized === true) {
41
+ throw new Error(
42
+ "applyHttpFloors must run before app.init(): the application is already initialised"
43
+ );
44
+ }
45
+ return server;
46
+ }
32
47
  async function applyHttpFloors(app, options = {}) {
33
48
  const headersTimeoutMs = options.headersTimeoutMs ?? DEFAULT_HEADERS_TIMEOUT_MS;
34
49
  const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
@@ -42,13 +57,7 @@ async function applyHttpFloors(app, options = {}) {
42
57
  const checkingIntervalMs = options.connectionsCheckingIntervalMs ?? Math.min(DEFAULT_CONNECTIONS_CHECKING_INTERVAL_MS, headersTimeoutMs);
43
58
  assertPositiveInteger("connectionsCheckingIntervalMs", checkingIntervalMs);
44
59
  const express = app;
45
- if (express.getHttpAdapter().getType?.() !== "express") {
46
- throw new Error("applyHttpFloors supports the Express adapter only");
47
- }
48
- const server = express.getHttpServer();
49
- if (server.listening) {
50
- throw new Error("applyHttpFloors must run before listen(): the server is already listening");
51
- }
60
+ const server = assertBootstrapState(express);
52
61
  const given = options.helmet ?? {};
53
62
  if (given !== false) {
54
63
  const helmet = await loadHelmet();
@@ -5,7 +5,7 @@ import { INestApplication } from '@nestjs/common';
5
5
  * tune per deployment: security headers without HSTS, compressed request bodies refused, and
6
6
  * timeouts that bound how long a client may hold a socket, enforced to within a second.
7
7
  *
8
- * Order is part of the contract. Call it right after the access log is installed
8
+ * Order is part of the contract. Call it before `app.init()`, right after the access log is installed
9
9
  * (`applyAccessLog` from `@bymax-one/nest-logger`) and before anything that reads the request:
10
10
  * helmet is mounted first so even a request the body parsers refuse carries the headers, and the
11
11
  * parsers are registered here so Nest's defaults (which accept gzip) are not. Cookie parsing, the
@@ -5,7 +5,7 @@ import { INestApplication } from '@nestjs/common';
5
5
  * tune per deployment: security headers without HSTS, compressed request bodies refused, and
6
6
  * timeouts that bound how long a client may hold a socket, enforced to within a second.
7
7
  *
8
- * Order is part of the contract. Call it right after the access log is installed
8
+ * Order is part of the contract. Call it before `app.init()`, right after the access log is installed
9
9
  * (`applyAccessLog` from `@bymax-one/nest-logger`) and before anything that reads the request:
10
10
  * helmet is mounted first so even a request the body parsers refuse carries the headers, and the
11
11
  * parsers are registered here so Nest's defaults (which accept gzip) are not. Cookie parsing, the
@@ -27,6 +27,21 @@ function assertPositiveInteger(name, value) {
27
27
  throw new Error(`${name} must be a positive integer, received ${String(value)}`);
28
28
  }
29
29
  }
30
+ function assertBootstrapState(express) {
31
+ if (express.getHttpAdapter().getType?.() !== "express") {
32
+ throw new Error("applyHttpFloors supports the Express adapter only");
33
+ }
34
+ const server = express.getHttpServer();
35
+ if (server.listening) {
36
+ throw new Error("applyHttpFloors must run before listen(): the server is already listening");
37
+ }
38
+ if (express.isInitialized === true) {
39
+ throw new Error(
40
+ "applyHttpFloors must run before app.init(): the application is already initialised"
41
+ );
42
+ }
43
+ return server;
44
+ }
30
45
  async function applyHttpFloors(app, options = {}) {
31
46
  const headersTimeoutMs = options.headersTimeoutMs ?? DEFAULT_HEADERS_TIMEOUT_MS;
32
47
  const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
@@ -40,13 +55,7 @@ async function applyHttpFloors(app, options = {}) {
40
55
  const checkingIntervalMs = options.connectionsCheckingIntervalMs ?? Math.min(DEFAULT_CONNECTIONS_CHECKING_INTERVAL_MS, headersTimeoutMs);
41
56
  assertPositiveInteger("connectionsCheckingIntervalMs", checkingIntervalMs);
42
57
  const express = app;
43
- if (express.getHttpAdapter().getType?.() !== "express") {
44
- throw new Error("applyHttpFloors supports the Express adapter only");
45
- }
46
- const server = express.getHttpServer();
47
- if (server.listening) {
48
- throw new Error("applyHttpFloors must run before listen(): the server is already listening");
49
- }
58
+ const server = assertBootstrapState(express);
50
59
  const given = options.helmet ?? {};
51
60
  if (given !== false) {
52
61
  const helmet = await loadHelmet();
package/dist/index.cjs CHANGED
@@ -86,12 +86,19 @@ function codeForStatus(status) {
86
86
 
87
87
  // src/body-depth/body-depth.middleware.ts
88
88
  var BODY_TOO_DEEP_MESSAGE = "Request body is nested too deeply";
89
+ function isContainer(node) {
90
+ if (typeof node !== "object" || node === null) {
91
+ return false;
92
+ }
93
+ const proto = Object.getPrototypeOf(node);
94
+ return Array.isArray(node) || proto === Object.prototype || proto === null;
95
+ }
89
96
  function exceedsDepth(value, maxDepth) {
90
97
  let level = [value];
91
98
  for (let depth = 0; level.length > 0; depth += 1) {
92
99
  const next = [];
93
100
  for (const node of level) {
94
- if (typeof node === "object" && node !== null) {
101
+ if (isContainer(node)) {
95
102
  if (depth >= maxDepth) {
96
103
  return true;
97
104
  }
@@ -148,6 +155,8 @@ function runtimeEnvironmentName(declared) {
148
155
 
149
156
  // src/core.options.ts
150
157
  var DEFAULT_INDICATOR_TIMEOUT_MS = 5e3;
158
+ var MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
159
+ var DEFAULT_READINESS_CACHE_MS = 1e3;
151
160
  var DEFAULT_OPENAPI_PATH = "docs";
152
161
  var DEFAULT_OPENAPI_JSON_PATH = "docs-json";
153
162
  var DEFAULT_OPENAPI_TITLE = "API";
@@ -167,27 +176,48 @@ function resolveEnvelope(raw) {
167
176
  exposeInternals: raw?.exposeInternals ?? false
168
177
  };
169
178
  }
179
+ function positiveSafeInteger(name, value, max) {
180
+ if (!Number.isSafeInteger(value) || value < 1) {
181
+ throw new Error(`${name} must be a positive safe integer, received ${String(value)}`);
182
+ }
183
+ if (max !== void 0 && value > max) {
184
+ throw new Error(`${name} must be a positive integer of at most ${max}, received ${value}`);
185
+ }
186
+ return value;
187
+ }
170
188
  function resolveTiming(raw) {
171
189
  const enabled = raw?.enabled ?? true;
172
190
  const threshold = raw?.slowRequestThresholdMs;
173
- return threshold === void 0 ? { enabled } : { enabled, slowRequestThresholdMs: threshold };
191
+ return threshold === void 0 ? { enabled } : {
192
+ enabled,
193
+ slowRequestThresholdMs: positiveSafeInteger("timing.slowRequestThresholdMs", threshold)
194
+ };
174
195
  }
175
196
  var DEFAULT_MAX_BODY_DEPTH = 32;
176
197
  function resolveBodyDepth(raw) {
177
- const maxDepth = raw?.maxDepth ?? DEFAULT_MAX_BODY_DEPTH;
178
- if (!Number.isSafeInteger(maxDepth) || maxDepth < 1) {
179
- throw new Error(
180
- `bodyDepth.maxDepth must be a positive safe integer, received ${String(maxDepth)}`
181
- );
182
- }
198
+ const maxDepth = positiveSafeInteger(
199
+ "bodyDepth.maxDepth",
200
+ raw?.maxDepth ?? DEFAULT_MAX_BODY_DEPTH
201
+ );
183
202
  return { enabled: raw?.enabled ?? false, maxDepth };
184
203
  }
185
204
  function resolveHealth(raw) {
205
+ const readinessCacheMs = raw?.readinessCacheMs ?? DEFAULT_READINESS_CACHE_MS;
206
+ if (!Number.isSafeInteger(readinessCacheMs) || readinessCacheMs < 0) {
207
+ throw new Error(
208
+ `health.readinessCacheMs must be a non-negative safe integer, received ${String(readinessCacheMs)}`
209
+ );
210
+ }
186
211
  return {
212
+ readinessCacheMs,
187
213
  enabled: raw?.enabled ?? true,
188
214
  path: raw?.path ?? DEFAULT_HEALTH_PATH,
189
215
  exposeIndicatorErrors: raw?.exposeIndicatorErrors ?? false,
190
- indicatorTimeoutMs: raw?.indicatorTimeoutMs ?? DEFAULT_INDICATOR_TIMEOUT_MS,
216
+ indicatorTimeoutMs: positiveSafeInteger(
217
+ "health.indicatorTimeoutMs",
218
+ raw?.indicatorTimeoutMs ?? DEFAULT_INDICATOR_TIMEOUT_MS,
219
+ MAX_TIMER_DELAY_MS
220
+ ),
191
221
  autoDiscover: raw?.autoDiscover ?? false
192
222
  };
193
223
  }
@@ -846,10 +876,12 @@ function summarizeRejection(reason) {
846
876
  }
847
877
  return `${message.slice(0, MAX_ERROR_MESSAGE_LENGTH - TRUNCATION_ELLIPSIS.length)}${TRUNCATION_ELLIPSIS}`;
848
878
  }
849
- async function runIndicator(indicator, timeoutMs, exposeErrors) {
879
+ function startTimeout(indicator, timeoutMs) {
880
+ const controller = new AbortController();
850
881
  let timer;
851
882
  const timedOut = new Promise((resolve) => {
852
883
  timer = setTimeout(() => {
884
+ controller.abort();
853
885
  resolve({
854
886
  entry: { name: indicator.name, status: "down", details: { timedOutAfterMs: timeoutMs } },
855
887
  isUp: false,
@@ -857,8 +889,12 @@ async function runIndicator(indicator, timeoutMs, exposeErrors) {
857
889
  });
858
890
  }, timeoutMs);
859
891
  });
860
- const checked = Promise.resolve().then(() => indicator.check()).then((result) => {
861
- const entry = { ...result, name: indicator.name };
892
+ return { signal: controller.signal, timedOut, clear: () => clearTimeout(timer) };
893
+ }
894
+ async function runIndicator(indicator, timeoutMs, exposeErrors) {
895
+ const { signal, timedOut, clear } = startTimeout(indicator, timeoutMs);
896
+ const checked = Promise.resolve().then(() => indicator.check(signal)).then((result) => {
897
+ const entry = exposeErrors ? { ...result, name: indicator.name } : { name: indicator.name, status: result.status };
862
898
  return result.status === "up" ? { entry, isUp: true } : { entry, isUp: false, cause: { kind: "reported-down" } };
863
899
  }).catch((reason) => {
864
900
  const message = summarizeRejection(reason);
@@ -871,7 +907,7 @@ async function runIndicator(indicator, timeoutMs, exposeErrors) {
871
907
  try {
872
908
  return await Promise.race([checked, timedOut]);
873
909
  } finally {
874
- clearTimeout(timer);
910
+ clear();
875
911
  }
876
912
  }
877
913
  function describeCause(cause) {
@@ -931,6 +967,8 @@ var HealthService = class {
931
967
  * Comparing states alone would then let the stale observation win.
932
968
  */
933
969
  this.probeSequence = 0;
970
+ /** Set once shutdown begins, after which readiness answers not ready without running anything. */
971
+ this.draining = false;
934
972
  }
935
973
  /**
936
974
  * Report one check's current state, emitting only when it differs from the
@@ -1103,6 +1141,14 @@ var HealthService = class {
1103
1141
  );
1104
1142
  return this.effectiveIndicators;
1105
1143
  }
1144
+ /**
1145
+ * Stop reporting ready the moment shutdown begins, so a load balancer stops routing here while
1146
+ * the process drains. Nothing cached or in progress is consulted again: every later probe is answered
1147
+ * before either is read.
1148
+ */
1149
+ beforeApplicationShutdown() {
1150
+ this.draining = true;
1151
+ }
1106
1152
  /**
1107
1153
  * Liveness check: the process is up and able to respond. Runs no
1108
1154
  * indicators, so it never depends on the health of anything else.
@@ -1117,9 +1163,33 @@ var HealthService = class {
1117
1163
  * aggregate the results. `status` is `'ok'` only when every indicator
1118
1164
  * reports `up`; an empty indicator list is vacuously `'ok'`.
1119
1165
  *
1166
+ * Probes share the work: one that arrives while a run is in progress awaits it, and for
1167
+ * `health.readinessCacheMs` after it ends the answer is returned without running anything.
1168
+ * The endpoint is usually unauthenticated, so probe volume must not set dependency volume.
1169
+ * Once shutdown has begun the answer is always not ready. The returned object is shared between
1170
+ * callers and must not be mutated.
1171
+ *
1120
1172
  * @returns The aggregated health response.
1121
1173
  */
1122
- async checkReadiness() {
1174
+ checkReadiness() {
1175
+ if (this.draining) {
1176
+ return Promise.resolve({ status: "error", checks: [{ name: "shutdown", status: "down" }] });
1177
+ }
1178
+ const cached = this.cached;
1179
+ if (cached !== void 0 && performance.now() - cached.at < this.options.health.readinessCacheMs) {
1180
+ return Promise.resolve(cached.response);
1181
+ }
1182
+ this.inflight ??= this.runReadiness().finally(() => {
1183
+ this.inflight = void 0;
1184
+ });
1185
+ return this.inflight;
1186
+ }
1187
+ /**
1188
+ * Run every indicator once and record the answer.
1189
+ *
1190
+ * @returns The aggregated health response.
1191
+ */
1192
+ async runReadiness() {
1123
1193
  const timeoutMs = this.options.health.indicatorTimeoutMs;
1124
1194
  const exposeErrors = this.options.health.exposeIndicatorErrors;
1125
1195
  const seq = ++this.probeSequence;
@@ -1131,7 +1201,9 @@ var HealthService = class {
1131
1201
  }
1132
1202
  const checks = outcomes.map((outcome) => outcome.entry);
1133
1203
  const status = checks.every((check) => check.status === "up") ? "ok" : "error";
1134
- return { status, checks };
1204
+ const response = { status, checks };
1205
+ this.cached = { at: performance.now(), response };
1206
+ return response;
1135
1207
  }
1136
1208
  };
1137
1209
  HealthService = __decorateClass([
package/dist/index.d.cts CHANGED
@@ -19,7 +19,7 @@ interface EnvelopeOptions {
19
19
  interface TimingOptions {
20
20
  /** Register the request-timing middleware (`BymaxTimingMiddleware`). Default: `true`. */
21
21
  enabled?: boolean;
22
- /** Samples above this threshold are flagged as slow. No default (unset). */
22
+ /** Samples above this threshold are flagged as slow. Positive integer. No default (unset). */
23
23
  slowRequestThresholdMs?: number;
24
24
  }
25
25
  /** Request-body nesting limit, applied before the application's own validation runs. */
@@ -47,8 +47,17 @@ interface HealthOptions {
47
47
  enabled?: boolean;
48
48
  /** Route prefix for the health endpoints. Default: `'health'`. */
49
49
  path?: string;
50
- /** Per-indicator timeout before a check is reported as down. Default: `5000`. */
50
+ /** Per-indicator timeout before a check is reported as down. Positive integer up to 2147483647. Default: `5000`. */
51
51
  indicatorTimeoutMs?: number;
52
+ /**
53
+ * How long a finished readiness answer is reused, in milliseconds. `0` turns the cache off.
54
+ * Default: `1000`.
55
+ *
56
+ * Readiness is usually reachable without credentials, and every probe would otherwise reach
57
+ * every dependency. Probes that arrive while one is running always share it, whatever this is set to;
58
+ * this is only how long its answer outlives it.
59
+ */
60
+ readinessCacheMs?: number;
52
61
  /**
53
62
  * Aggregate every provider marked with `@BymaxHealthIndicator()`, anywhere in
54
63
  * the application, in addition to those registered under
@@ -62,15 +71,16 @@ interface HealthOptions {
62
71
  */
63
72
  autoDiscover?: boolean;
64
73
  /**
65
- * Include the failing indicator's message in the readiness response under
66
- * `details.error`. Never enable in production. Default: `false`.
74
+ * Serve what an indicator returned in `details`, and the failing indicator's
75
+ * message under `details.error`, in the readiness response. Never enable in
76
+ * production. Default: `false`.
67
77
  *
68
78
  * Readiness is typically unauthenticated and reachable by whatever probes it,
69
79
  * and an indicator usually does not author its own failure message — it lets a
70
80
  * driver's error propagate, and driver errors carry hosts, ports and sometimes
71
- * credentials. With this off, the response names which indicator is down and
72
- * nothing else; the message goes to the logger, where access is already
73
- * controlled.
81
+ * credentials. With this off, the response carries each indicator's name and
82
+ * status and nothing else, except the timeout the aggregator itself reports;
83
+ * the message goes to the logger, where access is already controlled.
74
84
  */
75
85
  exposeIndicatorErrors?: boolean;
76
86
  }
@@ -339,6 +349,7 @@ interface ResolvedHealthOptions {
339
349
  enabled: boolean;
340
350
  path: string;
341
351
  indicatorTimeoutMs: number;
352
+ readinessCacheMs: number;
342
353
  exposeIndicatorErrors: boolean;
343
354
  autoDiscover: boolean;
344
355
  }
package/dist/index.d.ts CHANGED
@@ -19,7 +19,7 @@ interface EnvelopeOptions {
19
19
  interface TimingOptions {
20
20
  /** Register the request-timing middleware (`BymaxTimingMiddleware`). Default: `true`. */
21
21
  enabled?: boolean;
22
- /** Samples above this threshold are flagged as slow. No default (unset). */
22
+ /** Samples above this threshold are flagged as slow. Positive integer. No default (unset). */
23
23
  slowRequestThresholdMs?: number;
24
24
  }
25
25
  /** Request-body nesting limit, applied before the application's own validation runs. */
@@ -47,8 +47,17 @@ interface HealthOptions {
47
47
  enabled?: boolean;
48
48
  /** Route prefix for the health endpoints. Default: `'health'`. */
49
49
  path?: string;
50
- /** Per-indicator timeout before a check is reported as down. Default: `5000`. */
50
+ /** Per-indicator timeout before a check is reported as down. Positive integer up to 2147483647. Default: `5000`. */
51
51
  indicatorTimeoutMs?: number;
52
+ /**
53
+ * How long a finished readiness answer is reused, in milliseconds. `0` turns the cache off.
54
+ * Default: `1000`.
55
+ *
56
+ * Readiness is usually reachable without credentials, and every probe would otherwise reach
57
+ * every dependency. Probes that arrive while one is running always share it, whatever this is set to;
58
+ * this is only how long its answer outlives it.
59
+ */
60
+ readinessCacheMs?: number;
52
61
  /**
53
62
  * Aggregate every provider marked with `@BymaxHealthIndicator()`, anywhere in
54
63
  * the application, in addition to those registered under
@@ -62,15 +71,16 @@ interface HealthOptions {
62
71
  */
63
72
  autoDiscover?: boolean;
64
73
  /**
65
- * Include the failing indicator's message in the readiness response under
66
- * `details.error`. Never enable in production. Default: `false`.
74
+ * Serve what an indicator returned in `details`, and the failing indicator's
75
+ * message under `details.error`, in the readiness response. Never enable in
76
+ * production. Default: `false`.
67
77
  *
68
78
  * Readiness is typically unauthenticated and reachable by whatever probes it,
69
79
  * and an indicator usually does not author its own failure message — it lets a
70
80
  * driver's error propagate, and driver errors carry hosts, ports and sometimes
71
- * credentials. With this off, the response names which indicator is down and
72
- * nothing else; the message goes to the logger, where access is already
73
- * controlled.
81
+ * credentials. With this off, the response carries each indicator's name and
82
+ * status and nothing else, except the timeout the aggregator itself reports;
83
+ * the message goes to the logger, where access is already controlled.
74
84
  */
75
85
  exposeIndicatorErrors?: boolean;
76
86
  }
@@ -339,6 +349,7 @@ interface ResolvedHealthOptions {
339
349
  enabled: boolean;
340
350
  path: string;
341
351
  indicatorTimeoutMs: number;
352
+ readinessCacheMs: number;
342
353
  exposeIndicatorErrors: boolean;
343
354
  autoDiscover: boolean;
344
355
  }
package/dist/index.mjs CHANGED
@@ -84,12 +84,19 @@ function codeForStatus(status) {
84
84
 
85
85
  // src/body-depth/body-depth.middleware.ts
86
86
  var BODY_TOO_DEEP_MESSAGE = "Request body is nested too deeply";
87
+ function isContainer(node) {
88
+ if (typeof node !== "object" || node === null) {
89
+ return false;
90
+ }
91
+ const proto = Object.getPrototypeOf(node);
92
+ return Array.isArray(node) || proto === Object.prototype || proto === null;
93
+ }
87
94
  function exceedsDepth(value, maxDepth) {
88
95
  let level = [value];
89
96
  for (let depth = 0; level.length > 0; depth += 1) {
90
97
  const next = [];
91
98
  for (const node of level) {
92
- if (typeof node === "object" && node !== null) {
99
+ if (isContainer(node)) {
93
100
  if (depth >= maxDepth) {
94
101
  return true;
95
102
  }
@@ -146,6 +153,8 @@ function runtimeEnvironmentName(declared) {
146
153
 
147
154
  // src/core.options.ts
148
155
  var DEFAULT_INDICATOR_TIMEOUT_MS = 5e3;
156
+ var MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
157
+ var DEFAULT_READINESS_CACHE_MS = 1e3;
149
158
  var DEFAULT_OPENAPI_PATH = "docs";
150
159
  var DEFAULT_OPENAPI_JSON_PATH = "docs-json";
151
160
  var DEFAULT_OPENAPI_TITLE = "API";
@@ -165,27 +174,48 @@ function resolveEnvelope(raw) {
165
174
  exposeInternals: raw?.exposeInternals ?? false
166
175
  };
167
176
  }
177
+ function positiveSafeInteger(name, value, max) {
178
+ if (!Number.isSafeInteger(value) || value < 1) {
179
+ throw new Error(`${name} must be a positive safe integer, received ${String(value)}`);
180
+ }
181
+ if (max !== void 0 && value > max) {
182
+ throw new Error(`${name} must be a positive integer of at most ${max}, received ${value}`);
183
+ }
184
+ return value;
185
+ }
168
186
  function resolveTiming(raw) {
169
187
  const enabled = raw?.enabled ?? true;
170
188
  const threshold = raw?.slowRequestThresholdMs;
171
- return threshold === void 0 ? { enabled } : { enabled, slowRequestThresholdMs: threshold };
189
+ return threshold === void 0 ? { enabled } : {
190
+ enabled,
191
+ slowRequestThresholdMs: positiveSafeInteger("timing.slowRequestThresholdMs", threshold)
192
+ };
172
193
  }
173
194
  var DEFAULT_MAX_BODY_DEPTH = 32;
174
195
  function resolveBodyDepth(raw) {
175
- const maxDepth = raw?.maxDepth ?? DEFAULT_MAX_BODY_DEPTH;
176
- if (!Number.isSafeInteger(maxDepth) || maxDepth < 1) {
177
- throw new Error(
178
- `bodyDepth.maxDepth must be a positive safe integer, received ${String(maxDepth)}`
179
- );
180
- }
196
+ const maxDepth = positiveSafeInteger(
197
+ "bodyDepth.maxDepth",
198
+ raw?.maxDepth ?? DEFAULT_MAX_BODY_DEPTH
199
+ );
181
200
  return { enabled: raw?.enabled ?? false, maxDepth };
182
201
  }
183
202
  function resolveHealth(raw) {
203
+ const readinessCacheMs = raw?.readinessCacheMs ?? DEFAULT_READINESS_CACHE_MS;
204
+ if (!Number.isSafeInteger(readinessCacheMs) || readinessCacheMs < 0) {
205
+ throw new Error(
206
+ `health.readinessCacheMs must be a non-negative safe integer, received ${String(readinessCacheMs)}`
207
+ );
208
+ }
184
209
  return {
210
+ readinessCacheMs,
185
211
  enabled: raw?.enabled ?? true,
186
212
  path: raw?.path ?? DEFAULT_HEALTH_PATH,
187
213
  exposeIndicatorErrors: raw?.exposeIndicatorErrors ?? false,
188
- indicatorTimeoutMs: raw?.indicatorTimeoutMs ?? DEFAULT_INDICATOR_TIMEOUT_MS,
214
+ indicatorTimeoutMs: positiveSafeInteger(
215
+ "health.indicatorTimeoutMs",
216
+ raw?.indicatorTimeoutMs ?? DEFAULT_INDICATOR_TIMEOUT_MS,
217
+ MAX_TIMER_DELAY_MS
218
+ ),
189
219
  autoDiscover: raw?.autoDiscover ?? false
190
220
  };
191
221
  }
@@ -844,10 +874,12 @@ function summarizeRejection(reason) {
844
874
  }
845
875
  return `${message.slice(0, MAX_ERROR_MESSAGE_LENGTH - TRUNCATION_ELLIPSIS.length)}${TRUNCATION_ELLIPSIS}`;
846
876
  }
847
- async function runIndicator(indicator, timeoutMs, exposeErrors) {
877
+ function startTimeout(indicator, timeoutMs) {
878
+ const controller = new AbortController();
848
879
  let timer;
849
880
  const timedOut = new Promise((resolve) => {
850
881
  timer = setTimeout(() => {
882
+ controller.abort();
851
883
  resolve({
852
884
  entry: { name: indicator.name, status: "down", details: { timedOutAfterMs: timeoutMs } },
853
885
  isUp: false,
@@ -855,8 +887,12 @@ async function runIndicator(indicator, timeoutMs, exposeErrors) {
855
887
  });
856
888
  }, timeoutMs);
857
889
  });
858
- const checked = Promise.resolve().then(() => indicator.check()).then((result) => {
859
- const entry = { ...result, name: indicator.name };
890
+ return { signal: controller.signal, timedOut, clear: () => clearTimeout(timer) };
891
+ }
892
+ async function runIndicator(indicator, timeoutMs, exposeErrors) {
893
+ const { signal, timedOut, clear } = startTimeout(indicator, timeoutMs);
894
+ const checked = Promise.resolve().then(() => indicator.check(signal)).then((result) => {
895
+ const entry = exposeErrors ? { ...result, name: indicator.name } : { name: indicator.name, status: result.status };
860
896
  return result.status === "up" ? { entry, isUp: true } : { entry, isUp: false, cause: { kind: "reported-down" } };
861
897
  }).catch((reason) => {
862
898
  const message = summarizeRejection(reason);
@@ -869,7 +905,7 @@ async function runIndicator(indicator, timeoutMs, exposeErrors) {
869
905
  try {
870
906
  return await Promise.race([checked, timedOut]);
871
907
  } finally {
872
- clearTimeout(timer);
908
+ clear();
873
909
  }
874
910
  }
875
911
  function describeCause(cause) {
@@ -929,6 +965,8 @@ var HealthService = class {
929
965
  * Comparing states alone would then let the stale observation win.
930
966
  */
931
967
  this.probeSequence = 0;
968
+ /** Set once shutdown begins, after which readiness answers not ready without running anything. */
969
+ this.draining = false;
932
970
  }
933
971
  /**
934
972
  * Report one check's current state, emitting only when it differs from the
@@ -1101,6 +1139,14 @@ var HealthService = class {
1101
1139
  );
1102
1140
  return this.effectiveIndicators;
1103
1141
  }
1142
+ /**
1143
+ * Stop reporting ready the moment shutdown begins, so a load balancer stops routing here while
1144
+ * the process drains. Nothing cached or in progress is consulted again: every later probe is answered
1145
+ * before either is read.
1146
+ */
1147
+ beforeApplicationShutdown() {
1148
+ this.draining = true;
1149
+ }
1104
1150
  /**
1105
1151
  * Liveness check: the process is up and able to respond. Runs no
1106
1152
  * indicators, so it never depends on the health of anything else.
@@ -1115,9 +1161,33 @@ var HealthService = class {
1115
1161
  * aggregate the results. `status` is `'ok'` only when every indicator
1116
1162
  * reports `up`; an empty indicator list is vacuously `'ok'`.
1117
1163
  *
1164
+ * Probes share the work: one that arrives while a run is in progress awaits it, and for
1165
+ * `health.readinessCacheMs` after it ends the answer is returned without running anything.
1166
+ * The endpoint is usually unauthenticated, so probe volume must not set dependency volume.
1167
+ * Once shutdown has begun the answer is always not ready. The returned object is shared between
1168
+ * callers and must not be mutated.
1169
+ *
1118
1170
  * @returns The aggregated health response.
1119
1171
  */
1120
- async checkReadiness() {
1172
+ checkReadiness() {
1173
+ if (this.draining) {
1174
+ return Promise.resolve({ status: "error", checks: [{ name: "shutdown", status: "down" }] });
1175
+ }
1176
+ const cached = this.cached;
1177
+ if (cached !== void 0 && performance.now() - cached.at < this.options.health.readinessCacheMs) {
1178
+ return Promise.resolve(cached.response);
1179
+ }
1180
+ this.inflight ??= this.runReadiness().finally(() => {
1181
+ this.inflight = void 0;
1182
+ });
1183
+ return this.inflight;
1184
+ }
1185
+ /**
1186
+ * Run every indicator once and record the answer.
1187
+ *
1188
+ * @returns The aggregated health response.
1189
+ */
1190
+ async runReadiness() {
1121
1191
  const timeoutMs = this.options.health.indicatorTimeoutMs;
1122
1192
  const exposeErrors = this.options.health.exposeIndicatorErrors;
1123
1193
  const seq = ++this.probeSequence;
@@ -1129,7 +1199,9 @@ var HealthService = class {
1129
1199
  }
1130
1200
  const checks = outcomes.map((outcome) => outcome.entry);
1131
1201
  const status = checks.every((check) => check.status === "up") ? "ok" : "error";
1132
- return { status, checks };
1202
+ const response = { status, checks };
1203
+ this.cached = { at: performance.now(), response };
1204
+ return response;
1133
1205
  }
1134
1206
  };
1135
1207
  HealthService = __decorateClass([
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bymax-one/nest-core",
3
- "version": "1.6.7",
3
+ "version": "1.6.8",
4
4
  "description": "Zero-dependency NestJS 11 application foundation kit: error-envelope exception filter, request-timing middleware, pagination helpers, health endpoints with indicator discovery, an optional Prometheus metrics endpoint with a contribution contract, OpenAPI documents in development, and OpenTelemetry trace correlation.",
5
5
  "author": "Bymax One <support@bymax.one>",
6
6
  "license": "MIT",