@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.
- package/CHANGELOG.md +99 -0
- package/README.md +5 -0
- package/dist/binary.d.ts +15 -1
- package/dist/binary.d.ts.map +1 -1
- package/dist/binary.js +1 -0
- package/dist/binary.js.map +1 -1
- package/dist/edge-kit.d.ts +1 -0
- package/dist/edge-kit.d.ts.map +1 -1
- package/dist/edge-kit.js +1 -0
- package/dist/edge-kit.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/evidence.d.ts +2 -0
- package/dist/evidence.d.ts.map +1 -1
- package/dist/evidence.js +2 -0
- package/dist/evidence.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/hook-origins.d.ts +2 -0
- package/dist/internal/hook-origins.d.ts.map +1 -1
- package/dist/internal/hook-origins.js +12 -0
- package/dist/internal/hook-origins.js.map +1 -1
- package/dist/reflection.d.ts +5 -0
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +12 -0
- package/dist/reflection.js.map +1 -1
- package/dist/server/answer-step.d.ts +27 -0
- package/dist/server/answer-step.d.ts.map +1 -0
- package/dist/server/answer-step.js +33 -0
- package/dist/server/answer-step.js.map +1 -0
- package/dist/server/base64-answers.d.ts +5 -0
- package/dist/server/base64-answers.d.ts.map +1 -0
- package/dist/server/base64-answers.js +132 -0
- package/dist/server/base64-answers.js.map +1 -0
- package/dist/server/core-copies.d.ts.map +1 -1
- package/dist/server/core-copies.js +5 -2
- package/dist/server/core-copies.js.map +1 -1
- package/dist/server/install.d.ts +2 -0
- package/dist/server/install.d.ts.map +1 -1
- package/dist/server/install.js +2 -0
- package/dist/server/install.js.map +1 -1
- package/dist/server/json-response.d.ts +3 -0
- package/dist/server/json-response.d.ts.map +1 -0
- package/dist/server/json-response.js +14 -0
- package/dist/server/json-response.js.map +1 -0
- package/dist/server/json-text.d.ts +16 -0
- package/dist/server/json-text.d.ts.map +1 -0
- package/dist/server/json-text.js +34 -0
- package/dist/server/json-text.js.map +1 -0
- package/dist/server/node-outcome.d.ts +5 -0
- package/dist/server/node-outcome.d.ts.map +1 -1
- package/dist/server/node-outcome.js +3 -4
- package/dist/server/node-outcome.js.map +1 -1
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +4 -2
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/respond.d.ts +2 -1
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +27 -24
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +2 -1
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/route-template.d.ts +10 -0
- package/dist/server/route-template.d.ts.map +1 -0
- package/dist/server/route-template.js +21 -0
- package/dist/server/route-template.js.map +1 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js +2 -1
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +26 -6
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +22 -10
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +109 -45
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +2 -1
- package/dist/server/sse.js.map +1 -1
- package/dist/server/text-slots.d.ts +8 -0
- package/dist/server/text-slots.d.ts.map +1 -0
- package/dist/server/text-slots.js +19 -0
- package/dist/server/text-slots.js.map +1 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1 -0
- package/dist/server.js.map +1 -1
- package/dist/single-copy.d.ts +5 -0
- package/dist/single-copy.d.ts.map +1 -1
- package/dist/single-copy.js +26 -17
- package/dist/single-copy.js.map +1 -1
- package/dist/transport-codec.d.ts.map +1 -1
- package/dist/transport-codec.js +2 -1
- package/dist/transport-codec.js.map +1 -1
- package/package.json +1 -1
- package/src/binary.ts +17 -0
- package/src/edge-kit.ts +1 -0
- package/src/errors.ts +1 -0
- package/src/evidence.ts +4 -0
- package/src/index.ts +2 -1
- package/src/internal/hook-origins.ts +10 -0
- package/src/reflection.ts +18 -0
- package/src/server/answer-step.ts +68 -0
- package/src/server/base64-answers.ts +138 -0
- package/src/server/core-copies.ts +5 -2
- package/src/server/install.ts +3 -0
- package/src/server/json-response.ts +13 -0
- package/src/server/json-text.ts +34 -0
- package/src/server/node-outcome.ts +4 -5
- package/src/server/request-context.ts +4 -2
- package/src/server/respond.ts +27 -18
- package/src/server/response-contract-lane.ts +2 -1
- package/src/server/route-template.ts +24 -0
- package/src/server/runtime-core.ts +2 -1
- package/src/server/server-types.ts +27 -4
- package/src/server/server.ts +131 -63
- package/src/server/sse.ts +2 -1
- package/src/server/text-slots.ts +21 -0
- package/src/server.ts +1 -0
- package/src/single-copy.ts +31 -15
- 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.
|
|
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. */
|
package/src/server/install.ts
CHANGED
|
@@ -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 :
|
|
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 : (
|
|
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(
|
|
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
|
package/src/server/respond.ts
CHANGED
|
@@ -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 ?
|
|
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
|
-
/**
|
|
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 : (
|
|
508
|
-
return body === undefined ?
|
|
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 =
|
|
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 =
|
|
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
|
-
:
|
|
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 =
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
*
|
|
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 |
|
|
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
|
|
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
|