@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.
Files changed (79) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.js +1 -1
  3. package/dist/internal/route-execution.d.ts +8 -2
  4. package/dist/internal/route-execution.d.ts.map +1 -1
  5. package/dist/node-direct.d.ts.map +1 -1
  6. package/dist/node-direct.js +3 -1
  7. package/dist/node-direct.js.map +1 -1
  8. package/dist/router/router.d.ts.map +1 -1
  9. package/dist/router/router.js +26 -6
  10. package/dist/router/router.js.map +1 -1
  11. package/dist/server/context.d.ts +2 -0
  12. package/dist/server/context.d.ts.map +1 -1
  13. package/dist/server/cookies.d.ts.map +1 -1
  14. package/dist/server/cookies.js +4 -1
  15. package/dist/server/cookies.js.map +1 -1
  16. package/dist/server/http.d.ts.map +1 -1
  17. package/dist/server/http.js +16 -3
  18. package/dist/server/http.js.map +1 -1
  19. package/dist/server/node-outcome-hook.d.ts +78 -1
  20. package/dist/server/node-outcome-hook.d.ts.map +1 -1
  21. package/dist/server/node-outcome-hook.js +197 -1
  22. package/dist/server/node-outcome-hook.js.map +1 -1
  23. package/dist/server/node-outcome.d.ts +22 -2
  24. package/dist/server/node-outcome.d.ts.map +1 -1
  25. package/dist/server/node-outcome.js +86 -11
  26. package/dist/server/node-outcome.js.map +1 -1
  27. package/dist/server/query.d.ts +2 -0
  28. package/dist/server/query.d.ts.map +1 -1
  29. package/dist/server/query.js +27 -1
  30. package/dist/server/query.js.map +1 -1
  31. package/dist/server/request-context.d.ts +8 -1
  32. package/dist/server/request-context.d.ts.map +1 -1
  33. package/dist/server/request-context.js +23 -2
  34. package/dist/server/request-context.js.map +1 -1
  35. package/dist/server/respond.d.ts +29 -3
  36. package/dist/server/respond.d.ts.map +1 -1
  37. package/dist/server/respond.js +279 -16
  38. package/dist/server/respond.js.map +1 -1
  39. package/dist/server/response-contract-lane.d.ts +17 -3
  40. package/dist/server/response-contract-lane.d.ts.map +1 -1
  41. package/dist/server/response-contract-lane.js +6 -3
  42. package/dist/server/response-contract-lane.js.map +1 -1
  43. package/dist/server/server-types.d.ts +37 -0
  44. package/dist/server/server-types.d.ts.map +1 -1
  45. package/dist/server/server.d.ts +151 -7
  46. package/dist/server/server.d.ts.map +1 -1
  47. package/dist/server/server.js +1163 -174
  48. package/dist/server/server.js.map +1 -1
  49. package/dist/server/static-headers.d.ts +47 -0
  50. package/dist/server/static-headers.d.ts.map +1 -0
  51. package/dist/server/static-headers.js +109 -0
  52. package/dist/server/static-headers.js.map +1 -0
  53. package/dist/server.d.ts +2 -1
  54. package/dist/server.d.ts.map +1 -1
  55. package/dist/server.js +1 -0
  56. package/dist/server.js.map +1 -1
  57. package/dist/transport-codec.d.ts +15 -0
  58. package/dist/transport-codec.d.ts.map +1 -1
  59. package/dist/transport-codec.js +49 -10
  60. package/dist/transport-codec.js.map +1 -1
  61. package/package.json +1 -1
  62. package/src/index.ts +1 -1
  63. package/src/internal/route-execution.ts +19 -1
  64. package/src/node-direct.ts +7 -1
  65. package/src/router/router.ts +24 -6
  66. package/src/server/context.ts +2 -0
  67. package/src/server/cookies.ts +4 -1
  68. package/src/server/http.ts +16 -3
  69. package/src/server/node-outcome-hook.ts +315 -1
  70. package/src/server/node-outcome.ts +100 -14
  71. package/src/server/query.ts +26 -1
  72. package/src/server/request-context.ts +23 -1
  73. package/src/server/respond.ts +386 -17
  74. package/src/server/response-contract-lane.ts +17 -3
  75. package/src/server/server-types.ts +42 -0
  76. package/src/server/server.ts +1618 -237
  77. package/src/server/static-headers.ts +142 -0
  78. package/src/server.ts +9 -0
  79. 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,
@@ -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 new TextEncoder().encode(text).byteLength
57
+ return BYTE_ENCODER.encode(text).byteLength
56
58
  }
57
59
 
58
- function assertBounded(text: string, options: TransportDecodeOptions): void {
59
- if (byteLength(text) > maxBytesOf(options)) {
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
- const base = canonical.split(";")[0]
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
- () => new TextDecoder("utf-8", { fatal: true }).decode(bytes),
246
- "transport payload is not valid UTF-8",
290
+ () => codec.decode(text),
291
+ `malformed transport body for codec ${codec.id}@${codec.version}`,
247
292
  )
248
293
  }
249
294