@nifrajs/core 2.14.1 → 3.2.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 (226) hide show
  1. package/README.md +25 -0
  2. package/dist/assurance.d.ts +16 -8
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +33 -0
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/durable-adapters.d.ts.map +1 -1
  7. package/dist/durable-adapters.js +26 -3
  8. package/dist/durable-adapters.js.map +1 -1
  9. package/dist/durable-approval.d.ts +56 -0
  10. package/dist/durable-approval.d.ts.map +1 -0
  11. package/dist/durable-approval.js +233 -0
  12. package/dist/durable-approval.js.map +1 -0
  13. package/dist/durable-effect.d.ts +42 -0
  14. package/dist/durable-effect.d.ts.map +1 -0
  15. package/dist/durable-effect.js +234 -0
  16. package/dist/durable-effect.js.map +1 -0
  17. package/dist/durable-execution.d.ts +8 -346
  18. package/dist/durable-execution.d.ts.map +1 -1
  19. package/dist/durable-execution.js +7 -1150
  20. package/dist/durable-execution.js.map +1 -1
  21. package/dist/durable-saga.d.ts +94 -0
  22. package/dist/durable-saga.d.ts.map +1 -0
  23. package/dist/durable-saga.js +550 -0
  24. package/dist/durable-saga.js.map +1 -0
  25. package/dist/edge-kit.d.ts +27 -0
  26. package/dist/edge-kit.d.ts.map +1 -0
  27. package/dist/edge-kit.js +24 -0
  28. package/dist/edge-kit.js.map +1 -0
  29. package/dist/errors.d.ts +1 -1
  30. package/dist/errors.d.ts.map +1 -1
  31. package/dist/errors.js.map +1 -1
  32. package/dist/index.d.ts +23 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +18 -2
  35. package/dist/index.js.map +1 -1
  36. package/dist/internal/durable-approval-protocol.d.ts +1 -1
  37. package/dist/internal/durable-approval-protocol.d.ts.map +1 -1
  38. package/dist/internal/durable-effect-protocol.d.ts +1 -1
  39. package/dist/internal/durable-effect-protocol.d.ts.map +1 -1
  40. package/dist/internal/durable-saga-protocol.d.ts +1 -1
  41. package/dist/internal/durable-saga-protocol.d.ts.map +1 -1
  42. package/dist/internal/durable-shared.d.ts +26 -0
  43. package/dist/internal/durable-shared.d.ts.map +1 -0
  44. package/dist/internal/durable-shared.js +131 -0
  45. package/dist/internal/durable-shared.js.map +1 -0
  46. package/dist/internal/durable-types.d.ts +159 -0
  47. package/dist/internal/durable-types.d.ts.map +1 -0
  48. package/dist/internal/durable-types.js +2 -0
  49. package/dist/internal/durable-types.js.map +1 -0
  50. package/dist/internal/route-compiler.d.ts +61 -0
  51. package/dist/internal/route-compiler.d.ts.map +1 -0
  52. package/dist/internal/route-compiler.js +121 -0
  53. package/dist/internal/route-compiler.js.map +1 -0
  54. package/dist/internal/route-execution.d.ts +17 -9
  55. package/dist/internal/route-execution.d.ts.map +1 -1
  56. package/dist/internal/route-execution.js +4 -18
  57. package/dist/internal/route-execution.js.map +1 -1
  58. package/dist/internal/route-lanes.d.ts +35 -0
  59. package/dist/internal/route-lanes.d.ts.map +1 -0
  60. package/dist/internal/route-lanes.js +150 -0
  61. package/dist/internal/route-lanes.js.map +1 -0
  62. package/dist/internal/route-program.d.ts +67 -0
  63. package/dist/internal/route-program.d.ts.map +1 -0
  64. package/dist/internal/route-program.js +199 -0
  65. package/dist/internal/route-program.js.map +1 -0
  66. package/dist/response-observer.d.ts +25 -0
  67. package/dist/response-observer.d.ts.map +1 -0
  68. package/dist/response-observer.js +40 -0
  69. package/dist/response-observer.js.map +1 -0
  70. package/dist/security/baseline.d.ts +68 -0
  71. package/dist/security/baseline.d.ts.map +1 -0
  72. package/dist/security/baseline.js +118 -0
  73. package/dist/security/baseline.js.map +1 -0
  74. package/dist/server/bare-error-lane.d.ts +26 -0
  75. package/dist/server/bare-error-lane.d.ts.map +1 -0
  76. package/dist/server/bare-error-lane.js +60 -0
  77. package/dist/server/bare-error-lane.js.map +1 -0
  78. package/dist/server/body-lane.d.ts +13 -0
  79. package/dist/server/body-lane.d.ts.map +1 -0
  80. package/dist/server/body-lane.js +134 -0
  81. package/dist/server/body-lane.js.map +1 -0
  82. package/dist/server/body.d.ts +7 -0
  83. package/dist/server/body.d.ts.map +1 -1
  84. package/dist/server/body.js +7 -0
  85. package/dist/server/body.js.map +1 -1
  86. package/dist/server/bridge-markers.d.ts +25 -0
  87. package/dist/server/bridge-markers.d.ts.map +1 -0
  88. package/dist/server/bridge-markers.js +25 -0
  89. package/dist/server/bridge-markers.js.map +1 -0
  90. package/dist/server/context.d.ts +6 -1
  91. package/dist/server/context.d.ts.map +1 -1
  92. package/dist/server/contract.d.ts +40 -14
  93. package/dist/server/contract.d.ts.map +1 -1
  94. package/dist/server/contract.js +7 -0
  95. package/dist/server/contract.js.map +1 -1
  96. package/dist/server/cookies.d.ts.map +1 -1
  97. package/dist/server/cookies.js +16 -2
  98. package/dist/server/cookies.js.map +1 -1
  99. package/dist/server/header-case.d.ts.map +1 -1
  100. package/dist/server/header-case.js +2 -1
  101. package/dist/server/header-case.js.map +1 -1
  102. package/dist/server/http.d.ts +36 -3
  103. package/dist/server/http.d.ts.map +1 -1
  104. package/dist/server/http.js +49 -3
  105. package/dist/server/http.js.map +1 -1
  106. package/dist/server/install.d.ts +6 -8
  107. package/dist/server/install.d.ts.map +1 -1
  108. package/dist/server/install.js +7 -0
  109. package/dist/server/install.js.map +1 -1
  110. package/dist/server/node-outcome-hook.d.ts +2 -1
  111. package/dist/server/node-outcome-hook.d.ts.map +1 -1
  112. package/dist/server/node-outcome-hook.js.map +1 -1
  113. package/dist/server/node-outcome.d.ts +17 -2
  114. package/dist/server/node-outcome.d.ts.map +1 -1
  115. package/dist/server/node-outcome.js +37 -3
  116. package/dist/server/node-outcome.js.map +1 -1
  117. package/dist/server/plugin.d.ts +2 -2
  118. package/dist/server/plugin.d.ts.map +1 -1
  119. package/dist/server/plugin.js +6 -1
  120. package/dist/server/plugin.js.map +1 -1
  121. package/dist/server/query.d.ts +7 -1
  122. package/dist/server/query.d.ts.map +1 -1
  123. package/dist/server/query.js +8 -3
  124. package/dist/server/query.js.map +1 -1
  125. package/dist/server/registry.d.ts +40 -12
  126. package/dist/server/registry.d.ts.map +1 -1
  127. package/dist/server/request-context.d.ts +2 -13
  128. package/dist/server/request-context.d.ts.map +1 -1
  129. package/dist/server/request-context.js +18 -159
  130. package/dist/server/request-context.js.map +1 -1
  131. package/dist/server/respond.d.ts.map +1 -1
  132. package/dist/server/respond.js +58 -4
  133. package/dist/server/respond.js.map +1 -1
  134. package/dist/server/response-contract-lane.d.ts.map +1 -1
  135. package/dist/server/response-contract-lane.js +5 -2
  136. package/dist/server/response-contract-lane.js.map +1 -1
  137. package/dist/server/response-observer-runtime.d.ts +20 -0
  138. package/dist/server/response-observer-runtime.d.ts.map +1 -0
  139. package/dist/server/response-observer-runtime.js +95 -0
  140. package/dist/server/response-observer-runtime.js.map +1 -0
  141. package/dist/server/runtime-core.d.ts +76 -0
  142. package/dist/server/runtime-core.d.ts.map +1 -1
  143. package/dist/server/runtime-core.js +42 -1
  144. package/dist/server/runtime-core.js.map +1 -1
  145. package/dist/server/server-types.d.ts +6 -1
  146. package/dist/server/server-types.d.ts.map +1 -1
  147. package/dist/server/server.d.ts +158 -116
  148. package/dist/server/server.d.ts.map +1 -1
  149. package/dist/server/server.js +707 -844
  150. package/dist/server/server.js.map +1 -1
  151. package/dist/server/validation.d.ts +5 -0
  152. package/dist/server/validation.d.ts.map +1 -0
  153. package/dist/server/validation.js +13 -0
  154. package/dist/server/validation.js.map +1 -0
  155. package/dist/server/websocket.d.ts +20 -4
  156. package/dist/server/websocket.d.ts.map +1 -1
  157. package/dist/server/websocket.js +81 -11
  158. package/dist/server/websocket.js.map +1 -1
  159. package/dist/server/ws-bun.d.ts +12 -4
  160. package/dist/server/ws-bun.d.ts.map +1 -1
  161. package/dist/server/ws-bun.js +50 -16
  162. package/dist/server/ws-bun.js.map +1 -1
  163. package/dist/server/ws-hook.d.ts +3 -2
  164. package/dist/server/ws-hook.d.ts.map +1 -1
  165. package/dist/server.d.ts +3 -2
  166. package/dist/server.d.ts.map +1 -1
  167. package/dist/server.js +2 -1
  168. package/dist/server.js.map +1 -1
  169. package/dist/single-copy-register.d.ts +2 -0
  170. package/dist/single-copy-register.d.ts.map +1 -0
  171. package/dist/single-copy-register.js +24 -0
  172. package/dist/single-copy-register.js.map +1 -0
  173. package/dist/single-copy.d.ts +156 -0
  174. package/dist/single-copy.d.ts.map +1 -0
  175. package/dist/single-copy.js +472 -0
  176. package/dist/single-copy.js.map +1 -0
  177. package/package.json +28 -3
  178. package/src/assurance.ts +58 -1
  179. package/src/durable-adapters.ts +20 -0
  180. package/src/durable-approval.ts +281 -0
  181. package/src/durable-effect.ts +300 -0
  182. package/src/durable-execution.ts +8 -1627
  183. package/src/durable-saga.ts +744 -0
  184. package/src/edge-kit.ts +27 -0
  185. package/src/errors.ts +1 -0
  186. package/src/index.ts +129 -2
  187. package/src/internal/durable-approval-protocol.ts +1 -1
  188. package/src/internal/durable-effect-protocol.ts +1 -1
  189. package/src/internal/durable-saga-protocol.ts +1 -1
  190. package/src/internal/durable-shared.ts +157 -0
  191. package/src/internal/durable-types.ts +194 -0
  192. package/src/internal/route-compiler.ts +234 -0
  193. package/src/internal/route-execution.ts +53 -96
  194. package/src/internal/route-lanes.ts +221 -0
  195. package/src/internal/route-program.ts +404 -0
  196. package/src/response-observer.ts +64 -0
  197. package/src/security/baseline.ts +172 -0
  198. package/src/server/bare-error-lane.ts +77 -0
  199. package/src/server/body-lane.ts +203 -0
  200. package/src/server/body.ts +8 -0
  201. package/src/server/bridge-markers.ts +24 -0
  202. package/src/server/context.ts +6 -1
  203. package/src/server/contract.ts +70 -20
  204. package/src/server/cookies.ts +16 -2
  205. package/src/server/header-case.ts +3 -1
  206. package/src/server/http.ts +53 -3
  207. package/src/server/install.ts +14 -0
  208. package/src/server/node-outcome-hook.ts +2 -1
  209. package/src/server/node-outcome.ts +44 -3
  210. package/src/server/plugin.ts +11 -5
  211. package/src/server/query.ts +10 -4
  212. package/src/server/registry.ts +71 -16
  213. package/src/server/request-context.ts +13 -216
  214. package/src/server/respond.ts +71 -3
  215. package/src/server/response-contract-lane.ts +6 -2
  216. package/src/server/response-observer-runtime.ts +135 -0
  217. package/src/server/runtime-core.ts +124 -1
  218. package/src/server/server-types.ts +6 -1
  219. package/src/server/server.ts +1446 -1485
  220. package/src/server/validation.ts +19 -0
  221. package/src/server/websocket.ts +127 -17
  222. package/src/server/ws-bun.ts +67 -15
  223. package/src/server/ws-hook.ts +3 -2
  224. package/src/server.ts +8 -2
  225. package/src/single-copy-register.ts +24 -0
  226. package/src/single-copy.ts +526 -0
