@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
@@ -1,9 +1,323 @@
1
1
  /** Lazy Node-direct renderer seam. The base server retains the typed method but not its renderer. */
2
+
3
+ import type { Platform } from "./context.ts"
2
4
  import type { NodeServeOutcome } from "./node-outcome.ts"
3
- import type { CtxSet } from "./server.ts"
5
+ import type { CtxSet, MaybePromise } from "./server.ts"
6
+
7
+ /**
8
+ * Allocation-light request view used by Node-native header middleware.
9
+ *
10
+ * Identity contract: within one request, the SAME object is passed to every `onNodeRequest` hook
11
+ * and every `onNodeResponse` hook (the native lanes engage together - see the server's gate), so a
12
+ * middleware may use it as a `WeakMap` key to carry per-request state from its request twin to its
13
+ * response twin.
14
+ */
15
+ export interface NodeRequestContext {
16
+ readonly method: string
17
+ readonly url: string
18
+ readonly header: (name: string) => string | null
19
+ }
20
+
21
+ /**
22
+ * The mutable, case-insensitive header surface a portable {@link ResponseHeadersHook} writes
23
+ * through. Deliberately the subset of the Web `Headers` interface every runtime can satisfy
24
+ * natively: on the Web paths the hook receives the response's own `Headers` object directly (which
25
+ * structurally implements this), and on the Node direct-writer path it receives a thin view over
26
+ * the outcome's plain header record - so ONE hook implementation is fast everywhere.
27
+ */
28
+ export interface ResponseHeadersView {
29
+ get(name: string): string | null
30
+ has(name: string): boolean
31
+ set(name: string, value: string): void
32
+ append(name: string, value: string): void
33
+ delete(name: string): void
34
+ }
35
+
36
+ /**
37
+ * A portable header-only response hook - the recommended shape for response middleware that only
38
+ * reads or writes headers (security headers, CORS reflection, cache directives, negotiation). It
39
+ * runs on every runtime from one implementation: the server adapts it into the Web `onResponse`
40
+ * walk AND the Node-native response lane, so registering one never forces the Node adapter off its
41
+ * direct socket writer the way a full `onResponse(res: Response)` hook does. It cannot replace the
42
+ * response or touch the body - middleware needing that keeps the full `onResponse` contract and
43
+ * its cost.
44
+ */
45
+ export type ResponseHeadersHook = (
46
+ headers: ResponseHeadersView,
47
+ req: NodeRequestContext,
48
+ status: number,
49
+ ) => MaybePromise<void>
50
+
51
+ /** Native equivalent of a paired `onRequest` hook. It may short-circuit, but cannot rewrite a request. */
52
+ export type NodeRequestHook = (
53
+ request: NodeRequestContext,
54
+ platform?: Platform,
55
+ ) => MaybePromise<Response | undefined>
56
+
57
+ /**
58
+ * Response view used by Node-direct middleware. Header hooks mutate `headers`; a BODY hook
59
+ * (adapted from the portable `onResponseBody`) may replace `body` - the already-serialized bytes
60
+ * the direct writer is about to send - and, through its structured replacement, the status
61
+ * (an ETag 304 being the canonical case).
62
+ */
63
+ export interface NodeResponseContext {
64
+ /** Mutable ONLY through a body hook's replacement object (e.g. an ETag 304); header hooks read. */
65
+ status: number
66
+ headers: Record<string, string | readonly string[]> | undefined
67
+ readonly cookies: readonly string[] | undefined
68
+ /** The framework-serialized body bytes (`null` for a bodiless render). Replaceable by body hooks. */
69
+ body: string | Uint8Array | null
70
+ }
71
+
72
+ /** A body hook's structured replacement: new bytes (or `null` to drop the body) and/or a status. */
73
+ export interface ResponseBodyReplacement {
74
+ readonly body?: string | Uint8Array | null
75
+ readonly status?: number
76
+ }
77
+
78
+ /**
79
+ * A portable post-serialization body hook - the Fastify-`onSend`-shaped tier. The hook receives
80
+ * the FINAL framework-serialized bytes plus the header view, and may return replacement bytes
81
+ * (`undefined` keeps the body unchanged). It runs at the framework's cheapest point on every
82
+ * runtime: the bytes are already resident before any Web `Response` exists, so no body stream is
83
+ * ever drained. A handler-returned raw `Response` (a proxied fetch, SSE, streamed SSR) is SKIPPED
84
+ * by definition - transforming those is exactly what the full `onResponse` contract is for.
85
+ */
86
+ export type ResponseBodyHook = (
87
+ body: string | Uint8Array,
88
+ headers: ResponseHeadersView,
89
+ req: NodeRequestContext,
90
+ status: number,
91
+ ) => MaybePromise<string | Uint8Array | ResponseBodyReplacement | undefined>
92
+
93
+ /** Native equivalent of a paired `onResponse` hook. It must preserve the Web hook's header semantics. */
94
+ export type NodeResponseHook = (
95
+ response: NodeResponseContext,
96
+ req: NodeRequestContext,
97
+ ) => MaybePromise<void>
98
+
99
+ /**
100
+ * A {@link ResponseHeadersView} over a Node outcome's plain header record - the direct-writer
101
+ * counterpart of handing a Web hook the response's `Headers`. Mutations write lowercase names (the
102
+ * wire form every runtime emits) and reads are case-insensitive across whatever casing earlier
103
+ * writers used; repeated values join with `", "` on read, matching `Headers.get`. A class with
104
+ * prototype methods (not per-call closures), memoized per context, so a request running several
105
+ * portable hooks allocates ONE small view total.
106
+ *
107
+ * Name resolution is prepared ONCE, the first time this view touches the record, rather than per
108
+ * operation. Resolving each name by scanning the record - the straightforward way to stay
109
+ * case-insensitive - allocates an `Object.keys` array and lowercases every stored key on every
110
+ * get/set, and that is paid per header, so a middleware writing a handful of them (security
111
+ * headers, CORS) paid it several times per request: profiling a realistic Node route put that scan
112
+ * at roughly a fifth of the framework's own CPU time. Instead one pass records an alias only for
113
+ * keys whose casing differs from their lowercase form - none, for the records the framework itself
114
+ * builds - after which every operation is a direct property access and the common all-lowercase
115
+ * record allocates no index at all. Stored casing is left exactly as the writer chose it.
116
+ */
117
+ class RecordHeadersView implements ResponseHeadersView {
118
+ readonly #target: NodeResponseContext
119
+ #prepared = false
120
+ /** lowercase name -> the differently-cased key actually stored. Absent when none differ. */
121
+ #alias: Map<string, string> | undefined
122
+
123
+ constructor(target: NodeResponseContext) {
124
+ this.#target = target
125
+ }
126
+
127
+ #prepare(record: Record<string, string | readonly string[]>): void {
128
+ this.#prepared = true
129
+ // `Object.keys`, not `for...in`: the record is a `{}`-prototype literal on the render paths, and
130
+ // a for-in would let an enumerable `Object.prototype` property become a header on the wire.
131
+ const keys = Object.keys(record)
132
+ for (let i = 0; i < keys.length; i++) {
133
+ const key = keys[i] as string
134
+ // `key.toLowerCase() !== key` answers this too, but pays a runtime call plus a string compare
135
+ // for EVERY key on EVERY request, and the answer is almost always "already lowercase" - the
136
+ // framework, `c.set`, and middleware twins all write the wire spelling. An inline ASCII scan
137
+ // answers the common case without leaving the loop, and only a genuinely mixed-case key (a
138
+ // hand-written `X-Foo`) pays for the lowercase copy and the alias entry.
139
+ if (!RecordHeadersView.#hasUpperAscii(key)) continue
140
+ if (this.#alias === undefined) this.#alias = new Map()
141
+ this.#alias.set(key.toLowerCase(), key)
142
+ }
143
+ }
144
+
145
+ /** True when the name contains an ASCII `A`-`Z`, i.e. is not already the wire spelling. */
146
+ static #hasUpperAscii(name: string): boolean {
147
+ for (let i = 0; i < name.length; i++) {
148
+ const code = name.charCodeAt(i)
149
+ if (code >= 65 && code <= 90) return true
150
+ }
151
+ return false
152
+ }
153
+
154
+ /** The wire spelling of a header name, without paying `toLowerCase` when it already is one. */
155
+ static #lower(name: string): string {
156
+ return RecordHeadersView.#hasUpperAscii(name) ? name.toLowerCase() : name
157
+ }
158
+
159
+ /** The stored key for an already-lowercased name. Resolution is the prepared index ONLY - a
160
+ * lowercase own-property check, then the alias map - never a per-operation key scan: a rescan on
161
+ * every miss (and setting a NEW header is always a miss) walked and lowercased the whole record
162
+ * per write, which measured as the single largest cost of a portable hook walk on JSC. The alias
163
+ * index is complete for the record as first touched and for every write made through this view;
164
+ * a raw record write from a custom native twin uses lowercase names (the wire form the record
165
+ * documents), so it stays visible without a scan. */
166
+ #actual(lower: string): string {
167
+ const record = this.#target.headers
168
+ if (record !== undefined && Object.hasOwn(record, lower)) return lower
169
+ const known = this.#alias?.get(lower)
170
+ if (known !== undefined && record !== undefined && Object.hasOwn(record, known)) return known
171
+ return lower
172
+ }
173
+
174
+ /** The backing record if the outcome has one, prepared on first touch; `undefined` otherwise. */
175
+ #readable(): Record<string, string | readonly string[]> | undefined {
176
+ const existing = this.#target.headers
177
+ if (existing === undefined) return undefined
178
+ if (!this.#prepared) this.#prepare(existing)
179
+ return existing
180
+ }
181
+
182
+ /** As {@link #readable}, but creates the record when the outcome has none. A literal record from
183
+ * the render path is used AS-IS - values are strings/arrays, and assigning those through the
184
+ * inherited `__proto__` setter is a spec-level no-op, so the record cannot be polluted through
185
+ * this view; the one name that setter would swallow is stored via `defineProperty` in
186
+ * {@link set}/{@link append} instead. Copying to a null-prototype object here would demote every
187
+ * hook-carrying request's record to V8's dictionary mode (~2% of route throughput). */
188
+ #writable(): Record<string, string | readonly string[]> {
189
+ const existing = this.#target.headers
190
+ if (existing === undefined) {
191
+ const fresh: Record<string, string | readonly string[]> = {}
192
+ this.#target.headers = fresh
193
+ this.#prepared = true
194
+ return fresh
195
+ }
196
+ if (!this.#prepared) this.#prepare(existing)
197
+ return existing
198
+ }
199
+
200
+ /** Store under an attacker-influenceable name. `__proto__` (any casing is already lowercased)
201
+ * must not go through plain assignment - the inherited setter would silently swallow it - so it
202
+ * is defined as an own data property; every other name takes the fast plain store. */
203
+ static #store(
204
+ record: Record<string, string | readonly string[]>,
205
+ lower: string,
206
+ value: string | readonly string[],
207
+ ): void {
208
+ if (lower === "__proto__") {
209
+ Object.defineProperty(record, lower, {
210
+ value,
211
+ writable: true,
212
+ enumerable: true,
213
+ configurable: true,
214
+ })
215
+ return
216
+ }
217
+ record[lower] = value
218
+ }
219
+
220
+ get(name: string): string | null {
221
+ const headers = this.#readable()
222
+ if (headers === undefined) return null
223
+ const value = headers[this.#actual(RecordHeadersView.#lower(name))]
224
+ if (value === undefined) return null
225
+ return typeof value === "string" ? value : (value.join(", ") ?? null)
226
+ }
227
+
228
+ has(name: string): boolean {
229
+ const headers = this.#readable()
230
+ return (
231
+ headers !== undefined && headers[this.#actual(RecordHeadersView.#lower(name))] !== undefined
232
+ )
233
+ }
234
+
235
+ set(name: string, value: string): void {
236
+ const headers = this.#writable()
237
+ const lower = RecordHeadersView.#lower(name)
238
+ // The common response record is already lowercase (framework middleware and c.set use the wire
239
+ // spelling). A set cannot need to replace a differently-cased key in that shape, so skip the
240
+ // own-property/alias lookup that reads and deletes pay for. Mixed-case records were indexed by
241
+ // #prepare and retain the full case-insensitive replacement semantics below.
242
+ if (this.#alias === undefined) {
243
+ RecordHeadersView.#store(headers, lower, value)
244
+ return
245
+ }
246
+ const actual = this.#actual(lower)
247
+ if (actual !== lower) {
248
+ delete headers[actual]
249
+ this.#alias?.delete(lower)
250
+ }
251
+ RecordHeadersView.#store(headers, lower, value)
252
+ }
253
+
254
+ append(name: string, value: string): void {
255
+ const headers = this.#writable()
256
+ const lower = RecordHeadersView.#lower(name)
257
+ if (this.#alias === undefined) {
258
+ const current = Object.hasOwn(headers, lower) ? headers[lower] : undefined
259
+ if (current === undefined) {
260
+ RecordHeadersView.#store(headers, lower, value)
261
+ return
262
+ }
263
+ RecordHeadersView.#store(
264
+ headers,
265
+ lower,
266
+ typeof current === "string" ? [current, value] : [...current, value],
267
+ )
268
+ return
269
+ }
270
+ const actual = this.#actual(lower)
271
+ const current = Object.hasOwn(headers, actual) ? headers[actual] : undefined
272
+ if (current === undefined) {
273
+ RecordHeadersView.#store(headers, lower, value)
274
+ return
275
+ }
276
+ if (actual !== lower) {
277
+ delete headers[actual]
278
+ this.#alias?.delete(lower)
279
+ }
280
+ RecordHeadersView.#store(
281
+ headers,
282
+ lower,
283
+ typeof current === "string" ? [current, value] : [...current, value],
284
+ )
285
+ }
286
+
287
+ delete(name: string): void {
288
+ const headers = this.#readable()
289
+ if (headers === undefined) return
290
+ const lower = RecordHeadersView.#lower(name)
291
+ if (this.#alias === undefined) {
292
+ delete headers[lower]
293
+ return
294
+ }
295
+ const actual = this.#actual(lower)
296
+ delete headers[actual]
297
+ if (actual !== lower) this.#alias?.delete(lower)
298
+ }
299
+ }
300
+
301
+ const RECORD_VIEW = Symbol("nifra.record-headers-view")
302
+
303
+ export function recordHeadersView(target: NodeResponseContext): ResponseHeadersView {
304
+ const holder = target as NodeResponseContext & { [RECORD_VIEW]?: ResponseHeadersView }
305
+ let view = holder[RECORD_VIEW]
306
+ if (view === undefined) {
307
+ view = new RecordHeadersView(target)
308
+ holder[RECORD_VIEW] = view
309
+ }
310
+ return view
311
+ }
4
312
 
