@nifrajs/core 2.12.0 → 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.
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/range.d.ts.map +1 -1
- package/dist/range.js +4 -2
- package/dist/range.js.map +1 -1
- package/dist/server/body.d.ts +5 -0
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +26 -9
- package/dist/server/body.js.map +1 -1
- package/dist/server/request-context.d.ts +12 -0
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +111 -2
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/respond.d.ts +1 -0
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +1 -1
- package/dist/server/respond.js.map +1 -1
- package/dist/server/server.d.ts +6 -0
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +8 -3
- package/dist/server/server.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +1 -1
- package/src/range.ts +3 -1
- package/src/server/body.ts +25 -8
- package/src/server/request-context.ts +115 -2
- package/src/server/respond.ts +1 -1
- package/src/server/server.ts +18 -3
package/src/server/body.ts
CHANGED
|
@@ -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
|
-
|
|
260
|
-
|
|
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
|
|
357
|
-
//
|
|
358
|
-
if (maxBytes === undefined || TRANSPORT_CAPPED.has(request)
|
|
359
|
-
|
|
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
|
-
|
|
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":
|
|
358
|
+
return new Response(body, { ...i, headers: { "content-type": TEXT_CONTENT_TYPE } })
|
|
246
359
|
}
|
|
247
360
|
|
|
248
361
|
get query(): unknown {
|
package/src/server/respond.ts
CHANGED
|
@@ -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
|
}
|
package/src/server/server.ts
CHANGED
|
@@ -49,7 +49,12 @@ import type {
|
|
|
49
49
|
StandardResult,
|
|
50
50
|
StandardSchemaV1,
|
|
51
51
|
} from "../schema/standard.ts"
|
|
52
|
-
import {
|
|
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. */
|
|
@@ -2841,7 +2851,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2841
2851
|
// never direct-reads pays nothing. Framework readers bypass the shadow (`rawBodySourceOf`) and
|
|
2842
2852
|
// keep their own caps, so `c.boundedBody(explicit)` still overrides upward.
|
|
2843
2853
|
// `bodyLimit: "unlimited"` (undefined here) skips the cap entirely.
|
|
2844
|
-
|
|
2854
|
+
// A schema route consumes and validates the body through `readBodyInput` before any derive or
|
|
2855
|
+
// handler can reach `c.req`; that lane already enforces `entry.bodyLimit`. Installing the full
|
|
2856
|
+
// direct-reader cap on `c.req` here would allocate bound readers, closures, and a stream wrapper
|
|
2857
|
+
// for a body that is already consumed. Keep the lazy transport cap for raw-body routes, where a
|
|
2858
|
+
// user read is the only framework-owned body boundary.
|
|
2859
|
+
if (entry.bodyLimit !== undefined && entry.schema?.body === undefined) {
|
|
2845
2860
|
const method = source.method
|
|
2846
2861
|
if (method !== "GET" && method !== "HEAD") markTransportCap(source, entry.bodyLimit)
|
|
2847
2862
|
}
|
|
@@ -4701,7 +4716,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4701
4716
|
// The fused native lane bypasses `runMatched`, so the route's transport byte cap must be
|
|
4702
4717
|
// marked here too - otherwise a Bun `listen()` fused route would leave direct `c.req` body
|
|
4703
4718
|
// reads uncapped. The non-fused branches go through `fetchMatched` -> `runMatched`, which marks.
|
|
4704
|
-
const bodyLimit = entry.bodyLimit
|
|
4719
|
+
const bodyLimit = entry.schema?.body === undefined ? entry.bodyLimit : undefined
|
|
4705
4720
|
const inner = fused
|
|
4706
4721
|
const capped: FusedWebRunner | undefined =
|
|
4707
4722
|
inner === undefined || bodyLimit === undefined
|