@bymax-one/nest-core 1.5.3 → 1.6.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.mjs CHANGED
@@ -155,6 +155,9 @@ var BYMAX_TIMING_SINK = /* @__PURE__ */ Symbol.for("@bymax-one/nest-core:timing-
155
155
  var BYMAX_HEALTH_INDICATORS = /* @__PURE__ */ Symbol.for(
156
156
  "@bymax-one/nest-core:health-indicators"
157
157
  );
158
+ var BYMAX_HEALTH_TRANSITION_SINK = /* @__PURE__ */ Symbol.for(
159
+ "@bymax-one/nest-core:health-transition-sink"
160
+ );
158
161
  var BYMAX_METRICS_REGISTRY = /* @__PURE__ */ Symbol.for(
159
162
  "@bymax-one/nest-core:metrics-registry"
160
163
  );
@@ -634,6 +637,13 @@ function selectAsyncExceptionFilter(options, correlation, adapterHost) {
634
637
  return options.envelope.enabled ? new BymaxExceptionFilter(options, correlation, adapterHost) : new PassThroughExceptionFilter(adapterHost);
635
638
  }
636
639
 
640
+ // src/contain-rejection.ts
641
+ function containRejection(returned, onFailure) {
642
+ if (typeof returned?.then === "function") {
643
+ Promise.resolve(returned).catch(onFailure);
644
+ }
645
+ }
646
+
637
647
  // src/discovery.ts
638
648
  function labelFor(className, token) {
639
649
  return className === "" ? String(token) : className;
@@ -685,7 +695,7 @@ var TRUNCATION_ELLIPSIS = "...";
685
695
  function summarizeRejection(reason) {
686
696
  let message;
687
697
  try {
688
- message = reason instanceof Error ? reason.message : String(reason);
698
+ message = String(reason instanceof Error ? reason.message : reason);
689
699
  } catch {
690
700
  message = "Unknown error";
691
701
  }
@@ -694,17 +704,27 @@ function summarizeRejection(reason) {
694
704
  }
695
705
  return `${message.slice(0, MAX_ERROR_MESSAGE_LENGTH - TRUNCATION_ELLIPSIS.length)}${TRUNCATION_ELLIPSIS}`;
696
706
  }
697
- async function runIndicator(indicator, timeoutMs, exposeErrors, logger) {
707
+ async function runIndicator(indicator, timeoutMs, exposeErrors) {
698
708
  let timer;
699
709
  const timedOut = new Promise((resolve) => {
700
710
  timer = setTimeout(() => {
701
- resolve({ name: indicator.name, status: "down", details: { timedOutAfterMs: timeoutMs } });
711
+ resolve({
712
+ entry: { name: indicator.name, status: "down", details: { timedOutAfterMs: timeoutMs } },
713
+ isUp: false,
714
+ cause: { kind: "timed-out", timeoutMs }
715
+ });
702
716
  }, timeoutMs);
703
717
  });
704
- const checked = Promise.resolve().then(() => indicator.check()).then((result) => ({ ...result, name: indicator.name })).catch((reason) => {
718
+ const checked = Promise.resolve().then(() => indicator.check()).then((result) => {
719
+ const entry = { ...result, name: indicator.name };
720
+ return result.status === "up" ? { entry, isUp: true } : { entry, isUp: false, cause: { kind: "reported-down" } };
721
+ }).catch((reason) => {
705
722
  const message = summarizeRejection(reason);
706
- logger.warn(`Health indicator "${indicator.name}" reported down: ${message}`);
707
- return exposeErrors ? { name: indicator.name, status: "down", details: { error: message } } : { name: indicator.name, status: "down" };
723
+ return {
724
+ entry: exposeErrors ? { name: indicator.name, status: "down", details: { error: message } } : { name: indicator.name, status: "down" },
725
+ isUp: false,
726
+ cause: { kind: "rejected", message }
727
+ };
708
728
  });
709
729
  try {
710
730
  return await Promise.race([checked, timedOut]);
@@ -712,6 +732,16 @@ async function runIndicator(indicator, timeoutMs, exposeErrors, logger) {
712
732
  clearTimeout(timer);
713
733
  }
714
734
  }
735
+ function describeCause(cause) {
736
+ switch (cause.kind) {
737
+ case "rejected":
738
+ return `the indicator rejected: ${cause.message}`;
739
+ case "timed-out":
740
+ return `the indicator did not answer within ${cause.timeoutMs}ms`;
741
+ case "reported-down":
742
+ return "the indicator reported it down";
743
+ }
744
+ }
715
745
  var HealthService = class {
716
746
  /**
717
747
  * @param indicators - Every explicitly registered indicator; empty when none
@@ -726,18 +756,169 @@ var HealthService = class {
726
756
  * without it when the feature is off.
727
757
  * @param reflector - Nest's metadata reader, used to match the indicator
728
758
  * marker. Optional for the same reason.
759
+ * @param transitionSink - Receives one event per change of readiness state.
760
+ * `@Optional()` and bound to nothing by default, for the same reason as the
761
+ * indicator array: a local default here would shadow a consumer's override.
729
762
  */
730
- constructor(indicators = [], options, discovery, reflector) {
763
+ constructor(indicators = [], options, discovery, reflector, transitionSink) {
731
764
  this.indicators = indicators;
732
765
  this.options = options;
733
766
  this.discovery = discovery;
734
767
  this.reflector = reflector;
768
+ this.transitionSink = transitionSink;
735
769
  /**
736
770
  * Nest's own logger, scoped to this class. The failure reason of a `down`
737
771
  * indicator is written here rather than into the HTTP response, so the
738
772
  * diagnostic survives without being served to whoever can reach the probe.
739
773
  */
740
774
  this.logger = new Logger(HealthService.name);
775
+ /**
776
+ * The last state observed per check name, absent until the first observation,
777
+ * tagged with the probe that observed it. Keyed by name, so one map serves
778
+ * every check and one dependency can neither trigger nor suppress another's
779
+ * line. This is the state that makes the difference between a line per change
780
+ * and a line per probe.
781
+ */
782
+ this.lastState = /* @__PURE__ */ new Map();
783
+ /**
784
+ * Counts readiness probes, so an outcome can be ordered against what is
785
+ * already recorded. Readiness is not called one at a time: an orchestrator's
786
+ * probe and a load balancer's health check reach this concurrently, and this
787
+ * feature's own subject — a dependency that hangs until `indicatorTimeoutMs`
788
+ * elapses — is exactly what makes an earlier probe finish after a later one.
789
+ * Comparing states alone would then let the stale observation win.
790
+ */
791
+ this.probeSequence = 0;
792
+ }
793
+ /**
794
+ * Report one check's current state, emitting only when it differs from the
795
+ * last state observed for that name.
796
+ *
797
+ * The asymmetry on a first observation is deliberate. A first observation that
798
+ * is FAILING is a transition: a process that boots against a dependency
799
+ * already down would otherwise look healthy in the log forever. A first
800
+ * observation that is HEALTHY is not: that is the expected state, and
801
+ * announcing it would write one line per dependency on every boot — the noise
802
+ * the probe-path exclusion exists to keep out.
803
+ *
804
+ * A cause is recorded at the transition, so a dependency that stays down while
805
+ * its failure mode changes underneath keeps the first one. That is the cost of
806
+ * one line per outage instead of one per probe.
807
+ *
808
+ * @param outcome - The indicator's resolved outcome for this probe.
809
+ * @param seq - The probe that produced it, from {@link probeSequence}.
810
+ */
811
+ reportTransition(outcome, seq) {
812
+ const name = outcome.entry.name;
813
+ const previous = this.lastState.get(name);
814
+ if (previous !== void 0 && seq <= previous.seq) {
815
+ return;
816
+ }
817
+ const changed = previous?.isUp !== outcome.isUp;
818
+ this.lastState.set(name, { isUp: outcome.isUp, seq });
819
+ if (!changed) {
820
+ return;
821
+ }
822
+ if (outcome.isUp) {
823
+ if (previous === void 0) {
824
+ return;
825
+ }
826
+ this.describe(`Health check "${name}" recovered`, false);
827
+ this.emit({ name, isUp: true });
828
+ return;
829
+ }
830
+ this.describe(`Health check "${name}" went down: ${describeCause(outcome.cause)}`, true);
831
+ this.emit({ name, isUp: false, cause: outcome.cause });
832
+ }
833
+ /**
834
+ * Write a transition to this package's own logger, unless a sink is bound —
835
+ * the reasoning for standing down is on `IHealthTransitionSink`.
836
+ *
837
+ * Down is a warning, not an error: an unreachable dependency is what readiness
838
+ * exists to route around, while `error` is what pages someone.
839
+ *
840
+ * @param message - The line to write.
841
+ * @param isDown - Whether it describes a check going down, which sets the level.
842
+ */
843
+ describe(message, isDown) {
844
+ if (this.transitionSink !== void 0) {
845
+ return;
846
+ }
847
+ this.writeLine(message, isDown);
848
+ }
849
+ /**
850
+ * Write one line to this package's logger, absorbing a logger that fails.
851
+ *
852
+ * Nest's logger is replaceable, so what this calls is application code. It can
853
+ * throw — most plausibly for the very reason a transition is being reported at
854
+ * all, when the sink and the logger share a backend that is down. Uncontained
855
+ * that throw leaves the readiness path: synchronously it rejects the probe,
856
+ * and from the asynchronous sink handler it becomes an unhandled rejection.
857
+ * Either way a healthy deployment leaves rotation because its logging broke,
858
+ * which is the outcome this whole seam exists to prevent.
859
+ *
860
+ * The failure is swallowed rather than reported, because a logger is the last
861
+ * place a failure could be reported to. There is nowhere left to go.
862
+ *
863
+ * @param message - The line to write.
864
+ * @param asWarning - Whether to write it at warning level.
865
+ */
866
+ writeLine(message, asWarning) {
867
+ try {
868
+ if (asWarning) {
869
+ this.logger.warn(message);
870
+ return;
871
+ }
872
+ this.logger.log(message);
873
+ } catch {
874
+ }
875
+ }
876
+ /**
877
+ * Hand a transition to the consumer's sink, if one is bound. A failure is
878
+ * contained: readiness answering `500` because its own logging broke would
879
+ * take a healthy deployment out of rotation over an observability fault.
880
+ *
881
+ * Both ways a sink can fail are caught, for the same reason `runIndicator`
882
+ * defends against an indicator that throws synchronously — a public seam
883
+ * cannot assume the implementation behind it honors its own signature.
884
+ * `record` is declared to return `void`, but TypeScript accepts any return
885
+ * value in a void-returning position, so `async record()` compiles and is the
886
+ * shape a consumer reaches for when the logger it delegates to is async. Its
887
+ * rejection lands a microtask after the `try` block has exited, which is an
888
+ * unhandled rejection rather than the contained failure documented on the
889
+ * contract.
890
+ *
891
+ * Whatever comes back is assimilated with `Promise.resolve`, not tested with
892
+ * `instanceof Promise`: `Promise` is a per-realm binding, so an `async` sink
893
+ * defined in another realm returns a native promise that fails `instanceof`
894
+ * here, and a userland promise library's result is not an instance either.
895
+ * Assimilation is the language's own thenable test, and it is inert for the
896
+ * `undefined` an ordinary synchronous sink returns.
897
+ *
898
+ * @param transition - The event to deliver.
899
+ */
900
+ emit(transition) {
901
+ if (this.transitionSink === void 0) {
902
+ return;
903
+ }
904
+ try {
905
+ containRejection(this.transitionSink.record(transition), (error) => {
906
+ this.reportSinkFailure(error);
907
+ });
908
+ } catch (error) {
909
+ this.reportSinkFailure(error);
910
+ }
911
+ }
912
+ /**
913
+ * Log a sink failure, without letting it reach the probe.
914
+ *
915
+ * @param error - Whatever the sink threw or rejected with.
916
+ */
917
+ reportSinkFailure(error) {
918
+ this.writeLine(
919
+ `Health transition sink threw and was ignored: ${summarizeRejection(error)}`,
920
+ true
921
+ );
741
922
  }
742
923
  /**
743
924
  * Resolve the readiness set once the whole container is instantiated.
@@ -799,11 +980,14 @@ var HealthService = class {
799
980
  async checkReadiness() {
800
981
  const timeoutMs = this.options.health.indicatorTimeoutMs;
801
982
  const exposeErrors = this.options.health.exposeIndicatorErrors;
802
- const checks = await Promise.all(
803
- this.resolveIndicators().map(
804
- (indicator) => runIndicator(indicator, timeoutMs, exposeErrors, this.logger)
805
- )
983
+ const seq = ++this.probeSequence;
984
+ const outcomes = await Promise.all(
985
+ this.resolveIndicators().map((indicator) => runIndicator(indicator, timeoutMs, exposeErrors))
806
986
  );
987
+ for (const outcome of outcomes) {
988
+ this.reportTransition(outcome, seq);
989
+ }
990
+ const checks = outcomes.map((outcome) => outcome.entry);
807
991
  const status = checks.every((check) => check.status === "up") ? "ok" : "error";
808
992
  return { status, checks };
809
993
  }
@@ -816,7 +1000,9 @@ HealthService = __decorateClass([
816
1000
  __decorateParam(2, Optional()),
817
1001
  __decorateParam(2, Inject(DiscoveryService)),
818
1002
  __decorateParam(3, Optional()),
819
- __decorateParam(3, Inject(Reflector))
1003
+ __decorateParam(3, Inject(Reflector)),
1004
+ __decorateParam(4, Optional()),
1005
+ __decorateParam(4, Inject(BYMAX_HEALTH_TRANSITION_SINK))
820
1006
  ], HealthService);
821
1007
 
822
1008
  // src/health/health.controller.ts
@@ -1204,6 +1390,13 @@ function buildTimingSample(input) {
1204
1390
  ...trace !== void 0 ? { traceId: trace.traceId, spanId: trace.spanId } : {}
1205
1391
  };
1206
1392
  }
1393
+ function deliverSample(sink, sample) {
1394
+ try {
1395
+ containRejection(sink.record(sample), () => {
1396
+ });
1397
+ } catch {
1398
+ }
1399
+ }
1207
1400
 
1208
1401
  // src/timing/timing.middleware.ts
1209
1402
  var BymaxTimingMiddleware = class {
@@ -1253,7 +1446,8 @@ var BymaxTimingMiddleware = class {
1253
1446
  next();
1254
1447
  }
1255
1448
  /**
1256
- * Build the sample and hand it to the sink, guarding both steps.
1449
+ * Build the sample and deliver it. `deliverSample` absorbs whatever the sink
1450
+ * does with it, so a broken sink never reaches the request it observes.
1257
1451
  *
1258
1452
  * @param request - The framework request object.
1259
1453
  * @param response - The framework response object.
@@ -1271,10 +1465,7 @@ var BymaxTimingMiddleware = class {
1271
1465
  threshold: this.options.timing.slowRequestThresholdMs,
1272
1466
  trace
1273
1467
  });
1274
- try {
1275
- this.sink.record(sample);
1276
- } catch {
1277
- }
1468
+ deliverSample(this.sink, sample);
1278
1469
  }
1279
1470
  };
1280
1471
  BymaxTimingMiddleware = __decorateClass([
@@ -1590,9 +1781,8 @@ var TimingInterceptor = class {
1590
1781
  return error instanceof HttpException ? error.getStatus() : UNKNOWN_ERROR_STATUS;
1591
1782
  }
1592
1783
  /**
1593
- * Build the sample and deliver it to the sink inside a try/catch that
1594
- * silences any failure: a throwing sink must never affect the request it is
1595
- * observing.
1784
+ * Build the sample and deliver it. `deliverSample` absorbs whatever the sink
1785
+ * does with it: a broken sink must never affect the request it observes.
1596
1786
  *
1597
1787
  * @param method - HTTP method of the request.
1598
1788
  * @param route - Route template of the request.
@@ -1608,10 +1798,7 @@ var TimingInterceptor = class {
1608
1798
  threshold: this.options.timing.slowRequestThresholdMs,
1609
1799
  trace: readTraceContext(this.traceContext)
1610
1800
  });
1611
- try {
1612
- this.sink.record(sample);
1613
- } catch {
1614
- }
1801
+ deliverSample(this.sink, sample);
1615
1802
  }
1616
1803
  };
1617
1804
  TimingInterceptor = __decorateClass([
@@ -1624,4 +1811,4 @@ TimingInterceptor = __decorateClass([
1624
1811
  __decorateParam(3, Inject(BYMAX_TRACE_CONTEXT))
1625
1812
  ], TimingInterceptor);
1626
1813
 
1627
- 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 };
1814
+ 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_HEALTH_TRANSITION_SINK, 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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bymax-one/nest-core",
3
- "version": "1.5.3",
3
+ "version": "1.6.0",
4
4
  "description": "Zero-dependency NestJS 11 application foundation kit: error-envelope exception filter, request-timing interceptor, 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",