Plugins & middleware
Nifra's plugin surface lives in the agnostic core, so it's the same on every runtime and framework. A plugin is a function over the app; middleware is a bundle of lifecycle hooks. Both apply with app.use().
The plugin convention
A plugin is (app) => app - it calls use/derive/decorate or registers routes, and returns the app. Because derive and decorate are type-threaded, any context a plugin adds is typed on every handler defined after app.use(plugin) - no extra generics. Wrap a plugin with definePlugin(name, …) to make it idempotent: applied twice (e.g. because two plugins both depend on it), it wires its hooks once.
// doc-check: skip - fragment: the outer `app`, `verify`, and `db` are your application's.
import { definePlugin } from "@nifrajs/core/server"
// A plugin is just (app) => app - call use/derive/decorate or register routes.
// definePlugin adds a name so applying it twice (even transitively) is a no-op.
export const auth = definePlugin("auth", (app) =>
app.derive((c) => ({ user: verify(c.req) })), // adds c.user…
)
app
.use(auth) // …threaded to every handler after this:
.get("/me", (c) => ({ id: c.user.id })) // c.user is fully typed
// Inline plugins thread context too - no definePlugin needed for one-offs:
app.use((a) => a.decorate("db", db).derive((c) => ({ now: Date.now() })))Route/hook plugins: keep types with defineRouterPlugin
A plugin that registers routes or hooks but adds no context type - e.g. mounting an auth router - should be built with defineRouterPlugin (the clearer-named alias of defineIdentityPlugin), not definePlugin. It threads the app's exact type through use, so the route registry (and the typed client derived from it) survives the plugin. Mount routes as a side effect then return app (registering with .get/.post directly would change the type away from the identity, so those routes run but aren't in the typed registry - the trade that keeps everything else typed).
Footgun: reach for definePlugin here instead and a plain definePlugin((app) => app.get(...)) infers app as Server<any, any>, collapsing use()'s result - and your whole typed client - to any, with no type error and no runtime error. @nifrajs/better-auth is built with the identity form, so server().use(betterAuth(auth)).get(...) keeps every route typed.
import { defineRouterPlugin, server } from "@nifrajs/core/server"
// A plugin that mounts routes/hooks but adds NO context type: defineRouterPlugin (the clearer name for
// defineIdentityPlugin) keeps app.use()'s return type EXACTLY the caller's server, so routes added after
// .use() stay typed - and the typed client derived from them stays intact.
export const scim = defineRouterPlugin("scim", (app) => {
app.get("/scim/v2/Users", () => ({ Resources: [] })) // mount as a SIDE EFFECT (a runtime-only route)
return app // return the app unchanged → caller's registry preserved
})
// /a AND /b stay fully typed across the .use():
const api = server().get("/a", () => ({ a: 1 })).use(scim).get("/b", () => ({ b: 2 }))Lifecycle hooks
Plugins can attach five lifecycle hooks: onRequest (pre-routing, can short-circuit), beforeHandle/afterHandle (around the handler),onError, and onResponse (transform every response - success, error, 404). Hardening middleware uses the same hook model:
import { server } from "@nifrajs/core/server"
import { cors, rateLimit, securityHeaders, MemoryStore } from "@nifrajs/middleware"
// Hardening middleware is a hook bundle (context-agnostic) - same app.use():
const app = server()
.use(securityHeaders())
.use(cors({ origin: ["https://app.example"] }))
// MemoryStore is dev/single-instance only - use a shared store (Redis, etc.) in production.
.use(rateLimit({ store: new MemoryStore(), max: 100, windowMs: 60_000 }))Header middleware: one portable hook, fast everywhere
Most response middleware only reads or writes headers - security headers, CORS reflection, cache directives, negotiation. For that shape, prefer onResponseHeaders over onResponse: it receives a mutable case-insensitive header view, a minimal request view ({ method, url, header(name) }), and the status. One implementation runs on every runtime - on Bun/Deno inside the normal response walk against the response's own Headers (in place, no clone), and on Node against the outcome's plain header record on the direct socket writer, without ever materializing Web Request/Response objects:
import type { Middleware } from "@nifrajs/core/server"
// ONE implementation, fast on every runtime: on Bun/Deno it mutates the
// response's own Headers in place; on Node it writes the outcome record on the
// direct socket writer - no Web Request/Response is ever built for it.
export function serverName(value: string): Middleware {
return {
onResponseHeaders(headers, req, status) {
if (status < 500) headers.set("server", value)
},
}
}Why this matters on Node: a full onResponse(res: Response) hook needs real Web objects, and building them costs several microseconds per request - registering even one drops a realistic Node app from ~95% to ~70% of a raw node:http server's throughput (it was worse before the lazy bridge below). onResponseHeaders never pays that: the built-ins that use it (cors, static cacheControl, language) keep the direct writer at full speed.
Fixed headers: declare them, don't hook them
Some response headers have no per-request decision behind them at all - a frame policy, a referrer policy, an HSTS directive. Declaring those with app.responseHeaders(record) registers no hook: the values are folded into response construction (one prebuilt init for JSON renders, one record merge when the route set its own headers), so the app keeps the lanes a hook would close - Bun's fused native routes, and the Node direct writer for a full onResponse. They still apply to every response a hook would cover: success, error, 404/405, timeout, short-circuit.
// doc-check: skip - fragment: `app` is your application's server instance.
// No hook is registered, so a bare route still takes Bun's fused native lane.
app.responseHeaders({
"x-frame-options": "DENY",
"referrer-policy": "no-referrer",
"strict-transport-security": "max-age=31536000; includeSubDomains",
})Declared headers are defaults: a value the request itself produced - c.set.headers, or a response hook - wins, whatever casing it used. Names are lowercased once at wire-up, and a name the render owns (content-type, content-length, transfer-encoding, set-cookie), an invalid name, or a non-string value throws immediately. Declare them before your response hooks: a declaration made after one cannot precede it, so it degrades to an ordinary onResponseHeaders hook to keep registration order honest.
securityHeaders() and the default poweredBy() ship this way - measured +11% on a bare Bun GET against the same headers written by a hook, byte-identical on the wire (pinned by parity suites on Bun, Node, and Deno). A route whose headers depend on the request keeps onResponseHeaders: that is why cors (origin reflection) and conditional cacheControl are still hooks.
- Body middleware gets its own portable tier:
onResponseBody. It receives the FINAL framework-serialized bytes (plus the same header view and status) and may return replacement bytes - on every runtime the bytes are already resident before any WebResponseexists, so nothing is drained. A body-hashing middleware benchmarks at ~92% of a rawnode:httpserver this way - Fastify-class - vs ~50% through the fullonResponsecontract. Handler-returned rawResponses (proxied fetch, SSE, streamed SSR) are skipped by contract. - The full
onResponse(res: Response)stays for what only it can do: wrapping streams, capturing complete responses (caching, idempotency replay), and intercepting rawResponses uniformly. On Node such a response is bridged through a lazy spec-shaped Response that materializes headers and body machinery only when the hook actually touches them - powerful, and priced accordingly. - Advanced - native twins: a middleware that needs per-request state or a native preflight short-circuit can pair its Web hooks with
onNodeRequest/onNodeResponsetwins (that is howrateLimitandloggerstay native: the same context object reaches both twins, so aWeakMapkeyed on it carries state across the request). Twins are all-or-nothing per app on Node and must stay semantically identical to their Web hooks;onResponseHeadersneeds none of this. timingstays Web-only for now (itsc.timingmetric channel crosses the handler lifecycle).
Official plugins
@nifrajs/middleware seeds a few definePlugin plugins to build on:
import { server } from "@nifrajs/core/server"
import { requestId, logger, etag } from "@nifrajs/middleware"
const app = server()
.use(requestId()) // reuse/generate x-request-id → c.requestId (typed) + response header
.use(logger()) // one structured line/request: { method, path, status, ms }
.use(etag()) // content-hash ETag on GET 200s → 304 on matching If-None-MatchrequestId()- reuses an inboundx-request-idor generates one, threads it asc.requestId, and echoes the header.logger()- one structured line per request (method, path, status, duration); covers 404s and errors; route it to your own sink vialog.etag()- adds a content-hashETagtoGET200s and returns304on a matchingIf-None-Match.
Authentication
bearer and apiKey guard the routes defined after them and expose a fully typed principal. Because the derive path can't carry a precise type through a named plugin, the principal is read from the returned instance - auth.principal(req) (nullable) or auth.requirePrincipal(req) (throws 401) - mirroring @nifrajs/auth and @nifrajs/better-auth. It's verified once per request and cached. For full session-based auth (OAuth, magic links, 2FA), see Auth & sessions.
// doc-check: skip - fragment: `app`, `lookupUser`, and the `db` lookup are your application's.
import { bearer, apiKey } from "@nifrajs/middleware"
// Bearer tokens - verify returns your principal (its type is inferred), 401s missing/invalid:
const auth = bearer({ verify: (token) => lookupUser(token) }) // AuthPlugin<User>
app
.use(auth) // guards routes defined after it
.get("/me", (c) => auth.requirePrincipal(c.req)) // typed principal, or throws 401
// API keys via a header (default x-api-key) - a fixed set compared in CONSTANT TIME…
app.use(apiKey({ keys: [process.env.API_KEY!] })) // matched key becomes the principal
// …or custom (DB-backed) verification; 'optional' lets unauthenticated requests through:
app.use(apiKey({ verify: (key) => db.apiKeys.find(key), optional: true }))bearer({ verify })- parsesAuthorization: Bearer, rejects with401+WWW-Authenticateunlessoptional.apiKey({ keys })- a fixed key set compared in constant time (SHA-256 + early-exit-free byte compare; the matched key is the principal).apiKey({ verify })does custom, typed verification.
Performance
compression() gzips compressible responses (via the Web-standard CompressionStream, so it works on every runtime) when the client sends Accept-Encoding: gzip, peeking the body so tiny responses aren't enlarged. cacheControl() sets Cache-Control on matching responses without clobbering one a handler already set.
Security note (BREACH): any HTTP compression makes the response size depend on its content, so a response that mixes a secret with attacker-reflected request input can leak the secret through the compressed length. Nifra's CSRF tokens are HMAC-signed per session and safe to compress, but if a route reflects request input alongside a per-request secret, exclude it via the compressible option or route scoping.
import { server } from "@nifrajs/core/server"
import { compression, cacheControl } from "@nifrajs/middleware"
const app = server()
.use(compression()) // gzip compressible responses (Accept-Encoding)
.use(cacheControl("public, max-age=60")) // Cache-Control on GET/HEAD 2xx (won't clobber)
// …or a per-path policy - return undefined to leave a response untouched:
.use(cacheControl((req) =>
new URL(req.url).pathname.startsWith("/assets/")
? "public, max-age=31536000, immutable"
: undefined))Operations & docs
healthcheck() adds liveness (/health) and readiness (/ready) endpoints - apply it before any auth guard so they stay public. openapi() serves an OpenAPI 3.1 document at /openapi.json, generated from your routes - add ui: true for a Scalar API-reference page at /reference. Paths, methods, and params are introspected; servers, tags, security, and securitySchemes are document options, and operations (keyed by "GET /users/:id") supplies per-route bodies/security that Standard Schema can't expose. For full request/response schemas, generate from a defineContract with @nifrajs/schema's toOpenAPI (it reads the t JSON Schema and emits $ref reuse). buildOpenApiDocument is exported for build-time generation too.
// doc-check: skip - fragment: `app`, `db`, and `redis` are your application's clients.
import { healthcheck, openapi } from "@nifrajs/middleware"
app
// Liveness + readiness. /ready runs each check concurrently → 200 (all pass) or 503.
.use(healthcheck({ checks: { db: () => db.ping(), cache: () => redis.ping() } }))
// GET /openapi.json from your routes (paths, methods, params); ui adds a Scalar page at /reference.
.use(openapi({ info: { title: "My API", version: "1.0.0" }, ui: true }))Reliability
idempotency() makes a retried unsafe request (same Idempotency-Key header) replay the first response instead of re-running the side effect - no double-charge on a dropped connection. It short-circuits before the handler; a concurrent retry gets a 409. See Security & hardening for the store contract, the production guidance (shared store + DB constraint), and the Set-Cookie rule.
import { server } from "@nifrajs/core/server"
import { idempotency, MemoryIdempotencyStore } from "@nifrajs/middleware"
// A retried POST with the same Idempotency-Key replays the first response instead of
// re-running the side effect. Shared store in production (atomic claim); dev-only memory store.
const app = server()
app.use(idempotency({ store: new MemoryIdempotencyStore() }))JWT & Basic auth
jwt verifies tokens with WebCrypto. The algorithms allowlist is required; alg:none and RSA/HMAC confusion are rejected, exp is enforced by default, and claims (iss/aud/nbf) are checked. Read the typed claims off the returned plugin - auth.requireClaims(c.req) (throws 401) or auth.claims(c.req) (nullable). For rotating keys, pass key: jwks({ url }) (HTTPS-only, cached, size/time-bounded). basicAuth compares static credentials in constant time (or takes a verify callback).
import { server } from "@nifrajs/core/server"
import { jwt, jwks, basicAuth } from "@nifrajs/middleware"
// JWT (WebCrypto): an explicit algorithm allowlist is REQUIRED; alg:none and RSA/HMAC confusion are rejected.
const auth = jwt({ key: process.env.JWT_SECRET!, algorithms: ["HS256"], issuer: "my-app" })
const app = server()
.use(auth) // 401s missing/invalid (optional:true lets them through)
.get("/me", (c) => auth.requireClaims(c.req)) // typed claims, or throws 401; auth.claims(req) is nullable
// Asymmetric (rotating keys): key: jwks({ url: "https://issuer/.well-known/jwks.json" }) - https-only, cached.
// HTTP Basic - static creds compared in CONSTANT TIME (SHA-256 + timing-safe), or a verify callback.
app.use(basicAuth({ username: "admin", password: process.env.PASS!, realm: "staging" }))Response caching
cache is a full response cache with a pluggable store, Vary-aware keys, and a byte cap. It bypasses Set-Cookie and honors request/response Cache-Control (no-store/private) so it never serves one user's response to another. MemoryResponseCache is per-instance and refuses NODE_ENV=production unless opted in - use a shared store in prod. prettyJson pretty-prints JSON responses (capped, with an optional query toggle).
import { server } from "@nifrajs/core/server"
import { cache, MemoryResponseCache, prettyJson } from "@nifrajs/middleware"
// Full response cache: pluggable store, Vary-aware keys, byte cap. Bypasses Set-Cookie and respects
// Cache-Control (no-store/private). MemoryResponseCache is per-instance - refuses prod unless opted in.
const app = server()
app.use(cache({ store: new MemoryResponseCache(), ttlMs: 30_000, vary: ["accept-language"] }))
app.use(prettyJson()) // pretty-print JSON responses (size-capped; optional ?pretty query toggle)Request shaping & negotiation
These build on the onRequest hook's ability to return a replacement Request (a real pre-routing rewrite, so handlers/validation/response hooks all see the rewritten request). methodOverride tunnels PUT/PATCH/DELETE through a POST header (query tunneling is off by default); trimTrailingSlash/appendTrailingSlash canonicalize URLs (same-origin, no open redirect); language negotiates Accept-Language into c.language; timing emits Server-Timing; poweredBy is opt-in; combine bundles several middleware into one.
import { server } from "@nifrajs/core/server"
import { methodOverride, trimTrailingSlash, language, timing, poweredBy, combine } from "@nifrajs/middleware"
const app = server()
.use(methodOverride()) // POST + X-HTTP-Method-Override → PUT/PATCH/DELETE (a real pre-routing request rewrite)
.use(trimTrailingSlash()) // canonicalize URLs: 308 redirect (or rewrite), same-origin only, conservative methods
.use(language({ supported: ["en", "fr"], defaultLanguage: "en" })) // Accept-Language → c.language + Content-Language
.use(timing()) // Server-Timing header + typed c.timing marks/measures
// poweredBy() is opt-in (Nifra emits no X-Powered-By by default). combine(a, b, c) bundles several into one plugin.Security middleware
The security set - csrf (signed double-submit + Origin/Referer), jwt, ipRestriction (IPv4/IPv6 + CIDR, fails closed), and bodyLimit (Content-Length cap before routing) - is documented with hardening guidance on Security & hardening. All comparisons are constant-time and all defaults fail closed.
Error reporting
For SSR apps, createWebApp's onLoaderError lets a reporting plugin observe every loader/action failure - including ones a nearest _error boundary would otherwise hide.
// @nifrajs/web: observe loader/action failures for error reporting (Sentry-style).
createWebApp({
adapter, manifest, clientEntry,
onLoaderError: (error, { route, request }) => report(error, { route }),
})
// Fires before the nearest _error boundary renders - so errors the boundary
// would hide still reach your reporter. (Control-flow redirects aren't reported.)