@nifrajs/core 2.8.2 → 2.9.1
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/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/route-execution.d.ts +8 -2
- package/dist/internal/route-execution.d.ts.map +1 -1
- package/dist/node-direct.d.ts.map +1 -1
- package/dist/node-direct.js +3 -1
- package/dist/node-direct.js.map +1 -1
- package/dist/router/router.d.ts.map +1 -1
- package/dist/router/router.js +26 -6
- package/dist/router/router.js.map +1 -1
- package/dist/server/context.d.ts +2 -0
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/cookies.d.ts.map +1 -1
- package/dist/server/cookies.js +4 -1
- package/dist/server/cookies.js.map +1 -1
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +16 -3
- package/dist/server/http.js.map +1 -1
- package/dist/server/node-outcome-hook.d.ts +78 -1
- package/dist/server/node-outcome-hook.d.ts.map +1 -1
- package/dist/server/node-outcome-hook.js +197 -1
- package/dist/server/node-outcome-hook.js.map +1 -1
- package/dist/server/node-outcome.d.ts +22 -2
- package/dist/server/node-outcome.d.ts.map +1 -1
- package/dist/server/node-outcome.js +86 -11
- package/dist/server/node-outcome.js.map +1 -1
- package/dist/server/query.d.ts +2 -0
- package/dist/server/query.d.ts.map +1 -1
- package/dist/server/query.js +27 -1
- package/dist/server/query.js.map +1 -1
- package/dist/server/request-context.d.ts +8 -1
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +23 -2
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/respond.d.ts +29 -3
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +279 -16
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +17 -3
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +6 -3
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/server-types.d.ts +37 -0
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +151 -7
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +1163 -174
- package/dist/server/server.js.map +1 -1
- package/dist/server/static-headers.d.ts +47 -0
- package/dist/server/static-headers.d.ts.map +1 -0
- package/dist/server/static-headers.js +109 -0
- package/dist/server/static-headers.js.map +1 -0
- package/dist/server.d.ts +2 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1 -0
- package/dist/server.js.map +1 -1
- package/dist/transport-codec.d.ts +15 -0
- package/dist/transport-codec.d.ts.map +1 -1
- package/dist/transport-codec.js +49 -10
- package/dist/transport-codec.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +1 -1
- package/src/internal/route-execution.ts +19 -1
- package/src/node-direct.ts +7 -1
- package/src/router/router.ts +24 -6
- package/src/server/context.ts +2 -0
- package/src/server/cookies.ts +4 -1
- package/src/server/http.ts +16 -3
- package/src/server/node-outcome-hook.ts +315 -1
- package/src/server/node-outcome.ts +100 -14
- package/src/server/query.ts +26 -1
- package/src/server/request-context.ts +23 -1
- package/src/server/respond.ts +386 -17
- package/src/server/response-contract-lane.ts +17 -3
- package/src/server/server-types.ts +42 -0
- package/src/server/server.ts +1618 -237
- package/src/server/static-headers.ts +142 -0
- package/src/server.ts +9 -0
- package/src/transport-codec.ts +55 -10
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The static response-header tier: registration-time validation, the prebuilt per-server shapes the
|
|
3
|
+
* render paths reuse, and the one merge routine every lane folds them in with.
|
|
4
|
+
*
|
|
5
|
+
* A statically declared header is a DEFAULT. It carries no per-request decision, so it does not need
|
|
6
|
+
* to run as a response hook - it is folded into response construction instead, which is what lets an
|
|
7
|
+
* app whose only response middleware is static keep the fused/native lanes a real `onResponse` hook
|
|
8
|
+
* would have cost it. Anything the request itself produced (`c.set.headers`, a cookie, a response
|
|
9
|
+
* hook) is applied later and therefore wins.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Valid HTTP field-name characters (RFC 9110 token). */
|
|
13
|
+
const HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Names the tier refuses outright, so a static declaration can never fight the render:
|
|
17
|
+
* `content-length`/`transfer-encoding` are framing the writers derive from the final bytes,
|
|
18
|
+
* `content-type` is the render's own (declare it per response instead), and `set-cookie` is the one
|
|
19
|
+
* multi-value header a `Record<string, string>` cannot represent - queue those with `c.set.cookie`.
|
|
20
|
+
*/
|
|
21
|
+
const REFUSED_HEADERS: ReadonlySet<string> = new Set([
|
|
22
|
+
"content-length",
|
|
23
|
+
"content-type",
|
|
24
|
+
"set-cookie",
|
|
25
|
+
"transfer-encoding",
|
|
26
|
+
])
|
|
27
|
+
|
|
28
|
+
/** The per-server shapes built once from a validated static record. */
|
|
29
|
+
export interface StaticResponseHeaders {
|
|
30
|
+
/** Lowercase-keyed and frozen: shared by every request, so no lane may mutate it. */
|
|
31
|
+
readonly record: Readonly<Record<string, string>>
|
|
32
|
+
/** `record` as pairs, for applying to an already-built `Headers`/`Response`. */
|
|
33
|
+
readonly entries: ReadonlyArray<readonly [string, string]>
|
|
34
|
+
/** The static record plus the content-type the framework's own JSON init carries, prebuilt. A
|
|
35
|
+
* `Response` init is copied into the response, never retained, so one instance serves every
|
|
36
|
+
* request. */
|
|
37
|
+
readonly jsonHeaders: Headers
|
|
38
|
+
/** `{ status: 200, headers: jsonHeaders }` - a static app's whole per-response header cost. */
|
|
39
|
+
readonly jsonInit200: ResponseInit
|
|
40
|
+
/**
|
|
41
|
+
* The same shape, but carrying the content-type THIS runtime's `Response.json` emits - which is not
|
|
42
|
+
* always the framework init's (Deno omits the charset). Used by the lane that would otherwise call
|
|
43
|
+
* `Response.json`, so declaring headers never changes the content-type. Probed on first use:
|
|
44
|
+
* workerd forbids `Response.json()` during startup, and declarations happen at startup.
|
|
45
|
+
*/
|
|
46
|
+
responseJsonInit200(): ResponseInit
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** CR, LF, or NUL in a declared value would forge extra header lines on the wire. */
|
|
50
|
+
function hasControlCharacter(value: string): boolean {
|
|
51
|
+
for (let i = 0; i < value.length; i++) {
|
|
52
|
+
const code = value.charCodeAt(i)
|
|
53
|
+
if (code === 13 || code === 10 || code === 0) return true
|
|
54
|
+
}
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Validate a declared record and lowercase its names once, at registration. Every rejection is a
|
|
60
|
+
* programming error surfaced loudly at wire-up rather than a header silently missing (or, for the
|
|
61
|
+
* refused names, silently corrupting framing) on every response afterwards.
|
|
62
|
+
*/
|
|
63
|
+
export function normalizeStaticResponseHeaders(
|
|
64
|
+
record: Readonly<Record<string, string>>,
|
|
65
|
+
): Record<string, string> {
|
|
66
|
+
const out: Record<string, string> = {}
|
|
67
|
+
for (const name of Object.keys(record)) {
|
|
68
|
+
const value = record[name]
|
|
69
|
+
if (typeof value !== "string") {
|
|
70
|
+
throw new TypeError(`responseHeaders: the value of "${name}" must be a string`)
|
|
71
|
+
}
|
|
72
|
+
if (!HEADER_NAME.test(name)) {
|
|
73
|
+
throw new TypeError(`responseHeaders: "${name}" is not a valid header name`)
|
|
74
|
+
}
|
|
75
|
+
if (hasControlCharacter(value)) {
|
|
76
|
+
throw new TypeError(`responseHeaders: the value of "${name}" contains a control character`)
|
|
77
|
+
}
|
|
78
|
+
if (value !== value.trim()) {
|
|
79
|
+
throw new TypeError(`responseHeaders: the value of "${name}" has leading or trailing space`)
|
|
80
|
+
}
|
|
81
|
+
const lower = name.toLowerCase()
|
|
82
|
+
// A `__proto__` entry cannot be stored by plain assignment (the inherited setter swallows it),
|
|
83
|
+
// and a header nobody can observe is worse than a refusal at wire-up.
|
|
84
|
+
if (lower === "__proto__") {
|
|
85
|
+
throw new TypeError('responseHeaders: "__proto__" is not a usable header name')
|
|
86
|
+
}
|
|
87
|
+
if (REFUSED_HEADERS.has(lower)) {
|
|
88
|
+
throw new TypeError(
|
|
89
|
+
`responseHeaders: "${lower}" is set per response, not statically - use c.set.headers (or c.set.cookie for set-cookie)`,
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
out[lower] = value
|
|
93
|
+
}
|
|
94
|
+
return out
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Store an own-property under a name that may be `__proto__`. Mirrors the native header view's
|
|
99
|
+
* store: a literal-shaped record keeps V8/JSC's fast object shape (a null-prototype record demotes
|
|
100
|
+
* to dictionary mode), so the one name plain assignment would swallow is defined explicitly.
|
|
101
|
+
*/
|
|
102
|
+
function store<V>(record: Record<string, V>, name: string, value: V): void {
|
|
103
|
+
if (name === "__proto__") {
|
|
104
|
+
Object.defineProperty(record, name, {
|
|
105
|
+
value,
|
|
106
|
+
writable: true,
|
|
107
|
+
enumerable: true,
|
|
108
|
+
configurable: true,
|
|
109
|
+
})
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
record[name] = value
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Fold the static defaults UNDER a record the request produced. A name the request set wins, keeping
|
|
117
|
+
* the casing it used; when the two spellings differ only by case, the static entry is dropped rather
|
|
118
|
+
* than left alongside - one header name spelled two ways in a single record ships as a comma-joined
|
|
119
|
+
* value on the Web paths and as two lines on Node, so writing the static entry blind would change
|
|
120
|
+
* the wire instead of being overridden.
|
|
121
|
+
*/
|
|
122
|
+
export function mergeStaticHeaderRecord<V>(
|
|
123
|
+
statics: Readonly<Record<string, string>>,
|
|
124
|
+
own: Readonly<Record<string, V>>,
|
|
125
|
+
): Record<string, V | string> {
|
|
126
|
+
const merged: Record<string, V | string> = { ...statics }
|
|
127
|
+
for (const name of Object.keys(own)) {
|
|
128
|
+
const value = own[name] as V
|
|
129
|
+
if (value === undefined) continue
|
|
130
|
+
const lower = name.toLowerCase()
|
|
131
|
+
if (lower !== name && Object.hasOwn(merged, lower)) delete merged[lower]
|
|
132
|
+
store(merged, name, value)
|
|
133
|
+
}
|
|
134
|
+
return merged
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A fresh, mutable copy of the static record - what the lanes whose writers mutate the record get. */
|
|
138
|
+
export function staticHeaderRecordCopy(
|
|
139
|
+
statics: StaticResponseHeaders,
|
|
140
|
+
): Record<string, string | readonly string[]> {
|
|
141
|
+
return { ...statics.record }
|
|
142
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -43,6 +43,7 @@ export {
|
|
|
43
43
|
type ScheduledHandler,
|
|
44
44
|
toFetchHandler,
|
|
45
45
|
} from "./server/edge.ts"
|
|
46
|
+
export { pathnameOf, type UrlParts, urlPartsOf } from "./server/http.ts"
|
|
46
47
|
export {
|
|
47
48
|
commonSecretPatterns,
|
|
48
49
|
jsonLogger,
|
|
@@ -66,11 +67,19 @@ export {
|
|
|
66
67
|
type McpResourceDescriptor,
|
|
67
68
|
type Middleware,
|
|
68
69
|
type NifraPlugin,
|
|
70
|
+
type NodeRequestContext,
|
|
71
|
+
type NodeRequestHook,
|
|
72
|
+
type NodeResponseContext,
|
|
73
|
+
type NodeResponseHook,
|
|
69
74
|
type NodeServeOutcome,
|
|
70
75
|
type OnRequestResult,
|
|
71
76
|
type PromptArgument,
|
|
72
77
|
type PromptMessage,
|
|
78
|
+
type ResponseBodyHook,
|
|
79
|
+
type ResponseBodyReplacement,
|
|
73
80
|
type ResponseFinalization,
|
|
81
|
+
type ResponseHeadersHook,
|
|
82
|
+
type ResponseHeadersView,
|
|
74
83
|
type RouteDescriptor,
|
|
75
84
|
type RunningServer,
|
|
76
85
|
Server,
|
package/src/transport-codec.ts
CHANGED
|
@@ -51,16 +51,31 @@ function maxBytesOf(options: TransportDecodeOptions): number {
|
|
|
51
51
|
return maxBytes
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
const BYTE_ENCODER = new TextEncoder()
|
|
55
|
+
|
|
54
56
|
function byteLength(text: string): number {
|
|
55
|
-
return
|
|
57
|
+
return BYTE_ENCODER.encode(text).byteLength
|
|
56
58
|
}
|
|
57
59
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Enforce the transport byte cap on already-read text - the same limit (and the same error) the
|
|
62
|
+
* streaming reader applies during a bounded read. Exported for callers that read a body through a
|
|
63
|
+
* native, non-streaming path (the typed client's in-process branch) and still owe the cap contract.
|
|
64
|
+
*/
|
|
65
|
+
export function assertTransportTextBounded(text: string, options: TransportDecodeOptions): void {
|
|
66
|
+
const maxBytes = maxBytesOf(options)
|
|
67
|
+
// Fast accept: UTF-8 emits at most 3 bytes per UTF-16 code unit, so `length * 3` bounds the
|
|
68
|
+
// encoded size from above - a body comfortably under the cap (the overwhelmingly common case
|
|
69
|
+
// against the 16 MB default) never pays a full re-encode just to prove it fits. Only a string
|
|
70
|
+
// within 3x of the cap needs the exact count.
|
|
71
|
+
if (text.length * 3 <= maxBytes) return
|
|
72
|
+
if (byteLength(text) > maxBytes) {
|
|
60
73
|
throw new TransportCodecError("transport payload exceeds maxBytes")
|
|
61
74
|
}
|
|
62
75
|
}
|
|
63
76
|
|
|
77
|
+
const assertBounded = assertTransportTextBounded
|
|
78
|
+
|
|
64
79
|
export const plainJsonCodec: TransportCodec = Object.freeze({
|
|
65
80
|
id: "json",
|
|
66
81
|
version: 1,
|
|
@@ -112,17 +127,23 @@ export function createTransportCodecRegistry(
|
|
|
112
127
|
}
|
|
113
128
|
if (!byId.has(`${fallback.id}@${fallback.version}`))
|
|
114
129
|
throw new TypeError("transport fallback must be registered")
|
|
130
|
+
const jsonCodec = byId.get("json@1")
|
|
115
131
|
|
|
116
132
|
const forContentType = (contentType: string | null): TransportCodec => {
|
|
117
133
|
if (contentType === null || contentType.trim() === "") return fallback
|
|
134
|
+
// Direct probe before canonicalizing: a server-emitted content type is already lowercase with
|
|
135
|
+
// no stray spacing (nifra's own always is), so the common case skips the split/trim/join walk.
|
|
136
|
+
// The charset-suffixed JSON string is what `Response.json` (and nifra's respond path) emits on
|
|
137
|
+
// every JSON response - it resolves to the same `json@1` the parameter-stripping walk below
|
|
138
|
+
// would reach, just without paying the walk.
|
|
139
|
+
const exact = byMedia.get(contentType)
|
|
140
|
+
if (exact !== undefined) return exact
|
|
141
|
+
if (contentType === "application/json;charset=utf-8" && jsonCodec !== undefined)
|
|
142
|
+
return jsonCodec
|
|
118
143
|
const canonical = canonicalMediaType(contentType)
|
|
119
144
|
const direct = byMedia.get(canonical)
|
|
120
145
|
if (direct !== undefined) return direct
|
|
121
|
-
|
|
122
|
-
if (base === "application/json") {
|
|
123
|
-
const json = byId.get("json@1")
|
|
124
|
-
if (json !== undefined) return json
|
|
125
|
-
}
|
|
146
|
+
if (canonical.split(";")[0] === "application/json" && jsonCodec !== undefined) return jsonCodec
|
|
126
147
|
throw new TransportCodecError(`unsupported transport content type: ${contentType}`)
|
|
127
148
|
}
|
|
128
149
|
|
|
@@ -236,14 +257,38 @@ export async function readBoundedBytes(
|
|
|
236
257
|
return bytes
|
|
237
258
|
}
|
|
238
259
|
|
|
260
|
+
// Shared decoder: `TextDecoder.decode` on a whole buffer is stateless, so one instance serves every
|
|
261
|
+
// call (constructing one costs ~115ns - it was previously built per response).
|
|
262
|
+
const FATAL_UTF8 = new TextDecoder("utf-8", { fatal: true })
|
|
263
|
+
|
|
239
264
|
async function readBoundedText(
|
|
240
265
|
response: Response,
|
|
241
266
|
options: TransportDecodeOptions,
|
|
242
267
|
): Promise<string> {
|
|
243
268
|
const bytes = await readBoundedBytes(response, options)
|
|
269
|
+
return decodeOrThrow(() => FATAL_UTF8.decode(bytes), "transport payload is not valid UTF-8")
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Decode an ALREADY-READ transport body. The counterpart to {@link decodeTransportResponse} for a
|
|
274
|
+
* caller that obtained the text through its own bounded read - notably the typed client's
|
|
275
|
+
* in-process path, where the response body is same-process memory that was fully resident before
|
|
276
|
+
* the read, so the native `Response.text()` is safe and the streaming byte-cap loop would protect
|
|
277
|
+
* nothing while costing ~23x. The byte cap is still enforced here (identical error), just after
|
|
278
|
+
* the read instead of during it.
|
|
279
|
+
*/
|
|
280
|
+
export function decodeTransportText(
|
|
281
|
+
text: string,
|
|
282
|
+
contentType: string | null,
|
|
283
|
+
registry: TransportCodecRegistry = defaultTransportCodecs,
|
|
284
|
+
options: TransportDecodeOptions = {},
|
|
285
|
+
): unknown {
|
|
286
|
+
assertBounded(text, options)
|
|
287
|
+
if (text === "") return undefined
|
|
288
|
+
const codec = registry.forContentType(contentType)
|
|
244
289
|
return decodeOrThrow(
|
|
245
|
-
() =>
|
|
246
|
-
|
|
290
|
+
() => codec.decode(text),
|
|
291
|
+
`malformed transport body for codec ${codec.id}@${codec.version}`,
|
|
247
292
|
)
|
|
248
293
|
}
|
|
249
294
|
|