@bymax-one/nest-core 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +157 -1
- package/README.md +387 -64
- package/dist/health/index.cjs +10 -0
- package/dist/health/index.d.cts +33 -1
- package/dist/health/index.d.ts +33 -1
- package/dist/health/index.mjs +8 -0
- package/dist/index.cjs +422 -37
- package/dist/index.d.cts +237 -11
- package/dist/index.d.ts +237 -11
- package/dist/index.mjs +424 -40
- package/dist/metrics/index.cjs +12 -0
- package/dist/metrics/index.d.cts +57 -0
- package/dist/metrics/index.d.ts +57 -0
- package/dist/metrics/index.mjs +9 -0
- package/dist/openapi/index.cjs +268 -0
- package/dist/openapi/index.d.cts +44 -0
- package/dist/openapi/index.d.ts +44 -0
- package/dist/openapi/index.mjs +266 -0
- package/package.json +70 -27
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
<a href="https://www.npmjs.com/package/@bymax-one/nest-core"><img src="https://img.shields.io/npm/dm/@bymax-one/nest-core?style=flat-square&colorA=000000&colorB=000000" alt="npm downloads" /></a>
|
|
15
15
|
<a href="https://github.com/bymaxone/nest-core/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/bymaxone/nest-core/ci.yml?branch=main&style=flat-square&colorA=000000&label=CI" alt="CI status" /></a>
|
|
16
16
|
<a href="https://github.com/bymaxone/nest-core/actions/workflows/ci.yml"><img src="https://img.shields.io/badge/coverage-100%25-brightgreen?style=flat-square&colorA=000000" alt="coverage" /></a>
|
|
17
|
-
<a href="https://github.com/bymaxone/nest-core/blob/main/docs/mutation_testing_results.md"><img src="https://img.shields.io/badge/mutation-
|
|
17
|
+
<a href="https://github.com/bymaxone/nest-core/blob/main/docs/mutation_testing_results.md"><img src="https://img.shields.io/badge/mutation-98.76%25-brightgreen?style=flat-square&colorA=000000" alt="mutation score" /></a>
|
|
18
18
|
<a href="https://scorecard.dev/viewer/?uri=github.com/bymaxone/nest-core"><img src="https://api.scorecard.dev/projects/github.com/bymaxone/nest-core/badge?style=flat-square" alt="OpenSSF Scorecard" /></a>
|
|
19
19
|
<a href="https://github.com/bymaxone/nest-core/blob/main/LICENSE"><img src="https://img.shields.io/github/license/bymaxone/nest-core?style=flat-square&colorA=000000&colorB=000000" alt="license" /></a>
|
|
20
20
|
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-strict-3178C6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript" /></a>
|
|
@@ -40,7 +40,9 @@ different ways, and how a client integration breaks because one of them changed
|
|
|
40
40
|
body.
|
|
41
41
|
|
|
42
42
|
It ships `"dependencies": {}`. Everything it touches — NestJS, `rxjs`, `reflect-metadata`, and
|
|
43
|
-
|
|
43
|
+
the three optional peers behind the features that need them (`prom-client`, `@nestjs/swagger`,
|
|
44
|
+
`@opentelemetry/api`) — is a peer whose version you already control. A feature you leave off
|
|
45
|
+
never loads its peer, which the release gate asserts against the packed tarball.
|
|
44
46
|
|
|
45
47
|
### Why nest-core?
|
|
46
48
|
|
|
@@ -79,6 +81,12 @@ It ships `"dependencies": {}`. Everything it touches — NestJS, `rxjs`, `reflec
|
|
|
79
81
|
branch without re-deriving the threshold
|
|
80
82
|
- ✅ **Prometheus endpoint** — opt-in scrape route over `BYMAX_METRICS_REGISTRY`;
|
|
81
83
|
`prom-client` is imported only when it is enabled
|
|
84
|
+
- ✅ **Contributed metrics** — a provider marked `@BymaxMetricsContributor()` publishes its
|
|
85
|
+
own collectors on that same registry, so an imported library's metrics land in your scrape
|
|
86
|
+
- ✅ **Trace correlation** — reads the active OpenTelemetry span, so timing samples (and,
|
|
87
|
+
when you opt in, error envelopes) carry the trace id; never creates a span or an SDK
|
|
88
|
+
- ✅ **OpenAPI document, development only** — one bootstrap call publishes an interactive
|
|
89
|
+
UI carrying the schemas this package owns; in production it is never served, guarded twice
|
|
82
90
|
|
|
83
91
|
### 📄 Pagination & Health
|
|
84
92
|
|
|
@@ -90,13 +98,15 @@ It ships `"dependencies": {}`. Everything it touches — NestJS, `rxjs`, `reflec
|
|
|
90
98
|
without restarting the pod
|
|
91
99
|
- ✅ **Pluggable indicators** — implement `IHealthIndicator` against a client you already own
|
|
92
100
|
and register it under the `BYMAX_HEALTH_INDICATORS` multi-token
|
|
101
|
+
- ✅ **Discovered indicators** — opt in, and any provider marked
|
|
102
|
+
`@BymaxHealthIndicator()` joins readiness: a library you import brings its own check
|
|
93
103
|
|
|
94
104
|
### 🧩 Developer Experience
|
|
95
105
|
|
|
96
106
|
- ✅ **Zero runtime dependencies** — `@nestjs/*`, `rxjs` and `reflect-metadata` arrive as
|
|
97
107
|
peers, so you pin the versions
|
|
98
|
-
- ✅ **
|
|
99
|
-
import without pulling the module in
|
|
108
|
+
- ✅ **Five subpaths** — the module, plus `./pagination`, `./health`, `./metrics` and
|
|
109
|
+
`./openapi` that a package can import without pulling the module in
|
|
100
110
|
- ✅ **Dual-format output** — ESM + CJS with declarations for each format, verified against
|
|
101
111
|
the packed tarball on every run
|
|
102
112
|
- ✅ **Independent features** — each is enabled on its own; the providers for the rest are
|
|
@@ -108,11 +118,13 @@ It ships `"dependencies": {}`. Everything it touches — NestJS, `rxjs`, `reflec
|
|
|
108
118
|
|
|
109
119
|
## 📦 Subpath Exports
|
|
110
120
|
|
|
111
|
-
| Subpath | Contents
|
|
112
|
-
| -------------- |
|
|
113
|
-
| `.` | `BymaxCoreModule`, the error envelope and its code catalog, the timing interceptor, the DI tokens, and every option type
|
|
114
|
-
| `./pagination` | `normalizePageQuery`, `buildPageResult`, `normalizeCursorQuery`, `buildCursorResult`, `encodeCursor`, `decodeCursor` and their types — pure functions, no NestJS provider involved
|
|
115
|
-
| `./health` | `IHealthIndicator`, `HealthResponse
|
|
121
|
+
| Subpath | Contents |
|
|
122
|
+
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
123
|
+
| `.` | `BymaxCoreModule`, the error envelope and its code catalog, the timing interceptor, the DI tokens, and every option type |
|
|
124
|
+
| `./pagination` | `normalizePageQuery`, `buildPageResult`, `normalizeCursorQuery`, `buildCursorResult`, `encodeCursor`, `decodeCursor` and their types — pure functions, no NestJS provider involved |
|
|
125
|
+
| `./health` | `IHealthIndicator`, `HealthResponse`, the indicator contracts and the `@BymaxHealthIndicator()` marker, so a package that only implements an indicator does not import the module |
|
|
126
|
+
| `./metrics` | `IMetricsContributor` and the `@BymaxMetricsContributor()` marker, so a package that only publishes metrics imports neither the module nor its DI tokens. The one subpath whose types name `prom-client`, which anyone implementing the contract already depends on |
|
|
127
|
+
| `./openapi` | `applyBymaxOpenApi`, the one bootstrap call that builds and mounts the OpenAPI document — separate so an application that never documents its API never loads the code that does |
|
|
116
128
|
|
|
117
129
|
Each subpath ships ESM and CommonJS with its own `.d.ts` and `.d.cts`, so
|
|
118
130
|
`require()` and `import` both resolve the declarations meant for them.
|
|
@@ -123,13 +135,20 @@ Each subpath ships ESM and CommonJS with its own `.d.ts` and `.d.cts`, so
|
|
|
123
135
|
pnpm add @bymax-one/nest-core @nestjs/common @nestjs/core reflect-metadata rxjs
|
|
124
136
|
```
|
|
125
137
|
|
|
126
|
-
Add `prom-client`
|
|
127
|
-
|
|
138
|
+
Add `prom-client` if you enable the metrics feature, `@nestjs/swagger` if you
|
|
139
|
+
enable the OpenAPI feature, and `@opentelemetry/api` if you enable trace
|
|
140
|
+
correlation. All three are optional peer dependencies: none is required, or ever
|
|
141
|
+
loaded, unless you turn its feature on.
|
|
128
142
|
|
|
129
143
|
```bash
|
|
130
144
|
pnpm add prom-client
|
|
145
|
+
pnpm add @opentelemetry/api
|
|
146
|
+
pnpm add -D @nestjs/swagger
|
|
131
147
|
```
|
|
132
148
|
|
|
149
|
+
`@nestjs/swagger` belongs in `devDependencies`: the document is never served in
|
|
150
|
+
production, so a production install has no reason to carry it.
|
|
151
|
+
|
|
133
152
|
## 🚀 Quick Start
|
|
134
153
|
|
|
135
154
|
```typescript
|
|
@@ -199,11 +218,13 @@ falls back to the documented default. Pass only what you want to change.
|
|
|
199
218
|
|
|
200
219
|
### `health`
|
|
201
220
|
|
|
202
|
-
| Option
|
|
203
|
-
|
|
|
204
|
-
| `enabled`
|
|
205
|
-
| `path`
|
|
206
|
-
| `indicatorTimeoutMs`
|
|
221
|
+
| Option | Type | Default | Description |
|
|
222
|
+
| ----------------------- | --------- | ---------- | ----------------------------------------------------------------------------------------------------------------------- |
|
|
223
|
+
| `enabled` | `boolean` | `true` | Registers the health controller. |
|
|
224
|
+
| `path` | `string` | `'health'` | Route prefix: `GET /<path>/live`, `GET /<path>/ready`. |
|
|
225
|
+
| `indicatorTimeoutMs` | `number` | `5000` | Per-indicator timeout before a check reports down. |
|
|
226
|
+
| `exposeIndicatorErrors` | `boolean` | `false` | Includes the failing indicator's message in the response under `details.error`. Never enable in production — see below. |
|
|
227
|
+
| `autoDiscover` | `boolean` | `false` | Also aggregates every provider marked `@BymaxHealthIndicator()`, anywhere in the application. |
|
|
207
228
|
|
|
208
229
|
On `forRoot`, `enabled` and `path` are applied at module-definition time: a
|
|
209
230
|
disabled feature registers no controller, and a custom `path` mounts the routes.
|
|
@@ -226,6 +247,31 @@ As with `health`, `enabled` and `path` register conditionally on `forRoot`. On
|
|
|
226
247
|
and enforces `enabled` and the default path with a request-time guard, so a
|
|
227
248
|
disabled or custom-path async configuration fails fast at the route.
|
|
228
249
|
|
|
250
|
+
### `telemetry`
|
|
251
|
+
|
|
252
|
+
| Option | Type | Default | Description |
|
|
253
|
+
| --------------- | --------- | ------- | ------------------------------------------------------------------------------- |
|
|
254
|
+
| `enabled` | `boolean` | `false` | Reads the active span and carries its ids into timing samples and the log seam. |
|
|
255
|
+
| `exposeTraceId` | `boolean` | `false` | Also publishes `traceId` in the error-envelope body served to the client. |
|
|
256
|
+
|
|
257
|
+
### `openapi`
|
|
258
|
+
|
|
259
|
+
| Option | Type | Default | Description |
|
|
260
|
+
| -------------------- | ------------------------- | ------------- | ------------------------------------------------------------------------------ |
|
|
261
|
+
| `enabled` | `boolean` | `false` | Builds and serves the document. Ignored in production, where it is always off. |
|
|
262
|
+
| `path` | `string` | `'docs'` | Route serving the interactive UI. |
|
|
263
|
+
| `jsonPath` | `string` | `'docs-json'` | Route serving the raw JSON document. |
|
|
264
|
+
| `title` | `string` | `'API'` | Document title. |
|
|
265
|
+
| `description` | `string` | `''` | Document description. |
|
|
266
|
+
| `version` | `string` | `'1.0.0'` | Document version, independent of the package version. |
|
|
267
|
+
| `servers` | `{ url, description? }[]` | `[]` | Servers advertised by the document. |
|
|
268
|
+
| `securitySchemes` | `Record<string, object>` | `{}` | Security schemes copied into the document's components. |
|
|
269
|
+
| `includeCoreSchemas` | `boolean` | `true` | Contributes this package's own schemas — envelope, health, pagination. |
|
|
270
|
+
|
|
271
|
+
Unlike `health` and `metrics`, this block behaves identically on `forRoot` and
|
|
272
|
+
`forRootAsync`: the document is mounted from the bootstrap helper, after the
|
|
273
|
+
options have resolved, so a custom `path` is honored on both registration paths.
|
|
274
|
+
|
|
229
275
|
## 🔑 DI Tokens
|
|
230
276
|
|
|
231
277
|
Every token is a `Symbol`. `BYMAX_CORRELATION_PROVIDER` and
|
|
@@ -241,13 +287,14 @@ consumer `BYMAX_TIMING_SINK` override is honored on `forRoot` but shadowed on
|
|
|
241
287
|
[Integration with `@bymax-one/nest-logger`](#-integration-with-bymax-onenest-logger)
|
|
242
288
|
below.
|
|
243
289
|
|
|
244
|
-
| Token | Provides | When you do not provide one
|
|
245
|
-
| ---------------------------- | ------------------------------------- |
|
|
246
|
-
| `BYMAX_CORE_OPTIONS` | The resolved `BymaxCoreModuleOptions` | always set by the module
|
|
247
|
-
| `BYMAX_CORRELATION_PROVIDER` | `ICorrelationIdProvider` | internal no-op (omits `correlationId`)
|
|
248
|
-
| `BYMAX_TIMING_SINK` | `ITimingSink` | internal no-op, or the metrics bridge when timing and metrics are both enabled
|
|
249
|
-
| `BYMAX_HEALTH_INDICATORS` | `IHealthIndicator[]` | treated as an empty indicator set
|
|
250
|
-
| `BYMAX_METRICS_REGISTRY` | the `prom-client` `Registry` | bound when metrics are enabled; on `forRootAsync` always registered, guarded-placeholder when off
|
|
290
|
+
| Token | Provides | When you do not provide one |
|
|
291
|
+
| ---------------------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
|
292
|
+
| `BYMAX_CORE_OPTIONS` | The resolved `BymaxCoreModuleOptions` | always set by the module |
|
|
293
|
+
| `BYMAX_CORRELATION_PROVIDER` | `ICorrelationIdProvider` | internal no-op (omits `correlationId`) |
|
|
294
|
+
| `BYMAX_TIMING_SINK` | `ITimingSink` | internal no-op, or the metrics bridge when timing and metrics are both enabled |
|
|
295
|
+
| `BYMAX_HEALTH_INDICATORS` | `IHealthIndicator[]` | treated as an empty indicator set |
|
|
296
|
+
| `BYMAX_METRICS_REGISTRY` | the `prom-client` `Registry` | bound when metrics are enabled; on `forRootAsync` always registered, guarded-placeholder when off |
|
|
297
|
+
| `BYMAX_TRACE_CONTEXT` | `ITraceContextProvider` | bound on every path: the OpenTelemetry reader when telemetry is enabled, a no-op that resolves no trace otherwise |
|
|
251
298
|
|
|
252
299
|
## 🚨 Error Envelope
|
|
253
300
|
|
|
@@ -391,6 +438,12 @@ Liveness always replies `200` with an empty checks array; readiness runs
|
|
|
391
438
|
every registered indicator concurrently and replies `200` only when every
|
|
392
439
|
indicator reports `up`, `503` otherwise, naming every check either way.
|
|
393
440
|
|
|
441
|
+
A failing indicator is named but not quoted: the response says which check is
|
|
442
|
+
down, and the reason goes to the logger. See
|
|
443
|
+
[the security model](#-security-model) for why, and
|
|
444
|
+
`health.exposeIndicatorErrors` if you want the message in the response while
|
|
445
|
+
debugging locally.
|
|
446
|
+
|
|
394
447
|
```json
|
|
395
448
|
{ "status": "ok", "checks": [{ "name": "redis", "status": "up" }] }
|
|
396
449
|
```
|
|
@@ -440,6 +493,52 @@ A rejecting, throwing, or slow indicator (past `indicatorTimeoutMs`) is
|
|
|
440
493
|
converted to a `down` entry with a safe, bounded diagnostic detail; it never
|
|
441
494
|
hides the results of the other registered indicators.
|
|
442
495
|
|
|
496
|
+
### Discovered indicators
|
|
497
|
+
|
|
498
|
+
Registering every indicator by hand stops scaling once the libraries an
|
|
499
|
+
application imports have their own health to report. Mark a provider instead,
|
|
500
|
+
and turn discovery on:
|
|
501
|
+
|
|
502
|
+
```typescript
|
|
503
|
+
// in a library, or anywhere in your application
|
|
504
|
+
import { Injectable } from '@nestjs/common'
|
|
505
|
+
import { BymaxHealthIndicator } from '@bymax-one/nest-core/health'
|
|
506
|
+
import type { HealthIndicatorResult, IHealthIndicator } from '@bymax-one/nest-core/health'
|
|
507
|
+
|
|
508
|
+
@BymaxHealthIndicator()
|
|
509
|
+
@Injectable()
|
|
510
|
+
export class RedisHealthIndicator implements IHealthIndicator {
|
|
511
|
+
readonly name = 'redis'
|
|
512
|
+
|
|
513
|
+
async check(): Promise<HealthIndicatorResult> {
|
|
514
|
+
await this.redis.ping()
|
|
515
|
+
return { status: 'up' }
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
```typescript
|
|
521
|
+
BymaxCoreModule.forRoot({ health: { autoDiscover: true } })
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
Readiness now includes `redis` with nothing registered anywhere. The rules:
|
|
525
|
+
|
|
526
|
+
| Rule | Behavior |
|
|
527
|
+
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
528
|
+
| Marked, not shaped | Only providers carrying the marker are collected. A provider that merely has `name` and `check` is ignored. |
|
|
529
|
+
| Explicit wins | An indicator registered under `BYMAX_HEALTH_INDICATORS` keeps its name and its position; a discovered one with the same name is dropped. |
|
|
530
|
+
| Stable order | Discovered indicators are sorted by name, so the `checks` array does not reshuffle between restarts. |
|
|
531
|
+
| Marked but incomplete fails | A marked provider that does not implement `IHealthIndicator` fails the boot, naming the class. Skipping it would hide a check you believe is running. |
|
|
532
|
+
| Scanned once | The provider graph is walked at bootstrap, not per probe. |
|
|
533
|
+
|
|
534
|
+
It is off by default because it changes which failures can take an application
|
|
535
|
+
out of rotation: with it on, a library you merely import gains the ability to
|
|
536
|
+
fail your readiness probe. That is the point — the dependency understands its own
|
|
537
|
+
health better than you do — but it is your decision, not something you inherit.
|
|
538
|
+
|
|
539
|
+
`@BymaxHealthIndicator()` lives in `./health`, alongside the contract, so a
|
|
540
|
+
library that only ships an indicator never imports the module.
|
|
541
|
+
|
|
443
542
|
## 📈 Metrics
|
|
444
543
|
|
|
445
544
|
Disabled by default. Enabling it registers `GET /metrics`, serving Prometheus
|
|
@@ -465,6 +564,165 @@ default HTTP metrics with a bounded label set:
|
|
|
465
564
|
Inject `BYMAX_METRICS_REGISTRY` to register your own application metrics
|
|
466
565
|
against the same registry the endpoint scrapes.
|
|
467
566
|
|
|
567
|
+
### Contributed metrics
|
|
568
|
+
|
|
569
|
+
Injecting the token works for your own code, but it makes a library depend on
|
|
570
|
+
this package's DI tokens — and therefore on the module. A library declares its
|
|
571
|
+
metrics instead:
|
|
572
|
+
|
|
573
|
+
```typescript
|
|
574
|
+
// in a library
|
|
575
|
+
import { Injectable } from '@nestjs/common'
|
|
576
|
+
import { BymaxMetricsContributor } from '@bymax-one/nest-core/metrics'
|
|
577
|
+
import type { IMetricsContributor, MetricsRegistry } from '@bymax-one/nest-core/metrics'
|
|
578
|
+
import { Gauge } from 'prom-client'
|
|
579
|
+
|
|
580
|
+
@BymaxMetricsContributor()
|
|
581
|
+
@Injectable()
|
|
582
|
+
export class QueueMetrics implements IMetricsContributor {
|
|
583
|
+
registerMetrics(registry: MetricsRegistry): void {
|
|
584
|
+
new Gauge({ name: 'bymax_queue_depth', help: 'Jobs waiting', registers: [registry] })
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
Enable metrics and the contributor runs — there is no second flag:
|
|
590
|
+
|
|
591
|
+
```typescript
|
|
592
|
+
BymaxCoreModule.forRoot({ metrics: { enabled: true } })
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
| Rule | Behavior |
|
|
596
|
+
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
597
|
+
| Marked, not shaped | Only providers carrying the marker are called. A provider that merely has `registerMetrics` is never touched. |
|
|
598
|
+
| Called once | At bootstrap, with the registry the scrape endpoint serves. Never per request, never per scrape. |
|
|
599
|
+
| Stable order | Contributors run sorted by class name, so a collision fails the same way on every boot. |
|
|
600
|
+
| Named failures | A registration failure — usually a metric name another library already claimed — fails the boot naming the contributor. `prom-client` names the metric; this names who registered it. |
|
|
601
|
+
| Off with metrics | With the metrics feature disabled, no contributor runs and `prom-client` is never loaded. |
|
|
602
|
+
|
|
603
|
+
**Naming and labels.** Contributors share one registry and one namespace, so the
|
|
604
|
+
conventions are part of the contract:
|
|
605
|
+
|
|
606
|
+
- Prefix every metric with `bymax_<library>_` (`bymax_queue_depth`,
|
|
607
|
+
`bymax_cache_hits_total`). An application's own metrics need no prefix — they
|
|
608
|
+
have no one to collide with but themselves.
|
|
609
|
+
- Follow Prometheus naming: `_total` for counters, `_seconds` for durations, base
|
|
610
|
+
units, no units in the middle of a name.
|
|
611
|
+
- Keep labels bounded. Route templates, never raw paths; status codes, never
|
|
612
|
+
messages. **Never** a tenant, user, or request id — one unbounded label is
|
|
613
|
+
enough to make a scrape endpoint the most expensive route in a service.
|
|
614
|
+
|
|
615
|
+
## 📘 OpenAPI
|
|
616
|
+
|
|
617
|
+
Disabled by default, and **never served in production**. Enabling it and calling
|
|
618
|
+
one helper during bootstrap publishes an interactive UI at `GET /docs` and the
|
|
619
|
+
raw document at `GET /docs-json`:
|
|
620
|
+
|
|
621
|
+
```typescript
|
|
622
|
+
// app.module.ts — configuration lives with every other feature
|
|
623
|
+
BymaxCoreModule.forRoot({ openapi: { enabled: true, title: 'Invoices API' } })
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
```typescript
|
|
627
|
+
// main.ts — the one call that needs the application instance
|
|
628
|
+
import { NestFactory } from '@nestjs/core'
|
|
629
|
+
import { applyBymaxOpenApi } from '@bymax-one/nest-core/openapi'
|
|
630
|
+
import { AppModule } from './app.module'
|
|
631
|
+
|
|
632
|
+
async function bootstrap(): Promise<void> {
|
|
633
|
+
const app = await NestFactory.create(AppModule)
|
|
634
|
+
await applyBymaxOpenApi(app)
|
|
635
|
+
await app.listen(3000)
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
void bootstrap()
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
> [!IMPORTANT]
|
|
642
|
+
> Call `applyBymaxOpenApi` **before** `app.listen()`. Mounting the document
|
|
643
|
+
> re-registers routes on the HTTP adapter, and doing that against an
|
|
644
|
+
> already-initialized Express 5 application replaces the router: the document
|
|
645
|
+
> appears and every other route in the application — yours and this package's
|
|
646
|
+
> health endpoints alike — starts returning 404.
|
|
647
|
+
|
|
648
|
+
The call is safe to make unconditionally. It returns what it did, so a template
|
|
649
|
+
can emit it once and never branch:
|
|
650
|
+
|
|
651
|
+
| Result | Meaning |
|
|
652
|
+
| ------------------------------------------ | ------------------------------------------------------- |
|
|
653
|
+
| `{ mounted: true, path }` | The UI and the document are served at `path`. |
|
|
654
|
+
| `{ mounted: false, reason: 'disabled' }` | `openapi.enabled` is off. |
|
|
655
|
+
| `{ mounted: false, reason: 'production' }` | The runtime is production. Nothing was built or served. |
|
|
656
|
+
|
|
657
|
+
### Production is a closed door
|
|
658
|
+
|
|
659
|
+
`NODE_ENV` decides, and the decision is fail-closed: only `development` and
|
|
660
|
+
`test` are non-production. Any other value — including an unset variable —
|
|
661
|
+
is production, and in production the document is never built and never mounted,
|
|
662
|
+
whatever the configuration says. The guard runs twice, independently: the option
|
|
663
|
+
resolver forces the feature off, and the bootstrap helper refuses again without
|
|
664
|
+
trusting that resolution. There is no override.
|
|
665
|
+
|
|
666
|
+
Enabling it in production is not an error, it is a no-op with a warning naming
|
|
667
|
+
the option that was ignored, so a single configuration can be shared across
|
|
668
|
+
environments.
|
|
669
|
+
|
|
670
|
+
### What the library contributes
|
|
671
|
+
|
|
672
|
+
With `includeCoreSchemas` on, the document carries the contracts this package
|
|
673
|
+
already serves, so an operation can `$ref` them instead of redeclaring them:
|
|
674
|
+
|
|
675
|
+
| Component | Describes |
|
|
676
|
+
| --------------------------------------------------------------------- | --------------------------------------- |
|
|
677
|
+
| `BymaxErrorEnvelope`, `BymaxErrorDetails`, `BymaxErrorCode` | The error contract and its code catalog |
|
|
678
|
+
| `BymaxHealthResponse`, `BymaxHealthCheckEntry` | The liveness and readiness bodies |
|
|
679
|
+
| `BymaxPageResult`, `BymaxPageMeta`, `BymaxCursorResult` | The offset and cursor page shapes |
|
|
680
|
+
| `BymaxPageQueryPage`, `BymaxPageQueryLimit`, `BymaxCursorQueryCursor` | The pagination query parameters |
|
|
681
|
+
|
|
682
|
+
They are contributed as plain specification objects, not as decorated classes.
|
|
683
|
+
That is what keeps `@nestjs/swagger` genuinely optional: a decorator runs when
|
|
684
|
+
its class is defined, so describing these contracts with `@ApiProperty` would
|
|
685
|
+
load the peer in every application that imports this package, including the ones
|
|
686
|
+
that never enable the feature.
|
|
687
|
+
|
|
688
|
+
A contributed entry never overwrites one the document already has: if you
|
|
689
|
+
document your own `BymaxErrorEnvelope`, yours wins.
|
|
690
|
+
|
|
691
|
+
## 🧵 Trace correlation
|
|
692
|
+
|
|
693
|
+
Off by default. Enabled, it reads the span your instrumentation already opened
|
|
694
|
+
and carries its identifiers into the signals this package produces:
|
|
695
|
+
|
|
696
|
+
```typescript
|
|
697
|
+
BymaxCoreModule.forRoot({ telemetry: { enabled: true } })
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
| Where it lands | When |
|
|
701
|
+
| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
|
702
|
+
| `RequestTimingSample.traceId` / `.spanId` | Whenever a span is recording. Your sink forwards them to logs or wherever samples go. |
|
|
703
|
+
| `FilterErrorContext.traceId` | Always available to the `onUnexpectedError` seam, so a logging pipeline can record it. |
|
|
704
|
+
| The error-envelope body | Only with `exposeTraceId` on. |
|
|
705
|
+
| Metric labels | Never. A trace id is unbounded; as a label it would make the scrape endpoint the most expensive route in the service. |
|
|
706
|
+
|
|
707
|
+
This package **reads**; it never traces. It starts no span, configures no SDK,
|
|
708
|
+
registers no exporter, and installs no instrumentation — all of which your
|
|
709
|
+
collector setup already does, and doing it twice produces two spans per request.
|
|
710
|
+
`@opentelemetry/api` is an optional peer, loaded once at bootstrap and only when
|
|
711
|
+
the feature is on.
|
|
712
|
+
|
|
713
|
+
A request with nothing recording, or an all-zero span context, resolves to no
|
|
714
|
+
trace at all: the fields are absent rather than set to a sentinel, so a sink
|
|
715
|
+
never has to recognize a string of zeros.
|
|
716
|
+
|
|
717
|
+
### Publishing the id is a separate decision
|
|
718
|
+
|
|
719
|
+
`exposeTraceId` is off by default. A trace id is not a secret, but in a response
|
|
720
|
+
body it tells a caller that a tracing backend exists and hands them the
|
|
721
|
+
identifier that ties their request to everything else in that trace. Support
|
|
722
|
+
teams often want exactly that, which is why the option exists; it is opt-in so it
|
|
723
|
+
is a decision rather than a side effect. With it off, the identifiers still reach
|
|
724
|
+
your samples and your logs.
|
|
725
|
+
|
|
468
726
|
## 🔗 Integration with `@bymax-one/nest-logger`
|
|
469
727
|
|
|
470
728
|
Pairing this package with `@bymax-one/nest-logger` yields correlated logs and
|
|
@@ -523,9 +781,21 @@ BYMAX_CORRELATION_PROVIDER no module,
|
|
|
523
781
|
comes from) script or a test
|
|
524
782
|
```
|
|
525
783
|
|
|
784
|
+
Four opt-in integrations attach to that spine rather than adding columns to it:
|
|
785
|
+
|
|
786
|
+
```
|
|
787
|
+
health/ + @BymaxHealthIndicator() → a marked provider joins readiness
|
|
788
|
+
metrics/ + @BymaxMetricsContributor() → a marked provider publishes on the registry
|
|
789
|
+
envelope/ + telemetry → the active trace id reaches the envelope and the seam
|
|
790
|
+
timing/ + telemetry → the sample carries traceId and spanId
|
|
791
|
+
./openapi → one bootstrap call serves the document, in development
|
|
792
|
+
```
|
|
793
|
+
|
|
526
794
|
Each feature registers only when it is on. Turning metrics off does not leave a
|
|
527
795
|
disabled provider in the container — it leaves no provider, and `prom-client` is
|
|
528
|
-
never imported, which is why it can stay an optional peer.
|
|
796
|
+
never imported, which is why it can stay an optional peer. The same holds for
|
|
797
|
+
`@nestjs/swagger` and `@opentelemetry/api`: the release gate loads the packed
|
|
798
|
+
tarball and fails if any of the three is reachable with its feature off.
|
|
529
799
|
|
|
530
800
|
Nothing here holds state across requests. The timing interceptor emits and forgets;
|
|
531
801
|
the health service runs the indicators the app registered and folds their results;
|
|
@@ -558,12 +828,28 @@ original message and stack are captured for your logger, not for the response.
|
|
|
558
828
|
`envelope.exposeInternals` puts them in the body and exists for local debugging — its own
|
|
559
829
|
documentation says never to enable it in production, and it defaults to `false`.
|
|
560
830
|
|
|
561
|
-
###
|
|
831
|
+
### The readiness response names the failure, it does not describe it
|
|
562
832
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
833
|
+
A failing indicator produces `{ name, status: 'down' }` and nothing else. The reason goes
|
|
834
|
+
to the logger.
|
|
835
|
+
|
|
836
|
+
That split is deliberate. Readiness is usually unauthenticated and reachable by whatever
|
|
837
|
+
probes it, and an indicator rarely authors its own failure text — it writes
|
|
838
|
+
`await this.redis.ping()` and lets the driver's error propagate. Driver errors carry hosts,
|
|
839
|
+
ports, and in the case of a connection string, credentials. Putting that text in the
|
|
840
|
+
response publishes it to everyone who can reach the endpoint; putting it in the log keeps
|
|
841
|
+
it where access is already controlled, without losing the diagnostic.
|
|
842
|
+
|
|
843
|
+
`health.exposeIndicatorErrors` puts the message back in the response for local debugging.
|
|
844
|
+
It defaults to `false`, and its documentation says the same thing `envelope.exposeInternals`
|
|
845
|
+
does: never enable it in production. The two options are the same decision, made the same
|
|
846
|
+
way, about the same risk.
|
|
847
|
+
|
|
848
|
+
What reaches the log is bounded the same way it always was: the top-level `Error#message`
|
|
849
|
+
only — never the raw error, its stack, or a nested cause — truncated at 300 characters.
|
|
850
|
+
A slow indicator is converted to `down` by the aggregator rather than hanging the probe,
|
|
851
|
+
and its `timedOutAfterMs` stays in the response either way, because that number is one this
|
|
852
|
+
library chose rather than text an indicator produced.
|
|
567
853
|
|
|
568
854
|
### Cursors are opaque, not secret
|
|
569
855
|
|
|
@@ -576,20 +862,33 @@ do not treat a cursor as proof of anything.
|
|
|
576
862
|
It is off by default. When it is on, nothing in this library authenticates it — apply the
|
|
577
863
|
guard you would apply to any internal endpoint, or keep it off the public listener.
|
|
578
864
|
|
|
865
|
+
### The OpenAPI document does not exist in production
|
|
866
|
+
|
|
867
|
+
A published document is a map of every route, parameter and error shape an application has —
|
|
868
|
+
useful to a developer, and just as useful to anyone probing the service. So unlike the metrics
|
|
869
|
+
endpoint, it is not left to a guard: it is refused outright whenever the runtime is not
|
|
870
|
+
positively `development` or `test`, in two independent layers, with no option to override.
|
|
871
|
+
An unset `NODE_ENV` counts as production, because the deployment nobody configured is the one
|
|
872
|
+
most likely to be exposed.
|
|
873
|
+
|
|
579
874
|
---
|
|
580
875
|
|
|
581
876
|
## 🛡️ Security Table
|
|
582
877
|
|
|
583
|
-
| Layer
|
|
584
|
-
|
|
|
585
|
-
| Error responses
|
|
586
|
-
| Internals
|
|
587
|
-
| Health output
|
|
588
|
-
| Slow indicators
|
|
589
|
-
| Correlation
|
|
590
|
-
| Pagination cursors
|
|
591
|
-
| Metrics
|
|
592
|
-
|
|
|
878
|
+
| Layer | Implementation |
|
|
879
|
+
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
880
|
+
| Error responses | One shape for everything; unknown errors become a generic 500 |
|
|
881
|
+
| Internals | Message and stack captured for logging, in the body only under `exposeInternals` (default `false`) |
|
|
882
|
+
| Health output | The response names which indicator is down and nothing more; the reason goes to the logger. `exposeIndicatorErrors` (default `false`) puts it back in the response for debugging |
|
|
883
|
+
| Slow indicators | Converted to `down` by the aggregator, so a probe cannot hang on one |
|
|
884
|
+
| Correlation | Resolved through `BYMAX_CORRELATION_PROVIDER` — the app decides where the id comes from |
|
|
885
|
+
| Pagination cursors | Opaque, not authenticated; treated as client-supplied input on the way back in |
|
|
886
|
+
| Metrics | Opt-in; `prom-client` never imported while it is off |
|
|
887
|
+
| OpenAPI | Opt-in and development-only; refused in production by two independent guards, `@nestjs/swagger` never imported while it is off |
|
|
888
|
+
| Discovered checks | Matched by an explicit marker, never by shape; off by default, because it lets an imported library fail your readiness probe |
|
|
889
|
+
| Contributed metrics | Called only when marked; a name collision fails the boot naming the contributor rather than half-populating a scrape |
|
|
890
|
+
| Trace ids | Read-only, never used as a metric label; published in a response body only under `telemetry.exposeTraceId` (default off) |
|
|
891
|
+
| Supply chain | `dependencies: {}`; third-party Actions pinned by commit SHA (org-internal reusables by tag); CodeQL and OpenSSF Scorecard |
|
|
593
892
|
|
|
594
893
|
> [!IMPORTANT]
|
|
595
894
|
> **`exposeInternals` is a debugging switch, not a verbosity setting.** With it on,
|
|
@@ -603,7 +902,9 @@ guard you would apply to any internal endpoint, or keep it off the public listen
|
|
|
603
902
|
- **Runtime:** Node.js 24+
|
|
604
903
|
- **Framework:** NestJS 11 (`ConfigurableModuleBuilder`, `APP_FILTER`, `APP_INTERCEPTOR`)
|
|
605
904
|
- **Peers:** `@nestjs/common ^11`, `@nestjs/core ^11`, `rxjs ^7`, `reflect-metadata ^0.2`
|
|
606
|
-
- **Optional
|
|
905
|
+
- **Optional peers:** `prom-client ^15` when metrics are enabled, `@nestjs/swagger ^11` when
|
|
906
|
+
OpenAPI is enabled, `@opentelemetry/api ^1.9` when trace correlation is enabled — none is
|
|
907
|
+
imported while its feature is off
|
|
607
908
|
- **Build:** tsup — ESM + CJS per subpath, with `.d.ts` _and_ `.d.cts` declarations
|
|
608
909
|
- **Tests:** Jest (unit + e2e over a real Nest application) + Stryker (mutation)
|
|
609
910
|
- **TypeScript:** 5.x strict (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`), zero `any`
|
|
@@ -616,11 +917,13 @@ This library sits in the path of every request and every failure of every servic
|
|
|
616
917
|
installs it, so the suite is held to a bar beyond "the tests pass".
|
|
617
918
|
|
|
618
919
|
- ✅ **100% line coverage** — statements, branches, functions and lines, enforced as a gate
|
|
619
|
-
- ✅ **
|
|
920
|
+
- ✅ **98.76% mutation score** — verified with [Stryker](https://stryker-mutator.io/) at
|
|
620
921
|
`break: 95`; every killable survivor was killed by a strengthened test, with no production
|
|
621
|
-
change
|
|
622
|
-
|
|
623
|
-
|
|
922
|
+
change, and the nine that remain are documented equivalents rather than suppressions
|
|
923
|
+
([report](./docs/mutation_testing_results.md))
|
|
924
|
+
- ✅ **End-to-end against a real application** — the filter, the interceptor, the health and
|
|
925
|
+
metrics routes, the served OpenAPI document, discovered indicators, contributed metrics and
|
|
926
|
+
trace correlation are all exercised through a booted Nest app, not against mocks of it
|
|
624
927
|
- ✅ **Published-artifact gates** — `check:exports` resolves the types the way each module
|
|
625
928
|
system does, `check:runtime` loads every subpath from the packed tarball in ESM and
|
|
626
929
|
CommonJS, and `check:published` compiles this README's snippets against `dist/`
|
|
@@ -644,20 +947,21 @@ in the sections above.
|
|
|
644
947
|
|
|
645
948
|
### `.` (root)
|
|
646
949
|
|
|
647
|
-
| Export
|
|
648
|
-
|
|
|
649
|
-
| `BymaxCoreModule`
|
|
650
|
-
| `BymaxCoreModuleOptions`, `EnvelopeOptions`, `TimingOptions`, `HealthOptions`, `MetricsOptions`, `ResolvedCoreOptions`
|
|
651
|
-
| `BYMAX_CORE_OPTIONS`, `BYMAX_CORRELATION_PROVIDER`, `BYMAX_TIMING_SINK`, `BYMAX_HEALTH_INDICATORS`, `BYMAX_METRICS_REGISTRY`
|
|
652
|
-
| `ICorrelationIdProvider`
|
|
653
|
-
| `
|
|
654
|
-
| `
|
|
655
|
-
| `
|
|
656
|
-
| `
|
|
657
|
-
| `
|
|
658
|
-
| `
|
|
659
|
-
| `
|
|
660
|
-
| `
|
|
950
|
+
| Export | Kind | Description |
|
|
951
|
+
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | ---------------------------------------------------------------------- |
|
|
952
|
+
| `BymaxCoreModule` | class | The dynamic module: `forRoot` and `forRootAsync`. |
|
|
953
|
+
| `BymaxCoreModuleOptions`, `EnvelopeOptions`, `TimingOptions`, `HealthOptions`, `MetricsOptions`, `TelemetryOptions`, `OpenApiOptions`, `OpenApiServerDescriptor`, `OpenApiSecurityScheme`, `ResolvedCoreOptions` | types | The options surface and its resolved shape. |
|
|
954
|
+
| `BYMAX_CORE_OPTIONS`, `BYMAX_CORRELATION_PROVIDER`, `BYMAX_TIMING_SINK`, `BYMAX_HEALTH_INDICATORS`, `BYMAX_METRICS_REGISTRY` | tokens | The DI tokens; see the [token table](#-di-tokens). |
|
|
955
|
+
| `ICorrelationIdProvider` | type | The correlation-provider contract. |
|
|
956
|
+
| `ITraceContextProvider`, `TraceContext` | types | The trace-context contract and the identifiers it resolves. |
|
|
957
|
+
| `BymaxExceptionFilter` | class | The envelope exception filter. |
|
|
958
|
+
| `FilterErrorContext` | type | The neutral request context passed to the filter's observability seam. |
|
|
959
|
+
| `buildErrorEnvelope` | function | Pure builder assembling an `ErrorEnvelope`. |
|
|
960
|
+
| `ErrorEnvelope`, `ErrorDetails`, `BuildErrorEnvelopeInput` | types | The envelope contract and its builder input. |
|
|
961
|
+
| `TimingInterceptor` | class | The request-timing interceptor. |
|
|
962
|
+
| `ITimingSink`, `RequestTimingSample` | types | The timing-sink contract and its sample shape. |
|
|
963
|
+
| `BYMAX_BAD_GATEWAY` … `BYMAX_VALIDATION_FAILED` | constants | The full error-code catalog (see [Error envelope](#-error-envelope)). |
|
|
964
|
+
| `codeForStatus` | function | Derives a catalog code from an HTTP status. |
|
|
661
965
|
|
|
662
966
|
### `./pagination`
|
|
663
967
|
|
|
@@ -670,12 +974,31 @@ in the sections above.
|
|
|
670
974
|
|
|
671
975
|
### `./health`
|
|
672
976
|
|
|
673
|
-
| Export
|
|
674
|
-
|
|
|
675
|
-
| `IHealthIndicator`
|
|
676
|
-
| `HealthIndicatorResult`
|
|
677
|
-
| `HealthCheckEntry`
|
|
678
|
-
| `HealthResponse`
|
|
977
|
+
| Export | Kind | Description |
|
|
978
|
+
| --------------------------------- | -------- | --------------------------------------------------- |
|
|
979
|
+
| `IHealthIndicator` | type | The pluggable indicator contract. |
|
|
980
|
+
| `HealthIndicatorResult` | type | The outcome of a single indicator check. |
|
|
981
|
+
| `HealthCheckEntry` | type | One named entry in a `HealthResponse.checks` array. |
|
|
982
|
+
| `HealthResponse` | type | The stable liveness and readiness response shape. |
|
|
983
|
+
| `BymaxHealthIndicator` | function | Class decorator marking a provider as discoverable. |
|
|
984
|
+
| `BYMAX_HEALTH_INDICATOR_METADATA` | constant | The metadata key the marker writes. |
|
|
985
|
+
|
|
986
|
+
### `./metrics`
|
|
987
|
+
|
|
988
|
+
| Export | Kind | Description |
|
|
989
|
+
| ------------------------------------ | -------- | ------------------------------------------------------------ |
|
|
990
|
+
| `BymaxMetricsContributor` | function | Class decorator marking a provider as a metrics contributor. |
|
|
991
|
+
| `BYMAX_METRICS_CONTRIBUTOR_METADATA` | constant | The metadata key the marker writes. |
|
|
992
|
+
| `IMetricsContributor` | type | The contract: `registerMetrics(registry)`. |
|
|
993
|
+
| `MetricsRegistry` | type | The `prom-client` registry the scrape endpoint serves. |
|
|
994
|
+
|
|
995
|
+
### `./openapi`
|
|
996
|
+
|
|
997
|
+
| Export | Kind | Description |
|
|
998
|
+
| --------------------- | -------- | ----------------------------------------------------------------- |
|
|
999
|
+
| `applyBymaxOpenApi` | function | Builds and mounts the document; call it before `app.listen()`. |
|
|
1000
|
+
| `OpenApiMountOutcome` | type | What the helper did: mounted at a path, or skipped with a reason. |
|
|
1001
|
+
| `OpenApiSkipReason` | type | Why it was skipped: `'disabled'` or `'production'`. |
|
|
679
1002
|
|
|
680
1003
|
## 🧩 Compatibility
|
|
681
1004
|
|
package/dist/health/index.cjs
CHANGED
|
@@ -1,2 +1,12 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
var common = require('@nestjs/common');
|
|
4
|
+
|
|
5
|
+
// src/health/health.marker.ts
|
|
6
|
+
var BYMAX_HEALTH_INDICATOR_METADATA = "bymax-one:health-indicator";
|
|
7
|
+
function BymaxHealthIndicator() {
|
|
8
|
+
return common.SetMetadata(BYMAX_HEALTH_INDICATOR_METADATA, true);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
exports.BYMAX_HEALTH_INDICATOR_METADATA = BYMAX_HEALTH_INDICATOR_METADATA;
|
|
12
|
+
exports.BymaxHealthIndicator = BymaxHealthIndicator;
|