@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
@@ -52,5 +52,8 @@ export const RESOLVE_NODE_MOUNT: unique symbol = Symbol.for(
52
52
  NODE_BRIDGE_MARKER_KEYS.resolveNodeMount,
53
53
  )
54
54
 
55
+ /** @internal Run a server's `onStopping` or `onStop` hooks; see `Server.stop()`. */
56
+ export const SERVER_LIFECYCLE: unique symbol = Symbol.for("@nifrajs/core/server-lifecycle")
57
+
55
58
  /** @internal Marker on a mounted handler that knows how to serve a native Node request/response. */
56
59
  export const NODE_NATIVE_MOUNT: unique symbol = Symbol.for("@nifrajs/core/node-native-mount")
@@ -0,0 +1,13 @@
1
+ import { retried } from "./json-text.ts"
2
+
3
+ /** `Response.json`, with each `bigint` written as its decimal string, as `jsonText` writes it. */
4
+ export function jsonResponse(value: unknown, init?: ResponseInit): Response {
5
+ try {
6
+ return Response.json(value, init)
7
+ } catch (error) {
8
+ const text = retried(value, error)
9
+ // Parsed back so the runtime's own `Response.json` still picks the content-type, and still
10
+ // throws for a value with no JSON form.
11
+ return Response.json(typeof text === "string" ? JSON.parse(text) : value, init)
12
+ }
13
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * JSON has no `bigint`, and `JSON.stringify` throws on one, so a handler returning a database's 64-bit
3
+ * id answered a 500. Every plain JSON writer goes through this instead: a `bigint` is written as its
4
+ * decimal string, the encoding OpenAPI generators and protobuf's JSON mapping use for 64-bit integers.
5
+ * Only a first attempt that threw is retried, so a value without one costs nothing more. The Node
6
+ * adapter runs a generated copy (scripts/gen-node-outcome.ts), so this module imports nothing.
7
+ */
8
+
9
+ const bigintAsText = (_key: string, value: unknown): unknown =>
10
+ typeof value === "bigint" ? value.toString() : value
11
+
12
+ /**
13
+ * `JSON.stringify`, with each `bigint` written as its decimal string. Typed as the lib types
14
+ * `JSON.stringify`: a value with no JSON form (`undefined`, a function) still gives `undefined`.
15
+ */
16
+ export function jsonText(value: unknown): string {
17
+ try {
18
+ return JSON.stringify(value)
19
+ } catch (error) {
20
+ return retried(value, error)
21
+ }
22
+ }
23
+
24
+ /** Write `value` again, each `bigint` as text, after `JSON.stringify(value)` threw `error`; any other
25
+ * failure rethrows `error`. */
26
+ export function retried(value: unknown, error: unknown): string {
27
+ if (!(error instanceof TypeError)) throw error
28
+ try {
29
+ return JSON.stringify(value, bigintAsText)
30
+ } catch {
31
+ // A cycle or a throwing `toJSON` fails this pass too; the first error is the one to report.
32
+ throw error
33
+ }
34
+ }
@@ -4,6 +4,9 @@
4
4
  * A route reads JSON and urlencoded bodies by default and answers `415` to everything else. Wrapping
5
5
  * its body schema opts that one route into multipart: the parts are read under the route's
6
6
  * `bodyLimit`, bounded by count and size, collected into one record, and handed to the schema.
7
+ * The body is parsed here, by the Fetch standard's `multipart/form-data` parser, and not by the
8
+ * runtime's own `formData()`: those disagree on bare line feeds, parts with no name, escaped or
9
+ * percent-encoded names, and a file's default type, so one body was a different form on each runtime.
7
10
  *
8
11
  * import { multipartBody } from "@nifrajs/core/multipart"
9
12
  * import { t } from "@nifrajs/schema"
@@ -73,6 +76,8 @@ const MAX_FILE_NAME_LENGTH = 255
73
76
  const DASH = 45
74
77
  const CR = 13
75
78
  const LF = 10
79
+ const COLON = 58
80
+ const PERCENT = 37
76
81
  const QUOTE = 34
77
82
  const BACKSLASH = 92
78
83
  const SLASH = 47
@@ -82,6 +87,23 @@ const TAB = 9
82
87
 
83
88
  const INVALID = (): ResponseResult => plainError(400, "invalid_multipart")
84
89
 
90
+ const ASCII = new TextEncoder()
91
+ // The Fetch standard reads a disposition only in this exact form, which every browser writes.
92
+ const FORM_DATA_NAME = ASCII.encode('form-data; name="')
93
+ const FILE_NAME = ASCII.encode('; filename="')
94
+ const CONTENT_DISPOSITION = ASCII.encode("content-disposition")
95
+ const CONTENT_TYPE = ASCII.encode("content-type")
96
+ // "UTF-8 decode without BOM": a leading BOM is kept as U+FEFF, not dropped.
97
+ const UTF8 = new TextDecoder("utf-8", { ignoreBOM: true })
98
+ // A short ASCII name or value is read code by code: a decoder call costs more than the bytes.
99
+ const SHORT_TEXT = 64
100
+
101
+ // RFC 9110 `tchar`, by byte value.
102
+ const TCHAR = new Uint8Array(128)
103
+ for (const char of "!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") {
104
+ TCHAR[char.charCodeAt(0)] = 1
105
+ }
106
+
85
107
  function limitOf(value: number | undefined, fallback: number, name: string): number {
86
108
  if (value === undefined) return fallback
87
109
  assertByteLimit(value, `multipartBody: ${name}`)
@@ -150,14 +172,10 @@ function failureTable(pattern: Uint8Array): Uint8Array {
150
172
  return table
151
173
  }
152
174
 
153
- /**
154
- * What a scan for `--boundary` found: how many, how many of them close a part with no content, and
155
- * whether the last one closes the body.
156
- */
175
+ /** What a scan for `--boundary` found: where each one starts, and how many close an empty part. */
157
176
  interface Delimiters {
158
- readonly count: number
177
+ readonly at: readonly number[]
159
178
  readonly empty: number
160
- readonly closed: boolean
161
179
  }
162
180
 
163
181
  /** The most delimiters a scan counts: past either budget the body is refused unparsed. */
@@ -166,11 +184,6 @@ interface DelimiterLimits {
166
184
  readonly empty: number
167
185
  }
168
186
 
169
- /** `end` is the index after the last delimiter, or -1 when there was none. */
170
- function delimiters(bytes: Uint8Array, count: number, empty: number, end: number): Delimiters {
171
- return { count, empty, closed: end !== -1 && bytes[end] === DASH && bytes[end + 1] === DASH }
172
- }
173
-
174
187
  /**
175
188
  * Whether the delimiter at `at` closes a part with no content: the blank line ending the part's
176
189
  * headers is followed at once by the CRLF that opens the delimiter. A part whose own content ends
@@ -196,30 +209,34 @@ function scanFrom(
196
209
  bytes: Uint8Array,
197
210
  pattern: Uint8Array,
198
211
  start: number,
199
- counted: number,
212
+ at: number[],
200
213
  countedEmpty: number,
201
- lastEnd: number,
202
214
  limits: DelimiterLimits,
203
215
  ): Delimiters {
204
216
  const table = failureTable(pattern)
205
217
  const size = pattern.length
206
- let count = counted
207
218
  let empty = countedEmpty
208
- let end = lastEnd
209
219
  for (let i = start, k = 0; i < bytes.length; i++) {
210
220
  const byte = bytes[i]
211
221
  while (k > 0 && byte !== pattern[k]) k = table[k - 1]!
212
222
  if (byte === pattern[k]) k++
213
223
  if (k === size) {
214
- if (count > 0 && closesEmptyPart(bytes, i + 1 - size)) empty++
215
- end = i + 1
216
- if (overLimits(++count, empty, limits)) break
224
+ if (at.length > 0 && closesEmptyPart(bytes, i + 1 - size)) empty++
225
+ at.push(i + 1 - size)
226
+ if (overLimits(at.length, empty, limits)) break
217
227
  k = 0
218
228
  }
219
229
  }
220
- return delimiters(bytes, count, empty, end)
230
+ return { at, empty }
221
231
  }
222
232
 
233
+ declare const Deno: unknown
234
+
235
+ // V8's TypedArray `indexOf` reads one element at a time (~0.6 ms a MiB on Node 26), where Buffer's is
236
+ // native on Node and Bun. Deno's Buffer is a slower polyfill, and Workers have none by default.
237
+ const bufferIndexOf =
238
+ typeof Deno === "undefined" && typeof Buffer === "function" ? Buffer.prototype.indexOf : undefined
239
+
223
240
  /**
224
241
  * Find the non-overlapping occurrences of `pattern` (which starts with a dash), stopping once the
225
242
  * count passes `limit`. Every `--boundary` is counted wherever it sits, which is at least what any
@@ -236,51 +253,199 @@ function scanDelimiters(
236
253
  ): Delimiters {
237
254
  const size = pattern.length
238
255
  const last = bytes.length - size
239
- let count = 0
256
+ const at: number[] = []
240
257
  let empty = 0
241
- let end = -1
242
258
  let work = 0
243
259
  let i = 0
244
260
  while (i <= last) {
245
- i = bytes.indexOf(DASH, i)
261
+ i = bufferIndexOf === undefined ? bytes.indexOf(DASH, i) : bufferIndexOf.call(bytes, DASH, i)
246
262
  if (i === -1 || i > last) break
247
263
  let k = 1
248
264
  while (k < size && bytes[i + k] === pattern[k]) k++
249
265
  if (k === size) {
250
- if (count > 0 && closesEmptyPart(bytes, i)) empty++
266
+ if (at.length > 0 && closesEmptyPart(bytes, i)) empty++
267
+ at.push(i)
251
268
  i += size
252
- end = i
253
- if (overLimits(++count, empty, limits)) break
269
+ if (overLimits(at.length, empty, limits)) break
254
270
  continue
255
271
  }
256
272
  work += k
257
- if (work > bytes.length) return scanFrom(bytes, pattern, i, count, empty, end, limits)
273
+ if (work > bytes.length) return scanFrom(bytes, pattern, i, at, empty, limits)
274
+ i++
275
+ }
276
+ return { at, empty }
277
+ }
278
+
279
+ // What the File constructor keeps of a type: printable ASCII, lower-cased; anything else is "".
280
+ const PRINTABLE = /^[\x20-\x7e]*$/
281
+
282
+ /**
283
+ * `content` as a File whose type is what the standard makes of `type`. Bun's constructor appends
284
+ * `;charset=utf-8` to an exact lower-case `text/plain`, `text/html` or `application/json`, but not
285
+ * to the same type in upper case, which it lower-cases like every runtime.
286
+ */
287
+ function fileOf(content: Uint8Array<ArrayBuffer>, name: string, type: string): File {
288
+ const wanted = PRINTABLE.test(type) ? type.toLowerCase() : ""
289
+ const file = new File([content], name, { type: wanted })
290
+ return file.type === wanted ? file : new File([content], name, { type: wanted.toUpperCase() })
291
+ }
292
+
293
+ /** One part as the parser read it: its field, its file name if it is a file, and its content. */
294
+ interface Part {
295
+ readonly name: string
296
+ readonly filename: string | null
297
+ readonly type: string | null
298
+ readonly start: number
299
+ readonly end: number
300
+ }
301
+
302
+ /** The first index from `from` holding byte `a`, `b` or `c`, or the end of `bytes`. */
303
+ function scanUntil(bytes: Uint8Array, from: number, a: number, b: number, c: number): number {
304
+ let i = from
305
+ while (i < bytes.length) {
306
+ const byte = bytes[i]
307
+ if (byte === a || byte === b || byte === c) break
258
308
  i++
259
309
  }
260
- return delimiters(bytes, count, empty, end)
310
+ return i
261
311
  }
262
312
 
263
- /** True when `value` takes more than `max` bytes as UTF-8. */
264
- function exceedsUtf8(value: string, max: number): boolean {
265
- const length = value.length
266
- // A UTF-16 code unit is one to three UTF-8 bytes, so most values are decided without a walk.
267
- if (length > max) return true
268
- if (length * 3 <= max) return false
269
- let bytes = 0
270
- for (let i = 0; i < length; i++) {
271
- const code = value.charCodeAt(i)
272
- if (code < 0x80) bytes += 1
273
- else if (code < 0x800) bytes += 2
274
- else if (code >= 0xd800 && code <= 0xdbff && i + 1 < length) {
275
- const low = value.charCodeAt(i + 1)
276
- if (low >= 0xdc00 && low <= 0xdfff) {
277
- bytes += 4
278
- i++
279
- } else bytes += 3
280
- } else bytes += 3
281
- if (bytes > max) return true
313
+ function startsWith(bytes: Uint8Array, at: number, prefix: Uint8Array): boolean {
314
+ if (at + prefix.length > bytes.length) return false
315
+ for (let i = 0; i < prefix.length; i++) if (bytes[at + i] !== prefix[i]) return false
316
+ return true
317
+ }
318
+
319
+ /** `bytes[start, end)` as UTF-8 text. */
320
+ function textOf(bytes: Uint8Array, start: number, end: number): string {
321
+ if (end - start <= SHORT_TEXT) {
322
+ let out = ""
323
+ for (let i = start; i < end; i++) {
324
+ const byte = bytes[i]!
325
+ if (byte >= 0x80) return UTF8.decode(bytes.subarray(start, end))
326
+ out += String.fromCharCode(byte)
327
+ }
328
+ return out
329
+ }
330
+ return UTF8.decode(bytes.subarray(start, end))
331
+ }
332
+
333
+ /** Whether `bytes[start, end)` spells `lower`, a lower-case ASCII header name, in any case. */
334
+ function spells(bytes: Uint8Array, start: number, end: number, lower: Uint8Array): boolean {
335
+ if (end - start !== lower.length) return false
336
+ // A CR also folds onto `-`, but no header name holds one.
337
+ for (let i = 0; i < lower.length; i++) if ((bytes[start + i]! | 0x20) !== lower[i]) return false
338
+ return true
339
+ }
340
+
341
+ /** Bytes as the code points of the same values ("isomorphic decode"), for a header's value. */
342
+ function isomorphic(bytes: Uint8Array, start: number, end: number): string {
343
+ let out = ""
344
+ for (let i = start; i < end; i += 4096) {
345
+ out += String.fromCharCode(...bytes.subarray(i, Math.min(end, i + 4096)))
346
+ }
347
+ return out
348
+ }
349
+
350
+ /**
351
+ * A quoted `name` or `filename` from the byte after its opening quote: everything up to the closing
352
+ * one, with `%0A`, `%0D` and `%22` restored. No backslash escapes, as the standard reads it.
353
+ */
354
+ function quotedName(bytes: Uint8Array, from: number): { value: string; next: number } | undefined {
355
+ const close = scanUntil(bytes, from, LF, CR, QUOTE)
356
+ if (bytes[close] !== QUOTE) return undefined
357
+ let value = textOf(bytes, from, close)
358
+ if (bytes.subarray(from, close).includes(PERCENT)) {
359
+ value = value.replaceAll("%0A", "\n").replaceAll("%0D", "\r").replaceAll("%22", '"')
360
+ }
361
+ return { value, next: close + 1 }
362
+ }
363
+
364
+ /**
365
+ * The headers of the part whose first header line starts at `from`, up to the blank line that
366
+ * ends them. Undefined when a line does not parse or the part names no field.
367
+ */
368
+ function partHeaders(
369
+ bytes: Uint8Array,
370
+ from: number,
371
+ ): { name: string; filename: string | null; type: string | null; next: number } | undefined {
372
+ let position = from
373
+ let name: string | null = null
374
+ let filename: string | null = null
375
+ let type: string | null = null
376
+ for (;;) {
377
+ if (bytes[position] === CR && bytes[position + 1] === LF) {
378
+ return name === null ? undefined : { name, filename, type, next: position + 2 }
379
+ }
380
+ const colon = scanUntil(bytes, position, LF, CR, COLON)
381
+ if (bytes[colon] !== COLON) return undefined
382
+ let start = position
383
+ let end = colon
384
+ while (start < end && isBlank(bytes[start]!)) start++
385
+ while (end > start && isBlank(bytes[end - 1]!)) end--
386
+ if (start === end) return undefined
387
+ for (let i = start; i < end; i++) if (TCHAR[bytes[i]!] !== 1) return undefined
388
+ position = colon + 1
389
+ while (isBlank(bytes[position] ?? 0)) position++
390
+ if (spells(bytes, start, end, CONTENT_DISPOSITION)) {
391
+ name = null
392
+ filename = null
393
+ if (!startsWith(bytes, position, FORM_DATA_NAME)) return undefined
394
+ const parsed = quotedName(bytes, position + FORM_DATA_NAME.length)
395
+ if (parsed === undefined) return undefined
396
+ name = parsed.value
397
+ position = parsed.next
398
+ if (startsWith(bytes, position, FILE_NAME)) {
399
+ const file = quotedName(bytes, position + FILE_NAME.length)
400
+ if (file === undefined) return undefined
401
+ filename = file.value
402
+ position = file.next
403
+ }
404
+ } else {
405
+ const stop = scanUntil(bytes, position, LF, CR, LF)
406
+ if (spells(bytes, start, end, CONTENT_TYPE)) {
407
+ let end = stop
408
+ while (end > position && isBlank(bytes[end - 1]!)) end--
409
+ type = isomorphic(bytes, position, end)
410
+ }
411
+ position = stop
412
+ }
413
+ if (bytes[position] !== CR || bytes[position + 1] !== LF) return undefined
414
+ position += 2
415
+ }
416
+ }
417
+
418
+ /**
419
+ * The parts of a body, read as the Fetch standard's `multipart/form-data` parser reads them: the
420
+ * body opens with `--boundary`, every part's content runs to the next CRLF `--boundary`, and the
421
+ * first `--boundary--` closes it. `at` holds every `--boundary` in the body; those that do not start
422
+ * a line are content. Undefined when the body is not such a form.
423
+ */
424
+ function partsOf(bytes: Uint8Array, at: readonly number[], size: number): Part[] | undefined {
425
+ if (at[0] !== 0) return undefined
426
+ const parts: Part[] = []
427
+ let position = 0
428
+ let next = 0
429
+ for (;;) {
430
+ position += size
431
+ if (bytes[position] === DASH && bytes[position + 1] === DASH) return parts
432
+ if (bytes[position] !== CR || bytes[position + 1] !== LF) return undefined
433
+ const head = partHeaders(bytes, position + 2)
434
+ if (head === undefined) return undefined
435
+ position = head.next
436
+ let end: number
437
+ do end = at[++next] ?? -1
438
+ while (end !== -1 && (end - 2 < position || bytes[end - 2] !== CR || bytes[end - 1] !== LF))
439
+ if (end === -1) return undefined
440
+ parts.push({
441
+ name: head.name,
442
+ filename: head.filename,
443
+ type: head.type,
444
+ start: position,
445
+ end: end - 2,
446
+ })
447
+ position = end
282
448
  }
283
- return false
284
449
  }
285
450
 
286
451
  /** C0 controls, DEL, line separators, and the characters that reorder or hide text in a label. */
@@ -326,21 +491,6 @@ function safeFileName(raw: string): string {
326
491
  return name === "" || name === "." || name === ".." ? FALLBACK_FILE_NAME : name
327
492
  }
328
493
 
329
- /** `part` as a `File` called `name`, keeping its bytes, type, and timestamp. */
330
- function renamedFile(part: Blob, name: string): File {
331
- const modified = (part as Partial<File>).lastModified
332
- const options: FilePropertyBag =
333
- modified === undefined ? { type: part.type } : { type: part.type, lastModified: modified }
334
- let file = new File([part], name, options)
335
- // A runtime may hand back the single source part with its own name when nothing else changes;
336
- // a second, empty part forces a new file.
337
- if (file.name !== name) file = new File([part, ""], name, options)
338
- if (file.name !== name || file.size !== part.size) {
339
- throw new Error("multipartBody: this runtime did not apply a file name")
340
- }
341
- return file
342
- }
343
-
344
494
  function createReader(
345
495
  limits: MultipartLimits,
346
496
  inner: SchemaBodyReader | undefined,
@@ -368,65 +518,47 @@ function createReader(
368
518
  if (boundary === null) return INVALID()
369
519
 
370
520
  const read = await readBoundedBytes(source, maxBodyBytes)
371
- if (!read.ok) {
372
- return read.status === 413
373
- ? plainError(413, "payload_too_large")
374
- : plainError(400, "invalid_content_length")
375
- }
521
+ if (!read.ok) return plainError(read.status, read.error)
376
522
  const bytes = read.bytes
377
523
 
378
- // Counted on the raw bytes: the platform parser allocates per part, so a body of many tiny
379
- // parts must be refused before it is parsed, not after.
524
+ // Counted on the raw bytes: the parser allocates per part, so a body of many tiny parts must be
525
+ // refused before it is parsed, not after.
380
526
  const delimiter = new Uint8Array(boundary.length + 2)
381
527
  delimiter[0] = DASH
382
528
  delimiter[1] = DASH
383
529
  for (let i = 0; i < boundary.length; i++) delimiter[i + 2] = boundary.charCodeAt(i)
384
530
  const found = scanDelimiters(bytes, delimiter, delimiterLimits)
385
- if (overLimits(found.count, found.empty, delimiterLimits)) {
531
+ if (overLimits(found.at.length, found.empty, delimiterLimits)) {
386
532
  return plainError(413, "too_many_parts")
387
533
  }
388
- // A body is over when `--boundary--` says so. One that stops short is refused here, because a
389
- // parser that tolerates it hands back the parts it saw as if they were the whole form.
390
- if (!found.closed) return INVALID()
391
-
392
- // The parser is given the boundary this reader extracted, never the request's own header: two
393
- // readings of one header (a repeated or oddly quoted parameter) would let a body be counted
394
- // under one boundary and parsed under another.
395
- const canonical = `${MEDIA_TYPE}; boundary=${TOKEN.test(boundary) ? boundary : `"${boundary}"`}`
396
- // Typed by what is read from it: the global `FormData` and `BodyInit` names do not resolve to
397
- // the same declarations under every lib config.
398
- let form: Iterable<[string, string | Blob]>
399
- try {
400
- form = (await new Response(bytes as ConstructorParameters<typeof Response>[0], {
401
- headers: { "content-type": canonical },
402
- }).formData()) as unknown as Iterable<[string, string | Blob]>
403
- } catch {
404
- return INVALID()
405
- }
534
+ // A body that stops before `--boundary--` is refused with the rest, because a reader that
535
+ // tolerated it would hand back the parts it saw as if they were the whole form.
536
+ const parts = partsOf(bytes, found.at, delimiter.length)
537
+ if (parts === undefined) return INVALID()
406
538
 
407
539
  const record = Object.create(null) as Record<string, MultipartValue>
408
540
  let fields = 0
409
541
  let files = 0
410
- for (const [name, part] of form) {
542
+ for (const part of parts) {
543
+ const name = part.name
411
544
  if (name === "__proto__" && protoPoisoning !== "ignore") {
412
545
  if (protoPoisoning === "reject") return INVALID()
413
546
  continue
414
547
  }
548
+ const size = part.end - part.start
415
549
  let value: string | File
416
- if (typeof part === "string") {
550
+ if (part.filename === null) {
417
551
  if (++fields > maxFields) return plainError(413, "too_many_fields")
418
- if (exceedsUtf8(part, maxFieldBytes)) return plainError(413, "field_too_large")
419
- value = part
552
+ if (size > maxFieldBytes) return plainError(413, "field_too_large")
553
+ value = textOf(bytes, part.start, part.end)
420
554
  } else {
421
- // A file input left empty still submits a part with no bytes. Runtimes disagree on whether
422
- // a named empty file keeps its name, so every empty file is treated as that input.
423
- if (part.size === 0) continue
424
- const rawName = (part as Partial<File>).name
425
- const clientName = typeof rawName === "string" ? rawName : ""
555
+ // A file input left empty still submits a part with no bytes, so an empty file is that input.
556
+ if (size === 0) continue
426
557
  if (++files > maxFiles) return plainError(413, "too_many_files")
427
- if (part.size > maxFileBytes) return plainError(413, "file_too_large")
428
- const name = safeFileName(clientName)
429
- value = part instanceof File && part.name === name ? part : renamedFile(part, name)
558
+ if (size > maxFileBytes) return plainError(413, "file_too_large")
559
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: readBoundedBytes allocates its own ArrayBuffer, never a shared one.
560
+ const content = bytes.subarray(part.start, part.end) as Uint8Array<ArrayBuffer>
561
+ value = fileOf(content, safeFileName(part.filename), part.type ?? "text/plain")
430
562
  }
431
563
  const existing = record[name]
432
564
  if (existing === undefined) record[name] = value
@@ -3,6 +3,8 @@
3
3
  * conversion. Mirrors `respond.ts` but skips the undici `Response` build where the adapter can write a
4
4
  * plain-data render straight to the socket. Imports only runtime-core + respond + the spine types.
5
5
  */
6
+
7
+ import { jsonText } from "./json-text.ts"
6
8
  import {
7
9
  applySetToResponse,
8
10
  applyStaticResponseHeaders,
@@ -88,7 +90,7 @@ export function toNodeOutcome(result: HandlerResult, set: CtxSet): NodeServeOutc
88
90
  return nodeOutcomeFromResponse(applySetToResponse(normalizeBodylessResponse(result), set))
89
91
  }
90
92
  const status = set.status ?? (result === undefined ? 204 : 200)
91
- const body = result === undefined || isBodylessStatus(status) ? null : JSON.stringify(result)
93
+ const body = result === undefined || isBodylessStatus(status) ? null : jsonText(result)
92
94
  return {
93
95
  kind: "json",
94
96
  status,
@@ -109,10 +111,7 @@ export function plainNodeOutcome(plain: PlainRender, set: CtxSet): NodeServeOutc
109
111
  status: plain.status,
110
112
  headers: plainRenderHeaders(plain, set),
111
113
  cookies: set._cookies,
112
- body:
113
- plain.body === undefined || isBodylessStatus(plain.status)
114
- ? null
115
- : JSON.stringify(plain.body),
114
+ body: plain.body === undefined || isBodylessStatus(plain.status) ? null : jsonText(plain.body),
116
115
  }
117
116
  }
118
117
 
@@ -49,12 +49,37 @@ export function guardParsedValue(value: unknown, policy: ProtoPoisoning): unknow
49
49
  */
50
50
  /** Reused walk stack - `sweep` is synchronous and single-threaded, and a thrown rejection can
51
51
  * leave residue, so each entry clears it. Saves one array allocation per swept request. */
52
- const STACK: unknown[] = []
52
+ const STORAGE: unknown[] = []
53
+ // Reuse at most 1,024 logical slots between requests; larger walks use the existing full reset.
54
+ const MAX_REUSED_STACK = 1024
55
+ // JSC keeps native Array.pop on this hot path; a JS cursor lets it inline the stack operations.
56
+ // Select once so V8 keeps its faster native array stack, with one shared security walk below.
57
+ const INDEXED_STACK = typeof Bun !== "undefined"
58
+ const STACK = !INDEXED_STACK
59
+ ? STORAGE
60
+ : {
61
+ length: 0,
62
+ push(value: unknown): void {
63
+ STORAGE[this.length++] = value
64
+ },
65
+ pop(): unknown {
66
+ const index = --this.length
67
+ const value = STORAGE[index]
68
+ STORAGE[index] = undefined
69
+ return value
70
+ },
71
+ }
53
72
 
54
73
  function sweep(root: unknown, policy: "reject" | "strip"): unknown {
55
74
  if (root === null || typeof root !== "object") return root
56
75
  const stack = STACK
57
- stack.length = 0
76
+ // Keep V8's native-array reset: skipping it regresses the deep-chain control. Bun only needs
77
+ // a reset after an unexpected custom-source throw; ordinary pops already cleared every slot.
78
+ if (!INDEXED_STACK) stack.length = 0
79
+ else if (stack.length > 0) {
80
+ stack.length = 0
81
+ STORAGE.length = 0
82
+ }
58
83
  stack.push(root)
59
84
  while (stack.length > 0) {
60
85
  const node = stack.pop() as object
@@ -95,7 +120,11 @@ function sweep(root: unknown, policy: "reject" | "strip"): unknown {
95
120
  // + first-code-unit guards avoid a full string comparison for the overwhelming majority of
96
121
  // fields while preserving exact equality for the two security-sensitive names.
97
122
  if (key.length === 9 && key.charCodeAt(0) === 95 /* _ */ && key === "__proto__") {
98
- if (policy === "reject") throw POISONED
123
+ if (policy === "reject") {
124
+ stack.length = 0
125
+ STORAGE.length = 0
126
+ throw POISONED
127
+ }
99
128
  // biome-ignore lint/complexity/useLiteralKeys: bracket access keeps the guarded key an explicit string, never a prototype walk
100
129
  delete record["__proto__"]
101
130
  continue
@@ -107,7 +136,11 @@ function sweep(root: unknown, policy: "reject" | "strip"): unknown {
107
136
  nested &&
108
137
  Object.hasOwn(value, "prototype")
109
138
  ) {
110
- if (policy === "reject") throw POISONED
139
+ if (policy === "reject") {
140
+ stack.length = 0
141
+ STORAGE.length = 0
142
+ throw POISONED
143
+ }
111
144
  // biome-ignore lint/complexity/useLiteralKeys: bracket access keeps the guarded key an explicit string, never a prototype walk
112
145
  delete record["constructor"]
113
146
  continue
@@ -115,6 +148,8 @@ function sweep(root: unknown, policy: "reject" | "strip"): unknown {
115
148
  if (nested) stack.push(value)
116
149
  }
117
150
  }
151
+ // Bun pops clear their references immediately, so ordinary requests can reuse the empty buffer.
152
+ if (INDEXED_STACK && STORAGE.length > MAX_REUSED_STACK) STORAGE.length = 0
118
153
  return root
119
154
  }
120
155
 
@@ -64,12 +64,11 @@ function setQueryValue(out: Record<string, QueryValue>, rawKey: string, value: s
64
64
  }
65
65
 
66
66
  function queryObjectFallback(search: string): Record<string, QueryValue> {
67
- // Manual iteration instead of Object.fromEntries: repeated keys must promote to arrays
68
- // (fromEntries is last-wins), and __proto__ needs the same own-property guard.
67
+ // Native forEach avoids iterator/pair arrays while preserving repeated-key order and safe keys.
69
68
  const out: Record<string, QueryValue> = Object.create(null) as Record<string, QueryValue>
70
- for (const [key, value] of new URLSearchParams(search)) {
69
+ new URLSearchParams(search).forEach((value, key) => {
71
70
  setQueryValue(out, key, value)
72
- }
71
+ })
73
72
  return out
74
73
  }
75
74
 
@@ -87,10 +86,9 @@ export function queryObjectOf(search: string): Record<string, QueryValue> {
87
86
  const start = search.charCodeAt(0) === 63 /* ? */ ? 1 : 0
88
87
  if (start >= search.length) return EMPTY_QUERY
89
88
 
90
- for (let i = start; i < search.length; i++) {
91
- const c = search.charCodeAt(i)
92
- if (c === 37 /* % */ || c === 43 /* + */) return queryObjectFallback(search)
93
- }
89
+ // Native single-character searches avoid a JS pass over every unencoded value byte.
90
+ if (search.indexOf("+", start) !== -1 || search.indexOf("%", start) !== -1)
91
+ return queryObjectFallback(search)
94
92
 
95
93
  const out: Record<string, QueryValue> = Object.create(null) as Record<string, QueryValue>
96
94
  let pos = start
@@ -191,11 +189,7 @@ export async function readBoundedForm(
191
189
  maxBytes: number,
192
190
  ): Promise<Record<string, QueryValue> | ResponseResult> {
193
191
  const read = await readBoundedBytes(req, maxBytes)
194
- if (!read.ok) {
195
- return read.status === 413
196
- ? plainError(413, "payload_too_large")
197
- : plainError(400, "invalid_content_length")
198
- }
192
+ if (!read.ok) return plainError(read.status, read.error)
199
193
  const out: Record<string, QueryValue> = Object.create(null) as Record<string, QueryValue>
200
194
  // URLSearchParams owns the format's quirks (`+` as space, percent-decoding, empty keys);
201
195
  // it never throws on junk input, so no try/catch is needed here.