@nifrajs/core 3.3.0 → 4.0.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 (331) hide show
  1. package/README.md +26 -0
  2. package/dist/assurance.d.ts +7 -0
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +39 -3
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/capabilities.d.ts +20 -2
  7. package/dist/capabilities.d.ts.map +1 -1
  8. package/dist/capabilities.js +47 -15
  9. package/dist/capabilities.js.map +1 -1
  10. package/dist/diff.d.ts +6 -1
  11. package/dist/diff.d.ts.map +1 -1
  12. package/dist/diff.js +36 -2
  13. package/dist/diff.js.map +1 -1
  14. package/dist/durable-adapters.d.ts.map +1 -1
  15. package/dist/durable-adapters.js +16 -3
  16. package/dist/durable-adapters.js.map +1 -1
  17. package/dist/durable-effect.d.ts.map +1 -1
  18. package/dist/durable-effect.js +6 -3
  19. package/dist/durable-effect.js.map +1 -1
  20. package/dist/edge-kit.d.ts +2 -0
  21. package/dist/edge-kit.d.ts.map +1 -1
  22. package/dist/edge-kit.js +2 -0
  23. package/dist/edge-kit.js.map +1 -1
  24. package/dist/effect-lifecycle.d.ts.map +1 -1
  25. package/dist/effect-lifecycle.js +2 -1
  26. package/dist/effect-lifecycle.js.map +1 -1
  27. package/dist/effect-scope.d.ts.map +1 -1
  28. package/dist/effect-scope.js +1 -1
  29. package/dist/effect-scope.js.map +1 -1
  30. package/dist/errors.d.ts +1 -1
  31. package/dist/errors.d.ts.map +1 -1
  32. package/dist/errors.js.map +1 -1
  33. package/dist/evidence.d.ts +36 -1
  34. package/dist/evidence.d.ts.map +1 -1
  35. package/dist/evidence.js +219 -13
  36. package/dist/evidence.js.map +1 -1
  37. package/dist/idempotency.d.ts +31 -0
  38. package/dist/idempotency.d.ts.map +1 -1
  39. package/dist/idempotency.js +48 -9
  40. package/dist/idempotency.js.map +1 -1
  41. package/dist/index.d.ts +9 -6
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +6 -3
  44. package/dist/index.js.map +1 -1
  45. package/dist/internal/code-unit-order.d.ts +7 -0
  46. package/dist/internal/code-unit-order.d.ts.map +1 -0
  47. package/dist/internal/code-unit-order.js +9 -0
  48. package/dist/internal/code-unit-order.js.map +1 -0
  49. package/dist/internal/durable-saga-protocol.d.ts.map +1 -1
  50. package/dist/internal/durable-saga-protocol.js +2 -0
  51. package/dist/internal/durable-saga-protocol.js.map +1 -1
  52. package/dist/internal/route-catalog.d.ts +6 -6
  53. package/dist/internal/route-catalog.d.ts.map +1 -1
  54. package/dist/internal/route-catalog.js +20 -18
  55. package/dist/internal/route-catalog.js.map +1 -1
  56. package/dist/internal/route-compiler.d.ts +7 -6
  57. package/dist/internal/route-compiler.d.ts.map +1 -1
  58. package/dist/internal/route-compiler.js +36 -4
  59. package/dist/internal/route-compiler.js.map +1 -1
  60. package/dist/internal/route-execution.d.ts +7 -3
  61. package/dist/internal/route-execution.d.ts.map +1 -1
  62. package/dist/internal/route-execution.js.map +1 -1
  63. package/dist/internal/route-lanes.d.ts +1 -0
  64. package/dist/internal/route-lanes.d.ts.map +1 -1
  65. package/dist/internal/route-lanes.js +34 -29
  66. package/dist/internal/route-lanes.js.map +1 -1
  67. package/dist/internal/route-program.d.ts +8 -2
  68. package/dist/internal/route-program.d.ts.map +1 -1
  69. package/dist/internal/route-program.js +222 -20
  70. package/dist/internal/route-program.js.map +1 -1
  71. package/dist/ledger.d.ts.map +1 -1
  72. package/dist/ledger.js +8 -5
  73. package/dist/ledger.js.map +1 -1
  74. package/dist/manifest.d.ts +1 -1
  75. package/dist/manifest.d.ts.map +1 -1
  76. package/dist/manifest.js +52 -46
  77. package/dist/manifest.js.map +1 -1
  78. package/dist/mount.d.ts +36 -0
  79. package/dist/mount.d.ts.map +1 -1
  80. package/dist/mount.js +32 -0
  81. package/dist/mount.js.map +1 -1
  82. package/dist/reflection.d.ts +26 -0
  83. package/dist/reflection.d.ts.map +1 -1
  84. package/dist/reflection.js +65 -1
  85. package/dist/reflection.js.map +1 -1
  86. package/dist/response-observer.d.ts +1 -1
  87. package/dist/response-observer.d.ts.map +1 -1
  88. package/dist/response-observer.js.map +1 -1
  89. package/dist/router/overlap.d.ts +23 -0
  90. package/dist/router/overlap.d.ts.map +1 -0
  91. package/dist/router/overlap.js +224 -0
  92. package/dist/router/overlap.js.map +1 -0
  93. package/dist/router/pattern.d.ts +117 -2
  94. package/dist/router/pattern.d.ts.map +1 -1
  95. package/dist/router/pattern.js +283 -23
  96. package/dist/router/pattern.js.map +1 -1
  97. package/dist/router/router.d.ts +18 -4
  98. package/dist/router/router.d.ts.map +1 -1
  99. package/dist/router/router.js +51 -44
  100. package/dist/router/router.js.map +1 -1
  101. package/dist/server/auth.d.ts +53 -0
  102. package/dist/server/auth.d.ts.map +1 -0
  103. package/dist/server/auth.js +9 -0
  104. package/dist/server/auth.js.map +1 -0
  105. package/dist/server/bare-error-lane.d.ts +12 -7
  106. package/dist/server/bare-error-lane.d.ts.map +1 -1
  107. package/dist/server/bare-error-lane.js +13 -11
  108. package/dist/server/bare-error-lane.js.map +1 -1
  109. package/dist/server/body-lane.d.ts +20 -2
  110. package/dist/server/body-lane.d.ts.map +1 -1
  111. package/dist/server/body-lane.js +26 -7
  112. package/dist/server/body-lane.js.map +1 -1
  113. package/dist/server/body-parser.d.ts +68 -0
  114. package/dist/server/body-parser.d.ts.map +1 -0
  115. package/dist/server/body-parser.js +172 -0
  116. package/dist/server/body-parser.js.map +1 -0
  117. package/dist/server/body.d.ts.map +1 -1
  118. package/dist/server/body.js +62 -7
  119. package/dist/server/body.js.map +1 -1
  120. package/dist/server/client-ip.d.ts +8 -0
  121. package/dist/server/client-ip.d.ts.map +1 -1
  122. package/dist/server/client-ip.js +8 -0
  123. package/dist/server/client-ip.js.map +1 -1
  124. package/dist/server/context.d.ts +71 -6
  125. package/dist/server/context.d.ts.map +1 -1
  126. package/dist/server/contract.d.ts +15 -5
  127. package/dist/server/contract.d.ts.map +1 -1
  128. package/dist/server/contract.js +25 -4
  129. package/dist/server/contract.js.map +1 -1
  130. package/dist/server/cookies.d.ts +4 -1
  131. package/dist/server/cookies.d.ts.map +1 -1
  132. package/dist/server/cookies.js +40 -2
  133. package/dist/server/cookies.js.map +1 -1
  134. package/dist/server/core-copies.d.ts +2 -0
  135. package/dist/server/core-copies.d.ts.map +1 -0
  136. package/dist/server/core-copies.js +20 -0
  137. package/dist/server/core-copies.js.map +1 -0
  138. package/dist/server/edge.d.ts.map +1 -1
  139. package/dist/server/edge.js +2 -0
  140. package/dist/server/edge.js.map +1 -1
  141. package/dist/server/http.d.ts +11 -0
  142. package/dist/server/http.d.ts.map +1 -1
  143. package/dist/server/http.js +36 -0
  144. package/dist/server/http.js.map +1 -1
  145. package/dist/server/idempotency-lane.d.ts +6 -0
  146. package/dist/server/idempotency-lane.d.ts.map +1 -1
  147. package/dist/server/idempotency-lane.js +130 -73
  148. package/dist/server/idempotency-lane.js.map +1 -1
  149. package/dist/server/install.d.ts +4 -0
  150. package/dist/server/install.d.ts.map +1 -1
  151. package/dist/server/install.js +4 -0
  152. package/dist/server/install.js.map +1 -1
  153. package/dist/server/methods.d.ts +45 -0
  154. package/dist/server/methods.d.ts.map +1 -0
  155. package/dist/server/methods.js +37 -0
  156. package/dist/server/methods.js.map +1 -0
  157. package/dist/server/multipart.d.ts +63 -0
  158. package/dist/server/multipart.d.ts.map +1 -0
  159. package/dist/server/multipart.js +374 -0
  160. package/dist/server/multipart.js.map +1 -0
  161. package/dist/server/node-outcome-hook.d.ts +20 -0
  162. package/dist/server/node-outcome-hook.d.ts.map +1 -1
  163. package/dist/server/node-outcome-hook.js +15 -6
  164. package/dist/server/node-outcome-hook.js.map +1 -1
  165. package/dist/server/not-found-answer.d.ts +40 -0
  166. package/dist/server/not-found-answer.d.ts.map +1 -0
  167. package/dist/server/not-found-answer.js +56 -0
  168. package/dist/server/not-found-answer.js.map +1 -0
  169. package/dist/server/not-found.d.ts +23 -0
  170. package/dist/server/not-found.d.ts.map +1 -0
  171. package/dist/server/not-found.js +81 -0
  172. package/dist/server/not-found.js.map +1 -0
  173. package/dist/server/plugin.d.ts +26 -1
  174. package/dist/server/plugin.d.ts.map +1 -1
  175. package/dist/server/plugin.js.map +1 -1
  176. package/dist/server/proto-guard.d.ts +18 -5
  177. package/dist/server/proto-guard.d.ts.map +1 -1
  178. package/dist/server/proto-guard.js +114 -9
  179. package/dist/server/proto-guard.js.map +1 -1
  180. package/dist/server/query.d.ts +7 -0
  181. package/dist/server/query.d.ts.map +1 -1
  182. package/dist/server/query.js +47 -0
  183. package/dist/server/query.js.map +1 -1
  184. package/dist/server/registry.d.ts +13 -2
  185. package/dist/server/registry.d.ts.map +1 -1
  186. package/dist/server/request-context.d.ts +11 -0
  187. package/dist/server/request-context.d.ts.map +1 -1
  188. package/dist/server/request-context.js +33 -19
  189. package/dist/server/request-context.js.map +1 -1
  190. package/dist/server/request-lineage.d.ts +8 -0
  191. package/dist/server/request-lineage.d.ts.map +1 -0
  192. package/dist/server/request-lineage.js +14 -0
  193. package/dist/server/request-lineage.js.map +1 -0
  194. package/dist/server/request-target.d.ts +31 -0
  195. package/dist/server/request-target.d.ts.map +1 -0
  196. package/dist/server/request-target.js +44 -0
  197. package/dist/server/request-target.js.map +1 -0
  198. package/dist/server/respond.d.ts +1 -1
  199. package/dist/server/respond.d.ts.map +1 -1
  200. package/dist/server/respond.js +16 -4
  201. package/dist/server/respond.js.map +1 -1
  202. package/dist/server/response-contract-lane.d.ts +19 -1
  203. package/dist/server/response-contract-lane.d.ts.map +1 -1
  204. package/dist/server/response-contract-lane.js +59 -14
  205. package/dist/server/response-contract-lane.js.map +1 -1
  206. package/dist/server/response-observer-runtime.d.ts +9 -5
  207. package/dist/server/response-observer-runtime.d.ts.map +1 -1
  208. package/dist/server/response-observer-runtime.js +10 -6
  209. package/dist/server/response-observer-runtime.js.map +1 -1
  210. package/dist/server/runtime-core.d.ts +2 -0
  211. package/dist/server/runtime-core.d.ts.map +1 -1
  212. package/dist/server/runtime-core.js.map +1 -1
  213. package/dist/server/server-types.d.ts +55 -3
  214. package/dist/server/server-types.d.ts.map +1 -1
  215. package/dist/server/server.d.ts +194 -19
  216. package/dist/server/server.d.ts.map +1 -1
  217. package/dist/server/server.js +1342 -274
  218. package/dist/server/server.js.map +1 -1
  219. package/dist/server/sse.d.ts.map +1 -1
  220. package/dist/server/sse.js +10 -7
  221. package/dist/server/sse.js.map +1 -1
  222. package/dist/server/static-headers.d.ts +3 -0
  223. package/dist/server/static-headers.d.ts.map +1 -1
  224. package/dist/server/static-headers.js +19 -1
  225. package/dist/server/static-headers.js.map +1 -1
  226. package/dist/server/transport-lane.d.ts +8 -0
  227. package/dist/server/transport-lane.d.ts.map +1 -1
  228. package/dist/server/transport-lane.js +9 -8
  229. package/dist/server/transport-lane.js.map +1 -1
  230. package/dist/server/validation.d.ts.map +1 -1
  231. package/dist/server/validation.js +5 -1
  232. package/dist/server/validation.js.map +1 -1
  233. package/dist/server/websocket.d.ts +10 -3
  234. package/dist/server/websocket.d.ts.map +1 -1
  235. package/dist/server/websocket.js +7 -4
  236. package/dist/server/websocket.js.map +1 -1
  237. package/dist/server/ws-bun.d.ts +2 -0
  238. package/dist/server/ws-bun.d.ts.map +1 -1
  239. package/dist/server/ws-bun.js +6 -3
  240. package/dist/server/ws-bun.js.map +1 -1
  241. package/dist/server/ws-hook.d.ts +2 -1
  242. package/dist/server/ws-hook.d.ts.map +1 -1
  243. package/dist/server.d.ts +8 -5
  244. package/dist/server.d.ts.map +1 -1
  245. package/dist/server.js +5 -2
  246. package/dist/server.js.map +1 -1
  247. package/dist/single-copy-register.js +5 -0
  248. package/dist/single-copy-register.js.map +1 -1
  249. package/dist/single-copy.d.ts +46 -3
  250. package/dist/single-copy.d.ts.map +1 -1
  251. package/dist/single-copy.js +125 -12
  252. package/dist/single-copy.js.map +1 -1
  253. package/dist/tool-contract.d.ts +23 -0
  254. package/dist/tool-contract.d.ts.map +1 -1
  255. package/dist/tool-contract.js +37 -3
  256. package/dist/tool-contract.js.map +1 -1
  257. package/dist/transport-codec.d.ts.map +1 -1
  258. package/dist/transport-codec.js +2 -1
  259. package/dist/transport-codec.js.map +1 -1
  260. package/package.json +27 -2
  261. package/src/assurance.ts +52 -2
  262. package/src/capabilities.ts +81 -22
  263. package/src/diff.ts +54 -7
  264. package/src/durable-adapters.ts +20 -3
  265. package/src/durable-effect.ts +10 -3
  266. package/src/edge-kit.ts +7 -0
  267. package/src/effect-lifecycle.ts +5 -1
  268. package/src/effect-scope.ts +2 -1
  269. package/src/errors.ts +3 -0
  270. package/src/evidence.ts +302 -12
  271. package/src/idempotency.ts +80 -9
  272. package/src/index.ts +41 -3
  273. package/src/internal/code-unit-order.ts +8 -0
  274. package/src/internal/durable-saga-protocol.ts +2 -0
  275. package/src/internal/route-catalog.ts +19 -18
  276. package/src/internal/route-compiler.ts +62 -13
  277. package/src/internal/route-execution.ts +13 -3
  278. package/src/internal/route-lanes.ts +37 -28
  279. package/src/internal/route-program.ts +434 -20
  280. package/src/ledger.ts +11 -5
  281. package/src/manifest.ts +52 -46
  282. package/src/mount.ts +62 -0
  283. package/src/reflection.ts +84 -1
  284. package/src/response-observer.ts +1 -0
  285. package/src/router/overlap.ts +258 -0
  286. package/src/router/pattern.ts +349 -25
  287. package/src/router/router.ts +76 -51
  288. package/src/server/auth.ts +75 -0
  289. package/src/server/bare-error-lane.ts +12 -11
  290. package/src/server/body-lane.ts +46 -6
  291. package/src/server/body-parser.ts +241 -0
  292. package/src/server/body.ts +82 -9
  293. package/src/server/client-ip.ts +9 -0
  294. package/src/server/context.ts +219 -12
  295. package/src/server/contract.ts +54 -9
  296. package/src/server/cookies.ts +34 -2
  297. package/src/server/core-copies.ts +21 -0
  298. package/src/server/edge.ts +2 -0
  299. package/src/server/http.ts +34 -0
  300. package/src/server/idempotency-lane.ts +147 -72
  301. package/src/server/install.ts +6 -0
  302. package/src/server/methods.ts +143 -0
  303. package/src/server/multipart.ts +424 -0
  304. package/src/server/node-outcome-hook.ts +40 -7
  305. package/src/server/not-found-answer.ts +109 -0
  306. package/src/server/not-found.ts +121 -0
  307. package/src/server/plugin.ts +45 -1
  308. package/src/server/proto-guard.ts +128 -9
  309. package/src/server/query.ts +49 -0
  310. package/src/server/registry.ts +17 -2
  311. package/src/server/request-context.ts +44 -16
  312. package/src/server/request-lineage.ts +15 -0
  313. package/src/server/request-target.ts +46 -0
  314. package/src/server/respond.ts +14 -3
  315. package/src/server/response-contract-lane.ts +97 -16
  316. package/src/server/response-observer-runtime.ts +37 -8
  317. package/src/server/runtime-core.ts +2 -0
  318. package/src/server/server-types.ts +61 -2
  319. package/src/server/server.ts +1798 -355
  320. package/src/server/sse.ts +10 -8
  321. package/src/server/static-headers.ts +22 -1
  322. package/src/server/transport-lane.ts +17 -9
  323. package/src/server/validation.ts +7 -1
  324. package/src/server/websocket.ts +23 -9
  325. package/src/server/ws-bun.ts +8 -3
  326. package/src/server/ws-hook.ts +2 -1
  327. package/src/server.ts +39 -2
  328. package/src/single-copy-register.ts +5 -0
  329. package/src/single-copy.ts +160 -12
  330. package/src/tool-contract.ts +66 -3
  331. package/src/transport-codec.ts +2 -1
