@bymax-one/nest-core 1.6.0 → 1.6.2
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 +136 -1
- package/README.md +108 -23
- package/dist/index.cjs +85 -36
- package/dist/index.d.cts +119 -5
- package/dist/index.d.ts +119 -5
- package/dist/index.mjs +85 -37
- package/dist/openapi/index.cjs +107 -34
- package/dist/openapi/index.d.cts +97 -53
- package/dist/openapi/index.d.ts +97 -53
- package/dist/openapi/index.mjs +107 -34
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,139 @@ heading here.
|
|
|
11
11
|
|
|
12
12
|
## [Unreleased]
|
|
13
13
|
|
|
14
|
+
## [1.6.2] - 2026-10-22
|
|
15
|
+
|
|
16
|
+
An unexpected error became `500 BYMAX_INTERNAL_ERROR` and the application had no
|
|
17
|
+
way to record what it was. `BymaxCoreModule` builds the filter itself, and the
|
|
18
|
+
only seam — the protected `onUnexpectedError` — is reachable only by subclassing
|
|
19
|
+
a filter the module never lets you substitute. So the access log saw the status
|
|
20
|
+
and nothing else, and a 500 on a read endpoint left no recoverable cause.
|
|
21
|
+
|
|
22
|
+
**This patch adds public surface**, which a patch number cannot tell you: the
|
|
23
|
+
`BYMAX_UNEXPECTED_ERROR_SINK` token and the `IUnexpectedErrorSink` and
|
|
24
|
+
`UnexpectedErrorReport` types. All are additive and nothing is removed or
|
|
25
|
+
renamed, so the digit stays at a patch under this project's versioning policy.
|
|
26
|
+
|
|
27
|
+
**Apply to a derived backend:** nothing is required; with no sink bound the
|
|
28
|
+
filter behaves as before. To record each unexpected error's cause, bind an
|
|
29
|
+
`IUnexpectedErrorSink` under `BYMAX_UNEXPECTED_ERROR_SINK` from a `@Global()`
|
|
30
|
+
module of your own, as the README's "Recording the cause of a 500" shows. A
|
|
31
|
+
backend registered through `forRootAsync` with `telemetry.exposeTraceId` on
|
|
32
|
+
starts serving `traceId` in its error envelopes, which it was configured to do
|
|
33
|
+
and did not; see Fixed.
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- **`BYMAX_UNEXPECTED_ERROR_SINK`, `IUnexpectedErrorSink` and
|
|
38
|
+
`UnexpectedErrorReport`.** The filter calls `record(report)` once per
|
|
39
|
+
unexpected error, before replying, with the original thrown value, the
|
|
40
|
+
method, the matched route template (`<unmatched>` before routing), and the
|
|
41
|
+
correlation and trace ids when they resolve. An `HttpException` and an
|
|
42
|
+
exposed `4xx` from the body pipeline never reach it.
|
|
43
|
+
|
|
44
|
+
The report carries no raw URL, query string, headers or body: a log outlives
|
|
45
|
+
the request and a query string is where tokens end up. The thrown value is
|
|
46
|
+
passed uncut whatever `envelope.exposeInternals` says, since that option
|
|
47
|
+
governs the response and the sink is not the response.
|
|
48
|
+
|
|
49
|
+
The response never depends on the sink. A synchronous throw is caught and an
|
|
50
|
+
`async record()` rejection is contained, both silently. The interface is
|
|
51
|
+
structural, so a logging library can ship a compatible class without
|
|
52
|
+
importing this package. The protected `onUnexpectedError` seam is unchanged
|
|
53
|
+
and runs alongside the sink.
|
|
54
|
+
|
|
55
|
+
### Fixed
|
|
56
|
+
|
|
57
|
+
- **`forRootAsync` never put the trace id in the error envelope.** The async
|
|
58
|
+
filter slot is built by a factory whose injection list did not include
|
|
59
|
+
`BYMAX_TRACE_CONTEXT`, so the filter fell back to its no-op reader: with
|
|
60
|
+
`telemetry.exposeTraceId` on, `forRoot` served `traceId` and `forRootAsync`
|
|
61
|
+
silently did not. The factory now injects every dependency the sync path
|
|
62
|
+
resolves through the class.
|
|
63
|
+
|
|
64
|
+
## [1.6.1] - 2026-09-05
|
|
65
|
+
|
|
66
|
+
A library contributing an OpenAPI component under a name the application already
|
|
67
|
+
declared had its entry discarded, and neither side could tell. The document
|
|
68
|
+
validated, the served output looked exactly as it would have had nothing been
|
|
69
|
+
offered, and the contributing library received no signal at all — so the failure
|
|
70
|
+
was indistinguishable from success at both ends.
|
|
71
|
+
|
|
72
|
+
Two changes, and they are independent: an empty placeholder no longer counts as a
|
|
73
|
+
declaration, and every genuine collision is now reported.
|
|
74
|
+
|
|
75
|
+
**This patch adds public surface**, which a patch number cannot tell you:
|
|
76
|
+
`discarded` on `OpenApiMountOutcome`, and the `DiscardedComponent` type it holds.
|
|
77
|
+
Both are additive and nothing is removed or renamed, so the digit stays at a
|
|
78
|
+
patch under this project's versioning policy — which means this paragraph, rather
|
|
79
|
+
than the version, is what says there is something new to look for.
|
|
80
|
+
|
|
81
|
+
**Apply to a derived backend:** nothing is required, and no served document loses
|
|
82
|
+
anything it carried. A schema the application declares with a shape of its own
|
|
83
|
+
still wins, exactly as before. What changes is that a schema present only as
|
|
84
|
+
`{"type":"object","properties":{}}` — which `@nestjs/swagger` emits for every DTO
|
|
85
|
+
in a package built without its plugin — is now filled from the contribution
|
|
86
|
+
rather than preferred over it. Read `discarded` on the `OpenApiMountOutcome` to
|
|
87
|
+
see what a contributor offered and did not place; the boot log reports the same
|
|
88
|
+
entries.
|
|
89
|
+
|
|
90
|
+
### Added
|
|
91
|
+
|
|
92
|
+
- **`discarded` on `OpenApiMountOutcome`, and the `DiscardedComponent` type.**
|
|
93
|
+
Lists every contributed component entry the document already declared with a
|
|
94
|
+
shape of its own, naming the contributor, the member and the entry. Present
|
|
95
|
+
whenever the document mounted, empty when every contribution was placed — an
|
|
96
|
+
empty array reports that the merge looked and found none, which absence would
|
|
97
|
+
confuse with not having looked.
|
|
98
|
+
|
|
99
|
+
Carries the contributor's label because a collision is only actionable if you
|
|
100
|
+
know whose entry lost: an application with several contributing libraries would
|
|
101
|
+
otherwise be told a name was dropped without being told by whom.
|
|
102
|
+
|
|
103
|
+
A warning reports the same entries on the way up, elided into a count on the
|
|
104
|
+
same reasoning as the unsecured-operation line beside it — a library contributing a
|
|
105
|
+
schema per DTO turns one collision into a hundred, and the version that prints
|
|
106
|
+
them all is the one an operator learns to skip.
|
|
107
|
+
|
|
108
|
+
### Changed
|
|
109
|
+
|
|
110
|
+
- **A schema that describes no shape no longer counts as a declaration.** The
|
|
111
|
+
merge still prefers what the document carries: a consumer authored theirs on
|
|
112
|
+
purpose, and a library silently overwriting it is the worse defect wearing the
|
|
113
|
+
other sign. But `@nestjs/swagger` emits `{"type":"object","properties":{}}` for
|
|
114
|
+
every DTO in a package built without its plugin, so the name is never actually
|
|
115
|
+
missing and a contributed schema under it would never be written. The more
|
|
116
|
+
correct the library's own schema, the more completely it was discarded, because
|
|
117
|
+
a correct schema and an empty one collide on the same key.
|
|
118
|
+
|
|
119
|
+
This is the rule `mergeResponses` already applied to responses, arriving
|
|
120
|
+
through a different placeholder — a `200` carrying a description and no
|
|
121
|
+
`content` occupies its status the same way. Filling one in is additive: it
|
|
122
|
+
constrains nothing before and describes a shape after.
|
|
123
|
+
|
|
124
|
+
The test is deliberately narrow. `type` must be absent or `object`, because
|
|
125
|
+
`{"type":"string"}` carries no properties and is nonetheless complete;
|
|
126
|
+
reading "no properties" as "no shape" would discard a real schema, which is
|
|
127
|
+
the failure this path exists to prevent. An empty `required` and a
|
|
128
|
+
`description` do not change that; any other keyword, or a single property,
|
|
129
|
+
makes the entry a declaration. A description the document
|
|
130
|
+
wrote survives being filled in, the same carve-out `mergeResponses` makes.
|
|
131
|
+
|
|
132
|
+
The carve-out applies to `schemas` alone. A parameter carries `name` and `in`
|
|
133
|
+
rather than `properties`, so a schema-shaped emptiness test reads every one of
|
|
134
|
+
them as a placeholder and would let a contributor overwrite one the consumer
|
|
135
|
+
wrote — the precedence failure inverted, arriving through the fix for the
|
|
136
|
+
other one.
|
|
137
|
+
|
|
138
|
+
This package's own catalogue, under `openapi.includeCoreSchemas`, is placed by
|
|
139
|
+
the same rule and before any library. A placeholder under a catalogue name is
|
|
140
|
+
filled with the package's definition, so a library offering that name meets a
|
|
141
|
+
declaration and is reported rather than outranking the package.
|
|
142
|
+
|
|
143
|
+
- **`fastify` and `@nestjs/platform-fastify` devDependency floors raised to
|
|
144
|
+
`^5.12.5` and `^11.2.4`.** Shipped in the tarball's manifest only; `dist/` is
|
|
145
|
+
unchanged by them.
|
|
146
|
+
|
|
14
147
|
## [1.6.0] - 2026-08-29
|
|
15
148
|
|
|
16
149
|
A readiness check that fails without rejecting left no record an operator would
|
|
@@ -1003,4 +1136,6 @@ have regressed from. They are kept because the reasoning is worth having.
|
|
|
1003
1136
|
[1.5.2]: https://github.com/bymaxone/nest-core/compare/v1.5.1...v1.5.2
|
|
1004
1137
|
[1.5.3]: https://github.com/bymaxone/nest-core/compare/v1.5.2...v1.5.3
|
|
1005
1138
|
[1.6.0]: https://github.com/bymaxone/nest-core/compare/v1.5.3...v1.6.0
|
|
1006
|
-
[
|
|
1139
|
+
[1.6.1]: https://github.com/bymaxone/nest-core/compare/v1.6.0...v1.6.1
|
|
1140
|
+
[1.6.2]: https://github.com/bymaxone/nest-core/compare/v1.6.1...v1.6.2
|
|
1141
|
+
[Unreleased]: https://github.com/bymaxone/nest-core/compare/v1.6.2...HEAD
|
package/README.md
CHANGED
|
@@ -516,8 +516,9 @@ requirement.
|
|
|
516
516
|
## 🔑 DI Tokens
|
|
517
517
|
|
|
518
518
|
Every token is a `Symbol`. `BYMAX_CORRELATION_PROVIDER`,
|
|
519
|
-
`BYMAX_HEALTH_INDICATORS
|
|
520
|
-
`@Optional()` and are not bound
|
|
519
|
+
`BYMAX_HEALTH_INDICATORS`, `BYMAX_HEALTH_TRANSITION_SINK` and
|
|
520
|
+
`BYMAX_UNEXPECTED_ERROR_SINK` are consumed with `@Optional()` and are not bound
|
|
521
|
+
by the module: provide any of them from your own module to supply your own
|
|
521
522
|
implementation, otherwise the internal fallback in the last column applies.
|
|
522
523
|
`BYMAX_TIMING_SINK` and `BYMAX_METRICS_REGISTRY` behave differently on
|
|
523
524
|
`forRootAsync`, where options resolve after the module is defined: there the
|
|
@@ -535,6 +536,7 @@ below.
|
|
|
535
536
|
| `BYMAX_TIMING_SINK` | `ITimingSink` | internal no-op, or the metrics bridge when timing and metrics are both enabled |
|
|
536
537
|
| `BYMAX_HEALTH_INDICATORS` | `IHealthIndicator[]` | treated as an empty indicator set |
|
|
537
538
|
| `BYMAX_HEALTH_TRANSITION_SINK` | `IHealthTransitionSink` | no sink; readiness transitions go to Nest's logger instead (binding one stands that line down) |
|
|
539
|
+
| `BYMAX_UNEXPECTED_ERROR_SINK` | `IUnexpectedErrorSink` | no sink; the cause of a `500 BYMAX_INTERNAL_ERROR` is not recorded anywhere |
|
|
538
540
|
| `BYMAX_METRICS_REGISTRY` | the `prom-client` `Registry` | bound when metrics are enabled; on `forRootAsync` always registered, guarded-placeholder when off |
|
|
539
541
|
| `BYMAX_TRACE_CONTEXT` | `ITraceContextProvider` | bound on every path: the OpenTelemetry reader when telemetry is enabled, a no-op that resolves no trace otherwise |
|
|
540
542
|
|
|
@@ -667,6 +669,62 @@ what exhausts the stack leaves a wide margin: one consumer runs `32`, against
|
|
|
667
669
|
the ~2000 levels that overflow. Walk the parsed body iteratively — a recursive
|
|
668
670
|
depth check on a hostile payload overflows the stack it was written to protect.
|
|
669
671
|
|
|
672
|
+
### Recording the cause of a 500
|
|
673
|
+
|
|
674
|
+
An error the filter does not recognise collapses to a fixed
|
|
675
|
+
`500 BYMAX_INTERNAL_ERROR`, and that collapse is the point: the client learns
|
|
676
|
+
nothing about your internals. It also means that once the response is written,
|
|
677
|
+
the thrown value is gone — an access log sees only the status. Bind an
|
|
678
|
+
`IUnexpectedErrorSink` and the filter hands it the original value first:
|
|
679
|
+
|
|
680
|
+
```typescript
|
|
681
|
+
import { Global, Logger, Module } from '@nestjs/common'
|
|
682
|
+
import { BYMAX_UNEXPECTED_ERROR_SINK } from '@bymax-one/nest-core'
|
|
683
|
+
import type { IUnexpectedErrorSink, UnexpectedErrorReport } from '@bymax-one/nest-core'
|
|
684
|
+
|
|
685
|
+
export class UnexpectedErrorLogger implements IUnexpectedErrorSink {
|
|
686
|
+
private readonly logger = new Logger('UnexpectedError')
|
|
687
|
+
|
|
688
|
+
record({ error, method, route, correlationId }: UnexpectedErrorReport): void {
|
|
689
|
+
this.logger.error(
|
|
690
|
+
`${method} ${route} failed (correlationId=${correlationId ?? '-'})`,
|
|
691
|
+
error instanceof Error ? error.stack : String(error)
|
|
692
|
+
)
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
@Global()
|
|
697
|
+
@Module({
|
|
698
|
+
providers: [{ provide: BYMAX_UNEXPECTED_ERROR_SINK, useClass: UnexpectedErrorLogger }],
|
|
699
|
+
exports: [BYMAX_UNEXPECTED_ERROR_SINK]
|
|
700
|
+
})
|
|
701
|
+
export class ErrorReportingModule {}
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
The sink is called once per unexpected error, on both registration paths, and
|
|
705
|
+
for nothing else: an `HttpException` is a deliberate answer and an exposed `4xx`
|
|
706
|
+
from the body pipeline is a client mistake, so neither reaches it. The report
|
|
707
|
+
carries the thrown value as it was thrown, the method, the route **template**
|
|
708
|
+
(`/invoices/:id`, or `<unmatched>` for an error raised before routing), and the
|
|
709
|
+
correlation and trace ids when they resolve. It carries no raw URL, query
|
|
710
|
+
string, headers or body.
|
|
711
|
+
|
|
712
|
+
The response never depends on the sink. A sink that throws, or an
|
|
713
|
+
`async record()` that rejects, is absorbed and the client receives the same
|
|
714
|
+
envelope it would have received with no sink bound, so a broken logger cannot
|
|
715
|
+
turn one failure into two. It runs synchronously before the reply, so hand the
|
|
716
|
+
report off and return.
|
|
717
|
+
|
|
718
|
+
The value is passed uncut whatever `envelope.exposeInternals` says, since that
|
|
719
|
+
option governs the response body and the sink is not the response. A driver's
|
|
720
|
+
error message can carry hosts and query fragments: send the report where access
|
|
721
|
+
is already controlled.
|
|
722
|
+
|
|
723
|
+
`IUnexpectedErrorSink` is matched by shape, so a library can ship a compatible
|
|
724
|
+
class without importing this package. Subclassing `BymaxExceptionFilter` and
|
|
725
|
+
overriding `onUnexpectedError` still works and runs alongside the sink; the
|
|
726
|
+
sink is what reaches the filter `BymaxCoreModule` registers itself.
|
|
727
|
+
|
|
670
728
|
## ⏱️ Request Timing
|
|
671
729
|
|
|
672
730
|
One `RequestTimingSample` is delivered to whatever implements `ITimingSink` for
|
|
@@ -1163,10 +1221,35 @@ can emit it once and never branch:
|
|
|
1163
1221
|
|
|
1164
1222
|
| Result | Meaning |
|
|
1165
1223
|
| ------------------------------------------ | ------------------------------------------------------- |
|
|
1166
|
-
| `{ mounted: true, path }`
|
|
1224
|
+
| `{ mounted: true, path, discarded }` | The UI and the document are served at `path`. |
|
|
1167
1225
|
| `{ mounted: false, reason: 'disabled' }` | `openapi.enabled` is off. |
|
|
1168
1226
|
| `{ mounted: false, reason: 'production' }` | The runtime is production. Nothing was built or served. |
|
|
1169
1227
|
|
|
1228
|
+
`discarded` lists every component entry a library contributed that the served
|
|
1229
|
+
document already defined with a shape of its own — a `DiscardedComponent` per
|
|
1230
|
+
entry, naming the contributor, the member and the name. It is empty when every
|
|
1231
|
+
contribution was placed, and the boot log reports the same entries.
|
|
1232
|
+
|
|
1233
|
+
A collision is not an error and does not stop the boot: the merge prefers
|
|
1234
|
+
whatever holds the name already. That is usually your own declaration, and you
|
|
1235
|
+
authored it on purpose — but it may also be a core schema this package
|
|
1236
|
+
contributes under `openapi.includeCoreSchemas`, or an earlier contributor's
|
|
1237
|
+
entry, since contributors merge in class-name order. The report names what was
|
|
1238
|
+
dropped and by whom, not who owns the definition that won, so read the document
|
|
1239
|
+
before deciding what to remove.
|
|
1240
|
+
|
|
1241
|
+
It is reported because it is otherwise unobservable from either side: the
|
|
1242
|
+
contributing library cannot tell its entry lost, the application cannot tell one
|
|
1243
|
+
was offered, and the served document looks the same either way.
|
|
1244
|
+
|
|
1245
|
+
A schema present only as `{"type":"object","properties":{}}` is **not** a
|
|
1246
|
+
declaration for this purpose. `@nestjs/swagger` emits exactly that for every DTO
|
|
1247
|
+
in a package built without its plugin, so such a name is filled from the
|
|
1248
|
+
contribution rather than preferred over it, and does not appear in `discarded`.
|
|
1249
|
+
An empty `required: []` and a `description` do not change that; anything else
|
|
1250
|
+
does. `{"type":"object"}` and `{}` are deliberate declarations — "any object" and
|
|
1251
|
+
"any value" — and are kept.
|
|
1252
|
+
|
|
1170
1253
|
### Production is a closed door
|
|
1171
1254
|
|
|
1172
1255
|
`NODE_ENV` decides, and the decision is fail-closed: only `development` and
|
|
@@ -1714,26 +1797,27 @@ in the sections above.
|
|
|
1714
1797
|
|
|
1715
1798
|
### `.` (root)
|
|
1716
1799
|
|
|
1717
|
-
| Export
|
|
1718
|
-
|
|
|
1719
|
-
| `BymaxCoreModule`
|
|
1720
|
-
| `BymaxCoreModuleOptions`, `EnvelopeOptions`, `TimingOptions`, `HealthOptions`, `MetricsOptions`, `TelemetryOptions`, `OpenApiOptions`, `OpenApiServerDescriptor`, `OpenApiSecurityScheme`, `ResolvedCoreOptions`
|
|
1721
|
-
| `OpenApiSecurityRequirement`, `OpenApiHttpMethod`, `OpenApiOperationKey`, `OperationSecurityMap`
|
|
1722
|
-
| `OpenApiOperationIdFactory`
|
|
1723
|
-
| `BYMAX_CORE_OPTIONS`, `BYMAX_CORRELATION_PROVIDER`, `BYMAX_TIMING_SINK`, `BYMAX_HEALTH_INDICATORS`, `BYMAX_HEALTH_TRANSITION_SINK`, `BYMAX_METRICS_REGISTRY`, `BYMAX_TRACE_CONTEXT`
|
|
1724
|
-
| `IHealthTransitionSink`, `HealthTransition`, `HealthTransitionCause`
|
|
1725
|
-
| `ICorrelationIdProvider`
|
|
1726
|
-
| `ITraceContextProvider`, `TraceContext`
|
|
1727
|
-
| `BymaxExceptionFilter`
|
|
1728
|
-
| `FilterErrorContext`
|
|
1729
|
-
| `
|
|
1730
|
-
| `
|
|
1731
|
-
| `
|
|
1732
|
-
| `
|
|
1733
|
-
| `
|
|
1734
|
-
| `
|
|
1735
|
-
| `
|
|
1736
|
-
| `
|
|
1800
|
+
| Export | Kind | Description |
|
|
1801
|
+
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1802
|
+
| `BymaxCoreModule` | class | The dynamic module: `forRoot` and `forRootAsync`. |
|
|
1803
|
+
| `BymaxCoreModuleOptions`, `EnvelopeOptions`, `TimingOptions`, `HealthOptions`, `MetricsOptions`, `TelemetryOptions`, `OpenApiOptions`, `OpenApiServerDescriptor`, `OpenApiSecurityScheme`, `ResolvedCoreOptions` | types | The options surface and its resolved shape. |
|
|
1804
|
+
| `OpenApiSecurityRequirement`, `OpenApiHttpMethod`, `OpenApiOperationKey`, `OperationSecurityMap` | types | The operation-key contract a sibling library targets to ship its own security map. |
|
|
1805
|
+
| `OpenApiOperationIdFactory` | type | Names the operations in the generated document. |
|
|
1806
|
+
| `BYMAX_CORE_OPTIONS`, `BYMAX_CORRELATION_PROVIDER`, `BYMAX_TIMING_SINK`, `BYMAX_HEALTH_INDICATORS`, `BYMAX_HEALTH_TRANSITION_SINK`, `BYMAX_UNEXPECTED_ERROR_SINK`, `BYMAX_METRICS_REGISTRY`, `BYMAX_TRACE_CONTEXT` | tokens | The DI tokens; see the [token table](#-di-tokens). |
|
|
1807
|
+
| `IHealthTransitionSink`, `HealthTransition`, `HealthTransitionCause` | types | The readiness-transition contract; also on `./health`. |
|
|
1808
|
+
| `ICorrelationIdProvider` | type | The correlation-provider contract. |
|
|
1809
|
+
| `ITraceContextProvider`, `TraceContext` | types | The trace-context contract and the identifiers it resolves. |
|
|
1810
|
+
| `BymaxExceptionFilter` | class | The envelope exception filter. |
|
|
1811
|
+
| `FilterErrorContext` | type | The neutral request context passed to the filter's observability seam. |
|
|
1812
|
+
| `IUnexpectedErrorSink`, `UnexpectedErrorReport` | types | Receives the cause behind each collapsed `500 BYMAX_INTERNAL_ERROR`; see [Recording the cause of a 500](#recording-the-cause-of-a-500). |
|
|
1813
|
+
| `buildErrorEnvelope` | function | Pure builder assembling an `ErrorEnvelope`. |
|
|
1814
|
+
| `ErrorEnvelope`, `ErrorDetails`, `BuildErrorEnvelopeInput` | types | The envelope contract and its builder input. |
|
|
1815
|
+
| `BymaxTimingMiddleware` | class | The request-timing middleware, applied to every route when timing is enabled. |
|
|
1816
|
+
| `UNMATCHED_ROUTE` | constant | The bounded label recorded when a request matched no route (`<unmatched>`). |
|
|
1817
|
+
| `TimingInterceptor` | class | Deprecated: superseded by `BymaxTimingMiddleware`, which the module registers. |
|
|
1818
|
+
| `ITimingSink`, `RequestTimingSample` | types | The timing-sink contract and its sample shape. |
|
|
1819
|
+
| `BYMAX_BAD_GATEWAY` … `BYMAX_VALIDATION_FAILED` | constants | The full error-code catalog (see [Error envelope](#-error-envelope)). |
|
|
1820
|
+
| `codeForStatus` | function | Derives a catalog code from an HTTP status. |
|
|
1737
1821
|
|
|
1738
1822
|
### `./pagination`
|
|
1739
1823
|
|
|
@@ -1773,6 +1857,7 @@ in the sections above.
|
|
|
1773
1857
|
| -------------------------------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------ |
|
|
1774
1858
|
| `applyBymaxOpenApi` | function | Builds and mounts the document; call it before `app.listen()`. |
|
|
1775
1859
|
| `OpenApiMountOutcome` | type | What the helper did: mounted at a path, or skipped with a reason. |
|
|
1860
|
+
| `DiscardedComponent` | type | A contributed component entry the document already declared. |
|
|
1776
1861
|
| `OpenApiSkipReason` | type | Why it was skipped: `'disabled'` or `'production'`. |
|
|
1777
1862
|
| `BymaxOpenApiContributor`, `BYMAX_OPENAPI_CONTRIBUTOR_METADATA` | decorator, constant | Marks a provider as describing its own routes, and the metadata key behind it. |
|
|
1778
1863
|
| `IOpenApiContributor`, `OpenApiFragment`, `OpenApiFragmentObject`, `OpenApiHandlerKey` | types | The contributor contract and the shape of what it returns. |
|
package/dist/index.cjs
CHANGED
|
@@ -160,6 +160,9 @@ var BYMAX_HEALTH_INDICATORS = /* @__PURE__ */ Symbol.for(
|
|
|
160
160
|
var BYMAX_HEALTH_TRANSITION_SINK = /* @__PURE__ */ Symbol.for(
|
|
161
161
|
"@bymax-one/nest-core:health-transition-sink"
|
|
162
162
|
);
|
|
163
|
+
var BYMAX_UNEXPECTED_ERROR_SINK = /* @__PURE__ */ Symbol.for(
|
|
164
|
+
"@bymax-one/nest-core:unexpected-error-sink"
|
|
165
|
+
);
|
|
163
166
|
var BYMAX_METRICS_REGISTRY = /* @__PURE__ */ Symbol.for(
|
|
164
167
|
"@bymax-one/nest-core:metrics-registry"
|
|
165
168
|
);
|
|
@@ -265,6 +268,13 @@ function buildTraceContextProvider() {
|
|
|
265
268
|
};
|
|
266
269
|
}
|
|
267
270
|
|
|
271
|
+
// src/contain-rejection.ts
|
|
272
|
+
function containRejection(returned, onFailure) {
|
|
273
|
+
if (typeof returned?.then === "function") {
|
|
274
|
+
Promise.resolve(returned).catch(onFailure);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
268
278
|
// src/envelope/error-codes.ts
|
|
269
279
|
var BYMAX_BAD_REQUEST = "BYMAX_BAD_REQUEST";
|
|
270
280
|
var BYMAX_VALIDATION_FAILED = "BYMAX_VALIDATION_FAILED";
|
|
@@ -328,6 +338,23 @@ function buildErrorEnvelope(input) {
|
|
|
328
338
|
};
|
|
329
339
|
}
|
|
330
340
|
|
|
341
|
+
// src/timing/request-info.accessor.ts
|
|
342
|
+
var UNMATCHED_ROUTE = "<unmatched>";
|
|
343
|
+
function readExpressTemplate(request) {
|
|
344
|
+
const path = request.route?.path;
|
|
345
|
+
return path === void 0 ? void 0 : `${request.baseUrl ?? ""}${path}`;
|
|
346
|
+
}
|
|
347
|
+
function readFastifyTemplate(request) {
|
|
348
|
+
return request.routeOptions?.url;
|
|
349
|
+
}
|
|
350
|
+
function readRequestInfo(request) {
|
|
351
|
+
const template = readExpressTemplate(request) ?? readFastifyTemplate(request);
|
|
352
|
+
return { method: request.method ?? "", route: template ?? UNMATCHED_ROUTE };
|
|
353
|
+
}
|
|
354
|
+
function extractRequestInfo(context) {
|
|
355
|
+
return readRequestInfo(context.switchToHttp().getRequest());
|
|
356
|
+
}
|
|
357
|
+
|
|
331
358
|
// src/envelope/exception.filter.ts
|
|
332
359
|
var INTERNAL_ERROR_STATUS = 500;
|
|
333
360
|
var INTERNAL_ERROR_MESSAGE = "Internal server error";
|
|
@@ -408,10 +435,13 @@ exports.BymaxExceptionFilter = class BymaxExceptionFilter {
|
|
|
408
435
|
* (from their own, globally-visible module) is not shadowed by one; when
|
|
409
436
|
* nothing is bound, this falls back to a no-op that omits `correlationId`.
|
|
410
437
|
* @param adapterHost - Host of the live HTTP adapter, resolved lazily per catch.
|
|
438
|
+
* @param traceContext - Reader for the active trace; a no-op when unbound.
|
|
439
|
+
* @param unexpectedErrorSink - Receives every unexpected error; unbound by default.
|
|
411
440
|
*/
|
|
412
|
-
constructor(options, correlation, adapterHost, traceContext) {
|
|
441
|
+
constructor(options, correlation, adapterHost, traceContext, unexpectedErrorSink) {
|
|
413
442
|
this.options = options;
|
|
414
443
|
this.adapterHost = adapterHost;
|
|
444
|
+
this.unexpectedErrorSink = unexpectedErrorSink;
|
|
415
445
|
/** Clock used to stamp the envelope timestamp; overridable in tests via fake timers. */
|
|
416
446
|
this.now = () => /* @__PURE__ */ new Date();
|
|
417
447
|
this.correlation = correlation ?? new NoopCorrelationIdProvider();
|
|
@@ -470,19 +500,20 @@ exports.BymaxExceptionFilter = class BymaxExceptionFilter {
|
|
|
470
500
|
...correlationId !== void 0 ? { correlationId } : {},
|
|
471
501
|
...traceId !== void 0 ? { traceId } : {}
|
|
472
502
|
};
|
|
473
|
-
const envelope = this.buildEnvelope(exception, context);
|
|
503
|
+
const envelope = this.buildEnvelope(exception, context, request);
|
|
474
504
|
httpAdapter.reply(response, envelope, envelope.statusCode);
|
|
475
505
|
}
|
|
476
506
|
/**
|
|
477
507
|
* Select the mapping rule for the exception and build its envelope. An
|
|
478
|
-
* unknown error is handed to the observability seam
|
|
479
|
-
* an integration can record the original error with the
|
|
508
|
+
* unknown error is handed to the observability seam once its envelope is
|
|
509
|
+
* built, so an integration can record the original error with the context.
|
|
480
510
|
*
|
|
481
511
|
* @param exception - The error that escaped the handler.
|
|
482
512
|
* @param context - The neutral request context.
|
|
513
|
+
* @param request - The framework request, read only for the sink's route template.
|
|
483
514
|
* @returns The formatted envelope.
|
|
484
515
|
*/
|
|
485
|
-
buildEnvelope(exception, context) {
|
|
516
|
+
buildEnvelope(exception, context, request) {
|
|
486
517
|
if (exception instanceof common.HttpException) {
|
|
487
518
|
return this.mapHttpException(exception, context);
|
|
488
519
|
}
|
|
@@ -495,11 +526,39 @@ exports.BymaxExceptionFilter = class BymaxExceptionFilter {
|
|
|
495
526
|
context
|
|
496
527
|
);
|
|
497
528
|
}
|
|
529
|
+
const envelope = this.mapUnknown(exception, context);
|
|
498
530
|
try {
|
|
499
531
|
this.onUnexpectedError(exception, context);
|
|
500
532
|
} catch {
|
|
501
533
|
}
|
|
502
|
-
|
|
534
|
+
this.reportUnexpected(exception, context, request);
|
|
535
|
+
return envelope;
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Hand an unexpected error to the bound sink, if any. Whatever the sink does
|
|
539
|
+
* — throw, or return a promise that rejects — is absorbed: the response must
|
|
540
|
+
* not depend on it. See `IUnexpectedErrorSink` for the contract.
|
|
541
|
+
*
|
|
542
|
+
* @param error - The original thrown value.
|
|
543
|
+
* @param context - The neutral request context.
|
|
544
|
+
* @param request - The framework request, read for its route template only.
|
|
545
|
+
*/
|
|
546
|
+
reportUnexpected(error, context, request) {
|
|
547
|
+
const sink = this.unexpectedErrorSink;
|
|
548
|
+
if (sink === void 0) {
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
try {
|
|
552
|
+
const report = {
|
|
553
|
+
error,
|
|
554
|
+
method: context.method,
|
|
555
|
+
route: readRequestInfo(request).route,
|
|
556
|
+
...context.correlationId !== void 0 ? { correlationId: context.correlationId } : {},
|
|
557
|
+
...context.traceId !== void 0 ? { traceId: context.traceId } : {}
|
|
558
|
+
};
|
|
559
|
+
containRejection(sink.record(report), () => void 0);
|
|
560
|
+
} catch {
|
|
561
|
+
}
|
|
503
562
|
}
|
|
504
563
|
/**
|
|
505
564
|
* Map an `HttpException` to the envelope. A domain error passes its own code,
|
|
@@ -589,7 +648,7 @@ exports.BymaxExceptionFilter = class BymaxExceptionFilter {
|
|
|
589
648
|
}
|
|
590
649
|
/**
|
|
591
650
|
* Observability seam invoked for every unexpected (non-`HttpException`) error
|
|
592
|
-
*
|
|
651
|
+
* collapsed to the generic 500. The base implementation is a no-op:
|
|
593
652
|
* this library owns no logger. An integration (for example
|
|
594
653
|
* `@bymax-one/nest-logger`) subclasses the filter and overrides this to record
|
|
595
654
|
* the original error with the current request context and correlation id.
|
|
@@ -608,7 +667,9 @@ exports.BymaxExceptionFilter = __decorateClass([
|
|
|
608
667
|
__decorateParam(1, common.Inject(BYMAX_CORRELATION_PROVIDER)),
|
|
609
668
|
__decorateParam(2, common.Inject(core.HttpAdapterHost)),
|
|
610
669
|
__decorateParam(3, common.Optional()),
|
|
611
|
-
__decorateParam(3, common.Inject(BYMAX_TRACE_CONTEXT))
|
|
670
|
+
__decorateParam(3, common.Inject(BYMAX_TRACE_CONTEXT)),
|
|
671
|
+
__decorateParam(4, common.Optional()),
|
|
672
|
+
__decorateParam(4, common.Inject(BYMAX_UNEXPECTED_ERROR_SINK))
|
|
612
673
|
], exports.BymaxExceptionFilter);
|
|
613
674
|
var PassThroughExceptionFilter = class {
|
|
614
675
|
constructor(adapterHost) {
|
|
@@ -635,15 +696,8 @@ function assertAsyncFeatureEnabled(feature, enabled) {
|
|
|
635
696
|
);
|
|
636
697
|
}
|
|
637
698
|
}
|
|
638
|
-
function selectAsyncExceptionFilter(options, correlation, adapterHost) {
|
|
639
|
-
return options.envelope.enabled ? new exports.BymaxExceptionFilter(options, correlation, adapterHost) : new PassThroughExceptionFilter(adapterHost);
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
// src/contain-rejection.ts
|
|
643
|
-
function containRejection(returned, onFailure) {
|
|
644
|
-
if (typeof returned?.then === "function") {
|
|
645
|
-
Promise.resolve(returned).catch(onFailure);
|
|
646
|
-
}
|
|
699
|
+
function selectAsyncExceptionFilter(options, correlation, adapterHost, traceContext, unexpectedErrorSink) {
|
|
700
|
+
return options.envelope.enabled ? new exports.BymaxExceptionFilter(options, correlation, adapterHost, traceContext, unexpectedErrorSink) : new PassThroughExceptionFilter(adapterHost);
|
|
647
701
|
}
|
|
648
702
|
|
|
649
703
|
// src/discovery.ts
|
|
@@ -1354,23 +1408,6 @@ function isFastifyInstance(instance) {
|
|
|
1354
1408
|
return typeof instance?.addHook === "function";
|
|
1355
1409
|
}
|
|
1356
1410
|
|
|
1357
|
-
// src/timing/request-info.accessor.ts
|
|
1358
|
-
var UNMATCHED_ROUTE = "<unmatched>";
|
|
1359
|
-
function readExpressTemplate(request) {
|
|
1360
|
-
const path = request.route?.path;
|
|
1361
|
-
return path === void 0 ? void 0 : `${request.baseUrl ?? ""}${path}`;
|
|
1362
|
-
}
|
|
1363
|
-
function readFastifyTemplate(request) {
|
|
1364
|
-
return request.routeOptions?.url;
|
|
1365
|
-
}
|
|
1366
|
-
function readRequestInfo(request) {
|
|
1367
|
-
const template = readExpressTemplate(request) ?? readFastifyTemplate(request);
|
|
1368
|
-
return { method: request.method ?? "", route: template ?? UNMATCHED_ROUTE };
|
|
1369
|
-
}
|
|
1370
|
-
function extractRequestInfo(context) {
|
|
1371
|
-
return readRequestInfo(context.switchToHttp().getRequest());
|
|
1372
|
-
}
|
|
1373
|
-
|
|
1374
1411
|
// src/timing/timing.sample.ts
|
|
1375
1412
|
function readTraceContext(traceContext) {
|
|
1376
1413
|
try {
|
|
@@ -1529,11 +1566,22 @@ function buildAsyncSlots() {
|
|
|
1529
1566
|
return [
|
|
1530
1567
|
{
|
|
1531
1568
|
provide: core.APP_FILTER,
|
|
1532
|
-
useFactory: (options, correlation, adapterHost) => selectAsyncExceptionFilter(
|
|
1569
|
+
useFactory: (options, correlation, adapterHost, traceContext, unexpectedErrorSink) => selectAsyncExceptionFilter(
|
|
1570
|
+
options,
|
|
1571
|
+
correlation,
|
|
1572
|
+
adapterHost,
|
|
1573
|
+
traceContext,
|
|
1574
|
+
unexpectedErrorSink
|
|
1575
|
+
),
|
|
1576
|
+
// Every constructor dependency the sync path resolves through `useClass`
|
|
1577
|
+
// has to be listed here by hand; one left out reaches the filter as its
|
|
1578
|
+
// no-op fallback and nothing fails.
|
|
1533
1579
|
inject: [
|
|
1534
1580
|
BYMAX_CORE_OPTIONS,
|
|
1535
1581
|
{ token: BYMAX_CORRELATION_PROVIDER, optional: true },
|
|
1536
|
-
core.HttpAdapterHost
|
|
1582
|
+
core.HttpAdapterHost,
|
|
1583
|
+
BYMAX_TRACE_CONTEXT,
|
|
1584
|
+
{ token: BYMAX_UNEXPECTED_ERROR_SINK, optional: true }
|
|
1537
1585
|
]
|
|
1538
1586
|
}
|
|
1539
1587
|
];
|
|
@@ -1833,6 +1881,7 @@ exports.BYMAX_TIMING_SINK = BYMAX_TIMING_SINK;
|
|
|
1833
1881
|
exports.BYMAX_TOO_MANY_REQUESTS = BYMAX_TOO_MANY_REQUESTS;
|
|
1834
1882
|
exports.BYMAX_TRACE_CONTEXT = BYMAX_TRACE_CONTEXT;
|
|
1835
1883
|
exports.BYMAX_UNAUTHORIZED = BYMAX_UNAUTHORIZED;
|
|
1884
|
+
exports.BYMAX_UNEXPECTED_ERROR_SINK = BYMAX_UNEXPECTED_ERROR_SINK;
|
|
1836
1885
|
exports.BYMAX_UNPROCESSABLE_ENTITY = BYMAX_UNPROCESSABLE_ENTITY;
|
|
1837
1886
|
exports.BYMAX_UNSUPPORTED_MEDIA_TYPE = BYMAX_UNSUPPORTED_MEDIA_TYPE;
|
|
1838
1887
|
exports.BYMAX_VALIDATION_FAILED = BYMAX_VALIDATION_FAILED;
|