@nifrajs/core 2.8.2 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +9 -1
  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 +1151 -173
  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 +8 -1
  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 +1608 -236
  77. package/src/server/static-headers.ts +142 -0
  78. package/src/server.ts +9 -0
  79. package/src/transport-codec.ts +55 -10
@@ -11,6 +11,7 @@ import {
11
11
  isResponseResult,
12
12
  } from "./runtime-core.ts"
13
13
  import type { CtxSet, RawContext } from "./server.ts"
14
+ import { mergeStaticHeaderRecord, type StaticResponseHeaders } from "./static-headers.ts"
14
15
 
15
16
  /** Build the response headers init. The common path (no `c.set`) returns `undefined` so `Response`
16
17
  * gets no `headers` at all. Cookies force a `Headers` object - multiple `Set-Cookie`s can't live in a
@@ -26,15 +27,273 @@ function headersInit(set: CtxSet): Record<string, string> | Headers | undefined
26
27
  // Keep the fast JSON respond path byte-identical to `Response.json` without probing it at module
27
28
  // scope: workerd forbids `Response.json()` during startup. A shared `Headers` is safe to reuse
28
29
  // across responses: the Response constructor copies `init.headers` into its own list.
29
- const JSON_CT_HEADERS = new Headers({
30
- "content-type": "application/json;charset=utf-8",
31
- })
30
+ const JSON_CONTENT_TYPE = "application/json;charset=utf-8"
31
+ const JSON_CT_HEADERS = new Headers({ "content-type": JSON_CONTENT_TYPE })
32
32
  const JSON_INIT_200: ResponseInit = { status: 200, headers: JSON_CT_HEADERS }
33
33
 
34
+ /**
35
+ * Build the per-server shapes for a validated static header record. The JSON init is prebuilt once
36
+ * here so a static-middleware app's fused JSON lane pays a shared init read instead of a header write
37
+ * per declared name per request - the same reuse the hook-free lane already gets from
38
+ * {@link JSON_INIT_200}.
39
+ */
40
+ export function buildStaticResponseHeaders(
41
+ record: Readonly<Record<string, string>>,
42
+ ): StaticResponseHeaders {
43
+ const frozen = Object.freeze({ ...record })
44
+ const jsonHeaders = new Headers({ ...frozen, "content-type": JSON_CONTENT_TYPE })
45
+ let responseJson: ResponseInit | undefined
46
+ return {
47
+ record: frozen,
48
+ entries: Object.freeze(Object.entries(frozen)),
49
+ jsonHeaders,
50
+ jsonInit200: { status: 200, headers: jsonHeaders },
51
+ responseJsonInit200: () => {
52
+ responseJson ??= {
53
+ status: 200,
54
+ headers: new Headers({ ...frozen, "content-type": responseJsonContentType() }),
55
+ }
56
+ return responseJson
57
+ },
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Apply the static defaults to an ALREADY-BUILT response - the framework's error/404/timeout renders
63
+ * and a handler-returned `Response`, none of which go through the header init below. Defaults, so a
64
+ * name the response already carries is left alone. A guarded `Headers` (a raw `fetch()` result a
65
+ * handler returned) rejects every write, so the first `set` throws with nothing applied and the
66
+ * retry lands on a mutable copy.
67
+ */
68
+ export function applyStaticResponseHeaders(
69
+ response: Response,
70
+ statics: StaticResponseHeaders,
71
+ ): Response {
72
+ const headers = response.headers
73
+ if (knownMutableHeaders(headers)) {
74
+ applyStaticDefaults(headers, statics)
75
+ return response
76
+ }
77
+ try {
78
+ applyStaticDefaults(headers, statics)
79
+ } catch {
80
+ const clone = new Response(response.body, response)
81
+ applyStaticDefaults(clone.headers, statics)
82
+ return stamped(clone)
83
+ }
84
+ rememberMutableHeaders(headers)
85
+ return response
86
+ }
87
+
88
+ /**
89
+ * Build a JSON response carrying the declared headers, the cheapest way this runtime allows.
90
+ *
91
+ * Everywhere but Deno that is one constructor call with the prebuilt init. Deno charges far more to
92
+ * ingest a header init than to mutate a built response's `Headers` - the same asymmetry the
93
+ * caller-set-headers path above already routes around - and handing it the prebuilt init measured
94
+ * 15% BELOW writing the same headers from a response hook, which would make declaring them a
95
+ * pessimization on that runtime.
96
+ */
97
+ function staticJsonResponse(
98
+ body: string,
99
+ status: number,
100
+ statics: StaticResponseHeaders,
101
+ contentType: string,
102
+ init: ResponseInit,
103
+ ): Response {
104
+ if (IS_DENO) {
105
+ return denoResponseWithJsonBody(
106
+ body,
107
+ status,
108
+ statics.record as Record<string, string>,
109
+ contentType,
110
+ )
111
+ }
112
+ return new Response(body, init)
113
+ }
114
+
115
+ function withStatics(response: Response, statics: StaticResponseHeaders | undefined): Response {
116
+ return statics === undefined ? response : applyStaticResponseHeaders(response, statics)
117
+ }
118
+
119
+ function applyStaticDefaults(headers: Headers, statics: StaticResponseHeaders): void {
120
+ for (const [name, value] of statics.entries) {
121
+ if (!headers.has(name)) headers.set(name, value)
122
+ }
123
+ }
124
+
125
+ /** Fold the static defaults under whatever shape the request's own headers took. */
126
+ function withStaticHeaderInit(
127
+ own: Record<string, string> | Headers | undefined,
128
+ statics: StaticResponseHeaders,
129
+ ): Record<string, string> | Headers {
130
+ if (own === undefined) return statics.record
131
+ if (own instanceof Headers) {
132
+ // Freshly built by `headersInit` for the cookies case, so mutating it in place is safe.
133
+ applyStaticDefaults(own, statics)
134
+ return own
135
+ }
136
+ return mergeStaticHeaderRecord(statics.record, own) as Record<string, string>
137
+ }
138
+
139
+ // Deno's Response/Headers implementation ingests a plain header record much more slowly than it
140
+ // mutates an already-created response (the difference is material on realistic middleware routes
141
+ // that add several security/CORS headers). Bun is faster on the record constructor, so keep the
142
+ // portable constructor path there and select the Deno-specific shape only for Web responses that
143
+ // carry a plain record. The first request lazily probes Deno's native JSON content-type so this
144
+ // preserves the runtime's Response.json contract instead of baking in Bun's charset suffix.
145
+ const IS_DENO = typeof (globalThis as { Deno?: unknown }).Deno !== "undefined"
146
+ let denoJsonContentType: string | undefined
147
+
148
+ function responseJsonContentType(): string {
149
+ if (denoJsonContentType === undefined) {
150
+ denoJsonContentType = Response.json(0).headers.get("content-type") ?? "application/json"
151
+ }
152
+ return denoJsonContentType
153
+ }
154
+
155
+ function denoResponseWithJsonBody(
156
+ body: string,
157
+ status: number,
158
+ headers: Record<string, string>,
159
+ defaultContentType = responseJsonContentType(),
160
+ ): Response {
161
+ const response = new Response(body, { status })
162
+ let hasContentType = false
163
+ for (const [name, value] of Object.entries(headers)) {
164
+ response.headers.set(name, value)
165
+ if (name.toLowerCase() === "content-type") {
166
+ hasContentType = true
167
+ }
168
+ }
169
+ if (!hasContentType) response.headers.set("content-type", defaultContentType)
170
+ return response
171
+ }
172
+
173
+ // The framework-buffered-body marker shared with the Node adapter: a Response carrying it exposes
174
+ // its already-serialized bytes without draining. On the Web serving paths it exists ONLY when a
175
+ // registered body/raw hook needs it, so hook-less apps pay nothing.
176
+ const RESPONSE_BODY = Symbol.for("nifra.response.body")
177
+
178
+ /**
179
+ * Headers objects the framework itself constructed - guaranteed mutable, no guard. Response
180
+ * middleware adapters consult this before falling back to a mutability probe, so the hot path
181
+ * (every framework-rendered response) never pays per-request `Headers` operations to learn what
182
+ * construction already knew; only a handler-returned foreign `Response` (a raw `fetch()` result,
183
+ * whose headers are guarded immutable) reaches the probe.
184
+ */
185
+ const MUTABLE_RESPONSE_HEADERS = new WeakSet<Headers>()
186
+
187
+ export function knownMutableHeaders(headers: Headers): boolean {
188
+ return MUTABLE_RESPONSE_HEADERS.has(headers)
189
+ }
190
+
191
+ export function rememberMutableHeaders(headers: Headers): void {
192
+ MUTABLE_RESPONSE_HEADERS.add(headers)
193
+ }
194
+
195
+ /** Stamp a framework-constructed Response's headers as known-mutable, and return it. */
196
+ function stamped(response: Response): Response {
197
+ MUTABLE_RESPONSE_HEADERS.add(response.headers)
198
+ return response
199
+ }
200
+
201
+ function isBodylessStatus(status: number): boolean {
202
+ return status === 204 || status === 205 || status === 304
203
+ }
204
+
205
+ /** Normalize handler-produced bodyless responses before response middleware sees them. */
206
+ export function normalizeBodylessResponse(response: Response): Response {
207
+ if (!isBodylessStatus(response.status)) return response
208
+ if (response.body === null && !response.headers.has("content-length")) return response
209
+ const headers = new Headers(response.headers)
210
+ headers.delete("content-length")
211
+ return stamped(
212
+ new Response(null, {
213
+ status: response.status,
214
+ statusText: response.statusText,
215
+ headers,
216
+ }),
217
+ )
218
+ }
219
+
220
+ type TaggedResponseMarker = {
221
+ readonly body: string | Uint8Array
222
+ readonly owner?: object
223
+ }
224
+
225
+ type ResponseBodyTagOption = false | true | object | undefined
226
+
227
+ /** The framework-serialized bytes riding a tagged Response, or `undefined` for raw/streamed ones. */
228
+ export function taggedResponseBody(
229
+ response: Response,
230
+ owners?: ReadonlySet<object>,
231
+ ): string | Uint8Array | undefined {
232
+ const marker = (response as { [RESPONSE_BODY]?: string | Uint8Array | TaggedResponseMarker })[
233
+ RESPONSE_BODY
234
+ ]
235
+ if (owners !== undefined) {
236
+ // A Web response is framework-buffered only when this app (or an intentionally merged app) put
237
+ // the marker on it. The legacy primitive marker remains readable by the Node bridge, but must
238
+ // not let an arbitrary response impersonate a local framework body on the Web hook path.
239
+ if (
240
+ typeof marker !== "object" ||
241
+ marker === null ||
242
+ marker instanceof Uint8Array ||
243
+ marker.owner === undefined ||
244
+ !owners.has(marker.owner)
245
+ ) {
246
+ return undefined
247
+ }
248
+ return marker.body
249
+ }
250
+ if (typeof marker === "string" || marker instanceof Uint8Array) return marker
251
+ return marker?.body
252
+ }
253
+
254
+ export function taggedResponseOwner(response: Response): object | undefined {
255
+ const marker = (response as { [RESPONSE_BODY]?: string | Uint8Array | TaggedResponseMarker })[
256
+ RESPONSE_BODY
257
+ ]
258
+ return typeof marker === "object" && marker !== null && "owner" in marker
259
+ ? marker.owner
260
+ : undefined
261
+ }
262
+
263
+ export function markTaggedResponse(
264
+ response: Response,
265
+ body: string | Uint8Array,
266
+ owner?: object,
267
+ ): Response {
268
+ Object.defineProperty(response, RESPONSE_BODY, {
269
+ value: owner === undefined ? body : ({ body, owner } satisfies TaggedResponseMarker),
270
+ })
271
+ return response
272
+ }
273
+
274
+ function tagged(
275
+ response: Response,
276
+ body: string,
277
+ tagResponseBody: ResponseBodyTagOption,
278
+ ): Response {
279
+ stamped(response)
280
+ return tagResponseBody === false || tagResponseBody === undefined
281
+ ? response
282
+ : markTaggedResponse(
283
+ response,
284
+ body,
285
+ typeof tagResponseBody === "object" ? tagResponseBody : undefined,
286
+ )
287
+ }
288
+
34
289
  /** Fused-lane respond when `c.set` is untouched. Bun 1.3's native `Response.json` now beats the
35
290
  * older hand-inlined stringify + Response construction on this lane while preserving the exact
36
291
  * body/content-type contract; keep the generic fallback for non-JSON values. */
37
- export function fusedRespondNoSet(result: unknown): Response {
292
+ export function fusedRespondNoSet(
293
+ result: unknown,
294
+ tagResponseBody: ResponseBodyTagOption = false,
295
+ statics?: StaticResponseHeaders,
296
+ ): Response {
38
297
  if (
39
298
  result !== undefined &&
40
299
  !(result instanceof Response) &&
@@ -42,43 +301,153 @@ export function fusedRespondNoSet(result: unknown): Response {
42
301
  result !== null &&
43
302
  !isResponseResult(result)
44
303
  ) {
45
- return Response.json(result)
304
+ // Deno's cheapest shape for this lane is its own `Response.json` (which fuses serialization and
305
+ // construction natively, and sets the content-type natively) followed by post-construction header
306
+ // writes - NOT a header init, which measured 15% below writing the same names from a hook. So on
307
+ // Deno the tier's only saving here is the response walk itself; every other runtime keeps the
308
+ // one-call prebuilt init below.
309
+ if (statics !== undefined && IS_DENO && !tagResponseBody) {
310
+ const response = Response.json(result)
311
+ for (const [name, value] of statics.entries) response.headers.set(name, value)
312
+ return stamped(response)
313
+ }
314
+ // With declared static headers the prebuilt init already carries them plus the JSON
315
+ // content-type, so this lane still constructs the response in one call - the whole point of
316
+ // declaring headers statically instead of writing them per response.
317
+ if (tagResponseBody || statics !== undefined) {
318
+ const body = JSON.stringify(result) as string | undefined
319
+ // Each shape carries the content-type of the lane it stands in for: the framework's own for the
320
+ // tagging path this branch already owned, and `Response.json`'s for the construction below that
321
+ // the static tier replaces. They differ on Deno, and a declared header must not change one.
322
+ if (body !== undefined)
323
+ return tagged(
324
+ statics === undefined
325
+ ? new Response(body, JSON_INIT_200)
326
+ : tagResponseBody
327
+ ? staticJsonResponse(body, 200, statics, JSON_CONTENT_TYPE, statics.jsonInit200)
328
+ : staticJsonResponse(
329
+ body,
330
+ 200,
331
+ statics,
332
+ responseJsonContentType(),
333
+ statics.responseJsonInit200(),
334
+ ),
335
+ body,
336
+ tagResponseBody,
337
+ )
338
+ }
339
+ return stamped(
340
+ statics === undefined
341
+ ? Response.json(result)
342
+ : Response.json(result, statics.responseJsonInit200()),
343
+ )
46
344
  }
47
- return toResponse(result as HandlerResult, EMPTY_RESPONSE_CONTROLS)
345
+ return toResponse(result as HandlerResult, EMPTY_RESPONSE_CONTROLS, tagResponseBody, statics)
48
346
  }
49
347
 
50
348
  /** Fused-lane respond with a context: read `c.set` once; untouched (the common case) -> the fast
51
349
  * JSON respond; touched -> the generic `toResponse` with those controls (statuses, headers, cookies). */
52
- export function fusedRespond(result: unknown, ctx: RawContext): Response {
350
+ export function fusedRespond(
351
+ result: unknown,
352
+ ctx: RawContext,
353
+ tagResponseBody: ResponseBodyTagOption = false,
354
+ statics?: StaticResponseHeaders,
355
+ ): Response {
53
356
  const set = ctx[CONTEXT_SET]()
54
- if (set === undefined) return fusedRespondNoSet(result)
55
- return toResponse(result as HandlerResult, set)
357
+ if (set === undefined) return fusedRespondNoSet(result, tagResponseBody, statics)
358
+ return toResponse(result as HandlerResult, set, tagResponseBody, statics)
56
359
  }
57
360
 
58
- export function toResponse(result: HandlerResult, set: CtxSet): Response {
361
+ export function toResponse(
362
+ result: HandlerResult,
363
+ set: CtxSet,
364
+ tagResponseBody: ResponseBodyTagOption = false,
365
+ statics?: StaticResponseHeaders,
366
+ ): Response {
59
367
  if (isResponseResult(result)) {
60
- return appendCookiesToResponse(result.toResponse(), set)
368
+ return withStatics(
369
+ appendCookiesToResponse(normalizeBodylessResponse(result.toResponse()), set),
370
+ statics,
371
+ )
61
372
  }
62
373
  if (result instanceof Response) {
63
- return appendCookiesToResponse(result, set)
374
+ return withStatics(appendCookiesToResponse(normalizeBodylessResponse(result), set), statics)
64
375
  }
65
- const headers = headersInit(set)
376
+ // The static tier only changes WHICH prebuilt header shape the branches below reach for: the
377
+ // request's own headers are folded on top of the declared defaults once, here.
378
+ const own = headersInit(set)
379
+ const headers = statics === undefined ? own : withStaticHeaderInit(own, statics)
380
+ const jsonCtHeaders = statics === undefined ? JSON_CT_HEADERS : statics.jsonHeaders
66
381
  const status = set.status ?? (result === undefined ? 204 : 200)
67
- if (headers === undefined && result !== undefined) {
382
+ if (status === 204 || status === 205 || status === 304) {
383
+ return stamped(new Response(null, headers === undefined ? { status } : { status, headers }))
384
+ }
385
+ if (own === undefined && result !== undefined) {
68
386
  // Fast respond (profiled ~50 ns/req faster on every plain-JSON return): `JSON.stringify` + a
69
387
  // prebuilt init beats `Response.json`'s internal init handling. Output is byte-identical -
70
388
  // same body bytes, same probed content-type. `undefined` from stringify (a function/symbol
71
389
  // result) delegates to Response.json so its TypeError contract stays the single source.
72
390
  const body = JSON.stringify(result) as string | undefined
73
391
  if (body !== undefined) {
74
- return new Response(
392
+ const init: ResponseInit =
393
+ status === 200
394
+ ? (statics?.jsonInit200 ?? JSON_INIT_200)
395
+ : { status, headers: jsonCtHeaders }
396
+ return tagged(
397
+ statics === undefined
398
+ ? new Response(body, init)
399
+ : staticJsonResponse(body, status, statics, JSON_CONTENT_TYPE, init),
75
400
  body,
76
- status === 200 ? JSON_INIT_200 : { status, headers: JSON_CT_HEADERS },
401
+ tagResponseBody,
77
402
  )
78
403
  }
79
404
  }
80
405
  const init: ResponseInit = headers === undefined ? { status } : { status, headers }
81
- return result === undefined ? new Response(null, init) : Response.json(result, init)
406
+ if (result === undefined) return stamped(new Response(null, init))
407
+ if (tagResponseBody) {
408
+ const body = JSON.stringify(result) as string | undefined
409
+ if (body !== undefined) {
410
+ const response =
411
+ IS_DENO && !(headers instanceof Headers)
412
+ ? denoResponseWithJsonBody(
413
+ body,
414
+ status,
415
+ headers as Record<string, string>,
416
+ JSON_CT_HEADERS.get("content-type") ?? "application/json;charset=utf-8",
417
+ )
418
+ : new Response(body, { status, headers: withJsonContentType(headers) })
419
+ return tagged(
420
+ response,
421
+ body,
422
+ typeof tagResponseBody === "object" ? tagResponseBody : undefined,
423
+ )
424
+ }
425
+ }
426
+ if (IS_DENO && headers !== undefined && !(headers instanceof Headers)) {
427
+ const body = JSON.stringify(result) as string | undefined
428
+ if (body !== undefined) return stamped(denoResponseWithJsonBody(body, status, headers))
429
+ }
430
+ return stamped(Response.json(result, init))
431
+ }
432
+
433
+ // Add a JSON content-type without pre-building a `Headers` instance just to check-and-set one key:
434
+ // `new Headers(record)` measured ~8us/call on V8 (Deno) for a handful of header entries - it's a
435
+ // full parse-and-validate of every entry, done here only to ask "is content-type present," when
436
+ // `new Response(body, { headers })` a few lines up already does that same ingestion once, for free,
437
+ // on whatever shape is handed to it. A plain `Record` (the common case - no cookies queued) gets a
438
+ // shallow copy; an existing `Headers` (the cookies case - freshly built by `headersInit`, not
439
+ // exposed elsewhere) is mutated in place, same as before.
440
+ function withJsonContentType(
441
+ headers: Record<string, string> | Headers | undefined,
442
+ ): Record<string, string> | Headers {
443
+ if (headers === undefined) return JSON_CT_HEADERS
444
+ if (headers instanceof Headers) {
445
+ if (!headers.has("content-type")) headers.set("content-type", "application/json;charset=utf-8")
446
+ return headers
447
+ }
448
+ return Object.hasOwn(headers, "content-type")
449
+ ? headers
450
+ : { ...headers, "content-type": "application/json;charset=utf-8" }
82
451
  }
83
452
 
84
453
  export function appendCookiesToResponse(response: Response, set: CtxSet): Response {
@@ -29,6 +29,17 @@
29
29
  * That is not an inconsistency to paper over: a strict schema has already declared that extra fields
30
30
  * are an error, and a stripping one has declared they are ignorable. Enforcement honours what the
31
31
  * author wrote rather than overriding it.
32
+ *
33
+ * ## What enforcement costs
34
+ *
35
+ * Less than it looks like it should. With a compiled validator (`@nifrajs/schema`'s `t` compiles at
36
+ * construction; zod/valibot equivalents are similarly cheap), the check itself measures in the
37
+ * ~100ns-per-response range - on a realistic middleware-carrying route, enforce mode benchmarks
38
+ * within measurement noise of the same route with no contract at all, on Bun and Node alike. The
39
+ * one real cost is structural: a contracted route cannot take the bare-route fused lane, because
40
+ * the check needs the handler's VALUE before it becomes bytes. A route with any middleware, derive,
41
+ * or lifecycle hook has already left that lane, so for the routes that look like production the
42
+ * contract is effectively free - declare it.
32
43
  */
33
44
  import type { StandardIssue, StandardResult, StandardSchemaV1 } from "../schema/standard.ts"
34
45
  import { INSTALL_RESPONSE_CONTRACT } from "./install.ts"
@@ -147,9 +158,12 @@ interface ResponseContractInstallable {
147
158
  * Install it before the routes it should cover - like `idempotency()`, the decision is made per route
148
159
  * at registration, so routes registered earlier are not retroactively covered.
149
160
  *
150
- * A contracted route leaves the fused and native fast paths, because the check needs the handler's
151
- * value before it becomes bytes. That is the same trade an idempotent route makes, and the reason this
152
- * is opt-in rather than always on.
161
+ * The check itself is cheap - with a compiled validator it measures in the ~100ns-per-response
162
+ * range, within benchmark noise of an uncontracted route on any route that carries middleware or a
163
+ * derive. What a contracted route does give up is the bare-route fused lane (the check needs the
164
+ * handler's value before it becomes bytes), which only a route with NO other lifecycle steps was
165
+ * taking anyway. Opt-in because not installing the plugin keeps the lane out of the bundle, not
166
+ * because enforcement is expensive.
153
167
  */
154
168
  export function responseContract(mode: ResponseContractMode = "warn"): IdentityPlugin {
155
169
  const runtime: ResponseContractRuntime = {
@@ -9,6 +9,12 @@ import type { Method } from "../router/router.ts"
9
9
  import type { ClientIpTrust } from "./client-ip.ts"
10
10
  import type { Context, Platform, RouteSchema } from "./context.ts"
11
11
  import type { Logger } from "./logger.ts"
12
+ import type {
13
+ NodeRequestHook,
14
+ NodeResponseHook,
15
+ ResponseBodyHook,
16
+ ResponseHeadersHook,
17
+ } from "./node-outcome-hook.ts"
12
18
  import type { MaybePromise, OnRequestResult } from "./server.ts"
13
19
 
14
20
  /**
@@ -187,10 +193,46 @@ export interface RunningServer {
187
193
  export interface Middleware {
188
194
  readonly name?: string
189
195
  readonly onRequest?: (req: Request, platform?: Platform) => MaybePromise<OnRequestResult>
196
+ /** Allocation-light Node equivalent of `onRequest` for header-only hooks. */
197
+ readonly onNodeRequest?: NodeRequestHook
190
198
  readonly around?: <T>(context: Context, next: () => MaybePromise<T>) => MaybePromise<T>
191
199
  readonly beforeHandle?: (context: Context) => MaybePromise<unknown>
192
200
  readonly afterHandle?: (result: unknown, context: Context) => MaybePromise<unknown>
193
201
  readonly onResponse?: (response: Response, req: Request) => MaybePromise<Response>
202
+ /**
203
+ * Header-only equivalent of `onResponse` for the Node-direct serializer. It is used only when every
204
+ * response hook in the app supplies this paired native implementation; arbitrary response transforms
205
+ * continue through the Web Response path.
206
+ */
207
+ readonly onNodeResponse?: NodeResponseHook
208
+ /**
209
+ * Response headers with NO per-request decision behind them. Declared here instead of written by a
210
+ * hook, they are folded into response construction, so a bundle that only sets static headers does
211
+ * not cost the app its fused/native lanes (see {@link Server.responseHeaders}). They are defaults:
212
+ * anything the request produced wins. Reach for `onResponseHeaders` the moment a value depends on
213
+ * the request, the route, or the response.
214
+ */
215
+ readonly responseHeaders?: Readonly<Record<string, string>>
216
+ /**
217
+ * PORTABLE header-only response hook - the recommended shape when the middleware only reads or
218
+ * writes headers. One implementation runs on every runtime: inside the Web `onResponse` walk
219
+ * against the response's own `Headers`, and on Node as a self-paired native hook against the
220
+ * outcome record - so it never forces the Node adapter off its direct writer. Use `onResponse` +
221
+ * an optional `onNodeResponse` twin only when the hook must see a real `Response`.
222
+ */
223
+ readonly onResponseHeaders?: ResponseHeadersHook
224
+ /**
225
+ * PORTABLE post-serialization body hook - the payload tier. Receives the final
226
+ * framework-serialized bytes (already resident on every runtime; no stream is drained) plus the
227
+ * header view and status, and may return replacement bytes. Raw handler-returned `Response`s
228
+ * (proxies, SSE, streamed SSR) are skipped by contract - transforming those needs `onResponse`.
229
+ */
230
+ readonly onResponseBody?: ResponseBodyHook
231
+ /**
232
+ * Response transform for untagged/raw responses (streams, proxied fetches, and framework errors).
233
+ * Framework-serialized payloads use `onResponseBody` and remain on the Node direct lane.
234
+ */
235
+ readonly onResponseRaw?: (response: Response, req: Request) => MaybePromise<Response>
194
236
  readonly onResponseFinalized?: (outcome: ResponseFinalization, req: Request) => MaybePromise<void>
195
237
  readonly onError?: (error: unknown, context: Context) => MaybePromise<unknown>
196
238
  }