@znight/http 0.1.2 → 0.1.4

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 (46) hide show
  1. package/README.md +154 -35
  2. package/dist/compiler.d.mts +77 -2
  3. package/dist/compiler.mjs +259 -101
  4. package/dist/compiler.mjs.map +1 -1
  5. package/dist/express.mjs +3 -2
  6. package/dist/express.mjs.map +1 -1
  7. package/dist/fastify.mjs +200 -23
  8. package/dist/fastify.mjs.map +1 -1
  9. package/dist/hono.mjs +174 -43
  10. package/dist/hono.mjs.map +1 -1
  11. package/dist/index.d.mts +36 -4
  12. package/dist/index.mjs +1005 -257
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/{node-Clm1Ia7T.mjs → node-DnYv_AMI.mjs} +4 -3
  15. package/dist/{node-Clm1Ia7T.mjs.map → node-DnYv_AMI.mjs.map} +1 -1
  16. package/dist/{node-io-CVnqpVNS.mjs → node-io-BkdRSQeg.mjs} +99 -60
  17. package/dist/{node-io-CVnqpVNS.mjs.map → node-io-BkdRSQeg.mjs.map} +1 -1
  18. package/dist/node.mjs +1 -1
  19. package/dist/utils-DIWdzVht.mjs +46 -0
  20. package/dist/utils-DIWdzVht.mjs.map +1 -0
  21. package/package.json +4 -4
  22. package/src/adapters/express.ts +4 -1
  23. package/src/adapters/fastify.ts +265 -25
  24. package/src/adapters/hono.ts +225 -58
  25. package/src/adapters/node-io.ts +128 -58
  26. package/src/adapters/node.ts +4 -1
  27. package/src/body.ts +31 -2
  28. package/src/coerce.ts +31 -0
  29. package/src/compiler/analyze.ts +3 -3
  30. package/src/compiler/generate.ts +127 -22
  31. package/src/compiler/index.ts +13 -0
  32. package/src/compiler/route-decorators.ts +45 -2
  33. package/src/compiler/route-groups.ts +216 -0
  34. package/src/compiler/validate.ts +15 -126
  35. package/src/context.ts +148 -78
  36. package/src/decorators.ts +19 -3
  37. package/src/index.ts +2 -1
  38. package/src/pipeline.ts +936 -177
  39. package/src/plugin-routes.ts +19 -0
  40. package/src/plugin.ts +7 -3
  41. package/src/response.ts +6 -8
  42. package/src/serialize.ts +48 -21
  43. package/src/utils.ts +25 -2
  44. package/src/validate.ts +28 -12
  45. package/dist/utils-DCbu3a4w.mjs +0 -27
  46. package/dist/utils-DCbu3a4w.mjs.map +0 -1
@@ -56,47 +56,84 @@ export function readNodeBody(request: IncomingMessage, limit: number): Promise<U
56
56
  request.resume()
57
57
  return Promise.reject(payloadTooLarge(limit))
58
58
  }
59
+ // a read in progress (the adapters read a body once: not expected)
60
+ const reading = (request as ReadingRequest)[BODY_READ]
61
+ if (reading != null) return reading.promise
59
62
  if (consumedElsewhere(request)) return Promise.reject(consumedError())
60
63
  if (request.readableEnded) return Promise.resolve(new Uint8Array(0))
61
64
  if (request.destroyed) return Promise.reject(new BadRequestException('The request was aborted'))
