@nifrajs/core 5.7.0 → 5.9.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 (223) hide show
  1. package/CHANGELOG.md +105 -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/serve-static.d.ts +22 -0
  80. package/dist/serve-static.d.ts.map +1 -0
  81. package/dist/serve-static.js +187 -0
  82. package/dist/serve-static.js.map +1 -0
  83. package/dist/server/bare-error-lane.d.ts +2 -1
  84. package/dist/server/bare-error-lane.d.ts.map +1 -1
  85. package/dist/server/bare-error-lane.js +12 -3
  86. package/dist/server/bare-error-lane.js.map +1 -1
  87. package/dist/server/base64-answers.d.ts.map +1 -1
  88. package/dist/server/base64-answers.js +1 -4
  89. package/dist/server/base64-answers.js.map +1 -1
  90. package/dist/server/body-lane.d.ts.map +1 -1
  91. package/dist/server/body-lane.js +43 -38
  92. package/dist/server/body-lane.js.map +1 -1
  93. package/dist/server/body.d.ts.map +1 -1
  94. package/dist/server/body.js +4 -1
  95. package/dist/server/body.js.map +1 -1
  96. package/dist/server/bridge-markers.d.ts +1 -1
  97. package/dist/server/bridge-markers.js +1 -1
  98. package/dist/server/cookies.d.ts.map +1 -1
  99. package/dist/server/cookies.js +4 -1
  100. package/dist/server/cookies.js.map +1 -1
  101. package/dist/server/core-copies.js +1 -1
  102. package/dist/server/http.d.ts +5 -0
  103. package/dist/server/http.d.ts.map +1 -1
  104. package/dist/server/http.js +13 -0
  105. package/dist/server/http.js.map +1 -1
  106. package/dist/server/idempotency-lane.d.ts.map +1 -1
  107. package/dist/server/idempotency-lane.js +2 -0
  108. package/dist/server/idempotency-lane.js.map +1 -1
  109. package/dist/server/idle-timeout.d.ts +39 -0
  110. package/dist/server/idle-timeout.d.ts.map +1 -0
  111. package/dist/server/idle-timeout.js +95 -0
  112. package/dist/server/idle-timeout.js.map +1 -0
  113. package/dist/server/json-shared.d.ts +23 -0
  114. package/dist/server/json-shared.d.ts.map +1 -0
  115. package/dist/server/json-shared.js +33 -0
  116. package/dist/server/json-shared.js.map +1 -0
  117. package/dist/server/json-text.d.ts +1 -1
  118. package/dist/server/json-text.js +1 -1
  119. package/dist/server/node-outcome.js +1 -1
  120. package/dist/server/node-outcome.js.map +1 -1
  121. package/dist/server/request-context.d.ts +22 -1
  122. package/dist/server/request-context.d.ts.map +1 -1
  123. package/dist/server/request-context.js +44 -4
  124. package/dist/server/request-context.js.map +1 -1
  125. package/dist/server/request-local.d.ts +44 -0
  126. package/dist/server/request-local.d.ts.map +1 -0
  127. package/dist/server/request-local.js +91 -0
  128. package/dist/server/request-local.js.map +1 -0
  129. package/dist/server/respond.js +1 -1
  130. package/dist/server/respond.js.map +1 -1
  131. package/dist/server/response-contract-lane.d.ts +10 -2
  132. package/dist/server/response-contract-lane.d.ts.map +1 -1
  133. package/dist/server/response-contract-lane.js +93 -64
  134. package/dist/server/response-contract-lane.js.map +1 -1
  135. package/dist/server/runtime-core.d.ts +7 -4
  136. package/dist/server/runtime-core.d.ts.map +1 -1
  137. package/dist/server/runtime-core.js +9 -7
  138. package/dist/server/runtime-core.js.map +1 -1
  139. package/dist/server/server-types.d.ts +27 -0
  140. package/dist/server/server-types.d.ts.map +1 -1
  141. package/dist/server/server.d.ts +59 -59
  142. package/dist/server/server.d.ts.map +1 -1
  143. package/dist/server/server.js +869 -512
  144. package/dist/server/server.js.map +1 -1
  145. package/dist/server/transport-lane.d.ts.map +1 -1
  146. package/dist/server/transport-lane.js +2 -0
  147. package/dist/server/transport-lane.js.map +1 -1
  148. package/dist/server/webhook.js +1 -1
  149. package/dist/server/webhook.js.map +1 -1
  150. package/dist/server/websocket.d.ts +3 -1
  151. package/dist/server/websocket.d.ts.map +1 -1
  152. package/dist/server/websocket.js +14 -1
  153. package/dist/server/websocket.js.map +1 -1
  154. package/dist/server/ws-adapter-view.d.ts +54 -0
  155. package/dist/server/ws-adapter-view.d.ts.map +1 -0
  156. package/dist/server/ws-adapter-view.js +7 -0
  157. package/dist/server/ws-adapter-view.js.map +1 -0
  158. package/dist/server.d.ts +2 -1
  159. package/dist/server.d.ts.map +1 -1
  160. package/dist/server.js +2 -1
  161. package/dist/server.js.map +1 -1
  162. package/dist/single-copy.d.ts.map +1 -1
  163. package/dist/single-copy.js +1 -1
  164. package/dist/single-copy.js.map +1 -1
  165. package/dist/static-file.d.ts +156 -4
  166. package/dist/static-file.d.ts.map +1 -1
  167. package/dist/static-file.js +198 -4
  168. package/dist/static-file.js.map +1 -1
  169. package/dist/wire.d.ts.map +1 -1
  170. package/dist/wire.js +1 -3
  171. package/dist/wire.js.map +1 -1
  172. package/package.json +16 -1
  173. package/src/assurance.ts +66 -16
  174. package/src/canonical-json.ts +43 -0
  175. package/src/capabilities.ts +1 -8
  176. package/src/data.ts +2 -8
  177. package/src/diff.ts +2 -12
  178. package/src/durable-adapters.ts +53 -50
  179. package/src/durable-saga.ts +30 -23
  180. package/src/evidence.ts +25 -90
  181. package/src/idempotency.ts +62 -20
  182. package/src/index.ts +13 -2
  183. package/src/internal/control-character.ts +8 -0
  184. package/src/internal/durable-shared.ts +4 -1
  185. package/src/internal/hex.ts +14 -0
  186. package/src/internal/record.ts +4 -0
  187. package/src/internal/route-compiler.ts +2 -2
  188. package/src/internal/route-execution.ts +7 -20
  189. package/src/internal/route-lanes.ts +20 -44
  190. package/src/internal/schema-parts.ts +19 -0
  191. package/src/ledger.ts +2 -16
  192. package/src/manifest.ts +10 -38
  193. package/src/open-resolved.ts +64 -0
  194. package/src/range.ts +1 -1
  195. package/src/serve-static.ts +227 -0
  196. package/src/server/bare-error-lane.ts +11 -2
  197. package/src/server/base64-answers.ts +2 -6
  198. package/src/server/body-lane.ts +58 -51
  199. package/src/server/body.ts +4 -1
  200. package/src/server/bridge-markers.ts +1 -1
  201. package/src/server/cookies.ts +4 -1
  202. package/src/server/core-copies.ts +1 -1
  203. package/src/server/http.ts +12 -0
  204. package/src/server/idempotency-lane.ts +2 -0
  205. package/src/server/idle-timeout.ts +107 -0
  206. package/src/server/json-shared.ts +38 -0
  207. package/src/server/json-text.ts +1 -1
  208. package/src/server/node-outcome.ts +1 -1
  209. package/src/server/request-context.ts +62 -3
  210. package/src/server/request-local.ts +109 -0
  211. package/src/server/respond.ts +1 -1
  212. package/src/server/response-contract-lane.ts +112 -65
  213. package/src/server/runtime-core.ts +10 -8
  214. package/src/server/server-types.ts +27 -0
  215. package/src/server/server.ts +1230 -800
  216. package/src/server/transport-lane.ts +2 -0
  217. package/src/server/webhook.ts +1 -1
  218. package/src/server/websocket.ts +18 -1
  219. package/src/server/ws-adapter-view.ts +63 -0
  220. package/src/server.ts +12 -1
  221. package/src/single-copy.ts +1 -3
  222. package/src/static-file.ts +350 -4
  223. package/src/wire.ts +2 -4
