@znight/http 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marker decorators of `@znight/http`. Like every Znight decorator they do nothing at runtime: the
|
|
3
|
+
* compiler extension `@znight/http/compiler` reads them from the source code and generates the
|
|
4
|
+
* routes (`metadata.extensions.http`). Arguments marked _static_ must be statically evaluable
|
|
5
|
+
* (literals, `const` references); _value_ arguments (schemas, guards...) may also be references to
|
|
6
|
+
* exported bindings. See docs/internals/architecture.md §4.6 and §5.
|
|
7
|
+
*/
|
|
8
|
+
import type { ClassMarker, MethodMarker, ParameterMarker, Scope } from '@znight/core'
|
|
9
|
+
import type { HttpContext } from './context.ts'
|
|
10
|
+
import type { GuardClass, HttpInterceptorClass } from './interfaces.ts'
|
|
11
|
+
import type { ByteSize } from './size.ts'
|
|
12
|
+
import type { SseOptions } from './sse.ts'
|
|
13
|
+
import type { StandardSchemaV1 } from './standard-schema.ts'
|
|
14
|
+
import type { UploadedFileOptions, UploadedFilesOptions } from './uploads.ts'
|
|
15
|
+
|
|
16
|
+
function marker(): void {
|
|
17
|
+
// intentionally empty: decorators are compile-time markers
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const classMarker: ClassMarker = marker
|
|
21
|
+
const methodMarker: MethodMarker = marker
|
|
22
|
+
const parameterMarker: ParameterMarker = marker
|
|
23
|
+
const classOrMethodMarker: ClassMarker & MethodMarker = marker
|
|
24
|
+
|
|
25
|
+
/** Options of {@link Controller} (static). */
|
|
26
|
+
export interface ControllerOptions {
|
|
27
|
+
/** Base path of every route of the controller (`/users`). Default: `/`. */
|
|
28
|
+
readonly path?: string
|
|
29
|
+
/** Provider scope. Default: `singleton`; `request` creates one controller per request. */
|
|
30
|
+
readonly scope?: Scope
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Declares a controller: a provider (singleton by default) whose decorated methods are routes.
|
|
35
|
+
*
|
|
36
|
+
* ```ts
|
|
37
|
+
* @Controller('/users')
|
|
38
|
+
* export class UserController {
|
|
39
|
+
* constructor(private readonly users: UserService) {}
|
|
40
|
+
*
|
|
41
|
+
* @Get('/:id')
|
|
42
|
+
* find(@Param('id') id: number) {
|
|
43
|
+
* return this.users.findById(id)
|
|
44
|
+
* }
|
|
45
|
+
* }
|
|
46
|
+
* ```
|
|
47
|
+
*
|
|
48
|
+
* @param pathOrOptions static: base path, or `{ path?, scope? }`.
|
|
49
|
+
*/
|
|
50
|
+
export function Controller(_pathOrOptions?: string | ControllerOptions): ClassMarker {
|
|
51
|
+
return classMarker
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** `GET` route. `path` (static) is relative to the controller path; default: the controller path. */
|
|
55
|
+
export function Get(_path?: string): MethodMarker {
|
|
56
|
+
return methodMarker
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `POST` route (default status 201). */
|
|
60
|
+
export function Post(_path?: string): MethodMarker {
|
|
61
|
+
return methodMarker
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** `PUT` route. */
|
|
65
|
+
export function Put(_path?: string): MethodMarker {
|
|
66
|
+
return methodMarker
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** `PATCH` route. */
|
|
70
|
+
export function Patch(_path?: string): MethodMarker {
|
|
71
|
+
return methodMarker
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** `DELETE` route. */
|
|
75
|
+
export function Delete(_path?: string): MethodMarker {
|
|
76
|
+
return methodMarker
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** `HEAD` route (`GET` routes already answer `HEAD` requests). */
|
|
80
|
+
export function Head(_path?: string): MethodMarker {
|
|
81
|
+
return methodMarker
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** `OPTIONS` route (paths without one answer `OPTIONS` with `204` and an `allow` header). */
|
|
85
|
+
export function Options(_path?: string): MethodMarker {
|
|
86
|
+
return methodMarker
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Route for GET, HEAD, POST, PUT, PATCH, DELETE and OPTIONS (methods without a dedicated route on the same path). */
|
|
90
|
+
export function All(_path?: string): MethodMarker {
|
|
91
|
+
return methodMarker
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A `GET` route serving an event stream (Server-Sent Events). The handler returns an
|
|
96
|
+
* `AsyncIterable`, an `Iterable` or a `ReadableStream` of `SseEvent` (`{ data, event, id, retry,
|
|
97
|
+
* comment }`), or an `HttpResponse` / exception for other outcomes (204 stops `EventSource`). The
|
|
98
|
+
* plugin frames the events, sends heartbeats, stops the source when the client leaves (`ctx.signal`)
|
|
99
|
+
* and closes the stream gracefully on shutdown.
|
|
100
|
+
*
|
|
101
|
+
* ```ts
|
|
102
|
+
* @Sse('/:id/events', { retry: 5_000 })
|
|
103
|
+
* async events(@Param('id') id: string, @Ctx() ctx: HttpContext): Promise<AsyncIterable<SseEvent<OrderEvent>>> {
|
|
104
|
+
* if ((await this.orders.find(id)) === undefined) throw new NotFoundException() // pre-flight checks first
|
|
105
|
+
* return this.hub.subscribe(id, { signal: ctx.signal })
|
|
106
|
+
* }
|
|
107
|
+
* ```
|
|
108
|
+
*
|
|
109
|
+
* Prefer an `async` method returning the source over an `async *` generator: the body of a
|
|
110
|
+
* generator only runs once the 200 and the headers are sent.
|
|
111
|
+
*
|
|
112
|
+
* @param path static: route path (relative to the controller path).
|
|
113
|
+
* @param options static: `{ heartbeat?, retry?, maxDuration? }` (see `SseOptions`).
|
|
114
|
+
*/
|
|
115
|
+
export function Sse(path?: string, options?: SseOptions): MethodMarker
|
|
116
|
+
export function Sse(options: SseOptions): MethodMarker
|
|
117
|
+
export function Sse(_pathOrOptions?: string | SseOptions, _options?: SseOptions): MethodMarker {
|
|
118
|
+
return methodMarker
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Injects a route parameter, coerced to the parameter type (`string`, `number`, `boolean`,
|
|
123
|
+
* `bigint`, `Date`, literal unions) or validated with `schema`. Without arguments: every parameter
|
|
124
|
+
* (`Record<string, string>`).
|
|
125
|
+
*
|
|
126
|
+
* ```ts
|
|
127
|
+
* @Get('/:id')
|
|
128
|
+
* find(@Param('id') id: number) {}
|
|
129
|
+
* ```
|
|
130
|
+
*
|
|
131
|
+
* @param name static: name of a `:param` of the route path.
|
|
132
|
+
* @param schema value: a Standard Schema validating the raw string (its output is injected).
|
|
133
|
+
*/
|
|
134
|
+
export function Param(): ParameterMarker
|
|
135
|
+
export function Param(name: string, schema?: StandardSchemaV1): ParameterMarker
|
|
136
|
+
export function Param(schema: StandardSchemaV1): ParameterMarker
|
|
137
|
+
export function Param(_nameOrSchema?: string | StandardSchemaV1, _schema?: StandardSchemaV1): ParameterMarker {
|
|
138
|
+
return parameterMarker
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Injects a query parameter (coerced to the parameter type; `T[]` accepts repeated and single
|
|
143
|
+
* values; optional when the parameter is optional or has a default) or, without name, the whole
|
|
144
|
+
* query (`Record<string, string | string[]>`), typically validated with a schema.
|
|
145
|
+
*
|
|
146
|
+
* ```ts
|
|
147
|
+
* list(@Query('page') page = 1, @Query('tag') tags: string[] = []) {}
|
|
148
|
+
* search(@Query(SearchQuery) query: SearchQuery) {}
|
|
149
|
+
* ```
|
|
150
|
+
*
|
|
151
|
+
* @param name static: query key.
|
|
152
|
+
* @param schema value: a Standard Schema (its output is injected).
|
|
153
|
+
*/
|
|
154
|
+
export function Query(): ParameterMarker
|
|
155
|
+
export function Query(name: string, schema?: StandardSchemaV1): ParameterMarker
|
|
156
|
+
export function Query(schema: StandardSchemaV1): ParameterMarker
|
|
157
|
+
export function Query(_nameOrSchema?: string | StandardSchemaV1, _schema?: StandardSchemaV1): ParameterMarker {
|
|
158
|
+
return parameterMarker
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Injects a request cookie (decoded, coerced to the parameter type like `@Headers`: `string`,
|
|
163
|
+
* `number`, `boolean`, `bigint`, `Date`, literal unions, enums, branded primitives) or validated
|
|
164
|
+
* with `schema`; without name, every cookie (`Record<string, string>`). An absent cookie answers 400
|
|
165
|
+
* `Missing cookie "name"` unless the parameter is optional. Error messages never echo the value.
|
|
166
|
+
*
|
|
167
|
+
* ```ts
|
|
168
|
+
* // a stale or forged value falls back instead of answering 400 until the cookie expires
|
|
169
|
+
* export const Theme = z.enum(['light', 'dark']).catch('light')
|
|
170
|
+
*
|
|
171
|
+
* read(@Cookie('theme', Theme) theme: 'light' | 'dark', @Cookie('visits') visits = 0) {}
|
|
172
|
+
* ```
|
|
173
|
+
*
|
|
174
|
+
* @param name static: cookie name (an RFC 9110 token).
|
|
175
|
+
* @param schema value: a Standard Schema (its output is injected).
|
|
176
|
+
*/
|
|
177
|
+
export function Cookie(): ParameterMarker
|
|
178
|
+
export function Cookie(name: string, schema?: StandardSchemaV1): ParameterMarker
|
|
179
|
+
export function Cookie(schema: StandardSchemaV1): ParameterMarker
|
|
180
|
+
export function Cookie(_nameOrSchema?: string | StandardSchemaV1, _schema?: StandardSchemaV1): ParameterMarker {
|
|
181
|
+
return parameterMarker
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Injects the **verified** value of a signed cookie (set with `{ signed: true }`, plugin option
|
|
186
|
+
* `cookies.secrets`): absent, tampered or signed with a retired key, the cookie does not exist
|
|
187
|
+
* (400 `Missing cookie "name"` unless the parameter is optional). Same coercion and schema rules as
|
|
188
|
+
* {@link Cookie}. A route reading a signed cookie fails the boot when `cookies.secrets` is not set.
|
|
189
|
+
*
|
|
190
|
+
* @param name static: cookie name.
|
|
191
|
+
* @param schema value: a Standard Schema (its output is injected).
|
|
192
|
+
*/
|
|
193
|
+
export function SignedCookie(_name: string, _schema?: StandardSchemaV1): ParameterMarker {
|
|
194
|
+
return parameterMarker
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Injects the parsed request body, validated with `schema` when given (the schema output is
|
|
199
|
+
* injected: defaults, transforms, stripped keys). A non-primitive body without schema is reported
|
|
200
|
+
* by the compiler (ZNIGHT017).
|
|
201
|
+
*
|
|
202
|
+
* @param schema value: a Standard Schema (Zod, Valibot, ArkType...).
|
|
203
|
+
*/
|
|
204
|
+
export function Body(_schema?: StandardSchemaV1): ParameterMarker {
|
|
205
|
+
return parameterMarker
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Injects a request header (case-insensitive `name`, coerced to the parameter type) or, without
|
|
210
|
+
* name, every header (`Record<string, string | undefined>`).
|
|
211
|
+
*
|
|
212
|
+
* @param name static: header name.
|
|
213
|
+
*/
|
|
214
|
+
export function Headers(_name?: string): ParameterMarker {
|
|
215
|
+
return parameterMarker
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Injects a file of a `multipart/form-data` body as a standard `File` (`name`, `type`, `size`,
|
|
220
|
+
* `arrayBuffer()`, `text()`, `stream()`), held in memory. The route parses multipart bodies before
|
|
221
|
+
* its interceptors, enforcing the options while the body is read: a file above `maxSize` answers
|
|
222
|
+
* 413, a refused type 415, a second file in the field or a file in an undeclared field 400, a
|
|
223
|
+
* missing required file 400, a non-multipart body 415. `@Body()` receives the text fields.
|
|
224
|
+
*
|
|
225
|
+
* ```ts
|
|
226
|
+
* @Post('/avatar')
|
|
227
|
+
* upload(@UploadedFile('avatar', { maxSize: '2mb', mimeTypes: ['image/png', 'image/jpeg'] }) avatar: File) {}
|
|
228
|
+
*
|
|
229
|
+
* @Post('/attachment')
|
|
230
|
+
* attach(@UploadedFile('file') file?: File) {} // optional: undefined when absent
|
|
231
|
+
* ```
|
|
232
|
+
*
|
|
233
|
+
* `File.name` and `File.type` are sent by the client: never trust them (see the README, "Files").
|
|
234
|
+
*
|
|
235
|
+
* @param field static: the multipart field; omitted: the single file of the body, whatever its field.
|
|
236
|
+
* @param options static: `{ required?, maxSize?, mimeTypes? }`.
|
|
237
|
+
*/
|
|
238
|
+
export function UploadedFile(field?: string, options?: UploadedFileOptions): ParameterMarker
|
|
239
|
+
export function UploadedFile(options: UploadedFileOptions): ParameterMarker
|
|
240
|
+
export function UploadedFile(
|
|
241
|
+
_fieldOrOptions?: string | UploadedFileOptions,
|
|
242
|
+
_options?: UploadedFileOptions,
|
|
243
|
+
): ParameterMarker {
|
|
244
|
+
return parameterMarker
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Injects the files of a multipart field (`File[]`, in body order), or of every field without
|
|
249
|
+
* `field`. Same rules as {@link UploadedFile}; above `maxFiles` files the request answers 413. An
|
|
250
|
+
* optional parameter (`files?: File[]`, `required: false`) receives an empty array when no file is
|
|
251
|
+
* sent.
|
|
252
|
+
*
|
|
253
|
+
* ```ts
|
|
254
|
+
* @Post('/documents')
|
|
255
|
+
* add(@UploadedFiles('documents', { maxFiles: 5, maxSize: '20mb', mimeTypes: ['application/pdf'] }) documents: File[]) {}
|
|
256
|
+
* ```
|
|
257
|
+
*
|
|
258
|
+
* @param field static: the multipart field; omitted: the files of every field.
|
|
259
|
+
* @param options static: `{ required?, maxSize?, maxFiles?, mimeTypes? }`.
|
|
260
|
+
*/
|
|
261
|
+
export function UploadedFiles(field?: string, options?: UploadedFilesOptions): ParameterMarker
|
|
262
|
+
export function UploadedFiles(options: UploadedFilesOptions): ParameterMarker
|
|
263
|
+
export function UploadedFiles(
|
|
264
|
+
_fieldOrOptions?: string | UploadedFilesOptions,
|
|
265
|
+
_options?: UploadedFilesOptions,
|
|
266
|
+
): ParameterMarker {
|
|
267
|
+
return parameterMarker
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Sets the maximum request body size of a route, or of every route of a controller (the route wins),
|
|
272
|
+
* over the plugin `bodyLimit` (and over the limit computed for upload routes). `Infinity` disables
|
|
273
|
+
* the limit, e.g. for a route streaming its body with `ctx.bodyStream()`.
|
|
274
|
+
*
|
|
275
|
+
* ```ts
|
|
276
|
+
* @Put('/files/:name')
|
|
277
|
+
* @BodyLimit('5gb')
|
|
278
|
+
* async store(@Param('name') name: string, @Ctx() ctx: HttpContext) {
|
|
279
|
+
* await pipeline(Readable.fromWeb(ctx.bodyStream()), createWriteStream(join(DIR, safeName(name))))
|
|
280
|
+
* }
|
|
281
|
+
* ```
|
|
282
|
+
*
|
|
283
|
+
* @param size static: bytes (`10_485_760`) or a size with a unit (`'10mb'`, `'512kb'`; `kb` = 1024 bytes).
|
|
284
|
+
*/
|
|
285
|
+
export function BodyLimit(_size: ByteSize): ClassMarker & MethodMarker {
|
|
286
|
+
return classOrMethodMarker
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Injects the native request of the adapter (escape hatch; the compiler warns). */
|
|
290
|
+
export function Req(): ParameterMarker {
|
|
291
|
+
return parameterMarker
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Injects the native response of the adapter (escape hatch; the compiler warns). The handler then
|
|
296
|
+
* writes the response itself, and must have finished when its promise settles.
|
|
297
|
+
*/
|
|
298
|
+
export function Res(): ParameterMarker {
|
|
299
|
+
return parameterMarker
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Injects the {@link HttpContext} of the request. */
|
|
303
|
+
export function Ctx(): ParameterMarker {
|
|
304
|
+
return parameterMarker
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Sets the status of successful responses (wins over `HttpResponse.status` and the defaults).
|
|
309
|
+
*
|
|
310
|
+
* @param status static: an integer between 100 and 599.
|
|
311
|
+
*/
|
|
312
|
+
export function HttpCode(_status: number): MethodMarker {
|
|
313
|
+
return methodMarker
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Adds a header to successful responses. Repeat the decorator for several headers.
|
|
318
|
+
*
|
|
319
|
+
* @param name static: header name.
|
|
320
|
+
* @param value static: header value.
|
|
321
|
+
*/
|
|
322
|
+
export function Header(_name: string, _value: string): MethodMarker {
|
|
323
|
+
return methodMarker
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Protects a controller (every route) or a route with guards (providers implementing
|
|
328
|
+
* {@link CanActivate}). Order: global guards → controller guards → route guards, each list in
|
|
329
|
+
* declaration order.
|
|
330
|
+
*
|
|
331
|
+
* @param guards value: guard classes.
|
|
332
|
+
*/
|
|
333
|
+
export function UseGuard(..._guards: [GuardClass, ...GuardClass[]]): ClassMarker & MethodMarker {
|
|
334
|
+
return classOrMethodMarker
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Wraps a controller (every route) or a route with interceptors (providers implementing
|
|
339
|
+
* {@link HttpInterceptor}). Order: global → controller → route, the first one being the outermost.
|
|
340
|
+
*
|
|
341
|
+
* @param interceptors value: interceptor classes.
|
|
342
|
+
*/
|
|
343
|
+
export function UseInterceptor(
|
|
344
|
+
..._interceptors: [HttpInterceptorClass, ...HttpInterceptorClass[]]
|
|
345
|
+
): ClassMarker & MethodMarker {
|
|
346
|
+
return classOrMethodMarker
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Validates the request body with `schema`: sugar for `@Body(schema)` on the body parameter (using
|
|
351
|
+
* both is ZNIGHT005).
|
|
352
|
+
*
|
|
353
|
+
* @param schema value: a Standard Schema.
|
|
354
|
+
*/
|
|
355
|
+
export function Validate(_schema: StandardSchemaV1): MethodMarker {
|
|
356
|
+
return methodMarker
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Arguments of a route decorator: optional when `T` accepts `undefined` or is `true` (flag decorators). */
|
|
360
|
+
export type RouteDecoratorArgs<T> = [T] extends [true] ? [value?: T] : undefined extends T ? [value?: T] : [value: T]
|
|
361
|
+
|
|
362
|
+
/** Options of {@link createRouteDecorator} (static). */
|
|
363
|
+
export interface RouteDecoratorOptions {
|
|
364
|
+
/**
|
|
365
|
+
* Decision group. Decorators of a group are alternatives: one per controller or route, and the
|
|
366
|
+
* route's decision replaces the controller's. Default: the key (the decorator is its own group).
|
|
367
|
+
* A letter followed by letters, digits, `_`, `.` or `-`.
|
|
368
|
+
*/
|
|
369
|
+
readonly group?: string
|
|
370
|
+
/**
|
|
371
|
+
* Every route must carry a decision of the group: ZNIGHT023 at build time when a route has none,
|
|
372
|
+
* and at boot when no guard of its chain enforces it (see {@link GuardClass}). All the decorators
|
|
373
|
+
* of a group must agree. Default `false`.
|
|
374
|
+
*/
|
|
375
|
+
readonly required?: boolean
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** The data part of a route decorator: what guards list in `static enforces`. */
|
|
379
|
+
export interface RouteDecoratorInfo {
|
|
380
|
+
/** Key of the value in `route.meta`. */
|
|
381
|
+
readonly key: string
|
|
382
|
+
/** Decision group (`options.group ?? key`). */
|
|
383
|
+
readonly group: string
|
|
384
|
+
/** Every route must carry a decision of the group. */
|
|
385
|
+
readonly required: boolean
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* A route metadata entry given as data rather than as a decorator: the `meta` of plugin routes
|
|
390
|
+
* (`[Public.meta()]`). Built by {@link RouteDecorator.meta}.
|
|
391
|
+
*/
|
|
392
|
+
export interface RouteMetaEntry<T = unknown> {
|
|
393
|
+
readonly key: string
|
|
394
|
+
readonly value: T
|
|
395
|
+
/** Decision group. Default: the key. */
|
|
396
|
+
readonly group?: string
|
|
397
|
+
/** Default `false`. */
|
|
398
|
+
readonly required?: boolean
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** A user-defined route metadata decorator created with {@link createRouteDecorator}. */
|
|
402
|
+
export interface RouteDecorator<T> extends RouteDecoratorInfo {
|
|
403
|
+
(...args: RouteDecoratorArgs<T>): ClassMarker & MethodMarker
|
|
404
|
+
/**
|
|
405
|
+
* The value of the current route, if set. Reads the effective metadata: the route's decision of
|
|
406
|
+
* a group wins over the controller's.
|
|
407
|
+
*/
|
|
408
|
+
get(ctx: HttpContext): T | undefined
|
|
409
|
+
/**
|
|
410
|
+
* The same metadata as data (`{ key, value, group, required }`), for routes that are not compiled
|
|
411
|
+
* controllers (plugin routes: `OpenApiPlugin.configure({ meta: [Public.meta()] })`). The value is
|
|
412
|
+
* `true` when omitted, like `@Public()`.
|
|
413
|
+
*/
|
|
414
|
+
meta(...args: RouteDecoratorArgs<T>): RouteMetaEntry<T>
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Grammar of an explicit decision group name (internal; the compiler extension has the same rule).
|
|
419
|
+
* A decorator without `group` is its own group, named after its key: any non-empty key is valid.
|
|
420
|
+
*/
|
|
421
|
+
export const GROUP_NAME: RegExp = /^[A-Za-z][\w.-]*$/
|
|
422
|
+
|
|
423
|
+
/** `true` when `group` is a valid group of `key`: the key itself (the default) or a valid name. */
|
|
424
|
+
export function isGroupOf(group: unknown, key: string): group is string {
|
|
425
|
+
return typeof group === 'string' && (group === key || GROUP_NAME.test(group))
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Creates a metadata decorator for controllers and routes. The compiler stores its argument
|
|
430
|
+
* (`value`, statically evaluated or an exported reference; `true` when omitted) in
|
|
431
|
+
* `meta[key]`, read by guards and interceptors through `ctx.route.meta` or `decorator.get(ctx)`.
|
|
432
|
+
*
|
|
433
|
+
* ```ts
|
|
434
|
+
* export const Public = createRouteDecorator('public', { group: 'access', required: true })
|
|
435
|
+
* export const Authenticated = createRouteDecorator('authenticated', { group: 'access', required: true })
|
|
436
|
+
* export const Roles = createRouteDecorator<readonly string[]>('roles', { group: 'access', required: true })
|
|
437
|
+
*
|
|
438
|
+
* @Controller('/books')
|
|
439
|
+
* @Roles(['reader']) // the decision of every route of the controller...
|
|
440
|
+
* export class BookController {
|
|
441
|
+
* @Get('/:id/cover')
|
|
442
|
+
* @Public() // ...unless the route has its own decision of the group
|
|
443
|
+
* cover() {}
|
|
444
|
+
* }
|
|
445
|
+
*
|
|
446
|
+
* // in a guard
|
|
447
|
+
* if (Public.get(ctx)) return true
|
|
448
|
+
* const roles = Roles.get(ctx) ?? []
|
|
449
|
+
* ```
|
|
450
|
+
*
|
|
451
|
+
* Decorators of one `group` are alternatives: two of them on the same class or method are
|
|
452
|
+
* ZNIGHT005, and the route's decision replaces the controller's (without `group`, each decorator is
|
|
453
|
+
* its own group: the route value wins over the controller value). With `required: true`, a route
|
|
454
|
+
* without decision fails the build (ZNIGHT023).
|
|
455
|
+
*
|
|
456
|
+
* @param key static: unique metadata key.
|
|
457
|
+
* @param options static: `{ group?, required? }`.
|
|
458
|
+
*/
|
|
459
|
+
export function createRouteDecorator<T = true>(key: string, options?: RouteDecoratorOptions): RouteDecorator<T> {
|
|
460
|
+
if (typeof key !== 'string' || key === '') {
|
|
461
|
+
throw new TypeError(`createRouteDecorator(): the key must be a non-empty string (received ${String(key)})`)
|
|
462
|
+
}
|
|
463
|
+
if (options !== undefined && (options === null || typeof options !== 'object')) {
|
|
464
|
+
throw new TypeError(`createRouteDecorator('${key}'): options must be an object { group?, required? }`)
|
|
465
|
+
}
|
|
466
|
+
// the grammar applies to an explicit group only: without one, any key is its own group (as before groups)
|
|
467
|
+
const group = options?.group ?? key
|
|
468
|
+
if (!isGroupOf(group, key)) {
|
|
469
|
+
throw new TypeError(
|
|
470
|
+
`createRouteDecorator('${key}'): "group" must be a name made of letters, digits, _ . - starting with a letter (received ${String(group)})`,
|
|
471
|
+
)
|
|
472
|
+
}
|
|
473
|
+
const required = options?.required ?? false
|
|
474
|
+
if (typeof required !== 'boolean') {
|
|
475
|
+
throw new TypeError(`createRouteDecorator('${key}'): "required" must be a boolean (received ${String(required)})`)
|
|
476
|
+
}
|
|
477
|
+
const decorator = (..._args: RouteDecoratorArgs<T>): ClassMarker & MethodMarker => classOrMethodMarker
|
|
478
|
+
return Object.assign(decorator, {
|
|
479
|
+
key,
|
|
480
|
+
group,
|
|
481
|
+
required,
|
|
482
|
+
get: (ctx: HttpContext): T | undefined => ctx.route?.meta[key] as T | undefined,
|
|
483
|
+
meta: (...args: RouteDecoratorArgs<T>): RouteMetaEntry<T> =>
|
|
484
|
+
Object.freeze({ key, value: (args.length === 0 ? true : args[0]) as T, group, required }),
|
|
485
|
+
})
|
|
486
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `EventChannel`: a bounded bridge from push-based producers (an event bus, an `EventEmitter`, a
|
|
3
|
+
* database listener) to a pull-based `AsyncIterable` (an SSE source, a GraphQL subscription), with
|
|
4
|
+
* no dependency.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Options of {@link EventChannel}. */
|
|
8
|
+
export interface EventChannelOptions {
|
|
9
|
+
/** Buffered items before `overflow` applies. Default: 1 000. */
|
|
10
|
+
readonly capacity?: number
|
|
11
|
+
/**
|
|
12
|
+
* What happens when an item is pushed into a full buffer:
|
|
13
|
+
* - `'drop-oldest'` (default): the oldest buffered item is discarded;
|
|
14
|
+
* - `'drop-newest'`: the item is discarded (`push()` returns false);
|
|
15
|
+
* - `'close'`: the item is discarded and the channel closes after the buffered items (an SSE
|
|
16
|
+
* client then reconnects and resumes with `Last-Event-ID`).
|
|
17
|
+
*/
|
|
18
|
+
readonly overflow?: 'drop-oldest' | 'drop-newest' | 'close'
|
|
19
|
+
/** Closes the channel when aborted (pass `ctx.signal`). */
|
|
20
|
+
readonly signal?: AbortSignal
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const DEFAULT_CAPACITY = 1_000
|
|
24
|
+
|
|
25
|
+
/** A pending `next()`. */
|
|
26
|
+
interface Waiter<T> {
|
|
27
|
+
resolve(result: IteratorResult<T>): void
|
|
28
|
+
reject(error: unknown): void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A bounded, single-consumer queue that is an `AsyncIterable`: producers `push()`, the consumer
|
|
33
|
+
* iterates. A pending `next()` resolves with `{ done: true }` as soon as the channel closes for any
|
|
34
|
+
* reason, so an SSE handler built on a channel always stops when its client leaves.
|
|
35
|
+
*
|
|
36
|
+
* ```ts
|
|
37
|
+
* const channel = new EventChannel<SseEvent<Order>>({ capacity: 256, overflow: 'close', signal: ctx.signal })
|
|
38
|
+
* const unsubscribe = bus.on('order', order => channel.push({ event: 'order', data: order }))
|
|
39
|
+
* channel.onClose(unsubscribe)
|
|
40
|
+
* return channel // from an @Sse handler
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export class EventChannel<T> implements AsyncIterable<T> {
|
|
44
|
+
readonly #capacity: number
|
|
45
|
+
readonly #overflow: 'drop-oldest' | 'drop-newest' | 'close'
|
|
46
|
+
readonly #buffer: T[] = []
|
|
47
|
+
readonly #waiters: Waiter<T>[] = []
|
|
48
|
+
readonly #listeners: (() => void)[] = []
|
|
49
|
+
readonly #signal: AbortSignal | undefined
|
|
50
|
+
#closed = false
|
|
51
|
+
#failure: { readonly error: unknown } | undefined
|
|
52
|
+
#iterating = false
|
|
53
|
+
#onAbort: (() => void) | undefined
|
|
54
|
+
|
|
55
|
+
constructor(options: EventChannelOptions = {}) {
|
|
56
|
+
if (options === null || typeof options !== 'object') {
|
|
57
|
+
throw new TypeError('EventChannel: options must be an object { capacity?, overflow?, signal? }')
|
|
58
|
+
}
|
|
59
|
+
const capacity = options.capacity ?? DEFAULT_CAPACITY
|
|
60
|
+
if (!(Number.isSafeInteger(capacity) && capacity >= 1) && capacity !== Number.POSITIVE_INFINITY) {
|
|
61
|
+
throw new TypeError(`EventChannel: capacity must be a positive integer (received ${String(capacity)})`)
|
|
62
|
+
}
|
|
63
|
+
const overflow = options.overflow ?? 'drop-oldest'
|
|
64
|
+
if (overflow !== 'drop-oldest' && overflow !== 'drop-newest' && overflow !== 'close') {
|
|
65
|
+
throw new TypeError(
|
|
66
|
+
`EventChannel: overflow must be 'drop-oldest', 'drop-newest' or 'close' (received ${String(overflow)})`,
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
this.#capacity = capacity
|
|
70
|
+
this.#overflow = overflow
|
|
71
|
+
const signal = options.signal
|
|
72
|
+
this.#signal = signal
|
|
73
|
+
if (signal !== undefined) {
|
|
74
|
+
if (signal.aborted) {
|
|
75
|
+
this.#closed = true
|
|
76
|
+
} else {
|
|
77
|
+
this.#onAbort = (): void => this.close()
|
|
78
|
+
signal.addEventListener('abort', this.#onAbort, { once: true })
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Buffered items. */
|
|
84
|
+
get size(): number {
|
|
85
|
+
return this.#buffer.length
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Whether the channel no longer accepts items (closed, failed, overflowed, aborted, returned). */
|
|
89
|
+
get closed(): boolean {
|
|
90
|
+
return this.#closed
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Buffers an item (or hands it to a waiting consumer). Returns false when it was dropped or the channel is closed. */
|
|
94
|
+
push(item: T): boolean {
|
|
95
|
+
if (this.#closed) return false
|
|
96
|
+
const waiter = this.#waiters.shift()
|
|
97
|
+
if (waiter !== undefined) {
|
|
98
|
+
waiter.resolve({ done: false, value: item })
|
|
99
|
+
return true
|
|
100
|
+
}
|
|
101
|
+
if (this.#buffer.length < this.#capacity) {
|
|
102
|
+
this.#buffer.push(item)
|
|
103
|
+
return true
|
|
104
|
+
}
|
|
105
|
+
switch (this.#overflow) {
|
|
106
|
+
case 'drop-oldest':
|
|
107
|
+
this.#buffer.shift()
|
|
108
|
+
this.#buffer.push(item)
|
|
109
|
+
return true
|
|
110
|
+
case 'drop-newest':
|
|
111
|
+
return false
|
|
112
|
+
default:
|
|
113
|
+
this.close()
|
|
114
|
+
return false
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Ends the iteration after the buffered items. Idempotent. */
|
|
119
|
+
close(): void {
|
|
120
|
+
if (this.#closed) return
|
|
121
|
+
this.#closed = true
|
|
122
|
+
this.#settle()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Makes the iteration throw `error` after the buffered items (ignored once closed). */
|
|
126
|
+
fail(error: unknown): void {
|
|
127
|
+
if (this.#closed) return
|
|
128
|
+
this.#failure = { error }
|
|
129
|
+
this.#closed = true
|
|
130
|
+
this.#settle()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Calls `listener` once when the channel closes (close, fail, overflow, signal, consumer
|
|
135
|
+
* `return()`); immediately when it is already closed.
|
|
136
|
+
*/
|
|
137
|
+
onClose(listener: () => void): void {
|
|
138
|
+
if (typeof listener !== 'function') throw new TypeError('EventChannel.onClose() expects a function')
|
|
139
|
+
if (this.#closed) listener()
|
|
140
|
+
else this.#listeners.push(listener)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The iterator of the single consumer (a second call throws). Its `return()` closes the channel
|
|
145
|
+
* and resolves at once, even while a `next()` waits.
|
|
146
|
+
*/
|
|
147
|
+
[Symbol.asyncIterator](): AsyncIterator<T> {
|
|
148
|
+
if (this.#iterating) throw new TypeError('EventChannel: a channel has a single consumer (already iterated)')
|
|
149
|
+
this.#iterating = true
|
|
150
|
+
return {
|
|
151
|
+
next: (): Promise<IteratorResult<T>> => this.#next(),
|
|
152
|
+
return: (): Promise<IteratorResult<T>> => {
|
|
153
|
+
this.#buffer.length = 0
|
|
154
|
+
this.close()
|
|
155
|
+
return Promise.resolve({ done: true, value: undefined })
|
|
156
|
+
},
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
#next(): Promise<IteratorResult<T>> {
|
|
161
|
+
if (this.#buffer.length > 0) return Promise.resolve({ done: false, value: this.#buffer.shift() as T })
|
|
162
|
+
if (this.#closed) {
|
|
163
|
+
const failure = this.#failure
|
|
164
|
+
if (failure !== undefined) {
|
|
165
|
+
this.#failure = undefined
|
|
166
|
+
return Promise.reject(failure.error)
|
|
167
|
+
}
|
|
168
|
+
return Promise.resolve({ done: true, value: undefined })
|
|
169
|
+
}
|
|
170
|
+
return new Promise((resolve, reject) => this.#waiters.push({ resolve, reject }))
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The channel just closed: release waiting consumers, the signal listener and notify `onClose` listeners. */
|
|
174
|
+
#settle(): void {
|
|
175
|
+
if (this.#onAbort !== undefined) {
|
|
176
|
+
this.#signal?.removeEventListener('abort', this.#onAbort)
|
|
177
|
+
this.#onAbort = undefined
|
|
178
|
+
}
|
|
179
|
+
// waiters only exist while the buffer is empty
|
|
180
|
+
for (const waiter of this.#waiters.splice(0)) {
|
|
181
|
+
const failure = this.#failure
|
|
182
|
+
if (failure === undefined) {
|
|
183
|
+
waiter.resolve({ done: true, value: undefined })
|
|
184
|
+
} else {
|
|
185
|
+
this.#failure = undefined
|
|
186
|
+
waiter.reject(failure.error)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
for (const listener of this.#listeners.splice(0)) {
|
|
190
|
+
try {
|
|
191
|
+
listener()
|
|
192
|
+
} catch {
|
|
193
|
+
// a failing listener must not break the producer that closed the channel
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|