@nifrajs/core 5.5.1 → 5.6.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/CHANGELOG.md +153 -0
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js.map +1 -1
- package/dist/evidence.d.ts +2 -0
- package/dist/evidence.d.ts.map +1 -1
- package/dist/evidence.js +2 -0
- package/dist/evidence.js.map +1 -1
- package/dist/file-type.d.ts +30 -0
- package/dist/file-type.d.ts.map +1 -0
- package/dist/file-type.js +118 -0
- package/dist/file-type.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/route-program.d.ts +11 -0
- package/dist/internal/route-program.d.ts.map +1 -1
- package/dist/internal/route-program.js +133 -85
- package/dist/internal/route-program.js.map +1 -1
- package/dist/reflection.d.ts +14 -1
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +34 -7
- package/dist/reflection.js.map +1 -1
- package/dist/response-observer.d.ts +1 -0
- package/dist/response-observer.d.ts.map +1 -1
- package/dist/response-observer.js +1 -0
- package/dist/response-observer.js.map +1 -1
- package/dist/server/bare-error-lane.d.ts +4 -1
- package/dist/server/bare-error-lane.d.ts.map +1 -1
- package/dist/server/bare-error-lane.js +9 -3
- package/dist/server/bare-error-lane.js.map +1 -1
- package/dist/server/before-handler.d.ts +10 -0
- package/dist/server/before-handler.d.ts.map +1 -0
- package/dist/server/before-handler.js +12 -0
- package/dist/server/before-handler.js.map +1 -0
- package/dist/server/body-lane.d.ts.map +1 -1
- package/dist/server/body-lane.js +8 -2
- package/dist/server/body-lane.js.map +1 -1
- package/dist/server/body-parser.d.ts.map +1 -1
- package/dist/server/body-parser.js +2 -5
- package/dist/server/body-parser.js.map +1 -1
- package/dist/server/body.d.ts +24 -8
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +86 -33
- package/dist/server/body.js.map +1 -1
- package/dist/server/core-copies.js +1 -1
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +6 -17
- package/dist/server/http.js.map +1 -1
- package/dist/server/multipart.d.ts +3 -0
- package/dist/server/multipart.d.ts.map +1 -1
- package/dist/server/multipart.js +226 -97
- package/dist/server/multipart.js.map +1 -1
- package/dist/server/proto-guard.d.ts.map +1 -1
- package/dist/server/proto-guard.js +39 -4
- package/dist/server/proto-guard.js.map +1 -1
- package/dist/server/query.d.ts.map +1 -1
- package/dist/server/query.js +8 -14
- package/dist/server/query.js.map +1 -1
- package/dist/server/repeated-prefix.d.ts +17 -0
- package/dist/server/repeated-prefix.d.ts.map +1 -0
- package/dist/server/repeated-prefix.js +17 -0
- package/dist/server/repeated-prefix.js.map +1 -0
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +1 -3
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/respond.d.ts +1 -3
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +21 -16
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +12 -2
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +135 -8
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/runtime-core.d.ts +7 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js +8 -1
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +3 -2
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +6 -2
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +239 -78
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse.d.ts +1 -1
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +5 -4
- package/dist/server/sse.js.map +1 -1
- package/dist/server/webhook.d.ts +1 -1
- package/dist/server/webhook.d.ts.map +1 -1
- package/dist/server/webhook.js +24 -19
- package/dist/server/webhook.js.map +1 -1
- package/dist/server/websocket.d.ts +18 -3
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +45 -5
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-bun.d.ts.map +1 -1
- package/dist/server/ws-bun.js +7 -4
- package/dist/server/ws-bun.js.map +1 -1
- package/dist/server.d.ts +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1 -0
- package/dist/server.js.map +1 -1
- package/dist/tool-cli.d.ts +64 -0
- package/dist/tool-cli.d.ts.map +1 -0
- package/dist/tool-cli.js +495 -0
- package/dist/tool-cli.js.map +1 -0
- package/package.json +11 -1
- package/src/assurance.ts +1 -0
- package/src/evidence.ts +4 -0
- package/src/file-type.ts +130 -0
- package/src/index.ts +2 -1
- package/src/internal/route-program.ts +261 -93
- package/src/reflection.ts +53 -7
- package/src/response-observer.ts +6 -0
- package/src/server/bare-error-lane.ts +9 -4
- package/src/server/before-handler.ts +14 -0
- package/src/server/body-lane.ts +19 -9
- package/src/server/body-parser.ts +1 -5
- package/src/server/body.ts +96 -36
- package/src/server/core-copies.ts +1 -1
- package/src/server/http.ts +6 -15
- package/src/server/multipart.ts +232 -100
- package/src/server/proto-guard.ts +39 -4
- package/src/server/query.ts +7 -13
- package/src/server/repeated-prefix.ts +25 -0
- package/src/server/request-context.ts +1 -3
- package/src/server/respond.ts +23 -15
- package/src/server/response-contract-lane.ts +130 -8
- package/src/server/runtime-core.ts +10 -1
- package/src/server/server-types.ts +3 -2
- package/src/server/server.ts +265 -76
- package/src/server/sse.ts +6 -5
- package/src/server/webhook.ts +29 -24
- package/src/server/websocket.ts +57 -8
- package/src/server/ws-bun.ts +7 -3
- package/src/server.ts +1 -0
- package/src/tool-cli.ts +584 -0
package/src/reflection.ts
CHANGED
|
@@ -54,6 +54,11 @@ export interface SchemaReflection {
|
|
|
54
54
|
readonly standard: StandardSchemaV1 | undefined
|
|
55
55
|
/** Raw JSON Schema metadata, when the value exposes it or is itself a raw JSON Schema. */
|
|
56
56
|
readonly jsonSchema: JsonSchema | undefined
|
|
57
|
+
/**
|
|
58
|
+
* Set when `jsonSchema` came from the schema's Standard JSON Schema converter (`~standard.jsonSchema`)
|
|
59
|
+
* rather than from metadata it carries. Such output may use `$defs` and `$ref`.
|
|
60
|
+
*/
|
|
61
|
+
readonly converted?: true
|
|
57
62
|
/** Top-level object fields, or `undefined` when the JSON Schema is absent/non-object. */
|
|
58
63
|
readonly fields: readonly ReflectedSchemaField[] | undefined
|
|
59
64
|
/**
|
|
@@ -83,8 +88,16 @@ export interface ReflectedRouteSchema {
|
|
|
83
88
|
readonly errors?: Readonly<Record<string, SchemaReflection>>
|
|
84
89
|
/** The SSE event-payload schema of a typed streaming route (`app.sse()`). */
|
|
85
90
|
readonly sse?: SchemaReflection
|
|
91
|
+
/**
|
|
92
|
+
* Set when an `onValidationError` hook (the route's own, or the app-wide default) answers this
|
|
93
|
+
* route's validation failures, so its 422 body is whatever the hook returns.
|
|
94
|
+
*/
|
|
95
|
+
readonly validationErrorHook?: true
|
|
86
96
|
}
|
|
87
97
|
|
|
98
|
+
/** Which side of a schema to describe: the value a request sends, or the value a response carries. */
|
|
99
|
+
export type SchemaSide = "input" | "output"
|
|
100
|
+
|
|
88
101
|
export interface ReflectedRoute {
|
|
89
102
|
readonly method: string
|
|
90
103
|
readonly path: string
|
|
@@ -118,6 +131,22 @@ const standardOf = (value: unknown): StandardSchemaV1 | undefined => {
|
|
|
118
131
|
: undefined
|
|
119
132
|
}
|
|
120
133
|
|
|
134
|
+
/** The JSON Schema a Standard JSON Schema (`~standard.jsonSchema`) converts `side` to, if it has one. */
|
|
135
|
+
const standardJsonSchemaOf = (
|
|
136
|
+
standard: StandardSchemaV1,
|
|
137
|
+
side: SchemaSide,
|
|
138
|
+
): JsonSchema | undefined => {
|
|
139
|
+
const converters = recordOf(recordOf(standard["~standard"])?.jsonSchema)
|
|
140
|
+
const convert = converters?.[side]
|
|
141
|
+
if (typeof convert !== "function") return undefined
|
|
142
|
+
try {
|
|
143
|
+
return normalizeJsonSchema(Reflect.apply(convert, converters, [{ target: "draft-2020-12" }]))
|
|
144
|
+
} catch {
|
|
145
|
+
// A library throws for a type JSON Schema cannot express (zod's `z.date()`); the schema stays opaque.
|
|
146
|
+
return undefined
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
121
150
|
const jsonSchemaOf = (
|
|
122
151
|
value: unknown,
|
|
123
152
|
standard: StandardSchemaV1 | undefined,
|
|
@@ -197,14 +226,21 @@ const mediaTypesOf = (value: unknown): readonly string[] | undefined => {
|
|
|
197
226
|
/**
|
|
198
227
|
* Reflect a Standard Schema, Nifra/TypeBox schema carrier, or raw JSON Schema. Never throws.
|
|
199
228
|
* Validation-only schemas have `standard` but no `jsonSchema`; raw JSON Schema has the reverse.
|
|
229
|
+
* A Standard JSON Schema is read on `side`, since a default or a transform makes the two differ.
|
|
200
230
|
*/
|
|
201
|
-
export function reflectSchema(value: unknown): SchemaReflection {
|
|
231
|
+
export function reflectSchema(value: unknown, side: SchemaSide = "input"): SchemaReflection {
|
|
202
232
|
const standard = standardOf(value)
|
|
203
|
-
const
|
|
233
|
+
const carried = jsonSchemaOf(value, standard)
|
|
234
|
+
const converted =
|
|
235
|
+
carried === undefined && standard !== undefined
|
|
236
|
+
? standardJsonSchemaOf(standard, side)
|
|
237
|
+
: undefined
|
|
238
|
+
const jsonSchema = carried ?? converted
|
|
204
239
|
const mediaTypes = mediaTypesOf(value)
|
|
205
240
|
return {
|
|
206
241
|
standard,
|
|
207
242
|
jsonSchema,
|
|
243
|
+
...(converted !== undefined ? { converted: true as const } : {}),
|
|
208
244
|
fields: fieldsOf(jsonSchema),
|
|
209
245
|
...(mediaTypes !== undefined ? { mediaTypes } : {}),
|
|
210
246
|
}
|
|
@@ -222,14 +258,17 @@ const routeCandidates = (source: unknown): readonly unknown[] => {
|
|
|
222
258
|
}
|
|
223
259
|
}
|
|
224
260
|
|
|
225
|
-
const reflectedRouteSchema = (
|
|
261
|
+
const reflectedRouteSchema = (
|
|
262
|
+
value: unknown,
|
|
263
|
+
appValidationHook: boolean,
|
|
264
|
+
): ReflectedRouteSchema | undefined => {
|
|
226
265
|
const schema = recordOf(value)
|
|
227
266
|
if (schema === undefined) return undefined
|
|
228
267
|
const errors = recordOf(schema.errors)
|
|
229
268
|
const reflectedErrors: Record<string, SchemaReflection> = {}
|
|
230
269
|
if (errors !== undefined) {
|
|
231
270
|
for (const [status, errorSchema] of Object.entries(errors)) {
|
|
232
|
-
reflectedErrors[status] = reflectSchema(errorSchema)
|
|
271
|
+
reflectedErrors[status] = reflectSchema(errorSchema, "output")
|
|
233
272
|
}
|
|
234
273
|
}
|
|
235
274
|
return {
|
|
@@ -249,9 +288,14 @@ const reflectedRouteSchema = (value: unknown): ReflectedRouteSchema | undefined
|
|
|
249
288
|
...(schema.body !== undefined ? { body: reflectSchema(schema.body) } : {}),
|
|
250
289
|
...(schema.query !== undefined ? { query: reflectSchema(schema.query) } : {}),
|
|
251
290
|
...(schema.params !== undefined ? { params: reflectSchema(schema.params) } : {}),
|
|
252
|
-
...(schema.response !== undefined
|
|
291
|
+
...(schema.response !== undefined
|
|
292
|
+
? { response: reflectSchema(schema.response, "output") }
|
|
293
|
+
: {}),
|
|
253
294
|
...(Object.keys(reflectedErrors).length > 0 ? { errors: reflectedErrors } : {}),
|
|
254
|
-
...(schema.sse !== undefined ? { sse: reflectSchema(schema.sse) } : {}),
|
|
295
|
+
...(schema.sse !== undefined ? { sse: reflectSchema(schema.sse, "output") } : {}),
|
|
296
|
+
...(appValidationHook || typeof schema.onValidationError === "function"
|
|
297
|
+
? { validationErrorHook: true as const }
|
|
298
|
+
: {}),
|
|
255
299
|
}
|
|
256
300
|
}
|
|
257
301
|
|
|
@@ -326,10 +370,12 @@ const reflectedSockets = (source: unknown): ReflectedRoute[] => {
|
|
|
326
370
|
*/
|
|
327
371
|
export function reflectRoutes(source: unknown): readonly ReflectedRoute[] {
|
|
328
372
|
const reflected: ReflectedRoute[] = []
|
|
373
|
+
// The app-wide `server({ onValidationError })` lives on the server, not on each route descriptor.
|
|
374
|
+
const appValidationHook = typeof recordOf(source)?.defaultOnValidationError === "function"
|
|
329
375
|
for (const candidate of routeCandidates(source)) {
|
|
330
376
|
const route = recordOf(candidate)
|
|
331
377
|
if (typeof route?.method !== "string" || typeof route.path !== "string") continue
|
|
332
|
-
const schema = reflectedRouteSchema(route.schema)
|
|
378
|
+
const schema = reflectedRouteSchema(route.schema, appValidationHook)
|
|
333
379
|
const tool = reflectedTool(route.tool)
|
|
334
380
|
const assurance = reflectedAssurance(route.assurance)
|
|
335
381
|
const capabilities = normalizeRouteCapabilities(
|
package/src/response-observer.ts
CHANGED
|
@@ -5,6 +5,12 @@ import {
|
|
|
5
5
|
} from "./server/response-observer-runtime.ts"
|
|
6
6
|
import type { AnyServer } from "./server/server.ts"
|
|
7
7
|
|
|
8
|
+
export {
|
|
9
|
+
markRepeatedPrefix,
|
|
10
|
+
type RepeatedPrefix,
|
|
11
|
+
repeatedPrefixOf,
|
|
12
|
+
} from "./server/repeated-prefix.ts"
|
|
13
|
+
|
|
8
14
|
export type {
|
|
9
15
|
NodeResponseBodyHook,
|
|
10
16
|
ResponseBodyHook,
|
|
@@ -17,9 +17,10 @@
|
|
|
17
17
|
* which lane caught it. Both the generic bare lane and the fused body runner route here, so that
|
|
18
18
|
* decision lives in exactly one place.
|
|
19
19
|
*/
|
|
20
|
-
import {
|
|
20
|
+
import { clientBodyErrorCode } from "./body.ts"
|
|
21
|
+
import { pathnameOf, plainError } from "./http.ts"
|
|
21
22
|
import type { Logger } from "./logger.ts"
|
|
22
|
-
import { isResponseResult } from "./runtime-core.ts"
|
|
23
|
+
import { isResponseResult, type ResponseResult } from "./runtime-core.ts"
|
|
23
24
|
import type { CtxSet, RawContext } from "./server.ts"
|
|
24
25
|
|
|
25
26
|
/** How much of a thrown error's own text reaches the log sink. Mirrors `Server`'s option. */
|
|
@@ -31,6 +32,8 @@ export type ErrorLogDetail = "full" | "message" | "none"
|
|
|
31
32
|
* Either renders through the same `finalize` a RETURNED one takes, with the request's `c.set`,
|
|
32
33
|
* so a thrown one means what the return means: a `Response` ships as built, plus the cookies the
|
|
33
34
|
* request queued (the set-session-then-`throw redirect()` pattern);
|
|
35
|
+
* - a request body the app's own `c.req.json()` or `c.req.formData()` could not parse is the
|
|
36
|
+
* client's 400 (`invalid_json`, `invalid_form`);
|
|
34
37
|
* - anything else is a real fault: logged once, then a bodyless 500 (never the caller's error text).
|
|
35
38
|
*
|
|
36
39
|
* `responseSet` and `logError` are injected rather than imported so this file keeps no runtime edge
|
|
@@ -43,11 +46,13 @@ export function renderBareError<T>(
|
|
|
43
46
|
finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
|
|
44
47
|
responseSet: (ctx: RawContext) => CtxSet,
|
|
45
48
|
logError: (err: unknown, ctx: RawContext) => void,
|
|
46
|
-
|
|
49
|
+
wrapResponse: (response: ResponseResult) => T,
|
|
47
50
|
): T {
|
|
48
51
|
if (err instanceof Response || isResponseResult(err)) return finalize(err, responseSet(ctx), ctx)
|
|
52
|
+
const clientCode = clientBodyErrorCode(err)
|
|
53
|
+
if (clientCode !== undefined) return wrapResponse(plainError(400, clientCode))
|
|
49
54
|
logError(err, ctx)
|
|
50
|
-
return
|
|
55
|
+
return wrapResponse(plainError(500, "internal_error"))
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
/**
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Context } from "./context.ts"
|
|
2
|
+
|
|
3
|
+
/** Hooks {@link beforeHandler} marked; the kernel keeps them after guards registered later. */
|
|
4
|
+
export const LAST_BEFORE_HANDLE = new WeakSet<object>()
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Mark a `beforeHandle` hook to run just before the handler: a guard registered after it in the same
|
|
8
|
+
* scope still goes ahead of it, so it only sees requests every guard admitted. A hook that answers
|
|
9
|
+
* from stored state (an idempotency replay) needs this, or it would answer ahead of a later guard.
|
|
10
|
+
*/
|
|
11
|
+
export function beforeHandler<H extends (context: Context) => unknown>(hook: H): H {
|
|
12
|
+
LAST_BEFORE_HANDLE.add(hook)
|
|
13
|
+
return hook
|
|
14
|
+
}
|
package/src/server/body-lane.ts
CHANGED
|
@@ -30,6 +30,10 @@ type FramedJsonSource = RequestSource & {
|
|
|
30
30
|
|
|
31
31
|
const INVALID_JSON = (): ResponseResult => plainError(400, "invalid_json")
|
|
32
32
|
|
|
33
|
+
// `Deno.serve`'s own `Request.json()` keeps a leading UTF-8 byte order mark that its `text()` and every
|
|
34
|
+
// other runtime's `json()` drop, so it refuses a body they parse. Deno decodes the bytes itself below.
|
|
35
|
+
const nativeJsonKeepsBom = "Deno" in globalThis
|
|
36
|
+
|
|
33
37
|
/** Read a JSON source with the declared-length precheck, delivered-byte recheck, streaming cap, and
|
|
34
38
|
* prototype-poisoning guard in one lane. */
|
|
35
39
|
export function readBoundedJsonSource(
|
|
@@ -72,7 +76,7 @@ export function readBoundedJsonSource<T>(
|
|
|
72
76
|
? Promise.resolve(plainError(413, "payload_too_large"))
|
|
73
77
|
: Promise.resolve(plainError(413, "payload_too_large")).then(onResult)
|
|
74
78
|
|
|
75
|
-
if (hasTrustedBodyFraming(req)) {
|
|
79
|
+
if (hasTrustedBodyFraming(req) && !nativeJsonKeepsBom) {
|
|
76
80
|
return raw.json().then(
|
|
77
81
|
(parsed) => {
|
|
78
82
|
try {
|
|
@@ -156,17 +160,23 @@ export function readBoundedJsonSource<T>(
|
|
|
156
160
|
}
|
|
157
161
|
|
|
158
162
|
const body = raw.body
|
|
163
|
+
// A body off a client's connection that fails mid-read is refused as this lane's other failures
|
|
164
|
+
// are; one built in this process, or a stream the app already holds, keeps its error.
|
|
165
|
+
const refuseFailedRead = body !== null && !body.locked && hasTrustedBodyFraming(req)
|
|
159
166
|
const streamed =
|
|
160
167
|
body === null
|
|
161
168
|
? Promise.resolve<unknown | ResponseResult>(plainError(400, "invalid_json"))
|
|
162
|
-
: drainCapped(body, maxBytes).then(
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
169
|
+
: drainCapped(body, maxBytes).then(
|
|
170
|
+
(drained) => {
|
|
171
|
+
if (!drained.ok) return plainError(413, "payload_too_large")
|
|
172
|
+
try {
|
|
173
|
+
return parseJsonGuarded(TEXT_DECODER.decode(drained.bytes), protoPoisoning)
|
|
174
|
+
} catch {
|
|
175
|
+
return plainError(400, "invalid_json")
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
refuseFailedRead ? INVALID_JSON : undefined,
|
|
179
|
+
)
|
|
170
180
|
return onResult === undefined ? streamed : streamed.then(onResult, onError)
|
|
171
181
|
}
|
|
172
182
|
|
|
@@ -210,11 +210,7 @@ export function bodyParser<Schema extends StandardSchemaV1>(
|
|
|
210
210
|
: inner(source, contentType, maxBodyBytes, protoPoisoning)
|
|
211
211
|
}
|
|
212
212
|
const body = await readBoundedBytes(source, maxBodyBytes)
|
|
213
|
-
if (!body.ok)
|
|
214
|
-
return body.status === 413
|
|
215
|
-
? plainError(413, "payload_too_large")
|
|
216
|
-
: plainError(400, "invalid_content_length")
|
|
217
|
-
}
|
|
213
|
+
if (!body.ok) return plainError(body.status, body.error)
|
|
218
214
|
try {
|
|
219
215
|
const value = await parse(body.bytes, { mediaType, contentType })
|
|
220
216
|
settle(value, protoPoisoning)
|
package/src/server/body.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { jsonError } from "./http.ts"
|
|
|
12
12
|
interface BodySource {
|
|
13
13
|
readonly headers: Pick<Headers, "get">
|
|
14
14
|
readonly body: ReadableStream<Uint8Array> | null
|
|
15
|
+
readonly bodyUsed?: boolean
|
|
15
16
|
arrayBuffer(): Promise<ArrayBuffer>
|
|
16
17
|
bytes?(): Promise<Uint8Array>
|
|
17
18
|
}
|
|
@@ -20,6 +21,7 @@ interface BodySource {
|
|
|
20
21
|
export interface RawBodyReaders {
|
|
21
22
|
readonly headers: Pick<Headers, "get">
|
|
22
23
|
readonly body: ReadableStream<Uint8Array> | null
|
|
24
|
+
readonly bodyUsed?: boolean
|
|
23
25
|
arrayBuffer(): Promise<ArrayBuffer>
|
|
24
26
|
/** Runtime-native exact-byte reader, when the request implementation exposes one. */
|
|
25
27
|
bytes?(): Promise<Uint8Array>
|
|
@@ -65,13 +67,26 @@ export function parseContentLength(value: string): number | undefined {
|
|
|
65
67
|
return length
|
|
66
68
|
}
|
|
67
69
|
|
|
70
|
+
/** Why a bounded read refused a body: the status and the error code it is answered with. */
|
|
71
|
+
export type BoundedReadFailure =
|
|
72
|
+
| { ok: false; status: 413; error: "payload_too_large" }
|
|
73
|
+
| { ok: false; status: 400; error: "invalid_content_length" | "invalid_body" }
|
|
74
|
+
|
|
75
|
+
const TOO_LARGE: { ok: false; status: 413; error: "payload_too_large" } = {
|
|
76
|
+
ok: false,
|
|
77
|
+
status: 413,
|
|
78
|
+
error: "payload_too_large",
|
|
79
|
+
}
|
|
80
|
+
const BAD_LENGTH: BoundedReadFailure = { ok: false, status: 400, error: "invalid_content_length" }
|
|
81
|
+
const UNREADABLE: BoundedReadFailure = { ok: false, status: 400, error: "invalid_body" }
|
|
82
|
+
|
|
68
83
|
/** The shared streaming byte-cap loop: read until done, or cancel + 413 once over `maxBytes`.
|
|
69
84
|
* A single-chunk body (the common case for small chunked payloads) returns the runtime's own
|
|
70
85
|
* chunk directly - no chunk array, no copy-merge. */
|
|
71
86
|
export async function drainCapped(
|
|
72
87
|
body: ReadableStream<Uint8Array>,
|
|
73
88
|
maxBytes: number,
|
|
74
|
-
): Promise<{ ok: true; bytes: Uint8Array } |
|
|
89
|
+
): Promise<{ ok: true; bytes: Uint8Array } | typeof TOO_LARGE> {
|
|
75
90
|
assertByteLimit(maxBytes)
|
|
76
91
|
const reader = body.getReader()
|
|
77
92
|
let first: Uint8Array | undefined
|
|
@@ -85,7 +100,7 @@ export async function drainCapped(
|
|
|
85
100
|
// Not awaited: on a teed body (a `clone()` taken first) the cancel settles only once the other
|
|
86
101
|
// branch is read or cancelled too, and nothing here ever reads it.
|
|
87
102
|
reader.cancel().catch(() => {})
|
|
88
|
-
return
|
|
103
|
+
return TOO_LARGE
|
|
89
104
|
}
|
|
90
105
|
if (first === undefined) {
|
|
91
106
|
first = value
|
|
@@ -112,38 +127,55 @@ export async function drainCapped(
|
|
|
112
127
|
* `Content-Length` within the cap is read via native `arrayBuffer()`, then the **real** byte count
|
|
113
128
|
* is checked against the declared length - a source that delivers more than it declared (a lying
|
|
114
129
|
* or upstream-decoding adapter) is rejected with `413` even though its header passed the hint.
|
|
130
|
+
* A body a runtime took off a client's connection that fails while it is read - a client that hung
|
|
131
|
+
* up inside it, which only Deno hands an app - is refused `400` `invalid_body`. A body built in this
|
|
132
|
+
* process keeps its producer's error, and a reader the app spent or locked keeps its throw.
|
|
115
133
|
*/
|
|
116
134
|
export async function readBoundedBytes(
|
|
117
135
|
source: BodySource,
|
|
118
136
|
maxBytes: number,
|
|
119
|
-
): Promise<{ ok: true; bytes: Uint8Array } |
|
|
137
|
+
): Promise<{ ok: true; bytes: Uint8Array } | BoundedReadFailure> {
|
|
120
138
|
assertByteLimit(maxBytes)
|
|
121
139
|
const req = rawBodySourceOf(source)
|
|
122
140
|
const declared = req.headers.get("content-length")
|
|
141
|
+
let length: number | undefined
|
|
123
142
|
if (declared !== null) {
|
|
124
143
|
// A present Content-Length must be a non-negative integer (HTTP grammar: `1*DIGIT`). A non-numeric
|
|
125
144
|
// / negative / fractional value is malformed → 400, rather than falling through to the streaming
|
|
126
145
|
// guard (an UPPER-bound cap only, so a lying SMALLER length would otherwise be read in full).
|
|
127
|
-
|
|
128
|
-
if (length === undefined) return
|
|
129
|
-
if (length > maxBytes) return
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
146
|
+
length = parseContentLength(declared)
|
|
147
|
+
if (length === undefined) return BAD_LENGTH
|
|
148
|
+
if (length > maxBytes) return TOO_LARGE
|
|
149
|
+
}
|
|
150
|
+
const fromClient = hasTrustedBodyFraming(source) || hasTrustedBodyFraming(req)
|
|
151
|
+
if (length !== undefined && req.headers.get("transfer-encoding") === null) {
|
|
152
|
+
// Called outside the `try`: a spent reader throws synchronously, and that throw is the app's.
|
|
153
|
+
// A Request the app already read or locked rejects instead. Only a read that disturbed an unused
|
|
154
|
+
// body failed on the client's side; Bun marks a locked body used, Node and Deno leave it unused.
|
|
155
|
+
const usedBefore = req.bodyUsed
|
|
156
|
+
const bytesReader = req.bytes
|
|
157
|
+
const reading =
|
|
158
|
+
bytesReader === undefined
|
|
159
|
+
? req.arrayBuffer().then((buffer) => new Uint8Array(buffer))
|
|
160
|
+
: bytesReader.call(req)
|
|
161
|
+
let bytes: Uint8Array
|
|
162
|
+
try {
|
|
163
|
+
bytes = await reading
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (fromClient && usedBefore !== true && req.bodyUsed !== false) return UNREADABLE
|
|
166
|
+
throw error
|
|
142
167
|
}
|
|
168
|
+
// Content-Length was the fast-reject hint, never the enforcement: a lying or buffering
|
|
169
|
+
// source (an adapter that decoded/expanded the body upstream) can hand over more bytes
|
|
170
|
+
// than it declared. `length <= maxBytes` held above, so one comparison seals the cap.
|
|
171
|
+
if (bytes.byteLength > length) return TOO_LARGE
|
|
172
|
+
return { ok: true, bytes }
|
|
143
173
|
}
|
|
144
174
|
const body = req.body
|
|
145
175
|
if (body === null) return { ok: true, bytes: new Uint8Array(0) }
|
|
146
|
-
|
|
176
|
+
const lockedByApp = body.locked
|
|
177
|
+
const draining = drainCapped(body, maxBytes)
|
|
178
|
+
return fromClient && !lockedByApp ? draining.catch(() => UNREADABLE) : draining
|
|
147
179
|
}
|
|
148
180
|
|
|
149
181
|
/** Key under which a transport-capped request stashes its {@link RawBodyReaders}. */
|
|
@@ -184,14 +216,37 @@ const TRANSPORT_CAPPED = new WeakSet<object>()
|
|
|
184
216
|
|
|
185
217
|
const CAP_DECODER = new TextDecoder()
|
|
186
218
|
|
|
219
|
+
// The error a capped `json()` or `formData()` raised on the request's own bytes, kept as the
|
|
220
|
+
// `Request` reader rejects so app code that catches it is unchanged, with the code it answers. One
|
|
221
|
+
// the app lets escape is the client's 400.
|
|
222
|
+
const CLIENT_BODY_ERRORS = new WeakMap<object, "invalid_json" | "invalid_form">()
|
|
223
|
+
|
|
224
|
+
/** The 400 code for a request body a capped reader could not parse, or `undefined` for any other error. */
|
|
225
|
+
export const clientBodyErrorCode = (err: unknown): "invalid_json" | "invalid_form" | undefined =>
|
|
226
|
+
err instanceof Error ? CLIENT_BODY_ERRORS.get(err) : undefined
|
|
227
|
+
|
|
228
|
+
function parseClientJson(bytes: Uint8Array): unknown {
|
|
229
|
+
try {
|
|
230
|
+
return JSON.parse(CAP_DECODER.decode(bytes))
|
|
231
|
+
} catch (error) {
|
|
232
|
+
if (error instanceof Error) CLIENT_BODY_ERRORS.set(error, "invalid_json")
|
|
233
|
+
throw error
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function clientFormError(error: unknown): never {
|
|
238
|
+
if (error instanceof Error) CLIENT_BODY_ERRORS.set(error, "invalid_form")
|
|
239
|
+
throw error
|
|
240
|
+
}
|
|
241
|
+
|
|
187
242
|
/** Key under which the dispatcher marks a source with its route's transport byte cap. */
|
|
188
243
|
const TRANSPORT_CAP = Symbol("nifra.body.transportCap")
|
|
189
244
|
|
|
190
245
|
/** The transport cap's over-cap rejection: the flat error a shadowed direct reader throws (and the
|
|
191
246
|
* capped `body` stream errors with). The lifecycle already treats a thrown `Response` as control
|
|
192
247
|
* flow (the `c.boundedBody` contract), so an over-cap direct read answers 413 like every other cap. */
|
|
193
|
-
function transportCapError(
|
|
194
|
-
return jsonError(status,
|
|
248
|
+
function transportCapError(failure: BoundedReadFailure): Response {
|
|
249
|
+
return jsonError(failure.status, failure.error)
|
|
195
250
|
}
|
|
196
251
|
|
|
197
252
|
/**
|
|
@@ -211,11 +266,11 @@ export function capBodyBytes(request: Request, maxBytes: number): Request {
|
|
|
211
266
|
new TransformStream<Uint8Array, Uint8Array>({
|
|
212
267
|
start(controller) {
|
|
213
268
|
if (length === undefined || length > maxBytes)
|
|
214
|
-
controller.error(transportCapError(length === undefined ?
|
|
269
|
+
controller.error(transportCapError(length === undefined ? BAD_LENGTH : TOO_LARGE))
|
|
215
270
|
},
|
|
216
271
|
transform(chunk, controller) {
|
|
217
272
|
total += chunk.byteLength
|
|
218
|
-
if (total > maxBytes) controller.error(transportCapError(
|
|
273
|
+
if (total > maxBytes) controller.error(transportCapError(TOO_LARGE))
|
|
219
274
|
else controller.enqueue(chunk)
|
|
220
275
|
},
|
|
221
276
|
}),
|
|
@@ -279,11 +334,11 @@ function spendRaw(raw: RawBodyReaders): void {
|
|
|
279
334
|
Object.defineProperty(raw, "body", { get: spent })
|
|
280
335
|
}
|
|
281
336
|
|
|
282
|
-
/** A pass-through stream that errors (with `reject
|
|
337
|
+
/** A pass-through stream that errors (with `reject` of a 413) once more than `maxBytes` flow through. */
|
|
283
338
|
function cappedStreamOf(
|
|
284
339
|
body: RawStream,
|
|
285
340
|
maxBytes: number,
|
|
286
|
-
reject: (
|
|
341
|
+
reject: (failure: BoundedReadFailure) => unknown,
|
|
287
342
|
): ReadableStream<Uint8Array> {
|
|
288
343
|
// The reader is acquired on first pull, and `highWaterMark: 0` defers that first pull until a
|
|
289
344
|
// consumer actually reads (the default of 1 pulls eagerly at construction): merely *accessing*
|
|
@@ -312,7 +367,7 @@ function cappedStreamOf(
|
|
|
312
367
|
// Not awaited, as in drainCapped: on a teed body the cancel settles only once the other
|
|
313
368
|
// branch is read or cancelled too, and the 413 must not wait for an unread clone.
|
|
314
369
|
active.cancel().catch(() => {})
|
|
315
|
-
controller.error(reject(
|
|
370
|
+
controller.error(reject(TOO_LARGE))
|
|
316
371
|
return
|
|
317
372
|
}
|
|
318
373
|
controller.enqueue(value)
|
|
@@ -332,8 +387,9 @@ function cappedStreamOf(
|
|
|
332
387
|
* pre-decoded body), and hook-observed references all survive. Nothing is read eagerly: the cap
|
|
333
388
|
* only pays when user code actually reads, and framework readers bypass it through
|
|
334
389
|
* {@link rawBodySourceOf} (stashed under {@link RAW_BODY_READERS}) to enforce their own caps.
|
|
335
|
-
* An over-cap read rejects with
|
|
336
|
-
* the lifecycle already treats as control flow -
|
|
390
|
+
* An over-cap read rejects with the cap's flat 413 (a lying `Content-Length` with its 400, a body
|
|
391
|
+
* that fails mid-read with 400 `invalid_body`), which the lifecycle already treats as control flow -
|
|
392
|
+
* the same contract as `c.boundedBody`.
|
|
337
393
|
*/
|
|
338
394
|
/** The request's own pre-shadow readers - the fallback surface when the source offers none. */
|
|
339
395
|
function requestBodyReaders(request: Request): RawBodyReaders {
|
|
@@ -349,6 +405,9 @@ function requestBodyReaders(request: Request): RawBodyReaders {
|
|
|
349
405
|
: (bodyDescriptor?.value ?? null)
|
|
350
406
|
) as ReadableStream<Uint8Array> | null
|
|
351
407
|
},
|
|
408
|
+
get bodyUsed() {
|
|
409
|
+
return request.bodyUsed
|
|
410
|
+
},
|
|
352
411
|
arrayBuffer: request.arrayBuffer.bind(request),
|
|
353
412
|
json: request.json.bind(request),
|
|
354
413
|
}
|
|
@@ -361,13 +420,15 @@ function capRequestBodyReads(request: Request, maxBytes: number, preset?: RawBod
|
|
|
361
420
|
TRANSPORT_CAPPED.add(request)
|
|
362
421
|
const reject = transportCapError
|
|
363
422
|
const raw = preset ?? requestBodyReaders(request)
|
|
423
|
+
// The shadowed readers read through `raw`, which must still say where the body came from.
|
|
424
|
+
if (hasTrustedBodyFraming(request)) markTrustedBodyFraming(raw)
|
|
364
425
|
// One bounded buffer backs every shadowed reader; a second read replays it instead of failing
|
|
365
426
|
// with "body already used" (a strict superset of the native one-shot contract).
|
|
366
427
|
let buffered: Promise<Uint8Array> | undefined
|
|
367
428
|
const cappedBytes = (): Promise<Uint8Array> => {
|
|
368
429
|
if (buffered === undefined) {
|
|
369
430
|
const read = readBoundedBytes(raw, maxBytes).then((result) => {
|
|
370
|
-
if (!result.ok) throw reject(result
|
|
431
|
+
if (!result.ok) throw reject(result)
|
|
371
432
|
return result.bytes
|
|
372
433
|
})
|
|
373
434
|
buffered = read
|
|
@@ -401,8 +462,7 @@ function capRequestBodyReads(request: Request, maxBytes: number, preset?: RawBod
|
|
|
401
462
|
shadowed[RAW_BODY_READERS] = raw
|
|
402
463
|
shadowed.arrayBuffer = () => cappedBytes().then(exactArrayBuffer)
|
|
403
464
|
shadowed.bytes = () => cappedBytes().then((bytes) => bytes.slice())
|
|
404
|
-
shadowed.json = () =>
|
|
405
|
-
cappedBytes().then((bytes) => JSON.parse(CAP_DECODER.decode(bytes)) as unknown)
|
|
465
|
+
shadowed.json = () => cappedBytes().then(parseClientJson)
|
|
406
466
|
shadowed.text = () => cappedBytes().then((bytes) => CAP_DECODER.decode(bytes))
|
|
407
467
|
shadowed.blob = () =>
|
|
408
468
|
cappedBytes().then(
|
|
@@ -415,7 +475,9 @@ function capRequestBodyReads(request: Request, maxBytes: number, preset?: RawBod
|
|
|
415
475
|
cappedBytes().then((bytes) =>
|
|
416
476
|
new Response(exactArrayBuffer(bytes), {
|
|
417
477
|
headers: { "content-type": request.headers.get("content-type") ?? "" },
|
|
418
|
-
})
|
|
478
|
+
})
|
|
479
|
+
.formData()
|
|
480
|
+
.catch(clientFormError),
|
|
419
481
|
)
|
|
420
482
|
// `clone()` must not mint an uncapped escape hatch: the clone gets the same cap.
|
|
421
483
|
if (typeof request.clone === "function") {
|
|
@@ -489,10 +551,8 @@ export function applyTransportCap(source: object, request: Request): void {
|
|
|
489
551
|
if (maxBytes === undefined || TRANSPORT_CAPPED.has(request)) return
|
|
490
552
|
const provide = (source as { rawBodyReaders?: () => RawBodyReaders }).rawBodyReaders
|
|
491
553
|
const preset = provide === undefined ? undefined : provide.call(source)
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
// readers exist to avoid. Capping a bodiless request costs nothing but the shadowing itself.
|
|
495
|
-
if (preset === undefined && request.body === null) return
|
|
554
|
+
// A bodiless request is capped too: its `json()` must fail the way a capped one does, as a client
|
|
555
|
+
// input error, and Bun.serve hands over `Content-Length: 0` with no body where others give one.
|
|
496
556
|
capRequestBodyReads(request, maxBytes, preset)
|
|
497
557
|
// A wrapper source (Node's lazy source) delegates its own body readers to the request once it
|
|
498
558
|
// materializes - which are now the shadowed ones. Mirror the raw-reader stash onto the source so
|
|
@@ -23,7 +23,7 @@ if (!copies.includes(here) && copies.push(here) > 1) {
|
|
|
23
23
|
|
|
24
24
|
/** This copy's version: a literal like the package root's `VERSION` (no fs on the edge), which
|
|
25
25
|
* `scripts/version.ts` rewrites on every release and `check:publish` asserts. */
|
|
26
|
-
const COPY_VERSION = "5.
|
|
26
|
+
const COPY_VERSION = "5.6.0"
|
|
27
27
|
|
|
28
28
|
/** Record this copy's version beside its URL. Called by each `server()` behind the development guard,
|
|
29
29
|
* so a production bundle drops it with the version; a copy that built a server has recorded one. */
|
package/src/server/http.ts
CHANGED
|
@@ -114,26 +114,17 @@ export interface UrlParts {
|
|
|
114
114
|
// Extract pathname + query WITHOUT a full WHATWG `new URL(req.url)` parse.
|
|
115
115
|
// `req.url` from every supported runtime is an absolute, already-normalized URL, so the pathname is
|
|
116
116
|
// the substring after `scheme://host[:port]` up to `?`/`#`. Query-schema routes also need the search
|
|
117
|
-
// string;
|
|
117
|
+
// string; splitting both together avoids the old `pathnameOf()` + `searchOf()` repeated parsing.
|
|
118
118
|
export function urlPartsOf(url: string): UrlParts {
|
|
119
119
|
const schemeEnd = url.indexOf("://")
|
|
120
120
|
const start = schemeEnd === -1 ? url.indexOf("/") : url.indexOf("/", schemeEnd + 3)
|
|
121
121
|
if (start === -1) return { pathname: "/", search: "" }
|
|
122
122
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (c === 63 /* ? */ && searchStart === -1) {
|
|
129
|
-
pathEnd = i
|
|
130
|
-
searchStart = i
|
|
131
|
-
} else if (c === 35 /* # */) {
|
|
132
|
-
if (searchStart === -1) pathEnd = i
|
|
133
|
-
searchEnd = i
|
|
134
|
-
break
|
|
135
|
-
}
|
|
136
|
-
}
|
|
123
|
+
const fragment = url.indexOf("#", start)
|
|
124
|
+
const searchEnd = fragment === -1 ? url.length : fragment
|
|
125
|
+
const question = url.indexOf("?", start)
|
|
126
|
+
const searchStart = question !== -1 && question < searchEnd ? question : -1
|
|
127
|
+
const pathEnd = searchStart === -1 ? searchEnd : searchStart
|
|
137
128
|
|
|
138
129
|
return {
|
|
139
130
|
pathname: url.slice(start, pathEnd),
|