@znight/http 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2333 -2
  3. package/dist/adapter-CGSceVEN.d.mts +876 -0
  4. package/dist/compiler.d.mts +156 -0
  5. package/dist/compiler.mjs +2180 -0
  6. package/dist/compiler.mjs.map +1 -0
  7. package/dist/express.d.mts +39 -0
  8. package/dist/express.mjs +135 -0
  9. package/dist/express.mjs.map +1 -0
  10. package/dist/fastify.d.mts +33 -0
  11. package/dist/fastify.mjs +191 -0
  12. package/dist/fastify.mjs.map +1 -0
  13. package/dist/hono.d.mts +49 -0
  14. package/dist/hono.mjs +319 -0
  15. package/dist/hono.mjs.map +1 -0
  16. package/dist/index.d.mts +1254 -0
  17. package/dist/index.mjs +5618 -0
  18. package/dist/index.mjs.map +1 -0
  19. package/dist/node-BUnkdSsa.mjs +183 -0
  20. package/dist/node-BUnkdSsa.mjs.map +1 -0
  21. package/dist/node-io-BY1ZDLRQ.mjs +1566 -0
  22. package/dist/node-io-BY1ZDLRQ.mjs.map +1 -0
  23. package/dist/node.d.mts +30 -0
  24. package/dist/node.mjs +1 -0
  25. package/dist/path-JI87C4k8.d.mts +109 -0
  26. package/dist/path.d.mts +2 -0
  27. package/dist/path.mjs +245 -0
  28. package/dist/path.mjs.map +1 -0
  29. package/dist/size-D8rsQcV0.mjs +184 -0
  30. package/dist/size-D8rsQcV0.mjs.map +1 -0
  31. package/dist/utils-DCbu3a4w.mjs +27 -0
  32. package/dist/utils-DCbu3a4w.mjs.map +1 -0
  33. package/package.json +142 -4
  34. package/src/adapter.ts +240 -0
  35. package/src/adapters/express.ts +201 -0
  36. package/src/adapters/fastify.ts +280 -0
  37. package/src/adapters/hono.ts +488 -0
  38. package/src/adapters/node-io.ts +368 -0
  39. package/src/adapters/node.ts +135 -0
  40. package/src/adapters/shared.ts +508 -0
  41. package/src/body.ts +135 -0
  42. package/src/coerce.ts +195 -0
  43. package/src/compiler/analyze.ts +1513 -0
  44. package/src/compiler/ast.ts +90 -0
  45. package/src/compiler/coercion.ts +267 -0
  46. package/src/compiler/generate.ts +374 -0
  47. package/src/compiler/index.ts +76 -0
  48. package/src/compiler/model.ts +260 -0
  49. package/src/compiler/route-decorators.ts +405 -0
  50. package/src/compiler/validate.ts +288 -0
  51. package/src/context.ts +533 -0
  52. package/src/cookie-settings.ts +68 -0
  53. package/src/cookie-signer.ts +106 -0
  54. package/src/cookies.ts +726 -0
  55. package/src/cors.ts +349 -0
  56. package/src/decisions.ts +331 -0
  57. package/src/decorators.ts +486 -0
  58. package/src/event-channel.ts +197 -0
  59. package/src/exceptions.ts +257 -0
  60. package/src/files.ts +429 -0
  61. package/src/headers.ts +39 -0
  62. package/src/index.ts +185 -0
  63. package/src/inject-sse.ts +309 -0
  64. package/src/interfaces.ts +101 -0
  65. package/src/metadata.ts +162 -0
  66. package/src/mime.ts +157 -0
  67. package/src/multipart.ts +597 -0
  68. package/src/option-error.ts +31 -0
  69. package/src/path.ts +288 -0
  70. package/src/pipeline.ts +918 -0
  71. package/src/plugin-routes.ts +188 -0
  72. package/src/plugin.ts +1432 -0
  73. package/src/proxy.ts +236 -0
  74. package/src/query.ts +30 -0
  75. package/src/response.ts +240 -0
  76. package/src/router.ts +119 -0
  77. package/src/security-headers.ts +161 -0
  78. package/src/serialize.ts +264 -0
  79. package/src/size.ts +66 -0
  80. package/src/sse-parse.ts +194 -0
  81. package/src/sse.ts +650 -0
  82. package/src/standard-schema.ts +81 -0
  83. package/src/status.ts +78 -0
  84. package/src/stream.ts +200 -0
  85. package/src/tokens.ts +21 -0
  86. package/src/uploads.ts +176 -0
  87. package/src/utils.ts +31 -0
  88. package/src/validate.ts +65 -0
