@znight/http 0.1.1 → 0.1.3

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 (47) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +83 -32
  3. package/dist/{adapter-CGSceVEN.d.mts → adapter-J0UXFnNM.d.mts} +7 -1
  4. package/dist/compiler.d.mts +1 -1
  5. package/dist/compiler.mjs +82 -13
  6. package/dist/compiler.mjs.map +1 -1
  7. package/dist/express.d.mts +1 -1
  8. package/dist/express.mjs +3 -2
  9. package/dist/express.mjs.map +1 -1
  10. package/dist/fastify.d.mts +1 -1
  11. package/dist/fastify.mjs +254 -42
  12. package/dist/fastify.mjs.map +1 -1
  13. package/dist/hono.d.mts +1 -1
  14. package/dist/hono.mjs +272 -67
  15. package/dist/hono.mjs.map +1 -1
  16. package/dist/index.d.mts +10 -2
  17. package/dist/index.mjs +930 -278
  18. package/dist/index.mjs.map +1 -1
  19. package/dist/{node-BUnkdSsa.mjs → node-DnYv_AMI.mjs} +4 -3
  20. package/dist/{node-BUnkdSsa.mjs.map → node-DnYv_AMI.mjs.map} +1 -1
  21. package/dist/{node-io-BY1ZDLRQ.mjs → node-io-BkdRSQeg.mjs} +151 -86
  22. package/dist/{node-io-BY1ZDLRQ.mjs.map → node-io-BkdRSQeg.mjs.map} +1 -1
  23. package/dist/node.d.mts +1 -1
  24. package/dist/node.mjs +1 -1
  25. package/dist/utils-DIWdzVht.mjs +46 -0
  26. package/dist/utils-DIWdzVht.mjs.map +1 -0
  27. package/package.json +4 -4
  28. package/src/adapter.ts +6 -0
  29. package/src/adapters/express.ts +4 -1
  30. package/src/adapters/fastify.ts +329 -44
  31. package/src/adapters/hono.ts +341 -86
  32. package/src/adapters/node-io.ts +185 -82
  33. package/src/adapters/node.ts +4 -1
  34. package/src/body.ts +31 -2
  35. package/src/coerce.ts +31 -0
  36. package/src/compiler/generate.ts +127 -22
  37. package/src/context.ts +151 -78
  38. package/src/index.ts +1 -1
  39. package/src/pipeline.ts +809 -208
  40. package/src/plugin.ts +10 -4
  41. package/src/query.ts +7 -1
  42. package/src/response.ts +6 -8
  43. package/src/serialize.ts +83 -16
  44. package/src/utils.ts +25 -2
  45. package/src/validate.ts +28 -12
  46. package/dist/utils-DCbu3a4w.mjs +0 -27
  47. 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,25 +218,56 @@ 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
