@nifrajs/core 3.3.0 → 4.0.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/README.md +26 -0
- package/dist/assurance.d.ts +7 -0
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +39 -3
- package/dist/assurance.js.map +1 -1
- package/dist/capabilities.d.ts +20 -2
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +47 -15
- package/dist/capabilities.js.map +1 -1
- package/dist/diff.d.ts +6 -1
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +36 -2
- package/dist/diff.js.map +1 -1
- package/dist/durable-adapters.d.ts.map +1 -1
- package/dist/durable-adapters.js +16 -3
- package/dist/durable-adapters.js.map +1 -1
- package/dist/durable-effect.d.ts.map +1 -1
- package/dist/durable-effect.js +6 -3
- package/dist/durable-effect.js.map +1 -1
- package/dist/edge-kit.d.ts +2 -0
- package/dist/edge-kit.d.ts.map +1 -1
- package/dist/edge-kit.js +2 -0
- package/dist/edge-kit.js.map +1 -1
- package/dist/effect-lifecycle.d.ts.map +1 -1
- package/dist/effect-lifecycle.js +2 -1
- package/dist/effect-lifecycle.js.map +1 -1
- package/dist/effect-scope.d.ts.map +1 -1
- package/dist/effect-scope.js +1 -1
- package/dist/effect-scope.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 +36 -1
- package/dist/evidence.d.ts.map +1 -1
- package/dist/evidence.js +219 -13
- package/dist/evidence.js.map +1 -1
- package/dist/idempotency.d.ts +31 -0
- package/dist/idempotency.d.ts.map +1 -1
- package/dist/idempotency.js +48 -9
- package/dist/idempotency.js.map +1 -1
- package/dist/index.d.ts +9 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/internal/code-unit-order.d.ts +7 -0
- package/dist/internal/code-unit-order.d.ts.map +1 -0
- package/dist/internal/code-unit-order.js +9 -0
- package/dist/internal/code-unit-order.js.map +1 -0
- package/dist/internal/durable-saga-protocol.d.ts.map +1 -1
- package/dist/internal/durable-saga-protocol.js +2 -0
- package/dist/internal/durable-saga-protocol.js.map +1 -1
- package/dist/internal/route-catalog.d.ts +6 -6
- package/dist/internal/route-catalog.d.ts.map +1 -1
- package/dist/internal/route-catalog.js +20 -18
- package/dist/internal/route-catalog.js.map +1 -1
- package/dist/internal/route-compiler.d.ts +7 -6
- package/dist/internal/route-compiler.d.ts.map +1 -1
- package/dist/internal/route-compiler.js +36 -4
- package/dist/internal/route-compiler.js.map +1 -1
- package/dist/internal/route-execution.d.ts +7 -3
- package/dist/internal/route-execution.d.ts.map +1 -1
- package/dist/internal/route-execution.js.map +1 -1
- package/dist/internal/route-lanes.d.ts +1 -0
- package/dist/internal/route-lanes.d.ts.map +1 -1
- package/dist/internal/route-lanes.js +34 -29
- package/dist/internal/route-lanes.js.map +1 -1
- package/dist/internal/route-program.d.ts +8 -2
- package/dist/internal/route-program.d.ts.map +1 -1
- package/dist/internal/route-program.js +222 -20
- package/dist/internal/route-program.js.map +1 -1
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +8 -5
- package/dist/ledger.js.map +1 -1
- package/dist/manifest.d.ts +1 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +52 -46
- package/dist/manifest.js.map +1 -1
- package/dist/mount.d.ts +36 -0
- package/dist/mount.d.ts.map +1 -1
- package/dist/mount.js +32 -0
- package/dist/mount.js.map +1 -1
- package/dist/reflection.d.ts +26 -0
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +65 -1
- package/dist/reflection.js.map +1 -1
- package/dist/response-observer.d.ts +1 -1
- package/dist/response-observer.d.ts.map +1 -1
- package/dist/response-observer.js.map +1 -1
- package/dist/router/overlap.d.ts +23 -0
- package/dist/router/overlap.d.ts.map +1 -0
- package/dist/router/overlap.js +224 -0
- package/dist/router/overlap.js.map +1 -0
- package/dist/router/pattern.d.ts +117 -2
- package/dist/router/pattern.d.ts.map +1 -1
- package/dist/router/pattern.js +283 -23
- package/dist/router/pattern.js.map +1 -1
- package/dist/router/router.d.ts +18 -4
- package/dist/router/router.d.ts.map +1 -1
- package/dist/router/router.js +51 -44
- package/dist/router/router.js.map +1 -1
- package/dist/server/auth.d.ts +53 -0
- package/dist/server/auth.d.ts.map +1 -0
- package/dist/server/auth.js +9 -0
- package/dist/server/auth.js.map +1 -0
- package/dist/server/bare-error-lane.d.ts +12 -7
- package/dist/server/bare-error-lane.d.ts.map +1 -1
- package/dist/server/bare-error-lane.js +13 -11
- package/dist/server/bare-error-lane.js.map +1 -1
- package/dist/server/body-lane.d.ts +20 -2
- package/dist/server/body-lane.d.ts.map +1 -1
- package/dist/server/body-lane.js +26 -7
- package/dist/server/body-lane.js.map +1 -1
- package/dist/server/body-parser.d.ts +68 -0
- package/dist/server/body-parser.d.ts.map +1 -0
- package/dist/server/body-parser.js +172 -0
- package/dist/server/body-parser.js.map +1 -0
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +62 -7
- package/dist/server/body.js.map +1 -1
- package/dist/server/client-ip.d.ts +8 -0
- package/dist/server/client-ip.d.ts.map +1 -1
- package/dist/server/client-ip.js +8 -0
- package/dist/server/client-ip.js.map +1 -1
- package/dist/server/context.d.ts +71 -6
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/contract.d.ts +15 -5
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js +25 -4
- package/dist/server/contract.js.map +1 -1
- package/dist/server/cookies.d.ts +4 -1
- package/dist/server/cookies.d.ts.map +1 -1
- package/dist/server/cookies.js +40 -2
- package/dist/server/cookies.js.map +1 -1
- package/dist/server/core-copies.d.ts +2 -0
- package/dist/server/core-copies.d.ts.map +1 -0
- package/dist/server/core-copies.js +20 -0
- package/dist/server/core-copies.js.map +1 -0
- package/dist/server/edge.d.ts.map +1 -1
- package/dist/server/edge.js +2 -0
- package/dist/server/edge.js.map +1 -1
- package/dist/server/http.d.ts +11 -0
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +36 -0
- package/dist/server/http.js.map +1 -1
- package/dist/server/idempotency-lane.d.ts +6 -0
- package/dist/server/idempotency-lane.d.ts.map +1 -1
- package/dist/server/idempotency-lane.js +130 -73
- package/dist/server/idempotency-lane.js.map +1 -1
- package/dist/server/install.d.ts +4 -0
- package/dist/server/install.d.ts.map +1 -1
- package/dist/server/install.js +4 -0
- package/dist/server/install.js.map +1 -1
- package/dist/server/methods.d.ts +45 -0
- package/dist/server/methods.d.ts.map +1 -0
- package/dist/server/methods.js +37 -0
- package/dist/server/methods.js.map +1 -0
- package/dist/server/multipart.d.ts +63 -0
- package/dist/server/multipart.d.ts.map +1 -0
- package/dist/server/multipart.js +374 -0
- package/dist/server/multipart.js.map +1 -0
- package/dist/server/node-outcome-hook.d.ts +20 -0
- package/dist/server/node-outcome-hook.d.ts.map +1 -1
- package/dist/server/node-outcome-hook.js +15 -6
- package/dist/server/node-outcome-hook.js.map +1 -1
- package/dist/server/not-found-answer.d.ts +40 -0
- package/dist/server/not-found-answer.d.ts.map +1 -0
- package/dist/server/not-found-answer.js +56 -0
- package/dist/server/not-found-answer.js.map +1 -0
- package/dist/server/not-found.d.ts +23 -0
- package/dist/server/not-found.d.ts.map +1 -0
- package/dist/server/not-found.js +81 -0
- package/dist/server/not-found.js.map +1 -0
- package/dist/server/plugin.d.ts +26 -1
- package/dist/server/plugin.d.ts.map +1 -1
- package/dist/server/plugin.js.map +1 -1
- package/dist/server/proto-guard.d.ts +18 -5
- package/dist/server/proto-guard.d.ts.map +1 -1
- package/dist/server/proto-guard.js +114 -9
- package/dist/server/proto-guard.js.map +1 -1
- package/dist/server/query.d.ts +7 -0
- package/dist/server/query.d.ts.map +1 -1
- package/dist/server/query.js +47 -0
- package/dist/server/query.js.map +1 -1
- package/dist/server/registry.d.ts +13 -2
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +11 -0
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +33 -19
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/request-lineage.d.ts +8 -0
- package/dist/server/request-lineage.d.ts.map +1 -0
- package/dist/server/request-lineage.js +14 -0
- package/dist/server/request-lineage.js.map +1 -0
- package/dist/server/request-target.d.ts +31 -0
- package/dist/server/request-target.d.ts.map +1 -0
- package/dist/server/request-target.js +44 -0
- package/dist/server/request-target.js.map +1 -0
- package/dist/server/respond.d.ts +1 -1
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +16 -4
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +19 -1
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +59 -14
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/response-observer-runtime.d.ts +9 -5
- package/dist/server/response-observer-runtime.d.ts.map +1 -1
- package/dist/server/response-observer-runtime.js +10 -6
- package/dist/server/response-observer-runtime.js.map +1 -1
- package/dist/server/runtime-core.d.ts +2 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +55 -3
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +194 -19
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +1342 -274
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +10 -7
- package/dist/server/sse.js.map +1 -1
- package/dist/server/static-headers.d.ts +3 -0
- package/dist/server/static-headers.d.ts.map +1 -1
- package/dist/server/static-headers.js +19 -1
- package/dist/server/static-headers.js.map +1 -1
- package/dist/server/transport-lane.d.ts +8 -0
- package/dist/server/transport-lane.d.ts.map +1 -1
- package/dist/server/transport-lane.js +9 -8
- package/dist/server/transport-lane.js.map +1 -1
- package/dist/server/validation.d.ts.map +1 -1
- package/dist/server/validation.js +5 -1
- package/dist/server/validation.js.map +1 -1
- package/dist/server/websocket.d.ts +10 -3
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +7 -4
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-bun.d.ts +2 -0
- package/dist/server/ws-bun.d.ts.map +1 -1
- package/dist/server/ws-bun.js +6 -3
- package/dist/server/ws-bun.js.map +1 -1
- package/dist/server/ws-hook.d.ts +2 -1
- package/dist/server/ws-hook.d.ts.map +1 -1
- package/dist/server.d.ts +8 -5
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +5 -2
- package/dist/server.js.map +1 -1
- package/dist/single-copy-register.js +5 -0
- package/dist/single-copy-register.js.map +1 -1
- package/dist/single-copy.d.ts +46 -3
- package/dist/single-copy.d.ts.map +1 -1
- package/dist/single-copy.js +125 -12
- package/dist/single-copy.js.map +1 -1
- package/dist/tool-contract.d.ts +23 -0
- package/dist/tool-contract.d.ts.map +1 -1
- package/dist/tool-contract.js +37 -3
- package/dist/tool-contract.js.map +1 -1
- 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 +27 -2
- package/src/assurance.ts +52 -2
- package/src/capabilities.ts +81 -22
- package/src/diff.ts +54 -7
- package/src/durable-adapters.ts +20 -3
- package/src/durable-effect.ts +10 -3
- package/src/edge-kit.ts +7 -0
- package/src/effect-lifecycle.ts +5 -1
- package/src/effect-scope.ts +2 -1
- package/src/errors.ts +3 -0
- package/src/evidence.ts +302 -12
- package/src/idempotency.ts +80 -9
- package/src/index.ts +41 -3
- package/src/internal/code-unit-order.ts +8 -0
- package/src/internal/durable-saga-protocol.ts +2 -0
- package/src/internal/route-catalog.ts +19 -18
- package/src/internal/route-compiler.ts +62 -13
- package/src/internal/route-execution.ts +13 -3
- package/src/internal/route-lanes.ts +37 -28
- package/src/internal/route-program.ts +434 -20
- package/src/ledger.ts +11 -5
- package/src/manifest.ts +52 -46
- package/src/mount.ts +62 -0
- package/src/reflection.ts +84 -1
- package/src/response-observer.ts +1 -0
- package/src/router/overlap.ts +258 -0
- package/src/router/pattern.ts +349 -25
- package/src/router/router.ts +76 -51
- package/src/server/auth.ts +75 -0
- package/src/server/bare-error-lane.ts +12 -11
- package/src/server/body-lane.ts +46 -6
- package/src/server/body-parser.ts +241 -0
- package/src/server/body.ts +82 -9
- package/src/server/client-ip.ts +9 -0
- package/src/server/context.ts +219 -12
- package/src/server/contract.ts +54 -9
- package/src/server/cookies.ts +34 -2
- package/src/server/core-copies.ts +21 -0
- package/src/server/edge.ts +2 -0
- package/src/server/http.ts +34 -0
- package/src/server/idempotency-lane.ts +147 -72
- package/src/server/install.ts +6 -0
- package/src/server/methods.ts +143 -0
- package/src/server/multipart.ts +424 -0
- package/src/server/node-outcome-hook.ts +40 -7
- package/src/server/not-found-answer.ts +109 -0
- package/src/server/not-found.ts +121 -0
- package/src/server/plugin.ts +45 -1
- package/src/server/proto-guard.ts +128 -9
- package/src/server/query.ts +49 -0
- package/src/server/registry.ts +17 -2
- package/src/server/request-context.ts +44 -16
- package/src/server/request-lineage.ts +15 -0
- package/src/server/request-target.ts +46 -0
- package/src/server/respond.ts +14 -3
- package/src/server/response-contract-lane.ts +97 -16
- package/src/server/response-observer-runtime.ts +37 -8
- package/src/server/runtime-core.ts +2 -0
- package/src/server/server-types.ts +61 -2
- package/src/server/server.ts +1798 -355
- package/src/server/sse.ts +10 -8
- package/src/server/static-headers.ts +22 -1
- package/src/server/transport-lane.ts +17 -9
- package/src/server/validation.ts +7 -1
- package/src/server/websocket.ts +23 -9
- package/src/server/ws-bun.ts +8 -3
- package/src/server/ws-hook.ts +2 -1
- package/src/server.ts +39 -2
- package/src/single-copy-register.ts +5 -0
- package/src/single-copy.ts +160 -12
- package/src/tool-contract.ts +66 -3
- package/src/transport-codec.ts +2 -1
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { ResponseResult } from "./runtime-core.ts"
|
|
2
|
+
|
|
3
|
+
/** A promise-or-value return used by authentication providers. */
|
|
4
|
+
export type AuthMaybePromise<T> = T | Promise<T>
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The deliberately narrow header surface given to an authentication provider.
|
|
8
|
+
*
|
|
9
|
+
* It is not a `Headers` object: providers can inspect credentials, but cannot mutate the request
|
|
10
|
+
* headers or reach the request body through this value. The server constructs the view per request.
|
|
11
|
+
*/
|
|
12
|
+
export interface AuthHeaders {
|
|
13
|
+
readonly get: (name: string) => string | null
|
|
14
|
+
readonly has: (name: string) => boolean
|
|
15
|
+
readonly forEach: (callback: (value: string, name: string) => void) => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The authentication trust boundary. There is intentionally no `Request`, `body`, or parsed query
|
|
20
|
+
* here. Route parameters are present only for selecting an identity policy and remain untrusted
|
|
21
|
+
* strings until the normal route validation stage completes.
|
|
22
|
+
*/
|
|
23
|
+
export interface AuthenticationInput<Env = unknown> {
|
|
24
|
+
readonly method: string
|
|
25
|
+
readonly pathname: string
|
|
26
|
+
readonly headers: AuthHeaders
|
|
27
|
+
readonly params: Readonly<Record<string, string>>
|
|
28
|
+
readonly env: Env
|
|
29
|
+
readonly clientIp: string | undefined
|
|
30
|
+
readonly signal: AbortSignal
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type AuthenticationFailureReason = "unauthenticated" | "forbidden" | "unavailable"
|
|
34
|
+
|
|
35
|
+
export interface AuthenticationSuccess<Principal> {
|
|
36
|
+
readonly kind: "authenticated"
|
|
37
|
+
readonly principal: Principal
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface AuthenticationFailure {
|
|
41
|
+
readonly kind: "rejected"
|
|
42
|
+
readonly reason?: AuthenticationFailureReason
|
|
43
|
+
/** An integration may provide a redirect or protocol-specific challenge response. */
|
|
44
|
+
readonly response?: Response | ResponseResult
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type AuthenticationResult<Principal> =
|
|
48
|
+
| AuthenticationSuccess<Principal>
|
|
49
|
+
| AuthenticationFailure
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A dedicated authentication stage. Stages are captured by route scope at registration time and
|
|
53
|
+
* execute before input validation on required protected routes.
|
|
54
|
+
*/
|
|
55
|
+
export interface AuthenticationStage<Principal = unknown, Env = unknown> {
|
|
56
|
+
readonly id: string
|
|
57
|
+
/** Declares the common path so the compiler can preserve a synchronous protected lane. */
|
|
58
|
+
readonly mode?: "sync" | "async"
|
|
59
|
+
readonly run: (
|
|
60
|
+
input: AuthenticationInput<Env>,
|
|
61
|
+
) => AuthMaybePromise<AuthenticationResult<Principal>>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function authenticated<Principal>(principal: Principal): AuthenticationSuccess<Principal> {
|
|
65
|
+
return { kind: "authenticated", principal }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function rejected(
|
|
69
|
+
reason: AuthenticationFailureReason = "unauthenticated",
|
|
70
|
+
response?: Response | ResponseResult,
|
|
71
|
+
): AuthenticationFailure {
|
|
72
|
+
return response === undefined
|
|
73
|
+
? { kind: "rejected", reason }
|
|
74
|
+
: { kind: "rejected", reason, response }
|
|
75
|
+
}
|
|
@@ -19,17 +19,18 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { pathnameOf } from "./http.ts"
|
|
21
21
|
import type { Logger } from "./logger.ts"
|
|
22
|
-
import { isResponseResult
|
|
22
|
+
import { isResponseResult } from "./runtime-core.ts"
|
|
23
23
|
import type { CtxSet, RawContext } from "./server.ts"
|
|
24
24
|
|
|
25
25
|
/** How much of a thrown error's own text reaches the log sink. Mirrors `Server`'s option. */
|
|
26
26
|
export type ErrorLogDetail = "full" | "message" | "none"
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
* Render a value thrown on the bare lane.
|
|
30
|
-
* - a
|
|
31
|
-
*
|
|
32
|
-
* a
|
|
29
|
+
* Render a value thrown on the bare lane. Two shapes, in the order the lane means them:
|
|
30
|
+
* - a `Response` or a `status(...)` is control flow - the app answering directly, or plain data.
|
|
31
|
+
* Either renders through the same `finalize` a RETURNED one takes, with the request's `c.set`,
|
|
32
|
+
* so a thrown one means what the return means: a `Response` ships as built, plus the cookies the
|
|
33
|
+
* request queued (the set-session-then-`throw redirect()` pattern);
|
|
33
34
|
* - anything else is a real fault: logged once, then a bodyless 500 (never the caller's error text).
|
|
34
35
|
*
|
|
35
36
|
* `responseSet` and `logError` are injected rather than imported so this file keeps no runtime edge
|
|
@@ -40,13 +41,11 @@ export function renderBareError<T>(
|
|
|
40
41
|
err: unknown,
|
|
41
42
|
ctx: RawContext,
|
|
42
43
|
finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
|
|
43
|
-
wrapResponse: (response: Response | ResponseResult) => T,
|
|
44
44
|
responseSet: (ctx: RawContext) => CtxSet,
|
|
45
45
|
logError: (err: unknown, ctx: RawContext) => void,
|
|
46
46
|
internalError: () => T,
|
|
47
47
|
): T {
|
|
48
|
-
if (err instanceof Response) return
|
|
49
|
-
if (isResponseResult(err)) return finalize(err, responseSet(ctx), ctx)
|
|
48
|
+
if (err instanceof Response || isResponseResult(err)) return finalize(err, responseSet(ctx), ctx)
|
|
50
49
|
logError(err, ctx)
|
|
51
50
|
return internalError()
|
|
52
51
|
}
|
|
@@ -62,11 +61,13 @@ export function emitRequestErrorLog(
|
|
|
62
61
|
logger: Logger,
|
|
63
62
|
detail: ErrorLogDetail,
|
|
64
63
|
err: unknown,
|
|
65
|
-
|
|
64
|
+
// Only the request line is read, so a lane with no context yet (a request no route matched) can
|
|
65
|
+
// log through the same policy.
|
|
66
|
+
// Absent for a failure the runtime reports without its request (Bun's serve `error` callback).
|
|
67
|
+
ctx: { readonly req: { readonly method: string; readonly url: string } } | undefined,
|
|
66
68
|
): void {
|
|
67
69
|
logger.error("unhandled request error", {
|
|
68
|
-
method: ctx.req.method,
|
|
69
|
-
path: pathnameOf(ctx.req.url),
|
|
70
|
+
...(ctx === undefined ? {} : { method: ctx.req.method, path: pathnameOf(ctx.req.url) }),
|
|
70
71
|
name: err instanceof Error ? err.name : "Error",
|
|
71
72
|
// `detail`, not `message`: the logger uses `message` for its own first argument, so a field of
|
|
72
73
|
// that name is silently overwritten and the thrown error's own text never reaches the sink. It
|
package/src/server/body-lane.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { drainCapped, hasTrustedBodyFraming, parseContentLength, RAW_BODY_READERS } from "./body.ts"
|
|
7
7
|
import { plainError } from "./http.ts"
|
|
8
8
|
import { guardParsedValue, type ProtoPoisoning, parseJsonGuarded } from "./proto-guard.ts"
|
|
9
|
-
import { isUrlEncodedForm, readBoundedForm } from "./query.ts"
|
|
9
|
+
import { isJsonMediaType, isUrlEncodedForm, readBoundedForm } from "./query.ts"
|
|
10
10
|
import {
|
|
11
11
|
headerOf,
|
|
12
12
|
isResponseResult,
|
|
@@ -53,9 +53,9 @@ export function readBoundedJsonSource<T>(
|
|
|
53
53
|
): Promise<unknown | ResponseResult | T> {
|
|
54
54
|
const preDecoded = (req as { [PRE_DECODED_BODY]?: PreDecodedBody })[PRE_DECODED_BODY]
|
|
55
55
|
if (preDecoded !== undefined) {
|
|
56
|
-
|
|
57
|
-
?
|
|
58
|
-
|
|
56
|
+
const value =
|
|
57
|
+
preDecoded.byteLength > maxBytes ? plainError(413, "payload_too_large") : preDecoded.value
|
|
58
|
+
return onResult === undefined ? Promise.resolve(value) : Promise.resolve(value).then(onResult)
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
const raw = ((req as { [RAW_BODY_READERS]?: RequestSource })[RAW_BODY_READERS] ??
|
|
@@ -170,7 +170,32 @@ export function readBoundedJsonSource<T>(
|
|
|
170
170
|
return onResult === undefined ? streamed : streamed.then(onResult, onError)
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
-
/**
|
|
173
|
+
/**
|
|
174
|
+
* Brand under which a body schema carries its own reader for a media type the built-in lanes do not
|
|
175
|
+
* parse. A registered symbol, so a schema branded by one copy of this module is read by another.
|
|
176
|
+
*/
|
|
177
|
+
export const SCHEMA_BODY_READER: unique symbol = Symbol.for("nifra.body.schemaReader")
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* A schema-carried body reader. It receives the request's own `content-type`, the route's byte cap,
|
|
181
|
+
* and the app's prototype-poisoning policy, and owns all three: it answers the value to validate, or
|
|
182
|
+
* the rejection to send (a `415` for a media type it does not read).
|
|
183
|
+
*/
|
|
184
|
+
export type SchemaBodyReader = ((
|
|
185
|
+
source: RequestSource,
|
|
186
|
+
contentType: string,
|
|
187
|
+
maxBodyBytes: number,
|
|
188
|
+
protoPoisoning: ProtoPoisoning,
|
|
189
|
+
) => Promise<unknown | ResponseResult>) & {
|
|
190
|
+
/** The media types a reader declares it reads, for the documents generated from a route. */
|
|
191
|
+
readonly mediaTypes?: readonly string[]
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The shared content-type dispatcher around the JSON and urlencoded lanes. A `bodySchema` branded
|
|
196
|
+
* with {@link SCHEMA_BODY_READER} takes every other media type; JSON and urlencoded bodies keep
|
|
197
|
+
* their lanes whatever the schema carries.
|
|
198
|
+
*/
|
|
174
199
|
export function readBodyFramed<T>(
|
|
175
200
|
source: RequestSource,
|
|
176
201
|
maxBodyBytes: number,
|
|
@@ -178,15 +203,30 @@ export function readBodyFramed<T>(
|
|
|
178
203
|
onParsed: (parsed: unknown) => MaybePromise<T>,
|
|
179
204
|
wrapResponse: (response: Response | ResponseResult) => T,
|
|
180
205
|
onError: (err: unknown) => MaybePromise<T>,
|
|
206
|
+
bodySchema?: object,
|
|
181
207
|
): Promise<T> {
|
|
182
208
|
const contentType = headerOf(source, "content-type") ?? ""
|
|
183
|
-
if (
|
|
209
|
+
if (!isJsonMediaType(contentType)) {
|
|
184
210
|
if (isUrlEncodedForm(contentType)) {
|
|
185
211
|
return readBoundedForm(source, maxBodyBytes).then(
|
|
186
212
|
(form) => (isResponseResult(form) ? wrapResponse(form) : onParsed(form)),
|
|
187
213
|
onError,
|
|
188
214
|
) as Promise<T>
|
|
189
215
|
}
|
|
216
|
+
const reader =
|
|
217
|
+
bodySchema === undefined
|
|
218
|
+
? undefined
|
|
219
|
+
: (bodySchema as { readonly [SCHEMA_BODY_READER]?: SchemaBodyReader })[SCHEMA_BODY_READER]
|
|
220
|
+
if (typeof reader === "function") {
|
|
221
|
+
try {
|
|
222
|
+
return reader(source, contentType, maxBodyBytes, protoPoisoning).then(
|
|
223
|
+
(parsed) => (isResponseResult(parsed) ? wrapResponse(parsed) : onParsed(parsed)),
|
|
224
|
+
onError,
|
|
225
|
+
) as Promise<T>
|
|
226
|
+
} catch (err) {
|
|
227
|
+
return Promise.resolve(onError(err))
|
|
228
|
+
}
|
|
229
|
+
}
|
|
190
230
|
return Promise.resolve(wrapResponse(plainError(415, "unsupported_media_type")))
|
|
191
231
|
}
|
|
192
232
|
try {
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request bodies in a media type of your own - `bodyParser(schema, { types, parse })`.
|
|
3
|
+
*
|
|
4
|
+
* A route reads JSON and urlencoded bodies by default and answers `415` to everything else. Wrapping
|
|
5
|
+
* its body schema names the other media types that one route reads and the function that decodes
|
|
6
|
+
* them. The decoded value is then validated by the schema like any other body.
|
|
7
|
+
*
|
|
8
|
+
* import { bodyParser } from "@nifrajs/core/body-parser"
|
|
9
|
+
* import { parse } from "yaml"
|
|
10
|
+
*
|
|
11
|
+
* const utf8 = new TextDecoder("utf-8", { fatal: true })
|
|
12
|
+
*
|
|
13
|
+
* app.post(
|
|
14
|
+
* "/pipelines",
|
|
15
|
+
* {
|
|
16
|
+
* body: bodyParser(Pipeline, {
|
|
17
|
+
* types: ["application/yaml"],
|
|
18
|
+
* parse: (bytes) => parse(utf8.decode(bytes), { maxAliasCount: 0 }),
|
|
19
|
+
* }),
|
|
20
|
+
* },
|
|
21
|
+
* (c) => c.body.name,
|
|
22
|
+
* )
|
|
23
|
+
*
|
|
24
|
+
* What is settled before `parse` runs:
|
|
25
|
+
* - the request's media type is one of `types`: compared whole, case folded, parameters set aside
|
|
26
|
+
* - the body is at most the route's `bodyLimit`, measured on the bytes delivered and not on the
|
|
27
|
+
* header, and it is fully read: `parse` is handed bytes and never the request
|
|
28
|
+
*
|
|
29
|
+
* What is settled before the schema runs:
|
|
30
|
+
* - a `parse` that throws or rejects answers `400 invalid_body`
|
|
31
|
+
* - the value is a tree. An array or object reached twice is what an alias or a cycle decodes to,
|
|
32
|
+
* and walking it can cost far more than the bytes that described it, so it answers `400`
|
|
33
|
+
* - the app's prototype-poisoning policy: a `__proto__` key, a `constructor` that carries a
|
|
34
|
+
* `prototype`, and an object whose prototype was replaced by decoded data
|
|
35
|
+
*
|
|
36
|
+
* What it does not: a parser's own limits. Nesting depth, alias expansion, and number or string
|
|
37
|
+
* sizes belong to the decoder you pass, so configure it for untrusted input.
|
|
38
|
+
*
|
|
39
|
+
* `types` cannot name a media type that already has a reader (JSON, urlencoded, multipart) or
|
|
40
|
+
* `text/plain`. A browser sends `text/plain` from any site with the user's cookies and no preflight,
|
|
41
|
+
* so a route that parsed it would be reachable by a form no CORS policy ever saw. Read such a body
|
|
42
|
+
* in the handler with `c.boundedBody`, behind an explicit origin check.
|
|
43
|
+
*/
|
|
44
|
+
import type { StandardSchemaV1 } from "../schema/standard.ts"
|
|
45
|
+
import { readBoundedBytes } from "./body.ts"
|
|
46
|
+
import { SCHEMA_BODY_READER, type SchemaBodyReader } from "./body-lane.ts"
|
|
47
|
+
import { plainError } from "./http.ts"
|
|
48
|
+
import type { ProtoPoisoning } from "./proto-guard.ts"
|
|
49
|
+
import { isJsonMediaType } from "./query.ts"
|
|
50
|
+
|
|
51
|
+
/** What `parse` is told about the body it is decoding. */
|
|
52
|
+
export interface BodyParserInput {
|
|
53
|
+
/** The media type that matched, lower-cased and without parameters. */
|
|
54
|
+
readonly mediaType: string
|
|
55
|
+
/** The request's `content-type` as sent, for a parser that reads a parameter such as `charset`. */
|
|
56
|
+
readonly contentType: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface BodyParserOptions {
|
|
60
|
+
/** The media types this parser reads, each a full `type/subtype` with no parameters or wildcard. */
|
|
61
|
+
readonly types: readonly string[]
|
|
62
|
+
/** Decode the body. The result is what the schema validates; throw to refuse the body with `400`. */
|
|
63
|
+
readonly parse: (bytes: Uint8Array, input: BodyParserInput) => unknown
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// RFC 6838 `restricted-name "/" restricted-name`, lower-cased.
|
|
67
|
+
const MEDIA_TYPE = /^[a-z0-9][a-z0-9!#$&^_.+-]{0,126}\/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}$/
|
|
68
|
+
|
|
69
|
+
const REFUSED = new Error("invalid_body")
|
|
70
|
+
|
|
71
|
+
function mediaTypesOf(types: readonly string[]): readonly string[] {
|
|
72
|
+
if (!Array.isArray(types) || types.length === 0) {
|
|
73
|
+
throw new TypeError("bodyParser: types must name at least one media type")
|
|
74
|
+
}
|
|
75
|
+
const seen = new Set<string>()
|
|
76
|
+
for (const value of types as readonly unknown[]) {
|
|
77
|
+
if (typeof value !== "string") throw new TypeError("bodyParser: a media type must be a string")
|
|
78
|
+
const type = value.toLowerCase()
|
|
79
|
+
if (!MEDIA_TYPE.test(type)) {
|
|
80
|
+
throw new TypeError(
|
|
81
|
+
`bodyParser: "${value}" is not a media type. Name it in full, such as "application/yaml", with no parameters and no wildcard`,
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
if (isJsonMediaType(type)) {
|
|
85
|
+
throw new TypeError(`bodyParser: "${value}" is JSON, which every body schema already reads`)
|
|
86
|
+
}
|
|
87
|
+
if (type === "application/x-www-form-urlencoded") {
|
|
88
|
+
throw new TypeError(`bodyParser: "${value}" is a form, which every body schema already reads`)
|
|
89
|
+
}
|
|
90
|
+
if (type.startsWith("multipart/")) {
|
|
91
|
+
throw new TypeError(
|
|
92
|
+
`bodyParser: "${value}" is a multipart body. Use multipartBody from "@nifrajs/core/multipart"`,
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
if (type === "text/plain") {
|
|
96
|
+
throw new TypeError(
|
|
97
|
+
`bodyParser: "${value}" is sent by a browser from any site without a preflight. Read it in the handler with c.boundedBody, behind an origin check`,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
seen.add(type)
|
|
101
|
+
}
|
|
102
|
+
return Object.freeze([...seen])
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Whether `proto` is the prototype of a class, as opposed to an object decoded from the body. */
|
|
106
|
+
function isClassPrototype(proto: object): boolean {
|
|
107
|
+
const own = Object.getOwnPropertyDescriptor(proto, "constructor")?.value as unknown
|
|
108
|
+
return typeof own === "function" && (own as { prototype?: unknown }).prototype === proto
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* One pass over a decoded value. It throws for a value that is not a tree, and applies the
|
|
113
|
+
* prototype-poisoning policy to every plain object in it. Iterative, so nesting cannot exhaust the
|
|
114
|
+
* call stack, and each node is visited once, so the cost is the size of what the parser built.
|
|
115
|
+
*/
|
|
116
|
+
function settle(root: unknown, policy: ProtoPoisoning): void {
|
|
117
|
+
const seen = new Set<object>()
|
|
118
|
+
const stack: object[] = []
|
|
119
|
+
const push = (value: unknown): void => {
|
|
120
|
+
if (value === null || typeof value !== "object") return
|
|
121
|
+
if (!Array.isArray(value) && !(value instanceof Map) && !(value instanceof Set)) {
|
|
122
|
+
const proto = Object.getPrototypeOf(value) as object | null
|
|
123
|
+
if (proto !== null && proto !== Object.prototype) {
|
|
124
|
+
if (!isClassPrototype(proto)) {
|
|
125
|
+
// A plain object whose prototype is now data: a decoder that assigns keys one by one
|
|
126
|
+
// turns a `__proto__` key into exactly this, and every property of that data then reads
|
|
127
|
+
// as the object's own.
|
|
128
|
+
if (policy === "reject") throw REFUSED
|
|
129
|
+
if (policy === "strip") Object.setPrototypeOf(value, Object.prototype)
|
|
130
|
+
} else if (Object.getPrototypeOf(proto) !== null) {
|
|
131
|
+
// An instance of a class (a Date, a typed array) is a leaf: it has no keys a body chose.
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
// A class prototype with nothing above it is the `Object.prototype` of another realm, and
|
|
135
|
+
// the object is as plain as one made here.
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (seen.has(value)) throw REFUSED
|
|
139
|
+
seen.add(value)
|
|
140
|
+
stack.push(value)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
push(root)
|
|
144
|
+
while (stack.length > 0) {
|
|
145
|
+
const node = stack.pop() as object
|
|
146
|
+
if (Array.isArray(node)) {
|
|
147
|
+
for (let i = 0; i < node.length; i++) push(node[i])
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
if (node instanceof Map) {
|
|
151
|
+
for (const [key, value] of node) {
|
|
152
|
+
push(key)
|
|
153
|
+
push(value)
|
|
154
|
+
}
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
if (node instanceof Set) {
|
|
158
|
+
for (const value of node) push(value)
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
const record = node as Record<string, unknown>
|
|
162
|
+
for (const key of Object.keys(record)) {
|
|
163
|
+
const value = record[key]
|
|
164
|
+
if (policy !== "ignore") {
|
|
165
|
+
const poisoned =
|
|
166
|
+
key === "__proto__" ||
|
|
167
|
+
(key === "constructor" &&
|
|
168
|
+
value !== null &&
|
|
169
|
+
typeof value === "object" &&
|
|
170
|
+
Object.hasOwn(value, "prototype"))
|
|
171
|
+
if (poisoned) {
|
|
172
|
+
if (policy === "reject") throw REFUSED
|
|
173
|
+
delete record[key]
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
push(value)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Opt a route's body schema into media types of your own. Returns a copy of `schema` that also reads
|
|
184
|
+
* a body whose media type is one of `types`, decoded by `parse`; the schema itself is not changed,
|
|
185
|
+
* and JSON and urlencoded bodies still reach it. A schema that already reads another type, such as a
|
|
186
|
+
* form, keeps reading it.
|
|
187
|
+
*
|
|
188
|
+
* Throws a `TypeError` for a `types` entry that is not a full media type, or that names JSON,
|
|
189
|
+
* urlencoded, multipart, or `text/plain`.
|
|
190
|
+
*/
|
|
191
|
+
export function bodyParser<Schema extends StandardSchemaV1>(
|
|
192
|
+
schema: Schema,
|
|
193
|
+
options: BodyParserOptions,
|
|
194
|
+
): Schema {
|
|
195
|
+
const types = mediaTypesOf(options.types)
|
|
196
|
+
const parse = options.parse
|
|
197
|
+
if (typeof parse !== "function") throw new TypeError("bodyParser: parse must be a function")
|
|
198
|
+
const accepts = new Set(types)
|
|
199
|
+
const carried = (schema as { readonly [SCHEMA_BODY_READER]?: SchemaBodyReader })[
|
|
200
|
+
SCHEMA_BODY_READER
|
|
201
|
+
]
|
|
202
|
+
const inner = typeof carried === "function" ? carried : undefined
|
|
203
|
+
|
|
204
|
+
const read: SchemaBodyReader = async (source, contentType, maxBodyBytes, protoPoisoning) => {
|
|
205
|
+
const semi = contentType.indexOf(";")
|
|
206
|
+
const mediaType = (semi === -1 ? contentType : contentType.slice(0, semi)).trim().toLowerCase()
|
|
207
|
+
if (!accepts.has(mediaType)) {
|
|
208
|
+
return inner === undefined
|
|
209
|
+
? plainError(415, "unsupported_media_type")
|
|
210
|
+
: inner(source, contentType, maxBodyBytes, protoPoisoning)
|
|
211
|
+
}
|
|
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
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
const value = await parse(body.bytes, { mediaType, contentType })
|
|
220
|
+
settle(value, protoPoisoning)
|
|
221
|
+
return value
|
|
222
|
+
} catch {
|
|
223
|
+
return plainError(400, "invalid_body")
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const mediaTypes = Object.freeze([...new Set([...types, ...(inner?.mediaTypes ?? [])])])
|
|
227
|
+
const reader: PropertyDescriptor = {
|
|
228
|
+
value: Object.defineProperty(read, "mediaTypes", { value: mediaTypes }),
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (typeof schema === "function") {
|
|
232
|
+
// A callable schema cannot be copied; an object that delegates to it reads the same.
|
|
233
|
+
return Object.defineProperty(Object.create(schema), SCHEMA_BODY_READER, reader) as Schema
|
|
234
|
+
}
|
|
235
|
+
// The reader goes in with the copied properties rather than after them: a schema that was already
|
|
236
|
+
// opted in carries a reader that cannot be redefined.
|
|
237
|
+
return Object.create(Object.getPrototypeOf(schema), {
|
|
238
|
+
...Object.getOwnPropertyDescriptors(schema),
|
|
239
|
+
[SCHEMA_BODY_READER]: reader,
|
|
240
|
+
}) as Schema
|
|
241
|
+
}
|
package/src/server/body.ts
CHANGED
|
@@ -82,7 +82,9 @@ export async function drainCapped(
|
|
|
82
82
|
if (done) break
|
|
83
83
|
total += value.byteLength
|
|
84
84
|
if (total > maxBytes) {
|
|
85
|
-
|
|
85
|
+
// Not awaited: on a teed body (a `clone()` taken first) the cancel settles only once the other
|
|
86
|
+
// branch is read or cancelled too, and nothing here ever reads it.
|
|
87
|
+
reader.cancel().catch(() => {})
|
|
86
88
|
return { ok: false, status: 413 }
|
|
87
89
|
}
|
|
88
90
|
if (first === undefined) {
|
|
@@ -210,9 +212,45 @@ function exactArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
|
|
210
212
|
: (bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer)
|
|
211
213
|
}
|
|
212
214
|
|
|
215
|
+
/** The two members of a raw body stream {@link cappedStreamOf} uses. */
|
|
216
|
+
interface RawStream {
|
|
217
|
+
getReader(): unknown
|
|
218
|
+
cancel(reason?: unknown): Promise<void>
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Deno's `process.env` throws without `--allow-env`, so the dev guard in `capRequestBodyReads` asks
|
|
222
|
+
// `typeof Deno` first: a bare typeof is pure, so a production `NODE_ENV` define folds the guard away.
|
|
223
|
+
declare const Deno: unknown
|
|
224
|
+
|
|
225
|
+
/** Dev only: `body`, recording the first reader user code takes from it through the capped getter. */
|
|
226
|
+
function markedOnRead(body: ReadableStream<Uint8Array>, mark: () => void): RawStream {
|
|
227
|
+
return {
|
|
228
|
+
getReader: () => {
|
|
229
|
+
mark()
|
|
230
|
+
return body.getReader()
|
|
231
|
+
},
|
|
232
|
+
cancel: (reason) => body.cancel(reason),
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Dev only: user code streamed the native body, which is one-shot, so every framework reader after
|
|
238
|
+
* it (a body schema, `c.boundedJson()`, the handler's `c.req.json()`) can only fail. Each raw reader
|
|
239
|
+
* now throws, synchronously so no lane maps it to a client error, naming the reader that replays.
|
|
240
|
+
*/
|
|
241
|
+
function spendRaw(raw: RawBodyReaders): void {
|
|
242
|
+
const spent = (): never => {
|
|
243
|
+
throw new TypeError(
|
|
244
|
+
"[nifra] c.req.body was read as a stream; read it with c.req.bytes() in a hook",
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
Object.assign(raw, { arrayBuffer: spent, bytes: spent, json: spent })
|
|
248
|
+
Object.defineProperty(raw, "body", { get: spent })
|
|
249
|
+
}
|
|
250
|
+
|
|
213
251
|
/** A pass-through stream that errors (with `reject(413)`) once more than `maxBytes` flow through. */
|
|
214
252
|
function cappedStreamOf(
|
|
215
|
-
body:
|
|
253
|
+
body: RawStream,
|
|
216
254
|
maxBytes: number,
|
|
217
255
|
reject: (status: 400 | 413) => unknown,
|
|
218
256
|
): ReadableStream<Uint8Array> {
|
|
@@ -231,7 +269,7 @@ function cappedStreamOf(
|
|
|
231
269
|
return new ReadableStream<Uint8Array>(
|
|
232
270
|
{
|
|
233
271
|
async pull(controller) {
|
|
234
|
-
reader ??= body.getReader() as
|
|
272
|
+
reader ??= body.getReader() as CappedReader
|
|
235
273
|
const active = reader
|
|
236
274
|
const { done, value } = await active.read()
|
|
237
275
|
if (done) {
|
|
@@ -293,11 +331,34 @@ function capRequestBodyReads(request: Request, maxBytes: number, preset?: RawBod
|
|
|
293
331
|
// One bounded buffer backs every shadowed reader; a second read replays it instead of failing
|
|
294
332
|
// with "body already used" (a strict superset of the native one-shot contract).
|
|
295
333
|
let buffered: Promise<Uint8Array> | undefined
|
|
296
|
-
const cappedBytes = (): Promise<Uint8Array> =>
|
|
297
|
-
(buffered
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
334
|
+
const cappedBytes = (): Promise<Uint8Array> => {
|
|
335
|
+
if (buffered === undefined) {
|
|
336
|
+
const read = readBoundedBytes(raw, maxBytes).then((result) => {
|
|
337
|
+
if (!result.ok) throw reject(result.status)
|
|
338
|
+
return result.bytes
|
|
339
|
+
})
|
|
340
|
+
buffered = read
|
|
341
|
+
// The raw readers now replay these bytes, so a framework reader that runs after a hook read
|
|
342
|
+
// `c.req` (an auth-first route's body schema) parses the same body instead of a spent stream.
|
|
343
|
+
// readBoundedBytes took its reader before its first await, so it never reads the replay.
|
|
344
|
+
Object.assign(raw, {
|
|
345
|
+
arrayBuffer: shadowed.arrayBuffer,
|
|
346
|
+
bytes: shadowed.bytes,
|
|
347
|
+
json: shadowed.json,
|
|
348
|
+
})
|
|
349
|
+
Object.defineProperty(raw, "body", {
|
|
350
|
+
get: () =>
|
|
351
|
+
new ReadableStream<Uint8Array>({
|
|
352
|
+
start: (controller) =>
|
|
353
|
+
read.then((bytes) => {
|
|
354
|
+
controller.enqueue(bytes)
|
|
355
|
+
controller.close()
|
|
356
|
+
}),
|
|
357
|
+
}),
|
|
358
|
+
})
|
|
359
|
+
}
|
|
360
|
+
return buffered
|
|
361
|
+
}
|
|
301
362
|
// The reader methods live on the prototype as writable data properties, so a plain instance
|
|
302
363
|
// assignment shadows them - materially cheaper than defineProperty on this per-read path. Only
|
|
303
364
|
// `body` (a prototype accessor) needs defineProperty below.
|
|
@@ -339,7 +400,19 @@ function capRequestBodyReads(request: Request, maxBytes: number, preset?: RawBod
|
|
|
339
400
|
get(): ReadableStream<Uint8Array> | null {
|
|
340
401
|
if (cappedBody === undefined) {
|
|
341
402
|
const rawBody = raw.body
|
|
342
|
-
cappedBody =
|
|
403
|
+
cappedBody =
|
|
404
|
+
rawBody === null
|
|
405
|
+
? null
|
|
406
|
+
: cappedStreamOf(
|
|
407
|
+
typeof Deno === "undefined" && process.env.NODE_ENV !== "production"
|
|
408
|
+
? markedOnRead(rawBody, () => {
|
|
409
|
+
// After a buffered read the getter streams a replay, which spends nothing.
|
|
410
|
+
if (buffered === undefined) spendRaw(raw)
|
|
411
|
+
})
|
|
412
|
+
: rawBody,
|
|
413
|
+
maxBytes,
|
|
414
|
+
reject,
|
|
415
|
+
)
|
|
343
416
|
}
|
|
344
417
|
return cappedBody
|
|
345
418
|
},
|
package/src/server/client-ip.ts
CHANGED
|
@@ -25,6 +25,15 @@
|
|
|
25
25
|
*/
|
|
26
26
|
export type ClientIpTrust = { readonly trustedHops: number } | { readonly header: string }
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Marks a platform whose `clientIp` an enclosing nifra server already derived under its own `clientIp`
|
|
30
|
+
* trust declaration. A server that receives such a platform takes `clientIp` as-is instead of re-deriving
|
|
31
|
+
* it from the request's forwarding headers - an in-process call's `Request` is synthesized, so identity
|
|
32
|
+
* travels in the platform, never in headers a rebuilt request would lose. Only server code builds a
|
|
33
|
+
* platform; a client cannot set it.
|
|
34
|
+
*/
|
|
35
|
+
export const NIFRA_PLATFORM_CLIENT_IP_DERIVED = Symbol.for("@nifrajs/platform-client-ip-derived")
|
|
36
|
+
|
|
28
37
|
function firstHeaderValue(req: Request, header: string): string | undefined {
|
|
29
38
|
const raw = req.headers.get(header)
|
|
30
39
|
if (raw === null) return undefined
|