@@ -0,0 +1,1513 @@
1
+ /**
2
+ * `analyzeClass` of the `@znight/http` compiler extension: controllers, routes, parameters,
3
+ * guards, interceptors, statuses, headers and route metadata. Everything is checked here except
4
+ * what needs the whole application (paths and duplicate routes, see `validate.ts`).
5
+ */
6
+ import type {
7
+ AnalyzeContext,
8
+ ArgInfo,
9
+ ClassInfo,
10
+ ClassRef,
11
+ DecoratorInfo,
12
+ Location,
13
+ MethodInfo,
14
+ ParameterInfo,
15
+ StaticValue,
16
+ TypeInfo,
17
+ ValueRef,
18
+ } from '@znight/compiler'
19
+ import type { HttpMethod } from '../metadata.ts'
20
+ import type {
21
+ HttpControllerData,
22
+ HttpParamData,
23
+ HttpRouteData,
24
+ ParamCoercion,
25
+ SseRouteData,
26
+ UploadData,
27
+ } from './model.ts'
28
+
29
+ import { isMediaTypePattern } from '../mime.ts'
30
+ import { joinPaths, normalizePath } from '../path.ts'
31
+ import { parseByteSize } from '../size.ts'
32
+ import { acceptsUndefined, isRaw, parameterType, planCoercion } from './coercion.ts'
33
+ import { Codes, HTTP_PACKAGE, Ids, PARAM_SOURCES, ROUTE_METHODS } from './model.ts'
34
+ import { isRouteMetaDecorator, isValueRef, routeMeta } from './route-decorators.ts'
35
+
36
+ /** Name of the extension (owner of the decorators in `DecoratorInfo.owner`). */
37
+ export const EXTENSION_NAME = 'http'
38
+
39
+ type Context = AnalyzeContext<HttpControllerData>
40
+ type Scope = 'singleton' | 'request' | 'transient'
41
+
42
+ const SCOPES: readonly Scope[] = ['singleton', 'request', 'transient']
43
+ /** RFC 9110 token (header names). */
44
+ const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
45
+ /** Types an upload parameter may have (`File`, `Blob`, arrays of them for `@UploadedFiles`); `true`: cast needed. */
46
+ const FILE_TYPES: ReadonlyMap<string, boolean> = new Map([
47
+ ['globalThis#File', false],
48
+ ['globalThis#Blob', false],
49
+ ['node:buffer#File', true],
50
+ ['node:buffer#Blob', true],
51
+ ])
52
+ /** Methods whose requests usually have no body (upload parameters there are a warning). */
53
+ const BODYLESS_METHODS: ReadonlySet<HttpMethod> = new Set<HttpMethod>(['GET', 'HEAD', 'DELETE', 'OPTIONS'])
54
+ const UPLOAD_OPTIONS = ['required', 'maxSize', 'maxFiles', 'mimeTypes']
55
+ /** Smallest `@Sse` heartbeat and `maxDuration` (ms). */
56
+ const MIN_SSE_INTERVAL_MS = 1_000
57
+ /** Longest `@Sse` heartbeat and `maxDuration` (ms): the longest `setTimeout` delay. */
58
+ const MAX_SSE_TIMER_MS = 2_147_483_647
59
+ /** Primitive return types an `@Sse` handler cannot return (`any`, `unknown`, `object`, `never` are accepted). */
60
+ const NOT_SSE_SOURCES: ReadonlySet<string> = new Set([
61
+ 'string',
62
+ 'number',
63
+ 'boolean',
64
+ 'bigint',
65
+ 'symbol',
66
+ 'undefined',
67
+ 'null',
68
+ 'void',
69
+ ])
70
+ /** Values that are fine without schema (`@Body() body: string`, `@Body() bytes: Uint8Array`...). */
71
+ const UNVALIDATED_REFERENCES = new Set([
72
+ 'globalThis#Uint8Array',
73
+ 'globalThis#ArrayBuffer',
74
+ 'globalThis#Buffer',
75
+ 'globalThis#Blob',
76
+ 'globalThis#FormData',
77
+ 'node:buffer#Buffer',
78
+ '@znight/http#QueryRecord',
79
+ ])
80
+
81
+ /** Analyses a class carrying at least one `@znight/http` decorator. */
82
+ export function analyzeHttpClass(cls: ClassInfo, ctx: Context): void {
83
+ const controllers = cls.decorators.filter(decorator => decorator.identity === Ids.Controller)
84
+ const [controller] = controllers
85
+ if (controller === undefined) {
86
+ reportOutsideController(cls, ctx)
87
+ return
88
+ }
89
+ for (const duplicate of controllers.slice(1)) {
90
+ error(ctx, duplicate.location, `@Controller is applied several times to ${cls.displayName}`, [
91
+ 'Keep a single @Controller()',
92
+ ])
93
+ }
94
+ const options = controllerOptions(ctx, controller)
95
+ const token = ctx.registerProvider(cls, { scope: options.scope })
96
+ const basePath = normalizePath(options.path)
97
+ const guards = classRefs(ctx, cls, cls.decorators, Ids.UseGuard, 'guard')
98
+ const interceptors = classRefs(ctx, cls, cls.decorators, Ids.UseInterceptor, 'interceptor')
99
+ const meta = routeMeta(ctx, cls, cls.decorators, cls.displayName)
100
+ const bodyLimit = bodyLimitOf(ctx, cls.decorators)
101
+ const routes: HttpRouteData[] = []
102
+ for (const method of cls.methods) {
103
+ routes.push(...analyzeMethod(ctx, cls, method, basePath, bodyLimit))
104
+ }
105
+ ctx.store({
106
+ token,
107
+ identity: cls.identity,
108
+ displayName: cls.displayName,
109
+ rawBasePath: options.path,
110
+ basePath,
111
+ guards,
112
+ interceptors,
113
+ meta,
114
+ routes,
115
+ location: controller.location,
116
+ pathLocation: options.pathLocation,
117
+ })
118
+ }
119
+
120
+ // -------------------------------------------------------------------------------------------------
121
+ // Controller
122
+ // -------------------------------------------------------------------------------------------------
123
+
124
+ interface ControllerOptions {
125
+ readonly path: string
126
+ readonly pathLocation: Location
127
+ readonly scope: Scope
128
+ }
129
+
130
+ function controllerOptions(ctx: Context, decorator: DecoratorInfo): ControllerOptions {
131
+ const [arg] = decorator.args
132
+ const defaults: ControllerOptions = { path: '', pathLocation: decorator.location, scope: 'singleton' }
133
+ if (arg === undefined || !arg.evaluated) {
134
+ // missing, or not evaluable (already reported)
135
+ return defaults
136
+ }
137
+ const value = arg.value
138
+ if (typeof value === 'string') {
139
+ return { ...defaults, path: value, pathLocation: arg.location }
140
+ }
141
+ if (!isPlainObject(value)) {
142
+ error(ctx, arg.location, `@Controller expects a path or { path?, scope? }, got ${describe(value)}`, [
143
+ "@Controller('/users') or @Controller({ path: '/users', scope: 'request' })",
144
+ ])
145
+ return defaults
146
+ }
147
+ let { path, scope } = defaults
148
+ for (const [key, option] of Object.entries(value)) {
149
+ if (key === 'path') {
150
+ if (typeof option === 'string') {
151
+ path = option
152
+ } else if (option !== undefined) {
153
+ error(ctx, arg.location, `@Controller: "path" must be a string, got ${describe(option)}`)
154
+ }
155
+ } else if (key === 'scope') {
156
+ if (SCOPES.includes(option as Scope)) {
157
+ scope = option as Scope
158
+ } else if (option !== undefined) {
159
+ error(ctx, arg.location, `@Controller: invalid scope ${describe(option)}`, [
160
+ `Use one of ${SCOPES.map(candidate => `'${candidate}'`).join(', ')}`,
161
+ ])
162
+ }
163
+ } else {
164
+ error(ctx, arg.location, `@Controller: unknown option "${key}"`, ['Options: { path?, scope? }'])
165
+ }
166
+ }
167
+ return { path, pathLocation: arg.location, scope }
168
+ }
169
+
170
+ /**
171
+ * HTTP decorators on a class that is not a controller have no effect. On a class another extension
172
+ * owns (a `@Consumer` of `@znight/messaging`...), adding `@Controller()` is no fix: a class is the
173
+ * provider of a single extension.
174
+ */
175
+ function reportOutsideController(cls: ClassInfo, ctx: Context): void {
176
+ const owner = foreignOwner(cls)
177
+ const hint =
178
+ owner === undefined
179
+ ? [`Add @Controller() to ${cls.displayName} (routes are not inherited from base classes)`]
180
+ : [
181
+ `${cls.displayName} is a @${owner.name} class of '${owner.module}': HTTP decorators only apply to @Controller() classes`,
182
+ ]
183
+ const decorators: DecoratorInfo[] = [...cls.decorators]
184
+ for (const method of cls.methods) {
185
+ decorators.push(...method.decorators)
186
+ const handler =
187
+ owner === undefined ? undefined : method.decorators.find(decorator => decorator.owner === owner.decorator.owner)
188
+ if (owner !== undefined && handler !== undefined) {
189
+ reportHandlerParams(ctx, cls, method, owner, handler)
190
+ continue
191
+ }
192
+ for (const param of method.params) {
193
+ decorators.push(...unique(param.decorators))
194
+ }
195
+ }
196
+ for (const decorator of decorators) {
197
+ if (decorator.owner === EXTENSION_NAME && validTarget(decorator)) {
198
+ error(ctx, decorator.location, `@${decorator.name} has no effect: ${cls.displayName} is not a controller`, hint)
199
+ }
200
+ }
201
+ }
202
+
203
+ /**
204
+ * HTTP decorators on the parameters of a handler of the extension owning the class (an `@OnMessage`
205
+ * handler...). That extension reads its handlers' parameters and reports those it cannot read
206
+ * (`Headers` imported from `@znight/http` instead of `@znight/messaging`...): an HTTP decorator is
207
+ * only reported here when the parameter is read through a decorator of that extension, or is
208
+ * optional (left undefined without a word).
209
+ */
210
+ function reportHandlerParams(
211
+ ctx: Context,
212
+ cls: ClassInfo,
213
+ method: MethodInfo,
214
+ owner: QualifiedDecorator,
215
+ handler: DecoratorInfo,
216
+ ): void {
217
+ for (const param of method.params) {
218
+ const read = param.decorators.some(decorator => decorator.owner === owner.decorator.owner)
219
+ if (!read && !param.optional && !param.hasDefault && !param.rest) {
220
+ continue
221
+ }
222
+ for (const decorator of unique(param.decorators)) {
223
+ if (decorator.owner === EXTENSION_NAME && validTarget(decorator)) {
224
+ error(
225
+ ctx,
226
+ decorator.location,
227
+ `@${decorator.name} has no effect on parameter '${param.name}' of ${cls.displayName}.${method.name}: it is an @${handler.name} handler of '${owner.module}'`,
228
+ [`Use the parameter decorators of '${owner.module}', or remove it`],
229
+ )
230
+ }
231
+ }
232
+ }
233
+ }
234
+
235
+ /** A decorator with the module and the export name of its identity (`'@znight/messaging#Consumer'`). */
236
+ interface QualifiedDecorator {
237
+ readonly decorator: DecoratorInfo
238
+ readonly module: string
239
+ readonly name: string
240
+ }
241
+
242
+ function qualify(decorator: DecoratorInfo): QualifiedDecorator | undefined {
243
+ const identity = decorator.identity
244
+ const separator = identity?.lastIndexOf('#') ?? -1
245
+ if (identity === undefined || separator <= 0) {
246
+ return undefined
247
+ }
248
+ return { decorator, module: identity.slice(0, separator), name: identity.slice(separator + 1) }
249
+ }
250
+
251
+ /** The class decorator through which another extension owns the class (`@Consumer`...). */
252
+ function foreignOwner(cls: ClassInfo): QualifiedDecorator | undefined {
253
+ for (const decorator of cls.decorators) {
254
+ const owner = decorator.owner
255
+ if (owner === undefined || owner === EXTENSION_NAME || owner === 'core' || owner === 'manifest') {
256
+ continue
257
+ }
258
+ const qualified = qualify(decorator)
259
+ if (qualified !== undefined) {
260
+ return qualified
261
+ }
262
+ }
263
+ return undefined
264
+ }
265
+
266
+ /**
267
+ * A parameter decorator named like an HTTP one but imported from another package (`Headers` and
268
+ * `Ctx` of `@znight/messaging`, typically auto-imported).
269
+ */
270
+ function foreignParameterDecorator(param: ParameterInfo): QualifiedDecorator | undefined {
271
+ for (const decorator of unique(param.decorators)) {
272
+ const qualified = qualify(decorator)
273
+ if (
274
+ qualified !== undefined &&
275
+ qualified.module !== HTTP_PACKAGE &&
276
+ PARAM_SOURCES.has(`${HTTP_PACKAGE}#${qualified.name}`)
277
+ ) {
278
+ return qualified
279
+ }
280
+ }
281
+ return undefined
282
+ }
283
+
284
+ // -------------------------------------------------------------------------------------------------
285
+ // Routes
286
+ // -------------------------------------------------------------------------------------------------
287
+
288
+ function analyzeMethod(
289
+ ctx: Context,
290
+ cls: ClassInfo,
291
+ method: MethodInfo,
292
+ basePath: string,
293
+ controllerBodyLimit: number | undefined,
294
+ ): HttpRouteData[] {
295
+ const where = `${cls.displayName}.${method.name}`
296
+ let routeDecorators = method.decorators.filter(decorator => ROUTE_METHODS.has(decorator.identity ?? ''))
297
+ if (routeDecorators.length === 0) {
298
+ reportNotARoute(ctx, method, where)
299
+ return []
300
+ }
301
+ if (!checkHandler(ctx, method, where, routeDecorators[0] as DecoratorInfo)) {
302
+ return []
303
+ }
304
+ const sseDecorator = routeDecorators.find(decorator => decorator.identity === Ids.Sse)
305
+ if (sseDecorator !== undefined) {
306
+ // one handler per event stream: the other route decorators are reported and ignored
307
+ for (const other of routeDecorators) {
308
+ if (other === sseDecorator) {
309
+ continue
310
+ }
311
+ error(
312
+ ctx,
313
+ other.location,
314
+ other.identity === Ids.Sse
315
+ ? `@Sse is applied several times to ${where}`
316
+ : `@${other.name} cannot be combined with @Sse on ${where}`,
317
+ [
318
+ 'One handler per event stream: declare other routes on other methods (HttpResponse.sse() serves an event stream from any route)',
319
+ ],
320
+ )
321
+ }
322
+ routeDecorators = [sseDecorator]
323
+ }
324
+ const decorators = method.decorators
325
+ const bodyValidation = validateSchema(ctx, decorators)
326
+ const { params, complete } = analyzeParams(ctx, cls, method, where, bodyValidation)
327
+ let status = httpCode(ctx, decorators)
328
+ let headers = responseHeaders(ctx, decorators)
329
+ const guards = classRefs(ctx, cls, decorators, Ids.UseGuard, 'guard')
330
+ const interceptors = classRefs(ctx, cls, decorators, Ids.UseInterceptor, 'interceptor')
331
+ const meta = routeMeta(ctx, cls, decorators, where)
332
+ const bodySchema =
333
+ bodyValidation !== undefined && !params.some(param => param.source === 'body') ? bodyValidation.schema : undefined
334
+ let rawResponse = params.some(param => param.source === 'response')
335
+ const bodyLimit = bodyLimitOf(ctx, decorators) ?? controllerBodyLimit
336
+ const uploads = checkUploads(ctx, params, where)
337
+ if (sseDecorator !== undefined) {
338
+ checkSseHandler(ctx, method, params, where, bodyValidation)
339
+ status = undefined
340
+ headers = headers.filter(([name]) => name !== 'content-type')
341
+ rawResponse = false
342
+ }
343
+
344
+ const routes: HttpRouteData[] = []
345
+ for (const decorator of routeDecorators) {
346
+ const httpMethod = ROUTE_METHODS.get(decorator.identity ?? '') as HttpMethod
347
+ const target = decorator === sseDecorator ? sseArgs(ctx, decorator) : routePath(ctx, decorator)
348
+ if (target === undefined) {
349
+ continue
350
+ }
351
+ const { rawPath, arg } = target
352
+ const path = normalizePath(rawPath)
353
+ if (uploads && BODYLESS_METHODS.has(httpMethod)) {
354
+ ctx.report({
355
+ code: Codes.InvalidDecorator,
356
+ severity: 'warning',
357
+ message: `${where} reads uploaded files but is a ${httpMethod} route: ${httpMethod} requests usually have no body`,
358
+ location: decorator.location,
359
+ hints: ['Upload files with @Post(), @Put() or @Patch()'],
360
+ })
361
+ }
362
+ routes.push({
363
+ method: httpMethod,
364
+ rawPath,
365
+ path,
366
+ fullPath: joinPaths(basePath, path),
367
+ handler: method.name,
368
+ status,
369
+ headers,
370
+ meta,
371
+ guards,
372
+ interceptors,
373
+ params,
374
+ complete,
375
+ bodySchema,
376
+ bodyLimit,
377
+ rawResponse,
378
+ ...(target.sse === undefined ? {} : { sse: target.sse }),
379
+ location: decorator.location,
380
+ pathLocation: arg?.location ?? decorator.location,
381
+ })
382
+ }
383
+ return routes
384
+ }
385
+
386
+ /** The path of a route decorator, its argument and, for `@Sse`, its options. */
387
+ interface RouteTarget {
388
+ readonly rawPath: string
389
+ readonly arg: ArgInfo | undefined
390
+ readonly sse?: SseRouteData
391
+ }
392
+
393
+ /** The path argument of a route decorator (`undefined` after an error). */
394
+ function routePath(ctx: Context, decorator: DecoratorInfo): RouteTarget | undefined {
395
+ const [arg] = decorator.args
396
+ if (arg === undefined) {
397
+ return { rawPath: '', arg }
398
+ }
399
+ if (!arg.evaluated) {
400
+ return undefined // not evaluable: already reported
401
+ }
402
+ if (typeof arg.value !== 'string') {
403
+ error(ctx, arg.location, `@${decorator.name} expects a path (string), got ${describe(arg.value)}`, [
404
+ `@${decorator.name}('/:id')`,
405
+ ])
406
+ return undefined
407
+ }
408
+ return { rawPath: arg.value, arg }
409
+ }
410
+
411
+ /** `@Sse(path?, options?)` / `@Sse(options)`: the path and the options (`undefined` after an error). */
412
+ function sseArgs(ctx: Context, decorator: DecoratorInfo): RouteTarget | undefined {
413
+ const [first, second] = decorator.args
414
+ let pathArg: ArgInfo | undefined
415
+ let options: ArgInfo | undefined
416
+ if (first !== undefined) {
417
+ if (!first.evaluated) {
418
+ return undefined // already reported
419
+ }
420
+ if (typeof first.value === 'string') {
421
+ pathArg = first
422
+ options = second
423
+ } else if (first.value === undefined) {
424
+ options = second
425
+ } else if (isPlainObject(first.value)) {
426
+ if (second !== undefined) {
427
+ error(ctx, second.location, '@Sse(options) takes a single argument', [
428
+ "Put the path first: @Sse('/events', { retry: 5000 })",
429
+ ])
430
+ return undefined
431
+ }
432
+ options = first
433
+ } else {
434
+ error(ctx, first.location, `@Sse expects a path (string) or options, got ${describe(first.value)}`, [
435
+ "@Sse('/events'), @Sse('/events', { heartbeat: 15_000 }) or @Sse({ retry: 5000 })",
436
+ ])
437
+ return undefined
438
+ }
439
+ }
440
+ const sse = sseOptions(ctx, options)
441
+ if (sse === undefined) {
442
+ return undefined
443
+ }
444
+ return { rawPath: typeof pathArg?.value === 'string' ? pathArg.value : '', arg: pathArg, sse }
445
+ }
446
+
447
+ /** The static `{ heartbeat?, retry?, maxDuration? }` of `@Sse` (`undefined` after an error). */
448
+ function sseOptions(ctx: Context, arg: ArgInfo | undefined): SseRouteData | undefined {
449
+ if (arg === undefined) {
450
+ return {}
451
+ }
452
+ if (!arg.evaluated) {
453
+ return undefined
454
+ }
455
+ const shape = '{ heartbeat?: number | false, retry?: number, maxDuration?: number }'
456
+ if (arg.value === undefined) {
457
+ return {}
458
+ }
459
+ if (!isPlainObject(arg.value)) {
460
+ error(ctx, arg.location, `@Sse options must be an object ${shape}, got ${describe(arg.value)}`)
461
+ return undefined
462
+ }
463
+ const result: { heartbeat?: number; retry?: number; maxDuration?: number } = {}
464
+ let ok = true
465
+ const invalid = (message: string, hints: readonly string[] = []): void => {
466
+ ok = false
467
+ error(ctx, arg.location, `@Sse: ${message}`, hints)
468
+ }
469
+ const integer = (value: StaticValue, min: number, max = Number.MAX_SAFE_INTEGER): value is number =>
470
+ typeof value === 'number' && Number.isSafeInteger(value) && value >= min && value <= max
471
+ for (const [key, value] of Object.entries(arg.value)) {
472
+ if (value === undefined) {
473
+ continue
474
+ }
475
+ switch (key) {
476
+ case 'heartbeat':
477
+ if (value === false) {
478
+ result.heartbeat = 0
479
+ } else if (integer(value, MIN_SSE_INTERVAL_MS, MAX_SSE_TIMER_MS)) {
480
+ result.heartbeat = value
481
+ } else {
482
+ invalid(
483
+ `"heartbeat" must be an integer number of ms from ${MIN_SSE_INTERVAL_MS} to ${MAX_SSE_TIMER_MS}, or false, got ${describe(value)}`,
484
+ ['Keep it below the idle timeouts of the proxies and of Bun.serve() (10 s): the default is 8 000'],
485
+ )
486
+ }
487
+ break
488
+ case 'retry':
489
+ if (integer(value, 0)) {
490
+ result.retry = value
491
+ } else {
492
+ invalid(`"retry" must be an integer number of ms ≥ 0, got ${describe(value)}`)
493
+ }
494
+ break
495
+ case 'maxDuration':
496
+ if (integer(value, MIN_SSE_INTERVAL_MS, MAX_SSE_TIMER_MS)) {
497
+ result.maxDuration = value
498
+ } else {
499
+ invalid(
500
+ `"maxDuration" must be an integer number of ms from ${MIN_SSE_INTERVAL_MS} to ${MAX_SSE_TIMER_MS} (about 24.8 days), got ${describe(value)}`,
501
+ )
502
+ }
503
+ break
504
+ default:
505
+ invalid(`unknown option "${key}"`, [`Options: ${shape}`])
506
+ }
507
+ }
508
+ return ok ? result : undefined
509
+ }
510
+
511
+ /**
512
+ * `@Sse` handlers: the plugin writes the stream with status 200 and its own content type, so
513
+ * `@HttpCode`, `@Header('content-type')` and `@Res()` are errors; a body is a warning (EventSource
514
+ * sends `GET` without body); a primitive return type is an error.
515
+ */
516
+ function checkSseHandler(
517
+ ctx: Context,
518
+ method: MethodInfo,
519
+ params: readonly HttpParamData[],
520
+ where: string,
521
+ bodyValidation: BodyValidation | undefined,
522
+ ): void {
523
+ for (const decorator of method.decorators) {
524
+ if (decorator.identity === Ids.HttpCode) {
525
+ error(ctx, decorator.location, `@HttpCode has no effect on @Sse route ${where}: an event stream answers 200`, [
526
+ 'Throw an HttpException or return an HttpResponse from the handler for other statuses',
527
+ ])
528
+ } else if (decorator.identity === Ids.Header) {
529
+ const [name] = decorator.args
530
+ if (name?.evaluated === true && typeof name.value === 'string' && name.value.toLowerCase() === 'content-type') {
531
+ error(ctx, decorator.location, `@Header('content-type') has no effect on @Sse route ${where}`, [
532
+ 'An event stream is always sent as text/event-stream; charset=utf-8',
533
+ ])
534
+ }
535
+ }
536
+ }
537
+ for (const param of params) {
538
+ if (param.source === 'response') {
539
+ error(ctx, param.location, `@Res() cannot be used on @Sse route ${where}: the plugin writes the event stream`, [
540
+ 'Return an AsyncIterable of SseEvent, or use a plain route to write the response yourself',
541
+ ])
542
+ }
543
+ }
544
+ const body = params.find(param => param.source === 'body' || param.upload !== undefined)
545
+ const bodyLocation = body?.location ?? bodyValidation?.decorator.location
546
+ if (bodyLocation !== undefined) {
547
+ ctx.report({
548
+ code: Codes.InvalidDecorator,
549
+ severity: 'warning',
550
+ message: `@Sse route ${where} reads a request body: EventSource sends GET requests without body`,
551
+ location: bodyLocation,
552
+ hints: ['For fetch() clients streaming a POST, use @Post() and return HttpResponse.sse(source)'],
553
+ })
554
+ }
555
+ const returned = method.returnType === undefined ? undefined : ctx.types.awaited(method.returnType)
556
+ if (returned !== undefined && !method.generator) {
557
+ const members = returned.kind === 'union' ? returned.types : [returned]
558
+ const invalid = members.some(
559
+ member => member.kind === 'literal' || (member.kind === 'primitive' && NOT_SSE_SOURCES.has(member.name)),
560
+ )
561
+ if (invalid) {
562
+ error(
563
+ ctx,
564
+ method.location,
565
+ `@Sse handler ${where} returns ${ctx.types.format(returned)}: return an AsyncIterable, an Iterable or a ReadableStream of SseEvent (or an HttpResponse)`,
566
+ ['async events(): Promise<AsyncIterable<SseEvent<T>>> { ...; return channel }'],
567
+ )
568
+ }
569
+ }
570
+ }
571
+
572
+ /** `@BodyLimit(size)` of a class or method, in bytes. */
573
+ function bodyLimitOf(ctx: Context, decorators: readonly DecoratorInfo[]): number | undefined {
574
+ const [decorator, ...duplicates] = decorators.filter(candidate => candidate.identity === Ids.BodyLimit)
575
+ for (const duplicate of duplicates) {
576
+ error(ctx, duplicate.location, '@BodyLimit is applied several times', ['Keep a single @BodyLimit(size)'])
577
+ }
578
+ if (decorator === undefined) {
579
+ return undefined
580
+ }
581
+ const [arg] = decorator.args
582
+ if (arg === undefined) {
583
+ error(ctx, decorator.location, '@BodyLimit requires a size', ["@BodyLimit('10mb') or @BodyLimit(10_485_760)"])
584
+ return undefined
585
+ }
586
+ if (!arg.evaluated) {
587
+ return undefined
588
+ }
589
+ const size = parseByteSize(arg.value)
590
+ if (size === undefined) {
591
+ error(ctx, arg.location, `@BodyLimit expects a size in bytes, got ${describe(arg.value)}`, [
592
+ "A non-negative integer (10_485_760), a size with a unit ('10mb', '512kb'; kb = 1024 bytes) or Infinity",
593
+ ])
594
+ }
595
+ return size
596
+ }
597
+
598
+ /**
599
+ * Checks the upload parameters of a handler together (one parameter per field; a parameter without
600
+ * field takes every file and stands alone). Returns whether the handler reads uploaded files.
601
+ */
602
+ function checkUploads(ctx: Context, params: readonly HttpParamData[], where: string): boolean {
603
+ const uploads = params.filter(param => param.upload !== undefined)
604
+ uploads.forEach((param, index) => {
605
+ const field = param.upload?.field
606
+ const previous = uploads
607
+ .slice(0, index)
608
+ .find(other => field === undefined || other.upload?.field === undefined || other.upload.field === field)
609
+ if (previous === undefined) {
610
+ return
611
+ }
612
+ ctx.report({
613
+ code: Codes.InvalidDecorator,
614
+ severity: 'error',
615
+ message:
616
+ field !== undefined && previous.upload?.field === field
617
+ ? `The field "${field}" of ${where} is read by several upload parameters`
618
+ : `${where}: an upload parameter without field receives every file of the body and cannot be combined with other upload parameters`,
619
+ location: param.location,
620
+ hints: ['Give each @UploadedFile / @UploadedFiles parameter its own field'],
621
+ related: [{ message: 'Other upload parameter', location: previous.location }],
622
+ })
623
+ })
624
+ return uploads.length > 0
625
+ }
626
+
627
+ /** HTTP method decorators (and parameter decorators) on a method without route decorator. */
628
+ function reportNotARoute(ctx: Context, method: MethodInfo, where: string): void {
629
+ const hint = [`Add a route decorator to ${where}: @Get(), @Post(), @Put(), @Patch(), @Delete()...`]
630
+ for (const decorator of method.decorators) {
631
+ if (decorator.owner === EXTENSION_NAME && validTarget(decorator)) {
632
+ error(ctx, decorator.location, `@${decorator.name} has no effect: ${where} is not a route`, hint)
633
+ } else if (isRouteMetaDecorator(ctx, decorator)) {
634
+ ctx.report({
635
+ code: Codes.InvalidDecorator,
636
+ severity: 'warning',
637
+ message: `@${decorator.name} has no effect: ${where} is not a route`,
638
+ location: decorator.location,
639
+ hints: hint,
640
+ })
641
+ }
642
+ }
643
+ for (const param of method.params) {
644
+ for (const decorator of unique(param.decorators)) {
645
+ if (decorator.owner === EXTENSION_NAME && validTarget(decorator)) {
646
+ error(ctx, decorator.location, `@${decorator.name} has no effect: ${where} is not a route`, hint)
647
+ }
648
+ }
649
+ }
650
+ }
651
+
652
+ /** A route handler must be a public, non-static method with a plain name. */
653
+ function checkHandler(ctx: Context, method: MethodInfo, where: string, decorator: DecoratorInfo): boolean {
654
+ let problem: string | undefined
655
+ let hint = 'Route handlers are public instance methods'
656
+ if (method.static) {
657
+ problem = `Static method ${where} cannot be a route handler`
658
+ } else if (method.privateName) {
659
+ problem = `#private method ${where} cannot be a route handler`
660
+ hint = 'Use a public method (the generated code calls it from outside the class)'
661
+ } else if (method.visibility !== 'public') {
662
+ problem = `${method.visibility === 'protected' ? 'Protected' : 'Private'} method ${where} cannot be a route handler`
663
+ hint = 'Make the method public (the generated code calls it from outside the class)'
664
+ } else if (method.kind !== 'method') {
665
+ problem = `Accessor ${where} cannot be a route handler`
666
+ } else if (method.computed) {
667
+ problem = `${where} cannot be a route handler: handlers need a plain name (no computed key)`
668
+ }
669
+ if (problem !== undefined) {
670
+ error(ctx, decorator.location, problem, [hint])
671
+ return false
672
+ }
673
+ return true
674
+ }
675
+
676
+ function httpCode(ctx: Context, decorators: readonly DecoratorInfo[]): number | undefined {
677
+ const [decorator, ...duplicates] = decorators.filter(candidate => candidate.identity === Ids.HttpCode)
678
+ for (const duplicate of duplicates) {
679
+ error(ctx, duplicate.location, '@HttpCode is applied several times', ['Keep a single @HttpCode(status)'])
680
+ }
681
+ if (decorator === undefined) {
682
+ return undefined
683
+ }
684
+ const [arg] = decorator.args
685
+ if (arg === undefined) {
686
+ error(ctx, decorator.location, '@HttpCode requires a status', ['@HttpCode(202)'])
687
+ return undefined
688
+ }
689
+ if (!arg.evaluated) {
690
+ return undefined
691
+ }
692
+ const status = arg.value
693
+ if (typeof status !== 'number' || !Number.isInteger(status) || status < 100 || status > 599) {
694
+ error(ctx, arg.location, `@HttpCode expects an integer status between 100 and 599, got ${describe(status)}`)
695
+ return undefined
696
+ }
697
+ return status
698
+ }
699
+
700
+ function responseHeaders(ctx: Context, decorators: readonly DecoratorInfo[]): (readonly [string, string])[] {
701
+ const headers: (readonly [string, string])[] = []
702
+ const seen = new Map<string, Location>()
703
+ for (const decorator of decorators) {
704
+ if (decorator.identity !== Ids.Header) {
705
+ continue
706
+ }
707
+ const [name, value] = decorator.args
708
+ if (name === undefined || value === undefined) {
709
+ error(ctx, decorator.location, '@Header requires a name and a value', ["@Header('cache-control', 'no-store')"])
710
+ continue
711
+ }
712
+ if (!name.evaluated || !value.evaluated) {
713
+ continue
714
+ }
715
+ if (typeof name.value !== 'string' || !TOKEN.test(name.value)) {
716
+ error(ctx, name.location, `@Header: invalid header name ${describe(name.value)}`, [
717
+ "Header names are tokens: letters, digits and ! # $ % & ' * + - . ^ _ ` | ~",
718
+ ])
719
+ continue
720
+ }
721
+ if (typeof value.value !== 'string' || /[\r\n\0]/.test(value.value)) {
722
+ error(ctx, value.location, `@Header: the value of "${name.value}" must be a string without line breaks`)
723
+ continue
724
+ }
725
+ const lower = name.value.toLowerCase()
726
+ const previous = seen.get(lower)
727
+ if (previous) {
728
+ ctx.report({
729
+ code: Codes.InvalidDecorator,
730
+ severity: 'error',
731
+ message: `@Header: "${lower}" is set several times`,
732
+ location: decorator.location,
733
+ hints: ['Set each response header once (or use ctx.appendHeader() in the handler)'],
734
+ related: [{ message: 'First set here', location: previous }],
735
+ })
736
+ continue
737
+ }
738
+ seen.set(lower, decorator.location)
739
+ headers.push([lower, value.value])
740
+ }
741
+ return headers
742
+ }
743
+
744
+ interface BodyValidation {
745
+ readonly schema: ValueRef
746
+ readonly decorator: DecoratorInfo
747
+ }
748
+
749
+ /** `@Validate(schema)` of a handler. */
750
+ function validateSchema(ctx: Context, decorators: readonly DecoratorInfo[]): BodyValidation | undefined {
751
+ const [decorator, ...duplicates] = decorators.filter(candidate => candidate.identity === Ids.Validate)
752
+ for (const duplicate of duplicates) {
753
+ error(ctx, duplicate.location, '@Validate is applied several times', [
754
+ 'Keep a single @Validate(schema): compose the validation in the schema',
755
+ ])
756
+ }
757
+ if (decorator === undefined) {
758
+ return undefined
759
+ }
760
+ const [arg] = decorator.args
761
+ if (arg === undefined) {
762
+ error(ctx, decorator.location, '@Validate requires a schema', ['@Validate(CreateUser)'])
763
+ return undefined
764
+ }
765
+ const schema = schemaRef(ctx, arg, '@Validate')
766
+ return schema ? { schema, decorator } : undefined
767
+ }
768
+
769
+ /** Guard / interceptor references of a class or method, bound to provider tokens (in order). */
770
+ function classRefs(
771
+ ctx: Context,
772
+ cls: ClassInfo,
773
+ decorators: readonly DecoratorInfo[],
774
+ identity: string,
775
+ role: string,
776
+ ): ClassRef[] {
777
+ const refs: ClassRef[] = []
778
+ for (const decorator of decorators) {
779
+ if (decorator.identity !== identity) {
780
+ continue
781
+ }
782
+ if (decorator.called && decorator.args.length === 0) {
783
+ error(ctx, decorator.location, `@${decorator.name} requires at least one ${role}`, [
784
+ `@${decorator.name}(My${role === 'guard' ? 'Guard' : 'Interceptor'})`,
785
+ ])
786
+ }
787
+ for (const arg of decorator.args) {
788
+ // binds the class (auto-registered as a singleton when it is not a provider) and declares the
789
+ // controller → guard edge (ZNIGHT003, ZNIGHT019)
790
+ const ref = ctx.resolveClassRef(arg, { from: cls, role })
791
+ if (ref) {
792
+ refs.push(ref)
793
+ }
794
+ }
795
+ }
796
+ return refs
797
+ }
798
+
799
+ // -------------------------------------------------------------------------------------------------
800
+ // Parameters
801
+ // -------------------------------------------------------------------------------------------------
802
+
803
+ function analyzeParams(
804
+ ctx: Context,
805
+ cls: ClassInfo,
806
+ method: MethodInfo,
807
+ where: string,
808
+ bodyValidation: BodyValidation | undefined,
809
+ ): { readonly params: HttpParamData[]; readonly complete: boolean } {
810
+ const params: HttpParamData[] = []
811
+ let complete = true
812
+ for (const param of method.params) {
813
+ const decorators = unique(param.decorators).filter(
814
+ decorator => decorator.owner === EXTENSION_NAME && PARAM_SOURCES.has(decorator.identity ?? ''),
815
+ )
816
+ const [decorator, ...extra] = decorators
817
+ const foreign = decorator === undefined ? foreignParameterDecorator(param) : undefined
818
+ if (foreign !== undefined) {
819
+ error(
820
+ ctx,
821
+ foreign.decorator.location,
822
+ `@${foreign.decorator.name} on parameter '${param.name}' of ${where} is imported from '${foreign.module}': route parameters use ${foreign.name} from '${HTTP_PACKAGE}'`,
823
+ [`Import it from '${HTTP_PACKAGE}': import { ${foreign.name} } from '${HTTP_PACKAGE}'`],
824
+ )
825
+ complete = false
826
+ continue
827
+ }
828
+ if (decorator === undefined) {
829
+ error(ctx, param.location, `Parameter '${param.name}' of ${where} has no HTTP decorator`, [
830
+ 'Decorate it with @Param(), @Query(), @Body(), @Headers(), @Cookie(), @SignedCookie(), @UploadedFile(), @UploadedFiles(), @Ctx(), @Req() or @Res()',
831
+ ])
832
+ complete = false
833
+ continue
834
+ }
835
+ for (const other of extra) {
836
+ error(ctx, other.location, `Parameter '${param.name}' of ${where} has several HTTP decorators`, [
837
+ 'Keep a single parameter decorator',
838
+ ])
839
+ }
840
+ if (param.rest) {
841
+ error(ctx, decorator.location, `Rest parameter '${param.name}' of ${where} cannot be injected`)
842
+ complete = false
843
+ continue
844
+ }
845
+ const data = analyzeParam(ctx, cls, method, param, decorator, where, bodyValidation)
846
+ if (data) {
847
+ params.push(data)
848
+ } else {
849
+ complete = false
850
+ }
851
+ }
852
+ return { params, complete }
853
+ }
854
+
855
+ function analyzeParam(
856
+ ctx: Context,
857
+ cls: ClassInfo,
858
+ method: MethodInfo,
859
+ param: ParameterInfo,
860
+ decorator: DecoratorInfo,
861
+ where: string,
862
+ bodyValidation: BodyValidation | undefined,
863
+ ): HttpParamData | undefined {
864
+ const source = PARAM_SOURCES.get(decorator.identity ?? '')
865
+ if (source === undefined) {
866
+ return undefined
867
+ }
868
+ const declared = parameterType(ctx, cls, method, param)
869
+ const defaultValue = staticDefault(param)
870
+ const base: Omit<HttpParamData, 'key' | 'schema' | 'coercion' | 'optional'> = {
871
+ index: param.index,
872
+ name: param.name,
873
+ source,
874
+ type: undefined,
875
+ ...(defaultValue === undefined ? {} : { defaultValue: defaultValue.value }),
876
+ location: decorator.location,
877
+ }
878
+ // the declared type is recorded for coerced values (informational, `params[i].type`)
879
+ const type = declared.type === undefined ? undefined : ctx.types.format(declared.type)
880
+ const plain = (): HttpParamData => ({
881
+ ...base,
882
+ key: undefined,
883
+ optional: false,
884
+ schema: undefined,
885
+ coercion: undefined,
886
+ })
887
+
888
+ switch (source) {
889
+ case 'request':
890
+ case 'context':
891
+ return plain()
892
+ case 'response':
893
+ ctx.report({
894
+ code: Codes.InvalidDecorator,
895
+ severity: 'warning',
896
+ message: `@Res() makes ${where} adapter-specific: the handler writes the native response itself`,
897
+ location: decorator.location,
898
+ hints: ['Prefer returning a value, an HttpResponse or a Web Response (portable across adapters)'],
899
+ })
900
+ return plain()
901
+ case 'body': {
902
+ const [arg] = decorator.args
903
+ let schema: ValueRef | undefined
904
+ if (arg !== undefined) {
905
+ schema = schemaRef(ctx, arg, '@Body')
906
+ if (schema === undefined) {
907
+ return undefined
908
+ }
909
+ }
910
+ if (bodyValidation !== undefined) {
911
+ if (schema !== undefined) {
912
+ ctx.report({
913
+ code: Codes.InvalidDecorator,
914
+ severity: 'error',
915
+ message: `The body of ${where} is validated twice: @Validate(${bodyValidation.schema.text}) and @Body(${schema.text})`,
916
+ location: bodyValidation.decorator.location,
917
+ hints: ['Keep one of them: @Validate(S) on the method is the same as @Body(S) on the parameter'],
918
+ related: [{ message: '@Body schema', location: decorator.location }],
919
+ })
920
+ return undefined
921
+ }
922
+ schema = bodyValidation.schema
923
+ }
924
+ if (schema === undefined) {
925
+ checkUnvalidated(ctx, declared.type, decorator, param, where, 'body')
926
+ }
927
+ return { ...base, key: undefined, optional: declared.optional, schema, coercion: undefined }
928
+ }
929
+ case 'file':
930
+ case 'files': {
931
+ const upload = uploadOf(ctx, decorator, param, declared, where, source === 'files')
932
+ return upload === undefined
933
+ ? undefined
934
+ : {
935
+ ...base,
936
+ key: upload.field,
937
+ optional: !upload.required,
938
+ schema: undefined,
939
+ coercion: undefined,
940
+ upload,
941
+ }
942
+ }
943
+ case 'headers': {
944
+ const [arg] = decorator.args
945
+ if (arg === undefined) {
946
+ return plain()
947
+ }
948
+ if (!arg.evaluated) {
949
+ return undefined
950
+ }
951
+ if (typeof arg.value !== 'string' || !TOKEN.test(arg.value)) {
952
+ error(ctx, arg.location, `@Headers: invalid header name ${describe(arg.value)}`, [
953
+ "@Headers('x-api-key'), or @Headers() for every header",
954
+ ])
955
+ return undefined
956
+ }
957
+ const key = arg.value.toLowerCase()
958
+ const coercion = coercionOf(ctx, declared, param, decorator, where, 'headers', key)
959
+ return coercion ? { ...base, key, type, optional: declared.optional, schema: undefined, coercion } : undefined
960
+ }
961
+ case 'param':
962
+ case 'query': {
963
+ const parsed = nameAndSchema(ctx, decorator)
964
+ if (!parsed.ok) {
965
+ return undefined
966
+ }
967
+ const { key, schema } = parsed
968
+ const optional = source === 'query' && declared.optional
969
+ if (schema !== undefined) {
970
+ return { ...base, key, optional, schema, coercion: undefined }
971
+ }
972
+ if (key === undefined) {
973
+ if (source === 'query') {
974
+ checkUnvalidated(ctx, declared.type, decorator, param, where, 'query')
975
+ }
976
+ return { ...base, key, optional: false, schema: undefined, coercion: undefined }
977
+ }
978
+ const coercion = coercionOf(ctx, declared, param, decorator, where, source, key)
979
+ return coercion ? { ...base, key, type, optional, schema: undefined, coercion } : undefined
980
+ }
981
+ case 'cookie':
982
+ case 'signedCookie':
983
+ return cookieParam(ctx, param, decorator, declared, where, base, type)
984
+ }
985
+ }
986
+
987
+ /** `@Cookie(name?, schema?)` / `@SignedCookie(name, schema?)`. */
988
+ function cookieParam(
989
+ ctx: Context,
990
+ param: ParameterInfo,
991
+ decorator: DecoratorInfo,
992
+ declared: ReturnType<typeof parameterType>,
993
+ where: string,
994
+ base: Omit<HttpParamData, 'key' | 'schema' | 'coercion' | 'optional'>,
995
+ type: string | undefined,
996
+ ): HttpParamData | undefined {
997
+ const parsed = nameAndSchema(ctx, decorator)
998
+ if (!parsed.ok) {
999
+ return undefined
1000
+ }
1001
+ const { key, schema } = parsed
1002
+ const name = `@${decorator.name}`
1003
+ if (key !== undefined && !TOKEN.test(key)) {
1004
+ error(ctx, decorator.args[0]?.location ?? decorator.location, `${name}: invalid cookie name ${describe(key)}`, [
1005
+ `Cookie names are tokens (letters, digits and ! # $ % & ' * + - . ^ _ \` | ~): ${name}('session_id')`,
1006
+ ])
1007
+ return undefined
1008
+ }
1009
+ if (decorator.identity === Ids.SignedCookie && key === undefined) {
1010
+ error(ctx, decorator.location, '@SignedCookie requires a cookie name', ["@SignedCookie('session_id')"])
1011
+ return undefined
1012
+ }
1013
+ const optional = declared.optional
1014
+ if (schema !== undefined) {
1015
+ return { ...base, key, optional, schema, coercion: undefined }
1016
+ }
1017
+ if (key === undefined) {
1018
+ checkUnvalidated(ctx, declared.type, decorator, param, where, 'cookies')
1019
+ return { ...base, key, optional: false, schema: undefined, coercion: undefined }
1020
+ }
1021
+ const coercion = coercionOf(ctx, declared, param, decorator, where, 'cookie', key)
1022
+ return coercion ? { ...base, key, type, optional, schema: undefined, coercion } : undefined
1023
+ }
1024
+
1025
+ /**
1026
+ * The statically evaluable default value of a parameter (`page = 1`), without references to
1027
+ * exported bindings. `ParameterInfo.initializer` is read defensively (compilers before RFC 0002 C4
1028
+ * do not provide it).
1029
+ */
1030
+ function staticDefault(param: ParameterInfo): { readonly value: StaticValue } | undefined {
1031
+ const initializer = (param as { readonly initializer?: ArgInfo | undefined }).initializer
1032
+ if (initializer === undefined || !initializer.evaluated || initializer.ref !== undefined) {
1033
+ return undefined
1034
+ }
1035
+ const value = initializer.value
1036
+ if (value === undefined || hasRef(value)) {
1037
+ return undefined
1038
+ }
1039
+ return { value }
1040
+ }
1041
+
1042
+ function hasRef(value: StaticValue): boolean {
1043
+ if (isValueRef(value)) {
1044
+ return true
1045
+ }
1046
+ if (Array.isArray(value)) {
1047
+ return value.some(hasRef)
1048
+ }
1049
+ if (typeof value === 'object' && value !== null) {
1050
+ return Object.values(value).some(hasRef)
1051
+ }
1052
+ return false
1053
+ }
1054
+
1055
+ /** `@UploadedFile(field?, options?)` / `@UploadedFiles(field?, options?)`: field, options and type. */
1056
+ function uploadOf(
1057
+ ctx: Context,
1058
+ decorator: DecoratorInfo,
1059
+ param: ParameterInfo,
1060
+ declared: ReturnType<typeof parameterType>,
1061
+ where: string,
1062
+ multiple: boolean,
1063
+ ): UploadData | undefined {
1064
+ const name = `@${decorator.name}`
1065
+ const [first, second] = decorator.args
1066
+ let field: string | undefined
1067
+ let options: ArgInfo | undefined
1068
+ if (first !== undefined) {
1069
+ if (!first.evaluated) {
1070
+ return undefined // already reported
1071
+ }
1072
+ if (typeof first.value === 'string') {
1073
+ if (first.value === '') {
1074
+ error(ctx, first.location, `${name} expects a non-empty field name`, [`${name}('avatar')`])
1075
+ return undefined
1076
+ }
1077
+ field = first.value
1078
+ options = second
1079
+ } else if (first.value === undefined) {
1080
+ options = second
1081
+ } else if (isPlainObject(first.value)) {
1082
+ if (second !== undefined) {
1083
+ error(ctx, second.location, `${name}(options) takes a single argument`, [
1084
+ `Put the field first: ${name}('field', { ... })`,
1085
+ ])
1086
+ return undefined
1087
+ }
1088
+ options = first
1089
+ } else {
1090
+ error(ctx, first.location, `${name} expects a field name (string) or options, got ${describe(first.value)}`, [
1091
+ `${name}('avatar'), ${name}('avatar', { maxSize: '5mb' }) or ${name}({ maxSize: '5mb' })`,
1092
+ ])
1093
+ return undefined
1094
+ }
1095
+ }
1096
+ const parsed = uploadOptions(ctx, name, options, multiple)
1097
+ if (parsed === undefined) {
1098
+ return undefined
1099
+ }
1100
+ const type = uploadType(ctx, declared.type, multiple)
1101
+ if (!type.ok) {
1102
+ error(
1103
+ ctx,
1104
+ param.typeLocation,
1105
+ `${name} parameter '${param.name}' of ${where} must be typed ${multiple ? 'File[]' : 'File'}, got ${type.got}`,
1106
+ [multiple ? `${param.name}: File[]` : `${param.name}: File (or ${param.name}?: File when optional)`],
1107
+ )
1108
+ return undefined
1109
+ }
1110
+ const required = parsed.required ?? !declared.optional
1111
+ let cast = type.cast
1112
+ if (!multiple && !required) {
1113
+ const acceptsMissing =
1114
+ declared.type === undefined ||
1115
+ param.optional ||
1116
+ param.hasDefault ||
1117
+ acceptsUndefined(declared.type) ||
1118
+ isRaw(ctx.types.stripNullish(declared.type).type)
1119
+ if (!acceptsMissing && !declared.optional) {
1120
+ error(
1121
+ ctx,
1122
+ options?.location ?? decorator.location,
1123
+ `${name}: '${param.name}' of ${where} is optional ({ required: false }) but typed ${ctx.types.format(declared.type as TypeInfo)}`,
1124
+ [`Type it ${param.name}?: File (undefined when no file is sent)`],
1125
+ )
1126
+ return undefined
1127
+ }
1128
+ // `File | null` receives undefined
1129
+ cast ||= !acceptsMissing
1130
+ }
1131
+ return {
1132
+ field,
1133
+ multiple,
1134
+ required,
1135
+ maxSize: parsed.maxSize,
1136
+ maxFiles: parsed.maxFiles,
1137
+ mimeTypes: parsed.mimeTypes,
1138
+ cast,
1139
+ }
1140
+ }
1141
+
1142
+ interface ParsedUploadOptions {
1143
+ readonly required: boolean | undefined
1144
+ readonly maxSize: number | undefined
1145
+ readonly maxFiles: number | undefined
1146
+ readonly mimeTypes: readonly string[] | undefined
1147
+ }
1148
+
1149
+ /** The options object of an upload decorator (`undefined` after an error). */
1150
+ function uploadOptions(
1151
+ ctx: Context,
1152
+ name: string,
1153
+ arg: ArgInfo | undefined,
1154
+ multiple: boolean,
1155
+ ): ParsedUploadOptions | undefined {
1156
+ const result: { -readonly [K in keyof ParsedUploadOptions]: ParsedUploadOptions[K] } = {
1157
+ required: undefined,
1158
+ maxSize: undefined,
1159
+ maxFiles: undefined,
1160
+ mimeTypes: undefined,
1161
+ }
1162
+ if (arg === undefined) {
1163
+ return result
1164
+ }
1165
+ if (!arg.evaluated) {
1166
+ return undefined
1167
+ }
1168
+ if (arg.value === undefined) {
1169
+ return result
1170
+ }
1171
+ const allowed = multiple ? UPLOAD_OPTIONS : UPLOAD_OPTIONS.filter(option => option !== 'maxFiles')
1172
+ const shape = `{ ${allowed.map(option => `${option}?`).join(', ')} }`
1173
+ if (!isPlainObject(arg.value)) {
1174
+ error(ctx, arg.location, `${name} options must be an object ${shape}, got ${describe(arg.value)}`)
1175
+ return undefined
1176
+ }
1177
+ let ok = true
1178
+ const invalid = (message: string, hints: readonly string[] = []): void => {
1179
+ ok = false
1180
+ error(ctx, arg.location, `${name}: ${message}`, hints)
1181
+ }
1182
+ for (const [key, value] of Object.entries(arg.value)) {
1183
+ if (value === undefined) {
1184
+ continue
1185
+ }
1186
+ switch (key) {
1187
+ case 'required':
1188
+ if (typeof value === 'boolean') {
1189
+ result.required = value
1190
+ } else {
1191
+ invalid(`"required" must be a boolean, got ${describe(value)}`)
1192
+ }
1193
+ break
1194
+ case 'maxSize': {
1195
+ const size = parseByteSize(value)
1196
+ if (size === undefined) {
1197
+ invalid(`"maxSize" must be a size in bytes, got ${describe(value)}`, [
1198
+ "A non-negative integer (5_242_880), a size with a unit ('5mb', '512kb'; kb = 1024 bytes) or Infinity",
1199
+ ])
1200
+ } else {
1201
+ result.maxSize = size
1202
+ }
1203
+ break
1204
+ }
1205
+ case 'maxFiles':
1206
+ if (!multiple) {
1207
+ invalid('"maxFiles" only applies to @UploadedFiles (a single file is expected here)', [
1208
+ 'Use @UploadedFiles() to receive several files',
1209
+ ])
1210
+ } else if (value === Number.POSITIVE_INFINITY || (Number.isSafeInteger(value) && (value as number) >= 1)) {
1211
+ result.maxFiles = value as number
1212
+ } else {
1213
+ invalid(`"maxFiles" must be a positive integer, got ${describe(value)}`)
1214
+ }
1215
+ break
1216
+ case 'mimeTypes': {
1217
+ const patterns = Array.isArray(value) ? value : undefined
1218
+ const wrong = patterns?.filter(pattern => typeof pattern !== 'string' || !isMediaTypePattern(pattern)) ?? []
1219
+ if (patterns === undefined || patterns.length === 0) {
1220
+ invalid(`"mimeTypes" must be a non-empty array of media types, got ${describe(value)}`, [
1221
+ "mimeTypes: ['image/png', 'image/*']",
1222
+ ])
1223
+ } else if (wrong.length > 0) {
1224
+ invalid(`invalid media type ${wrong.map(describe).join(', ')} in "mimeTypes"`, [
1225
+ "Use type/subtype, type/* or */* without parameters: 'image/png', 'image/*'",
1226
+ ])
1227
+ } else {
1228
+ result.mimeTypes = patterns as string[]
1229
+ }
1230
+ break
1231
+ }
1232
+ default:
1233
+ invalid(`unknown option "${key}"`, [`Options: ${shape}`])
1234
+ }
1235
+ }
1236
+ return ok ? result : undefined
1237
+ }
1238
+
1239
+ /** Whether a parameter type can receive uploaded files (and whether the value must be cast). */
1240
+ function uploadType(
1241
+ ctx: Context,
1242
+ type: TypeInfo | undefined,
1243
+ multiple: boolean,
1244
+ ): { readonly ok: true; readonly cast: boolean } | { readonly ok: false; readonly got: string } {
1245
+ if (type === undefined) {
1246
+ return { ok: true, cast: false }
1247
+ }
1248
+ const { type: base } = ctx.types.stripNullish(type)
1249
+ if (isRaw(base)) {
1250
+ return { ok: true, cast: false }
1251
+ }
1252
+ let target: TypeInfo | undefined = base
1253
+ if (multiple) {
1254
+ target = base.kind === 'array' ? base.element : undefined
1255
+ }
1256
+ if (target !== undefined && isRaw(target)) {
1257
+ return { ok: true, cast: false }
1258
+ }
1259
+ const cast = target?.kind === 'reference' ? FILE_TYPES.get(target.identity) : undefined
1260
+ return cast === undefined ? { ok: false, got: ctx.types.format(type) } : { ok: true, cast }
1261
+ }
1262
+
1263
+ /** Name and/or schema of `@Param(name?, schema?)` / `@Query(name?, schema?)`. */
1264
+ function nameAndSchema(
1265
+ ctx: Context,
1266
+ decorator: DecoratorInfo,
1267
+ ):
1268
+ | { readonly ok: true; readonly key: string | undefined; readonly schema: ValueRef | undefined }
1269
+ | { readonly ok: false } {
1270
+ const [first, second] = decorator.args
1271
+ if (first === undefined) {
1272
+ return { ok: true, key: undefined, schema: undefined }
1273
+ }
1274
+ if (first.error !== undefined) {
1275
+ return { ok: false }
1276
+ }
1277
+ if (first.ref !== undefined) {
1278
+ if (second !== undefined) {
1279
+ error(ctx, second.location, `@${decorator.name}(schema) takes a single argument`, [
1280
+ `Put the name first: @${decorator.name}('name', Schema)`,
1281
+ ])
1282
+ return { ok: false }
1283
+ }
1284
+ return { ok: true, key: undefined, schema: first.ref }
1285
+ }
1286
+ if (typeof first.value !== 'string' || first.value === '') {
1287
+ error(
1288
+ ctx,
1289
+ first.location,
1290
+ `@${decorator.name} expects a name (non-empty string) or a schema, got ${describe(first.value)}`,
1291
+ [`@${decorator.name}('id'), @${decorator.name}('id', Schema) or @${decorator.name}(Schema)`],
1292
+ )
1293
+ return { ok: false }
1294
+ }
1295
+ if (second === undefined) {
1296
+ return { ok: true, key: first.value, schema: undefined }
1297
+ }
1298
+ const schema = schemaRef(ctx, second, `@${decorator.name}`)
1299
+ return schema ? { ok: true, key: first.value, schema } : { ok: false }
1300
+ }
1301
+
1302
+ /** A schema argument: a reference to an exported binding. */
1303
+ function schemaRef(ctx: Context, arg: ArgInfo, what: string): ValueRef | undefined {
1304
+ if (arg.error !== undefined) {
1305
+ return undefined // already reported
1306
+ }
1307
+ if (arg.ref === undefined) {
1308
+ error(ctx, arg.location, `${what} expects a Standard Schema, got ${describe(arg.value)}`, [
1309
+ 'Pass a schema declared with `export const Schema = z.object({ ... })` (Zod, Valibot, ArkType...)',
1310
+ ])
1311
+ return undefined
1312
+ }
1313
+ const symbol = arg.ref.symbol
1314
+ if (symbol.kind === 'declaration' && symbol.declaration.kind === 'class' && !hasStandardSchema(ctx, arg.ref)) {
1315
+ ctx.report({
1316
+ code: Codes.InvalidDecorator,
1317
+ severity: 'warning',
1318
+ message: `${what}(${arg.ref.text}): ${arg.ref.name} is a class, not a Standard Schema; the validation fails at runtime`,
1319
+ location: arg.location,
1320
+ hints: ['Pass a Standard Schema object (Zod, Valibot, ArkType...): classes are not validators'],
1321
+ })
1322
+ }
1323
+ return arg.ref
1324
+ }
1325
+
1326
+ function hasStandardSchema(ctx: Context, ref: ValueRef): boolean {
1327
+ const info = ref.identity === undefined ? undefined : ctx.classInfo(ref.identity)
1328
+ return (
1329
+ info === undefined ||
1330
+ info.superClass !== undefined ||
1331
+ info.properties.some(property => property.static && property.name === '~standard') ||
1332
+ info.methods.some(member => member.static && member.name === '~standard')
1333
+ )
1334
+ }
1335
+
1336
+ /** Coercion of a named parameter / query value / header without schema. */
1337
+ function coercionOf(
1338
+ ctx: Context,
1339
+ declared: ReturnType<typeof parameterType>,
1340
+ param: ParameterInfo,
1341
+ decorator: DecoratorInfo,
1342
+ where: string,
1343
+ source: 'param' | 'query' | 'headers' | 'cookie',
1344
+ key: string,
1345
+ ): ParamCoercion | undefined {
1346
+ const result = planCoercion(ctx, declared, param, source === 'param')
1347
+ const label = `${source === 'headers' ? 'header' : source} "${key}"`
1348
+ if (result.ok) {
1349
+ if (source === 'query' && declared.type !== undefined && isAny(declared.type)) {
1350
+ unvalidated(ctx, decorator, `${label} of ${where} is typed any`)
1351
+ }
1352
+ return result.coercion
1353
+ }
1354
+ if (source === 'query') {
1355
+ // unvalidated input: the raw value is passed as is
1356
+ unvalidated(ctx, decorator, `${label} of ${where}: ${result.reason}`, result.hints)
1357
+ return { plan: { kind: 'raw' }, cast: false }
1358
+ }
1359
+ ctx.report({
1360
+ code: Codes.InvalidRoute,
1361
+ severity: 'error',
1362
+ message: `Invalid ${label} of ${where}: ${result.reason}`,
1363
+ location: param.typeLocation,
1364
+ hints: [...result.hints],
1365
+ })
1366
+ return undefined
1367
+ }
1368
+
1369
+ /** ZNIGHT017 for a non-primitive `@Body()` / `@Query()` without schema. */
1370
+ function checkUnvalidated(
1371
+ ctx: Context,
1372
+ type: TypeInfo | undefined,
1373
+ decorator: DecoratorInfo,
1374
+ param: ParameterInfo,
1375
+ where: string,
1376
+ what: 'body' | 'query' | 'cookies',
1377
+ ): void {
1378
+ if (type !== undefined && acceptedWithoutSchema(ctx, type)) {
1379
+ return
1380
+ }
1381
+ const described = type === undefined ? 'untyped' : `typed ${ctx.types.format(type)}`
1382
+ unvalidated(ctx, decorator, `the ${what} of ${where} ('${param.name}', ${described}) has no schema`)
1383
+ }
1384
+
1385
+ function unvalidated(ctx: Context, decorator: DecoratorInfo, detail: string, extraHints: readonly string[] = []): void {
1386
+ ctx.report({
1387
+ code: Codes.UnvalidatedInput,
1388
+ severity: ctx.options.strictValidation ? 'error' : 'warning',
1389
+ message: `Unvalidated input: ${detail}`,
1390
+ location: decorator.location,
1391
+ hints: [
1392
+ ...extraHints.filter(hint => !hint.startsWith('Or validate')),
1393
+ `Validate it with a Standard Schema: ${schemaHint(decorator.name)}`,
1394
+ 'Or type the parameter as unknown and check it yourself',
1395
+ ],
1396
+ })
1397
+ }
1398
+
1399
+ function schemaHint(decorator: string): string {
1400
+ if (decorator === 'Body') {
1401
+ return '@Body(Schema) or @Validate(Schema)'
1402
+ }
1403
+ if (decorator === 'Cookie') {
1404
+ return "@Cookie(Schema) for every cookie, or @Cookie('name', Schema)"
1405
+ }
1406
+ return `@${decorator}('name', Schema)`
1407
+ }
1408
+
1409
+ function acceptedWithoutSchema(ctx: Context, type: TypeInfo): boolean {
1410
+ const { type: base } = ctx.types.stripNullish(type)
1411
+ switch (base.kind) {
1412
+ case 'primitive':
1413
+ return base.name !== 'any' && base.name !== 'object' && base.name !== 'never' && base.name !== 'symbol'
1414
+ case 'literal':
1415
+ return true
1416
+ case 'reference':
1417
+ if (UNVALIDATED_REFERENCES.has(base.identity)) {
1418
+ return true
1419
+ }
1420
+ // Record<string, string | string[]> (the shape of ctx.query and of form bodies)
1421
+ return (
1422
+ base.identity === 'globalThis#Record' &&
1423
+ base.typeArgs.length === 2 &&
1424
+ isStringLike(base.typeArgs[1] as TypeInfo)
1425
+ )
1426
+ default:
1427
+ return false
1428
+ }
1429
+ }
1430
+
1431
+ function isStringLike(type: TypeInfo): boolean {
1432
+ switch (type.kind) {
1433
+ case 'primitive':
1434
+ return type.name === 'string' || type.name === 'undefined' || type.name === 'unknown'
1435
+ case 'array':
1436
+ return type.element.kind === 'primitive' && type.element.name === 'string'
1437
+ case 'union':
1438
+ return type.types.every(isStringLike)
1439
+ default:
1440
+ return false
1441
+ }
1442
+ }
1443
+
1444
+ function isAny(type: TypeInfo): boolean {
1445
+ return isRaw(type) && type.kind === 'primitive' && type.name === 'any'
1446
+ }
1447
+
1448
+ // -------------------------------------------------------------------------------------------------
1449
+ // Helpers
1450
+ // -------------------------------------------------------------------------------------------------
1451
+
1452
+ /**
1453
+ * Decorators of a parameter without duplicates (a parameter with a default value may list its
1454
+ * decorators twice).
1455
+ */
1456
+ function unique(decorators: readonly DecoratorInfo[]): DecoratorInfo[] {
1457
+ const seen = new Set<number>()
1458
+ return decorators.filter(decorator => {
1459
+ if (seen.has(decorator.location.start)) {
1460
+ return false
1461
+ }
1462
+ seen.add(decorator.location.start)
1463
+ return true
1464
+ })
1465
+ }
1466
+
1467
+ /** `false` when the decorator is applied to a target it does not support (reported by the core). */
1468
+ function validTarget(decorator: DecoratorInfo): boolean {
1469
+ const identity = decorator.identity ?? ''
1470
+ if (ROUTE_METHODS.has(identity)) {
1471
+ return decorator.target === 'method'
1472
+ }
1473
+ if (PARAM_SOURCES.has(identity)) {
1474
+ return decorator.target === 'parameter'
1475
+ }
1476
+ switch (identity) {
1477
+ case Ids.Controller:
1478
+ return decorator.target === 'class'
1479
+ case Ids.UseGuard:
1480
+ case Ids.UseInterceptor:
1481
+ case Ids.BodyLimit:
1482
+ return decorator.target === 'class' || decorator.target === 'method'
1483
+ default:
1484
+ return decorator.target === 'method'
1485
+ }
1486
+ }
1487
+
1488
+ function isPlainObject(value: StaticValue): value is { readonly [key: string]: StaticValue } {
1489
+ return typeof value === 'object' && value !== null && !Array.isArray(value) && !isValueRef(value)
1490
+ }
1491
+
1492
+ function describe(value: StaticValue): string {
1493
+ if (value === null) {
1494
+ return 'null'
1495
+ }
1496
+ if (Array.isArray(value)) {
1497
+ return 'an array'
1498
+ }
1499
+ if (isValueRef(value)) {
1500
+ return `a reference to '${value.text}'`
1501
+ }
1502
+ if (typeof value === 'object') {
1503
+ return 'an object'
1504
+ }
1505
+ if (typeof value === 'string') {
1506
+ return JSON.stringify(value)
1507
+ }
1508
+ return typeof value === 'bigint' ? `${value}n` : String(value)
1509
+ }
1510
+
1511
+ function error(ctx: Context, location: Location, message: string, hints?: readonly string[]): void {
1512
+ ctx.report({ code: Codes.InvalidDecorator, severity: 'error', message, location, ...(hints ? { hints } : {}) })
1513
+ }