@nifrajs/core 2.14.1 → 3.1.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 +4 -0
- package/dist/assurance.d.ts +16 -8
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +33 -0
- package/dist/assurance.js.map +1 -1
- package/dist/edge-kit.d.ts +27 -0
- package/dist/edge-kit.d.ts.map +1 -0
- package/dist/edge-kit.js +24 -0
- package/dist/edge-kit.js.map +1 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/route-execution.d.ts +11 -5
- package/dist/internal/route-execution.d.ts.map +1 -1
- package/dist/internal/route-execution.js +2 -1
- package/dist/internal/route-execution.js.map +1 -1
- package/dist/response-observer.d.ts +25 -0
- package/dist/response-observer.d.ts.map +1 -0
- package/dist/response-observer.js +40 -0
- package/dist/response-observer.js.map +1 -0
- package/dist/security/baseline.d.ts +68 -0
- package/dist/security/baseline.d.ts.map +1 -0
- package/dist/security/baseline.js +118 -0
- package/dist/security/baseline.js.map +1 -0
- package/dist/server/bare-error-lane.d.ts +26 -0
- package/dist/server/bare-error-lane.d.ts.map +1 -0
- package/dist/server/bare-error-lane.js +60 -0
- package/dist/server/bare-error-lane.js.map +1 -0
- package/dist/server/body.d.ts +7 -0
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +7 -0
- package/dist/server/body.js.map +1 -1
- package/dist/server/bridge-markers.d.ts +25 -0
- package/dist/server/bridge-markers.d.ts.map +1 -0
- package/dist/server/bridge-markers.js +25 -0
- package/dist/server/bridge-markers.js.map +1 -0
- package/dist/server/context.d.ts +6 -1
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/header-case.d.ts.map +1 -1
- package/dist/server/header-case.js +2 -1
- package/dist/server/header-case.js.map +1 -1
- package/dist/server/http.d.ts +36 -3
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +49 -3
- package/dist/server/http.js.map +1 -1
- package/dist/server/install.d.ts +6 -8
- package/dist/server/install.d.ts.map +1 -1
- package/dist/server/install.js +7 -0
- package/dist/server/install.js.map +1 -1
- package/dist/server/node-outcome-hook.d.ts +2 -1
- package/dist/server/node-outcome-hook.d.ts.map +1 -1
- package/dist/server/node-outcome-hook.js.map +1 -1
- package/dist/server/node-outcome.d.ts +17 -2
- package/dist/server/node-outcome.d.ts.map +1 -1
- package/dist/server/node-outcome.js +37 -3
- package/dist/server/node-outcome.js.map +1 -1
- package/dist/server/query.d.ts +7 -1
- package/dist/server/query.d.ts.map +1 -1
- package/dist/server/query.js +8 -3
- package/dist/server/query.js.map +1 -1
- package/dist/server/request-context.d.ts +17 -4
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +63 -29
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +19 -2
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +5 -2
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/response-observer-runtime.d.ts +20 -0
- package/dist/server/response-observer-runtime.d.ts.map +1 -0
- package/dist/server/response-observer-runtime.js +95 -0
- package/dist/server/response-observer-runtime.js.map +1 -0
- package/dist/server/runtime-core.d.ts +60 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js +65 -1
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +3 -1
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +52 -39
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +267 -271
- package/dist/server/server.js.map +1 -1
- package/dist/server/websocket.d.ts +20 -4
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +81 -11
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-bun.d.ts +1 -1
- package/dist/server/ws-bun.d.ts.map +1 -1
- package/dist/server/ws-bun.js +12 -8
- package/dist/server/ws-bun.js.map +1 -1
- package/dist/server.d.ts +2 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +2 -1
- package/dist/server.js.map +1 -1
- package/dist/single-copy-register.d.ts +2 -0
- package/dist/single-copy-register.d.ts.map +1 -0
- package/dist/single-copy-register.js +24 -0
- package/dist/single-copy-register.js.map +1 -0
- package/dist/single-copy.d.ts +156 -0
- package/dist/single-copy.d.ts.map +1 -0
- package/dist/single-copy.js +470 -0
- package/dist/single-copy.js.map +1 -0
- package/package.json +24 -2
- package/src/assurance.ts +58 -1
- package/src/edge-kit.ts +27 -0
- package/src/errors.ts +1 -0
- package/src/index.ts +1 -1
- package/src/internal/route-execution.ts +23 -16
- package/src/response-observer.ts +64 -0
- package/src/security/baseline.ts +172 -0
- package/src/server/bare-error-lane.ts +76 -0
- package/src/server/body.ts +8 -0
- package/src/server/bridge-markers.ts +24 -0
- package/src/server/context.ts +6 -1
- package/src/server/header-case.ts +3 -1
- package/src/server/http.ts +53 -3
- package/src/server/install.ts +14 -0
- package/src/server/node-outcome-hook.ts +2 -1
- package/src/server/node-outcome.ts +44 -3
- package/src/server/query.ts +10 -4
- package/src/server/request-context.ts +85 -33
- package/src/server/respond.ts +26 -1
- package/src/server/response-contract-lane.ts +6 -2
- package/src/server/response-observer-runtime.ts +135 -0
- package/src/server/runtime-core.ts +98 -1
- package/src/server/server-types.ts +3 -1
- package/src/server/server.ts +436 -367
- package/src/server/websocket.ts +127 -17
- package/src/server/ws-bun.ts +26 -7
- package/src/server.ts +2 -1
- package/src/single-copy-register.ts +24 -0
- package/src/single-copy.ts +524 -0
package/src/server/install.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* - the seam stays off the server's public typed surface (symbol-keyed methods are not part of it).
|
|
7
7
|
* Both sides reference the SAME registered symbol, so no module has to import the other.
|
|
8
8
|
*/
|
|
9
|
+
import { NODE_BRIDGE_MARKER_KEYS } from "./bridge-markers.ts"
|
|
9
10
|
|
|
10
11
|
/** @internal Install the idempotency runtime on a server (called by the `idempotency()` plugin). */
|
|
11
12
|
export const INSTALL_IDEMPOTENCY: unique symbol = Symbol.for("@nifrajs/core/install-idempotency")
|
|
@@ -31,3 +32,16 @@ export const INSTALL_WS: unique symbol = Symbol.for("@nifrajs/core/install-ws")
|
|
|
31
32
|
export const INSTALL_RESPONSE_CONTRACT: unique symbol = Symbol.for(
|
|
32
33
|
"@nifrajs/core/install-response-contract",
|
|
33
34
|
)
|
|
35
|
+
|
|
36
|
+
/** @internal Install the response-observer adapters on a server. */
|
|
37
|
+
export const INSTALL_RESPONSE_OBSERVER: unique symbol = Symbol.for(
|
|
38
|
+
"@nifrajs/core/install-response-observer",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
/** @internal Resolve a native mount for an adapter that can keep the request on its own stream lane. */
|
|
42
|
+
export const RESOLVE_NODE_MOUNT: unique symbol = Symbol.for(
|
|
43
|
+
NODE_BRIDGE_MARKER_KEYS.resolveNodeMount,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
/** @internal Marker on a mounted handler that knows how to serve a native Node request/response. */
|
|
47
|
+
export const NODE_NATIVE_MOUNT: unique symbol = Symbol.for("@nifrajs/core/node-native-mount")
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import type { Platform } from "./context.ts"
|
|
4
4
|
import { hasLowercaseHeaderKeysMark, hasUpperAscii } from "./header-case.ts"
|
|
5
5
|
import type { NodeServeOutcome } from "./node-outcome.ts"
|
|
6
|
+
import type { ResponseResult } from "./runtime-core.ts"
|
|
6
7
|
import type { CtxSet, MaybePromise } from "./server.ts"
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -309,7 +310,7 @@ export interface NodeOutcomeRuntime {
|
|
|
309
310
|
toOutcome(result: unknown, set: CtxSet): NodeServeOutcome
|
|
310
311
|
/** Materialize a buffered outcome for a Web response hook without losing its direct-write marker. */
|
|
311
312
|
toResponse(outcome: NodeServeOutcome): Response
|
|
312
|
-
fromResponse(response: Response): NodeServeOutcome
|
|
313
|
+
fromResponse(response: Response | ResponseResult): NodeServeOutcome
|
|
313
314
|
timeout(): NodeServeOutcome
|
|
314
315
|
/** The `Content-Type` this runtime's json render writes implicitly. The native hook walk
|
|
315
316
|
* materializes it into the hook-visible header record so a body hook's content-type checks see
|
|
@@ -11,7 +11,14 @@ import {
|
|
|
11
11
|
rememberMutableHeaders,
|
|
12
12
|
taggedResponseBody,
|
|
13
13
|
} from "./respond.ts"
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
EMPTY_RESPONSE_CONTROLS,
|
|
16
|
+
type HandlerResult,
|
|
17
|
+
isResponseResult,
|
|
18
|
+
type PlainRender,
|
|
19
|
+
plainRenderHeaders,
|
|
20
|
+
type ResponseResult,
|
|
21
|
+
} from "./runtime-core.ts"
|
|
15
22
|
import type { CtxSet } from "./server.ts"
|
|
16
23
|
import {
|
|
17
24
|
mergeStaticHeaderRecord,
|
|
@@ -58,6 +65,8 @@ export type NodeServeOutcome =
|
|
|
58
65
|
*/
|
|
59
66
|
export function toNodeOutcome(result: HandlerResult, set: CtxSet): NodeServeOutcome {
|
|
60
67
|
if (isResponseResult(result)) {
|
|
68
|
+
const plain = result.plain
|
|
69
|
+
if (plain !== undefined) return plainNodeOutcome(plain, set)
|
|
61
70
|
const body = result.toNodeBody?.()
|
|
62
71
|
if (body !== undefined) {
|
|
63
72
|
return {
|
|
@@ -85,6 +94,24 @@ export function toNodeOutcome(result: HandlerResult, set: CtxSet): NodeServeOutc
|
|
|
85
94
|
}
|
|
86
95
|
}
|
|
87
96
|
|
|
97
|
+
/**
|
|
98
|
+
* A `status(...)` (or any other {@link PlainRender} carrier) on the node lane: the same `kind: "json"`
|
|
99
|
+
* outcome a handler's plain return produces, so an early exit is written to the socket by the same
|
|
100
|
+
* direct writer, with a `content-length`, and never builds a `Response`.
|
|
101
|
+
*/
|
|
102
|
+
export function plainNodeOutcome(plain: PlainRender, set: CtxSet): NodeServeOutcome {
|
|
103
|
+
return {
|
|
104
|
+
kind: "json",
|
|
105
|
+
status: plain.status,
|
|
106
|
+
headers: plainRenderHeaders(plain, set),
|
|
107
|
+
cookies: set._cookies,
|
|
108
|
+
body:
|
|
109
|
+
plain.body === undefined || isBodylessStatus(plain.status)
|
|
110
|
+
? null
|
|
111
|
+
: JSON.stringify(plain.body),
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
88
115
|
/**
|
|
89
116
|
* Materialize a buffered node outcome only when a Web `onResponse` hook needs to see a real
|
|
90
117
|
* `Response`. The marker lets an in-place hook (`response.headers.set(...); return response`) go back
|
|
@@ -162,8 +189,22 @@ export function withStaticNodeHeaders(
|
|
|
162
189
|
return { ...outcome, headers }
|
|
163
190
|
}
|
|
164
191
|
|
|
165
|
-
|
|
166
|
-
|
|
192
|
+
/**
|
|
193
|
+
* The node lane's `wrapResponse`: what an early exit built OUTSIDE the handler's finalizer renders as
|
|
194
|
+
* - an `onRequest` hook's `Response`, a mount's, and every framework error render.
|
|
195
|
+
*
|
|
196
|
+
* A plain-data carrier ({@link plainError}, `status(...)`) takes the same `kind: "json"` lane a
|
|
197
|
+
* handler's plain return takes: no `Response` built, and none drained. `EMPTY_RESPONSE_CONTROLS`
|
|
198
|
+
* rather than the request's `c.set` on purpose - these renders happen where no context exists (before
|
|
199
|
+
* routing, or after it was abandoned), which is exactly why they are wrapped rather than finalized.
|
|
200
|
+
*/
|
|
201
|
+
export function nodeOutcomeFromResponse(result: Response | ResponseResult): NodeServeOutcome {
|
|
202
|
+
if (!(result instanceof Response)) {
|
|
203
|
+
const plain = result.plain
|
|
204
|
+
if (plain !== undefined) return plainNodeOutcome(plain, EMPTY_RESPONSE_CONTROLS)
|
|
205
|
+
return nodeOutcomeFromResponse(result.toResponse())
|
|
206
|
+
}
|
|
207
|
+
const response = normalizeBodylessResponse(result)
|
|
167
208
|
const body = nodeResponseBody(response)
|
|
168
209
|
return body === undefined
|
|
169
210
|
? { kind: "response", response }
|
package/src/server/query.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* and the byte-capped HTML-form reader. Split out of the server kernel so the file stays one domain.
|
|
5
5
|
*/
|
|
6
6
|
import { readBoundedBytes } from "./body.ts"
|
|
7
|
-
import {
|
|
7
|
+
import { plainError, urlPartsOf } from "./http.ts"
|
|
8
|
+
import type { ResponseResult } from "./runtime-core.ts"
|
|
8
9
|
import type { RequestSource } from "./server.ts"
|
|
9
10
|
|
|
10
11
|
// The query string ("?a=1", or "" when absent) - for lazily building `c.query` only when read.
|
|
@@ -123,16 +124,21 @@ const FORM_DECODER = new TextDecoder()
|
|
|
123
124
|
* Read an HTML-form body (urlencoded) into the plain object a body schema validates - same byte
|
|
124
125
|
* cap as the JSON path (a lying/absent Content-Length can't force an oversized buffer), same
|
|
125
126
|
* repeated-key -> `string[]` promotion as query parsing, same `__proto__` guard.
|
|
127
|
+
*
|
|
128
|
+
* A rejection is a `ResponseResult`, not a built `Response`, matching the JSON reader: the cap is a
|
|
129
|
+
* framework render, and answering one on the plain lane costs what a handler's plain return costs
|
|
130
|
+
* (see `plainError`). The distinction is observable on the Node adapter - a `Response` there is
|
|
131
|
+
* untagged, so its body drains through a Web stream and the answer goes out chunked.
|
|
126
132
|
*/
|
|
127
133
|
export async function readBoundedForm(
|
|
128
134
|
req: RequestSource,
|
|
129
135
|
maxBytes: number,
|
|
130
|
-
): Promise<Record<string, QueryValue> |
|
|
136
|
+
): Promise<Record<string, QueryValue> | ResponseResult> {
|
|
131
137
|
const read = await readBoundedBytes(req, maxBytes)
|
|
132
138
|
if (!read.ok) {
|
|
133
139
|
return read.status === 413
|
|
134
|
-
?
|
|
135
|
-
:
|
|
140
|
+
? plainError(413, "payload_too_large")
|
|
141
|
+
: plainError(400, "invalid_content_length")
|
|
136
142
|
}
|
|
137
143
|
const out: Record<string, QueryValue> = Object.create(null) as Record<string, QueryValue>
|
|
138
144
|
// URLSearchParams owns the format's quirks (`+` as space, percent-decoding, empty keys);
|
|
@@ -16,9 +16,9 @@ import {
|
|
|
16
16
|
import type { Platform } from "./context.ts"
|
|
17
17
|
import { type CookieOptions, cookieNamePrefix, parseCookies, serializeCookie } from "./cookies.ts"
|
|
18
18
|
import { headerObjectOf } from "./headers.ts"
|
|
19
|
-
import {
|
|
19
|
+
import { plainError } from "./http.ts"
|
|
20
20
|
import { guardParsedValue, type ProtoPoisoning, parseJsonGuarded } from "./proto-guard.ts"
|
|
21
|
-
import { searchOf } from "./query.ts"
|
|
21
|
+
import { isUrlEncodedForm, readBoundedForm, searchOf } from "./query.ts"
|
|
22
22
|
import { responseJsonContentType } from "./respond.ts"
|
|
23
23
|
import {
|
|
24
24
|
CONTEXT_SEARCH,
|
|
@@ -27,12 +27,14 @@ import {
|
|
|
27
27
|
getNeverAbortSignal,
|
|
28
28
|
getUnboundedRequestBudget,
|
|
29
29
|
headerOf,
|
|
30
|
+
isResponseResult,
|
|
30
31
|
PRE_DECODED_BODY,
|
|
31
32
|
type PreDecodedBody,
|
|
33
|
+
type ResponseResult,
|
|
32
34
|
requestOf,
|
|
33
35
|
TEXT_DECODER,
|
|
34
36
|
} from "./runtime-core.ts"
|
|
35
|
-
import type { CtxSet, RawContext, RequestSource } from "./server.ts"
|
|
37
|
+
import type { CtxSet, MaybePromise, RawContext, RequestSource } from "./server.ts"
|
|
36
38
|
|
|
37
39
|
/** A fixed past instant for cookie deletion (`Expires`). A literal epoch - deterministic, unlike an
|
|
38
40
|
* argless `new Date()`. */
|
|
@@ -390,7 +392,7 @@ export class RequestContext implements RawContext {
|
|
|
390
392
|
}
|
|
391
393
|
}
|
|
392
394
|
|
|
393
|
-
/** Backs `c.boundedBody`: bounded byte read that throws a flat 413/400
|
|
395
|
+
/** Backs `c.boundedBody`: bounded byte read that throws a flat 413/400 plain render (caught by
|
|
394
396
|
* `runLifecycle` as control flow, like `throw redirect(...)`), so a handler can't ignore the cap.
|
|
395
397
|
* The byte-cap itself lives in `./body.ts` (shared with the schema path and `verifyWebhook`). */
|
|
396
398
|
async function readBoundedBodyOrThrow(
|
|
@@ -401,8 +403,8 @@ async function readBoundedBodyOrThrow(
|
|
|
401
403
|
const r = await readBoundedBytes(req, maxBytes ?? maxBodyBytes)
|
|
402
404
|
if (r.ok) return r.bytes
|
|
403
405
|
throw r.status === 413
|
|
404
|
-
?
|
|
405
|
-
:
|
|
406
|
+
? plainError(413, "payload_too_large")
|
|
407
|
+
: plainError(400, "invalid_content_length")
|
|
406
408
|
}
|
|
407
409
|
|
|
408
410
|
/** Backs `c.boundedJson`: `readBoundedJsonSource`, throwing its flat 400/413 on failure. */
|
|
@@ -413,7 +415,9 @@ async function readBoundedJsonBodyOrThrow<T>(
|
|
|
413
415
|
maxBytes?: number,
|
|
414
416
|
): Promise<T> {
|
|
415
417
|
const parsed = await readBoundedJsonSource(req, maxBytes ?? maxBodyBytes, protoPoisoning)
|
|
416
|
-
|
|
418
|
+
// The lane's own failures are the only branded values it can produce: a parsed JSON body is
|
|
419
|
+
// `JSON.parse` output, which carries string keys only and so can never answer to a symbol brand.
|
|
420
|
+
if (isResponseResult(parsed)) throw parsed
|
|
417
421
|
return parsed as T
|
|
418
422
|
}
|
|
419
423
|
|
|
@@ -431,7 +435,7 @@ async function readBoundedJsonBodyOrThrow<T>(
|
|
|
431
435
|
* own promise chain instead, and only the streaming fallback - which is already doing IO in a loop
|
|
432
436
|
* - runs as an async function. Callers that `await` the result are unaffected.
|
|
433
437
|
*/
|
|
434
|
-
type JsonResultContinuation<T> = (result: unknown |
|
|
438
|
+
type JsonResultContinuation<T> = (result: unknown | ResponseResult) => T | Promise<T>
|
|
435
439
|
type JsonErrorContinuation<T> = (error: unknown) => T | Promise<T>
|
|
436
440
|
type FramedJsonSource = RequestSource & {
|
|
437
441
|
readonly bytes?: () => Promise<Uint8Array>
|
|
@@ -445,7 +449,7 @@ export function readBoundedJsonSource(
|
|
|
445
449
|
req: RequestSource,
|
|
446
450
|
maxBytes: number,
|
|
447
451
|
protoPoisoning?: ProtoPoisoning,
|
|
448
|
-
): Promise<unknown |
|
|
452
|
+
): Promise<unknown | ResponseResult>
|
|
449
453
|
export function readBoundedJsonSource<T>(
|
|
450
454
|
req: RequestSource,
|
|
451
455
|
maxBytes: number,
|
|
@@ -459,7 +463,7 @@ export function readBoundedJsonSource<T>(
|
|
|
459
463
|
protoPoisoning: ProtoPoisoning = "reject",
|
|
460
464
|
onResult?: JsonResultContinuation<T>,
|
|
461
465
|
onError?: JsonErrorContinuation<T>,
|
|
462
|
-
): Promise<unknown |
|
|
466
|
+
): Promise<unknown | ResponseResult | T> {
|
|
463
467
|
// A pre-parsing hook (the transport-codec lane) may have decoded the body already - its stash
|
|
464
468
|
// is taken verbatim: the stasher enforced its own byte cap and poisoning policy on text this
|
|
465
469
|
// lane never sees. One symbol read; a miss on ordinary requests costs a cache-line, not a parse.
|
|
@@ -493,12 +497,12 @@ export function readBoundedJsonSource<T>(
|
|
|
493
497
|
const length = parseContentLength(declared)
|
|
494
498
|
if (length === undefined)
|
|
495
499
|
return onResult === undefined
|
|
496
|
-
? Promise.resolve(
|
|
497
|
-
: Promise.resolve(
|
|
500
|
+
? Promise.resolve(plainError(400, "invalid_content_length"))
|
|
501
|
+
: Promise.resolve(plainError(400, "invalid_content_length")).then(onResult)
|
|
498
502
|
if (length > maxBytes)
|
|
499
503
|
return onResult === undefined
|
|
500
|
-
? Promise.resolve(
|
|
501
|
-
: Promise.resolve(
|
|
504
|
+
? Promise.resolve(plainError(413, "payload_too_large"))
|
|
505
|
+
: Promise.resolve(plainError(413, "payload_too_large")).then(onResult)
|
|
502
506
|
// Trusted framing (Bun's native route table, Deno.serve): the runtime's HTTP parser already
|
|
503
507
|
// delimited the bytes, so the declared length IS the transport frame and chunked routing is
|
|
504
508
|
// moot - the over-cap 413 above already enforced the byte cap. Take the runtime's fused
|
|
@@ -514,8 +518,8 @@ export function readBoundedJsonSource<T>(
|
|
|
514
518
|
guarded = guardParsedValue(parsed, protoPoisoning)
|
|
515
519
|
} catch {
|
|
516
520
|
return onResult === undefined
|
|
517
|
-
?
|
|
518
|
-
: onResult(
|
|
521
|
+
? plainError(400, "invalid_json")
|
|
522
|
+
: onResult(plainError(400, "invalid_json"))
|
|
519
523
|
}
|
|
520
524
|
return onResult === undefined ? guarded : onResult(guarded)
|
|
521
525
|
},
|
|
@@ -533,16 +537,16 @@ export function readBoundedJsonSource<T>(
|
|
|
533
537
|
({ value, byteLength }) => {
|
|
534
538
|
if (byteLength > length || byteLength > maxBytes) {
|
|
535
539
|
return onResult === undefined
|
|
536
|
-
?
|
|
537
|
-
: onResult(
|
|
540
|
+
? plainError(413, "payload_too_large")
|
|
541
|
+
: onResult(plainError(413, "payload_too_large"))
|
|
538
542
|
}
|
|
539
543
|
let guarded: unknown
|
|
540
544
|
try {
|
|
541
545
|
guarded = guardParsedValue(value, protoPoisoning)
|
|
542
546
|
} catch {
|
|
543
547
|
return onResult === undefined
|
|
544
|
-
?
|
|
545
|
-
: onResult(
|
|
548
|
+
? plainError(400, "invalid_json")
|
|
549
|
+
: onResult(plainError(400, "invalid_json"))
|
|
546
550
|
}
|
|
547
551
|
return onResult === undefined ? guarded : onResult(guarded)
|
|
548
552
|
},
|
|
@@ -555,16 +559,16 @@ export function readBoundedJsonSource<T>(
|
|
|
555
559
|
(bytes) => {
|
|
556
560
|
if (bytes.byteLength > length || bytes.byteLength > maxBytes) {
|
|
557
561
|
return onResult === undefined
|
|
558
|
-
?
|
|
559
|
-
: onResult(
|
|
562
|
+
? plainError(413, "payload_too_large")
|
|
563
|
+
: onResult(plainError(413, "payload_too_large"))
|
|
560
564
|
}
|
|
561
565
|
try {
|
|
562
566
|
const parsed = parseJsonGuarded(TEXT_DECODER.decode(bytes), protoPoisoning)
|
|
563
567
|
return onResult === undefined ? parsed : onResult(parsed)
|
|
564
568
|
} catch {
|
|
565
569
|
return onResult === undefined
|
|
566
|
-
?
|
|
567
|
-
: onResult(
|
|
570
|
+
? plainError(400, "invalid_json")
|
|
571
|
+
: onResult(plainError(400, "invalid_json"))
|
|
568
572
|
}
|
|
569
573
|
},
|
|
570
574
|
() => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
|
|
@@ -581,16 +585,16 @@ export function readBoundedJsonSource<T>(
|
|
|
581
585
|
const bytes = new Uint8Array(buffer)
|
|
582
586
|
if (bytes.byteLength > length || bytes.byteLength > maxBytes) {
|
|
583
587
|
return onResult === undefined
|
|
584
|
-
?
|
|
585
|
-
: onResult(
|
|
588
|
+
? plainError(413, "payload_too_large")
|
|
589
|
+
: onResult(plainError(413, "payload_too_large"))
|
|
586
590
|
}
|
|
587
591
|
let parsed: unknown
|
|
588
592
|
try {
|
|
589
593
|
parsed = parseJsonGuarded(TEXT_DECODER.decode(bytes), protoPoisoning)
|
|
590
594
|
} catch {
|
|
591
595
|
return onResult === undefined
|
|
592
|
-
?
|
|
593
|
-
: onResult(
|
|
596
|
+
? plainError(400, "invalid_json")
|
|
597
|
+
: onResult(plainError(400, "invalid_json"))
|
|
594
598
|
}
|
|
595
599
|
return onResult === undefined ? parsed : onResult(parsed)
|
|
596
600
|
},
|
|
@@ -605,21 +609,69 @@ export function readBoundedJsonSource<T>(
|
|
|
605
609
|
/** The shared rejection handler for a body read that never completed (client abort, socket error,
|
|
606
610
|
* malformed framing): the same flat 400 a syntactically invalid body gets. Hoisted so the framed
|
|
607
611
|
* lane allocates no per-request closure for it. */
|
|
608
|
-
const INVALID_JSON = ():
|
|
612
|
+
const INVALID_JSON = (): ResponseResult => plainError(400, "invalid_json")
|
|
609
613
|
|
|
610
614
|
/** The chunked / length-less fallback: drain under the streaming byte cap, then parse guarded. */
|
|
611
615
|
async function readStreamedJsonSource(
|
|
612
616
|
raw: RequestSource,
|
|
613
617
|
maxBytes: number,
|
|
614
618
|
protoPoisoning: ProtoPoisoning,
|
|
615
|
-
): Promise<unknown |
|
|
619
|
+
): Promise<unknown | ResponseResult> {
|
|
616
620
|
const body = raw.body
|
|
617
|
-
if (body === null) return
|
|
621
|
+
if (body === null) return plainError(400, "invalid_json")
|
|
618
622
|
const drained = await drainCapped(body, maxBytes)
|
|
619
|
-
if (!drained.ok) return
|
|
623
|
+
if (!drained.ok) return plainError(413, "payload_too_large")
|
|
620
624
|
try {
|
|
621
625
|
return parseJsonGuarded(TEXT_DECODER.decode(drained.bytes), protoPoisoning)
|
|
622
626
|
} catch {
|
|
623
|
-
return
|
|
627
|
+
return plainError(400, "invalid_json")
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* The shared bounded body framing/parser: the single trust-boundary enforcement point every body
|
|
633
|
+
* lane routes through. Content-type dispatch, the urlencoded-form cap, and the JSON path
|
|
634
|
+
* (`Content-Length` pre-reject -> streaming cap -> prototype-poisoning guard, all inside
|
|
635
|
+
* `readBoundedJsonSource`) live here so the fused body runner, the generic body-only lane, and a
|
|
636
|
+
* compact edge entrypoint all enforce the same boundary from one definition. `protoPoisoning` is a
|
|
637
|
+
* parameter, not read off a server instance, so this stays free of any dependency on the class.
|
|
638
|
+
*
|
|
639
|
+
* `onParsed` receives the decoded, guard-screened value; a framing rejection (`Content-Length` over
|
|
640
|
+
* the cap, a lying length caught mid-stream, malformed JSON, an unsupported media type) is rendered
|
|
641
|
+
* through `wrapResponse`; a read that never completed (client abort, socket error) reaches `onError`.
|
|
642
|
+
*/
|
|
643
|
+
export function readBodyFramed<T>(
|
|
644
|
+
source: RequestSource,
|
|
645
|
+
maxBodyBytes: number,
|
|
646
|
+
protoPoisoning: ProtoPoisoning,
|
|
647
|
+
onParsed: (parsed: unknown) => MaybePromise<T>,
|
|
648
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
649
|
+
onError: (err: unknown) => MaybePromise<T>,
|
|
650
|
+
): Promise<T> {
|
|
651
|
+
const contentType = headerOf(source, "content-type") ?? ""
|
|
652
|
+
if (contentType !== "application/json" && !contentType.includes("application/json")) {
|
|
653
|
+
if (isUrlEncodedForm(contentType)) {
|
|
654
|
+
return readBoundedForm(source, maxBodyBytes).then(
|
|
655
|
+
(form) => (isResponseResult(form) ? wrapResponse(form) : onParsed(form)),
|
|
656
|
+
onError,
|
|
657
|
+
) as Promise<T>
|
|
658
|
+
}
|
|
659
|
+
// multipart/form-data (file uploads) stays 415 on the schema path by design - files don't fit a
|
|
660
|
+
// value schema; use a schema-less route + @nifrajs/uploads helpers for those.
|
|
661
|
+
return Promise.resolve(wrapResponse(plainError(415, "unsupported_media_type")))
|
|
662
|
+
}
|
|
663
|
+
// All JSON bodies go through readBoundedJsonSource - the single enforcement point for the framed
|
|
664
|
+
// fast path (post-read byte re-check), the streaming cap, and the prototype-poisoning guard. A
|
|
665
|
+
// separate inlined native-json() fast path here would fork the trust boundary in two.
|
|
666
|
+
try {
|
|
667
|
+
return readBoundedJsonSource(
|
|
668
|
+
source,
|
|
669
|
+
maxBodyBytes,
|
|
670
|
+
protoPoisoning,
|
|
671
|
+
(parsed) => (isResponseResult(parsed) ? wrapResponse(parsed) : onParsed(parsed)),
|
|
672
|
+
onError,
|
|
673
|
+
)
|
|
674
|
+
} catch (err) {
|
|
675
|
+
return Promise.resolve(onError(err))
|
|
624
676
|
}
|
|
625
677
|
}
|
package/src/server/respond.ts
CHANGED
|
@@ -4,11 +4,14 @@
|
|
|
4
4
|
* spine types, so the wire-response format lives in one module distinct from the request engine.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { NODE_BRIDGE_MARKER_KEYS } from "./bridge-markers.ts"
|
|
7
8
|
import {
|
|
8
9
|
CONTEXT_SET,
|
|
9
10
|
EMPTY_RESPONSE_CONTROLS,
|
|
10
11
|
type HandlerResult,
|
|
11
12
|
isResponseResult,
|
|
13
|
+
type PlainRender,
|
|
14
|
+
plainRenderHeaders,
|
|
12
15
|
} from "./runtime-core.ts"
|
|
13
16
|
import type { CtxSet, RawContext } from "./server.ts"
|
|
14
17
|
import { mergeStaticHeaderRecord, type StaticResponseHeaders } from "./static-headers.ts"
|
|
@@ -24,6 +27,16 @@ function headersInit(set: CtxSet): Record<string, string> | Headers | undefined
|
|
|
24
27
|
return headers
|
|
25
28
|
}
|
|
26
29
|
|
|
30
|
+
/** The `c.set` a plain render stands for: its status and headers, carrying over the request's queued
|
|
31
|
+
* cookies so `set.cookie(...)` then an early exit still ships them. */
|
|
32
|
+
function plainRenderSet(plain: PlainRender, set: CtxSet): CtxSet {
|
|
33
|
+
return {
|
|
34
|
+
status: plain.status,
|
|
35
|
+
_headers: plainRenderHeaders(plain, set),
|
|
36
|
+
_cookies: set._cookies,
|
|
37
|
+
} as CtxSet
|
|
38
|
+
}
|
|
39
|
+
|
|
27
40
|
// Keep the fast JSON respond path byte-identical to `Response.json` without probing it at module
|
|
28
41
|
// scope: workerd forbids `Response.json()` during startup. A shared `Headers` is safe to reuse
|
|
29
42
|
// across responses: the Response constructor copies `init.headers` into its own list.
|
|
@@ -178,7 +191,7 @@ function denoResponseWithJsonBody(
|
|
|
178
191
|
// The framework-buffered-body marker shared with the Node adapter: a Response carrying it exposes
|
|
179
192
|
// its already-serialized bytes without draining. On the Web serving paths it exists ONLY when a
|
|
180
193
|
// registered body/raw hook needs it, so hook-less apps pay nothing.
|
|
181
|
-
const RESPONSE_BODY = Symbol.for(
|
|
194
|
+
const RESPONSE_BODY = Symbol.for(NODE_BRIDGE_MARKER_KEYS.responseBody)
|
|
182
195
|
|
|
183
196
|
/**
|
|
184
197
|
* Headers objects the framework itself constructed - guaranteed mutable, no guard. Response
|
|
@@ -396,6 +409,18 @@ export function toResponse(
|
|
|
396
409
|
statics?: StaticResponseHeaders,
|
|
397
410
|
): Response {
|
|
398
411
|
if (isResponseResult(result)) {
|
|
412
|
+
const plain = result.plain
|
|
413
|
+
if (plain !== undefined) {
|
|
414
|
+
// Rendered as the value it is, on the branches below, rather than through `toResponse()`: the
|
|
415
|
+
// status and headers fold into a `set` and the body takes the ordinary JSON lane. One level
|
|
416
|
+
// only - a plain render's body is a user value, never another response carrier.
|
|
417
|
+
return toResponse(
|
|
418
|
+
plain.body as HandlerResult,
|
|
419
|
+
plainRenderSet(plain, set),
|
|
420
|
+
tagResponseBody,
|
|
421
|
+
statics,
|
|
422
|
+
)
|
|
423
|
+
}
|
|
399
424
|
return withStatics(
|
|
400
425
|
appendCookiesToResponse(normalizeBodylessResponse(result.toResponse()), set),
|
|
401
426
|
statics,
|
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
} from "../schema/standard.ts"
|
|
49
49
|
import { INSTALL_RESPONSE_CONTRACT } from "./install.ts"
|
|
50
50
|
import type { IdentityPlugin } from "./plugin.ts"
|
|
51
|
+
import { isResponseResult } from "./runtime-core.ts"
|
|
51
52
|
import type { AnyServer } from "./server.ts"
|
|
52
53
|
|
|
53
54
|
/**
|
|
@@ -91,8 +92,11 @@ export function checkResponseContract(
|
|
|
91
92
|
mode: "warn" | "enforce",
|
|
92
93
|
): ResponseContractOutcome | Promise<ResponseContractOutcome> {
|
|
93
94
|
// A handler may return a raw Response as deliberate control flow (a redirect, a stream). There is no
|
|
94
|
-
// JSON payload to hold to the contract, and re-serializing one would corrupt it.
|
|
95
|
-
|
|
95
|
+
// JSON payload to hold to the contract, and re-serializing one would corrupt it. A `status(...)` is
|
|
96
|
+
// the same control flow in plain-data form: the contract describes the route's SUCCESS payload, so
|
|
97
|
+
// holding an early exit (a 401 body, say) to it would fail every guarded route that declares one.
|
|
98
|
+
if (result instanceof Response || result === undefined || isResponseResult(result))
|
|
99
|
+
return { kind: "ok", value: result }
|
|
96
100
|
const settled = schema["~standard"].validate(result)
|
|
97
101
|
return settled instanceof Promise
|
|
98
102
|
? settled.then((r) => interpret(r, result, mode))
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
NodeRequestContext,
|
|
3
|
+
NodeResponseHook,
|
|
4
|
+
ResponseBodyHook,
|
|
5
|
+
ResponseHeadersHook,
|
|
6
|
+
ResponseHeadersView,
|
|
7
|
+
} from "./node-outcome-hook.ts"
|
|
8
|
+
import { recordHeadersView } from "./node-outcome-hook.ts"
|
|
9
|
+
import { knownMutableHeaders, rememberMutableHeaders, taggedResponseBody } from "./respond.ts"
|
|
10
|
+
import { applyBodyReplacement, withReplacedBody } from "./response-hooks.ts"
|
|
11
|
+
|
|
12
|
+
type MaybePromise<T> = T | Promise<T>
|
|
13
|
+
type WebResponseHook = (response: Response, req: Request) => MaybePromise<Response>
|
|
14
|
+
|
|
15
|
+
export type { ResponseBodyHook, ResponseHeadersHook } from "./node-outcome-hook.ts"
|
|
16
|
+
|
|
17
|
+
export interface ResponseObserverHost {
|
|
18
|
+
assertConfigurable(operation: string): void
|
|
19
|
+
addResponseHook(web: WebResponseHook, node: NodeResponseHook | undefined): void
|
|
20
|
+
enableResponseBodyTagging(): object
|
|
21
|
+
responseBodyOwners(): ReadonlySet<object>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ResponseObserverMethods {
|
|
25
|
+
onResponseHeaders(fn: ResponseHeadersHook): this
|
|
26
|
+
onResponseBody(fn: ResponseBodyHook): this
|
|
27
|
+
onResponseRaw(fn: (response: Response, req: Request) => MaybePromise<Response>): this
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ResponseObserverRuntime {
|
|
31
|
+
install(host: ResponseObserverHost): ResponseObserverMethods
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const HEADER_MUTABILITY_PROBE = "x-nifra-header-probe"
|
|
35
|
+
const GUARDED_RESPONSE_HEADERS = new WeakSet<Headers>()
|
|
36
|
+
|
|
37
|
+
function hasMutableResponseHeaders(headers: Headers): boolean {
|
|
38
|
+
if (knownMutableHeaders(headers)) return true
|
|
39
|
+
if (GUARDED_RESPONSE_HEADERS.has(headers)) return false
|
|
40
|
+
let previous: string | null = null
|
|
41
|
+
try {
|
|
42
|
+
previous = headers.get(HEADER_MUTABILITY_PROBE)
|
|
43
|
+
headers.set(HEADER_MUTABILITY_PROBE, "1")
|
|
44
|
+
if (previous === null) headers.delete(HEADER_MUTABILITY_PROBE)
|
|
45
|
+
else headers.set(HEADER_MUTABILITY_PROBE, previous)
|
|
46
|
+
rememberMutableHeaders(headers)
|
|
47
|
+
return true
|
|
48
|
+
} catch {
|
|
49
|
+
try {
|
|
50
|
+
if (previous === null) headers.delete(HEADER_MUTABILITY_PROBE)
|
|
51
|
+
else headers.set(HEADER_MUTABILITY_PROBE, previous)
|
|
52
|
+
} catch {
|
|
53
|
+
// A guarded response rejected the probe and the clone path is authoritative.
|
|
54
|
+
}
|
|
55
|
+
GUARDED_RESPONSE_HEADERS.add(headers)
|
|
56
|
+
return false
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function webRequestView(req: Request): NodeRequestContext {
|
|
61
|
+
return { method: req.method, url: req.url, header: (name) => req.headers.get(name) }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function webHeadersView(
|
|
65
|
+
response: Response,
|
|
66
|
+
req: Request,
|
|
67
|
+
): {
|
|
68
|
+
readonly response: Response
|
|
69
|
+
readonly headers: ResponseHeadersView
|
|
70
|
+
readonly request: NodeRequestContext
|
|
71
|
+
} {
|
|
72
|
+
const request = webRequestView(req)
|
|
73
|
+
if (!hasMutableResponseHeaders(response.headers)) {
|
|
74
|
+
const clone = new Response(response.body, response)
|
|
75
|
+
return { response: clone, headers: clone.headers, request }
|
|
76
|
+
}
|
|
77
|
+
return { response, headers: response.headers, request }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function createResponseObserverRuntime(): ResponseObserverRuntime {
|
|
81
|
+
return {
|
|
82
|
+
install(host) {
|
|
83
|
+
return {
|
|
84
|
+
onResponseHeaders(this: ResponseObserverMethods, fn): ResponseObserverMethods {
|
|
85
|
+
host.assertConfigurable("onResponseHeaders()")
|
|
86
|
+
host.addResponseHook(
|
|
87
|
+
(response, req) => {
|
|
88
|
+
const view = webHeadersView(response, req)
|
|
89
|
+
const out = fn(view.headers, view.request, response.status)
|
|
90
|
+
return out instanceof Promise ? out.then(() => view.response) : view.response
|
|
91
|
+
},
|
|
92
|
+
(response, req) => fn(recordHeadersView(response), req, response.status),
|
|
93
|
+
)
|
|
94
|
+
return this
|
|
95
|
+
},
|
|
96
|
+
onResponseBody(this: ResponseObserverMethods, fn): ResponseObserverMethods {
|
|
97
|
+
host.assertConfigurable("onResponseBody()")
|
|
98
|
+
host.enableResponseBodyTagging()
|
|
99
|
+
host.addResponseHook(
|
|
100
|
+
(response, req) => {
|
|
101
|
+
const body = taggedResponseBody(response, host.responseBodyOwners())
|
|
102
|
+
if (body === undefined) return response
|
|
103
|
+
const view = webHeadersView(response, req)
|
|
104
|
+
const out = fn(body, view.headers, view.request, response.status)
|
|
105
|
+
return out instanceof Promise
|
|
106
|
+
? out.then((replaced) => withReplacedBody(response, replaced))
|
|
107
|
+
: withReplacedBody(response, out)
|
|
108
|
+
},
|
|
109
|
+
(response, req) => {
|
|
110
|
+
if (response.body === null) return undefined
|
|
111
|
+
const out = fn(response.body, recordHeadersView(response), req, response.status)
|
|
112
|
+
if (out instanceof Promise)
|
|
113
|
+
return out.then((replaced) => applyBodyReplacement(response, replaced))
|
|
114
|
+
applyBodyReplacement(response, out)
|
|
115
|
+
return undefined
|
|
116
|
+
},
|
|
117
|
+
)
|
|
118
|
+
return this
|
|
119
|
+
},
|
|
120
|
+
onResponseRaw(this: ResponseObserverMethods, fn): ResponseObserverMethods {
|
|
121
|
+
host.assertConfigurable("onResponseRaw()")
|
|
122
|
+
host.enableResponseBodyTagging()
|
|
123
|
+
host.addResponseHook(
|
|
124
|
+
(response, req) =>
|
|
125
|
+
taggedResponseBody(response, host.responseBodyOwners()) === undefined
|
|
126
|
+
? fn(response, req)
|
|
127
|
+
: response,
|
|
128
|
+
() => undefined,
|
|
129
|
+
)
|
|
130
|
+
return this
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
}
|