@znight/http 0.0.0-stage → 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +2333 -2
- package/dist/adapter-CGSceVEN.d.mts +876 -0
- package/dist/compiler.d.mts +156 -0
- package/dist/compiler.mjs +2180 -0
- package/dist/compiler.mjs.map +1 -0
- package/dist/express.d.mts +39 -0
- package/dist/express.mjs +135 -0
- package/dist/express.mjs.map +1 -0
- package/dist/fastify.d.mts +33 -0
- package/dist/fastify.mjs +191 -0
- package/dist/fastify.mjs.map +1 -0
- package/dist/hono.d.mts +49 -0
- package/dist/hono.mjs +319 -0
- package/dist/hono.mjs.map +1 -0
- package/dist/index.d.mts +1254 -0
- package/dist/index.mjs +5618 -0
- package/dist/index.mjs.map +1 -0
- package/dist/node-BUnkdSsa.mjs +183 -0
- package/dist/node-BUnkdSsa.mjs.map +1 -0
- package/dist/node-io-BY1ZDLRQ.mjs +1566 -0
- package/dist/node-io-BY1ZDLRQ.mjs.map +1 -0
- package/dist/node.d.mts +30 -0
- package/dist/node.mjs +1 -0
- package/dist/path-JI87C4k8.d.mts +109 -0
- package/dist/path.d.mts +2 -0
- package/dist/path.mjs +245 -0
- package/dist/path.mjs.map +1 -0
- package/dist/size-D8rsQcV0.mjs +184 -0
- package/dist/size-D8rsQcV0.mjs.map +1 -0
- package/dist/utils-DCbu3a4w.mjs +27 -0
- package/dist/utils-DCbu3a4w.mjs.map +1 -0
- package/package.json +142 -4
- package/src/adapter.ts +240 -0
- package/src/adapters/express.ts +201 -0
- package/src/adapters/fastify.ts +280 -0
- package/src/adapters/hono.ts +488 -0
- package/src/adapters/node-io.ts +368 -0
- package/src/adapters/node.ts +135 -0
- package/src/adapters/shared.ts +508 -0
- package/src/body.ts +135 -0
- package/src/coerce.ts +195 -0
- package/src/compiler/analyze.ts +1513 -0
- package/src/compiler/ast.ts +90 -0
- package/src/compiler/coercion.ts +267 -0
- package/src/compiler/generate.ts +374 -0
- package/src/compiler/index.ts +76 -0
- package/src/compiler/model.ts +260 -0
- package/src/compiler/route-decorators.ts +405 -0
- package/src/compiler/validate.ts +288 -0
- package/src/context.ts +533 -0
- package/src/cookie-settings.ts +68 -0
- package/src/cookie-signer.ts +106 -0
- package/src/cookies.ts +726 -0
- package/src/cors.ts +349 -0
- package/src/decisions.ts +331 -0
- package/src/decorators.ts +486 -0
- package/src/event-channel.ts +197 -0
- package/src/exceptions.ts +257 -0
- package/src/files.ts +429 -0
- package/src/headers.ts +39 -0
- package/src/index.ts +185 -0
- package/src/inject-sse.ts +309 -0
- package/src/interfaces.ts +101 -0
- package/src/metadata.ts +162 -0
- package/src/mime.ts +157 -0
- package/src/multipart.ts +597 -0
- package/src/option-error.ts +31 -0
- package/src/path.ts +288 -0
- package/src/pipeline.ts +918 -0
- package/src/plugin-routes.ts +188 -0
- package/src/plugin.ts +1432 -0
- package/src/proxy.ts +236 -0
- package/src/query.ts +30 -0
- package/src/response.ts +240 -0
- package/src/router.ts +119 -0
- package/src/security-headers.ts +161 -0
- package/src/serialize.ts +264 -0
- package/src/size.ts +66 -0
- package/src/sse-parse.ts +194 -0
- package/src/sse.ts +650 -0
- package/src/standard-schema.ts +81 -0
- package/src/status.ts +78 -0
- package/src/stream.ts +200 -0
- package/src/tokens.ts +21 -0
- package/src/uploads.ts +176 -0
- package/src/utils.ts +31 -0
- package/src/validate.ts +65 -0
package/README.md
CHANGED
|
@@ -1,3 +1,2334 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @znight/http
|
|
2
2
|
|
|
3
|
-
|
|
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`.
|