npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@znight/http

v0.6.1

Published

Znight HTTP plugin: controllers, routing, guards, interceptors, validation with Fastify, Express or Hono

Readme

@znight/http

HTTP controllers for Znight, resolved at build time and served by the built-in node:http server, Fastify, Express or Hono.

Controllers, routing, parameter coercion, validation with any Standard Schema library (Zod, Valibot, ArkType...), file uploads and downloads, streaming, cookies (signed or not), Server-Sent Events, guards, interceptors, exception mappers, request scopes and graceful shutdown. Security is built in: access decisions on every route checked at build time and at boot, CORS, security headers and trusted proxies. It ships a zero-dependency server on node:http and adapters for Fastify, Express and Hono, and behaves the same on every one of them (on Node.js and Bun).

Like the rest of Znight, routes are resolved at build time: @znight/http/compiler reads your decorators and generates the route table and one invoke function per handler (argument extraction, coercion, validation). At runtime the plugin only registers the generated routes: no reflection, no metadata lookup per request.

# bun
bun add @znight/core @znight/http zod   # zod, or any Standard Schema library
bun add -d @znight/compiler vite
bun add fastify                          # optional: or express, or hono (+ @hono/node-server on Node.js)

# npm
npm install @znight/core @znight/http zod
npm install -D @znight/compiler vite
npm install fastify

Runs on Node.js >= 24 and Bun >= 1.3 (ESM only). Decorators require "experimentalDecorators": true. Every adapter is an optional peer dependency (light-my-request too, for inject() with the node and express adapters).

Documentation: @znight/http and the security guide. Examples: rest-api (Fastify, API keys), secure-api (route decisions, JWT, cookies, CORS), microservice and storefront-api (Hono), uptime-monitor (Server-Sent Events), monorepo (Express).

Quick start

// src/users/user.controller.ts
import { Body, Controller, Delete, Get, HttpResponse, NotFoundException, Param, Post, Query } from '@znight/http'
import { z } from 'zod'

export const CreateUser = z.object({ name: z.string().min(1), email: z.email() })
export type CreateUser = z.output<typeof CreateUser>

@Controller('/users')
export class UserController {
  constructor(private readonly users: UserService) {}

  @Get()
  list(@Query('page') page = 1, @Query('tag') tags: string[] = []) {
    return this.users.list({ page, tags })
  }

  @Get('/:id')
  async find(@Param('id') id: number) {
    const user = await this.users.findById(id)
    if (user === undefined) throw new NotFoundException(`User ${id} not found`)
    return user
  }

  @Post()
  async create(@Body(CreateUser) input: CreateUser) {
    const user = await this.users.create(input)
    return HttpResponse.created(user, `/users/${user.id}`)
  }

  @Delete('/:id')
  remove(@Param('id') id: number) {
    return this.users.remove(id) // undefined → 204 No Content
  }
}
// src/main.ts
import { Application } from '@znight/core'
import { HttpPlugin } from '@znight/http'
import { __ZNIGHT_METADATA__ } from './.znight/container.generated.js'

const app = await Application.create({
  metadata: __ZNIGHT_METADATA__,
  plugins: [HttpPlugin.configure({ port: 3000, prefix: '/api' })],
})
await app.start() // SIGINT / SIGTERM: stop accepting connections, drain, close
curl localhost:3000/api/users/42          # {"id":42,...}
curl localhost:3000/api/users/abc         # 400 {"statusCode":400,"error":"Bad Request","message":"Invalid param \"id\": expected a number, received \"abc\""}
curl -X POST localhost:3000/api/users -H 'content-type: application/json' -d '{"name":""}'
# 400 {"statusCode":400,"error":"Bad Request","message":"Invalid body","details":[{"path":"name","message":"..."},{"path":"email","message":"..."}]}

Controllers and routes

| Decorator | Target | Description | | ----------------------------------------- | -------------- | -------------------------------------------------------------------------------------- | | @Controller(path?) | class | A provider (singleton) whose decorated methods are routes | | @Controller({ path?, scope? }) | class | scope: 'request' creates one controller instance per request | | @Get @Post @Put @Patch @Delete | method | Route; path is relative to the controller path (default: the controller path) | | @Head(path?) @Options(path?) | method | Explicit HEAD / OPTIONS routes | | @All(path?) | method | GET, HEAD, POST, PUT, PATCH, DELETE and OPTIONS, for methods without their own route | | @Sse(path?, options?) / @Sse(options) | method | GET route serving an event stream, see Server-Sent Events | | @HttpCode(status) | method | Status of successful responses | | @Header(name, value) | method | Header of successful responses (repeatable) | | @BodyLimit(size) | class / method | Maximum request body size of the route(s) ('10mb'), see Limits | | @UseGuard(...guards) | class / method | Guards | | @UseInterceptor(...interceptors) | class / method | Interceptors | | @Validate(schema) | method | Sugar for @Body(schema) | | createRouteDecorator<T>(key) | — | Your own metadata decorators (@Roles(['admin']), @Public()), see Guards |

Arguments of paths, statuses and header names must be statically evaluable (literals, const references): the compiler reads them.

Path grammar

Paths are made of static segments, :name parameters and an optional trailing * wildcard:

