@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,75 @@
1
+ import type { ResponseResult } from "./runtime-core.ts"
2
+
3
+ /** A promise-or-value return used by authentication providers. */
4
+ export type AuthMaybePromise<T> = T | Promise<T>
5
+
6
+ /**
7
+ * The deliberately narrow header surface given to an authentication provider.
8
+ *
9
+ * It is not a `Headers` object: providers can inspect credentials, but cannot mutate the request
10
+ * headers or reach the request body through this value. The server constructs the view per request.
11
+ */
12
+ export interface AuthHeaders {
13
+ readonly get: (name: string) => string | null
14
+ readonly has: (name: string) => boolean
15
+ readonly forEach: (callback: (value: string, name: string) => void) => void
16
+ }
17
+
18
+ /**
19
+ * The authentication trust boundary. There is intentionally no `Request`, `body`, or parsed query
20
+ * here. Route parameters are present only for selecting an identity policy and remain untrusted
21
+ * strings until the normal route validation stage completes.
22
+ */
23
+ export interface AuthenticationInput<Env = unknown> {
24
+ readonly method: string
25
+ readonly pathname: string
26
+ readonly headers: AuthHeaders
27
+ readonly params: Readonly<Record<string, string>>
28
+ readonly env: Env
29
+ readonly clientIp: string | undefined
30
+ readonly signal: AbortSignal
31
+ }
32
+
33
+ export type AuthenticationFailureReason = "unauthenticated" | "forbidden" | "unavailable"
34
+
35
+ export interface AuthenticationSuccess<Principal> {
36
+ readonly kind: "authenticated"
37
+ readonly principal: Principal
38
+ }
39
+
40
+ export interface AuthenticationFailure {
41
+ readonly kind: "rejected"
42
+ readonly reason?: AuthenticationFailureReason
43
+ /** An integration may provide a redirect or protocol-specific challenge response. */
44
+ readonly response?: Response | ResponseResult
45
+ }
46
+
47
+ export type AuthenticationResult<Principal> =
48
+ | AuthenticationSuccess<Principal>
49
+ | AuthenticationFailure
50
+
51
+ /**
52
+ * A dedicated authentication stage. Stages are captured by route scope at registration time and
53
+ * execute before input validation on required protected routes.
54
+ */
55
+ export interface AuthenticationStage<Principal = unknown, Env = unknown> {
56
+ readonly id: string
57
+ /** Declares the common path so the compiler can preserve a synchronous protected lane. */
58
+ readonly mode?: "sync" | "async"
59
+ readonly run: (
60
+ input: AuthenticationInput<Env>,
61
+ ) => AuthMaybePromise<AuthenticationResult<Principal>>
62
+ }
63
+
64
+ export function authenticated<Principal>(principal: Principal): AuthenticationSuccess<Principal> {
65
+ return { kind: "authenticated", principal }
66
+ }
67
+
68
+ export function rejected(
69
+ reason: AuthenticationFailureReason = "unauthenticated",
70
+ response?: Response | ResponseResult,
71
+ ): AuthenticationFailure {
72
+ return response === undefined
73
+ ? { kind: "rejected", reason }
74
+ : { kind: "rejected", reason, response }
75
+ }
@@ -19,17 +19,18 @@
19
19
  */
20
20
  import { pathnameOf } from "./http.ts"
21
21
  import type { Logger } from "./logger.ts"
22
- import { isResponseResult, type ResponseResult } from "./runtime-core.ts"
22
+ import { isResponseResult } from "./runtime-core.ts"
23
23
  import type { CtxSet, RawContext } from "./server.ts"
24
24
 
25
25
  /** How much of a thrown error's own text reaches the log sink. Mirrors `Server`'s option. */
26
26
  export type ErrorLogDetail = "full" | "message" | "none"
27
27
 