5
313
  export interface NodeOutcomeRuntime {
6
314
  toOutcome(result: unknown, set: CtxSet): NodeServeOutcome
315
+ /** Materialize a buffered outcome for a Web response hook without losing its direct-write marker. */
316
+ toResponse(outcome: NodeServeOutcome): Response
7
317
  fromResponse(response: Response): NodeServeOutcome
8
318
  timeout(): NodeServeOutcome
319
+ /** The `Content-Type` this runtime's json render writes implicitly. The native hook walk
320
+ * materializes it into the hook-visible header record so a body hook's content-type checks see
321
+ * what will actually ship (the writer's own value - the wire is unchanged). */
322
+ readonly jsonContentType?: string
9
323
  }
@@ -3,9 +3,25 @@
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
- import { appendCookiesToResponse } from "./respond.ts"
6
+ import {
7
+ appendCookiesToResponse,
8
+ applyStaticResponseHeaders,
9
+ markTaggedResponse,
10
+ normalizeBodylessResponse,
11
+ rememberMutableHeaders,
12
+ taggedResponseBody,
13
+ } from "./respond.ts"
7
14
  import { type HandlerResult, isResponseResult } from "./runtime-core.ts"
8
15
  import type { CtxSet } from "./server.ts"
16
+ import {
17
+ mergeStaticHeaderRecord,
18
+ type StaticResponseHeaders,
19
+ staticHeaderRecordCopy,
20
+ } from "./static-headers.ts"
21
+
22
+ function isBodylessStatus(status: number): boolean {
23
+ return status === 204 || status === 205 || status === 304
24
+ }
9
25
 
