Docs

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, however each copy was configured. A plugin meant to stack needs a name per instance: nifra's guards (bearer(), jwt(), ipRestriction(), rateLimit() and the rest) have one, so a second, stricter guard before admin routes or inside a group() applies too.

TS
// 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() })))

What a parent’s use() covers

A group() scope inherits the parent’s chain as it stands at the call. A merge()d server was compiled on its own, so its routes keep their chain. Only what the parent installed before the group() or merge() counts.

Installed on the parentgroup() routemerge()d route
derive, decorate, authenticate, beforeHandle, afterHandle, onError, around, route-scoped assuranceyesno - wire it on the merged server
onRequest, onResponse, responseHeadersyesyes (app-wide)
responseContract()yesyes, when the route declares a response and its own server installed no contract
effectLedger()yesyes, when the route declares capabilities and has no ledger of its own
idempotency()yesthe merged server’s own - a route declaring idempotency without one is refused at registration

Two checks catch the silent cases. In development and tests, unusedScopedHooks reports a hook followed only by merge() calls, and a responseContract() or effectLedger() installed after routes it would have covered. nifra check reports a merge() whose server authenticates none of its routes while this server’s authenticate() or auth plugin covers its own (NF-C043); a module that is public on purpose takes // nifra-expect NF-C043: <reason> on the merge line.

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.

TS
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:

TS
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 }))

To refuse a request in onRequest, return the same status() value a route would: return status(401, { error: "key required" }). It takes the normal response path, so the body is serialized with its content-type, and declared responseHeaders and every onResponse hook apply to it. A hand-built Response still works.

Which hooks run where

Where a hook is registered decides which requests it sees. nifra routes --hooks prints, for every backend route and for each prefix's unmatched paths, the hooks that run and the site that registered them (app, group /admin, merge #1, mount /m), named by plugin where one registered them:

  • A group's hooks cover its whole prefix, including that prefix's 404 and 405 responses.
  • A merged server's onRequest runs only for its own routes; its response hooks and static headers become app-wide.
  • A mount runs the child's full lifecycle inside the parent's request and response hooks.
  • around, derive, and beforeHandle apply only to routes declared after them.
  • A WebSocket route, listed as WS /path, runs the request hooks, then the authentication stages, derives, beforeHandles and onError declared before it. Its handshake runs no around, afterHandle or response hooks, but a refused handshake carries the app’s responseHeaders().

nifra routes --hooks --json writes the report; --since <file> prints what runs differently from a saved one. nifra upgrade --write saves the report before it edits anything and prints the scope diff once the target is installed, and nifra check --json includes it as hooks when nifra.assurance.ts names a source. Origins are recorded only outside production builds.

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:

TS
import { responseObserver } from "@nifrajs/core/response-observer"
import { server } from "@nifrajs/core/server"
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)
    },
  }
}

const app = server().use(responseObserver()).use(serverName("nifra"))

Why this matters on Node: a full onResponse(res: Response) hook needs real Web objects, and building them adds work per request. A full hook can close the direct-writer path; onResponseHeaders avoids materializing a full Web response for header-only changes. 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.

TS
// 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 - They avoid the full response hook while producing headers that stay 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 Web Response exists, so nothing is drained. A body-hashing middleware avoids materializing a Web Response and keeps body handling on the portable byte path. The full onResponse contract remains available when an extension needs a complete response. Handler-returned raw Responses (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 raw Responses 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/onNodeResponse twins (that is how rateLimit and logger stay native: the same context object reaches both twins, so a WeakMap keyed 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; onResponseHeaders needs none of this.
  • timing stays Web-only for now (its c.timing metric channel crosses the handler lifecycle).

Official plugins

@nifrajs/middleware seeds a few definePlugin plugins to build on:

TS
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-Match
  • requestId() - reuses an inbound x-request-id or generates one, threads it as c.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 via log.
  • etag() - adds a content-hash ETag to GET 200s and returns 304 on a matching If-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.

TS
// 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 }) - parses Authorization: Bearer, rejects with 401 + WWW-Authenticate unless optional.
  • 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.

TS
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. The page loads an exact Scalar version from jsDelivr under a Subresource Integrity hash; serve your own copy with ui: { cdn, integrity } to keep a third-party origin out of your Content-Security-Policy. 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.

TS
// 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.

TS
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). A fetch that fails or returns no keys is remembered for failureCooldownMs (default 5 s), so while the identity provider is down a request fails at once instead of waiting on a fetch of its own. basicAuth compares static credentials in constant time (or takes a verify callback).

TS
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).

TS
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.

TS
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.

Rate-limit tiers

A rateLimit() inside a group() still sits under an app-wide one, so a group can only be stricter than the app. rateLimitTiers() is one limiter that charges each request to exactly one bucket - the tier with the longest static prefix holding the path, else the floor - so a public API can allow more than the floor while a login path allows less. It runs before routing, so 404s, 405s and bodies that fail validation are charged too. Prefixes are checked when the limiter is built (static, no trailing slash, no duplicates), and each tier counts in its own keys, so one store serves them all. skip, on both limiters, leaves a request uncharged.

TS
import { server } from "@nifrajs/core/server"
import { MemoryStore, rateLimitTiers } from "@nifrajs/middleware"

// Each request is charged to ONE bucket: the longest matching prefix, else the floor.
// A tier may sit above the floor - tiers never stack on it.
const app = server().use(
  rateLimitTiers({
    store: new MemoryStore(),
    floor: { max: 120, windowMs: 60_000 },
    tiers: [
      { prefix: "/api/public", max: 600, windowMs: 60_000 },
      { prefix: "/auth/login", max: 10, windowMs: 60_000 },
    ],
    skip: (req) => new URL(req.url).pathname === "/health", // never charged
  }),
)

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.

TS
// @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.)