@bymax-one/nest-core 1.0.1 → 1.1.1
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 +155 -1
- package/README.md +358 -55
- 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 +408 -31
- package/dist/index.d.cts +224 -11
- package/dist/index.d.ts +224 -11
- package/dist/index.mjs +409 -33
- 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/dist/pagination/index.cjs +1 -0
- package/dist/pagination/index.mjs +1 -0
- package/package.json +48 -17
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-100%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
|
|
@@ -205,6 +224,7 @@ falls back to the documented default. Pass only what you want to change.
|
|
|
205
224
|
| `path` | `string` | `'health'` | Route prefix: `GET /<path>/live`, `GET /<path>/ready`. |
|
|
206
225
|
| `indicatorTimeoutMs` | `number` | `5000` | Per-indicator timeout before a check reports down. |
|
|
207
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. |
|
|
208
228
|
|
|
209
229
|
On `forRoot`, `enabled` and `path` are applied at module-definition time: a
|
|
210
230
|
disabled feature registers no controller, and a custom `path` mounts the routes.
|
|
@@ -227,6 +247,31 @@ As with `health`, `enabled` and `path` register conditionally on `forRoot`. On
|
|
|
227
247
|
and enforces `enabled` and the default path with a request-time guard, so a
|
|
228
248
|
disabled or custom-path async configuration fails fast at the route.
|
|
229
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
|
+
|
|
230
275
|
## 🔑 DI Tokens
|
|
231
276
|
|
|
232
277
|
Every token is a `Symbol`. `BYMAX_CORRELATION_PROVIDER` and
|
|
@@ -242,13 +287,14 @@ consumer `BYMAX_TIMING_SINK` override is honored on `forRoot` but shadowed on
|
|
|
242
287
|
[Integration with `@bymax-one/nest-logger`](#-integration-with-bymax-onenest-logger)
|
|
243
288
|
below.
|
|
244
289
|
|
|
245
|
-
| Token | Provides | When you do not provide one
|
|
246
|
-
| ---------------------------- | ------------------------------------- |
|
|
247
|
-
| `BYMAX_CORE_OPTIONS` | The resolved `BymaxCoreModuleOptions` | always set by the module
|
|
248
|
-
| `BYMAX_CORRELATION_PROVIDER` | `ICorrelationIdProvider` | internal no-op (omits `correlationId`)
|
|
249
|
-
| `BYMAX_TIMING_SINK` | `ITimingSink` | internal no-op, or the metrics bridge when timing and metrics are both enabled
|
|
250
|
-
| `BYMAX_HEALTH_INDICATORS` | `IHealthIndicator[]` | treated as an empty indicator set
|
|
251
|
-
| `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 |
|
|
252
298
|
|
|
253
299
|
## 🚨 Error Envelope
|
|
254
300
|
|
|
@@ -447,6 +493,52 @@ A rejecting, throwing, or slow indicator (past `indicatorTimeoutMs`) is
|
|
|
447
493
|
converted to a `down` entry with a safe, bounded diagnostic detail; it never
|
|
448
494
|
hides the results of the other registered indicators.
|
|
449
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
|
+
|
|
450
542
|
## 📈 Metrics
|
|
451
543
|
|
|
452
544
|
Disabled by default. Enabling it registers `GET /metrics`, serving Prometheus
|
|
@@ -472,6 +564,165 @@ default HTTP metrics with a bounded label set:
|
|
|
472
564
|
Inject `BYMAX_METRICS_REGISTRY` to register your own application metrics
|
|
473
565
|
against the same registry the endpoint scrapes.
|
|
474
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
|
+
|
|
475
726
|
## 🔗 Integration with `@bymax-one/nest-logger`
|
|
476
727
|
|
|
477
728
|
Pairing this package with `@bymax-one/nest-logger` yields correlated logs and
|
|
@@ -530,9 +781,21 @@ BYMAX_CORRELATION_PROVIDER no module,
|
|
|
530
781
|
comes from) script or a test
|
|
531
782
|
```
|
|
532
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
|
+
|
|
533
794
|
Each feature registers only when it is on. Turning metrics off does not leave a
|
|
534
795
|
disabled provider in the container — it leaves no provider, and `prom-client` is
|
|
535
|
-
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.
|
|
536
799
|
|
|
537
800
|
Nothing here holds state across requests. The timing interceptor emits and forgets;
|
|
538
801
|
the health service runs the indicators the app registered and folds their results;
|
|
@@ -599,20 +862,33 @@ do not treat a cursor as proof of anything.
|
|
|
599
862
|
It is off by default. When it is on, nothing in this library authenticates it — apply the
|
|
600
863
|
guard you would apply to any internal endpoint, or keep it off the public listener.
|
|
601
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
|
+
|
|
602
874
|
---
|
|
603
875
|
|
|
604
876
|
## 🛡️ Security Table
|
|
605
877
|
|
|
606
|
-
| Layer
|
|
607
|
-
|
|
|
608
|
-
| Error responses
|
|
609
|
-
| Internals
|
|
610
|
-
| Health output
|
|
611
|
-
| Slow indicators
|
|
612
|
-
| Correlation
|
|
613
|
-
| Pagination cursors
|
|
614
|
-
| Metrics
|
|
615
|
-
|
|
|
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 |
|
|
616
892
|
|
|
617
893
|
> [!IMPORTANT]
|
|
618
894
|
> **`exposeInternals` is a debugging switch, not a verbosity setting.** With it on,
|
|
@@ -626,7 +902,9 @@ guard you would apply to any internal endpoint, or keep it off the public listen
|
|
|
626
902
|
- **Runtime:** Node.js 24+
|
|
627
903
|
- **Framework:** NestJS 11 (`ConfigurableModuleBuilder`, `APP_FILTER`, `APP_INTERCEPTOR`)
|
|
628
904
|
- **Peers:** `@nestjs/common ^11`, `@nestjs/core ^11`, `rxjs ^7`, `reflect-metadata ^0.2`
|
|
629
|
-
- **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
|
|
630
908
|
- **Build:** tsup — ESM + CJS per subpath, with `.d.ts` _and_ `.d.cts` declarations
|
|
631
909
|
- **Tests:** Jest (unit + e2e over a real Nest application) + Stryker (mutation)
|
|
632
910
|
- **TypeScript:** 5.x strict (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`), zero `any`
|
|
@@ -639,15 +917,20 @@ This library sits in the path of every request and every failure of every servic
|
|
|
639
917
|
installs it, so the suite is held to a bar beyond "the tests pass".
|
|
640
918
|
|
|
641
919
|
- ✅ **100% line coverage** — statements, branches, functions and lines, enforced as a gate
|
|
642
|
-
- ✅ **
|
|
920
|
+
- ✅ **100% mutation score** — verified with [Stryker](https://stryker-mutator.io/) at
|
|
643
921
|
`break: 95`; every killable survivor was killed by a strengthened test, with no production
|
|
644
|
-
change
|
|
645
|
-
|
|
646
|
-
|
|
922
|
+
change, and the nine equivalents that no test can kill each carry their reason on the line
|
|
923
|
+
they apply to ([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
|
|
647
927
|
- ✅ **Published-artifact gates** — `check:exports` resolves the types the way each module
|
|
648
928
|
system does, `check:runtime` loads every subpath from the packed tarball in ESM and
|
|
649
929
|
CommonJS, and `check:published` compiles this README's snippets against `dist/`
|
|
650
|
-
- ✅ **
|
|
930
|
+
- ✅ **Every suppression carries its reason** — no coverage directives anywhere; each
|
|
931
|
+
`// Stryker disable` in the production source names, after the `:` Stryker reads it from,
|
|
932
|
+
why the mutant it silences is behaviour-preserving, and `check:mutants` proves those reasons
|
|
933
|
+
parse so they reach the mutation report rather than the `Ignored using a comment` fallback
|
|
651
934
|
|
|
652
935
|
```bash
|
|
653
936
|
pnpm test # unit suite
|
|
@@ -667,20 +950,21 @@ in the sections above.
|
|
|
667
950
|
|
|
668
951
|
### `.` (root)
|
|
669
952
|
|
|
670
|
-
| Export
|
|
671
|
-
|
|
|
672
|
-
| `BymaxCoreModule`
|
|
673
|
-
| `BymaxCoreModuleOptions`, `EnvelopeOptions`, `TimingOptions`, `HealthOptions`, `MetricsOptions`, `ResolvedCoreOptions`
|
|
674
|
-
| `BYMAX_CORE_OPTIONS`, `BYMAX_CORRELATION_PROVIDER`, `BYMAX_TIMING_SINK`, `BYMAX_HEALTH_INDICATORS`, `BYMAX_METRICS_REGISTRY`
|
|
675
|
-
| `ICorrelationIdProvider`
|
|
676
|
-
| `
|
|
677
|
-
| `
|
|
678
|
-
| `
|
|
679
|
-
| `
|
|
680
|
-
| `
|
|
681
|
-
| `
|
|
682
|
-
| `
|
|
683
|
-
| `
|
|
953
|
+
| Export | Kind | Description |
|
|
954
|
+
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | ---------------------------------------------------------------------- |
|
|
955
|
+
| `BymaxCoreModule` | class | The dynamic module: `forRoot` and `forRootAsync`. |
|
|
956
|
+
| `BymaxCoreModuleOptions`, `EnvelopeOptions`, `TimingOptions`, `HealthOptions`, `MetricsOptions`, `TelemetryOptions`, `OpenApiOptions`, `OpenApiServerDescriptor`, `OpenApiSecurityScheme`, `ResolvedCoreOptions` | types | The options surface and its resolved shape. |
|
|
957
|
+
| `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). |
|
|
958
|
+
| `ICorrelationIdProvider` | type | The correlation-provider contract. |
|
|
959
|
+
| `ITraceContextProvider`, `TraceContext` | types | The trace-context contract and the identifiers it resolves. |
|
|
960
|
+
| `BymaxExceptionFilter` | class | The envelope exception filter. |
|
|
961
|
+
| `FilterErrorContext` | type | The neutral request context passed to the filter's observability seam. |
|
|
962
|
+
| `buildErrorEnvelope` | function | Pure builder assembling an `ErrorEnvelope`. |
|
|
963
|
+
| `ErrorEnvelope`, `ErrorDetails`, `BuildErrorEnvelopeInput` | types | The envelope contract and its builder input. |
|
|
964
|
+
| `TimingInterceptor` | class | The request-timing interceptor. |
|
|
965
|
+
| `ITimingSink`, `RequestTimingSample` | types | The timing-sink contract and its sample shape. |
|
|
966
|
+
| `BYMAX_BAD_GATEWAY` … `BYMAX_VALIDATION_FAILED` | constants | The full error-code catalog (see [Error envelope](#-error-envelope)). |
|
|
967
|
+
| `codeForStatus` | function | Derives a catalog code from an HTTP status. |
|
|
684
968
|
|
|
685
969
|
### `./pagination`
|
|
686
970
|
|
|
@@ -693,12 +977,31 @@ in the sections above.
|
|
|
693
977
|
|
|
694
978
|
### `./health`
|
|
695
979
|
|
|
696
|
-
| Export
|
|
697
|
-
|
|
|
698
|
-
| `IHealthIndicator`
|
|
699
|
-
| `HealthIndicatorResult`
|
|
700
|
-
| `HealthCheckEntry`
|
|
701
|
-
| `HealthResponse`
|
|
980
|
+
| Export | Kind | Description |
|
|
981
|
+
| --------------------------------- | -------- | --------------------------------------------------- |
|
|
982
|
+
| `IHealthIndicator` | type | The pluggable indicator contract. |
|
|
983
|
+
| `HealthIndicatorResult` | type | The outcome of a single indicator check. |
|
|
984
|
+
| `HealthCheckEntry` | type | One named entry in a `HealthResponse.checks` array. |
|
|
985
|
+
| `HealthResponse` | type | The stable liveness and readiness response shape. |
|
|
986
|
+
| `BymaxHealthIndicator` | function | Class decorator marking a provider as discoverable. |
|
|
987
|
+
| `BYMAX_HEALTH_INDICATOR_METADATA` | constant | The metadata key the marker writes. |
|
|
988
|
+
|
|
989
|
+
### `./metrics`
|
|
990
|
+
|
|
991
|
+
| Export | Kind | Description |
|
|
992
|
+
| ------------------------------------ | -------- | ------------------------------------------------------------ |
|
|
993
|
+
| `BymaxMetricsContributor` | function | Class decorator marking a provider as a metrics contributor. |
|
|
994
|
+
| `BYMAX_METRICS_CONTRIBUTOR_METADATA` | constant | The metadata key the marker writes. |
|
|
995
|
+
| `IMetricsContributor` | type | The contract: `registerMetrics(registry)`. |
|
|
996
|
+
| `MetricsRegistry` | type | The `prom-client` registry the scrape endpoint serves. |
|
|
997
|
+
|
|
998
|
+
### `./openapi`
|
|
999
|
+
|
|
1000
|
+
| Export | Kind | Description |
|
|
1001
|
+
| --------------------- | -------- | ----------------------------------------------------------------- |
|
|
1002
|
+
| `applyBymaxOpenApi` | function | Builds and mounts the document; call it before `app.listen()`. |
|
|
1003
|
+
| `OpenApiMountOutcome` | type | What the helper did: mounted at a path, or skipped with a reason. |
|
|
1004
|
+
| `OpenApiSkipReason` | type | Why it was skipped: `'disabled'` or `'production'`. |
|
|
702
1005
|
|
|
703
1006
|
## 🧩 Compatibility
|
|
704
1007
|
|
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;
|
package/dist/health/index.d.cts
CHANGED
|
@@ -1,3 +1,35 @@
|
|
|
1
|
+
import { CustomDecorator } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reflect metadata key carrying the discoverable marker. Namespaced so it cannot
|
|
5
|
+
* collide with a consumer's own metadata, and exported so a test — or a sibling
|
|
6
|
+
* library's own conformance suite — can assert a class is marked without
|
|
7
|
+
* depending on how the decorator is implemented.
|
|
8
|
+
*/
|
|
9
|
+
declare const BYMAX_HEALTH_INDICATOR_METADATA = "bymax-one:health-indicator";
|
|
10
|
+
/**
|
|
11
|
+
* Mark a provider class as a readiness indicator, so `BymaxCoreModule` discovers
|
|
12
|
+
* it without the application registering it by hand.
|
|
13
|
+
*
|
|
14
|
+
* The class must implement `IHealthIndicator`; a marked provider that does not
|
|
15
|
+
* fails at bootstrap with a message naming it, rather than being skipped
|
|
16
|
+
* silently. Discovery has to be enabled (`health.autoDiscover`) for the marker to
|
|
17
|
+
* be read at all — marking a class in an application that never enables it costs
|
|
18
|
+
* one metadata entry and changes nothing.
|
|
19
|
+
*
|
|
20
|
+
* @returns The class decorator carrying the marker.
|
|
21
|
+
* @example
|
|
22
|
+
* \@BymaxHealthIndicator()
|
|
23
|
+
* \@Injectable()
|
|
24
|
+
* export class RedisHealthIndicator implements IHealthIndicator {
|
|
25
|
+
* readonly name = 'redis'
|
|
26
|
+
* async check(): Promise<HealthIndicatorResult> {
|
|
27
|
+
* return { status: 'up' }
|
|
28
|
+
* }
|
|
29
|
+
* }
|
|
30
|
+
*/
|
|
31
|
+
declare function BymaxHealthIndicator(): CustomDecorator<string>;
|
|
32
|
+
|
|
1
33
|
/**
|
|
2
34
|
* @fileoverview Health-check contracts. Consumers implement
|
|
3
35
|
* {@link IHealthIndicator} against a client they already own (a cache, a
|
|
@@ -61,4 +93,4 @@ interface HealthResponse {
|
|
|
61
93
|
checks: HealthCheckEntry[];
|
|
62
94
|
}
|
|
63
95
|
|
|
64
|
-
export type
|
|
96
|
+
export { BYMAX_HEALTH_INDICATOR_METADATA, BymaxHealthIndicator, type HealthCheckEntry, type HealthIndicatorResult, type HealthResponse, type IHealthIndicator };
|