@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.
- package/CHANGELOG.md +84 -1
- package/README.md +474 -901
- package/dist/health/index.d.cts +6 -3
- package/dist/health/index.d.ts +6 -3
- package/dist/http/index.cjs +16 -7
- package/dist/http/index.d.cts +1 -1
- package/dist/http/index.d.ts +1 -1
- package/dist/http/index.mjs +16 -7
- package/dist/index.cjs +87 -15
- package/dist/index.d.cts +18 -7
- package/dist/index.d.ts +18 -7
- package/dist/index.mjs +87 -15
- package/package.json +1 -1
package/dist/health/index.d.cts
CHANGED
|
@@ -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
|
|
53
|
-
*
|
|
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,
|
package/dist/health/index.d.ts
CHANGED
|
@@ -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
|
|
53
|
-
*
|
|
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,
|
package/dist/http/index.cjs
CHANGED
|
@@ -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
|
-
|
|
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();
|
package/dist/http/index.d.cts
CHANGED
|
@@ -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
|
package/dist/http/index.d.ts
CHANGED
|
@@ -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
|
package/dist/http/index.mjs
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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 } : {
|
|
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 =
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
861
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
66
|
-
* `details.error
|
|
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
|
|
72
|
-
* nothing else
|
|
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
|
-
*
|
|
66
|
-
* `details.error
|
|
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
|
|
72
|
-
* nothing else
|
|
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 (
|
|
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 } : {
|
|
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 =
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
859
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|