@nifrajs/core 5.6.0 → 5.8.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 (124) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/README.md +5 -0
  3. package/dist/binary.d.ts +15 -1
  4. package/dist/binary.d.ts.map +1 -1
  5. package/dist/binary.js +1 -0
  6. package/dist/binary.js.map +1 -1
  7. package/dist/edge-kit.d.ts +1 -0
  8. package/dist/edge-kit.d.ts.map +1 -1
  9. package/dist/edge-kit.js +1 -0
  10. package/dist/edge-kit.js.map +1 -1
  11. package/dist/errors.d.ts +1 -1
  12. package/dist/errors.d.ts.map +1 -1
  13. package/dist/errors.js.map +1 -1
  14. package/dist/evidence.d.ts +2 -0
  15. package/dist/evidence.d.ts.map +1 -1
  16. package/dist/evidence.js +2 -0
  17. package/dist/evidence.js.map +1 -1
  18. package/dist/index.d.ts +2 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +2 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/internal/hook-origins.d.ts +2 -0
  23. package/dist/internal/hook-origins.d.ts.map +1 -1
  24. package/dist/internal/hook-origins.js +12 -0
  25. package/dist/internal/hook-origins.js.map +1 -1
  26. package/dist/reflection.d.ts +5 -0
  27. package/dist/reflection.d.ts.map +1 -1
  28. package/dist/reflection.js +12 -0
  29. package/dist/reflection.js.map +1 -1
  30. package/dist/server/answer-step.d.ts +27 -0
  31. package/dist/server/answer-step.d.ts.map +1 -0
  32. package/dist/server/answer-step.js +33 -0
  33. package/dist/server/answer-step.js.map +1 -0
  34. package/dist/server/base64-answers.d.ts +5 -0
  35. package/dist/server/base64-answers.d.ts.map +1 -0
  36. package/dist/server/base64-answers.js +132 -0
  37. package/dist/server/base64-answers.js.map +1 -0
  38. package/dist/server/core-copies.d.ts.map +1 -1
  39. package/dist/server/core-copies.js +5 -2
  40. package/dist/server/core-copies.js.map +1 -1
  41. package/dist/server/install.d.ts +2 -0
  42. package/dist/server/install.d.ts.map +1 -1
  43. package/dist/server/install.js +2 -0
  44. package/dist/server/install.js.map +1 -1
  45. package/dist/server/json-response.d.ts +3 -0
  46. package/dist/server/json-response.d.ts.map +1 -0
  47. package/dist/server/json-response.js +14 -0
  48. package/dist/server/json-response.js.map +1 -0
  49. package/dist/server/json-text.d.ts +16 -0
  50. package/dist/server/json-text.d.ts.map +1 -0
  51. package/dist/server/json-text.js +34 -0
  52. package/dist/server/json-text.js.map +1 -0
  53. package/dist/server/node-outcome.d.ts +5 -0
  54. package/dist/server/node-outcome.d.ts.map +1 -1
  55. package/dist/server/node-outcome.js +3 -4
  56. package/dist/server/node-outcome.js.map +1 -1
  57. package/dist/server/request-context.d.ts.map +1 -1
  58. package/dist/server/request-context.js +4 -2
  59. package/dist/server/request-context.js.map +1 -1
  60. package/dist/server/respond.d.ts +2 -1
  61. package/dist/server/respond.d.ts.map +1 -1
  62. package/dist/server/respond.js +27 -24
  63. package/dist/server/respond.js.map +1 -1
  64. package/dist/server/response-contract-lane.d.ts.map +1 -1
  65. package/dist/server/response-contract-lane.js +2 -1
  66. package/dist/server/response-contract-lane.js.map +1 -1
  67. package/dist/server/route-template.d.ts +10 -0
  68. package/dist/server/route-template.d.ts.map +1 -0
  69. package/dist/server/route-template.js +21 -0
  70. package/dist/server/route-template.js.map +1 -0
  71. package/dist/server/runtime-core.d.ts.map +1 -1
  72. package/dist/server/runtime-core.js +2 -1
  73. package/dist/server/runtime-core.js.map +1 -1
  74. package/dist/server/server-types.d.ts +26 -6
  75. package/dist/server/server-types.d.ts.map +1 -1
  76. package/dist/server/server.d.ts +22 -10
  77. package/dist/server/server.d.ts.map +1 -1
  78. package/dist/server/server.js +109 -45
  79. package/dist/server/server.js.map +1 -1
  80. package/dist/server/sse.d.ts.map +1 -1
  81. package/dist/server/sse.js +2 -1
  82. package/dist/server/sse.js.map +1 -1
  83. package/dist/server/text-slots.d.ts +8 -0
  84. package/dist/server/text-slots.d.ts.map +1 -0
  85. package/dist/server/text-slots.js +19 -0
  86. package/dist/server/text-slots.js.map +1 -0
  87. package/dist/server.d.ts +1 -0
  88. package/dist/server.d.ts.map +1 -1
  89. package/dist/server.js +1 -0
  90. package/dist/server.js.map +1 -1
  91. package/dist/single-copy.d.ts +5 -0
  92. package/dist/single-copy.d.ts.map +1 -1
  93. package/dist/single-copy.js +26 -17
  94. package/dist/single-copy.js.map +1 -1
  95. package/dist/transport-codec.d.ts.map +1 -1
  96. package/dist/transport-codec.js +2 -1
  97. package/dist/transport-codec.js.map +1 -1
  98. package/package.json +1 -1
  99. package/src/binary.ts +17 -0
  100. package/src/edge-kit.ts +1 -0
  101. package/src/errors.ts +1 -0
  102. package/src/evidence.ts +4 -0
  103. package/src/index.ts +2 -1
  104. package/src/internal/hook-origins.ts +10 -0
  105. package/src/reflection.ts +18 -0
  106. package/src/server/answer-step.ts +68 -0
  107. package/src/server/base64-answers.ts +138 -0
  108. package/src/server/core-copies.ts +5 -2
  109. package/src/server/install.ts +3 -0
  110. package/src/server/json-response.ts +13 -0
  111. package/src/server/json-text.ts +34 -0
  112. package/src/server/node-outcome.ts +4 -5
  113. package/src/server/request-context.ts +4 -2
  114. package/src/server/respond.ts +27 -18
  115. package/src/server/response-contract-lane.ts +2 -1
  116. package/src/server/route-template.ts +24 -0
  117. package/src/server/runtime-core.ts +2 -1
  118. package/src/server/server-types.ts +27 -4
  119. package/src/server/server.ts +131 -63
  120. package/src/server/sse.ts +2 -1
  121. package/src/server/text-slots.ts +21 -0
  122. package/src/server.ts +1 -0
  123. package/src/single-copy.ts +31 -15
  124. package/src/transport-codec.ts +3 -1