/users                 static
/users/:id             :id matches one non-empty segment
/users/:id/posts/:post several parameters
/files/*               the rest of the path, possibly empty ('/files/' and '/files/a/b', not '/files'),
                       available as ctx.params['*']

Static segments accept letters, digits and - . _ ~ ! $ & ' + , ; = @. Anything else (//, ., .., :a-b, * in the middle, regular expressions, ?) is a ZNIGHT007 error at build time (and at boot for hand-written metadata). Matching is case-sensitive, on the raw (percent-encoded) request path: dot segments are not resolved (GET /files/a/../b gives ctx.params['*'] = a/../b, /files/%2e%2e/x gives ../x), so never build file paths from parameters without root (see Downloads). The Hono adapter on Bun is the exception: Bun.serve() only gives it the WHATWG-normalised URL (/files/a/../b arrives as /files/b).

When several routes match a path, the most specific one wins on every adapter: static segments beat parameters, which beat the wildcard (/users/me before /users/:id).

Automatic HEAD, OPTIONS, 404 and 405

  • Every GET route answers HEAD (same status and headers, no body) unless a @Head route exists for the same path.
  • A path that exists with other methods answers 405 Method Not Allowed with an allow header, and OPTIONS with 204 and allow (unless an @Options route exists). Both list the same methods, OPTIONS included (allow: GET, HEAD, POST, OPTIONS).
  • Unknown paths answer 404 Not Found. Both go through exception mappers and hooks.

Trailing slashes and prefix

trailingSlash applies to every adapter: 'strict' (default: /users/ does not match /users), 'ignore' (it does) or 'redirect' (308 to /users, query string kept; the location always stays on the same origin: a backslash, a control character or a leading // of the raw path is percent-encoded or collapsed, so GET /\evil.com/ redirects to /%5Cevil.com). prefix: '/api' is prepended to every route.

Parameters

| Decorator | Injects | | ----------------------- | -------------------------------------------------------------------------------------- | | @Param('id') | A route parameter, decoded and coerced to the parameter type | | @Param() | Every route parameter (Record<string, string>) | | @Query('page') | A query value, coerced; T[] accepts ?tag=a&tag=b and ?tag=a | | @Query() | The whole query (Record<string, string \| string[]>), usually with a schema | | @Body() | The parsed body (see Request bodies), usually with a schema | | @Headers('x-api-key') | A request header (case-insensitive), coerced | | @Headers() | Every header (Record<string, string \| undefined>, lower-case names) | | @Cookie('name') | A request cookie (decoded), coerced (see Cookies) | | @Cookie() | Every cookie (Record<string, string>) | | @SignedCookie('name') | The verified value of a signed cookie, coerced | | @UploadedFile('f') | An uploaded File of a multipart body (see Files) | | @UploadedFiles('f') | The uploaded files of a field (File[]) | | @Ctx() | The HttpContext | | @Req() / @Res() | The native request / response of the adapter (escape hatch, see Adapters) |

@Param, @Query, @Body, @Cookie and @SignedCookie take an optional Standard Schema (@Param('id', IdSchema), @Query(SearchQuery), @Body(CreateUser), @Cookie('theme', Theme)): the schema output is injected (no coercion then).

Without a schema, typed parameters are coerced:

| Declared type | Accepted input | Example | | ---------------------------------- | ------------------------------------------------- | ------------------ | | string | any single value | abc | | number | finite decimal number (no hex, NaN, Infinity) | -1.5, 1e3 | | boolean | true/false/1/0 (case-insensitive) | true | | bigint | integer | 9007199254740993 | | Date | ISO 8601 date, date-time with offset, timestamp | 2024-01-02 | | literal unions ('asc' \| 'desc') | one of the literals | asc | | T[] | repeated or single values, each coerced as T | ?id=1&id=2 |

Invalid values answer 400 naming the input (Invalid query "page": expected a number, received "x"). Dates accept YYYY-MM-DD (UTC midnight), YYYY-MM-DDTHH:mm[:ss[.fraction]] with an offset (Z, +02:00, +0200) and integer millisecond timestamps; anything else (March 7, 01/02/2024), impossible dates (2024-02-30) and date-times without offset (they would depend on the server's time zone) answer 400. A missing required value answers 400 Missing query "page". Optional parameters (?, | undefined, default value) receive undefined when absent, so default values apply: @Query('page') page = 1. A repeated query key for a scalar parameter is a 400.

Custom parameter decorators

createParamDecorator from @znight/core defines your own parameter decorators, such as a @CurrentUser() reading what an authentication guard stored (RFC 0010). The http resolver receives the HttpContext and the data argument of the use:

import { createParamDecorator } from '@znight/core'
import { UnauthorizedException } from '@znight/http'
import type { User } from './user.ts'

export const CurrentUser = createParamDecorator<User>({
  http: ctx => {
    const user = ctx.state.get('user') as User | undefined
    if (user === undefined) throw new UnauthorizedException()
    return user
  },
})

// @Get('/me') me(@CurrentUser() user: User) { return user }

The resolver runs inside invoke, after the guards and the interceptors, in parameter order with the other arguments; what it throws is answered like a handler error. It may return a promise (the route then awaits it), or be the class of a provider implementing ParamResolver (constructor injection, any scope: a request-scoped resolver is created once per request). The compiler checks every use: the decorator must have an http resolver, be the only parameter decorator of the parameter, and inject a type the parameter accepts. Custom parameters are not documented by @znight/openapi: document the header or cookie they read with @ApiHeader or @ApiSecurity.

Validation

Any library implementing Standard Schema v1 works, without adapter: Zod 3.24+/4, Valibot 1, ArkType 2, Effect Schema...

const SearchQuery = z.object({ q: z.string(), limit: z.coerce.number().int().max(100).default(10) })

@Get('/search')
search(@Query(SearchQuery) query: z.output<typeof SearchQuery>) {
  // query.limit is a number (default applied), unknown keys are stripped by the schema
}
  • Handlers receive the schema output: defaults, transforms, coercions, stripped keys.
  • Async schemas (async refinements) are supported.
  • A failure answers 400 with one entry per issue (path is dotted, '' for the root):
{
  "statusCode": 400,
  "error": "Bad Request",
  "message": "Invalid body",
  "details": [{ "path": "user.emails.1", "message": "Invalid email address" }]
}

The error is a ValidationException (a BadRequestException) carrying location (body, query, param "id"...) and the raw Standard Schema issues: map it to your own format with an exception mapper.

A non-primitive @Body() / @Query() without schema is reported by the compiler (ZNIGHT017, an error with strictValidation).

Request bodies

Znight parses bodies itself (identically on every adapter), after the guards and before the interceptors, for routes declaring @Body() or upload parameters:

| content-type | Body | | ----------------------------------- | ----------------------------------------------------------------------------- | | (empty body) | undefined | | application/json, */*+json | parsed JSON (400 when malformed) | | text/* | string (charset honoured) | | application/x-www-form-urlencoded | record, repeated keys as arrays | | multipart/form-data | record of the text fields, repeated names as arrays; files in ctx.files | | anything else / none | Uint8Array |

  • Bodies above the body limit answer 413: bodyLimit (default 1 MiB), @BodyLimit(size) on the route or controller, or the computed limit of upload routes (see Limits). It is checked on content-length first, then while reading; the connection is closed.
  • A content-encoding other than identity answers 415 (decompress upstream).
  • Whether a request has a body is decided by its content-length / transfer-encoding headers (an HTTP/1.1 request without them has none), so content-length: 0 or no framing header is an empty body on every adapter.
  • Other routes do not read the body: call await ctx.parseBody() (memoised), await ctx.rawBody() (bytes, e.g. to verify a webhook signature) or ctx.bodyStream() (a Web stream, nothing buffered: see Large files).

Responses

| Handler result | Response | | ----------------------------------------------- | -------------------------------------------------------------- | | object, array, number, boolean, null | JSON (application/json; charset=utf-8), toJSON() honoured | | string | text/plain; charset=utf-8 | | Uint8Array, Buffer, ArrayBuffer, views | application/octet-stream | | ReadableStream, Node.js Readable | streamed application/octet-stream (string chunks as UTF-8) | | Blob / File | its bytes, with the blob type and its size | | HttpResponse.file() / HttpResponse.stream() | a file or a stream, see Files | | HttpResponse.sse() | an event stream, see Server-Sent Events | | undefined | empty body, 204 (201 for @Post) | | HttpResponse | its body (same rules), status and headers | | Web Response | sent as is (@HttpCode / @Header do not apply) |

Status precedence: @HttpCode > HttpResponse.status > default (201 for @Post routes, 204 without body, 200 otherwise).

Header precedence: content type < @Header < ctx.setHeader() < HttpResponse headers. Headers set with ctx.setHeader() / ctx.appendHeader() are also sent with error responses (a guard can set www-authenticate and throw). Every response carries the request id header and the security headers it does not set itself.

return HttpResponse.ok(user)
return HttpResponse.created(user, `/users/${user.id}`) // location header; typed HttpResponse<User>
return HttpResponse.accepted()
return HttpResponse.noContent()
return HttpResponse.redirect('/login', 303)
return HttpResponse.file('report.pdf', { root: REPORTS_DIR }) // see Files
return HttpResponse.stream(rows(), { filename: 'export.csv' })
return HttpResponse.sse(this.llm.complete(prompt)) // see Server-Sent Events
return new HttpResponse(csv, { status: 200, headers: { 'content-type': 'text/csv' } }).withHeader('x-total', '42')
return HttpResponse.ok(user).withCookie('__Host-uid', user.id, { signed: true }) // see Cookies

withHeader(), withStatus(), withCookie() and withoutCookie() return copies (headers, status and cookies are kept).

HEAD requests and 204/304 statuses never have a body (streams are cancelled).

Writing the response yourself

@Res() injects the native response; the handler then writes it and must have finished when its promise settles. Returning a Web Response is portable and usually better.

| Adapter | @Req() | @Res() | | --------- | ---------------------- | ---------------------------------------------------- | | node | http.IncomingMessage | http.ServerResponse | | fastify | FastifyRequest | FastifyReply (return reply.send(...)) | | express | Express Request | Express Response | | hono | Web Request | Hono Context (return c.text(...), c.json(...)) |

Cookies

Cookies are handled by the plugin itself, identically on every adapter (no cookie-parser, @fastify/cookie or hono/cookie needed), with secure defaults, strict validation and optional signing. Tests send and read them with inject(): see Testing cookies.

Reading cookies

import { Body, Controller, Cookie, Ctx, Get, type HttpContext, HttpResponse, Post } from '@znight/http'
import { z } from 'zod'

export const ThemeInput = z.object({ theme: z.enum(['light', 'dark']) })
// a stale or forged value falls back instead of answering 400 forever (cookies outlive deployments)
export const ThemeCookie = z.enum(['light', 'dark']).catch('light')

@Controller('/prefs')
export class PrefsController {
  @Get()
  read(@Cookie('theme', ThemeCookie) theme: 'light' | 'dark', @Cookie('visits') visits = 0) {
    return { theme, visits }
  }

  @Post('/theme')
  setTheme(@Body(ThemeInput) input: z.output<typeof ThemeInput>, @Ctx() ctx: HttpContext) {
    // readable by the front-end: httpOnly off for this one
    ctx.setCookie('theme', input.theme, { maxAge: 365 * 86_400, httpOnly: false })
    return HttpResponse.noContent()
  }
}

| Decorator / member | Injects | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | @Cookie('name') v: T | The decoded cookie, coerced to T like @Headers (string, number, boolean, bigint, Date, literal unions, enums, branded primitives). Absent and required: 400 Missing cookie "name" | | @Cookie('name', S) | The decoded value (or undefined) validated with the Standard Schema S (its output is injected) | | @Cookie() / @Cookie(S) | Every cookie (Record<string, string>), validated by S when given | | @SignedCookie('name', S?) | The verified value of a signed cookie; absent, tampered or signed with an unknown key: the cookie does not exist | | ctx.cookies | Readonly<Record<string, string>>, parsed on first access | | ctx.signedCookie(name) | string \| undefined, verified once per name |

The cookie header is split on ; only (never ,), pairs are trimmed, quoted values unquoted and percent-encoded values decoded (a malformed sequence keeps the raw value). Pairs without = and empty names are ignored; names are case-sensitive and kept as sent; when a name repeats, the first one wins (browsers send the cookie with the longest path first). Repeated Cookie header fields (HTTP/2) are joined with ; on every adapter. The record has no prototype (__proto__ is just a name) and parsing is linear, without regular expressions.

An invalid cookie answers 400, on purpose: Invalid cookie "visits": expected a number. The message never echoes the value (cookies often carry tokens): with a schema (@Cookie(name, S), @SignedCookie(name, S), @Cookie(S)), the details keep the issue paths but their messages are Invalid value, since some libraries (Valibot, ArkType) quote the received value in them. Since cookies outlive deployments, a stale value would fail every request until it expires: give such cookies a schema with a fallback (z.enum([...]).catch('light'), as above) rather than relying on lenient coercion, which would hide tampering.

Setting cookies

ctx.setCookie('theme', 'dark', { maxAge: 365 * 86_400, httpOnly: false })
ctx.clearCookie('__Host-uid') // Max-Age=0 with the same Path / Secure: valid for the __Host- prefix

return HttpResponse.ok({ id: user.id }).withCookie('__Host-uid', user.id, { signed: true, maxAge: 8 * 3600 })
return HttpResponse.noContent().withoutCookie('session')

| Option | Default | Description | | ------------- | ------- | -------------------------------------------------------------------------------------------------- | | maxAge | — | Lifetime in seconds (integer); 0 or less expires the cookie now. Default: a session cookie | | expires | — | Absolute expiry (Date in the years 1601 to 9999, sent as an IMF-fixdate) | | domain | — | Domain (sub-domains included). Default: a host-only cookie; null: host-only despite the defaults | | path | '/' | Path | | secure | true | Sent over HTTPS only (and http://localhost) | | httpOnly | true | Not readable from JavaScript | | sameSite | 'lax' | 'strict', 'lax' or 'none' (requires secure) | | partitioned | false | CHIPS partitioned cookie (requires secure) | | priority | — | Chromium Priority: 'low', 'medium', 'high' | | signed | false | Signs the value with the first secret of cookies.secrets (see Signed cookies) |

The defaults are Path=/; Secure; HttpOnly; SameSite=Lax, host-only; change them for the whole application with the plugin option cookies.defaults, and per cookie with the options above. With a default domain (cookies shared across subdomains), domain: null makes one cookie host-only, and __Host- cookies never take the default domain or path (the prefix forbids them): an explicit domain or a path other than / on such a cookie still throws. Secure is right behind TLS-terminating proxies: it governs the browser to edge connection, not the proxy to app hop. Plain http:// development setups on a non-localhost host (and old Safari versions on http://localhost) need defaults: { secure: config.env !== 'development' }.

Strict serialisation (RFC 6265bis). A cookie browsers would drop silently throws where it is set instead:

  • the name must be an RFC 9110 token, the value a well-formed string; the value is percent-encoded outside cookie-octet (and %), so it always round-trips (a b;c → a%20b%3Bc);
  • name=value is at most 4096 bytes on the wire (after encoding and signing), Domain and Path at most 1024 bytes each;
  • maxAge is an integer, expires a valid Date in the years 1601 to 9999 (browsers ignore other years: new Date(8.64e15) would silently make a session cookie; use maxAge), domain an ASCII host name (IDNs in punycode; lower-cased, a leading . removed), path starts with / and has no ; or controls;
  • SameSite=None and partitioned require secure;
  • prefixes (case-insensitive, like browsers): __Secure- requires secure; __Host- requires secure, path: '/' and no domain; __Http- and __Host-Http- also require httpOnly.

Browsers cap cookie lifetimes at 400 days; nothing is clamped server-side.

ctx.setCookie() / ctx.clearCookie() validate everything at call time (TypeError or RangeError), so the stack trace points to the faulty line. HttpResponse.withCookie() is called before any request exists: it checks the name, the value, maxAge / expires, the domain, the path and the size before signing at call time; what depends on the plugin (the secure default with a prefix or SameSite=None, signing, the size after signing) is checked when the response is sent, and a violation answers a generic 500 logged as Invalid cookie "x": ….

Which cookies are sent. A response carries a jar keyed by name, domain and path: a later cookie with the same key replaces the earlier one in place (set then clear gives one clearing cookie).

  • The cookies of ctx.setCookie() / ctx.clearCookie() are sent with every response of the request: success and errors (mapped or not), redirects, automatic OPTIONS. A guard can ctx.clearCookie('sid') and then throw a 401.
  • The cookies of the HttpResponse (handler result or exception mapper result) replace those of the context with the same key (like headers: ctx.setHeader() < HttpResponse headers).
  • The set-cookie field holds the raw values first (@Header, ctx.setHeader() / appendHeader(), HttpResponse headers, a Web Response's own cookies), then the jar, in insertion order: one field line per cookie on every adapter, one array entry in inject().
  • Cookies the native framework set before the Znight route (Express res.cookie() in a middleware, Fastify reply.header('set-cookie', ...) in a hook, Hono setCookie(c, ...) in a middleware) are kept, before the plugin's, on every reply (event streams included).
  • A @Res() handler writes its own response: the jar is not applied.
  • Once the response headers are sent (a running event stream), setCookie() / clearCookie() throw Cannot set cookie "x": the response headers were already sent.

serializeCookie(name, value, options?), serializeClearCookie(name, options?), parseCookieHeader(header) and parseSetCookie(line) are exported for gateways and tests (serializeCookie applies the secure defaults; signing needs the plugin: plugin.signCookie()).

Signed cookies

HttpPlugin.configure(c => {
  const config = c.get(AppConfig) // @Secret('COOKIE_SECRETS') → ['current…', 'previous…']
  return { cookies: { secrets: config.cookieSecrets } } // the first signs, all verify
})

@Post('/login')
async login(@Body(Credentials) input: Credentials) {
  const user = await this.users.authenticate(input) // throws UnauthorizedException
  return HttpResponse.ok({ id: user.id }).withCookie('__Host-uid', user.id, { signed: true, maxAge: 8 * 3600 })
}

@Get('/me')
me(@SignedCookie('__Host-uid') uid: string) {
  // absent OR tampered OR signed with a retired key → 400 `Missing cookie "__Host-uid"`
  return this.users.find(uid)
}

// a guard reads the same thing imperatively
canActivate(ctx: HttpContext): boolean {
  const uid = ctx.signedCookie('__Host-uid')
  if (uid === undefined) throw new UnauthorizedException()
  ctx.state.set('uid', uid)
  return true
}
  • Secrets: cookies.secrets is one secret or a list (string or Uint8Array), each at least 32 bytes: generate them with crypto.randomBytes(32).toString('base64url') and load them with @Secret from @znight/config. Short secrets, duplicates and an empty list fail the boot (ZNIGHT005, the secrets are never echoed).
  • Format: <encoded value>.<base64url(HMAC-SHA256(secret, "<name>=<encoded value>"))>. The MAC covers the name: a value of a replayed as b fails, sid and __Host-sid have different signatures. Verification is constant-time (timingSafeEqual); an invalid signature makes the cookie absent (no 400 "bad signature", no oracle). Not compatible with Express cookie-parser.
  • Rotation: deploy secrets: [next, current] (new cookies use next, old ones still verify), wait for the longest cookie lifetime, then deploy [next]. Nothing is re-signed automatically; calling setCookie() again migrates a cookie.
  • Missing secrets: a route reading @SignedCookie fails the boot with ZNIGHT006 (AuthController.me reads a signed cookie (@SignedCookie('__Host-uid')) but cookies.secrets is not set); ctx.signedCookie() and { signed: true } without secrets throw (a logged 500).
  • Limits: signing gives integrity, not confidentiality (the client can read the value); the MAC does not cover an expiry, so a stolen signed cookie stays valid until rotation: put an expiry in the value when it matters. Sessions, encrypted cookies and CSRF middleware are out of scope.

plugin.signCookie(name, value) returns the signed wire value (gateways minting cookies, tests).

Security notes

  • Use __Host- names for authentication cookies: they cannot be shadowed by a cookie set from a sibling subdomain ("cookie tossing").
  • SameSite=Lax blocks cookies on cross-site POSTs in modern browsers but is no CSRF defense on its own (same-site subdomains, GET side effects).
  • Znight's request logs do not include headers. If you log headers yourself, redact cookie and set-cookie (pino: redact: ['req.headers.cookie', 'res.headers["set-cookie"]']).

Files

Uploads, downloads and streamed responses work the same on every adapter, on Node.js and Bun, without dependency: files are standard File objects, streams are Web ReadableStreams.

Uploads

import { Body, BodyLimit, Controller, Post, UploadedFile, UploadedFiles } from '@znight/http'
import { z } from 'zod'

export const AvatarForm = z.object({ title: z.string().min(1), public: z.stringbool().default(false) })

@Controller('/profile')
export class ProfileController {
  constructor(private readonly storage: Storage) {}

  @Post('/avatar')
  async avatar(
    @UploadedFile('avatar', { maxSize: '2mb', mimeTypes: ['image/png', 'image/jpeg'] }) avatar: File,
    @Body(AvatarForm) form: z.output<typeof AvatarForm>, // the text fields of the form
  ) {
    const key = await this.storage.save(crypto.randomUUID(), avatar.stream(), avatar.type)
    return { key, size: avatar.size, title: form.title }
  }

  @Post('/documents')
  add(
    @UploadedFiles('documents', { maxFiles: 5, maxSize: '20mb', mimeTypes: ['application/pdf'] }) documents: File[],
    @UploadedFile('cover') cover?: File, // optional: undefined when absent
  ) {}
}
curl -F title=Me -F [email protected] localhost:3000/profile/avatar

| Decorator | Injects | | -------------------------------- | ----------------------------------------------------------------------------------- | | @UploadedFile('avatar', opts?) | the file of the field (File, or File \| undefined when optional) | | @UploadedFile(opts?) | the single file of the body, whatever its field | | @UploadedFiles('docs', opts?) | the files of the field, in body order (File[]) | | @UploadedFiles(opts?) | every file of the body | | @Body(schema?) | the text fields: Record<string, string \| string[]> (repeated names → arrays) |

The injected values are standard File objects, held in memory: name, type, size, lastModified, arrayBuffer(), bytes(), text(), stream(), slice(). file.type is the media type declared by the client, lower-cased (application/octet-stream when absent). Text fields are strings: validate them with a schema that converts them (z.coerce.number(), z.stringbool()); a repeated name gives an array and a single one a string (z.union([z.string(), z.array(z.string())]) accepts both).

Options (statically evaluated, like every decorator argument):

| Option | Default | Description | | ----------- | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | required | true, unless the parameter is optional | Answer 400 when no file is sent. An optional parameter (file?: File, File \| undefined) receives undefined; @UploadedFiles receives [] | | maxSize | plugin uploads.maxFileSize (10 MiB) | Maximum size of each file: bytes or '512kb', '5mb' (kb = 1024), Infinity | | maxFiles | plugin uploads.maxFiles (10) | @UploadedFiles only: maximum number of files | | mimeTypes | any | Accepted types: 'image/png', 'image/*', '*/*' (parameters are ignored) |

