@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/CHANGELOG.md +77 -0
- package/LICENSE +21 -0
- package/README.md +716 -0
- package/dist/health/index.cjs +2 -0
- package/dist/health/index.d.cts +64 -0
- package/dist/health/index.d.ts +64 -0
- package/dist/health/index.mjs +1 -0
- package/dist/index.cjs +984 -0
- package/dist/index.d.cts +550 -0
- package/dist/index.d.ts +550 -0
- package/dist/index.mjs +960 -0
- package/dist/pagination/index.cjs +112 -0
- package/dist/pagination/index.d.cts +146 -0
- package/dist/pagination/index.d.ts +146 -0
- package/dist/pagination/index.mjs +105 -0
- package/package.json +169 -0
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 & 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>
|