@nifrajs/core 5.8.0 → 5.10.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 (239) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +9 -4
  3. package/dist/assurance.d.ts +26 -1
  4. package/dist/assurance.d.ts.map +1 -1
  5. package/dist/assurance.js +43 -11
  6. package/dist/assurance.js.map +1 -1
  7. package/dist/canonical-json.d.ts +11 -0
  8. package/dist/canonical-json.d.ts.map +1 -0
  9. package/dist/canonical-json.js +46 -0
  10. package/dist/canonical-json.js.map +1 -0
  11. package/dist/capabilities.d.ts.map +1 -1
  12. package/dist/capabilities.js +1 -8
  13. package/dist/capabilities.js.map +1 -1
  14. package/dist/data.d.ts.map +1 -1
  15. package/dist/data.js +1 -8
  16. package/dist/data.js.map +1 -1
  17. package/dist/diff.d.ts.map +1 -1
  18. package/dist/diff.js +2 -12
  19. package/dist/diff.js.map +1 -1
  20. package/dist/durable-adapters.d.ts +2 -0
  21. package/dist/durable-adapters.d.ts.map +1 -1
  22. package/dist/durable-adapters.js +41 -49
  23. package/dist/durable-adapters.js.map +1 -1
  24. package/dist/durable-saga.d.ts.map +1 -1
  25. package/dist/durable-saga.js +14 -20
  26. package/dist/durable-saga.js.map +1 -1
  27. package/dist/evidence.d.ts.map +1 -1
  28. package/dist/evidence.js +14 -68
  29. package/dist/evidence.js.map +1 -1
  30. package/dist/idempotency.d.ts.map +1 -1
  31. package/dist/idempotency.js +60 -23
  32. package/dist/idempotency.js.map +1 -1
  33. package/dist/index.d.ts +3 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +3 -2
  36. package/dist/index.js.map +1 -1
  37. package/dist/internal/control-character.d.ts +3 -0
  38. package/dist/internal/control-character.d.ts.map +1 -0
  39. package/dist/internal/control-character.js +10 -0
  40. package/dist/internal/control-character.js.map +1 -0
  41. package/dist/internal/durable-shared.d.ts.map +1 -1
  42. package/dist/internal/durable-shared.js +4 -1
  43. package/dist/internal/durable-shared.js.map +1 -1
  44. package/dist/internal/hex.d.ts +5 -0
  45. package/dist/internal/hex.d.ts.map +1 -0
  46. package/dist/internal/hex.js +14 -0
  47. package/dist/internal/hex.js.map +1 -0
  48. package/dist/internal/record.d.ts +3 -0
  49. package/dist/internal/record.d.ts.map +1 -0
  50. package/dist/internal/record.js +5 -0
  51. package/dist/internal/record.js.map +1 -0
  52. package/dist/internal/route-compiler.d.ts.map +1 -1
  53. package/dist/internal/route-compiler.js +2 -2
  54. package/dist/internal/route-compiler.js.map +1 -1
  55. package/dist/internal/route-execution.d.ts +4 -4
  56. package/dist/internal/route-execution.d.ts.map +1 -1
  57. package/dist/internal/route-execution.js +4 -0
  58. package/dist/internal/route-execution.js.map +1 -1
  59. package/dist/internal/route-lanes.d.ts +4 -3
  60. package/dist/internal/route-lanes.d.ts.map +1 -1
  61. package/dist/internal/route-lanes.js +16 -33
  62. package/dist/internal/route-lanes.js.map +1 -1
  63. package/dist/internal/schema-parts.d.ts +13 -0
  64. package/dist/internal/schema-parts.d.ts.map +1 -0
  65. package/dist/internal/schema-parts.js +15 -0
  66. package/dist/internal/schema-parts.js.map +1 -0
  67. package/dist/ledger.d.ts.map +1 -1
  68. package/dist/ledger.js +2 -14
  69. package/dist/ledger.js.map +1 -1
  70. package/dist/manifest.d.ts.map +1 -1
  71. package/dist/manifest.js +10 -37
  72. package/dist/manifest.js.map +1 -1
  73. package/dist/open-resolved.d.ts +18 -0
  74. package/dist/open-resolved.d.ts.map +1 -0
  75. package/dist/open-resolved.js +62 -0
  76. package/dist/open-resolved.js.map +1 -0
  77. package/dist/range.js +1 -1
  78. package/dist/range.js.map +1 -1
  79. package/dist/router/path-codec.d.ts +22 -0
  80. package/dist/router/path-codec.d.ts.map +1 -0
  81. package/dist/router/path-codec.js +87 -0
  82. package/dist/router/path-codec.js.map +1 -0
  83. package/dist/router/pattern.d.ts +21 -1
  84. package/dist/router/pattern.d.ts.map +1 -1
  85. package/dist/router/pattern.js +117 -0
  86. package/dist/router/pattern.js.map +1 -1
  87. package/dist/router/same-origin-path.d.ts +22 -0
  88. package/dist/router/same-origin-path.d.ts.map +1 -0
  89. package/dist/router/same-origin-path.js +37 -0
  90. package/dist/router/same-origin-path.js.map +1 -0
  91. package/dist/serve-static.d.ts +22 -0
  92. package/dist/serve-static.d.ts.map +1 -0
  93. package/dist/serve-static.js +187 -0
  94. package/dist/serve-static.js.map +1 -0
  95. package/dist/server/bare-error-lane.d.ts +2 -1
  96. package/dist/server/bare-error-lane.d.ts.map +1 -1
  97. package/dist/server/bare-error-lane.js +12 -3
  98. package/dist/server/bare-error-lane.js.map +1 -1
  99. package/dist/server/base64-answers.d.ts.map +1 -1
  100. package/dist/server/base64-answers.js +1 -4
  101. package/dist/server/base64-answers.js.map +1 -1
  102. package/dist/server/body-lane.d.ts.map +1 -1
  103. package/dist/server/body-lane.js +43 -38
  104. package/dist/server/body-lane.js.map +1 -1
  105. package/dist/server/body.d.ts.map +1 -1
  106. package/dist/server/body.js +4 -1
  107. package/dist/server/body.js.map +1 -1
  108. package/dist/server/bridge-markers.d.ts +1 -1
  109. package/dist/server/bridge-markers.js +1 -1
  110. package/dist/server/cookies.d.ts.map +1 -1
  111. package/dist/server/cookies.js +4 -1
  112. package/dist/server/cookies.js.map +1 -1
  113. package/dist/server/core-copies.js +1 -1
  114. package/dist/server/core-copies.js.map +1 -1
  115. package/dist/server/http.d.ts +6 -18
  116. package/dist/server/http.d.ts.map +1 -1
  117. package/dist/server/http.js +14 -33
  118. package/dist/server/http.js.map +1 -1
  119. package/dist/server/idempotency-lane.d.ts.map +1 -1
  120. package/dist/server/idempotency-lane.js +2 -0
  121. package/dist/server/idempotency-lane.js.map +1 -1
  122. package/dist/server/idle-timeout.d.ts +39 -0
  123. package/dist/server/idle-timeout.d.ts.map +1 -0
  124. package/dist/server/idle-timeout.js +95 -0
  125. package/dist/server/idle-timeout.js.map +1 -0
  126. package/dist/server/json-shared.d.ts +23 -0
  127. package/dist/server/json-shared.d.ts.map +1 -0
  128. package/dist/server/json-shared.js +33 -0
  129. package/dist/server/json-shared.js.map +1 -0
  130. package/dist/server/json-text.d.ts +1 -1
  131. package/dist/server/json-text.js +1 -1
  132. package/dist/server/node-outcome.js +1 -1
  133. package/dist/server/node-outcome.js.map +1 -1
  134. package/dist/server/request-context.d.ts +22 -1
  135. package/dist/server/request-context.d.ts.map +1 -1
  136. package/dist/server/request-context.js +44 -4
  137. package/dist/server/request-context.js.map +1 -1
  138. package/dist/server/request-local.d.ts +44 -0
  139. package/dist/server/request-local.d.ts.map +1 -0
  140. package/dist/server/request-local.js +91 -0
  141. package/dist/server/request-local.js.map +1 -0
  142. package/dist/server/respond.js +1 -1
  143. package/dist/server/respond.js.map +1 -1
  144. package/dist/server/response-contract-lane.d.ts +10 -2
  145. package/dist/server/response-contract-lane.d.ts.map +1 -1
  146. package/dist/server/response-contract-lane.js +93 -64
  147. package/dist/server/response-contract-lane.js.map +1 -1
  148. package/dist/server/runtime-core.d.ts +7 -4
  149. package/dist/server/runtime-core.d.ts.map +1 -1
  150. package/dist/server/runtime-core.js +9 -7
  151. package/dist/server/runtime-core.js.map +1 -1
  152. package/dist/server/server-types.d.ts +23 -8
  153. package/dist/server/server-types.d.ts.map +1 -1
  154. package/dist/server/server.d.ts +58 -59
  155. package/dist/server/server.d.ts.map +1 -1
  156. package/dist/server/server.js +860 -510
  157. package/dist/server/server.js.map +1 -1
  158. package/dist/server/transport-lane.d.ts.map +1 -1
  159. package/dist/server/transport-lane.js +2 -0
  160. package/dist/server/transport-lane.js.map +1 -1
  161. package/dist/server/webhook.js +1 -1
  162. package/dist/server/webhook.js.map +1 -1
  163. package/dist/server/websocket.d.ts +3 -1
  164. package/dist/server/websocket.d.ts.map +1 -1
  165. package/dist/server/websocket.js +14 -1
  166. package/dist/server/websocket.js.map +1 -1
  167. package/dist/server/ws-adapter-view.d.ts +54 -0
  168. package/dist/server/ws-adapter-view.d.ts.map +1 -0
  169. package/dist/server/ws-adapter-view.js +7 -0
  170. package/dist/server/ws-adapter-view.js.map +1 -0
  171. package/dist/server.d.ts +2 -1
  172. package/dist/server.d.ts.map +1 -1
  173. package/dist/server.js +2 -1
  174. package/dist/server.js.map +1 -1
  175. package/dist/single-copy.d.ts.map +1 -1
  176. package/dist/single-copy.js +1 -1
  177. package/dist/single-copy.js.map +1 -1
  178. package/dist/static-file.d.ts +156 -4
  179. package/dist/static-file.d.ts.map +1 -1
  180. package/dist/static-file.js +198 -4
  181. package/dist/static-file.js.map +1 -1
  182. package/dist/wire.d.ts.map +1 -1
  183. package/dist/wire.js +1 -3
  184. package/dist/wire.js.map +1 -1
  185. package/package.json +16 -1
  186. package/src/assurance.ts +66 -16
  187. package/src/canonical-json.ts +43 -0
  188. package/src/capabilities.ts +1 -8
  189. package/src/data.ts +2 -8
  190. package/src/diff.ts +2 -12
  191. package/src/durable-adapters.ts +53 -50
  192. package/src/durable-saga.ts +30 -23
  193. package/src/evidence.ts +25 -90
  194. package/src/idempotency.ts +62 -20
  195. package/src/index.ts +13 -2
  196. package/src/internal/control-character.ts +8 -0
  197. package/src/internal/durable-shared.ts +4 -1
  198. package/src/internal/hex.ts +14 -0
  199. package/src/internal/record.ts +4 -0
  200. package/src/internal/route-compiler.ts +2 -2
  201. package/src/internal/route-execution.ts +7 -20
  202. package/src/internal/route-lanes.ts +20 -44
  203. package/src/internal/schema-parts.ts +19 -0
  204. package/src/ledger.ts +2 -16
  205. package/src/manifest.ts +10 -38
  206. package/src/open-resolved.ts +64 -0
  207. package/src/range.ts +1 -1
  208. package/src/router/path-codec.ts +93 -0
  209. package/src/router/pattern.ts +127 -0
  210. package/src/router/same-origin-path.ts +36 -0
  211. package/src/serve-static.ts +227 -0
  212. package/src/server/bare-error-lane.ts +11 -2
  213. package/src/server/base64-answers.ts +2 -6
  214. package/src/server/body-lane.ts +58 -51
  215. package/src/server/body.ts +4 -1
  216. package/src/server/bridge-markers.ts +1 -1
  217. package/src/server/cookies.ts +4 -1
  218. package/src/server/core-copies.ts +1 -1
  219. package/src/server/http.ts +13 -33
  220. package/src/server/idempotency-lane.ts +2 -0
  221. package/src/server/idle-timeout.ts +107 -0
  222. package/src/server/json-shared.ts +38 -0
  223. package/src/server/json-text.ts +1 -1
  224. package/src/server/node-outcome.ts +1 -1
  225. package/src/server/request-context.ts +62 -3
  226. package/src/server/request-local.ts +109 -0
  227. package/src/server/respond.ts +1 -1
  228. package/src/server/response-contract-lane.ts +112 -65
  229. package/src/server/runtime-core.ts +10 -8
  230. package/src/server/server-types.ts +23 -8
  231. package/src/server/server.ts +1220 -798
  232. package/src/server/transport-lane.ts +2 -0
  233. package/src/server/webhook.ts +1 -1
  234. package/src/server/websocket.ts +18 -1
  235. package/src/server/ws-adapter-view.ts +63 -0
  236. package/src/server.ts +12 -1
  237. package/src/single-copy.ts +1 -3
  238. package/src/static-file.ts +350 -4
  239. package/src/wire.ts +2 -4