Routes with upload parameters parse multipart/form-data bodies before the interceptors, as the body arrives, and enforce the options while reading: an invalid upload is rejected as soon as the offending part shows up, before the rest of the body is read. Files are only accepted in the declared fields. Errors:

| Status | When | Message | | ------ | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | | 400 | a required file is missing | Missing file "avatar" / Missing files "documents" | | 400 | a text value was sent in a file field | Expected a file in field "avatar", received a text value | | 400 | a second file for @UploadedFile | Expected a single file in field "avatar" | | 400 | a file in an undeclared field | Unexpected file field "other" (the issue lists the expected fields) | | 400 | a malformed body (boundary, part headers, truncated body) | Invalid multipart body: ... | | 413 | a file above maxSize | File in field "avatar" exceeds the maximum size of 2 MiB | | 413 | more than maxFiles files | Too many files in field "documents" (at most 5) | | 413 | too many parts, a text field too large, a body above the body limit | Too many parts in the multipart body (at most 1000)... | | 415 | a refused media type | Unsupported file type "text/plain" in field "avatar" (accepted: image/png, image/jpeg) | | 415 | a non-empty body that is not multipart/form-data | Expected a multipart/form-data body (received "application/json") |

The details of a 400 list the issue like a validation error does ([{ path: 'avatar', message: 'Missing file' }], the ValidationErrorBody of @znight/openapi); those of a 413 or 415 carry the field and the limit ({ field: 'avatar', maxSize: 2097152 }). Errors raised while the body is still being sent carry connection: close: the rest of the body is discarded and the connection closed after the response.