10
26
  /**
11
27
  * What {@link Server.resolveNode} returns: either a plain-data render the `@nifrajs/node` adapter writes
@@ -19,8 +35,8 @@ export type NodeServeOutcome =
19
35
  | {
20
36
  readonly kind: "json"
21
37
  readonly status: number
22
- /** `c.set.headers` backing, or `undefined` when the handler never set a header. */
23
- readonly headers: Readonly<Record<string, string>> | undefined
38
+ /** Header record after native hooks; repeated values are retained as arrays. */
39
+ readonly headers: Readonly<Record<string, string | readonly string[]>> | undefined
24
40
  /** Queued `Set-Cookie` lines, or `undefined`; the adapter emits one header line each. */
25
41
  readonly cookies: readonly string[] | undefined
26
42
  /** The JSON body already stringified, or `null` for an empty (204) response. */
@@ -48,27 +64,98 @@ export function toNodeOutcome(result: HandlerResult, set: CtxSet): NodeServeOutc
48
64
  kind: "body",
49
65
  status: body.status,
50
66
  headers: appendCookiesToNodeHeaders(body.headers, set._cookies),
51
- body: body.body,
67
+ body: isBodylessStatus(body.status) ? new Uint8Array(0) : body.body,
52
68
  }
53
69
  }
