@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
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 Ugho STEPHAN
3
+ Copyright (c) 2026 Ugho STEPHAN
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1493,6 +1493,14 @@ app.hooks.wrap('http:request', async (event, next) => {
1493
1493
  stream, `run` resolves when the stream opens (`status: 200`): durations measure the time to open.
1494
1494
  A stream-lifetime hook is not provided.
1495
1495
 
1496
+ When no `http:request` wrapper or listener is registered (and CORS is off), routes (other than
1497
+ `@All`) without guards, interceptors, `@Sse`, `@Res()` or `@Header` take a fast lane: the plugin
1498
+ decides at boot what the pipeline would do for them, runs a synchronous handler without creating a
1499
+ promise and builds the headers of a JSON reply from a template. The replies are the same as through
1500
+ the full pipeline (a thrown error, an `HttpResponse` or anything unusual continues there, in the
1501
+ same request scope; a promise is awaited first); registering a hook simply sends every request
1502
+ through the full pipeline again.
1503
+
1496
1504
  ## Plugin options
1497
1505
 
1498
1506
  `HttpPlugin.configure(options)` — `options` may also be a function of the container, evaluated
@@ -1638,8 +1646,18 @@ HttpPlugin.configure({ adapter: fastifyAdapter({ instance }) })
1638
1646
 
1639
1647
  Znight routes are registered in an encapsulated context with their own body handling and 404
1640
1648
  handler, so your other routes keep Fastify's parsers. A `content-type` Fastify cannot parse (`foo`,
1641
- an empty one) is hidden from Fastify for Znight routes, which then answer like on the other adapters
1642
- instead of Fastify's `415`. Created instances use
1649
+ an empty one) never reaches Fastify's parsers for Znight routes, which then answer like on the other
1650
+ adapters instead of Fastify's `415`.
1651
+
1652
+ Znight parses bodies itself, so a Znight route that no other hook needs is served straight from the
1653
+ `onRequest` hook of its context: Fastify skips its content type parsing and the steps before the
1654
+ handler, and a text reply is written on the raw response exactly as `reply.send()` writes it. Your
1655
+ hooks always run: with a `preParsing`, `preValidation` or `preHandler` hook (or an `onRoute` hook
1656
+ wrapping the handler, a schema, a subscriber of the `fastify.request.handler` tracing channel), the
1657
+ request goes through Fastify's whole lifecycle; with an `onSend` or `onResponse` hook, or headers
1658
+ and trailers set by an `onRequest` hook, the reply goes through `reply.send()`. The adapter reads
1659
+ these hooks on Fastify's internal route and reply state: should a Fastify release change it, every
1660
+ request goes through the lifecycle (slower, same responses). Created instances use
1643
1661
  `exposeHeadRoutes: false` (Znight serves HEAD), a `maxParamLength` of 8 KiB and a
1644
1662
  `frameworkErrors` handler rendering Znight errors (with your own instance, malformed URLs are
1645
1663
  answered by Fastify). `inject()` uses `fastify.inject()`. Event streams are sent through
@@ -1695,10 +1713,13 @@ handler returning its own `Response`.
1695
1713
  | `serveOptions` | Options of `Bun.serve()` (`tls`, `idleTimeout`, `development`, `error`...; Znight sets `port`, `hostname`, `fetch`). Bun only |
1696
1714
 
1697
1715
  On Bun, the body limits are Znight's (`bodyLimit`, `@BodyLimit`): `maxRequestBodySize` defaults to
1698
- unlimited instead of Bun's 128 MiB. Bun's `idleTimeout` (10 s by default) still closes idle
1716
+ unlimited instead of Bun's 128 MiB. A body with a `Content-Length` is read at once
1717
+ (`request.bytes()`, refused with 413 before reading when the length is above the limit), a chunked
1718
+ one as a stream (413 as soon as it passes the limit). Bun's `idleTimeout` (10 s by default) still closes idle
1699
1719
  connections and stalled requests, but is lifted while a streamed response is sent (SSE, slow
1700
1720
  producers), then restored. In your own `Bun.serve({ fetch: adapter.fetch })` the adapter cannot