@@ -0,0 +1,68 @@
1
+ import { RouteConfigError } from "../errors.ts"
2
+ import type { RouteSchema } from "./context.ts"
3
+
4
+ /**
5
+ * A schema's own step around what answers a route, for a schema that changes how the answer it
6
+ * describes is written: `t.bytes()` has every `Uint8Array` leave as base64. It sits on the schema's
7
+ * `validate`, so a spread copy keeps it, and is read once, at registration.
8
+ */
9
+ const ANSWER_STEP = Symbol.for("nifra.schema.answerStep")
10
+
11
+ /** The part of a request's context a step reads: the `c.json(...)` reply it made, and its value. */
12
+ export interface JsonReplyContext {
13
+ readonly jsonReply?: Response | undefined
14
+ readonly jsonBody?: unknown
15
+ json(body: unknown, init?: ResponseInit): Response
16
+ }
17
+
18
+ /**
19
+ * What can answer a route: its handler, and each hook whose return value takes the handler's place -
20
+ * a `beforeHandle` that answers first, an `afterHandle` that replaces the answer, an `onError` that
21
+ * answers an error. Each takes the request's context, as a `JsonReplyContext`.
22
+ */
23
+ export interface AnswerStages {
24
+ readonly handler: (context: never) => unknown
25
+ readonly beforeHandle: readonly ((context: never) => unknown)[]
26
+ readonly afterHandle: readonly ((result: unknown, context: never) => unknown)[]
27
+ readonly onError: readonly ((error: unknown, context: never) => unknown)[]
28
+ }
29
+
30
+ export interface AnswerStep {
31
+ /** Why the schema cannot describe a request or an event stream, and what to use there instead. */
32
+ readonly misplaced: string
33
+ /** `stages`, each wrapped so what it answers is written the schema's way. */
34
+ readonly wrap: <S extends AnswerStages>(stages: S) => S
35
+ }
36
+
37
+ function stepOf(schema: object | undefined): AnswerStep | undefined {
38
+ const validate = schema === undefined ? undefined : Reflect.get(schema, "~standard")?.validate
39
+ return typeof validate === "function" ? Reflect.get(validate, ANSWER_STEP) : undefined
40
+ }
41
+
42
+ const REQUEST_FIELDS = ["params", "headers", "cookies", "query", "body", "sse"] as const
43
+
44
+ /** `stages` wrapped by each step its route's `response` and `errors` schemas carry. */
45
+ export function withAnswerSteps<S extends AnswerStages>(
46
+ schema: RouteSchema | undefined,
47
+ method: string,
48
+ path: string,
49
+ stages: S,
50
+ ): S {
51
+ if (schema === undefined) return stages
52
+ for (const field of REQUEST_FIELDS) {
53
+ const step = stepOf(schema[field])
54
+ if (step !== undefined) {
55
+ throw new RouteConfigError(
56
+ "RESPONSE_ONLY_SCHEMA",
57
+ `route ${method} ${path}, \`${field}\`: ${step.misplaced}`,
58
+ )
59
+ }
60
+ }
61
+ const steps = new Set<AnswerStep["wrap"]>()
62
+ for (const answer of [schema.response, ...Object.values(schema.errors ?? {})]) {
63
+ const step = stepOf(answer)
64
+ if (step !== undefined) steps.add(step.wrap)
65
+ }
66
+ for (const wrap of steps) stages = wrap(stages)
67
+ return stages
68
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Bytes in a JSON answer, written as base64.
3
+ *
4
+ * `JSON.stringify` writes a `Uint8Array` as `{"0":1,"1":2,...}`, and nothing about that throws, so
5
+ * finding one costs a walk of every answer. Only a route that asks pays for that walk: `t.bytes()` in
6
+ * its `response` or `errors` has core hand {@link withBase64Bytes} the route's handler and the hooks
7
+ * that can answer in its place, and every `Uint8Array` in what any of them returns or throws as an
8
+ * answer becomes a base64 string before a later hook, contract or writer sees it. Nothing in the
9
+ * server imports this module, so an app that never declares the field never bundles it.
10
+ */
11
+ import { tagStandIn } from "../internal/hook-origins.ts"
12
+ import type { AnswerStages, JsonReplyContext } from "./answer-step.ts"
13
+ import { isResponseResult, status } from "./runtime-core.ts"
14
+
15
+ // Deeper than any real payload.
16
+ const MAX_DEPTH = 64
17
+ // So `String.fromCharCode(...)` never overflows the call stack.
18
+ const CHUNK = 0x8000
19
+
20
+ function base64(bytes: Uint8Array): string {
21
+ let binary = ""
22
+ for (let i = 0; i < bytes.length; i += CHUNK)
23
+ binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK))
24
+ return btoa(binary)
25
+ }
26
+
27
+ function define(out: object, key: string, value: unknown): void {
28
+ // `out[key] = value` for "__proto__" would replace the prototype instead of adding the field.
29
+ Object.defineProperty(out, key, { value, enumerable: true, writable: true, configurable: true })
30
+ }
31
+
32
+ /**
33
+ * `value` with each `Uint8Array` as base64, copying only the containers above one. A container already
34
+ * on the walk's path is left as it is, so a cycle costs one step, as it does `JSON.stringify`, which
35
+ * then refuses it.
36
+ */
37
+ function encoded(value: unknown, depth: number, path: Set<object>): unknown {
38
+ if (value instanceof Uint8Array) return base64(value)
39
+ if (typeof value !== "object" || value === null || depth > MAX_DEPTH || path.has(value))
40
+ return value
41
+ // A `Date` (or any value with a hook) writes itself.
42
+ if (typeof Reflect.get(value, "toJSON") === "function") return value
43
+ path.add(value)
44
+ try {
45
+ if (Array.isArray(value)) {
46
+ let out: unknown[] | undefined
47
+ for (let i = 0; i < value.length; i++) {
48
+ const next = encoded(value[i], depth + 1, path)
49
+ if (next === value[i]) continue
50
+ out ??= [...value]
51
+ out[i] = next
52
+ }
53
+ return out ?? value
54
+ }
55
+ let out: object | undefined
56
+ for (const key of Object.keys(value)) {
57
+ const item: unknown = Reflect.get(value, key)
58
+ const next = encoded(item, depth + 1, path)
59
+ if (next === item) continue
60
+ out ??= { ...value }
61
+ define(out, key, next)
62
+ }
63
+ return out ?? value
64
+ } finally {
65
+ path.delete(value)
66
+ }
67
+ }
68
+
69
+ const encodedBody = (body: unknown): unknown => encoded(body, 0, new Set())
70
+
71
+ /** An answer with its bytes encoded. A raw `Response` is left alone; a `c.json(...)` reply is rebuilt
72
+ * from the value it was made from, keeping its status and headers. */
73
+ function encodedAnswer(answer: unknown, c: JsonReplyContext): unknown {
74
+ if (answer instanceof Response) {
75
+ if (answer !== c.jsonReply) return answer
76
+ const body = encodedBody(c.jsonBody)
77
+ if (body === c.jsonBody) return answer
78
+ const headers = new Headers(answer.headers)
79
+ headers.delete("content-length")
80
+ return c.json(body, { status: answer.status, headers })
81
+ }
82
+ if (isResponseResult(answer)) {
83
+ const plain = answer.plain
84
+ if (plain === undefined) return answer
85
+ const body = encodedBody(plain.body)
86
+ if (body === plain.body) return answer
87
+ return status(
88
+ plain.status,
89
+ body,
90
+ plain.headers === undefined ? undefined : { headers: plain.headers },
91
+ )
92
+ }
93
+ return encodedBody(answer)
94
+ }
95
+
96
+ /** A thrown answer (`status(...)`, `c.json(...)`) is encoded too; any other throw passes untouched. */
97
+ const encodedThrow = (thrown: unknown, c: JsonReplyContext): unknown =>
98
+ thrown instanceof Response || isResponseResult(thrown) ? encodedAnswer(thrown, c) : thrown
99
+
100
+ /**
101
+ * `stage` with what it returns or throws as an answer encoded. Its context is argument `at`; an
102
+ * `undefined` return (a hook that lets the route go on) stays `undefined`.
103
+ */
104
+ function encoding<F extends (...args: never[]) => unknown>(stage: F, at: 0 | 1): F {
105
+ const wrapped = (...args: never[]): unknown => {
106
+ const c: JsonReplyContext = args[at]!
107
+ let result: unknown
108
+ try {
109
+ result = stage(...args)
110
+ } catch (thrown) {
111
+ throw encodedThrow(thrown, c)
112
+ }
113
+ if (!(result instanceof Promise)) return encodedAnswer(result, c)
114
+ return result.then(
115
+ (answer) => encodedAnswer(answer, c),
116
+ (thrown) => {
117
+ throw encodedThrow(thrown, c)
118
+ },
119
+ )
120
+ }
121
+ // Named as the stage, so stack traces and the hook report still name the author's function.
122
+ Object.defineProperty(wrapped, "name", { value: stage.name })
123
+ tagStandIn(wrapped, stage)
124
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: the wrapper takes `F`'s arguments and returns `unknown`, which is every stage's own return type.
125
+ return wrapped as F
126
+ }
127
+
128
+ /** A route's handler and answering hooks, each wrapped so every `Uint8Array` it answers leaves as
129
+ * base64. */
130
+ export function withBase64Bytes<S extends AnswerStages>(stages: S): S {
131
+ return {
132
+ ...stages,
133
+ handler: encoding(stages.handler, 0),
134
+ beforeHandle: stages.beforeHandle.map((hook) => encoding(hook, 0)),
135
+ afterHandle: stages.afterHandle.map((hook) => encoding(hook, 1)),
136
+ onError: stages.onError.map((hook) => encoding(hook, 1)),
137
+ }
138
+ }
@@ -16,14 +16,17 @@ const copies = registry[COPIES]
16
16
  const here = String(import.meta.url).split("?")[0] as string