54
- return nodeOutcomeFromResponse(appendCookiesToResponse(result.toResponse(), set))
70
+ return nodeOutcomeFromResponse(
71
+ appendCookiesToResponse(normalizeBodylessResponse(result.toResponse()), set),
72
+ )
55
73
  }
56
74
  if (result instanceof Response) {
57
- return nodeOutcomeFromResponse(appendCookiesToResponse(result, set))
75
+ return nodeOutcomeFromResponse(appendCookiesToResponse(normalizeBodylessResponse(result), set))
58
76
  }
59
77
  const status = set.status ?? (result === undefined ? 204 : 200)
78
+ const body = result === undefined || isBodylessStatus(status) ? null : JSON.stringify(result)
60
79
  return {
61
80
  kind: "json",
62
81
  status,
63
82
  headers: set._headers,
64
83
  cookies: set._cookies,
65
- body: result === undefined ? null : JSON.stringify(result),
84
+ body,
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Materialize a buffered node outcome only when a Web `onResponse` hook needs to see a real
90
+ * `Response`. The marker lets an in-place hook (`response.headers.set(...); return response`) go back
91
+ * to the direct socket writer without draining the body through a Web stream. A hook that replaces,
92
+ * consumes, or otherwise changes the response naturally loses the marker and stays on the portable
93
+ * response path.
94
+ */
95
+ export function nodeOutcomeToResponse(outcome: NodeServeOutcome): Response {
96
+ if (outcome.kind === "response") return outcome.response
97
+ // A prebuilt `Headers` on purpose: undici's Response constructor takes a fast clone path for a
98
+ // `Headers` instance, which measured cheaper than handing it a pairs list to fill (pairs pay a
99
+ // webidl sequence conversion per entry).
100
+ const headers = new Headers()
101
+ if (outcome.headers !== undefined) {
102
+ for (const [name, value] of Object.entries(outcome.headers)) {
103
+ if (typeof value !== "string") {
104
+ for (const item of value) headers.append(name, item)
105
+ } else {
106
+ headers.set(name, value as string)
107
+ }
108
+ }
66
109
  }
110
+ if (outcome.kind === "json") {
111
+ if (outcome.cookies !== undefined) {
112
+ for (const cookie of outcome.cookies) headers.append("set-cookie", cookie)
113
+ }
114
+ if (outcome.body !== null && headers.get("content-type") === null) {
115
+ headers.set("content-type", "application/json;charset=utf-8")
116
+ }
117
+ }
118
+ if (isBodylessStatus(outcome.status)) headers.delete("content-length")
119
+ const body = isBodylessStatus(outcome.status) ? null : outcome.body
120
+ // `Uint8Array<ArrayBufferLike>` vs the lib's body-init generic - runtime-accepted everywhere,
121
+ // only the type narrows wrong under the DOM-free lib set (same idiom as the Headers cast in
122
+ // transport-codec.ts).
123
+ const response = new Response(body as ConstructorParameters<typeof Response>[0], {
124
+ status: outcome.status,
125
+ headers,
126
+ })
127
+ if (body !== null) markTaggedResponse(response, body)
128
+ rememberMutableHeaders(response.headers)
129
+ return response
67
130
  }
68
131
 
69
- const NODE_RESPONSE_BODY = Symbol.for("nifra.response.body")
132
+ /**
133
+ * Fold declared static headers into a resolved outcome, ONCE, before any native response hook runs -
134
+ * so a header or body twin sees the declared values through its view exactly as it would see values
135
+ * a hook had written, and so the no-hook direct-writer path (which never calls the finish step) still
136
+ * ships them.
137
+ *
138
+ * The record handed over is always a fresh copy: the Node writers mutate the outcome's record in
139
+ * place (content-type, content-length, cookies), and the static record is shared by every request.
140
+ * Its names are already lowercase, so the writer's all-lowercase fast path still holds.
141
+ */
142
+ export function withStaticNodeHeaders(
143
+ outcome: NodeServeOutcome,
144
+ statics: StaticResponseHeaders,
145
+ ): NodeServeOutcome {
146
+ if (outcome.kind === "response") {
147
+ return { kind: "response", response: applyStaticResponseHeaders(outcome.response, statics) }
148
+ }
149
+ const own = outcome.headers
150
+ const headers =
151
+ own === undefined
152
+ ? staticHeaderRecordCopy(statics)
153
+ : mergeStaticHeaderRecord(statics.record, own)
154
+ return { ...outcome, headers }
155
+ }
70
156
 
71
157
  export function nodeOutcomeFromResponse(response: Response): NodeServeOutcome {
158
+ response = normalizeBodylessResponse(response)
72
159
  const body = nodeResponseBody(response)
73
160
  return body === undefined
74
161
  ? { kind: "response", response }
@@ -77,8 +164,7 @@ export function nodeOutcomeFromResponse(response: Response): NodeServeOutcome {
77
164
 
78
165
  function nodeResponseBody(response: Response): string | Uint8Array | undefined {
79
166
  if (response.bodyUsed) return undefined
80
- const body = (response as { readonly [NODE_RESPONSE_BODY]?: unknown })[NODE_RESPONSE_BODY]
81
- return typeof body === "string" || body instanceof Uint8Array ? body : undefined
167
+ return taggedResponseBody(response)
82
168
  }
83
169
 
84
170
  function responseHeadersForNode(
@@ -86,12 +172,12 @@ function responseHeadersForNode(
86
172
  ): Readonly<Record<string, string | readonly string[]>> | undefined {
87
173
  let headers: Record<string, string | readonly string[]> | undefined
88
174
  response.headers.forEach((value, key) => {
89
- headers ??= {}
175
+ headers ??= Object.create(null) as Record<string, string | readonly string[]>
90
176
  headers[key] = value
91
177
  })
92
178
  const setCookies = response.headers.getSetCookie?.()
93
179
  if (setCookies !== undefined && setCookies.length > 0) {
94
- headers ??= {}
180
+ headers ??= Object.create(null) as Record<string, string | readonly string[]>
95
181
  headers["set-cookie"] = setCookies
96
182
  }
97
183
  return headers
@@ -102,8 +188,8 @@ function appendCookiesToNodeHeaders(
102
188
  cookies: readonly string[] | undefined,
103
189
  ): Readonly<Record<string, string | readonly string[]>> | undefined {
104
190
  if (cookies === undefined || cookies.length === 0) return headers
105
- const out: Record<string, string | readonly string[]> =
106
- headers === undefined ? {} : { ...headers }
191
+ const out = Object.create(null) as Record<string, string | readonly string[]>
192
+ if (headers !== undefined) Object.assign(out, headers)
107
193
  const existing = out["set-cookie"]
108
194
  const setCookies =
109
195
  existing === undefined ? [] : typeof existing === "string" ? [existing] : [...existing]
@@ -22,11 +22,36 @@ export function searchOf(url: string): string {
22
22
  * (an explicit 400 beats silently picking one). */
23
23
  export type QueryValue = string | string[]
24
24
 
25
+ // V8 stores a FRESHLY-SLICED string key on a dictionary-mode (null-prototype) record ~13x slower
26
+ // than an already-internalized one - the store internalizes the fresh string every time (measured
27
+ // ~300ns vs ~22ns for a literal key; it was the single largest slice of query parsing on Node).
28
+ // JSC has no such cliff (~2.5ns either way), so the whole scheme is bypassed there. Interning
29
+ // repeated keys through a small bounded map hands every later request the SAME first-seen (and by
30
+ // then internalized) string, so the store takes the fast path. Query/cookie key sets in a real app
31
+ // are tiny and stable; a hostile high-cardinality caller stops being cached at the cap and an
32
+ // oversized key never enters, so both degrade to exactly today's uncached behavior - the returned
33
+ // key is always value-equal to the input, cached or not.
34
+ const FRESH_KEY_STORE_IS_CHEAP = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined"
35
+ const PARSE_KEY_INTERN = new Map<string, string>()
36
+ const PARSE_KEY_INTERN_CAP = 2048
37
+ const PARSE_KEY_MAX_LENGTH = 64
38
+
39
+ /** Return a value-equal, likely-internalized copy of a parsed record key. See the note above. */
40
+ export function internParseKey(key: string): string {
41
+ if (FRESH_KEY_STORE_IS_CHEAP) return key
42
+ const hit = PARSE_KEY_INTERN.get(key)
43
+ if (hit !== undefined) return hit
44
+ if (key.length > PARSE_KEY_MAX_LENGTH || PARSE_KEY_INTERN.size >= PARSE_KEY_INTERN_CAP) return key
45
+ PARSE_KEY_INTERN.set(key, key)
46
+ return key
47
+ }
48
+
25
49
  /** Accumulate into a NULL-PROTOTYPE record (every call site below creates one): with no inherited
26
50
  * `constructor`/`toString`/`__proto__` accessors, a hostile key is just an own data key - the
27
51
  * promotion logic can't collide with `Object.prototype` members, and `__proto__` needs no special
28
52
  * case (direct assignment on a null-proto object creates an own property). */
29
- function setQueryValue(out: Record<string, QueryValue>, key: string, value: string): void {
53
+ function setQueryValue(out: Record<string, QueryValue>, rawKey: string, value: string): void {
54
+ const key = internParseKey(rawKey)
30
55
  const existing = out[key]
31
56
  if (existing === undefined) {
32
57
  out[key] = value
@@ -32,6 +32,13 @@ class LazyResponseControls implements CtxSet {
32
32
  _cookies?: string[]
33
33
 
34
34
  get headers(): Record<string, string> {
35
+ // A plain literal record on purpose: values here are strings, and assigning a STRING through
36
+ // the inherited `__proto__` setter is a silent no-op by spec - it cannot mutate the record's
37
+ // prototype - so a literal object is not pollutable through header writes. The trade is that a
38
+ // header literally named "__proto__" is dropped rather than stored (the sinks that accept
39
+ // attacker-influenced names guard that name explicitly); in exchange the record stays in V8's
40
+ // fast property mode, which a null-prototype object never enters - measured at ~2% of
41
+ // throughput on a realistic middleware route on BOTH V8 and JSC.
35
42
  this._headers ??= {}
36
43
  return this._headers
37
44
  }
@@ -114,12 +121,23 @@ export class RequestContext implements RawContext {
114
121
  this.maxBodyBytes = maxBodyBytes as number
115
122
  }
116
123
 
124
+ /**
125
+ * The no-timeout context: `c.signal`/`c.budget` fall back to the never-abort signal and the
126
+ * unbounded budget, which is exactly what the request would have been handed, so the two slots
127
+ * are left unwritten instead of initialized. `search` carries the router's already-split query
128
+ * string when it has one; `undefined` leaves `c.query` to re-scan the URL lazily.
129
+ */
117
130
  static native(
118
131
  source: RequestSource,
119
132
  params: Record<string, string>,
133
+ search: string | undefined,
120
134
  maxBodyBytes: number,
135
+ platform?: Platform,
121
136
  ): RequestContext {
122
- return new RequestContext(source, params, maxBodyBytes)
137
+ const context = new RequestContext(source, params, maxBodyBytes)
138
+ if (search !== undefined) context.searchValue = search
139
+ if (platform !== undefined) context.platformValue = platform
140
+ return context
123
141
  }
124
142
 
125
143
  [CONTEXT_SET](): CtxSet | undefined {
@@ -166,6 +184,10 @@ export class RequestContext implements RawContext {
166
184
  return requestOf(this.source)
167
185
  }
168
186
 
187
+ header(name: string): string | null {
188
+ return headerOf(this.source, name)
189
+ }
190
+
169
191
  json(body: unknown, init?: ResponseInit | number): Response {
170
192
  return Response.json(body, statusInit(init))
171
193
  }