@bymax-one/nest-core 1.0.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/dist/index.cjs ADDED
@@ -0,0 +1,984 @@
1
+ 'use strict';
2
+
3
+ var common = require('@nestjs/common');
4
+ var core = require('@nestjs/core');
5
+ var rxjs = require('rxjs');
6
+
7
+ var __defProp = Object.defineProperty;
8
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
9
+ var __decorateClass = (decorators, target, key, kind) => {
10
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
11
+ for (var i = decorators.length - 1, decorator; i >= 0; i--)
12
+ if (decorator = decorators[i])
13
+ result = (kind ? decorator(target, key, result) : decorator(result)) || result;
14
+ if (kind && result) __defProp(target, key, result);
15
+ return result;
16
+ };
17
+ var __decorateParam = (index, decorator) => (target, key) => decorator(target, key, index);
18
+
19
+ // src/core.options.ts
20
+ var DEFAULT_HEALTH_PATH = "health";
21
+ var DEFAULT_INDICATOR_TIMEOUT_MS = 5e3;
22
+ var DEFAULT_METRICS_PATH = "metrics";
23
+ function deepFreeze(value) {
24
+ if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
25
+ Object.freeze(value);
26
+ for (const child of Object.values(value)) {
27
+ deepFreeze(child);
28
+ }
29
+ }
30
+ return value;
31
+ }
32
+ function resolveEnvelope(raw) {
33
+ return {
34
+ enabled: raw?.enabled ?? true,
35
+ exposeInternals: raw?.exposeInternals ?? false
36
+ };
37
+ }
38
+ function resolveTiming(raw) {
39
+ const enabled = raw?.enabled ?? true;
40
+ const threshold = raw?.slowRequestThresholdMs;
41
+ return threshold === void 0 ? { enabled } : { enabled, slowRequestThresholdMs: threshold };
42
+ }
43
+ function resolveHealth(raw) {
44
+ return {
45
+ enabled: raw?.enabled ?? true,
46
+ path: raw?.path ?? DEFAULT_HEALTH_PATH,
47
+ indicatorTimeoutMs: raw?.indicatorTimeoutMs ?? DEFAULT_INDICATOR_TIMEOUT_MS
48
+ };
49
+ }
50
+ function resolveMetrics(raw) {
51
+ return {
52
+ enabled: raw?.enabled ?? false,
53
+ path: raw?.path ?? DEFAULT_METRICS_PATH,
54
+ collectDefaultMetrics: raw?.collectDefaultMetrics ?? true,
55
+ defaultLabels: { ...raw?.defaultLabels ?? {} }
56
+ };
57
+ }
58
+ function normalizeCoreOptions(raw) {
59
+ return deepFreeze({
60
+ envelope: resolveEnvelope(raw?.envelope),
61
+ timing: resolveTiming(raw?.timing),
62
+ health: resolveHealth(raw?.health),
63
+ metrics: resolveMetrics(raw?.metrics)
64
+ });
65
+ }
66
+ normalizeCoreOptions();
67
+
68
+ // src/core.tokens.ts
69
+ var BYMAX_CORE_OPTIONS = /* @__PURE__ */ Symbol("BYMAX_CORE_OPTIONS");
70
+ var BYMAX_CORRELATION_PROVIDER = /* @__PURE__ */ Symbol("BYMAX_CORRELATION_PROVIDER");
71
+ var BYMAX_TIMING_SINK = /* @__PURE__ */ Symbol("BYMAX_TIMING_SINK");
72
+ var BYMAX_HEALTH_INDICATORS = /* @__PURE__ */ Symbol("BYMAX_HEALTH_INDICATORS");
73
+ var BYMAX_METRICS_REGISTRY = /* @__PURE__ */ Symbol("BYMAX_METRICS_REGISTRY");
74
+
75
+ // src/timing/timing.clock.ts
76
+ var DEFAULT_MONOTONIC_CLOCK = {
77
+ now: () => performance.now()
78
+ };
79
+ var BYMAX_TIMING_CLOCK = /* @__PURE__ */ Symbol("BYMAX_TIMING_CLOCK");
80
+
81
+ // src/defaults.providers.ts
82
+ var NoopCorrelationIdProvider = class {
83
+ /**
84
+ * Resolve no correlation id.
85
+ *
86
+ * @returns Always `undefined`.
87
+ */
88
+ getCorrelationId() {
89
+ return void 0;
90
+ }
91
+ };
92
+ var NoopTimingSink = class {
93
+ /**
94
+ * Discard the sample.
95
+ *
96
+ * @param _sample - The sample to discard.
97
+ */
98
+ record(_sample) {
99
+ }
100
+ };
101
+ function buildDefaultProviders() {
102
+ return [{ provide: BYMAX_TIMING_CLOCK, useValue: DEFAULT_MONOTONIC_CLOCK }];
103
+ }
104
+
105
+ // src/envelope/error-codes.ts
106
+ var BYMAX_BAD_REQUEST = "BYMAX_BAD_REQUEST";
107
+ var BYMAX_VALIDATION_FAILED = "BYMAX_VALIDATION_FAILED";
108
+ var BYMAX_UNAUTHORIZED = "BYMAX_UNAUTHORIZED";
109
+ var BYMAX_FORBIDDEN = "BYMAX_FORBIDDEN";
110
+ var BYMAX_NOT_FOUND = "BYMAX_NOT_FOUND";
111
+ var BYMAX_CONFLICT = "BYMAX_CONFLICT";
112
+ var BYMAX_PAYLOAD_TOO_LARGE = "BYMAX_PAYLOAD_TOO_LARGE";
113
+ var BYMAX_UNSUPPORTED_MEDIA_TYPE = "BYMAX_UNSUPPORTED_MEDIA_TYPE";
114
+ var BYMAX_UNPROCESSABLE_ENTITY = "BYMAX_UNPROCESSABLE_ENTITY";
115
+ var BYMAX_TOO_MANY_REQUESTS = "BYMAX_TOO_MANY_REQUESTS";
116
+ var BYMAX_CLIENT_ERROR = "BYMAX_CLIENT_ERROR";
117
+ var BYMAX_INTERNAL_ERROR = "BYMAX_INTERNAL_ERROR";
118
+ var BYMAX_NOT_IMPLEMENTED = "BYMAX_NOT_IMPLEMENTED";
119
+ var BYMAX_BAD_GATEWAY = "BYMAX_BAD_GATEWAY";
120
+ var BYMAX_SERVICE_UNAVAILABLE = "BYMAX_SERVICE_UNAVAILABLE";
121
+ var BYMAX_GATEWAY_TIMEOUT = "BYMAX_GATEWAY_TIMEOUT";
122
+ var STATUS_CODES = /* @__PURE__ */ new Map([
123
+ [400, BYMAX_BAD_REQUEST],
124
+ [401, BYMAX_UNAUTHORIZED],
125
+ [403, BYMAX_FORBIDDEN],
126
+ [404, BYMAX_NOT_FOUND],
127
+ [409, BYMAX_CONFLICT],
128
+ [413, BYMAX_PAYLOAD_TOO_LARGE],
129
+ [415, BYMAX_UNSUPPORTED_MEDIA_TYPE],
130
+ [422, BYMAX_UNPROCESSABLE_ENTITY],
131
+ [429, BYMAX_TOO_MANY_REQUESTS],
132
+ [500, BYMAX_INTERNAL_ERROR],
133
+ [501, BYMAX_NOT_IMPLEMENTED],
134
+ [502, BYMAX_BAD_GATEWAY],
135
+ [503, BYMAX_SERVICE_UNAVAILABLE],
136
+ [504, BYMAX_GATEWAY_TIMEOUT]
137
+ ]);
138
+ var CLIENT_ERROR_MIN = 400;
139
+ var CLIENT_ERROR_MAX = 500;
140
+ function codeForStatus(status) {
141
+ const catalogued = STATUS_CODES.get(status);
142
+ if (catalogued !== void 0) {
143
+ return catalogued;
144
+ }
145
+ if (status >= CLIENT_ERROR_MIN && status < CLIENT_ERROR_MAX) {
146
+ return BYMAX_CLIENT_ERROR;
147
+ }
148
+ return BYMAX_INTERNAL_ERROR;
149
+ }
150
+
151
+ // src/envelope/error-envelope.ts
152
+ function buildErrorEnvelope(input) {
153
+ const base = {
154
+ statusCode: input.statusCode,
155
+ code: input.code,
156
+ message: input.message,
157
+ timestamp: input.now().toISOString(),
158
+ path: input.path
159
+ };
160
+ return {
161
+ ...base,
162
+ ...input.details !== void 0 ? { details: input.details } : {},
163
+ ...input.correlationId !== void 0 ? { correlationId: input.correlationId } : {}
164
+ };
165
+ }
166
+
167
+ // src/envelope/exception.filter.ts
168
+ var INTERNAL_ERROR_STATUS = 500;
169
+ var INTERNAL_ERROR_MESSAGE = "Internal server error";
170
+ var VALIDATION_FAILED_MESSAGE = "Validation failed";
171
+ function extractExplicitCode(response) {
172
+ if (typeof response !== "object" || response === null || !("code" in response)) {
173
+ return void 0;
174
+ }
175
+ const code = response.code;
176
+ return typeof code === "string" ? code : void 0;
177
+ }
178
+ function isValidationResponse(response) {
179
+ return typeof response === "object" && response !== null && "message" in response && Array.isArray(response.message);
180
+ }
181
+ function toValidationDetails(violations) {
182
+ return violations.map(
183
+ (violation) => typeof violation === "string" ? { issue: violation } : violation
184
+ );
185
+ }
186
+ function extractHttpMessage(response, exception) {
187
+ if (typeof response === "string") {
188
+ return response;
189
+ }
190
+ if (typeof response === "object" && response !== null && "message" in response) {
191
+ const message = response.message;
192
+ if (typeof message === "string") {
193
+ return message;
194
+ }
195
+ }
196
+ return exception.message;
197
+ }
198
+ function buildInternalDetails(exception) {
199
+ if (exception instanceof Error) {
200
+ return exception.stack !== void 0 ? { message: exception.message, stack: exception.stack } : { message: exception.message };
201
+ }
202
+ return { message: String(exception) };
203
+ }
204
+ exports.BymaxExceptionFilter = class BymaxExceptionFilter {
205
+ /**
206
+ * @param options - Resolved core options; drives the `exposeInternals` switch.
207
+ * @param correlation - Provider resolving the current request's correlation id.
208
+ * Injected with `@Optional()`: `BymaxCoreModule` binds no local default for
209
+ * this token, so a consumer's own `BYMAX_CORRELATION_PROVIDER` binding
210
+ * (from their own, globally-visible module) is not shadowed by one; when
211
+ * nothing is bound, this falls back to a no-op that omits `correlationId`.
212
+ * @param adapterHost - Host of the live HTTP adapter, resolved lazily per catch.
213
+ */
214
+ constructor(options, correlation, adapterHost) {
215
+ this.options = options;
216
+ this.adapterHost = adapterHost;
217
+ /** Clock used to stamp the envelope timestamp; overridable in tests via fake timers. */
218
+ this.now = () => /* @__PURE__ */ new Date();
219
+ this.correlation = correlation ?? new NoopCorrelationIdProvider();
220
+ }
221
+ /**
222
+ * Format the exception into the stable envelope and reply with it.
223
+ *
224
+ * Non-HTTP execution contexts (GraphQL, RPC) are out of scope and are
225
+ * rethrown untouched so their own error handling applies.
226
+ *
227
+ * @param exception - The error that escaped the handler.
228
+ * @param host - The arguments host for the current execution context.
229
+ */
230
+ catch(exception, host) {
231
+ if (host.getType() !== "http") {
232
+ throw exception;
233
+ }
234
+ const { httpAdapter } = this.adapterHost;
235
+ const ctx = host.switchToHttp();
236
+ const request = ctx.getRequest();
237
+ const response = ctx.getResponse();
238
+ const correlationId = this.correlation.getCorrelationId();
239
+ const context = {
240
+ method: String(httpAdapter.getRequestMethod(request)),
241
+ path: String(httpAdapter.getRequestUrl(request)),
242
+ ...correlationId !== void 0 ? { correlationId } : {}
243
+ };
244
+ const envelope = this.buildEnvelope(exception, context);
245
+ httpAdapter.reply(response, envelope, envelope.statusCode);
246
+ }
247
+ /**
248
+ * Select the mapping rule for the exception and build its envelope. An
249
+ * unknown error is handed to the observability seam before it collapses, so
250
+ * an integration can record the original error with the request context.
251
+ *
252
+ * @param exception - The error that escaped the handler.
253
+ * @param context - The neutral request context.
254
+ * @returns The formatted envelope.
255
+ */
256
+ buildEnvelope(exception, context) {
257
+ if (exception instanceof common.HttpException) {
258
+ return this.mapHttpException(exception, context);
259
+ }
260
+ try {
261
+ this.onUnexpectedError(exception, context);
262
+ } catch {
263
+ }
264
+ return this.mapUnknown(exception, context);
265
+ }
266
+ /**
267
+ * Map an `HttpException` to the envelope. Explicit domain codes pass through;
268
+ * the validation shape becomes `BYMAX_VALIDATION_FAILED` with structured
269
+ * details; everything else derives its code from the status.
270
+ *
271
+ * @param exception - The HTTP exception to format.
272
+ * @param context - The neutral request context.
273
+ * @returns The formatted envelope.
274
+ */
275
+ mapHttpException(exception, context) {
276
+ const status = exception.getStatus();
277
+ const response = exception.getResponse();
278
+ const explicitCode = extractExplicitCode(response);
279
+ if (explicitCode !== void 0) {
280
+ return this.toEnvelope(status, explicitCode, extractHttpMessage(response, exception), context);
281
+ }
282
+ if (isValidationResponse(response)) {
283
+ return this.toEnvelope(
284
+ status,
285
+ BYMAX_VALIDATION_FAILED,
286
+ VALIDATION_FAILED_MESSAGE,
287
+ context,
288
+ toValidationDetails(response.message)
289
+ );
290
+ }
291
+ return this.toEnvelope(
292
+ status,
293
+ codeForStatus(status),
294
+ extractHttpMessage(response, exception),
295
+ context
296
+ );
297
+ }
298
+ /**
299
+ * Collapse an unknown error to the fixed, production-safe 500. The original
300
+ * error is never serialized unless `exposeInternals` is on, in which case its
301
+ * message and stack are attached to `details` (development only).
302
+ *
303
+ * @param exception - The original thrown value.
304
+ * @param context - The neutral request context.
305
+ * @returns The generic internal-error envelope.
306
+ */
307
+ mapUnknown(exception, context) {
308
+ const details = this.options.envelope.exposeInternals ? buildInternalDetails(exception) : void 0;
309
+ return this.toEnvelope(
310
+ INTERNAL_ERROR_STATUS,
311
+ BYMAX_INTERNAL_ERROR,
312
+ INTERNAL_ERROR_MESSAGE,
313
+ context,
314
+ details
315
+ );
316
+ }
317
+ /**
318
+ * Assemble the envelope through the pure builder, threading the shared clock
319
+ * and omitting absent optional details.
320
+ *
321
+ * @param statusCode - HTTP status for the envelope.
322
+ * @param code - Stable machine-readable code.
323
+ * @param message - Human-readable, end-user-safe message.
324
+ * @param context - The neutral request context.
325
+ * @param details - Optional structured context; omitted when absent.
326
+ * @returns The formatted envelope.
327
+ */
328
+ toEnvelope(statusCode, code, message, context, details) {
329
+ return buildErrorEnvelope({
330
+ statusCode,
331
+ code,
332
+ message,
333
+ path: context.path,
334
+ now: this.now,
335
+ ...details !== void 0 ? { details } : {},
336
+ ...context.correlationId !== void 0 ? { correlationId: context.correlationId } : {}
337
+ });
338
+ }
339
+ /**
340
+ * Observability seam invoked for every unexpected (non-`HttpException`) error
341
+ * before it collapses to the generic 500. The base implementation is a no-op:
342
+ * this library owns no logger. An integration (for example
343
+ * `@bymax-one/nest-logger`) subclasses the filter and overrides this to record
344
+ * the original error with the current request context and correlation id.
345
+ * Overrides must never throw and must never write to the response.
346
+ *
347
+ * @param _error - The original thrown value.
348
+ * @param _context - The neutral request context.
349
+ */
350
+ onUnexpectedError(_error, _context) {
351
+ }
352
+ };
353
+ exports.BymaxExceptionFilter = __decorateClass([
354
+ common.Catch(),
355
+ __decorateParam(0, common.Inject(BYMAX_CORE_OPTIONS)),
356
+ __decorateParam(1, common.Optional()),
357
+ __decorateParam(1, common.Inject(BYMAX_CORRELATION_PROVIDER)),
358
+ __decorateParam(2, common.Inject(core.HttpAdapterHost))
359
+ ], exports.BymaxExceptionFilter);
360
+
361
+ // src/timing/request-info.accessor.ts
362
+ function stripQueryString(rawUrl) {
363
+ const queryIndex = rawUrl.indexOf("?");
364
+ return queryIndex === -1 ? rawUrl : rawUrl.slice(0, queryIndex);
365
+ }
366
+ function readExpressTemplate(request) {
367
+ const path = request.route?.path;
368
+ return path === void 0 ? void 0 : `${request.baseUrl ?? ""}${path}`;
369
+ }
370
+ function readFastifyTemplate(request) {
371
+ return request.routeOptions?.url;
372
+ }
373
+ function extractRequestInfo(context) {
374
+ const request = context.switchToHttp().getRequest();
375
+ const template = readExpressTemplate(request) ?? readFastifyTemplate(request);
376
+ const rawUrl = request.originalUrl ?? request.url ?? "";
377
+ return { method: request.method ?? "", route: template ?? stripQueryString(rawUrl) };
378
+ }
379
+
380
+ // src/timing/timing.interceptor.ts
381
+ var DEFAULT_SUCCESS_STATUS = 200;
382
+ var UNKNOWN_ERROR_STATUS = 500;
383
+ exports.TimingInterceptor = class TimingInterceptor {
384
+ /**
385
+ * @param options - Resolved core options; supplies `slowRequestThresholdMs`.
386
+ * @param sink - The bound timing sink; its `record` failures are swallowed.
387
+ * Injected with `@Optional()`: `BymaxCoreModule` binds no local default for
388
+ * this token on the sync path when the metrics bridge is not registered, so
389
+ * a consumer's own `BYMAX_TIMING_SINK` binding is not shadowed by one; when
390
+ * nothing resolves, this falls back to a no-op sink.
391
+ * @param clock - Monotonic clock seam; defaults to `performance.now()`, and
392
+ * is bound explicitly through {@link BYMAX_TIMING_CLOCK} so tests inject a
393
+ * stub advancing by controlled amounts.
394
+ */
395
+ constructor(options, sink, clock = DEFAULT_MONOTONIC_CLOCK) {
396
+ this.options = options;
397
+ this.clock = clock;
398
+ this.sink = sink ?? new NoopTimingSink();
399
+ }
400
+ /**
401
+ * Measure the handler chain and record exactly one sample per completed
402
+ * request, on the success path and on the error path alike.
403
+ *
404
+ * @param context - The execution context of the current request.
405
+ * @param next - The next handler in the chain.
406
+ * @returns The downstream response stream, unmodified beyond the measurement.
407
+ */
408
+ intercept(context, next) {
409
+ if (context.getType() !== "http") {
410
+ return next.handle();
411
+ }
412
+ const start = this.clock.now();
413
+ const { method, route } = extractRequestInfo(context);
414
+ return next.handle().pipe(
415
+ rxjs.tap({
416
+ complete: () => {
417
+ this.recordSample(method, route, this.readSuccessStatus(context), start);
418
+ }
419
+ }),
420
+ rxjs.catchError((error) => {
421
+ this.recordSample(method, route, this.readErrorStatus(error), start);
422
+ return rxjs.throwError(() => error);
423
+ })
424
+ );
425
+ }
426
+ /**
427
+ * Read the final status code from the response object on the success path.
428
+ *
429
+ * @param context - The execution context of the current request.
430
+ * @returns The response's status code, or the default success status when absent.
431
+ */
432
+ readSuccessStatus(context) {
433
+ const response = context.switchToHttp().getResponse();
434
+ return response.statusCode ?? DEFAULT_SUCCESS_STATUS;
435
+ }
436
+ /**
437
+ * Derive the final status code for an error that escaped the handler.
438
+ *
439
+ * @param error - The error propagated by the handler chain.
440
+ * @returns The `HttpException` status, or the generic 500 for anything else.
441
+ */
442
+ readErrorStatus(error) {
443
+ return error instanceof common.HttpException ? error.getStatus() : UNKNOWN_ERROR_STATUS;
444
+ }
445
+ /**
446
+ * Build the sample, compute the slow flag, and deliver it to the sink inside
447
+ * a try/catch that silences any failure: a throwing sink must never affect
448
+ * the request it is observing.
449
+ *
450
+ * @param method - HTTP method of the request.
451
+ * @param route - Route template of the request.
452
+ * @param statusCode - Final status code, success or error.
453
+ * @param start - Monotonic start timestamp captured before the handler ran.
454
+ */
455
+ recordSample(method, route, statusCode, start) {
456
+ const durationMs = this.clock.now() - start;
457
+ const threshold = this.options.timing.slowRequestThresholdMs;
458
+ const slow = threshold !== void 0 && durationMs > threshold;
459
+ const sample = { method, route, statusCode, durationMs, slow };
460
+ try {
461
+ this.sink.record(sample);
462
+ } catch {
463
+ }
464
+ }
465
+ };
466
+ exports.TimingInterceptor = __decorateClass([
467
+ common.Injectable(),
468
+ __decorateParam(0, common.Inject(BYMAX_CORE_OPTIONS)),
469
+ __decorateParam(1, common.Optional()),
470
+ __decorateParam(1, common.Inject(BYMAX_TIMING_SINK)),
471
+ __decorateParam(2, common.Inject(BYMAX_TIMING_CLOCK))
472
+ ], exports.TimingInterceptor);
473
+
474
+ // src/passthrough.providers.ts
475
+ var PassThroughExceptionFilter = class {
476
+ constructor(adapterHost) {
477
+ this.adapterHost = adapterHost;
478
+ }
479
+ /**
480
+ * Format the exception exactly as Nest's default handler would.
481
+ *
482
+ * @param exception - The exception that escaped the handler.
483
+ * @param host - The arguments host for the current request.
484
+ */
485
+ catch(exception, host) {
486
+ this.delegate ??= new core.BaseExceptionFilter(this.adapterHost.httpAdapter);
487
+ this.delegate.catch(exception, host);
488
+ }
489
+ };
490
+ PassThroughExceptionFilter = __decorateClass([
491
+ common.Catch()
492
+ ], PassThroughExceptionFilter);
493
+ var PassThroughInterceptor = class {
494
+ /**
495
+ * Forward the request to the next handler unchanged.
496
+ *
497
+ * @param _context - The execution context; unused by a transparent forwarder.
498
+ * @param next - The next handler in the chain.
499
+ * @returns The downstream response stream, unmodified.
500
+ */
501
+ intercept(_context, next) {
502
+ return next.handle();
503
+ }
504
+ };
505
+ function assertAsyncFeatureEnabled(feature, enabled) {
506
+ if (!enabled) {
507
+ throw new Error(
508
+ `[BymaxCoreModule] The "${feature}" controller was reached while the feature is disabled. On the forRootAsync path this controller is always registered because options resolve after the module is defined; enable "${feature}" in the resolved options, or do not expose this controller while the feature is disabled.`
509
+ );
510
+ }
511
+ }
512
+ function selectAsyncExceptionFilter(options, correlation, adapterHost) {
513
+ return options.envelope.enabled ? new exports.BymaxExceptionFilter(options, correlation, adapterHost) : new PassThroughExceptionFilter(adapterHost);
514
+ }
515
+ function selectAsyncTimingInterceptor(options, sink, clock) {
516
+ return options.timing.enabled ? new exports.TimingInterceptor(options, sink, clock) : new PassThroughInterceptor();
517
+ }
518
+ var MAX_ERROR_MESSAGE_LENGTH = 300;
519
+ var TRUNCATION_ELLIPSIS = "...";
520
+ function summarizeRejection(reason) {
521
+ let message;
522
+ try {
523
+ message = reason instanceof Error ? reason.message : String(reason);
524
+ } catch {
525
+ message = "Unknown error";
526
+ }
527
+ if (message.length <= MAX_ERROR_MESSAGE_LENGTH) {
528
+ return message;
529
+ }
530
+ return `${message.slice(0, MAX_ERROR_MESSAGE_LENGTH - TRUNCATION_ELLIPSIS.length)}${TRUNCATION_ELLIPSIS}`;
531
+ }
532
+ async function runIndicator(indicator, timeoutMs) {
533
+ let timer;
534
+ const timedOut = new Promise((resolve) => {
535
+ timer = setTimeout(() => {
536
+ resolve({ name: indicator.name, status: "down", details: { timedOutAfterMs: timeoutMs } });
537
+ }, timeoutMs);
538
+ });
539
+ const checked = Promise.resolve().then(() => indicator.check()).then((result) => ({ ...result, name: indicator.name })).catch((reason) => ({
540
+ name: indicator.name,
541
+ status: "down",
542
+ details: { error: summarizeRejection(reason) }
543
+ }));
544
+ try {
545
+ return await Promise.race([checked, timedOut]);
546
+ } finally {
547
+ clearTimeout(timer);
548
+ }
549
+ }
550
+ var HealthService = class {
551
+ /**
552
+ * @param indicators - Every registered indicator; empty when none resolve.
553
+ * Injected with `@Optional()`: `BymaxCoreModule` binds no local default for
554
+ * this token, so a consumer's own `BYMAX_HEALTH_INDICATORS` binding (from
555
+ * their own, globally-visible module) is not shadowed by one; when nothing
556
+ * is bound, this defaults to an empty array.
557
+ * @param options - Resolved core options; supplies `indicatorTimeoutMs`.
558
+ */
559
+ constructor(indicators = [], options) {
560
+ this.indicators = indicators;
561
+ this.options = options;
562
+ }
563
+ /**
564
+ * Liveness check: the process is up and able to respond. Runs no
565
+ * indicators, so it never depends on the health of anything else.
566
+ *
567
+ * @returns The documented liveness shape: `{ status: 'ok', checks: [] }`.
568
+ */
569
+ checkLiveness() {
570
+ return { status: "ok", checks: [] };
571
+ }
572
+ /**
573
+ * Readiness check: run every registered indicator concurrently and
574
+ * aggregate the results. `status` is `'ok'` only when every indicator
575
+ * reports `up`; an empty indicator list is vacuously `'ok'`.
576
+ *
577
+ * @returns The aggregated health response.
578
+ */
579
+ async checkReadiness() {
580
+ const timeoutMs = this.options.health.indicatorTimeoutMs;
581
+ const checks = await Promise.all(
582
+ this.indicators.map((indicator) => runIndicator(indicator, timeoutMs))
583
+ );
584
+ const status = checks.every((check) => check.status === "up") ? "ok" : "error";
585
+ return { status, checks };
586
+ }
587
+ };
588
+ HealthService = __decorateClass([
589
+ common.Injectable(),
590
+ __decorateParam(0, common.Optional()),
591
+ __decorateParam(0, common.Inject(BYMAX_HEALTH_INDICATORS)),
592
+ __decorateParam(1, common.Inject(BYMAX_CORE_OPTIONS))
593
+ ], HealthService);
594
+
595
+ // src/health/health.controller.ts
596
+ function assertControllerMatchesOptions(options, registeredPath) {
597
+ assertAsyncFeatureEnabled("health", options.health.enabled);
598
+ if (options.health.path !== registeredPath) {
599
+ throw new Error(
600
+ `[BymaxCoreModule] The "health" controller is registered at "${registeredPath}" but the resolved options request "${options.health.path}". Route metadata is fixed before forRootAsync's options resolve, so a custom "health.path" is only honored through forRoot(); register synchronously, or keep the default "${DEFAULT_HEALTH_PATH}" prefix on the async path.`
601
+ );
602
+ }
603
+ }
604
+ function createHealthController(registeredPath) {
605
+ let HealthController = class {
606
+ /**
607
+ * @param healthService - The readiness and liveness aggregator.
608
+ * @param options - Resolved core options, used to guard consistency at request time.
609
+ * @param adapterHost - The live HTTP adapter, used to reply with a dynamic status.
610
+ */
611
+ constructor(healthService, options, adapterHost) {
612
+ this.healthService = healthService;
613
+ this.options = options;
614
+ this.adapterHost = adapterHost;
615
+ }
616
+ live() {
617
+ assertControllerMatchesOptions(this.options, registeredPath);
618
+ return this.healthService.checkLiveness();
619
+ }
620
+ async ready(response) {
621
+ assertControllerMatchesOptions(this.options, registeredPath);
622
+ const result = await this.healthService.checkReadiness();
623
+ const status = result.status === "ok" ? common.HttpStatus.OK : common.HttpStatus.SERVICE_UNAVAILABLE;
624
+ this.adapterHost.httpAdapter.reply(response, result, status);
625
+ }
626
+ };
627
+ __decorateClass([
628
+ common.Get("live")
629
+ ], HealthController.prototype, "live", 1);
630
+ __decorateClass([
631
+ common.Get("ready"),
632
+ __decorateParam(0, common.Res())
633
+ ], HealthController.prototype, "ready", 1);
634
+ HealthController = __decorateClass([
635
+ common.Controller(registeredPath),
636
+ __decorateParam(0, common.Inject(HealthService)),
637
+ __decorateParam(1, common.Inject(BYMAX_CORE_OPTIONS)),
638
+ __decorateParam(2, common.Inject(core.HttpAdapterHost))
639
+ ], HealthController);
640
+ return HealthController;
641
+ }
642
+ function assertControllerMatchesOptions2(options, registeredPath) {
643
+ assertAsyncFeatureEnabled("metrics", options.metrics.enabled);
644
+ if (options.metrics.path !== registeredPath) {
645
+ throw new Error(
646
+ `[BymaxCoreModule] The "metrics" controller is registered at "${registeredPath}" but the resolved options request "${options.metrics.path}". Route metadata is fixed before forRootAsync's options resolve, so a custom "metrics.path" is only honored through forRoot(); register synchronously, or keep the default "metrics" route on the async path.`
647
+ );
648
+ }
649
+ }
650
+ function createMetricsController(registeredPath) {
651
+ let MetricsController = class {
652
+ /**
653
+ * @param registry - The dedicated `prom-client` registry to scrape.
654
+ * @param options - Resolved core options, used to guard consistency at request time.
655
+ * @param adapterHost - The live HTTP adapter, used to reply with the correct content type.
656
+ */
657
+ constructor(registry, options, adapterHost) {
658
+ this.registry = registry;
659
+ this.options = options;
660
+ this.adapterHost = adapterHost;
661
+ }
662
+ async scrape(response) {
663
+ assertControllerMatchesOptions2(this.options, registeredPath);
664
+ const body = await this.registry.metrics();
665
+ this.adapterHost.httpAdapter.setHeader(response, "Content-Type", this.registry.contentType);
666
+ this.adapterHost.httpAdapter.reply(response, body, common.HttpStatus.OK);
667
+ }
668
+ };
669
+ __decorateClass([
670
+ common.Get(),
671
+ __decorateParam(0, common.Res())
672
+ ], MetricsController.prototype, "scrape", 1);
673
+ MetricsController = __decorateClass([
674
+ common.Controller(registeredPath),
675
+ __decorateParam(0, common.Inject(BYMAX_METRICS_REGISTRY)),
676
+ __decorateParam(1, common.Inject(BYMAX_CORE_OPTIONS)),
677
+ __decorateParam(2, common.Inject(core.HttpAdapterHost))
678
+ ], MetricsController);
679
+ return MetricsController;
680
+ }
681
+
682
+ // src/metrics/metrics.registry.ts
683
+ var MISSING_PEER_MESSAGE = "metrics.enabled is true but the optional peer prom-client is not installed. Run: pnpm add prom-client";
684
+ function isMissingModuleError(cause) {
685
+ const code = cause.code;
686
+ return code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND";
687
+ }
688
+ async function loadPromClient() {
689
+ try {
690
+ return await import('prom-client');
691
+ } catch (cause) {
692
+ if (isMissingModuleError(cause)) {
693
+ throw new Error(MISSING_PEER_MESSAGE, { cause });
694
+ }
695
+ throw cause;
696
+ }
697
+ }
698
+ async function createMetricsRegistry(options) {
699
+ const promClient = await loadPromClient();
700
+ const registry = new promClient.Registry();
701
+ registry.setDefaultLabels(options.metrics.defaultLabels);
702
+ if (options.metrics.collectDefaultMetrics) {
703
+ promClient.collectDefaultMetrics({ register: registry });
704
+ }
705
+ return registry;
706
+ }
707
+
708
+ // src/metrics/timing-metrics.sink.ts
709
+ var REQUESTS_TOTAL = "http_requests_total";
710
+ var REQUEST_DURATION_SECONDS = "http_request_duration_seconds";
711
+ var HTTP_METRIC_LABELS = ["method", "route", "status_code"];
712
+ var MILLISECONDS_PER_SECOND = 1e3;
713
+ function getOrCreateCounter(promClient, registry) {
714
+ const existing = registry.getSingleMetric(REQUESTS_TOTAL);
715
+ if (existing !== void 0) {
716
+ if (!(existing instanceof promClient.Counter)) {
717
+ throw new Error(
718
+ `A metric named "${REQUESTS_TOTAL}" is already registered on the metrics registry with a different type; the HTTP metrics bridge requires it to be a Counter.`
719
+ );
720
+ }
721
+ return existing;
722
+ }
723
+ return new promClient.Counter({
724
+ name: REQUESTS_TOTAL,
725
+ help: "Total number of completed HTTP requests, labeled by method, route, and status_code.",
726
+ labelNames: [...HTTP_METRIC_LABELS],
727
+ registers: [registry]
728
+ });
729
+ }
730
+ function getOrCreateHistogram(promClient, registry) {
731
+ const existing = registry.getSingleMetric(REQUEST_DURATION_SECONDS);
732
+ if (existing !== void 0) {
733
+ if (!(existing instanceof promClient.Histogram)) {
734
+ throw new Error(
735
+ `A metric named "${REQUEST_DURATION_SECONDS}" is already registered on the metrics registry with a different type; the HTTP metrics bridge requires it to be a Histogram.`
736
+ );
737
+ }
738
+ return existing;
739
+ }
740
+ return new promClient.Histogram({
741
+ name: REQUEST_DURATION_SECONDS,
742
+ help: "HTTP request duration in seconds, labeled by method, route, and status_code.",
743
+ labelNames: [...HTTP_METRIC_LABELS],
744
+ registers: [registry]
745
+ });
746
+ }
747
+ var TimingMetricsSink = class {
748
+ /**
749
+ * @param registry - The dedicated metrics registry the samples feed.
750
+ * @param promClient - The lazily loaded `prom-client` module supplying the
751
+ * `Counter` and `Histogram` constructors, passed in so this bridge never
752
+ * imports the optional peer at the top level.
753
+ */
754
+ constructor(registry, promClient) {
755
+ this.counter = getOrCreateCounter(promClient, registry);
756
+ this.histogram = getOrCreateHistogram(promClient, registry);
757
+ }
758
+ /**
759
+ * Record one completed request: increment the counter once and observe the
760
+ * duration in seconds, both under the bounded label set. Any failure is
761
+ * swallowed so a metrics backend problem can never break the request being
762
+ * observed.
763
+ *
764
+ * @param sample - The timing sample for a completed request.
765
+ */
766
+ record(sample) {
767
+ const labels = {
768
+ method: sample.method,
769
+ route: sample.route,
770
+ status_code: String(sample.statusCode)
771
+ };
772
+ try {
773
+ this.counter.inc(labels);
774
+ this.histogram.observe(labels, sample.durationMs / MILLISECONDS_PER_SECOND);
775
+ } catch {
776
+ }
777
+ }
778
+ };
779
+
780
+ // src/metrics/metrics.providers.ts
781
+ function createDisabledRegistryPlaceholder() {
782
+ const throwDisabled = () => {
783
+ throw new Error(
784
+ '[BymaxCoreModule] The metrics registry was accessed while metrics are disabled. Enable "metrics" in the resolved options before injecting the registry.'
785
+ );
786
+ };
787
+ return { metrics: throwDisabled };
788
+ }
789
+ async function resolveMetricsRegistry(options) {
790
+ if (!options.metrics.enabled) {
791
+ return createDisabledRegistryPlaceholder();
792
+ }
793
+ return createMetricsRegistry(options);
794
+ }
795
+ async function resolveTimingSink(options, registry) {
796
+ if (options.metrics.enabled && options.timing.enabled) {
797
+ return new TimingMetricsSink(registry, await loadPromClient());
798
+ }
799
+ return new NoopTimingSink();
800
+ }
801
+ function buildMetricsRegistryProvider() {
802
+ return {
803
+ provide: BYMAX_METRICS_REGISTRY,
804
+ useFactory: (options) => resolveMetricsRegistry(options),
805
+ inject: [BYMAX_CORE_OPTIONS]
806
+ };
807
+ }
808
+ function buildMetricsTimingSinkProvider() {
809
+ return {
810
+ provide: BYMAX_TIMING_SINK,
811
+ useFactory: (options, registry) => resolveTimingSink(options, registry),
812
+ inject: [BYMAX_CORE_OPTIONS, BYMAX_METRICS_REGISTRY]
813
+ };
814
+ }
815
+
816
+ // src/core.module.ts
817
+ var {
818
+ ConfigurableModuleClass: BymaxCoreModuleBase,
819
+ MODULE_OPTIONS_TOKEN: BUILDER_OPTIONS_TOKEN,
820
+ OPTIONS_TYPE,
821
+ ASYNC_OPTIONS_TYPE
822
+ } = new common.ConfigurableModuleBuilder().setClassMethodName("forRoot").setExtras({ isGlobal: true }, (definition, extras) => ({
823
+ ...definition,
824
+ // `setExtras` merges the `{ isGlobal: true }` default first, so `isGlobal`
825
+ // is always defined; `!== false` keeps "global unless explicitly disabled".
826
+ global: extras.isGlobal !== false
827
+ })).build();
828
+ function buildSyncProviders(resolved) {
829
+ const providers = [];
830
+ if (resolved.envelope.enabled) {
831
+ providers.push({ provide: core.APP_FILTER, useClass: exports.BymaxExceptionFilter });
832
+ }
833
+ if (resolved.timing.enabled) {
834
+ providers.push({ provide: core.APP_INTERCEPTOR, useClass: exports.TimingInterceptor });
835
+ }
836
+ if (resolved.health.enabled) {
837
+ providers.push(HealthService);
838
+ }
839
+ if (resolved.metrics.enabled) {
840
+ providers.push(buildMetricsRegistryProvider());
841
+ if (resolved.timing.enabled) {
842
+ providers.push(buildMetricsTimingSinkProvider());
843
+ }
844
+ }
845
+ return providers;
846
+ }
847
+ function buildControllers(resolved) {
848
+ const controllers = [];
849
+ if (resolved.health.enabled) {
850
+ controllers.push(createHealthController(resolved.health.path));
851
+ }
852
+ if (resolved.metrics.enabled) {
853
+ controllers.push(createMetricsController(resolved.metrics.path));
854
+ }
855
+ return controllers;
856
+ }
857
+ function buildAsyncSlots() {
858
+ return [
859
+ {
860
+ provide: core.APP_FILTER,
861
+ useFactory: (options, correlation, adapterHost) => selectAsyncExceptionFilter(options, correlation, adapterHost),
862
+ inject: [
863
+ BYMAX_CORE_OPTIONS,
864
+ { token: BYMAX_CORRELATION_PROVIDER, optional: true },
865
+ core.HttpAdapterHost
866
+ ]
867
+ },
868
+ {
869
+ provide: core.APP_INTERCEPTOR,
870
+ useFactory: (options, sink, clock) => selectAsyncTimingInterceptor(options, sink, clock),
871
+ inject: [BYMAX_CORE_OPTIONS, BYMAX_TIMING_SINK, BYMAX_TIMING_CLOCK]
872
+ }
873
+ ];
874
+ }
875
+ function augmentModule(base, providers, controllers, exportTokens = [BYMAX_CORE_OPTIONS]) {
876
+ return {
877
+ ...base,
878
+ module: exports.BymaxCoreModule,
879
+ providers: [...base.providers ?? [], ...providers],
880
+ controllers: [...base.controllers ?? [], ...controllers],
881
+ exports: [...base.exports ?? [], ...exportTokens]
882
+ };
883
+ }
884
+ exports.BymaxCoreModule = class BymaxCoreModule extends BymaxCoreModuleBase {
885
+ /**
886
+ * Register the module synchronously. Options are known now, so disabled
887
+ * features are omitted from the providers and controllers arrays and the
888
+ * resolved snapshot is provided under {@link BYMAX_CORE_OPTIONS}.
889
+ *
890
+ * @param options - Core options plus the optional `isGlobal` extra. Omit for
891
+ * all documented defaults.
892
+ * @returns The configured `DynamicModule`.
893
+ * @example
894
+ * BymaxCoreModule.forRoot({ metrics: { enabled: true } })
895
+ */
896
+ static forRoot(options = {}) {
897
+ const resolved = normalizeCoreOptions(options);
898
+ const providers = [
899
+ { provide: BYMAX_CORE_OPTIONS, useValue: resolved },
900
+ ...buildDefaultProviders(),
901
+ ...buildSyncProviders(resolved)
902
+ ];
903
+ const exportTokens = [BYMAX_CORE_OPTIONS];
904
+ if (resolved.metrics.enabled) {
905
+ exportTokens.push(BYMAX_METRICS_REGISTRY);
906
+ if (resolved.timing.enabled) {
907
+ exportTokens.push(BYMAX_TIMING_SINK);
908
+ }
909
+ }
910
+ return augmentModule(
911
+ super.forRoot(options),
912
+ providers,
913
+ buildControllers(resolved),
914
+ exportTokens
915
+ );
916
+ }
917
+ /**
918
+ * Register the module asynchronously. The resolved options are produced by
919
+ * the consumer's factory and normalized under {@link BYMAX_CORE_OPTIONS}.
920
+ * Because those options are unknown when the module is defined, the pipeline
921
+ * slots register unconditionally and gate at runtime with transparent
922
+ * pass-throughs. The health controller cannot register conditionally either,
923
+ * since its route metadata is fixed before the async options resolve: it is
924
+ * always registered at the default health path, and its handlers guard
925
+ * every request against the resolved options being disabled or requesting a
926
+ * different path, throwing a descriptive configuration error in either case.
927
+ * The metrics controller follows the same mechanism at the default metrics
928
+ * path; the `BYMAX_METRICS_REGISTRY` factory gates on the resolved options and
929
+ * resolves to a guarded placeholder when metrics are disabled, so the optional
930
+ * peer `prom-client` is never loaded unless metrics are actually enabled.
931
+ *
932
+ * @param options - Async options (factory + inject + imports, or class).
933
+ * @returns The configured `DynamicModule`.
934
+ * @example
935
+ * BymaxCoreModule.forRootAsync({ inject: [Config], useFactory: (c) => ({ ... }) })
936
+ */
937
+ static forRootAsync(options) {
938
+ const providers = [
939
+ {
940
+ provide: BYMAX_CORE_OPTIONS,
941
+ useFactory: (raw) => normalizeCoreOptions(raw),
942
+ inject: [BUILDER_OPTIONS_TOKEN]
943
+ },
944
+ ...buildDefaultProviders(),
945
+ ...buildAsyncSlots(),
946
+ HealthService,
947
+ buildMetricsRegistryProvider(),
948
+ buildMetricsTimingSinkProvider()
949
+ ];
950
+ return augmentModule(
951
+ super.forRootAsync(options),
952
+ providers,
953
+ [createHealthController(DEFAULT_HEALTH_PATH), createMetricsController(DEFAULT_METRICS_PATH)],
954
+ [BYMAX_CORE_OPTIONS, BYMAX_TIMING_SINK, BYMAX_METRICS_REGISTRY]
955
+ );
956
+ }
957
+ };
958
+ exports.BymaxCoreModule = __decorateClass([
959
+ common.Module({})
960
+ ], exports.BymaxCoreModule);
961
+
962
+ exports.BYMAX_BAD_GATEWAY = BYMAX_BAD_GATEWAY;
963
+ exports.BYMAX_BAD_REQUEST = BYMAX_BAD_REQUEST;
964
+ exports.BYMAX_CLIENT_ERROR = BYMAX_CLIENT_ERROR;
965
+ exports.BYMAX_CONFLICT = BYMAX_CONFLICT;
966
+ exports.BYMAX_CORE_OPTIONS = BYMAX_CORE_OPTIONS;
967
+ exports.BYMAX_CORRELATION_PROVIDER = BYMAX_CORRELATION_PROVIDER;
968
+ exports.BYMAX_FORBIDDEN = BYMAX_FORBIDDEN;
969
+ exports.BYMAX_GATEWAY_TIMEOUT = BYMAX_GATEWAY_TIMEOUT;
970
+ exports.BYMAX_HEALTH_INDICATORS = BYMAX_HEALTH_INDICATORS;
971
+ exports.BYMAX_INTERNAL_ERROR = BYMAX_INTERNAL_ERROR;
972
+ exports.BYMAX_METRICS_REGISTRY = BYMAX_METRICS_REGISTRY;
973
+ exports.BYMAX_NOT_FOUND = BYMAX_NOT_FOUND;
974
+ exports.BYMAX_NOT_IMPLEMENTED = BYMAX_NOT_IMPLEMENTED;
975
+ exports.BYMAX_PAYLOAD_TOO_LARGE = BYMAX_PAYLOAD_TOO_LARGE;
976
+ exports.BYMAX_SERVICE_UNAVAILABLE = BYMAX_SERVICE_UNAVAILABLE;
977
+ exports.BYMAX_TIMING_SINK = BYMAX_TIMING_SINK;
978
+ exports.BYMAX_TOO_MANY_REQUESTS = BYMAX_TOO_MANY_REQUESTS;
979
+ exports.BYMAX_UNAUTHORIZED = BYMAX_UNAUTHORIZED;
980
+ exports.BYMAX_UNPROCESSABLE_ENTITY = BYMAX_UNPROCESSABLE_ENTITY;
981
+ exports.BYMAX_UNSUPPORTED_MEDIA_TYPE = BYMAX_UNSUPPORTED_MEDIA_TYPE;
982
+ exports.BYMAX_VALIDATION_FAILED = BYMAX_VALIDATION_FAILED;
983
+ exports.buildErrorEnvelope = buildErrorEnvelope;
984
+ exports.codeForStatus = codeForStatus;