@nifrajs/core 2.14.1 → 3.2.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 +25 -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/durable-adapters.d.ts.map +1 -1
- package/dist/durable-adapters.js +26 -3
- package/dist/durable-adapters.js.map +1 -1
- package/dist/durable-approval.d.ts +56 -0
- package/dist/durable-approval.d.ts.map +1 -0
- package/dist/durable-approval.js +233 -0
- package/dist/durable-approval.js.map +1 -0
- package/dist/durable-effect.d.ts +42 -0
- package/dist/durable-effect.d.ts.map +1 -0
- package/dist/durable-effect.js +234 -0
- package/dist/durable-effect.js.map +1 -0
- package/dist/durable-execution.d.ts +8 -346
- package/dist/durable-execution.d.ts.map +1 -1
- package/dist/durable-execution.js +7 -1150
- package/dist/durable-execution.js.map +1 -1
- package/dist/durable-saga.d.ts +94 -0
- package/dist/durable-saga.d.ts.map +1 -0
- package/dist/durable-saga.js +550 -0
- package/dist/durable-saga.js.map +1 -0
- 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 +23 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -2
- package/dist/index.js.map +1 -1
- package/dist/internal/durable-approval-protocol.d.ts +1 -1
- package/dist/internal/durable-approval-protocol.d.ts.map +1 -1
- package/dist/internal/durable-effect-protocol.d.ts +1 -1
- package/dist/internal/durable-effect-protocol.d.ts.map +1 -1
- package/dist/internal/durable-saga-protocol.d.ts +1 -1
- package/dist/internal/durable-saga-protocol.d.ts.map +1 -1
- package/dist/internal/durable-shared.d.ts +26 -0
- package/dist/internal/durable-shared.d.ts.map +1 -0
- package/dist/internal/durable-shared.js +131 -0
- package/dist/internal/durable-shared.js.map +1 -0
- package/dist/internal/durable-types.d.ts +159 -0
- package/dist/internal/durable-types.d.ts.map +1 -0
- package/dist/internal/durable-types.js +2 -0
- package/dist/internal/durable-types.js.map +1 -0
- package/dist/internal/route-compiler.d.ts +61 -0
- package/dist/internal/route-compiler.d.ts.map +1 -0
- package/dist/internal/route-compiler.js +121 -0
- package/dist/internal/route-compiler.js.map +1 -0
- package/dist/internal/route-execution.d.ts +17 -9
- package/dist/internal/route-execution.d.ts.map +1 -1
- package/dist/internal/route-execution.js +4 -18
- package/dist/internal/route-execution.js.map +1 -1
- package/dist/internal/route-lanes.d.ts +35 -0
- package/dist/internal/route-lanes.d.ts.map +1 -0
- package/dist/internal/route-lanes.js +150 -0
- package/dist/internal/route-lanes.js.map +1 -0
- package/dist/internal/route-program.d.ts +67 -0
- package/dist/internal/route-program.d.ts.map +1 -0
- package/dist/internal/route-program.js +199 -0
- package/dist/internal/route-program.js.map +1 -0
- 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-lane.d.ts +13 -0
- package/dist/server/body-lane.d.ts.map +1 -0
- package/dist/server/body-lane.js +134 -0
- package/dist/server/body-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/contract.d.ts +40 -14
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js +7 -0
- package/dist/server/contract.js.map +1 -1
- package/dist/server/cookies.d.ts.map +1 -1
- package/dist/server/cookies.js +16 -2
- package/dist/server/cookies.js.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/plugin.d.ts +2 -2
- package/dist/server/plugin.d.ts.map +1 -1
- package/dist/server/plugin.js +6 -1
- package/dist/server/plugin.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/registry.d.ts +40 -12
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +2 -13
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +18 -159
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +58 -4
- 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 +76 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js +42 -1
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +6 -1
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +158 -116
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +707 -844
- package/dist/server/server.js.map +1 -1
- package/dist/server/validation.d.ts +5 -0
- package/dist/server/validation.d.ts.map +1 -0
- package/dist/server/validation.js +13 -0
- package/dist/server/validation.js.map +1 -0
- 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 +12 -4
- package/dist/server/ws-bun.d.ts.map +1 -1
- package/dist/server/ws-bun.js +50 -16
- package/dist/server/ws-bun.js.map +1 -1
- package/dist/server/ws-hook.d.ts +3 -2
- package/dist/server/ws-hook.d.ts.map +1 -1
- package/dist/server.d.ts +3 -2
- 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 +472 -0
- package/dist/single-copy.js.map +1 -0
- package/package.json +28 -3
- package/src/assurance.ts +58 -1
- package/src/durable-adapters.ts +20 -0
- package/src/durable-approval.ts +281 -0
- package/src/durable-effect.ts +300 -0
- package/src/durable-execution.ts +8 -1627
- package/src/durable-saga.ts +744 -0
- package/src/edge-kit.ts +27 -0
- package/src/errors.ts +1 -0
- package/src/index.ts +129 -2
- package/src/internal/durable-approval-protocol.ts +1 -1
- package/src/internal/durable-effect-protocol.ts +1 -1
- package/src/internal/durable-saga-protocol.ts +1 -1
- package/src/internal/durable-shared.ts +157 -0
- package/src/internal/durable-types.ts +194 -0
- package/src/internal/route-compiler.ts +234 -0
- package/src/internal/route-execution.ts +53 -96
- package/src/internal/route-lanes.ts +221 -0
- package/src/internal/route-program.ts +404 -0
- package/src/response-observer.ts +64 -0
- package/src/security/baseline.ts +172 -0
- package/src/server/bare-error-lane.ts +77 -0
- package/src/server/body-lane.ts +203 -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/contract.ts +70 -20
- package/src/server/cookies.ts +16 -2
- 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/plugin.ts +11 -5
- package/src/server/query.ts +10 -4
- package/src/server/registry.ts +71 -16
- package/src/server/request-context.ts +13 -216
- package/src/server/respond.ts +71 -3
- package/src/server/response-contract-lane.ts +6 -2
- package/src/server/response-observer-runtime.ts +135 -0
- package/src/server/runtime-core.ts +124 -1
- package/src/server/server-types.ts +6 -1
- package/src/server/server.ts +1446 -1485
- package/src/server/validation.ts +19 -0
- package/src/server/websocket.ts +127 -17
- package/src/server/ws-bun.ts +67 -15
- package/src/server/ws-hook.ts +3 -2
- package/src/server.ts +8 -2
- package/src/single-copy-register.ts +24 -0
- package/src/single-copy.ts +526 -0
|
@@ -5,19 +5,13 @@
|
|
|
5
5
|
* sits below the server module in the graph.
|
|
6
6
|
*/
|
|
7
7
|
import type { RequestBudget } from "../budget.ts"
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
drainCapped,
|
|
11
|
-
hasTrustedBodyFraming,
|
|
12
|
-
parseContentLength,
|
|
13
|
-
RAW_BODY_READERS,
|
|
14
|
-
readBoundedBytes,
|
|
15
|
-
} from "./body.ts"
|
|
8
|
+
import { applyTransportCap, readBoundedBytes } from "./body.ts"
|
|
9
|
+
import { readBoundedJsonSource } from "./body-lane.ts"
|
|
16
10
|
import type { Platform } from "./context.ts"
|
|
17
11
|
import { type CookieOptions, cookieNamePrefix, parseCookies, serializeCookie } from "./cookies.ts"
|
|
18
12
|
import { headerObjectOf } from "./headers.ts"
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
13
|
+
import { plainError } from "./http.ts"
|
|
14
|
+
import type { ProtoPoisoning } from "./proto-guard.ts"
|
|
21
15
|
import { searchOf } from "./query.ts"
|
|
22
16
|
import { responseJsonContentType } from "./respond.ts"
|
|
23
17
|
import {
|
|
@@ -27,13 +21,13 @@ import {
|
|
|
27
21
|
getNeverAbortSignal,
|
|
28
22
|
getUnboundedRequestBudget,
|
|
29
23
|
headerOf,
|
|
30
|
-
|
|
31
|
-
type PreDecodedBody,
|
|
24
|
+
isResponseResult,
|
|
32
25
|
requestOf,
|
|
33
|
-
TEXT_DECODER,
|
|
34
26
|
} from "./runtime-core.ts"
|
|
35
27
|
import type { CtxSet, RawContext, RequestSource } from "./server.ts"
|
|
36
28
|
|
|
29
|
+
export { readBodyFramed, readBoundedJsonSource } from "./body-lane.ts"
|
|
30
|
+
|
|
37
31
|
/** A fixed past instant for cookie deletion (`Expires`). A literal epoch - deterministic, unlike an
|
|
38
32
|
* argless `new Date()`. */
|
|
39
33
|
const EPOCH = new Date(0)
|
|
@@ -390,7 +384,7 @@ export class RequestContext implements RawContext {
|
|
|
390
384
|
}
|
|
391
385
|
}
|
|
392
386
|
|
|
393
|
-
/** Backs `c.boundedBody`: bounded byte read that throws a flat 413/400
|
|
387
|
+
/** Backs `c.boundedBody`: bounded byte read that throws a flat 413/400 plain render (caught by
|
|
394
388
|
* `runLifecycle` as control flow, like `throw redirect(...)`), so a handler can't ignore the cap.
|
|
395
389
|
* The byte-cap itself lives in `./body.ts` (shared with the schema path and `verifyWebhook`). */
|
|
396
390
|
async function readBoundedBodyOrThrow(
|
|
@@ -401,8 +395,8 @@ async function readBoundedBodyOrThrow(
|
|
|
401
395
|
const r = await readBoundedBytes(req, maxBytes ?? maxBodyBytes)
|
|
402
396
|
if (r.ok) return r.bytes
|
|
403
397
|
throw r.status === 413
|
|
404
|
-
?
|
|
405
|
-
:
|
|
398
|
+
? plainError(413, "payload_too_large")
|
|
399
|
+
: plainError(400, "invalid_content_length")
|
|
406
400
|
}
|
|
407
401
|
|
|
408
402
|
/** Backs `c.boundedJson`: `readBoundedJsonSource`, throwing its flat 400/413 on failure. */
|
|
@@ -413,7 +407,9 @@ async function readBoundedJsonBodyOrThrow<T>(
|
|
|
413
407
|
maxBytes?: number,
|
|
414
408
|
): Promise<T> {
|
|
415
409
|
const parsed = await readBoundedJsonSource(req, maxBytes ?? maxBodyBytes, protoPoisoning)
|
|
416
|
-
|
|
410
|
+
// The lane's own failures are the only branded values it can produce: a parsed JSON body is
|
|
411
|
+
// `JSON.parse` output, which carries string keys only and so can never answer to a symbol brand.
|
|
412
|
+
if (isResponseResult(parsed)) throw parsed
|
|
417
413
|
return parsed as T
|
|
418
414
|
}
|
|
419
415
|
|
|
@@ -424,202 +420,3 @@ async function readBoundedJsonBodyOrThrow<T>(
|
|
|
424
420
|
* with an in-cap `Content-Length` take the runtime's fused `json()` and walk the parsed value;
|
|
425
421
|
* chunked or length-less requests fall back to the streaming byte-cap guard.
|
|
426
422
|
*/
|
|
427
|
-
/**
|
|
428
|
-
* Deliberately **not** `async`: the framed lane is the hot path for every JSON POST, and an async
|
|
429
|
-
* wrapper costs a coroutine frame, a wrapper promise, and an extra microtask hop on top of the one
|
|
430
|
-
* the body read already pays (~90ns/request on Node, measured). The lane returns the body read's
|
|
431
|
-
* own promise chain instead, and only the streaming fallback - which is already doing IO in a loop
|
|
432
|
-
* - runs as an async function. Callers that `await` the result are unaffected.
|
|
433
|
-
*/
|
|
434
|
-
type JsonResultContinuation<T> = (result: unknown | Response) => T | Promise<T>
|
|
435
|
-
type JsonErrorContinuation<T> = (error: unknown) => T | Promise<T>
|
|
436
|
-
type FramedJsonSource = RequestSource & {
|
|
437
|
-
readonly bytes?: () => Promise<Uint8Array>
|
|
438
|
-
readonly jsonWithByteLength?: () => Promise<{
|
|
439
|
-
readonly value: unknown
|
|
440
|
-
readonly byteLength: number
|
|
441
|
-
}>
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
export function readBoundedJsonSource(
|
|
445
|
-
req: RequestSource,
|
|
446
|
-
maxBytes: number,
|
|
447
|
-
protoPoisoning?: ProtoPoisoning,
|
|
448
|
-
): Promise<unknown | Response>
|
|
449
|
-
export function readBoundedJsonSource<T>(
|
|
450
|
-
req: RequestSource,
|
|
451
|
-
maxBytes: number,
|
|
452
|
-
protoPoisoning: ProtoPoisoning,
|
|
453
|
-
onResult: JsonResultContinuation<T>,
|
|
454
|
-
onError: JsonErrorContinuation<T>,
|
|
455
|
-
): Promise<T>
|
|
456
|
-
export function readBoundedJsonSource<T>(
|
|
457
|
-
req: RequestSource,
|
|
458
|
-
maxBytes: number,
|
|
459
|
-
protoPoisoning: ProtoPoisoning = "reject",
|
|
460
|
-
onResult?: JsonResultContinuation<T>,
|
|
461
|
-
onError?: JsonErrorContinuation<T>,
|
|
462
|
-
): Promise<unknown | Response | T> {
|
|
463
|
-
// A pre-parsing hook (the transport-codec lane) may have decoded the body already - its stash
|
|
464
|
-
// is taken verbatim: the stasher enforced its own byte cap and poisoning policy on text this
|
|
465
|
-
// lane never sees. One symbol read; a miss on ordinary requests costs a cache-line, not a parse.
|
|
466
|
-
const preDecoded = (req as { [PRE_DECODED_BODY]?: PreDecodedBody })[PRE_DECODED_BODY]
|
|
467
|
-
if (preDecoded !== undefined) {
|
|
468
|
-
return onResult === undefined
|
|
469
|
-
? Promise.resolve(preDecoded.value)
|
|
470
|
-
: Promise.resolve(preDecoded.value).then(onResult)
|
|
471
|
-
}
|
|
472
|
-
// Read through the pre-cap readers when the request is transport-capped: this lane enforces its
|
|
473
|
-
// own `maxBytes` (which may exceed the route's transport cap), so the shadowed user-facing
|
|
474
|
-
// readers must not narrow it.
|
|
475
|
-
// Keep the source's header dispatch local: the normal Node source exposes a direct `header`
|
|
476
|
-
// lookup, while a Web Request only exposes `Headers.get`. Rechecking that optional method through
|
|
477
|
-
// `headerOf` twice per body is measurable on the framed lane. The raw-reader shadow is still
|
|
478
|
-
// bypassed for reads, but header authority remains the original source (same semantics as before).
|
|
479
|
-
const raw = ((req as { [RAW_BODY_READERS]?: RequestSource })[RAW_BODY_READERS] ??
|
|
480
|
-
req) as FramedJsonSource
|
|
481
|
-
const sourceHeader = req.header
|
|
482
|
-
const declared =
|
|
483
|
-
sourceHeader === undefined
|
|
484
|
-
? req.headers.get("content-length")
|
|
485
|
-
: sourceHeader.call(req, "content-length")
|
|
486
|
-
if (declared !== null) {
|
|
487
|
-
// A present Content-Length must be a non-negative integer (HTTP grammar: `1*DIGIT`). A
|
|
488
|
-
// non-numeric / negative / fractional / exponential value (`Number()` would happily accept
|
|
489
|
-
// "abc"->NaN, "-5", "1.5", "1e3", "0x10") is malformed -> 400, rather than silently falling
|
|
490
|
-
// through to the streaming guard - which is an UPPER-bound cap only, so a lying SMALLER length
|
|
491
|
-
// would otherwise be read in full. Real HTTP servers only hand us a valid framed length; this
|
|
492
|
-
// hardens hand-built Requests (tests, the in-process client) and crafted input.
|
|
493
|
-
const length = parseContentLength(declared)
|
|
494
|
-
if (length === undefined)
|
|
495
|
-
return onResult === undefined
|
|
496
|
-
? Promise.resolve(jsonError(400, "invalid_content_length"))
|
|
497
|
-
: Promise.resolve(jsonError(400, "invalid_content_length")).then(onResult)
|
|
498
|
-
if (length > maxBytes)
|
|
499
|
-
return onResult === undefined
|
|
500
|
-
? Promise.resolve(jsonError(413, "payload_too_large"))
|
|
501
|
-
: Promise.resolve(jsonError(413, "payload_too_large")).then(onResult)
|
|
502
|
-
// Trusted framing (Bun's native route table, Deno.serve): the runtime's HTTP parser already
|
|
503
|
-
// delimited the bytes, so the declared length IS the transport frame and chunked routing is
|
|
504
|
-
// moot - the over-cap 413 above already enforced the byte cap. Take the runtime's fused
|
|
505
|
-
// decode+parse straight away, before the transfer-encoding probe that only exists to route
|
|
506
|
-
// *untrusted* Node/adapter sources to the streaming lane. Skipping that probe drops one
|
|
507
|
-
// `Headers.get()` from every Bun/Deno JSON POST; Node/adapter sources are never marked and fall
|
|
508
|
-
// through below. The parsed-value poisoning walk still applies.
|
|
509
|
-
if (hasTrustedBodyFraming(req)) {
|
|
510
|
-
return raw.json().then(
|
|
511
|
-
(parsed) => {
|
|
512
|
-
let guarded: unknown
|
|
513
|
-
try {
|
|
514
|
-
guarded = guardParsedValue(parsed, protoPoisoning)
|
|
515
|
-
} catch {
|
|
516
|
-
return onResult === undefined
|
|
517
|
-
? jsonError(400, "invalid_json")
|
|
518
|
-
: onResult(jsonError(400, "invalid_json"))
|
|
519
|
-
}
|
|
520
|
-
return onResult === undefined ? guarded : onResult(guarded)
|
|
521
|
-
},
|
|
522
|
-
() => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
|
|
523
|
-
)
|
|
524
|
-
}
|
|
525
|
-
const chunked =
|
|
526
|
-
(sourceHeader === undefined
|
|
527
|
-
? req.headers.get("transfer-encoding")
|
|
528
|
-
: sourceHeader.call(req, "transfer-encoding")) !== null
|
|
529
|
-
if (!chunked) {
|
|
530
|
-
const jsonWithByteLength = raw.jsonWithByteLength
|
|
531
|
-
if (jsonWithByteLength !== undefined) {
|
|
532
|
-
return jsonWithByteLength.call(raw).then(
|
|
533
|
-
({ value, byteLength }) => {
|
|
534
|
-
if (byteLength > length || byteLength > maxBytes) {
|
|
535
|
-
return onResult === undefined
|
|
536
|
-
? jsonError(413, "payload_too_large")
|
|
537
|
-
: onResult(jsonError(413, "payload_too_large"))
|
|
538
|
-
}
|
|
539
|
-
let guarded: unknown
|
|
540
|
-
try {
|
|
541
|
-
guarded = guardParsedValue(value, protoPoisoning)
|
|
542
|
-
} catch {
|
|
543
|
-
return onResult === undefined
|
|
544
|
-
? jsonError(400, "invalid_json")
|
|
545
|
-
: onResult(jsonError(400, "invalid_json"))
|
|
546
|
-
}
|
|
547
|
-
return onResult === undefined ? guarded : onResult(guarded)
|
|
548
|
-
},
|
|
549
|
-
() => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
|
|
550
|
-
)
|
|
551
|
-
}
|
|
552
|
-
const bytesReader = raw.bytes
|
|
553
|
-
if (bytesReader !== undefined) {
|
|
554
|
-
return bytesReader.call(raw).then(
|
|
555
|
-
(bytes) => {
|
|
556
|
-
if (bytes.byteLength > length || bytes.byteLength > maxBytes) {
|
|
557
|
-
return onResult === undefined
|
|
558
|
-
? jsonError(413, "payload_too_large")
|
|
559
|
-
: onResult(jsonError(413, "payload_too_large"))
|
|
560
|
-
}
|
|
561
|
-
try {
|
|
562
|
-
const parsed = parseJsonGuarded(TEXT_DECODER.decode(bytes), protoPoisoning)
|
|
563
|
-
return onResult === undefined ? parsed : onResult(parsed)
|
|
564
|
-
} catch {
|
|
565
|
-
return onResult === undefined
|
|
566
|
-
? jsonError(400, "invalid_json")
|
|
567
|
-
: onResult(jsonError(400, "invalid_json"))
|
|
568
|
-
}
|
|
569
|
-
},
|
|
570
|
-
() => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
|
|
571
|
-
)
|
|
572
|
-
}
|
|
573
|
-
// Read the actual framed bytes before parsing. A declared length is only a hint on Web
|
|
574
|
-
// Requests and adapter-shaped sources: a caller can construct a Request (or an adapter can
|
|
575
|
-
// decode/expand a body) whose header claims fewer bytes than the delivered payload. Calling
|
|
576
|
-
// native `json()` here would accept that understatement and bypass the cap. The byte lane
|
|
577
|
-
// already owns this post-read check; keeping the JSON lane on the same raw-byte read makes the
|
|
578
|
-
// trust boundary consistent. `Uint8Array(buffer)` is a view, not a copy.
|
|
579
|
-
return raw.arrayBuffer().then(
|
|
580
|
-
(buffer) => {
|
|
581
|
-
const bytes = new Uint8Array(buffer)
|
|
582
|
-
if (bytes.byteLength > length || bytes.byteLength > maxBytes) {
|
|
583
|
-
return onResult === undefined
|
|
584
|
-
? jsonError(413, "payload_too_large")
|
|
585
|
-
: onResult(jsonError(413, "payload_too_large"))
|
|
586
|
-
}
|
|
587
|
-
let parsed: unknown
|
|
588
|
-
try {
|
|
589
|
-
parsed = parseJsonGuarded(TEXT_DECODER.decode(bytes), protoPoisoning)
|
|
590
|
-
} catch {
|
|
591
|
-
return onResult === undefined
|
|
592
|
-
? jsonError(400, "invalid_json")
|
|
593
|
-
: onResult(jsonError(400, "invalid_json"))
|
|
594
|
-
}
|
|
595
|
-
return onResult === undefined ? parsed : onResult(parsed)
|
|
596
|
-
},
|
|
597
|
-
() => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
|
|
598
|
-
)
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
const streamed = readStreamedJsonSource(raw, maxBytes, protoPoisoning)
|
|
602
|
-
return onResult === undefined ? streamed : streamed.then(onResult, onError)
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
/** The shared rejection handler for a body read that never completed (client abort, socket error,
|
|
606
|
-
* malformed framing): the same flat 400 a syntactically invalid body gets. Hoisted so the framed
|
|
607
|
-
* lane allocates no per-request closure for it. */
|
|
608
|
-
const INVALID_JSON = (): Response => jsonError(400, "invalid_json")
|
|
609
|
-
|
|
610
|
-
/** The chunked / length-less fallback: drain under the streaming byte cap, then parse guarded. */
|
|
611
|
-
async function readStreamedJsonSource(
|
|
612
|
-
raw: RequestSource,
|
|
613
|
-
maxBytes: number,
|
|
614
|
-
protoPoisoning: ProtoPoisoning,
|
|
615
|
-
): Promise<unknown | Response> {
|
|
616
|
-
const body = raw.body
|
|
617
|
-
if (body === null) return jsonError(400, "invalid_json")
|
|
618
|
-
const drained = await drainCapped(body, maxBytes)
|
|
619
|
-
if (!drained.ok) return jsonError(413, "payload_too_large")
|
|
620
|
-
try {
|
|
621
|
-
return parseJsonGuarded(TEXT_DECODER.decode(drained.bytes), protoPoisoning)
|
|
622
|
-
} catch {
|
|
623
|
-
return jsonError(400, "invalid_json")
|
|
624
|
-
}
|
|
625
|
-
}
|
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.
|
|
@@ -109,6 +122,8 @@ function staticJsonResponse(
|
|
|
109
122
|
contentType,
|
|
110
123
|
)
|
|
111
124
|
}
|
|
125
|
+
// lgtm [js/stack-trace-exposure] `body` is already serialized; ordinary thrown errors are replaced
|
|
126
|
+
// with the fixed internal-error envelope before reaching this response boundary.
|
|
112
127
|
return new Response(body, init)
|
|
113
128
|
}
|
|
114
129
|
|
|
@@ -147,6 +162,7 @@ function withStaticHeaderInit(
|
|
|
147
162
|
// portable constructor path there and select the Deno-specific shape only for Web responses that
|
|
148
163
|
// carry a plain record. The first request lazily probes Deno's native JSON content-type so this
|
|
149
164
|
// preserves the runtime's Response.json contract instead of baking in Bun's charset suffix.
|
|
165
|
+
const IS_BUN = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined"
|
|
150
166
|
const IS_DENO = typeof (globalThis as { Deno?: unknown }).Deno !== "undefined"
|
|
151
167
|
let denoJsonContentType: string | undefined
|
|
152
168
|
|
|
@@ -163,6 +179,8 @@ function denoResponseWithJsonBody(
|
|
|
163
179
|
headers: Record<string, string>,
|
|
164
180
|
defaultContentType = responseJsonContentType(),
|
|
165
181
|
): Response {
|
|
182
|
+
// lgtm [js/stack-trace-exposure] this body is serialized data; ordinary thrown errors are replaced
|
|
183
|
+
// with the fixed internal-error envelope before reaching this response boundary.
|
|
166
184
|
const response = new Response(body, { status })
|
|
167
185
|
let hasContentType = false
|
|
168
186
|
for (const [name, value] of Object.entries(headers)) {
|
|
@@ -175,10 +193,28 @@ function denoResponseWithJsonBody(
|
|
|
175
193
|
return response
|
|
176
194
|
}
|
|
177
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Render a JSON value with caller-owned headers on the runtimes where mutating the response is
|
|
198
|
+
* cheaper than asking the Response constructor to ingest a record. Node deliberately stays on the
|
|
199
|
+
* record path: its undici implementation is a little faster there, and its production HTTP adapter
|
|
200
|
+
* normally uses the direct node outcome anyway. The two operations are equivalent at the Web
|
|
201
|
+
* boundary: Response.json supplies the same default content type, then the caller's headers override
|
|
202
|
+
* it exactly as `withJsonContentType` did.
|
|
203
|
+
*/
|
|
204
|
+
function jsonResponseWithOwnHeaders(
|
|
205
|
+
result: object,
|
|
206
|
+
status: number,
|
|
207
|
+
headers: Record<string, string>,
|
|
208
|
+
): Response {
|
|
209
|
+
const response = status === 200 ? Response.json(result) : Response.json(result, { status })
|
|
210
|
+
for (const name of Object.keys(headers)) response.headers.set(name, headers[name]!)
|
|
211
|
+
return stamped(response)
|
|
212
|
+
}
|
|
213
|
+
|
|
178
214
|
// The framework-buffered-body marker shared with the Node adapter: a Response carrying it exposes
|
|
179
215
|
// its already-serialized bytes without draining. On the Web serving paths it exists ONLY when a
|
|
180
216
|
// registered body/raw hook needs it, so hook-less apps pay nothing.
|
|
181
|
-
const RESPONSE_BODY = Symbol.for(
|
|
217
|
+
const RESPONSE_BODY = Symbol.for(NODE_BRIDGE_MARKER_KEYS.responseBody)
|
|
182
218
|
|
|
183
219
|
/**
|
|
184
220
|
* Headers objects the framework itself constructed - guaranteed mutable, no guard. Response
|
|
@@ -396,6 +432,18 @@ export function toResponse(
|
|
|
396
432
|
statics?: StaticResponseHeaders,
|
|
397
433
|
): Response {
|
|
398
434
|
if (isResponseResult(result)) {
|
|
435
|
+
const plain = result.plain
|
|
436
|
+
if (plain !== undefined) {
|
|
437
|
+
// Rendered as the value it is, on the branches below, rather than through `toResponse()`: the
|
|
438
|
+
// status and headers fold into a `set` and the body takes the ordinary JSON lane. One level
|
|
439
|
+
// only - a plain render's body is a user value, never another response carrier.
|
|
440
|
+
return toResponse(
|
|
441
|
+
plain.body as HandlerResult,
|
|
442
|
+
plainRenderSet(plain, set),
|
|
443
|
+
tagResponseBody,
|
|
444
|
+
statics,
|
|
445
|
+
)
|
|
446
|
+
}
|
|
399
447
|
return withStatics(
|
|
400
448
|
appendCookiesToResponse(normalizeBodylessResponse(result.toResponse()), set),
|
|
401
449
|
statics,
|
|
@@ -413,6 +461,24 @@ export function toResponse(
|
|
|
413
461
|
if (status === 204 || status === 205 || status === 304) {
|
|
414
462
|
return stamped(new Response(null, headers === undefined ? { status } : { status, headers }))
|
|
415
463
|
}
|
|
464
|
+
// Bun and Deno both make a JSON response first and then accept a few header mutations more cheaply
|
|
465
|
+
// than ingesting the same plain record in the constructor. This is the common dynamic-header
|
|
466
|
+
// shape (`c.set.headers`) after all cookies/static-header/tagging cases have been excluded. Keep the
|
|
467
|
+
// Node record path unchanged: its Web constructor wins by a small margin, and the native Node
|
|
468
|
+
// adapter's normal route finalizer does not build this Response in the first place.
|
|
469
|
+
if (
|
|
470
|
+
own !== undefined &&
|
|
471
|
+
!(own instanceof Headers) &&
|
|
472
|
+
statics === undefined &&
|
|
473
|
+
(set._cookies === undefined || set._cookies.length === 0) &&
|
|
474
|
+
(IS_BUN || IS_DENO) &&
|
|
475
|
+
!tagResponseBody &&
|
|
476
|
+
result !== undefined &&
|
|
477
|
+
typeof result === "object" &&
|
|
478
|
+
result !== null
|
|
479
|
+
) {
|
|
480
|
+
return jsonResponseWithOwnHeaders(result, status, own)
|
|
481
|
+
}
|
|
416
482
|
if (own === undefined && result !== undefined) {
|
|
417
483
|
// Fast respond (profiled ~50 ns/req faster on every plain-JSON return): `JSON.stringify` + a
|
|
418
484
|
// prebuilt init beats `Response.json`'s internal init handling. Output is byte-identical -
|
|
@@ -426,7 +492,8 @@ export function toResponse(
|
|
|
426
492
|
: { status, headers: jsonCtHeaders }
|
|
427
493
|
return tagged(
|
|
428
494
|
statics === undefined
|
|
429
|
-
?
|
|
495
|
+
? // lgtm [js/stack-trace-exposure] the serialized body is not a caught error stack.
|
|
496
|
+
new Response(body, init)
|
|
430
497
|
: staticJsonResponse(body, status, statics, JSON_CONTENT_TYPE, init),
|
|
431
498
|
body,
|
|
432
499
|
tagResponseBody,
|
|
@@ -446,7 +513,8 @@ export function toResponse(
|
|
|
446
513
|
headers as Record<string, string>,
|
|
447
514
|
JSON_CT_HEADERS.get("content-type") ?? "application/json;charset=utf-8",
|
|
448
515
|
)
|
|
449
|
-
:
|
|
516
|
+
: // lgtm [js/stack-trace-exposure] the serialized body is not a caught error stack.
|
|
517
|
+
new Response(body, { status, headers: withJsonContentType(headers) })
|
|
450
518
|
return tagged(
|
|
451
519
|
response,
|
|
452
520
|
body,
|
|
@@ -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
|
+
}
|