@nifrajs/core 4.0.2 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/README.md +6 -2
  2. package/dist/assurance.d.ts +71 -5
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +90 -25
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/capabilities.d.ts +6 -0
  7. package/dist/capabilities.d.ts.map +1 -1
  8. package/dist/capabilities.js +52 -0
  9. package/dist/capabilities.js.map +1 -1
  10. package/dist/durable-effect.js +1 -1
  11. package/dist/durable-effect.js.map +1 -1
  12. package/dist/durable-saga.d.ts +6 -0
  13. package/dist/durable-saga.d.ts.map +1 -1
  14. package/dist/durable-saga.js +83 -10
  15. package/dist/durable-saga.js.map +1 -1
  16. package/dist/edge-kit.d.ts +7 -2
  17. package/dist/edge-kit.d.ts.map +1 -1
  18. package/dist/edge-kit.js +7 -2
  19. package/dist/edge-kit.js.map +1 -1
  20. package/dist/effect-scope.d.ts.map +1 -1
  21. package/dist/effect-scope.js +16 -5
  22. package/dist/effect-scope.js.map +1 -1
  23. package/dist/errors.d.ts +1 -1
  24. package/dist/errors.d.ts.map +1 -1
  25. package/dist/errors.js.map +1 -1
  26. package/dist/evidence.d.ts +2 -0
  27. package/dist/evidence.d.ts.map +1 -1
  28. package/dist/evidence.js +9 -1
  29. package/dist/evidence.js.map +1 -1
  30. package/dist/index.d.ts +4 -3
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +3 -2
  33. package/dist/index.js.map +1 -1
  34. package/dist/internal/durable-shared.d.ts +1 -2
  35. package/dist/internal/durable-shared.d.ts.map +1 -1
  36. package/dist/internal/durable-shared.js +25 -44
  37. package/dist/internal/durable-shared.js.map +1 -1
  38. package/dist/internal/durable-types.d.ts +3 -0
  39. package/dist/internal/durable-types.d.ts.map +1 -1
  40. package/dist/internal/hook-origins.d.ts +67 -0
  41. package/dist/internal/hook-origins.d.ts.map +1 -0
  42. package/dist/internal/hook-origins.js +86 -0
  43. package/dist/internal/hook-origins.js.map +1 -0
  44. package/dist/internal/route-assurance.d.ts +6 -3
  45. package/dist/internal/route-assurance.d.ts.map +1 -1
  46. package/dist/internal/route-assurance.js +29 -14
  47. package/dist/internal/route-assurance.js.map +1 -1
  48. package/dist/internal/route-program.d.ts +1 -1
  49. package/dist/internal/route-program.d.ts.map +1 -1
  50. package/dist/internal/route-program.js +59 -16
  51. package/dist/internal/route-program.js.map +1 -1
  52. package/dist/reconciliation-worker.d.ts.map +1 -1
  53. package/dist/reconciliation-worker.js +12 -2
  54. package/dist/reconciliation-worker.js.map +1 -1
  55. package/dist/reflection.d.ts +53 -2
  56. package/dist/reflection.d.ts.map +1 -1
  57. package/dist/reflection.js +352 -5
  58. package/dist/reflection.js.map +1 -1
  59. package/dist/router/pattern.d.ts +1 -1
  60. package/dist/router/pattern.d.ts.map +1 -1
  61. package/dist/router/pattern.js +6 -3
  62. package/dist/router/pattern.js.map +1 -1
  63. package/dist/security/baseline.d.ts +3 -0
  64. package/dist/security/baseline.d.ts.map +1 -1
  65. package/dist/security/baseline.js +9 -5
  66. package/dist/security/baseline.js.map +1 -1
  67. package/dist/server/auth.d.ts +32 -3
  68. package/dist/server/auth.d.ts.map +1 -1
  69. package/dist/server/auth.js +23 -0
  70. package/dist/server/auth.js.map +1 -1
  71. package/dist/server/body.d.ts +7 -0
  72. package/dist/server/body.d.ts.map +1 -1
  73. package/dist/server/body.js +34 -1
  74. package/dist/server/body.js.map +1 -1
  75. package/dist/server/client-ip.d.ts +3 -0
  76. package/dist/server/client-ip.d.ts.map +1 -1
  77. package/dist/server/client-ip.js.map +1 -1
  78. package/dist/server/context.d.ts +2 -1
  79. package/dist/server/context.d.ts.map +1 -1
  80. package/dist/server/idempotency-lane.d.ts +6 -3
  81. package/dist/server/idempotency-lane.d.ts.map +1 -1
  82. package/dist/server/idempotency-lane.js +38 -17
  83. package/dist/server/idempotency-lane.js.map +1 -1
  84. package/dist/server/install.d.ts +2 -0
  85. package/dist/server/install.d.ts.map +1 -1
  86. package/dist/server/install.js +2 -0
  87. package/dist/server/install.js.map +1 -1
  88. package/dist/server/logger.d.ts.map +1 -1
  89. package/dist/server/logger.js +3 -1
  90. package/dist/server/logger.js.map +1 -1
  91. package/dist/server/methods.d.ts +1 -0
  92. package/dist/server/methods.d.ts.map +1 -1
  93. package/dist/server/methods.js +1 -0
  94. package/dist/server/methods.js.map +1 -1
  95. package/dist/server/node-outcome.d.ts.map +1 -1
  96. package/dist/server/node-outcome.js +29 -11
  97. package/dist/server/node-outcome.js.map +1 -1
  98. package/dist/server/platform-adapters.d.ts.map +1 -1
  99. package/dist/server/platform-adapters.js +3 -2
  100. package/dist/server/platform-adapters.js.map +1 -1
  101. package/dist/server/request-context.d.ts +2 -1
  102. package/dist/server/request-context.d.ts.map +1 -1
  103. package/dist/server/request-context.js +4 -1
  104. package/dist/server/request-context.js.map +1 -1
  105. package/dist/server/respond.d.ts +44 -11
  106. package/dist/server/respond.d.ts.map +1 -1
  107. package/dist/server/respond.js +192 -79
  108. package/dist/server/respond.js.map +1 -1
  109. package/dist/server/response-observer-runtime.d.ts.map +1 -1
  110. package/dist/server/response-observer-runtime.js +10 -41
  111. package/dist/server/response-observer-runtime.js.map +1 -1
  112. package/dist/server/runtime-core.d.ts +2 -0
  113. package/dist/server/runtime-core.d.ts.map +1 -1
  114. package/dist/server/runtime-core.js +2 -0
  115. package/dist/server/runtime-core.js.map +1 -1
  116. package/dist/server/server-types.d.ts +29 -5
  117. package/dist/server/server-types.d.ts.map +1 -1
  118. package/dist/server/server.d.ts +44 -31
  119. package/dist/server/server.d.ts.map +1 -1
  120. package/dist/server/server.js +444 -321
  121. package/dist/server/server.js.map +1 -1
  122. package/dist/server/sse.d.ts +11 -1
  123. package/dist/server/sse.d.ts.map +1 -1
  124. package/dist/server/sse.js +18 -3
  125. package/dist/server/sse.js.map +1 -1
  126. package/dist/server/transport-lane.d.ts +7 -1
  127. package/dist/server/transport-lane.d.ts.map +1 -1
  128. package/dist/server/transport-lane.js.map +1 -1
  129. package/dist/server/webhook.d.ts +5 -2
  130. package/dist/server/webhook.d.ts.map +1 -1
  131. package/dist/server/webhook.js +17 -1
  132. package/dist/server/webhook.js.map +1 -1
  133. package/dist/server/websocket.d.ts +22 -14
  134. package/dist/server/websocket.d.ts.map +1 -1
  135. package/dist/server/websocket.js +21 -0
  136. package/dist/server/websocket.js.map +1 -1
  137. package/dist/server/ws-hook.d.ts +7 -1
  138. package/dist/server/ws-hook.d.ts.map +1 -1
  139. package/dist/server.d.ts +3 -2
  140. package/dist/server.d.ts.map +1 -1
  141. package/dist/server.js +2 -1
  142. package/dist/server.js.map +1 -1
  143. package/dist/single-copy.d.ts +6 -0
  144. package/dist/single-copy.d.ts.map +1 -1
  145. package/dist/single-copy.js +20 -1
  146. package/dist/single-copy.js.map +1 -1
  147. package/dist/tool-contract.d.ts.map +1 -1
  148. package/dist/tool-contract.js +71 -40
  149. package/dist/tool-contract.js.map +1 -1
  150. package/dist/ws.d.ts.map +1 -1
  151. package/dist/ws.js +3 -1
  152. package/dist/ws.js.map +1 -1
  153. package/package.json +1 -1
  154. package/src/assurance.ts +180 -31
  155. package/src/capabilities.ts +59 -0
  156. package/src/durable-effect.ts +1 -1
  157. package/src/durable-saga.ts +94 -10
  158. package/src/edge-kit.ts +7 -2
  159. package/src/effect-scope.ts +15 -5
  160. package/src/errors.ts +1 -0
  161. package/src/evidence.ts +12 -3
  162. package/src/index.ts +6 -1
  163. package/src/internal/durable-shared.ts +25 -37
  164. package/src/internal/durable-types.ts +3 -0
  165. package/src/internal/hook-origins.ts +153 -0
  166. package/src/internal/route-assurance.ts +32 -18
  167. package/src/internal/route-program.ts +98 -44
  168. package/src/reconciliation-worker.ts +10 -2
  169. package/src/reflection.ts +455 -4
  170. package/src/router/pattern.ts +6 -3
  171. package/src/security/baseline.ts +10 -5
  172. package/src/server/auth.ts +45 -3
  173. package/src/server/body.ts +34 -1
  174. package/src/server/client-ip.ts +3 -0
  175. package/src/server/context.ts +2 -1
  176. package/src/server/idempotency-lane.ts +64 -22
  177. package/src/server/install.ts +3 -0
  178. package/src/server/logger.ts +3 -1
  179. package/src/server/methods.ts +1 -0
  180. package/src/server/node-outcome.ts +34 -12
  181. package/src/server/platform-adapters.ts +3 -2
  182. package/src/server/request-context.ts +5 -0
  183. package/src/server/respond.ts +222 -69
  184. package/src/server/response-observer-runtime.ts +16 -38
  185. package/src/server/runtime-core.ts +2 -0
  186. package/src/server/server-types.ts +29 -5
  187. package/src/server/server.ts +659 -530
  188. package/src/server/sse.ts +84 -55
  189. package/src/server/transport-lane.ts +7 -1
  190. package/src/server/webhook.ts +22 -3
  191. package/src/server/websocket.ts +44 -5
  192. package/src/server/ws-hook.ts +5 -0
  193. package/src/server.ts +5 -0
  194. package/src/single-copy.ts +20 -1
  195. package/src/tool-contract.ts +67 -39
  196. package/src/ws.ts +4 -0
