@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
@@ -0,0 +1,68 @@
1
+ import { RouteConfigError } from "../errors.ts"
2
+ import type { RouteSchema } from "./context.ts"
3
+
4
+ /**
5
+ * A schema's own step around what answers a route, for a schema that changes how the answer it
6
+ * describes is written: `t.bytes()` has every `Uint8Array` leave as base64. It sits on the schema's
7
+ * `validate`, so a spread copy keeps it, and is read once, at registration.
8
+ */
9
+ const ANSWER_STEP = Symbol.for("nifra.schema.answerStep")
10
+
11
+ /** The part of a request's context a step reads: the `c.json(...)` reply it made, and its value. */
12
+ export interface JsonReplyContext {
13
+ readonly jsonReply?: Response | undefined
14
+ readonly jsonBody?: unknown
15
+ json(body: unknown, init?: ResponseInit): Response
16
+ }
17
+
18
+ /**
19
+ * What can answer a route: its handler, and each hook whose return value takes the handler's place -
20
+ * a `beforeHandle` that answers first, an `afterHandle` that replaces the answer, an `onError` that
21
+ * answers an error. Each takes the request's context, as a `JsonReplyContext`.
22
+ */
23
+ export interface AnswerStages {
24
+ readonly handler: (context: never) => unknown
25
+ readonly beforeHandle: readonly ((context: never) => unknown)[]
26
+ readonly afterHandle: readonly ((result: unknown, context: never) => unknown)[]
27
+ readonly onError: readonly ((error: unknown, context: never) => unknown)[]
28
+ }
29
+
30
+ export interface AnswerStep {
31
+ /** Why the schema cannot describe a request or an event stream, and what to use there instead. */
32
+ readonly misplaced: string
33
+ /** `stages`, each wrapped so what it answers is written the schema's way. */
34
+ readonly wrap: <S extends AnswerStages>(stages: S) => S
35
+ }
36
+
37
+ function stepOf(schema: object | undefined): AnswerStep | undefined {
38
+ const validate = schema === undefined ? undefined : Reflect.get(schema, "~standard")?.validate
39
+ return typeof validate === "function" ? Reflect.get(validate, ANSWER_STEP) : undefined
40
+ }
41
+
42
+ const REQUEST_FIELDS = ["params", "headers", "cookies", "query", "body", "sse"] as const
43
+
44
+ /** `stages` wrapped by each step its route's `response` and `errors` schemas carry. */
45
+ export function withAnswerSteps<S extends AnswerStages>(
46
+ schema: RouteSchema | undefined,
47
+ method: string,
48
+ path: string,
49
+ stages: S,
50
+ ): S {
51
+ if (schema === undefined) return stages
52
+ for (const field of REQUEST_FIELDS) {
53
+ const step = stepOf(schema[field])
54
+ if (step !== undefined) {
55
+ throw new RouteConfigError(
56
+ "RESPONSE_ONLY_SCHEMA",
57
+ `route ${method} ${path}, \`${field}\`: ${step.misplaced}`,
58
+ )
59
+ }
60
+ }
61
+ const steps = new Set<AnswerStep["wrap"]>()
62
+ for (const answer of [schema.response, ...Object.values(schema.errors ?? {})]) {
63
+ const step = stepOf(answer)
64
+ if (step !== undefined) steps.add(step.wrap)
65
+ }
66
+ for (const wrap of steps) stages = wrap(stages)
67
+ return stages
68
+ }
@@ -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,138 @@
1
+ /**
2
+ * Bytes in a JSON answer, written as base64.
3
+ *
4
+ * `JSON.stringify` writes a `Uint8Array` as `{"0":1,"1":2,...}`, and nothing about that throws, so
5
+ * finding one costs a walk of every answer. Only a route that asks pays for that walk: `t.bytes()` in
6
+ * its `response` or `errors` has core hand {@link withBase64Bytes} the route's handler and the hooks
7
+ * that can answer in its place, and every `Uint8Array` in what any of them returns or throws as an
8
+ * answer becomes a base64 string before a later hook, contract or writer sees it. Nothing in the
9
+ * server imports this module, so an app that never declares the field never bundles it.
10
+ */
11
+ import { tagStandIn } from "../internal/hook-origins.ts"
12
+ import type { AnswerStages, JsonReplyContext } from "./answer-step.ts"
13
+ import { isResponseResult, status } from "./runtime-core.ts"
14
+
15
+ // Deeper than any real payload.
16
+ const MAX_DEPTH = 64
17
+ // So `String.fromCharCode(...)` never overflows the call stack.
18
+ const CHUNK = 0x8000
19
+
20
+ function base64(bytes: Uint8Array): string {
21
+ let binary = ""
22
+ for (let i = 0; i < bytes.length; i += CHUNK)
23
+ binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK))
24
+ return btoa(binary)
25
+ }
26
+
27
+ function define(out: object, key: string, value: unknown): void {
28
+ // `out[key] = value` for "__proto__" would replace the prototype instead of adding the field.
29
+ Object.defineProperty(out, key, { value, enumerable: true, writable: true, configurable: true })
30
+ }
31
+
32
+ /**
33
+ * `value` with each `Uint8Array` as base64, copying only the containers above one. A container already
34
+ * on the walk's path is left as it is, so a cycle costs one step, as it does `JSON.stringify`, which
35
+ * then refuses it.
36
+ */
37
+ function encoded(value: unknown, depth: number, path: Set<object>): unknown {
38
+ if (value instanceof Uint8Array) return base64(value)
39
+ if (typeof value !== "object" || value === null || depth > MAX_DEPTH || path.has(value))
40
+ return value
41
+ // A `Date` (or any value with a hook) writes itself.
42
+ if (typeof Reflect.get(value, "toJSON") === "function") return value
43
+ path.add(value)
44
+ try {
45
+ if (Array.isArray(value)) {
46
+ let out: unknown[] | undefined
47
+ for (let i = 0; i < value.length; i++) {
48
+ const next = encoded(value[i], depth + 1, path)
49
+ if (next === value[i]) continue
50
+ out ??= [...value]
51
+ out[i] = next
52
+ }
53
+ return out ?? value
54
+ }
55
+ let out: object | undefined
56
+ for (const key of Object.keys(value)) {
57
+ const item: unknown = Reflect.get(value, key)
58
+ const next = encoded(item, depth + 1, path)
59
+ if (next === item) continue
60
+ out ??= { ...value }
61
+ define(out, key, next)
62
+ }
63
+ return out ?? value
64
+ } finally {
65
+ path.delete(value)
66
+ }
67
+ }
68
+
69
+ const encodedBody = (body: unknown): unknown => encoded(body, 0, new Set())
70
+
71
+ /** An answer with its bytes encoded. A raw `Response` is left alone; a `c.json(...)` reply is rebuilt
72
+ * from the value it was made from, keeping its status and headers. */
73
+ function encodedAnswer(answer: unknown, c: JsonReplyContext): unknown {
74
+ if (answer instanceof Response) {
75
+ if (answer !== c.jsonReply) return answer
76
+ const body = encodedBody(c.jsonBody)
77
+ if (body === c.jsonBody) return answer
78
+ const headers = new Headers(answer.headers)
79
+ headers.delete("content-length")
80
+ return c.json(body, { status: answer.status, headers })
81
+ }
82
+ if (isResponseResult(answer)) {
83
+ const plain = answer.plain
84
+ if (plain === undefined) return answer
85
+ const body = encodedBody(plain.body)
86
+ if (body === plain.body) return answer
87
+ return status(
88
+ plain.status,
89
+ body,
90
+ plain.headers === undefined ? undefined : { headers: plain.headers },
91
+ )
92
+ }
93
+ return encodedBody(answer)
94
+ }
95
+
96
+ /** A thrown answer (`status(...)`, `c.json(...)`) is encoded too; any other throw passes untouched. */
97
+ const encodedThrow = (thrown: unknown, c: JsonReplyContext): unknown =>
98
+ thrown instanceof Response || isResponseResult(thrown) ? encodedAnswer(thrown, c) : thrown
99
+
100
+ /**
101
+ * `stage` with what it returns or throws as an answer encoded. Its context is argument `at`; an
102
+ * `undefined` return (a hook that lets the route go on) stays `undefined`.
103
+ */
104
+ function encoding<F extends (...args: never[]) => unknown>(stage: F, at: 0 | 1): F {
105
+ const wrapped = (...args: never[]): unknown => {
106
+ const c: JsonReplyContext = args[at]!
107
+ let result: unknown
108
+ try {
109
+ result = stage(...args)
110
+ } catch (thrown) {
111
+ throw encodedThrow(thrown, c)
112
+ }
113
+ if (!(result instanceof Promise)) return encodedAnswer(result, c)
114
+ return result.then(
115
+ (answer) => encodedAnswer(answer, c),
116
+ (thrown) => {
117
+ throw encodedThrow(thrown, c)
118
+ },
119
+ )
120
+ }
121
+ // Named as the stage, so stack traces and the hook report still name the author's function.
122
+ Object.defineProperty(wrapped, "name", { value: stage.name })
123
+ tagStandIn(wrapped, stage)
124
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: the wrapper takes `F`'s arguments and returns `unknown`, which is every stage's own return type.
125
+ return wrapped as F
126
+ }
127
+
128
+ /** A route's handler and answering hooks, each wrapped so every `Uint8Array` it answers leaves as
129
+ * base64. */
130
+ export function withBase64Bytes<S extends AnswerStages>(stages: S): S {
131
+ return {
132
+ ...stages,
133
+ handler: encoding(stages.handler, 0),
134
+ beforeHandle: stages.beforeHandle.map((hook) => encoding(hook, 0)),
135
+ afterHandle: stages.afterHandle.map((hook) => encoding(hook, 1)),
136
+ onError: stages.onError.map((hook) => encoding(hook, 1)),
137
+ }
138
+ }
@@ -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
@@ -16,14 +16,17 @@ const copies = registry[COPIES]
16
16
  const here = String(import.meta.url).split("?")[0] as string
