@bymax-one/nest-core 1.3.1 → 1.4.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/CHANGELOG.md +251 -1
- package/README.md +418 -48
- package/dist/index.cjs +344 -163
- package/dist/index.d.cts +327 -18
- package/dist/index.d.ts +327 -18
- package/dist/index.mjs +345 -165
- package/dist/openapi/index.cjs +401 -15
- package/dist/openapi/index.d.cts +159 -4
- package/dist/openapi/index.d.ts +159 -4
- package/dist/openapi/index.mjs +400 -17
- package/package.json +3 -1
package/dist/index.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Catch, Inject, Optional, Injectable, ConfigurableModuleBuilder, Module, HttpException, Logger, Get, Res, Controller, Req, HttpStatus, UnauthorizedException, NotFoundException } from '@nestjs/common';
|
|
2
|
-
import { HttpAdapterHost, DiscoveryService, Reflector, BaseExceptionFilter, APP_FILTER,
|
|
3
|
-
import { tap, catchError, throwError } from 'rxjs';
|
|
2
|
+
import { HttpAdapterHost, DiscoveryService, Reflector, BaseExceptionFilter, APP_FILTER, DiscoveryModule } from '@nestjs/core';
|
|
4
3
|
import { createHash, timingSafeEqual } from 'crypto';
|
|
4
|
+
import { AsyncResource } from 'async_hooks';
|
|
5
|
+
import { tap, catchError, throwError } from 'rxjs';
|
|
5
6
|
|
|
6
7
|
var __defProp = Object.defineProperty;
|
|
7
8
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
@@ -15,6 +16,10 @@ var __decorateClass = (decorators, target, key, kind) => {
|
|
|
15
16
|
};
|
|
16
17
|
var __decorateParam = (index, decorator) => (target, key) => decorator(target, key, index);
|
|
17
18
|
|
|
19
|
+
// src/route-defaults.ts
|
|
20
|
+
var DEFAULT_HEALTH_PATH = "health";
|
|
21
|
+
var DEFAULT_METRICS_PATH = "metrics";
|
|
22
|
+
|
|
18
23
|
// src/runtime.environment.ts
|
|
19
24
|
var NON_PRODUCTION_ENVIRONMENTS = /* @__PURE__ */ new Set(["development", "test"]);
|
|
20
25
|
function isProductionRuntime(value = process.env["NODE_ENV"]) {
|
|
@@ -25,9 +30,7 @@ function isProductionRuntime(value = process.env["NODE_ENV"]) {
|
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
// src/core.options.ts
|
|
28
|
-
var DEFAULT_HEALTH_PATH = "health";
|
|
29
33
|
var DEFAULT_INDICATOR_TIMEOUT_MS = 5e3;
|
|
30
|
-
var DEFAULT_METRICS_PATH = "metrics";
|
|
31
34
|
var DEFAULT_OPENAPI_PATH = "docs";
|
|
32
35
|
var DEFAULT_OPENAPI_JSON_PATH = "docs-json";
|
|
33
36
|
var DEFAULT_OPENAPI_TITLE = "API";
|
|
@@ -103,6 +106,14 @@ function resolveOpenApi(raw) {
|
|
|
103
106
|
// consumer-owned nested objects, and the deep-freeze below would otherwise
|
|
104
107
|
// reach into them.
|
|
105
108
|
securitySchemes: structuredClone(raw?.securitySchemes ?? {}),
|
|
109
|
+
// Cloned for the same reason as the schemes above: both are consumer-owned
|
|
110
|
+
// nested structures, and the deep-freeze applied to the snapshot would
|
|
111
|
+
// otherwise reach into objects the consumer still holds a reference to.
|
|
112
|
+
security: structuredClone(raw?.security ?? []),
|
|
113
|
+
operationSecurity: structuredClone(raw?.operationSecurity ?? {}),
|
|
114
|
+
// Carried by reference, not cloned: it is a function the consumer owns, and
|
|
115
|
+
// `structuredClone` cannot copy one at all.
|
|
116
|
+
...raw?.operationIdFactory === void 0 ? {} : { operationIdFactory: raw.operationIdFactory },
|
|
106
117
|
includeCoreSchemas: raw?.includeCoreSchemas ?? true
|
|
107
118
|
};
|
|
108
119
|
}
|
|
@@ -583,141 +594,6 @@ BymaxExceptionFilter = __decorateClass([
|
|
|
583
594
|
__decorateParam(3, Optional()),
|
|
584
595
|
__decorateParam(3, Inject(BYMAX_TRACE_CONTEXT))
|
|
585
596
|
], BymaxExceptionFilter);
|
|
586
|
-
|
|
587
|
-
// src/timing/request-info.accessor.ts
|
|
588
|
-
function stripQueryString(rawUrl) {
|
|
589
|
-
const queryIndex = rawUrl.indexOf("?");
|
|
590
|
-
return queryIndex === -1 ? rawUrl : rawUrl.slice(0, queryIndex);
|
|
591
|
-
}
|
|
592
|
-
function readExpressTemplate(request) {
|
|
593
|
-
const path = request.route?.path;
|
|
594
|
-
return path === void 0 ? void 0 : `${request.baseUrl ?? ""}${path}`;
|
|
595
|
-
}
|
|
596
|
-
function readFastifyTemplate(request) {
|
|
597
|
-
return request.routeOptions?.url;
|
|
598
|
-
}
|
|
599
|
-
function extractRequestInfo(context) {
|
|
600
|
-
const request = context.switchToHttp().getRequest();
|
|
601
|
-
const template = readExpressTemplate(request) ?? readFastifyTemplate(request);
|
|
602
|
-
const rawUrl = request.originalUrl ?? request.url ?? "";
|
|
603
|
-
return { method: request.method ?? "", route: template ?? stripQueryString(rawUrl) };
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
// src/timing/timing.interceptor.ts
|
|
607
|
-
var DEFAULT_SUCCESS_STATUS = 200;
|
|
608
|
-
var UNKNOWN_ERROR_STATUS = 500;
|
|
609
|
-
var TimingInterceptor = class {
|
|
610
|
-
/**
|
|
611
|
-
* @param options - Resolved core options; supplies `slowRequestThresholdMs`.
|
|
612
|
-
* @param sink - The bound timing sink; its `record` failures are swallowed.
|
|
613
|
-
* Injected with `@Optional()`: `BymaxCoreModule` binds no local default for
|
|
614
|
-
* this token on the sync path when the metrics bridge is not registered, so
|
|
615
|
-
* a consumer's own `BYMAX_TIMING_SINK` binding is not shadowed by one; when
|
|
616
|
-
* nothing resolves, this falls back to a no-op sink.
|
|
617
|
-
* @param clock - Monotonic clock seam; defaults to `performance.now()`, and
|
|
618
|
-
* is bound explicitly through {@link BYMAX_TIMING_CLOCK} so tests inject a
|
|
619
|
-
* stub advancing by controlled amounts.
|
|
620
|
-
* @param traceContext - Reads the active span's identifiers. Injected with
|
|
621
|
-
* `@Optional()` so this interceptor stays constructible on its own; when
|
|
622
|
-
* nothing resolves, a no-op resolves no trace and the sample simply omits
|
|
623
|
-
* the fields.
|
|
624
|
-
*/
|
|
625
|
-
constructor(options, sink, clock = DEFAULT_MONOTONIC_CLOCK, traceContext) {
|
|
626
|
-
this.options = options;
|
|
627
|
-
this.clock = clock;
|
|
628
|
-
this.sink = sink ?? new NoopTimingSink();
|
|
629
|
-
this.traceContext = traceContext ?? new NoopTraceContextProvider();
|
|
630
|
-
}
|
|
631
|
-
/**
|
|
632
|
-
* Measure the handler chain and record exactly one sample per completed
|
|
633
|
-
* request, on the success path and on the error path alike.
|
|
634
|
-
*
|
|
635
|
-
* @param context - The execution context of the current request.
|
|
636
|
-
* @param next - The next handler in the chain.
|
|
637
|
-
* @returns The downstream response stream, unmodified beyond the measurement.
|
|
638
|
-
*/
|
|
639
|
-
intercept(context, next) {
|
|
640
|
-
if (context.getType() !== "http") {
|
|
641
|
-
return next.handle();
|
|
642
|
-
}
|
|
643
|
-
const start = this.clock.now();
|
|
644
|
-
const { method, route } = extractRequestInfo(context);
|
|
645
|
-
return next.handle().pipe(
|
|
646
|
-
tap({
|
|
647
|
-
complete: () => {
|
|
648
|
-
this.recordSample(method, route, this.readSuccessStatus(context), start);
|
|
649
|
-
}
|
|
650
|
-
}),
|
|
651
|
-
catchError((error) => {
|
|
652
|
-
this.recordSample(method, route, this.readErrorStatus(error), start);
|
|
653
|
-
return throwError(() => error);
|
|
654
|
-
})
|
|
655
|
-
);
|
|
656
|
-
}
|
|
657
|
-
/**
|
|
658
|
-
* Read the final status code from the response object on the success path.
|
|
659
|
-
*
|
|
660
|
-
* @param context - The execution context of the current request.
|
|
661
|
-
* @returns The response's status code, or the default success status when absent.
|
|
662
|
-
*/
|
|
663
|
-
readSuccessStatus(context) {
|
|
664
|
-
const response = context.switchToHttp().getResponse();
|
|
665
|
-
return response.statusCode ?? DEFAULT_SUCCESS_STATUS;
|
|
666
|
-
}
|
|
667
|
-
/**
|
|
668
|
-
* Derive the final status code for an error that escaped the handler.
|
|
669
|
-
*
|
|
670
|
-
* @param error - The error propagated by the handler chain.
|
|
671
|
-
* @returns The `HttpException` status, or the generic 500 for anything else.
|
|
672
|
-
*/
|
|
673
|
-
readErrorStatus(error) {
|
|
674
|
-
return error instanceof HttpException ? error.getStatus() : UNKNOWN_ERROR_STATUS;
|
|
675
|
-
}
|
|
676
|
-
/**
|
|
677
|
-
* Build the sample, compute the slow flag, and deliver it to the sink inside
|
|
678
|
-
* a try/catch that silences any failure: a throwing sink must never affect
|
|
679
|
-
* the request it is observing.
|
|
680
|
-
*
|
|
681
|
-
* @param method - HTTP method of the request.
|
|
682
|
-
* @param route - Route template of the request.
|
|
683
|
-
* @param statusCode - Final status code, success or error.
|
|
684
|
-
* @param start - Monotonic start timestamp captured before the handler ran.
|
|
685
|
-
*/
|
|
686
|
-
recordSample(method, route, statusCode, start) {
|
|
687
|
-
const durationMs = this.clock.now() - start;
|
|
688
|
-
const threshold = this.options.timing.slowRequestThresholdMs;
|
|
689
|
-
const slow = threshold !== void 0 && durationMs > threshold;
|
|
690
|
-
let trace;
|
|
691
|
-
try {
|
|
692
|
-
trace = this.traceContext.getTraceContext();
|
|
693
|
-
} catch {
|
|
694
|
-
}
|
|
695
|
-
try {
|
|
696
|
-
this.sink.record({
|
|
697
|
-
method,
|
|
698
|
-
route,
|
|
699
|
-
statusCode,
|
|
700
|
-
durationMs,
|
|
701
|
-
slow,
|
|
702
|
-
// Spread rather than assigned: an absent trace must leave the keys off
|
|
703
|
-
// the sample entirely, so a sink cannot mistake `undefined` for an id.
|
|
704
|
-
...trace !== void 0 ? { traceId: trace.traceId, spanId: trace.spanId } : {}
|
|
705
|
-
});
|
|
706
|
-
} catch {
|
|
707
|
-
}
|
|
708
|
-
}
|
|
709
|
-
};
|
|
710
|
-
TimingInterceptor = __decorateClass([
|
|
711
|
-
Injectable(),
|
|
712
|
-
__decorateParam(0, Inject(BYMAX_CORE_OPTIONS)),
|
|
713
|
-
__decorateParam(1, Optional()),
|
|
714
|
-
__decorateParam(1, Inject(BYMAX_TIMING_SINK)),
|
|
715
|
-
__decorateParam(2, Inject(BYMAX_TIMING_CLOCK)),
|
|
716
|
-
__decorateParam(3, Optional()),
|
|
717
|
-
__decorateParam(3, Inject(BYMAX_TRACE_CONTEXT))
|
|
718
|
-
], TimingInterceptor);
|
|
719
|
-
|
|
720
|
-
// src/passthrough.providers.ts
|
|
721
597
|
var PassThroughExceptionFilter = class {
|
|
722
598
|
constructor(adapterHost) {
|
|
723
599
|
this.adapterHost = adapterHost;
|
|
@@ -736,18 +612,6 @@ var PassThroughExceptionFilter = class {
|
|
|
736
612
|
PassThroughExceptionFilter = __decorateClass([
|
|
737
613
|
Catch()
|
|
738
614
|
], PassThroughExceptionFilter);
|
|
739
|
-
var PassThroughInterceptor = class {
|
|
740
|
-
/**
|
|
741
|
-
* Forward the request to the next handler unchanged.
|
|
742
|
-
*
|
|
743
|
-
* @param _context - The execution context; unused by a transparent forwarder.
|
|
744
|
-
* @param next - The next handler in the chain.
|
|
745
|
-
* @returns The downstream response stream, unmodified.
|
|
746
|
-
*/
|
|
747
|
-
intercept(_context, next) {
|
|
748
|
-
return next.handle();
|
|
749
|
-
}
|
|
750
|
-
};
|
|
751
615
|
function assertAsyncFeatureEnabled(feature, enabled) {
|
|
752
616
|
if (!enabled) {
|
|
753
617
|
throw new NotFoundException(
|
|
@@ -758,9 +622,6 @@ function assertAsyncFeatureEnabled(feature, enabled) {
|
|
|
758
622
|
function selectAsyncExceptionFilter(options, correlation, adapterHost) {
|
|
759
623
|
return options.envelope.enabled ? new BymaxExceptionFilter(options, correlation, adapterHost) : new PassThroughExceptionFilter(adapterHost);
|
|
760
624
|
}
|
|
761
|
-
function selectAsyncTimingInterceptor(options, sink, clock) {
|
|
762
|
-
return options.timing.enabled ? new TimingInterceptor(options, sink, clock) : new PassThroughInterceptor();
|
|
763
|
-
}
|
|
764
625
|
|
|
765
626
|
// src/discovery.ts
|
|
766
627
|
function labelFor(className, token) {
|
|
@@ -1216,12 +1077,12 @@ var TimingMetricsSink = class {
|
|
|
1216
1077
|
this.histogram = getOrCreateHistogram(promClient, registry);
|
|
1217
1078
|
}
|
|
1218
1079
|
/**
|
|
1219
|
-
* Record one
|
|
1080
|
+
* Record one closed request: increment the counter once and observe the
|
|
1220
1081
|
* duration in seconds, both under the bounded label set. Any failure is
|
|
1221
1082
|
* swallowed so a metrics backend problem can never break the request being
|
|
1222
1083
|
* observed.
|
|
1223
1084
|
*
|
|
1224
|
-
* @param sample - The timing sample for a
|
|
1085
|
+
* @param sample - The timing sample for a closed request, however it ended.
|
|
1225
1086
|
*/
|
|
1226
1087
|
record(sample) {
|
|
1227
1088
|
const labels = {
|
|
@@ -1273,6 +1134,148 @@ function buildMetricsTimingSinkProvider() {
|
|
|
1273
1134
|
};
|
|
1274
1135
|
}
|
|
1275
1136
|
|
|
1137
|
+
// src/timing/fastify-route.bridge.ts
|
|
1138
|
+
function bridgeFastifyRouteMetadata(adapter) {
|
|
1139
|
+
if (adapter?.getType?.() !== "fastify") {
|
|
1140
|
+
return false;
|
|
1141
|
+
}
|
|
1142
|
+
const instance = adapter.getInstance?.();
|
|
1143
|
+
if (!isFastifyInstance(instance)) {
|
|
1144
|
+
return false;
|
|
1145
|
+
}
|
|
1146
|
+
instance.addHook("onRequest", (request, _reply, done) => {
|
|
1147
|
+
if (request.raw !== void 0 && request.routeOptions !== void 0) {
|
|
1148
|
+
request.raw["routeOptions"] = request.routeOptions;
|
|
1149
|
+
}
|
|
1150
|
+
done();
|
|
1151
|
+
});
|
|
1152
|
+
return true;
|
|
1153
|
+
}
|
|
1154
|
+
function isFastifyInstance(instance) {
|
|
1155
|
+
return typeof instance?.addHook === "function";
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// src/timing/request-info.accessor.ts
|
|
1159
|
+
var UNMATCHED_ROUTE = "<unmatched>";
|
|
1160
|
+
function readExpressTemplate(request) {
|
|
1161
|
+
const path = request.route?.path;
|
|
1162
|
+
return path === void 0 ? void 0 : `${request.baseUrl ?? ""}${path}`;
|
|
1163
|
+
}
|
|
1164
|
+
function readFastifyTemplate(request) {
|
|
1165
|
+
return request.routeOptions?.url;
|
|
1166
|
+
}
|
|
1167
|
+
function readRequestInfo(request) {
|
|
1168
|
+
const template = readExpressTemplate(request) ?? readFastifyTemplate(request);
|
|
1169
|
+
return { method: request.method ?? "", route: template ?? UNMATCHED_ROUTE };
|
|
1170
|
+
}
|
|
1171
|
+
function extractRequestInfo(context) {
|
|
1172
|
+
return readRequestInfo(context.switchToHttp().getRequest());
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// src/timing/timing.sample.ts
|
|
1176
|
+
function readTraceContext(traceContext) {
|
|
1177
|
+
try {
|
|
1178
|
+
return traceContext.getTraceContext();
|
|
1179
|
+
} catch {
|
|
1180
|
+
return void 0;
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
function buildTimingSample(input) {
|
|
1184
|
+
const { method, route, statusCode, durationMs, threshold, trace } = input;
|
|
1185
|
+
return {
|
|
1186
|
+
method,
|
|
1187
|
+
route,
|
|
1188
|
+
statusCode,
|
|
1189
|
+
durationMs,
|
|
1190
|
+
slow: threshold !== void 0 && durationMs > threshold,
|
|
1191
|
+
// Spread rather than assigned: an absent trace must leave the keys off the
|
|
1192
|
+
// sample entirely, so a sink cannot mistake `undefined` for an id.
|
|
1193
|
+
...trace !== void 0 ? { traceId: trace.traceId, spanId: trace.spanId } : {}
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// src/timing/timing.middleware.ts
|
|
1198
|
+
var BymaxTimingMiddleware = class {
|
|
1199
|
+
/**
|
|
1200
|
+
* @param options - The resolved options, read for the slow-request threshold.
|
|
1201
|
+
* @param sink - The bound timing sink; its failures are swallowed, because a
|
|
1202
|
+
* throwing sink must never affect the request it is observing.
|
|
1203
|
+
* @param clock - Monotonic clock seam, so a test advances time by controlled
|
|
1204
|
+
* amounts instead of sleeping.
|
|
1205
|
+
* @param traceContext - Reads the active span's identifiers. Optional: the
|
|
1206
|
+
* middleware stays constructible on its own, and a no-op resolves nothing.
|
|
1207
|
+
*/
|
|
1208
|
+
constructor(options, sink, clock = DEFAULT_MONOTONIC_CLOCK, traceContext) {
|
|
1209
|
+
this.options = options;
|
|
1210
|
+
this.clock = clock;
|
|
1211
|
+
this.sink = sink ?? new NoopTimingSink();
|
|
1212
|
+
this.traceContext = traceContext ?? new NoopTraceContextProvider();
|
|
1213
|
+
}
|
|
1214
|
+
/**
|
|
1215
|
+
* Start the measurement and arrange for the sample to be recorded once the
|
|
1216
|
+
* connection closes, then hand the request straight on.
|
|
1217
|
+
*
|
|
1218
|
+
* The route is read inside the listener rather than here: at middleware time
|
|
1219
|
+
* the router has not matched yet, so `req.route` is still empty. By the time
|
|
1220
|
+
* the connection closes it is populated — including for a request a guard
|
|
1221
|
+
* rejected, since matching happens before guards run.
|
|
1222
|
+
*
|
|
1223
|
+
* The trace lookup is tried in the live context first and in this moment's
|
|
1224
|
+
* captured context second; see this file's header for the measurements that
|
|
1225
|
+
* decided that order.
|
|
1226
|
+
*
|
|
1227
|
+
* @param request - The framework request object.
|
|
1228
|
+
* @param response - The framework response object.
|
|
1229
|
+
* @param next - Continues the chain; called synchronously and unconditionally.
|
|
1230
|
+
*/
|
|
1231
|
+
use(request, response, next) {
|
|
1232
|
+
const start = this.clock.now();
|
|
1233
|
+
const readCapturedTrace = AsyncResource.bind(() => readTraceContext(this.traceContext));
|
|
1234
|
+
response.on("close", () => {
|
|
1235
|
+
this.record(
|
|
1236
|
+
request,
|
|
1237
|
+
response,
|
|
1238
|
+
start,
|
|
1239
|
+
readTraceContext(this.traceContext) ?? readCapturedTrace()
|
|
1240
|
+
);
|
|
1241
|
+
});
|
|
1242
|
+
next();
|
|
1243
|
+
}
|
|
1244
|
+
/**
|
|
1245
|
+
* Build the sample and hand it to the sink, guarding both steps.
|
|
1246
|
+
*
|
|
1247
|
+
* @param request - The framework request object.
|
|
1248
|
+
* @param response - The framework response object.
|
|
1249
|
+
* @param start - Monotonic timestamp captured before the chain ran.
|
|
1250
|
+
* @param trace - The span identifiers already resolved by the caller, which
|
|
1251
|
+
* owns the choice of which context to read them from.
|
|
1252
|
+
*/
|
|
1253
|
+
record(request, response, start, trace) {
|
|
1254
|
+
const { method, route } = readRequestInfo(request);
|
|
1255
|
+
const sample = buildTimingSample({
|
|
1256
|
+
method,
|
|
1257
|
+
route,
|
|
1258
|
+
statusCode: response.statusCode ?? 0,
|
|
1259
|
+
durationMs: this.clock.now() - start,
|
|
1260
|
+
threshold: this.options.timing.slowRequestThresholdMs,
|
|
1261
|
+
trace
|
|
1262
|
+
});
|
|
1263
|
+
try {
|
|
1264
|
+
this.sink.record(sample);
|
|
1265
|
+
} catch {
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
};
|
|
1269
|
+
BymaxTimingMiddleware = __decorateClass([
|
|
1270
|
+
Injectable(),
|
|
1271
|
+
__decorateParam(0, Inject(BYMAX_CORE_OPTIONS)),
|
|
1272
|
+
__decorateParam(1, Optional()),
|
|
1273
|
+
__decorateParam(1, Inject(BYMAX_TIMING_SINK)),
|
|
1274
|
+
__decorateParam(2, Inject(BYMAX_TIMING_CLOCK)),
|
|
1275
|
+
__decorateParam(3, Optional()),
|
|
1276
|
+
__decorateParam(3, Inject(BYMAX_TRACE_CONTEXT))
|
|
1277
|
+
], BymaxTimingMiddleware);
|
|
1278
|
+
|
|
1276
1279
|
// src/core.module.ts
|
|
1277
1280
|
var {
|
|
1278
1281
|
ConfigurableModuleClass: BymaxCoreModuleBase,
|
|
@@ -1291,7 +1294,7 @@ function buildSyncProviders(resolved) {
|
|
|
1291
1294
|
providers.push({ provide: APP_FILTER, useClass: BymaxExceptionFilter });
|
|
1292
1295
|
}
|
|
1293
1296
|
if (resolved.timing.enabled) {
|
|
1294
|
-
providers.push(
|
|
1297
|
+
providers.push(BymaxTimingMiddleware);
|
|
1295
1298
|
}
|
|
1296
1299
|
if (resolved.health.enabled) {
|
|
1297
1300
|
providers.push(HealthService);
|
|
@@ -1328,11 +1331,6 @@ function buildAsyncSlots() {
|
|
|
1328
1331
|
{ token: BYMAX_CORRELATION_PROVIDER, optional: true },
|
|
1329
1332
|
HttpAdapterHost
|
|
1330
1333
|
]
|
|
1331
|
-
},
|
|
1332
|
-
{
|
|
1333
|
-
provide: APP_INTERCEPTOR,
|
|
1334
|
-
useFactory: (options, sink, clock) => selectAsyncTimingInterceptor(options, sink, clock),
|
|
1335
|
-
inject: [BYMAX_CORE_OPTIONS, BYMAX_TIMING_SINK, BYMAX_TIMING_CLOCK]
|
|
1336
1334
|
}
|
|
1337
1335
|
];
|
|
1338
1336
|
}
|
|
@@ -1347,6 +1345,77 @@ function augmentModule(base, providers, controllers, exportTokens = [BYMAX_CORE_
|
|
|
1347
1345
|
};
|
|
1348
1346
|
}
|
|
1349
1347
|
var BymaxCoreModule = class extends BymaxCoreModuleBase {
|
|
1348
|
+
/**
|
|
1349
|
+
* @param options - The resolved snapshot, read to decide whether the timing
|
|
1350
|
+
* middleware is applied at all.
|
|
1351
|
+
*/
|
|
1352
|
+
constructor(resolved, adapterHost) {
|
|
1353
|
+
super();
|
|
1354
|
+
this.resolved = resolved;
|
|
1355
|
+
this.adapterHost = adapterHost;
|
|
1356
|
+
}
|
|
1357
|
+
/**
|
|
1358
|
+
* Apply the request-timing middleware to every route.
|
|
1359
|
+
*
|
|
1360
|
+
* Middleware rather than the interceptor this replaced, because guards run
|
|
1361
|
+
* before interceptors: a request rejected by an authentication, authorization
|
|
1362
|
+
* or throttling guard never reached the interceptor, and one matching no
|
|
1363
|
+
* route never reached a controller at all. A deployment could therefore be
|
|
1364
|
+
* under a credential-stuffing run — a flood of 401s — with a flat error
|
|
1365
|
+
* graph, which is why this is a security fix rather than a metrics
|
|
1366
|
+
* improvement.
|
|
1367
|
+
*
|
|
1368
|
+
* Applied on both registration paths, and only when timing is enabled: the
|
|
1369
|
+
* middleware is the sole recorder now, since a second one would count every
|
|
1370
|
+
* matched request twice.
|
|
1371
|
+
*
|
|
1372
|
+
* There is no single pattern that covers both adapters, which is the whole
|
|
1373
|
+
* reason this reads the adapter first. Measured, requesting the root, a
|
|
1374
|
+
* parameterised route, a nested path and an unmatched path, with and without
|
|
1375
|
+
* `setGlobalPrefix('api')`:
|
|
1376
|
+
*
|
|
1377
|
+
* | `forRoutes(...)` | Express | Fastify |
|
|
1378
|
+
* | ---------------- | ---------------- | -------------------- |
|
|
1379
|
+
* | `'*splat'` | skips the root | — |
|
|
1380
|
+
* | `'{*splat}'` | skips `/api` | every path |
|
|
1381
|
+
* | `'/'` | every path | matches `/` only |
|
|
1382
|
+
*
|
|
1383
|
+
* On Express `'/'` is a mount and matches everything beneath whatever prefix
|
|
1384
|
+
* it is scoped to, while `'{*splat}'` — the form Nest 11's migration guide
|
|
1385
|
+
* prescribes for "all routes" — stops matching the prefixed root once an
|
|
1386
|
+
* application calls `setGlobalPrefix`. That was reported as nest#14520 and
|
|
1387
|
+
* fixed by nest#14522, whose regression test covers Fastify; on
|
|
1388
|
+
* `@nestjs/core` 11.1.28 with the Express adapter the prefixed root still
|
|
1389
|
+
* reaches no middleware while resolving to `200`.
|
|
1390
|
+
*
|
|
1391
|
+
* On Fastify the same `'/'` is an exact match rather than a mount — one of
|
|
1392
|
+
* three requests reached the middleware — so the wildcard is the only form
|
|
1393
|
+
* that works there.
|
|
1394
|
+
*
|
|
1395
|
+
* A recorder that quietly omits one route is the same class of defect this
|
|
1396
|
+
* middleware exists to fix, so each adapter gets the form that omits none.
|
|
1397
|
+
*
|
|
1398
|
+
* Fastify also needs {@link bridgeFastifyRouteMetadata}, because middie hands
|
|
1399
|
+
* middleware the raw request, which carries no route metadata at all; see
|
|
1400
|
+
* that file for why the label would otherwise be `<unmatched>` for every
|
|
1401
|
+
* request.
|
|
1402
|
+
*
|
|
1403
|
+
* One limit stays and is documented in the README: Nest scopes module
|
|
1404
|
+
* middleware to the global prefix, so with `setGlobalPrefix('api')` a request
|
|
1405
|
+
* to `/nope` — outside the prefix entirely — reaches no middleware and is not
|
|
1406
|
+
* recorded, while `/api/nope` is. No `forRoutes` argument changes that, and
|
|
1407
|
+
* nothing this module can register reaches outside its own scope.
|
|
1408
|
+
*
|
|
1409
|
+
* @param consumer - Nest's middleware consumer.
|
|
1410
|
+
*/
|
|
1411
|
+
configure(consumer) {
|
|
1412
|
+
if (!this.resolved.timing.enabled) {
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
const adapter = this.adapterHost?.httpAdapter;
|
|
1416
|
+
const onFastify = bridgeFastifyRouteMetadata(adapter);
|
|
1417
|
+
consumer.apply(BymaxTimingMiddleware).forRoutes(onFastify ? "{*splat}" : "/");
|
|
1418
|
+
}
|
|
1350
1419
|
/**
|
|
1351
1420
|
* Register the module synchronously. Options are known now, so disabled
|
|
1352
1421
|
* features are omitted from the providers and controllers arrays and the
|
|
@@ -1372,7 +1441,7 @@ var BymaxCoreModule = class extends BymaxCoreModuleBase {
|
|
|
1372
1441
|
exportTokens.push(BYMAX_TIMING_SINK);
|
|
1373
1442
|
}
|
|
1374
1443
|
}
|
|
1375
|
-
const scansProviders = resolved.health.enabled && resolved.health.autoDiscover || resolved.metrics.enabled;
|
|
1444
|
+
const scansProviders = resolved.health.enabled && resolved.health.autoDiscover || resolved.metrics.enabled || resolved.openapi.enabled;
|
|
1376
1445
|
const imports = scansProviders ? [DiscoveryModule] : [];
|
|
1377
1446
|
return augmentModule(
|
|
1378
1447
|
super.forRoot(options),
|
|
@@ -1411,6 +1480,11 @@ var BymaxCoreModule = class extends BymaxCoreModuleBase {
|
|
|
1411
1480
|
},
|
|
1412
1481
|
...buildDefaultProviders(),
|
|
1413
1482
|
...buildAsyncSlots(),
|
|
1483
|
+
// Registered unconditionally, applied conditionally: the class must be
|
|
1484
|
+
// resolvable before `configure` can apply it, and whether timing is
|
|
1485
|
+
// enabled is unknown until the consumer's factory has run. An unapplied
|
|
1486
|
+
// middleware provider costs one construction and observes nothing.
|
|
1487
|
+
BymaxTimingMiddleware,
|
|
1414
1488
|
HealthService,
|
|
1415
1489
|
buildMetricsRegistryProvider(),
|
|
1416
1490
|
buildMetricsTimingSinkProvider(),
|
|
@@ -1427,7 +1501,113 @@ var BymaxCoreModule = class extends BymaxCoreModuleBase {
|
|
|
1427
1501
|
}
|
|
1428
1502
|
};
|
|
1429
1503
|
BymaxCoreModule = __decorateClass([
|
|
1430
|
-
Module({})
|
|
1504
|
+
Module({}),
|
|
1505
|
+
__decorateParam(0, Inject(BYMAX_CORE_OPTIONS)),
|
|
1506
|
+
__decorateParam(1, Optional()),
|
|
1507
|
+
__decorateParam(1, Inject(HttpAdapterHost))
|
|
1431
1508
|
], BymaxCoreModule);
|
|
1509
|
+
var DEFAULT_SUCCESS_STATUS = 200;
|
|
1510
|
+
var UNKNOWN_ERROR_STATUS = 500;
|
|
1511
|
+
var TimingInterceptor = class {
|
|
1512
|
+
/**
|
|
1513
|
+
* @param options - Resolved core options; supplies `slowRequestThresholdMs`.
|
|
1514
|
+
* @param sink - The bound timing sink; its `record` failures are swallowed.
|
|
1515
|
+
* Injected with `@Optional()`: `BymaxCoreModule` binds no local default for
|
|
1516
|
+
* this token on the sync path when the metrics bridge is not registered, so
|
|
1517
|
+
* a consumer's own `BYMAX_TIMING_SINK` binding is not shadowed by one; when
|
|
1518
|
+
* nothing resolves, this falls back to a no-op sink.
|
|
1519
|
+
* @param clock - Monotonic clock seam; defaults to `performance.now()`, and
|
|
1520
|
+
* is bound explicitly through {@link BYMAX_TIMING_CLOCK} so tests inject a
|
|
1521
|
+
* stub advancing by controlled amounts.
|
|
1522
|
+
* @param traceContext - Reads the active span's identifiers. Injected with
|
|
1523
|
+
* `@Optional()` so this interceptor stays constructible on its own; when
|
|
1524
|
+
* nothing resolves, a no-op resolves no trace and the sample simply omits
|
|
1525
|
+
* the fields.
|
|
1526
|
+
*/
|
|
1527
|
+
constructor(options, sink, clock = DEFAULT_MONOTONIC_CLOCK, traceContext) {
|
|
1528
|
+
this.options = options;
|
|
1529
|
+
this.clock = clock;
|
|
1530
|
+
this.sink = sink ?? new NoopTimingSink();
|
|
1531
|
+
this.traceContext = traceContext ?? new NoopTraceContextProvider();
|
|
1532
|
+
}
|
|
1533
|
+
/**
|
|
1534
|
+
* Measure the handler chain and record exactly one sample per completed
|
|
1535
|
+
* request, on the success path and on the error path alike.
|
|
1536
|
+
*
|
|
1537
|
+
* @param context - The execution context of the current request.
|
|
1538
|
+
* @param next - The next handler in the chain.
|
|
1539
|
+
* @returns The downstream response stream, unmodified beyond the measurement.
|
|
1540
|
+
*/
|
|
1541
|
+
intercept(context, next) {
|
|
1542
|
+
if (context.getType() !== "http") {
|
|
1543
|
+
return next.handle();
|
|
1544
|
+
}
|
|
1545
|
+
const start = this.clock.now();
|
|
1546
|
+
const { method, route } = extractRequestInfo(context);
|
|
1547
|
+
return next.handle().pipe(
|
|
1548
|
+
tap({
|
|
1549
|
+
complete: () => {
|
|
1550
|
+
this.recordSample(method, route, this.readSuccessStatus(context), start);
|
|
1551
|
+
}
|
|
1552
|
+
}),
|
|
1553
|
+
catchError((error) => {
|
|
1554
|
+
this.recordSample(method, route, this.readErrorStatus(error), start);
|
|
1555
|
+
return throwError(() => error);
|
|
1556
|
+
})
|
|
1557
|
+
);
|
|
1558
|
+
}
|
|
1559
|
+
/**
|
|
1560
|
+
* Read the final status code from the response object on the success path.
|
|
1561
|
+
*
|
|
1562
|
+
* @param context - The execution context of the current request.
|
|
1563
|
+
* @returns The response's status code, or the default success status when absent.
|
|
1564
|
+
*/
|
|
1565
|
+
readSuccessStatus(context) {
|
|
1566
|
+
const response = context.switchToHttp().getResponse();
|
|
1567
|
+
return response.statusCode ?? DEFAULT_SUCCESS_STATUS;
|
|
1568
|
+
}
|
|
1569
|
+
/**
|
|
1570
|
+
* Derive the final status code for an error that escaped the handler.
|
|
1571
|
+
*
|
|
1572
|
+
* @param error - The error propagated by the handler chain.
|
|
1573
|
+
* @returns The `HttpException` status, or the generic 500 for anything else.
|
|
1574
|
+
*/
|
|
1575
|
+
readErrorStatus(error) {
|
|
1576
|
+
return error instanceof HttpException ? error.getStatus() : UNKNOWN_ERROR_STATUS;
|
|
1577
|
+
}
|
|
1578
|
+
/**
|
|
1579
|
+
* Build the sample and deliver it to the sink inside a try/catch that
|
|
1580
|
+
* silences any failure: a throwing sink must never affect the request it is
|
|
1581
|
+
* observing.
|
|
1582
|
+
*
|
|
1583
|
+
* @param method - HTTP method of the request.
|
|
1584
|
+
* @param route - Route template of the request.
|
|
1585
|
+
* @param statusCode - Final status code, success or error.
|
|
1586
|
+
* @param start - Monotonic start timestamp captured before the handler ran.
|
|
1587
|
+
*/
|
|
1588
|
+
recordSample(method, route, statusCode, start) {
|
|
1589
|
+
const sample = buildTimingSample({
|
|
1590
|
+
method,
|
|
1591
|
+
route,
|
|
1592
|
+
statusCode,
|
|
1593
|
+
durationMs: this.clock.now() - start,
|
|
1594
|
+
threshold: this.options.timing.slowRequestThresholdMs,
|
|
1595
|
+
trace: readTraceContext(this.traceContext)
|
|
1596
|
+
});
|
|
1597
|
+
try {
|
|
1598
|
+
this.sink.record(sample);
|
|
1599
|
+
} catch {
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
};
|
|
1603
|
+
TimingInterceptor = __decorateClass([
|
|
1604
|
+
Injectable(),
|
|
1605
|
+
__decorateParam(0, Inject(BYMAX_CORE_OPTIONS)),
|
|
1606
|
+
__decorateParam(1, Optional()),
|
|
1607
|
+
__decorateParam(1, Inject(BYMAX_TIMING_SINK)),
|
|
1608
|
+
__decorateParam(2, Inject(BYMAX_TIMING_CLOCK)),
|
|
1609
|
+
__decorateParam(3, Optional()),
|
|
1610
|
+
__decorateParam(3, Inject(BYMAX_TRACE_CONTEXT))
|
|
1611
|
+
], TimingInterceptor);
|
|
1432
1612
|
|
|
1433
|
-
export { BYMAX_BAD_GATEWAY, BYMAX_BAD_REQUEST, BYMAX_CLIENT_ERROR, BYMAX_CONFLICT, BYMAX_CORE_OPTIONS, BYMAX_CORRELATION_PROVIDER, BYMAX_FORBIDDEN, BYMAX_GATEWAY_TIMEOUT, BYMAX_HEALTH_INDICATORS, BYMAX_INTERNAL_ERROR, BYMAX_METRICS_REGISTRY, BYMAX_NOT_FOUND, BYMAX_NOT_IMPLEMENTED, BYMAX_PAYLOAD_TOO_LARGE, BYMAX_SERVICE_UNAVAILABLE, BYMAX_TIMING_SINK, BYMAX_TOO_MANY_REQUESTS, BYMAX_TRACE_CONTEXT, BYMAX_UNAUTHORIZED, BYMAX_UNPROCESSABLE_ENTITY, BYMAX_UNSUPPORTED_MEDIA_TYPE, BYMAX_VALIDATION_FAILED, BymaxCoreModule, BymaxExceptionFilter, TimingInterceptor, buildErrorEnvelope, codeForStatus };
|
|
1613
|
+
export { BYMAX_BAD_GATEWAY, BYMAX_BAD_REQUEST, BYMAX_CLIENT_ERROR, BYMAX_CONFLICT, BYMAX_CORE_OPTIONS, BYMAX_CORRELATION_PROVIDER, BYMAX_FORBIDDEN, BYMAX_GATEWAY_TIMEOUT, BYMAX_HEALTH_INDICATORS, BYMAX_INTERNAL_ERROR, BYMAX_METRICS_REGISTRY, BYMAX_NOT_FOUND, BYMAX_NOT_IMPLEMENTED, BYMAX_PAYLOAD_TOO_LARGE, BYMAX_SERVICE_UNAVAILABLE, BYMAX_TIMING_SINK, BYMAX_TOO_MANY_REQUESTS, BYMAX_TRACE_CONTEXT, BYMAX_UNAUTHORIZED, BYMAX_UNPROCESSABLE_ENTITY, BYMAX_UNSUPPORTED_MEDIA_TYPE, BYMAX_VALIDATION_FAILED, BymaxCoreModule, BymaxExceptionFilter, BymaxTimingMiddleware, TimingInterceptor, UNMATCHED_ROUTE, buildErrorEnvelope, codeForStatus };
|