@nifrajs/core 5.5.1 → 5.7.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 (209) hide show
  1. package/CHANGELOG.md +246 -0
  2. package/README.md +5 -0
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js.map +1 -1
  5. package/dist/binary.d.ts +15 -1
  6. package/dist/binary.d.ts.map +1 -1
  7. package/dist/binary.js +1 -0
  8. package/dist/binary.js.map +1 -1
  9. package/dist/edge-kit.d.ts +1 -0
  10. package/dist/edge-kit.d.ts.map +1 -1
  11. package/dist/edge-kit.js +1 -0
  12. package/dist/edge-kit.js.map +1 -1
  13. package/dist/errors.d.ts +1 -1
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js.map +1 -1
  16. package/dist/evidence.d.ts +4 -0
  17. package/dist/evidence.d.ts.map +1 -1
  18. package/dist/evidence.js +4 -0
  19. package/dist/evidence.js.map +1 -1
  20. package/dist/file-type.d.ts +30 -0
  21. package/dist/file-type.d.ts.map +1 -0
  22. package/dist/file-type.js +118 -0
  23. package/dist/file-type.js.map +1 -0
  24. package/dist/index.d.ts +3 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +3 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/internal/hook-origins.d.ts +2 -0
  29. package/dist/internal/hook-origins.d.ts.map +1 -1
  30. package/dist/internal/hook-origins.js +12 -0
  31. package/dist/internal/hook-origins.js.map +1 -1
  32. package/dist/internal/route-program.d.ts +11 -0
  33. package/dist/internal/route-program.d.ts.map +1 -1
  34. package/dist/internal/route-program.js +133 -85
  35. package/dist/internal/route-program.js.map +1 -1
  36. package/dist/reflection.d.ts +19 -1
  37. package/dist/reflection.d.ts.map +1 -1
  38. package/dist/reflection.js +46 -7
  39. package/dist/reflection.js.map +1 -1
  40. package/dist/response-observer.d.ts +1 -0
  41. package/dist/response-observer.d.ts.map +1 -1
  42. package/dist/response-observer.js +1 -0
  43. package/dist/response-observer.js.map +1 -1
  44. package/dist/server/answer-step.d.ts +27 -0
  45. package/dist/server/answer-step.d.ts.map +1 -0
  46. package/dist/server/answer-step.js +33 -0
  47. package/dist/server/answer-step.js.map +1 -0
  48. package/dist/server/bare-error-lane.d.ts +4 -1
  49. package/dist/server/bare-error-lane.d.ts.map +1 -1
  50. package/dist/server/bare-error-lane.js +9 -3
  51. package/dist/server/bare-error-lane.js.map +1 -1
  52. package/dist/server/base64-answers.d.ts +5 -0
  53. package/dist/server/base64-answers.d.ts.map +1 -0
  54. package/dist/server/base64-answers.js +132 -0
  55. package/dist/server/base64-answers.js.map +1 -0
  56. package/dist/server/before-handler.d.ts +10 -0
  57. package/dist/server/before-handler.d.ts.map +1 -0
  58. package/dist/server/before-handler.js +12 -0
  59. package/dist/server/before-handler.js.map +1 -0
  60. package/dist/server/body-lane.d.ts.map +1 -1
  61. package/dist/server/body-lane.js +8 -2
  62. package/dist/server/body-lane.js.map +1 -1
  63. package/dist/server/body-parser.d.ts.map +1 -1
  64. package/dist/server/body-parser.js +2 -5
  65. package/dist/server/body-parser.js.map +1 -1
  66. package/dist/server/body.d.ts +24 -8
  67. package/dist/server/body.d.ts.map +1 -1
  68. package/dist/server/body.js +86 -33
  69. package/dist/server/body.js.map +1 -1
  70. package/dist/server/core-copies.d.ts.map +1 -1
  71. package/dist/server/core-copies.js +5 -2
  72. package/dist/server/core-copies.js.map +1 -1
  73. package/dist/server/http.d.ts.map +1 -1
  74. package/dist/server/http.js +6 -17
  75. package/dist/server/http.js.map +1 -1
  76. package/dist/server/install.d.ts +2 -0
  77. package/dist/server/install.d.ts.map +1 -1
  78. package/dist/server/install.js +2 -0
  79. package/dist/server/install.js.map +1 -1
  80. package/dist/server/json-response.d.ts +3 -0
  81. package/dist/server/json-response.d.ts.map +1 -0
  82. package/dist/server/json-response.js +14 -0
  83. package/dist/server/json-response.js.map +1 -0
  84. package/dist/server/json-text.d.ts +16 -0
  85. package/dist/server/json-text.d.ts.map +1 -0
  86. package/dist/server/json-text.js +34 -0
  87. package/dist/server/json-text.js.map +1 -0
  88. package/dist/server/multipart.d.ts +3 -0
  89. package/dist/server/multipart.d.ts.map +1 -1
  90. package/dist/server/multipart.js +226 -97
  91. package/dist/server/multipart.js.map +1 -1
  92. package/dist/server/node-outcome.d.ts +5 -0
  93. package/dist/server/node-outcome.d.ts.map +1 -1
  94. package/dist/server/node-outcome.js +3 -4
  95. package/dist/server/node-outcome.js.map +1 -1
  96. package/dist/server/proto-guard.d.ts.map +1 -1
  97. package/dist/server/proto-guard.js +39 -4
  98. package/dist/server/proto-guard.js.map +1 -1
  99. package/dist/server/query.d.ts.map +1 -1
  100. package/dist/server/query.js +8 -14
  101. package/dist/server/query.js.map +1 -1
  102. package/dist/server/repeated-prefix.d.ts +17 -0
  103. package/dist/server/repeated-prefix.d.ts.map +1 -0
  104. package/dist/server/repeated-prefix.js +17 -0
  105. package/dist/server/repeated-prefix.js.map +1 -0
  106. package/dist/server/request-context.d.ts.map +1 -1
  107. package/dist/server/request-context.js +5 -5
  108. package/dist/server/request-context.js.map +1 -1
  109. package/dist/server/respond.d.ts +3 -4
  110. package/dist/server/respond.d.ts.map +1 -1
  111. package/dist/server/respond.js +45 -37
  112. package/dist/server/respond.js.map +1 -1
  113. package/dist/server/response-contract-lane.d.ts +12 -2
  114. package/dist/server/response-contract-lane.d.ts.map +1 -1
  115. package/dist/server/response-contract-lane.js +137 -9
  116. package/dist/server/response-contract-lane.js.map +1 -1
  117. package/dist/server/route-template.d.ts +10 -0
  118. package/dist/server/route-template.d.ts.map +1 -0
  119. package/dist/server/route-template.js +21 -0
  120. package/dist/server/route-template.js.map +1 -0
  121. package/dist/server/runtime-core.d.ts +7 -0
  122. package/dist/server/runtime-core.d.ts.map +1 -1
  123. package/dist/server/runtime-core.js +10 -2
  124. package/dist/server/runtime-core.js.map +1 -1
  125. package/dist/server/server-types.d.ts +17 -8
  126. package/dist/server/server-types.d.ts.map +1 -1
  127. package/dist/server/server.d.ts +27 -12
  128. package/dist/server/server.d.ts.map +1 -1
  129. package/dist/server/server.js +326 -108
  130. package/dist/server/server.js.map +1 -1
  131. package/dist/server/sse.d.ts +1 -1
  132. package/dist/server/sse.d.ts.map +1 -1
  133. package/dist/server/sse.js +7 -5
  134. package/dist/server/sse.js.map +1 -1
  135. package/dist/server/text-slots.d.ts +8 -0
  136. package/dist/server/text-slots.d.ts.map +1 -0
  137. package/dist/server/text-slots.js +19 -0
  138. package/dist/server/text-slots.js.map +1 -0
  139. package/dist/server/webhook.d.ts +1 -1
  140. package/dist/server/webhook.d.ts.map +1 -1
  141. package/dist/server/webhook.js +24 -19
  142. package/dist/server/webhook.js.map +1 -1
  143. package/dist/server/websocket.d.ts +18 -3
  144. package/dist/server/websocket.d.ts.map +1 -1
  145. package/dist/server/websocket.js +45 -5
  146. package/dist/server/websocket.js.map +1 -1
  147. package/dist/server/ws-bun.d.ts.map +1 -1
  148. package/dist/server/ws-bun.js +7 -4
  149. package/dist/server/ws-bun.js.map +1 -1
  150. package/dist/server.d.ts +2 -0
  151. package/dist/server.d.ts.map +1 -1
  152. package/dist/server.js +2 -0
  153. package/dist/server.js.map +1 -1
  154. package/dist/single-copy.d.ts +5 -0
  155. package/dist/single-copy.d.ts.map +1 -1
  156. package/dist/single-copy.js +26 -17
  157. package/dist/single-copy.js.map +1 -1
  158. package/dist/tool-cli.d.ts +64 -0
  159. package/dist/tool-cli.d.ts.map +1 -0
  160. package/dist/tool-cli.js +495 -0
  161. package/dist/tool-cli.js.map +1 -0
  162. package/dist/transport-codec.d.ts.map +1 -1
  163. package/dist/transport-codec.js +2 -1
  164. package/dist/transport-codec.js.map +1 -1
  165. package/package.json +11 -1
  166. package/src/assurance.ts +1 -0
  167. package/src/binary.ts +17 -0
  168. package/src/edge-kit.ts +1 -0
  169. package/src/errors.ts +1 -0
  170. package/src/evidence.ts +8 -0
  171. package/src/file-type.ts +130 -0
  172. package/src/index.ts +3 -1
  173. package/src/internal/hook-origins.ts +10 -0
  174. package/src/internal/route-program.ts +261 -93
  175. package/src/reflection.ts +71 -7
  176. package/src/response-observer.ts +6 -0
  177. package/src/server/answer-step.ts +68 -0
  178. package/src/server/bare-error-lane.ts +9 -4
  179. package/src/server/base64-answers.ts +138 -0
  180. package/src/server/before-handler.ts +14 -0
  181. package/src/server/body-lane.ts +19 -9
  182. package/src/server/body-parser.ts +1 -5
  183. package/src/server/body.ts +96 -36
  184. package/src/server/core-copies.ts +5 -2
  185. package/src/server/http.ts +6 -15
  186. package/src/server/install.ts +3 -0
  187. package/src/server/json-response.ts +13 -0
  188. package/src/server/json-text.ts +34 -0
  189. package/src/server/multipart.ts +232 -100
  190. package/src/server/node-outcome.ts +4 -5
  191. package/src/server/proto-guard.ts +39 -4
  192. package/src/server/query.ts +7 -13
  193. package/src/server/repeated-prefix.ts +25 -0
  194. package/src/server/request-context.ts +5 -5
  195. package/src/server/respond.ts +47 -30
  196. package/src/server/response-contract-lane.ts +132 -9
  197. package/src/server/route-template.ts +24 -0
  198. package/src/server/runtime-core.ts +12 -2
  199. package/src/server/server-types.ts +18 -6
  200. package/src/server/server.ts +361 -112
  201. package/src/server/sse.ts +8 -6
  202. package/src/server/text-slots.ts +21 -0
  203. package/src/server/webhook.ts +29 -24
  204. package/src/server/websocket.ts +57 -8
  205. package/src/server/ws-bun.ts +7 -3
  206. package/src/server.ts +2 -0
  207. package/src/single-copy.ts +31 -15
  208. package/src/tool-cli.ts +584 -0
  209. package/src/transport-codec.ts +3 -1