28
28
  /**
29
- * Render a value thrown on the bare lane. Three shapes, in the order the lane means them:
30
- * - a bare `Response` is the app answering directly - passed through untouched;
31
- * - a thrown `status(...)` is control flow, still plain data: rendered through the same `finalize`
32
- * a RETURNED one takes, with the request's `c.set`, so it costs what the return costs;
29
+ * Render a value thrown on the bare lane. Two shapes, in the order the lane means them:
30
+ * - a `Response` or a `status(...)` is control flow - the app answering directly, or plain data.
31
+ * Either renders through the same `finalize` a RETURNED one takes, with the request's `c.set`,
32
+ * so a thrown one means what the return means: a `Response` ships as built, plus the cookies the
33
+ * request queued (the set-session-then-`throw redirect()` pattern);
33
34
  * - anything else is a real fault: logged once, then a bodyless 500 (never the caller's error text).
34
35
  *
35
36
  * `responseSet` and `logError` are injected rather than imported so this file keeps no runtime edge
@@ -40,13 +41,11 @@ export function renderBareError<T>(
40
41
  err: unknown,
41
42
  ctx: RawContext,
42
43
  finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
43
- wrapResponse: (response: Response | ResponseResult) => T,
44
44
  responseSet: (ctx: RawContext) => CtxSet,
45
45
  logError: (err: unknown, ctx: RawContext) => void,
46
46
  internalError: () => T,
47
47
  ): T {
48
- if (err instanceof Response) return wrapResponse(err)
49
- if (isResponseResult(err)) return finalize(err, responseSet(ctx), ctx)
48
+ if (err instanceof Response || isResponseResult(err)) return finalize(err, responseSet(ctx), ctx)
50
49
  logError(err, ctx)
51
50
  return internalError()
52
51
  }
@@ -62,11 +61,13 @@ export function emitRequestErrorLog(
62
61
  logger: Logger,
63
62
  detail: ErrorLogDetail,
64
63
  err: unknown,
65
- ctx: RawContext,
64
+ // Only the request line is read, so a lane with no context yet (a request no route matched) can
65
+ // log through the same policy.
66
+ // Absent for a failure the runtime reports without its request (Bun's serve `error` callback).
67
+ ctx: { readonly req: { readonly method: string; readonly url: string } } | undefined,
66
68
  ): void {
67
69
  logger.error("unhandled request error", {
68
- method: ctx.req.method,
69
- path: pathnameOf(ctx.req.url),
70
+ ...(ctx === undefined ? {} : { method: ctx.req.method, path: pathnameOf(ctx.req.url) }),
70
71
  name: err instanceof Error ? err.name : "Error",
71
72
  // `detail`, not `message`: the logger uses `message` for its own first argument, so a field of
72
73
  // that name is silently overwritten and the thrown error's own text never reaches the sink. It
@@ -6,7 +6,7 @@
6
6
  import { drainCapped, hasTrustedBodyFraming, parseContentLength, RAW_BODY_READERS } from "./body.ts"
7
7
  import { plainError } from "./http.ts"
8
8
  import { guardParsedValue, type ProtoPoisoning, parseJsonGuarded } from "./proto-guard.ts"
9
- import { isUrlEncodedForm, readBoundedForm } from "./query.ts"
9
+ import { isJsonMediaType, isUrlEncodedForm, readBoundedForm } from "./query.ts"
10
10
  import {
11
11
  headerOf,
12
12
  isResponseResult,
@@ -53,9 +53,9 @@ export function readBoundedJsonSource<T>(
53
53
  ): Promise<unknown | ResponseResult | T> {
54
54
  const preDecoded = (req as { [PRE_DECODED_BODY]?: PreDecodedBody })[PRE_DECODED_BODY]
55
55
  if (preDecoded !== undefined) {
56
- return onResult === undefined
57
- ? Promise.resolve(preDecoded.value)
58
- : Promise.resolve(preDecoded.value).then(onResult)
56
+ const value =
57
+ preDecoded.byteLength > maxBytes ? plainError(413, "payload_too_large") : preDecoded.value
58
+ return onResult === undefined ? Promise.resolve(value) : Promise.resolve(value).then(onResult)
59
59
  }
60
60
 
61
61
  const raw = ((req as { [RAW_BODY_READERS]?: RequestSource })[RAW_BODY_READERS] ??
@@ -170,7 +170,32 @@ export function readBoundedJsonSource<T>(
170
170
  return onResult === undefined ? streamed : streamed.then(onResult, onError)
171
171
  }
172
172
 
173
- /** The shared content-type dispatcher around the JSON and urlencoded lanes. */
173
+ /**
174
+ * Brand under which a body schema carries its own reader for a media type the built-in lanes do not
175
+ * parse. A registered symbol, so a schema branded by one copy of this module is read by another.
176
+ */
177
+ export const SCHEMA_BODY_READER: unique symbol = Symbol.for("nifra.body.schemaReader")
178
+
179
+ /**
180
+ * A schema-carried body reader. It receives the request's own `content-type`, the route's byte cap,
181
+ * and the app's prototype-poisoning policy, and owns all three: it answers the value to validate, or
182
+ * the rejection to send (a `415` for a media type it does not read).
183
+ */
184
+ export type SchemaBodyReader = ((
185
+ source: RequestSource,
186
+ contentType: string,
187
+ maxBodyBytes: number,
188
+ protoPoisoning: ProtoPoisoning,
189
+ ) => Promise<unknown | ResponseResult>) & {
190
+ /** The media types a reader declares it reads, for the documents generated from a route. */
191
+ readonly mediaTypes?: readonly string[]
192
+ }
193
+
194
+ /**
195
+ * The shared content-type dispatcher around the JSON and urlencoded lanes. A `bodySchema` branded
196
+ * with {@link SCHEMA_BODY_READER} takes every other media type; JSON and urlencoded bodies keep
197
+ * their lanes whatever the schema carries.
198
+ */
174
199
  export function readBodyFramed<T>(
175
200
  source: RequestSource,
176
201
  maxBodyBytes: number,
@@ -178,15 +203,30 @@ export function readBodyFramed<T>(
178
203
  onParsed: (parsed: unknown) => MaybePromise<T>,
179
204
  wrapResponse: (response: Response | ResponseResult) => T,
180
205
  onError: (err: unknown) => MaybePromise<T>,
206
+ bodySchema?: object,
181
207
  ): Promise<T> {
182
208
  const contentType = headerOf(source, "content-type") ?? ""
183
- if (contentType !== "application/json" && !contentType.includes("application/json")) {
209
+ if (!isJsonMediaType(contentType)) {
184
210
  if (isUrlEncodedForm(contentType)) {
185
211
  return readBoundedForm(source, maxBodyBytes).then(
186
212
  (form) => (isResponseResult(form) ? wrapResponse(form) : onParsed(form)),
187
213
  onError,
188
214
  ) as Promise<T>
189
215
  }
216
+ const reader =
217
+ bodySchema === undefined
218
+ ? undefined
219
+ : (bodySchema as { readonly [SCHEMA_BODY_READER]?: SchemaBodyReader })[SCHEMA_BODY_READER]
220
+ if (typeof reader === "function") {
221
+ try {
222
+ return reader(source, contentType, maxBodyBytes, protoPoisoning).then(
223
+ (parsed) => (isResponseResult(parsed) ? wrapResponse(parsed) : onParsed(parsed)),
224
+ onError,
225
+ ) as Promise<T>
226
+ } catch (err) {
227
+ return Promise.resolve(onError(err))
228
+ }
229
+ }
190
230
  return Promise.resolve(wrapResponse(plainError(415, "unsupported_media_type")))
191
231
  }
192
232
  try {
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Request bodies in a media type of your own - `bodyParser(schema, { types, parse })`.
3
+ *
4
+ * A route reads JSON and urlencoded bodies by default and answers `415` to everything else. Wrapping
5
+ * its body schema names the other media types that one route reads and the function that decodes
6
+ * them. The decoded value is then validated by the schema like any other body.
7
+ *
8
+ * import { bodyParser } from "@nifrajs/core/body-parser"
9
+ * import { parse } from "yaml"
10
+ *
11
+ * const utf8 = new TextDecoder("utf-8", { fatal: true })
12
+ *
13
+ * app.post(
14
+ * "/pipelines",
15
+ * {
16
+ * body: bodyParser(Pipeline, {
17
+ * types: ["application/yaml"],
18
+ * parse: (bytes) => parse(utf8.decode(bytes), { maxAliasCount: 0 }),
19
+ * }),
20
+ * },
21
+ * (c) => c.body.name,
22
+ * )
23
+ *
24
+ * What is settled before `parse` runs:
25
+ * - the request's media type is one of `types`: compared whole, case folded, parameters set aside
26
+ * - the body is at most the route's `bodyLimit`, measured on the bytes delivered and not on the
27
+ * header, and it is fully read: `parse` is handed bytes and never the request
28
+ *
29
+ * What is settled before the schema runs:
30
+ * - a `parse` that throws or rejects answers `400 invalid_body`
31
+ * - the value is a tree. An array or object reached twice is what an alias or a cycle decodes to,
32
+ * and walking it can cost far more than the bytes that described it, so it answers `400`
33
+ * - the app's prototype-poisoning policy: a `__proto__` key, a `constructor` that carries a
34
+ * `prototype`, and an object whose prototype was replaced by decoded data
35
+ *
36
+ * What it does not: a parser's own limits. Nesting depth, alias expansion, and number or string
37
+ * sizes belong to the decoder you pass, so configure it for untrusted input.
38
+ *
39
+ * `types` cannot name a media type that already has a reader (JSON, urlencoded, multipart) or
40
+ * `text/plain`. A browser sends `text/plain` from any site with the user's cookies and no preflight,
41
+ * so a route that parsed it would be reachable by a form no CORS policy ever saw. Read such a body
42
+ * in the handler with `c.boundedBody`, behind an explicit origin check.
43
+ */
44
+ import type { StandardSchemaV1 } from "../schema/standard.ts"
45
+ import { readBoundedBytes } from "./body.ts"
46
+ import { SCHEMA_BODY_READER, type SchemaBodyReader } from "./body-lane.ts"
47
+ import { plainError } from "./http.ts"
48
+ import type { ProtoPoisoning } from "./proto-guard.ts"
49
+ import { isJsonMediaType } from "./query.ts"
50
+
51
+ /** What `parse` is told about the body it is decoding. */
52
+ export interface BodyParserInput {
53
+ /** The media type that matched, lower-cased and without parameters. */
54
+ readonly mediaType: string
55
+ /** The request's `content-type` as sent, for a parser that reads a parameter such as `charset`. */
56
+ readonly contentType: string
57
+ }
58
+
59
+ export interface BodyParserOptions {
60
+ /** The media types this parser reads, each a full `type/subtype` with no parameters or wildcard. */
61
+ readonly types: readonly string[]
62
+ /** Decode the body. The result is what the schema validates; throw to refuse the body with `400`. */
63
+ readonly parse: (bytes: Uint8Array, input: BodyParserInput) => unknown
64
+ }
65
+
66
+ // RFC 6838 `restricted-name "/" restricted-name`, lower-cased.
67
+ const MEDIA_TYPE = /^[a-z0-9][a-z0-9!#$&^_.+-]{0,126}\/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}$/
68
+
69
+ const REFUSED = new Error("invalid_body")
70
+
71
+ function mediaTypesOf(types: readonly string[]): readonly string[] {
72
+ if (!Array.isArray(types) || types.length === 0) {
73
+ throw new TypeError("bodyParser: types must name at least one media type")
74
+ }
75
+ const seen = new Set<string>()
76
+ for (const value of types as readonly unknown[]) {
77
+ if (typeof value !== "string") throw new TypeError("bodyParser: a media type must be a string")
78
+ const type = value.toLowerCase()
79
+ if (!MEDIA_TYPE.test(type)) {
80
+ throw new TypeError(
81
+ `bodyParser: "${value}" is not a media type. Name it in full, such as "application/yaml", with no parameters and no wildcard`,
82
+ )
83
+ }
84
+ if (isJsonMediaType(type)) {
85
+ throw new TypeError(`bodyParser: "${value}" is JSON, which every body schema already reads`)
86
+ }
87
+ if (type === "application/x-www-form-urlencoded") {
88
+ throw new TypeError(`bodyParser: "${value}" is a form, which every body schema already reads`)
89
+ }
90
+ if (type.startsWith("multipart/")) {
91
+ throw new TypeError(
92
+ `bodyParser: "${value}" is a multipart body. Use multipartBody from "@nifrajs/core/multipart"`,
93
+ )
94
+ }
95
+ if (type === "text/plain") {
96
+ throw new TypeError(
97
+ `bodyParser: "${value}" is sent by a browser from any site without a preflight. Read it in the handler with c.boundedBody, behind an origin check`,
98
+ )
99
+ }
100
+ seen.add(type)
101
+ }
102
+ return Object.freeze([...seen])
103
+ }
104
+
105
+ /** Whether `proto` is the prototype of a class, as opposed to an object decoded from the body. */
106
+ function isClassPrototype(proto: object): boolean {
107
+ const own = Object.getOwnPropertyDescriptor(proto, "constructor")?.value as unknown
108
+ return typeof own === "function" && (own as { prototype?: unknown }).prototype === proto
109
+ }
110
+
111
+ /**
112
+ * One pass over a decoded value. It throws for a value that is not a tree, and applies the
113
+ * prototype-poisoning policy to every plain object in it. Iterative, so nesting cannot exhaust the
114
+ * call stack, and each node is visited once, so the cost is the size of what the parser built.
115
+ */
116
+ function settle(root: unknown, policy: ProtoPoisoning): void {
117
+ const seen = new Set<object>()
118
+ const stack: object[] = []
119
+ const push = (value: unknown): void => {
120
+ if (value === null || typeof value !== "object") return
121
+ if (!Array.isArray(value) && !(value instanceof Map) && !(value instanceof Set)) {
122
+ const proto = Object.getPrototypeOf(value) as object | null
123
+ if (proto !== null && proto !== Object.prototype) {
124
+ if (!isClassPrototype(proto)) {
125
+ // A plain object whose prototype is now data: a decoder that assigns keys one by one
126
+ // turns a `__proto__` key into exactly this, and every property of that data then reads
127
+ // as the object's own.
128
+ if (policy === "reject") throw REFUSED
129
+ if (policy === "strip") Object.setPrototypeOf(value, Object.prototype)
130
+ } else if (Object.getPrototypeOf(proto) !== null) {
131
+ // An instance of a class (a Date, a typed array) is a leaf: it has no keys a body chose.
132
+ return
133
+ }
134
+ // A class prototype with nothing above it is the `Object.prototype` of another realm, and
135
+ // the object is as plain as one made here.
136
+ }
137
+ }
138
+ if (seen.has(value)) throw REFUSED
139
+ seen.add(value)
140
+ stack.push(value)
141
+ }
142
+
143
+ push(root)
144
+ while (stack.length > 0) {
145
+ const node = stack.pop() as object
146
+ if (Array.isArray(node)) {
147
+ for (let i = 0; i < node.length; i++) push(node[i])
148
+ continue
149
+ }
150
+ if (node instanceof Map) {
151
+ for (const [key, value] of node) {
152
+ push(key)
153
+ push(value)
154
+ }
155
+ continue
156
+ }
157
+ if (node instanceof Set) {
158
+ for (const value of node) push(value)
159
+ continue
160
+ }
161
+ const record = node as Record<string, unknown>
162
+ for (const key of Object.keys(record)) {
163
+ const value = record[key]
164
+ if (policy !== "ignore") {
165
+ const poisoned =
166
+ key === "__proto__" ||
167
+ (key === "constructor" &&
168
+ value !== null &&
169
+ typeof value === "object" &&
170
+ Object.hasOwn(value, "prototype"))
171
+ if (poisoned) {
172
+ if (policy === "reject") throw REFUSED
173
+ delete record[key]
174
+ continue
175
+ }
176
+ }
177
+ push(value)
178
+ }
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Opt a route's body schema into media types of your own. Returns a copy of `schema` that also reads
184
+ * a body whose media type is one of `types`, decoded by `parse`; the schema itself is not changed,
185
+ * and JSON and urlencoded bodies still reach it. A schema that already reads another type, such as a
186
+ * form, keeps reading it.
187
+ *
188
+ * Throws a `TypeError` for a `types` entry that is not a full media type, or that names JSON,
189
+ * urlencoded, multipart, or `text/plain`.
190
+ */
191
+ export function bodyParser<Schema extends StandardSchemaV1>(
192
+ schema: Schema,
193
+ options: BodyParserOptions,
194
+ ): Schema {
195
+ const types = mediaTypesOf(options.types)
196
+ const parse = options.parse
197
+ if (typeof parse !== "function") throw new TypeError("bodyParser: parse must be a function")
198
+ const accepts = new Set(types)
199
+ const carried = (schema as { readonly [SCHEMA_BODY_READER]?: SchemaBodyReader })[
200
+ SCHEMA_BODY_READER
201
+ ]
202
+ const inner = typeof carried === "function" ? carried : undefined
203
+
204
+ const read: SchemaBodyReader = async (source, contentType, maxBodyBytes, protoPoisoning) => {
205
+ const semi = contentType.indexOf(";")
206
+ const mediaType = (semi === -1 ? contentType : contentType.slice(0, semi)).trim().toLowerCase()
207
+ if (!accepts.has(mediaType)) {
208
+ return inner === undefined
209
+ ? plainError(415, "unsupported_media_type")
210
+ : inner(source, contentType, maxBodyBytes, protoPoisoning)
211
+ }
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
+ }
218
+ try {
219
+ const value = await parse(body.bytes, { mediaType, contentType })
220
+ settle(value, protoPoisoning)
221
+ return value
222
+ } catch {
223
+ return plainError(400, "invalid_body")
224
+ }
225
+ }
226
+ const mediaTypes = Object.freeze([...new Set([...types, ...(inner?.mediaTypes ?? [])])])
227
+ const reader: PropertyDescriptor = {
228
+ value: Object.defineProperty(read, "mediaTypes", { value: mediaTypes }),
229
+ }
230
+
231
+ if (typeof schema === "function") {
232
+ // A callable schema cannot be copied; an object that delegates to it reads the same.
233
+ return Object.defineProperty(Object.create(schema), SCHEMA_BODY_READER, reader) as Schema
234
+ }
235
+ // The reader goes in with the copied properties rather than after them: a schema that was already
236
+ // opted in carries a reader that cannot be redefined.
237
+ return Object.create(Object.getPrototypeOf(schema), {
238
+ ...Object.getOwnPropertyDescriptors(schema),
239
+ [SCHEMA_BODY_READER]: reader,
240
+ }) as Schema
241
+ }
@@ -82,7 +82,9 @@ export async function drainCapped(
82
82
  if (done) break
83
83
  total += value.byteLength
84
84
  if (total > maxBytes) {
85
- await reader.cancel()
85
+ // Not awaited: on a teed body (a `clone()` taken first) the cancel settles only once the other
86
+ // branch is read or cancelled too, and nothing here ever reads it.
87
+ reader.cancel().catch(() => {})
86
88
  return { ok: false, status: 413 }
87
89
  }
88
90
  if (first === undefined) {
@@ -210,9 +212,45 @@ function exactArrayBuffer(bytes: Uint8Array): ArrayBuffer {
210
212
  : (bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer)
211
213
  }
212
214
 
215
+ /** The two members of a raw body stream {@link cappedStreamOf} uses. */
216
+ interface RawStream {
217
+ getReader(): unknown
218
+ cancel(reason?: unknown): Promise<void>
219
+ }
220
+
221
+ // Deno's `process.env` throws without `--allow-env`, so the dev guard in `capRequestBodyReads` asks
222
+ // `typeof Deno` first: a bare typeof is pure, so a production `NODE_ENV` define folds the guard away.
223
+ declare const Deno: unknown
224
+
225
+ /** Dev only: `body`, recording the first reader user code takes from it through the capped getter. */
226
+ function markedOnRead(body: ReadableStream<Uint8Array>, mark: () => void): RawStream {
227
+ return {
228
+ getReader: () => {
229
+ mark()
230
+ return body.getReader()
231
+ },
232
+ cancel: (reason) => body.cancel(reason),
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Dev only: user code streamed the native body, which is one-shot, so every framework reader after
238
+ * it (a body schema, `c.boundedJson()`, the handler's `c.req.json()`) can only fail. Each raw reader
239
+ * now throws, synchronously so no lane maps it to a client error, naming the reader that replays.
240
+ */
241
+ function spendRaw(raw: RawBodyReaders): void {
242
+ const spent = (): never => {
243
+ throw new TypeError(
244
+ "[nifra] c.req.body was read as a stream; read it with c.req.bytes() in a hook",
245
+ )
246
+ }
247
+ Object.assign(raw, { arrayBuffer: spent, bytes: spent, json: spent })
248
+ Object.defineProperty(raw, "body", { get: spent })
249
+ }
250
+
213
251
  /** A pass-through stream that errors (with `reject(413)`) once more than `maxBytes` flow through. */
214
252
  function cappedStreamOf(
215
- body: ReadableStream<Uint8Array>,
253
+ body: RawStream,
216
254
  maxBytes: number,
217
255
  reject: (status: 400 | 413) => unknown,
218
256
  ): ReadableStream<Uint8Array> {
@@ -231,7 +269,7 @@ function cappedStreamOf(
231
269
  return new ReadableStream<Uint8Array>(
232
270
  {
233
271
  async pull(controller) {
234
- reader ??= body.getReader() as unknown as CappedReader
272
+ reader ??= body.getReader() as CappedReader
235
273
  const active = reader
236
274
  const { done, value } = await active.read()
237
275
  if (done) {
@@ -293,11 +331,34 @@ function capRequestBodyReads(request: Request, maxBytes: number, preset?: RawBod
293
331
  // One bounded buffer backs every shadowed reader; a second read replays it instead of failing
294
332
  // with "body already used" (a strict superset of the native one-shot contract).
295
333
  let buffered: Promise<Uint8Array> | undefined
296
- const cappedBytes = (): Promise<Uint8Array> =>
297
- (buffered ??= readBoundedBytes(raw, maxBytes).then((read) => {
298
- if (!read.ok) throw reject(read.status)
299
- return read.bytes
300
- }))
334
+ const cappedBytes = (): Promise<Uint8Array> => {
335
+ if (buffered === undefined) {
336
+ const read = readBoundedBytes(raw, maxBytes).then((result) => {
337
+ if (!result.ok) throw reject(result.status)
338
+ return result.bytes
339
+ })
340
+ buffered = read
341
+ // The raw readers now replay these bytes, so a framework reader that runs after a hook read
342
+ // `c.req` (an auth-first route's body schema) parses the same body instead of a spent stream.
343
+ // readBoundedBytes took its reader before its first await, so it never reads the replay.
344
+ Object.assign(raw, {
345
+ arrayBuffer: shadowed.arrayBuffer,
346
+ bytes: shadowed.bytes,
347
+ json: shadowed.json,
348
+ })
349
+ Object.defineProperty(raw, "body", {
350
+ get: () =>
351
+ new ReadableStream<Uint8Array>({
352
+ start: (controller) =>
353
+ read.then((bytes) => {
354
+ controller.enqueue(bytes)
355
+ controller.close()
356
+ }),
357
+ }),
358
+ })
359
+ }
360
+ return buffered
361
+ }
301
362
  // The reader methods live on the prototype as writable data properties, so a plain instance
302
363
  // assignment shadows them - materially cheaper than defineProperty on this per-read path. Only
303
364
  // `body` (a prototype accessor) needs defineProperty below.
@@ -339,7 +400,19 @@ function capRequestBodyReads(request: Request, maxBytes: number, preset?: RawBod
339
400
  get(): ReadableStream<Uint8Array> | null {
340
401
  if (cappedBody === undefined) {
341
402
  const rawBody = raw.body
342
- cappedBody = rawBody === null ? null : cappedStreamOf(rawBody, maxBytes, reject)
403
+ cappedBody =
404
+ rawBody === null
405
+ ? null
406
+ : cappedStreamOf(
407
+ typeof Deno === "undefined" && process.env.NODE_ENV !== "production"
408
+ ? markedOnRead(rawBody, () => {
409
+ // After a buffered read the getter streams a replay, which spends nothing.
410
+ if (buffered === undefined) spendRaw(raw)
411
+ })
412
+ : rawBody,
413
+ maxBytes,
414
+ reject,
415
+ )
343
416
  }
344
417
  return cappedBody
345
418
  },
@@ -25,6 +25,15 @@
25
25
  */
26
26
  export type ClientIpTrust = { readonly trustedHops: number } | { readonly header: string }
27
27
 
28
+ /**
29
+ * Marks a platform whose `clientIp` an enclosing nifra server already derived under its own `clientIp`
30
+ * trust declaration. A server that receives such a platform takes `clientIp` as-is instead of re-deriving
31
+ * it from the request's forwarding headers - an in-process call's `Request` is synthesized, so identity
32
+ * travels in the platform, never in headers a rebuilt request would lose. Only server code builds a
33
+ * platform; a client cannot set it.
34
+ */
35
+ export const NIFRA_PLATFORM_CLIENT_IP_DERIVED = Symbol.for("@nifrajs/platform-client-ip-derived")
36
+
28
37
  function firstHeaderValue(req: Request, header: string): string | undefined {
29
38
  const raw = req.headers.get(header)
30
39
  if (raw === null) return undefined