17
17
 
18
18
  if (!copies.includes(here) && copies.push(here) > 1) {
19
+ // The registrar marks the process when it installs; without the mark a declared singleCopy was never
20
+ // armed here, most often because bunfig.toml's preload line sits below a [table] header. Bun-only and
21
+ // behind the inline NODE_ENV literal, so a production bundle drops the hint.
19
22
  console.warn(
20
- `[nifra] @nifrajs/core is loaded ${copies.length} times; \`nifra check\` names each importer:\n ${copies.join("\n ")}`,
23
+ `[nifra] @nifrajs/core is loaded ${copies.length} times; \`nifra check\` names each importer:\n ${copies.join("\n ")}${typeof Bun !== "undefined" && process.env.NODE_ENV !== "production" && !(Symbol.for("nifra.single-copy.active") in globalThis) ? "\n No single-copy registrar ran in this process: bunfig.toml must preload @nifrajs/core/single-copy/register at its top level, above any [table], for bun run, and under [test] for bun test." : ""}`,
21
24
  )
22
25
  }
23
26
 
24
27
  /** This copy's version: a literal like the package root's `VERSION` (no fs on the edge), which
25
28
  * `scripts/version.ts` rewrites on every release and `check:publish` asserts. */
26
- const COPY_VERSION = "5.6.0"
29
+ const COPY_VERSION = "5.8.0"
27
30
 
