@nifrajs/core 5.5.1 → 5.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/CHANGELOG.md +246 -0
  2. package/README.md +5 -0
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js.map +1 -1
  5. package/dist/binary.d.ts +15 -1
  6. package/dist/binary.d.ts.map +1 -1
  7. package/dist/binary.js +1 -0
  8. package/dist/binary.js.map +1 -1
  9. package/dist/edge-kit.d.ts +1 -0
  10. package/dist/edge-kit.d.ts.map +1 -1
  11. package/dist/edge-kit.js +1 -0
  12. package/dist/edge-kit.js.map +1 -1
  13. package/dist/errors.d.ts +1 -1
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js.map +1 -1
  16. package/dist/evidence.d.ts +4 -0
  17. package/dist/evidence.d.ts.map +1 -1
  18. package/dist/evidence.js +4 -0
  19. package/dist/evidence.js.map +1 -1
  20. package/dist/file-type.d.ts +30 -0
  21. package/dist/file-type.d.ts.map +1 -0
  22. package/dist/file-type.js +118 -0
  23. package/dist/file-type.js.map +1 -0
  24. package/dist/index.d.ts +3 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +3 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/internal/hook-origins.d.ts +2 -0
  29. package/dist/internal/hook-origins.d.ts.map +1 -1
  30. package/dist/internal/hook-origins.js +12 -0
  31. package/dist/internal/hook-origins.js.map +1 -1
  32. package/dist/internal/route-program.d.ts +11 -0
  33. package/dist/internal/route-program.d.ts.map +1 -1
  34. package/dist/internal/route-program.js +133 -85
  35. package/dist/internal/route-program.js.map +1 -1
  36. package/dist/reflection.d.ts +19 -1
  37. package/dist/reflection.d.ts.map +1 -1
  38. package/dist/reflection.js +46 -7
  39. package/dist/reflection.js.map +1 -1
  40. package/dist/response-observer.d.ts +1 -0
  41. package/dist/response-observer.d.ts.map +1 -1
  42. package/dist/response-observer.js +1 -0
  43. package/dist/response-observer.js.map +1 -1
  44. package/dist/server/answer-step.d.ts +27 -0
  45. package/dist/server/answer-step.d.ts.map +1 -0
  46. package/dist/server/answer-step.js +33 -0
  47. package/dist/server/answer-step.js.map +1 -0
  48. package/dist/server/bare-error-lane.d.ts +4 -1
  49. package/dist/server/bare-error-lane.d.ts.map +1 -1
  50. package/dist/server/bare-error-lane.js +9 -3
  51. package/dist/server/bare-error-lane.js.map +1 -1
  52. package/dist/server/base64-answers.d.ts +5 -0
  53. package/dist/server/base64-answers.d.ts.map +1 -0
  54. package/dist/server/base64-answers.js +132 -0
  55. package/dist/server/base64-answers.js.map +1 -0
  56. package/dist/server/before-handler.d.ts +10 -0
  57. package/dist/server/before-handler.d.ts.map +1 -0
  58. package/dist/server/before-handler.js +12 -0
  59. package/dist/server/before-handler.js.map +1 -0
  60. package/dist/server/body-lane.d.ts.map +1 -1
  61. package/dist/server/body-lane.js +8 -2
  62. package/dist/server/body-lane.js.map +1 -1
  63. package/dist/server/body-parser.d.ts.map +1 -1
  64. package/dist/server/body-parser.js +2 -5
  65. package/dist/server/body-parser.js.map +1 -1
  66. package/dist/server/body.d.ts +24 -8
  67. package/dist/server/body.d.ts.map +1 -1
  68. package/dist/server/body.js +86 -33
  69. package/dist/server/body.js.map +1 -1
  70. package/dist/server/core-copies.d.ts.map +1 -1
  71. package/dist/server/core-copies.js +5 -2
  72. package/dist/server/core-copies.js.map +1 -1
  73. package/dist/server/http.d.ts.map +1 -1
  74. package/dist/server/http.js +6 -17
  75. package/dist/server/http.js.map +1 -1
  76. package/dist/server/install.d.ts +2 -0
  77. package/dist/server/install.d.ts.map +1 -1
  78. package/dist/server/install.js +2 -0
  79. package/dist/server/install.js.map +1 -1
  80. package/dist/server/json-response.d.ts +3 -0
  81. package/dist/server/json-response.d.ts.map +1 -0
  82. package/dist/server/json-response.js +14 -0
  83. package/dist/server/json-response.js.map +1 -0
  84. package/dist/server/json-text.d.ts +16 -0
  85. package/dist/server/json-text.d.ts.map +1 -0
  86. package/dist/server/json-text.js +34 -0
  87. package/dist/server/json-text.js.map +1 -0
  88. package/dist/server/multipart.d.ts +3 -0
  89. package/dist/server/multipart.d.ts.map +1 -1
  90. package/dist/server/multipart.js +226 -97
  91. package/dist/server/multipart.js.map +1 -1
  92. package/dist/server/node-outcome.d.ts +5 -0
  93. package/dist/server/node-outcome.d.ts.map +1 -1
  94. package/dist/server/node-outcome.js +3 -4
  95. package/dist/server/node-outcome.js.map +1 -1
  96. package/dist/server/proto-guard.d.ts.map +1 -1
  97. package/dist/server/proto-guard.js +39 -4
  98. package/dist/server/proto-guard.js.map +1 -1
  99. package/dist/server/query.d.ts.map +1 -1
  100. package/dist/server/query.js +8 -14
  101. package/dist/server/query.js.map +1 -1
  102. package/dist/server/repeated-prefix.d.ts +17 -0
  103. package/dist/server/repeated-prefix.d.ts.map +1 -0
  104. package/dist/server/repeated-prefix.js +17 -0
  105. package/dist/server/repeated-prefix.js.map +1 -0
  106. package/dist/server/request-context.d.ts.map +1 -1
  107. package/dist/server/request-context.js +5 -5
  108. package/dist/server/request-context.js.map +1 -1
  109. package/dist/server/respond.d.ts +3 -4
  110. package/dist/server/respond.d.ts.map +1 -1
  111. package/dist/server/respond.js +45 -37
  112. package/dist/server/respond.js.map +1 -1
  113. package/dist/server/response-contract-lane.d.ts +12 -2
  114. package/dist/server/response-contract-lane.d.ts.map +1 -1
  115. package/dist/server/response-contract-lane.js +137 -9
  116. package/dist/server/response-contract-lane.js.map +1 -1
  117. package/dist/server/route-template.d.ts +10 -0
  118. package/dist/server/route-template.d.ts.map +1 -0
  119. package/dist/server/route-template.js +21 -0
  120. package/dist/server/route-template.js.map +1 -0
  121. package/dist/server/runtime-core.d.ts +7 -0
  122. package/dist/server/runtime-core.d.ts.map +1 -1
  123. package/dist/server/runtime-core.js +10 -2
  124. package/dist/server/runtime-core.js.map +1 -1
  125. package/dist/server/server-types.d.ts +17 -8
  126. package/dist/server/server-types.d.ts.map +1 -1
  127. package/dist/server/server.d.ts +27 -12
  128. package/dist/server/server.d.ts.map +1 -1
  129. package/dist/server/server.js +326 -108
  130. package/dist/server/server.js.map +1 -1
  131. package/dist/server/sse.d.ts +1 -1
  132. package/dist/server/sse.d.ts.map +1 -1
  133. package/dist/server/sse.js +7 -5
  134. package/dist/server/sse.js.map +1 -1
  135. package/dist/server/text-slots.d.ts +8 -0
  136. package/dist/server/text-slots.d.ts.map +1 -0
  137. package/dist/server/text-slots.js +19 -0
  138. package/dist/server/text-slots.js.map +1 -0
  139. package/dist/server/webhook.d.ts +1 -1
  140. package/dist/server/webhook.d.ts.map +1 -1
  141. package/dist/server/webhook.js +24 -19
  142. package/dist/server/webhook.js.map +1 -1
  143. package/dist/server/websocket.d.ts +18 -3
  144. package/dist/server/websocket.d.ts.map +1 -1
  145. package/dist/server/websocket.js +45 -5
  146. package/dist/server/websocket.js.map +1 -1
  147. package/dist/server/ws-bun.d.ts.map +1 -1
  148. package/dist/server/ws-bun.js +7 -4
  149. package/dist/server/ws-bun.js.map +1 -1
  150. package/dist/server.d.ts +2 -0
  151. package/dist/server.d.ts.map +1 -1
  152. package/dist/server.js +2 -0
  153. package/dist/server.js.map +1 -1
  154. package/dist/single-copy.d.ts +5 -0
  155. package/dist/single-copy.d.ts.map +1 -1
  156. package/dist/single-copy.js +26 -17
  157. package/dist/single-copy.js.map +1 -1
  158. package/dist/tool-cli.d.ts +64 -0
  159. package/dist/tool-cli.d.ts.map +1 -0
  160. package/dist/tool-cli.js +495 -0
  161. package/dist/tool-cli.js.map +1 -0
  162. package/dist/transport-codec.d.ts.map +1 -1
  163. package/dist/transport-codec.js +2 -1
  164. package/dist/transport-codec.js.map +1 -1
  165. package/package.json +11 -1
  166. package/src/assurance.ts +1 -0
  167. package/src/binary.ts +17 -0
  168. package/src/edge-kit.ts +1 -0
  169. package/src/errors.ts +1 -0
  170. package/src/evidence.ts +8 -0
  171. package/src/file-type.ts +130 -0
  172. package/src/index.ts +3 -1
  173. package/src/internal/hook-origins.ts +10 -0
  174. package/src/internal/route-program.ts +261 -93
  175. package/src/reflection.ts +71 -7
  176. package/src/response-observer.ts +6 -0
  177. package/src/server/answer-step.ts +68 -0
  178. package/src/server/bare-error-lane.ts +9 -4
  179. package/src/server/base64-answers.ts +138 -0
  180. package/src/server/before-handler.ts +14 -0
  181. package/src/server/body-lane.ts +19 -9
  182. package/src/server/body-parser.ts +1 -5
  183. package/src/server/body.ts +96 -36
  184. package/src/server/core-copies.ts +5 -2
  185. package/src/server/http.ts +6 -15
  186. package/src/server/install.ts +3 -0
  187. package/src/server/json-response.ts +13 -0
  188. package/src/server/json-text.ts +34 -0
  189. package/src/server/multipart.ts +232 -100
  190. package/src/server/node-outcome.ts +4 -5
  191. package/src/server/proto-guard.ts +39 -4
  192. package/src/server/query.ts +7 -13
  193. package/src/server/repeated-prefix.ts +25 -0
  194. package/src/server/request-context.ts +5 -5
  195. package/src/server/respond.ts +47 -30
  196. package/src/server/response-contract-lane.ts +132 -9
  197. package/src/server/route-template.ts +24 -0
  198. package/src/server/runtime-core.ts +12 -2
  199. package/src/server/server-types.ts +18 -6
  200. package/src/server/server.ts +361 -112
  201. package/src/server/sse.ts +8 -6
  202. package/src/server/text-slots.ts +21 -0
  203. package/src/server/webhook.ts +29 -24
  204. package/src/server/websocket.ts +57 -8
  205. package/src/server/ws-bun.ts +7 -3
  206. package/src/server.ts +2 -0
  207. package/src/single-copy.ts +31 -15
  208. package/src/tool-cli.ts +584 -0
  209. package/src/transport-codec.ts +3 -1