17
17
 
18
18
  if (!copies.includes(here) && copies.push(here) > 1) {
19
+ // The registrar marks the process when it installs; without the mark a declared singleCopy was never
20
+ // armed here, most often because bunfig.toml's preload line sits below a [table] header. Bun-only and
21
+ // behind the inline NODE_ENV literal, so a production bundle drops the hint.
19
22
  console.warn(
20
- `[nifra] @nifrajs/core is loaded ${copies.length} times; \`nifra check\` names each importer:\n ${copies.join("\n ")}`,
23
+ `[nifra] @nifrajs/core is loaded ${copies.length} times; \`nifra check\` names each importer:\n ${copies.join("\n ")}${typeof Bun !== "undefined" && process.env.NODE_ENV !== "production" && !(Symbol.for("nifra.single-copy.active") in globalThis) ? "\n No single-copy registrar ran in this process: bunfig.toml must preload @nifrajs/core/single-copy/register at its top level, above any [table], for bun run, and under [test] for bun test." : ""}`,
21
24
  )
22
25
  }
23
26
 
24
27
  /** This copy's version: a literal like the package root's `VERSION` (no fs on the edge), which
25
28
  * `scripts/version.ts` rewrites on every release and `check:publish` asserts. */
26
- const COPY_VERSION = "5.5.1"
29
+ const COPY_VERSION = "5.7.0"
27
30
 
28
31
  /** Record this copy's version beside its URL. Called by each `server()` behind the development guard,
29
32
  * 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),