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.

TS
import { definePlugin } from "@nifrajs/core"

// 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 defineIdentityPlugin

A plugin that registers routes or hooks but adds no context type — e.g. mounting an auth handler — should be built with 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. A plain definePlugin((app) => app) infers app as Server<any, any>, which collapses use()'s result — and your whole typed client — to any. @nifrajs/better-auth is built this way, so server().use(betterAuth(auth)).get(...) keeps every route typed.

TS
import { defineIdentityPlugin, server } from "@nifrajs/core"

// A plugin that registers routes/hooks but adds NO context type is a type-IDENTITY plugin.
// defineIdentityPlugin keeps app.use()'s return type EXACTLY the caller's server, so routes added
// after .use() keep their types — and the typed client derived from them stays intact.
export const audit = defineIdentityPlugin("audit", (app) =>
  app.onResponse((res) => res),
)

// /a AND /b stay fully typed across the .use():
const api = server().get("/a", () => ({ a: 1 })).use(audit).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 { cors, rateLimit, securityHeaders } from "@nifrajs/middleware"

// Hardening middleware is a hook bundle (context-agnostic) — same app.use():
app
  .use(securityHeaders())
  .use(cors({ origins: ["https://app.example"] }))
  .use(rateLimit({ limit: 100, windowMs: 60_000 }))

Official plugins

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

TS
import { requestId, logger, etag } from "@nifrajs/middleware"

app
  .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 instanceauth.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
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.

TS
import { compression, cacheControl } from "@nifrajs/middleware"

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

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

TS
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" })
app
  .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 { 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.
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 { methodOverride, trimTrailingSlash, language, timing, poweredBy, combine } from "@nifrajs/middleware"

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

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.)
Proudly built with Nifra — server-rendered on Cloudflare Pages.MIT