@@ -31,7 +31,8 @@ export type ErrorLogDetail = "full" | "message" | "none"
31
31
  * - a `Response` or a `status(...)` is control flow - the app answering directly, or plain data.
32
32
  * Either renders through the same `finalize` a RETURNED one takes, with the request's `c.set`,
33
33
  * so a thrown one means what the return means: a `Response` ships as built, plus the cookies the
34
- * request queued (the set-session-then-`throw redirect()` pattern);
34
+ * request queued (the set-session-then-`throw redirect()` pattern); one whose body cannot render
35
+ * is a fault like any other, never a rejected request;
35
36
  * - a request body the app's own `c.req.json()` or `c.req.formData()` could not parse is the
36
37
  * client's 400 (`invalid_json`, `invalid_form`);
37
38
  * - anything else is a real fault: logged once, then a bodyless 500 (never the caller's error text).
@@ -48,7 +49,15 @@ export function renderBareError<T>(
48
49
  logError: (err: unknown, ctx: RawContext) => void,
49
50
  wrapResponse: (response: ResponseResult) => T,
50
51
  ): T {
51
- if (err instanceof Response || isResponseResult(err)) return finalize(err, responseSet(ctx), ctx)
52
+ if (err instanceof Response || isResponseResult(err)) {
53
+ try {
54
+ return finalize(err, responseSet(ctx), ctx)
55
+ } catch (renderErr) {
56
+ // A thrown answer whose body cannot render (a `toJSON` that throws) is a fault, not control flow.
57
+ logError(renderErr, ctx)
58
+ return wrapResponse(plainError(500, "internal_error"))
59
+ }
60
+ }
52
61
  const clientCode = clientBodyErrorCode(err)
53
62
  if (clientCode !== undefined) return wrapResponse(plainError(400, clientCode))
54
63
  logError(err, ctx)
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { tagStandIn } from "../internal/hook-origins.ts"
12
12
  import type { AnswerStages, JsonReplyContext } from "./answer-step.ts"
13
+ import { define } from "./json-shared.ts"
13
14
  import { isResponseResult, status } from "./runtime-core.ts"
14
15
 
15
16
  // Deeper than any real payload.
@@ -24,11 +25,6 @@ function base64(bytes: Uint8Array): string {
24
25
  return btoa(binary)
25
26
  }
26
27
 
27
- function define(out: object, key: string, value: unknown): void {
28
- // `out[key] = value` for "__proto__" would replace the prototype instead of adding the field.
29
- Object.defineProperty(out, key, { value, enumerable: true, writable: true, configurable: true })
30
- }
31
-
32
28
  /**
33
29
  * `value` with each `Uint8Array` as base64, copying only the containers above one. A container already
34
30
  * on the walk's path is left as it is, so a cycle costs one step, as it does `JSON.stringify`, which
@@ -52,7 +48,7 @@ function encoded(value: unknown, depth: number, path: Set<object>): unknown {
52
48
  }
53
49
  return out ?? value
54
50
  }
55
- let out: object | undefined
51
+ let out: Record<string, unknown> | undefined
56
52
  for (const key of Object.keys(value)) {
57
53
  const item: unknown = Reflect.get(value, key)
58
54
  const next = encoded(item, depth + 1, path)
@@ -22,16 +22,25 @@ type JsonErrorContinuation<T> = (error: unknown) => T | Promise<T>
22
22
 
23
23
  type FramedJsonSource = RequestSource & {
24
24
  readonly bytes?: () => Promise<Uint8Array>
25
+ readonly text?: () => Promise<string>
25
26
  readonly jsonWithByteLength?: () => Promise<{
26
27
  readonly value: unknown
27
28
  readonly byteLength: number
28
29
  }>
29
30
  }
30
31
 
32
+ // Every lane calls `onResult` outside its parse guard: a throw from the continuation is the app's,
33
+ // and must never come back to the client as `invalid_json`.
31
34
  const INVALID_JSON = (): ResponseResult => plainError(400, "invalid_json")
35
+ // A parameter, not a closure over the caller's `settle`: capturing it there costs every call a scope object.
36
+ const fromBuffer =
37
+ <T>(settle: (bytes: Uint8Array) => T) =>
38
+ (buffer: ArrayBuffer): T =>
39
+ settle(new Uint8Array(buffer))
32
40
 
33
41
  // `Deno.serve`'s own `Request.json()` keeps a leading UTF-8 byte order mark that its `text()` and every
34
- // other runtime's `json()` drop, so it refuses a body they parse. Deno decodes the bytes itself below.
42
+ // other runtime's `json()` drop, so it refuses a body they parse. Deno uses `text()` for an
43
+ // untouched, runtime-framed request, or decodes the checked bytes below for other sources.
35
44
  const nativeJsonKeepsBom = "Deno" in globalThis
36
45
 
37
46
  /** Read a JSON source with the declared-length precheck, delivered-byte recheck, streaming cap, and
@@ -76,17 +85,39 @@ export function readBoundedJsonSource<T>(
76
85
  ? Promise.resolve(plainError(413, "payload_too_large"))
77
86
  : Promise.resolve(plainError(413, "payload_too_large")).then(onResult)
78
87
 
88
+ // A trusted runtime frame makes Content-Length the byte bound. Wrapped or untrusted sources
89
+ // must keep the delivered-byte check below, and chunked bodies must keep the streaming cap.
90
+ if (
91
+ nativeJsonKeepsBom &&
92
+ raw === req &&
93
+ hasTrustedBodyFraming(req) &&
94
+ raw.text !== undefined &&
95
+ headerOf(req, "transfer-encoding") === null
96
+ ) {
97
+ return raw.text().then(
98
+ (text) => {
99
+ let parsed: unknown
100
+ try {
101
+ parsed = parseJsonGuarded(text, protoPoisoning)
102
+ } catch {
103
+ parsed = INVALID_JSON()
104
+ }
105
+ return onResult === undefined ? parsed : onResult(parsed)
106
+ },
107
+ () => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
108
+ )
109
+ }
110
+
79
111
  if (hasTrustedBodyFraming(req) && !nativeJsonKeepsBom) {
80
112
  return raw.json().then(
81
113
  (parsed) => {
114
+ let guarded: unknown
82
115
  try {
83
- const guarded = guardParsedValue(parsed, protoPoisoning)
84
- return onResult === undefined ? guarded : onResult(guarded)
116
+ guarded = guardParsedValue(parsed, protoPoisoning)
85
117
  } catch {
86
- return onResult === undefined
87
- ? plainError(400, "invalid_json")
88
- : onResult(plainError(400, "invalid_json"))
118
+ guarded = INVALID_JSON()
89
119
  }
120
+ return onResult === undefined ? guarded : onResult(guarded)
90
121
  },
91
122
  () => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
92
123
  )
@@ -102,60 +133,36 @@ export function readBoundedJsonSource<T>(
102
133
  ? plainError(413, "payload_too_large")
103
134
  : onResult(plainError(413, "payload_too_large"))
104
135
  }
136
+ let guarded: unknown
105
137
  try {
106
- const guarded = guardParsedValue(value, protoPoisoning)
107
- return onResult === undefined ? guarded : onResult(guarded)
138
+ guarded = guardParsedValue(value, protoPoisoning)
108
139
  } catch {
109
- return onResult === undefined
110
- ? plainError(400, "invalid_json")
111
- : onResult(plainError(400, "invalid_json"))
140
+ guarded = INVALID_JSON()
112
141
  }
142
+ return onResult === undefined ? guarded : onResult(guarded)
113
143
  },
114
144
  () => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
115
145
  )
116
146
  }
117
147
 
118
- const bytesReader = raw.bytes
119
- if (bytesReader !== undefined) {
120
- return bytesReader.call(raw).then(
121
- (bytes) => {
122
- if (bytes.byteLength > length || bytes.byteLength > maxBytes) {
123
- return onResult === undefined
124
- ? plainError(413, "payload_too_large")
125
- : onResult(plainError(413, "payload_too_large"))
126
- }
127
- try {
128
- const parsed = parseJsonGuarded(TEXT_DECODER.decode(bytes), protoPoisoning)
129
- return onResult === undefined ? parsed : onResult(parsed)
130
- } catch {
131
- return onResult === undefined
132
- ? plainError(400, "invalid_json")
133
- : onResult(plainError(400, "invalid_json"))
134
- }
135
- },
136
- () => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
137
- )
148
+ const settle = (bytes: Uint8Array) => {
149
+ if (bytes.byteLength > length || bytes.byteLength > maxBytes) {
150
+ return onResult === undefined
151
+ ? plainError(413, "payload_too_large")
152
+ : onResult(plainError(413, "payload_too_large"))
153
+ }
154
+ let parsed: unknown
155
+ try {
156
+ parsed = parseJsonGuarded(TEXT_DECODER.decode(bytes), protoPoisoning)
157
+ } catch {
158
+ parsed = INVALID_JSON()
159
+ }
160
+ return onResult === undefined ? parsed : onResult(parsed)
138
161
  }
139
-
140
- return raw.arrayBuffer().then(
141
- (buffer) => {
142
- const bytes = new Uint8Array(buffer)
143
- if (bytes.byteLength > length || bytes.byteLength > maxBytes) {
144
- return onResult === undefined
145
- ? plainError(413, "payload_too_large")
146
- : onResult(plainError(413, "payload_too_large"))
147
- }
148
- try {
149
- const parsed = parseJsonGuarded(TEXT_DECODER.decode(bytes), protoPoisoning)
150
- return onResult === undefined ? parsed : onResult(parsed)
151
- } catch {
152
- return onResult === undefined
153
- ? plainError(400, "invalid_json")
154
- : onResult(plainError(400, "invalid_json"))
155
- }
156
- },
157
- () => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
158
- )
162
+ const refuse = () => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON()))
163
+ const bytesReader = raw.bytes
164
+ if (bytesReader !== undefined) return bytesReader.call(raw).then(settle, refuse)
165
+ return raw.arrayBuffer().then(fromBuffer(settle), refuse)
159
166
  }
160
167
  }
161
168
 
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { jsonError } from "./http.ts"
11
+ import { carryToCopy } from "./request-local.ts"
11
12
 
12
13
  interface BodySource {
13
14
  readonly headers: Pick<Headers, "get">
@@ -277,7 +278,9 @@ export function capBodyBytes(request: Request, maxBytes: number): Request {
277
278
  ),
278
279
  duplex: "half",
279
280
  }
280
- return new Request(request, init)
281
+ const capped = new Request(request, init)
282
+ carryToCopy(request, capped)
283
+ return capped
281
284
  }
282
285
 
283
286
  /** First matching own-or-inherited descriptor, so instance shadows and prototype accessors both resolve. */
@@ -4,7 +4,7 @@
4
4
  * Each entry names a symbol looked up with `Symbol.for(...)`, so the two packages agree ONLY by the
5
5
  * string. A rename on one side and not the other is a silent protocol break - no type error, just a
6
6
  * symbol that never matches and a body that stops taking the direct-write lane. This module is the
7
- * single source: core defines its own symbols from these literals, and `scripts/gen-node-outcome.ts`
7
+ * single source: core defines its own symbols from these literals, and `scripts/gen-vendored.ts`
8
8
  * copies the map verbatim into the adapter's generated contract, so @nifrajs/node reads the identical
9
9
  * strings without a runtime dependency on core (the same deployment shape the generated outcome type
10
10
  * preserves).
@@ -247,7 +247,10 @@ const toBase64Url = (buf: ArrayBuffer): string => {
247
247
  let bin = ""
248
248
  const bytes = new Uint8Array(buf)
249
249
  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!)
250
- return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
250
+ return btoa(bin)
251
+ .replace(/\+/g, "-")
252
+ .replace(/\//g, "_")
253
+ .replace(/={1,2}$/, "")
251
254
  }
252
255
 
253
256
  const BASE64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
@@ -26,7 +26,7 @@ if (!copies.includes(here) && copies.push(here) > 1) {
26
26
 
27
27
  /** This copy's version: a literal like the package root's `VERSION` (no fs on the edge), which
28
28
  * `scripts/version.ts` rewrites on every release and `check:publish` asserts. */
29
- const COPY_VERSION = "5.8.0"
29
+ const COPY_VERSION = "5.10.0"
30
30
 
31
31
  /** Record this copy's version beside its URL. Called by each `server()` behind the development guard,
32
32
  * so a production bundle drops it with the version; a copy that built a server has recorded one. */
@@ -5,39 +5,7 @@
5
5
  */
6
6
  import { type ResponseResult, status as statusResult } from "./runtime-core.ts"
7
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
- // `/.//host` and `/a/..//host` (or their `%2e` forms) resolve to the path `//host`, which any
34
- // relative reference later built from that path - a client router's redirect - reads as a host.
35
- // A path-absolute reference against a valid base always parses, so this cannot throw.
36
- return (
37
- !(value.includes(".") || value.includes("%")) ||
38
- !new URL(value, "http://n.invalid").pathname.startsWith("//")
39
- )
40
- }
8
+ export { isSameOriginPath } from "../router/same-origin-path.ts"
41
9
 
42
10
  /**
43
11
  * Whether `method` is a token a route can be registered under: uppercase ASCII letters, digits and
@@ -150,3 +118,15 @@ export function pathnameOf(url: string): string {
150
118
  }
151
119
  return url.slice(start, end)
152
120
  }
121
+
122
+ /**
123
+ * A `Vary` header value (`null` when the response has none) that names each of `names` once, matched
124
+ * without regard to case. `Vary: *` already covers every request header, so it comes back unchanged.
125
+ */
126
+ export function varyOn(value: string | null, names: readonly string[]): string {
127
+ if (value === null || value.trim() === "") return names.join(", ")
128
+ const fields = value.split(",").map((field) => field.trim().toLowerCase())
129
+ if (fields.includes("*")) return value
130
+ const missing = names.filter((name) => !fields.includes(name.toLowerCase()))
131
+ return missing.length === 0 ? value : `${value}, ${missing.join(", ")}`
132
+ }
@@ -35,6 +35,7 @@ import { markTransportCap, readBoundedBytes, transportCapOf, UNLIMITED_BODY_BYTE
35
35
  import type { Platform, RouteSchema } from "./context.ts"
36
36
  import { jsonError, urlPartsOf } from "./http.ts"
37
37
  import { INSTALL_IDEMPOTENCY } from "./install.ts"
38
+ import { carryToCopy } from "./request-local.ts"
38
39
  import { CONTEXT_REQUEST, PRE_DECODED_BODY, preDecodedBody, requestOf } from "./runtime-core.ts"
39
40
  import type { AnyServer, IdentityPlugin, RawContext, RequestSource } from "./server.ts"
40
41
 
@@ -467,6 +468,7 @@ function prepareRequest(source: RequestSource, platform: Platform | undefined):
467
468
  duplex: "half",
468
469
  }
469
470
  prepared = new Request(request.url, init)
471
+ carryToCopy(request, prepared)
470
472
  const decoded = preDecodedBody(request)
471
473
  if (decoded !== undefined) Object.defineProperty(prepared, PRE_DECODED_BODY, { value: decoded })
472
474
  } catch (error) {
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `withIdleTimeout()`: one request's own idle timeout on a Bun `listen()` server, held while slow
3
+ * work runs and put back before the response goes out.
4
+ */
5
+ import type { Context } from "./context.ts"
6
+ import { CONTEXT_IDLE_TIMEOUT, getNeverAbortSignal } from "./runtime-core.ts"
7
+
8
+ type MaybePromise<T> = T | Promise<T>
9
+
10
+ type IdleTimeoutControl = (this: unknown, seconds: number | undefined) => boolean
11
+
12
+ /** The `seconds` of every scope still running on a context. Deleted when the last one ends or the
13
+ * request is aborted, which also tells a scope ending after an abort to leave the socket alone. */
14
+ const scopesOf = new WeakMap<object, number[]>()
15
+
16
+ const longest = (scopes: readonly number[]): number =>
17
+ scopes.includes(0) ? 0 : Math.max(...scopes)
18
+
19
+ /**
20
+ * Run `work` with the idle timeout of the request's connection set to `seconds` (`0` turns it off),
21
+ * then put back the listener's.
22
+ *
23
+ * Bun closes a connection that moves no bytes for `listen()`'s `idleTimeoutSec` seconds (10 by
24
+ * default), and a handler that is still working sends none, so an export or a long upstream call is
25
+ * cut mid-flight. Raising `idleTimeoutSec` raises it for every connection, idle keep-alive ones
26
+ * included; this raises it for one request. The listener's value is back before the response goes
27
+ * out because Bun keeps the last value on the socket for the requests that follow on it, so a value
28
+ * left in place would hold an idle connection open.
29
+ *
30
+ * The request's deadline (`requestTimeoutMs`, an inbound deadline) still bounds the response: when it
31
+ * fires, the 503 is sent with the listener's timeout back and `c.signal` aborts, but `work`
32
+ * keeps running unless it stops on that signal. Scopes nest: while several run on one request the
33
+ * longest applies, and `0` outlasts any number. `seconds` runs from 0 to 255, the longest Bun holds
34
+ * an idle connection; work that may idle longer takes `0`.
35
+ *
36
+ * A streamed response is sent after the handler returns, so call this inside the stream's producer
37
+ * to hold it until the last chunk. Off `listen()` - another adapter, `app.fetch` in a test - it runs
38
+ * `work` unchanged.
39
+ *
40
+ * @example
41
+ * ```ts
42
+ * import { withIdleTimeout } from "@nifrajs/core/idle-timeout"
43
+ *
44
+ * app.post("/exports", (c) => withIdleTimeout(c, 0, () => buildExport(c.body)))
45
+ * // Every route declared after this one:
46
+ * app.around((c, next) => withIdleTimeout(c, 120, next))
47
+ * ```
48
+ */
49
+ export function withIdleTimeout<T>(
50
+ c: Context,
51
+ seconds: number,
52
+ work: () => MaybePromise<T>,
53
+ ): MaybePromise<T> {
54
+ if (!Number.isSafeInteger(seconds) || seconds < 0 || seconds > 255) {
55
+ throw new RangeError("withIdleTimeout: seconds must be an integer from 0 to 255")
56
+ }
57
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: only RequestContext defines this key, with this signature; any other context leaves it undefined.
58
+ const control = (c as Context & { [CONTEXT_IDLE_TIMEOUT]?: IdleTimeoutControl })[
59
+ CONTEXT_IDLE_TIMEOUT
60
+ ]
61
+ const signal = c.signal
62
+ if (control === undefined || signal.aborted) return work()
63
+ const set = (value: number | undefined): void => {
64
+ control.call(c, value)
65
+ }
66
+ let scopes = scopesOf.get(c)
67
+ if (scopes === undefined) {
68
+ scopes = []
69
+ scopesOf.set(c, scopes)
70
+ }
71
+ const own = scopes
72
+ own.push(seconds)
73
+ set(longest(own))
74
+ // An abort is the deadline's 503 or a closed client: either way the response is final, so the
75
+ // listener's timeout goes back now rather than when the abandoned work settles.
76
+ const abort = (): void => {
77
+ if (scopesOf.get(c) !== own) return
78
+ scopesOf.delete(c)
79
+ set(undefined)
80
+ }
81
+ // The never-abort signal is one object shared by every request without a deadline.
82
+ const listens = signal !== getNeverAbortSignal()
83
+ if (listens) signal.addEventListener("abort", abort, { once: true })
84
+ const end = (): void => {
85
+ if (listens) signal.removeEventListener("abort", abort)
86
+ if (scopesOf.get(c) !== own) return
87
+ own.splice(own.indexOf(seconds), 1)
88
+ if (own.length > 0) {
89
+ set(longest(own))
90
+ return
91
+ }
92
+ scopesOf.delete(c)
93
+ set(undefined)
94
+ }
95
+ let result: MaybePromise<T>
96
+ try {
97
+ result = work()
98
+ } catch (err) {
99
+ end()
100
+ throw err
101
+ }
102
+ if (!(result instanceof Promise)) {
103
+ end()
104
+ return result
105
+ }
106
+ return result.finally(end)
107
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * What core's JSON walks share besides the writer: the `Date` hooks the response contract reads, the
3
+ * `__proto__`-safe field setter, and the plain-record tests canonical JSON uses. Apart from
4
+ * `json-text.ts` so the Node adapter's generated copy of that module holds only the writer.
5
+ */
6
+
7
+ export const DATE_TO_JSON = Date.prototype.toJSON
8
+ // What `Date.prototype.toJSON` reads a `Date`'s time value through.
9
+ const DATE_TO_PRIMITIVE = Date.prototype[Symbol.toPrimitive]
10
+ const DATE_VALUE_OF = Date.prototype.valueOf
11
+
12
+ /**
13
+ * Whether `date` reaches the `@@toPrimitive` and `valueOf` that `Date.prototype.toJSON` reads its time
14
+ * value through as they ship, on itself, its class or `Date.prototype`. Then its steps, taken by the
15
+ * caller, give what the call would: `null` for a time value that is not finite, else what its
16
+ * `toISOString` answers, called through it as `toJSON` calls it.
17
+ */
18
+ export const readsShippedTime = (date: Date): boolean =>
19
+ date[Symbol.toPrimitive] === DATE_TO_PRIMITIVE && date.valueOf === DATE_VALUE_OF
20
+
21
+ /** Sets `key` on `out` as an own field, `__proto__` included, which an assignment takes as the prototype. */
22
+ export function define(out: Record<string, unknown>, key: string, value: unknown): void {
23
+ if (key === "__proto__")
24
+ Object.defineProperty(out, key, { value, enumerable: true, writable: true, configurable: true })
25
+ else out[key] = value
26
+ }
27
+
28
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: the member is optional, so an engine without JSON.rawJSON leaves it undefined.
29
+ export const isRawJson = (JSON as JSON & { readonly isRawJSON?: (value: unknown) => boolean })
30
+ .isRawJSON
31
+
32
+ /**
33
+ * Whether `proto` is a plain record's prototype: `Object.prototype`, `null`, or any prototype whose own
34
+ * prototype is null, which admits another realm's plain object. A boxed primitive's or a class's is
35
+ * not. A `JSON.rawJSON` value's is `null` too, so a caller tells one apart first.
36
+ */
37
+ export const isPlainPrototype = (proto: unknown): boolean =>
38
+ proto === null || proto === Object.prototype || Object.getPrototypeOf(proto) === null
@@ -3,7 +3,7 @@
3
3
  * id answered a 500. Every plain JSON writer goes through this instead: a `bigint` is written as its
4
4
  * decimal string, the encoding OpenAPI generators and protobuf's JSON mapping use for 64-bit integers.
5
5
  * Only a first attempt that threw is retried, so a value without one costs nothing more. The Node
6
- * adapter runs a generated copy (scripts/gen-node-outcome.ts), so this module imports nothing.
6
+ * adapter runs a generated copy (scripts/gen-vendored.ts), so this module imports nothing.
7
7
  */
8
8
 
9
9
  const bigintAsText = (_key: string, value: unknown): unknown =>
@@ -109,7 +109,7 @@ export function plainNodeOutcome(plain: PlainRender, set: CtxSet): NodeServeOutc
109
109
  return {
110
110
  kind: "json",
111
111
  status: plain.status,
112
- headers: plainRenderHeaders(plain, set),
112
+ headers: plainRenderHeaders(plain.headers, set._headers),
113
113
  cookies: set._cookies,
114
114
  body: plain.body === undefined || isBodylessStatus(plain.status) ? null : jsonText(plain.body),
115
115
  }
@@ -17,7 +17,12 @@ import { jsonText } from "./json-text.ts"
17
17
  import type { ProtoPoisoning } from "./proto-guard.ts"
18
18
  import { searchOf } from "./query.ts"
19
19
  import { residentText, responseJsonContentType } from "./respond.ts"
20
+ import type {
21
+ ResponseContractDefinition,
22
+ ResponseContractRuntime,
23
+ } from "./response-contract-lane.ts"
20
24
  import {
25
+ CONTEXT_IDLE_TIMEOUT,
21
26
  CONTEXT_REQUEST,
22
27
  CONTEXT_SEARCH,
23
28
  CONTEXT_SET,
@@ -166,8 +171,40 @@ const TEXT_CONTENT_TYPE = "text/plain; charset=utf-8"
166
171
  * handler never reads it costs the adapter neither a lookup nor a platform object of its own. */
167
172
  export const PLATFORM_PEER = Symbol()
168
173
 
174
+ /** Where a Bun listener leaves the idle timeout of a request's connection: called with the request
175
+ * and the seconds to allow, or `undefined` to put back the listener's own. */
176
+ export const PLATFORM_IDLE_TIMEOUT = Symbol()
177
+
178
+ /** The request the adapter received, on a platform made for one request: the routed source may be a
179
+ * rewritten or stripped copy, which Bun does not know. */
180
+ export const PLATFORM_REQUEST = Symbol()
181
+
169
182
  export type PeerPlatform = Platform & {
170
183
  readonly [PLATFORM_PEER]?: (source: RequestSource) => string | undefined
184
+ readonly [PLATFORM_IDLE_TIMEOUT]?: (source: RequestSource, seconds: number | undefined) => void
185
+ readonly [PLATFORM_REQUEST]?: RequestSource
186
+ }
187
+
188
+ /**
189
+ * Whether merging `decorations` into a context can complete. A decoration named for a read-only
190
+ * accessor of the context (`req`, `set`, `signal`) makes `Object.assign` throw on every request, a
191
+ * failure the general program routes to the route's error hooks and a fused lane would not.
192
+ */
193
+ export function decorationsAssignable(decorations: Record<PropertyKey, unknown>): boolean {
194
+ for (const key of Reflect.ownKeys(decorations)) {
195
+ for (
196
+ let proto: object | null = RequestContext.prototype;
197
+ proto !== null && proto !== Object.prototype;
198
+ proto = Object.getPrototypeOf(proto)
199
+ ) {
200
+ const descriptor = Object.getOwnPropertyDescriptor(proto, key)
201
+ if (descriptor === undefined) continue
202
+ if (descriptor.get !== undefined && descriptor.set === undefined) return false
203
+ if (descriptor.writable === false) return false
204
+ break
205
+ }
206
+ }
207
+ return true
171
208
  }
172
209
 
173
210
  export class RequestContext implements RawContext {
@@ -190,6 +227,13 @@ export class RequestContext implements RawContext {
190
227
  private declare cookiesValue: Readonly<Record<string, string>> | undefined
191
228
  declare jsonReply: Response | undefined
192
229
  declare jsonBody: unknown
230
+ /** The route's response contract, if it has one, for {@link json} to write by. */
231
+ declare jsonContract:
232
+ | {
233
+ readonly runtime: ResponseContractRuntime
234
+ readonly definition: ResponseContractDefinition
235
+ }
236
+ | undefined
193
237
  private declare readonly source: RequestSource
194
238
  private declare readonly maxBodyBytes: number
195
239
  private declare readonly protoPoisoning: ProtoPoisoning
@@ -264,6 +308,14 @@ export class RequestContext implements RawContext {
264
308
  return requestOf(this.source)
265
309
  }
266
310
 
311
+ [CONTEXT_IDLE_TIMEOUT](seconds: number | undefined): boolean {
312
+ const platform = this.platformValue
313
+ const set = platform?.[PLATFORM_IDLE_TIMEOUT]
314
+ if (set === undefined) return false
315
+ set(platform?.[PLATFORM_REQUEST] ?? this.source, seconds)
316
+ return true
317
+ }
318
+
267
319
  get [CONTEXT_SEARCH](): string {
268
320
  this.searchValue ??= searchOf(this.source.url)
269
321
  return this.searchValue
@@ -336,6 +388,13 @@ export class RequestContext implements RawContext {
336
388
  */
337
389
  json(body: unknown, init?: ResponseInit | number): Response {
338
390
  const i = statusInit(init)
391
+ // A contracted route's value is written here once, as its contract judges it: the bytes and the
392
+ // check share one call of each `toJSON` hook, a `Date`'s included.
393
+ const contract = this.jsonContract
394
+ const value =
395
+ contract?.runtime.write === undefined
396
+ ? body
397
+ : contract.runtime.write(contract.definition, body, i?.status)
339
398
  let reply: Response | undefined
340
399
  if (DEFERS_RESPONSE) {
341
400
  const defer = deferredResponderFor()
@@ -344,17 +403,17 @@ export class RequestContext implements RawContext {
344
403
  // (`undefined`, a function, a symbol) - exactly the case where `Response.json` throws, so it
345
404
  // gets the throw rather than an invented body. The content-type is the runtime's own, read
346
405
  // off `Response.json` once, so a deferred response carries the same one byte for byte.
347
- const text = defer === undefined ? undefined : (jsonText(body) as string | undefined)
406
+ const text = defer === undefined ? undefined : (jsonText(value) as string | undefined)
348
407
  const headers =
349
408
  text === undefined ? undefined : ownHeaderRecord(i?.headers, responseJsonContentType())
350
409
  if (defer !== undefined && text !== undefined && headers !== undefined) {
351
410
  reply = defer(text, i?.status ?? 200, headers)
352
411
  }
353
412
  }
354
- reply ??= markResidentBody(jsonResponse(body, i))
413
+ reply ??= markResidentBody(jsonResponse(value, i))
355
414
  // Remembered so a response contract holds the value to the route's schema: this is the
356
415
  // framework's JSON helper, not a raw-Response escape hatch.
357
- this.jsonBody = body
416
+ this.jsonBody = value
358
417
  this.jsonReply = reply
359
418
  return reply
360
419
  }