On routes without upload parameters, a multipart body read with @Body() or ctx.parseBody() also gives the text fields; its files are available in ctx.files (by field, within the plugin uploads limits and the body limit), and files of any field are accepted.

Why not Request.formData()? The Web-standard formData() buffers the whole body before parsing it, has no per-file limits and its results differ between Node.js and Bun (default part types, filename*, malformed bodies). Znight uses its own streaming RFC 7578 parser, shared by every adapter: same results everywhere, early rejection, explicit limits.

Limits

| Limit | Where | Default | | -------------------------------- | ---------------------------------------------- | -------------------------- | | size of each file | maxSize option, plugin uploads.maxFileSize | 10 MiB | | number of files of a parameter | maxFiles option, plugin uploads.maxFiles | 10 (1 for @UploadedFile) | | number of parts (fields + files) | plugin uploads.maxParts | 1000 | | size of a text field | plugin uploads.maxFieldSize | 1 MiB | | size of the part headers | built in | 16 KiB | | size of the whole body | @BodyLimit(size), plugin bodyLimit | see below |

HttpPlugin.configure({
  bodyLimit: '1mb', // JSON, text, forms...
  uploads: { maxFileSize: '25mb', maxFiles: 20, maxParts: 200, maxFieldSize: '64kb' },
})