@@ -0,0 +1,25 @@
1
+ /** What {@link markRepeatedPrefix} recorded for a chunk. */
2
+ export interface RepeatedPrefix {
3
+ /** Bytes the producer sends again in later responses and never changes. */
4
+ readonly source: Uint8Array
5
+ /** How many of `source`'s leading bytes the chunk starts with. */
6
+ readonly length: number
7
+ }
8
+
9
+ const prefixes = new WeakMap<Uint8Array, RepeatedPrefix>()
10
+
11
+ /**
12
+ * Record that `chunk` starts with the first `length` bytes of `source`: bytes its producer sends again
13
+ * in later responses and never changes, such as a cached page shell. A response observer that
14
+ * transforms streamed bodies may keep its work on those bytes, keyed by `source`, and reuse it. It must
15
+ * check that the chunk still holds them, since a later reader may have written into the chunk.
16
+ */
17
+ export function markRepeatedPrefix(chunk: Uint8Array, source: Uint8Array, length: number): void {
18
+ if (!Number.isSafeInteger(length) || length <= 0) return
19
+ if (length > chunk.byteLength || length > source.byteLength) return
20
+ prefixes.set(chunk, { source, length })
21
+ }
22
+
23
+ /** The repeated prefix {@link markRepeatedPrefix} recorded for `chunk`, if any. */
24
+ export const repeatedPrefixOf = (chunk: Uint8Array): RepeatedPrefix | undefined =>
25
+ prefixes.get(chunk)
@@ -12,6 +12,8 @@ import type { Platform } from "./context.ts"
12
12
  import { type CookieOptions, cookieNamePrefix, parseCookies, serializeCookie } from "./cookies.ts"