@@ -202,11 +202,25 @@ const toBase64Url = (buf: ArrayBuffer): string => {
202
202
  return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
203
203
  }
204
204
 
205
+ const BASE64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
206
+ const BASE64URL_RE = /^[A-Za-z0-9_-]+$/
207
+
205
208
  // Returns `Uint8Array<ArrayBuffer>` (not the generic `Uint8Array<ArrayBufferLike>`) so it satisfies
206
- // WebCrypto's `BufferSource` parameter under TS 5.7+'s typed-array generics.
209
+ // WebCrypto's `BufferSource` parameter under TS 5.7+'s typed-array generics. Reject non-canonical
210
+ // encodings: base64 decoders otherwise accept alternate final characters whose unused padding bits
211
+ // decode to the same bytes, which lets a visibly changed signed cookie verify as if it were original.
207
212
  const fromBase64Url = (s: string): Uint8Array<ArrayBuffer> | null => {
213
+ if (s.length === 0 || s.length % 4 === 1 || !BASE64URL_RE.test(s)) return null
214
+ const remainder = s.length % 4
215
+ const last = BASE64URL_ALPHABET.indexOf(s[s.length - 1]!)
216
+ // Two or three base64 characters leave four or two unused low bits respectively. The encoder above
217
+ // always emits those bits as zero; accepting anything else creates a second spelling of the same
218
+ // byte sequence.
219
+ if ((remainder === 2 && (last & 0x0f) !== 0) || (remainder === 3 && (last & 0x03) !== 0))
220
+ return null
208
221
  try {
209
- const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/"))
222
+ const padded = s + "=".repeat((4 - remainder) % 4)
223
+ const bin = atob(padded.replace(/-/g, "+").replace(/_/g, "/"))
210
224
  const bytes = new Uint8Array(bin.length)
211
225
  for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
212
226
  return bytes
@@ -1,3 +1,5 @@
1
+ import { NODE_BRIDGE_MARKER_KEYS } from "./bridge-markers.ts"
2
+
1
3
  /**
2
4
  * The all-lowercase header-name proof: one answer, computed once per request, read by everyone who
3
5
  * would otherwise re-derive it.
@@ -23,7 +25,7 @@
23
25
  * Declared with `Symbol.for` so the `@nifrajs/node` adapter can read it by key rather than importing
24
26
  * across the package boundary - the same cross-package convention as the body and result marks.
25
27
  */
26
- const LOWERCASE_HEADER_KEYS = Symbol.for("nifra.headers.lowercase")
28
+ const LOWERCASE_HEADER_KEYS = Symbol.for(NODE_BRIDGE_MARKER_KEYS.lowercaseHeaderKeys)
27
29
 
28
30
  /** True when the name contains an ASCII `A`-`Z`, i.e. is not already the wire spelling. */
29
31
  export function hasUpperAscii(name: string): boolean {
@@ -1,8 +1,37 @@
1
1
  /**
2
- * Dependency-free HTTP helpers shared by the kernel and the opt-in request lanes (idempotency,
3
- * effect-ledger). Kept in a leaf module so a lane can reuse them without importing the server, and so
4
- * the server can reuse them without pulling a lane's feature code into the base bundle.
2
+ * HTTP helpers shared by the kernel and the opt-in request lanes (idempotency, effect-ledger). Kept
3
+ * in a leaf module (runtime-core aside, itself a leaf) so a lane can reuse them without importing the
4
+ * server, and so the server can reuse them without pulling a lane's feature code into the base bundle.
5
5
  */
6
+ import { type ResponseResult, status as statusResult } from "./runtime-core.ts"
7
+
8
+ /**
9
+ * Is `value` a destination that stays on this origin?
10
+ *
11
+ * True only for an absolute path: one leading `/`, never `//` (protocol-relative → another origin),
12
+ * and free of any character a URL parser turns into an origin escape. A leading `/` alone is not
13
+ * enough: under a special scheme `\` parses as `/`, and tab/CR/LF are STRIPPED before parsing, so
14
+ * `/\evil.example` and `/<TAB>/evil.example` both resolve to the external host `evil.example` while
15
+ * passing a `//` test. Rejecting CR/LF here also keeps a validated destination out of the
16
+ * response-splitting sink it usually flows into.
17
+ *
18
+ * One implementation, because every caller is the same open-redirect gate: `redirect()` in
19
+ * `@nifrajs/web`, the `redirectTo` of the `@nifrajs/auth` and `@nifrajs/better-auth` guards. Three
20
+ * copies of a security predicate is three chances for one of them to be hardened alone.
21
+ *
22
+ * It answers about a PATH, not a URL: an absolute `https://this.host/x` is false even for the
23
+ * current origin, because the whole point is that the value never got to name a host.
24
+ */
25
+ export function isSameOriginPath(value: string): boolean {
26
+ if (!value.startsWith("/") || value.startsWith("//")) return false
27
+ // Scanned by character code rather than matched by regex: no control characters have to be written
28
+ // into a pattern, and the check is a single pass over a short string.
29
+ for (let i = 1; i < value.length; i++) {
30
+ const code = value.charCodeAt(i)
31
+ if (code === 0x5c /* \ */ || code <= 0x1f || code === 0x7f) return false
32
+ }
33
+ return true
34
+ }
6
35
 
7
36
  /** A uniform JSON error envelope: `{ ok: false, error }` at the given status. */
8
37
  export function jsonError(
@@ -16,6 +45,27 @@ export function jsonError(
16
45
  )
17
46
  }
18
47
 
48
+ /**
49
+ * The same envelope as {@link jsonError}, as plain data rather than a built `Response`.
50
+ *
51
+ * This is what the framework's own renders - 404, 405, 422, the body caps, the timeouts, 500 - answer
52
+ * with wherever the value flows into a lane's response wrapper, so an error costs what a handler's
53
+ * plain return costs. Building the `Response` was measured as the dominant cost of answering early
54
+ * (see `PlainRender`), and on the Node lane an error `Response` is worse still: it is untagged, so
55
+ * the adapter cannot recognize its body and drains it through a Web stream, which is also why an
56
+ * error answered chunked while an ordinary return carries a `content-length`.
57
+ *
58
+ * `jsonError` stays for the callers that genuinely need a `Response` object (mount bridges, the
59
+ * lanes' own typed returns).
60
+ */
61
+ export function plainError(
62
+ status: number,
63
+ error: string,
64
+ headers?: Record<string, string>,
65
+ ): ResponseResult {
66
+ return statusResult(status, { ok: false, error }, headers !== undefined ? { headers } : undefined)
67
+ }
68
+
19
69
  export interface UrlParts {
20
70
  readonly pathname: string
21
71
  readonly search: string
@@ -6,6 +6,7 @@
6
6
  * - the seam stays off the server's public typed surface (symbol-keyed methods are not part of it).
7
7
  * Both sides reference the SAME registered symbol, so no module has to import the other.
8
8
  */
9
+ import { NODE_BRIDGE_MARKER_KEYS } from "./bridge-markers.ts"
9
10
 
10
11
  /** @internal Install the idempotency runtime on a server (called by the `idempotency()` plugin). */
11
12
  export const INSTALL_IDEMPOTENCY: unique symbol = Symbol.for("@nifrajs/core/install-idempotency")
@@ -31,3 +32,16 @@ export const INSTALL_WS: unique symbol = Symbol.for("@nifrajs/core/install-ws")
31
32
  export const INSTALL_RESPONSE_CONTRACT: unique symbol = Symbol.for(
32
33
  "@nifrajs/core/install-response-contract",
33
34
  )
35
+
36
+ /** @internal Install the response-observer adapters on a server. */
37
+ export const INSTALL_RESPONSE_OBSERVER: unique symbol = Symbol.for(
38
+ "@nifrajs/core/install-response-observer",
39
+ )
40
+
41
+ /** @internal Resolve a native mount for an adapter that can keep the request on its own stream lane. */
42
+ export const RESOLVE_NODE_MOUNT: unique symbol = Symbol.for(
43
+ NODE_BRIDGE_MARKER_KEYS.resolveNodeMount,
44
+ )
45
+
46
+ /** @internal Marker on a mounted handler that knows how to serve a native Node request/response. */
47
+ export const NODE_NATIVE_MOUNT: unique symbol = Symbol.for("@nifrajs/core/node-native-mount")
@@ -3,6 +3,7 @@
3
3
  import type { Platform } from "./context.ts"
4
4
  import { hasLowercaseHeaderKeysMark, hasUpperAscii } from "./header-case.ts"
5
5
  import type { NodeServeOutcome } from "./node-outcome.ts"
6
+ import type { ResponseResult } from "./runtime-core.ts"
6
7
  import type { CtxSet, MaybePromise } from "./server.ts"
7
8
 
8
9
  /**
@@ -309,7 +310,7 @@ export interface NodeOutcomeRuntime {
309
310
  toOutcome(result: unknown, set: CtxSet): NodeServeOutcome
310
311
  /** Materialize a buffered outcome for a Web response hook without losing its direct-write marker. */
311
312
  toResponse(outcome: NodeServeOutcome): Response
312
- fromResponse(response: Response): NodeServeOutcome
313
+ fromResponse(response: Response | ResponseResult): NodeServeOutcome
313
314
  timeout(): NodeServeOutcome
314
315
  /** The `Content-Type` this runtime's json render writes implicitly. The native hook walk
315
316
  * materializes it into the hook-visible header record so a body hook's content-type checks see
@@ -11,7 +11,14 @@ import {
11
11
  rememberMutableHeaders,
12
12
  taggedResponseBody,
13
13
  } from "./respond.ts"
14
- import { type HandlerResult, isResponseResult } from "./runtime-core.ts"
14
+ import {
15
+ EMPTY_RESPONSE_CONTROLS,
16
+ type HandlerResult,
17
+ isResponseResult,
18
+ type PlainRender,
19
+ plainRenderHeaders,
20
+ type ResponseResult,
21
+ } from "./runtime-core.ts"
15
22
  import type { CtxSet } from "./server.ts"
16
23
  import {
17
24
  mergeStaticHeaderRecord,
@@ -58,6 +65,8 @@ export type NodeServeOutcome =
58
65
  */
59
66
  export function toNodeOutcome(result: HandlerResult, set: CtxSet): NodeServeOutcome {
60
67
  if (isResponseResult(result)) {
68
+ const plain = result.plain
69
+ if (plain !== undefined) return plainNodeOutcome(plain, set)
61
70
  const body = result.toNodeBody?.()
62
71
  if (body !== undefined) {
63
72
  return {
@@ -85,6 +94,24 @@ export function toNodeOutcome(result: HandlerResult, set: CtxSet): NodeServeOutc
85
94
  }
86
95
  }
87
96
 
97
+ /**
98
+ * A `status(...)` (or any other {@link PlainRender} carrier) on the node lane: the same `kind: "json"`
99
+ * outcome a handler's plain return produces, so an early exit is written to the socket by the same
100
+ * direct writer, with a `content-length`, and never builds a `Response`.
101
+ */
102
+ export function plainNodeOutcome(plain: PlainRender, set: CtxSet): NodeServeOutcome {
103
+ return {
104
+ kind: "json",
105
+ status: plain.status,
106
+ headers: plainRenderHeaders(plain, set),
107
+ cookies: set._cookies,
108
+ body:
109
+ plain.body === undefined || isBodylessStatus(plain.status)
110
+ ? null
111
+ : JSON.stringify(plain.body),
112
+ }
113
+ }
114
+
88
115
  /**
89
116
  * Materialize a buffered node outcome only when a Web `onResponse` hook needs to see a real
90
117
  * `Response`. The marker lets an in-place hook (`response.headers.set(...); return response`) go back
@@ -162,8 +189,22 @@ export function withStaticNodeHeaders(
162
189
  return { ...outcome, headers }
163
190
  }
164
191
 
165
- export function nodeOutcomeFromResponse(response: Response): NodeServeOutcome {
166
- response = normalizeBodylessResponse(response)
192
+ /**
193
+ * The node lane's `wrapResponse`: what an early exit built OUTSIDE the handler's finalizer renders as
194
+ * - an `onRequest` hook's `Response`, a mount's, and every framework error render.
195
+ *
196
+ * A plain-data carrier ({@link plainError}, `status(...)`) takes the same `kind: "json"` lane a
197
+ * handler's plain return takes: no `Response` built, and none drained. `EMPTY_RESPONSE_CONTROLS`
198
+ * rather than the request's `c.set` on purpose - these renders happen where no context exists (before
199
+ * routing, or after it was abandoned), which is exactly why they are wrapped rather than finalized.
200
+ */
201
+ export function nodeOutcomeFromResponse(result: Response | ResponseResult): NodeServeOutcome {
202
+ if (!(result instanceof Response)) {
203
+ const plain = result.plain
204
+ if (plain !== undefined) return plainNodeOutcome(plain, EMPTY_RESPONSE_CONTROLS)
205
+ return nodeOutcomeFromResponse(result.toResponse())
206
+ }
207
+ const response = normalizeBodylessResponse(result)
167
208
  const body = nodeResponseBody(response)
168
209
  return body === undefined
169
210
  ? { kind: "response", response }
@@ -23,7 +23,9 @@ export type NifraPlugin<In extends AnyServer = AnyServer, Out extends AnyServer
23
23
  * server type it receives, preserving the caller's typed registry and context across `.use()` while
24
24
  * still allowing the plugin to register runtime hooks or handlers.
25
25
  */
26
- export type IdentityPlugin = (<S extends AnyServer>(app: S) => S) & {
26
+ export type IdentityPlugin<AddedHookOutput = never> = (<R extends Registry, Ctx, HookOutput>(
27
+ app: Server<R, Ctx, HookOutput>,
28
+ ) => Server<R, Ctx, HookOutput | AddedHookOutput>) & {
27
29
  readonly pluginName?: string
28
30
  }
29
31
 
@@ -142,11 +144,15 @@ export function definePlugin(
142
144
  * const api = server().get("/a", h).use(audit).get("/b", h) // /a AND /b stay typed
143
145
  * ```
144
146
  */
145
- export function defineIdentityPlugin(
147
+ export function defineIdentityPlugin<AddedHookOutput = never>(
146
148
  name: string,
147
- apply: <S extends AnyServer>(app: S) => S,
148
- ): IdentityPlugin {
149
- return Object.assign(apply, { pluginName: name }) as IdentityPlugin
149
+ // The runtime identity plugin may install lifecycle hooks whose richer type is intentionally not
150
+ // threaded through this legacy identity seam. Accept the resulting server here so existing
151
+ // middleware remains source-compatible; direct typed hooks and future typed middleware can use the
152
+ // concrete Server return type without forcing every identity plugin to become response-aware.
153
+ apply: <S extends AnyServer>(app: S) => S | AnyServer,
154
+ ): IdentityPlugin<AddedHookOutput> {
155
+ return Object.assign(apply, { pluginName: name }) as IdentityPlugin<AddedHookOutput>
150
156
  }
151
157
 
152
158
  /**
@@ -4,7 +4,8 @@
4
4
  * and the byte-capped HTML-form reader. Split out of the server kernel so the file stays one domain.
5
5
  */
6
6
  import { readBoundedBytes } from "./body.ts"
7
- import { jsonError, urlPartsOf } from "./http.ts"
7
+ import { plainError, urlPartsOf } from "./http.ts"
8
+ import type { ResponseResult } from "./runtime-core.ts"
8
9
  import type { RequestSource } from "./server.ts"
9
10
 
10
11
  // The query string ("?a=1", or "" when absent) - for lazily building `c.query` only when read.
@@ -123,16 +124,21 @@ const FORM_DECODER = new TextDecoder()
123
124
  * Read an HTML-form body (urlencoded) into the plain object a body schema validates - same byte
124
125
  * cap as the JSON path (a lying/absent Content-Length can't force an oversized buffer), same
125
126
  * repeated-key -> `string[]` promotion as query parsing, same `__proto__` guard.
127
+ *
128
+ * A rejection is a `ResponseResult`, not a built `Response`, matching the JSON reader: the cap is a
129
+ * framework render, and answering one on the plain lane costs what a handler's plain return costs
130
+ * (see `plainError`). The distinction is observable on the Node adapter - a `Response` there is
131
+ * untagged, so its body drains through a Web stream and the answer goes out chunked.
126
132
  */
127
133
  export async function readBoundedForm(
128
134
  req: RequestSource,
129
135
  maxBytes: number,
130
- ): Promise<Record<string, QueryValue> | Response> {
136
+ ): Promise<Record<string, QueryValue> | ResponseResult> {
131
137
  const read = await readBoundedBytes(req, maxBytes)
132
138
  if (!read.ok) {
133
139
  return read.status === 413
134
- ? jsonError(413, "payload_too_large")
135
- : jsonError(400, "invalid_content_length")
140
+ ? plainError(413, "payload_too_large")
141
+ : plainError(400, "invalid_content_length")
136
142
  }
137
143
  const out: Record<string, QueryValue> = Object.create(null) as Record<string, QueryValue>
138
144
  // URLSearchParams owns the format's quirks (`+` as space, percent-decoding, empty keys);
@@ -1,6 +1,7 @@
1
1
  import type { BinaryResponse, RawResponse } from "../binary.ts"
2
2
  import type { InferInput, InferOutput, StandardSchemaV1 } from "../schema/standard.ts"
3
3
  import type { Params, RouteSchema } from "./context.ts"
4
+ import type { StatusResponse } from "./runtime-core.ts"
4
5
 
5
6
  /**
6
7
  * One route's input/output shape as the **client** will consume it. `query`/`body`
@@ -17,6 +18,8 @@ export interface RouteInfo {
17
18
  readonly query: unknown
18
19
  readonly body: unknown
19
20
  readonly output: unknown
21
+ /** The complete inferred/declared status-keyed response map. */
22
+ readonly responses?: unknown
20
23
  /** The route's declared error bodies as a status-keyed record (from `schema.errors`, e.g.
21
24
  * `{ 404: NotFound }`); `unknown` when the route declares none. The typed client turns this into
22
25
  * a status-discriminated failure union. */
@@ -48,22 +51,73 @@ type RegistryQuery<S extends RouteSchema> = S extends { query: infer Q extends S
48
51
  ? InferInput<Q>
49
52
  : never
50
53
 
51
- /** The client-visible output: the declared `response` contract when present (so the client sees the
52
- * contract, not the handler's incidental return), otherwise the inferred handler output. */
53
- type RegistryOutput<S extends RouteSchema, Output> = S extends {
54
+ type StatusCodeOf<Output> =
55
+ Output extends StatusResponse<infer Code, unknown> ? (number extends Code ? never : Code) : never
56
+
57
+ type StatusBodyAt<Output, Code extends number> =
58
+ Output extends StatusResponse<infer Actual, infer Body>
59
+ ? Code extends Actual
60
+ ? Body
61
+ : never
62
+ : never
63
+
64
+ type IsSuccessCode<Code extends number> = `${Code}` extends `2${string}` ? true : false
65
+
66
+ type EmptyResponseMap = NonNullable<unknown>
67
+
68
+ type StatusBodies<Output, Codes extends number = StatusCodeOf<Output>> = [Codes] extends [never]
69
+ ? EmptyResponseMap
70
+ : { [Code in Codes]: StatusBodyAt<Output, Code> }
71
+
72
+ /** A dynamic status code cannot be classified as success or failure; expose an opaque success arm
73
+ * rather than guessing a status or body schema. Explicit route schemas still override this fallback. */
74
+ type DynamicStatusResponses<Output> =
75
+ Output extends StatusResponse<infer Code, unknown>
76
+ ? number extends Code
77
+ ? { 200: unknown }
78
+ : EmptyResponseMap
79
+ : EmptyResponseMap
80
+
81
+ type InferredResponses<Output> = (Exclude<
82
+ Output,
83
+ StatusResponse<number, unknown> | Response
84
+ > extends never
85
+ ? EmptyResponseMap
86
+ : { 200: Exclude<Output, StatusResponse<number, unknown> | Response> }) &
87
+ StatusBodies<Output> &
88
+ DynamicStatusResponses<Output>
89
+
90
+ type ExplicitResponses<S extends RouteSchema> = (S extends {
54
91
  response: infer R extends StandardSchemaV1
55
92
  }
56
- ? InferOutput<R>
57
- : Output
58
-
59
- /** A route's declared error-response body types as a STATUS-KEYED record (`schema.errors` →
60
- * `{ 404: NotFound, 409: Conflict }`), so the typed client can discriminate the failure body by
61
- * `status`, not just surface a union. `unknown` when the route declares no `errors`. */
62
- type RegistryErrors<S extends RouteSchema> = S extends {
63
- errors: infer E extends Record<number, StandardSchemaV1>
93
+ ? { 200: InferOutput<R> }
94
+ : EmptyResponseMap) &
95
+ (S extends { errors: infer E extends Record<number, StandardSchemaV1> }
96
+ ? { [K in keyof E]: E[K] extends StandardSchemaV1 ? InferOutput<E[K]> : never }
97
+ : EmptyResponseMap)
98
+
99
+ type MergeResponseMaps<Inferred, Explicit> = Omit<Inferred, keyof Explicit> & Explicit
100
+
101
+ type ResponseMap<S extends RouteSchema, Output> = MergeResponseMaps<
102
+ InferredResponses<Output>,
103
+ ExplicitResponses<S>
104
+ >
105
+
106
+ /** Public type seam for build-time consumers that need the complete route response map. */
107
+ export type ResponseMapFor<S extends RouteSchema, Output> = ResponseMap<S, Output>
108
+
109
+ type ResponseErrors<M> = {
110
+ [K in keyof M as K extends number ? (IsSuccessCode<K> extends true ? never : K) : never]: M[K]
64
111
  }
65
- ? { [K in keyof E]: E[K] extends StandardSchemaV1 ? InferOutput<E[K]> : never }
66
- : unknown
112
+
113
+ type ResponseSuccessBodies<M> = {
114
+ [K in keyof M]: K extends number ? (IsSuccessCode<K> extends true ? M[K] : never) : never
115
+ }[keyof M]
116
+
117
+ type NonEmptyOrUnknown<M> = keyof M extends never ? unknown : M
118
+
119
+ /** The client-visible output is the union of all inferred/declared 2xx bodies. */
120
+ type RegistryOutput<S extends RouteSchema, Output> = ResponseSuccessBodies<ResponseMap<S, Output>>
67
121
 
68
122
  /** The SSE event payload type from a route's `sse` schema; `never` for ordinary routes. */
69
123
  type RegistrySse<S extends RouteSchema> = S extends { sse: infer E extends StandardSchemaV1 }
@@ -71,12 +125,13 @@ type RegistrySse<S extends RouteSchema> = S extends { sse: infer E extends Stand
71
125
  : never
72
126
 
73
127
  /** Build a {@link RouteInfo} from a route's path, schema, and handler output type. */
74
- export type RouteInfoFor<Path extends string, S extends RouteSchema, Output> = {
128
+ export type RouteInfoFor<Path extends string, S extends RouteSchema, Output, HookOutput = never> = {
75
129
  readonly params: Params<Path>
76
130
  readonly query: RegistryQuery<S>
77
131
  readonly body: RegistryBody<S>
78
- readonly output: RegistryOutput<S, Output>
79
- readonly errors: RegistryErrors<S>
132
+ readonly output: RegistryOutput<S, Output | HookOutput>
133
+ readonly responses?: ResponseMapFor<S, Output | HookOutput>
134
+ readonly errors: NonEmptyOrUnknown<ResponseErrors<ResponseMapFor<S, Output | HookOutput>>>
80
135
  readonly sse: RegistrySse<S>
81
136
  }
82
137