The body limit of a route is @BodyLimit(size) (on the route, or on the controller for all its routes) when present. Otherwise it is the plugin bodyLimit for ordinary routes and, for upload routes, the sum of what their upload parameters accept (maxSize × maxFiles each) plus bodyLimit (room for the text fields and the multipart framing): an avatar route with maxSize: '2mb' accepts bodies up to 3 MiB by default. Set @BodyLimit to cap a route accepting many large files, or @BodyLimit(Infinity) to disable the limit of a route that streams its body itself.

Uploaded files are held in memory within these limits: keep them proportionate to your memory and expected concurrency, and see Large files beyond a few dozen megabytes. parseByteSize('10mb') (→ 10485760) and formatByteSize(n) convert sizes like the options do.

Downloads

HttpResponse.file(path | URL | Blob, options?) sends a file:

import { Controller, Get, HttpResponse, Param } from '@znight/http'

const REPORTS = '/srv/app/reports'

@Controller('/reports')
export class ReportController {
  @Get('/*')
  download(@Param('*') name: string) {
    // `root` confines the path: '../../etc/passwd' or a symbolic link leaving the directory answer 404
    return HttpResponse.file(name, { root: REPORTS })
  }

  @Get('/:id/export')
  async export(@Param('id') id: string) {
    const report = await this.reports.find(id)
    return HttpResponse.file(report.path, { filename: `Rapport été ${report.year}.pdf` }) // attachment
  }