13
13
  import { headerObjectOf } from "./headers.ts"
14
14
  import { plainError } from "./http.ts"
15
+ import { jsonResponse } from "./json-response.ts"
16
+ import { jsonText } from "./json-text.ts"
15
17
  import type { ProtoPoisoning } from "./proto-guard.ts"
16
18
  import { searchOf } from "./query.ts"
17
19
  import { residentText, responseJsonContentType } from "./respond.ts"
@@ -342,14 +344,14 @@ export class RequestContext implements RawContext {
342
344
  // (`undefined`, a function, a symbol) - exactly the case where `Response.json` throws, so it
343
345
  // gets the throw rather than an invented body. The content-type is the runtime's own, read
344
346
  // off `Response.json` once, so a deferred response carries the same one byte for byte.
345
- const text = defer === undefined ? undefined : (JSON.stringify(body) as string | undefined)
347
+ const text = defer === undefined ? undefined : (jsonText(body) as string | undefined)
346
348
  const headers =
347
349
  text === undefined ? undefined : ownHeaderRecord(i?.headers, responseJsonContentType())
348
350
  if (defer !== undefined && text !== undefined && headers !== undefined) {
349
351
  reply = defer(text, i?.status ?? 200, headers)
350
352
  }
351
353
  }
352
- reply ??= markResidentBody(Response.json(body, i))
354
+ reply ??= markResidentBody(jsonResponse(body, i))
353
355
  // Remembered so a response contract holds the value to the route's schema: this is the
354
356
  // framework's JSON helper, not a raw-Response escape hatch.
355
357
  this.jsonBody = body
@@ -433,9 +435,7 @@ async function readBoundedBodyOrThrow(
433
435
  ): Promise<Uint8Array> {
434
436
  const r = await readBoundedBytes(req, maxBytes ?? maxBodyBytes)
435
437
  if (r.ok) return r.bytes
436
- throw r.status === 413
437
- ? plainError(413, "payload_too_large")
438
- : plainError(400, "invalid_content_length")
438
+ throw plainError(r.status, r.error)
439
439
  }
440
440
 
441
441
  /** Backs `c.boundedJson`: `readBoundedJsonSource`, throwing its flat 400/413 on failure. */
@@ -7,12 +7,15 @@
7
7
  import { hasResidentBody, markResidentBody, residentBodiesRecorded } from "../mount.ts"
8
8
  import { NODE_BRIDGE_MARKER_KEYS } from "./bridge-markers.ts"
9
9
  import { plainError } from "./http.ts"
10
+ import { jsonResponse } from "./json-response.ts"
11
+ import { jsonText } from "./json-text.ts"
10
12
  import {
11
13
  CONTEXT_REQUEST,
12
14
  CONTEXT_SET,
13
15
  EMPTY_RESPONSE_CONTROLS,
14
16
  type HandlerResult,
15
17
  isResponseResult,
18
+ JSON_CONTENT_TYPE,
16
19
  type PlainRender,
17
20
  plainRenderHeaders,
18
21
  type ResponseResult,
@@ -41,10 +44,9 @@ function plainRenderSet(plain: PlainRender, set: CtxSet): CtxSet {
41
44
  } as CtxSet
42
45
  }
43
46
 
44
- // Keep the fast JSON respond path byte-identical to `Response.json` without probing it at module
45
- // scope: workerd forbids `Response.json()` during startup. A shared `Headers` is safe to reuse
46
- // across responses: the Response constructor copies `init.headers` into its own list.
47
- const JSON_CONTENT_TYPE = "application/json;charset=utf-8"
47
+ // Keep the fast JSON respond path byte-identical to `Response.json` (see `JSON_CONTENT_TYPE`). A
48
+ // shared `Headers` is safe to reuse across responses: the Response constructor copies
49
+ // `init.headers` into its own list.
48
50
  const JSON_CT_HEADERS = new Headers({ "content-type": JSON_CONTENT_TYPE })
49
51
  const JSON_INIT_200: ResponseInit = { status: 200, headers: JSON_CT_HEADERS }
50
52
 
