@bymax-one/nest-core 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,716 @@
1
+ <p align="center">
2
+ <img src="https://img.shields.io/badge/%40bymax--one-nest--core-000000?style=for-the-badge&logo=nestjs&logoColor=E0234E" alt="@bymax-one/nest-core" />
3
+ </p>
4
+
5
+ <h1 align="center">@bymax-one/nest-core</h1>
6
+
7
+ <p align="center">
8
+ <strong>Zero-dependency application foundation kit for NestJS</strong><br />
9
+ <sub>Error Envelope · Request Timing · Offset &amp; Cursor Pagination · Health Probes · Prometheus Metrics · Zero Runtime Dependencies</sub>
10
+ </p>
11
+
12
+ <p align="center">
13
+ <a href="https://www.npmjs.com/package/@bymax-one/nest-core"><img src="https://img.shields.io/npm/v/@bymax-one/nest-core?style=flat-square&colorA=000000&colorB=000000" alt="npm version" /></a>
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
+ <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
+ <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-97.86%25-brightgreen?style=flat-square&colorA=000000" alt="mutation score" /></a>
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
+ <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
+ <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>
21
+ <a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-24%2B-339933?style=flat-square&logo=node.js&logoColor=white" alt="Node.js" /></a>
22
+ </p>
23
+
24
+ <p align="center">
25
+ <a href="https://github.com/bymaxone/nest-core">GitHub</a> ·
26
+ <a href="https://github.com/bymaxone/nest-core/issues">Issues</a> ·
27
+ <a href="#-quick-start">Quick Start</a> ·
28
+ <a href="#-api-reference">API Reference</a> ·
29
+ <a href="https://github.com/bymaxone/nest-core-example">Example App</a>
30
+ </p>
31
+
32
+ ---
33
+
34
+ ## ✨ Overview
35
+
36
+ `@bymax-one/nest-core` is the layer every service in a fleet ends up writing for itself: one
37
+ error shape, one timing sample, one pagination contract, one health probe, one metrics
38
+ endpoint. Writing it per service is how five services end up answering the same failure five
39
+ different ways, and how a client integration breaks because one of them changed its error
40
+ body.
41
+
42
+ It ships `"dependencies": {}`. Everything it touches — NestJS, `rxjs`, `reflect-metadata`, and
43
+ `prom-client` for the optional metrics endpoint — is a peer whose version you already control.
44
+
45
+ ### Why nest-core?
46
+
47
+ - **One error shape, fleet-wide.** A versioned code catalog and a fixed envelope, so a client
48
+ writes one error handler instead of one per service — and an unknown failure becomes a
49
+ generic 500 rather than whatever the framework happened to serialize.
50
+ - **Features register only when enabled.** Turning metrics off does not leave a disabled
51
+ provider in the container; it leaves no provider, and `prom-client` is never imported. That
52
+ is what lets it stay an optional peer.
53
+ - **Pagination without a provider.** `./pagination` is pure functions on their own subpath —
54
+ no module to import, nothing to inject, usable from a script or a test.
55
+ - **Health that cannot hang.** An indicator that rejects becomes a `down` entry from its
56
+ top-level message alone, truncated; a slow one is converted by the aggregator rather than
57
+ holding the probe open.
58
+
59
+ ---
60
+
61
+ ## 🔥 Features
62
+
63
+ ### 🚨 Errors
64
+
65
+ - ✅ **Stable envelope** — one JSON shape for every error an application returns:
66
+ `statusCode`, `code`, `message`, `details`, `correlationId`, `timestamp`, `path`
67
+ - ✅ **Versioned code catalog** — `BYMAX_NOT_FOUND`, `BYMAX_CONFLICT`, `BYMAX_BAD_GATEWAY`
68
+ and the rest, exported as constants so a client maps a `code` rather than a message string
69
+ - ✅ **Internals stay internal** — an unknown error becomes a generic 500; its message and
70
+ stack are captured for your logger, and reach the body only under `exposeInternals`
71
+ - ✅ **Correlation id** — resolved through `BYMAX_CORRELATION_PROVIDER`, so the id comes from
72
+ wherever your request context already keeps it
73
+
74
+ ### ⏱️ Observability
75
+
76
+ - ✅ **Request timing** — one sample per completed request, handed to the sink you register;
77
+ the library stores nothing itself
78
+ - ✅ **Slow-request flag** — samples above `slowRequestThresholdMs` are marked, so a sink can
79
+ branch without re-deriving the threshold
80
+ - ✅ **Prometheus endpoint** — opt-in scrape route over `BYMAX_METRICS_REGISTRY`;
81
+ `prom-client` is imported only when it is enabled
82
+
83
+ ### 📄 Pagination & Health
84
+
85
+ - ✅ **Offset and cursor** — `normalizePageQuery` / `buildPageResult` and
86
+ `normalizeCursorQuery` / `buildCursorResult`, pure functions with no NestJS involvement
87
+ - ✅ **Opaque cursors** — `encodeCursor` / `decodeCursor` round-trip a token a client carries
88
+ back, treated as untrusted input on the way in
89
+ - ✅ **Liveness and readiness** — separate endpoints, so a slow dependency fails readiness
90
+ without restarting the pod
91
+ - ✅ **Pluggable indicators** — implement `IHealthIndicator` against a client you already own
92
+ and register it under the `BYMAX_HEALTH_INDICATORS` multi-token
93
+
94
+ ### 🧩 Developer Experience
95
+
96
+ - ✅ **Zero runtime dependencies** — `@nestjs/*`, `rxjs` and `reflect-metadata` arrive as
97
+ peers, so you pin the versions
98
+ - ✅ **Three subpaths** — the module, plus `./pagination` and `./health` that a package can
99
+ import without pulling the module in
100
+ - ✅ **Dual-format output** — ESM + CJS with declarations for each format, verified against
101
+ the packed tarball on every run
102
+ - ✅ **Independent features** — each is enabled on its own; the providers for the rest are
103
+ never registered
104
+ - ✅ **Typed end to end** — TypeScript `strict` with `exactOptionalPropertyTypes` and
105
+ `noUncheckedIndexedAccess`; zero `any`
106
+
107
+ ---
108
+
109
+ ## 📦 Subpath Exports
110
+
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` and the indicator contracts, so a package that only implements an indicator does not import the module |
116
+
117
+ Each subpath ships ESM and CommonJS with its own `.d.ts` and `.d.cts`, so
118
+ `require()` and `import` both resolve the declarations meant for them.
119
+
120
+ ### Install
121
+
122
+ ```bash
123
+ pnpm add @bymax-one/nest-core @nestjs/common @nestjs/core reflect-metadata rxjs
124
+ ```
125
+
126
+ Add `prom-client` as well if you enable the metrics feature; it is an optional
127
+ peer dependency, so it is never required unless you turn metrics on:
128
+
129
+ ```bash
130
+ pnpm add prom-client
131
+ ```
132
+
133
+ ## 🚀 Quick Start
134
+
135
+ ```typescript
136
+ import { Module } from '@nestjs/common'
137
+ import { BymaxCoreModule } from '@bymax-one/nest-core'
138
+
139
+ @Module({
140
+ imports: [BymaxCoreModule.forRoot()]
141
+ })
142
+ export class AppModule {}
143
+ ```
144
+
145
+ With no options, `forRoot()` enables the error envelope, request timing, and
146
+ health endpoints, and leaves metrics off. Every documented default is listed
147
+ in the [configuration reference](#-configuration) below.
148
+
149
+ ## 🏭 Production Wiring with `forRootAsync`
150
+
151
+ The standard pattern in real applications: resolve options from your own
152
+ configuration service, so behavior can vary by environment without a second
153
+ code path.
154
+
155
+ ```typescript
156
+ import { Module } from '@nestjs/common'
157
+ import { BymaxCoreModule } from '@bymax-one/nest-core'
158
+
159
+ @Module({
160
+ imports: [
161
+ BymaxCoreModule.forRootAsync({
162
+ inject: [AppConfigService],
163
+ useFactory: (config: AppConfigService) => ({
164
+ envelope: { exposeInternals: config.env === 'development' },
165
+ timing: { slowRequestThresholdMs: 1_000 },
166
+ metrics: { enabled: config.env === 'production' }
167
+ })
168
+ })
169
+ ]
170
+ })
171
+ export class AppModule {}
172
+ ```
173
+
174
+ `isGlobal` is a module extra, not part of the options object, defaulting to
175
+ `true`:
176
+
177
+ ```typescript
178
+ BymaxCoreModule.forRoot({ isGlobal: false })
179
+ ```
180
+
181
+ ## ⚙️ Configuration
182
+
183
+ Every block is optional; an omitted block, or an omitted field within it,
184
+ falls back to the documented default. Pass only what you want to change.
185
+
186
+ ### `envelope`
187
+
188
+ | Option | Type | Default | Description |
189
+ | ----------------- | --------- | ------- | ---------------------------------------------------------------------------------------------- |
190
+ | `enabled` | `boolean` | `true` | Registers the global exception filter. |
191
+ | `exposeInternals` | `boolean` | `false` | Includes the original message and stack of unknown errors. Development only, never production. |
192
+
193
+ ### `timing`
194
+
195
+ | Option | Type | Default | Description |
196
+ | ------------------------ | --------- | ------- | ------------------------------------------------------------------------------ |
197
+ | `enabled` | `boolean` | `true` | Registers the request-timing interceptor. |
198
+ | `slowRequestThresholdMs` | `number` | unset | Samples above this duration are flagged `slow: true`. Absent means never slow. |
199
+
200
+ ### `health`
201
+
202
+ | Option | Type | Default | Description |
203
+ | -------------------- | --------- | ---------- | ------------------------------------------------------ |
204
+ | `enabled` | `boolean` | `true` | Registers the health controller. |
205
+ | `path` | `string` | `'health'` | Route prefix: `GET /<path>/live`, `GET /<path>/ready`. |
206
+ | `indicatorTimeoutMs` | `number` | `5000` | Per-indicator timeout before a check reports down. |
207
+
208
+ On `forRoot`, `enabled` and `path` are applied at module-definition time: a
209
+ disabled feature registers no controller, and a custom `path` mounts the routes.
210
+ On `forRootAsync`, options resolve after the module is defined, so the health
211
+ controller is always registered at the default path and enforces `enabled` and
212
+ the default path with a request-time guard; a disabled or custom-path async
213
+ configuration fails fast at the route rather than at boot.
214
+
215
+ ### `metrics`
216
+
217
+ | Option | Type | Default | Description |
218
+ | ----------------------- | ------------------------ | ----------- | --------------------------------------------------------------------- |
219
+ | `enabled` | `boolean` | `false` | Registers the metrics controller and the registry. |
220
+ | `path` | `string` | `'metrics'` | Route serving the Prometheus scrape. |
221
+ | `defaultLabels` | `Record<string, string>` | `{}` | Static labels attached to every metric. |
222
+ | `collectDefaultMetrics` | `boolean` | `true` | Collects `prom-client`'s process CPU, memory, and event-loop metrics. |
223
+
224
+ As with `health`, `enabled` and `path` register conditionally on `forRoot`. On
225
+ `forRootAsync` the metrics controller is always registered at the default path
226
+ and enforces `enabled` and the default path with a request-time guard, so a
227
+ disabled or custom-path async configuration fails fast at the route.
228
+
229
+ ## 🔑 DI Tokens
230
+
231
+ Every token is a `Symbol`. `BYMAX_CORRELATION_PROVIDER` and
232
+ `BYMAX_HEALTH_INDICATORS` are consumed with `@Optional()` and are not bound by
233
+ the module: provide either from your own module to supply your own
234
+ implementation, otherwise the internal fallback in the last column applies.
235
+ `BYMAX_TIMING_SINK` and `BYMAX_METRICS_REGISTRY` behave differently on
236
+ `forRootAsync`, where options resolve after the module is defined: there the
237
+ module always binds and exports both (the timing sink as the metrics bridge or a
238
+ no-op, the registry as a guarded placeholder when metrics are off), so a
239
+ consumer `BYMAX_TIMING_SINK` override is honored on `forRoot` but shadowed on
240
+ `forRootAsync`. Follow the pattern in
241
+ [Integration with `@bymax-one/nest-logger`](#-integration-with-bymax-onenest-logger)
242
+ below.
243
+
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 |
251
+
252
+ ## 🚨 Error Envelope
253
+
254
+ Every error that leaves an application registered with the envelope feature
255
+ follows this exact, versioned shape:
256
+
257
+ ```json
258
+ {
259
+ "statusCode": 404,
260
+ "code": "BYMAX_NOT_FOUND",
261
+ "message": "Invoice inv_123 was not found",
262
+ "details": [{ "field": "id", "issue": "unknown identifier" }],
263
+ "correlationId": "8f14e45f-ceea-4677-a9de-6ec3f1f0a1b2",
264
+ "timestamp": "2026-07-16T12:00:00.000Z",
265
+ "path": "/invoices/inv_123"
266
+ }
267
+ ```
268
+
269
+ | Field | Type | Presence | Notes |
270
+ | --------------- | ----------------- | -------- | -------------------------------------------------- |
271
+ | `statusCode` | number | always | HTTP status. |
272
+ | `code` | string | always | Stable, machine-readable code. |
273
+ | `message` | string | always | Human-readable, safe for end users. |
274
+ | `details` | array or object | optional | Structured context, for example validation issues. |
275
+ | `correlationId` | string | optional | Present when a correlation provider is bound. |
276
+ | `timestamp` | string (ISO 8601) | always | Time the error was formatted. |
277
+ | `path` | string | always | Request URL path. |
278
+
279
+ Codes are stable strings under a reserved `BYMAX_` prefix, derived from the
280
+ HTTP status: `BYMAX_NOT_FOUND` for 404, `BYMAX_VALIDATION_FAILED` for the
281
+ shape a validation pipe produces, `BYMAX_INTERNAL_ERROR` for anything
282
+ unmapped, and so on. Throw an `HttpException` whose response object carries
283
+ your own `code` and the filter passes it through verbatim:
284
+
285
+ ```typescript
286
+ import { BadRequestException } from '@nestjs/common'
287
+
288
+ throw new BadRequestException({ code: 'INVOICE_OVERDUE', message: 'Invoice is overdue' })
289
+ ```
290
+
291
+ ## ⏱️ Request Timing
292
+
293
+ One `RequestTimingSample` is delivered per completed request, success or
294
+ error, to whatever implements `ITimingSink`:
295
+
296
+ ```typescript
297
+ export interface RequestTimingSample {
298
+ method: string
299
+ route: string
300
+ statusCode: number
301
+ durationMs: number
302
+ slow: boolean
303
+ }
304
+ ```
305
+
306
+ Bind your own sink by providing `BYMAX_TIMING_SINK` from your own module, the
307
+ same override pattern shown below for the correlation provider. This applies on
308
+ the `forRoot` path; on `forRootAsync` the module owns `BYMAX_TIMING_SINK` (the
309
+ metrics bridge or a no-op) so a consumer binding is shadowed there:
310
+
311
+ ```typescript
312
+ import { Global, Module } from '@nestjs/common'
313
+ import { BYMAX_TIMING_SINK, type ITimingSink } from '@bymax-one/nest-core'
314
+
315
+ class LoggerTimingSink implements ITimingSink {
316
+ record(sample: import('@bymax-one/nest-core').RequestTimingSample): void {
317
+ // forward to your own logger or telemetry pipeline
318
+ }
319
+ }
320
+
321
+ @Global()
322
+ @Module({
323
+ providers: [{ provide: BYMAX_TIMING_SINK, useClass: LoggerTimingSink }],
324
+ exports: [BYMAX_TIMING_SINK]
325
+ })
326
+ export class ObservabilityModule {}
327
+ ```
328
+
329
+ ## 📄 Pagination
330
+
331
+ Framework-neutral, pure functions on the `./pagination` subpath: no NestJS
332
+ provider, no ORM awareness. Your repository translates the normalized query
333
+ into its own persistence call.
334
+
335
+ ### Offset pagination
336
+
337
+ ```typescript
338
+ import { Controller, Get, Query } from '@nestjs/common'
339
+ import {
340
+ buildPageResult,
341
+ normalizePageQuery,
342
+ type PageResult
343
+ } from '@bymax-one/nest-core/pagination'
344
+
345
+ @Controller('invoices')
346
+ export class InvoiceController {
347
+ constructor(private readonly invoices: InvoiceRepository) {}
348
+
349
+ @Get()
350
+ async list(@Query() raw: Record<string, unknown>): Promise<PageResult<Invoice>> {
351
+ const query = normalizePageQuery(raw, { maxLimit: 50 })
352
+ const { rows, total } = await this.invoices.findPage(query)
353
+ return buildPageResult(rows, total, query)
354
+ }
355
+ }
356
+ ```
357
+
358
+ ### Cursor pagination
359
+
360
+ ```typescript
361
+ import { Controller, Get, Query } from '@nestjs/common'
362
+ import {
363
+ buildCursorResult,
364
+ decodeCursor,
365
+ normalizeCursorQuery,
366
+ type CursorResult
367
+ } from '@bymax-one/nest-core/pagination'
368
+
369
+ @Controller('invoices')
370
+ export class InvoiceCursorController {
371
+ constructor(private readonly invoices: InvoiceRepository) {}
372
+
373
+ @Get('cursor')
374
+ async list(@Query() raw: Record<string, unknown>): Promise<CursorResult<Invoice>> {
375
+ const query = normalizeCursorQuery(raw, { maxLimit: 50 })
376
+ const after = query.cursor ? decodeCursor<{ id: string }>(query.cursor) : undefined
377
+ // fetch limit + 1 rows ordered after `after`, the fetch-one-extra convention
378
+ const rows = await this.invoices.findAfter(after, query.limit + 1)
379
+ return buildCursorResult(rows, query.limit, (last) => ({ id: last.id }))
380
+ }
381
+ }
382
+ ```
383
+
384
+ A malformed or tampered cursor rejects with `BYMAX_VALIDATION_FAILED`. Cursors
385
+ are opaque `base64url` strings but are neither encrypted nor signed: encode
386
+ ordering keys only, never sensitive data.
387
+
388
+ ## ❤️ Health
389
+
390
+ Liveness always replies `200` with an empty checks array; readiness runs
391
+ every registered indicator concurrently and replies `200` only when every
392
+ indicator reports `up`, `503` otherwise, naming every check either way.
393
+
394
+ ```json
395
+ { "status": "ok", "checks": [{ "name": "redis", "status": "up" }] }
396
+ ```
397
+
398
+ Implement `IHealthIndicator` against a client you already own:
399
+
400
+ ```typescript
401
+ import { Injectable } from '@nestjs/common'
402
+ import type { HealthIndicatorResult, IHealthIndicator } from '@bymax-one/nest-core/health'
403
+
404
+ @Injectable()
405
+ export class RedisHealthIndicator implements IHealthIndicator {
406
+ readonly name = 'redis'
407
+
408
+ constructor(private readonly redis: RedisClient) {}
409
+
410
+ async check(): Promise<HealthIndicatorResult> {
411
+ await this.redis.ping()
412
+ return { status: 'up' }
413
+ }
414
+ }
415
+ ```
416
+
417
+ Register it under the shared `BYMAX_HEALTH_INDICATORS` token from your own
418
+ module, the same override pattern used throughout this README:
419
+
420
+ ```typescript
421
+ import { Global, Module } from '@nestjs/common'
422
+ import { BYMAX_HEALTH_INDICATORS } from '@bymax-one/nest-core'
423
+
424
+ @Global()
425
+ @Module({
426
+ providers: [
427
+ RedisHealthIndicator,
428
+ {
429
+ provide: BYMAX_HEALTH_INDICATORS,
430
+ useFactory: (r: RedisHealthIndicator) => [r],
431
+ inject: [RedisHealthIndicator]
432
+ }
433
+ ],
434
+ exports: [BYMAX_HEALTH_INDICATORS]
435
+ })
436
+ export class HealthIndicatorsModule {}
437
+ ```
438
+
439
+ A rejecting, throwing, or slow indicator (past `indicatorTimeoutMs`) is
440
+ converted to a `down` entry with a safe, bounded diagnostic detail; it never
441
+ hides the results of the other registered indicators.
442
+
443
+ ## 📈 Metrics
444
+
445
+ Disabled by default. Enabling it registers `GET /metrics`, serving Prometheus
446
+ text format from a dedicated `prom-client` registry:
447
+
448
+ ```typescript
449
+ BymaxCoreModule.forRoot({ metrics: { enabled: true } })
450
+ ```
451
+
452
+ `prom-client` is an optional peer, loaded lazily only when `metrics.enabled`
453
+ is `true`. If you enable metrics without installing it, the module fails fast
454
+ at boot with a descriptive error naming the missing package and the install
455
+ command, rather than a cryptic resolution failure at the first scrape.
456
+
457
+ When timing and metrics are both enabled, an internal bridge feeds two
458
+ default HTTP metrics with a bounded label set:
459
+
460
+ | Metric | Type | Labels |
461
+ | ------------------------------- | --------- | -------------------------------- |
462
+ | `http_requests_total` | counter | `method`, `route`, `status_code` |
463
+ | `http_request_duration_seconds` | histogram | `method`, `route`, `status_code` |
464
+
465
+ Inject `BYMAX_METRICS_REGISTRY` to register your own application metrics
466
+ against the same registry the endpoint scrapes.
467
+
468
+ ## 🔗 Integration with `@bymax-one/nest-logger`
469
+
470
+ Pairing this package with `@bymax-one/nest-logger` yields correlated logs and
471
+ error responses with one binding and no hard coupling: `LogContextService`
472
+ satisfies `ICorrelationIdProvider` out of the box, so `useExisting` aliases it
473
+ onto the shared token. Bind it from a `@Global()` module of your own so the
474
+ binding is visible outside the module that declares it:
475
+
476
+ ```typescript
477
+ import { Global, Module } from '@nestjs/common'
478
+ import { BYMAX_CORRELATION_PROVIDER } from '@bymax-one/nest-core'
479
+ import { LogContextService, NestLoggerModule } from '@bymax-one/nest-logger'
480
+
481
+ @Global()
482
+ @Module({
483
+ imports: [NestLoggerModule.forRoot()],
484
+ providers: [{ provide: BYMAX_CORRELATION_PROVIDER, useExisting: LogContextService }],
485
+ exports: [BYMAX_CORRELATION_PROVIDER]
486
+ })
487
+ export class ObservabilityModule {}
488
+ ```
489
+
490
+ Every error envelope now carries the same correlation id your logs do. The
491
+ same `@Global()`-module pattern is how every pluggable token in this package
492
+ is overridden: `BYMAX_TIMING_SINK` and `BYMAX_HEALTH_INDICATORS` follow it
493
+ identically.
494
+
495
+ ## 🏗️ Architecture
496
+
497
+ ```
498
+ BymaxCoreModule.forRoot / forRootAsync
499
+
500
+ each feature registers only if enabled
501
+ (off means no provider, not a disabled one)
502
+
503
+ ┌───────────┬───────────────┼───────────────┬───────────┐
504
+ │ │ │ │ │
505
+ envelope/ timing/ health/ pagination/ metrics/
506
+ │ │ │ │ │
507
+ APP_FILTER APP_INTERCEPTOR liveness + pure functions Prometheus
508
+ │ │ readiness on their own scrape route
509
+ │ │ │ subpath (opt-in)
510
+ ▼ ▼ ▼ │ │
511
+ one JSON one sample BYMAX_HEALTH_ │ ▼
512
+ shape for per request INDICATORS │ BYMAX_METRICS_
513
+ every → your sink (multi-token) │ REGISTRY
514
+ error │ │ │ │
515
+ │ │ ▼ │ prom-client is
516
+ versioned library a rejecting or │ imported ONLY
517
+ code stores slow indicator │ while enabled
518
+ catalog nothing → `down`, bounded │
519
+ │ │
520
+ ▼ no provider,
521
+ BYMAX_CORRELATION_PROVIDER no module,
522
+ (the app decides where the id usable from a
523
+ comes from) script or a test
524
+ ```
525
+
526
+ Each feature registers only when it is on. Turning metrics off does not leave a
527
+ 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.
529
+
530
+ Nothing here holds state across requests. The timing interceptor emits and forgets;
531
+ the health service runs the indicators the app registered and folds their results;
532
+ the pagination helpers are functions of their arguments.
533
+
534
+ ### Design Principles
535
+
536
+ | Principle | Description |
537
+ | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
538
+ | 🎭 **One shape for every failure** | The filter's job is to make a client's error handling independent of which service failed and how. An unknown error becomes a generic 500 with a code, not a leaked stack |
539
+ | 🔌 **Enabled means registered** | A feature that is off registers no provider at all, which is what lets `prom-client` remain an optional peer instead of an always-installed one |
540
+ | 🧮 **Pure where it can be** | Pagination is functions on their own subpath — no provider, no module, no container. A script can use it |
541
+ | 🧊 **Zero runtime dependencies** | `dependencies` is `{}`. Every version you install is one you chose |
542
+ | 🩺 **A probe cannot hang** | The aggregator converts a rejecting or slow indicator to `down` itself, so an indicator implementation never needs to guard its own timeout |
543
+ | 🧬 **Explicit DI tokens** | Tokens are `Symbol()`, so no string token can collide with them, and every injectable constructor parameter is decorated explicitly |
544
+
545
+ ---
546
+
547
+ ## 🔐 Security Model
548
+
549
+ This library writes the response a client sees when something fails, and exposes the
550
+ endpoints an operator scrapes. Its security contract is about what those two surfaces
551
+ disclose.
552
+
553
+ ### An error envelope is an exfiltration surface
554
+
555
+ The filter's job is to make every failure look the same to a client, so an unknown error
556
+ becomes a generic 500 whose body carries the code, the correlation id and nothing else. The
557
+ original message and stack are captured for your logger, not for the response.
558
+ `envelope.exposeInternals` puts them in the body and exists for local debugging — its own
559
+ documentation says never to enable it in production, and it defaults to `false`.
560
+
561
+ ### Health output is bounded by construction
562
+
563
+ An indicator that rejects is folded into a `down` entry from its top-level `Error#message`
564
+ only — never the raw error, its stack, or a nested cause — and the message is truncated. An
565
+ indicator cannot leak more than it already chose to put in a message, and a slow one is
566
+ converted to `down` by the aggregator rather than hanging the probe.
567
+
568
+ ### Cursors are opaque, not secret
569
+
570
+ `encodeCursor` produces a token a client can round-trip; it is not encrypted and not
571
+ authenticated. Do not put anything in a cursor that the client is not allowed to read, and
572
+ do not treat a cursor as proof of anything.
573
+
574
+ ### The metrics endpoint is a route like any other
575
+
576
+ It is off by default. When it is on, nothing in this library authenticates it — apply the
577
+ guard you would apply to any internal endpoint, or keep it off the public listener.
578
+
579
+ ---
580
+
581
+ ## 🛡️ Security Table
582
+
583
+ | Layer | Implementation |
584
+ | ------------------ | -------------------------------------------------------------------------------------------------------------------------- |
585
+ | Error responses | One shape for everything; unknown errors become a generic 500 |
586
+ | Internals | Message and stack captured for logging, in the body only under `exposeInternals` (default `false`) |
587
+ | Health output | Top-level `Error#message` only, truncated; no raw error, stack or cause |
588
+ | Slow indicators | Converted to `down` by the aggregator, so a probe cannot hang on one |
589
+ | Correlation | Resolved through `BYMAX_CORRELATION_PROVIDER` — the app decides where the id comes from |
590
+ | Pagination cursors | Opaque, not authenticated; treated as client-supplied input on the way back in |
591
+ | Metrics | Opt-in; `prom-client` never imported while it is off |
592
+ | Supply chain | `dependencies: {}`; third-party Actions pinned by commit SHA (org-internal reusables by tag); CodeQL and OpenSSF Scorecard |
593
+
594
+ > [!IMPORTANT]
595
+ > **`exposeInternals` is a debugging switch, not a verbosity setting.** With it on,
596
+ > the body of a 500 carries the original message and stack of whatever failed —
597
+ > including anything a driver, an SDK or a template put in them.
598
+
599
+ ---
600
+
601
+ ## 🧱 Tech Stack
602
+
603
+ - **Runtime:** Node.js 24+
604
+ - **Framework:** NestJS 11 (`ConfigurableModuleBuilder`, `APP_FILTER`, `APP_INTERCEPTOR`)
605
+ - **Peers:** `@nestjs/common ^11`, `@nestjs/core ^11`, `rxjs ^7`, `reflect-metadata ^0.2`
606
+ - **Optional peer:** `prom-client ^15` — required only when metrics are enabled
607
+ - **Build:** tsup — ESM + CJS per subpath, with `.d.ts` _and_ `.d.cts` declarations
608
+ - **Tests:** Jest (unit + e2e over a real Nest application) + Stryker (mutation)
609
+ - **TypeScript:** 5.x strict (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`), zero `any`
610
+
611
+ ---
612
+
613
+ ## 🧪 Testing & Quality
614
+
615
+ This library sits in the path of every request and every failure of every service that
616
+ installs it, so the suite is held to a bar beyond "the tests pass".
617
+
618
+ - ✅ **100% line coverage** — statements, branches, functions and lines, enforced as a gate
619
+ - ✅ **97.86% mutation score** — verified with [Stryker](https://stryker-mutator.io/) at
620
+ `break: 95`; every killable survivor was killed by a strengthened test, with no production
621
+ change ([report](./docs/mutation_testing_results.md))
622
+ - ✅ **End-to-end against a real application** — the filter, the interceptor and the health
623
+ routes are exercised through a booted Nest app, not against mocks of it
624
+ - ✅ **Published-artifact gates** — `check:exports` resolves the types the way each module
625
+ system does, `check:runtime` loads every subpath from the packed tarball in ESM and
626
+ CommonJS, and `check:published` compiles this README's snippets against `dist/`
627
+ - ✅ **Zero suppressions** — no coverage or mutation directives in the production source
628
+
629
+ ```bash
630
+ pnpm test # unit suite
631
+ pnpm test:cov # unit suite with the 100% coverage gate
632
+ pnpm test:e2e # end-to-end against a real Nest application
633
+ pnpm mutation # Stryker mutation testing (break: 95)
634
+ pnpm typecheck # tsc strict check
635
+ pnpm lint # ESLint
636
+ ```
637
+
638
+ ---
639
+
640
+ ## 📖 API Reference
641
+
642
+ Every export of every subpath, for quick lookup; each is documented in detail
643
+ in the sections above.
644
+
645
+ ### `.` (root)
646
+
647
+ | Export | Kind | Description |
648
+ | ---------------------------------------------------------------------------------------------------------------------------- | --------- | ---------------------------------------------------------------------- |
649
+ | `BymaxCoreModule` | class | The dynamic module: `forRoot` and `forRootAsync`. |
650
+ | `BymaxCoreModuleOptions`, `EnvelopeOptions`, `TimingOptions`, `HealthOptions`, `MetricsOptions`, `ResolvedCoreOptions` | types | The options surface and its resolved shape. |
651
+ | `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). |
652
+ | `ICorrelationIdProvider` | type | The correlation-provider contract. |
653
+ | `BymaxExceptionFilter` | class | The envelope exception filter. |
654
+ | `FilterErrorContext` | type | The neutral request context passed to the filter's observability seam. |
655
+ | `buildErrorEnvelope` | function | Pure builder assembling an `ErrorEnvelope`. |
656
+ | `ErrorEnvelope`, `ErrorDetails`, `BuildErrorEnvelopeInput` | types | The envelope contract and its builder input. |
657
+ | `TimingInterceptor` | class | The request-timing interceptor. |
658
+ | `ITimingSink`, `RequestTimingSample` | types | The timing-sink contract and its sample shape. |
659
+ | `BYMAX_BAD_GATEWAY` … `BYMAX_VALIDATION_FAILED` | constants | The full error-code catalog (see [Error envelope](#-error-envelope)). |
660
+ | `codeForStatus` | function | Derives a catalog code from an HTTP status. |
661
+
662
+ ### `./pagination`
663
+
664
+ | Export | Kind | Description |
665
+ | --------------------------------------------------------------------------- | -------- | ---------------------------------------------------- |
666
+ | `normalizePageQuery`, `buildPageResult` | function | Offset pagination: clamp input, shape a page. |
667
+ | `PageQuery`, `PageMeta`, `PageResult` | types | The offset query, its metadata, and the page shape. |
668
+ | `normalizeCursorQuery`, `encodeCursor`, `decodeCursor`, `buildCursorResult` | function | Cursor pagination: clamp input, codec, shape a page. |
669
+ | `CursorQuery`, `CursorResult` | types | The cursor query and the page shape. |
670
+
671
+ ### `./health`
672
+
673
+ | Export | Kind | Description |
674
+ | ----------------------- | ---- | --------------------------------------------------- |
675
+ | `IHealthIndicator` | type | The pluggable indicator contract. |
676
+ | `HealthIndicatorResult` | type | The outcome of a single indicator check. |
677
+ | `HealthCheckEntry` | type | One named entry in a `HealthResponse.checks` array. |
678
+ | `HealthResponse` | type | The stable liveness and readiness response shape. |
679
+
680
+ ## 🧩 Compatibility
681
+
682
+ - Node.js `>= 24`
683
+ - NestJS `^11`
684
+ - Express and Fastify, through framework-agnostic accessors for path, method,
685
+ and status. GraphQL and RPC execution contexts are out of scope for the
686
+ error envelope and the timing interceptor in this release; both pass errors
687
+ and requests through untouched.
688
+
689
+ ## 🤝 Contributing
690
+
691
+ Pull requests are welcome. Please open an issue first for significant changes.
692
+
693
+ - Read [`docs/technical_specification.md`](./docs/technical_specification.md) for architecture decisions.
694
+ - Run the full gate listed in [`CONTRIBUTING.md`](./CONTRIBUTING.md) before opening a PR.
695
+ - Conventional Commits are enforced by `commitlint.config.cjs`.
696
+
697
+ ---
698
+
699
+ ## 🔒 Security Policy
700
+
701
+ If you discover a security vulnerability, please **do not** open a public
702
+ issue. Instead, email us at **support@bymax.one** with details. We take
703
+ security seriously and will respond promptly. See
704
+ [`SECURITY.md`](./SECURITY.md) for the full policy.
705
+
706
+ ---
707
+
708
+ ## 📄 License
709
+
710
+ [MIT](./LICENSE) © [Bymax One](https://github.com/bymaxone)
711
+
712
+ ---
713
+
714
+ <p align="center">
715
+ <sub>Built with ❤️ by <a href="https://github.com/bymaxone">Bymax One</a></sub>
716
+ </p>