@nifrajs/core 5.5.0 → 5.6.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 (138) hide show
  1. package/CHANGELOG.md +159 -0
  2. package/dist/assurance.d.ts.map +1 -1
  3. package/dist/assurance.js.map +1 -1
  4. package/dist/evidence.d.ts +2 -0
  5. package/dist/evidence.d.ts.map +1 -1
  6. package/dist/evidence.js +2 -0
  7. package/dist/evidence.js.map +1 -1
  8. package/dist/file-type.d.ts +30 -0
  9. package/dist/file-type.d.ts.map +1 -0
  10. package/dist/file-type.js +118 -0
  11. package/dist/file-type.js.map +1 -0
  12. package/dist/index.d.ts +2 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +2 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/internal/route-program.d.ts +11 -0
  17. package/dist/internal/route-program.d.ts.map +1 -1
  18. package/dist/internal/route-program.js +133 -85
  19. package/dist/internal/route-program.js.map +1 -1
  20. package/dist/reflection.d.ts +14 -1
  21. package/dist/reflection.d.ts.map +1 -1
  22. package/dist/reflection.js +34 -7
  23. package/dist/reflection.js.map +1 -1
  24. package/dist/response-observer.d.ts +1 -0
  25. package/dist/response-observer.d.ts.map +1 -1
  26. package/dist/response-observer.js +1 -0
  27. package/dist/response-observer.js.map +1 -1
  28. package/dist/server/bare-error-lane.d.ts +4 -1
  29. package/dist/server/bare-error-lane.d.ts.map +1 -1
  30. package/dist/server/bare-error-lane.js +9 -3
  31. package/dist/server/bare-error-lane.js.map +1 -1
  32. package/dist/server/before-handler.d.ts +10 -0
  33. package/dist/server/before-handler.d.ts.map +1 -0
  34. package/dist/server/before-handler.js +12 -0
  35. package/dist/server/before-handler.js.map +1 -0
  36. package/dist/server/body-lane.d.ts.map +1 -1
  37. package/dist/server/body-lane.js +8 -2
  38. package/dist/server/body-lane.js.map +1 -1
  39. package/dist/server/body-parser.d.ts.map +1 -1
  40. package/dist/server/body-parser.js +2 -5
  41. package/dist/server/body-parser.js.map +1 -1
  42. package/dist/server/body.d.ts +24 -8
  43. package/dist/server/body.d.ts.map +1 -1
  44. package/dist/server/body.js +86 -33
  45. package/dist/server/body.js.map +1 -1
  46. package/dist/server/core-copies.js +1 -1
  47. package/dist/server/http.d.ts.map +1 -1
  48. package/dist/server/http.js +6 -17
  49. package/dist/server/http.js.map +1 -1
  50. package/dist/server/multipart.d.ts +3 -0
  51. package/dist/server/multipart.d.ts.map +1 -1
  52. package/dist/server/multipart.js +226 -97
  53. package/dist/server/multipart.js.map +1 -1
  54. package/dist/server/proto-guard.d.ts.map +1 -1
  55. package/dist/server/proto-guard.js +39 -4
  56. package/dist/server/proto-guard.js.map +1 -1
  57. package/dist/server/query.d.ts.map +1 -1
  58. package/dist/server/query.js +8 -14
  59. package/dist/server/query.js.map +1 -1
  60. package/dist/server/repeated-prefix.d.ts +17 -0
  61. package/dist/server/repeated-prefix.d.ts.map +1 -0
  62. package/dist/server/repeated-prefix.js +17 -0
  63. package/dist/server/repeated-prefix.js.map +1 -0
  64. package/dist/server/request-context.d.ts.map +1 -1
  65. package/dist/server/request-context.js +1 -3
  66. package/dist/server/request-context.js.map +1 -1
  67. package/dist/server/respond.d.ts +1 -3
  68. package/dist/server/respond.d.ts.map +1 -1
  69. package/dist/server/respond.js +21 -16
  70. package/dist/server/respond.js.map +1 -1
  71. package/dist/server/response-contract-lane.d.ts +12 -2
  72. package/dist/server/response-contract-lane.d.ts.map +1 -1
  73. package/dist/server/response-contract-lane.js +135 -8
  74. package/dist/server/response-contract-lane.js.map +1 -1
  75. package/dist/server/runtime-core.d.ts +7 -0
  76. package/dist/server/runtime-core.d.ts.map +1 -1
  77. package/dist/server/runtime-core.js +8 -1
  78. package/dist/server/runtime-core.js.map +1 -1
  79. package/dist/server/server-types.d.ts +6 -3
  80. package/dist/server/server-types.d.ts.map +1 -1
  81. package/dist/server/server.d.ts +6 -2
  82. package/dist/server/server.d.ts.map +1 -1
  83. package/dist/server/server.js +239 -78
  84. package/dist/server/server.js.map +1 -1
  85. package/dist/server/sse.d.ts +1 -1
  86. package/dist/server/sse.d.ts.map +1 -1
  87. package/dist/server/sse.js +5 -4
  88. package/dist/server/sse.js.map +1 -1
  89. package/dist/server/webhook.d.ts +1 -1
  90. package/dist/server/webhook.d.ts.map +1 -1
  91. package/dist/server/webhook.js +24 -19
  92. package/dist/server/webhook.js.map +1 -1
  93. package/dist/server/websocket.d.ts +18 -3
  94. package/dist/server/websocket.d.ts.map +1 -1
  95. package/dist/server/websocket.js +45 -5
  96. package/dist/server/websocket.js.map +1 -1
  97. package/dist/server/ws-bun.d.ts.map +1 -1
  98. package/dist/server/ws-bun.js +12 -3
  99. package/dist/server/ws-bun.js.map +1 -1
  100. package/dist/server.d.ts +1 -0
  101. package/dist/server.d.ts.map +1 -1
  102. package/dist/server.js +1 -0
  103. package/dist/server.js.map +1 -1
  104. package/dist/tool-cli.d.ts +64 -0
  105. package/dist/tool-cli.d.ts.map +1 -0
  106. package/dist/tool-cli.js +495 -0
  107. package/dist/tool-cli.js.map +1 -0
  108. package/package.json +11 -1
  109. package/src/assurance.ts +1 -0
  110. package/src/evidence.ts +4 -0
  111. package/src/file-type.ts +130 -0
  112. package/src/index.ts +2 -1
  113. package/src/internal/route-program.ts +261 -93
  114. package/src/reflection.ts +53 -7
  115. package/src/response-observer.ts +6 -0
  116. package/src/server/bare-error-lane.ts +9 -4
  117. package/src/server/before-handler.ts +14 -0
  118. package/src/server/body-lane.ts +19 -9
  119. package/src/server/body-parser.ts +1 -5
  120. package/src/server/body.ts +96 -36
  121. package/src/server/core-copies.ts +1 -1
  122. package/src/server/http.ts +6 -15
  123. package/src/server/multipart.ts +232 -100
  124. package/src/server/proto-guard.ts +39 -4
  125. package/src/server/query.ts +7 -13
  126. package/src/server/repeated-prefix.ts +25 -0
  127. package/src/server/request-context.ts +1 -3
  128. package/src/server/respond.ts +23 -15
  129. package/src/server/response-contract-lane.ts +130 -8
  130. package/src/server/runtime-core.ts +10 -1
  131. package/src/server/server-types.ts +6 -3
  132. package/src/server/server.ts +265 -76
  133. package/src/server/sse.ts +6 -5
  134. package/src/server/webhook.ts +29 -24
  135. package/src/server/websocket.ts +57 -8
  136. package/src/server/ws-bun.ts +17 -2
  137. package/src/server.ts +1 -0
  138. package/src/tool-cli.ts +584 -0
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Magic-byte file-type detection - trust the bytes, not the `Content-Type` header (which a client
3
+ * sets freely). Reads only the leading bytes; imports nothing, so it is edge-safe. Covers the common
4
+ * upload types; returns `null` for anything unrecognized (incl. text formats like SVG/CSV that have no
5
+ * magic number - handle those explicitly if you accept them).
6
+ *
7
+ * The shared owner for every type-by-bytes check: `@nifrajs/schema/form`'s `t.file({ accept })` and
8
+ * `@nifrajs/uploads`'s `validateUpload` must give one file one verdict, so both read this module.
9
+ */
10
+
11
+ export interface FileType {
12
+ /** Detected MIME type, e.g. `"image/png"`. */
13
+ readonly mime: string
14
+ /** Canonical extension (no dot), e.g. `"png"`. */
15
+ readonly ext: string
16
+ }
17
+
18
+ /**
19
+ * How many leading bytes {@link detectFileType} looks at. Reading this many from the start of a file
20
+ * (`file.slice(0, FILE_TYPE_PREFIX_BYTES)`) is enough for every type it knows: it reaches the first four
21
+ * compatible brands of an ISO-BMFF `ftyp` box, where an AVIF with the generic `mif1` brand says `avif`.
22
+ */
23
+ export const FILE_TYPE_PREFIX_BYTES = 32
24
+
25
+ /**
26
+ * Every MIME type {@link detectFileType} can return. An allow-list entry outside this set (and
27
+ * outside a `type/*` wildcard that covers one of them) can never match, so check a configured
28
+ * allow-list against it up front instead of rejecting every upload at request time.
29
+ */
30
+ export const DETECTABLE_MIME_TYPES: readonly string[] = [
31
+ "image/jpeg",
32
+ "image/png",
33
+ "image/gif",
34
+ "image/webp",
35
+ "audio/wav",
36
+ "video/x-msvideo",
37
+ "image/avif",
38
+ "image/heic",
39
+ "audio/mp4",
40
+ "video/mp4",
41
+ "video/webm",
42
+ "audio/ogg",
43
+ "audio/mpeg",
44
+ "application/pdf",
45
+ "application/zip",
46
+ "application/gzip",
47
+ ]
48
+
49
+ /**
50
+ * An MPEG audio frame header: an 11-bit frame sync, then a version, layer, bitrate and sample rate that
51
+ * are not reserved values. An MP3 needs no ID3 tag, so this is how an untagged one starts. AAC's ADTS
52
+ * sync shares the first 12 bits, but its layer is always the reserved `00`.
53
+ */
54
+ function mpegAudioFrame(bytes: Uint8Array): boolean {
55
+ const [b0 = 0, b1 = 0, b2 = 0] = bytes
56
+ if (bytes.length < 4 || b0 !== 0xff || (b1 & 0xe0) !== 0xe0) return false
57
+ const version = (b1 >> 3) & 0b11
58
+ const layer = (b1 >> 1) & 0b11
59
+ const bitrate = b2 >> 4
60
+ const sampleRate = (b2 >> 2) & 0b11
61
+ return version !== 0b01 && layer !== 0b00 && bitrate !== 0b1111 && sampleRate !== 0b11
62
+ }
63
+
64
+ /** Detect a file's type from its magic bytes, or `null` if unrecognized. */
65
+ export function detectFileType(bytes: Uint8Array): FileType | null {
66
+ const at = (offset: number, ...sig: number[]): boolean =>
67
+ sig.every((byte, i) => bytes[offset + i] === byte)
68
+
69
+ // Images
70
+ if (at(0, 0xff, 0xd8, 0xff)) return { mime: "image/jpeg", ext: "jpg" }
71
+ if (at(0, 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a))
72
+ return { mime: "image/png", ext: "png" }
73
+ if (at(0, 0x47, 0x49, 0x46, 0x38)) return { mime: "image/gif", ext: "gif" }
74
+
75
+ // RIFF container (bytes 0-3 "RIFF", 8-11 the form type)
76
+ if (at(0, 0x52, 0x49, 0x46, 0x46)) {
77
+ if (at(8, 0x57, 0x45, 0x42, 0x50)) return { mime: "image/webp", ext: "webp" }
78
+ if (at(8, 0x57, 0x41, 0x56, 0x45)) return { mime: "audio/wav", ext: "wav" }
79
+ if (at(8, 0x41, 0x56, 0x49, 0x20)) return { mime: "video/x-msvideo", ext: "avi" }
80
+ }
81
+
82
+ // ISO-BMFF (`ftyp` box at offset 4) - mp4 / avif / heic / m4a, disambiguated by the major brand.
83
+ if (at(4, 0x66, 0x74, 0x79, 0x70)) {
84
+ const brandAt = (offset: number): string =>
85
+ String.fromCharCode(
86
+ bytes[offset] ?? 0,
87
+ bytes[offset + 1] ?? 0,
88
+ bytes[offset + 2] ?? 0,
89
+ bytes[offset + 3] ?? 0,
90
+ )
91
+ const brand = brandAt(8)
92
+ // The compatible brands follow the minor version, to the end of the ftyp box. Only those inside the
93
+ // prefix count: the box size is the sender's claim, and a verdict must not depend on bytes a caller
94
+ // that sniffs `FILE_TYPE_PREFIX_BYTES` never reads.
95
+ const boxEnd = Math.min(
96
+ bytes.length,
97
+ FILE_TYPE_PREFIX_BYTES,
98
+ ((bytes[0] ?? 0) << 24) | ((bytes[1] ?? 0) << 16) | ((bytes[2] ?? 0) << 8) | (bytes[3] ?? 0),
99
+ )
100
+ const compatible: string[] = []
101
+ for (let offset = 16; offset + 4 <= boxEnd; offset += 4) compatible.push(brandAt(offset))
102
+ // A HEIF-family file whose major brand is the generic `mif1`/`msf1`/`miaf` names its codec among
103
+ // the compatible brands: AVIF says `avif`/`avis`.
104
+ const avif = (name: string): boolean => name === "avif" || name === "avis"
105
+ const generic = brand === "mif1" || brand === "msf1" || brand === "miaf"
106
+ if (avif(brand) || (generic && compatible.some(avif)))
107
+ return { mime: "image/avif", ext: "avif" }
108
+ if (brand.startsWith("hei") || brand.startsWith("hev") || generic) {
109
+ return { mime: "image/heic", ext: "heic" }
110
+ }
111
+ // Audio brands share the ISO-BMFF container but are NOT video - labeling them `video/mp4` would
112
+ // both reject real audio under an `audio/*` allow-list and admit it under `video/*`. `M4A `/`M4B `
113
+ // are AAC audio / audiobooks.
114
+ if (brand === "M4A " || brand === "M4B ") return { mime: "audio/mp4", ext: "m4a" }
115
+ return { mime: "video/mp4", ext: "mp4" }
116
+ }
117
+
118
+ // A/V + archives
119
+ if (at(0, 0x1a, 0x45, 0xdf, 0xa3)) return { mime: "video/webm", ext: "webm" } // Matroska/WebM
120
+ if (at(0, 0x4f, 0x67, 0x67, 0x53)) return { mime: "audio/ogg", ext: "ogg" }
121
+ if (at(0, 0x49, 0x44, 0x33)) return { mime: "audio/mpeg", ext: "mp3" } // ID3 tag
122
+ if (mpegAudioFrame(bytes)) return { mime: "audio/mpeg", ext: "mp3" } // an untagged MP3
123
+ if (at(0, 0x25, 0x50, 0x44, 0x46)) return { mime: "application/pdf", ext: "pdf" } // "%PDF"
124
+ if (at(0, 0x50, 0x4b, 0x03, 0x04) || at(0, 0x50, 0x4b, 0x05, 0x06)) {
125
+ return { mime: "application/zip", ext: "zip" } // also docx/xlsx/odt containers
126
+ }
127
+ if (at(0, 0x1f, 0x8b)) return { mime: "application/gzip", ext: "gz" }
128
+
129
+ return null
130
+ }
package/src/index.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  * read its own package.json at runtime. `scripts/version.ts` rewrites it on every release bump and
17
17
  * `check:publish` asserts it equals `@nifrajs/core`'s package version.
18
18
  */
19
- export const VERSION = "5.5.0" as const
19
+ export const VERSION = "5.6.0" as const
20
20
 
21
21
  export type Version = typeof VERSION
22
22
 
@@ -58,6 +58,7 @@ export {
58
58
  markAuthenticatedRequest,
59
59
  rejected,
60
60
  } from "./server/auth.ts"
61
+ export { beforeHandler } from "./server/before-handler.ts"
61
62
  export type {
62
63
  Context,
63
64
  Params,
@@ -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
  }