1701
- lift it: keep the SSE `heartbeat` below `idleTimeout` (the 8 s default does).
1721
+ lift it: keep the SSE `heartbeat` below `idleTimeout` (the 8 s default does). Bun checks idle timeouts
1722
+ every 4 seconds, so an `idleTimeout` of 4 s or less closes a connection even while it is written to.
1702
1723
 
1703
1724
  `Bun.serve()` sends streamed bodies with chunked transfer encoding even when a `Content-Length`
1704
1725
  header is set (unless the whole stream is available at once): files sent with
@@ -2084,7 +2105,8 @@ Rules:
2084
2105
  boot.
2085
2106
  - `@Validate(S)` applies `S` to the `@Body()` parameter (`@Validate(S)` + `@Body()` ≡ `@Body(S)`);
2086
2107
  `@Validate(S)` + `@Body(S2)` is ZNIGHT005. Without a `@Body()` parameter the body is still
2087
- validated (and discarded): `invoke: async (c, ctx) => { await validate(S, await ctx.parseBody(), 'body'); return c.m() }`.
2108
+ validated (and discarded): `invoke: async (c, ctx) => { await validate(S, await ctx.parseBody(), 'body'); return c.m() }`
2109
+ (this `async` arrow `await`s its other validations too).
2088
2110
  - The plugin parses the body before the interceptors when `params` contains a `source: 'body'`
2089
2111
  entry or `uploads` is not empty; `ctx.body` is `undefined` otherwise (generated code then uses
2090
2112
  `await ctx.parseBody()`).
@@ -2117,31 +2139,53 @@ Rules:
2117
2139
 
2118
2140
  ### `invoke` generation
2119
2141
 