@@ -150,3 +150,15 @@ export function pathnameOf(url: string): string {
150
150
  }
151
151
  return url.slice(start, end)
152
152
  }
153
+
154
+ /**
155
+ * A `Vary` header value (`null` when the response has none) that names each of `names` once, matched
156
+ * without regard to case. `Vary: *` already covers every request header, so it comes back unchanged.
157
+ */
158
+ export function varyOn(value: string | null, names: readonly string[]): string {
159
+ if (value === null || value.trim() === "") return names.join(", ")
160
+ const fields = value.split(",").map((field) => field.trim().toLowerCase())
161
+ if (fields.includes("*")) return value
162
+ const missing = names.filter((name) => !fields.includes(name.toLowerCase()))
163
+ return missing.length === 0 ? value : `${value}, ${missing.join(", ")}`
164
+ }
@@ -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
  }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Request-local state: a value tied to one request that follows it through the copies made of it.
3
+ * A `WeakMap<Request, T>` loses its value at the first `new Request(url, req)`, and nifra itself
4
+ * rebuilds a request when a mount strips its prefix, when a WebSocket pass is reused for HTTP, and
5
+ * when a target is resolved. Every such copy joins the request's scope, so each reads and writes
6
+ * the same values.
7
+ */
8
+
9
+ /** One request-local slot from {@link requestLocal}. */
10
+ export interface RequestLocal<T> {
11
+ /** The value set through `request` or any request in its scope, else `undefined`. */
12
+ get(request: Request): T | undefined
13
+ /** Set the value for `request`'s whole scope. */
14
+ set(request: Request, value: T): void
15
+ has(request: Request): boolean
16
+ /** Remove the value from `request`'s scope; `true` when there was one. */
17
+ delete(request: Request): boolean
18
+ }
19
+
20
+ type Values = Map<object, unknown>
21
+
22
+ /**
23
+ * A copy maps to the request it was made from, and the end of that chain (the anchor) to its
24
+ * scope's values once one is set. Keyed in the global symbol registry so two copies of core keep
25
+ * one table, and weak, so a scope lives exactly as long as a request in it.
26
+ */
27
+ type Links = WeakMap<Request, Request | Values>
28
+
29
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion lint/plugin/noChainedTypeAssertions: only this module writes the slot, and only a Links table.
30
+ const shared = globalThis as unknown as Record<symbol, Links | undefined>
31
+ const LINKS = Symbol.for("nifra.requestLocals")
32
+
33
+ const linksOf = (): Links => (shared[LINKS] ??= new WeakMap())
34
+
35
+ /** The request at the end of `request`'s chain of copies. */
36
+ function anchorOf(links: Links, request: Request): Request {
37
+ let anchor = request
38
+ let link = links.get(anchor)
39
+ while (link !== undefined && !(link instanceof Map)) {
40
+ anchor = link
41
+ link = links.get(anchor)
42
+ }
43
+ return anchor
44
+ }
45
+
46
+ /** `to` was just built from `from`, with nothing set on it: it joins `from`'s scope. Until a first
47
+ * slot makes the table there is no scope to join, and a copy records nothing. */
48
+ export function carryToCopy(from: Request, to: Request): void {
49
+ shared[LINKS]?.set(to, from)
50
+ }
51
+
52
+ /**
53
+ * Join `to` to `from`'s request-local scope: each then reads every value set through either, now
54
+ * and later. nifra does this wherever it rebuilds a request itself; call it where a wrapper or an
55
+ * `onRequest` hook rebuilds one, right after `new Request(url, req)`. A value `to` already holds is
56
+ * kept where `from` has none.
57
+ */
58
+ export function carryRequestLocals(from: Request, to: Request): void {
59
+ const links = shared[LINKS]
60
+ if (links === undefined) return
61
+ const target = anchorOf(links, from)
62
+ const joining = anchorOf(links, to)
63
+ if (target === joining) return
64
+ const own = links.get(joining)
65
+ links.set(joining, target)
66
+ if (!(own instanceof Map)) return
67
+ const held = links.get(target)
68
+ if (!(held instanceof Map)) links.set(target, own)
69
+ else for (const [key, value] of own) if (!held.has(key)) held.set(key, value)
70
+ }
71
+
72
+ function valuesOf(request: Request, create: boolean): Values | undefined {
73
+ const links = linksOf()
74
+ const anchor = anchorOf(links, request)
75
+ const held = links.get(anchor)
76
+ if (held instanceof Map || !create) return held instanceof Map ? held : undefined
77
+ const fresh: Values = new Map()
78
+ links.set(anchor, fresh)
79
+ return fresh
80
+ }
81
+
82
+ /**
83
+ * A slot for one per-request value - a verified client IP, a request id, an auth decision - that
84
+ * survives the request copies nifra makes (a mount's prefix strip, a WebSocket pass reused for HTTP,
85
+ * target resolution) and the ones a wrapper makes with {@link carryRequestLocals}. A copy made any
86
+ * other way starts empty. Values are held weakly and never cross requests. Make slots at startup, as
87
+ * below: copies are linked only once a first slot exists, so a request copied before then starts
88
+ * empty.
89
+ *
90
+ * ```ts
91
+ * const clientIp = requestLocal<string>()
92
+ * app.onRequest((req, platform) => { clientIp.set(req, platform?.clientIp ?? "") })
93
+ * // ... in a mounted child app, after the prefix strip:
94
+ * clientIp.get(c.req)
95
+ * ```
96
+ */
97
+ export function requestLocal<T>(): RequestLocal<T> {
98
+ linksOf()
99
+ const key = {}
100
+ return {
101
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: only `set` below writes `key`, and only a T.
102
+ get: (request) => valuesOf(request, false)?.get(key) as T | undefined,
103
+ set: (request, value) => {
104
+ valuesOf(request, true)?.set(key, value)
105
+ },
106
+ has: (request) => valuesOf(request, false)?.has(key) === true,
107
+ delete: (request) => valuesOf(request, false)?.delete(key) === true,
108
+ }
109
+ }
@@ -39,7 +39,7 @@ function headersInit(set: CtxSet): Record<string, string> | Headers | undefined
39
39
  function plainRenderSet(plain: PlainRender, set: CtxSet): CtxSet {
40
40
  return {
41
41
  status: plain.status,
42
- _headers: plainRenderHeaders(plain, set),
42
+ _headers: plainRenderHeaders(plain.headers, set._headers),
43
43
  _cookies: set._cookies,
44
44
  } as CtxSet
45
45
  }