  @Get('/:id/preview')
  preview(@Param('id') id: string) {
    return HttpResponse.file(this.reports.pathOf(id), { disposition: 'inline', contentType: 'application/pdf' })
  }

  @Get('/generated.json')
  generated() {
    return HttpResponse.file(new File([JSON.stringify(data)], 'data.json', { type: 'application/json' }))
  }
}

| Option | Default | Description | | ------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | root | none (the path is used as given, relative to the working directory) | Directory the path is resolved in; the real file (links followed) must stay inside it, otherwise 404 | | dotfiles | 'deny' | With root: 'allow' to send names starting with a dot (.env, .git/config answer 404 otherwise) | | filename | none | Name proposed to the client; makes the response an attachment | | disposition | attachment with filename, else no header | 'attachment' (download) or 'inline' (display); without filename the file name is used | | contentType | the Blob type, else from the extension of filename / of the path, else application/octet-stream | Media type (text types get ; charset=utf-8) |

The response carries Content-Type, Content-Length, Accept-Ranges: bytes, Last-Modified and a strong ETag ("<mtime>-<size>", from the modification time and the size; File objects use lastModified), and answers:

| Request | Response | | --------------------------------------------------------------------------- | ---------------------------------------------------------------- | | GET / HEAD | 200 (HEAD: headers only) | | If-None-Match matching (weak comparison) or If-Modified-Since not older | 304 Not Modified, no body | | If-Match not matching, or If-Unmodified-Since older | 412 Precondition Failed | | Range: bytes=0-499, bytes=500-, bytes=-500 (single range, GET) | 206 Partial Content with Content-Range | | a range starting after the end | 416 Range Not Satisfiable with Content-Range: bytes */<size> | | several ranges, another unit, a stale If-Range | 200 with the whole file (as RFC 9110 allows) | | a missing file, a directory, a path leaving root, a dotfile | 404 File not found (the path is never echoed) |

Content-Disposition follows RFC 6266: an ASCII fallback in filename and the UTF-8 name in filename* (RFC 8187 / 5987), directory parts and control characters removed: attachment; filename="Rapport _t_ 2024.pdf"; filename*=UTF-8''Rapport%20%C3%A9t%C3%A9%202024.pdf. contentDisposition(filename, type?) builds this header for your own responses, contentTypeFor(filename) looks a type up in the built-in table, parseRange(header, size) parses a Range header and matchesMediaType(type, patterns) applies mimeTypes-like filters.

Files on disk are sent as file-backed blobs (fs.openAsBlob()): read lazily as the client receives them, no descriptor kept open, sendfile with the Hono adapter on Bun. The request stays in flight and its scope open until the file has been sent (or the client went away), so a request-scoped provider may own the file (a temporary export deleted in [Symbol.dispose]()); on Bun.serve() the file is handed to Bun, which opens it at once, and the scope closes then. Headers set with .withHeader() (cache-control...), @Header and ctx.setHeader() are added; a 200 follows the usual status precedence, 206 / 304 keep theirs.

Returning a File / Blob directly sends it with its type and size (no Content-Disposition, ranges or validators: use HttpResponse.file(blob) for those).

Streaming responses

HttpResponse.stream(source, { contentType?, filename?, disposition? }) streams a Web ReadableStream, a Node.js Readable or any async iterable (async generator) of Uint8Array / string chunks (strings are encoded as UTF-8):

