@znight/http 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2333 -2
  3. package/dist/adapter-CGSceVEN.d.mts +876 -0
  4. package/dist/compiler.d.mts +156 -0
  5. package/dist/compiler.mjs +2180 -0
  6. package/dist/compiler.mjs.map +1 -0
  7. package/dist/express.d.mts +39 -0
  8. package/dist/express.mjs +135 -0
  9. package/dist/express.mjs.map +1 -0
  10. package/dist/fastify.d.mts +33 -0
  11. package/dist/fastify.mjs +191 -0
  12. package/dist/fastify.mjs.map +1 -0
  13. package/dist/hono.d.mts +49 -0
  14. package/dist/hono.mjs +319 -0
  15. package/dist/hono.mjs.map +1 -0
  16. package/dist/index.d.mts +1254 -0
  17. package/dist/index.mjs +5618 -0
  18. package/dist/index.mjs.map +1 -0
  19. package/dist/node-BUnkdSsa.mjs +183 -0
  20. package/dist/node-BUnkdSsa.mjs.map +1 -0
  21. package/dist/node-io-BY1ZDLRQ.mjs +1566 -0
  22. package/dist/node-io-BY1ZDLRQ.mjs.map +1 -0
  23. package/dist/node.d.mts +30 -0
  24. package/dist/node.mjs +1 -0
  25. package/dist/path-JI87C4k8.d.mts +109 -0
  26. package/dist/path.d.mts +2 -0
  27. package/dist/path.mjs +245 -0
  28. package/dist/path.mjs.map +1 -0
  29. package/dist/size-D8rsQcV0.mjs +184 -0
  30. package/dist/size-D8rsQcV0.mjs.map +1 -0
  31. package/dist/utils-DCbu3a4w.mjs +27 -0
  32. package/dist/utils-DCbu3a4w.mjs.map +1 -0
  33. package/package.json +142 -4
  34. package/src/adapter.ts +240 -0
  35. package/src/adapters/express.ts +201 -0
  36. package/src/adapters/fastify.ts +280 -0
  37. package/src/adapters/hono.ts +488 -0
  38. package/src/adapters/node-io.ts +368 -0
  39. package/src/adapters/node.ts +135 -0
  40. package/src/adapters/shared.ts +508 -0
  41. package/src/body.ts +135 -0
  42. package/src/coerce.ts +195 -0
  43. package/src/compiler/analyze.ts +1513 -0
  44. package/src/compiler/ast.ts +90 -0
  45. package/src/compiler/coercion.ts +267 -0
  46. package/src/compiler/generate.ts +374 -0
  47. package/src/compiler/index.ts +76 -0
  48. package/src/compiler/model.ts +260 -0
  49. package/src/compiler/route-decorators.ts +405 -0
  50. package/src/compiler/validate.ts +288 -0
  51. package/src/context.ts +533 -0
  52. package/src/cookie-settings.ts +68 -0
  53. package/src/cookie-signer.ts +106 -0
  54. package/src/cookies.ts +726 -0
  55. package/src/cors.ts +349 -0
  56. package/src/decisions.ts +331 -0
  57. package/src/decorators.ts +486 -0
  58. package/src/event-channel.ts +197 -0
  59. package/src/exceptions.ts +257 -0
  60. package/src/files.ts +429 -0
  61. package/src/headers.ts +39 -0
  62. package/src/index.ts +185 -0
  63. package/src/inject-sse.ts +309 -0
  64. package/src/interfaces.ts +101 -0
  65. package/src/metadata.ts +162 -0
  66. package/src/mime.ts +157 -0
  67. package/src/multipart.ts +597 -0
  68. package/src/option-error.ts +31 -0
  69. package/src/path.ts +288 -0
  70. package/src/pipeline.ts +918 -0
  71. package/src/plugin-routes.ts +188 -0
  72. package/src/plugin.ts +1432 -0
  73. package/src/proxy.ts +236 -0
  74. package/src/query.ts +30 -0
  75. package/src/response.ts +240 -0
  76. package/src/router.ts +119 -0
  77. package/src/security-headers.ts +161 -0
  78. package/src/serialize.ts +264 -0
  79. package/src/size.ts +66 -0
  80. package/src/sse-parse.ts +194 -0
  81. package/src/sse.ts +650 -0
  82. package/src/standard-schema.ts +81 -0
  83. package/src/status.ts +78 -0
  84. package/src/stream.ts +200 -0
  85. package/src/tokens.ts +21 -0
  86. package/src/uploads.ts +176 -0
  87. package/src/utils.ts +31 -0
  88. package/src/validate.ts +65 -0
