@nifrajs/core 5.5.1 → 5.7.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 +246 -0
- package/README.md +5 -0
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js.map +1 -1
- package/dist/binary.d.ts +15 -1
- package/dist/binary.d.ts.map +1 -1
- package/dist/binary.js +1 -0
- package/dist/binary.js.map +1 -1
- package/dist/edge-kit.d.ts +1 -0
- package/dist/edge-kit.d.ts.map +1 -1
- package/dist/edge-kit.js +1 -0
- package/dist/edge-kit.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/evidence.d.ts +4 -0
- package/dist/evidence.d.ts.map +1 -1
- package/dist/evidence.js +4 -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 +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/hook-origins.d.ts +2 -0
- package/dist/internal/hook-origins.d.ts.map +1 -1
- package/dist/internal/hook-origins.js +12 -0
- package/dist/internal/hook-origins.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 +19 -1
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +46 -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/answer-step.d.ts +27 -0
- package/dist/server/answer-step.d.ts.map +1 -0
- package/dist/server/answer-step.js +33 -0
- package/dist/server/answer-step.js.map +1 -0
- 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/base64-answers.d.ts +5 -0
- package/dist/server/base64-answers.d.ts.map +1 -0
- package/dist/server/base64-answers.js +132 -0
- package/dist/server/base64-answers.js.map +1 -0
- 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.d.ts.map +1 -1
- package/dist/server/core-copies.js +5 -2
- package/dist/server/core-copies.js.map +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/install.d.ts +2 -0
- package/dist/server/install.d.ts.map +1 -1
- package/dist/server/install.js +2 -0
- package/dist/server/install.js.map +1 -1
- package/dist/server/json-response.d.ts +3 -0
- package/dist/server/json-response.d.ts.map +1 -0
- package/dist/server/json-response.js +14 -0
- package/dist/server/json-response.js.map +1 -0
- package/dist/server/json-text.d.ts +16 -0
- package/dist/server/json-text.d.ts.map +1 -0
- package/dist/server/json-text.js +34 -0
- package/dist/server/json-text.js.map +1 -0
- 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/node-outcome.d.ts +5 -0
- package/dist/server/node-outcome.d.ts.map +1 -1
- package/dist/server/node-outcome.js +3 -4
- package/dist/server/node-outcome.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 +5 -5
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/respond.d.ts +3 -4
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +45 -37
- 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 +137 -9
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/route-template.d.ts +10 -0
- package/dist/server/route-template.d.ts.map +1 -0
- package/dist/server/route-template.js +21 -0
- package/dist/server/route-template.js.map +1 -0
- 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 +10 -2
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +17 -8
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +27 -12
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +326 -108
- 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 +7 -5
- package/dist/server/sse.js.map +1 -1
- package/dist/server/text-slots.d.ts +8 -0
- package/dist/server/text-slots.d.ts.map +1 -0
- package/dist/server/text-slots.js +19 -0
- package/dist/server/text-slots.js.map +1 -0
- 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 +2 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +2 -0
- package/dist/server.js.map +1 -1
- package/dist/single-copy.d.ts +5 -0
- package/dist/single-copy.d.ts.map +1 -1
- package/dist/single-copy.js +26 -17
- package/dist/single-copy.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/dist/transport-codec.d.ts.map +1 -1
- package/dist/transport-codec.js +2 -1
- package/dist/transport-codec.js.map +1 -1
- package/package.json +11 -1
- package/src/assurance.ts +1 -0
- package/src/binary.ts +17 -0
- package/src/edge-kit.ts +1 -0
- package/src/errors.ts +1 -0
- package/src/evidence.ts +8 -0
- package/src/file-type.ts +130 -0
- package/src/index.ts +3 -1
- package/src/internal/hook-origins.ts +10 -0
- package/src/internal/route-program.ts +261 -93
- package/src/reflection.ts +71 -7
- package/src/response-observer.ts +6 -0
- package/src/server/answer-step.ts +68 -0
- package/src/server/bare-error-lane.ts +9 -4
- package/src/server/base64-answers.ts +138 -0
- 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 +5 -2
- package/src/server/http.ts +6 -15
- package/src/server/install.ts +3 -0
- package/src/server/json-response.ts +13 -0
- package/src/server/json-text.ts +34 -0
- package/src/server/multipart.ts +232 -100
- package/src/server/node-outcome.ts +4 -5
- 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 +5 -5
- package/src/server/respond.ts +47 -30
- package/src/server/response-contract-lane.ts +132 -9
- package/src/server/route-template.ts +24 -0
- package/src/server/runtime-core.ts +12 -2
- package/src/server/server-types.ts +18 -6
- package/src/server/server.ts +361 -112
- package/src/server/sse.ts +8 -6
- package/src/server/text-slots.ts +21 -0
- 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 +2 -0
- package/src/single-copy.ts +31 -15
- package/src/tool-cli.ts +584 -0
- package/src/transport-codec.ts +3 -1
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { RouteConfigError } from "../errors.ts"
|
|
2
|
+
import type { RouteSchema } from "./context.ts"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A schema's own step around what answers a route, for a schema that changes how the answer it
|
|
6
|
+
* describes is written: `t.bytes()` has every `Uint8Array` leave as base64. It sits on the schema's
|
|
7
|
+
* `validate`, so a spread copy keeps it, and is read once, at registration.
|
|
8
|
+
*/
|
|
9
|
+
const ANSWER_STEP = Symbol.for("nifra.schema.answerStep")
|
|
10
|
+
|
|
11
|
+
/** The part of a request's context a step reads: the `c.json(...)` reply it made, and its value. */
|
|
12
|
+
export interface JsonReplyContext {
|
|
13
|
+
readonly jsonReply?: Response | undefined
|
|
14
|
+
readonly jsonBody?: unknown
|
|
15
|
+
json(body: unknown, init?: ResponseInit): Response
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* What can answer a route: its handler, and each hook whose return value takes the handler's place -
|
|
20
|
+
* a `beforeHandle` that answers first, an `afterHandle` that replaces the answer, an `onError` that
|
|
21
|
+
* answers an error. Each takes the request's context, as a `JsonReplyContext`.
|
|
22
|
+
*/
|
|
23
|
+
export interface AnswerStages {
|
|
24
|
+
readonly handler: (context: never) => unknown
|
|
25
|
+
readonly beforeHandle: readonly ((context: never) => unknown)[]
|
|
26
|
+
readonly afterHandle: readonly ((result: unknown, context: never) => unknown)[]
|
|
27
|
+
readonly onError: readonly ((error: unknown, context: never) => unknown)[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface AnswerStep {
|
|
31
|
+
/** Why the schema cannot describe a request or an event stream, and what to use there instead. */
|
|
32
|
+
readonly misplaced: string
|
|
33
|
+
/** `stages`, each wrapped so what it answers is written the schema's way. */
|
|
34
|
+
readonly wrap: <S extends AnswerStages>(stages: S) => S
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function stepOf(schema: object | undefined): AnswerStep | undefined {
|
|
38
|
+
const validate = schema === undefined ? undefined : Reflect.get(schema, "~standard")?.validate
|
|
39
|
+
return typeof validate === "function" ? Reflect.get(validate, ANSWER_STEP) : undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const REQUEST_FIELDS = ["params", "headers", "cookies", "query", "body", "sse"] as const
|
|
43
|
+
|
|
44
|
+
/** `stages` wrapped by each step its route's `response` and `errors` schemas carry. */
|
|
45
|
+
export function withAnswerSteps<S extends AnswerStages>(
|
|
46
|
+
schema: RouteSchema | undefined,
|
|
47
|
+
method: string,
|
|
48
|
+
path: string,
|
|
49
|
+
stages: S,
|
|
50
|
+
): S {
|
|
51
|
+
if (schema === undefined) return stages
|
|
52
|
+
for (const field of REQUEST_FIELDS) {
|
|
53
|
+
const step = stepOf(schema[field])
|
|
54
|
+
if (step !== undefined) {
|
|
55
|
+
throw new RouteConfigError(
|
|
56
|
+
"RESPONSE_ONLY_SCHEMA",
|
|
57
|
+
`route ${method} ${path}, \`${field}\`: ${step.misplaced}`,
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const steps = new Set<AnswerStep["wrap"]>()
|
|
62
|
+
for (const answer of [schema.response, ...Object.values(schema.errors ?? {})]) {
|
|
63
|
+
const step = stepOf(answer)
|
|
64
|
+
if (step !== undefined) steps.add(step.wrap)
|
|
65
|
+
}
|
|
66
|
+
for (const wrap of steps) stages = wrap(stages)
|
|
67
|
+
return stages
|
|
68
|
+
}
|
|
@@ -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,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bytes in a JSON answer, written as base64.
|
|
3
|
+
*
|
|
4
|
+
* `JSON.stringify` writes a `Uint8Array` as `{"0":1,"1":2,...}`, and nothing about that throws, so
|
|
5
|
+
* finding one costs a walk of every answer. Only a route that asks pays for that walk: `t.bytes()` in
|
|
6
|
+
* its `response` or `errors` has core hand {@link withBase64Bytes} the route's handler and the hooks
|
|
7
|
+
* that can answer in its place, and every `Uint8Array` in what any of them returns or throws as an
|
|
8
|
+
* answer becomes a base64 string before a later hook, contract or writer sees it. Nothing in the
|
|
9
|
+
* server imports this module, so an app that never declares the field never bundles it.
|
|
10
|
+
*/
|
|
11
|
+
import { tagStandIn } from "../internal/hook-origins.ts"
|
|
12
|
+
import type { AnswerStages, JsonReplyContext } from "./answer-step.ts"
|
|
13
|
+
import { isResponseResult, status } from "./runtime-core.ts"
|
|
14
|
+
|
|
15
|
+
// Deeper than any real payload.
|
|
16
|
+
const MAX_DEPTH = 64
|
|
17
|
+
// So `String.fromCharCode(...)` never overflows the call stack.
|
|
18
|
+
const CHUNK = 0x8000
|
|
19
|
+
|
|
20
|
+
function base64(bytes: Uint8Array): string {
|
|
21
|
+
let binary = ""
|
|
22
|
+
for (let i = 0; i < bytes.length; i += CHUNK)
|
|
23
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK))
|
|
24
|
+
return btoa(binary)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function define(out: object, key: string, value: unknown): void {
|
|
28
|
+
// `out[key] = value` for "__proto__" would replace the prototype instead of adding the field.
|
|
29
|
+
Object.defineProperty(out, key, { value, enumerable: true, writable: true, configurable: true })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `value` with each `Uint8Array` as base64, copying only the containers above one. A container already
|
|
34
|
+
* on the walk's path is left as it is, so a cycle costs one step, as it does `JSON.stringify`, which
|
|
35
|
+
* then refuses it.
|
|
36
|
+
*/
|
|
37
|
+
function encoded(value: unknown, depth: number, path: Set<object>): unknown {
|
|
38
|
+
if (value instanceof Uint8Array) return base64(value)
|
|
39
|
+
if (typeof value !== "object" || value === null || depth > MAX_DEPTH || path.has(value))
|
|
40
|
+
return value
|
|
41
|
+
// A `Date` (or any value with a hook) writes itself.
|
|
42
|
+
if (typeof Reflect.get(value, "toJSON") === "function") return value
|
|
43
|
+
path.add(value)
|
|
44
|
+
try {
|
|
45
|
+
if (Array.isArray(value)) {
|
|
46
|
+
let out: unknown[] | undefined
|
|
47
|
+
for (let i = 0; i < value.length; i++) {
|
|
48
|
+
const next = encoded(value[i], depth + 1, path)
|
|
49
|
+
if (next === value[i]) continue
|
|
50
|
+
out ??= [...value]
|
|
51
|
+
out[i] = next
|
|
52
|
+
}
|
|
53
|
+
return out ?? value
|
|
54
|
+
}
|
|
55
|
+
let out: object | undefined
|
|
56
|
+
for (const key of Object.keys(value)) {
|
|
57
|
+
const item: unknown = Reflect.get(value, key)
|
|
58
|
+
const next = encoded(item, depth + 1, path)
|
|
59
|
+
if (next === item) continue
|
|
60
|
+
out ??= { ...value }
|
|
61
|
+
define(out, key, next)
|
|
62
|
+
}
|
|
63
|
+
return out ?? value
|
|
64
|
+
} finally {
|
|
65
|
+
path.delete(value)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const encodedBody = (body: unknown): unknown => encoded(body, 0, new Set())
|
|
70
|
+
|
|
71
|
+
/** An answer with its bytes encoded. A raw `Response` is left alone; a `c.json(...)` reply is rebuilt
|
|
72
|
+
* from the value it was made from, keeping its status and headers. */
|
|
73
|
+
function encodedAnswer(answer: unknown, c: JsonReplyContext): unknown {
|
|
74
|
+
if (answer instanceof Response) {
|
|
75
|
+
if (answer !== c.jsonReply) return answer
|
|
76
|
+
const body = encodedBody(c.jsonBody)
|
|
77
|
+
if (body === c.jsonBody) return answer
|
|
78
|
+
const headers = new Headers(answer.headers)
|
|
79
|
+
headers.delete("content-length")
|
|
80
|
+
return c.json(body, { status: answer.status, headers })
|
|
81
|
+
}
|
|
82
|
+
if (isResponseResult(answer)) {
|
|
83
|
+
const plain = answer.plain
|
|
84
|
+
if (plain === undefined) return answer
|
|
85
|
+
const body = encodedBody(plain.body)
|
|
86
|
+
if (body === plain.body) return answer
|
|
87
|
+
return status(
|
|
88
|
+
plain.status,
|
|
89
|
+
body,
|
|
90
|
+
plain.headers === undefined ? undefined : { headers: plain.headers },
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
return encodedBody(answer)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A thrown answer (`status(...)`, `c.json(...)`) is encoded too; any other throw passes untouched. */
|
|
97
|
+
const encodedThrow = (thrown: unknown, c: JsonReplyContext): unknown =>
|
|
98
|
+
thrown instanceof Response || isResponseResult(thrown) ? encodedAnswer(thrown, c) : thrown
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* `stage` with what it returns or throws as an answer encoded. Its context is argument `at`; an
|
|
102
|
+
* `undefined` return (a hook that lets the route go on) stays `undefined`.
|
|
103
|
+
*/
|
|
104
|
+
function encoding<F extends (...args: never[]) => unknown>(stage: F, at: 0 | 1): F {
|
|
105
|
+
const wrapped = (...args: never[]): unknown => {
|
|
106
|
+
const c: JsonReplyContext = args[at]!
|
|
107
|
+
let result: unknown
|
|
108
|
+
try {
|
|
109
|
+
result = stage(...args)
|
|
110
|
+
} catch (thrown) {
|
|
111
|
+
throw encodedThrow(thrown, c)
|
|
112
|
+
}
|
|
113
|
+
if (!(result instanceof Promise)) return encodedAnswer(result, c)
|
|
114
|
+
return result.then(
|
|
115
|
+
(answer) => encodedAnswer(answer, c),
|
|
116
|
+
(thrown) => {
|
|
117
|
+
throw encodedThrow(thrown, c)
|
|
118
|
+
},
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
// Named as the stage, so stack traces and the hook report still name the author's function.
|
|
122
|
+
Object.defineProperty(wrapped, "name", { value: stage.name })
|
|
123
|
+
tagStandIn(wrapped, stage)
|
|
124
|
+
// biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: the wrapper takes `F`'s arguments and returns `unknown`, which is every stage's own return type.
|
|
125
|
+
return wrapped as F
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A route's handler and answering hooks, each wrapped so every `Uint8Array` it answers leaves as
|
|
129
|
+
* base64. */
|
|
130
|
+
export function withBase64Bytes<S extends AnswerStages>(stages: S): S {
|
|
131
|
+
return {
|
|
132
|
+
...stages,
|
|
133
|
+
handler: encoding(stages.handler, 0),
|
|
134
|
+
beforeHandle: stages.beforeHandle.map((hook) => encoding(hook, 0)),
|
|
135
|
+
afterHandle: stages.afterHandle.map((hook) => encoding(hook, 1)),
|
|
136
|
+
onError: stages.onError.map((hook) => encoding(hook, 1)),
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -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
|
|
@@ -16,14 +16,17 @@ const copies = registry[COPIES]
|
|
|
16
16
|
const here = String(import.meta.url).split("?")[0] as string
|
|
17
17
|
|
|
18
18
|
if (!copies.includes(here) && copies.push(here) > 1) {
|
|
19
|
+
// The registrar marks the process when it installs; without the mark a declared singleCopy was never
|
|
20
|
+
// armed here, most often because bunfig.toml's preload line sits below a [table] header. Bun-only and
|
|
21
|
+
// behind the inline NODE_ENV literal, so a production bundle drops the hint.
|
|
19
22
|
console.warn(
|
|
20
|
-
`[nifra] @nifrajs/core is loaded ${copies.length} times; \`nifra check\` names each importer:\n ${copies.join("\n ")}`,
|
|
23
|
+
`[nifra] @nifrajs/core is loaded ${copies.length} times; \`nifra check\` names each importer:\n ${copies.join("\n ")}${typeof Bun !== "undefined" && process.env.NODE_ENV !== "production" && !(Symbol.for("nifra.single-copy.active") in globalThis) ? "\n No single-copy registrar ran in this process: bunfig.toml must preload @nifrajs/core/single-copy/register at its top level, above any [table], for bun run, and under [test] for bun test." : ""}`,
|
|
21
24
|
)
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
/** This copy's version: a literal like the package root's `VERSION` (no fs on the edge), which
|
|
25
28
|
* `scripts/version.ts` rewrites on every release and `check:publish` asserts. */
|
|
26
|
-
const COPY_VERSION = "5.
|
|
29
|
+
const COPY_VERSION = "5.7.0"
|
|
27
30
|
|
|
28
31
|
/** Record this copy's version beside its URL. Called by each `server()` behind the development guard,
|
|
29
32
|
* 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),
|