@@ -11,8 +11,10 @@ import type { StandardSchemaV1 } from "../schema/standard.ts"
11
11
  import { markAuthenticatedRequest } from "../server/auth.ts"
12
12
  import { declaredHeaderNames } from "../server/headers.ts"
13
13
  import { pathnameOf } from "../server/http.ts"
14
+ import type { Logger } from "../server/logger.ts"
14
15
  import { queryObjectOf } from "../server/query.ts"
15
16
  import type { Registry } from "../server/registry.ts"
17
+ import { toResponse } from "../server/respond.ts"
16
18
  import {
17
19
  CONTEXT_SEARCH,
18
20
  CONTEXT_SET,
@@ -262,6 +264,77 @@ function authenticationInputOf(
262
264
  })
263
265
  }
264
266
 
267
+ /**
268
+ * Run the `authenticate()` stages a mount was registered after, in order, against the request the
269
+ * mount would receive: `undefined` once each one authenticated, else the response that refuses it. A
270
+ * stage that throws or decides nothing valid answers 503, and its cause goes to `logger`, as a route
271
+ * error no hook answers does.
272
+ */
273
+ export function authenticateMount(
274
+ stages: readonly RawAuthStage[],
275
+ request: Request,
276
+ platform: { readonly env?: unknown; readonly clientIp?: string | undefined } | undefined,
277
+ logger: Logger,
278
+ ): MaybePromise<Response | undefined> {
279
+ let query: Readonly<Record<string, string | readonly string[]>> | undefined
280
+ const search = (() => {
281
+ const at = request.url.indexOf("?")
282
+ return at === -1 ? "" : request.url.slice(at)
283
+ })()
284
+ const input = Object.freeze({
285
+ method: request.method,
286
+ pathname: pathnameOf(request.url),
287
+ headers: authHeadersOf(request),
288
+ params: Object.freeze({}),
289
+ search,
290
+ get query() {
291
+ query ??= frozenQueryOf(search)
292
+ return query
293
+ },
294
+ request,
295
+ env: platform?.env,
296
+ clientIp: platform?.clientIp,
297
+ signal: request.signal,
298
+ })
299
+ const unavailable = (error: unknown): Response => {
300
+ logger.error("authenticate() stage failed before a mount", {
301
+ method: request.method,
302
+ path: input.pathname,
303
+ error: error instanceof Error ? error.message : String(error),
304
+ })
305
+ return toResponse(defaultAuthenticationResponse("unavailable"), EMPTY_RESPONSE_CONTROLS)
306
+ }
307
+ const decide = (
308
+ index: number,
309
+ result: import("../server/auth.ts").AuthenticationResult<unknown>,
310
+ ): MaybePromise<Response | undefined> => {
311
+ if (!isAuthenticationDecision(result)) {
312
+ const id = stages[index]?.id
313
+ return unavailable(new TypeError(`authenticate() stage "${id}" returned an invalid decision`))
314
+ }
315
+ if (result.kind === "rejected") {
316
+ const refusal = result.response ?? defaultAuthenticationResponse(result.reason)
317
+ return refusal instanceof Response ? refusal : toResponse(refusal, EMPTY_RESPONSE_CONTROLS)
318
+ }
319
+ markAuthenticatedRequest(request)
320
+ return run(index + 1)
321
+ }
322
+ const run = (index: number): MaybePromise<Response | undefined> => {
323
+ const stage = stages[index]
324
+ if (stage === undefined) return undefined
325
+ let outcome: MaybePromise<import("../server/auth.ts").AuthenticationResult<unknown>>
326
+ try {
327
+ outcome = stage.run(input)
328
+ } catch (error) {
329
+ return unavailable(error)
330
+ }
331
+ return outcome instanceof Promise
332
+ ? outcome.then((settled) => decide(index, settled), unavailable)
333
+ : decide(index, outcome)
334
+ }
335
+ return run(0)
336
+ }
337
+
265
338
  function defaultAuthenticationResponse(
266
339
  reason: import("../server/auth.ts").AuthenticationFailureReason | undefined,
267
340
  ): ResponseResult {
@@ -270,6 +343,25 @@ function defaultAuthenticationResponse(
270
343
  return status(401, { ok: false, error: "unauthorized" })
271
344
  }
272
345
 
346
+ /** Whether a stage returned a decision it may: a rejection with a known reason and a response, if
347
+ * any, that answers; or a principal. */
348
+ function isAuthenticationDecision(
349
+ result: import("../server/auth.ts").AuthenticationResult<unknown>,
350
+ ): boolean {
351
+ return (
352
+ result !== null &&
353
+ typeof result === "object" &&
354
+ (result.kind === "rejected"
355
+ ? validAuthenticationFailureReason(result.reason) &&
356
+ (result.response === undefined ||
357
+ result.response instanceof Response ||
358
+ isResponseResult(result.response))
359
+ : result.kind === "authenticated" &&
360
+ result.principal !== null &&
361
+ result.principal !== undefined)
362
+ )
363
+ }
364
+
273
365
  function validAuthenticationFailureReason(
274
366
  value: unknown,
275
367
  ): value is import("../server/auth.ts").AuthenticationFailureReason | undefined {
@@ -410,18 +502,7 @@ function finishAuthentication<T>(
410
502
  next: number,
411
503
  validationComplete = false,
412
504
  ): ProgramResult<T> {
413
- if (
414
- result === null ||
415
- typeof result !== "object" ||
416
- (result.kind === "rejected"
417
- ? !validAuthenticationFailureReason(result.reason) ||
418
- (result.response !== undefined &&
419
- !(result.response instanceof Response) &&
420
- !isResponseResult(result.response))
421
- : result.kind !== "authenticated" ||
422
- result.principal === null ||
423
- result.principal === undefined)
424
- ) {
505
+ if (!isAuthenticationDecision(result)) {
425
506
  const id = program.authStages[next - 1]!.id
426
507
  const error = new TypeError(`authenticate() stage "${id}" returned an invalid decision`)
427
508
  return failAuthentication(host, entry, ctx, finalize, wrapResponse, error)
@@ -499,7 +580,7 @@ async function continueFastAuthenticatedProgram<T>(
499
580
  try {
500
581
  const settled = await pending
501
582
  if (settled !== undefined) return wrapResponse(settled as Response | ResponseResult)
502
- return await runFastAuthenticatedProgram(
583
+ const outcome = runFastAuthenticatedProgram(
503
584
  host,
504
585
  entry,
505
586
  program,
@@ -509,6 +590,7 @@ async function continueFastAuthenticatedProgram<T>(
509
590
  wrapResponse,
510
591
  validationIndex,
511
592
  )
593
+ return outcome instanceof Promise ? await outcome : outcome
512
594
  } catch (error) {
513
595
  return await host.handleProgramError(entry, error, ctx, finalize, wrapResponse)
514
596
  }
@@ -585,7 +667,7 @@ async function continueValidationBeforeAuthentication<T>(
585
667
  try {
586
668
  const settled = await pending
587
669
  if (settled !== undefined) return wrapResponse(settled as Response | ResponseResult)
588
- return await runValidationBeforeAuthentication(
670
+ const outcome = runValidationBeforeAuthentication(
589
671
  host,
590
672
  entry,
591
673
  program,
@@ -595,6 +677,7 @@ async function continueValidationBeforeAuthentication<T>(
595
677
  wrapResponse,
596
678
  validationIndex,
597
679
  )
680
+ return outcome instanceof Promise ? await outcome : outcome
598
681
  } catch (error) {
599
682
  return await host.handleProgramError(entry, error, ctx, finalize, wrapResponse)
600
683
  }
@@ -616,7 +699,7 @@ function runValidatedProgram<T>(
616
699
  type ProgramResult<T> = MaybePromise<T>
617
700
 
618
701
  /** Opt-in sensitive-route lane: run authentication/policy hooks before parsing untrusted input. */
619
- async function runAuthBeforeValidation<T>(
702
+ function runAuthBeforeValidation<T>(
620
703
  host: RouteProgramRuntime,
621
704
  entry: RouteEntry,
622
705
  program: RouteProgram,
@@ -625,23 +708,53 @@ async function runAuthBeforeValidation<T>(
625
708
  finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
626
709
  wrapResponse: (response: Response | ResponseResult) => T,
627
710
  gated = false,
628
- ): Promise<T> {
711
+ deriveStart = 0,
712
+ beforeStart = 0,
713
+ validationStart = 0,
714
+ ): ProgramResult<T> {
629
715
  try {
630
716
  if (!gated) {
631
- if (program.hasDecorations) Object.assign(ctx, program.decorations)
717
+ if (deriveStart === 0 && beforeStart === 0 && program.hasDecorations)
718
+ Object.assign(ctx, program.decorations)
632
719
 
633
- for (const derive of program.derives) {
634
- const result = derive(ctx)
635
- const settled = result instanceof Promise ? await result : result
636
- if (isEarly(settled)) return finishEarly(host, entry, ctx, settled, finalize, wrapResponse)
637
- Object.assign(ctx, settled)
720
+ for (let i = deriveStart; i < program.derives.length; i++) {
721
+ const result = program.derives[i]!(ctx)
722
+ if (result instanceof Promise) {
723
+ return continueProgram(
724
+ host,
725
+ entry,
726
+ program,
727
+ source,
728
+ ctx,
729
+ finalize,
730
+ wrapResponse,
731
+ { kind: "derive", next: i + 1, policy: true },
732
+ result,
733
+ undefined,
734
+ )
735
+ }
736
+ if (isEarly(result)) return finishEarly(host, entry, ctx, result, finalize, wrapResponse)
737
+ Object.assign(ctx, result)
638
738
  }
639
739
 
640
- for (const before of program.beforeHandle) {
641
- const result = before(ctx)
642
- const settled = result instanceof Promise ? await result : result
643
- if (settled !== undefined)
644
- return finishEarly(host, entry, ctx, settled, finalize, wrapResponse)
740
+ for (let i = beforeStart; i < program.beforeHandle.length; i++) {
741
+ const result = program.beforeHandle[i]!(ctx)
742
+ if (result instanceof Promise) {
743
+ return continueProgram(
744
+ host,
745
+ entry,
746
+ program,
747
+ source,
748
+ ctx,
749
+ finalize,
750
+ wrapResponse,
751
+ { kind: "before", next: i + 1, policy: true },
752
+ result,
753
+ undefined,
754
+ )
755
+ }
756
+ if (result !== undefined)
757
+ return finishEarly(host, entry, ctx, result, finalize, wrapResponse)
645
758
  }
646
759
 
647
760
  // The policy hooks have passed, so the idempotency gate may buffer the body now. Not awaited:
@@ -653,30 +766,52 @@ async function runAuthBeforeValidation<T>(
653
766
  }
654
767
  }
655
768
 
656
- for (const stage of program.validationStages) {
769
+ for (let i = validationStart; i < program.validationStages.length; i++) {
770
+ const stage = program.validationStages[i]!
657
771
  const result =
658
772
  stage.kind === "body"
659
- ? await host.readProgramBody(entry, source, ctx)
773
+ ? host.readProgramBody(entry, source, ctx)
660
774
  : host.validateProgramStage(entry, stage, source, ctx)
661
- const settled = result instanceof Promise ? await result : result
662
- if (settled !== undefined) return wrapResponse(settled)
775
+ if (result instanceof Promise) {
776
+ return continueProgram(
777
+ host,
778
+ entry,
779
+ program,
780
+ source,
781
+ ctx,
782
+ finalize,
783
+ wrapResponse,
784
+ { kind: "validation", next: i + 1, policy: true },
785
+ result,
786
+ undefined,
787
+ )
788
+ }
789
+ if (result !== undefined) return wrapResponse(result)
663
790
  }
664
791
 
665
- let result = await program.handler(ctx)
666
- for (const after of program.afterHandle) {
667
- const transformed = after(result, ctx)
668
- result = transformed instanceof Promise ? await transformed : transformed
669
- }
670
- return await host.finishProgramResult(entry, ctx, result, finalize, wrapResponse)
792
+ // A pending outcome here already met the error boundary (continueProgram or the response
793
+ // contract), so wrapping it in another catch would only add a promise.
794
+ return runHooksSync(
795
+ host,
796
+ entry,
797
+ program,
798
+ source,
799
+ ctx,
800
+ finalize,
801
+ wrapResponse,
802
+ program.derives.length,
803
+ program.beforeHandle.length,
804
+ undefined,
805
+ )
671
806
  } catch (error) {
672
- return await host.handleProgramError(entry, error, ctx, finalize, wrapResponse)
807
+ return host.handleProgramError(entry, error, ctx, finalize, wrapResponse)
673
808
  }
674
809
  }
675
810
 
676
811
  type PendingPhase =
677
- | { readonly kind: "validation"; readonly next: number }
678
- | { readonly kind: "derive"; readonly next: number }
679
- | { readonly kind: "before"; readonly next: number }
812
+ | { readonly kind: "validation"; readonly next: number; readonly policy?: true }
813
+ | { readonly kind: "derive"; readonly next: number; readonly policy?: true }
814
+ | { readonly kind: "before"; readonly next: number; readonly policy?: true }
680
815
  | { readonly kind: "handler" }
681
816
  | { readonly kind: "after"; readonly next: number }
682
817
 
@@ -757,7 +892,7 @@ function runSync<T>(
757
892
  }
758
893
  if (outcome !== undefined) return wrapResponse(outcome)
759
894
  }
760
- return runHooksSync(host, entry, program, source, ctx, finalize, wrapResponse, 0, 0, 0, initial)
895
+ return runHooksSync(host, entry, program, source, ctx, finalize, wrapResponse, 0, 0, initial)
761
896
  }
762
897
 
763
898
  function runHooksSync<T>(
@@ -770,7 +905,6 @@ function runHooksSync<T>(
770
905
  wrapResponse: (response: Response | ResponseResult) => T,
771
906
  deriveIndex: number,
772
907
  beforeIndex: number,
773
- afterIndex: number,
774
908
  initial: unknown,
775
909
  ): ProgramResult<T> {
776
910
  for (let i = deriveIndex; i < program.derives.length; i++) {
@@ -826,7 +960,20 @@ function runHooksSync<T>(
826
960
  initial,
827
961
  )
828
962
  }
829
- let result = output
963
+ return runAfterSync(host, entry, program, source, ctx, finalize, wrapResponse, 0, output)
964
+ }
965
+
966
+ function runAfterSync<T>(
967
+ host: RouteProgramRuntime,
968
+ entry: RouteEntry,
969
+ program: RouteProgram,
970
+ source: RequestSource,
971
+ ctx: RawContext,
972
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
973
+ wrapResponse: (response: Response | ResponseResult) => T,
974
+ afterIndex: number,
975
+ result: unknown,
976
+ ): ProgramResult<T> {
830
977
  for (let i = afterIndex; i < program.afterHandle.length; i++) {
831
978
  const outcome = program.afterHandle[i]!(result, ctx)
832
979
  if (outcome instanceof Promise) {
@@ -876,7 +1023,7 @@ async function continueProgram<T>(
876
1023
  } else {
877
1024
  result = settled
878
1025
  }
879
- return await runAsyncStages(
1026
+ const outcome = runAsyncStages(
880
1027
  host,
881
1028
  entry,
882
1029
  program,
@@ -887,12 +1034,13 @@ async function continueProgram<T>(
887
1034
  pendingPhase,
888
1035
  result,
889
1036
  )
1037
+ return outcome instanceof Promise ? await outcome : outcome
890
1038
  } catch (error) {
891
1039
  return await host.handleProgramError(entry, error, ctx, finalize, wrapResponse)
892
1040
  }
893
1041
  }
894
1042
 
895
- async function runAsyncStages<T>(
1043
+ function runAsyncStages<T>(
896
1044
  host: RouteProgramRuntime,
897
1045
  entry: RouteEntry,
898
1046
  program: RouteProgram,
@@ -902,54 +1050,74 @@ async function runAsyncStages<T>(
902
1050
  wrapResponse: (response: Response | ResponseResult) => T,
903
1051
  pendingPhase: PendingPhase,
904
1052
  result: unknown,
905
- ): Promise<T> {
906
- try {
907
- if (pendingPhase.kind === "validation") {
908
- const validations = program.validationStages
909
- for (let i = pendingPhase.next; i < validations.length; i++) {
910
- const stage = validations[i]!
911
- const outcome =
912
- stage.kind === "body"
913
- ? await host.readProgramBody(entry, source, ctx)
914
- : host.validateProgramStage(entry, stage, source, ctx)
915
- const settled = outcome instanceof Promise ? await outcome : outcome
916
- if (settled !== undefined) return wrapResponse(settled)
917
- }
918
- }
919
-
920
- const deriveStart = pendingPhase.kind === "derive" ? pendingPhase.next : 0
921
- if (pendingPhase.kind === "validation" || pendingPhase.kind === "derive") {
922
- for (let i = deriveStart; i < program.derives.length; i++) {
923
- const outcome = program.derives[i]!(ctx)
924
- const settled = outcome instanceof Promise ? await outcome : outcome
925
- if (isEarly(settled)) return finishEarly(host, entry, ctx, settled, finalize, wrapResponse)
926
- Object.assign(ctx, settled)
927
- }
928
- }
929
-
930
- const beforeStart = pendingPhase.kind === "before" ? pendingPhase.next : 0
931
- if (
932
- pendingPhase.kind === "validation" ||
933
- pendingPhase.kind === "derive" ||
934
- pendingPhase.kind === "before"
935
- ) {
936
- for (let i = beforeStart; i < program.beforeHandle.length; i++) {
937
- const outcome = program.beforeHandle[i]!(ctx)
938
- const settled = outcome instanceof Promise ? await outcome : outcome
939
- if (settled !== undefined)
940
- return finishEarly(host, entry, ctx, settled, finalize, wrapResponse)
941
- }
942
- const output = program.handler(ctx)
943
- result = output instanceof Promise ? await output : output
1053
+ ): ProgramResult<T> {
1054
+ // After an actual async boundary, resume the same sync runner until the next pending stage.
1055
+ // Sync hooks and finalization need no async function wrapper or additional microtask turn.
1056
+ if (pendingPhase.kind === "validation") {
1057
+ if (pendingPhase.policy) {
1058
+ return runAuthBeforeValidation(
1059
+ host,
1060
+ entry,
1061
+ program,
1062
+ source,
1063
+ ctx,
1064
+ finalize,
1065
+ wrapResponse,
1066
+ true,
1067
+ 0,
1068
+ 0,
1069
+ pendingPhase.next,
1070
+ )
944
1071
  }
945
-
946
- const afterStart = pendingPhase.kind === "after" ? pendingPhase.next : 0
947
- for (let i = afterStart; i < program.afterHandle.length; i++) {
948
- const outcome = program.afterHandle[i]!(result, ctx)
949
- result = outcome instanceof Promise ? await outcome : outcome
1072
+ return runSync(
1073
+ host,
1074
+ entry,
1075
+ program,
1076
+ source,
1077
+ ctx,
1078
+ finalize,
1079
+ wrapResponse,
1080
+ pendingPhase.next,
1081
+ result,
1082
+ )
1083
+ }
1084
+ if (pendingPhase.kind === "derive" || pendingPhase.kind === "before") {
1085
+ if (pendingPhase.policy) {
1086
+ return runAuthBeforeValidation(
1087
+ host,
1088
+ entry,
1089
+ program,
1090
+ source,
1091
+ ctx,
1092
+ finalize,
1093
+ wrapResponse,
1094
+ false,
1095
+ pendingPhase.kind === "derive" ? pendingPhase.next : program.derives.length,
1096
+ pendingPhase.kind === "before" ? pendingPhase.next : 0,
1097
+ )
950
1098
  }
951
- return await host.finishProgramResult(entry, ctx, result, finalize, wrapResponse)
952
- } catch (error) {
953
- return await host.handleProgramError(entry, error, ctx, finalize, wrapResponse)
1099
+ return runHooksSync(
1100
+ host,
1101
+ entry,
1102
+ program,
1103
+ source,
1104
+ ctx,
1105
+ finalize,
1106
+ wrapResponse,
1107
+ pendingPhase.kind === "derive" ? pendingPhase.next : program.derives.length,
1108
+ pendingPhase.kind === "before" ? pendingPhase.next : 0,
1109
+ result,
1110
+ )
954
1111
  }
1112
+ return runAfterSync(
1113
+ host,
1114
+ entry,
1115
+ program,
1116
+ source,
1117
+ ctx,
1118
+ finalize,
1119
+ wrapResponse,
1120
+ pendingPhase.kind === "after" ? pendingPhase.next : 0,
1121
+ result,
1122
+ )
955
1123
  }
package/src/reflection.ts CHANGED
@@ -54,6 +54,11 @@ export interface SchemaReflection {
54
54
  readonly standard: StandardSchemaV1 | undefined
55
55
  /** Raw JSON Schema metadata, when the value exposes it or is itself a raw JSON Schema. */
56
56
  readonly jsonSchema: JsonSchema | undefined
57
+ /**
58
+ * Set when `jsonSchema` came from the schema's Standard JSON Schema converter (`~standard.jsonSchema`)
59
+ * rather than from metadata it carries. Such output may use `$defs` and `$ref`.
60
+ */
61
+ readonly converted?: true
57
62
  /** Top-level object fields, or `undefined` when the JSON Schema is absent/non-object. */
58
63
  readonly fields: readonly ReflectedSchemaField[] | undefined
59
64
  /**
@@ -61,6 +66,11 @@ export interface SchemaReflection {
61
66
  * JSON and urlencoded bodies every body schema reads. Absent when it declares none.
62
67
  */
63
68
  readonly mediaTypes?: readonly string[]
69
+ /**
70
+ * Set when the schema reads text values with `t.query`'s string coercion (`"1"` and `"TRUE"` are
71
+ * `true`, a lone value is a one-item list), as `t.query`, `t.headers` and `t.cookies` do.
72
+ */
73
+ readonly coerces?: true
64
74
  }
65
75
 
66
76
  export interface ReflectedRouteSchema {
@@ -83,8 +93,16 @@ export interface ReflectedRouteSchema {
83
93
  readonly errors?: Readonly<Record<string, SchemaReflection>>
84
94
  /** The SSE event-payload schema of a typed streaming route (`app.sse()`). */
85
95
  readonly sse?: SchemaReflection
96
+ /**
97
+ * Set when an `onValidationError` hook (the route's own, or the app-wide default) answers this
98
+ * route's validation failures, so its 422 body is whatever the hook returns.
99
+ */
100
+ readonly validationErrorHook?: true
86
101
  }
87
102
 
103
+ /** Which side of a schema to describe: the value a request sends, or the value a response carries. */
104
+ export type SchemaSide = "input" | "output"
105
+
88
106
  export interface ReflectedRoute {
89
107
  readonly method: string
90
108
  readonly path: string
@@ -118,6 +136,22 @@ const standardOf = (value: unknown): StandardSchemaV1 | undefined => {
118
136
  : undefined
119
137
  }
120
138
 
139
+ /** The JSON Schema a Standard JSON Schema (`~standard.jsonSchema`) converts `side` to, if it has one. */
140
+ const standardJsonSchemaOf = (
141
+ standard: StandardSchemaV1,
142
+ side: SchemaSide,
143
+ ): JsonSchema | undefined => {
144
+ const converters = recordOf(recordOf(standard["~standard"])?.jsonSchema)
145
+ const convert = converters?.[side]
146
+ if (typeof convert !== "function") return undefined
147
+ try {
148
+ return normalizeJsonSchema(Reflect.apply(convert, converters, [{ target: "draft-2020-12" }]))
149
+ } catch {
150
+ // A library throws for a type JSON Schema cannot express (zod's `z.date()`); the schema stays opaque.
151
+ return undefined
152
+ }
153
+ }
154
+
121
155
  const jsonSchemaOf = (
122
156
  value: unknown,
123
157
  standard: StandardSchemaV1 | undefined,
@@ -194,19 +228,39 @@ const mediaTypesOf = (value: unknown): readonly string[] | undefined => {
194
228
  }
195
229
  }
196
230
 
231
+ // The mark a coercing `@nifrajs/schema` validate carries.
232
+ const COERCES = Symbol.for("nifra.schema.coerce")
233
+
234
+ const coercesOf = (standard: StandardSchemaV1 | undefined): boolean => {
235
+ try {
236
+ const validate: unknown = standard?.["~standard"].validate
237
+ return typeof validate === "function" && Reflect.get(validate, COERCES) === true
238
+ } catch {
239
+ return false
240
+ }
241
+ }
242
+
197
243
  /**
198
244
  * Reflect a Standard Schema, Nifra/TypeBox schema carrier, or raw JSON Schema. Never throws.
199
245
  * Validation-only schemas have `standard` but no `jsonSchema`; raw JSON Schema has the reverse.
246
+ * A Standard JSON Schema is read on `side`, since a default or a transform makes the two differ.
200
247
  */
201
- export function reflectSchema(value: unknown): SchemaReflection {
248
+ export function reflectSchema(value: unknown, side: SchemaSide = "input"): SchemaReflection {
202
249
  const standard = standardOf(value)
203
- const jsonSchema = jsonSchemaOf(value, standard)
250
+ const carried = jsonSchemaOf(value, standard)
251
+ const converted =
252
+ carried === undefined && standard !== undefined
253
+ ? standardJsonSchemaOf(standard, side)
254
+ : undefined
255
+ const jsonSchema = carried ?? converted
204
256
  const mediaTypes = mediaTypesOf(value)
205
257
  return {
206
258
  standard,
207
259
  jsonSchema,
260
+ ...(converted !== undefined ? { converted: true as const } : {}),
208
261
  fields: fieldsOf(jsonSchema),
209
262
  ...(mediaTypes !== undefined ? { mediaTypes } : {}),
263
+ ...(coercesOf(standard) ? { coerces: true as const } : {}),
210
264
  }
211
265
  }
212
266
 
@@ -222,14 +276,17 @@ const routeCandidates = (source: unknown): readonly unknown[] => {
222
276
  }
223
277
  }
224
278
 
225
- const reflectedRouteSchema = (value: unknown): ReflectedRouteSchema | undefined => {
279
+ const reflectedRouteSchema = (
280
+ value: unknown,
281
+ appValidationHook: boolean,
282
+ ): ReflectedRouteSchema | undefined => {
226
283
  const schema = recordOf(value)
227
284
  if (schema === undefined) return undefined
228
285
  const errors = recordOf(schema.errors)
229
286
  const reflectedErrors: Record<string, SchemaReflection> = {}
230
287
  if (errors !== undefined) {
231
288
  for (const [status, errorSchema] of Object.entries(errors)) {
232
- reflectedErrors[status] = reflectSchema(errorSchema)
289
+ reflectedErrors[status] = reflectSchema(errorSchema, "output")
233
290
  }
234
291
  }
235
292
  return {
@@ -249,9 +306,14 @@ const reflectedRouteSchema = (value: unknown): ReflectedRouteSchema | undefined
249
306
  ...(schema.body !== undefined ? { body: reflectSchema(schema.body) } : {}),
250
307
  ...(schema.query !== undefined ? { query: reflectSchema(schema.query) } : {}),
251
308
  ...(schema.params !== undefined ? { params: reflectSchema(schema.params) } : {}),
252
- ...(schema.response !== undefined ? { response: reflectSchema(schema.response) } : {}),
309
+ ...(schema.response !== undefined
310
+ ? { response: reflectSchema(schema.response, "output") }
311
+ : {}),
253
312
  ...(Object.keys(reflectedErrors).length > 0 ? { errors: reflectedErrors } : {}),
254
- ...(schema.sse !== undefined ? { sse: reflectSchema(schema.sse) } : {}),
313
+ ...(schema.sse !== undefined ? { sse: reflectSchema(schema.sse, "output") } : {}),
314
+ ...(appValidationHook || typeof schema.onValidationError === "function"
315
+ ? { validationErrorHook: true as const }
316
+ : {}),
255
317
  }
256
318
  }
257
319
 
@@ -326,10 +388,12 @@ const reflectedSockets = (source: unknown): ReflectedRoute[] => {
326
388
  */
327
389
  export function reflectRoutes(source: unknown): readonly ReflectedRoute[] {
328
390
  const reflected: ReflectedRoute[] = []
391
+ // The app-wide `server({ onValidationError })` lives on the server, not on each route descriptor.
392
+ const appValidationHook = typeof recordOf(source)?.defaultOnValidationError === "function"
329
393
  for (const candidate of routeCandidates(source)) {
330
394
  const route = recordOf(candidate)
331
395
  if (typeof route?.method !== "string" || typeof route.path !== "string") continue
332
- const schema = reflectedRouteSchema(route.schema)
396
+ const schema = reflectedRouteSchema(route.schema, appValidationHook)
333
397
  const tool = reflectedTool(route.tool)
334
398
  const assurance = reflectedAssurance(route.assurance)
335
399
  const capabilities = normalizeRouteCapabilities(
@@ -5,6 +5,12 @@ import {
5
5
  } from "./server/response-observer-runtime.ts"
6
6
  import type { AnyServer } from "./server/server.ts"
7
7
 
8
+ export {
9
+ markRepeatedPrefix,
10
+ type RepeatedPrefix,
11
+ repeatedPrefixOf,
12
+ } from "./server/repeated-prefix.ts"
13
+
8
14
  export type {
9
15
  NodeResponseBodyHook,
10
16
  ResponseBodyHook,