@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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2333 -2
  3. package/dist/adapter-CGSceVEN.d.mts +876 -0
  4. package/dist/compiler.d.mts +156 -0
  5. package/dist/compiler.mjs +2180 -0
  6. package/dist/compiler.mjs.map +1 -0
  7. package/dist/express.d.mts +39 -0
  8. package/dist/express.mjs +135 -0
  9. package/dist/express.mjs.map +1 -0
  10. package/dist/fastify.d.mts +33 -0
  11. package/dist/fastify.mjs +191 -0
  12. package/dist/fastify.mjs.map +1 -0
  13. package/dist/hono.d.mts +49 -0
  14. package/dist/hono.mjs +319 -0
  15. package/dist/hono.mjs.map +1 -0
  16. package/dist/index.d.mts +1254 -0
  17. package/dist/index.mjs +5618 -0
  18. package/dist/index.mjs.map +1 -0
  19. package/dist/node-BUnkdSsa.mjs +183 -0
  20. package/dist/node-BUnkdSsa.mjs.map +1 -0
  21. package/dist/node-io-BY1ZDLRQ.mjs +1566 -0
  22. package/dist/node-io-BY1ZDLRQ.mjs.map +1 -0
  23. package/dist/node.d.mts +30 -0
  24. package/dist/node.mjs +1 -0
  25. package/dist/path-JI87C4k8.d.mts +109 -0
  26. package/dist/path.d.mts +2 -0
  27. package/dist/path.mjs +245 -0
  28. package/dist/path.mjs.map +1 -0
  29. package/dist/size-D8rsQcV0.mjs +184 -0
  30. package/dist/size-D8rsQcV0.mjs.map +1 -0
  31. package/dist/utils-DCbu3a4w.mjs +27 -0
  32. package/dist/utils-DCbu3a4w.mjs.map +1 -0
  33. package/package.json +142 -4
  34. package/src/adapter.ts +240 -0
  35. package/src/adapters/express.ts +201 -0
  36. package/src/adapters/fastify.ts +280 -0
  37. package/src/adapters/hono.ts +488 -0
  38. package/src/adapters/node-io.ts +368 -0
  39. package/src/adapters/node.ts +135 -0
  40. package/src/adapters/shared.ts +508 -0
  41. package/src/body.ts +135 -0
  42. package/src/coerce.ts +195 -0
  43. package/src/compiler/analyze.ts +1513 -0
  44. package/src/compiler/ast.ts +90 -0
  45. package/src/compiler/coercion.ts +267 -0
  46. package/src/compiler/generate.ts +374 -0
  47. package/src/compiler/index.ts +76 -0
  48. package/src/compiler/model.ts +260 -0
  49. package/src/compiler/route-decorators.ts +405 -0
  50. package/src/compiler/validate.ts +288 -0
  51. package/src/context.ts +533 -0
  52. package/src/cookie-settings.ts +68 -0
  53. package/src/cookie-signer.ts +106 -0
  54. package/src/cookies.ts +726 -0
  55. package/src/cors.ts +349 -0
  56. package/src/decisions.ts +331 -0
  57. package/src/decorators.ts +486 -0
  58. package/src/event-channel.ts +197 -0
  59. package/src/exceptions.ts +257 -0
  60. package/src/files.ts +429 -0
  61. package/src/headers.ts +39 -0
  62. package/src/index.ts +185 -0
  63. package/src/inject-sse.ts +309 -0
  64. package/src/interfaces.ts +101 -0
  65. package/src/metadata.ts +162 -0
  66. package/src/mime.ts +157 -0
  67. package/src/multipart.ts +597 -0
  68. package/src/option-error.ts +31 -0
  69. package/src/path.ts +288 -0
  70. package/src/pipeline.ts +918 -0
  71. package/src/plugin-routes.ts +188 -0
  72. package/src/plugin.ts +1432 -0
  73. package/src/proxy.ts +236 -0
  74. package/src/query.ts +30 -0
  75. package/src/response.ts +240 -0
  76. package/src/router.ts +119 -0
  77. package/src/security-headers.ts +161 -0
  78. package/src/serialize.ts +264 -0
  79. package/src/size.ts +66 -0
  80. package/src/sse-parse.ts +194 -0
  81. package/src/sse.ts +650 -0
  82. package/src/standard-schema.ts +81 -0
  83. package/src/status.ts +78 -0
  84. package/src/stream.ts +200 -0
  85. package/src/tokens.ts +21 -0
  86. package/src/uploads.ts +176 -0
  87. package/src/utils.ts +31 -0
  88. package/src/validate.ts +65 -0
@@ -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
+ }