62
- return new Promise((resolve, reject) => {
63
- const chunks: Uint8Array[] = []
64
- let size = 0
65
- const cleanup = (): void => {
66
- request.off('data', onData)
67
- request.off('end', onEnd)
68
- request.off('error', onError)
69
- request.off('close', onClose)
70
- }
71
- const onData = (chunk: Uint8Array | string): void => {
72
- const bytes = typeof chunk === 'string' ? Buffer.from(chunk) : chunk
73
- size += bytes.byteLength
74
- if (size > limit) {
75
- cleanup()
76
- request.resume() // discard the rest; the 413 response closes the connection
77
- reject(payloadTooLarge(limit))
78
- return
79
- }
80
- chunks.push(bytes)
81
- }
82
- const onEnd = (): void => {
83
- cleanup()
84
- resolve(concat(chunks, size))
85
- }
86
- const onError = (error: Error): void => {
87
- cleanup()
88
- reject(new BadRequestException('Failed to read the request body', undefined, { cause: error }))
89
- }
90
- const onClose = (): void => {
91
- if (request.complete) return
92
- cleanup()
93
- reject(new BadRequestException('The request was aborted'))
94
- }
95
- request.on('data', onData)
96
- request.on('end', onEnd)
97
- request.on('error', onError)
98
- request.on('close', onClose)
65
+ let resolve!: (bytes: Uint8Array) => void
66
+ let reject!: (error: unknown) => void
67
+ const promise = new Promise<Uint8Array>((onRead, onError) => {
68
+ resolve = onRead
69
+ reject = onError
99
70
  })
71
+ ;(request as ReadingRequest)[BODY_READ] = { limit, chunks: [], size: 0, resolve, reject, promise }
72
+ if (reading === undefined) {
73
+ request.on('data', onBodyData)
74
+ request.on('end', onBodyEnd)
75
+ request.on('error', onBodyError)
76
+ request.on('close', onBodyClose)
77
+ }
78
+ return promise
79
+ }
80
+
81
+ /**
82
+ * The state of the body read by {@link readNodeBody}, on its request: `null` once it is over (the
83
+ * listeners stay), `undefined` before.
84
+ */
85
+ const BODY_READ = Symbol('znight.bodyRead')
86
+
87
+ interface BodyRead {
88
+ readonly limit: number
89
+ readonly chunks: Uint8Array[]
90
+ size: number
91
+ readonly resolve: (bytes: Uint8Array) => void
92
+ readonly reject: (error: unknown) => void
93
+ readonly promise: Promise<Uint8Array>
94
+ }
95
+
96
+ type ReadingRequest = IncomingMessage & { [BODY_READ]?: BodyRead | null }
97
+
98
+ // The listeners of readNodeBody: shared functions (`this` is the request), no closure per request.
99
+ // They stay attached once the read is over (removing them costs a lot more than leaving them): they
100
+ // then do nothing. Node.js emits `error` on a destroyed request only when it has listeners, so the
101
+ // `error` listener left behind changes nothing either.
102
+
103
+ /** Stops reading: returns the state of the read (`undefined` when it is already over). */
104
+ function stopBodyRead(request: ReadingRequest): BodyRead | undefined {
105
+ const read = request[BODY_READ]
106
+ if (read == null) return undefined
107
+ request[BODY_READ] = null
108
+ return read
109
+ }
110
+
111
+ function onBodyData(this: ReadingRequest, chunk: Uint8Array | string): void {
112
+ const read = this[BODY_READ]
113
+ if (read == null) return
114
+ const bytes = typeof chunk === 'string' ? Buffer.from(chunk) : chunk
115
+ read.size += bytes.byteLength
116
+ if (read.size > read.limit) {
117
+ stopBodyRead(this)
118
+ this.resume() // discard the rest; the 413 response closes the connection
119
+ read.reject(payloadTooLarge(read.limit))
120
+ return
121
+ }
122
+ read.chunks.push(bytes)
123
+ }
124
+
125
+ function onBodyEnd(this: ReadingRequest): void {
126
+ const read = stopBodyRead(this)
127
+ read?.resolve(concat(read.chunks, read.size))
128
+ }
129
+
130
+ function onBodyError(this: ReadingRequest, error: Error): void {
131
+ stopBodyRead(this)?.reject(new BadRequestException('Failed to read the request body', undefined, { cause: error }))
132
+ }
133
+
134
+ function onBodyClose(this: ReadingRequest): void {
135
+ if (this.complete) return
136
+ stopBodyRead(this)?.reject(new BadRequestException('The request was aborted'))
100
137
  }
101
138
 
102
139
  /**
@@ -181,11 +218,13 @@ export interface NodeRequestOptions {
181
218
  export function nodeAdapterRequest(
182
219
  request: IncomingMessage,
183
220
  response: ServerResponse,
184
- options: NodeRequestOptions = {},
221
+ options: NodeRequestOptions = NO_OPTIONS,
185
222
  ): AdapterRequest {
186
223
  return new NodeAdapterRequest(request, response, options)
187
224
  }
188
225
 
226
+ const NO_OPTIONS: NodeRequestOptions = Object.freeze({})
227
+
189
228
  /** A Node.js request/response pair as an {@link AdapterRequest} (a class: no closure per request). */
190
229
  class NodeAdapterRequest implements AdapterRequest {
191
230
  readonly method: string
@@ -269,7 +308,10 @@ export function writeNodeReply(response: ServerResponse, reply: AdapterReply): v
269
308
  try {
270
309
  // one call (merged over headers set earlier with setHeader(), e.g. by Express middleware); the
271
310
  // cookies set earlier are kept before the reply's own (writeHead would replace them)
272
- const headers = withNativeCookies(response.getHeader('set-cookie'), reply.headers)
311
+ const headers =
312
+ reply.headers['set-cookie'] === undefined
313
+ ? reply.headers
314
+ : withNativeCookies(response.getHeader('set-cookie'), reply.headers)
273
315
  response.writeHead(reply.status, headers as OutgoingHttpHeaders)
274
316
  } catch {
275
317
  // invalid header name or value set by user code
@@ -334,44 +376,72 @@ export function writeNodeFailure(response: ServerResponse): void {
334
376
  response.end('{"statusCode":500,"error":"Internal Server Error"}')
335
377
  }
336
378
 
379
+ /** The tracker of a socket (see {@link ConnectionTracker}). */
380
+ const TRACKER = Symbol('znight.tracker')
381
+ /** The number of requests in progress on a tracked socket. */
382
+ const ACTIVE = Symbol('znight.active')
383
+
384
+ interface TrackedSocket extends Socket {
385
+ [TRACKER]?: ConnectionTracker
386
+ [ACTIVE]?: number
387
+ }
388
+
337
389
  /**
338
390
  * Tracks the connections of a `node:http` server to close them on shutdown: idle keep-alive
339
391
  * connections first (graceful), every connection when forced. `server.closeIdleConnections()` /
340
- * `closeAllConnections()` are not reliable on every runtime (Bun), so sockets are tracked here.
392
+ * `closeAllConnections()` are not reliable on every runtime (Bun), so sockets are tracked here. The
393
+ * count of requests in progress lives on the socket, and one shared `close` listener per response
394
+ * decrements it (no closure, no map lookup per request).
341
395
  */
342
396
  export class ConnectionTracker {
343
- /** Socket → number of requests in progress. */
344
- readonly #sockets = new Map<Socket, number>()
397
+ readonly #sockets = new Set<TrackedSocket>()
345
398
  #closing = false
346
399
 
347
- constructor(server: Server) {
348
- server.on('connection', (socket: Socket) => {
349
- this.#sockets.set(socket, 0)
400
+ /**
401
+ * `requests: false` when the caller's own `request` listener calls {@link track} for every request
402
+ * of the server (one listener less to call per request).
403
+ */
404
+ constructor(server: Server, options: { readonly requests?: boolean } = {}) {
405
+ server.on('connection', (socket: TrackedSocket) => {
406
+ socket[TRACKER] = this
407
+ socket[ACTIVE] = 0
408
+ this.#sockets.add(socket)
350
409
  socket.once('close', () => this.#sockets.delete(socket))
351
410
  })
352
- server.on('request', (request: IncomingMessage, response: ServerResponse) => {
353
- const socket = request.socket
354
- if (!this.#sockets.has(socket)) return
355
- this.#sockets.set(socket, (this.#sockets.get(socket) ?? 0) + 1)
356
- response.once('close', () => {
357
- const active = this.#sockets.get(socket)
358
- if (active === undefined) return
359
- this.#sockets.set(socket, active - 1)
360
- if (active - 1 === 0 && this.#closing) socket.end()
361
- })
362
- })
411
+ if (options.requests !== false) {
412
+ server.on('request', (request: IncomingMessage, response: ServerResponse) => this.track(request, response))
413
+ }
414
+ }
415
+
416
+ /** Counts a request in progress on its connection, until its response closes (other servers' requests are ignored). */
417
+ track(request: IncomingMessage, response: ServerResponse): void {
418
+ const socket = request.socket as TrackedSocket | null
419
+ if (socket?.[TRACKER] !== this) return
420
+ socket[ACTIVE] = (socket[ACTIVE] ?? 0) + 1
421
+ response.on('close', ConnectionTracker.#onResponseClose)
422
+ }
423
+
424
+ /** A response closed (`this`): one request less on its socket, ended when idle while closing. */
425
+ static readonly #onResponseClose = function (this: ServerResponse): void {
426
+ // the socket of a finished response is detached: reach it through its request
427
+ const socket = (this.req as IncomingMessage | undefined)?.socket as TrackedSocket | null | undefined
428
+ const tracker = socket?.[TRACKER]
429
+ if (socket == null || tracker === undefined) return
430
+ const active = (socket[ACTIVE] ?? 1) - 1
431
+ socket[ACTIVE] = active
432
+ if (active === 0 && tracker.#closing) socket.end()
363
433
  }
364
434
 
365
435
  /** Ends idle connections now and the others once their requests completed. */
366
436
  closeIdle(): void {
367
437
  this.#closing = true
368
- for (const [socket, active] of this.#sockets) if (active === 0) socket.end()
438
+ for (const socket of this.#sockets) if (socket[ACTIVE] === 0) socket.end()
369
439
  }
370
440
 
371
441
  /** Destroys every connection. */
372
442
  destroyAll(): void {
373
443
  this.#closing = true
374
- for (const socket of this.#sockets.keys()) socket.destroy()
444
+ for (const socket of this.#sockets) socket.destroy()
375
445
  }
376
446
  }
377
447
 
@@ -82,6 +82,8 @@ class NodeHttpAdapter implements NodeAdapter {
82
82
  }
83
83
 
84
84
  readonly handler = (request: IncomingMessage, response: ServerResponse): void => {
85
+ // the listening server's connections (a request of another server is ignored)
86
+ this.#tracker?.track(request, response)
85
87
  const url = request.url ?? '/'
86
88
  const query = url.indexOf('?')
87
89
  const handler = this.#routes.find(request.method ?? 'GET', query === -1 ? url : url.slice(0, query))
@@ -101,7 +103,8 @@ class NodeHttpAdapter implements NodeAdapter {
101
103
  async listen(port: number, host: string): Promise<HttpServerAddress> {
102
104
  const server = this.#server ?? createServer(this.#options.serverOptions ?? {})
103
105
  this.#server = server
104
- this.#tracker ??= new ConnectionTracker(server)
106
+ // `handler` counts the requests
107
+ this.#tracker ??= new ConnectionTracker(server, { requests: false })
105
108
  this.#closing = undefined
106
109
  if (!server.listeners('request').includes(this.handler)) server.on('request', this.handler)
107
110
  await new Promise<void>((resolve, reject) => {
package/src/body.ts CHANGED
@@ -19,9 +19,30 @@ export interface MediaType {
19
19
  readonly charset: string | undefined
20
20
  }
21
21
 
22
+ /**
23
+ * Parsed `content-type` values: an application receives a handful of them, parsed twice per request
24
+ * with a body (request context, then {@link parseBody}). Bounded, since clients choose the values:
25
+ * cleared when full.
26
+ */
27
+ const MEDIA_TYPES = new Map<string, MediaType>()
28
+ const MEDIA_TYPES_MAX = 64
29
+
22
30
  /** Parses a `content-type` header value (`application/json; charset=utf-8`). */
23
31
  export function parseMediaType(header: string | undefined): MediaType | undefined {
24
32
  if (header === undefined) return undefined
33
+ // the most common value, compared faster than hashed
34
+ if (header === 'application/json') return { type: 'application/json', charset: undefined }
35
+ const cached = MEDIA_TYPES.get(header)
36
+ // a fresh object, as when parsing (callers own the result)
37
+ if (cached !== undefined) return { type: cached.type, charset: cached.charset }
38
+ const media = parseUncached(header)
39
+ if (media === undefined) return undefined
40
+ if (MEDIA_TYPES.size === MEDIA_TYPES_MAX) MEDIA_TYPES.clear()
41
+ MEDIA_TYPES.set(header, media)
42
+ return { type: media.type, charset: media.charset }
43
+ }
44
+
45
+ function parseUncached(header: string): MediaType | undefined {
25
46
  const separator = header.indexOf(';')
26
47
  const type = (separator === -1 ? header : header.slice(0, separator)).trim().toLowerCase()
27
48
  if (type === '') return undefined
@@ -86,13 +107,21 @@ export function parseBody(
86
107
  contentEncoding?: string | undefined,
87
108
  ): Promise<unknown> {
88
109
  try {
89
- return Promise.resolve(parseBytes(bytes, contentType, contentEncoding))
110
+ return Promise.resolve(parseBodyBytes(bytes, contentType, contentEncoding))
90
111
  } catch (error) {
91
112
  return Promise.reject(error)
92
113
  }
93
114
  }
94
115
 
95
- function parseBytes(bytes: Uint8Array, contentType: string | undefined, contentEncoding: string | undefined): unknown {
116
+ /**
117
+ * {@link parseBody} without the promise: returns the parsed body or throws its `HttpException`
118
+ * (internal: the request context parses the buffered body with it, without an extra microtask).
119
+ */
120
+ export function parseBodyBytes(
121
+ bytes: Uint8Array,
122
+ contentType: string | undefined,
123
+ contentEncoding: string | undefined,
124
+ ): unknown {
96
125
  if (bytes.byteLength === 0) return undefined
97
126
  checkContentEncoding(contentEncoding)
98
127
  const media = parseMediaType(contentType)
package/src/coerce.ts CHANGED
@@ -60,7 +60,31 @@ function single(value: unknown, label: string): string {
60
60
  throw invalid(label, 'a string', value)
61
61
  }
62
62
 
63
+ /** Longest run of decimal digits whose value is exact without parsing (`999999999999999` < 2^53). */
64
+ const MAX_FAST_DIGITS = 15
65
+
66
+ /**
67
+ * The value of `text` when it is 1 to 15 ASCII decimal digits (the common `/users/42`), `-1`
68
+ * otherwise. The same number as `Number(text)` for such a string, without the regular expression,
69
+ * the trim and the parse of the general path.
70
+ */
71
+ function digitsValue(text: string): number {
72
+ const length = text.length
73
+ if (length === 0 || length > MAX_FAST_DIGITS) return -1
74
+ let result = 0
75
+ for (let index = 0; index < length; index++) {
76
+ const digit = text.charCodeAt(index) - 48
77
+ if (digit < 0 || digit > 9) return -1
78
+ result = result * 10 + digit
79
+ }
80
+ return result
81
+ }
82
+
63
83
  function toNumber(value: unknown, label: string): number {
84
+ if (typeof value === 'string') {
85
+ const digits = digitsValue(value)
86
+ if (digits !== -1) return digits
87
+ }
64
88
  const raw = single(value, label)
65
89
  const text = raw.trim()
66
90
  if (NUMBER.test(text)) {
@@ -71,6 +95,10 @@ function toNumber(value: unknown, label: string): number {
71
95
  }
72
96
 
73
97
  function toInteger(value: unknown, label: string): number {
98
+ if (typeof value === 'string') {
99
+ const digits = digitsValue(value)
100
+ if (digits !== -1) return digits
101
+ }
74
102
  const raw = single(value, label)
75
103
  const text = raw.trim()
76
104
  if (INTEGER.test(text)) {
@@ -81,6 +109,9 @@ function toInteger(value: unknown, label: string): number {
81
109
  }
82
110
 
83
111
  function toBoolean(value: unknown, label: string): boolean {
112
+ // the canonical spellings, without the trim and the lower-casing of the general path
113
+ if (value === 'true' || value === '1') return true
114
+ if (value === 'false' || value === '0') return false
84
115
  const raw = single(value, label)
85
116
  switch (raw.trim().toLowerCase()) {
86
117
  case 'true':
@@ -31,7 +31,7 @@ import { joinPaths, normalizePath } from '../path.ts'
31
31
  import { parseByteSize } from '../size.ts'
32
32
  import { acceptsUndefined, isRaw, parameterType, planCoercion } from './coercion.ts'
33
33
  import { Codes, HTTP_PACKAGE, Ids, PARAM_SOURCES, ROUTE_METHODS } from './model.ts'
34
- import { isRouteMetaDecorator, isValueRef, routeMeta } from './route-decorators.ts'
34
+ import { isRouteMetaDecorator, isValueRef, readRouteMeta } from './route-decorators.ts'
35
35
 
36
36
  /** Name of the extension (owner of the decorators in `DecoratorInfo.owner`). */
37
37
  export const EXTENSION_NAME = 'http'
@@ -96,7 +96,7 @@ export function analyzeHttpClass(cls: ClassInfo, ctx: Context): void {
96
96
  const basePath = normalizePath(options.path)
97
97
  const guards = classRefs(ctx, cls, cls.decorators, Ids.UseGuard, 'guard')
98
98
  const interceptors = classRefs(ctx, cls, cls.decorators, Ids.UseInterceptor, 'interceptor')
99
- const meta = routeMeta(ctx, cls, cls.decorators, cls.displayName)
99
+ const meta = readRouteMeta(ctx, cls)
100
100
  const bodyLimit = bodyLimitOf(ctx, cls.decorators)
101
101
  const routes: HttpRouteData[] = []
102
102
  for (const method of cls.methods) {
@@ -328,7 +328,7 @@ function analyzeMethod(
328
328
  let headers = responseHeaders(ctx, decorators)
329
329
  const guards = classRefs(ctx, cls, decorators, Ids.UseGuard, 'guard')
330
330
  const interceptors = classRefs(ctx, cls, decorators, Ids.UseInterceptor, 'interceptor')
331
- const meta = routeMeta(ctx, cls, decorators, where)
331
+ const meta = readRouteMeta(ctx, cls, method)
332
332
  const bodySchema =
333
333
  bodyValidation !== undefined && !params.some(param => param.source === 'body') ? bodyValidation.schema : undefined
334
334
  let rawResponse = params.some(param => param.source === 'response')
@@ -30,6 +30,7 @@ const UPLOAD_HELPERS = {
30
30
  interface Helpers {
31
31
  coerce(): GenNode
32
32
  validate(): GenNode
33
+ isThenable(): GenNode
33
34
  uploads(): GenNode
34
35
  context(): GenNode
35
36
  }
@@ -98,6 +99,7 @@ function groupsNode(ctx: Context, controllers: readonly HttpControllerData[]): G
98
99
  function createHelpers(ctx: Context): Helpers {
99
100
  let coerce: GenNode | undefined
100
101
  let validate: GenNode | undefined
102
+ let isThenable: GenNode | undefined
101
103
  let uploads: GenNode | undefined
102
104
  let context: GenNode | undefined
103
105
  return {
@@ -109,6 +111,10 @@ function createHelpers(ctx: Context): Helpers {
109
111
  validate ??= ctx.importFrom(HTTP_PACKAGE, 'validate')
110
112
  return validate
111
113
  },
114
+ isThenable: () => {
115
+ isThenable ??= ctx.importFrom(HTTP_PACKAGE, 'isThenable')
116
+ return isThenable
117
+ },
112
118
  uploads: () => {
113
119
  uploads ??= ctx.importFrom(HTTP_PACKAGE, 'uploads')
114
120
  return uploads
@@ -239,39 +245,138 @@ function tokens(ctx: Context, refs: readonly ClassRef[]): GenNode {
239
245
  // -------------------------------------------------------------------------------------------------
240
246
 
241
247
  /**
242
- * `(c: Controller, ctx: HttpContext) => c.handler(...args)`: `async` (awaiting every `validate()`)
243
- * when the route validates something with a schema, synchronous otherwise.
248
+ * `(c: Controller, ctx: HttpContext) => c.handler(...args)`, synchronous when no argument is
249
+ * validated with a schema. A route validating arguments gets a block that stays synchronous as long
250
+ * as every `validate()` returns a value (see {@link validatingBlock}). `@Validate(S)` without
251
+ * `@Body()` parameter reads the body in `invoke`: that arrow is `async` and `await`s every
252
+ * `validate()`.
244
253
  */
245
254
  function invokeNode(ctx: Context, helpers: Helpers, controller: HttpControllerData, route: HttpRouteData): GenNode {
246
- const async = route.bodySchema !== undefined || route.params.some(param => param.schema !== undefined)
247
255
  const params: [string, GenNode][] = [['c', ctx.importType(controller.identity)]]
248
256
  // `ctx` is declared only when used (generated code type-checks with `noUnusedParameters`)
249
257
  if (route.params.length > 0 || route.bodySchema !== undefined) {
250
258
  params.push(['ctx', helpers.context()])
251
259
  }
252
- return ctx.arrow(
253
- params,
254
- (c, http) => {
255
- const args: GenNode[] = []
256
- for (const param of route.params) {
257
- args[param.index] = argumentNode(ctx, helpers, http as GenNode, param)
260
+ // arguments in evaluation order (handler parameter order); a hole is passed as `undefined`
261
+ const slots: (HttpParamData | undefined)[] = []
262
+ for (const param of route.params) {
263
+ slots[param.index] = param
264
+ }
265
+ const bodySchema = route.bodySchema
266
+ if (bodySchema !== undefined) {
267
+ return ctx.arrow(
268
+ params,
269
+ (c, http) => {
270
+ const call = handlerCall(
271
+ ctx,
272
+ c,
273
+ route,
274
+ Array.from(slots, param => awaitedArgument(ctx, helpers, http as GenNode, param)),
275
+ )
276
+ // @Validate(S) without @Body() parameter: the body is validated (and discarded)
277
+ const body = ctx.code`await ${ctx.call(ctx.member(http as GenNode, 'parseBody'))}`
278
+ const check = ctx.code`await ${validation(ctx, helpers, bodySchema, body, 'body')}`
279
+ return ctx.code`{ ${check}; return ${call} }`
280
+ },
281
+ { async: true },
282
+ )
283
+ }
284
+ const last = slots.findLastIndex(param => param?.schema !== undefined)
285
+ return ctx.arrow(params, (c, http) =>
286
+ last === -1
287
+ ? handlerCall(
288
+ ctx,
289
+ c,
290
+ route,
291
+ Array.from(slots, param => plainArgument(ctx, helpers, http as GenNode, param)),
292
+ )
293
+ : validatingBlock(ctx, helpers, route, slots, last, c, http as GenNode),
294
+ )
295
+ }
296
+
297
+ /** `c.handler(args)`. */
298
+ function handlerCall(ctx: Context, c: GenNode, route: HttpRouteData, args: readonly GenNode[]): GenNode {
299
+ return ctx.call(ctx.member(c, route.handler), args)
300
+ }
301
+
302
+ /**
303
+ * The body of the `invoke` of a route validating arguments with schemas: the arguments up to the
304
+ * last validated one are evaluated in order into `const`s (`a<index>`), the handler is called with
305
+ * them. `validate()` returns the value when the schema validates synchronously (Zod, Valibot and
306
+ * ArkType without async refinements), so the request needs no promise; when one returns a
307
+ * thenable, the rest runs in an `async` arrow that awaits it, then evaluates the remaining arguments
308
+ * (`await`ing their validations) and calls the handler: the order of evaluation, the error reported
309
+ * when several arguments are invalid and the value received by the handler are those of an `async`
310
+ * `invoke` awaiting every `validate()`.
311
+ *
312
+ * ```ts
313
+ * (c: UserController, ctx: HttpContext) => {
314
+ * const a0 = coerce.number(ctx.params['id'], 'param "id"')
315
+ * const a1 = validate(UpdateUser, ctx.body, 'body')
316
+ * if (isThenable(a1)) return (async () => c.update(a0, await a1, ctx))()
317
+ * return c.update(a0, a1, ctx)
318
+ * }
319
+ * ```
320
+ */
321
+ function validatingBlock(
322
+ ctx: Context,
323
+ helpers: Helpers,
324
+ route: HttpRouteData,
325
+ slots: readonly (HttpParamData | undefined)[],
326
+ last: number,
327
+ c: GenNode,
328
+ http: GenNode,
329
+ ): GenNode {
330
+ // `@Ctx()` and holes are inlined: nothing to evaluate
331
+ const bound: number[] = []
332
+ for (let index = 0; index <= last; index++) {
333
+ const param = slots[index]
334
+ if (param !== undefined && param.source !== 'context') {
335
+ bound.push(index)
336
+ }
337
+ }
338
+ return ctx.block(
339
+ bound.map(index => `a${index}`),
340
+ (...locals) => {
341
+ const local = new Map(bound.map((index, position) => [index, locals[position] as GenNode]))
342
+ // the value of an argument evaluated before the current statement
343
+ const evaluated = (index: number): GenNode => local.get(index) ?? plainArgument(ctx, helpers, http, slots[index])
344
+ const statements: GenNode[] = []
345
+ for (const index of bound) {
346
+ const param = slots[index] as HttpParamData
347
+ const name = local.get(index) as GenNode
348
+ statements.push(ctx.code`const ${name} = ${plainArgument(ctx, helpers, http, param)}`)
349
+ if (param.schema !== undefined) {
350
+ const rest = Array.from(slots, (other, position) => {
351
+ if (position < index) {
352
+ return evaluated(position)
353
+ }
354
+ return position === index ? ctx.code`await ${name}` : awaitedArgument(ctx, helpers, http, other)
355
+ })
356
+ const resume = ctx.arrow([], () => handlerCall(ctx, c, route, rest), { async: true })
357
+ statements.push(ctx.code`if (${ctx.call(helpers.isThenable(), [name])}) return (${resume})()`)
358
+ }
258
359
  }
259
- const call = ctx.call(
260
- ctx.member(c, route.handler),
261
- Array.from(args, arg => arg ?? ctx.lit(undefined)),
360
+ const args = Array.from(slots, (param, position) =>
361
+ position <= last ? evaluated(position) : plainArgument(ctx, helpers, http, param),
262
362
  )
263
- if (route.bodySchema === undefined) {
264
- return call
265
- }
266
- // @Validate(S) without @Body() parameter: the body is validated (and discarded)
267
- const body = ctx.code`await ${ctx.call(ctx.member(http as GenNode, 'parseBody'))}`
268
- const check = validation(ctx, helpers, route.bodySchema, body, 'body')
269
- return ctx.code`{ ${check}; return ${call} }`
363
+ statements.push(ctx.code`return ${handlerCall(ctx, c, route, args)}`)
364
+ return statements
270
365
  },
271
- { async },
272
366
  )
273
367
  }
274
368
 
369
+ /** An argument evaluated in an `async` arrow: its validation, if any, is `await`ed. */
370
+ function awaitedArgument(ctx: Context, helpers: Helpers, http: GenNode, param: HttpParamData | undefined): GenNode {
371
+ const node = plainArgument(ctx, helpers, http, param)
372
+ return param?.schema === undefined ? node : ctx.code`await ${node}`
373
+ }
374
+
375
+ /** An argument as an expression (`validate()` not awaited); `undefined` for a hole. */
376
+ function plainArgument(ctx: Context, helpers: Helpers, http: GenNode, param: HttpParamData | undefined): GenNode {
377
+ return param === undefined ? ctx.lit(undefined) : argumentNode(ctx, helpers, http, param)
378
+ }
379
+
275
380
  /** The expression passed to the handler for one parameter. */
276
381
  function argumentNode(ctx: Context, helpers: Helpers, http: GenNode, param: HttpParamData): GenNode {
277
382
  switch (param.source) {
@@ -333,9 +438,9 @@ function argumentNode(ctx: Context, helpers: Helpers, http: GenNode, param: Http
333
438
  }
334
439
  }
335
440
 
336
- /** `await validate(schema, value, location)`. */
441
+ /** `validate(schema, value, location)` (the value, or a promise when the schema validates asynchronously). */
337
442
  function validation(ctx: Context, helpers: Helpers, schema: ValueRef, value: GenNode, location: string): GenNode {
338
- return ctx.code`await ${ctx.call(helpers.validate(), [ctx.ref(schema), value, location])}`
443
+ return ctx.call(helpers.validate(), [ctx.ref(schema), value, location])
339
444
  }
340
445
 
341
446
  /** `coerce.X(value, label, ...)`, cast with `as never` when the result does not have the declared type. */
@@ -10,6 +10,9 @@
10
10
  * - `generate`: `metadata.extensions.http` with one `invoke` function per route (README,
11
11
  * "Compiler contract").
12
12
  *
13
+ * It also exports the route decorator readers of other extensions: `readRouteMeta`,
14
+ * `effectiveRouteMeta` and `requiredRouteGroups`.
15
+ *
13
16
  * This entry only `import type`s from `@znight/compiler` and never loads the HTTP runtime.
14
17
  */
15
18
  import type { CompilerExtension, DecoratorSpec } from '@znight/compiler'
@@ -31,6 +34,16 @@ export type {
31
34
  SseRouteData,
32
35
  UploadData,
33
36
  } from './model.ts'
37
+ export type { RequiredRouteGroup, RouteGroupMember } from './route-groups.ts'
38
+
39
+ /**
40
+ * Route decorators for other extensions (RFC 0003 §6.4, RFC 0005 H3): read `createRouteDecorator`
41
+ * metadata on any class or method, and the required groups of the application, with the exact
42
+ * rules of controllers and routes. Stable API, see the README ("Route metadata in other
43
+ * extensions").
44
+ */
45
+ export { effectiveRouteMeta, readRouteMeta } from './route-decorators.ts'
46
+ export { requiredRouteGroups } from './route-groups.ts'
34
47
 
35
48
  const ROUTE: DecoratorSpec = { targets: ['method'], params: ['static'] }
36
49
  const NAME_OR_SCHEMA: DecoratorSpec = { targets: ['parameter'], params: ['value', 'value'] }