@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
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
  /**
@@ -83,8 +88,16 @@ export interface ReflectedRouteSchema {
83
88
  readonly errors?: Readonly<Record<string, SchemaReflection>>
84
89
  /** The SSE event-payload schema of a typed streaming route (`app.sse()`). */
85
90
  readonly sse?: SchemaReflection
91
+ /**
92
+ * Set when an `onValidationError` hook (the route's own, or the app-wide default) answers this
93
+ * route's validation failures, so its 422 body is whatever the hook returns.
94
+ */
95
+ readonly validationErrorHook?: true
86
96
  }
87
97
 
98
+ /** Which side of a schema to describe: the value a request sends, or the value a response carries. */
99
+ export type SchemaSide = "input" | "output"
100
+
88
101
  export interface ReflectedRoute {
89
102
  readonly method: string
90
103
  readonly path: string
@@ -118,6 +131,22 @@ const standardOf = (value: unknown): StandardSchemaV1 | undefined => {
118
131
  : undefined
119
132
  }
120
133
 
134
+ /** The JSON Schema a Standard JSON Schema (`~standard.jsonSchema`) converts `side` to, if it has one. */
135
+ const standardJsonSchemaOf = (
136
+ standard: StandardSchemaV1,
137
+ side: SchemaSide,
138
+ ): JsonSchema | undefined => {
139
+ const converters = recordOf(recordOf(standard["~standard"])?.jsonSchema)
140
+ const convert = converters?.[side]
141
+ if (typeof convert !== "function") return undefined
142
+ try {
143
+ return normalizeJsonSchema(Reflect.apply(convert, converters, [{ target: "draft-2020-12" }]))
144
+ } catch {
145
+ // A library throws for a type JSON Schema cannot express (zod's `z.date()`); the schema stays opaque.
146
+ return undefined
147
+ }
148
+ }
149
+
121
150
  const jsonSchemaOf = (
122
151
  value: unknown,
123
152
  standard: StandardSchemaV1 | undefined,
@@ -197,14 +226,21 @@ const mediaTypesOf = (value: unknown): readonly string[] | undefined => {
197
226
  /**
198
227
  * Reflect a Standard Schema, Nifra/TypeBox schema carrier, or raw JSON Schema. Never throws.
199
228
  * Validation-only schemas have `standard` but no `jsonSchema`; raw JSON Schema has the reverse.
229
+ * A Standard JSON Schema is read on `side`, since a default or a transform makes the two differ.
200
230
  */
201
- export function reflectSchema(value: unknown): SchemaReflection {
231
+ export function reflectSchema(value: unknown, side: SchemaSide = "input"): SchemaReflection {
202
232
  const standard = standardOf(value)
203
- const jsonSchema = jsonSchemaOf(value, standard)
233
+ const carried = jsonSchemaOf(value, standard)
234
+ const converted =
235
+ carried === undefined && standard !== undefined
236
+ ? standardJsonSchemaOf(standard, side)
237
+ : undefined
238
+ const jsonSchema = carried ?? converted
204
239
  const mediaTypes = mediaTypesOf(value)
205
240
  return {
206
241
  standard,
207
242
  jsonSchema,
243
+ ...(converted !== undefined ? { converted: true as const } : {}),
208
244
  fields: fieldsOf(jsonSchema),
209
245
  ...(mediaTypes !== undefined ? { mediaTypes } : {}),
210
246
  }
@@ -222,14 +258,17 @@ const routeCandidates = (source: unknown): readonly unknown[] => {
222
258
  }
223
259
  }
224
260
 
225
- const reflectedRouteSchema = (value: unknown): ReflectedRouteSchema | undefined => {
261
+ const reflectedRouteSchema = (
262
+ value: unknown,
263
+ appValidationHook: boolean,
264
+ ): ReflectedRouteSchema | undefined => {
226
265
  const schema = recordOf(value)
227
266
  if (schema === undefined) return undefined
228
267
  const errors = recordOf(schema.errors)
229
268
  const reflectedErrors: Record<string, SchemaReflection> = {}
230
269
  if (errors !== undefined) {
231
270
  for (const [status, errorSchema] of Object.entries(errors)) {
232
- reflectedErrors[status] = reflectSchema(errorSchema)
271
+ reflectedErrors[status] = reflectSchema(errorSchema, "output")
233
272
  }
234
273
  }
235
274
  return {
@@ -249,9 +288,14 @@ const reflectedRouteSchema = (value: unknown): ReflectedRouteSchema | undefined
249
288
  ...(schema.body !== undefined ? { body: reflectSchema(schema.body) } : {}),
250
289
  ...(schema.query !== undefined ? { query: reflectSchema(schema.query) } : {}),
251
290
  ...(schema.params !== undefined ? { params: reflectSchema(schema.params) } : {}),
252
- ...(schema.response !== undefined ? { response: reflectSchema(schema.response) } : {}),
291
+ ...(schema.response !== undefined
292
+ ? { response: reflectSchema(schema.response, "output") }
293
+ : {}),
253
294
  ...(Object.keys(reflectedErrors).length > 0 ? { errors: reflectedErrors } : {}),
254
- ...(schema.sse !== undefined ? { sse: reflectSchema(schema.sse) } : {}),
295
+ ...(schema.sse !== undefined ? { sse: reflectSchema(schema.sse, "output") } : {}),
296
+ ...(appValidationHook || typeof schema.onValidationError === "function"
297
+ ? { validationErrorHook: true as const }
298
+ : {}),
255
299
  }
256
300
  }
257
301
 
@@ -326,10 +370,12 @@ const reflectedSockets = (source: unknown): ReflectedRoute[] => {
326
370
  */
327
371
  export function reflectRoutes(source: unknown): readonly ReflectedRoute[] {
328
372
  const reflected: ReflectedRoute[] = []
373
+ // The app-wide `server({ onValidationError })` lives on the server, not on each route descriptor.
374
+ const appValidationHook = typeof recordOf(source)?.defaultOnValidationError === "function"
329
375
  for (const candidate of routeCandidates(source)) {
330
376
  const route = recordOf(candidate)
331
377
  if (typeof route?.method !== "string" || typeof route.path !== "string") continue
332
- const schema = reflectedRouteSchema(route.schema)
378
+ const schema = reflectedRouteSchema(route.schema, appValidationHook)
333
379
  const tool = reflectedTool(route.tool)
334
380
  const assurance = reflectedAssurance(route.assurance)
335
381
  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,
@@ -17,9 +17,10 @@
17
17
  * which lane caught it. Both the generic bare lane and the fused body runner route here, so that
18
18
  * decision lives in exactly one place.
19
19
  */
20
- import { pathnameOf } from "./http.ts"
20
+ import { clientBodyErrorCode } from "./body.ts"
21
+ import { pathnameOf, plainError } from "./http.ts"
21
22
  import type { Logger } from "./logger.ts"
22
- import { isResponseResult } from "./runtime-core.ts"
23
+ import { isResponseResult, type ResponseResult } from "./runtime-core.ts"
23
24
  import type { CtxSet, RawContext } from "./server.ts"
24
25
 
25
26
  /** How much of a thrown error's own text reaches the log sink. Mirrors `Server`'s option. */
@@ -31,6 +32,8 @@ export type ErrorLogDetail = "full" | "message" | "none"
31
32
  * Either renders through the same `finalize` a RETURNED one takes, with the request's `c.set`,
32
33
  * so a thrown one means what the return means: a `Response` ships as built, plus the cookies the
33
34
  * request queued (the set-session-then-`throw redirect()` pattern);
35
+ * - a request body the app's own `c.req.json()` or `c.req.formData()` could not parse is the
36
+ * client's 400 (`invalid_json`, `invalid_form`);
34
37
  * - anything else is a real fault: logged once, then a bodyless 500 (never the caller's error text).
35
38
  *
36
39
  * `responseSet` and `logError` are injected rather than imported so this file keeps no runtime edge
@@ -43,11 +46,13 @@ export function renderBareError<T>(
43
46
  finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
44
47
  responseSet: (ctx: RawContext) => CtxSet,
45
48
  logError: (err: unknown, ctx: RawContext) => void,
46
- internalError: () => T,
49
+ wrapResponse: (response: ResponseResult) => T,
47
50
  ): T {
48
51
  if (err instanceof Response || isResponseResult(err)) return finalize(err, responseSet(ctx), ctx)
52
+ const clientCode = clientBodyErrorCode(err)
53
+ if (clientCode !== undefined) return wrapResponse(plainError(400, clientCode))
49
54
  logError(err, ctx)
50
- return internalError()
55
+ return wrapResponse(plainError(500, "internal_error"))
51
56
  }
52
57
 
53
58
  /**
@@ -0,0 +1,14 @@
1
+ import type { Context } from "./context.ts"
2
+
3
+ /** Hooks {@link beforeHandler} marked; the kernel keeps them after guards registered later. */
4
+ export const LAST_BEFORE_HANDLE = new WeakSet<object>()
5
+
6
+ /**
7
+ * Mark a `beforeHandle` hook to run just before the handler: a guard registered after it in the same
8
+ * scope still goes ahead of it, so it only sees requests every guard admitted. A hook that answers
9
+ * from stored state (an idempotency replay) needs this, or it would answer ahead of a later guard.
10
+ */
11
+ export function beforeHandler<H extends (context: Context) => unknown>(hook: H): H {
12
+ LAST_BEFORE_HANDLE.add(hook)
13
+ return hook
14
+ }
@@ -30,6 +30,10 @@ type FramedJsonSource = RequestSource & {
30
30
 
31
31
  const INVALID_JSON = (): ResponseResult => plainError(400, "invalid_json")
32
32
 
33
+ // `Deno.serve`'s own `Request.json()` keeps a leading UTF-8 byte order mark that its `text()` and every
34
+ // other runtime's `json()` drop, so it refuses a body they parse. Deno decodes the bytes itself below.
35
+ const nativeJsonKeepsBom = "Deno" in globalThis
36
+
33
37
  /** Read a JSON source with the declared-length precheck, delivered-byte recheck, streaming cap, and
34
38
  * prototype-poisoning guard in one lane. */
35
39
  export function readBoundedJsonSource(
@@ -72,7 +76,7 @@ export function readBoundedJsonSource<T>(
72
76
  ? Promise.resolve(plainError(413, "payload_too_large"))
73
77
  : Promise.resolve(plainError(413, "payload_too_large")).then(onResult)
74
78
 
75
- if (hasTrustedBodyFraming(req)) {
79
+ if (hasTrustedBodyFraming(req) && !nativeJsonKeepsBom) {
76
80
  return raw.json().then(
77
81
  (parsed) => {
78
82
  try {
@@ -156,17 +160,23 @@ export function readBoundedJsonSource<T>(
156
160
  }
157
161
 
158
162
  const body = raw.body
163
+ // A body off a client's connection that fails mid-read is refused as this lane's other failures
164
+ // are; one built in this process, or a stream the app already holds, keeps its error.
165
+ const refuseFailedRead = body !== null && !body.locked && hasTrustedBodyFraming(req)
159
166
  const streamed =
160
167
  body === null
161
168
  ? Promise.resolve<unknown | ResponseResult>(plainError(400, "invalid_json"))
162
- : drainCapped(body, maxBytes).then((drained) => {
163
- if (!drained.ok) return plainError(413, "payload_too_large")
164
- try {
165
- return parseJsonGuarded(TEXT_DECODER.decode(drained.bytes), protoPoisoning)
166
- } catch {
167
- return plainError(400, "invalid_json")
168
- }
169
- })
169
+ : drainCapped(body, maxBytes).then(
170
+ (drained) => {
171
+ if (!drained.ok) return plainError(413, "payload_too_large")
172
+ try {
173
+ return parseJsonGuarded(TEXT_DECODER.decode(drained.bytes), protoPoisoning)
174
+ } catch {
175
+ return plainError(400, "invalid_json")
176
+ }
177
+ },
178
+ refuseFailedRead ? INVALID_JSON : undefined,
179
+ )
170
180
  return onResult === undefined ? streamed : streamed.then(onResult, onError)
171
181
  }
172
182
 
@@ -210,11 +210,7 @@ export function bodyParser<Schema extends StandardSchemaV1>(
210
210
  : inner(source, contentType, maxBodyBytes, protoPoisoning)
211
211
  }
212
212
  const body = await readBoundedBytes(source, maxBodyBytes)
213
- if (!body.ok) {
214
- return body.status === 413
215
- ? plainError(413, "payload_too_large")
216
- : plainError(400, "invalid_content_length")
217
- }
213
+ if (!body.ok) return plainError(body.status, body.error)
218
214
  try {
219
215
  const value = await parse(body.bytes, { mediaType, contentType })
220
216
  settle(value, protoPoisoning)
@@ -12,6 +12,7 @@ import { jsonError } from "./http.ts"
12
12
  interface BodySource {
13
13
  readonly headers: Pick<Headers, "get">
14
14
  readonly body: ReadableStream<Uint8Array> | null
15
+ readonly bodyUsed?: boolean
15
16
  arrayBuffer(): Promise<ArrayBuffer>
16
17
  bytes?(): Promise<Uint8Array>
17
18
  }
@@ -20,6 +21,7 @@ interface BodySource {
20
21
  export interface RawBodyReaders {
21
22
  readonly headers: Pick<Headers, "get">
22
23
  readonly body: ReadableStream<Uint8Array> | null
24
+ readonly bodyUsed?: boolean
23
25
  arrayBuffer(): Promise<ArrayBuffer>
24
26
  /** Runtime-native exact-byte reader, when the request implementation exposes one. */
25
27
  bytes?(): Promise<Uint8Array>
@@ -65,13 +67,26 @@ export function parseContentLength(value: string): number | undefined {
65
67
  return length
66
68
  }
67
69
 
70
+ /** Why a bounded read refused a body: the status and the error code it is answered with. */
71
+ export type BoundedReadFailure =
72
+ | { ok: false; status: 413; error: "payload_too_large" }
73
+ | { ok: false; status: 400; error: "invalid_content_length" | "invalid_body" }
74
+
75
+ const TOO_LARGE: { ok: false; status: 413; error: "payload_too_large" } = {
76
+ ok: false,
77
+ status: 413,
78
+ error: "payload_too_large",
79
+ }
80
+ const BAD_LENGTH: BoundedReadFailure = { ok: false, status: 400, error: "invalid_content_length" }
81
+ const UNREADABLE: BoundedReadFailure = { ok: false, status: 400, error: "invalid_body" }
82
+
68
83
  /** The shared streaming byte-cap loop: read until done, or cancel + 413 once over `maxBytes`.
69
84
  * A single-chunk body (the common case for small chunked payloads) returns the runtime's own
70
85
  * chunk directly - no chunk array, no copy-merge. */
71
86
  export async function drainCapped(
72
87
  body: ReadableStream<Uint8Array>,
73
88
  maxBytes: number,
74
- ): Promise<{ ok: true; bytes: Uint8Array } | { ok: false; status: 413 }> {
89
+ ): Promise<{ ok: true; bytes: Uint8Array } | typeof TOO_LARGE> {
75
90
  assertByteLimit(maxBytes)
76
91
  const reader = body.getReader()
77
92
  let first: Uint8Array | undefined
@@ -85,7 +100,7 @@ export async function drainCapped(
85
100
  // Not awaited: on a teed body (a `clone()` taken first) the cancel settles only once the other
86
101
  // branch is read or cancelled too, and nothing here ever reads it.
87
102
  reader.cancel().catch(() => {})
88
- return { ok: false, status: 413 }
103
+ return TOO_LARGE
89
104
  }
90
105
  if (first === undefined) {
91
106
  first = value
@@ -112,38 +127,55 @@ export async function drainCapped(
112
127
  * `Content-Length` within the cap is read via native `arrayBuffer()`, then the **real** byte count
113
128
  * is checked against the declared length - a source that delivers more than it declared (a lying
114
129
  * or upstream-decoding adapter) is rejected with `413` even though its header passed the hint.
130
+ * A body a runtime took off a client's connection that fails while it is read - a client that hung
131
+ * up inside it, which only Deno hands an app - is refused `400` `invalid_body`. A body built in this
132
+ * process keeps its producer's error, and a reader the app spent or locked keeps its throw.
115
133
  */
116
134
  export async function readBoundedBytes(
117
135
  source: BodySource,
118
136
  maxBytes: number,
119
- ): Promise<{ ok: true; bytes: Uint8Array } | { ok: false; status: 400 | 413 }> {
137
+ ): Promise<{ ok: true; bytes: Uint8Array } | BoundedReadFailure> {
120
138
  assertByteLimit(maxBytes)
121
139
  const req = rawBodySourceOf(source)
122
140
  const declared = req.headers.get("content-length")
141
+ let length: number | undefined
123
142
  if (declared !== null) {
124
143
  // A present Content-Length must be a non-negative integer (HTTP grammar: `1*DIGIT`). A non-numeric
125
144
  // / negative / fractional value is malformed → 400, rather than falling through to the streaming
126
145
  // guard (an UPPER-bound cap only, so a lying SMALLER length would otherwise be read in full).
127
- const length = parseContentLength(declared)
128
- if (length === undefined) return { ok: false, status: 400 }
129
- if (length > maxBytes) return { ok: false, status: 413 }
130
- const chunked = req.headers.get("transfer-encoding") !== null
131
- if (!chunked) {
132
- const bytesReader = req.bytes
133
- const bytes =
134
- bytesReader === undefined
135
- ? new Uint8Array(await req.arrayBuffer())
136
- : await bytesReader.call(req)
137
- // Content-Length was the fast-reject hint, never the enforcement: a lying or buffering
138
- // source (an adapter that decoded/expanded the body upstream) can hand over more bytes
139
- // than it declared. `length <= maxBytes` held above, so one comparison seals the cap.
140
- if (bytes.byteLength > length) return { ok: false, status: 413 }
141
- return { ok: true, bytes }
146
+ length = parseContentLength(declared)
147
+ if (length === undefined) return BAD_LENGTH
148
+ if (length > maxBytes) return TOO_LARGE
149
+ }
150
+ const fromClient = hasTrustedBodyFraming(source) || hasTrustedBodyFraming(req)
151
+ if (length !== undefined && req.headers.get("transfer-encoding") === null) {
152
+ // Called outside the `try`: a spent reader throws synchronously, and that throw is the app's.
153
+ // A Request the app already read or locked rejects instead. Only a read that disturbed an unused
154
+ // body failed on the client's side; Bun marks a locked body used, Node and Deno leave it unused.
155
+ const usedBefore = req.bodyUsed
156
+ const bytesReader = req.bytes
157
+ const reading =
158
+ bytesReader === undefined
159
+ ? req.arrayBuffer().then((buffer) => new Uint8Array(buffer))
160
+ : bytesReader.call(req)
161
+ let bytes: Uint8Array
162
+ try {
163
+ bytes = await reading
164
+ } catch (error) {
165
+ if (fromClient && usedBefore !== true && req.bodyUsed !== false) return UNREADABLE
166
+ throw error
142
167
  }
168
+ // Content-Length was the fast-reject hint, never the enforcement: a lying or buffering
169
+ // source (an adapter that decoded/expanded the body upstream) can hand over more bytes
170
+ // than it declared. `length <= maxBytes` held above, so one comparison seals the cap.
171
+ if (bytes.byteLength > length) return TOO_LARGE
172
+ return { ok: true, bytes }
143
173
  }
144
174
  const body = req.body
145
175
  if (body === null) return { ok: true, bytes: new Uint8Array(0) }
146
- return drainCapped(body, maxBytes)
176
+ const lockedByApp = body.locked
177
+ const draining = drainCapped(body, maxBytes)
178
+ return fromClient && !lockedByApp ? draining.catch(() => UNREADABLE) : draining
147
179
  }
148
180
 
149
181
  /** Key under which a transport-capped request stashes its {@link RawBodyReaders}. */
@@ -184,14 +216,37 @@ const TRANSPORT_CAPPED = new WeakSet<object>()
184
216
 
185
217
  const CAP_DECODER = new TextDecoder()
186
218
 
219
+ // The error a capped `json()` or `formData()` raised on the request's own bytes, kept as the
220
+ // `Request` reader rejects so app code that catches it is unchanged, with the code it answers. One
221
+ // the app lets escape is the client's 400.
222
+ const CLIENT_BODY_ERRORS = new WeakMap<object, "invalid_json" | "invalid_form">()
223
+
224
+ /** The 400 code for a request body a capped reader could not parse, or `undefined` for any other error. */
225
+ export const clientBodyErrorCode = (err: unknown): "invalid_json" | "invalid_form" | undefined =>
226
+ err instanceof Error ? CLIENT_BODY_ERRORS.get(err) : undefined
227
+
228
+ function parseClientJson(bytes: Uint8Array): unknown {
229
+ try {
230
+ return JSON.parse(CAP_DECODER.decode(bytes))
231
+ } catch (error) {
232
+ if (error instanceof Error) CLIENT_BODY_ERRORS.set(error, "invalid_json")
233
+ throw error
234
+ }
235
+ }
236
+
237
+ function clientFormError(error: unknown): never {
238
+ if (error instanceof Error) CLIENT_BODY_ERRORS.set(error, "invalid_form")
239
+ throw error
240
+ }
241
+
187
242
  /** Key under which the dispatcher marks a source with its route's transport byte cap. */
188
243
  const TRANSPORT_CAP = Symbol("nifra.body.transportCap")
189
244
 
190
245
  /** The transport cap's over-cap rejection: the flat error a shadowed direct reader throws (and the
191
246
  * capped `body` stream errors with). The lifecycle already treats a thrown `Response` as control
192
247
  * flow (the `c.boundedBody` contract), so an over-cap direct read answers 413 like every other cap. */
193
- function transportCapError(status: 400 | 413): Response {
194
- return jsonError(status, status === 413 ? "payload_too_large" : "invalid_content_length")
248
+ function transportCapError(failure: BoundedReadFailure): Response {
249
+ return jsonError(failure.status, failure.error)
195
250
  }
196
251
 
197
252
  /**
@@ -211,11 +266,11 @@ export function capBodyBytes(request: Request, maxBytes: number): Request {
211
266
  new TransformStream<Uint8Array, Uint8Array>({
212
267
  start(controller) {
213
268
  if (length === undefined || length > maxBytes)
214
- controller.error(transportCapError(length === undefined ? 400 : 413))
269
+ controller.error(transportCapError(length === undefined ? BAD_LENGTH : TOO_LARGE))
215
270
  },
216
271
  transform(chunk, controller) {
217
272
  total += chunk.byteLength
218
- if (total > maxBytes) controller.error(transportCapError(413))
273
+ if (total > maxBytes) controller.error(transportCapError(TOO_LARGE))
219
274
  else controller.enqueue(chunk)
220
275
  },
221
276
  }),
@@ -279,11 +334,11 @@ function spendRaw(raw: RawBodyReaders): void {
279
334
  Object.defineProperty(raw, "body", { get: spent })
280
335
  }
281
336
 
282
- /** A pass-through stream that errors (with `reject(413)`) once more than `maxBytes` flow through. */
337
+ /** A pass-through stream that errors (with `reject` of a 413) once more than `maxBytes` flow through. */
283
338
  function cappedStreamOf(
284
339
  body: RawStream,
285
340
  maxBytes: number,
286
- reject: (status: 400 | 413) => unknown,
341
+ reject: (failure: BoundedReadFailure) => unknown,
287
342
  ): ReadableStream<Uint8Array> {
288
343
  // The reader is acquired on first pull, and `highWaterMark: 0` defers that first pull until a
289
344
  // consumer actually reads (the default of 1 pulls eagerly at construction): merely *accessing*
@@ -312,7 +367,7 @@ function cappedStreamOf(
312
367
  // Not awaited, as in drainCapped: on a teed body the cancel settles only once the other
313
368
  // branch is read or cancelled too, and the 413 must not wait for an unread clone.
314
369
  active.cancel().catch(() => {})
315
- controller.error(reject(413))
370
+ controller.error(reject(TOO_LARGE))
316
371
  return
317
372
  }
318
373
  controller.enqueue(value)
@@ -332,8 +387,9 @@ function cappedStreamOf(
332
387
  * pre-decoded body), and hook-observed references all survive. Nothing is read eagerly: the cap
333
388
  * only pays when user code actually reads, and framework readers bypass it through
334
389
  * {@link rawBodySourceOf} (stashed under {@link RAW_BODY_READERS}) to enforce their own caps.
335
- * An over-cap read rejects with `reject(413)` (a lying `Content-Length` with `reject(400)`), which
336
- * the lifecycle already treats as control flow - the same contract as `c.boundedBody`.
390
+ * An over-cap read rejects with the cap's flat 413 (a lying `Content-Length` with its 400, a body
391
+ * that fails mid-read with 400 `invalid_body`), which the lifecycle already treats as control flow -
392
+ * the same contract as `c.boundedBody`.
337
393
  */
338
394
  /** The request's own pre-shadow readers - the fallback surface when the source offers none. */
339
395
  function requestBodyReaders(request: Request): RawBodyReaders {
@@ -349,6 +405,9 @@ function requestBodyReaders(request: Request): RawBodyReaders {
349
405
  : (bodyDescriptor?.value ?? null)
350
406
  ) as ReadableStream<Uint8Array> | null
351
407
  },
408
+ get bodyUsed() {
409
+ return request.bodyUsed
410
+ },
352
411
  arrayBuffer: request.arrayBuffer.bind(request),
353
412
  json: request.json.bind(request),
354
413
  }
@@ -361,13 +420,15 @@ function capRequestBodyReads(request: Request, maxBytes: number, preset?: RawBod
361
420
  TRANSPORT_CAPPED.add(request)
362
421
  const reject = transportCapError
363
422
  const raw = preset ?? requestBodyReaders(request)
423
+ // The shadowed readers read through `raw`, which must still say where the body came from.
424
+ if (hasTrustedBodyFraming(request)) markTrustedBodyFraming(raw)
364
425
  // One bounded buffer backs every shadowed reader; a second read replays it instead of failing
365
426
  // with "body already used" (a strict superset of the native one-shot contract).
366
427
  let buffered: Promise<Uint8Array> | undefined
367
428
  const cappedBytes = (): Promise<Uint8Array> => {
368
429
  if (buffered === undefined) {
369
430
  const read = readBoundedBytes(raw, maxBytes).then((result) => {
370
- if (!result.ok) throw reject(result.status)
431
+ if (!result.ok) throw reject(result)
371
432
  return result.bytes
372
433
  })
373
434
  buffered = read
@@ -401,8 +462,7 @@ function capRequestBodyReads(request: Request, maxBytes: number, preset?: RawBod
401
462
  shadowed[RAW_BODY_READERS] = raw
402
463
  shadowed.arrayBuffer = () => cappedBytes().then(exactArrayBuffer)
403
464
  shadowed.bytes = () => cappedBytes().then((bytes) => bytes.slice())
404
- shadowed.json = () =>
405
- cappedBytes().then((bytes) => JSON.parse(CAP_DECODER.decode(bytes)) as unknown)
465
+ shadowed.json = () => cappedBytes().then(parseClientJson)
406
466
  shadowed.text = () => cappedBytes().then((bytes) => CAP_DECODER.decode(bytes))
407
467
  shadowed.blob = () =>
408
468
  cappedBytes().then(
@@ -415,7 +475,9 @@ function capRequestBodyReads(request: Request, maxBytes: number, preset?: RawBod
415
475
  cappedBytes().then((bytes) =>
416
476
  new Response(exactArrayBuffer(bytes), {
417
477
  headers: { "content-type": request.headers.get("content-type") ?? "" },
418
- }).formData(),
478
+ })
479
+ .formData()
480
+ .catch(clientFormError),
419
481
  )
420
482
  // `clone()` must not mint an uncapped escape hatch: the clone gets the same cap.
421
483
  if (typeof request.clone === "function") {
@@ -489,10 +551,8 @@ export function applyTransportCap(source: object, request: Request): void {
489
551
  if (maxBytes === undefined || TRANSPORT_CAPPED.has(request)) return
490
552
  const provide = (source as { rawBodyReaders?: () => RawBodyReaders }).rawBodyReaders
491
553
  const preset = provide === undefined ? undefined : provide.call(source)
492
- // The null-body shortcut is only worth its probe when there is no cheaper surface: on a source
493
- // that defers its `Request`, reading `request.body` would materialize the very object the preset
494
- // readers exist to avoid. Capping a bodiless request costs nothing but the shadowing itself.
495
- if (preset === undefined && request.body === null) return
554
+ // A bodiless request is capped too: its `json()` must fail the way a capped one does, as a client
555
+ // input error, and Bun.serve hands over `Content-Length: 0` with no body where others give one.
496
556
  capRequestBodyReads(request, maxBytes, preset)
497
557
  // A wrapper source (Node's lazy source) delegates its own body readers to the request once it
498
558
  // materializes - which are now the shadowed ones. Mirror the raw-reader stash onto the source so
@@ -23,7 +23,7 @@ if (!copies.includes(here) && copies.push(here) > 1) {
23
23
 
24
24
  /** This copy's version: a literal like the package root's `VERSION` (no fs on the edge), which
25
25
  * `scripts/version.ts` rewrites on every release and `check:publish` asserts. */
26
- const COPY_VERSION = "5.5.0"
26
+ const COPY_VERSION = "5.6.0"
27
27
 
28
28
  /** Record this copy's version beside its URL. Called by each `server()` behind the development guard,
29
29
  * so a production bundle drops it with the version; a copy that built a server has recorded one. */
@@ -114,26 +114,17 @@ export interface UrlParts {
114
114
  // Extract pathname + query WITHOUT a full WHATWG `new URL(req.url)` parse.
115
115
  // `req.url` from every supported runtime is an absolute, already-normalized URL, so the pathname is
116
116
  // the substring after `scheme://host[:port]` up to `?`/`#`. Query-schema routes also need the search
117
- // string; parsing both in one scanner avoids the old `pathnameOf()` + `searchOf()` double scan.
117
+ // string; splitting both together avoids the old `pathnameOf()` + `searchOf()` repeated parsing.
118
118
  export function urlPartsOf(url: string): UrlParts {
119
119
  const schemeEnd = url.indexOf("://")
120
120
  const start = schemeEnd === -1 ? url.indexOf("/") : url.indexOf("/", schemeEnd + 3)
121
121
  if (start === -1) return { pathname: "/", search: "" }
122
122
 
123
- let pathEnd = url.length
124
- let searchStart = -1
125
- let searchEnd = url.length
126
- for (let i = start; i < url.length; i++) {
127
- const c = url.charCodeAt(i)
128
- if (c === 63 /* ? */ && searchStart === -1) {
129
- pathEnd = i
130
- searchStart = i
131
- } else if (c === 35 /* # */) {
132
- if (searchStart === -1) pathEnd = i
133
- searchEnd = i
134
- break
135
- }
136
- }
123
+ const fragment = url.indexOf("#", start)
124
+ const searchEnd = fragment === -1 ? url.length : fragment
125
+ const question = url.indexOf("?", start)
126
+ const searchStart = question !== -1 && question < searchEnd ? question : -1
127
+ const pathEnd = searchStart === -1 ? searchEnd : searchStart
137
128
 
138
129
  return {
139
130
  pathname: url.slice(start, pathEnd),