@@ -212,13 +214,14 @@ function denoResponseWithJsonBody(
212
214
  * it exactly as `withJsonContentType` did.
213
215
  */
214
216
  function jsonResponseWithOwnHeaders(
215
- result: object,
217
+ result: unknown,
216
218
  status: number,
217
219
  headers: Record<string, string>,
220
+ unstamped: boolean,
218
221
  ): Response {
219
- const response = status === 200 ? Response.json(result) : Response.json(result, { status })
222
+ const response = jsonResponse(result, status === 200 ? undefined : { status })
220
223
  for (const name of Object.keys(headers)) response.headers.set(name, headers[name]!)
221
- return withBody(response)
224
+ return withBody(response, unstamped)
222
225
  }
223
226
 
224
227
  // The framework-buffered-body marker shared with the Node adapter: a Response carrying it exposes
@@ -254,9 +257,9 @@ export function rememberRenderedResponse(response: Response, hasBody: boolean):
254
257
  return response
255
258
  }
256
259
 
257
- /** `rememberRenderedResponse` for a Response the framework built around a body it serialized. */
258
- function withBody(response: Response): Response {
259
- return markResidentBody(response)
260
+ /** Record serialized bodies when response hooks or an in-process client can consume the mark. */
261
+ function withBody(response: Response, unstamped = false): Response {
262
+ return unstamped && !residentBodiesRecorded ? response : markResidentBody(response)
260
263
  }
261
264
 
262
265
  const HEADER_MUTABILITY_PROBE = "x-nifra-header-probe"
@@ -488,8 +491,9 @@ function tagged(
488
491
  response: Response,
489
492
  body: string,
490
493
  tagResponseBody: ResponseBodyTagOption,
494
+ unstamped = false,
491
495
  ): Response {
492
- withBody(response)
496
+ withBody(response, unstamped)
493
497
  return tagResponseBody === false || tagResponseBody === undefined
494
498
  ? response
495
499
  : markTaggedResponse(
@@ -499,9 +503,16 @@ function tagged(
499
503
  )
500
504
  }
501
505
 
502
- /** Fused-lane respond when `c.set` is untouched. Bun 1.3's native `Response.json` now beats the
503
- * older hand-inlined stringify + Response construction on this lane while preserving the exact
504
- * body/content-type contract; keep the generic fallback for non-JSON values. */
506
+ /** A plain 200 JSON response. On Bun and Deno the native `Response.json` beats a hand-inlined
507
+ * stringify + Response construction; on Node undici's is 18-26% slower than stringify into the
508
+ * prebuilt init. Both keep the exact body/content-type contract. */
509
+ function plainJsonResponse(result: object): Response {
510
+ // `undefined` from stringify (a `toJSON` returning nothing) keeps `Response.json`'s TypeError.
511
+ const body = IS_BUN || IS_DENO ? undefined : (jsonText(result) as string | undefined)
512
+ return body === undefined ? jsonResponse(result) : new Response(body, JSON_INIT_200)
513
+ }
514
+
515
+ /** Fused-lane respond when `c.set` is untouched; keep the generic fallback for non-JSON values. */
505
516
  export function fusedRespondNoSet(
506
517
  result: unknown,
507
518
  tagResponseBody: ResponseBodyTagOption = false,
@@ -527,7 +538,7 @@ export function fusedRespondNoSet(
527
538
  !(result instanceof Response) &&
528
539
  !isResponseResult(result)
529
540
  ) {
530
- const response = Response.json(result)
541
+ const response = plainJsonResponse(result)
531
542
  return residentBodiesRecorded ? markResidentBody(response) : response
532
543
  }
533
544
  if (
@@ -543,7 +554,7 @@ export function fusedRespondNoSet(
543
554
  // Deno the tier's only saving here is the response walk itself; every other runtime keeps the
544
555
  // one-call prebuilt init below.
545
556
  if (statics !== undefined && IS_DENO && !tagResponseBody) {
546
- const response = Response.json(result)
557
+ const response = jsonResponse(result)
547
558
  // Indexed for the same reason as `applyStaticDefaults`: pair destructuring is a second
548
559
  // iterator walk per header.
549
560
  for (let i = 0; i < statics.entries.length; i++) {
@@ -556,7 +567,7 @@ export function fusedRespondNoSet(
556
567
  // content-type, so this lane still constructs the response in one call - the whole point of
557
568
  // declaring headers statically instead of writing them per response.
558
569
  if (tagResponseBody || statics !== undefined) {
559
- const body = JSON.stringify(result) as string | undefined
570
+ const body = jsonText(result) as string | undefined
560
571
  // Each shape carries the content-type of the lane it stands in for: the framework's own for the
561
572
  // tagging path this branch already owned, and `Response.json`'s for the construction below that
562
573
  // the static tier replaces. They differ on Deno, and a declared header must not change one.
@@ -579,8 +590,8 @@ export function fusedRespondNoSet(
579
590
  }
580
591
  return withBody(
581
592
  statics === undefined
582
- ? Response.json(result)
583
- : Response.json(result, statics.responseJsonInit200()),
593
+ ? plainJsonResponse(result)
594
+ : jsonResponse(result, statics.responseJsonInit200()),
584
595
  )
585
596
  }
586
597
  return toResponse(
@@ -605,13 +616,14 @@ export function fusedRespond(
605
616
  const request = unstamped ? undefined : ctx[CONTEXT_REQUEST]
606
617
  if (set === undefined)
607
618
  return fusedRespondNoSet(result, tagResponseBody, statics, unstamped, request)
608
- return toResponse(result as HandlerResult, set, tagResponseBody, statics, request)
619
+ return toResponse(result as HandlerResult, set, tagResponseBody, statics, request, unstamped)
609
620
  }
610
621
 
611
622
  /**
612
623
  * Render a handler's result. `request` is the request being answered, given when response hooks will
613
624
  * write next: a copy or body-less response built here is then recorded as that request's own, so the
614
625
  * hooks write it in place instead of copying it again.
626
+ * `unstamped` is the caller's proof that no response hook follows; unknown callers keep the mark.
615
627
  */
616
628
  export function toResponse(
617
629
  result: HandlerResult,
@@ -619,6 +631,7 @@ export function toResponse(
619
631
  tagResponseBody: ResponseBodyTagOption = false,
620
632
  statics?: StaticResponseHeaders,
621
633
  request?: Request,
634
+ unstamped = false,
622
635
  ): Response {
623
636
  if (isResponseResult(result)) {
624
637
  const plain = result.plain
@@ -632,6 +645,7 @@ export function toResponse(
632
645
  tagResponseBody,
633
646
  statics,
634
647
  request,
648
+ unstamped,
635
649
  )
636
650
  }
637
651
  // A buffered page is built from the body it reports, so the bytes its hooks see are the ones sent.
@@ -664,7 +678,7 @@ export function toResponse(
664
678
  typeof tagResponseBody === "object" ? tagResponseBody : undefined,
665
679
  )
666
680
  }
667
- return renderValue(result, set, tagResponseBody, statics, request)
681
+ return renderValue(result, set, tagResponseBody, statics, request, unstamped)
668
682
  }
669
683
 
670
684
  /**
@@ -694,6 +708,7 @@ function renderValue(
694
708
  tagResponseBody: ResponseBodyTagOption,
695
709
  statics: StaticResponseHeaders | undefined,
696
710
  request: Request | undefined,
711
+ unstamped = false,
697
712
  ): Response {
698
713
  // The static tier only changes WHICH prebuilt header shape the branches below reach for: the
699
714
  // request's own headers are folded on top of the declared defaults once, here.
@@ -723,14 +738,14 @@ function renderValue(
723
738
  typeof result === "object" &&
724
739
  result !== null
725
740
  ) {
726
- return jsonResponseWithOwnHeaders(result, status, own)
741
+ return jsonResponseWithOwnHeaders(result, status, own, unstamped)
727
742
  }
728
743
  if (own === undefined && result !== undefined) {
729
744
  // Fast respond (profiled ~50 ns/req faster on every plain-JSON return): `JSON.stringify` + a
730
745
  // prebuilt init beats `Response.json`'s internal init handling. Output is byte-identical -
731
746
  // same body bytes, same probed content-type. `undefined` from stringify (a function/symbol
732
747
  // result) delegates to Response.json so its TypeError contract stays the single source.
733
- const body = JSON.stringify(result) as string | undefined
748
+ const body = jsonText(result) as string | undefined
734
749
  if (body !== undefined) {
735
750
  const init: ResponseInit =
736
751
  status === 200
@@ -743,13 +758,14 @@ function renderValue(
743
758
  : staticJsonResponse(body, status, statics, JSON_CONTENT_TYPE, init),
744
759
  body,
745
760
  tagResponseBody,
761
+ unstamped,
746
762
  )
747
763
  }
748
764
  }
749
765
  const init: ResponseInit = headers === undefined ? { status } : { status, headers }
750
766
  if (result === undefined) return ownedBy(new Response(null, init), request)
751
767
  if (tagResponseBody) {
752
- const body = JSON.stringify(result) as string | undefined
768
+ const body = jsonText(result) as string | undefined
753
769
  if (body !== undefined) {
754
770
  const response =
755
771
  IS_DENO && !(headers instanceof Headers)
@@ -757,7 +773,7 @@ function renderValue(
757
773
  body,
758
774
  status,
759
775
  headers as Record<string, string>,
760
- JSON_CT_HEADERS.get("content-type") ?? "application/json;charset=utf-8",
776
+ JSON_CONTENT_TYPE,
761
777
  )
762
778
  : // lgtm [js/stack-trace-exposure] the serialized body is not a caught error stack.
763
779
  new Response(body, { status, headers: withJsonContentType(headers) })
@@ -769,10 +785,11 @@ function renderValue(
769
785
  }
770
786
  }
771
787
  if (IS_DENO && headers !== undefined && !(headers instanceof Headers)) {
772
- const body = JSON.stringify(result) as string | undefined
773
- if (body !== undefined) return withBody(denoResponseWithJsonBody(body, status, headers))
788
+ // Deno's own `Response.json` then header writes measured ~10% under stringify, construction and
789
+ // the same writes plus a content-type one; a declared content-type still overrides the default.
790
+ return jsonResponseWithOwnHeaders(result, status, headers, unstamped)
774
791
  }
775
- return withBody(Response.json(result, init))
792
+ return withBody(jsonResponse(result, init), unstamped)
776
793
  }
777
794
 
778
795
  // Add a JSON content-type without pre-building a `Headers` instance just to check-and-set one key:
@@ -787,12 +804,12 @@ function withJsonContentType(
787
804
  ): Record<string, string> | Headers {
788
805
  if (headers === undefined) return JSON_CT_HEADERS
789
806
  if (headers instanceof Headers) {
790
- if (!headers.has("content-type")) headers.set("content-type", "application/json;charset=utf-8")
807
+ if (!headers.has("content-type")) headers.set("content-type", JSON_CONTENT_TYPE)
791
808
  return headers
792
809
  }
793
810
  return Object.hasOwn(headers, "content-type")
794
811
  ? headers
795
- : { ...headers, "content-type": "application/json;charset=utf-8" }
812
+ : { ...headers, "content-type": JSON_CONTENT_TYPE }
796
813
  }
797
814
 
798
815
  /** Headers that describe a Response's body or its framing: a `c.set` value for one describes a body
@@ -32,13 +32,23 @@
32
32
  * carries its JSON Schema as `jsonSchema` (every `t` schema does) CAN list its declared keys, so for
33
33
  * those the payload is projected onto the declared shape before it is validated.
34
34
  *
35
+ * ## What is judged is what is written
36
+ *
37
+ * `JSON.stringify` writes what a `toJSON` hook returns, not the fields a validator reads off the
38
+ * object. So a value's hooks are applied first, as the serializer would, and that result is validated
39
+ * and sent: a class whose `toJSON` adds a private field fails a closed schema instead of passing on its
40
+ * public ones. `strip` keeps the declared fields of what the hook writes, so a hook that writes `id`
41
+ * from private state keeps it. A `Date` is the exception; it stays a `Date` for a date schema and is
42
+ * written as its ISO string.
43
+ *
35
44
  * ## What enforcement costs
36
45
  *
37
46
  * Less than it looks like it should. With a compiled validator (`@nifrajs/schema`'s `t` compiles at
38
47
  * construction; zod/valibot equivalents are similarly cheap), the check itself measures in the
39
48
  * ~100ns-per-response range - on a realistic middleware-carrying route, enforce mode benchmarks
40
- * within measurement noise of the same route with no contract at all, on Bun and Node alike. The
41
- * one real cost is structural: a contracted route cannot take the bare-route fused lane, because
49
+ * within measurement noise of the same route with no contract at all, on Bun and Node alike. Looking
50
+ * for `toJSON` hooks visits every object in the payload, about 13 ns each, so a 50-row list pays near
51
+ * 0.7 us. The other cost is structural: a contracted route cannot take the bare-route fused lane, because
42
52
  * the check needs the handler's VALUE before it becomes bytes. A route with any middleware, derive,
43
53
  * or lifecycle hook has already left that lane, so for the routes that look like production the
44
54
  * contract is effectively free - declare it.
@@ -49,6 +59,7 @@ import {
49
59
  type StandardSchemaV1,
50
60
  } from "../schema/standard.ts"
51
61
  import { INSTALL_RESPONSE_CONTRACT } from "./install.ts"
62
+ import { jsonText } from "./json-text.ts"
52
63
  import type { IdentityPlugin } from "./plugin.ts"
53
64
  import { isResponseResult, status } from "./runtime-core.ts"
54
65
  import type { AnyServer } from "./server.ts"
@@ -166,7 +177,12 @@ function compileProjector(node: unknown): Projector | undefined {
166
177
  const item = Array.isArray(items) ? undefined : compileProjector(items)
167
178
  return item === undefined
168
179
  ? undefined
169
- : (value) => (Array.isArray(value) ? value.map(item) : value)
180
+ : (value) => {
181
+ if (!Array.isArray(value)) return value
182
+ const out: unknown[] = []
183
+ for (let i = 0; i < value.length; i++) out.push(item(writtenAt(value[i], i)))
184
+ return out
185
+ }
170
186
  }
171
187
  if (type !== "object") return undefined
172
188
  const properties = objectField(schema, "properties") ?? {}
@@ -196,7 +212,7 @@ function compileProjector(node: unknown): Projector | undefined {
196
212
  for (const key of closed ? keys : nested.keys()) {
197
213
  if (!hasField(value, key)) continue
198
214
  const projector = nested.get(key)
199
- define(out, key, projector === undefined ? value[key] : projector(value[key]))
215
+ define(out, key, projector === undefined ? value[key] : projector(writtenAt(value[key], key)))
200
216
  }
201
217
  if (patterns.length > 0) projectPatternKeys(value, out, declared, patterns, closed)
202
218
  return out
@@ -340,7 +356,8 @@ function projectPatternKeys(
340
356
  matches++
341
357
  }
342
358
  if (matches === 0) continue
343
- if (matches === 1 && projector !== undefined) define(out, key, projector(value[key]))
359
+ if (matches === 1 && projector !== undefined)
360
+ define(out, key, projector(writtenAt(value[key], key)))
344
361
  else if (closed) define(out, key, value[key])
345
362
  }
346
363
  }
@@ -352,6 +369,101 @@ function define(out: Record<string, unknown>, key: string, value: unknown): void
352
369
  else out[key] = value
353
370
  }
354
371
 
372
+ const DATE_TO_JSON = Date.prototype.toJSON
373
+
374
+ // Deeper than this, the copying walk takes over with its own cycle check.
375
+ const MAX_HOOK_SCAN_DEPTH = 64
376
+
377
+ /** Whether `value` holds a `toJSON` hook other than a `Date`'s. Allocates nothing: most payloads
378
+ * have none, and this is the price every contracted response pays. */
379
+ function hasHook(value: object, depth: number): boolean {
380
+ if (depth > MAX_HOOK_SCAN_DEPTH) return true
381
+ const hook: unknown = Reflect.get(value, "toJSON")
382
+ if (typeof hook === "function") return !(hook === DATE_TO_JSON && value instanceof Date)
383
+ if (Array.isArray(value)) {
384
+ for (let i = 0; i < value.length; i++) {
385
+ const item: unknown = value[i]
386
+ if (typeof item === "object" && item !== null && hasHook(item, depth + 1)) return true
387
+ }
388
+ return false
389
+ }
390
+ // Inherited enumerable keys are scanned too; that only ever sends a value to the copying walk.
391
+ for (const key in value) {
392
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: `key` was enumerated from `value`; `Reflect.get` here halves the speed of this for-in.
393
+ const field: unknown = (value as Record<string, unknown>)[key]
394
+ if (typeof field === "object" && field !== null && hasHook(field, depth + 1)) return true
395
+ }
396
+ return false
397
+ }
398
+
399
+ /**
400
+ * What `JSON.stringify` writes for `value`: every `toJSON` hook applied as the serializer applies it,
401
+ * so the contract judges the bytes that go out rather than fields a hook then replaces. Copies only the
402
+ * containers above a hook. A `Date` keeps its object, since its own hook writes nothing but the ISO
403
+ * string a date schema declares.
404
+ */
405
+ const wireValueOf = (value: unknown): unknown =>
406
+ typeof value === "object" && value !== null && hasHook(value, 0)
407
+ ? wireValue(value, "", [])
408
+ : value
409
+
410
+ const wireValue = (value: unknown, key: string | number, path: object[]): unknown =>
411
+ wireChildren(writtenAt(value, key), path)
412
+
413
+ /** `value` as the serializer takes it under `key`: its own hook applied, its fields still as they are. */
414
+ function writtenAt(value: unknown, key: string | number): unknown {
415
+ if (typeof value !== "object" || value === null) return value
416
+ const hook: unknown = Reflect.get(value, "toJSON")
417
+ if (typeof hook !== "function" || (hook === DATE_TO_JSON && value instanceof Date)) return value
418
+ return fieldsOf(Reflect.apply(hook, value, [String(key)]))
419
+ }
420
+
421
+ /**
422
+ * The serializer calls one hook per value, so a hook's result that has a hook of its own is written
423
+ * by its fields. Copying them out keeps that second hook from running when the checked value is sent.
424
+ */
425
+ function fieldsOf(value: unknown): unknown {
426
+ if (typeof value !== "object" || value === null) return value
427
+ if (typeof Reflect.get(value, "toJSON") !== "function") return value
428
+ if (Array.isArray(value)) return Array.from({ length: value.length }, (_, i): unknown => value[i])
429
+ const fields: Record<string, unknown> = {}
430
+ for (const name of Object.keys(value))
431
+ if (name !== "toJSON") define(fields, name, Reflect.get(value, name))
432
+ return fields
433
+ }
434
+
435
+ function wireChildren(value: unknown, path: object[]): unknown {
436
+ // A cycle is left as it is for `JSON.stringify` to refuse.
437
+ if (typeof value !== "object" || value === null || path.includes(value)) return value
438
+ path.push(value)
439
+ if (Array.isArray(value)) {
440
+ let items: unknown[] | undefined
441
+ for (let i = 0; i < value.length; i++) {
442
+ const item: unknown = value[i]
443
+ const written = wireValue(item, i, path)
444
+ if (written !== item) items ??= value.slice(0, i)
445
+ if (items !== undefined) items[i] = written
446
+ }
447
+ path.pop()
448
+ return items ?? value
449
+ }
450
+ const keys = Object.keys(value)
451
+ let out: Record<string, unknown> | undefined
452
+ let index = 0
453
+ for (const name of keys) {
454
+ const field: unknown = Reflect.get(value, name)
455
+ const written = wireValue(field, name, path)
456
+ if (written !== field && out === undefined) {
457
+ out = {}
458
+ for (const earlier of keys.slice(0, index)) define(out, earlier, Reflect.get(value, earlier))
459
+ }
460
+ if (out !== undefined) define(out, name, written)
461
+ index++
462
+ }
463
+ path.pop()
464
+ return out ?? value
465
+ }
466
+
355
467
  const MAX_STRIPPED_NAMES = 20
356
468
  const MAX_NAME_LENGTH = 64
357
469
  const MAX_REPORTED_SETS = 32
@@ -435,7 +547,7 @@ const schemaFor = (definition: ResponseContractDefinition, code: number) =>
435
547
  function reserialize(reply: Response, value: unknown): Response {
436
548
  const headers = new Headers(reply.headers)
437
549
  headers.delete("content-length")
438
- return new Response(JSON.stringify(value), {
550
+ return new Response(jsonText(value), {
439
551
  status: reply.status,
440
552
  statusText: reply.statusText,
441
553
  headers,
@@ -523,10 +635,21 @@ function validateContract(
523
635
  replace: (value: unknown) => unknown,
524
636
  ): ResponseContractOutcome | Promise<ResponseContractOutcome> {
525
637
  const project = policy.strip ? projectorOf(schema) : undefined
526
- const settled = schema["~standard"].validate(project === undefined ? payload : project(payload))
638
+ // Naming what was stripped compares against everything the payload writes, so it walks it whole.
639
+ const before =
640
+ project === undefined || policy.report !== undefined || mode === "warn"
641
+ ? wireValueOf(payload)
642
+ : payload
643
+ // The projection applies the hook of each value it descends into and reads only declared fields,
644
+ // so a quiet strip never enumerates the payload's own keys. What it copies whole is walked after.
645
+ const sent = project === undefined ? before : wireValueOf(project(writtenAt(before, "")))
646
+ const settled = schema["~standard"].validate(sent)
647
+ // When hooks wrote the payload, `warn` sends what they wrote here rather than calling them again,
648
+ // so a hook that answers differently on a second call cannot send what was not judged.
649
+ const shown = mode === "warn" && before !== payload ? replace(before) : served
527
650
  return settled instanceof Promise
528
- ? settled.then((r) => interpret(r, payload, served, mode, replace, policy.report))
529
- : interpret(settled, payload, served, mode, replace, policy.report)
651
+ ? settled.then((r) => interpret(r, before, shown, mode, replace, policy.report))
652
+ : interpret(settled, before, shown, mode, replace, policy.report)
530
653
  }
531
654
 
532
655
  function interpret(
@@ -0,0 +1,24 @@
1
+ import { type Method, Router } from "../router/router.ts"
2
+ import type { RouteDescriptor } from "./server-types.ts"
3
+
4
+ /**
5
+ * A request's matched route template (`/users/:id`, never `/users/42`), or `undefined` when no route
6
+ * serves it. The matcher is built on first use: by serving time every route is registered, so
7
+ * `app.routes()` is complete, where building it when a plugin is applied would miss later routes.
8
+ */
9
+ export function routeTemplateOf(app: {
10
+ routes(): ReadonlyArray<RouteDescriptor>
11
+ }): (method: string, path: string) => string | undefined {
12
+ let matcher: Router<string> | undefined
13
+ return (method, path) => {
14
+ if (matcher === undefined) {
15
+ matcher = new Router<string>()
16
+ for (const route of app.routes()) {
17
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: every registered route's method is one this router already accepted when the app registered it.
18
+ matcher.add(route.method as Method, route.path, route.path)
19
+ }
20
+ }
21
+ const found = matcher.find(method, path)
22
+ return found.found ? found.payload : undefined
23
+ }
24
+ }
@@ -6,11 +6,21 @@
6
6
  */
7
7
  import { createUnboundedRequestBudget, type RequestBudget } from "../budget.ts"
8
8
  import { NODE_BRIDGE_MARKER_KEYS } from "./bridge-markers.ts"
9
+ import { jsonText } from "./json-text.ts"
9
10
  import type { CtxSet, RequestSource } from "./server.ts"
10
11
 
11
12
  /** A handler returns a `Response` (used as-is) or any value (serialized to JSON). */
12
13
  export type HandlerResult = Response | unknown
13
14
 
15
+ /**
16
+ * The Content-Type the runtime's own `Response.json` writes, which every JSON nifra renders on the Web
17
+ * lanes carries too, so a handler's JSON and the framework's envelopes (built with `Response.json`)
18
+ * agree on one runtime. Bun adds `;charset=utf-8`; Deno, workerd and undici do not. Named rather than
19
+ * probed: workerd forbids `Response.json()` while a module loads.
20
+ */
21
+ export const JSON_CONTENT_TYPE =
22
+ "Bun" in globalThis ? "application/json;charset=utf-8" : "application/json"
23
+
14
24
  export const RESPONSE_RESULT = Symbol.for(NODE_BRIDGE_MARKER_KEYS.responseResult)
15
25
  export const CONTEXT_SET = Symbol("nifra.context.set")
16
26
  export const CONTEXT_SEARCH = Symbol("nifra.context.search")
@@ -178,8 +188,8 @@ export function status(
178
188
  toResponse(): Response {
179
189
  const headers: Record<string, string> = { ...init?.headers }
180
190
  if (body === undefined) return new Response(null, { status: code, headers })
181
- headers["content-type"] ??= "application/json;charset=utf-8"
182
- return new Response(JSON.stringify(body), { status: code, headers })
191
+ headers["content-type"] ??= JSON_CONTENT_TYPE
192
+ return new Response(jsonText(body), { status: code, headers })
183
193
  },
184
194
  } as StatusResponse<number, unknown>
185
195
  }
@@ -91,8 +91,9 @@ export interface ServerOptions {
91
91
  readonly trustBodyFraming?: boolean
92
92
  /**
93
93
  * Max inbound WebSocket message size (bytes). A larger message never reaches the handler and the
94
- * socket closes with `1009`; when Bun's own frame limit refuses it, Bun drops the connection and the
95
- * client sees `1006`, while the `close` handler still gets `1009`. Default: `maxBodyBytes` (1 MB).
94
+ * socket closes with `1009`; when Bun's own frame limit refuses it, Bun up to 1.4.3 drops the
95
+ * connection (the client sees `1006`) and Bun 1.4.4 sends `1009`, while the `close` handler gets
96
+ * `1009` either way. Default: `maxBodyBytes` (1 MB).
96
97
  * Bun and Node stop buffering at the cap, so a huge frame is never held in memory; on Bun a mounted
97
98
  * app's smaller cap, and on Deno and Workers every cap, is checked after the runtime has buffered
98
99
  * the whole message.
@@ -124,10 +125,9 @@ export interface ServerOptions {
124
125
  readonly maxInboundDeadlineMs?: number
125
126
  /**
126
127
  * When `listen()`ing, install SIGTERM/SIGINT handlers that gracefully `stop()`. Default false.
127
- * `{ drainMs }` sets how long in-flight requests get to finish before the stop forces them closed
128
- * (default 10_000) - keep it under the platform's kill grace period (`docker stop` waits 10 s).
128
+ * The object form is passed to `stop()`: see {@link StopOptions}.
129
129
  */
130
- readonly gracefulSignals?: boolean | { readonly drainMs?: number }
130
+ readonly gracefulSignals?: boolean | StopOptions
131
131
  /**
132
132
  * Capacity-admission gate (see {@link AdmissionController}). Bounds concurrent in-flight work so a
133
133
  * healthy instance sheds load (`429` + `Retry-After`) instead of accepting more than it can finish.
@@ -253,9 +253,21 @@ export interface MountFetchOptions {
253
253
  readonly opaque?: string | OpaqueMountDeclaration
254
254
  }
255
255
 
256
- /** A callback awaited after the Bun server has drained and stopped. */
256
+ /** A callback a server awaits while it stops: `onStopping` when it begins, `onStop` after the drain. */
257
257
  export type StopHook = () => MaybePromise<void>
258
258
 
259
+ /** How `stop()` winds a listening server down. */
260
+ export interface StopOptions {
261
+ /** How long in-flight requests get to finish before the stop forces them closed. Default 10_000. */
262
+ readonly drainMs?: number
263
+ /**
264
+ * How long the server keeps serving after `stop()` began, before it drains: the time a load
265
+ * balancer takes to see readiness fail and route elsewhere. Default 0. Keep `delayMs + drainMs`
266
+ * under the platform's kill grace period (`docker stop` waits 10 s, Kubernetes 30 s).
267
+ */
268
+ readonly delayMs?: number
269
+ }
270
+
259
271
  /**
260
272
  * MCP tool safety hints, surfaced in `tools/list`, that tell an agent how risky a `.tool()` call is - so it
261
273
  * can decide whether to auto-invoke or confirm first. All optional; an omitted hint means "unknown". Mirrors