@nifrajs/core 2.12.1 → 2.13.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.
@@ -256,10 +256,8 @@ function cappedStreamOf(
256
256
  * An over-cap read rejects with `reject(413)` (a lying `Content-Length` with `reject(400)`), which
257
257
  * the lifecycle already treats as control flow - the same contract as `c.boundedBody`.
258
258
  */
259
- function capRequestBodyReads(request: Request, maxBytes: number): void {
260
- if (TRANSPORT_CAPPED.has(request)) return
261
- TRANSPORT_CAPPED.add(request)
262
- const reject = transportCapError
259
+ /** The request's own pre-shadow readers - the fallback surface when the source offers none. */
260
+ function requestBodyReaders(request: Request): RawBodyReaders {
263
261
  const bodyDescriptor = descriptorOf(request, "body")
264
262
  const raw: RawBodyReaders = {
265
263
  get headers() {
@@ -276,6 +274,14 @@ function capRequestBodyReads(request: Request, maxBytes: number): void {
276
274
  json: request.json.bind(request),
277
275
  }
278
276
  if (typeof request.bytes === "function") raw.bytes = request.bytes.bind(request)
277
+ return raw
278
+ }
279
+
280
+ function capRequestBodyReads(request: Request, maxBytes: number, preset?: RawBodyReaders): void {
281
+ if (TRANSPORT_CAPPED.has(request)) return
282
+ TRANSPORT_CAPPED.add(request)
283
+ const reject = transportCapError
284
+ const raw = preset ?? requestBodyReaders(request)
279
285
  // One bounded buffer backs every shadowed reader; a second read replays it instead of failing
280
286
  // with "body already used" (a strict superset of the native one-shot contract).
281
287
  let buffered: Promise<Uint8Array> | undefined
@@ -350,13 +356,24 @@ export function markTransportCap(source: object, maxBytes: number): void {
350
356
  * lane's pre-decoded body), and hook-observed references all survive. Framework readers bypass the
351
357
  * shadow through {@link rawBodySourceOf} and enforce their own caps - `c.boundedBody(explicit)`
352
358
  * still overrides in either direction. An unmarked source (`bodyLimit: "unlimited"`) is untouched.
359
+ *
360
+ * A source that owns its transport bytes directly (Node's lazy source reads the socket itself)
361
+ * supplies them through `RequestSource.rawBodyReaders`: the capped readers then buffer off the
362
+ * socket instead of routing every read back through a runtime `Request` the source was still
363
+ * deferring. The cap is identical either way - the same {@link readBoundedBytes} enforces it.
353
364
  */
354
365
  export function applyTransportCap(source: object, request: Request): void {
355
366
  const maxBytes = (source as { [TRANSPORT_CAP]?: number })[TRANSPORT_CAP]
356
- // The capped check comes BEFORE the body-null check: post-cap, `request.body` is the shadowed
357
- // getter, and re-reading it on every `c.req` access would rebuild capped state for nothing.
358
- if (maxBytes === undefined || TRANSPORT_CAPPED.has(request) || request.body === null) return
359
- capRequestBodyReads(request, maxBytes)
367
+ // The capped check comes BEFORE the body probe: post-cap, `request.body` is the shadowed getter,
368
+ // and re-reading it on every `c.req` access would rebuild capped state for nothing.
369
+ if (maxBytes === undefined || TRANSPORT_CAPPED.has(request)) return
370
+ const provide = (source as { rawBodyReaders?: () => RawBodyReaders }).rawBodyReaders
371
+ const preset = provide === undefined ? undefined : provide.call(source)
372
+ // The null-body shortcut is only worth its probe when there is no cheaper surface: on a source
373
+ // that defers its `Request`, reading `request.body` would materialize the very object the preset
374
+ // readers exist to avoid. Capping a bodiless request costs nothing but the shadowing itself.
375
+ if (preset === undefined && request.body === null) return
376
+ capRequestBodyReads(request, maxBytes, preset)
360
377
  // A wrapper source (Node's lazy source) delegates its own body readers to the request once it
361
378
  // materializes - which are now the shadowed ones. Mirror the raw-reader stash onto the source so
362
379
  // framework readers (`rawBodySourceOf(source)`) keep reading pre-shadow there too.
@@ -19,6 +19,7 @@ import { headerObjectOf } from "./headers.ts"
19
19
  import { jsonError } from "./http.ts"
20
20
  import { guardParsedValue, type ProtoPoisoning, parseJsonGuarded } from "./proto-guard.ts"
21
21
  import { searchOf } from "./query.ts"
22
+ import { responseJsonContentType } from "./respond.ts"
22
23
  import {
23
24
  CONTEXT_SEARCH,
24
25
  CONTEXT_SET,
@@ -88,6 +89,80 @@ function statusInit(init?: ResponseInit | number): ResponseInit | undefined {
88
89
  return typeof init === "number" ? { status: init } : init
89
90
  }
90
91
 
92
+ /**
93
+ * Whether a body built here should be deferred instead of turned into a `Response` on the spot.
94
+ *
95
+ * Only the Node bridge can use a deferred body: it writes status + headers + bytes straight to the
96
+ * socket, so the undici `Response` those two helpers used to build (~2us, about a quarter of the
97
+ * request budget on a small text response) was pure loss. Bun and Deno hand the `Response` to their
98
+ * native server, where nothing reads the deferral. Probed once at module load, not per response.
99
+ */
100
+ const DEFERS_RESPONSE =
101
+ typeof (globalThis as { Deno?: unknown }).Deno === "undefined" &&
102
+ typeof (globalThis as { Bun?: unknown }).Bun === "undefined" &&
103
+ typeof (globalThis as { process?: { versions?: { node?: unknown } } }).process?.versions?.node ===
104
+ "string"
105
+
106
+ /**
107
+ * The Node adapter's deferred-response factory, received over the shared-symbol seam rather than a
108
+ * package import - `@nifrajs/node` stays dependency-free from core's runtime and talks to it only
109
+ * through `Symbol.for` marks, so the stand-in it builds (and the ~0.4 KB gzip the class costs) never
110
+ * ships in a Bun/Deno bundle that will never run this branch. `c.json`/`c.text` route the Node fast
111
+ * lane through it: given a status, an owned header record, and the bytes, it returns a `Response`-shaped
112
+ * value the bridge writes straight to the socket (no undici `Response` built, no body stream drained).
113
+ * Absent - no adapter loaded - the two helpers fall back to a real `Response`. Cached on first read;
114
+ * the adapter registers at its own module load, before any request reaches a handler.
115
+ */
116
+ const DEFERRED_RESPONDER_KEY = Symbol.for("nifra.deferred.responder")
117
+ type DeferredResponder = (body: string, status: number, headers: Record<string, string>) => Response
118
+ let deferredResponder: DeferredResponder | undefined
119
+ function deferredResponderFor(): DeferredResponder | undefined {
120
+ if (deferredResponder === undefined) {
121
+ deferredResponder = (globalThis as unknown as Record<symbol, DeferredResponder | undefined>)[
122
+ DEFERRED_RESPONDER_KEY
123
+ ]
124
+ }
125
+ return deferredResponder
126
+ }
127
+
128
+ /**
129
+ * A header record the deferred response can own outright - a fresh object with lowercase names, since
130
+ * the Node writer mutates it in place to declare `content-length`. `undefined` means the caller's
131
+ * shape (a `Headers`, an entries array) is not worth walking here: those take the `Response` path.
132
+ */
133
+ function ownHeaderRecord(
134
+ // `unknown`: the lib's `ResponseInit.headers` is a runtime-specific `HeadersInit` (Bun's vs
135
+ // undici's differ), and this only needs to recognize the plain-record case anyway.
136
+ headers: unknown,
137
+ contentType: string,
138
+ ): Record<string, string> | undefined {
139
+ if (headers === undefined) return { "content-type": contentType }
140
+ if (typeof headers !== "object" || headers === null || Array.isArray(headers)) return undefined
141
+ if (headers instanceof Headers) return undefined
142
+ const record: Record<string, string> = {}
143
+ let hasContentType = false
144
+ for (const name of Object.keys(headers)) {
145
+ const lower = name.toLowerCase()
146
+ if (lower === "content-type") hasContentType = true
147
+ record[lower] = (headers as Record<string, string>)[name] as string
148
+ }
149
+ if (!hasContentType) record["content-type"] = contentType
150
+ return record
151
+ }
152
+
153
+ /** The content-type the runtime's own `Response` puts on a string body, read off it once. A deferred
154
+ * text response fills it in itself, so it must fill in exactly what the `Response` would have. */
155
+ let stringBodyContentType: string | undefined
156
+ function responseTextContentType(): string {
157
+ stringBodyContentType ??=
158
+ new Response("").headers.get("content-type") ?? "text/plain;charset=UTF-8"
159
+ return stringBodyContentType
160
+ }
161
+
162
+ /** `c.text`'s own default, unchanged: the explicit one nifra has always declared when the caller
163
+ * passes no headers of their own. */
164
+ const TEXT_CONTENT_TYPE = "text/plain; charset=utf-8"
165
+
91
166
  export class RequestContext implements RawContext {
92
167
  // `declare` keeps TypeScript's class-field emit from first writing `undefined` to every slot; the
93
168
  // constructor initializes only the eager request state, while lazy fields remain absent until used.
@@ -234,15 +309,53 @@ export class RequestContext implements RawContext {
234
309
  this.headersValue = value
235
310
  }
236
311
 
312
+ /**
313
+ * Return a JSON body. Prefer this (and {@link text}) over a hand-rolled `return new Response(...)`:
314
+ * on Node these helpers hand the adapter a status, a header record, and the bytes directly, so the
315
+ * Web `Response` is deferred and the reply lands on the fastest write lane. A raw `new Response` is
316
+ * fully supported and returns identical bytes, but it is the last-resort lane - the adapter has to
317
+ * drain its body stream, which no direct-write helper pays.
318
+ */
237
319
  json(body: unknown, init?: ResponseInit | number): Response {
238
- return Response.json(body, statusInit(init))
320
+ const i = statusInit(init)
321
+ if (!DEFERS_RESPONSE) return Response.json(body, i)
322
+ const defer = deferredResponderFor()
323
+ if (defer === undefined) return Response.json(body, i)
324
+ // The same bytes `Response.json` would produce, kept one step short of it so the direct writer
325
+ // can have them. `JSON.stringify` returns `undefined` for a value with no JSON form (`undefined`,
326
+ // a function, a symbol) - exactly the case where `Response.json` throws, so hand it back the
327
+ // throw rather than inventing a body. The content-type is the runtime's own, read off
328
+ // `Response.json` once, so a deferred response carries the same one byte for byte.
329
+ const text = JSON.stringify(body) as string | undefined
330
+ if (text === undefined) return Response.json(body, i)
331
+ const headers = ownHeaderRecord(i?.headers, responseJsonContentType())
332
+ if (headers === undefined) return Response.json(body, i)
333
+ return defer(text, i?.status ?? 200, headers)
239
334
  }
240
335
 
336
+ /**
337
+ * Return a text body. The fast lane on Node - see {@link json}. Reach for a raw `return new
338
+ * Response(body)` only when you need a Response shape these helpers do not build; it costs the
339
+ * adapter a body-stream drain that this path skips.
340
+ */
241
341
  text(body: string, init?: ResponseInit | number): Response {
242
342
  const i = statusInit(init)
343
+ if (DEFERS_RESPONSE) {
344
+ const defer = deferredResponderFor()
345
+ if (defer !== undefined) {
346
+ // The content-type each shape would have ended up with: nifra's declared default when the
347
+ // caller brought no headers, the runtime's own string-body default when they did (there the
348
+ // caller owns the content-type, and only fills in for one they did not set).
349
+ const headers = ownHeaderRecord(
350
+ i?.headers,
351
+ i?.headers === undefined ? TEXT_CONTENT_TYPE : responseTextContentType(),
352
+ )
353
+ if (headers !== undefined) return defer(body, i?.status ?? 200, headers)
354
+ }
355
+ }
243
356
  // Default to text/plain; if the caller passes their own headers, they own the content-type.
244
357
  if (i?.headers !== undefined) return new Response(body, i)
245
- return new Response(body, { ...i, headers: { "content-type": "text/plain; charset=utf-8" } })
358
+ return new Response(body, { ...i, headers: { "content-type": TEXT_CONTENT_TYPE } })
246
359
  }
247
360
 
248
361
  get query(): unknown {
@@ -150,7 +150,7 @@ function withStaticHeaderInit(
150
150
  const IS_DENO = typeof (globalThis as { Deno?: unknown }).Deno !== "undefined"
151
151
  let denoJsonContentType: string | undefined
152
152
 
153
- function responseJsonContentType(): string {
153
+ export function responseJsonContentType(): string {
154
154
  if (denoJsonContentType === undefined) {
155
155
  denoJsonContentType = Response.json(0).headers.get("content-type") ?? "application/json"
156
156
  }
@@ -49,7 +49,12 @@ import type {
49
49
  StandardResult,
50
50
  StandardSchemaV1,
51
51
  } from "../schema/standard.ts"
52
- import { assertByteLimit, markTransportCap, markTrustedBodyFraming } from "./body.ts"
52
+ import {
53
+ assertByteLimit,
54
+ markTransportCap,
55
+ markTrustedBodyFraming,
56
+ type RawBodyReaders,
57
+ } from "./body.ts"
53
58
  import { type ClientIpTrust, resolveClientIp } from "./client-ip.ts"
54
59
  import type { Context, Platform, ResponseControls, RouteSchema } from "./context.ts"
55
60
  import {
@@ -285,6 +290,11 @@ export interface RequestSource {
285
290
  /** Present only when materializing a `Request` is non-trivial (the Node lazy source); for a real
286
291
  * `Request` passed as the source it's absent and {@link requestOf} returns the source itself. */
287
292
  readonly request?: Request
293
+ /** Optional pre-`Request` reader surface for the transport cap. A source that reads its own
294
+ * transport bytes (the Node lazy source reads the socket) exposes them here so a capped direct
295
+ * read (`c.req.json()`) buffers off the transport instead of routing through the deferred
296
+ * `Request`. `body` must stay the live stream - the cap still guards a chunked body mid-flight. */
297
+ rawBodyReaders?(): RawBodyReaders
288
298
  }
289
299
 
290
300
  /** The empty context extension. `NonNullable<unknown>` is `{}` without tripping noBannedTypes. */