28
31
  /** Record this copy's version beside its URL. Called by each `server()` behind the development guard,
29
32
  * so a production bundle drops it with the version; a copy that built a server has recorded one. */
@@ -52,5 +52,8 @@ export const RESOLVE_NODE_MOUNT: unique symbol = Symbol.for(
52
52
  NODE_BRIDGE_MARKER_KEYS.resolveNodeMount,
53
53
  )
54
54
 
55
+ /** @internal Run a server's `onStopping` or `onStop` hooks; see `Server.stop()`. */
56
+ export const SERVER_LIFECYCLE: unique symbol = Symbol.for("@nifrajs/core/server-lifecycle")
57
+
55
58
  /** @internal Marker on a mounted handler that knows how to serve a native Node request/response. */
56
59
  export const NODE_NATIVE_MOUNT: unique symbol = Symbol.for("@nifrajs/core/node-native-mount")
@@ -0,0 +1,13 @@
1
+ import { retried } from "./json-text.ts"
2
+
3
+ /** `Response.json`, with each `bigint` written as its decimal string, as `jsonText` writes it. */
4
+ export function jsonResponse(value: unknown, init?: ResponseInit): Response {
5
+ try {
6
+ return Response.json(value, init)
7
+ } catch (error) {
8
+ const text = retried(value, error)
9
+ // Parsed back so the runtime's own `Response.json` still picks the content-type, and still
10
+ // throws for a value with no JSON form.
11
+ return Response.json(typeof text === "string" ? JSON.parse(text) : value, init)
12
+ }
13
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * JSON has no `bigint`, and `JSON.stringify` throws on one, so a handler returning a database's 64-bit
3
+ * id answered a 500. Every plain JSON writer goes through this instead: a `bigint` is written as its
4
+ * decimal string, the encoding OpenAPI generators and protobuf's JSON mapping use for 64-bit integers.
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.
7
+ */
8
+
9
+ const bigintAsText = (_key: string, value: unknown): unknown =>
10
+ typeof value === "bigint" ? value.toString() : value
11
+
12
+ /**
13
+ * `JSON.stringify`, with each `bigint` written as its decimal string. Typed as the lib types
14
+ * `JSON.stringify`: a value with no JSON form (`undefined`, a function) still gives `undefined`.
15
+ */
16
+ export function jsonText(value: unknown): string {
17
+ try {
18
+ return JSON.stringify(value)
19
+ } catch (error) {
20
+ return retried(value, error)
21
+ }
22
+ }
23
+
24
+ /** Write `value` again, each `bigint` as text, after `JSON.stringify(value)` threw `error`; any other
25
+ * failure rethrows `error`. */
26
+ export function retried(value: unknown, error: unknown): string {
27
+ if (!(error instanceof TypeError)) throw error
28
+ try {
29
+ return JSON.stringify(value, bigintAsText)
30
+ } catch {
31
+ // A cycle or a throwing `toJSON` fails this pass too; the first error is the one to report.
32
+ throw error
33
+ }
34
+ }
@@ -3,6 +3,8 @@
3
3
  * conversion. Mirrors `respond.ts` but skips the undici `Response` build where the adapter can write a
4
4
  * plain-data render straight to the socket. Imports only runtime-core + respond + the spine types.
5
5
  */
6
+
7
+ import { jsonText } from "./json-text.ts"
6
8
  import {
7
9
  applySetToResponse,
8
10
  applyStaticResponseHeaders,
@@ -88,7 +90,7 @@ export function toNodeOutcome(result: HandlerResult, set: CtxSet): NodeServeOutc
88
90
  return nodeOutcomeFromResponse(applySetToResponse(normalizeBodylessResponse(result), set))
89
91
  }
90
92
  const status = set.status ?? (result === undefined ? 204 : 200)
91
- const body = result === undefined || isBodylessStatus(status) ? null : JSON.stringify(result)
93
+ const body = result === undefined || isBodylessStatus(status) ? null : jsonText(result)
92
94
  return {
93
95
  kind: "json",
94
96
  status,
@@ -109,10 +111,7 @@ export function plainNodeOutcome(plain: PlainRender, set: CtxSet): NodeServeOutc
109
111
  status: plain.status,
110
112
  headers: plainRenderHeaders(plain, set),
111
113
  cookies: set._cookies,
112
- body:
113
- plain.body === undefined || isBodylessStatus(plain.status)
114
- ? null
115
- : JSON.stringify(plain.body),
114
+ body: plain.body === undefined || isBodylessStatus(plain.status) ? null : jsonText(plain.body),
116
115
  }
117
116
  }