2120
- `invoke(controller, ctx)` extracts every argument in parameter order and calls the handler. It is
2121
- `async` and `await`s every `validate()` call when the route has at least one schema; otherwise it
2122
- is synchronous. It returns the handler result as is (promises included).
2123
-
2124
- | Parameter | Generated expression |
2125
- | --------------------------------------- | ------------------------------------------------------------------------------ |
2126
- | `@Param('id') id: T` | `coerce.X(ctx.params['id'], 'param "id"')` (see below) |
2127
- | `@Param('id', S)` | `await validate(S, ctx.params['id'], 'param "id"')` |
2128
- | `@Param()` | `ctx.params as never` · with schema: `await validate(S, ctx.params, 'params')` |
2129
- | `@Query('page') page: T` | `coerce.X(ctx.query['page'], 'query "page"')` |
2130
- | `@Query('page', S)` | `await validate(S, ctx.query['page'], 'query "page"')` |
2131
- | `@Query()` / `@Query(S)` | `ctx.query as never` / `await validate(S, ctx.query, 'query')` |
2132
- | `@Body()` / `@Body(S)` / `@Validate(S)` | `ctx.body as never` / `await validate(S, ctx.body, 'body')` |
2133
- | `@Headers('X-Api-Key') key: T` | `coerce.X(ctx.headers['x-api-key'], 'header "x-api-key"')` (name lower-cased) |
2134
- | `@Headers()` | `ctx.headers as never` |
2135
- | `@Cookie('sid') sid: T` | `coerce.X(ctx.cookies['sid'], 'cookie "sid"')` (or `coerce.optional(...)`) |
2136
- | `@Cookie('sid', S)` | `await validate(S, ctx.cookies['sid'], 'cookie "sid"')` |
2137
- | `@Cookie()` / `@Cookie(S)` | `ctx.cookies as never` / `await validate(S, ctx.cookies, 'cookies')` |
2138
- | `@SignedCookie('sid') sid: T` | `coerce.X(ctx.signedCookie('sid'), 'cookie "sid"')` |
2139
- | `@SignedCookie('sid', S)` | `await validate(S, ctx.signedCookie('sid'), 'cookie "sid"')` |
2140
- | `@Req()` / `@Res()` | `ctx.request as never` / `ctx.response as never` (+ `rawResponse: true`) |
2141
- | `@UploadedFile('f') f: File` | `uploads.file(ctx, 'f')` · optional: `uploads.optionalFile(ctx, 'f')` |
2142
- | `@UploadedFiles('f') f: File[]` | `uploads.files(ctx, 'f')` · optional: `uploads.optionalFiles(ctx, 'f')` |
2143
- | `@UploadedFile()` / `@UploadedFiles()` | the same helpers with `undefined` as field (any field) |
2144
- | `@Ctx()` | `ctx` |
2142
+ `invoke(controller, ctx)` extracts every argument in parameter order and calls the handler. It
2143
+ returns the handler result as is (promises included); the plugin awaits it only when it is a
2144
+ thenable. Without schema it is a synchronous arrow:
2145
+ `(c: UserController, ctx: HttpContext) => c.find(coerce.number(ctx.params['id'], 'param "id"'))`.
2146
+
2147
+ With schemas it stays synchronous as long as every `validate()` returns a value (Zod, Valibot or
2148
+ ArkType schemas without async refinements): no promise, no `await` per request. The arguments up to
2149
+ the last validated one are evaluated in order into `a<index>` constants (`@Ctx()` stays `ctx`), the
2150
+ later ones are inlined in the call, and each validated argument is followed by a fallback for
2151
+ asynchronous schemas:
2152
+
2153
+ ```ts
2154
+ invoke: (c: UserController, ctx: HttpContext) => {
2155
+ const a0 = coerce.number(ctx.params['id'], 'param "id"')
2156
+ const a1 = validate(UpdateUser, ctx.body, 'body')
2157
+ if (isThenable(a1)) return (async () => c.update(a0, await a1, ctx))()
2158
+ return c.update(a0, a1, ctx)
2159
+ },
2160
+ ```
2161
+
2162
+ When `validate()` returns a thenable (an async refinement, or an output that is itself a promise),
2163
+ the `async` arrow awaits it, then evaluates the remaining arguments (`await`ing their validations)
2164
+ and calls the handler: the order of evaluation, the error of the first invalid argument and the
2165
+ values received by the handler are those of an `async` invoke `await`ing every `validate()`. The
2166
+ expressions of the table below are `validate(S, ...)` calls in both forms.
2167
+
2168
+ | Parameter | Generated expression |
2169
+ | --------------------------------------- | ----------------------------------------------------------------------------- |
2170
+ | `@Param('id') id: T` | `coerce.X(ctx.params['id'], 'param "id"')` (see below) |
2171
+ | `@Param('id', S)` | `validate(S, ctx.params['id'], 'param "id"')` |
2172
+ | `@Param()` | `ctx.params as never` · with schema: `validate(S, ctx.params, 'params')` |
2173
+ | `@Query('page') page: T` | `coerce.X(ctx.query['page'], 'query "page"')` |
2174
+ | `@Query('page', S)` | `validate(S, ctx.query['page'], 'query "page"')` |
2175
+ | `@Query()` / `@Query(S)` | `ctx.query as never` / `validate(S, ctx.query, 'query')` |
2176
+ | `@Body()` / `@Body(S)` / `@Validate(S)` | `ctx.body as never` / `validate(S, ctx.body, 'body')` |
2177
+ | `@Headers('X-Api-Key') key: T` | `coerce.X(ctx.headers['x-api-key'], 'header "x-api-key"')` (name lower-cased) |
2178
+ | `@Headers()` | `ctx.headers as never` |
2179
+ | `@Cookie('sid') sid: T` | `coerce.X(ctx.cookies['sid'], 'cookie "sid"')` (or `coerce.optional(...)`) |
2180
+ | `@Cookie('sid', S)` | `validate(S, ctx.cookies['sid'], 'cookie "sid"')` |
2181
+ | `@Cookie()` / `@Cookie(S)` | `ctx.cookies as never` / `validate(S, ctx.cookies, 'cookies')` |
2182
+ | `@SignedCookie('sid') sid: T` | `coerce.X(ctx.signedCookie('sid'), 'cookie "sid"')` |
2183
+ | `@SignedCookie('sid', S)` | `validate(S, ctx.signedCookie('sid'), 'cookie "sid"')` |
2184
+ | `@Req()` / `@Res()` | `ctx.request as never` / `ctx.response as never` (+ `rawResponse: true`) |
2185
+ | `@UploadedFile('f') f: File` | `uploads.file(ctx, 'f')` · optional: `uploads.optionalFile(ctx, 'f')` |
2186
+ | `@UploadedFiles('f') f: File[]` | `uploads.files(ctx, 'f')` · optional: `uploads.optionalFiles(ctx, 'f')` |
2187
+ | `@UploadedFile()` / `@UploadedFiles()` | the same helpers with `undefined` as field (any field) |
2188
+ | `@Ctx()` | `ctx` |
2145
2189
 