@Get('/export.csv')
export() {
  const users = this.users
  async function* lines() {
    yield 'id,email\n'
    for await (const user of users.iterate()) yield `${user.id},${user.email}\n`
  }
  return HttpResponse.stream(lines(), { filename: 'users.csv' }) // text/csv; charset=utf-8, attachment
}

@Get('/logs')
logs() {
  return HttpResponse.stream(createReadStream('/var/log/app.log'), { contentType: 'text/plain; charset=utf-8' })
}
  • Backpressure: the source is read only as fast as the client receives the data (on every adapter); a slow client never makes the server buffer the whole stream.
  • Cancellation: when the client disconnects, the source is cancelled (return() of the iterator — finally blocks run —, destroy() of a Readable, cancel() of a Web stream).
  • Request scope: request-scoped instances live until the stream has been fully sent or cancelled, and the source runs in the request scope (Lazy<HttpContext>, request-scoped providers and tracing contexts keep working while it produces chunks).
  • Errors: a source failing after the headers were sent cannot change the status: the connection is closed (the client sees a truncated response) and the error is logged (The response stream failed).
  • Shutdown: streamed responses are in-flight requests: the graceful drain waits for them up to drainTimeoutMs, then closes their connections (cancelling their sources).

Streams have no Content-Length (chunked transfer encoding). Returning a ReadableStream or a Readable directly also works (application/octet-stream); string, ArrayBuffer and typed array chunks of a returned ReadableStream are converted to bytes.

Large files

Uploaded files are buffered in memory. For files of hundreds of megabytes or more:

  1. Upload directly to object storage (recommended). The API authorises the upload and returns a presigned URL (S3, GCS, Azure Blob, R2...); the client sends the file to the storage, then notifies the API. Your servers never carry the bytes; storage handles resumable and multipart uploads.

    @Post('/uploads')
    async createUpload(@Body(NewUpload) input: NewUpload) {
      const key = `uploads/${crypto.randomUUID()}`
      const url = await getSignedUrl(s3, new PutObjectCommand({ Bucket, Key: key, ContentType: input.type }), {
        expiresIn: 300,
      })
      return { key, url } // the client PUTs the file to `url`, then calls POST /uploads/:key/complete
    }
  2. Stream the raw body. Send the file as the request body (PUT /files/:name, curl -T) and read it with ctx.bodyStream(): nothing is buffered, backpressure applies (the request is paused while you write), and the body limit of the route still applies (@BodyLimit):

    import { createWriteStream } from 'node:fs'
    import { join } from 'node:path'
    import { Readable } from 'node:stream'
    import { pipeline } from 'node:stream/promises'
    
    @Put('/files/:id')
    @BodyLimit('5gb')
    async store(@Param('id', FileId) id: string, @Ctx() ctx: HttpContext) {
      // never build the path from the client's file name
      await pipeline(Readable.fromWeb(ctx.bodyStream()), createWriteStream(join(UPLOADS_DIR, id)))
      return HttpResponse.created(undefined, `/files/${id}`)
    }

    ctx.bodyStream() reads the body once and is exclusive with @Body(), upload parameters, ctx.parseBody() and ctx.rawBody(). A body stream neither read to its end nor cancelled when the request is over (a handler that checked the first bytes, then threw) is cancelled: the rest of the body is discarded and the keep-alive connection serves the next request. A multipart/form-data body read this way is raw: parse it with a streaming parser of your choice (e.g. busboy) if you must accept huge browser form uploads.

  3. Serve large files with HttpResponse.file(): they are streamed from disk, with ranges for resumable downloads and video seeking.

Security

  • Never trust the client's file name. File.name is chosen by the client (it may contain ../, absolute paths, control characters or names of existing files). Do not use it as a path: store files under generated identifiers and keep the original name as metadata. When you must display or reuse it, path.basename() it and limit its characters.
  • Never trust the client's media type. File.type and mimeTypes filters rely on what the client declares: they reject honest mistakes, not attackers. When the type matters, check the content (magic numbers, e.g. with file-type) and decode images with a library that re-encodes them.
  • Confine downloads. Always pass root to HttpResponse.file() when any part of the path comes from the request: .. segments, absolute paths, encoded separators (..%2F), symbolic links leaving the directory and dotfiles (.env, .git/) answer 404. Without root, the path is used as given.
  • Store uploads outside of the web root, never in a directory served statically, and do not serve user content inline from your application's origin: send it as an attachment, with X-Content-Type-Options: nosniff, or from a separate domain (an uploaded HTML or SVG file served inline runs scripts in your origin).
  • Keep limits tight. Limits protect memory and CPU: size each route for what it accepts (maxSize, maxFiles, @BodyLimit) and lower the plugin defaults if your routes accept less. Rate-limit upload routes and consider quotas and malware scanning for untrusted files.

Server-Sent Events

@Sse(path?, options?) declares a GET route serving an event stream; its handler returns an AsyncIterable, an Iterable or a ReadableStream of SseEvent ({ data, event, id, retry, comment }). HttpResponse.sse(source, options?) does the same on any route and any method (POST streaming for fetch() clients). No dependency, identical on every adapter, Node.js and Bun. Tests read event streams in memory with injectSse(): see Testing event streams.

import { setTimeout as sleep } from 'node:timers/promises'
import {
  Body,
  Controller,
  Ctx,
  Headers,
  type HttpContext,
  HttpResponse,
  NotFoundException,
  Param,
  Post,
  Sse,
  type SseEvent,
} from '@znight/http'

@Controller('/orders')
export class OrderEventsController {
  constructor(
    private readonly orders: OrderService,
    private readonly hub: OrderHub,
    private readonly llm: Llm,
  ) {}

  /** 1. Recommended shape: an async method does the pre-flight checks, then returns the source. */
  @Sse('/:id/events', { retry: 5_000, maxDuration: 30 * 60_000 })
  async events(
    @Param('id') id: string,
    @Headers('last-event-id') lastEventId: string | undefined, // sent by EventSource on reconnection
    @Ctx() ctx: HttpContext,
  ): Promise<AsyncIterable<SseEvent<OrderEvent>> | HttpResponse> {
    const order = await this.orders.find(id)
    if (order === undefined) throw new NotFoundException(`Order ${id} not found`) // a regular 404 (EventSource stops)
    if (order.closed) return HttpResponse.noContent() // 204: EventSource stops reconnecting
    return this.hub.subscribe(id, { after: lastEventId, signal: ctx.signal })
  }