- let body: Promise<Uint8Array> | undefined
187
- return {
188
- method: request.method ?? 'GET',
189
- url: options.url ?? request.url ?? '/',
190
- headers: normalizeNodeHeaders(request.headers),
191
- ip: options.ip ?? request.socket?.remoteAddress,
192
- remoteAddress: request.socket?.remoteAddress,
193
- encrypted: isEncrypted(request),
194
- raw: options.raw ?? request,
195
- rawResponse: options.rawResponse ?? response,
196
- readBody: (limit: number): Promise<Uint8Array> => {
197
- body ??= readNodeBody(request, limit)
198
- return body
199
- },
200
- streamBody: (limit: number): ReadableStream<Uint8Array> => nodeBodyStream(request, limit),
201
- rawStatus: (): number => response.statusCode,
202
- onAbort: (listener: () => void): void => onResponseAbort(response, listener),
223
+ return new NodeAdapterRequest(request, response, options)
224
+ }
225
+
226
+ const NO_OPTIONS: NodeRequestOptions = Object.freeze({})
227
+
228
+ /** A Node.js request/response pair as an {@link AdapterRequest} (a class: no closure per request). */
229
+ class NodeAdapterRequest implements AdapterRequest {
230
+ readonly method: string
231
+ readonly url: string
232
+ readonly headers: Readonly<Record<string, string | undefined>>
233
+ readonly ip: string | undefined
234
+ readonly remoteAddress: string | undefined
235
+ readonly encrypted: boolean
236
+ readonly raw: unknown
237
+ readonly rawResponse: unknown
238
+ readonly #request: IncomingMessage
239
+ readonly #response: ServerResponse
240
+ #body: Promise<Uint8Array> | undefined
241
+
242
+ constructor(request: IncomingMessage, response: ServerResponse, options: NodeRequestOptions) {
243
+ const remoteAddress = request.socket?.remoteAddress
244
+ this.method = request.method ?? 'GET'
245
+ this.url = options.url ?? request.url ?? '/'
246
+ this.headers = normalizeNodeHeaders(request.headers)
247
+ this.ip = options.ip ?? remoteAddress
248
+ this.remoteAddress = remoteAddress
249
+ this.encrypted = isEncrypted(request)
250
+ this.raw = options.raw ?? request
251
+ this.rawResponse = options.rawResponse ?? response
252
+ this.#request = request
253
+ this.#response = response
254
+ }
255
+
256
+ readBody(limit: number): Promise<Uint8Array> {
257
+ this.#body ??= readNodeBody(this.#request, limit)
258
+ return this.#body
259
+ }
260
+
261
+ streamBody(limit: number): ReadableStream<Uint8Array> {
262
+ return nodeBodyStream(this.#request, limit)
263
+ }
264
+
265
+ rawStatus(): number {
266
+ return this.#response.statusCode
267
+ }
268
+
269
+ onAbort(listener: () => void): void {
270
+ onResponseAbort(this.#response, listener)
203
271
  }
204
272
  }
205
273
 
@@ -224,10 +292,10 @@ export function isEncrypted(request: IncomingMessage): boolean {
224
292
  }
225
293
 
226
294
  /**
227
- * Writes a reply to a `ServerResponse`. Resolves when the response is complete (streams included);
228
- * never rejects (a failing stream destroys the response).
295
+ * Writes a reply to a `ServerResponse`, piping a stream body (a failing stream destroys the
296
+ * response). Never throws.
229
297
  */
230
- export async function writeNodeReply(response: ServerResponse, reply: AdapterReply): Promise<void> {
298
+ export function writeNodeReply(response: ServerResponse, reply: AdapterReply): void {
231
299
  if (reply.raw === true) {
232
300
  discardStream(reply.body instanceof ReadableStream ? reply.body : null)
233
301
  return
@@ -240,7 +308,10 @@ export async function writeNodeReply(response: ServerResponse, reply: AdapterRep
240
308
  try {
241
309
  // one call (merged over headers set earlier with setHeader(), e.g. by Express middleware); the
242
310
  // cookies set earlier are kept before the reply's own (writeHead would replace them)
243
- 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)
244
315
  response.writeHead(reply.status, headers as OutgoingHttpHeaders)
245
316
  } catch {
246
317
  // invalid header name or value set by user code
@@ -263,14 +334,18 @@ export async function writeNodeReply(response: ServerResponse, reply: AdapterRep
263
334
  response.setTimeout(0)
264
335
  }
265
336
  if (body instanceof Uint8Array) {
266
- response.end(body)
337
+ // a string is written with the headers at once (bytes take a second write)
338
+ response.end(reply.text ?? body)
267
339
  return
268
340
  }
269
- const stream = body instanceof Blob ? body.stream() : body
341
+ const fail = (): void => {
342
+ if (!response.destroyed) response.destroy()
343
+ }
270
344
  try {
271
- await pipeline(Readable.fromWeb(stream as import('node:stream/web').ReadableStream), response)
345
+ const stream = body instanceof Blob ? body.stream() : body
346
+ pipeline(Readable.fromWeb(stream as import('node:stream/web').ReadableStream), response).catch(fail)
272
347
  } catch {
273
- if (!response.destroyed) response.destroy()
348
+ fail()
274
349
  }
275
350
  }
276
351
 
@@ -301,44 +376,72 @@ export function writeNodeFailure(response: ServerResponse): void {
301
376
  response.end('{"statusCode":500,"error":"Internal Server Error"}')
302
377
  }
303
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
+
304
389
  /**
305
390
  * Tracks the connections of a `node:http` server to close them on shutdown: idle keep-alive
306
391
  * connections first (graceful), every connection when forced. `server.closeIdleConnections()` /
307
- * `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).
308
395
  */
309
396
  export class ConnectionTracker {
310
- /** Socket → number of requests in progress. */
311
- readonly #sockets = new Map<Socket, number>()
397
+ readonly #sockets = new Set<TrackedSocket>()
312
398
  #closing = false
313
399
 
314
- constructor(server: Server) {
315
- server.on('connection', (socket: Socket) => {
316
- 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)
317
409
  socket.once('close', () => this.#sockets.delete(socket))
318
410
  })
319
- server.on('request', (request: IncomingMessage, response: ServerResponse) => {
320
- const socket = request.socket
321
- if (!this.#sockets.has(socket)) return
322
- this.#sockets.set(socket, (this.#sockets.get(socket) ?? 0) + 1)
323
- response.once('close', () => {
324
- const active = this.#sockets.get(socket)
325
- if (active === undefined) return
326
- this.#sockets.set(socket, active - 1)
327
- if (active - 1 === 0 && this.#closing) socket.end()
328
- })
329
- })
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()
330
433
  }
331
434
 
332
435
  /** Ends idle connections now and the others once their requests completed. */
333
436
  closeIdle(): void {
334
437
  this.#closing = true
335
- 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()
336
439
  }
337
440
 
338
441
  /** Destroys every connection. */
339
442
  destroyAll(): void {
340
443
  this.#closing = true
341
- for (const socket of this.#sockets.keys()) socket.destroy()
444
+ for (const socket of this.#sockets) socket.destroy()
342
445
  }
343
446
  }
344
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':
@@ -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. */