2146
2190
  `coerce.X` by declared type (`T`):
2147
2191
 
@@ -2187,6 +2231,8 @@ function validate<S extends StandardSchemaV1>(
2187
2231
  location: string,
2188
2232
  ): InferSchemaOutput<S> | Promise<InferSchemaOutput<S>>
2189
2233
 
2234
+ function isThenable(value: unknown): value is PromiseLike<unknown> // a `then` method: validate() result to await
2235
+
2190
2236
  const uploads: {
2191
2237
  file(ctx: HttpContext, field: string | undefined): File // 400 `Missing file "field"` when absent
2192
2238
  optionalFile(ctx: HttpContext, field: string | undefined): File | undefined
@@ -2199,8 +2245,9 @@ Every coercion failure throws `BadRequestException` (400); `validate` throws `Va
2199
2245
  (400, `details: [{ path, message }]`) and `TypeError` when `schema` is not a Standard Schema; the
2200
2246
  `uploads` helpers read `ctx.files` (`undefined` field: every field) and throw `BadRequestException`
2201
2247
  for missing required files (`Expected a file in field "f", received a text value` when the field
2202
- was sent as text). Generated modules import them with
2203
- `import { coerce, uploads, validate } from '@znight/http'` and the context type with
2248
+ was sent as text). `validate` returns the output synchronously when the schema validates
2249
+ synchronously, a promise otherwise. Generated modules import them with
2250
+ `import { coerce, isThenable, uploads, validate } from '@znight/http'` and the context type with
2204
2251
  `import type { HttpContext } from '@znight/http'`.
2205
2252
 
2206
2253
  ### Manifest
@@ -2303,6 +2350,10 @@ interface HttpAdapter {
2303
2350
  disconnects before the response was fully written (on the next microtask when that already
2304
2351
  happened), never after a complete response. Without it, `ctx.signal` only aborts when the reply
2305
2352
  body is cancelled or an event stream is closed by the server.
2353
+ - `AdapterReply.text` is the string a body was serialised from (JSON, text), next to its UTF-8
2354
+ bytes in `body`: an adapter that writes strings (`ServerResponse.end()`, `new Response()`) may
2355
+ send it instead, which writes the same bytes; on Node.js a string is written with the headers at
2356
+ once, where bytes take a second write.
2306
2357
  - `AdapterReply.eventStream` marks a long-lived event stream (any streamed `text/event-stream`
2307
2358
  body): send the headers without waiting for the first chunk, do not apply idle or inactivity
2308
2359
  timeouts to the response while it is sent (`response.setTimeout(0)`, Bun's `server.timeout()`),
@@ -724,6 +724,12 @@ interface AdapterReply {
724
724
  readonly headers: Readonly<Record<string, HeaderValue>>;
725
725
  /** `null` for HEAD requests and bodyless statuses (headers such as `content-length` are kept). */
726
726
  readonly body: ReplyBody;
727
+ /**
728
+ * The text `body` holds encoded as UTF-8, for a body serialised from a string (JSON, text).
729
+ * Adapters writing strings MAY send it instead of `body`, which writes the same bytes: Node.js then
730
+ * writes the headers and the body at once (bytes take a second write).
731
+ */
732
+ readonly text?: string;
727
733
  /**
728
734
  * The `Blob` (file) that `body` streams, for adapters able to send it natively (`sendfile`, exact
729
735
  * length: `new Response(blob)` on `Bun.serve()`). An adapter sending it instead of reading `body`
@@ -873,4 +879,4 @@ interface HttpAdapter {
873
879
  injectStream?(request: InjectStreamRequest): Promise<InjectStreamResponse>;
874
880
  }
875
881
  export { PayloadTooLargeException as $, UploadedFileOptions as A, BadRequestException as B, HttpUploadDefinition as C, CookieOptions as Ct, RequestAbortedError as D, parseSetCookie as Dt, RequestAbortKind as E, parseCookieHeader as Et, formatByteSize as F, HeaderValue as G, ForbiddenException as H, parseByteSize as I, HttpExceptionOptions as J, HttpErrorBody as K, QueryRecord as L, Uploads as M, uploads as N, RequestId as O, serializeClearCookie as Ot, ByteSize as P, NotImplementedException as Q, parseQuery as R, HttpSseDefinition as S, CookieDefaults as St, HttpRouteInfo as T, SetCookieInfo as Tt, GatewayTimeoutException as U, ConflictException as V, GoneException as W, MethodNotAllowedException as X, InternalServerErrorException as Y, NotFoundException as Z, HttpMethod as _, StandardSchemaV1Result as _t, AdapterRoute as a, ValidationException as at, HttpRouteDefinition as b, isStandardSchema as bt, HttpServerAddress as c, toIssueDetail as ct, InjectStreamOptions as d, StandardSchemaV1 as dt, ServiceUnavailableException as et, InjectStreamRequest as f, StandardSchemaV1FailureResult as ft, HttpMetadata as g, StandardSchemaV1Props as gt, HttpControllerDefinition as h, StandardSchemaV1PathSegment as ht, AdapterRequest as i, UnsupportedMediaTypeException as it, UploadedFilesOptions as j, UploadLimits as k, serializeCookie as kt, InjectRequest as l, InferSchemaInput as lt, ReplyBody as m, StandardSchemaV1Options as mt, AdapterHandler as n, UnauthorizedException as nt, FallbackHandler as o, ValidationIssueDetail as ot, InjectStreamResponse as p, StandardSchemaV1Issue as pt, HttpException as q, AdapterReply as r, UnprocessableEntityException as rt, HttpAdapter as s, isHttpException as st, AdapterCloseOptions as t, TooManyRequestsException as tt, InjectResponse as u, InferSchemaOutput as ut, HttpParamDefinition as v, StandardSchemaV1SuccessResult as vt, HttpContext as w, ResponseCookie as wt, HttpRouteGroupDefinition as x, ClearCookieOptions as xt, HttpParamSource as y, StandardSchemaV1Types as yt, BadGatewayException as z };
876
- //# sourceMappingURL=adapter-CGSceVEN.d.mts.map
882
+ //# sourceMappingURL=adapter-J0UXFnNM.d.mts.map
@@ -1,4 +1,4 @@
1
- import { _ as HttpMethod, y as HttpParamSource } from "./adapter-CGSceVEN.mjs";
1
+ import { _ as HttpMethod, y as HttpParamSource } from "./adapter-J0UXFnNM.mjs";
2
2
  import { ClassRef, CompilerExtension, Location, StaticValue, ValueRef } from "@znight/compiler";
3
3
  /** Primitive coercions (`coerce.<kind>`). */
4
4
  type ScalarCoercion = "string" | "number" | "integer" | "boolean" | "bigint" | "date";
package/dist/compiler.mjs CHANGED
@@ -1695,6 +1695,7 @@ function groupsNode(ctx, controllers) {
1695
1695
  function createHelpers(ctx) {
1696
1696
  let coerce;
1697
1697
  let validate;
1698
+ let isThenable;
1698
1699
  let uploads;
1699
1700
  let context;
1700
1701
  return {
@@ -1706,6 +1707,10 @@ function createHelpers(ctx) {
1706
1707
  validate ??= ctx.importFrom(HTTP_PACKAGE, "validate");
1707
1708
  return validate;
1708
1709
  },
1710
+ isThenable: () => {
1711
+ isThenable ??= ctx.importFrom(HTTP_PACKAGE, "isThenable");
1712
+ return isThenable;
1713
+ },
1709
1714
  uploads: () => {
1710
1715
  uploads ??= ctx.importFrom(HTTP_PACKAGE, "uploads");
1711
1716
  return uploads;
@@ -1778,22 +1783,86 @@ function tokens(ctx, refs) {
1778
1783
  return ctx.arr(refs.map((ref) => ctx.tokenOf(ref) ?? ref.token));
1779
1784
  }
1780
1785
  /**
1781
- * `(c: Controller, ctx: HttpContext) => c.handler(...args)`: `async` (awaiting every `validate()`)
1782
- * when the route validates something with a schema, synchronous otherwise.
1786
+ * `(c: Controller, ctx: HttpContext) => c.handler(...args)`, synchronous when no argument is
1787
+ * validated with a schema. A route validating arguments gets a block that stays synchronous as long
1788
+ * as every `validate()` returns a value (see {@link validatingBlock}). `@Validate(S)` without
1789
+ * `@Body()` parameter reads the body in `invoke`: that arrow is `async` and `await`s every
1790
+ * `validate()`.
1783
1791
  */
1784
1792
  function invokeNode(ctx, helpers, controller, route) {
1785
- const async = route.bodySchema !== void 0 || route.params.some((param) => param.schema !== void 0);
1786
1793
  const params = [["c", ctx.importType(controller.identity)]];
1787
1794
  if (route.params.length > 0 || route.bodySchema !== void 0) params.push(["ctx", helpers.context()]);
1788
- return ctx.arrow(params, (c, http) => {
1789
- const args = [];
1790
- for (const param of route.params) args[param.index] = argumentNode(ctx, helpers, http, param);
1791
- const call = ctx.call(ctx.member(c, route.handler), Array.from(args, (arg) => arg ?? ctx.lit(void 0)));
1792
- if (route.bodySchema === void 0) return call;
1795
+ const slots = [];
1796
+ for (const param of route.params) slots[param.index] = param;
1797
+ const bodySchema = route.bodySchema;
1798
+ if (bodySchema !== void 0) return ctx.arrow(params, (c, http) => {
1799
+ const call = handlerCall(ctx, c, route, Array.from(slots, (param) => awaitedArgument(ctx, helpers, http, param)));
1793
1800
  const body = ctx.code`await ${ctx.call(ctx.member(http, "parseBody"))}`;
1794
- const check = validation(ctx, helpers, route.bodySchema, body, "body");
1801
+ const check = ctx.code`await ${validation(ctx, helpers, bodySchema, body, "body")}`;
1795
1802
  return ctx.code`{ ${check}; return ${call} }`;
1796
- }, { async });
1803
+ }, { async: true });
1804
+ const last = slots.findLastIndex((param) => param?.schema !== void 0);
1805
+ return ctx.arrow(params, (c, http) => last === -1 ? handlerCall(ctx, c, route, Array.from(slots, (param) => plainArgument(ctx, helpers, http, param))) : validatingBlock(ctx, helpers, route, slots, last, c, http));
1806
+ }
1807
+ /** `c.handler(args)`. */
1808
+ function handlerCall(ctx, c, route, args) {
1809
+ return ctx.call(ctx.member(c, route.handler), args);
1810
+ }
1811
+ /**
1812
+ * The body of the `invoke` of a route validating arguments with schemas: the arguments up to the
1813
+ * last validated one are evaluated in order into `const`s (`a<index>`), the handler is called with
1814
+ * them. `validate()` returns the value when the schema validates synchronously (Zod, Valibot and
1815
+ * ArkType without async refinements), so the request needs no promise; when one returns a
1816
+ * thenable, the rest runs in an `async` arrow that awaits it, then evaluates the remaining arguments
1817
+ * (`await`ing their validations) and calls the handler: the order of evaluation, the error reported
1818
+ * when several arguments are invalid and the value received by the handler are those of an `async`
1819
+ * `invoke` awaiting every `validate()`.
1820
+ *
1821
+ * ```ts
1822
+ * (c: UserController, ctx: HttpContext) => {
1823
+ * const a0 = coerce.number(ctx.params['id'], 'param "id"')
1824
+ * const a1 = validate(UpdateUser, ctx.body, 'body')
1825
+ * if (isThenable(a1)) return (async () => c.update(a0, await a1, ctx))()
1826
+ * return c.update(a0, a1, ctx)
1827
+ * }
1828
+ * ```
1829
+ */
1830
+ function validatingBlock(ctx, helpers, route, slots, last, c, http) {
1831
+ const bound = [];
1832
+ for (let index = 0; index <= last; index++) {
1833
+ const param = slots[index];
1834
+ if (param !== void 0 && param.source !== "context") bound.push(index);
1835
+ }
1836
+ return ctx.block(bound.map((index) => `a${index}`), (...locals) => {
1837
+ const local = new Map(bound.map((index, position) => [index, locals[position]]));
1838
+ const evaluated = (index) => local.get(index) ?? plainArgument(ctx, helpers, http, slots[index]);
1839
+ const statements = [];
1840
+ for (const index of bound) {
1841
+ const param = slots[index];
1842
+ const name = local.get(index);
1843
+ statements.push(ctx.code`const ${name} = ${plainArgument(ctx, helpers, http, param)}`);
1844
+ if (param.schema !== void 0) {
1845
+ const rest = Array.from(slots, (other, position) => {
1846
+ if (position < index) return evaluated(position);
1847
+ return position === index ? ctx.code`await ${name}` : awaitedArgument(ctx, helpers, http, other);
1848
+ });
1849
+ const resume = ctx.arrow([], () => handlerCall(ctx, c, route, rest), { async: true });
1850
+ statements.push(ctx.code`if (${ctx.call(helpers.isThenable(), [name])}) return (${resume})()`);
1851
+ }
1852
+ }
1853
+ const args = Array.from(slots, (param, position) => position <= last ? evaluated(position) : plainArgument(ctx, helpers, http, param));
1854
+ statements.push(ctx.code`return ${handlerCall(ctx, c, route, args)}`);
1855
+ return statements;
1856
+ });
1857
+ }
1858
+ /** An argument evaluated in an `async` arrow: its validation, if any, is `await`ed. */
1859
+ function awaitedArgument(ctx, helpers, http, param) {
1860
+ const node = plainArgument(ctx, helpers, http, param);
1861
+ return param?.schema === void 0 ? node : ctx.code`await ${node}`;
1862
+ }
1863
+ /** An argument as an expression (`validate()` not awaited); `undefined` for a hole. */
1864
+ function plainArgument(ctx, helpers, http, param) {
1865
+ return param === void 0 ? ctx.lit(void 0) : argumentNode(ctx, helpers, http, param);
1797
1866
  }
1798
1867
  /** The expression passed to the handler for one parameter. */
1799
1868
  function argumentNode(ctx, helpers, http, param) {
@@ -1844,13 +1913,13 @@ function argumentNode(ctx, helpers, http, param) {
1844
1913
  }
1845
1914
  }
1846
1915
  }
1847
- /** `await validate(schema, value, location)`. */
1916
+ /** `validate(schema, value, location)` (the value, or a promise when the schema validates asynchronously). */
1848
1917
  function validation(ctx, helpers, schema, value, location) {
1849
- return ctx.code`await ${ctx.call(helpers.validate(), [
1918
+ return ctx.call(helpers.validate(), [
1850
1919
  ctx.ref(schema),
1851
1920
  value,
1852
1921
  location
1853
- ])}`;
1922
+ ]);
1854
1923
  }
1855
1924
  /** `coerce.X(value, label, ...)`, cast with `as never` when the result does not have the declared type. */
1856
1925
  function coercion(ctx, helpers, coercion, value, label) {