@@ -5,13 +5,16 @@
5
5
  */
6
6
 
7
7
  import { NODE_BRIDGE_MARKER_KEYS } from "./bridge-markers.ts"
8
+ import { plainError } from "./http.ts"
8
9
  import {
10
+ CONTEXT_REQUEST,
9
11
  CONTEXT_SET,
10
12
  EMPTY_RESPONSE_CONTROLS,
11
13
  type HandlerResult,
12
14
  isResponseResult,
13
15
  type PlainRender,
14
16
  plainRenderHeaders,
17
+ type ResponseResult,
15
18
  } from "./runtime-core.ts"
16
19
  import type { CtxSet, RawContext } from "./server.ts"
17
20
  import { mergeStaticHeaderRecord, type StaticResponseHeaders } from "./static-headers.ts"
@@ -76,28 +79,26 @@ export function buildStaticResponseHeaders(
76
79
  /**
77
80
  * Apply the static defaults to an ALREADY-BUILT response - the framework's error/404/timeout renders
78
81
  * and a handler-returned `Response`, none of which go through the header init below. Defaults, so a
79
- * name the response already carries is left alone. A guarded `Headers` (a raw `fetch()` result a
80
- * handler returned) rejects every write, so the first `set` throws with nothing applied and the
81
- * retry lands on a mutable copy.
82
+ * name the response already carries is left alone, and a response needing none is returned untouched.
83
+ * The writes land on {@link writableResponse}'s answer, never on an object other requests may share;
84
+ * `request` is the request being answered.
82
85
  */
83
86
  export function applyStaticResponseHeaders(
84
87
  response: Response,
85
88
  statics: StaticResponseHeaders,
89
+ request?: Request,
86
90
  ): Response {
87
- const headers = response.headers
88
- if (knownMutableHeaders(headers)) {
89
- applyStaticDefaults(headers, statics)
90
- return response
91
- }
92
- try {
93
- applyStaticDefaults(headers, statics)
94
- } catch {
95
- const clone = new Response(response.body, response)
96
- applyStaticDefaults(clone.headers, statics)
97
- return stamped(clone)
91
+ let target = response
92
+ const entries = statics.entries
93
+ for (let i = 0; i < entries.length; i++) {
94
+ const entry = entries[i]!
95
+ const current = response.headers.get(entry[0])
96
+ // Set by the request, or already the declared value: nothing to write.
97
+ if (current === entry[1] || current !== (statics.inherited?.[entry[0]] ?? null)) continue
98
+ if (target === response) target = writableResponse(response, request)
99
+ target.headers.set(entry[0], entry[1])
98
100
  }
99
- rememberMutableHeaders(headers)
100
- return response
101
+ return target
101
102
  }
102
103
 
103
104
  /**
@@ -129,8 +130,12 @@ function staticJsonResponse(
129
130
  return new Response(body, init)
130
131
  }
131
132
 
132
- function withStatics(response: Response, statics: StaticResponseHeaders | undefined): Response {
133
- return statics === undefined ? response : applyStaticResponseHeaders(response, statics)
133
+ function withStatics(
134
+ response: Response,
135
+ statics: StaticResponseHeaders | undefined,
136
+ request: Request | undefined,
137
+ ): Response {
138
+ return statics === undefined ? response : applyStaticResponseHeaders(response, statics, request)
134
139
  }
135
140
 
136
141
  function applyStaticDefaults(headers: Headers, statics: StaticResponseHeaders): void {
@@ -212,7 +217,7 @@ function jsonResponseWithOwnHeaders(
212
217
  ): Response {
213
218
  const response = status === 200 ? Response.json(result) : Response.json(result, { status })
214
219
  for (const name of Object.keys(headers)) response.headers.set(name, headers[name]!)
215
- return stamped(response)
220
+ return withBody(response)
216
221
  }
217
222
 
218
223
  // The framework-buffered-body marker shared with the Node adapter: a Response carrying it exposes
@@ -221,44 +226,118 @@ function jsonResponseWithOwnHeaders(
221
226
  const RESPONSE_BODY = Symbol.for(NODE_BRIDGE_MARKER_KEYS.responseBody)
222
227
 
223
228
  /**
224
- * Headers objects the framework itself constructed - guaranteed mutable, no guard. Response
225
- * middleware adapters consult this before falling back to a mutability probe, so the hot path
226
- * (every framework-rendered response) never pays per-request `Headers` operations to learn what
227
- * construction already knew; only a handler-returned foreign `Response` (a raw `fetch()` result,
228
- * whose headers are guarded immutable) reaches the probe.
229
+ * Responses known to carry a body and take header writes: framework renders built with one, and
230
+ * foreign ones `writableResponse` has already checked. A body is sent once, so such a response belongs
231
+ * to whichever request holds it and is written in place without reading `.body` again - on Bun that
232
+ * read turns a string body into a stream, measured at about 6% of a request.
229
233
  */
230
- const MUTABLE_RESPONSE_HEADERS = new WeakSet<Headers>()
234
+ const WRITABLE_BODY_RESPONSES = new WeakSet<Response>()
231
235
 
232
- export function knownMutableHeaders(headers: Headers): boolean {
233
- return MUTABLE_RESPONSE_HEADERS.has(headers)
236
+ /**
237
+ * Body-less responses nifra built for one request - `writableResponse` copies and the framework's own
238
+ * body-less renders - by that request. Only objects nifra constructs are entered, never one a handler
239
+ * or hook hands over, so the request's later writers write them in place and any other request that
240
+ * is handed one copies it.
241
+ */
242
+ const REQUEST_OWNED = new WeakMap<Response, Request>()
243
+
244
+ function ownedBy(response: Response, request: Request | undefined): Response {
245
+ if (request !== undefined) REQUEST_OWNED.set(response, request)
246
+ return response
234
247
  }
235
248
 
236
- export function rememberMutableHeaders(headers: Headers): void {
237
- MUTABLE_RESPONSE_HEADERS.add(headers)
249
+ /** Record a Response the framework constructed: one built with a body is single-use and writable. */
250
+ export function rememberRenderedResponse(response: Response, hasBody: boolean): Response {
251
+ if (hasBody) WRITABLE_BODY_RESPONSES.add(response)
252
+ return response
238
253
  }
239
254
 
240
- /** Stamp a framework-constructed Response's headers as known-mutable, and return it. */
241
- function stamped(response: Response): Response {
242
- MUTABLE_RESPONSE_HEADERS.add(response.headers)
255
+ /** `rememberRenderedResponse` for a Response the framework constructed with a body. */
256
+ function withBody(response: Response): Response {
257
+ WRITABLE_BODY_RESPONSES.add(response)
243
258
  return response
244
259
  }
245
260
 
261
+ const HEADER_MUTABILITY_PROBE = "x-nifra-header-probe"
262
+
263
+ /** Whether `headers` takes writes. A guarded `Headers` rejects the first one, so the probe changes
264
+ * nothing either way. */
265
+ function headersTakeWrites(headers: Headers): boolean {
266
+ try {
267
+ const previous = headers.get(HEADER_MUTABILITY_PROBE)
268
+ headers.set(HEADER_MUTABILITY_PROBE, "1")
269
+ if (previous === null) headers.delete(HEADER_MUTABILITY_PROBE)
270
+ else headers.set(HEADER_MUTABILITY_PROBE, previous)
271
+ } catch {
272
+ return false
273
+ }
274
+ return true
275
+ }
276
+
277
+ /**
278
+ * The response to write headers on for the request being answered: `response` itself, or a copy.
279
+ *
280
+ * A Response without a body can be sent any number of times, so the object a handler, hook or cache
281
+ * hands over may be answering other requests too - a module-level `new Response(null, { status: 204 })`,
282
+ * a kept redirect, even one nifra rendered earlier. A cookie or per-request header written onto it would
283
+ * ship with every later request it answers, so it is always copied, whoever built it. One with a body
284
+ * can be sent only once, so it already belongs to this request and is written in place; a copy would
285
+ * cost Bun's file responses their sendfile path and buy nothing. Guarded `Headers`
286
+ * (`Response.redirect()`, a raw `fetch()` result) reject writes, so those are copied as well.
287
+ *
288
+ * Pass the `request` being answered and a copy made for it is written in place by its later writers;
289
+ * handed back on any other request, that copy is copied again.
290
+ */
291
+ export function writableResponse(response: Response, request?: Request): Response {
292
+ if (WRITABLE_BODY_RESPONSES.has(response)) return response
293
+ if (response.body === null) {
294
+ // A 101 upgrade and the status-0 `Response.error()` cannot be constructed, and a runtime makes
295
+ // them per request.
296
+ if (response.status < 200) return response
297
+ if (request !== undefined && REQUEST_OWNED.get(response) === request) return response
298
+ const copy = copiedResponse(
299
+ response,
300
+ new Response(null, {
301
+ status: response.status,
302
+ statusText: response.statusText,
303
+ headers: response.headers,
304
+ }),
305
+ false,
306
+ )
307
+ return ownedBy(copy, request)
308
+ }
309
+ if (headersTakeWrites(response.headers)) {
310
+ WRITABLE_BODY_RESPONSES.add(response)
311
+ return response
312
+ }
313
+ return copiedResponse(response, new Response(response.body, response), true)
314
+ }
315
+
316
+ /** `copy`, recorded and carrying `original`'s buffered-body marker: the Node writer still sends the
317
+ * serialized bytes directly, and a marked body-less Response keeps the body only the marker holds. */
318
+ function copiedResponse(original: Response, copy: Response, hasBody: boolean): Response {
319
+ const marker: unknown = Reflect.get(original, RESPONSE_BODY)
320
+ if (marker !== undefined) Object.defineProperty(copy, RESPONSE_BODY, { value: marker })
321
+ return rememberRenderedResponse(copy, hasBody)
322
+ }
323
+
246
324
  function isBodylessStatus(status: number): boolean {
247
325
  return status === 204 || status === 205 || status === 304
248
326
  }
249
327
 
250
328
  /** Normalize handler-produced bodyless responses before response middleware sees them. */
251
- export function normalizeBodylessResponse(response: Response): Response {
329
+ export function normalizeBodylessResponse(response: Response, request?: Request): Response {
252
330
  if (!isBodylessStatus(response.status)) return response
253
331
  if (response.body === null && !response.headers.has("content-length")) return response
254
332
  const headers = new Headers(response.headers)
255
333
  headers.delete("content-length")
256
- return stamped(
334
+ return ownedBy(
257
335
  new Response(null, {
258
336
  status: response.status,
259
337
  statusText: response.statusText,
260
338
  headers,
261
339
  }),
340
+ request,
262
341
  )
263
342
  }
264
343
 
@@ -321,7 +400,7 @@ function tagged(
321
400
  body: string,
322
401
  tagResponseBody: ResponseBodyTagOption,
323
402
  ): Response {
324
- stamped(response)
403
+ withBody(response)
325
404
  return tagResponseBody === false || tagResponseBody === undefined
326
405
  ? response
327
406
  : markTaggedResponse(
@@ -339,14 +418,15 @@ export function fusedRespondNoSet(
339
418
  tagResponseBody: ResponseBodyTagOption = false,
340
419
  statics?: StaticResponseHeaders,
341
420
  unstamped = false,
421
+ request?: Request,
342
422
  ): Response {
343
- // With no response hook registered, no code path ever asks whether these headers are mutable, so
344
- // stamping the Headers object into the mutability WeakSet is dead work - `Response.json` alone is
345
- // cheaper on Bun than the same construction plus the WeakSet write. `unstamped` is the caller's
346
- // assertion that the hook walk is empty; an app that registers ANY response hook keeps the stamp,
347
- // because the alternative is the set/delete mutability probe on every single response (each one
348
- // carries a fresh `Headers`, so the probe's memo never amortizes) - measured 19% slower than
349
- // stamping. Default false: an unknown caller pays one WeakSet write rather than a probe per response.
423
+ // With no response hook registered, no code path ever asks whether this response takes writes in
424
+ // place, so recording it in the body WeakSet is dead work - `Response.json` alone is cheaper on Bun
425
+ // than the same construction plus the WeakSet write. `unstamped` is the caller's assertion that the
426
+ // hook walk is empty; an app that registers ANY response hook keeps the record, because the
427
+ // alternative is `writableResponse` reading `.body` and probing the headers of every response (each
428
+ // one is fresh, so nothing amortizes) - the probe alone measured 19% slower than the WeakSet write.
429
+ // Default false: an unknown caller pays one WeakSet write rather than a probe per response.
350
430
  if (
351
431
  unstamped &&
352
432
  (tagResponseBody === false || tagResponseBody === undefined) &&
@@ -379,7 +459,7 @@ export function fusedRespondNoSet(
379
459
  const entry = statics.entries[i]!
380
460
  response.headers.set(entry[0], entry[1])
381
461
  }
382
- return stamped(response)
462
+ return withBody(response)
383
463
  }
384
464
  // With declared static headers the prebuilt init already carries them plus the JSON
385
465
  // content-type, so this lane still constructs the response in one call - the whole point of
@@ -406,13 +486,19 @@ export function fusedRespondNoSet(
406
486
  tagResponseBody,
407
487
  )
408
488
  }
409
- return stamped(
489
+ return withBody(
410
490
  statics === undefined
411
491
  ? Response.json(result)
412
492
  : Response.json(result, statics.responseJsonInit200()),
413
493
  )
414
494
  }
415
- return toResponse(result as HandlerResult, EMPTY_RESPONSE_CONTROLS, tagResponseBody, statics)
495
+ return toResponse(
496
+ result as HandlerResult,
497
+ EMPTY_RESPONSE_CONTROLS,
498
+ tagResponseBody,
499
+ statics,
500
+ request,
501
+ )
416
502
  }
417
503
 
418
504
  /** Fused-lane respond with a context: read `c.set` once; untouched (the common case) -> the fast
@@ -425,15 +511,23 @@ export function fusedRespond(
425
511
  unstamped = false,
426
512
  ): Response {
427
513
  const set = ctx[CONTEXT_SET]()
428
- if (set === undefined) return fusedRespondNoSet(result, tagResponseBody, statics, unstamped)
429
- return toResponse(result as HandlerResult, set, tagResponseBody, statics)
514
+ const request = unstamped ? undefined : ctx[CONTEXT_REQUEST]
515
+ if (set === undefined)
516
+ return fusedRespondNoSet(result, tagResponseBody, statics, unstamped, request)
517
+ return toResponse(result as HandlerResult, set, tagResponseBody, statics, request)
430
518
  }
431
519
 
520
+ /**
521
+ * Render a handler's result. `request` is the request being answered, given when response hooks will
522
+ * write next: a copy or body-less response built here is then recorded as that request's own, so the
523
+ * hooks write it in place instead of copying it again.
524
+ */
432
525
  export function toResponse(
433
526
  result: HandlerResult,
434
527
  set: CtxSet,
435
528
  tagResponseBody: ResponseBodyTagOption = false,
436
529
  statics?: StaticResponseHeaders,
530
+ request?: Request,
437
531
  ): Response {
438
532
  if (isResponseResult(result)) {
439
533
  const plain = result.plain
@@ -446,16 +540,56 @@ export function toResponse(
446
540
  plainRenderSet(plain, set),
447
541
  tagResponseBody,
448
542
  statics,
543
+ request,
449
544
  )
450
545
  }
451
546
  return withStatics(
452
- appendCookiesToResponse(normalizeBodylessResponse(result.toResponse()), set),
547
+ applySetToResponse(normalizeBodylessResponse(result.toResponse(), request), set, request),
453
548
  statics,
549
+ request,
454
550
  )
455
551
  }
456
552
  if (result instanceof Response) {
457
- return withStatics(appendCookiesToResponse(normalizeBodylessResponse(result), set), statics)
553
+ if (result.bodyUsed) {
554
+ return toResponse(SENT_BODY, EMPTY_RESPONSE_CONTROLS, false, statics, request)
555
+ }
556
+ return withStatics(
557
+ applySetToResponse(normalizeBodylessResponse(result, request), set, request),
558
+ statics,
559
+ request,
560
+ )
458
561
  }
562
+ return renderValue(result, set, tagResponseBody, statics, request)
563
+ }
564
+
565
+ /**
566
+ * Render a framework {@link ResponseResult} - a rejection such as a `413`, `415` or `422` - with no
567
+ * request controls, byte-identical to `toResponse(result, EMPTY_RESPONSE_CONTROLS)`. It never takes a
568
+ * handler's `Response`, so a compact server rendering only its own rejections ships none of the code
569
+ * that adopts one.
570
+ */
571
+ export function renderResult(result: ResponseResult): Response {
572
+ const plain = result.plain
573
+ return plain === undefined
574
+ ? normalizeBodylessResponse(result.toResponse())
575
+ : renderValue(
576
+ plain.body,
577
+ plainRenderSet(plain, EMPTY_RESPONSE_CONTROLS),
578
+ false,
579
+ undefined,
580
+ undefined,
581
+ )
582
+ }
583
+
584
+ /** The JSON lane: a value that is neither a `Response` nor a response result, rendered with the
585
+ * request's status, headers and cookies. */
586
+ function renderValue(
587
+ result: unknown,
588
+ set: CtxSet,
589
+ tagResponseBody: ResponseBodyTagOption,
590
+ statics: StaticResponseHeaders | undefined,
591
+ request: Request | undefined,
592
+ ): Response {
459
593
  // The static tier only changes WHICH prebuilt header shape the branches below reach for: the
460
594
  // request's own headers are folded on top of the declared defaults once, here.
461
595
  const own = headersInit(set)
@@ -463,7 +597,10 @@ export function toResponse(
463
597
  const jsonCtHeaders = statics === undefined ? JSON_CT_HEADERS : statics.jsonHeaders
464
598
  const status = set.status ?? (result === undefined ? 204 : 200)
465
599
  if (status === 204 || status === 205 || status === 304) {
466
- return stamped(new Response(null, headers === undefined ? { status } : { status, headers }))
600
+ return ownedBy(
601
+ new Response(null, headers === undefined ? { status } : { status, headers }),
602
+ request,
603
+ )
467
604
  }
468
605
  // Bun and Deno both make a JSON response first and then accept a few header mutations more cheaply
469
606
  // than ingesting the same plain record in the constructor. This is the common dynamic-header
@@ -505,7 +642,7 @@ export function toResponse(
505
642
  }
506
643
  }
507
644
  const init: ResponseInit = headers === undefined ? { status } : { status, headers }
508
- if (result === undefined) return stamped(new Response(null, init))
645
+ if (result === undefined) return ownedBy(new Response(null, init), request)
509
646
  if (tagResponseBody) {
510
647
  const body = JSON.stringify(result) as string | undefined
511
648
  if (body !== undefined) {
@@ -528,9 +665,9 @@ export function toResponse(
528
665
  }
529
666
  if (IS_DENO && headers !== undefined && !(headers instanceof Headers)) {
530
667
  const body = JSON.stringify(result) as string | undefined
531
- if (body !== undefined) return stamped(denoResponseWithJsonBody(body, status, headers))
668
+ if (body !== undefined) return withBody(denoResponseWithJsonBody(body, status, headers))
532
669
  }
533
- return stamped(Response.json(result, init))
670
+ return withBody(Response.json(result, init))
534
671
  }
535
672
 
536
673
  // Add a JSON content-type without pre-building a `Headers` instance just to check-and-set one key:
@@ -553,21 +690,37 @@ function withJsonContentType(
553
690
  : { ...headers, "content-type": "application/json;charset=utf-8" }
554
691
  }
555
692
 
556
- export function appendCookiesToResponse(response: Response, set: CtxSet): Response {
557
- // A handler may queue cookies (`c.set.cookie` - e.g. a session cookie) AND return its own Response
558
- // (e.g. `redirect("/")` after login). Cookies accumulate additively, so append them to the
559
- // returned Response - otherwise the canonical set-session-then-redirect pattern would silently drop
560
- // the cookie. (Other `c.set` fields stay the returned Response's own concern.)
561
- const cookies = set._cookies
562
- if (cookies === undefined || cookies.length === 0) return response
563
- try {
564
- for (const cookie of cookies) response.headers.append("set-cookie", cookie)
565
- return response
566
- } catch {
567
- // Guarded `Headers` (`Response.redirect()`, a raw `fetch()` result) reject every write, so the
568
- // first append throws with nothing applied and the cookies land on a mutable copy instead.
569
- const clone = new Response(response.body, response)
570
- for (const cookie of cookies) clone.headers.append("set-cookie", cookie)
571
- return stamped(clone)
693
+ /** Headers that describe a Response's body or its framing: a `c.set` value for one describes a body
694
+ * the handler did not return. */
695
+ export const RESPONSE_OWNED_HEADERS: ReadonlySet<string> = new Set([
696
+ "content-type",
697
+ "content-length",
698
+ "content-encoding",
699
+ "content-range",
700
+ "transfer-encoding",
701
+ ])
702
+
703
+ export function applySetToResponse(response: Response, set: CtxSet, request?: Request): Response {
704
+ // A guard's `c.set.headers` (a `cache-control: no-store` on private data) and queued cookies belong
705
+ // to this request whatever the handler returned, so they go on `writableResponse`'s answer, never on
706
+ // an object another request may be served from. The Response's own headers win, and the ones that
707
+ // describe its body or framing come from it alone. A 101 or `Response.error()` takes no headers.
708
+ let target = response
709
+ const headers = set._headers
710
+ if (headers !== undefined && response.status >= 200) {
711
+ for (const name in headers) {
712
+ if (RESPONSE_OWNED_HEADERS.has(name.toLowerCase()) || target.headers.has(name)) continue
713
+ if (target === response) target = writableResponse(response, request)
714
+ target.headers.set(name, headers[name]!)
715
+ }
572
716
  }
717
+ const cookies = set._cookies
718
+ if (cookies === undefined || cookies.length === 0) return target
719
+ if (target === response) target = writableResponse(response, request)
720
+ for (const cookie of cookies) target.headers.append("set-cookie", cookie)
721
+ return target
573
722
  }
723
+
724
+ /** What a handler's Response renders as when its body was already sent: a runtime refuses to send it
725
+ * again, and the headers it carries were written for the request it already answered. */
726
+ export const SENT_BODY = plainError(500, "internal_error")
@@ -1,3 +1,4 @@
1
+ import { tagSource } from "../internal/hook-origins.ts"
1
2
  import type {
2
3
  NodeRequestContext,
3
4
  NodeResponseBodyHook,
@@ -7,7 +8,7 @@ import type {
7
8
  ResponseHeadersView,
8
9
  } from "./node-outcome-hook.ts"
9
10
  import { recordHeadersView } from "./node-outcome-hook.ts"
10
- import { knownMutableHeaders, rememberMutableHeaders, taggedResponseBody } from "./respond.ts"
11
+ import { taggedResponseBody, writableResponse } from "./respond.ts"
11
12
  import { applyBodyReplacement, withReplacedBody } from "./response-hooks.ts"
12
13
 
13
14
  type MaybePromise<T> = T | Promise<T>
@@ -19,6 +20,9 @@ export type {
19
20
  ResponseHeadersHook,
20
21
  } from "./node-outcome-hook.ts"
21
22
 
23
+ // Deno reports `process.env` reads as `NotCapable` without --allow-env; read it only off Deno.
24
+ declare const Deno: unknown
25
+
22
26
  export interface ResponseObserverHost {
23
27
  assertConfigurable(operation: string): void
24
28
  addResponseHook(
@@ -44,32 +48,6 @@ export interface ResponseObserverRuntime {
44
48
  install(host: ResponseObserverHost): ResponseObserverMethods
45
49
  }
46
50
 
47
- const HEADER_MUTABILITY_PROBE = "x-nifra-header-probe"
48
- const GUARDED_RESPONSE_HEADERS = new WeakSet<Headers>()
49
-
50
- function hasMutableResponseHeaders(headers: Headers): boolean {
51
- if (knownMutableHeaders(headers)) return true
52
- if (GUARDED_RESPONSE_HEADERS.has(headers)) return false
53
- let previous: string | null = null
54
- try {
55
- previous = headers.get(HEADER_MUTABILITY_PROBE)
56
- headers.set(HEADER_MUTABILITY_PROBE, "1")
57
- if (previous === null) headers.delete(HEADER_MUTABILITY_PROBE)
58
- else headers.set(HEADER_MUTABILITY_PROBE, previous)
59
- rememberMutableHeaders(headers)
60
- return true
61
- } catch {
62
- try {
63
- if (previous === null) headers.delete(HEADER_MUTABILITY_PROBE)
64
- else headers.set(HEADER_MUTABILITY_PROBE, previous)
65
- } catch {
66
- // A guarded response rejected the probe and the clone path is authoritative.
67
- }
68
- GUARDED_RESPONSE_HEADERS.add(headers)
69
- return false
70
- }
71
- }
72
-
73
51
  function webRequestView(req: Request): NodeRequestContext {
74
52
  return { method: req.method, url: req.url, header: (name) => req.headers.get(name) }
75
53
  }
@@ -82,12 +60,8 @@ function webHeadersView(
82
60
  readonly headers: ResponseHeadersView
83
61
  readonly request: NodeRequestContext
84
62
  } {
85
- const request = webRequestView(req)
86
- if (!hasMutableResponseHeaders(response.headers)) {
87
- const clone = new Response(response.body, response)
88
- return { response: clone, headers: clone.headers, request }
89
- }
90
- return { response, headers: response.headers, request }
63
+ const writable = writableResponse(response, req)
64
+ return { response: writable, headers: writable.headers, request: webRequestView(req) }
91
65
  }
92
66
 
93
67
  export function createResponseObserverRuntime(): ResponseObserverRuntime {
@@ -100,12 +74,16 @@ export function createResponseObserverRuntime(): ResponseObserverRuntime {
100
74
  node?: NodeResponseHook,
101
75
  ): ResponseObserverMethods {
102
76
  host.assertConfigurable("onResponseHeaders()")
77
+ const web: Parameters<typeof host.addResponseHook>[0] = (response, req) => {
78
+ const view = webHeadersView(response, req)
79
+ const out = fn(view.headers, view.request, response.status)
80
+ return out instanceof Promise ? out.then(() => view.response) : view.response
81
+ }
82
+ // Development-only provenance for `reflectHooks`; a production define folds it away.
83
+ if (typeof Deno === "undefined" && process.env.NODE_ENV !== "production")
84
+ tagSource(web, fn)
103
85
  host.addResponseHook(
104
- (response, req) => {
105
- const view = webHeadersView(response, req)
106
- const out = fn(view.headers, view.request, response.status)
107
- return out instanceof Promise ? out.then(() => view.response) : view.response
108
- },
86
+ web,
109
87
  node ?? ((response, req) => fn(recordHeadersView(response), req, response.status)),
110
88
  true,
111
89
  )
@@ -14,6 +14,8 @@ export type HandlerResult = Response | unknown
14
14
  export const RESPONSE_RESULT = Symbol.for(NODE_BRIDGE_MARKER_KEYS.responseResult)
15
15
  export const CONTEXT_SET = Symbol("nifra.context.set")
16
16
  export const CONTEXT_SEARCH = Symbol("nifra.context.search")
17
+ /** The request a context answers, read without the transport cap `req` applies for user code. */
18
+ export const CONTEXT_REQUEST = Symbol("nifra.context.request")
17
19
 
18
20
  /**
19
21
  * A transport codec (or other pre-parsing hook) that already decoded the body stashes the value
@@ -109,8 +109,12 @@ export interface ServerOptions {
109
109
  * Default 30_000; applies only when `acceptInboundDeadlines` is enabled and the header is present.
110
110
  */
111
111
  readonly maxInboundDeadlineMs?: number
112
- /** When `listen()`ing, install SIGTERM/SIGINT handlers that gracefully `stop()`. Default false. */
113
- readonly gracefulSignals?: boolean
112
+ /**
113
+ * When `listen()`ing, install SIGTERM/SIGINT handlers that gracefully `stop()`. Default false.
114
+ * `{ drainMs }` sets how long in-flight requests get to finish before the stop forces them closed
115
+ * (default 10_000) - keep it under the platform's kill grace period (`docker stop` waits 10 s).
116
+ */
117
+ readonly gracefulSignals?: boolean | { readonly drainMs?: number }
114
118
  /**
115
119
  * Capacity-admission gate (see {@link AdmissionController}). Bounds concurrent in-flight work so a
116
120
  * healthy instance sheds load (`429` + `Retry-After`) instead of accepting more than it can finish.
@@ -196,9 +200,29 @@ export interface MountOptions<Env = unknown> {
196
200
  * Why this child is not analyzed, for a handler whose effects nifra cannot follow (a closure from
197
201
  * another package). Capability assurance lists a mount with a reason as a known gap; a mount
198
202
  * without one fails it, since its routes are invisible. A nifra `server()` belongs in `merge()`,
199
- * where its routes are analyzed.
203
+ * where its routes are analyzed. Pass an {@link OpaqueMountDeclaration} to also state the routes
204
+ * the child serves.
200
205
  */
201
- readonly opaque?: string
206
+ readonly opaque?: string | OpaqueMountDeclaration
207
+ }
208
+
209
+ /**
210
+ * An opaque mount's reason plus the routes it serves. Assurance lists those routes as declared, not
211
+ * analyzed: their capabilities are the mount's word, checked against the policy but never proven.
212
+ */
213
+ export interface OpaqueMountDeclaration {
214
+ /** Why the child is not analyzed. A blank reason leaves the mount undeclared. */
215
+ readonly reason: string
216
+ readonly routes?: readonly DeclaredMountRoute[]
217
+ }
218
+
219
+ /** One route an opaque mount serves, as the app states it. */
220
+ export interface DeclaredMountRoute {
221
+ readonly method: string
222
+ /** The request path, from the app root, under the mount's prefix (`/api/auth/sign-in`). */
223
+ readonly path: string
224
+ /** Capability ids the route exercises. */
225
+ readonly capabilities?: readonly string[]
202
226
  }
203
227
 
204
228
  /** Options for a legacy fetch-handler mount. */
@@ -210,7 +234,7 @@ export interface MountFetchOptions {
210
234
  /** For safe replayable methods only, try the next matching legacy mount when it returns 404. */
211
235
  readonly fallbackOn?: 404
212
236
  /** Why this handler is not analyzed - see {@link MountOptions.opaque}. */
213
- readonly opaque?: string
237
+ readonly opaque?: string | OpaqueMountDeclaration
214
238
  }
215
239
 
216
240
  /** A callback awaited after the Bun server has drained and stopped. */