  /** 2. An async generator: its body runs once the stream is open (it cannot answer 404 anymore). */
  @Sse('/clock', { heartbeat: false })
  async *clock(@Ctx() ctx: HttpContext): AsyncGenerator<SseEvent<{ now: string }>> {
    while (!ctx.signal.aborted) {
      yield { event: 'tick', data: { now: new Date().toISOString() } }
      await sleep(1_000, undefined, { signal: ctx.signal }).catch(() => undefined)
    }
  }

  /** 3. Any method: LLM-style POST streaming for fetch() clients (EventSource only does GET). */
  @Post('/:id/assistant')
  assistant(@Param('id') id: string, @Body(Prompt) prompt: Prompt, @Ctx() ctx: HttpContext) {
    return HttpResponse.sse(this.llm.complete(id, prompt, { signal: ctx.signal })) // 200, not 201
  }
}

async versus async *. The body of an async generator only runs once the 200 and the headers are sent: pre-flight checks (existence, authorization beyond guards) belong in an async method that returns the source, where it can still throw a 404 or return a 204.

| Handler result (after awaiting) | Response | | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | | AsyncIterable, Iterable (not a string), ReadableStream | 200 event stream | | HttpResponse.sse(...) | 200 event stream, plus its headers and cookies (its options win over the route's) | | Any other HttpResponse, a Web Response, a thrown exception | Sent as usual: 204 tells EventSource to stop, 401 / 404 fail it without reconnection | | undefined, a string, any other value | 500, logged: @Sse handler C.m must return an AsyncIterable, an Iterable or a ReadableStream... |

| Option | Default | Description | | ------------- | ------------------------------ | -------------------------------------------------------------------------------------------------- | | heartbeat | plugin sse.heartbeat (8 000) | Ms without an event after which a keep-alive comment (:) is sent (≥ 1 000), false to disable | | retry | plugin sse.retry (none) | Client reconnection delay sent first (retry:, ms) | | maxDuration | none | The server ends the stream gracefully after this many ms (≥ 1 000): rebalancing, re-authentication |

heartbeat and maxDuration (like the plugin's sse.heartbeat and sse.shutdownGraceMs) are at most 2 147 483 647 ms (about 24.8 days), the longest timer: a larger delay would fire after 1 ms on Node.js and Bun (every stream expiring at once), so it is ZNIGHT005 at build time and at boot.

Events and framing

interface SseEvent<T = unknown> {
  readonly data?: T // a string is sent as is, anything else through JSON.stringify (toJSON honoured)
  readonly event?: string // event type (addEventListener(type)); the client defaults to `message`. No CR or LF
  readonly id?: string | number // echoed by the client as Last-Event-ID. No CR, LF or NUL; '' resets it
  readonly retry?: number // reconnection delay (ms, integer ≥ 0)
  readonly comment?: string // comment lines, ignored by EventSource
}

Each event is encoded as one chunk: one : <line> per comment line, id:, event:, retry:, then one data: <line> per line of the data, and an empty line. Data is split on CRLF, CR and LF, so user content can never inject fields or end an event early; data: always carries its space (a leading space survives). An event with event but no data gets an empty data: line (otherwise EventSource would not dispatch it: { event: 'done' } would vanish); events with only id, retry or comment have no data line. Anything else than a plain object with these keys (strings, numbers, null, unknown keys, an empty object, event / id with line breaks, data JSON cannot serialise) is a TypeError: yield { data: value }. A Uint8Array item (a Buffer of a Node.js Readable) is written as is: already-framed SSE text from an upstream (proxying an LLM provider), not validated. An event object yielded after raw bytes that do not end at an event boundary (an empty line) would merge into the unterminated raw event: it fails the stream like an invalid item. encodeSseEvent(event) is exported.

HTTP/1.1 200 OK
content-type: text/event-stream; charset=utf-8
cache-control: no-cache, no-transform
x-accel-buffering: no

retry: 5000

id: 41
event: status
data: {"orderId":"o-1","status":"paid"}

:

data: line 1
data: line 2

retry: 7342

(The first chunk, one event, one heartbeat, a multi-line event and the jittered retry of a graceful close.)

Headers. cache-control: no-cache, no-transform (no caching, no transformation by proxies or compression middleware) and x-accel-buffering: no (nginx) are set first, so @Header, ctx.setHeader() and the HttpResponse headers can override them; content-type: text/event-stream; charset=utf-8 is set last. Never content-length, transfer-encoding or connection: keep-alive. Status: 200; @HttpCode on @Sse is a build error; HttpResponse.sse() defaults to 200 even on POST routes, and honours @HttpCode / .withStatus() on other routes (EventSource requires 200; fetch() clients read any status).

Lifecycle

  • Open: the first chunk (retry: N, or the comment :) is written at once, without touching the source, so every adapter flushes the status and headers (EventSource.onopen fires before the first event).
  • Backpressure: the source is read once per chunk the client pulls, nothing is read ahead: a slow client stops the source, on every adapter. Push-based producers buffer in their own structure: use the bounded EventChannel.
  • Heartbeats: while the client waits heartbeat ms without an event, the comment : is sent (the pending read of the source is kept). They never pile up for a slow client, are never inserted inside raw bytes that do not end at an event boundary, and 8 s is below every common idle timeout (Bun.serve() 10 s, nginx / ALB 60 s, Heroku 55 s, Cloudflare 100 s).
  • The source ends: the response ends; EventSource then reconnects (that is the protocol). To stop a client for good, send an event it understands ({ event: 'done' }, then es.close()) or answer 204 on the next connection.
  • The source fails, or yields an invalid item: the error is logged (The SSE stream failed, with requestId and path), an invalid item stops the source, and the response ends cleanly (no extra event: an automatic event: error would collide with EventSource's own error). Unlike HttpResponse.stream(), which closes the connection abruptly: SSE events are self-delimited and the client reconnects either way.
  • *Client disconnect