@@ -0,0 +1,424 @@
1
+ /**
2
+ * Typed `multipart/form-data` request bodies - `multipartBody(schema, limits?)`.
3
+ *
4
+ * A route reads JSON and urlencoded bodies by default and answers `415` to everything else. Wrapping
5
+ * its body schema opts that one route into multipart: the parts are read under the route's
6
+ * `bodyLimit`, bounded by count and size, collected into one record, and handed to the schema.
7
+ *
8
+ * import { multipartBody } from "@nifrajs/core/multipart"
9
+ * import { t } from "@nifrajs/schema"
10
+ *
11
+ * app.post(
12
+ * "/avatars",
13
+ * {
14
+ * body: multipartBody(
15
+ * t.form({ avatar: t.file({ maxBytes: 2_000_000, accept: ["image/png"] }), alt: t.string() }),
16
+ * ),
17
+ * bodyLimit: 2_500_000,
18
+ * },
19
+ * (c) => store(c.body.avatar),
20
+ * )
21
+ *
22
+ * The schema sees a null-prototype record: a text part is a string, a file part is a `File`, and a
23
+ * name that repeats becomes an array in part order. A file part with no bytes is left out: that is
24
+ * what an empty file input submits, so an optional file field validates as absent.
25
+ *
26
+ * What the reader guarantees before the schema runs:
27
+ * - the body is at most `bodyLimit` bytes, measured on the bytes delivered and not on the header
28
+ * - at most `maxFields + maxFiles` parts, counted on the raw bytes before anything is parsed
29
+ * - one boundary, taken from the header by one strict grammar and handed to the parser as-is
30
+ * - every text value is at most `maxFieldBytes` and every file at most `maxFileBytes`
31
+ * - a file name is a bare name: no directory, no control or bidirectional-override characters
32
+ * - a `__proto__` part follows the app's prototype-poisoning policy
33
+ *
34
+ * What it does not: a file's `name` and `type` are still the client's claim. Check content with a
35
+ * schema that reads the bytes, and store uploads under a key you generate.
36
+ *
37
+ * The whole body is buffered, so a request costs about twice its size in memory while it is parsed.
38
+ * Send large uploads straight to storage with a presigned URL instead of raising `bodyLimit`.
39
+ */
40
+ import type { StandardSchemaV1 } from "../schema/standard.ts"
41
+ import { assertByteLimit, readBoundedBytes, UNLIMITED_BODY_BYTES } from "./body.ts"
42
+ import { SCHEMA_BODY_READER, type SchemaBodyReader } from "./body-lane.ts"
43
+ import { plainError } from "./http.ts"
44
+ import type { ResponseResult } from "./runtime-core.ts"
45
+
46
+ /** Per-route bounds on a multipart body. The total size is the route's `bodyLimit`. */
47
+ export interface MultipartLimits {
48
+ /** Most text parts a request may carry. Default `100`. */
49
+ readonly maxFields?: number
50
+ /** Most file parts a request may carry. Empty files are not counted. Default `10`. */
51
+ readonly maxFiles?: number
52
+ /** Largest text value, in UTF-8 bytes. Default `65536`. */
53
+ readonly maxFieldBytes?: number
54
+ /** Largest single file, in bytes. Defaults to no bound beyond the route's `bodyLimit`. */
55
+ readonly maxFileBytes?: number
56
+ }
57
+
58
+ /** A value of the record handed to the schema: one part, or every part that shared the name. */
59
+ export type MultipartValue = string | File | Array<string | File>
60
+
61
+ const DEFAULT_MAX_FIELDS = 100
62
+ const DEFAULT_MAX_FILES = 10
63
+ const DEFAULT_MAX_FIELD_BYTES = 65_536
64
+
65
+ const MEDIA_TYPE = "multipart/form-data"
66
+ // RFC 2046 `bchars` without the space: what a boundary may be made of.
67
+ const BOUNDARY = /^[0-9A-Za-z'()+_,\-./:=?]{1,70}$/
68
+ // RFC 9110 `token`: a parameter name, and a value that needs no quoting.
69
+ const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
70
+ const FALLBACK_FILE_NAME = "file"
71
+ const MAX_FILE_NAME_LENGTH = 255
72
+
73
+ const DASH = 45
74
+ const QUOTE = 34
75
+ const BACKSLASH = 92
76
+ const SLASH = 47
77
+ const SEMICOLON = 59
78
+ const SPACE = 32
79
+ const TAB = 9
80
+
81
+ const INVALID = (): ResponseResult => plainError(400, "invalid_multipart")
82
+
83
+ function limitOf(value: number | undefined, fallback: number, name: string): number {
84
+ if (value === undefined) return fallback
85
+ assertByteLimit(value, `multipartBody: ${name}`)
86
+ return value
87
+ }
88
+
89
+ /**
90
+ * The boundary of a `multipart/form-data` content type. `undefined` when the media type is another
91
+ * one, `null` when it is multipart but its parameters are malformed, repeat `boundary`, or carry a
92
+ * boundary outside the RFC 2046 alphabet.
93
+ */
94
+ function boundaryOf(contentType: string): string | null | undefined {
95
+ const semi = contentType.indexOf(";")
96
+ const essence = (semi === -1 ? contentType : contentType.slice(0, semi)).trim().toLowerCase()
97
+ if (essence !== MEDIA_TYPE) return undefined
98
+ if (semi === -1) return null
99
+
100
+ const end = contentType.length
101
+ let boundary: string | undefined
102
+ let i = semi
103
+ while (i < end) {
104
+ i++ // past the `;`
105
+ while (i < end && isBlank(contentType.charCodeAt(i))) i++
106
+ if (i >= end) break
107
+ const eq = contentType.indexOf("=", i)
108
+ if (eq === -1) return null
109
+ const name = contentType.slice(i, eq)
110
+ if (!TOKEN.test(name)) return null
111
+ i = eq + 1
112
+ let value: string
113
+ if (contentType.charCodeAt(i) === QUOTE) {
114
+ let close = i + 1
115
+ while (close < end && contentType.charCodeAt(close) !== QUOTE) {
116
+ close += contentType.charCodeAt(close) === BACKSLASH ? 2 : 1
117
+ }
118
+ if (close >= end) return null
119
+ // Kept raw: an escaped character is outside the boundary alphabet, so it fails the test below.
120
+ value = contentType.slice(i + 1, close)
121
+ i = close + 1
122
+ while (i < end && isBlank(contentType.charCodeAt(i))) i++
123
+ if (i < end && contentType.charCodeAt(i) !== SEMICOLON) return null
124
+ } else {
125
+ const next = contentType.indexOf(";", i)
126
+ const stop = next === -1 ? end : next
127
+ value = contentType.slice(i, stop).trimEnd()
128
+ i = stop
129
+ }
130
+ if (name.toLowerCase() !== "boundary") continue
131
+ if (boundary !== undefined || !BOUNDARY.test(value)) return null
132
+ boundary = value
133
+ }
134
+ return boundary ?? null
135
+ }
136
+
137
+ function isBlank(code: number): boolean {
138
+ return code === SPACE || code === TAB
139
+ }
140
+
141
+ function failureTable(pattern: Uint8Array): Uint8Array {
142
+ const table = new Uint8Array(pattern.length)
143
+ for (let i = 1, k = 0; i < pattern.length; i++) {
144
+ while (k > 0 && pattern[i] !== pattern[k]) k = table[k - 1]!
145
+ if (pattern[i] === pattern[k]) k++
146
+ table[i] = k
147
+ }
148
+ return table
149
+ }
150
+
151
+ /** What a scan for `--boundary` found: how many, and whether the last one closes the body. */
152
+ interface Delimiters {
153
+ readonly count: number
154
+ readonly closed: boolean
155
+ }
156
+
157
+ /** `end` is the index after the last delimiter, or -1 when there was none. */
158
+ function delimiters(bytes: Uint8Array, count: number, end: number): Delimiters {
159
+ return { count, closed: end !== -1 && bytes[end] === DASH && bytes[end + 1] === DASH }
160
+ }
161
+
162
+ /** Knuth-Morris-Pratt from `start`: linear in the input whatever the pattern repeats. */
163
+ function scanFrom(
164
+ bytes: Uint8Array,
165
+ pattern: Uint8Array,
166
+ start: number,
167
+ counted: number,
168
+ lastEnd: number,
169
+ limit: number,
170
+ ): Delimiters {
171
+ const table = failureTable(pattern)
172
+ const size = pattern.length
173
+ let count = counted
174
+ let end = lastEnd
175
+ for (let i = start, k = 0; i < bytes.length; i++) {
176
+ const byte = bytes[i]
177
+ while (k > 0 && byte !== pattern[k]) k = table[k - 1]!
178
+ if (byte === pattern[k]) k++
179
+ if (k === size) {
180
+ end = i + 1
181
+ if (++count > limit) break
182
+ k = 0
183
+ }
184
+ }
185
+ return delimiters(bytes, count, end)
186
+ }
187
+
188
+ /**
189
+ * Find the non-overlapping occurrences of `pattern` (which starts with a dash), stopping once the
190
+ * count passes `limit`. Every `--boundary` is counted wherever it sits, which is at least what any
191
+ * parser can treat as a delimiter.
192
+ *
193
+ * The native search for the next dash skips binary content at memory speed. A body built to make
194
+ * the verify loop re-read the same bytes (dashes against a boundary of dashes) spends a budget of
195
+ * one pass over the input, then the scan continues with the linear matcher.
196
+ */
197
+ function scanDelimiters(bytes: Uint8Array, pattern: Uint8Array, limit: number): Delimiters {
198
+ const size = pattern.length
199
+ const last = bytes.length - size
200
+ let count = 0
201
+ let end = -1
202
+ let work = 0
203
+ let i = 0
204
+ while (i <= last) {
205
+ i = bytes.indexOf(DASH, i)
206
+ if (i === -1 || i > last) break
207
+ let k = 1
208
+ while (k < size && bytes[i + k] === pattern[k]) k++
209
+ if (k === size) {
210
+ i += size
211
+ end = i
212
+ if (++count > limit) break
213
+ continue
214
+ }
215
+ work += k
216
+ if (work > bytes.length) return scanFrom(bytes, pattern, i, count, end, limit)
217
+ i++
218
+ }
219
+ return delimiters(bytes, count, end)
220
+ }
221
+
222
+ /** True when `value` takes more than `max` bytes as UTF-8. */
223
+ function exceedsUtf8(value: string, max: number): boolean {
224
+ const length = value.length
225
+ // A UTF-16 code unit is one to three UTF-8 bytes, so most values are decided without a walk.
226
+ if (length > max) return true
227
+ if (length * 3 <= max) return false
228
+ let bytes = 0
229
+ for (let i = 0; i < length; i++) {
230
+ const code = value.charCodeAt(i)
231
+ if (code < 0x80) bytes += 1
232
+ else if (code < 0x800) bytes += 2
233
+ else if (code >= 0xd800 && code <= 0xdbff && i + 1 < length) {
234
+ const low = value.charCodeAt(i + 1)
235
+ if (low >= 0xdc00 && low <= 0xdfff) {
236
+ bytes += 4
237
+ i++
238
+ } else bytes += 3
239
+ } else bytes += 3
240
+ if (bytes > max) return true
241
+ }
242
+ return false
243
+ }
244
+
245
+ /** C0 controls, DEL, line separators, and the characters that reorder or hide text in a label. */
246
+ function isUnsafeNameCode(code: number): boolean {
247
+ return (
248
+ code < 0x20 ||
249
+ code === 0x7f ||
250
+ code === 0x200e ||
251
+ code === 0x200f ||
252
+ (code >= 0x2028 && code <= 0x202e) ||
253
+ (code >= 0x2066 && code <= 0x2069)
254
+ )
255
+ }
256
+
257
+ /**
258
+ * A client file name reduced to a bare name: the text after the last `/` or `\`, without control
259
+ * and bidirectional characters, at most 255 code units. A name with nothing left is `"file"`.
260
+ */
261
+ function safeFileName(raw: string): string {
262
+ let start = 0
263
+ let dirty = false
264
+ for (let i = 0; i < raw.length; i++) {
265
+ const code = raw.charCodeAt(i)
266
+ if (code === SLASH || code === BACKSLASH) {
267
+ start = i + 1
268
+ dirty = false
269
+ } else if (isUnsafeNameCode(code)) dirty = true
270
+ }
271
+ let name = raw
272
+ if (dirty) {
273
+ name = ""
274
+ for (let i = start; i < raw.length; i++) {
275
+ if (!isUnsafeNameCode(raw.charCodeAt(i))) name += raw[i]
276
+ }
277
+ } else if (start > 0) name = raw.slice(start)
278
+ name = name.trim()
279
+ if (name.length > MAX_FILE_NAME_LENGTH) {
280
+ const lastKept = name.charCodeAt(MAX_FILE_NAME_LENGTH - 1)
281
+ // Never end on the first half of a surrogate pair.
282
+ const cut = lastKept >= 0xd800 && lastKept <= 0xdbff ? 1 : 0
283
+ name = name.slice(0, MAX_FILE_NAME_LENGTH - cut).trimEnd()
284
+ }
285
+ return name === "" || name === "." || name === ".." ? FALLBACK_FILE_NAME : name
286
+ }
287
+
288
+ /** `part` as a `File` called `name`, keeping its bytes, type, and timestamp. */
289
+ function renamedFile(part: Blob, name: string): File {
290
+ const modified = (part as Partial<File>).lastModified
291
+ const options: FilePropertyBag =
292
+ modified === undefined ? { type: part.type } : { type: part.type, lastModified: modified }
293
+ let file = new File([part], name, options)
294
+ // A runtime may hand back the single source part with its own name when nothing else changes;
295
+ // a second, empty part forces a new file.
296
+ if (file.name !== name) file = new File([part, ""], name, options)
297
+ if (file.name !== name || file.size !== part.size) {
298
+ throw new Error("multipartBody: this runtime did not apply a file name")
299
+ }
300
+ return file
301
+ }
302
+
303
+ function createReader(
304
+ limits: MultipartLimits,
305
+ inner: SchemaBodyReader | undefined,
306
+ ): SchemaBodyReader {
307
+ const maxFields = limitOf(limits.maxFields, DEFAULT_MAX_FIELDS, "maxFields")
308
+ const maxFiles = limitOf(limits.maxFiles, DEFAULT_MAX_FILES, "maxFiles")
309
+ const maxFieldBytes = limitOf(limits.maxFieldBytes, DEFAULT_MAX_FIELD_BYTES, "maxFieldBytes")
310
+ const maxFileBytes = limitOf(limits.maxFileBytes, UNLIMITED_BODY_BYTES, "maxFileBytes")
311
+ // Every part sits between two delimiters, and the last delimiter closes the body.
312
+ const maxDelimiters = maxFields + maxFiles + 1
313
+
314
+ const read: SchemaBodyReader = async (source, contentType, maxBodyBytes, protoPoisoning) => {
315
+ const boundary = boundaryOf(contentType)
316
+ if (boundary === undefined) {
317
+ // Not a multipart body: a reader the schema already carried for another media type takes it.
318
+ return inner === undefined
319
+ ? plainError(415, "unsupported_media_type")
320
+ : inner(source, contentType, maxBodyBytes, protoPoisoning)
321
+ }
322
+ if (boundary === null) return INVALID()
323
+
324
+ const read = await readBoundedBytes(source, maxBodyBytes)
325
+ if (!read.ok) {
326
+ return read.status === 413
327
+ ? plainError(413, "payload_too_large")
328
+ : plainError(400, "invalid_content_length")
329
+ }
330
+ const bytes = read.bytes
331
+
332
+ // Counted on the raw bytes: the platform parser allocates per part, so a body of many tiny
333
+ // parts must be refused before it is parsed, not after.
334
+ const delimiter = new Uint8Array(boundary.length + 2)
335
+ delimiter[0] = DASH
336
+ delimiter[1] = DASH
337
+ for (let i = 0; i < boundary.length; i++) delimiter[i + 2] = boundary.charCodeAt(i)
338
+ const found = scanDelimiters(bytes, delimiter, maxDelimiters)
339
+ if (found.count > maxDelimiters) return plainError(413, "too_many_parts")
340
+ // A body is over when `--boundary--` says so. One that stops short is refused here, because a
341
+ // parser that tolerates it hands back the parts it saw as if they were the whole form.
342
+ if (!found.closed) return INVALID()
343
+
344
+ // The parser is given the boundary this reader extracted, never the request's own header: two
345
+ // readings of one header (a repeated or oddly quoted parameter) would let a body be counted
346
+ // under one boundary and parsed under another.
347
+ const canonical = `${MEDIA_TYPE}; boundary=${TOKEN.test(boundary) ? boundary : `"${boundary}"`}`
348
+ // Typed by what is read from it: the global `FormData` and `BodyInit` names do not resolve to
349
+ // the same declarations under every lib config.
350
+ let form: Iterable<[string, string | Blob]>
351
+ try {
352
+ form = (await new Response(bytes as ConstructorParameters<typeof Response>[0], {
353
+ headers: { "content-type": canonical },
354
+ }).formData()) as unknown as Iterable<[string, string | Blob]>
355
+ } catch {
356
+ return INVALID()
357
+ }
358
+
359
+ const record = Object.create(null) as Record<string, MultipartValue>
360
+ let fields = 0
361
+ let files = 0
362
+ for (const [name, part] of form) {
363
+ if (name === "__proto__" && protoPoisoning !== "ignore") {
364
+ if (protoPoisoning === "reject") return INVALID()
365
+ continue
366
+ }
367
+ let value: string | File
368
+ if (typeof part === "string") {
369
+ if (++fields > maxFields) return plainError(413, "too_many_fields")
370
+ if (exceedsUtf8(part, maxFieldBytes)) return plainError(413, "field_too_large")
371
+ value = part
372
+ } else {
373
+ // A file input left empty still submits a part with no bytes. Runtimes disagree on whether
374
+ // a named empty file keeps its name, so every empty file is treated as that input.
375
+ if (part.size === 0) continue
376
+ const rawName = (part as Partial<File>).name
377
+ const clientName = typeof rawName === "string" ? rawName : ""
378
+ if (++files > maxFiles) return plainError(413, "too_many_files")
379
+ if (part.size > maxFileBytes) return plainError(413, "file_too_large")
380
+ const name = safeFileName(clientName)
381
+ value = part instanceof File && part.name === name ? part : renamedFile(part, name)
382
+ }
383
+ const existing = record[name]
384
+ if (existing === undefined) record[name] = value
385
+ else if (Array.isArray(existing)) existing.push(value)
386
+ else record[name] = [existing, value]
387
+ }
388
+ return record
389
+ }
390
+ const mediaTypes = inner?.mediaTypes
391
+ return mediaTypes === undefined
392
+ ? read
393
+ : Object.defineProperty(read, "mediaTypes", { value: mediaTypes })
394
+ }
395
+
396
+ /**
397
+ * Opt a route's body schema into `multipart/form-data`. Returns a copy of `schema` that also reads
398
+ * multipart bodies; the schema itself is not changed, and JSON and urlencoded bodies still reach it.
399
+ * Opting in a schema that already was replaces its limits, and a schema that reads another media
400
+ * type through a parser of its own keeps reading it.
401
+ *
402
+ * Throws a `RangeError` for a limit that is not a non-negative safe integer.
403
+ */
404
+ export function multipartBody<Schema extends StandardSchemaV1>(
405
+ schema: Schema,
406
+ limits: MultipartLimits = {},
407
+ ): Schema {
408
+ const carried = (schema as { readonly [SCHEMA_BODY_READER]?: SchemaBodyReader })[
409
+ SCHEMA_BODY_READER
410
+ ]
411
+ const reader: PropertyDescriptor = {
412
+ value: createReader(limits, typeof carried === "function" ? carried : undefined),
413
+ }
414
+ if (typeof schema === "function") {
415
+ // A callable schema cannot be copied; an object that delegates to it reads the same.
416
+ return Object.defineProperty(Object.create(schema), SCHEMA_BODY_READER, reader) as Schema
417
+ }
418
+ // The reader goes in with the copied properties rather than after them: a schema that was already
419
+ // opted in carries a reader that cannot be redefined, and the limits given here replace its own.
420
+ return Object.create(Object.getPrototypeOf(schema), {
421
+ ...Object.getOwnPropertyDescriptors(schema),
422
+ [SCHEMA_BODY_READER]: reader,
423
+ }) as Schema
424
+ }
@@ -66,6 +66,8 @@ export interface NodeResponseContext {
66
66
  /** Mutable ONLY through a body hook's replacement object (e.g. an ETag 304); header hooks read. */
67
67
  status: number
68
68
  headers: Record<string, string | readonly string[]> | undefined
69
+ /** True when core has proved the record uses lowercase wire names before native hooks run. */
70
+ readonly headersAreLowercase?: boolean
69
71
  readonly cookies: readonly string[] | undefined
70
72
  /** The framework-serialized body bytes (`null` for a bodiless render). Replaceable by body hooks. */
71
73
  body: string | Uint8Array | null
@@ -92,6 +94,30 @@ export type ResponseBodyHook = (
92
94
  status: number,
93
95
  ) => MaybePromise<string | Uint8Array | ResponseBodyReplacement | undefined>
94
96
 
97
+ /**
98
+ * Native equivalent of a paired `onResponseBody` hook - the body tier's twin, mirroring what
99
+ * `onNodeResponseHeaders` is to `onResponseHeaders`. It receives the same already-serialized bytes
100
+ * the portable hook would (never `null` - bodiless renders skip the twin exactly as they skip the
101
+ * portable hook) plus the outcome record itself, so a twin that only reads the bytes and writes
102
+ * headers pays no header-view allocation. It returns through the same replacement channel
103
+ * (`undefined` keeps the body; bytes or a `{ body, status }` replacement is applied by the shared
104
+ * `applyBodyReplacement`), so a twin and its portable hook are observably identical.
105
+ *
106
+ * Header writes go straight to `response.headers`, whose contract is the lowercase wire spelling
107
+ * (the same contract `setNodeHeader` documents): a plain assignment of a name like `__proto__`
108
+ * hits the inherited setter and is an inert no-op rather than pollution - `c.set.headers` behaves
109
+ * the same way - so a header that must round-trip under that name needs `defineProperty`. The
110
+ * twin must preserve its portable hook's observable header/body/status semantics; direct `body`
111
+ * or `status` writes outside the return channel are still honored deterministically by the finish
112
+ * step, but they have no portable equivalent, so prefer the return.
113
+ */
114
+ export type NodeResponseBodyHook = (
115
+ body: string | Uint8Array,
116
+ response: NodeResponseContext,
117
+ req: NodeRequestContext,
118
+ status: number,
119
+ ) => MaybePromise<string | Uint8Array | ResponseBodyReplacement | undefined>
120
+
95
121
  /** Native equivalent of a paired `onResponse` hook. It must preserve the Web hook's header semantics. */
96
122
  export type NodeResponseHook = (
97
123
  response: NodeResponseContext,
@@ -160,11 +186,17 @@ class RecordHeadersView implements ResponseHeadersView {
160
186
  * a raw record write from a custom native twin uses lowercase names (the wire form the record
161
187
  * documents), so it stays visible without a scan. */
162
188
  #actual(lower: string): string {
189
+ return this.#key(lower) ?? lower
190
+ }
191
+
192
+ /** As {@link #actual}, but `undefined` when the record holds no such header: only an own property
193
+ * is a header, so an inherited `constructor` or `toString` never reads as one. */
194
+ #key(lower: string): string | undefined {
163
195
  const record = this.#target.headers
164
- if (record !== undefined && Object.hasOwn(record, lower)) return lower
196
+ if (record === undefined) return undefined
197
+ if (Object.hasOwn(record, lower)) return lower
165
198
  const known = this.#alias?.get(lower)
166
- if (known !== undefined && record !== undefined && Object.hasOwn(record, known)) return known
167
- return lower
199
+ return known !== undefined && Object.hasOwn(record, known) ? known : undefined
168
200
  }
169
201
 
170
202
  /** The backing record if the outcome has one, prepared on first touch; `undefined` otherwise. */
@@ -216,16 +248,17 @@ class RecordHeadersView implements ResponseHeadersView {
216
248
  get(name: string): string | null {
217
249
  const headers = this.#readable()
218
250
  if (headers === undefined) return null
219
- const value = headers[this.#actual(RecordHeadersView.#lower(name))]
251
+ const key = this.#key(RecordHeadersView.#lower(name))
252
+ const value = key === undefined ? undefined : headers[key]
220
253
  if (value === undefined) return null
221
254
  return typeof value === "string" ? value : (value.join(", ") ?? null)
222
255
  }
223
256
 
224
257
  has(name: string): boolean {
225
258
  const headers = this.#readable()
226
- return (
227
- headers !== undefined && headers[this.#actual(RecordHeadersView.#lower(name))] !== undefined
228
- )
259
+ if (headers === undefined) return false
260
+ const key = this.#key(RecordHeadersView.#lower(name))
261
+ return key !== undefined && headers[key] !== undefined
229
262
  }
230
263
 
231
264
  set(name: string, value: string): void {
@@ -0,0 +1,109 @@
1
+ /** The not-found rules (see `./not-found.ts`), shared by `notFound()` and the edge server. */
2
+ import type { Platform } from "./context.ts"
3
+ import { isRoutableMethod, plainError } from "./http.ts"
4
+ import { getNeverAbortSignal, headerOf, type ResponseResult } from "./runtime-core.ts"
5
+ import type { MaybePromise, RequestSource } from "./server.ts"
6
+
7
+ /** What a {@link NotFoundHandler} is given: the request line and headers of a request no route matched. */
8
+ export interface NotFoundInput<Env = unknown> {
9
+ /** The request method, exactly as received. Always a token a route could be registered under. */
10
+ readonly method: string
11
+ /** The request URL, exactly as received. */
12
+ readonly url: string
13
+ /** The path the router looked up, exactly as received: NOT percent-decoded, so `/a%2Fb` stays
14
+ * `/a%2Fb`. Escape it before writing it into a body. */
15
+ readonly pathname: string
16
+ /** The request headers. */
17
+ readonly headers: Headers
18
+ /** Read one request header (case-insensitive), or `null` when it is absent. */
19
+ header(name: string): string | null
20
+ /** Aborts when the server's `requestTimeoutMs` elapses before the handler settles. Never aborts
21
+ * when no timeout is configured. */
22
+ readonly signal: AbortSignal
23
+ /** The platform inputs (`env`, `waitUntil`, the caller address) the serving adapter supplied. */
24
+ readonly platform: Platform<Env> | undefined
25
+ }
26
+
27
+ /**
28
+ * Answers a request no route matched. Return a `Response`, or `undefined` for the default `404`.
29
+ * May be async; a thrown `Response` is treated like a returned one.
30
+ */
31
+ export type NotFoundHandler<Env = unknown> = (
32
+ input: NotFoundInput<Env>,
33
+ ) => MaybePromise<Response | undefined>
34
+
35
+ /** @internal The read-only view a {@link NotFoundHandler} receives. `url` and `headers` stay getters
36
+ * so a source that builds them on demand is not forced to for a handler that never reads them. */
37
+ export function notFoundInput<Env>(
38
+ source: RequestSource,
39
+ pathname: string,
40
+ platform: Platform<Env> | undefined,
41
+ signal: AbortSignal = getNeverAbortSignal(),
42
+ ): NotFoundInput<Env> {
43
+ return {
44
+ method: source.method,
45
+ pathname,
46
+ signal,
47
+ platform,
48
+ get url() {
49
+ return source.url
50
+ },
51
+ get headers() {
52
+ return source.headers
53
+ },
54
+ header: (name) => headerOf(source, name),
55
+ }
56
+ }
57
+
58
+ /**
59
+ * @internal Run a {@link NotFoundHandler} and turn what it produced into the answer. One function
60
+ * for every server that has a `404` site, so the rules above hold identically on each of them.
61
+ * `wrap` lifts a response into the caller's output type; `failed` renders the fault answer.
62
+ */
63
+ export function answerNotFound<T, Env>(
64
+ handler: NotFoundHandler<Env>,
65
+ input: NotFoundInput<Env>,
66
+ wrap: (response: Response | ResponseResult) => T,
67
+ failed: (error: unknown) => T,
68
+ ): MaybePromise<T> {
69
+ if (!isRoutableMethod(input.method)) return wrap(plainError(404, "not_found"))
70
+ const settle = (value: unknown, thrown: boolean): T => {
71
+ if (value instanceof Response) {
72
+ // A status below 200 is `Response.error()`: a network-error marker, not an answer.
73
+ if (value.bodyUsed || value.status < 200) {
74
+ return failed(new TypeError("notFound handler answered with an unusable Response"))
75
+ }
76
+ // A miss is never a success. The body and headers are the handler's; the status is not.
77
+ return wrap(
78
+ value.status < 300
79
+ ? new Response(value.body, { status: 404, headers: value.headers })
80
+ : value,
81
+ )
82
+ }
83
+ if (thrown) return failed(value)
84
+ if (value === undefined) return wrap(plainError(404, "not_found"))
85
+ return failed(new TypeError("notFound handler must return a Response or undefined"))
86
+ }
87
+ let result: MaybePromise<Response | undefined>
88
+ try {
89
+ result = handler(input)
90
+ } catch (error) {
91
+ return settle(error, true)
92
+ }
93
+ return result instanceof Promise
94
+ ? result.then(
95
+ (value) => settle(value, false),
96
+ (error) => settle(error, true),
97
+ )
98
+ : settle(result, false)
99
+ }
100
+
101
+ /** @internal What the kernel calls at its `404` site once a handler is installed. */
102
+ export type NotFoundLane = <T>(
103
+ host: unknown,
104
+ source: RequestSource,
105
+ pathname: string,
106
+ platform: Platform<unknown> | undefined,
107
+ wrap: (response: Response | ResponseResult) => T,
108
+ onTimeout: () => T,
109
+ ) => MaybePromise<T>