@nifrajs/core 5.8.0 → 5.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +118 -0
- package/README.md +9 -4
- package/dist/assurance.d.ts +26 -1
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +43 -11
- package/dist/assurance.js.map +1 -1
- package/dist/canonical-json.d.ts +11 -0
- package/dist/canonical-json.d.ts.map +1 -0
- package/dist/canonical-json.js +46 -0
- package/dist/canonical-json.js.map +1 -0
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +1 -8
- package/dist/capabilities.js.map +1 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +1 -8
- package/dist/data.js.map +1 -1
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +2 -12
- package/dist/diff.js.map +1 -1
- package/dist/durable-adapters.d.ts +2 -0
- package/dist/durable-adapters.d.ts.map +1 -1
- package/dist/durable-adapters.js +41 -49
- package/dist/durable-adapters.js.map +1 -1
- package/dist/durable-saga.d.ts.map +1 -1
- package/dist/durable-saga.js +14 -20
- package/dist/durable-saga.js.map +1 -1
- package/dist/evidence.d.ts.map +1 -1
- package/dist/evidence.js +14 -68
- package/dist/evidence.js.map +1 -1
- package/dist/idempotency.d.ts.map +1 -1
- package/dist/idempotency.js +60 -23
- package/dist/idempotency.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/internal/control-character.d.ts +3 -0
- package/dist/internal/control-character.d.ts.map +1 -0
- package/dist/internal/control-character.js +10 -0
- package/dist/internal/control-character.js.map +1 -0
- package/dist/internal/durable-shared.d.ts.map +1 -1
- package/dist/internal/durable-shared.js +4 -1
- package/dist/internal/durable-shared.js.map +1 -1
- package/dist/internal/hex.d.ts +5 -0
- package/dist/internal/hex.d.ts.map +1 -0
- package/dist/internal/hex.js +14 -0
- package/dist/internal/hex.js.map +1 -0
- package/dist/internal/record.d.ts +3 -0
- package/dist/internal/record.d.ts.map +1 -0
- package/dist/internal/record.js +5 -0
- package/dist/internal/record.js.map +1 -0
- package/dist/internal/route-compiler.d.ts.map +1 -1
- package/dist/internal/route-compiler.js +2 -2
- package/dist/internal/route-compiler.js.map +1 -1
- package/dist/internal/route-execution.d.ts +4 -4
- package/dist/internal/route-execution.d.ts.map +1 -1
- package/dist/internal/route-execution.js +4 -0
- package/dist/internal/route-execution.js.map +1 -1
- package/dist/internal/route-lanes.d.ts +4 -3
- package/dist/internal/route-lanes.d.ts.map +1 -1
- package/dist/internal/route-lanes.js +16 -33
- package/dist/internal/route-lanes.js.map +1 -1
- package/dist/internal/schema-parts.d.ts +13 -0
- package/dist/internal/schema-parts.d.ts.map +1 -0
- package/dist/internal/schema-parts.js +15 -0
- package/dist/internal/schema-parts.js.map +1 -0
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +2 -14
- package/dist/ledger.js.map +1 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +10 -37
- package/dist/manifest.js.map +1 -1
- package/dist/open-resolved.d.ts +18 -0
- package/dist/open-resolved.d.ts.map +1 -0
- package/dist/open-resolved.js +62 -0
- package/dist/open-resolved.js.map +1 -0
- package/dist/range.js +1 -1
- package/dist/range.js.map +1 -1
- package/dist/router/path-codec.d.ts +22 -0
- package/dist/router/path-codec.d.ts.map +1 -0
- package/dist/router/path-codec.js +87 -0
- package/dist/router/path-codec.js.map +1 -0
- package/dist/router/pattern.d.ts +21 -1
- package/dist/router/pattern.d.ts.map +1 -1
- package/dist/router/pattern.js +117 -0
- package/dist/router/pattern.js.map +1 -1
- package/dist/router/same-origin-path.d.ts +22 -0
- package/dist/router/same-origin-path.d.ts.map +1 -0
- package/dist/router/same-origin-path.js +37 -0
- package/dist/router/same-origin-path.js.map +1 -0
- package/dist/serve-static.d.ts +22 -0
- package/dist/serve-static.d.ts.map +1 -0
- package/dist/serve-static.js +187 -0
- package/dist/serve-static.js.map +1 -0
- package/dist/server/bare-error-lane.d.ts +2 -1
- package/dist/server/bare-error-lane.d.ts.map +1 -1
- package/dist/server/bare-error-lane.js +12 -3
- package/dist/server/bare-error-lane.js.map +1 -1
- package/dist/server/base64-answers.d.ts.map +1 -1
- package/dist/server/base64-answers.js +1 -4
- package/dist/server/base64-answers.js.map +1 -1
- package/dist/server/body-lane.d.ts.map +1 -1
- package/dist/server/body-lane.js +43 -38
- package/dist/server/body-lane.js.map +1 -1
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +4 -1
- package/dist/server/body.js.map +1 -1
- package/dist/server/bridge-markers.d.ts +1 -1
- package/dist/server/bridge-markers.js +1 -1
- package/dist/server/cookies.d.ts.map +1 -1
- package/dist/server/cookies.js +4 -1
- package/dist/server/cookies.js.map +1 -1
- package/dist/server/core-copies.js +1 -1
- package/dist/server/core-copies.js.map +1 -1
- package/dist/server/http.d.ts +6 -18
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +14 -33
- package/dist/server/http.js.map +1 -1
- package/dist/server/idempotency-lane.d.ts.map +1 -1
- package/dist/server/idempotency-lane.js +2 -0
- package/dist/server/idempotency-lane.js.map +1 -1
- package/dist/server/idle-timeout.d.ts +39 -0
- package/dist/server/idle-timeout.d.ts.map +1 -0
- package/dist/server/idle-timeout.js +95 -0
- package/dist/server/idle-timeout.js.map +1 -0
- package/dist/server/json-shared.d.ts +23 -0
- package/dist/server/json-shared.d.ts.map +1 -0
- package/dist/server/json-shared.js +33 -0
- package/dist/server/json-shared.js.map +1 -0
- package/dist/server/json-text.d.ts +1 -1
- package/dist/server/json-text.js +1 -1
- package/dist/server/node-outcome.js +1 -1
- package/dist/server/node-outcome.js.map +1 -1
- package/dist/server/request-context.d.ts +22 -1
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +44 -4
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/request-local.d.ts +44 -0
- package/dist/server/request-local.d.ts.map +1 -0
- package/dist/server/request-local.js +91 -0
- package/dist/server/request-local.js.map +1 -0
- package/dist/server/respond.js +1 -1
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +10 -2
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +93 -64
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/runtime-core.d.ts +7 -4
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js +9 -7
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +23 -8
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +58 -59
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +860 -510
- package/dist/server/server.js.map +1 -1
- package/dist/server/transport-lane.d.ts.map +1 -1
- package/dist/server/transport-lane.js +2 -0
- package/dist/server/transport-lane.js.map +1 -1
- package/dist/server/webhook.js +1 -1
- package/dist/server/webhook.js.map +1 -1
- package/dist/server/websocket.d.ts +3 -1
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +14 -1
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-adapter-view.d.ts +54 -0
- package/dist/server/ws-adapter-view.d.ts.map +1 -0
- package/dist/server/ws-adapter-view.js +7 -0
- package/dist/server/ws-adapter-view.js.map +1 -0
- 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.d.ts.map +1 -1
- package/dist/single-copy.js +1 -1
- package/dist/single-copy.js.map +1 -1
- package/dist/static-file.d.ts +156 -4
- package/dist/static-file.d.ts.map +1 -1
- package/dist/static-file.js +198 -4
- package/dist/static-file.js.map +1 -1
- package/dist/wire.d.ts.map +1 -1
- package/dist/wire.js +1 -3
- package/dist/wire.js.map +1 -1
- package/package.json +16 -1
- package/src/assurance.ts +66 -16
- package/src/canonical-json.ts +43 -0
- package/src/capabilities.ts +1 -8
- package/src/data.ts +2 -8
- package/src/diff.ts +2 -12
- package/src/durable-adapters.ts +53 -50
- package/src/durable-saga.ts +30 -23
- package/src/evidence.ts +25 -90
- package/src/idempotency.ts +62 -20
- package/src/index.ts +13 -2
- package/src/internal/control-character.ts +8 -0
- package/src/internal/durable-shared.ts +4 -1
- package/src/internal/hex.ts +14 -0
- package/src/internal/record.ts +4 -0
- package/src/internal/route-compiler.ts +2 -2
- package/src/internal/route-execution.ts +7 -20
- package/src/internal/route-lanes.ts +20 -44
- package/src/internal/schema-parts.ts +19 -0
- package/src/ledger.ts +2 -16
- package/src/manifest.ts +10 -38
- package/src/open-resolved.ts +64 -0
- package/src/range.ts +1 -1
- package/src/router/path-codec.ts +93 -0
- package/src/router/pattern.ts +127 -0
- package/src/router/same-origin-path.ts +36 -0
- package/src/serve-static.ts +227 -0
- package/src/server/bare-error-lane.ts +11 -2
- package/src/server/base64-answers.ts +2 -6
- package/src/server/body-lane.ts +58 -51
- package/src/server/body.ts +4 -1
- package/src/server/bridge-markers.ts +1 -1
- package/src/server/cookies.ts +4 -1
- package/src/server/core-copies.ts +1 -1
- package/src/server/http.ts +13 -33
- package/src/server/idempotency-lane.ts +2 -0
- package/src/server/idle-timeout.ts +107 -0
- package/src/server/json-shared.ts +38 -0
- package/src/server/json-text.ts +1 -1
- package/src/server/node-outcome.ts +1 -1
- package/src/server/request-context.ts +62 -3
- package/src/server/request-local.ts +109 -0
- package/src/server/respond.ts +1 -1
- package/src/server/response-contract-lane.ts +112 -65
- package/src/server/runtime-core.ts +10 -8
- package/src/server/server-types.ts +23 -8
- package/src/server/server.ts +1220 -798
- package/src/server/transport-lane.ts +2 -0
- package/src/server/webhook.ts +1 -1
- package/src/server/websocket.ts +18 -1
- package/src/server/ws-adapter-view.ts +63 -0
- package/src/server.ts +12 -1
- package/src/single-copy.ts +1 -3
- package/src/static-file.ts +350 -4
- package/src/wire.ts +2 -4
|
@@ -31,7 +31,8 @@ export type ErrorLogDetail = "full" | "message" | "none"
|
|
|
31
31
|
* - a `Response` or a `status(...)` is control flow - the app answering directly, or plain data.
|
|
32
32
|
* Either renders through the same `finalize` a RETURNED one takes, with the request's `c.set`,
|
|
33
33
|
* so a thrown one means what the return means: a `Response` ships as built, plus the cookies the
|
|
34
|
-
* request queued (the set-session-then-`throw redirect()` pattern);
|
|
34
|
+
* request queued (the set-session-then-`throw redirect()` pattern); one whose body cannot render
|
|
35
|
+
* is a fault like any other, never a rejected request;
|
|
35
36
|
* - a request body the app's own `c.req.json()` or `c.req.formData()` could not parse is the
|
|
36
37
|
* client's 400 (`invalid_json`, `invalid_form`);
|
|
37
38
|
* - anything else is a real fault: logged once, then a bodyless 500 (never the caller's error text).
|
|
@@ -48,7 +49,15 @@ export function renderBareError<T>(
|
|
|
48
49
|
logError: (err: unknown, ctx: RawContext) => void,
|
|
49
50
|
wrapResponse: (response: ResponseResult) => T,
|
|
50
51
|
): T {
|
|
51
|
-
if (err instanceof Response || isResponseResult(err))
|
|
52
|
+
if (err instanceof Response || isResponseResult(err)) {
|
|
53
|
+
try {
|
|
54
|
+
return finalize(err, responseSet(ctx), ctx)
|
|
55
|
+
} catch (renderErr) {
|
|
56
|
+
// A thrown answer whose body cannot render (a `toJSON` that throws) is a fault, not control flow.
|
|
57
|
+
logError(renderErr, ctx)
|
|
58
|
+
return wrapResponse(plainError(500, "internal_error"))
|
|
59
|
+
}
|
|
60
|
+
}
|
|
52
61
|
const clientCode = clientBodyErrorCode(err)
|
|
53
62
|
if (clientCode !== undefined) return wrapResponse(plainError(400, clientCode))
|
|
54
63
|
logError(err, ctx)
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { tagStandIn } from "../internal/hook-origins.ts"
|
|
12
12
|
import type { AnswerStages, JsonReplyContext } from "./answer-step.ts"
|
|
13
|
+
import { define } from "./json-shared.ts"
|
|
13
14
|
import { isResponseResult, status } from "./runtime-core.ts"
|
|
14
15
|
|
|
15
16
|
// Deeper than any real payload.
|
|
@@ -24,11 +25,6 @@ function base64(bytes: Uint8Array): string {
|
|
|
24
25
|
return btoa(binary)
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
function define(out: object, key: string, value: unknown): void {
|
|
28
|
-
// `out[key] = value` for "__proto__" would replace the prototype instead of adding the field.
|
|
29
|
-
Object.defineProperty(out, key, { value, enumerable: true, writable: true, configurable: true })
|
|
30
|
-
}
|
|
31
|
-
|
|
32
28
|
/**
|
|
33
29
|
* `value` with each `Uint8Array` as base64, copying only the containers above one. A container already
|
|
34
30
|
* on the walk's path is left as it is, so a cycle costs one step, as it does `JSON.stringify`, which
|
|
@@ -52,7 +48,7 @@ function encoded(value: unknown, depth: number, path: Set<object>): unknown {
|
|
|
52
48
|
}
|
|
53
49
|
return out ?? value
|
|
54
50
|
}
|
|
55
|
-
let out:
|
|
51
|
+
let out: Record<string, unknown> | undefined
|
|
56
52
|
for (const key of Object.keys(value)) {
|
|
57
53
|
const item: unknown = Reflect.get(value, key)
|
|
58
54
|
const next = encoded(item, depth + 1, path)
|
package/src/server/body-lane.ts
CHANGED
|
@@ -22,16 +22,25 @@ type JsonErrorContinuation<T> = (error: unknown) => T | Promise<T>
|
|
|
22
22
|
|
|
23
23
|
type FramedJsonSource = RequestSource & {
|
|
24
24
|
readonly bytes?: () => Promise<Uint8Array>
|
|
25
|
+
readonly text?: () => Promise<string>
|
|
25
26
|
readonly jsonWithByteLength?: () => Promise<{
|
|
26
27
|
readonly value: unknown
|
|
27
28
|
readonly byteLength: number
|
|
28
29
|
}>
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
// Every lane calls `onResult` outside its parse guard: a throw from the continuation is the app's,
|
|
33
|
+
// and must never come back to the client as `invalid_json`.
|
|
31
34
|
const INVALID_JSON = (): ResponseResult => plainError(400, "invalid_json")
|
|
35
|
+
// A parameter, not a closure over the caller's `settle`: capturing it there costs every call a scope object.
|
|
36
|
+
const fromBuffer =
|
|
37
|
+
<T>(settle: (bytes: Uint8Array) => T) =>
|
|
38
|
+
(buffer: ArrayBuffer): T =>
|
|
39
|
+
settle(new Uint8Array(buffer))
|
|
32
40
|
|
|
33
41
|
// `Deno.serve`'s own `Request.json()` keeps a leading UTF-8 byte order mark that its `text()` and every
|
|
34
|
-
// other runtime's `json()` drop, so it refuses a body they parse. Deno
|
|
42
|
+
// other runtime's `json()` drop, so it refuses a body they parse. Deno uses `text()` for an
|
|
43
|
+
// untouched, runtime-framed request, or decodes the checked bytes below for other sources.
|
|
35
44
|
const nativeJsonKeepsBom = "Deno" in globalThis
|
|
36
45
|
|
|
37
46
|
/** Read a JSON source with the declared-length precheck, delivered-byte recheck, streaming cap, and
|
|
@@ -76,17 +85,39 @@ export function readBoundedJsonSource<T>(
|
|
|
76
85
|
? Promise.resolve(plainError(413, "payload_too_large"))
|
|
77
86
|
: Promise.resolve(plainError(413, "payload_too_large")).then(onResult)
|
|
78
87
|
|
|
88
|
+
// A trusted runtime frame makes Content-Length the byte bound. Wrapped or untrusted sources
|
|
89
|
+
// must keep the delivered-byte check below, and chunked bodies must keep the streaming cap.
|
|
90
|
+
if (
|
|
91
|
+
nativeJsonKeepsBom &&
|
|
92
|
+
raw === req &&
|
|
93
|
+
hasTrustedBodyFraming(req) &&
|
|
94
|
+
raw.text !== undefined &&
|
|
95
|
+
headerOf(req, "transfer-encoding") === null
|
|
96
|
+
) {
|
|
97
|
+
return raw.text().then(
|
|
98
|
+
(text) => {
|
|
99
|
+
let parsed: unknown
|
|
100
|
+
try {
|
|
101
|
+
parsed = parseJsonGuarded(text, protoPoisoning)
|
|
102
|
+
} catch {
|
|
103
|
+
parsed = INVALID_JSON()
|
|
104
|
+
}
|
|
105
|
+
return onResult === undefined ? parsed : onResult(parsed)
|
|
106
|
+
},
|
|
107
|
+
() => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
79
111
|
if (hasTrustedBodyFraming(req) && !nativeJsonKeepsBom) {
|
|
80
112
|
return raw.json().then(
|
|
81
113
|
(parsed) => {
|
|
114
|
+
let guarded: unknown
|
|
82
115
|
try {
|
|
83
|
-
|
|
84
|
-
return onResult === undefined ? guarded : onResult(guarded)
|
|
116
|
+
guarded = guardParsedValue(parsed, protoPoisoning)
|
|
85
117
|
} catch {
|
|
86
|
-
|
|
87
|
-
? plainError(400, "invalid_json")
|
|
88
|
-
: onResult(plainError(400, "invalid_json"))
|
|
118
|
+
guarded = INVALID_JSON()
|
|
89
119
|
}
|
|
120
|
+
return onResult === undefined ? guarded : onResult(guarded)
|
|
90
121
|
},
|
|
91
122
|
() => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
|
|
92
123
|
)
|
|
@@ -102,60 +133,36 @@ export function readBoundedJsonSource<T>(
|
|
|
102
133
|
? plainError(413, "payload_too_large")
|
|
103
134
|
: onResult(plainError(413, "payload_too_large"))
|
|
104
135
|
}
|
|
136
|
+
let guarded: unknown
|
|
105
137
|
try {
|
|
106
|
-
|
|
107
|
-
return onResult === undefined ? guarded : onResult(guarded)
|
|
138
|
+
guarded = guardParsedValue(value, protoPoisoning)
|
|
108
139
|
} catch {
|
|
109
|
-
|
|
110
|
-
? plainError(400, "invalid_json")
|
|
111
|
-
: onResult(plainError(400, "invalid_json"))
|
|
140
|
+
guarded = INVALID_JSON()
|
|
112
141
|
}
|
|
142
|
+
return onResult === undefined ? guarded : onResult(guarded)
|
|
113
143
|
},
|
|
114
144
|
() => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
|
|
115
145
|
)
|
|
116
146
|
}
|
|
117
147
|
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
return onResult === undefined
|
|
132
|
-
? plainError(400, "invalid_json")
|
|
133
|
-
: onResult(plainError(400, "invalid_json"))
|
|
134
|
-
}
|
|
135
|
-
},
|
|
136
|
-
() => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
|
|
137
|
-
)
|
|
148
|
+
const settle = (bytes: Uint8Array) => {
|
|
149
|
+
if (bytes.byteLength > length || bytes.byteLength > maxBytes) {
|
|
150
|
+
return onResult === undefined
|
|
151
|
+
? plainError(413, "payload_too_large")
|
|
152
|
+
: onResult(plainError(413, "payload_too_large"))
|
|
153
|
+
}
|
|
154
|
+
let parsed: unknown
|
|
155
|
+
try {
|
|
156
|
+
parsed = parseJsonGuarded(TEXT_DECODER.decode(bytes), protoPoisoning)
|
|
157
|
+
} catch {
|
|
158
|
+
parsed = INVALID_JSON()
|
|
159
|
+
}
|
|
160
|
+
return onResult === undefined ? parsed : onResult(parsed)
|
|
138
161
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if (bytes.byteLength > length || bytes.byteLength > maxBytes) {
|
|
144
|
-
return onResult === undefined
|
|
145
|
-
? plainError(413, "payload_too_large")
|
|
146
|
-
: onResult(plainError(413, "payload_too_large"))
|
|
147
|
-
}
|
|
148
|
-
try {
|
|
149
|
-
const parsed = parseJsonGuarded(TEXT_DECODER.decode(bytes), protoPoisoning)
|
|
150
|
-
return onResult === undefined ? parsed : onResult(parsed)
|
|
151
|
-
} catch {
|
|
152
|
-
return onResult === undefined
|
|
153
|
-
? plainError(400, "invalid_json")
|
|
154
|
-
: onResult(plainError(400, "invalid_json"))
|
|
155
|
-
}
|
|
156
|
-
},
|
|
157
|
-
() => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
|
|
158
|
-
)
|
|
162
|
+
const refuse = () => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON()))
|
|
163
|
+
const bytesReader = raw.bytes
|
|
164
|
+
if (bytesReader !== undefined) return bytesReader.call(raw).then(settle, refuse)
|
|
165
|
+
return raw.arrayBuffer().then(fromBuffer(settle), refuse)
|
|
159
166
|
}
|
|
160
167
|
}
|
|
161
168
|
|
package/src/server/body.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { jsonError } from "./http.ts"
|
|
11
|
+
import { carryToCopy } from "./request-local.ts"
|
|
11
12
|
|
|
12
13
|
interface BodySource {
|
|
13
14
|
readonly headers: Pick<Headers, "get">
|
|
@@ -277,7 +278,9 @@ export function capBodyBytes(request: Request, maxBytes: number): Request {
|
|
|
277
278
|
),
|
|
278
279
|
duplex: "half",
|
|
279
280
|
}
|
|
280
|
-
|
|
281
|
+
const capped = new Request(request, init)
|
|
282
|
+
carryToCopy(request, capped)
|
|
283
|
+
return capped
|
|
281
284
|
}
|
|
282
285
|
|
|
283
286
|
/** First matching own-or-inherited descriptor, so instance shadows and prototype accessors both resolve. */
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Each entry names a symbol looked up with `Symbol.for(...)`, so the two packages agree ONLY by the
|
|
5
5
|
* string. A rename on one side and not the other is a silent protocol break - no type error, just a
|
|
6
6
|
* symbol that never matches and a body that stops taking the direct-write lane. This module is the
|
|
7
|
-
* single source: core defines its own symbols from these literals, and `scripts/gen-
|
|
7
|
+
* single source: core defines its own symbols from these literals, and `scripts/gen-vendored.ts`
|
|
8
8
|
* copies the map verbatim into the adapter's generated contract, so @nifrajs/node reads the identical
|
|
9
9
|
* strings without a runtime dependency on core (the same deployment shape the generated outcome type
|
|
10
10
|
* preserves).
|
package/src/server/cookies.ts
CHANGED
|
@@ -247,7 +247,10 @@ const toBase64Url = (buf: ArrayBuffer): string => {
|
|
|
247
247
|
let bin = ""
|
|
248
248
|
const bytes = new Uint8Array(buf)
|
|
249
249
|
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!)
|
|
250
|
-
return btoa(bin)
|
|
250
|
+
return btoa(bin)
|
|
251
|
+
.replace(/\+/g, "-")
|
|
252
|
+
.replace(/\//g, "_")
|
|
253
|
+
.replace(/={1,2}$/, "")
|
|
251
254
|
}
|
|
252
255
|
|
|
253
256
|
const BASE64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
|
|
@@ -26,7 +26,7 @@ if (!copies.includes(here) && copies.push(here) > 1) {
|
|
|
26
26
|
|
|
27
27
|
/** This copy's version: a literal like the package root's `VERSION` (no fs on the edge), which
|
|
28
28
|
* `scripts/version.ts` rewrites on every release and `check:publish` asserts. */
|
|
29
|
-
const COPY_VERSION = "5.
|
|
29
|
+
const COPY_VERSION = "5.10.0"
|
|
30
30
|
|
|
31
31
|
/** Record this copy's version beside its URL. Called by each `server()` behind the development guard,
|
|
32
32
|
* so a production bundle drops it with the version; a copy that built a server has recorded one. */
|
package/src/server/http.ts
CHANGED
|
@@ -5,39 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { type ResponseResult, status as statusResult } from "./runtime-core.ts"
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
* Is `value` a destination that stays on this origin?
|
|
10
|
-
*
|
|
11
|
-
* True only for an absolute path: one leading `/`, never `//` (protocol-relative → another origin),
|
|
12
|
-
* and free of any character a URL parser turns into an origin escape. A leading `/` alone is not
|
|
13
|
-
* enough: under a special scheme `\` parses as `/`, and tab/CR/LF are STRIPPED before parsing, so
|
|
14
|
-
* `/\evil.example` and `/<TAB>/evil.example` both resolve to the external host `evil.example` while
|
|
15
|
-
* passing a `//` test. Rejecting CR/LF here also keeps a validated destination out of the
|
|
16
|
-
* response-splitting sink it usually flows into.
|
|
17
|
-
*
|
|
18
|
-
* One implementation, because every caller is the same open-redirect gate: `redirect()` in
|
|
19
|
-
* `@nifrajs/web`, the `redirectTo` of the `@nifrajs/auth` and `@nifrajs/better-auth` guards. Three
|
|
20
|
-
* copies of a security predicate is three chances for one of them to be hardened alone.
|
|
21
|
-
*
|
|
22
|
-
* It answers about a PATH, not a URL: an absolute `https://this.host/x` is false even for the
|
|
23
|
-
* current origin, because the whole point is that the value never got to name a host.
|
|
24
|
-
*/
|
|
25
|
-
export function isSameOriginPath(value: string): boolean {
|
|
26
|
-
if (!value.startsWith("/") || value.startsWith("//")) return false
|
|
27
|
-
// Scanned by character code rather than matched by regex: no control characters have to be written
|
|
28
|
-
// into a pattern, and the check is a single pass over a short string.
|
|
29
|
-
for (let i = 1; i < value.length; i++) {
|
|
30
|
-
const code = value.charCodeAt(i)
|
|
31
|
-
if (code === 0x5c /* \ */ || code <= 0x1f || code === 0x7f) return false
|
|
32
|
-
}
|
|
33
|
-
// `/.//host` and `/a/..//host` (or their `%2e` forms) resolve to the path `//host`, which any
|
|
34
|
-
// relative reference later built from that path - a client router's redirect - reads as a host.
|
|
35
|
-
// A path-absolute reference against a valid base always parses, so this cannot throw.
|
|
36
|
-
return (
|
|
37
|
-
!(value.includes(".") || value.includes("%")) ||
|
|
38
|
-
!new URL(value, "http://n.invalid").pathname.startsWith("//")
|
|
39
|
-
)
|
|
40
|
-
}
|
|
8
|
+
export { isSameOriginPath } from "../router/same-origin-path.ts"
|
|
41
9
|
|
|
42
10
|
/**
|
|
43
11
|
* Whether `method` is a token a route can be registered under: uppercase ASCII letters, digits and
|
|
@@ -150,3 +118,15 @@ export function pathnameOf(url: string): string {
|
|
|
150
118
|
}
|
|
151
119
|
return url.slice(start, end)
|
|
152
120
|
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A `Vary` header value (`null` when the response has none) that names each of `names` once, matched
|
|
124
|
+
* without regard to case. `Vary: *` already covers every request header, so it comes back unchanged.
|
|
125
|
+
*/
|
|
126
|
+
export function varyOn(value: string | null, names: readonly string[]): string {
|
|
127
|
+
if (value === null || value.trim() === "") return names.join(", ")
|
|
128
|
+
const fields = value.split(",").map((field) => field.trim().toLowerCase())
|
|
129
|
+
if (fields.includes("*")) return value
|
|
130
|
+
const missing = names.filter((name) => !fields.includes(name.toLowerCase()))
|
|
131
|
+
return missing.length === 0 ? value : `${value}, ${missing.join(", ")}`
|
|
132
|
+
}
|
|
@@ -35,6 +35,7 @@ import { markTransportCap, readBoundedBytes, transportCapOf, UNLIMITED_BODY_BYTE
|
|
|
35
35
|
import type { Platform, RouteSchema } from "./context.ts"
|
|
36
36
|
import { jsonError, urlPartsOf } from "./http.ts"
|
|
37
37
|
import { INSTALL_IDEMPOTENCY } from "./install.ts"
|
|
38
|
+
import { carryToCopy } from "./request-local.ts"
|
|
38
39
|
import { CONTEXT_REQUEST, PRE_DECODED_BODY, preDecodedBody, requestOf } from "./runtime-core.ts"
|
|
39
40
|
import type { AnyServer, IdentityPlugin, RawContext, RequestSource } from "./server.ts"
|
|
40
41
|
|
|
@@ -467,6 +468,7 @@ function prepareRequest(source: RequestSource, platform: Platform | undefined):
|
|
|
467
468
|
duplex: "half",
|
|
468
469
|
}
|
|
469
470
|
prepared = new Request(request.url, init)
|
|
471
|
+
carryToCopy(request, prepared)
|
|
470
472
|
const decoded = preDecodedBody(request)
|
|
471
473
|
if (decoded !== undefined) Object.defineProperty(prepared, PRE_DECODED_BODY, { value: decoded })
|
|
472
474
|
} catch (error) {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `withIdleTimeout()`: one request's own idle timeout on a Bun `listen()` server, held while slow
|
|
3
|
+
* work runs and put back before the response goes out.
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from "./context.ts"
|
|
6
|
+
import { CONTEXT_IDLE_TIMEOUT, getNeverAbortSignal } from "./runtime-core.ts"
|
|
7
|
+
|
|
8
|
+
type MaybePromise<T> = T | Promise<T>
|
|
9
|
+
|
|
10
|
+
type IdleTimeoutControl = (this: unknown, seconds: number | undefined) => boolean
|
|
11
|
+
|
|
12
|
+
/** The `seconds` of every scope still running on a context. Deleted when the last one ends or the
|
|
13
|
+
* request is aborted, which also tells a scope ending after an abort to leave the socket alone. */
|
|
14
|
+
const scopesOf = new WeakMap<object, number[]>()
|
|
15
|
+
|
|
16
|
+
const longest = (scopes: readonly number[]): number =>
|
|
17
|
+
scopes.includes(0) ? 0 : Math.max(...scopes)
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Run `work` with the idle timeout of the request's connection set to `seconds` (`0` turns it off),
|
|
21
|
+
* then put back the listener's.
|
|
22
|
+
*
|
|
23
|
+
* Bun closes a connection that moves no bytes for `listen()`'s `idleTimeoutSec` seconds (10 by
|
|
24
|
+
* default), and a handler that is still working sends none, so an export or a long upstream call is
|
|
25
|
+
* cut mid-flight. Raising `idleTimeoutSec` raises it for every connection, idle keep-alive ones
|
|
26
|
+
* included; this raises it for one request. The listener's value is back before the response goes
|
|
27
|
+
* out because Bun keeps the last value on the socket for the requests that follow on it, so a value
|
|
28
|
+
* left in place would hold an idle connection open.
|
|
29
|
+
*
|
|
30
|
+
* The request's deadline (`requestTimeoutMs`, an inbound deadline) still bounds the response: when it
|
|
31
|
+
* fires, the 503 is sent with the listener's timeout back and `c.signal` aborts, but `work`
|
|
32
|
+
* keeps running unless it stops on that signal. Scopes nest: while several run on one request the
|
|
33
|
+
* longest applies, and `0` outlasts any number. `seconds` runs from 0 to 255, the longest Bun holds
|
|
34
|
+
* an idle connection; work that may idle longer takes `0`.
|
|
35
|
+
*
|
|
36
|
+
* A streamed response is sent after the handler returns, so call this inside the stream's producer
|
|
37
|
+
* to hold it until the last chunk. Off `listen()` - another adapter, `app.fetch` in a test - it runs
|
|
38
|
+
* `work` unchanged.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```ts
|
|
42
|
+
* import { withIdleTimeout } from "@nifrajs/core/idle-timeout"
|
|
43
|
+
*
|
|
44
|
+
* app.post("/exports", (c) => withIdleTimeout(c, 0, () => buildExport(c.body)))
|
|
45
|
+
* // Every route declared after this one:
|
|
46
|
+
* app.around((c, next) => withIdleTimeout(c, 120, next))
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export function withIdleTimeout<T>(
|
|
50
|
+
c: Context,
|
|
51
|
+
seconds: number,
|
|
52
|
+
work: () => MaybePromise<T>,
|
|
53
|
+
): MaybePromise<T> {
|
|
54
|
+
if (!Number.isSafeInteger(seconds) || seconds < 0 || seconds > 255) {
|
|
55
|
+
throw new RangeError("withIdleTimeout: seconds must be an integer from 0 to 255")
|
|
56
|
+
}
|
|
57
|
+
// biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: only RequestContext defines this key, with this signature; any other context leaves it undefined.
|
|
58
|
+
const control = (c as Context & { [CONTEXT_IDLE_TIMEOUT]?: IdleTimeoutControl })[
|
|
59
|
+
CONTEXT_IDLE_TIMEOUT
|
|
60
|
+
]
|
|
61
|
+
const signal = c.signal
|
|
62
|
+
if (control === undefined || signal.aborted) return work()
|
|
63
|
+
const set = (value: number | undefined): void => {
|
|
64
|
+
control.call(c, value)
|
|
65
|
+
}
|
|
66
|
+
let scopes = scopesOf.get(c)
|
|
67
|
+
if (scopes === undefined) {
|
|
68
|
+
scopes = []
|
|
69
|
+
scopesOf.set(c, scopes)
|
|
70
|
+
}
|
|
71
|
+
const own = scopes
|
|
72
|
+
own.push(seconds)
|
|
73
|
+
set(longest(own))
|
|
74
|
+
// An abort is the deadline's 503 or a closed client: either way the response is final, so the
|
|
75
|
+
// listener's timeout goes back now rather than when the abandoned work settles.
|
|
76
|
+
const abort = (): void => {
|
|
77
|
+
if (scopesOf.get(c) !== own) return
|
|
78
|
+
scopesOf.delete(c)
|
|
79
|
+
set(undefined)
|
|
80
|
+
}
|
|
81
|
+
// The never-abort signal is one object shared by every request without a deadline.
|
|
82
|
+
const listens = signal !== getNeverAbortSignal()
|
|
83
|
+
if (listens) signal.addEventListener("abort", abort, { once: true })
|
|
84
|
+
const end = (): void => {
|
|
85
|
+
if (listens) signal.removeEventListener("abort", abort)
|
|
86
|
+
if (scopesOf.get(c) !== own) return
|
|
87
|
+
own.splice(own.indexOf(seconds), 1)
|
|
88
|
+
if (own.length > 0) {
|
|
89
|
+
set(longest(own))
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
scopesOf.delete(c)
|
|
93
|
+
set(undefined)
|
|
94
|
+
}
|
|
95
|
+
let result: MaybePromise<T>
|
|
96
|
+
try {
|
|
97
|
+
result = work()
|
|
98
|
+
} catch (err) {
|
|
99
|
+
end()
|
|
100
|
+
throw err
|
|
101
|
+
}
|
|
102
|
+
if (!(result instanceof Promise)) {
|
|
103
|
+
end()
|
|
104
|
+
return result
|
|
105
|
+
}
|
|
106
|
+
return result.finally(end)
|
|
107
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What core's JSON walks share besides the writer: the `Date` hooks the response contract reads, the
|
|
3
|
+
* `__proto__`-safe field setter, and the plain-record tests canonical JSON uses. Apart from
|
|
4
|
+
* `json-text.ts` so the Node adapter's generated copy of that module holds only the writer.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const DATE_TO_JSON = Date.prototype.toJSON
|
|
8
|
+
// What `Date.prototype.toJSON` reads a `Date`'s time value through.
|
|
9
|
+
const DATE_TO_PRIMITIVE = Date.prototype[Symbol.toPrimitive]
|
|
10
|
+
const DATE_VALUE_OF = Date.prototype.valueOf
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Whether `date` reaches the `@@toPrimitive` and `valueOf` that `Date.prototype.toJSON` reads its time
|
|
14
|
+
* value through as they ship, on itself, its class or `Date.prototype`. Then its steps, taken by the
|
|
15
|
+
* caller, give what the call would: `null` for a time value that is not finite, else what its
|
|
16
|
+
* `toISOString` answers, called through it as `toJSON` calls it.
|
|
17
|
+
*/
|
|
18
|
+
export const readsShippedTime = (date: Date): boolean =>
|
|
19
|
+
date[Symbol.toPrimitive] === DATE_TO_PRIMITIVE && date.valueOf === DATE_VALUE_OF
|
|
20
|
+
|
|
21
|
+
/** Sets `key` on `out` as an own field, `__proto__` included, which an assignment takes as the prototype. */
|
|
22
|
+
export function define(out: Record<string, unknown>, key: string, value: unknown): void {
|
|
23
|
+
if (key === "__proto__")
|
|
24
|
+
Object.defineProperty(out, key, { value, enumerable: true, writable: true, configurable: true })
|
|
25
|
+
else out[key] = value
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: the member is optional, so an engine without JSON.rawJSON leaves it undefined.
|
|
29
|
+
export const isRawJson = (JSON as JSON & { readonly isRawJSON?: (value: unknown) => boolean })
|
|
30
|
+
.isRawJSON
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether `proto` is a plain record's prototype: `Object.prototype`, `null`, or any prototype whose own
|
|
34
|
+
* prototype is null, which admits another realm's plain object. A boxed primitive's or a class's is
|
|
35
|
+
* not. A `JSON.rawJSON` value's is `null` too, so a caller tells one apart first.
|
|
36
|
+
*/
|
|
37
|
+
export const isPlainPrototype = (proto: unknown): boolean =>
|
|
38
|
+
proto === null || proto === Object.prototype || Object.getPrototypeOf(proto) === null
|
package/src/server/json-text.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* id answered a 500. Every plain JSON writer goes through this instead: a `bigint` is written as its
|
|
4
4
|
* decimal string, the encoding OpenAPI generators and protobuf's JSON mapping use for 64-bit integers.
|
|
5
5
|
* Only a first attempt that threw is retried, so a value without one costs nothing more. The Node
|
|
6
|
-
* adapter runs a generated copy (scripts/gen-
|
|
6
|
+
* adapter runs a generated copy (scripts/gen-vendored.ts), so this module imports nothing.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
const bigintAsText = (_key: string, value: unknown): unknown =>
|
|
@@ -109,7 +109,7 @@ export function plainNodeOutcome(plain: PlainRender, set: CtxSet): NodeServeOutc
|
|
|
109
109
|
return {
|
|
110
110
|
kind: "json",
|
|
111
111
|
status: plain.status,
|
|
112
|
-
headers: plainRenderHeaders(plain, set),
|
|
112
|
+
headers: plainRenderHeaders(plain.headers, set._headers),
|
|
113
113
|
cookies: set._cookies,
|
|
114
114
|
body: plain.body === undefined || isBodylessStatus(plain.status) ? null : jsonText(plain.body),
|
|
115
115
|
}
|
|
@@ -17,7 +17,12 @@ import { jsonText } from "./json-text.ts"
|
|
|
17
17
|
import type { ProtoPoisoning } from "./proto-guard.ts"
|
|
18
18
|
import { searchOf } from "./query.ts"
|
|
19
19
|
import { residentText, responseJsonContentType } from "./respond.ts"
|
|
20
|
+
import type {
|
|
21
|
+
ResponseContractDefinition,
|
|
22
|
+
ResponseContractRuntime,
|
|
23
|
+
} from "./response-contract-lane.ts"
|
|
20
24
|
import {
|
|
25
|
+
CONTEXT_IDLE_TIMEOUT,
|
|
21
26
|
CONTEXT_REQUEST,
|
|
22
27
|
CONTEXT_SEARCH,
|
|
23
28
|
CONTEXT_SET,
|
|
@@ -166,8 +171,40 @@ const TEXT_CONTENT_TYPE = "text/plain; charset=utf-8"
|
|
|
166
171
|
* handler never reads it costs the adapter neither a lookup nor a platform object of its own. */
|
|
167
172
|
export const PLATFORM_PEER = Symbol()
|
|
168
173
|
|
|
174
|
+
/** Where a Bun listener leaves the idle timeout of a request's connection: called with the request
|
|
175
|
+
* and the seconds to allow, or `undefined` to put back the listener's own. */
|
|
176
|
+
export const PLATFORM_IDLE_TIMEOUT = Symbol()
|
|
177
|
+
|
|
178
|
+
/** The request the adapter received, on a platform made for one request: the routed source may be a
|
|
179
|
+
* rewritten or stripped copy, which Bun does not know. */
|
|
180
|
+
export const PLATFORM_REQUEST = Symbol()
|
|
181
|
+
|
|
169
182
|
export type PeerPlatform = Platform & {
|
|
170
183
|
readonly [PLATFORM_PEER]?: (source: RequestSource) => string | undefined
|
|
184
|
+
readonly [PLATFORM_IDLE_TIMEOUT]?: (source: RequestSource, seconds: number | undefined) => void
|
|
185
|
+
readonly [PLATFORM_REQUEST]?: RequestSource
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Whether merging `decorations` into a context can complete. A decoration named for a read-only
|
|
190
|
+
* accessor of the context (`req`, `set`, `signal`) makes `Object.assign` throw on every request, a
|
|
191
|
+
* failure the general program routes to the route's error hooks and a fused lane would not.
|
|
192
|
+
*/
|
|
193
|
+
export function decorationsAssignable(decorations: Record<PropertyKey, unknown>): boolean {
|
|
194
|
+
for (const key of Reflect.ownKeys(decorations)) {
|
|
195
|
+
for (
|
|
196
|
+
let proto: object | null = RequestContext.prototype;
|
|
197
|
+
proto !== null && proto !== Object.prototype;
|
|
198
|
+
proto = Object.getPrototypeOf(proto)
|
|
199
|
+
) {
|
|
200
|
+
const descriptor = Object.getOwnPropertyDescriptor(proto, key)
|
|
201
|
+
if (descriptor === undefined) continue
|
|
202
|
+
if (descriptor.get !== undefined && descriptor.set === undefined) return false
|
|
203
|
+
if (descriptor.writable === false) return false
|
|
204
|
+
break
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return true
|
|
171
208
|
}
|
|
172
209
|
|
|
173
210
|
export class RequestContext implements RawContext {
|
|
@@ -190,6 +227,13 @@ export class RequestContext implements RawContext {
|
|
|
190
227
|
private declare cookiesValue: Readonly<Record<string, string>> | undefined
|
|
191
228
|
declare jsonReply: Response | undefined
|
|
192
229
|
declare jsonBody: unknown
|
|
230
|
+
/** The route's response contract, if it has one, for {@link json} to write by. */
|
|
231
|
+
declare jsonContract:
|
|
232
|
+
| {
|
|
233
|
+
readonly runtime: ResponseContractRuntime
|
|
234
|
+
readonly definition: ResponseContractDefinition
|
|
235
|
+
}
|
|
236
|
+
| undefined
|
|
193
237
|
private declare readonly source: RequestSource
|
|
194
238
|
private declare readonly maxBodyBytes: number
|
|
195
239
|
private declare readonly protoPoisoning: ProtoPoisoning
|
|
@@ -264,6 +308,14 @@ export class RequestContext implements RawContext {
|
|
|
264
308
|
return requestOf(this.source)
|
|
265
309
|
}
|
|
266
310
|
|
|
311
|
+
[CONTEXT_IDLE_TIMEOUT](seconds: number | undefined): boolean {
|
|
312
|
+
const platform = this.platformValue
|
|
313
|
+
const set = platform?.[PLATFORM_IDLE_TIMEOUT]
|
|
314
|
+
if (set === undefined) return false
|
|
315
|
+
set(platform?.[PLATFORM_REQUEST] ?? this.source, seconds)
|
|
316
|
+
return true
|
|
317
|
+
}
|
|
318
|
+
|
|
267
319
|
get [CONTEXT_SEARCH](): string {
|
|
268
320
|
this.searchValue ??= searchOf(this.source.url)
|
|
269
321
|
return this.searchValue
|
|
@@ -336,6 +388,13 @@ export class RequestContext implements RawContext {
|
|
|
336
388
|
*/
|
|
337
389
|
json(body: unknown, init?: ResponseInit | number): Response {
|
|
338
390
|
const i = statusInit(init)
|
|
391
|
+
// A contracted route's value is written here once, as its contract judges it: the bytes and the
|
|
392
|
+
// check share one call of each `toJSON` hook, a `Date`'s included.
|
|
393
|
+
const contract = this.jsonContract
|
|
394
|
+
const value =
|
|
395
|
+
contract?.runtime.write === undefined
|
|
396
|
+
? body
|
|
397
|
+
: contract.runtime.write(contract.definition, body, i?.status)
|
|
339
398
|
let reply: Response | undefined
|
|
340
399
|
if (DEFERS_RESPONSE) {
|
|
341
400
|
const defer = deferredResponderFor()
|
|
@@ -344,17 +403,17 @@ export class RequestContext implements RawContext {
|
|
|
344
403
|
// (`undefined`, a function, a symbol) - exactly the case where `Response.json` throws, so it
|
|
345
404
|
// gets the throw rather than an invented body. The content-type is the runtime's own, read
|
|
346
405
|
// off `Response.json` once, so a deferred response carries the same one byte for byte.
|
|
347
|
-
const text = defer === undefined ? undefined : (jsonText(
|
|
406
|
+
const text = defer === undefined ? undefined : (jsonText(value) as string | undefined)
|
|
348
407
|
const headers =
|
|
349
408
|
text === undefined ? undefined : ownHeaderRecord(i?.headers, responseJsonContentType())
|
|
350
409
|
if (defer !== undefined && text !== undefined && headers !== undefined) {
|
|
351
410
|
reply = defer(text, i?.status ?? 200, headers)
|
|
352
411
|
}
|
|
353
412
|
}
|
|
354
|
-
reply ??= markResidentBody(jsonResponse(
|
|
413
|
+
reply ??= markResidentBody(jsonResponse(value, i))
|
|
355
414
|
// Remembered so a response contract holds the value to the route's schema: this is the
|
|
356
415
|
// framework's JSON helper, not a raw-Response escape hatch.
|
|
357
|
-
this.jsonBody =
|
|
416
|
+
this.jsonBody = value
|
|
358
417
|
this.jsonReply = reply
|
|
359
418
|
return reply
|
|
360
419
|
}
|