@bymax-one/nest-core
v1.6.0
Published
Zero-dependency NestJS 11 application foundation kit: error-envelope exception filter, request-timing interceptor, pagination helpers, health endpoints with indicator discovery, an optional Prometheus metrics endpoint with a contribution contract, OpenAPI
Downloads
1,920
Readme
✨ Overview
@bymax-one/nest-core is the layer every service in a fleet ends up writing for itself: one
error shape, one timing sample, one pagination contract, one health probe, one metrics
endpoint. Writing it per service is how five services end up answering the same failure five
different ways, and how a client integration breaks because one of them changed its error
body.
It ships "dependencies": {}. Everything it touches — NestJS, rxjs, reflect-metadata, and
the three optional peers behind the features that need them (prom-client, @nestjs/swagger,
@opentelemetry/api) — is a peer whose version you already control. A feature you leave off
never loads its peer, which the release gate asserts against the packed tarball.
Why nest-core?
- One error shape, fleet-wide. A versioned code catalog and a fixed envelope, so a client writes one error handler instead of one per service — and an unknown failure becomes a generic 500 rather than whatever the framework happened to serialize.
- Features register only when enabled. Turning metrics off does not leave a disabled
provider in the container; it leaves no provider, and
prom-clientis never imported. That is what lets it stay an optional peer. - Pagination without a provider.
./paginationis pure functions on their own subpath — no module to import, nothing to inject, usable from a script or a test. - Health that cannot hang. An indicator that rejects becomes a
downentry from its top-level message alone, truncated; a slow one is converted by the aggregator rather than holding the probe open.
🔥 Features
🚨 Errors
- ✅ Stable envelope — one JSON shape for every error an application returns:
statusCode,code,message,details,correlationId,timestamp,path - ✅ Versioned code catalog —
BYMAX_NOT_FOUND,BYMAX_CONFLICT,BYMAX_BAD_GATEWAYand the rest, exported as constants so a client maps acoderather than a message string - ✅ Internals stay internal — an unknown error becomes a generic 500; its message and
stack are captured for your logger, and reach the body only under
exposeInternals - ✅ Correlation id — resolved through
BYMAX_CORRELATION_PROVIDER, so the id comes from wherever your request context already keeps it
⏱️ Observability
- ✅ Request timing — one sample per closed request, rejections included, handed to the sink you register; the library stores nothing itself
- ✅ Slow-request flag — samples above
slowRequestThresholdMsare marked, so a sink can branch without re-deriving the threshold - ✅ Prometheus endpoint — opt-in scrape route over
BYMAX_METRICS_REGISTRY;prom-clientis imported only when it is enabled - ✅ Contributed metrics — a provider marked
@BymaxMetricsContributor()publishes its own collectors on that same registry, so an imported library's metrics land in your scrape - ✅ Trace correlation — reads the active OpenTelemetry span, so timing samples (and, when you opt in, error envelopes) carry the trace id; never creates a span or an SDK
- ✅ OpenAPI document, development only — one bootstrap call publishes an interactive UI carrying the schemas this package owns; in production it is never served, guarded twice
📄 Pagination & Health
- ✅ Offset and cursor —
normalizePageQuery/buildPageResultandnormalizeCursorQuery/buildCursorResult, pure functions with no NestJS involvement - ✅ Opaque cursors —
encodeCursor/decodeCursorround-trip a token a client carries back, treated as untrusted input on the way in - ✅ Liveness and readiness — separate endpoints, so a slow dependency fails readiness without restarting the pod
- ✅ Pluggable indicators — implement
IHealthIndicatoragainst a client you already own and register it under theBYMAX_HEALTH_INDICATORSmulti-token - ✅ Discovered indicators — opt in, and any provider marked
@BymaxHealthIndicator()joins readiness: a library you import brings its own check
🧩 Developer Experience
- ✅ Zero runtime dependencies —
@nestjs/*,rxjsandreflect-metadataarrive as peers, so you pin the versions - ✅ Five subpaths — the module, plus
./pagination,./health,./metricsand./openapithat a package can import without pulling the module in - ✅ Dual-format output — ESM + CJS with declarations for each format, verified against the packed tarball on every run
- ✅ Independent features — each is enabled on its own; the providers for the rest are never registered
- ✅ Typed end to end — TypeScript
strictwithexactOptionalPropertyTypesandnoUncheckedIndexedAccess; zeroany
📦 Subpath Exports
| Subpath | Contents |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| . | BymaxCoreModule, the error envelope and its code catalog, the request-timing middleware, the DI tokens, and every option type |
| ./pagination | normalizePageQuery, buildPageResult, normalizeCursorQuery, buildCursorResult, encodeCursor, decodeCursor and their types — pure functions, no NestJS provider involved |
| ./health | IHealthIndicator, HealthResponse, the indicator contracts and the @BymaxHealthIndicator() marker, so a package that only implements an indicator does not import the module |
| ./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 |
| ./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 |
Each subpath ships ESM and CommonJS with its own .d.ts and .d.cts, so
require() and import both resolve the declarations meant for them.
Install
pnpm add @bymax-one/nest-core @nestjs/common @nestjs/core reflect-metadata rxjsAdd prom-client if you enable the metrics feature, @nestjs/swagger if you
enable the OpenAPI feature, and @opentelemetry/api if you enable trace
correlation. All three are optional peer dependencies: none is required, or ever
loaded, unless you turn its feature on.
pnpm add prom-client
pnpm add @opentelemetry/api
pnpm add -D @nestjs/swagger@nestjs/swagger belongs in devDependencies: the document is never served in
production, so a production install has no reason to carry it.
🚀 Quick Start
import { Module } from '@nestjs/common'
import { BymaxCoreModule } from '@bymax-one/nest-core'
@Module({
imports: [BymaxCoreModule.forRoot()]
})
export class AppModule {}With no options, forRoot() enables the error envelope, request timing, and
health endpoints, and leaves metrics off. Every documented default is listed
in the configuration reference below.
🏭 Production Wiring with forRootAsync
The standard pattern in real applications: resolve options from your own configuration service, so behavior can vary by environment without a second code path.
import { Module } from '@nestjs/common'
import { BymaxCoreModule } from '@bymax-one/nest-core'
@Module({
imports: [
BymaxCoreModule.forRootAsync({
inject: [AppConfigService],
useFactory: (config: AppConfigService) => ({
envelope: { exposeInternals: config.env === 'development' },
timing: { slowRequestThresholdMs: 1_000 },
metrics: { enabled: config.env === 'production' }
})
})
]
})
export class AppModule {}isGlobal is a module extra, not part of the options object, defaulting to
true:
BymaxCoreModule.forRoot({ isGlobal: false })⚙️ Configuration
Every block is optional; an omitted block, or an omitted field within it, falls back to the documented default. Pass only what you want to change.
environment
The one top-level option rather than a block, because it describes the deployment rather than a feature.
| Option | Type | Default | Description |
| ------------- | -------- | ------- | ------------------------------------------------------------------------------------- |
| environment | string | unset | The environment this deployment runs in, read only where NODE_ENV declares nothing. |
Set it when your application validates its own environment variable and does not
also set NODE_ENV. NODE_ENV wins whenever it says anything, so this can never
serve the OpenAPI document in a runtime that named itself production. Full rules
and the classification table: Production is a closed door.
envelope
| Option | Type | Default | Description |
| ----------------- | --------- | ------- | ---------------------------------------------------------------------------------------------- |
| enabled | boolean | true | Registers the global exception filter. |
| exposeInternals | boolean | false | Includes the original message and stack of unknown errors. Development only, never production. |
timing
| Option | Type | Default | Description |
| ------------------------ | --------- | ------- | ------------------------------------------------------------------------------ |
| enabled | boolean | true | Applies the request-timing middleware to every route. |
| slowRequestThresholdMs | number | unset | Samples above this duration are flagged slow: true. Absent means never slow. |
health
| Option | Type | Default | Description |
| ----------------------- | --------- | ---------- | ----------------------------------------------------------------------------------------------------------------------- |
| enabled | boolean | true | Registers the health controller. |
| path | string | 'health' | Route prefix: GET /<path>/live, GET /<path>/ready. |
| indicatorTimeoutMs | number | 5000 | Per-indicator timeout before a check reports down. |
| exposeIndicatorErrors | boolean | false | Includes the failing indicator's message in the response under details.error. Never enable in production — see below. |
| autoDiscover | boolean | false | Also aggregates every provider marked @BymaxHealthIndicator(), anywhere in the application. |
On forRoot, enabled and path are applied at module-definition time: a
disabled feature registers no controller, and a custom path mounts the routes.
On forRootAsync, options resolve after the module is defined, so the health
controller is always registered at the default path and enforces enabled and
the default path with a request-time guard; a disabled or custom-path async
configuration fails fast at the route rather than at boot.
metrics
| Option | Type | Default | Description |
| ----------------------- | ------------------------ | ----------- | ------------------------------------------------------------------------------------------------ |
| enabled | boolean | false | Registers the metrics controller and the registry. |
| path | string | 'metrics' | Route serving the Prometheus scrape. |
| defaultLabels | Record<string, string> | {} | Static labels attached to every metric. |
| collectDefaultMetrics | boolean | true | Collects prom-client's process CPU, memory, and event-loop metrics. |
| authToken | string | (unset) | Bearer required to scrape. Unset leaves the endpoint open; empty/whitespace is rejected at boot. |
As with health, enabled and path register conditionally on forRoot. On
forRootAsync the metrics controller is always registered at the default path
and enforces enabled and the default path with a request-time guard, so a
disabled or custom-path async configuration fails fast at the route.
By default the scrape endpoint is open — the exposition publishes the route
inventory and, with collectDefaultMetrics, process internals to any caller. Set
authToken to require Authorization: Bearer <token> (the scheme is matched
case-insensitively; the token is compared in constant time), or protect the route at
your edge (network policy, ingress auth). A token configured empty or whitespace-only
is rejected at boot rather than silently ignored, so a mistyped secret fails loud
instead of leaving the endpoint open:
BymaxCoreModule.forRoot({
metrics: { enabled: true, authToken: process.env.METRICS_TOKEN }
})
// Scrape: curl -H "Authorization: Bearer $METRICS_TOKEN" http://host/metricstelemetry
| Option | Type | Default | Description |
| --------------- | --------- | ------- | ------------------------------------------------------------------------------- |
| enabled | boolean | false | Reads the active span and carries its ids into timing samples and the log seam. |
| exposeTraceId | boolean | false | Also publishes traceId in the error-envelope body served to the client. |
openapi
| Option | Type | Default | Description |
| -------------------- | ------------------------------------------ | ------------- | ----------------------------------------------------------------------------------- |
| enabled | boolean | false | Builds and serves the document. Ignored in production, where it is always off. |
| path | string | 'docs' | Route serving the interactive UI. |
| jsonPath | string | 'docs-json' | Route serving the raw JSON document. |
| title | string | 'API' | Document title. |
| description | string | '' | Document description. |
| version | string | '1.0.0' | Document version, independent of the package version. |
| servers | { url, description? }[] | [] | Servers advertised by the document. |
| securitySchemes | Record<string, object> | {} | Security schemes copied into the document's components. |
| security | SecurityRequirement[] | [] | The requirement every operation carries unless it says otherwise. |
| operationSecurity | OperationSecurityMap | {} | Per-operation overrides. An empty array marks that operation public. |
| operationIdFactory | (controller, method, version?) => string | peer default | Names the operations. Leave unset and nothing an existing client generated changes. |
| includeCoreSchemas | boolean | true | Contributes this package's own schemas and references them from the responses. |
Unlike health and metrics, this block behaves identically on forRoot and
forRootAsync: the document is mounted from the bootstrap helper, after the
options have resolved, so a custom path is honored on both registration paths.
Documenting authentication
Set the default on the document and mark the exceptions. security names
schemes declared in securitySchemes; operationSecurity overrides it for one
operation, and an empty array is how the specification says "public" — which
matters more than it looks, because an operation with absent security inherits
the document default, so a generated client would attach credentials to your
registration endpoint.
openapi: {
enabled: true,
securitySchemes: {
cookieAuth: { type: 'apiKey', in: 'cookie', name: 'access_token' },
refreshCookie: { type: 'apiKey', in: 'cookie', name: 'refresh_token' }
},
security: [{ cookieAuth: [] }],
operationSecurity: {
'POST /auth/login': [],
'POST /auth/register': [],
'POST /auth/refresh': [{ refreshCookie: [] }]
}
}An operation that already declares its own requirement — because you decorated the handler — is never overwritten, on either path.
The operation key is a contract
Keys are "<METHOD> <path>", and the format is documented rather than
incidental: a sibling library can ship a plain-data map of its own operations
keyed this way, so you spread it in instead of restating which of its routes are
public. Import OperationSecurityMap to have that map checked at the library's
own compile time — it is a type-only export, so nothing couples at runtime.
- The method is uppercase, separated by exactly one space.
- The path is written exactly as it appears in the generated document:
leading slash, OpenAPI template braces (
/users/{id}), no trailing slash, and including any global prefix.@nestjs/swaggerputsapp.setGlobalPrefix('api')into the documented paths, so the key becomes'POST /api/auth/login'in an application that sets one.
Because of that last point, a library shipping such a map should expose a function taking the prefix, not a frozen constant — the call site is the only place that knows it:
// in the library
export function authOperationSecurity(prefix = ''): OperationSecurityMap { /* … */ }
// in the application
operationSecurity: { ...authOperationSecurity('api'), ...myOwnOverrides }A requirement naming a scheme that is not declared fails the document build too, listing the names that missed and the ones the document defines. A requirement is a reference, and a reference to nothing yields a document whose security cannot be resolved: a client generator looks the name up, finds nothing, and either fails or emits an unauthenticated client.
A key matching no operation fails the document build, listing both the keys that missed and the operations that exist. Silence would be worse: a route renamed out from under a stale key would quietly inherit the document default and be documented as authenticated when it is not, or the reverse. Failing is safe here in a way it rarely is — the document is only ever built outside production, so this can only stop a developer.
One consequence for conditionally-registered routes: the map is static wiring while a route may not be. If an operation belongs to a feature you register per environment — your own conditional module, or a library feature toggled off somewhere — a key naming it fails the boot in whichever docs-enabled environment lacks that route. That is the intended loud behavior, so build the map the same way you build the modules: assemble it per feature and spread the fragments in, rather than writing one flat literal that outlives the routes it names.
That last sentence cuts both ways, and the consequence is worth stating rather
than discovering. These checks only run when the document is actually built.
With openapi.enabled false, or in a production runtime where the feature is
forced off, nothing validates: a stale key, a renamed route, or a requirement
naming a scheme you deleted all sit there quietly until someone turns the
document on. That is deliberate — refusing to boot a production service over a
documentation setting it never serves would be the wrong trade — but it means
the errors surface on a developer's machine or in CI, not at the moment the
configuration went wrong. If you gate the document behind an environment flag,
make sure at least one environment that runs your tests has it on, or these
checks never fire.
When an operation ends up requiring nothing
The checks above catch a requirement that points at nothing. The opposite
mistake — an operation that ends up pointing at no requirement at all — breaks
nothing, and that is what makes it dangerous. It is a valid document. No name
dangles. The runtime still answers 401. A document test that asserts only the
operations you enumerated stays green. The only thing that changed is that a
client generated from the document now sends no credentials.
The edit that produces it is ordinary: a library starts describing its own
routes, its adoption note tells you to delete the entries you had written for
them, and the document-level security default is sitting in the same options
block and goes with them. Every route you own then reads as public.
So the document build says so, once, naming the operations:
[BymaxCoreModule] a client generated from the OpenAPI document will send no
credentials to 2 operation(s): GET /examples, POST /orders. They state no
security requirement, the document declares no default, and other operations in
it do state one — so this is more often a missing openapi.security default than
a public API. Set openapi.security, or state the intent per operation with an
explicit [] in openapi.operationSecurity.It is a warning, not a failure: an API that is public on purpose is a legitimate configuration, and failing a boot over one would be worse than the silence it replaces. The trigger is deliberately narrow, so the line stays worth reading:
| Condition | Why |
| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| The document declares no top-level security | With a default present, a bare operation inherits it. An explicit security: [] counts as an answer, not an omission. |
| At least one other operation does state a requirement | Somebody described a posture on purpose, so the bare ones beside it are an omission rather than a public API. |
| The operation is not one of this package's own | A health probe carrying nothing is the correct description of a route an orchestrator polls without a credential. |
The escape hatch is to say what you mean, in the document's own vocabulary
rather than by silencing output: operationSecurity: { 'GET /examples': [] }
marks the operation public, and it stops being reported. A library can do the
same for its own routes by contributing security: [] in its fragment.
There is one shape this warning cannot report. If you remove every requirement at once — no library describing anything, no decorator, no override, no document default — the second condition above is never met: nothing states a requirement, so there are no bare operations sitting beside described ones. That follows from the trigger and therefore holds in every version; it is not a gap a later release closes, and a reader who expects one is exactly the reader who stops checking.
The limit belongs to anything reading only the rendered document. Such a document is indistinguishable from that of an API which is public on purpose — both are a set of operations asking for nothing — and this package cannot tell them apart without also warning at every genuinely public API, which is how a warning earns the right to be ignored.
Your own suite has no such handicap, because you know which one you are. Assert it, and the check runs on every commit rather than when somebody remembers to look:
it('still requires a credential everywhere it should', () => {
const document = buildYourDocument()
// Assert the default you expect, not merely that one exists. `[]` is a
// defined value that requires nothing, so a "toBeDefined" check passes for
// a default that degraded to empty — which is the regression this test is
// here to catch, wearing the shape of a pass.
expect(document.security).toEqual([{ cookieAuth: [] }])
// An explicit `[]` is how an operation says "public". The set of operations
// saying it should be the set you meant — no more, no fewer.
expect(publicOperationsOf(document)).toEqual(['POST /auth/login'])
})Render-and-diff keeps a narrower job, and it is a good one: when you are changing something — adopting a library that describes its own routes, moving a default — render with and without the change and compare the operations you mount. It shows you what moved without your having to predict it. Then turn what it showed you into an assertion, so the next change is caught rather than inspected.
A contributed scheme's presence is part of the contributor's configuration
A library contributes security schemes, and which ones it contributes can depend on how you configured it. Any of them, gated on any of its inputs, and often on more than one — the scheme you have in mind may be the absent one, and the setting you are thinking of may not be the only gate. The names are stable; their presence is not.
That has one consequence worth stating as a rule, because getting it wrong produces a failure at either end of the loudness scale:
Derive a document-level default from the same configuration the contributor reads, never from the scheme names, and never from what the document happened to contain before you adopted the library.
Writing the name as a literal is correct only for the configuration you wrote it against. Under a configuration that declares that scheme plus another, the default still resolves but describes one of two credentials the route accepts — quietly incomplete. Under one that declares it not at all, the name resolves to nothing and the document build fails with the undeclared-scheme error above. Guarding on whether the scheme exists is the tempting fix and it is the wrong one: it clears the case that already announced itself and ships the one that does not.
Note what a document-level default does not let you say. Its entries are alternatives — any one of them satisfies an operation — and they apply to every operation that states nothing. So a backend whose routes sit behind two different credential families can certainly list both, and nothing rejects it: the result is a document asserting that either credential works for every inheriting route. That is not an incomplete document, it is a false one, and it is false in the permissive direction — it tells a client that a credential the route will reject is one the route accepts.
Give the majority family the default and the minority explicit
operationSecurity entries. Those outrank the default, and an operation
carrying one is never named by the warning above, because it states a
requirement.
🔑 DI Tokens
Every token is a Symbol. BYMAX_CORRELATION_PROVIDER,
BYMAX_HEALTH_INDICATORS and BYMAX_HEALTH_TRANSITION_SINK are consumed with
@Optional() and are not bound by the module: provide either from your own module to supply your own
implementation, otherwise the internal fallback in the last column applies.
BYMAX_TIMING_SINK and BYMAX_METRICS_REGISTRY behave differently on
forRootAsync, where options resolve after the module is defined: there the
module always binds and exports both (the timing sink as the metrics bridge or a
no-op, the registry as a guarded placeholder when metrics are off), so a
consumer BYMAX_TIMING_SINK override is honored on forRoot but shadowed on
forRootAsync. Follow the pattern in
Integration with @bymax-one/nest-logger
below.
| Token | Provides | When you do not provide one |
| ------------------------------ | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| BYMAX_CORE_OPTIONS | The resolved BymaxCoreModuleOptions | always set by the module |
| BYMAX_CORRELATION_PROVIDER | ICorrelationIdProvider | internal no-op (omits correlationId) |
| BYMAX_TIMING_SINK | ITimingSink | internal no-op, or the metrics bridge when timing and metrics are both enabled |
| BYMAX_HEALTH_INDICATORS | IHealthIndicator[] | treated as an empty indicator set |
| BYMAX_HEALTH_TRANSITION_SINK | IHealthTransitionSink | no sink; readiness transitions go to Nest's logger instead (binding one stands that line down) |
| BYMAX_METRICS_REGISTRY | the prom-client Registry | bound when metrics are enabled; on forRootAsync always registered, guarded-placeholder when off |
| BYMAX_TRACE_CONTEXT | ITraceContextProvider | bound on every path: the OpenTelemetry reader when telemetry is enabled, a no-op that resolves no trace otherwise |
🚨 Error Envelope
Every error that leaves an application registered with the envelope feature follows this exact, versioned shape:
{
"statusCode": 404,
"code": "BYMAX_NOT_FOUND",
"message": "Invoice inv_123 was not found",
"details": [{ "field": "id", "issue": "unknown identifier" }],
"correlationId": "8f14e45f-ceea-4677-a9de-6ec3f1f0a1b2",
"timestamp": "2026-07-16T12:00:00.000Z",
"path": "/invoices/inv_123"
}| Field | Type | Presence | Notes |
| --------------- | ----------------- | -------- | -------------------------------------------------- |
| statusCode | number | always | HTTP status. |
| code | string | always | Stable, machine-readable code. |
| message | string | always | Human-readable, safe for end users. |
| details | array or object | optional | Structured context, for example validation issues. |
| correlationId | string | optional | Present when a correlation provider is bound. |
| timestamp | string (ISO 8601) | always | Time the error was formatted. |
| path | string | always | Request URL path. |
Codes are stable strings under a reserved BYMAX_ prefix, derived from the
HTTP status: BYMAX_NOT_FOUND for 404, BYMAX_VALIDATION_FAILED for the
shape a validation pipe produces, BYMAX_INTERNAL_ERROR for anything
unmapped, and so on. Throw an HttpException whose response object carries
your own code and the filter passes it through verbatim:
import { BadRequestException } from '@nestjs/common'
throw new BadRequestException({ code: 'INVOICE_OVERDUE', message: 'Invoice is overdue' })What the filter classifies from, and what it cannot
An error raised before any handler ran — a malformed JSON body, a payload over
the size limit — still becomes a clean 4xx envelope rather than a 500. The
filter recognizes those by shape, not by class: it honours an error that
marks itself expose: true with a 4xx status, which is the http-errors
convention Node's body pipeline follows. Nothing in the application failed, so
it is not routed through the unexpected-error seam either.
An error that carries no such marking is a 500, including when a client
caused it. The clearest case is depth: a body of a few kilobytes nested
thousands of levels deep overflows the stack during validation and surfaces as
RangeError: Maximum call stack size exceeded — well under any size limit, and
answered 500 BYMAX_INTERNAL_ERROR.
That is deliberate, and the alternative is worse. Mapping RangeError to a
4xx would make the filter infer causation from an error class, and it would be
wrong exactly where it matters: a genuine stack overflow in your own code is a
500 that should page someone, and relabelling it as a client error would hide
the failure the 500 exists to surface. By the time the filter sees the error,
the body that caused it is gone.
So body-shape limits are the application's floor, not the filter's. Cap
nesting depth and the request is rejected as the 400 it is, instead of
becoming a 5xx that pollutes your error rate and writes a stack per request.
Position it after the body parser and before validation — that window is the only place the body exists in a form you can measure and nothing has walked it yet. Earlier there is nothing to inspect; later the overflow has already happened, which is the failure you are trying to prevent.
Where that window is depends on the adapter, and the obvious answer is wrong on Express. Measured against a real Nest application:
| Registration point | req.body when it runs |
| ---------------------------------------- | ----------------------- |
| app.use(...) in bootstrap.ts | undefined |
| Module middleware, configure(consumer) | the parsed body |
Nest registers its own parser during app.init(), so a middleware added with
app.use() before that is mounted ahead of it — a depth guard there inspects
nothing and silently protects nothing. Register it as module middleware
instead. On Fastify the ordering differs again, since Nest middleware runs
through @fastify/middie ahead of body parsing; a preValidation hook is the
place to look, and it is worth measuring rather than assuming.
Registering it in the right place is not enough — the route pattern silently
skips paths too. This package hit the same trap with its own timing
middleware, and the measured behaviour is in core.module.ts:
| forRoutes(...) | Express | Fastify |
| ---------------- | -------------- | ---------------- |
| '*splat' | skips the root | — |
| '{*splat}' | skips /api | every path |
| '/' | every path | matches / only |
So the named-wildcard form every migration guide reaches for leaves POST /
unguarded on Express. A consumer measured exactly that: with '*path', a
2000-level body to the root returned 404 because the middleware never ran;
with '{*path}', 400. If your application mounts nothing at the root, both
forms answer 4xx and the status alone cannot tell you which one you have.
Verify by behaviour, not by wiring — this is the part worth insisting on. Checking where the middleware is registered is what the consumer above did; it looked correct, they confirmed it to us, and the guard was inert. Send a body nested past your ceiling and require your own rejection:
it('refuses a body nested past the ceiling', async () => {
const deep = JSON.parse(`${'['.repeat(2000)}${']'.repeat(2000)}`)
const res = await request(app.getHttpServer()).post('/anything').send({ name: deep })
// Match your guard's own message, not the status: a validation pipe rejects
// this shape with a 400 as well, so a status assertion passes with the floor
// removed and proves nothing.
expect(res.body.message).toBe('Request body is nested too deeply.')
})Use a depth that actually overflows, and assert the guard's own message. A test at a depth your DTO validation already rejects passes identically with the guard deleted — which is a check that cannot produce a negative result, and the reason this defect survived a green suite.
A depth ceiling well above anything a legitimate payload nests and well below
what exhausts the stack leaves a wide margin: one consumer runs 32, against
the ~2000 levels that overflow. Walk the parsed body iteratively — a recursive
depth check on a hostile payload overflows the stack it was written to protect.
⏱️ Request Timing
One RequestTimingSample is delivered to whatever implements ITimingSink for
every request the server closes — not only the ones a handler answered:
export interface RequestTimingSample {
method: string
route: string
statusCode: number
durationMs: number
slow: boolean
}Rejected requests are counted too
The recorder is middleware (BymaxTimingMiddleware), applied to every route by
BymaxCoreModule when timing.enabled is true. That placement is the whole
point. Nest runs middleware → guards → interceptors → pipes → handler, so a
request rejected by a guard never reaches an interceptor, and a request matching
no route never reaches a controller. A recorder sitting in either place is blind
to exactly the traffic that matters during an incident:
| What happens | Status | Visible to an interceptor | Visible here |
| ------------------------------ | ------ | ------------------------- | ------------ |
| Handler answers | 2xx | ✅ | ✅ |
| Authentication guard rejects | 401 | ❌ | ✅ |
| Authorization guard rejects | 403 | ❌ | ✅ |
| Rate limiter sheds the request | 429 | ❌ | ✅ |
| No route matches | 404 | ❌ | ✅ |
| Client hangs up mid-request | — | ❌ | ✅ |
A credential-stuffing run is a flood of 401s, route enumeration is a flood of
404s, and a throttler doing its job is a flood of 429s. All three used to
leave the error graph flat.
Requests that matched no route are recorded under the fixed label
UNMATCHED_ROUTE (<unmatched>), never the path that was requested. The raw
path is attacker-controlled, and a metrics label that follows it lets anyone
mint one time series per probe until the process runs out of memory.
The sample is emitted when the connection closes, so a client that hangs up
before the response finishes is still counted — that is what a scanner does, and
durationMs covers guards and middleware as well as the handler.
An aborted request keeps whatever status the response held, which is 200 in
Node unless something settled another one. No sentinel status is introduced:
that would change the value of status_code="200" series that already exist in
your dashboards, without any change in traffic.
Express and Fastify behave identically here, and that is asserted end to
end on both. It needs saying because the two platforms disagree underneath: Nest
runs middleware on Fastify through @fastify/middie, which hands it the raw
IncomingMessage carrying no route metadata, and forRoutes('/') is a mount on
Express but an exact match on Fastify. The module resolves both for you.
One gap remains, and it is Nest's scoping rule, not a setting. Module middleware is scoped to the global prefix, so with
setGlobalPrefix('api')a request to/nope— outside the prefix entirely — reaches no middleware and is not recorded. Requests to/api/nopeare recorded normally, under<unmatched>, so a scan that probes below your prefix is still visible; only one that probes above it is not. Covering that too is not supported yet: the module has no way to register the recorder outside its own scope, andBymaxTimingMiddlewareis only provided whentimingis enabled — at which point the module already applies it, so resolving and re-registering it would double-count. If you need it, open an issue rather than wiring it by hand.
Bind your own sink by providing BYMAX_TIMING_SINK from your own module, the
same override pattern shown below for the correlation provider. This applies on
the forRoot path; on forRootAsync the module owns BYMAX_TIMING_SINK (the
metrics bridge or a no-op) so a consumer binding is shadowed there:
import { Global, Module } from '@nestjs/common'
import { BYMAX_TIMING_SINK, type ITimingSink } from '@bymax-one/nest-core'
class LoggerTimingSink implements ITimingSink {
record(sample: import('@bymax-one/nest-core').RequestTimingSample): void {
// forward to your own logger or telemetry pipeline
}
}
@Global()
@Module({
providers: [{ provide: BYMAX_TIMING_SINK, useClass: LoggerTimingSink }],
exports: [BYMAX_TIMING_SINK]
})
export class ObservabilityModule {}A sink that fails cannot reach the request: both a synchronous throw and a
rejection from an async record() are absorbed. Write it async if the backend
behind it is async — the void return type accepts it, and the rejection is
caught. The failure is swallowed rather than logged, unlike a health transition
sink, because this runs on every request and a systematically failing sink would
otherwise become a second flood beside the first.
📄 Pagination
Framework-neutral, pure functions on the ./pagination subpath: no NestJS
provider, no ORM awareness. Your repository translates the normalized query
into its own persistence call.
Offset pagination
import { Controller, Get, Query } from '@nestjs/common'
import {
buildPageResult,
normalizePageQuery,
type PageResult
} from '@bymax-one/nest-core/pagination'
@Controller('invoices')
export class InvoiceController {
constructor(private readonly invoices: InvoiceRepository) {}
@Get()
async list(@Query() raw: Record<string, unknown>): Promise<PageResult<Invoice>> {
const query = normalizePageQuery(raw, { maxLimit: 50, maxOffset: 100_000 })
const { rows, total } = await this.invoices.findPage(query)
return buildPageResult(rows, total, query)
}
}Bound the offset, not just the page size
maxLimit caps how many rows a request reads. maxOffset caps how far in it
starts — and on an offset-paginated database that is the half that costs:
GET /invoices?page=1000000000&limit=20 → OFFSET 19999999980Twenty bytes of query, and Postgres walks the table to reach a page that does
not exist. The page index has a floor of 1 and an arithmetic guard that keeps
(page - 1) * limit an exact integer, but nothing bounds the product itself
unless you say so.
maxOffset is absent by default and deliberately so: legitimate deep paging
exists, and a silent ceiling would change the rows a working query returns. Set
it wherever the page index reaches SQL and your dataset has a knowable ceiling.
0 is a valid bound and means "the first page only".
Clamping matches how maxLimit already behaves — the resolved values come back
in meta, so a caller that cares can compare what it asked for against what it
got.
Cursor pagination
import { Controller, Get, Query } from '@nestjs/common'
import {
buildCursorResult,
decodeCursor,
normalizeCursorQuery,
type CursorResult
} from '@bymax-one/nest-core/pagination'
@Controller('invoices')
export class InvoiceCursorController {
constructor(private readonly invoices: InvoiceRepository) {}
@Get('cursor')
async list(@Query() raw: Record<string, unknown>): Promise<CursorResult<Invoice>> {
const query = normalizeCursorQuery(raw, { maxLimit: 50 })
const after = query.cursor ? decodeCursor<{ id: string }>(query.cursor) : undefined
// fetch limit + 1 rows ordered after `after`, the fetch-one-extra convention
const rows = await this.invoices.findAfter(after, query.limit + 1)
return buildCursorResult(rows, query.limit, (last) => ({ id: last.id }))
}
}A malformed or tampered cursor rejects with BYMAX_VALIDATION_FAILED. Cursors
are opaque base64url strings but are neither encrypted nor signed: encode
ordering keys only, never sensitive data.
❤️ Health
Liveness always replies 200 with an empty checks array; readiness runs
every registered indicator concurrently and replies 200 only when every
indicator reports up, 503 otherwise, naming every check either way.
A failing indicator is named but not quoted: the response says which check is
down, and the reason goes to the logger. See
the security model for why, and
health.exposeIndicatorErrors if you want the message in the response while
debugging locally.
{ "status": "ok", "checks": [{ "name": "redis", "status": "up" }] }Implement IHealthIndicator against a client you already own:
import { Injectable } from '@nestjs/common'
import type { HealthIndicatorResult, IHealthIndicator } from '@bymax-one/nest-core/health'
@Injectable()
export class RedisHealthIndicator implements IHealthIndicator {
readonly name = 'redis'
constructor(private readonly redis: RedisClient) {}
async check(): Promise<HealthIndicatorResult> {
await this.redis.ping()
return { status: 'up' }
}
}Register it under the shared BYMAX_HEALTH_INDICATORS token from your own
module, the same override pattern used throughout this README:
import { Global, Module } from '@nestjs/common'
import { BYMAX_HEALTH_INDICATORS } from '@bymax-one/nest-core'
@Global()
@Module({
providers: [
RedisHealthIndicator,
{
provide: BYMAX_HEALTH_INDICATORS,
useFactory: (r: RedisHealthIndicator) => [r],
inject: [RedisHealthIndicator]
}
],
exports: [BYMAX_HEALTH_INDICATORS]
})
export class HealthIndicatorsModule {}A rejecting, throwing, or slow indicator (past indicatorTimeoutMs) is
converted to a down entry with a safe, bounded diagnostic detail; it never
hides the results of the other registered indicators.
A failing check leaves a record
A readiness failure that nothing records is a 503 an orchestrator acts on with
an empty log behind it, and diagnosing it means reproducing it. That is easy to
arrive at without deciding to: probe paths are the highest-volume request a
backend serves, so they are usually excluded from the HTTP log surface, and a
well-written indicator returns { status: 'down' } rather than throwing —
because readiness is typically unauthenticated and a driver's error carries
hosts, ports and sometimes credentials.
So the aggregator records it. It holds the last state of every check and reports each change — never once per probe — to its own logger, and to a sink you bind:
import { Injectable } from '@nestjs/common'
import type { HealthTransition, IHealthTransitionSink } from '@bymax-one/nest-core/health'
@Injectable()
export class HealthTransitionLogger implements IHealthTransitionSink {
constructor(private readonly logger: MyStructuredLogger) {}
record(transition: HealthTransition): void {
if (transition.isUp) {
this.logger.info('HEALTH_CHECK_RECOVERED', { check: transition.name })
return
}
this.logger.warn('HEALTH_CHECK_DEGRADED', {
check: transition.name,
cause: transition.cause.kind
})
}
}Bind it under BYMAX_HEALTH_TRANSITION_SINK from your own @Global() module,
the same override pattern as the indicator token above.
Binding nothing is supported, and is not the silent case: the transitions reach Nest's logger instead, so a readiness failure always leaves a record. Binding a sink stands that line down — both destinations are usually the same logger, so keeping it would put two records of one transition side by side, which is the noise this feature exists to remove. Your sink receives the cause as structured data, strictly more than the line renders, so what reaches the log after that is your decision rather than this package's.
transition.cause distinguishes the three ways a check can be down, which the
up/down response body cannot:
| cause.kind | Meaning | Carries |
| --------------- | -------------------------------------------------- | ----------- |
| reported-down | The indicator answered, and reported it down. | — |
| rejected | The indicator rejected or threw. | message |
| timed-out | The aggregator gave up after indicatorTimeoutMs. | timeoutMs |
timed-out is the one that cannot be observed anywhere else. An indicator the
aggregator abandoned is never told, so it reports nothing — and a hung
dependency is the common shape, not a refused one: a database under load, a
network partition and a paused container all hang, while a refusal comes back
immediately and would have been reported. That is why the rule lives in the
aggregator rather than in each backend: the obstacle is information, not effort.
Two details worth knowing before you rely on it. A first observation that is failing is reported, while a first observation that is healthy is not — a process that boots against a dependency already down would otherwise look healthy in the log forever, whereas announcing every healthy check would write a line per dependency on every boot. And the cause is the one seen at the transition: a dependency that stays down while its failure mode changes underneath keeps the first cause, which is the cost of one line per outage instead of one per probe.
The sink is called synchronously on the readiness path, so keep it cheap. A
throw is caught and logged rather than failing the probe — readiness answering
500 because its own logging broke would take a healthy deployment out of
rotation — but do not use that as flow control.
Discovered indicators
Registering every indicator by hand stops scaling once the libraries an application imports have their own health to report. Mark a provider instead, and turn discovery on:
// in a library, or anywhere in your application
import { Injectable } from '@nestjs/common'
import { BymaxHealthIndicator } from '@bymax-one/nest-core/health'
import type { HealthIndicatorResult, IHealthIndicator } from '@bymax-one/nest-core/health'
@BymaxHealthIndicator()
@Injectable()
export class RedisHealthIndicator implements IHealthIndicator {
readonly name = 'redis'
async check(): Promise<HealthIndicatorResult> {
await this.redis.ping()
return { status: 'up' }
}
}BymaxCoreModule.forRoot({ health: { autoDiscover: true } })Readiness now includes redis with nothing registered anywhere. The rules:
| Rule | Behavior |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| Marked, not shaped | Only providers carrying the marker are collected. A provider that merely has name and check is ignored. |
| Explicit wins | An indicator registered under BYMAX_HEALTH_INDICATORS keeps its name and its position; a discovered one with the same name is dropped. |
| Stable order | Discovered indicators are sorted by name, so the checks array does not reshuffle between restarts. |
| 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. |
| Scanned once | The provider graph is walked at bootstrap, not per probe. |
It is off by default because it changes which failures can take an application out of rotation: with it on, a library you merely import gains the ability to fail your readiness probe. That is the point — the dependency understands its own health better than you do — but it is your decision, not something you inherit.
@BymaxHealthIndicator() lives in ./health, alongside the contract, so a
library that only ships an indicator never imports the module.
📈 Metrics
Disabled by default. Enabling it registers GET /metrics, serving Prometheus
text format from a dedicated prom-client registry:
BymaxCoreModule.forRoot({ metrics: { enabled: true } })prom-client is an optional peer, loaded lazily only when metrics.enabled
is true. If you enable metrics without installing it, the module fails fast
at boot with a descriptive error naming the missing package and the install
command, rather than a cryptic resolution failure at the first scrape.
When timing and metrics are both enabled, an internal bridge feeds two default HTTP metrics with a bounded label set:
| Metric | Type | Labels |
| ------------------------------- | --------- | -------------------------------- |
| http_requests_total | counter | method, route, status_code |
| http_request_duration_seconds | histogram | method, route, status_code |
Inject BYMAX_METRICS_REGISTRY to register your own application metrics
against the same registry the endpoint scrapes.
Contributed metrics
Injecting the token works for your own code, but it makes a library depend on this package's DI tokens — and therefore on the module. A library declares its metrics instead:
// in a library
import { Injectable } from '@nestjs/common'
import { BymaxMetricsContributor } from '@bymax-one/nest-core/metrics'
import type { IMetricsContributor, MetricsRegistry } from '@bymax-one/nest-core/metrics'
import { Gauge } from 'prom-client'
@BymaxMetricsContributor()
@Injectable()
export class QueueMetrics implements IMetricsContributor {
registerMetrics(registry: MetricsRegistry): void {
new Gauge({ name: 'bymax_queue_depth', help: 'Jobs waiting', registers: [registry] })
}
}Enable metrics and the contributor runs — there is no second flag:
BymaxCoreModule.forRoot({ metrics: { enabled: true } })| Rule | Behavior |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Marked, not shaped | Only providers carrying the marker are called. A provider that merely has registerMetrics is never touched. |
| Called once | At bootstrap, with the registry the scrape endpoint serves. Never per request, never per scrape. |
| Stable order | Contributors run sorted by class name, so a collision fails the same way on every boot. |
| 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. |
| Off with metrics | With the metrics feature disabled, no contributor runs and prom-client is never loaded. |
Naming and labels. Contributors share one registry and one namespace, so the conventions are part of the contract:
- Prefix every metric with
bymax_<library>_(bymax_queue_depth,bymax_cache_hits_total). An application's own metrics need no prefix — they have no one to collide with but themselves. - Follow Prometheus naming:
_totalfor counters,_secondsfor durations, base units, no units in the middle of a name. - Keep labels bounded. Route templates, never raw paths; status codes, never
messages. Never a tenant, user, or request id — one unbounded label is
enough to make a scrape endpoint the most expensive route in a service.
tenantIddeserves naming twice: every library in this family is tenant-aware, so it is the first label anyone reaches for and it is unbounded by construction. - Publish the list. A library that contributes metrics documents them in its own README — name, type, labels. This package deliberately keeps no central catalogue: a list of everyone else's metrics rots the moment a library ships a new one. What it does require is that the list exists somewhere an operator can find it.
Why these rules live here. A Prometheus registry is a flat namespace, and
prom-client rejects a duplicate metric name. If two libraries independently
pick bymax_operations_total, the collision surfaces at the consumer's boot
— in an application neither library's CI ever assembles, as a hard failure, in
front of whoever wired the app. Neither library can test for it. A namespace
rule is the only thing that prevents it, and it can only be arbitrated by the
dependency they share, which is this package.
The rules are documentation, not enforcement. This package could inspect the
registry around each contributor and reject an unprefixed name, but that would
need a per-contributor prefix on the contract, and it would wrongly reject the
case that matters most — an application's own contributor, which has no
business being pushed into a bymax_ namespace.
📘 OpenAPI
Disabled by default, and never served in production. Enabling it and calling
one helper during bootstrap publishes an interactive UI at GET /docs and the
raw document at GET /docs-json:
// app.module.ts — configuration lives with every other feature
BymaxCoreModule.forRoot({ openapi: { enabled: true, title: 'Invoices API' } })// main.ts — the one call that needs the application instance
import { NestFactory } from '@nestjs/core'
import { applyBymaxOpenApi } from '@bymax-one/nest-core/openapi'
import { AppModule } from './app.module'
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule)
await applyBymaxOpenApi(app)
await app.listen(3000)
}
void bootstrap()[!IMPORTANT] Call
applyBymaxOpenApibeforeapp.listen(). Mounting the document re-registers routes on the HTTP adapter, and doing that against an already-initialized Express 5 application replaces the router: the document appears and every other route in the application — yours and this package's health endpoints alike — starts returning 404.
The call is safe to make unconditionally. It returns what it did, so a template can emit it once and never branch:
| Result | Meaning |
| ------------------------------------------ | ------------------------------------------------------- |
| { mounted: true, path } | The UI and the document are served at path. |
| { mounted: false, reason: 'disabled' } | openapi.enabled is off. |
| { mounted: false, reason: 'production' } | The runtime is production. Nothing was built or served. |
Production is a closed door
NODE_ENV decides, and the decision is fail-closed: only development and
test are non-production. Any other value is production, and in production the
document is never built and never mounted, whatever the configuration says. The
guard runs twice, independently: the option resolver forces the feature off, and
the bootstrap helper classifies the runtime again without trusting that
resolution.
NODE_ENV cannot be overridden. With it set to anything, no option serves
the document in a runtime it named production.
Enabling it in production is not an error, it is a no-op with a warning naming the option that was ignored, so a single configuration can be shared across environments.
When your application validates its own environment variable
Plenty of applications parse an APP_ENV through a config schema and never set
NODE_ENV at all. Those deployments used to be classified