118
117
 
@@ -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
@@ -7,6 +7,8 @@
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,
@@ -215,10 +217,11 @@ function jsonResponseWithOwnHeaders(
215
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(
@@ -504,8 +508,8 @@ function tagged(
504
508
  * prebuilt init. Both keep the exact body/content-type contract. */
505
509
  function plainJsonResponse(result: object): Response {
506
510
  // `undefined` from stringify (a `toJSON` returning nothing) keeps `Response.json`'s TypeError.
507
- const body = IS_BUN || IS_DENO ? undefined : (JSON.stringify(result) as string | undefined)
508
- return body === undefined ? Response.json(result) : new Response(body, JSON_INIT_200)
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)
509
513
  }
510
514
 
511
515
  /** Fused-lane respond when `c.set` is untouched; keep the generic fallback for non-JSON values. */
@@ -550,7 +554,7 @@ export function fusedRespondNoSet(
550
554
  // Deno the tier's only saving here is the response walk itself; every other runtime keeps the
551
555
  // one-call prebuilt init below.
552
556
  if (statics !== undefined && IS_DENO && !tagResponseBody) {
553
- const response = Response.json(result)
557
+ const response = jsonResponse(result)
554
558
  // Indexed for the same reason as `applyStaticDefaults`: pair destructuring is a second
555
559
  // iterator walk per header.
556
560
  for (let i = 0; i < statics.entries.length; i++) {
@@ -563,7 +567,7 @@ export function fusedRespondNoSet(
563
567
  // content-type, so this lane still constructs the response in one call - the whole point of
564
568
  // declaring headers statically instead of writing them per response.
565
569
  if (tagResponseBody || statics !== undefined) {
566
- const body = JSON.stringify(result) as string | undefined
570
+ const body = jsonText(result) as string | undefined
567
571
  // Each shape carries the content-type of the lane it stands in for: the framework's own for the
568
572
  // tagging path this branch already owned, and `Response.json`'s for the construction below that
569
573
  // the static tier replaces. They differ on Deno, and a declared header must not change one.
@@ -587,7 +591,7 @@ export function fusedRespondNoSet(
587
591
  return withBody(
588
592
  statics === undefined
589
593
  ? plainJsonResponse(result)
590
- : Response.json(result, statics.responseJsonInit200()),
594
+ : jsonResponse(result, statics.responseJsonInit200()),
591
595
  )
592
596
  }
593
597
  return toResponse(
@@ -612,13 +616,14 @@ export function fusedRespond(
612
616
  const request = unstamped ? undefined : ctx[CONTEXT_REQUEST]
613
617
  if (set === undefined)
614
618
  return fusedRespondNoSet(result, tagResponseBody, statics, unstamped, request)
615
- return toResponse(result as HandlerResult, set, tagResponseBody, statics, request)
619
+ return toResponse(result as HandlerResult, set, tagResponseBody, statics, request, unstamped)
616
620
  }
617
621
 
618
622
  /**
619
623
  * Render a handler's result. `request` is the request being answered, given when response hooks will
620
624
  * write next: a copy or body-less response built here is then recorded as that request's own, so the
621
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.
622
627
  */
623
628
  export function toResponse(
624
629
  result: HandlerResult,
@@ -626,6 +631,7 @@ export function toResponse(
626
631
  tagResponseBody: ResponseBodyTagOption = false,
627
632
  statics?: StaticResponseHeaders,
628
633
  request?: Request,
634
+ unstamped = false,
629
635
  ): Response {
630
636
  if (isResponseResult(result)) {
631
637
  const plain = result.plain
@@ -639,6 +645,7 @@ export function toResponse(
639
645
  tagResponseBody,
640
646
  statics,
641
647
  request,
648
+ unstamped,
642
649
  )
643
650
  }
644
651
  // A buffered page is built from the body it reports, so the bytes its hooks see are the ones sent.
@@ -671,7 +678,7 @@ export function toResponse(
671
678
  typeof tagResponseBody === "object" ? tagResponseBody : undefined,
672
679
  )
673
680
  }
674
- return renderValue(result, set, tagResponseBody, statics, request)
681
+ return renderValue(result, set, tagResponseBody, statics, request, unstamped)
675
682
  }
676
683
 
677
684
  /**
@@ -701,6 +708,7 @@ function renderValue(
701
708
  tagResponseBody: ResponseBodyTagOption,
702
709
  statics: StaticResponseHeaders | undefined,
703
710
  request: Request | undefined,
711
+ unstamped = false,
704
712
  ): Response {
705
713
  // The static tier only changes WHICH prebuilt header shape the branches below reach for: the
706
714
  // request's own headers are folded on top of the declared defaults once, here.
@@ -730,14 +738,14 @@ function renderValue(
730
738
  typeof result === "object" &&
731
739
  result !== null
732
740
  ) {
733
- return jsonResponseWithOwnHeaders(result, status, own)
741
+ return jsonResponseWithOwnHeaders(result, status, own, unstamped)
734
742
  }
735
743
  if (own === undefined && result !== undefined) {
736
744
  // Fast respond (profiled ~50 ns/req faster on every plain-JSON return): `JSON.stringify` + a
737
745
  // prebuilt init beats `Response.json`'s internal init handling. Output is byte-identical -
738
746
  // same body bytes, same probed content-type. `undefined` from stringify (a function/symbol
739
747
  // result) delegates to Response.json so its TypeError contract stays the single source.
740
- const body = JSON.stringify(result) as string | undefined
748
+ const body = jsonText(result) as string | undefined
741
749
  if (body !== undefined) {
742
750
  const init: ResponseInit =
743
751
  status === 200
@@ -750,13 +758,14 @@ function renderValue(
750
758
  : staticJsonResponse(body, status, statics, JSON_CONTENT_TYPE, init),
751
759
  body,
752
760
  tagResponseBody,
761
+ unstamped,
753
762
  )
754
763
  }
755
764
  }
756
765
  const init: ResponseInit = headers === undefined ? { status } : { status, headers }
757
766
  if (result === undefined) return ownedBy(new Response(null, init), request)
758
767
  if (tagResponseBody) {
759
- const body = JSON.stringify(result) as string | undefined
768
+ const body = jsonText(result) as string | undefined
760
769
  if (body !== undefined) {
761
770
  const response =
762
771
  IS_DENO && !(headers instanceof Headers)
@@ -778,9 +787,9 @@ function renderValue(
778
787
  if (IS_DENO && headers !== undefined && !(headers instanceof Headers)) {
779
788
  // Deno's own `Response.json` then header writes measured ~10% under stringify, construction and
780
789
  // the same writes plus a content-type one; a declared content-type still overrides the default.
781
- return jsonResponseWithOwnHeaders(result, status, headers)
790
+ return jsonResponseWithOwnHeaders(result, status, headers, unstamped)
782
791
  }
783
- return withBody(Response.json(result, init))
792
+ return withBody(jsonResponse(result, init), unstamped)
784
793
  }
785
794
 
786
795
  // Add a JSON content-type without pre-building a `Headers` instance just to check-and-set one key:
@@ -59,6 +59,7 @@ import {
59
59
  type StandardSchemaV1,
60
60
  } from "../schema/standard.ts"
61
61
  import { INSTALL_RESPONSE_CONTRACT } from "./install.ts"
62
+ import { jsonText } from "./json-text.ts"
62
63
  import type { IdentityPlugin } from "./plugin.ts"
63
64
  import { isResponseResult, status } from "./runtime-core.ts"
64
65
  import type { AnyServer } from "./server.ts"
@@ -546,7 +547,7 @@ const schemaFor = (definition: ResponseContractDefinition, code: number) =>
546
547
  function reserialize(reply: Response, value: unknown): Response {
547
548
  const headers = new Headers(reply.headers)
548
549
  headers.delete("content-length")
549
- return new Response(JSON.stringify(value), {
550
+ return new Response(jsonText(value), {
550
551
  status: reply.status,
551
552
  statusText: reply.statusText,
552
553
  headers,
@@ -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,6 +6,7 @@
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). */
@@ -188,7 +189,7 @@ export function status(
188
189
  const headers: Record<string, string> = { ...init?.headers }
189
190
  if (body === undefined) return new Response(null, { status: code, headers })
190
191
  headers["content-type"] ??= JSON_CONTENT_TYPE
191
- return new Response(JSON.stringify(body), { status: code, headers })
192
+ return new Response(jsonText(body), { status: code, headers })
192
193
  },
193
194
  } as StatusResponse<number, unknown>
194
195
  }
@@ -89,6 +89,18 @@ export interface ServerOptions {
89
89
  * untrusted input.
90
90
  */
91
91
  readonly trustBodyFraming?: boolean
92
+ /**
93
+ * Answer `400 invalid_host` on Bun to every Host that Node and Deno refuse: one sent twice, one
94
+ * no URL can be built on (a space, a byte past ASCII, a port past 65535, an IPv6 literal no parser
95
+ * takes), a percent-encoded or sub-delimiter one, and serve an HTTP/1.0 request with no Host as
96
+ * `localhost`. Bun serves those requests itself, building `c.req.url` on the first Host or leaving
97
+ * it relative, so `new URL(c.req.url)` can throw in a handler.
98
+ *
99
+ * Disabled by default: the check reads the Host on every request, which measured about 6% of a bare
100
+ * route on Bun. Left off, a target with `.` or `..` segments is still checked, so a dot segment is
101
+ * never routed raw. Node and Deno refuse these Hosts either way; the option changes nothing there.
102
+ */
103
+ readonly validateHost?: boolean
92
104
  /**
93
105
  * Max inbound WebSocket message size (bytes). A larger message never reaches the handler and the
94
106
  * socket closes with `1009`; when Bun's own frame limit refuses it, Bun up to 1.4.3 drops the
@@ -125,10 +137,9 @@ export interface ServerOptions {
125
137
  readonly maxInboundDeadlineMs?: number
126
138
  /**
127
139
  * When `listen()`ing, install SIGTERM/SIGINT handlers that gracefully `stop()`. Default false.
128
- * `{ drainMs }` sets how long in-flight requests get to finish before the stop forces them closed
129
- * (default 10_000) - keep it under the platform's kill grace period (`docker stop` waits 10 s).
140
+ * The object form is passed to `stop()`: see {@link StopOptions}.
130
141
  */
131
- readonly gracefulSignals?: boolean | { readonly drainMs?: number }
142
+ readonly gracefulSignals?: boolean | StopOptions
132
143
  /**
133
144
  * Capacity-admission gate (see {@link AdmissionController}). Bounds concurrent in-flight work so a
134
145
  * healthy instance sheds load (`429` + `Retry-After`) instead of accepting more than it can finish.
@@ -254,9 +265,21 @@ export interface MountFetchOptions {
254
265
  readonly opaque?: string | OpaqueMountDeclaration
255
266
  }
256
267
 
257
- /** A callback awaited after the Bun server has drained and stopped. */
268
+ /** A callback a server awaits while it stops: `onStopping` when it begins, `onStop` after the drain. */
258
269
  export type StopHook = () => MaybePromise<void>
259
270
 
271
+ /** How `stop()` winds a listening server down. */
272
+ export interface StopOptions {
273
+ /** How long in-flight requests get to finish before the stop forces them closed. Default 10_000. */
274
+ readonly drainMs?: number
275
+ /**
276
+ * How long the server keeps serving after `stop()` began, before it drains: the time a load
277
+ * balancer takes to see readiness fail and route elsewhere. Default 0. Keep `delayMs + drainMs`
278
+ * under the platform's kill grace period (`docker stop` waits 10 s, Kubernetes 30 s).
279
+ */
280
+ readonly delayMs?: number
281
+ }
282
+
260
283
  /**
261
284
  * MCP tool safety hints, surfaced in `tools/list`, that tell an agent how risky a `.tool()` call is - so it
262
285
  * can decide whether to auto-invoke or confirm first. All optional; an omitted hint means "unknown". Mirrors