package/README.md CHANGED
@@ -1,3 +1,2334 @@
1
- # Temporary Holding Version
1
+ # @znight/http
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ HTTP controllers for [Znight](../../README.md), resolved at build time and served by the built-in
4
+ `node:http` server, Fastify, Express or Hono.
5
+
6
+ Controllers, routing, parameter coercion, validation with any
7
+ [Standard Schema](https://standardschema.dev) library (Zod, Valibot, ArkType...), file uploads and
8
+ downloads, streaming, cookies (signed or not), Server-Sent Events, guards, interceptors, exception
9
+ mappers, request scopes and graceful shutdown.
10
+ Security is built in: access decisions on every route checked at build time and at boot, CORS,
11
+ security headers and trusted proxies.
12
+ It ships a zero-dependency server on `node:http` and adapters for **Fastify**, **Express** and
13
+ **Hono**, and behaves the same on every one of them (on Node.js and Bun).
14
+
15
+ Like the rest of Znight, routes are resolved **at build time**: `@znight/http/compiler` reads your
16
+ decorators and generates the route table and one `invoke` function per handler (argument
17
+ extraction, coercion, validation). At runtime the plugin only registers the generated routes: no
18
+ reflection, no metadata lookup per request.
19
+
20
+ ```sh
21
+ # bun
22
+ bun add @znight/core @znight/http zod # zod, or any Standard Schema library
23
+ bun add -d @znight/compiler vite
24
+ bun add fastify # optional: or express, or hono (+ @hono/node-server on Node.js)
25
+
26
+ # npm
27
+ npm install @znight/core @znight/http zod
28
+ npm install -D @znight/compiler vite
29
+ npm install fastify
30
+ ```
31
+
32
+ Runs on Node.js >= 24 and Bun >= 1.3 (ESM only). Decorators require `"experimentalDecorators": true`.
33
+ Every adapter is an [optional peer dependency](#optional-peer-dependencies) (`light-my-request` too,
34
+ for `inject()` with the `node` and `express` adapters).
35
+
36
+ **Documentation:** [`@znight/http`](https://github.com/znight-org/znight/blob/main/docs/packages/http.md)
37
+ and the [security guide](https://github.com/znight-org/znight/blob/main/docs/guide/security.md).
38
+ **Examples:** [`rest-api`](https://github.com/znight-org/znight/tree/main/examples/rest-api) (Fastify,
39
+ API keys), [`secure-api`](https://github.com/znight-org/znight/tree/main/examples/secure-api) (route
40
+ decisions, JWT, cookies, CORS), [`microservice`](https://github.com/znight-org/znight/tree/main/examples/microservice)
41
+ and [`storefront-api`](https://github.com/znight-org/znight/tree/main/examples/storefront-api) (Hono),
42
+ [`uptime-monitor`](https://github.com/znight-org/znight/tree/main/examples/uptime-monitor)
43
+ (Server-Sent Events), [`monorepo`](https://github.com/znight-org/znight/tree/main/examples/monorepo)
44
+ (Express).
45
+
46
+ - [Quick start](#quick-start)
47
+ - [Controllers and routes](#controllers-and-routes)
48
+ - [Parameters](#parameters)
49
+ - [Validation](#validation)
50
+ - [Request bodies](#request-bodies)
51
+ - [Responses](#responses)
52
+ - [Cookies](#cookies)
53
+ - [Files](#files)
54
+ - [Server-Sent Events](#server-sent-events)
55
+ - [Errors](#errors)
56
+ - [Guards](#guards)
57
+ - [Route decisions](#route-decisions)
58
+ - [CORS](#cors)
59
+ - [Security headers](#security-headers)
60
+ - [Behind a proxy](#behind-a-proxy)
61
+ - [Interceptors](#interceptors)
62
+ - [The request context](#the-request-context)
63
+ - [Hooks and observability](#hooks-and-observability)
64
+ - [Plugin options](#plugin-options)
65
+ - [Plugin routes](#plugin-routes)
66
+ - [Adapters](#adapters)
67
+ - [Testing](#testing)
68
+ - [Lifecycle and graceful shutdown](#lifecycle-and-graceful-shutdown)
69
+ - [Compiler](#compiler)
70
+ - [Compiler contract](#compiler-contract)
71
+ - [Compiler contract for other extensions](#compiler-contract-for-other-extensions)
72
+ - [Writing an adapter](#writing-an-adapter)
73
+ - [Limitations](#limitations)
74
+
75
+ ## Quick start
76
+
77
+ ```ts
78
+ // src/users/user.controller.ts
79
+ import { Body, Controller, Delete, Get, HttpResponse, NotFoundException, Param, Post, Query } from '@znight/http'
80
+ import { z } from 'zod'
81
+
82
+ export const CreateUser = z.object({ name: z.string().min(1), email: z.email() })
83
+ export type CreateUser = z.output<typeof CreateUser>
84
+
85
+ @Controller('/users')
86
+ export class UserController {
87
+ constructor(private readonly users: UserService) {}
88
+
89
+ @Get()
90
+ list(@Query('page') page = 1, @Query('tag') tags: string[] = []) {
91
+ return this.users.list({ page, tags })
92
+ }
93
+
94
+ @Get('/:id')
95
+ async find(@Param('id') id: number) {
96
+ const user = await this.users.findById(id)
97
+ if (user === undefined) throw new NotFoundException(`User ${id} not found`)
98
+ return user
99
+ }
100
+
101
+ @Post()
102
+ async create(@Body(CreateUser) input: CreateUser) {
103
+ const user = await this.users.create(input)
104
+ return HttpResponse.created(user, `/users/${user.id}`)
105
+ }
106
+
107
+ @Delete('/:id')
108
+ remove(@Param('id') id: number) {
109
+ return this.users.remove(id) // undefined → 204 No Content
110
+ }
111
+ }
112
+ ```
113
+
114
+ ```ts
115
+ // src/main.ts
116
+ import { Application } from '@znight/core'
117
+ import { HttpPlugin } from '@znight/http'
118
+ import { __ZNIGHT_METADATA__ } from './.znight/container.generated.js'
119
+
120
+ const app = await Application.create({
121
+ metadata: __ZNIGHT_METADATA__,
122
+ plugins: [HttpPlugin.configure({ port: 3000, prefix: '/api' })],
123
+ })
124
+ await app.start() // SIGINT / SIGTERM: stop accepting connections, drain, close
125
+ ```
126
+
127
+ ```sh
128
+ curl localhost:3000/api/users/42 # {"id":42,...}
129
+ curl localhost:3000/api/users/abc # 400 {"statusCode":400,"error":"Bad Request","message":"Invalid param \"id\": expected a number, received \"abc\""}
130
+ curl -X POST localhost:3000/api/users -H 'content-type: application/json' -d '{"name":""}'
131
+ # 400 {"statusCode":400,"error":"Bad Request","message":"Invalid body","details":[{"path":"name","message":"..."},{"path":"email","message":"..."}]}
132
+ ```
133
+
134
+ ## Controllers and routes
135
+
136
+ | Decorator | Target | Description |
137
+ | ----------------------------------------- | -------------- | -------------------------------------------------------------------------------------- |
138
+ | `@Controller(path?)` | class | A provider (singleton) whose decorated methods are routes |
139
+ | `@Controller({ path?, scope? })` | class | `scope: 'request'` creates one controller instance per request |
140
+ | `@Get` `@Post` `@Put` `@Patch` `@Delete` | method | Route; `path` is relative to the controller path (default: the controller path) |
141
+ | `@Head(path?)` `@Options(path?)` | method | Explicit `HEAD` / `OPTIONS` routes |
142
+ | `@All(path?)` | method | GET, HEAD, POST, PUT, PATCH, DELETE and OPTIONS, for methods without their own route |
143
+ | `@Sse(path?, options?)` / `@Sse(options)` | method | `GET` route serving an event stream, see [Server-Sent Events](#server-sent-events) |
144
+ | `@HttpCode(status)` | method | Status of successful responses |
145
+ | `@Header(name, value)` | method | Header of successful responses (repeatable) |
146
+ | `@BodyLimit(size)` | class / method | Maximum request body size of the route(s) (`'10mb'`), see [Limits](#limits) |
147
+ | `@UseGuard(...guards)` | class / method | [Guards](#guards) |
148
+ | `@UseInterceptor(...interceptors)` | class / method | [Interceptors](#interceptors) |
149
+ | `@Validate(schema)` | method | Sugar for `@Body(schema)` |
150
+ | `createRouteDecorator<T>(key)` | — | Your own metadata decorators (`@Roles(['admin'])`, `@Public()`), see [Guards](#guards) |
151
+
152
+ Arguments of paths, statuses and header names must be statically evaluable (literals, `const`
153
+ references): the compiler reads them.
154
+
155
+ ### Path grammar
156
+
157
+ Paths are made of static segments, `:name` parameters and an optional trailing `*` wildcard:
158
+
159
+ ```
160
+ /users static
161
+ /users/:id :id matches one non-empty segment
162
+ /users/:id/posts/:post several parameters
163
+ /files/* the rest of the path, possibly empty ('/files/' and '/files/a/b', not '/files'),
164
+ available as ctx.params['*']
165
+ ```
166
+
167
+ Static segments accept letters, digits and `- . _ ~ ! $ & ' + , ; = @`. Anything else (`//`, `.`,
168
+ `..`, `:a-b`, `*` in the middle, regular expressions, `?`) is a ZNIGHT007 error at build time (and
169
+ at boot for hand-written metadata). Matching is case-sensitive, on the raw (percent-encoded)
170
+ request path: dot segments are not resolved (`GET /files/a/../b` gives `ctx.params['*']` = `a/../b`,
171
+ `/files/%2e%2e/x` gives `../x`), so never build file paths from parameters without `root` (see
172
+ [Downloads](#downloads)). The Hono adapter on Bun is the exception: `Bun.serve()` only gives it the
173
+ WHATWG-normalised URL (`/files/a/../b` arrives as `/files/b`).
174
+
175
+ When several routes match a path, the most specific one wins on every adapter: static segments
176
+ beat parameters, which beat the wildcard (`/users/me` before `/users/:id`).
177
+
178
+ ### Automatic HEAD, OPTIONS, 404 and 405
179
+
180
+ - Every `GET` route answers `HEAD` (same status and headers, no body) unless a `@Head` route
181
+ exists for the same path.
182
+ - A path that exists with other methods answers `405 Method Not Allowed` with an `allow` header,
183
+ and `OPTIONS` with `204` and `allow` (unless an `@Options` route exists). Both list the same
184
+ methods, `OPTIONS` included (`allow: GET, HEAD, POST, OPTIONS`).
185
+ - Unknown paths answer `404 Not Found`. Both go through [exception mappers](#exception-mappers) and
186
+ [hooks](#hooks-and-observability).
187
+
188
+ ### Trailing slashes and prefix
189
+
190
+ `trailingSlash` applies to every adapter: `'strict'` (default: `/users/` does not match `/users`),
191
+ `'ignore'` (it does) or `'redirect'` (`308` to `/users`, query string kept; the `location` always
192
+ stays on the same origin: a backslash, a control character or a leading `//` of the raw path is
193
+ percent-encoded or collapsed, so `GET /\evil.com/` redirects to `/%5Cevil.com`). `prefix: '/api'`
194
+ is prepended to every route.
195
+
196
+ ## Parameters
197
+
198
+ | Decorator | Injects |
199
+ | ----------------------- | -------------------------------------------------------------------------------------- |
200
+ | `@Param('id')` | A route parameter, decoded and coerced to the parameter type |
201
+ | `@Param()` | Every route parameter (`Record<string, string>`) |
202
+ | `@Query('page')` | A query value, coerced; `T[]` accepts `?tag=a&tag=b` and `?tag=a` |
203
+ | `@Query()` | The whole query (`Record<string, string \| string[]>`), usually with a schema |
204
+ | `@Body()` | The parsed body (see [Request bodies](#request-bodies)), usually with a schema |
205
+ | `@Headers('x-api-key')` | A request header (case-insensitive), coerced |
206
+ | `@Headers()` | Every header (`Record<string, string \| undefined>`, lower-case names) |
207
+ | `@Cookie('name')` | A request cookie (decoded), coerced (see [Cookies](#cookies)) |
208
+ | `@Cookie()` | Every cookie (`Record<string, string>`) |
209
+ | `@SignedCookie('name')` | The verified value of a [signed cookie](#signed-cookies), coerced |
210
+ | `@UploadedFile('f')` | An uploaded `File` of a multipart body (see [Files](#uploads)) |
211
+ | `@UploadedFiles('f')` | The uploaded files of a field (`File[]`) |
212
+ | `@Ctx()` | The [`HttpContext`](#the-request-context) |
213
+ | `@Req()` / `@Res()` | The native request / response of the adapter (escape hatch, see [Adapters](#adapters)) |
214
+
215
+ `@Param`, `@Query`, `@Body`, `@Cookie` and `@SignedCookie` take an optional Standard Schema
216
+ (`@Param('id', IdSchema)`, `@Query(SearchQuery)`, `@Body(CreateUser)`, `@Cookie('theme', Theme)`):
217
+ the **schema output** is injected (no coercion then).
218
+
219
+ Without a schema, typed parameters are coerced:
220
+
221
+ | Declared type | Accepted input | Example |
222
+ | ---------------------------------- | ------------------------------------------------- | ------------------ |
223
+ | `string` | any single value | `abc` |
224
+ | `number` | finite decimal number (no hex, `NaN`, `Infinity`) | `-1.5`, `1e3` |
225
+ | `boolean` | `true`/`false`/`1`/`0` (case-insensitive) | `true` |
226
+ | `bigint` | integer | `9007199254740993` |
227
+ | `Date` | ISO 8601 date, date-time with offset, timestamp | `2024-01-02` |
228
+ | literal unions (`'asc' \| 'desc'`) | one of the literals | `asc` |
229
+ | `T[]` | repeated or single values, each coerced as `T` | `?id=1&id=2` |
230
+
231
+ Invalid values answer `400` naming the input (`Invalid query "page": expected a number, received
232
+ "x"`). Dates accept `YYYY-MM-DD` (UTC midnight), `YYYY-MM-DDTHH:mm[:ss[.fraction]]` with an offset
233
+ (`Z`, `+02:00`, `+0200`) and integer millisecond timestamps; anything else (`March 7`,
234
+ `01/02/2024`), impossible dates (`2024-02-30`) and date-times without offset (they would depend on
235
+ the server's time zone) answer `400`. A missing required value answers `400 Missing query "page"`. Optional parameters (`?`,
236
+ `| undefined`, default value) receive `undefined` when absent, so default values apply:
237
+ `@Query('page') page = 1`. A repeated query key for a scalar parameter is a `400`.
238
+
239
+ ## Validation
240
+
241
+ Any library implementing [Standard Schema v1](https://standardschema.dev) works, without adapter:
242
+ Zod 3.24+/4, Valibot 1, ArkType 2, Effect Schema...
243
+
244
+ ```ts
245
+ const SearchQuery = z.object({ q: z.string(), limit: z.coerce.number().int().max(100).default(10) })
246
+
247
+ @Get('/search')
248
+ search(@Query(SearchQuery) query: z.output<typeof SearchQuery>) {
249
+ // query.limit is a number (default applied), unknown keys are stripped by the schema
250
+ }
251
+ ```
252
+
253
+ - Handlers receive the schema **output**: defaults, transforms, coercions, stripped keys.
254
+ - Async schemas (async refinements) are supported.
255
+ - A failure answers `400` with one entry per issue (`path` is dotted, `''` for the root):
256
+
257
+ ```json
258
+ {
259
+ "statusCode": 400,
260
+ "error": "Bad Request",
261
+ "message": "Invalid body",
262
+ "details": [{ "path": "user.emails.1", "message": "Invalid email address" }]
263
+ }
264
+ ```
265
+
266
+ The error is a `ValidationException` (a `BadRequestException`) carrying `location` (`body`,
267
+ `query`, `param "id"`...) and the raw Standard Schema `issues`: map it to your own format with an
268
+ [exception mapper](#exception-mappers).
269
+
270
+ A non-primitive `@Body()` / `@Query()` without schema is reported by the compiler (ZNIGHT017, an
271
+ error with `strictValidation`).
272
+
273
+ ## Request bodies
274
+
275
+ Znight parses bodies itself (identically on every adapter), after the guards and before the
276
+ interceptors, for routes declaring `@Body()` or [upload parameters](#uploads):
277
+
278
+ | `content-type` | Body |
279
+ | ----------------------------------- | ----------------------------------------------------------------------------- |
280
+ | (empty body) | `undefined` |
281
+ | `application/json`, `*/*+json` | parsed JSON (`400` when malformed) |
282
+ | `text/*` | string (charset honoured) |
283
+ | `application/x-www-form-urlencoded` | record, repeated keys as arrays |
284
+ | `multipart/form-data` | record of the **text fields**, repeated names as arrays; files in `ctx.files` |
285
+ | anything else / none | `Uint8Array` |
286
+ - Bodies above the body limit answer `413`: `bodyLimit` (default 1 MiB), `@BodyLimit(size)` on the
287
+ route or controller, or the computed limit of upload routes (see [Limits](#limits)). It is
288
+ checked on `content-length` first, then while reading; the connection is closed.
289
+ - A `content-encoding` other than `identity` answers `415` (decompress upstream).
290
+ - Whether a request has a body is decided by its `content-length` / `transfer-encoding` headers
291
+ (an HTTP/1.1 request without them has none), so `content-length: 0` or no framing header is an
292
+ empty body on every adapter.
293
+ - Other routes do not read the body: call `await ctx.parseBody()` (memoised),
294
+ `await ctx.rawBody()` (bytes, e.g. to verify a webhook signature) or `ctx.bodyStream()` (a Web
295
+ stream, nothing buffered: see [Large files](#large-files)).
296
+
297
+ ## Responses
298
+
299
+ | Handler result | Response |
300
+ | ----------------------------------------------- | -------------------------------------------------------------- |
301
+ | object, array, number, boolean, `null` | JSON (`application/json; charset=utf-8`), `toJSON()` honoured |
302
+ | `string` | `text/plain; charset=utf-8` |
303
+ | `Uint8Array`, `Buffer`, `ArrayBuffer`, views | `application/octet-stream` |
304
+ | `ReadableStream`, Node.js `Readable` | streamed `application/octet-stream` (string chunks as UTF-8) |
305
+ | `Blob` / `File` | its bytes, with the blob type and its size |
306
+ | `HttpResponse.file()` / `HttpResponse.stream()` | a file or a stream, see [Files](#downloads) |
307
+ | `HttpResponse.sse()` | an event stream, see [Server-Sent Events](#server-sent-events) |
308
+ | `undefined` | empty body, `204` (`201` for `@Post`) |
309
+ | `HttpResponse` | its body (same rules), status and headers |
310
+ | Web `Response` | sent as is (`@HttpCode` / `@Header` do not apply) |
311
+
312
+ **Status precedence:** `@HttpCode` > `HttpResponse.status` > default (`201` for `@Post` routes,
313
+ `204` without body, `200` otherwise).
314
+
315
+ **Header precedence:** content type < `@Header` < `ctx.setHeader()` < `HttpResponse` headers.
316
+ Headers set with `ctx.setHeader()` / `ctx.appendHeader()` are also sent with error responses (a
317
+ guard can set `www-authenticate` and throw). Every response carries the request id header and the
318
+ [security headers](#security-headers) it does not set itself.
319
+
320
+ ```ts
321
+ return HttpResponse.ok(user)
322
+ return HttpResponse.created(user, `/users/${user.id}`) // location header; typed HttpResponse<User>
323
+ return HttpResponse.accepted()
324
+ return HttpResponse.noContent()
325
+ return HttpResponse.redirect('/login', 303)
326
+ return HttpResponse.file('report.pdf', { root: REPORTS_DIR }) // see Files
327
+ return HttpResponse.stream(rows(), { filename: 'export.csv' })
328
+ return HttpResponse.sse(this.llm.complete(prompt)) // see Server-Sent Events
329
+ return new HttpResponse(csv, { status: 200, headers: { 'content-type': 'text/csv' } }).withHeader('x-total', '42')
330
+ return HttpResponse.ok(user).withCookie('__Host-uid', user.id, { signed: true }) // see Cookies
331
+ ```
332
+
333
+ `withHeader()`, `withStatus()`, `withCookie()` and `withoutCookie()` return copies (headers,
334
+ status and cookies are kept).
335
+
336
+ `HEAD` requests and `204`/`304` statuses never have a body (streams are cancelled).
337
+
338
+ ### Writing the response yourself
339
+
340
+ `@Res()` injects the native response; the handler then writes it and must have finished when its
341
+ promise settles. Returning a Web `Response` is portable and usually better.
342
+
343
+ | Adapter | `@Req()` | `@Res()` |
344
+ | --------- | ---------------------- | ---------------------------------------------------- |
345
+ | `node` | `http.IncomingMessage` | `http.ServerResponse` |
346
+ | `fastify` | `FastifyRequest` | `FastifyReply` (return `reply.send(...)`) |
347
+ | `express` | Express `Request` | Express `Response` |
348
+ | `hono` | Web `Request` | Hono `Context` (return `c.text(...)`, `c.json(...)`) |
349
+
350
+ ## Cookies
351
+
352
+ Cookies are handled by the plugin itself, identically on every adapter (no `cookie-parser`,
353
+ `@fastify/cookie` or `hono/cookie` needed), with secure defaults, strict validation and optional
354
+ signing. Tests send and read them with `inject()`: see
355
+ [Testing cookies](#testing-cookies).
356
+
357
+ ### Reading cookies
358
+
359
+ ```ts
360
+ import { Body, Controller, Cookie, Ctx, Get, type HttpContext, HttpResponse, Post } from '@znight/http'
361
+ import { z } from 'zod'
362
+
363
+ export const ThemeInput = z.object({ theme: z.enum(['light', 'dark']) })
364
+ // a stale or forged value falls back instead of answering 400 forever (cookies outlive deployments)
365
+ export const ThemeCookie = z.enum(['light', 'dark']).catch('light')
366
+
367
+ @Controller('/prefs')
368
+ export class PrefsController {
369
+ @Get()
370
+ read(@Cookie('theme', ThemeCookie) theme: 'light' | 'dark', @Cookie('visits') visits = 0) {
371
+ return { theme, visits }
372
+ }
373
+
374
+ @Post('/theme')
375
+ setTheme(@Body(ThemeInput) input: z.output<typeof ThemeInput>, @Ctx() ctx: HttpContext) {
376
+ // readable by the front-end: httpOnly off for this one
377
+ ctx.setCookie('theme', input.theme, { maxAge: 365 * 86_400, httpOnly: false })
378
+ return HttpResponse.noContent()
379
+ }
380
+ }
381
+ ```
382
+
383
+ | Decorator / member | Injects |
384
+ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
385
+ | `@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"` |
386
+ | `@Cookie('name', S)` | The decoded value (or `undefined`) validated with the Standard Schema `S` (its output is injected) |
387
+ | `@Cookie()` / `@Cookie(S)` | Every cookie (`Record<string, string>`), validated by `S` when given |
388
+ | `@SignedCookie('name', S?)` | The **verified** value of a [signed cookie](#signed-cookies); absent, tampered or signed with an unknown key: the cookie does not exist |
389
+ | `ctx.cookies` | `Readonly<Record<string, string>>`, parsed on first access |
390
+ | `ctx.signedCookie(name)` | `string \| undefined`, verified once per name |
391
+
392
+ The `cookie` header is split on `;` only (never `,`), pairs are trimmed, quoted values unquoted
393
+ and percent-encoded values decoded (a malformed sequence keeps the raw value). Pairs without `=`
394
+ and empty names are ignored; names are case-sensitive and kept as sent; when a name repeats, **the
395
+ first one wins** (browsers send the cookie with the longest path first). Repeated `Cookie` header
396
+ fields (HTTP/2) are joined with `; ` on every adapter. The record has no prototype (`__proto__` is
397
+ just a name) and parsing is linear, without regular expressions.
398
+
399
+ **An invalid cookie answers 400, on purpose**: `Invalid cookie "visits": expected a number`. The
400
+ message never echoes the value (cookies often carry tokens): with a schema
401
+ (`@Cookie(name, S)`, `@SignedCookie(name, S)`, `@Cookie(S)`), the `details` keep the issue paths
402
+ but their messages are `Invalid value`, since some libraries (Valibot, ArkType) quote the received
403
+ value in them. Since cookies outlive deployments, a
404
+ stale value would fail every request until it expires: give such cookies a schema with a fallback
405
+ (`z.enum([...]).catch('light')`, as above) rather than relying on lenient coercion, which would
406
+ hide tampering.
407
+
408
+ ### Setting cookies
409
+
410
+ ```ts
411
+ ctx.setCookie('theme', 'dark', { maxAge: 365 * 86_400, httpOnly: false })
412
+ ctx.clearCookie('__Host-uid') // Max-Age=0 with the same Path / Secure: valid for the __Host- prefix
413
+
414
+ return HttpResponse.ok({ id: user.id }).withCookie('__Host-uid', user.id, { signed: true, maxAge: 8 * 3600 })
415
+ return HttpResponse.noContent().withoutCookie('session')
416
+ ```
417
+
418
+ | Option | Default | Description |
419
+ | ------------- | ------- | -------------------------------------------------------------------------------------------------- |
420
+ | `maxAge` | — | Lifetime in seconds (integer); `0` or less expires the cookie now. Default: a session cookie |
421
+ | `expires` | — | Absolute expiry (`Date` in the years 1601 to 9999, sent as an IMF-fixdate) |
422
+ | `domain` | — | Domain (sub-domains included). Default: a host-only cookie; `null`: host-only despite the defaults |
423
+ | `path` | `'/'` | Path |
424
+ | `secure` | `true` | Sent over HTTPS only (and `http://localhost`) |
425
+ | `httpOnly` | `true` | Not readable from JavaScript |
426
+ | `sameSite` | `'lax'` | `'strict'`, `'lax'` or `'none'` (requires `secure`) |
427
+ | `partitioned` | `false` | CHIPS partitioned cookie (requires `secure`) |
428
+ | `priority` | — | Chromium `Priority`: `'low'`, `'medium'`, `'high'` |
429
+ | `signed` | `false` | Signs the value with the first secret of `cookies.secrets` (see [Signed cookies](#signed-cookies)) |
430
+
431
+ The defaults are `Path=/; Secure; HttpOnly; SameSite=Lax`, host-only; change them for the whole
432
+ application with the plugin option `cookies.defaults`, and per cookie with the options above. With
433
+ a default `domain` (cookies shared across subdomains), `domain: null` makes one cookie host-only,
434
+ and `__Host-` cookies never take the default `domain` or `path` (the prefix forbids them): an
435
+ explicit `domain` or a `path` other than `/` on such a cookie still throws.
436
+ `Secure` is right behind TLS-terminating proxies: it governs the browser to edge connection, not
437
+ the proxy to app hop. Plain `http://` development setups on a non-localhost host (and old Safari
438
+ versions on `http://localhost`) need `defaults: { secure: config.env !== 'development' }`.
439
+
440
+ **Strict serialisation (RFC 6265bis).** A cookie browsers would drop silently throws where it is
441
+ set instead:
442
+
443
+ - the name must be an RFC 9110 token, the value a well-formed string; the value is percent-encoded
444
+ outside `cookie-octet` (and `%`), so it always round-trips (`a b;c` → `a%20b%3Bc`);
445
+ - `name=value` is at most 4096 bytes on the wire (after encoding and signing), `Domain` and `Path`
446
+ at most 1024 bytes each;
447
+ - `maxAge` is an integer, `expires` a valid `Date` in the years 1601 to 9999 (browsers ignore
448
+ other years: `new Date(8.64e15)` would silently make a session cookie; use `maxAge`), `domain` an
449
+ ASCII host name (IDNs in punycode; lower-cased, a leading `.` removed), `path` starts with `/` and
450
+ has no `;` or controls;
451
+ - `SameSite=None` and `partitioned` require `secure`;
452
+ - prefixes (case-insensitive, like browsers): `__Secure-` requires `secure`; `__Host-` requires
453
+ `secure`, `path: '/'` and no `domain`; `__Http-` and `__Host-Http-` also require `httpOnly`.
454
+
455
+ Browsers cap cookie lifetimes at 400 days; nothing is clamped server-side.
456
+
457
+ `ctx.setCookie()` / `ctx.clearCookie()` validate everything at call time (`TypeError` or
458
+ `RangeError`), so the stack trace points to the faulty line. `HttpResponse.withCookie()` is called
459
+ before any request exists: it checks the name, the value, `maxAge` / `expires`, the domain, the
460
+ path and the size before signing at call time; what depends on the plugin (the `secure` default
461
+ with a prefix or `SameSite=None`, signing, the size after signing) is checked when the response is
462
+ sent, and a violation answers a generic 500 logged as `Invalid cookie "x": …`.
463
+
464
+ **Which cookies are sent.** A response carries a _jar_ keyed by name, domain and path: a later
465
+ cookie with the same key replaces the earlier one in place (set then clear gives one clearing
466
+ cookie).
467
+
468
+ - The cookies of `ctx.setCookie()` / `ctx.clearCookie()` are sent with **every** response of the
469
+ request: success and errors (mapped or not), redirects, automatic `OPTIONS`. A guard can
470
+ `ctx.clearCookie('sid')` and then throw a 401.
471
+ - The cookies of the `HttpResponse` (handler result or exception mapper result) replace those of
472
+ the context with the same key (like headers: `ctx.setHeader()` < `HttpResponse` headers).
473
+ - The `set-cookie` field holds the raw values first (`@Header`, `ctx.setHeader()` /
474
+ `appendHeader()`, `HttpResponse` headers, a Web `Response`'s own cookies), then the jar, in
475
+ insertion order: one field line per cookie on every adapter, one array entry in `inject()`.
476
+ - Cookies the native framework set before the Znight route (Express `res.cookie()` in a
477
+ middleware, Fastify `reply.header('set-cookie', ...)` in a hook, Hono `setCookie(c, ...)` in a
478
+ middleware) are kept, before the plugin's, on every reply (event streams included).
479
+ - A `@Res()` handler writes its own response: the jar is not applied.
480
+ - Once the response headers are sent (a running event stream), `setCookie()` / `clearCookie()`
481
+ throw `Cannot set cookie "x": the response headers were already sent`.
482
+
483
+ `serializeCookie(name, value, options?)`, `serializeClearCookie(name, options?)`,
484
+ `parseCookieHeader(header)` and `parseSetCookie(line)` are exported for gateways and tests
485
+ (`serializeCookie` applies the secure defaults; signing needs the plugin: `plugin.signCookie()`).
486
+
487
+ ### Signed cookies
488
+
489
+ ```ts
490
+ HttpPlugin.configure(c => {
491
+ const config = c.get(AppConfig) // @Secret('COOKIE_SECRETS') → ['current…', 'previous…']
492
+ return { cookies: { secrets: config.cookieSecrets } } // the first signs, all verify
493
+ })
494
+
495
+ @Post('/login')
496
+ async login(@Body(Credentials) input: Credentials) {
497
+ const user = await this.users.authenticate(input) // throws UnauthorizedException
498
+ return HttpResponse.ok({ id: user.id }).withCookie('__Host-uid', user.id, { signed: true, maxAge: 8 * 3600 })
499
+ }
500
+
501
+ @Get('/me')
502
+ me(@SignedCookie('__Host-uid') uid: string) {
503
+ // absent OR tampered OR signed with a retired key → 400 `Missing cookie "__Host-uid"`
504
+ return this.users.find(uid)
505
+ }
506
+
507
+ // a guard reads the same thing imperatively
508
+ canActivate(ctx: HttpContext): boolean {
509
+ const uid = ctx.signedCookie('__Host-uid')
510
+ if (uid === undefined) throw new UnauthorizedException()
511
+ ctx.state.set('uid', uid)
512
+ return true
513
+ }
514
+ ```
515
+
516
+ - **Secrets**: `cookies.secrets` is one secret or a list (`string` or `Uint8Array`), each at least
517
+ 32 bytes: generate them with `crypto.randomBytes(32).toString('base64url')` and load them with
518
+ `@Secret` from `@znight/config`. Short secrets, duplicates and an empty list fail the boot
519
+ (ZNIGHT005, the secrets are never echoed).
520
+ - **Format**: `<encoded value>.<base64url(HMAC-SHA256(secret, "<name>=<encoded value>"))>`. The MAC
521
+ covers the name: a value of `a` replayed as `b` fails, `sid` and `__Host-sid` have different
522
+ signatures. Verification is constant-time (`timingSafeEqual`); an invalid signature makes the
523
+ cookie absent (no 400 "bad signature", no oracle). Not compatible with Express `cookie-parser`.
524
+ - **Rotation**: deploy `secrets: [next, current]` (new cookies use `next`, old ones still verify),
525
+ wait for the longest cookie lifetime, then deploy `[next]`. Nothing is re-signed automatically;
526
+ calling `setCookie()` again migrates a cookie.
527
+ - **Missing secrets**: a route reading `@SignedCookie` fails the boot with ZNIGHT006
528
+ (`AuthController.me reads a signed cookie (@SignedCookie('__Host-uid')) but cookies.secrets is
529
+ not set`); `ctx.signedCookie()` and `{ signed: true }` without secrets throw (a logged 500).
530
+ - **Limits**: signing gives integrity, not confidentiality (the client can read the value); the MAC
531
+ does not cover an expiry, so a stolen signed cookie stays valid until rotation: put an expiry in
532
+ the value when it matters. Sessions, encrypted cookies and CSRF middleware are out of scope.
533
+
534
+ `plugin.signCookie(name, value)` returns the signed wire value (gateways minting cookies, tests).
535
+
536
+ ### Security notes
537
+
538
+ - Use `__Host-` names for authentication cookies: they cannot be shadowed by a cookie set from a
539
+ sibling subdomain ("cookie tossing").
540
+ - `SameSite=Lax` blocks cookies on cross-site `POST`s in modern browsers but is no CSRF defense on
541
+ its own (same-site subdomains, `GET` side effects).
542
+ - Znight's request logs do not include headers. If you log headers yourself, redact `cookie` and
543
+ `set-cookie` (pino: `redact: ['req.headers.cookie', 'res.headers["set-cookie"]']`).
544
+
545
+ ## Files
546
+
547
+ Uploads, downloads and streamed responses work the same on every adapter, on Node.js and Bun,
548
+ without dependency: files are standard [`File`](https://developer.mozilla.org/docs/Web/API/File)
549
+ objects, streams are Web `ReadableStream`s.
550
+
551
+ - [Uploads](#uploads): `@UploadedFile` / `@UploadedFiles`, text fields with `@Body()`
552
+ - [Limits](#limits): sizes, counts, media types, body limits
553
+ - [Downloads](#downloads): `HttpResponse.file()`, conditional requests and ranges
554
+ - [Streaming responses](#streaming-responses): `HttpResponse.stream()`
555
+ - [Large files](#large-files): `ctx.bodyStream()`, presigned URLs
556
+ - [Security](#security)
557
+
558
+ ### Uploads
559
+
560
+ ```ts
561
+ import { Body, BodyLimit, Controller, Post, UploadedFile, UploadedFiles } from '@znight/http'
562
+ import { z } from 'zod'
563
+
564
+ export const AvatarForm = z.object({ title: z.string().min(1), public: z.stringbool().default(false) })
565
+
566
+ @Controller('/profile')
567
+ export class ProfileController {
568
+ constructor(private readonly storage: Storage) {}
569
+
570
+ @Post('/avatar')
571
+ async avatar(
572
+ @UploadedFile('avatar', { maxSize: '2mb', mimeTypes: ['image/png', 'image/jpeg'] }) avatar: File,
573
+ @Body(AvatarForm) form: z.output<typeof AvatarForm>, // the text fields of the form
574
+ ) {
575
+ const key = await this.storage.save(crypto.randomUUID(), avatar.stream(), avatar.type)
576
+ return { key, size: avatar.size, title: form.title }
577
+ }
578
+
579
+ @Post('/documents')
580
+ add(
581
+ @UploadedFiles('documents', { maxFiles: 5, maxSize: '20mb', mimeTypes: ['application/pdf'] }) documents: File[],
582
+ @UploadedFile('cover') cover?: File, // optional: undefined when absent
583
+ ) {}
584
+ }
585
+ ```
586
+
587
+ ```sh
588
+ curl -F title=Me -F avatar=@me.png localhost:3000/profile/avatar
589
+ ```
590
+
591
+ | Decorator | Injects |
592
+ | -------------------------------- | ----------------------------------------------------------------------------------- |
593
+ | `@UploadedFile('avatar', opts?)` | the file of the field (`File`, or `File \| undefined` when optional) |
594
+ | `@UploadedFile(opts?)` | the single file of the body, whatever its field |
595
+ | `@UploadedFiles('docs', opts?)` | the files of the field, in body order (`File[]`) |
596
+ | `@UploadedFiles(opts?)` | every file of the body |
597
+ | `@Body(schema?)` | the **text fields**: `Record<string, string \| string[]>` (repeated names → arrays) |
598
+
599
+ The injected values are standard `File` objects, held in memory: `name`, `type`, `size`,
600
+ `lastModified`, `arrayBuffer()`, `bytes()`, `text()`, `stream()`, `slice()`. `file.type` is the
601
+ media type declared by the client, lower-cased (`application/octet-stream` when absent). Text fields
602
+ are strings: validate them with a schema that converts them (`z.coerce.number()`,
603
+ `z.stringbool()`); a repeated name gives an array and a single one a string
604
+ (`z.union([z.string(), z.array(z.string())])` accepts both).
605
+
606
+ Options (statically evaluated, like every decorator argument):
607
+
608
+ | Option | Default | Description |
609
+ | ----------- | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
610
+ | `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 `[]` |
611
+ | `maxSize` | plugin `uploads.maxFileSize` (10 MiB) | Maximum size of **each** file: bytes or `'512kb'`, `'5mb'` (`kb` = 1024), `Infinity` |
612
+ | `maxFiles` | plugin `uploads.maxFiles` (10) | `@UploadedFiles` only: maximum number of files |
613
+ | `mimeTypes` | any | Accepted types: `'image/png'`, `'image/*'`, `'*/*'` (parameters are ignored) |
614
+
615
+ Routes with upload parameters parse `multipart/form-data` bodies **before the interceptors**, as the
616
+ body arrives, and enforce the options while reading: an invalid upload is rejected as soon as the
617
+ offending part shows up, before the rest of the body is read. Files are only accepted in the
618
+ declared fields. Errors:
619
+
620
+ | Status | When | Message |
621
+ | ------ | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
622
+ | `400` | a required file is missing | `Missing file "avatar"` / `Missing files "documents"` |
623
+ | `400` | a text value was sent in a file field | `Expected a file in field "avatar", received a text value` |
624
+ | `400` | a second file for `@UploadedFile` | `Expected a single file in field "avatar"` |
625
+ | `400` | a file in an undeclared field | `Unexpected file field "other"` (the issue lists the expected fields) |
626
+ | `400` | a malformed body (boundary, part headers, truncated body) | `Invalid multipart body: ...` |
627
+ | `413` | a file above `maxSize` | `File in field "avatar" exceeds the maximum size of 2 MiB` |
628
+ | `413` | more than `maxFiles` files | `Too many files in field "documents" (at most 5)` |
629
+ | `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)`... |
630
+ | `415` | a refused media type | `Unsupported file type "text/plain" in field "avatar" (accepted: image/png, image/jpeg)` |
631
+ | `415` | a non-empty body that is not `multipart/form-data` | `Expected a multipart/form-data body (received "application/json")` |
632
+
633
+ The `details` of a `400` list the issue like a validation error does
634
+ (`[{ path: 'avatar', message: 'Missing file' }]`, the `ValidationErrorBody` of `@znight/openapi`);
635
+ those of a `413` or `415` carry the field and the limit (`{ field: 'avatar', maxSize: 2097152 }`). Errors raised
636
+ while the body is still being sent carry `connection: close`: the rest of the body is discarded and
637
+ the connection closed after the response.
638
+
639
+ On routes **without** upload parameters, a multipart body read with `@Body()` or
640
+ `ctx.parseBody()` also gives the text fields; its files are available in `ctx.files` (by field,
641
+ within the plugin `uploads` limits and the body limit), and files of any field are accepted.
642
+
643
+ > **Why not `Request.formData()`?** The Web-standard `formData()` buffers the whole body before
644
+ > parsing it, has no per-file limits and its results differ between Node.js and Bun (default part
645
+ > types, `filename*`, malformed bodies). Znight uses its own streaming RFC 7578 parser, shared by every
646
+ > adapter: same results everywhere, early rejection, explicit limits.
647
+
648
+ ### Limits
649
+
650
+ | Limit | Where | Default |
651
+ | -------------------------------- | ---------------------------------------------- | -------------------------- |
652
+ | size of each file | `maxSize` option, plugin `uploads.maxFileSize` | 10 MiB |
653
+ | number of files of a parameter | `maxFiles` option, plugin `uploads.maxFiles` | 10 (1 for `@UploadedFile`) |
654
+ | number of parts (fields + files) | plugin `uploads.maxParts` | 1000 |
655
+ | size of a text field | plugin `uploads.maxFieldSize` | 1 MiB |
656
+ | size of the part headers | built in | 16 KiB |
657
+ | size of the whole body | `@BodyLimit(size)`, plugin `bodyLimit` | see below |
658
+
659
+ ```ts
660
+ HttpPlugin.configure({
661
+ bodyLimit: '1mb', // JSON, text, forms...
662
+ uploads: { maxFileSize: '25mb', maxFiles: 20, maxParts: 200, maxFieldSize: '64kb' },
663
+ })
664
+ ```
665
+
666
+ The **body limit** of a route is `@BodyLimit(size)` (on the route, or on the controller for all its
667
+ routes) when present. Otherwise it is the plugin `bodyLimit` for ordinary routes and, for upload
668
+ routes, the sum of what their upload parameters accept (`maxSize × maxFiles` each) plus `bodyLimit`
669
+ (room for the text fields and the multipart framing): an avatar route with `maxSize: '2mb'` accepts
670
+ bodies up to 3 MiB by default. Set `@BodyLimit` to cap a route accepting many large files, or
671
+ `@BodyLimit(Infinity)` to disable the limit of a route that streams its body itself.
672
+
673
+ Uploaded files are **held in memory** within these limits: keep them proportionate to your memory
674
+ and expected concurrency, and see [Large files](#large-files) beyond a few dozen megabytes.
675
+ `parseByteSize('10mb')` (→ `10485760`) and `formatByteSize(n)` convert sizes like the options do.
676
+
677
+ ### Downloads
678
+
679
+ `HttpResponse.file(path | URL | Blob, options?)` sends a file:
680
+
681
+ ```ts
682
+ import { Controller, Get, HttpResponse, Param } from '@znight/http'
683
+
684
+ const REPORTS = '/srv/app/reports'
685
+
686
+ @Controller('/reports')
687
+ export class ReportController {
688
+ @Get('/*')
689
+ download(@Param('*') name: string) {
690
+ // `root` confines the path: '../../etc/passwd' or a symbolic link leaving the directory answer 404
691
+ return HttpResponse.file(name, { root: REPORTS })
692
+ }
693
+
694
+ @Get('/:id/export')
695
+ async export(@Param('id') id: string) {
696
+ const report = await this.reports.find(id)
697
+ return HttpResponse.file(report.path, { filename: `Rapport été ${report.year}.pdf` }) // attachment
698
+ }
699
+
700
+ @Get('/:id/preview')
701
+ preview(@Param('id') id: string) {
702
+ return HttpResponse.file(this.reports.pathOf(id), { disposition: 'inline', contentType: 'application/pdf' })
703
+ }
704
+
705
+ @Get('/generated.json')
706
+ generated() {
707
+ return HttpResponse.file(new File([JSON.stringify(data)], 'data.json', { type: 'application/json' }))
708
+ }
709
+ }
710
+ ```
711
+
712
+ | Option | Default | Description |
713
+ | ------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
714
+ | `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` |
715
+ | `dotfiles` | `'deny'` | With `root`: `'allow'` to send names starting with a dot (`.env`, `.git/config` answer `404` otherwise) |
716
+ | `filename` | none | Name proposed to the client; makes the response an `attachment` |
717
+ | `disposition` | `attachment` with `filename`, else no header | `'attachment'` (download) or `'inline'` (display); without `filename` the file name is used |
718
+ | `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`) |
719
+
720
+ The response carries `Content-Type`, `Content-Length`, `Accept-Ranges: bytes`, `Last-Modified` and a
721
+ strong `ETag` (`"<mtime>-<size>"`, from the modification time and the size; `File` objects use
722
+ `lastModified`), and answers:
723
+
724
+ | Request | Response |
725
+ | --------------------------------------------------------------------------- | ---------------------------------------------------------------- |
726
+ | `GET` / `HEAD` | `200` (`HEAD`: headers only) |
727
+ | `If-None-Match` matching (weak comparison) or `If-Modified-Since` not older | `304 Not Modified`, no body |
728
+ | `If-Match` not matching, or `If-Unmodified-Since` older | `412 Precondition Failed` |
729
+ | `Range: bytes=0-499`, `bytes=500-`, `bytes=-500` (single range, `GET`) | `206 Partial Content` with `Content-Range` |
730
+ | a range starting after the end | `416 Range Not Satisfiable` with `Content-Range: bytes */<size>` |
731
+ | several ranges, another unit, a stale `If-Range` | `200` with the whole file (as RFC 9110 allows) |
732
+ | a missing file, a directory, a path leaving `root`, a dotfile | `404` `File not found` (the path is never echoed) |
733
+
734
+ `Content-Disposition` follows RFC 6266: an ASCII fallback in `filename` and the UTF-8 name in
735
+ `filename*` (RFC 8187 / 5987), directory parts and control characters removed:
736
+ `attachment; filename="Rapport _t_ 2024.pdf"; filename*=UTF-8''Rapport%20%C3%A9t%C3%A9%202024.pdf`.
737
+ `contentDisposition(filename, type?)` builds this header for your own responses,
738
+ `contentTypeFor(filename)` looks a type up in the built-in table, `parseRange(header, size)` parses a
739
+ `Range` header and `matchesMediaType(type, patterns)` applies `mimeTypes`-like filters.
740
+
741
+ Files on disk are sent as file-backed blobs (`fs.openAsBlob()`): read lazily as the client receives
742
+ them, no descriptor kept open, `sendfile` with the Hono adapter on Bun. The request stays in flight
743
+ and its scope open until the file has been sent (or the client went away), so a request-scoped
744
+ provider may own the file (a temporary export deleted in `[Symbol.dispose]()`); on `Bun.serve()`
745
+ the file is handed to Bun, which opens it at once, and the scope closes then. Headers set with `.withHeader()`
746
+ (`cache-control`...), `@Header` and `ctx.setHeader()` are added; a `200` follows the usual status
747
+ precedence, `206` / `304` keep theirs.
748
+
749
+ Returning a `File` / `Blob` directly sends it with its type and size (no `Content-Disposition`,
750
+ ranges or validators: use `HttpResponse.file(blob)` for those).
751
+
752
+ ### Streaming responses
753
+
754
+ `HttpResponse.stream(source, { contentType?, filename?, disposition? })` streams a Web
755
+ `ReadableStream`, a Node.js `Readable` or any async iterable (async generator) of `Uint8Array` /
756
+ `string` chunks (strings are encoded as UTF-8):
757
+
758
+ ```ts
759
+ @Get('/export.csv')
760
+ export() {
761
+ const users = this.users
762
+ async function* lines() {
763
+ yield 'id,email\n'
764
+ for await (const user of users.iterate()) yield `${user.id},${user.email}\n`
765
+ }
766
+ return HttpResponse.stream(lines(), { filename: 'users.csv' }) // text/csv; charset=utf-8, attachment
767
+ }
768
+
769
+ @Get('/logs')
770
+ logs() {
771
+ return HttpResponse.stream(createReadStream('/var/log/app.log'), { contentType: 'text/plain; charset=utf-8' })
772
+ }
773
+ ```
774
+
775
+ - **Backpressure**: the source is read only as fast as the client receives the data (on every
776
+ adapter); a slow client never makes the server buffer the whole stream.
777
+ - **Cancellation**: when the client disconnects, the source is cancelled (`return()` of the
778
+ iterator — `finally` blocks run —, `destroy()` of a `Readable`, `cancel()` of a Web stream).
779
+ - **Request scope**: request-scoped instances live until the stream has been fully sent or
780
+ cancelled, and the source runs in the request scope (`Lazy<HttpContext>`, request-scoped
781
+ providers and tracing contexts keep working while it produces chunks).
782
+ - **Errors**: a source failing after the headers were sent cannot change the status: the connection
783
+ is closed (the client sees a truncated response) and the error is logged (`The response stream
784
+ failed`).
785
+ - **Shutdown**: streamed responses are in-flight requests: the graceful drain waits for them up to
786
+ `drainTimeoutMs`, then closes their connections (cancelling their sources).
787
+
788
+ Streams have no `Content-Length` (chunked transfer encoding). Returning a `ReadableStream` or a
789
+ `Readable` directly also works (`application/octet-stream`); string, `ArrayBuffer` and typed array
790
+ chunks of a returned `ReadableStream` are converted to bytes.
791
+
792
+ ### Large files
793
+
794
+ Uploaded files are buffered in memory. For files of hundreds of megabytes or more:
795
+
796
+ 1. **Upload directly to object storage (recommended).** The API authorises the upload and returns a
797
+ presigned URL (S3, GCS, Azure Blob, R2...); the client sends the file to the storage, then
798
+ notifies the API. Your servers never carry the bytes; storage handles resumable and multipart
799
+ uploads.
800
+
801
+ ```ts
802
+ @Post('/uploads')
803
+ async createUpload(@Body(NewUpload) input: NewUpload) {
804
+ const key = `uploads/${crypto.randomUUID()}`
805
+ const url = await getSignedUrl(s3, new PutObjectCommand({ Bucket, Key: key, ContentType: input.type }), {
806
+ expiresIn: 300,
807
+ })
808
+ return { key, url } // the client PUTs the file to `url`, then calls POST /uploads/:key/complete
809
+ }
810
+ ```
811
+
812
+ 2. **Stream the raw body.** Send the file as the request body (`PUT /files/:name`, `curl -T`) and
813
+ read it with `ctx.bodyStream()`: nothing is buffered, backpressure applies (the request is paused
814
+ while you write), and the body limit of the route still applies (`@BodyLimit`):
815
+
816
+ ```ts
817
+ import { createWriteStream } from 'node:fs'
818
+ import { join } from 'node:path'
819
+ import { Readable } from 'node:stream'
820
+ import { pipeline } from 'node:stream/promises'
821
+
822
+ @Put('/files/:id')
823
+ @BodyLimit('5gb')
824
+ async store(@Param('id', FileId) id: string, @Ctx() ctx: HttpContext) {
825
+ // never build the path from the client's file name
826
+ await pipeline(Readable.fromWeb(ctx.bodyStream()), createWriteStream(join(UPLOADS_DIR, id)))
827
+ return HttpResponse.created(undefined, `/files/${id}`)
828
+ }
829
+ ```
830
+
831
+ `ctx.bodyStream()` reads the body once and is exclusive with `@Body()`, upload parameters,
832
+ `ctx.parseBody()` and `ctx.rawBody()`. A body stream neither read to its end nor cancelled when
833
+ the request is over (a handler that checked the first bytes, then threw) is cancelled: the rest
834
+ 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
835
+ it with a streaming parser of your choice (e.g. `busboy`) if you must accept huge browser form
836
+ uploads.
837
+
838
+ 3. **Serve large files with `HttpResponse.file()`**: they are streamed from disk, with ranges for
839
+ resumable downloads and video seeking.
840
+
841
+ ### Security
842
+
843
+ - **Never trust the client's file name.** `File.name` is chosen by the client (it may contain
844
+ `../`, absolute paths, control characters or names of existing files). Do not use it as a path:
845
+ store files under generated identifiers and keep the original name as metadata. When you must
846
+ display or reuse it, `path.basename()` it and limit its characters.
847
+ - **Never trust the client's media type.** `File.type` and `mimeTypes` filters rely on what the client
848
+ declares: they reject honest mistakes, not attackers. When the type matters, check the content
849
+ (magic numbers, e.g. with [`file-type`](https://github.com/sindresorhus/file-type)) and decode
850
+ images with a library that re-encodes them.
851
+ - **Confine downloads.** Always pass `root` to `HttpResponse.file()` when any part of the path comes
852
+ from the request: `..` segments, absolute paths, encoded separators (`..%2F`), symbolic links
853
+ leaving the directory and dotfiles (`.env`, `.git/`) answer `404`. Without `root`, the path is used
854
+ as given.
855
+ - **Store uploads outside of the web root**, never in a directory served statically, and do not serve
856
+ user content inline from your application's origin: send it as an `attachment`, with
857
+ `X-Content-Type-Options: nosniff`, or from a separate domain (an uploaded HTML or SVG file served
858
+ inline runs scripts in your origin).
859
+ - **Keep limits tight.** Limits protect memory and CPU: size each route for what it accepts
860
+ (`maxSize`, `maxFiles`, `@BodyLimit`) and lower the plugin defaults if your routes accept less.
861
+ Rate-limit upload routes and consider quotas and malware scanning for untrusted files.
862
+
863
+ ## Server-Sent Events
864
+
865
+ `@Sse(path?, options?)` declares a `GET` route serving an event stream; its handler returns an
866
+ `AsyncIterable`, an `Iterable` or a `ReadableStream` of `SseEvent` (`{ data, event, id, retry,
867
+ comment }`). `HttpResponse.sse(source, options?)` does the same on any route and any method (`POST`
868
+ streaming for `fetch()` clients). No dependency, identical on every adapter, Node.js and Bun.
869
+ Tests read event streams in memory with `injectSse()`: see
870
+ [Testing event streams](#testing-event-streams).
871
+
872
+ ```ts
873
+ import { setTimeout as sleep } from 'node:timers/promises'
874
+ import {
875
+ Body,
876
+ Controller,
877
+ Ctx,
878
+ Headers,
879
+ type HttpContext,
880
+ HttpResponse,
881
+ NotFoundException,
882
+ Param,
883
+ Post,
884
+ Sse,
885
+ type SseEvent,
886
+ } from '@znight/http'
887
+
888
+ @Controller('/orders')
889
+ export class OrderEventsController {
890
+ constructor(
891
+ private readonly orders: OrderService,
892
+ private readonly hub: OrderHub,
893
+ private readonly llm: Llm,
894
+ ) {}
895
+
896
+ /** 1. Recommended shape: an async method does the pre-flight checks, then returns the source. */
897
+ @Sse('/:id/events', { retry: 5_000, maxDuration: 30 * 60_000 })
898
+ async events(
899
+ @Param('id') id: string,
900
+ @Headers('last-event-id') lastEventId: string | undefined, // sent by EventSource on reconnection
901
+ @Ctx() ctx: HttpContext,
902
+ ): Promise<AsyncIterable<SseEvent<OrderEvent>> | HttpResponse> {
903
+ const order = await this.orders.find(id)
904
+ if (order === undefined) throw new NotFoundException(`Order ${id} not found`) // a regular 404 (EventSource stops)
905
+ if (order.closed) return HttpResponse.noContent() // 204: EventSource stops reconnecting
906
+ return this.hub.subscribe(id, { after: lastEventId, signal: ctx.signal })
907
+ }
908
+
909
+ /** 2. An async generator: its body runs once the stream is open (it cannot answer 404 anymore). */
910
+ @Sse('/clock', { heartbeat: false })
911
+ async *clock(@Ctx() ctx: HttpContext): AsyncGenerator<SseEvent<{ now: string }>> {
912
+ while (!ctx.signal.aborted) {
913
+ yield { event: 'tick', data: { now: new Date().toISOString() } }
914
+ await sleep(1_000, undefined, { signal: ctx.signal }).catch(() => undefined)
915
+ }
916
+ }
917
+
918
+ /** 3. Any method: LLM-style POST streaming for fetch() clients (EventSource only does GET). */
919
+ @Post('/:id/assistant')
920
+ assistant(@Param('id') id: string, @Body(Prompt) prompt: Prompt, @Ctx() ctx: HttpContext) {
921
+ return HttpResponse.sse(this.llm.complete(id, prompt, { signal: ctx.signal })) // 200, not 201
922
+ }
923
+ }
924
+ ```
925
+
926
+ **`async` versus `async *`.** The body of an async generator only runs once the 200 and the headers
927
+ are sent: pre-flight checks (existence, authorization beyond guards) belong in an `async` method
928
+ that _returns_ the source, where it can still throw a 404 or return a 204.
929
+
930
+ | Handler result (after awaiting) | Response |
931
+ | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
932
+ | `AsyncIterable`, `Iterable` (not a string), `ReadableStream` | 200 event stream |
933
+ | `HttpResponse.sse(...)` | 200 event stream, plus its headers and cookies (its options win over the route's) |
934
+ | Any other `HttpResponse`, a Web `Response`, a thrown exception | Sent as usual: 204 tells `EventSource` to stop, 401 / 404 fail it without reconnection |
935
+ | `undefined`, a string, any other value | 500, logged: `@Sse handler C.m must return an AsyncIterable, an Iterable or a ReadableStream...` |
936
+
937
+ | Option | Default | Description |
938
+ | ------------- | ------------------------------ | -------------------------------------------------------------------------------------------------- |
939
+ | `heartbeat` | plugin `sse.heartbeat` (8 000) | Ms without an event after which a keep-alive comment (`:`) is sent (≥ 1 000), `false` to disable |
940
+ | `retry` | plugin `sse.retry` (none) | Client reconnection delay sent first (`retry:`, ms) |
941
+ | `maxDuration` | none | The server ends the stream gracefully after this many ms (≥ 1 000): rebalancing, re-authentication |
942
+
943
+ `heartbeat` and `maxDuration` (like the plugin's `sse.heartbeat` and `sse.shutdownGraceMs`) are at
944
+ most 2 147 483 647 ms (about 24.8 days), the longest timer: a larger delay would fire after 1 ms on
945
+ Node.js and Bun (every stream expiring at once), so it is ZNIGHT005 at build time and at boot.
946
+
947
+ ### Events and framing
948
+
949
+ ```ts
950
+ interface SseEvent<T = unknown> {
951
+ readonly data?: T // a string is sent as is, anything else through JSON.stringify (toJSON honoured)
952
+ readonly event?: string // event type (addEventListener(type)); the client defaults to `message`. No CR or LF
953
+ readonly id?: string | number // echoed by the client as Last-Event-ID. No CR, LF or NUL; '' resets it
954
+ readonly retry?: number // reconnection delay (ms, integer ≥ 0)
955
+ readonly comment?: string // comment lines, ignored by EventSource
956
+ }
957
+ ```
958
+
959
+ Each event is encoded as one chunk: one `: <line>` per comment line, `id:`, `event:`, `retry:`, then
960
+ one `data: <line>` per line of the data, and an empty line. Data is split on CRLF, CR and LF, so
961
+ user content can never inject fields or end an event early; `data: ` always carries its space (a
962
+ leading space survives). An event with `event` but no `data` gets an empty `data:` line (otherwise
963
+ `EventSource` would not dispatch it: `{ event: 'done' }` would vanish); events with only `id`,
964
+ `retry` or `comment` have no data line. Anything else than a plain object with these keys (strings,
965
+ numbers, `null`, unknown keys, an empty object, `event` / `id` with line breaks, data JSON cannot
966
+ serialise) is a `TypeError`: yield `{ data: value }`. A `Uint8Array` item (a `Buffer` of a Node.js
967
+ `Readable`) is written **as is**: already-framed SSE text from an upstream (proxying an LLM
968
+ provider), not validated. An event object yielded after raw bytes that do not end at an event
969
+ boundary (an empty line) would merge into the unterminated raw event: it fails the stream like an
970
+ invalid item. `encodeSseEvent(event)` is exported.
971
+
972
+ ```text
973
+ HTTP/1.1 200 OK
974
+ content-type: text/event-stream; charset=utf-8
975
+ cache-control: no-cache, no-transform
976
+ x-accel-buffering: no
977
+
978
+ retry: 5000
979
+
980
+ id: 41
981
+ event: status
982
+ data: {"orderId":"o-1","status":"paid"}
983
+
984
+ :
985
+
986
+ data: line 1
987
+ data: line 2
988
+
989
+ retry: 7342
990
+
991
+ ```
992
+
993
+ (The first chunk, one event, one heartbeat, a multi-line event and the jittered `retry` of a
994
+ graceful close.)
995
+
996
+ **Headers.** `cache-control: no-cache, no-transform` (no caching, no transformation by proxies or
997
+ compression middleware) and `x-accel-buffering: no` (nginx) are set first, so `@Header`,
998
+ `ctx.setHeader()` and the `HttpResponse` headers can override them; `content-type:
999
+ text/event-stream; charset=utf-8` is set last. Never `content-length`, `transfer-encoding` or
1000
+ `connection: keep-alive`. **Status:** 200; `@HttpCode` on `@Sse` is a build error;
1001
+ `HttpResponse.sse()` defaults to 200 even on `POST` routes, and honours `@HttpCode` / `.withStatus()`
1002
+ on other routes (`EventSource` requires 200; `fetch()` clients read any status).
1003
+
1004
+ ### Lifecycle
1005
+
1006
+ - **Open**: the first chunk (`retry: N`, or the comment `:`) is written at once, without touching
1007
+ the source, so every adapter flushes the status and headers (`EventSource.onopen` fires before
1008
+ the first event).
1009
+ - **Backpressure**: the source is read once per chunk the client pulls, nothing is read ahead: a
1010
+ slow client stops the source, on every adapter. Push-based producers buffer in their own
1011
+ structure: use the bounded [`EventChannel`](#eventchannel).
1012
+ - **Heartbeats**: while the client waits `heartbeat` ms without an event, the comment `:` is sent
1013
+ (the pending read of the source is kept). They never pile up for a slow client, are never
1014
+ inserted inside raw bytes that do not end at an event boundary, and 8 s is below every common
1015
+ idle timeout (`Bun.serve()` 10 s, nginx / ALB 60 s, Heroku 55 s, Cloudflare 100 s).
1016
+ - **The source ends**: the response ends; `EventSource` then reconnects (that is the protocol). To
1017
+ stop a client for good, send an event it understands (`{ event: 'done' }`, then `es.close()`) or
1018
+ answer 204 on the next connection.
1019
+ - **The source fails, or yields an invalid item**: the error is logged (`The SSE stream failed`,
1020
+ with `requestId` and `path`), an invalid item stops the source, and the response ends cleanly
1021
+ (no extra event: an automatic `event: error` would collide with `EventSource`'s own `error`).
1022
+ Unlike `HttpResponse.stream()`, which closes the connection abruptly: SSE events are
1023
+ self-delimited and the client reconnects either way.
1024
+ - **Client disconnect**: `ctx.signal` aborts (`RequestAbortedError`, `kind: 'client'`), the source
1025
+ is stopped (`return()` / `cancel()`), and the request scope closes once it stopped, or after 5 s
1026
+ with a warning (`The SSE source did not stop within 5000 ms after its signal was aborted: make it
1027
+ honour ctx.signal`): an async generator blocked in an `await` that ignores the signal cannot be
1028
+ stopped otherwise. A client leaving before the handler returned (an async pre-flight) releases
1029
+ the reply the same way, on every adapter and runtime.
1030
+ - **`maxDuration` and shutdown** (graceful close): `ctx.signal` aborts (`'expired'` /
1031
+ `'shutdown'`), the source keeps being read for `sse.shutdownGraceMs` (default 1 000 ms) so that it
1032
+ can send final events and finish, then `retry: R` is sent with R drawn in `[r, 2r]` (r = the
1033
+ `retry` option, else 3 000 ms; the jitter spreads the reconnections of a rolling deployment), the
1034
+ source is stopped and the response ends. When the raw bytes written last do not end at an event
1035
+ boundary (a proxied upstream cut mid-event), the `retry` is not sent: the client discards the
1036
+ unterminated event at the end of the stream instead of receiving a corrupted one.
1037
+ - **HEAD** answers the headers only: the stream is cancelled before its first read (an async
1038
+ generator body never runs) and `ctx.signal` aborts (`'client'`), which releases a subscription
1039
+ made in a pre-flight.
1040
+ - **Request scope**: request-scoped instances and the in-flight count live until the stream ends;
1041
+ the source runs in the handler's async context (`Lazy<HttpContext>`, request-scoped providers,
1042
+ tracing). One scope is held per connected client: keep request-scoped state small on SSE routes.
1043
+ - **Guards, interceptors, mappers**: guards run before the stream (authentication happens when it
1044
+ connects: `maxDuration` forces periodic re-authentication); interceptors see the source and may
1045
+ wrap it (their `finally` runs when the handler returns); exception mappers only apply before the
1046
+ stream starts.
1047
+ - **Hooks**: `http:request` resolves when the stream opens (`status: 200`): request logs, the
1048
+ duration histogram and tracing spans measure the time to open. `plugin.streams` counts the open
1049
+ streams (health checks, gauges, a guard capping streams: a 503 would make `EventSource` give up).
1050
+
1051
+ **`ctx.signal`** exists on every route, not only SSE: it aborts when the client disconnects before
1052
+ the response was written, when the response body is cancelled, or when the server closes an event
1053
+ stream; never after a normal completion (pass it to work that outlives the response). Its reason
1054
+ is a `RequestAbortedError` (`name: 'AbortError'`, what `fetch` and `node:timers/promises` check)
1055
+ with `kind: 'client' | 'shutdown' | 'expired'`. Ordinary in-flight requests are not aborted on
1056
+ shutdown: they drain.
1057
+
1058
+ ### `EventChannel`
1059
+
1060
+ Pull-based sources give real backpressure, but most producers push (a bus, an `EventEmitter`, a
1061
+ database listener). `EventChannel` is a bounded, single-consumer bridge (generic: a GraphQL
1062
+ subscription transport or a messaging integration can use it too):
1063
+
1064
+ ```ts
1065
+ import { EventChannel, type SseEvent } from '@znight/http'
1066
+
1067
+ @Injectable()
1068
+ export class OrderHub {
1069
+ readonly #subscribers = new Map<string, Set<EventChannel<SseEvent<OrderEvent>>>>()
1070
+ constructor(private readonly store: OrderEventStore) {}
1071
+
1072
+ subscribe(orderId: string, options: { after?: string; signal: AbortSignal }): AsyncIterable<SseEvent<OrderEvent>> {
1073
+ // overflow 'close': a client that falls behind is disconnected; it reconnects with Last-Event-ID
1074
+ // and catches up from the store, instead of the server buffering without bound
1075
+ const channel = new EventChannel<SseEvent<OrderEvent>>({ capacity: 256, overflow: 'close', signal: options.signal })
1076
+ const set = this.#subscribers.get(orderId) ?? new Set()
1077
+ this.#subscribers.set(orderId, set.add(channel))
1078
+ channel.onClose(() => set.delete(channel))
1079
+ for (const missed of this.store.since(orderId, options.after)) channel.push(toSseEvent(missed))
1080
+ return channel
1081
+ }
1082
+
1083
+ publish(event: OrderEvent): void {
1084
+ for (const channel of this.#subscribers.get(event.orderId) ?? []) channel.push(toSseEvent(event))
1085
+ }
1086
+ }
1087
+ ```
1088
+
1089
+ | Member | Description |
1090
+ | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
1091
+ | `new EventChannel(options?)` | `capacity` (1 000), `overflow` (`'drop-oldest'`, `'drop-newest'`, `'close'`), `signal` (closes the channel: pass `ctx.signal`) |
1092
+ | `push(item)` | Buffers an item; `false` when it was dropped or the channel is closed |
1093
+ | `close()` / `fail(error)` | Ends the iteration (or makes it throw) after the buffered items |
1094
+ | `onClose(listener)` | Called once when the channel closes (close, fail, overflow, signal, consumer `return()`) |
1095
+ | `size`, `closed` | Buffered items, state |
1096
+ | `[Symbol.asyncIterator]()` | Single consumer; `return()` closes the channel and resolves at once, even while `next()` waits |
1097
+
1098
+ A pending `next()` resolves with `{ done: true }` as soon as the channel closes, so an SSE handler
1099
+ built on a channel always stops when its client leaves. Channels are in-process: fan-out across
1100
+ instances (Redis, NATS...) is application code.
1101
+
1102
+ ### Proxies, compression and timeouts
1103
+
1104
+ | Concern | What Znight does |
1105
+ | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1106
+ | Headers not flushed, proxy buffering | The first chunk is written at once; `x-accel-buffering: no` (nginx) |
1107
+ | Caching proxies, CDNs | `cache-control: no-cache, no-transform`. Cloudflare streams `text/event-stream`; its 100 s idle limit is covered by heartbeats |
1108
+ | Compression middleware | `no-transform` is honoured by Express `compression()` and `hono/compress` (which also skips `text/event-stream`). Fastify sends event streams through `reply.hijack()`: no `onSend` hook (`@fastify/compress`, ETag) ever touches them; headers set by `onRequest` hooks are kept |
1109
+ | Hand-written and proxied streams | Any streamed `text/event-stream` body (`return fetch(upstream)`, `HttpResponse.stream(body, { contentType: 'text/event-stream' })`) is sent as an event stream: headers at once (even before a quiet stream's first byte, on Fastify too), no socket timeout, no `onSend` hook. `@Sse` adds the framing, heartbeats and graceful close |
1110
+ | `Bun.serve()` `idleTimeout` (10 s) | `honoAdapter().listen()` lifts it while the stream is sent. With `adapter.fetch` in your own `Bun.serve()`, keep `heartbeat` below `idleTimeout` (the 8 s default does) |
1111
+ | Node.js socket timeouts | `server.timeout` / `setTimeout()` / Fastify `connectionTimeout` (and Bun's `node:http` emulation) never cut a quiet event stream: the adapters call `response.setTimeout(0)` for it |
1112
+ | HTTP/1.1 browser limit (6 per origin) | Serve HTTP/2 at the edge, or multiplex topics on one stream. Nothing connection-specific is sent: streams work through h2 proxies |
1113
+
1114
+ **Credentials.** `EventSource` cannot send an `Authorization` header and tokens in the query string
1115
+ leak into logs: authenticate event streams with a cookie (`new EventSource(url, { withCredentials:
1116
+ true })` cross-origin, with [CORS](#cors)) or a short-lived, single-use stream ticket.
1117
+ `Last-Event-ID` is client input: coerce or validate it, never trust it as an authorization
1118
+ boundary.
1119
+
1120
+ **GraphQL over SSE.** Nothing here is GraphQL-specific, but the primitives a "distinct connections"
1121
+ transport needs are there: `HttpResponse.sse()` on `POST`, free-form event names (`next`,
1122
+ `complete` with `data: ''`), ordered streams stopped through `return()` and `ctx.signal`, and
1123
+ `EventChannel` for PubSub engines.
1124
+
1125
+ ## Errors
1126
+
1127
+ Throw an `HttpException` (or a subclass) anywhere — handler, guard, interceptor, service:
1128
+
1129
+ ```ts
1130
+ throw new HttpException(409, 'Email already used', { field: 'email' })
1131
+ throw new NotFoundException(`User ${id} not found`)
1132
+ throw new UnauthorizedException('Token expired', undefined, { headers: { 'www-authenticate': 'Bearer' } })
1133
+ ```
1134
+
1135
+ ```json
1136
+ { "statusCode": 409, "error": "Conflict", "message": "Email already used", "details": { "field": "email" } }
1137
+ ```
1138
+
1139
+ Constructor: `new XxxException(message?, details?, { headers?, cause? })` (`HttpException` takes
1140
+ the status first). Override `toBody()` in a subclass to change the shape.
1141
+
1142
+ | Class | Status | Class | Status |
1143
+ | ------------------------------- | ------ | ------------------------------ | ------ |
1144
+ | `BadRequestException` | 400 | `UnprocessableEntityException` | 422 |
1145
+ | `ValidationException` | 400 | `TooManyRequestsException` | 429 |
1146
+ | `UnauthorizedException` | 401 | `InternalServerErrorException` | 500 |
1147
+ | `ForbiddenException` | 403 | `NotImplementedException` | 501 |
1148
+ | `NotFoundException` | 404 | `BadGatewayException` | 502 |
1149
+ | `MethodNotAllowedException` | 405 | `ServiceUnavailableException` | 503 |
1150
+ | `ConflictException` | 409 | `GatewayTimeoutException` | 504 |
1151
+ | `GoneException` | 410 | `PayloadTooLargeException` | 413 |
1152
+ | `UnsupportedMediaTypeException` | 415 | | |
1153
+
1154
+ Any other error answers `{ "statusCode": 500, "error": "Internal Server Error" }` — the message is
1155
+ hidden unless `exposeErrors: true` (development) — and is logged with the request id, route and
1156
+ handler. So does a response header that cannot be sent (a CR/LF or a character above `\xff` in a
1157
+ value, set with `ctx.setHeader()` or a redirect to a client-provided URL), identically on every
1158
+ adapter: the body is discarded and the error logged. `5xx` exceptions are logged too. Every error is also recorded on the
1159
+ [`http:request` event](#hooks-and-observability).
1160
+
1161
+ ### Exception mappers
1162
+
1163
+ Convert errors (domain errors, ORM errors...) to responses. Mappers run in order for every error of
1164
+ the pipeline, including `404`/`405`; the first one returning something wins:
1165
+
1166
+ ```ts
1167
+ @Injectable()
1168
+ export class DomainErrorMapper implements ExceptionMapper {
1169
+ map(error: unknown, ctx: HttpContext) {
1170
+ if (error instanceof UserNotFoundError) return new NotFoundException(error.message) // default body
1171
+ if (error instanceof ValidationException) return new HttpResponse({ errors: error.issues }, { status: 422 })
1172
+ if (error instanceof RateLimitError) return new Response('slow down', { status: 429 })
1173
+ return undefined // next mapper, then the default rendering
1174
+ }
1175
+ }
1176
+
1177
+ HttpPlugin.configure({ exceptionMappers: [DomainErrorMapper] })
1178
+ ```
1179
+
1180
+ A mapper that throws is logged and skipped.
1181
+
1182
+ ## Guards
1183
+
1184
+ A guard decides whether a request reaches the handler. Global guards (plugin options) run first,
1185
+ then controller guards, then route guards. Returning anything but `true` answers
1186
+ `403 Forbidden`; throw an `HttpException` for another status.
1187
+
1188
+ ```ts
1189
+ // access.ts: one decision per route, every route must have one
1190
+ export const Public = createRouteDecorator('public', { group: 'access', required: true })
1191
+ export const Authenticated = createRouteDecorator('authenticated', { group: 'access', required: true })
1192
+ export const Roles = createRouteDecorator<readonly Role[]>('roles', { group: 'access', required: true })
1193
+
1194
+ @Injectable()
1195
+ export class AuthGuard implements CanActivate {
1196
+ /** Read by HttpPlugin at boot: every route needs a guard enforcing the "access" group. */
1197
+ static readonly enforces = [Public, Authenticated, Roles]
1198
+
1199
+ constructor(private readonly tokens: TokenService) {}
1200
+
1201
+ async canActivate(ctx: HttpContext): Promise<boolean> {
1202
+ if (Public.get(ctx)) return true
1203
+ const user = await this.tokens.verify(ctx.header('authorization'))
1204
+ if (user === undefined) throw new UnauthorizedException()
1205
+ ctx.state.set('user', user)
1206
+ const roles = Roles.get(ctx)
1207
+ if (roles !== undefined) return roles.some(role => user.roles.includes(role))
1208
+ return Authenticated.get(ctx) === true // no decision: denied
1209
+ }
1210
+ }
1211
+
1212
+ @Controller('/admin')
1213
+ @Roles(['admin']) // the decision of every route of the controller...
1214
+ export class AdminController {
1215
+ @Get('/health')
1216
+ @Public() // ...unless the route has its own decision of the group
1217
+ health() {
1218
+ return 'ok'
1219
+ }
1220
+ }
1221
+
1222
+ HttpPlugin.configure({ guards: [AuthGuard] })
1223
+ ```
1224
+
1225
+ `decorator.get(ctx)` reads the route metadata (`ctx.route.meta`) with its type. Guards,
1226
+ interceptors and mappers are providers: they may be request-scoped (resolved per request) and have
1227
+ dependencies. Plugin options also accept instances (`guards: [new IpAllowList(['10.0.0.0/8'])]`).
1228
+
1229
+ ## Route decisions
1230
+
1231
+ `createRouteDecorator(key, { group, required })` makes route metadata safe to use for access
1232
+ control:
1233
+
1234
+ - **Decorators of a group are alternatives.** A route (or a controller) has at most one decision
1235
+ per group: `@Public() @Roles(['admin'])` on one method is ZNIGHT005 at build time.
1236
+ - **The nearest decision wins.** The effective metadata of a route (`ctx.route.meta`,
1237
+ `decorator.get(ctx)`) is made of the controller's entries whose group the route does not decide,
1238
+ then the route's entries. `@Public()` on a controller and `@Roles(['admin'])` on one of its
1239
+ routes give `{ roles: ['admin'] }` for that route: the route is not public. A decorator without
1240
+ `group` is its own group (the route value wins over the controller value, key by key).
1241
+ - **Every route has a decision of each `required` group, checked at build time.** A route without
1242
+ one, neither on itself nor on its controller, is **ZNIGHT023** — including routes of an
1243
+ application that defines the decorators of a required group without applying them yet:
1244
+
1245
+ ```
1246
+ ✗ ZNIGHT023: Route DELETE /books/:id (BookController.remove) has no "access" decision
1247
+ --> src/books/book.controller.ts:104:3
1248
+ = hint: Add @Public(), @Authenticated() or @Roles(...) to the route or to BookController
1249
+ = related: The "access" group is required (src/auth/access.ts:5:14)
1250
+ ```
1251
+
1252
+ - **Every decision is enforced, checked at boot.** Guards declare the decorators whose decisions
1253
+ they enforce (`static enforces = [Public, Authenticated, Roles]`, typed `readonly
1254
+ RouteDecoratorInfo[]`, see `GuardClass`). When `HttpPlugin` registers the routes, each route
1255
+ whose decision belongs to a required group, or to a group some guard enforces, must have a guard
1256
+ enforcing that group in its chain (global, controller or route guards). Otherwise the boot fails
1257
+ with one ZNIGHT023 listing every route, by group. "Some guard" is any guard class of the
1258
+ application, registered in a chain or not: a forgotten `guards: [AuthGuard]` or
1259
+ `guards: [RateLimitGuard]` in a second entrypoint or a test fails the boot, for required groups
1260
+ and for groups such as `@RateLimit` alike, with a hint naming the guards that enforce the group.
1261
+ A guard class registered nowhere only makes its groups known; a required group is known at boot
1262
+ through the compiled metadata and the `enforces` of the guards of the chains (an auth package
1263
+ shared between services). Plugin routes are checked too.
1264
+
1265
+ ```
1266
+ ZNIGHT023: HttpPlugin: No guard enforces the required "access" group on 9 routes: GET /books (BookController.list), ...
1267
+ hint: Register a guard declaring `static enforces` with the decorators of the "access" group (authenticated, public, roles), e.g. HttpPlugin.configure({ guards: [AuthGuard] })
1268
+ ```
1269
+
1270
+ The class whose `enforces` counts is the compiled class of controller and route guards (a guard
1271
+ replaced through `overrides` in a test keeps its declaration), the class or the
1272
+ `instance.constructor` of global guards. A test that deliberately runs without guards sets
1273
+ `enforcement: 'warn'` (one warning per group instead of the error).
1274
+
1275
+ - All the definitions of a key must use the same options, and the decorators of a group must agree
1276
+ on `required` (ZNIGHT005 at build time; at boot for contradicting `enforces`). Route decorators
1277
+ without options behave as before: the key is any non-empty string (`'auth:public'`,
1278
+ `'app/roles'`), and a decorator without `group` is its own group, named after its key. An explicit
1279
+ `group` is a letter followed by letters, digits, `_`, `.` or `-` (ZNIGHT005 at build time, a
1280
+ `TypeError` where the decorator is created).
1281
+
1282
+ `HttpPlugin.routes` lists the effective `meta` and the `guards` (display names, chain order) of
1283
+ every route: snapshot it in a test to review access changes in pull requests.
1284
+
1285
+ ```ts
1286
+ test('access matrix', async () => {
1287
+ await using app = await createTestApp()
1288
+ const matrix = app
1289
+ .get(HttpPlugin)
1290
+ .routes.filter(route => route.method !== 'HEAD')
1291
+ .map(route => `${route.method} ${route.path} ${JSON.stringify(route.meta)} [${route.guards.join(', ')}]`)
1292
+ expect(matrix).toMatchSnapshot()
1293
+ })
1294
+ ```
1295
+
1296
+ `decorator.meta(value?)` gives the same metadata as data (`{ key, value, group, required }`) for
1297
+ routes that are not compiled controllers: [plugin routes](#plugin-routes) such as
1298
+ `OpenApiPlugin.configure({ meta: [Public.meta()] })`.
1299
+
1300
+ ## CORS
1301
+
1302
+ ```ts
1303
+ HttpPlugin.configure({
1304
+ cors: {
1305
+ origin: ['https://app.example.com', /^https:\/\/[a-z0-9-]+\.preview\.example\.com$/],
1306
+ credentials: true,
1307
+ },
1308
+ })
1309
+ ```
1310
+
1311
+ | Option | Default | Description |
1312
+ | --------------- | ---------------------------- | ------------------------------------------------------------------------------------------------ |
1313
+ | `origin` | required | `'*'`, an origin, an anchored `RegExp`, a list of them, or `(origin) => boolean \| Promise<...>` |
1314
+ | `credentials` | `false` | `access-control-allow-credentials: true` (cookies, `authorization`); refused with `'*'` |
1315
+ | `methods` | the methods of the path | `access-control-allow-methods` of preflights; `'*'` is refused with `credentials` |
1316
+ | `allowHeaders` | the headers the browser asks | `access-control-allow-headers` of preflights, or `'*'` (refused with `credentials`) |
1317
+ | `exposeHeaders` | the request id header | `access-control-expose-headers` of replies; `'*'` is refused with `credentials` |
1318
+ | `maxAge` | `600` | `access-control-max-age` of preflights, seconds (0 to 86400) |
1319
+
1320
+ The options are validated at boot (ZNIGHT005): an origin must be written exactly as browsers send
1321
+ it (`https://app.example.com`: no path, no trailing slash, no default port), `'null'` is refused,
1322
+ a `RegExp` must be anchored with `^` and `$` around every alternative and have no `g` / `y` flag.
1323
+ With `credentials: true`, `'*'` is refused in `methods`, `allowHeaders` and `exposeHeaders`:
1324
+ browsers read it literally on credentialed requests (a method or header named `*`). Without
1325
+ credentials it is a wildcard, except that it never covers `Authorization` (Fetch rule): with
1326
+ `allowHeaders: '*'`, a preflight asking for `authorization` gets
1327
+ `access-control-allow-headers: *, authorization`.
1328
+
1329
+ Behaviour, identical on every adapter:
1330
+
1331
+ - **Preflights** (`OPTIONS` with `origin` and `access-control-request-method`) are answered by the
1332
+ pipeline inside the `http:request` hook (logs, metrics and traces see them) and **never run
1333
+ guards, interceptors or handlers**, even when an `@Options` or `@All` route matches: `204` with
1334
+ the CORS headers, `allow` and `vary`; `403 CORS origin not allowed` for a denied origin; `404`
1335
+ for a path without route (both without CORS headers).
1336
+ - **Other replies to an allowed origin** get `access-control-allow-origin`,
1337
+ `access-control-allow-credentials` and `access-control-expose-headers` unless the handler, an
1338
+ interceptor, a mapper or a hook wrapper set them: 2xx, guard 401 / 403, 404, 405, 413, 500,
1339
+ automatic `OPTIONS` and redirects alike. `@Res()` raw replies are the only exception.
1340
+ - **A denied origin** is served normally, without CORS headers: CORS protects what a page may read,
1341
+ it is not access control (use a guard).
1342
+ - **A wildcard policy** (`'*'`, alone or in a list) adds `access-control-allow-origin: *` (and
1343
+ `access-control-expose-headers`) to every reply, whether the request has an `origin` header or
1344
+ not: the reply does not depend on the origin, so a cached reply serves CORS and non-CORS requests
1345
+ alike.
1346
+ - Other policies merge `vary: origin` into every reply, for shared caches.
1347
+ - An origin function that throws or rejects denies the origin (logged at `error` level).
1348
+
1349
+ With Fastify, Express or Hono, use either this option or the native CORS middleware, not both.
1350
+
1351
+ ## Security headers
1352
+
1353
+ On by default, added to every reply that does not set them (`@Res()` raw replies excepted):
1354
+
1355
+ | Option | Header | Default |
1356
+ | --------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------- |
1357
+ | `contentSecurityPolicy` | `content-security-policy` | `default-src 'none'; frame-ancestors 'none'` |
1358
+ | `frameOptions` | `x-frame-options` | `'DENY'` (or `'SAMEORIGIN'`) |
1359
+ | `noSniff` | `x-content-type-options` | `true` (`nosniff`) |
1360
+ | `referrerPolicy` | `referrer-policy` | `'no-referrer'` |
1361
+ | `hsts` | `strict-transport-security` | `false`; `true`: `max-age=31536000; includeSubDomains`; or `{ maxAge, includeSubDomains, preload }` |
1362
+ | `crossOriginResourcePolicy` | `cross-origin-resource-policy` | `false` (`'same-origin'`, `'same-site'`, `'cross-origin'`) |
1363
+ | `cacheControl` | `cache-control` | `false` (`'no-store'` for APIs serving personal data) |
1364
+
1365
+ ```ts
1366
+ HttpPlugin.configure({ securityHeaders: { hsts: true, cacheControl: 'no-store' } }) // merged over the defaults
1367
+ HttpPlugin.configure({ securityHeaders: false }) // none
1368
+ ```
1369
+
1370
+ The defaults are safe for APIs: the CSP blocks a stored XSS through an uploaded HTML or SVG file
1371
+ served by `HttpResponse.file()` and changes nothing for JSON. Every header loses to one set by the
1372
+ handler (`@Header`, `ctx.setHeader`, `HttpResponse`), an interceptor, an exception or a hook
1373
+ wrapper: a route serving an HTML page sets its own `content-security-policy`. HSTS normally belongs
1374
+ to the TLS terminator. Values are validated at boot (ZNIGHT005).
1375
+
1376
+ ## Behind a proxy
1377
+
1378
+ ```ts
1379
+ HttpPlugin.configure({ trustProxy: 1 }) // one load balancer in front of the server
1380
+ HttpPlugin.configure({ trustProxy: ['loopback', '10.0.0.0/8'] }) // the addresses of the proxies
1381
+ ```
1382
+
1383
+ `trustProxy` (default `false`) gives a correct `ctx.ip` and `ctx.protocol` behind load balancers,
1384
+ the same way on every adapter. Both forms walk `[socket peer, ...x-forwarded-for from right to
1385
+ left]`:
1386
+
1387
+ - a **number** of hops (an integer ≥ 1): the client is the entry at that index, or the leftmost one
1388
+ when the list is shorter;
1389
+ - a **list** of IPv4 / IPv6 addresses, CIDR ranges and the aliases `'loopback'` (127.0.0.0/8,
1390
+ ::1), `'private'` (10/8, 172.16/12, 192.168/16, fc00::/7) and `'linklocal'` (169.254/16,
1391
+ fe80::/10): the walk continues while the addresses are trusted; the first untrusted one is the
1392
+ client (or the leftmost entry when all are trusted).
1393
+
1394
+ IPv4-mapped IPv6 addresses are normalised (`::ffff:10.0.0.1` → `10.0.0.1`), and a malformed entry
1395
+ stops the walk at the last valid address. When the socket peer is trusted, `ctx.protocol` is the
1396
+ leftmost `x-forwarded-proto` (`http` or `https` only); otherwise it is the protocol of the
1397
+ connection. `true` is refused (ZNIGHT005): trusting every hop would let any client choose its
1398
+ `ctx.ip`. Without the option, `ctx.ip` is the adapter's address, so the proxy settings of Fastify
1399
+ (`trustProxy`) and Express (`trust proxy`) still apply. The `Forwarded` header (RFC 7239) is not
1400
+ read.
1401
+
1402
+ ## Interceptors
1403
+
1404
+ Interceptors wrap the handler after the guards: global → controller → route, the first one being
1405
+ the outermost. `next()` resolves with the handler result (before serialisation):
1406
+
1407
+ ```ts
1408
+ @Injectable()
1409
+ export class TimingInterceptor implements HttpInterceptor {
1410
+ async intercept(ctx: HttpContext, next: CallHandler) {
1411
+ const started = performance.now()
1412
+ try {
1413
+ return await next() // or transform the result, or return something else without calling next()
1414
+ } finally {
1415
+ ctx.setHeader('server-timing', `app;dur=${(performance.now() - started).toFixed(1)}`)
1416
+ }
1417
+ }
1418
+ }
1419
+ ```
1420
+
1421
+ ## The request context
1422
+
1423
+ `HttpContext` is adapter-neutral. Use it with `@Ctx()`, in guards, interceptors and mappers, or
1424
+ inject it in request-scoped providers.
1425
+
1426
+ | Member | Description |
1427
+ | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
1428
+ | `requestId` | Incoming `x-request-id` (when valid) or a generated UUID |
1429
+ | `method`, `url`, `path` | Upper-case method, raw target (`/a%20b?x=1`), raw path |
1430
+ | `params` | Decoded route parameters (`'*'` for the wildcard) |
1431
+ | `query` | Parsed query, repeated keys as arrays (parsed on first access) |
1432
+ | `headers`, `header(name)` | Request headers (lower-case names) |
1433
+ | `body`, `parseBody()`, `rawBody()` | Parsed body ([when](#request-bodies)), lazy parsing, raw bytes |
1434
+ | `files` | Files of a multipart body by field (`Record<string, File[]>`) |
1435
+ | `bodyStream()` | The body as a Web stream, nothing buffered ([Large files](#large-files)) |
1436
+ | `route` | `{ method, path, controller, handler, meta }` (effective metadata, see [Route decisions](#route-decisions)), `undefined` for 404/405 |
1437
+ | `state` | `Map` shared by guards, interceptors and handlers |
1438
+ | `setHeader()`, `appendHeader()`, `responseHeaders` | Response headers, also sent with errors |
1439
+ | `cookies`, `signedCookie(name)` | Request cookies (parsed on first access), the verified value of a signed cookie ([Cookies](#cookies)) |
1440
+ | `setCookie()`, `clearCookie()`, `responseCookies` | Response cookies, also sent with errors; validated at call time |
1441
+ | `signal` | Aborted when the client leaves or an event stream is closed by the server, never after a normal completion ([`ctx.signal`](#lifecycle)) |
1442
+ | `request`, `response` | Native objects of the adapter |
1443
+ | `adapter` | Adapter name |
1444
+ | `ip`, `protocol` | Client address and `'http'` / `'https'` ([Behind a proxy](#behind-a-proxy)) |
1445
+
1446
+ Each request runs in a container scope seeded with `HttpContext` and `RequestId`:
1447
+
1448
+ ```ts
1449
+ @Injectable({ scope: 'request' })
1450
+ export class CurrentUser {
1451
+ constructor(ctx: HttpContext, requestId: RequestId) {}
1452
+ }
1453
+
1454
+ @Injectable() // a singleton reading the current request lazily
1455
+ export class AuditService {
1456
+ constructor(private readonly ctx: Lazy<HttpContext>) {}
1457
+ record(action: string) {
1458
+ log(action, this.ctx.get().requestId)
1459
+ }
1460
+ }
1461
+ ```
1462
+
1463
+ Request-scoped instances are disposed when the response has been sent: for a streamed or file
1464
+ body, once it has been fully sent or cancelled (client disconnected), so stream producers can use
1465
+ them and files they own are still there (on `Bun.serve()`, a file is handed to Bun, which opens it,
1466
+ first).
1467
+
1468
+ ## Hooks and observability
1469
+
1470
+ Every request — matched or not — runs through `hooks.run('http:request', event, ...)` (the event
1471
+ type, `HttpRequestEvent`, lives in `@znight/core`). Wrappers see the final `status` and `error`
1472
+ before `run` resolves; listeners (`hooks.on`) are called once the response is computed.
1473
+
1474
+ | Field | Description |
1475
+ | ----------------- | ------------------------------------------------------------ |
1476
+ | `method`, `path` | Request method and raw path |
1477
+ | `route` | Route pattern (`/api/users/:id`), `undefined` for 404/405 |
1478
+ | `requestId` | Request id |
1479
+ | `headers` | Request headers |
1480
+ | `context` | The `HttpContext` |
1481
+ | `startTime` | `performance.now()` when the request started |
1482
+ | `status`, `error` | Set before `run` resolves (`error`: what the pipeline threw) |
1483
+ | `responseHeaders` | Headers to add to the response (trace context...) |
1484
+
1485
+ ```ts
1486
+ app.hooks.wrap('http:request', async (event, next) => {
1487
+ await next()
1488
+ histogram.observe({ route: event.route ?? 'unmatched', status: event.status }, performance.now() - event.startTime)
1489
+ })
1490
+ ```
1491
+
1492
+ `@znight/observability` uses this hook for request logs, RED metrics and tracing. For an event
1493
+ stream, `run` resolves when the stream opens (`status: 200`): durations measure the time to open.
1494
+ A stream-lifetime hook is not provided.
1495
+
1496
+ ## Plugin options
1497
+
1498
+ `HttpPlugin.configure(options)` — `options` may also be a function of the container, evaluated
1499
+ once at bootstrap: `HttpPlugin.configure(c => ({ port: c.get(AppConfig).port }))`.
1500
+
1501
+ | Option | Default | Description |
1502
+ | ------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------ |
1503
+ | `adapter` | `nodeAdapter()` | Server implementation |
1504
+ | `port` | `3000` | Port (`0`: a free port, see `plugin.address`) |
1505
+ | `host` | `0.0.0.0` | Interface to bind |
1506
+ | `prefix` | — | Static path prefixed to every route (`/api`) |
1507
+ | `guards` | `[]` | Global guards: provider classes or instances |
1508
+ | `interceptors` | `[]` | Global interceptors (outermost first) |
1509
+ | `exceptionMappers` | `[]` | Exception mappers, tried in order |
1510
+ | `trailingSlash` | `'strict'` | `'strict'`, `'ignore'` or `'redirect'` |
1511
+ | `exposeErrors` | `false` | Send the message of unexpected errors in 500 responses |
1512
+ | `bodyLimit` | `1048576` | Maximum body size: bytes or `'10mb'` (see [Limits](#limits); routes: `@BodyLimit`) |
1513
+ | `uploads` | see [Limits](#limits) | `{ maxFileSize, maxFiles, maxParts, maxFieldSize }` of multipart bodies |
1514
+ | `requestIdHeader` | `x-request-id` | Header read (when ≤ 128 chars of `[A-Za-z0-9_-.:@/+=]`) and echoed; `false` to always generate |
1515
+ | `generateRequestId` | `randomUUID` | Request id generator |
1516
+ | `drainTimeoutMs` | `4000` | Maximum wait for in-flight requests on shutdown (keep it below `shutdown.hookTimeoutMs`; `Infinity`: no own limit) |
1517
+ | `cors` | off | [CORS](#cors) policy: `{ origin, credentials?, methods?, allowHeaders?, exposeHeaders?, maxAge? }` |
1518
+ | `securityHeaders` | `true` | [Security headers](#security-headers): `true`, `false` or settings merged over the defaults |
1519
+ | `trustProxy` | `false` | [Trusted proxies](#behind-a-proxy): a number of hops or addresses / CIDR ranges / aliases |
1520
+ | `enforcement` | `'error'` | [Route decisions](#route-decisions) not enforced by a guard: `'error'` fails the boot, `'warn'` logs |
1521
+ | `cookies` | — | `{ secrets?, defaults? }`: [signing keys](#signed-cookies) (≥ 32 bytes, the first signs) and cookie defaults |
1522
+ | `sse` | see below | `{ heartbeat?, retry?, shutdownGraceMs? }`: [Server-Sent Events](#server-sent-events) defaults |
1523
+
1524
+ `sse.heartbeat` defaults to 8 000 ms (an integer ≥ 1 000, or `false`), `sse.retry` to none and
1525
+ `sse.shutdownGraceMs` (the time an event source gets to finish on shutdown or `maxDuration`) to
1526
+ 1 000 ms; it must be lower than `drainTimeoutMs`. `cookies.defaults` is merged over
1527
+ `{ path: '/', secure: true, httpOnly: true, sameSite: 'lax' }`. Invalid values, and unknown keys in
1528
+ `cookies`, `cookies.defaults` and `sse`, fail the boot (ZNIGHT005).
1529
+
1530
+ The plugin instance (`app.getPlugin<HttpPlugin>('http')`, or injected as `HttpPlugin`) exposes
1531
+ `address` (`{ host, port, family, url }`, `String(address)` is the URL; `undefined` when not
1532
+ listening), `inject(request)`, `injectStream(request)`, `injectSse(request)` (see
1533
+ [Testing](#testing)), `routes`, `adapter`, `inflight`, `streams` (open event streams),
1534
+ `signCookie(name, value)`, `prefix` and `initRoots()`.
1535
+
1536
+ `routes` lists every registered route (HEAD and trailing-slash variants included):
1537
+ `{ method, path, controller, handler, source, meta, guards }`, where `source` is `'controller'` or
1538
+ the name of the plugin declaring a [plugin route](#plugin-routes), `meta` the effective metadata
1539
+ and `guards` the display names of the guard chain (global, controller, route).
1540
+
1541
+ `prefix` is the resolved `prefix` option (`''` or `/api`), for plugins that serve routes or publish
1542
+ URLs. It is readable at any time when the options are an object; when they are a function of the
1543
+ container, from the `onBootstrap` of any plugin (before or after `HttpPlugin`'s): the options are
1544
+ resolved once per application.
1545
+
1546
+ `initRoots()` lists the provider classes the plugin resolves at bootstrap although no compiled
1547
+ metadata references them: the classes of `guards`, `interceptors` and `exceptionMappers` (instances
1548
+ are left out), in that order. It returns `[]` when the options are a function of the container (they
1549
+ are only evaluated at bootstrap) and never throws (invalid options are reported by `onBootstrap`).
1550
+ A boot restricted with `init: { only }` must include them when they, or their dependencies, have an
1551
+ async factory (an async factory cannot run on demand); `TestApp` from `@znight/testing` adds them
1552
+ by itself:
1553
+
1554
+ ```ts
1555
+ const http = HttpPlugin.configure({ port: 0, guards: [AuthGuard] }) // AuthGuard needs the async JwksStore
1556
+ const app = await Application.create({
1557
+ metadata,
1558
+ plugins: [http],
1559
+ init: { only: [UserController, ...http.initRoots()] },
1560
+ })
1561
+ ```
1562
+
1563
+ ## Plugin routes
1564
+
1565
+ Another plugin can ask `HttpPlugin` to serve routes (documentation, status or metrics endpoints) by
1566
+ implementing `PluginHttpRoutes`. They are registered on every adapter in the same pipeline as the
1567
+ controllers, whatever the plugin order:
1568
+
1569
+ ```ts
1570
+ export class StatusPlugin implements Plugin, PluginHttpRoutes {
1571
+ readonly name = 'status'
1572
+
1573
+ httpRoutes({ prefix, container }: PluginHttpRoutesContext): readonly PluginRoute[] {
1574
+ return [
1575
+ {
1576
+ method: 'GET',
1577
+ path: '/status', // relative to the HttpPlugin prefix, controller path grammar
1578
+ name: 'status', // handler name in HttpPlugin.routes, logs and errors
1579
+ handler: ctx => ({ up: true }), // (ctx: HttpContext) => result, serialised like a controller result
1580
+ meta: [Public.meta()], // route metadata: read by guards, checked by the route decision check
1581
+ guards: [], // extra guards (classes or instances)
1582
+ },
1583
+ ]
1584
+ }
1585
+ }
1586
+ ```
1587
+
1588
+ - `httpRoutes()` is called once in `HttpPlugin.onBootstrap`, after `container.init()` and after the
1589
+ compiled routes, with the prefix and the container.
1590
+ - A request to a plugin route runs the `http:request` hook in a request scope, the global guards,
1591
+ then the route `guards`, the handler and the exception mappers. Global interceptors do not wrap
1592
+ plugin routes (a response envelope would break a document). The body is read lazily
1593
+ (`ctx.parseBody()`). `GET` routes answer `HEAD`; `ALL` works as for controllers.
1594
+ - `meta` entries (`decorator.meta(value)`, one per group) are the route metadata. A required group
1595
+ needs a decision there too: without one the boot fails (ZNIGHT023), with a hint naming the plugin.
1596
+ - An invalid route (method, path grammar, name, handler, meta) or a collision with another route is
1597
+ ZNIGHT007 naming the plugin. `HttpPlugin.routes` lists the routes with `source: '<plugin name>'`.
1598
+
1599
+ ## Adapters
1600
+
1601
+ All adapters share the same pipeline, so responses (bodies, errors, statuses, HEAD, 404/405,
1602
+ trailing slashes) are identical. They differ in their native objects and integrations.
1603
+
1604
+ ### `nodeAdapter()` — built-in (`@znight/http` or `@znight/http/node`)
1605
+
1606
+ Zero dependencies, `node:http` (works on Bun). Router: a segment trie (static > `:param` > `*`,
1607
+ with backtracking).
1608
+
1609
+ | Option | Description |
1610
+ | --------------- | ----------------------------------------------------------------------- |
1611
+ | `serverOptions` | Options of `http.createServer()` (`requestTimeout`, `maxHeaderSize`...) |
1612
+ | `server` | An existing server (e.g. `https.createServer({ key, cert })`) |
1613
+
1614
+ `adapter.handler` is a request listener you can mount in a server you manage. `inject()` needs the
1615
+ optional peer `light-my-request`.
1616
+
1617
+ ### `fastifyAdapter()` — `@znight/http/fastify`
1618
+
1619
+ ```sh
1620
+ bun add fastify
1621
+ ```
1622
+
1623
+ ```ts
1624
+ import { fastifyAdapter } from '@znight/http/fastify'
1625
+
1626
+ HttpPlugin.configure({ adapter: fastifyAdapter({ options: { trustProxy: true } }) })
1627
+
1628
+ // or with your instance (plugins, hooks, native routes)
1629
+ const instance = Fastify()
1630
+ await instance.register(helmet)
1631
+ HttpPlugin.configure({ adapter: fastifyAdapter({ instance }) })
1632
+ ```
1633
+
1634
+ | Option | Description |
1635
+ | ---------- | ------------------------------------------------------------------------------ |
1636
+ | `instance` | An existing Fastify instance (plugins, hooks, native routes) |
1637
+ | `options` | Options of the instance created otherwise (`trustProxy`, `https`, timeouts...) |
1638
+
1639
+ Znight routes are registered in an encapsulated context with their own body handling and 404
1640
+ handler, so your other routes keep Fastify's parsers. A `content-type` Fastify cannot parse (`foo`,
1641
+ an empty one) is hidden from Fastify for Znight routes, which then answer like on the other adapters
1642
+ instead of Fastify's `415`. Created instances use
1643
+ `exposeHeadRoutes: false` (Znight serves HEAD), a `maxParamLength` of 8 KiB and a
1644
+ `frameworkErrors` handler rendering Znight errors (with your own instance, malformed URLs are
1645
+ answered by Fastify). `inject()` uses `fastify.inject()`. Event streams are sent through
1646
+ `reply.hijack()`: `onSend` hooks (compression, ETag plugins) never touch them, and the headers your
1647
+ hooks already set (`onRequest`, helmet) are kept under Znight's (their cookies before Znight's).
1648
+
1649
+ ### `expressAdapter()` — `@znight/http/express`
1650
+
1651
+ ```sh
1652
+ bun add express light-my-request # light-my-request for inject()
1653
+ ```
1654
+
1655
+ ```ts
1656
+ import { expressAdapter } from '@znight/http/express'
1657
+
1658
+ const app = express()
1659
+ app.use(cors())
1660
+ HttpPlugin.configure({ adapter: expressAdapter({ app }) })
1661
+ ```
1662
+
1663
+ | Option | Description |
1664
+ | -------- | --------------------------------------------------------------------- |
1665
+ | `app` | An existing application: its middleware runs before the Znight routes |
1666
+ | `server` | The server to listen with (e.g. `https.createServer({ key, cert })`) |
1667
+
1668
+ Routes live on a dedicated router (`adapter.router`) mounted after your middleware. Do not add
1669
+ body parsers for Znight routes (Znight reads the body itself): a body already read by a parser
1670
+ (`app.use(express.json())`) answers `500` with an explicit log entry instead of an empty body.
1671
+
1672
+ ### `honoAdapter()` — `@znight/http/hono`
1673
+
1674
+ ```sh
1675
+ bun add hono
1676
+ bun add @hono/node-server # on Node.js only
1677
+ ```
1678
+
1679
+ ```ts
1680
+ import { honoAdapter } from '@znight/http/hono'
1681
+
1682
+ HttpPlugin.configure({ adapter: honoAdapter() })
1683
+ ```
1684
+
1685
+ On Bun the server is `Bun.serve()`; on Node.js, `@hono/node-server`. Pass `app` (an existing
1686
+ `Hono` instance) to add Hono middleware (Znight sets its `notFound` and `onError` handlers);
1687
+ `adapter.fetch` is the Fetch handler of the application. `inject()` uses
1688
+ `app.request()`. Cookies a middleware sets before the Znight route (`setCookie(c, ...)`) are kept,
1689
+ before Znight's; other headers set with `c.header()` before `next()` follow Hono's rules for a
1690
+ handler returning its own `Response`.
1691
+
1692
+ | Option | Description |
1693
+ | -------------- | ----------------------------------------------------------------------------------------------------------------------------- |
1694
+ | `app` | An existing `Hono` application |
1695
+ | `serveOptions` | Options of `Bun.serve()` (`tls`, `idleTimeout`, `development`, `error`...; Znight sets `port`, `hostname`, `fetch`). Bun only |
1696
+
1697
+ On Bun, the body limits are Znight's (`bodyLimit`, `@BodyLimit`): `maxRequestBodySize` defaults to
1698
+ unlimited instead of Bun's 128 MiB. Bun's `idleTimeout` (10 s by default) still closes idle
1699
+ connections and stalled requests, but is lifted while a streamed response is sent (SSE, slow
1700
+ producers), then restored. In your own `Bun.serve({ fetch: adapter.fetch })` the adapter cannot
1701
+ lift it: keep the SSE `heartbeat` below `idleTimeout` (the 8 s default does).
1702
+
1703
+ `Bun.serve()` sends streamed bodies with chunked transfer encoding even when a `Content-Length`
1704
+ header is set (unless the whole stream is available at once): files sent with
1705
+ `HttpResponse.file()` and `Blob` / `File` results are handed to Bun as blobs, with their length
1706
+ (`sendfile`), when the `Response` reaches Bun with its body (a Hono middleware replacing the body
1707
+ gets the stream). With `adapter.fetch` (your own server), they are streams.
1708
+
1709
+ `Bun.serve()` gives the adapter the WHATWG-normalised URL only: dot segments are resolved before
1710
+ routing (`/files/%2e%2e/x` is `/x`), unlike the other adapters. On Node.js the raw request target is
1711
+ used.
1712
+
1713
+ ### Optional peer dependencies
1714
+
1715
+ | Package | Needed for |
1716
+ | ------------------- | ------------------------------------------------- |
1717
+ | `fastify` | `@znight/http/fastify` |
1718
+ | `express` | `@znight/http/express` |
1719
+ | `hono` | `@znight/http/hono` |
1720
+ | `@hono/node-server` | `@znight/http/hono` on Node.js |
1721
+ | `light-my-request` | `inject()` with the `node` and `express` adapters |
1722
+
1723
+ ## Testing
1724
+
1725
+ `plugin.inject()` sends a request in memory through the adapter's routing and the whole pipeline,
1726
+ without listening (`@znight/testing`'s `TestApp.inject()` delegates to it, and `TestApp.create()`
1727
+ creates the global guards, interceptors and mappers listed by
1728
+ [`initRoots()`](#plugin-options) at boot):
1729
+
1730
+ ```ts
1731
+ const http = HttpPlugin.configure({ port: 0 })
1732
+ const app = await Application.create({ metadata, plugins: [http], overrides: [[UserRepository, fakeRepository]] })
1733
+
1734
+ const response = await http.inject({ method: 'POST', url: '/users', body: { name: 'Ada', email: 'ada@example.com' } })
1735
+ expect(response.status).toBe(201)
1736
+ expect(response.json()).toEqual({ id: 1, name: 'Ada', email: 'ada@example.com' })
1737
+ ```
1738
+
1739
+ `InjectRequest`: `{ method, url, headers?, body?, query?, cookies?, signedCookies? }` — strings and
1740
+ bytes are sent as is, a `FormData` as `multipart/form-data` (files included), a `Blob` / `File` with
1741
+ its type, other bodies as JSON. `InjectResponse`: `{ status, headers, body, bytes, cookies, json() }`
1742
+ (lower-case header names,
1743
+ repeated headers joined with `, ` except `set-cookie`, an array with one entry per cookie; `bytes`
1744
+ holds the raw body, for downloads). For end-to-end tests
1745
+ over the network, start the application with `port: 0` and use `String(http.address)` as the base
1746
+ URL.
1747
+
1748
+ ```ts
1749
+ const form = new FormData()
1750
+ form.append('title', 'Me')
1751
+ form.append('avatar', new File([await Bun.file('test/fixtures/me.png').bytes()], 'me.png', { type: 'image/png' }))
1752
+ const uploaded = await http.inject({ method: 'POST', url: '/profile/avatar', body: form })
1753
+
1754
+ const partial = await http.inject({ method: 'GET', url: '/reports/q1.pdf', headers: { range: 'bytes=0-99' } })
1755
+ expect(partial.status).toBe(206)
1756
+ expect(partial.bytes.byteLength).toBe(100)
1757
+ ```
1758
+
1759
+ ### Testing cookies
1760
+
1761
+ `cookies` sends request cookies (encoded like the cookies Znight sets, appended to any
1762
+ `cookie` header), `signedCookies` are signed with the first secret of `cookies.secrets` before
1763
+ dispatch (a `TypeError` without secrets), and `response.cookies` holds the parsed `set-cookie`
1764
+ lines by name (`SetCookieInfo`: `{ name, value, raw, expires?, maxAge?, domain?, path?, secure,
1765
+ httpOnly, sameSite?, partitioned, priority? }`; the last one wins). The encoding is canonical, so a
1766
+ value read from `response.cookies` can be sent back as is, signed values included:
1767
+
1768
+ ```ts
1769
+ const login = await http.inject({ method: 'POST', url: '/auth/login', body: { email, password } })
1770
+ expect(login.cookies['__Host-uid']).toMatchObject({ httpOnly: true, secure: true, sameSite: 'lax', path: '/' })
1771
+
1772
+ const me = await http.inject({
1773
+ method: 'GET',
1774
+ url: '/auth/me',
1775
+ cookies: { '__Host-uid': login.cookies['__Host-uid'].value },
1776
+ })
1777
+ const signed = await http.inject({ method: 'GET', url: '/auth/me', signedCookies: { '__Host-uid': 'u-1' } })
1778
+ const tampered = await http.inject({ method: 'GET', url: '/auth/me', cookies: { '__Host-uid': 'u-2.AAAA' } }) // 400
1779
+ ```
1780
+
1781
+ ### Testing event streams
1782
+
1783
+ `inject()` reads the whole body, so it rejects an event stream (which never
1784
+ ends) instead of hanging: `inject(): GET /orders/1/events answered an event stream
1785
+ (text/event-stream): use injectSse() or injectStream()`. `http.injectSse(request)` (and
1786
+ `TestApp.injectSse()`) reads it in memory like `EventSource`:
1787
+
1788
+ ```ts
1789
+ await using stream = await http.injectSse({ url: '/orders/o-1/events' })
1790
+ expect(stream.status).toBe(200)
1791
+ expect(stream.retry).toBe(5_000)
1792
+
1793
+ hub.publish({ orderId: 'o-1', sequence: 42, type: 'status', status: 'paid' })
1794
+ const event = await stream.next() // rejects after 2 000 ms by default
1795
+ expect(event).toMatchObject({ event: 'status', id: '42' })
1796
+ expect(event.json()).toMatchObject({ status: 'paid' })
1797
+
1798
+ const resumed = await stream.reconnect() // same request plus `last-event-id: 42`, like EventSource
1799
+ await stream.close() // the server sees a client disconnect: ctx.signal aborts, the channel closes
1800
+ ```
1801
+
1802
+ | Member | Description |
1803
+ | ------------------------------ | ------------------------------------------------------------------------------------------ |
1804
+ | `status`, `headers`, `cookies` | Available once the stream opened (see below) |
1805
+ | `body`, `json()` | `''` for an event stream; otherwise the whole body (an error, a 204...) |
1806
+ | `next({ timeoutMs? })` | The next event (comments skipped); rejects on timeout (2 000 ms) or when the stream ended |
1807
+ | `take(count, { timeoutMs? })` | The next `count` events |
1808
+ | `comments` | Comments received so far (heartbeats are `''`) |
1809
+ | `lastEventId`, `retry` | What a browser would send as `Last-Event-ID`; the last `retry` received |
1810
+ | `done` | Resolves when the server ends the stream (the remaining events stay available to `next()`) |
1811
+ | `close()`, `reconnect()` | Disconnects (idempotent; also `await using`); reconnects with `last-event-id` |
1812
+ | `[Symbol.asyncIterator]` | Every event until the end of the stream |
1813
+
1814
+ Opening never hangs. `injectSse()` resolves at the first chunk of the stream: an `@Sse` stream sends
1815
+ `retry` or a comment at once, so `retry` is known right after. A stream that sends nothing yet (a
1816
+ hand-written `ReadableStream`, a quiet proxied upstream) is open after 2 000 ms anyway, and
1817
+ `next()` reads what it sends later (its timeout message says when the stream has sent nothing since
1818
+ it opened). Without response headers within 2 000 ms (a handler that never answers), `injectSse()`
1819
+ rejects with `injectSse(): no response from GET /orders/o-1/events within 2000 ms` and aborts the
1820
+ request.
1821
+
1822
+ Events are read only when asked for, so a test controls the pace of the client. `injectStream()`
1823
+ resolves at the response headers with `{ status, headers, cookies, body: ReadableStream }`
1824
+ (cancelling `body` or aborting `signal` disconnects the client). Over real sockets,
1825
+ `parseSseStream(body, { signal? })` turns any `ReadableStream<Uint8Array>` into its items
1826
+ (`{ type: 'event', event, data, id, json() }`, `{ type: 'comment', text }`, `{ type: 'retry',
1827
+ retry }`), following the WHATWG parsing rules (BOM, CR / LF / CRLF, `id` with NUL ignored, an
1828
+ unterminated final event discarded):
1829
+
1830
+ ```ts
1831
+ const response = await fetch(`${http.address}/orders/o-1/events`)
1832
+ for await (const item of parseSseStream(response.body!)) {
1833
+ if (item.type === 'event') console.log(item.event, item.json())
1834
+ }
1835
+ ```
1836
+
1837
+
1838
+ ## Lifecycle and graceful shutdown
1839
+
1840
+ | Plugin hook | What happens |
1841
+ | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1842
+ | `register` | Declares the request-scoped externals `HttpContext` and `RequestId`, registers the plugin |
1843
+ | `onBootstrap` | Resolves options, controllers, guards, interceptors and mappers; collects plugin routes; checks route decisions (ZNIGHT023); validates and registers routes |
1844
+ | `onStart` | Listens (logs the address) |
1845
+ | `onStop` | Closes the event streams gracefully (see below), stops accepting connections, closes idle ones, waits for in-flight requests and streamed or file responses (`drainTimeoutMs`), then closes the remaining connections |
1846
+ | `onDispose` | Closes the server and the adapter |
1847
+
1848
+ While draining, responses carry `connection: close`. Event streams never end by themselves, so
1849
+ `onStop` closes them first: their `ctx.signal` aborts (`'shutdown'`), their sources get
1850
+ `sse.shutdownGraceMs` to send final events, then the clients receive a jittered `retry` and the
1851
+ streams end, well before `drainTimeoutMs` instead of holding the drain until its timeout. An event
1852
+ stream requested while draining is served (its guards run) with its signal already aborted: a
1853
+ well-behaved source ends at once and the client gets the `retry` (a 503 would make `EventSource`
1854
+ give up for good). Ordinary in-flight requests are not aborted: they drain. Keep
1855
+ `sse.shutdownGraceMs` < `drainTimeoutMs` < `shutdown.hookTimeoutMs`.
1856
+
1857
+ Singletons (controllers, singleton guards,
1858
+ interceptors and mappers) are resolved at bootstrap; request-scoped and transient ones on every
1859
+ request. Bootstrap fails with `ZNIGHT007` for invalid or duplicate routes (and invalid `sse`
1860
+ metadata), `ZNIGHT001` for controllers, guards or interceptors that are not providers, `ZNIGHT005`
1861
+ for invalid options (and contradicting route decorator groups), `ZNIGHT006` for a route reading a
1862
+ signed cookie without `cookies.secrets` and `ZNIGHT023` for route decisions that are missing or that
1863
+ no guard enforces.
1864
+
1865
+ A plugin instance serves one application at a time and can serve another one once the previous
1866
+ application is stopped (a `HttpPlugin.configure()` shared by the tests of a file); using it in two
1867
+ running applications fails with `ZNIGHT004` (`Call HttpPlugin.configure() once per application`).
1868
+ An adapter instance serves a single application: pass a function to create one per application
1869
+ (`HttpPlugin.configure(() => ({ adapter: fastifyAdapter() }))`), otherwise the second bootstrap
1870
+ fails with `ZNIGHT021`.
1871
+
1872
+ ## Compiler
1873
+
1874
+ `@znight/http/compiler` is loaded automatically by `@znight/compiler` (Vite plugin, `znight
1875
+ generate`, Bun preload) as soon as your code imports `@znight/http`: there is nothing to configure.
1876
+ It never loads the HTTP runtime (nor Fastify, Express or Hono) at build time.
1877
+
1878
+ For every `@Controller` class it:
1879
+
1880
+ 1. registers the controller as a provider (`scope` from `@Controller({ scope })`, singleton by
1881
+ default) and binds `@UseGuard` / `@UseInterceptor` classes to providers — classes without
1882
+ `@Injectable()` are registered as singletons automatically;
1883
+ 2. turns every method carrying `@Get`, `@Post`... into a route (several route decorators on one
1884
+ method give several routes), reads `@HttpCode`, `@Header`, `@Validate` and your
1885
+ [`createRouteDecorator`](#guards) decorators;
1886
+ 3. generates, for each route, an `invoke` function that extracts every argument from the request,
1887
+ coerces it to the declared type or validates it with its schema, and calls the handler;
1888
+ 4. checks the whole application and reports precise diagnostics (below).
1889
+
1890
+ ### Types of parameters
1891
+
1892
+ `@Param('name')`, `@Query('name')`, `@Headers('name')`, `@Cookie('name')` and
1893
+ `@SignedCookie('name')` values without schema are converted to the declared type of the parameter
1894
+ (see [Parameters](#parameters)). The compiler reads:
1895
+
1896
+ | Declared type | Conversion |
1897
+ | ----------------------------------------------------------------- | ------------------------------------------------- |
1898
+ | `string`, `number`, `boolean`, `bigint`, `Date` | `coerce.string` / `.number` / ... |
1899
+ | literal unions (`'asc' \| 'desc'`, `1 \| 2`, `'auto' \| boolean`) | one of the literals |
1900
+ | TypeScript enums (`enum Level { Low, High }`) | one of the member values |
1901
+ | branded primitives (`string & { readonly __brand: 'Id' }`) | the primitive |
1902
+ | `T[]`, `readonly T[]`, `Array<T>` of the above | repeated or single values |
1903
+ | `unknown` | the raw value (`string \| string[] \| undefined`) |
1904
+ | no annotation, with a default value (`page = 1`) | the type of the default value |
1905
+
1906
+ Type aliases are followed (`type Sort = 'asc' | 'desc'`). A parameter is optional when it has `?`,
1907
+ a default value, or `| undefined` / `| null` in its type; route parameters are always present.
1908
+ Anything else (objects, other unions, tuples, nested arrays) is an error for `@Param` / `@Headers`
1909
+ / `@Cookie` / `@SignedCookie` and a ZNIGHT017 warning for `@Query` (the raw value is passed): use a
1910
+ schema. `@Cookie()` (every cookie) without schema is fine typed `Record<string, string>` or
1911
+ `unknown`; other types are a ZNIGHT017 warning.
1912
+
1913
+ `@Sse` handlers return an `AsyncIterable`, an `Iterable` or a `ReadableStream` (or an
1914
+ `HttpResponse`): an annotated return type that is (after `Promise`) a primitive, a literal or `void`
1915
+ — or a union with one, such as `SseEvent[] | undefined` — is an error. References, arrays, type
1916
+ parameters, `unknown` and sync generator methods (`*events()`) are accepted.
1917
+
1918
+ Upload parameters are typed `File` (`@UploadedFile`) or `File[]` / `readonly File[]`
1919
+ (`@UploadedFiles`); `Blob`, `unknown`, `any`, `node:buffer`'s `File` and missing annotations are
1920
+ accepted too. `file?: File` / `File | undefined` makes an upload optional (`required: false`).
1921
+
1922
+ ### Diagnostics
1923
+
1924
+ - **ZNIGHT005** (invalid decorator usage): a decorator on the wrong target, outside a controller or
1925
+ on a method that is not a route; applied twice; invalid or non-static arguments (`@HttpCode(42)`,
1926
+ bad header names, a schema that is not a reference to an exported binding); a handler that is
1927
+ static, `private`/`protected`, `#private`, an accessor or has a computed name; a handler parameter
1928
+ without HTTP decorator, or with a look-alike imported from another package (`Headers` / `Ctx` of
1929
+ `@znight/messaging`: the error names the import to use); `@Validate(S)` combined with
1930
+ `@Body(S2)`; an invalid `createRouteDecorator` declaration; upload parameters: a type other than
1931
+ `File` / `File[]`, an invalid field or option (unknown key, `maxSize` that is not a size,
1932
+ `maxFiles` on `@UploadedFile`, invalid media type patterns), `{ required: false }` on a parameter
1933
+ typed `File`, two parameters reading the same field, a parameter without field combined with
1934
+ others; an invalid `@BodyLimit`. `@Res()`, `@Body(SomeClass)` (a class is not a schema) and upload
1935
+ parameters on `GET` / `HEAD` / `DELETE` / `OPTIONS` routes are warnings. On a class owned by
1936
+ another extension (a `@Consumer`), HTTP decorators are reported without suggesting
1937
+ `@Controller()`, and those on the parameters of its handlers (`@OnMessage`) are left to that
1938
+ extension (`@znight/messaging` reports a `Headers` imported from `@znight/http`), unless the
1939
+ parameter is also read through its decorators or is optional. Route decorator groups: invalid
1940
+ `createRouteDecorator` options (at the usage, related to the definition), two decisions of one
1941
+ group on a class or a method, two definitions of one key with different options, decorators of
1942
+ one group disagreeing on `required` (at the second definition). Cookies: an invalid cookie name
1943
+ (not an RFC 9110 token), `@SignedCookie` without name, `@Cookie` and `@SignedCookie` on one
1944
+ parameter. `@Sse`: applied twice or combined with another route decorator, invalid options
1945
+ (unknown key, `heartbeat` / `maxDuration` below 1 000 ms, negative `retry`, non-static), `@HttpCode`,
1946
+ `@Header('content-type')` or `@Res()` on an `@Sse` method, a primitive return type; a request body
1947
+ or uploads on an `@Sse` route are a warning (EventSource sends `GET` without body: use `@Post()`
1948
+ and `HttpResponse.sse()`).
1949
+ - **ZNIGHT007** (invalid route): path grammar (base path, route path and their combination); a
1950
+ `:param` not consumed by `@Param('param')` (a `@Param()`, `@Ctx()` or `@Req()` parameter consumes
1951
+ them all); a `@Param('x')` without `:x` in the path; a `@Param` / `@Headers` / `@Cookie` /
1952
+ `@SignedCookie` type that cannot be coerced; two routes with the same method and path shape
1953
+ anywhere in the application (an `@Sse` route is a `GET` route).
1954
+ - **ZNIGHT017** (unvalidated input): a non-primitive `@Body()` / `@Query()` / `@Cookie()` without schema (`string`,
1955
+ `unknown`, `Uint8Array`, `FormData`, `Record<string, string | string[]>` — the text fields of a
1956
+ form — are fine). A warning, an error with the compiler option `strictValidation`.
1957
+ - **ZNIGHT003** (scope mismatch): a singleton controller uses a request-scoped guard or interceptor
1958
+ (directly or through transient ones). Make the controller request-scoped, or the guard a
1959
+ singleton (it receives the `HttpContext` as argument).
1960
+ - **ZNIGHT023** (missing route decision): a route without decision of a required group, neither on
1961
+ itself nor on its controller, located at the route decorator and related to the group definition
1962
+ and the controller, with a hint listing the decorators of the group (see
1963
+ [Route decisions](#route-decisions)). The same code fails the boot when no guard enforces a
1964
+ decision.
1965
+
1966
+ ```
1967
+ ✗ ZNIGHT007: Route GET /users/:id declares ":id" but UserController.find has no @Param('id') parameter
1968
+ --> src/users/user.controller.ts:5:8
1969
+ |
1970
+ 5 | @Get('/:id')
1971
+ | ^^^^^^
1972
+ = hint: Add a parameter: @Param('id') id: string
1973
+
1974
+ ✗ ZNIGHT005: @HttpCode has no effect on @Sse route OrderEventsController.events: an event stream answers 200
1975
+ --> src/orders/order-events.controller.ts:14:3
1976
+ |
1977
+ 14 | @HttpCode(202)
1978
+ | ^^^^^^^^^^^^^^
1979
+ = hint: Throw an HttpException or return an HttpResponse from the handler for other statuses
1980
+ ```
1981
+
1982
+ ### Route decorators
1983
+
1984
+ A decorator is a route metadata decorator when it is a `const` initialised with
1985
+ `createRouteDecorator(key, options?)` where `key` and `options` (`{ group?, required? }`) are
1986
+ statically evaluable (literals, `const` references). Its argument is evaluated at build time:
1987
+ literals, `const` references (across files and packages), simple expressions, and references to
1988
+ exported bindings (classes, objects), which the generated module imports. Without argument the
1989
+ value is `true`. Route decorators must be declared in analysed sources (your application, or a
1990
+ provider library published with its sources): from a declaration file (`.d.ts`) the key and the
1991
+ options cannot be read and the compiler reports it. The compiler knows every definition of the
1992
+ analysed sources, applied or not: a required group defined anywhere needs a decision on every
1993
+ route.
1994
+
1995
+ ### Limits of the compiler
1996
+
1997
+ - Routes are the methods declared by the controller class itself: methods inherited from a base
1998
+ class are not routes (the base class decorators are reported).
1999
+ - The `type` of `params[i]` (informational) is the declared type of coerced values, formatted for
2000
+ humans.
2001
+ - TypeScript has no integer type: `coerce.integer` is never inferred, use a schema
2002
+ (`z.coerce.number().int()`) or a check in the handler.
2003
+
2004
+ ## Compiler contract
2005
+
2006
+ This section is the stable contract between the runtime and the compiler extension
2007
+ (`@znight/http/compiler`). Generated code only uses what is listed here.
2008
+
2009
+ ### Metadata (`metadata.extensions.http`)
2010
+
2011
+ Types: `HttpMetadata`, `HttpControllerDefinition`, `HttpRouteDefinition`, `HttpParamDefinition`,
2012
+ `HttpUploadDefinition` (exported from `@znight/http`; architecture §9.1).
2013
+
2014
+ ```ts
2015
+ extensions: {
2016
+ http: {
2017
+ controllers: [
2018
+ {
2019
+ token: 'src/users/user.controller.ts#UserController', // provider token (@Controller registers the provider)
2020
+ basePath: '/users', // normalizePath(controller path), '/' when absent
2021
+ guards: ['src/auth/auth.guard.ts#AuthGuard'], // @UseGuard on the class, provider tokens, in order
2022
+ interceptors: [], // @UseInterceptor on the class
2023
+ meta: { roles: ['admin'] }, // createRouteDecorator values on the class (one per group)
2024
+ routes: [
2025
+ {
2026
+ method: 'GET', // 'GET'|'POST'|'PUT'|'PATCH'|'DELETE'|'HEAD'|'OPTIONS'|'ALL'
2027
+ path: '/:id', // normalizePath(decorator path)
2028
+ fullPath: '/users/:id', // joinPaths(basePath, path) — WITHOUT the plugin prefix
2029
+ handler: 'find',
2030
+ status: 202, // @HttpCode, omitted when absent
2031
+ headers: { 'cache-control': 'no-store' }, // @Header (lower-case names), omitted when absent
2032
+ meta: { public: true }, // route createRouteDecorator values (one per group, nearest wins)
2033
+ guards: [], // @UseGuard on the method
2034
+ interceptors: [], // @UseInterceptor on the method (top-most first)
2035
+ params: [{ index: 0, source: 'param', name: 'id', type: 'number' }],
2036
+ rawResponse: true, // only when the handler takes @Res()
2037
+ sse: { retry: 5000 }, // only for @Sse routes (GET): `{}` without options, heartbeat 0 = disabled
2038
+ invoke: (c: UserController, ctx: HttpContext) => c.find(coerce.number(ctx.params['id'], 'param "id"')),
2039
+ },
2040
+ {
2041
+ method: 'POST',
2042
+ path: '/:id/avatar',
2043
+ fullPath: '/users/:id/avatar',
2044
+ handler: 'avatar',
2045
+ meta: {},
2046
+ guards: [],
2047
+ interceptors: [],
2048
+ params: [
2049
+ { index: 0, source: 'param', name: 'id', type: 'string' },
2050
+ { index: 1, source: 'file', name: 'avatar' }, // 'file' / 'files', name = field
2051
+ { index: 2, source: 'files', name: 'extra', optional: true },
2052
+ ],
2053
+ bodyLimit: 10485760, // @BodyLimit (route, else controller), bytes; omitted when absent
2054
+ uploads: [ // one per upload parameter, in parameter order; omitted when none
2055
+ { field: 'avatar', required: true, maxSize: 2097152, mimeTypes: ['image/*'] },
2056
+ { field: 'extra', multiple: true, maxFiles: 3 },
2057
+ ],
2058
+ invoke: (c: UserController, ctx: HttpContext) =>
2059
+ c.avatar(coerce.string(ctx.params['id'], 'param "id"'), uploads.file(ctx, 'avatar'), uploads.optionalFiles(ctx, 'extra')),
2060
+ },
2061
+ ],
2062
+ },
2063
+ ],
2064
+ // route decorator groups the runtime needs (required, several keys, or named differently from
2065
+ // their key), sorted, keys sorted; omitted when there is none (byte-identical output otherwise)
2066
+ groups: { access: { keys: ['authenticated', 'public', 'roles'], required: true } },
2067
+ },
2068
+ }
2069
+ ```
2070
+
2071
+ Rules:
2072
+ - Optional fields (`status`, `headers`, `bodyLimit`, `uploads`, `rawResponse`, `sse`, param
2073
+ `name`/`type`/`optional`/`schema`, upload `field`/`multiple`/`required`/`maxSize`/`maxFiles`/
2074
+ `mimeTypes`) are omitted when absent (`exactOptionalPropertyTypes`).
2075
+ - `meta` values: a `createRouteDecorator` call without argument stores `true`. A decorator is a
2076
+ route decorator when its callee is a `const` initialised with a call to
2077
+ `@znight/http#createRouteDecorator`; its `key` (first argument) and its options (second
2078
+ argument) are static. A target holds at most one key per group.
2079
+ - `groups` (`HttpRouteGroupDefinition`: `{ keys, required }`): a key belongs to one group at most;
2080
+ a key listed nowhere is its own group, not required. A group name is a letter followed by
2081
+ letters, digits, `_`, `.` or `-`, or the single key of the group (a decorator without `group`). The plugin computes the effective metadata
2082
+ of each route once at boot (the controller's entries whose group the route does not decide, then
2083
+ the route's entries). Malformed groups, or two keys of one group on a target, are ZNIGHT007 at
2084
+ boot.
2085
+ - `@Validate(S)` applies `S` to the `@Body()` parameter (`@Validate(S)` + `@Body()` ≡ `@Body(S)`);
2086
+ `@Validate(S)` + `@Body(S2)` is ZNIGHT005. Without a `@Body()` parameter the body is still
2087
+ validated (and discarded): `invoke: async (c, ctx) => { await validate(S, await ctx.parseBody(), 'body'); return c.m() }`.
2088
+ - The plugin parses the body before the interceptors when `params` contains a `source: 'body'`
2089
+ entry or `uploads` is not empty; `ctx.body` is `undefined` otherwise (generated code then uses
2090
+ `await ctx.parseBody()`).
2091
+ - `uploads` (`HttpUploadDefinition`: `{ field?, multiple?, required?, maxSize?, maxFiles?, mimeTypes? }`)
2092
+ drives the multipart parsing of the route: files are only accepted in these fields (any field for
2093
+ an entry without `field`), `maxSize` defaults to the plugin `uploads.maxFileSize`, `maxFiles` to
2094
+ `uploads.maxFiles` (always 1 without `multiple`), `mimeTypes` are patterns (`type/subtype`,
2095
+ `type/*`, `*/*`). At most one entry per field, and an entry without field stands alone (checked
2096
+ at boot, ZNIGHT007). `required` is informational: presence is checked by `invoke` (`uploads.file`
2097
+ vs `uploads.optionalFile`). Sizes are bytes (`Infinity` allowed): the compiler converts
2098
+ `'2mb'`-like options with `parseByteSize` (binary units).
2099
+ - `bodyLimit` (bytes, `Infinity` allowed) overrides the plugin `bodyLimit`; without it, routes with
2100
+ `uploads` use `bodyLimit + Σ maxSize × maxFiles`.
2101
+ - `sse` (`HttpSseDefinition`: `{ heartbeat?, retry?, maxDuration? }`, ms; `heartbeat: 0` disables
2102
+ the heartbeat, otherwise ≥ 1 000; `maxDuration` ≥ 1 000): the handler result is served as an event
2103
+ stream (an `HttpResponse` or a Web `Response` is sent as usual). Only on `GET` routes; invalid
2104
+ values are ZNIGHT007 at boot.
2105
+ - Param sources (`HttpParamSource`): `param`, `query`, `body`, `headers`, `request`, `response`,
2106
+ `context`, `file`, `files`, `cookie` and `signedCookie` (`name`: the cookie name; absent for
2107
+ `@Cookie()`). A route with a `signedCookie` parameter fails the boot without `cookies.secrets`
2108
+ (ZNIGHT006).
2109
+ - Guards, interceptors and controllers referenced by tokens must be providers (the compiler
2110
+ registers injectable helper classes, §6.5 `resolveClassRef`). Request-scoped guards/interceptors
2111
+ are allowed.
2112
+ - Path validation and joining must use `@znight/http/path` (pure module, safe to import from the
2113
+ compiler): `joinPaths`, `normalizePath`, `checkRoutePath` (→ `RoutePathIssue | undefined` for
2114
+ ZNIGHT007), `parseRoutePath(path).params` (to check `:param` ↔ `@Param` names) and
2115
+ `parseRoutePath(path).shape` (duplicate detection: same method + same shape = ZNIGHT007; `ALL`
2116
+ conflicts only with another `ALL`).
2117
+
2118
+ ### `invoke` generation
2119
+
2120
+ `invoke(controller, ctx)` extracts every argument in parameter order and calls the handler. It is
2121
+ `async` and `await`s every `validate()` call when the route has at least one schema; otherwise it
2122
+ is synchronous. It returns the handler result as is (promises included).
2123
+
2124
+ | Parameter | Generated expression |
2125
+ | --------------------------------------- | ------------------------------------------------------------------------------ |
2126
+ | `@Param('id') id: T` | `coerce.X(ctx.params['id'], 'param "id"')` (see below) |
2127
+ | `@Param('id', S)` | `await validate(S, ctx.params['id'], 'param "id"')` |
2128
+ | `@Param()` | `ctx.params as never` · with schema: `await validate(S, ctx.params, 'params')` |
2129
+ | `@Query('page') page: T` | `coerce.X(ctx.query['page'], 'query "page"')` |
2130
+ | `@Query('page', S)` | `await validate(S, ctx.query['page'], 'query "page"')` |
2131
+ | `@Query()` / `@Query(S)` | `ctx.query as never` / `await validate(S, ctx.query, 'query')` |
2132
+ | `@Body()` / `@Body(S)` / `@Validate(S)` | `ctx.body as never` / `await validate(S, ctx.body, 'body')` |
2133
+ | `@Headers('X-Api-Key') key: T` | `coerce.X(ctx.headers['x-api-key'], 'header "x-api-key"')` (name lower-cased) |
2134
+ | `@Headers()` | `ctx.headers as never` |
2135
+ | `@Cookie('sid') sid: T` | `coerce.X(ctx.cookies['sid'], 'cookie "sid"')` (or `coerce.optional(...)`) |
2136
+ | `@Cookie('sid', S)` | `await validate(S, ctx.cookies['sid'], 'cookie "sid"')` |
2137
+ | `@Cookie()` / `@Cookie(S)` | `ctx.cookies as never` / `await validate(S, ctx.cookies, 'cookies')` |
2138
+ | `@SignedCookie('sid') sid: T` | `coerce.X(ctx.signedCookie('sid'), 'cookie "sid"')` |
2139
+ | `@SignedCookie('sid', S)` | `await validate(S, ctx.signedCookie('sid'), 'cookie "sid"')` |
2140
+ | `@Req()` / `@Res()` | `ctx.request as never` / `ctx.response as never` (+ `rawResponse: true`) |
2141
+ | `@UploadedFile('f') f: File` | `uploads.file(ctx, 'f')` · optional: `uploads.optionalFile(ctx, 'f')` |
2142
+ | `@UploadedFiles('f') f: File[]` | `uploads.files(ctx, 'f')` · optional: `uploads.optionalFiles(ctx, 'f')` |
2143
+ | `@UploadedFile()` / `@UploadedFiles()` | the same helpers with `undefined` as field (any field) |
2144
+ | `@Ctx()` | `ctx` |
2145
+
2146
+ `coerce.X` by declared type (`T`):
2147
+
2148
+ | Type | Expression |
2149
+ | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
2150
+ | `string` | `coerce.string(v, label)` |
2151
+ | `number` | `coerce.number(v, label)` |
2152
+ | `boolean` | `coerce.boolean(v, label)` |
2153
+ | `bigint` | `coerce.bigint(v, label)` |
2154
+ | `Date` | `coerce.date(v, label)` |
2155
+ | literal union `'a' \| 'b'` / `1 \| 2` | `coerce.enum(v, label, ['a', 'b'])` |
2156
+ | `T[]` / `readonly T[]` / `Array<T>` | `coerce.array(v, label, ITEM)` where `ITEM` is `coerce.number` or `(v, l) => coerce.enum(v, l, [...])` |
2157
+ | optional (`?`, `\| undefined`, default value) | `coerce.optional(v, label, ITEM)` where `ITEM` is the coercer of the non-optional type (arrays: `(v, l) => coerce.array(v, l, ITEM)`) |
2158
+ | other types (objects, unions...) | ZNIGHT007 for `@Param`/`@Headers`; ZNIGHT017 for `@Query`/`@Body` without schema |
2159
+
2160
+ Labels are `param "<name>"`, `query "<name>"`, `header "<lower-case name>"`, `cookie "<name>"`;
2161
+ validation locations are the same, or `params`, `query`, `body`, `cookies` for whole objects.
2162
+ Coercion errors of labels starting with `cookie ` never echo the received value, and validation
2163
+ errors of the locations `cookie "<name>"` and `cookies` replace the issue messages with
2164
+ `Invalid value` (paths kept). A route parameter is always present
2165
+ (the route matched), so `@Param('id') id?: string` is still `coerce.string`.
2166
+
2167
+ ### Runtime helpers (exported from `@znight/http`)
2168
+
2169
+ ```ts
2170
+ type Coercer<T> = (value: unknown, label: string) => T
2171
+
2172
+ const coerce: {
2173
+ string: Coercer<string>
2174
+ number: Coercer<number>
2175
+ integer: Coercer<number>
2176
+ boolean: Coercer<boolean>
2177
+ bigint: Coercer<bigint>
2178
+ date: Coercer<Date>
2179
+ enum<const T extends readonly (string | number | boolean)[]>(value: unknown, label: string, values: T): T[number]
2180
+ array<T>(value: unknown, label: string, item: Coercer<T>): T[]
2181
+ optional<T>(value: unknown, label: string, item: Coercer<T>): T | undefined
2182
+ }
2183
+
2184
+ function validate<S extends StandardSchemaV1>(
2185
+ schema: S,
2186
+ value: unknown,
2187
+ location: string,
2188
+ ): InferSchemaOutput<S> | Promise<InferSchemaOutput<S>>
2189
+
2190
+ const uploads: {
2191
+ file(ctx: HttpContext, field: string | undefined): File // 400 `Missing file "field"` when absent
2192
+ optionalFile(ctx: HttpContext, field: string | undefined): File | undefined
2193
+ files(ctx: HttpContext, field: string | undefined): File[] // 400 `Missing files "field"` when empty
2194
+ optionalFiles(ctx: HttpContext, field: string | undefined): File[]
2195
+ }
2196
+ ```
2197
+
2198
+ Every coercion failure throws `BadRequestException` (400); `validate` throws `ValidationException`
2199
+ (400, `details: [{ path, message }]`) and `TypeError` when `schema` is not a Standard Schema; the
2200
+ `uploads` helpers read `ctx.files` (`undefined` field: every field) and throw `BadRequestException`
2201
+ for missing required files (`Expected a file in field "f", received a text value` when the field
2202
+ was sent as text). Generated modules import them with
2203
+ `import { coerce, uploads, validate } from '@znight/http'` and the context type with
2204
+ `import type { HttpContext } from '@znight/http'`.
2205
+
2206
+ ### Manifest
2207
+
2208
+ ```jsonc
2209
+ "znight": {
2210
+ "apiVersion": 1,
2211
+ "compiler": "@znight/http/compiler",
2212
+ "provides": [
2213
+ { "export": "HttpContext", "plugin": "HttpPlugin", "scope": "request" },
2214
+ { "export": "RequestId", "plugin": "HttpPlugin", "scope": "request" },
2215
+ { "export": "HttpPlugin", "plugin": "HttpPlugin", "scope": "singleton" }
2216
+ ]
2217
+ }
2218
+ ```
2219
+
2220
+ Tokens: `@znight/http#HttpContext`, `@znight/http#RequestId`, `@znight/http#HttpPlugin`
2221
+ (`HttpTokens`). `RequestId` is a branded string type alias. Decorator identities:
2222
+ `@znight/http#Controller`, `#Get`, `#Post`, `#Put`, `#Patch`, `#Delete`, `#Head`, `#Options`,
2223
+ `#All`, `#Param`, `#Query`, `#Body`, `#Headers`, `#Req`, `#Res`, `#Ctx`, `#HttpCode`, `#Header`,
2224
+ `#BodyLimit`, `#UploadedFile`, `#UploadedFiles`, `#UseGuard`, `#UseInterceptor`, `#Validate`,
2225
+ `#Cookie`, `#SignedCookie`, `#Sse`, `#createRouteDecorator`. `@Param`/`@Query`/`@Cookie`/
2226
+ `@SignedCookie` take either a static name (string) and an optional schema, or a schema alone
2227
+ (`@SignedCookie` requires the name); `@Sse` a static path and/or static options; `@UploadedFile`/`@UploadedFiles` a static field and/or static options;
2228
+ `@BodyLimit` a static size (class or method).
2229
+
2230
+ ## Compiler contract for other extensions
2231
+
2232
+ Other compiler extensions (`@znight/openapi`) read the data stored by the http extension through
2233
+ `ctx.graph.extensions` (`{ name: 'http', stored }`). The types are exported from
2234
+ `@znight/http/compiler` and follow the lockstep release line (a matching compiler is guaranteed by
2235
+ ZNIGHT018):
2236
+
2237
+ | Type | Content |
2238
+ | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2239
+ | `HttpControllerData` | `token`, `identity` (canonical identity of the class), `displayName`, `rawBasePath`, `basePath`, `guards` / `interceptors` (`ClassRef`), `meta`, `routes`, `location`, `pathLocation` |
2240
+ | `HttpRouteData` | `method`, `rawPath`, `path`, `fullPath` (without prefix), `handler`, `status`, `headers`, `meta`, `guards`, `interceptors`, `params`, `complete` (`false`: skip, http reported an error), `bodySchema`, `bodyLimit`, `rawResponse`, `sse` (`SseRouteData`, `@Sse` routes), `location`, `pathLocation` |
2241
+ | `HttpParamData` | `index`, `name`, `source` (`cookie` / `signedCookie` for cookies, `key` = the cookie name), `key`, `type`, `optional`, `schema` (`ValueRef`), `coercion` (`ParamCoercion`: plan and cast), `upload`, `defaultValue` (statically evaluable default value: `page = 1` → `1`, absent otherwise), `location` |
2242
+ | `UploadData` | `field`, `multiple`, `required`, `maxSize`, `maxFiles`, `mimeTypes`, `cast` |
2243
+ | `MetaEntry` | `key`, `value` (`StaticValue`), `group`, `required`, `decorator` (name of the `const`), `identity` (canonical identity of the decorator `const`: `src/auth/access.ts#Public`), `definition` and `location` |
2244
+
2245
+ Controllers are stored in identity order of the classes; sort them by `token` for deterministic
2246
+ output. The effective metadata of a route is the controller's entries whose `group` has no entry on
2247
+ the route, then the route's entries.
2248
+
2249
+ ## Writing an adapter
2250
+
2251
+ An adapter implements `HttpAdapter` (exported types): it routes requests to the handlers given by
2252
+ the plugin with its native router and translates I/O. The plugin does everything else.
2253
+
2254
+ ```ts
2255
+ interface HttpAdapter {
2256
+ readonly name: string
2257
+ registerRoute(route: AdapterRoute): void // { method (never 'ALL'), path: RoutePath, handler }
2258
+ setFallbackHandler(handler: FallbackHandler): void // unmatched requests (404/405/OPTIONS) and framework errors
2259
+ listen(port: number, host: string): Promise<HttpServerAddress>
2260
+ close(options?: { force?: boolean }): Promise<void>
2261
+ inject(request: InjectRequest): Promise<InjectResponse>
2262
+ injectStream?(request: InjectStreamRequest): Promise<InjectStreamResponse> // optional
2263
+ }
2264
+ ```
2265
+
2266
+ - Translate `route.path` with `@znight/http/path` (`toFastifyPath`, `toHonoPath`, `toRegExpSource`)
2267
+ or match it with `matchRoutePath` / the segment trie. Routes arrive sorted (HEAD first, most
2268
+ specific first), ready for first-match routers.
2269
+ - Build an `AdapterRequest` (`method`, raw `url`, lower-case `headers`, `ip`, `readBody(limit)`,
2270
+ native `raw`/`rawResponse`; also `remoteAddress`, the socket peer, **never** read from headers,
2271
+ where `trustProxy` starts walking `x-forwarded-for`, and `encrypted` for TLS connections, which
2272
+ gives `ctx.protocol`) and write the `AdapterReply` (`status`, `headers` — already validated, every
2273
+ name and value can be sent —, `body`: bytes, a stream or `null`; write nothing when `raw`).
2274
+ Never rethrow a failure to write a reply into the framework's error handler (the request would be
2275
+ served twice): release the body and answer a plain 500.
2276
+ - When the native router misses a request (it normalises paths...), call the fallback handler: the
2277
+ plugin routes the raw path itself.
2278
+ - Optionally implement `streamBody(limit)`: the body as a pull-based Web stream (pause the source
2279
+ while the consumer does not read), erroring with `PayloadTooLargeException` past `limit` bytes
2280
+ (check `content-length` first) and with `BadRequestException` when the client aborts; cancelling
2281
+ it must **discard** the rest of the body without closing the connection (the error response is
2282
+ still written). Without it, multipart bodies are buffered with `readBody` before being parsed and
2283
+ `ctx.bodyStream()` streams the buffered bytes. `hasBody()` tells whether a request without
2284
+ `content-length` / `transfer-encoding` has a body (Web `Request`s built in memory; `false` for a
2285
+ request read from an HTTP/1.1 connection). A body stream the request did not finish is cancelled
2286
+ by the plugin once the request is over.
2287
+ - A stream `body` MUST be read to its end or cancelled (client gone, response not sent): the request
2288
+ scope and the in-flight count are released then. Files are such streams, reading the `Blob` given
2289
+ in `reply.blob` (with its `content-length`): an adapter may send that `Blob` natively instead
2290
+ (`new Response(blob)` on `Bun.serve()`: `sendfile`), and then MUST cancel `body` once the server
2291
+ holds the blob (its file opened), which releases the request scope.
2292
+ - `inject()` accepts `FormData` and `Blob` bodies (encode them with `new Response(body)`), a
2293
+ `cookies` record (append `name=encodeCookieValue(value)` pairs to the `cookie` header; the plugin
2294
+ signs `signedCookies` before calling the adapter) and returns the raw body in
2295
+ `InjectResponse.bytes` and the parsed `set-cookie` lines in `InjectResponse.cookies`
2296
+ (`parseSetCookie`). The built-in adapters build `inject()` on `injectStream()` and reject an event
2297
+ stream (`text/event-stream`) with a hint instead of buffering it forever.
2298
+ - Optionally implement `injectStream(request)`: an in-memory request resolving at the response
2299
+ headers, its body (`ReadableStream<Uint8Array>`) read as it is produced; cancelling the body, or
2300
+ aborting `request.signal`, must disconnect the client (the server sees `onAbort` and a cancelled
2301
+ reply body). `HttpPlugin.injectStream()` / `injectSse()` require it.
2302
+ - Optionally implement `AdapterRequest.onAbort(listener)`: call the listener once when the client
2303
+ disconnects before the response was fully written (on the next microtask when that already
2304
+ happened), never after a complete response. Without it, `ctx.signal` only aborts when the reply
2305
+ body is cancelled or an event stream is closed by the server.
2306
+ - `AdapterReply.eventStream` marks a long-lived event stream (any streamed `text/event-stream`
2307
+ body): send the headers without waiting for the first chunk, do not apply idle or inactivity
2308
+ timeouts to the response while it is sent (`response.setTimeout(0)`, Bun's `server.timeout()`),
2309
+ write each chunk as soon as it is read, and keep compression and payload-rewriting hooks away
2310
+ from it (Fastify: `reply.hijack()`).
2311
+ - Repeated `Cookie` request header fields must reach the plugin joined with `; ` (Node.js and the
2312
+ Fetch `Headers` of Node.js and Bun do it): rebuild the header from the raw values if your runtime
2313
+ joins them with `, `.
2314
+ - Parameters, query, body parsing, HEAD bodies and errors are handled by the plugin: do not parse
2315
+ bodies or decode parameters in the adapter.
2316
+
2317
+ ## Limitations
2318
+
2319
+ - No built-in compression or static files yet: use the native ecosystem through the Fastify /
2320
+ Express / Hono adapters (their middleware runs before Znight routes).
2321
+ - No sessions, encrypted cookies or CSRF middleware (built-in sessions are out of scope: the
2322
+ [security guide](https://github.com/znight-org/znight/blob/main/docs/guide/security.md) has
2323
+ cookie-session and CSRF recipes), and no cookie jar in `inject()` (tests pass cookies explicitly).
2324
+ - Event streams: `EventChannel` is in-process (no cross-instance fan-out), hooks measure the time
2325
+ to open a stream (no stream-lifetime hook), and there is no built-in cap on open streams (count
2326
+ them with `plugin.streams` in a guard).
2327
+ - No per-route CORS settings, no Private Network Access, and the `Forwarded` header is not read.
2328
+ - Request bodies read with `@Body()` and uploaded files are buffered in memory (within the limits):
2329
+ stream large bodies with `ctx.bodyStream()` or upload to object storage (see
2330
+ [Large files](#large-files)). There is no streaming API for multipart parts yet.
2331
+ - Compressed request bodies are rejected (415).
2332
+ - `HttpResponse.file()` honours a single byte range (several ranges get the whole file) and no
2333
+ `Cache-Control` by default (add it with `.withHeader()`).
2334
+ - `inject()` with the `node` and `express` adapters needs `light-my-request`.