@znight/http
v0.6.1
Published
Znight HTTP plugin: controllers, routing, guards, interceptors, validation with Fastify, Express or Hono
Maintainers
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 fastifyRuns 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
- Controllers and routes
- Parameters
- Validation
- Request bodies
- Responses
- Cookies
- Files
- Server-Sent Events
- Errors
- Guards
- Route decisions
- CORS
- Security headers
- Behind a proxy
- Interceptors
- The request context
- Hooks and observability
- Plugin options
- Plugin routes
- Adapters
- Testing
- Lifecycle and graceful shutdown
- Compiler
- Compiler contract
- Compiler contract for other extensions
- Writing an adapter
- Limitations
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, closecurl 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
GETroute answersHEAD(same status and headers, no body) unless a@Headroute exists for the same path. - A path that exists with other methods answers
405 Method Not Allowedwith anallowheader, andOPTIONSwith204andallow(unless an@Optionsroute exists). Both list the same methods,OPTIONSincluded (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
400with one entry per issue (pathis 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 oncontent-lengthfirst, then while reading; the connection is closed. - A
content-encodingother thanidentityanswers415(decompress upstream). - Whether a request has a body is decided by its
content-length/transfer-encodingheaders (an HTTP/1.1 request without them has none), socontent-length: 0or 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) orctx.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 CookieswithHeader(), 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=valueis at most 4096 bytes on the wire (after encoding and signing),DomainandPathat most 1024 bytes each;maxAgeis an integer,expiresa validDatein the years 1601 to 9999 (browsers ignore other years:new Date(8.64e15)would silently make a session cookie; usemaxAge),domainan ASCII host name (IDNs in punycode; lower-cased, a leading.removed),pathstarts with/and has no;or controls;SameSite=Noneandpartitionedrequiresecure;- prefixes (case-insensitive, like browsers):
__Secure-requiressecure;__Host-requiressecure,path: '/'and nodomain;__Http-and__Host-Http-also requirehttpOnly.
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, automaticOPTIONS. A guard canctx.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()<HttpResponseheaders). - The
set-cookiefield holds the raw values first (@Header,ctx.setHeader()/appendHeader(),HttpResponseheaders, a WebResponse's own cookies), then the jar, in insertion order: one field line per cookie on every adapter, one array entry ininject(). - Cookies the native framework set before the Znight route (Express
res.cookie()in a middleware, Fastifyreply.header('set-cookie', ...)in a hook, HonosetCookie(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()throwCannot 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.secretsis one secret or a list (stringorUint8Array), each at least 32 bytes: generate them withcrypto.randomBytes(32).toString('base64url')and load them with@Secretfrom@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 ofareplayed asbfails,sidand__Host-sidhave different signatures. Verification is constant-time (timingSafeEqual); an invalid signature makes the cookie absent (no 400 "bad signature", no oracle). Not compatible with Expresscookie-parser. - Rotation: deploy
secrets: [next, current](new cookies usenext, old ones still verify), wait for the longest cookie lifetime, then deploy[next]. Nothing is re-signed automatically; callingsetCookie()again migrates a cookie. - Missing secrets: a route reading
@SignedCookiefails 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=Laxblocks cookies on cross-sitePOSTs in modern browsers but is no CSRF defense on its own (same-site subdomains,GETside effects).- Znight's request logs do not include headers. If you log headers yourself, redact
cookieandset-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:
@UploadedFile/@UploadedFiles, text fields with@Body() - Limits: sizes, counts, media types, body limits
- Downloads:
HttpResponse.file(), conditional requests and ranges - Streaming responses:
HttpResponse.stream() - Large files:
ctx.bodyStream(), presigned URLs - Security
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-standardformData()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 —finallyblocks run —,destroy()of aReadable,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:
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 }Stream the raw body. Send the file as the request body (
PUT /files/:name,curl -T) and read it withctx.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()andctx.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. Amultipart/form-databody 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.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.nameis 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.typeandmimeTypesfilters rely on what the client declares: they reject honest mistakes, not attackers. When the type matters, check the content (magic numbers, e.g. withfile-type) and decode images with a library that re-encodes them. - Confine downloads. Always pass
roottoHttpResponse.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/) answer404. Withoutroot, 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, withX-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.onopenfires 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
heartbeatms 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;
EventSourcethen reconnects (that is the protocol). To stop a client for good, send an event it understands ({ event: 'done' }, thenes.close()) or answer 204 on the next connection. - The source fails, or yields an invalid item: the error is logged (
The SSE stream failed, withrequestIdandpath), an invalid item stops the source, and the response ends cleanly (no extra event: an automaticevent: errorwould collide withEventSource's ownerror). UnlikeHttpResponse.stream(), which closes the connection abruptly: SSE events are self-delimited and the client reconnects either way. - *Client disconnect
