@nifrajs/core 2.13.0 → 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/binary.d.ts +32 -0
- package/dist/binary.d.ts.map +1 -1
- package/dist/binary.js +17 -0
- package/dist/binary.js.map +1 -1
- package/dist/durable-adapters.d.ts.map +1 -1
- package/dist/durable-adapters.js +21 -50
- package/dist/durable-adapters.js.map +1 -1
- package/dist/durable-execution.d.ts.map +1 -1
- package/dist/durable-execution.js +49 -69
- package/dist/durable-execution.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/durable-approval-protocol.d.ts +13 -0
- package/dist/internal/durable-approval-protocol.d.ts.map +1 -0
- package/dist/internal/durable-approval-protocol.js +66 -0
- package/dist/internal/durable-approval-protocol.js.map +1 -0
- package/dist/internal/durable-effect-protocol.d.ts +14 -0
- package/dist/internal/durable-effect-protocol.d.ts.map +1 -0
- package/dist/internal/durable-effect-protocol.js +16 -0
- package/dist/internal/durable-effect-protocol.js.map +1 -0
- package/dist/internal/durable-saga-protocol.d.ts +8 -0
- package/dist/internal/durable-saga-protocol.d.ts.map +1 -0
- package/dist/internal/durable-saga-protocol.js +21 -0
- package/dist/internal/durable-saga-protocol.js.map +1 -0
- package/dist/internal/route-catalog.d.ts +3 -0
- package/dist/internal/route-catalog.d.ts.map +1 -1
- package/dist/internal/route-catalog.js +5 -0
- package/dist/internal/route-catalog.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/plugin.d.ts +1 -1
- package/dist/server/plugin.d.ts.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 +11 -2
- package/dist/server/registry.d.ts.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 +82 -45
- 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 +18 -1
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +89 -48
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +397 -276
- 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/binary.ts +35 -0
- package/src/durable-adapters.ts +26 -51
- package/src/durable-execution.ts +46 -88
- package/src/edge-kit.ts +27 -0
- package/src/errors.ts +1 -0
- package/src/index.ts +1 -1
- package/src/internal/durable-approval-protocol.ts +92 -0
- package/src/internal/durable-effect-protocol.ts +37 -0
- package/src/internal/durable-saga-protocol.ts +55 -0
- package/src/internal/route-catalog.ts +6 -0
- 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/plugin.ts +1 -1
- package/src/server/query.ts +10 -4
- package/src/server/registry.ts +19 -4
- package/src/server/request-context.ts +106 -51
- 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 +18 -1
- package/src/server/server.ts +607 -376
- 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/server.ts
CHANGED
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
type RouteEntry,
|
|
42
42
|
} from "../internal/route-execution.ts"
|
|
43
43
|
import { isSameOriginRequest } from "../internal/same-origin.ts"
|
|
44
|
+
import type { ResponseObserverPlugin } from "../response-observer.ts"
|
|
44
45
|
import { compileRoutePattern, decodeRouteParams } from "../router/pattern.ts"
|
|
45
46
|
import { EMPTY_PARAMS, type Method, Router } from "../router/router.ts"
|
|
46
47
|
import type {
|
|
@@ -49,11 +50,13 @@ import type {
|
|
|
49
50
|
StandardResult,
|
|
50
51
|
StandardSchemaV1,
|
|
51
52
|
} from "../schema/standard.ts"
|
|
53
|
+
import { emitRequestErrorLog, renderBareError } from "./bare-error-lane.ts"
|
|
52
54
|
import {
|
|
53
55
|
assertByteLimit,
|
|
54
56
|
markTransportCap,
|
|
55
57
|
markTrustedBodyFraming,
|
|
56
58
|
type RawBodyReaders,
|
|
59
|
+
UNLIMITED_BODY_BYTES,
|
|
57
60
|
} from "./body.ts"
|
|
58
61
|
import { type ClientIpTrust, resolveClientIp } from "./client-ip.ts"
|
|
59
62
|
import type { Context, Platform, ResponseControls, RouteSchema } from "./context.ts"
|
|
@@ -63,19 +66,18 @@ import {
|
|
|
63
66
|
markLowercaseHeaderKeys,
|
|
64
67
|
} from "./header-case.ts"
|
|
65
68
|
import { headerObjectOf } from "./headers.ts"
|
|
66
|
-
import { jsonError, pathnameOf, type UrlParts, urlPartsOf } from "./http.ts"
|
|
69
|
+
import { jsonError, pathnameOf, plainError, type UrlParts, urlPartsOf } from "./http.ts"
|
|
67
70
|
import { type NodeServeOutcome, withStaticNodeHeaders } from "./node-outcome.ts"
|
|
68
|
-
import {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
recordHeadersView,
|
|
71
|
+
import type {
|
|
72
|
+
NodeOutcomeRuntime,
|
|
73
|
+
NodeRequestContext,
|
|
74
|
+
NodeRequestHook,
|
|
75
|
+
NodeResponseContext,
|
|
76
|
+
NodeResponseHook,
|
|
77
|
+
ResponseBodyHook,
|
|
78
|
+
ResponseBodyReplacement,
|
|
79
|
+
ResponseHeadersHook,
|
|
80
|
+
ResponseHeadersView,
|
|
79
81
|
} from "./node-outcome-hook.ts"
|
|
80
82
|
import {
|
|
81
83
|
isUrlEncodedForm,
|
|
@@ -84,21 +86,21 @@ import {
|
|
|
84
86
|
readBoundedForm,
|
|
85
87
|
searchOf,
|
|
86
88
|
} from "./query.ts"
|
|
87
|
-
import { RequestContext, readBoundedJsonSource } from "./request-context.ts"
|
|
89
|
+
import { RequestContext, readBodyFramed, readBoundedJsonSource } from "./request-context.ts"
|
|
88
90
|
import {
|
|
89
91
|
applyStaticResponseHeaders,
|
|
90
92
|
buildStaticResponseHeaders,
|
|
91
93
|
fusedRespond,
|
|
92
94
|
fusedRespondNoSet,
|
|
93
|
-
knownMutableHeaders,
|
|
94
|
-
rememberMutableHeaders,
|
|
95
|
-
taggedResponseBody,
|
|
96
95
|
toResponse,
|
|
97
96
|
} from "./respond.ts"
|
|
98
97
|
// Type-only: erased, so the kernel never pulls the lane's implementation into a bundle that does not
|
|
99
98
|
// install the plugin. The value side arrives through the symbol-keyed install seam.
|
|
100
99
|
import type { ResponseContractRuntime } from "./response-contract-lane.ts"
|
|
101
|
-
import {
|
|
100
|
+
import type {
|
|
101
|
+
ResponseObserverMethods,
|
|
102
|
+
ResponseObserverRuntime,
|
|
103
|
+
} from "./response-observer-runtime.ts"
|
|
102
104
|
import {
|
|
103
105
|
CONTEXT_SEARCH,
|
|
104
106
|
CONTEXT_SET,
|
|
@@ -107,7 +109,10 @@ import {
|
|
|
107
109
|
getUnboundedRequestBudget,
|
|
108
110
|
type HandlerResult,
|
|
109
111
|
headerOf,
|
|
112
|
+
isResponseResult,
|
|
113
|
+
type ResponseResult,
|
|
110
114
|
requestOf,
|
|
115
|
+
status,
|
|
111
116
|
} from "./runtime-core.ts"
|
|
112
117
|
import { normalizeStaticResponseHeaders, type StaticResponseHeaders } from "./static-headers.ts"
|
|
113
118
|
|
|
@@ -125,96 +130,6 @@ export type {
|
|
|
125
130
|
ResponseHeadersView,
|
|
126
131
|
}
|
|
127
132
|
|
|
128
|
-
const HEADER_MUTABILITY_PROBE = "x-nifra-header-probe"
|
|
129
|
-
const GUARDED_RESPONSE_HEADERS = new WeakSet<Headers>()
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Detect a guarded Web Headers object before invoking user code. Retrying after catching any
|
|
133
|
-
* TypeError is observably wrong: user hooks are allowed to throw TypeError themselves, and a retry
|
|
134
|
-
* runs those hooks twice. The hot path never reaches the probe: every framework-constructed
|
|
135
|
-
* Response stamps its headers as known-mutable at construction (see respond.ts), so only a
|
|
136
|
-
* handler-returned foreign `Response` - the case that can actually be guarded - pays it, once per
|
|
137
|
-
* headers object. The probe uses a valid private header name and restores a pre-existing value, so
|
|
138
|
-
* the fallback is limited to the actual immutable/guarded-header case.
|
|
139
|
-
*/
|
|
140
|
-
function hasMutableResponseHeaders(headers: Headers): boolean {
|
|
141
|
-
if (knownMutableHeaders(headers)) return true
|
|
142
|
-
if (GUARDED_RESPONSE_HEADERS.has(headers)) return false
|
|
143
|
-
let previous: string | null = null
|
|
144
|
-
try {
|
|
145
|
-
previous = headers.get(HEADER_MUTABILITY_PROBE)
|
|
146
|
-
headers.set(HEADER_MUTABILITY_PROBE, "1")
|
|
147
|
-
if (previous === null) headers.delete(HEADER_MUTABILITY_PROBE)
|
|
148
|
-
else headers.set(HEADER_MUTABILITY_PROBE, previous)
|
|
149
|
-
rememberMutableHeaders(headers)
|
|
150
|
-
return true
|
|
151
|
-
} catch {
|
|
152
|
-
try {
|
|
153
|
-
if (previous === null) headers.delete(HEADER_MUTABILITY_PROBE)
|
|
154
|
-
else headers.set(HEADER_MUTABILITY_PROBE, previous)
|
|
155
|
-
} catch {
|
|
156
|
-
// The guarded object rejected the cleanup too; the clone below is authoritative.
|
|
157
|
-
}
|
|
158
|
-
GUARDED_RESPONSE_HEADERS.add(headers)
|
|
159
|
-
return false
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** Adapt a portable {@link ResponseBodyHook} into the Web `onResponse` walk. Only a Response
|
|
164
|
-
* carrying the framework-buffered body tag participates - a raw or streamed Response is skipped by
|
|
165
|
-
* contract, never drained. */
|
|
166
|
-
function webResponseBodyHook(
|
|
167
|
-
fn: ResponseBodyHook,
|
|
168
|
-
owners: ReadonlySet<object>,
|
|
169
|
-
): (response: Response, req: Request) => MaybePromise<Response> {
|
|
170
|
-
return (response, req) => {
|
|
171
|
-
const body = taggedResponseBody(response, owners)
|
|
172
|
-
if (body === undefined) return response
|
|
173
|
-
const out = fn(body, response.headers, webRequestView(req), response.status)
|
|
174
|
-
if (out instanceof Promise) return out.then((replaced) => withReplacedBody(response, replaced))
|
|
175
|
-
return withReplacedBody(response, out)
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/** Minimal request view over a Web `Request` for portable header hooks on the Web serving paths. */
|
|
180
|
-
function webRequestView(req: Request): NodeRequestContext {
|
|
181
|
-
return { method: req.method, url: req.url, header: (name) => req.headers.get(name) }
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Adapt a portable {@link ResponseHeadersHook} into the Web `onResponse` walk: run it against the
|
|
186
|
-
* response's own `Headers` in place (no clone). A response whose headers are GUARDED (a raw
|
|
187
|
-
* `fetch()`ed Response returned by a handler) throws on the FIRST mutation, so nothing was applied
|
|
188
|
-
* yet - rerun once against a mutable copy. Async hooks should perform their mutations before their
|
|
189
|
-
* first await for the guard fallback to cover them.
|
|
190
|
-
*/
|
|
191
|
-
function webResponseHeadersHook(
|
|
192
|
-
fn: ResponseHeadersHook,
|
|
193
|
-
): (response: Response, req: Request) => MaybePromise<Response> {
|
|
194
|
-
return (response, req) => {
|
|
195
|
-
const view = webRequestView(req)
|
|
196
|
-
if (!hasMutableResponseHeaders(response.headers)) {
|
|
197
|
-
const clone = new Response(response.body, response)
|
|
198
|
-
const out = fn(clone.headers, view, clone.status)
|
|
199
|
-
return out instanceof Promise ? out.then(() => clone) : clone
|
|
200
|
-
}
|
|
201
|
-
const out = fn(response.headers, view, response.status)
|
|
202
|
-
return out instanceof Promise ? out.then(() => response) : response
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/** Adapt a raw-response fallback hook. Tagged framework payloads already ran through the body tier,
|
|
207
|
-
* while untagged Responses include streams, proxied fetches, and framework-generated error responses. */
|
|
208
|
-
function webResponseRawHook(
|
|
209
|
-
fn: (response: Response, req: Request) => MaybePromise<Response>,
|
|
210
|
-
owners: ReadonlySet<object>,
|
|
211
|
-
): (response: Response, req: Request) => MaybePromise<Response> {
|
|
212
|
-
return (response, req) => {
|
|
213
|
-
if (taggedResponseBody(response, owners) !== undefined) return response
|
|
214
|
-
return fn(response, req)
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
133
|
import type { IdempotencyRuntime } from "./idempotency-lane.ts"
|
|
219
134
|
import {
|
|
220
135
|
INSTALL_EFFECT_LEDGER,
|
|
@@ -222,13 +137,21 @@ import {
|
|
|
222
137
|
INSTALL_MCP,
|
|
223
138
|
INSTALL_NODE_DIRECT,
|
|
224
139
|
INSTALL_RESPONSE_CONTRACT,
|
|
140
|
+
INSTALL_RESPONSE_OBSERVER,
|
|
225
141
|
INSTALL_SSE,
|
|
226
142
|
INSTALL_WS,
|
|
143
|
+
NODE_NATIVE_MOUNT,
|
|
144
|
+
RESOLVE_NODE_MOUNT,
|
|
227
145
|
} from "./install.ts"
|
|
228
146
|
import type { EffectLedgerRuntime, ResolvedEffectLedger } from "./ledger-lane.ts"
|
|
229
147
|
import { jsonLogger, type Logger } from "./logger.ts"
|
|
230
148
|
import type { McpRuntime } from "./mcp-hook.ts"
|
|
231
|
-
import type {
|
|
149
|
+
import type {
|
|
150
|
+
ContextPlugin,
|
|
151
|
+
IdentityPlugin,
|
|
152
|
+
PluginTypeCollapsed,
|
|
153
|
+
ServerTypeUnpinned,
|
|
154
|
+
} from "./plugin.ts"
|
|
232
155
|
import type {
|
|
233
156
|
AddRoute,
|
|
234
157
|
EmptyRegistry,
|
|
@@ -358,6 +281,22 @@ interface BunUpgradeServer {
|
|
|
358
281
|
|
|
359
282
|
type MountedFetchHandler = (request: Request, platform?: Platform) => MaybePromise<Response>
|
|
360
283
|
|
|
284
|
+
/** Structural native mount contract. The serving adapter owns the concrete Node request/response
|
|
285
|
+
* types; the kernel only selects a handler after proving that taking this lane cannot skip its
|
|
286
|
+
* global lifecycle. `false` means the capability was unavailable at runtime, so the adapter must
|
|
287
|
+
* retry the ordinary Web mount with the same untouched source. */
|
|
288
|
+
type NativeMountHandler = (
|
|
289
|
+
request: unknown,
|
|
290
|
+
response: unknown,
|
|
291
|
+
platform?: Platform,
|
|
292
|
+
) => MaybePromise<undefined | false>
|
|
293
|
+
|
|
294
|
+
interface NativeMountSelection {
|
|
295
|
+
readonly handler: NativeMountHandler
|
|
296
|
+
readonly path: string
|
|
297
|
+
readonly stripPrefix: boolean
|
|
298
|
+
}
|
|
299
|
+
|
|
361
300
|
interface FetchMount {
|
|
362
301
|
readonly path: string
|
|
363
302
|
readonly handler: MountedFetchHandler
|
|
@@ -427,10 +366,11 @@ function requireMcpRuntime(runtime: McpRuntime | undefined): McpRuntime {
|
|
|
427
366
|
}
|
|
428
367
|
|
|
429
368
|
/** The handler's permitted return type. When the route declares a `response` schema, the return is
|
|
430
|
-
* constrained to the contract's type (
|
|
431
|
-
* declared
|
|
369
|
+
* constrained to the contract's type (a raw `Response`, or a `status(...)` early exit - both are
|
|
370
|
+
* control flow, not the declared payload) - so the implementation can't drift from the declared
|
|
371
|
+
* contract. Without a `response` schema it's unconstrained (`HandlerResult`), exactly as before. */
|
|
432
372
|
type ResponseOf<S extends RouteSchema> = S extends { response: infer R extends StandardSchemaV1 }
|
|
433
|
-
? InferOutput<R> | Response
|
|
373
|
+
? InferOutput<R> | Response | ResponseResult
|
|
434
374
|
: HandlerResult
|
|
435
375
|
|
|
436
376
|
/**
|
|
@@ -492,12 +432,23 @@ const STOP_HOOK_TIMEOUT_MS = 5_000
|
|
|
492
432
|
* for the same request, so a browser that could open a socket was told its POST was cross-origin. */
|
|
493
433
|
const wsSameOrigin = isSameOriginRequest
|
|
494
434
|
|
|
495
|
-
function
|
|
435
|
+
function validationIssues(issues: ReadonlyArray<StandardIssue>): {
|
|
436
|
+
ok: false
|
|
437
|
+
error: string
|
|
438
|
+
issues: unknown[]
|
|
439
|
+
} {
|
|
496
440
|
const serialized = issues.map((issue) => {
|
|
497
441
|
const path = issue.path?.map((seg) => String(typeof seg === "object" ? seg.key : seg))
|
|
498
442
|
return path !== undefined ? { message: issue.message, path } : { message: issue.message }
|
|
499
443
|
})
|
|
500
|
-
return
|
|
444
|
+
return { ok: false, error: "validation", issues: serialized }
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** The 422 as plain data - the shape every lane's response wrapper takes, so a rejected body costs
|
|
448
|
+
* what an accepted one costs: on Node it is written straight to the socket with a `content-length`
|
|
449
|
+
* instead of being built as a `Response` and drained back out. */
|
|
450
|
+
export function plainValidationError(issues: ReadonlyArray<StandardIssue>): ResponseResult {
|
|
451
|
+
return status(422, validationIssues(issues))
|
|
501
452
|
}
|
|
502
453
|
|
|
503
454
|
// `jsonError`, `urlPartsOf`, `pathnameOf` moved to `./http.ts` (a dependency-free leaf shared with the
|
|
@@ -552,6 +503,21 @@ function responseSet(ctx: RawContext): CtxSet {
|
|
|
552
503
|
return ctx[CONTEXT_SET]() ?? EMPTY_RESPONSE_CONTROLS
|
|
553
504
|
}
|
|
554
505
|
|
|
506
|
+
/**
|
|
507
|
+
* A `derive` that answered with a response rather than a context extension - `status(...)`, or a bare
|
|
508
|
+
* `Response`. The request ends there, on the same lane a `beforeHandle`'s early return takes.
|
|
509
|
+
*
|
|
510
|
+
* `derive` is the one stage that could not exit early: `beforeHandle` says "done" by returning a
|
|
511
|
+
* value, but a derive's return value IS the extension, so the only way out was `throw`ing a
|
|
512
|
+
* `Response` - which measured as the single most expensive way to answer (the `Response` object
|
|
513
|
+
* costs ~40% of the rejection lane; see `PlainRender`). Neither shape had a meaning here before:
|
|
514
|
+
* `Object.assign(ctx, response)` copies nothing (a `Response`'s properties live on its prototype),
|
|
515
|
+
* so a derive that returned one silently fell through to the handler it meant to prevent.
|
|
516
|
+
*/
|
|
517
|
+
function isDeriveEarlyExit(extension: unknown): boolean {
|
|
518
|
+
return isResponseResult(extension) || extension instanceof Response
|
|
519
|
+
}
|
|
520
|
+
|
|
555
521
|
function isContextlessNoArgArrow(handler: (context: never) => unknown): boolean {
|
|
556
522
|
if (handler.length !== 0) return false
|
|
557
523
|
try {
|
|
@@ -572,10 +538,117 @@ type FeatureVersionOf<V extends string> = V extends `${infer Major}.${infer Mino
|
|
|
572
538
|
*/
|
|
573
539
|
export type NifraFeatureVersion = FeatureVersionOf<Version>
|
|
574
540
|
|
|
541
|
+
/**
|
|
542
|
+
* The Web lanes' `wrapResponse` input, as a `Response`. A plain-data carrier ({@link plainError},
|
|
543
|
+
* `status(...)`) is rendered on the ordinary JSON lane rather than through its own `toResponse`, so
|
|
544
|
+
* it picks up the same prebuilt init a handler's plain return uses. `EMPTY_RESPONSE_CONTROLS` because
|
|
545
|
+
* these renders happen where no request context exists - before routing, or after it was abandoned.
|
|
546
|
+
*/
|
|
547
|
+
const webResponseOf = (result: Response | ResponseResult): Response =>
|
|
548
|
+
result instanceof Response ? result : toResponse(result, EMPTY_RESPONSE_CONTROLS)
|
|
549
|
+
|
|
575
550
|
// Stable module-level finalizers so `fetch`/`resolveNode` allocate no per-request closures.
|
|
576
|
-
const IDENTITY_RESPONSE = (response: Response): Response => response
|
|
551
|
+
const IDENTITY_RESPONSE = (response: Response | ResponseResult): Response => webResponseOf(response)
|
|
577
552
|
const RESPONSE_TIMEOUT = (): Response => jsonError(503, "request_timeout")
|
|
578
553
|
|
|
554
|
+
// The unused-order-scoped-hook audit (recorder, seal check, stack capture, message) is a dev-time
|
|
555
|
+
// diagnostic. Every guard below keeps the INLINE literal `process.env.NODE_ENV !== "production"` at the
|
|
556
|
+
// branch - never a shared const holding the folded boolean - because a bundler's `process.env.NODE_ENV`
|
|
557
|
+
// define only dead-code-eliminates the audit when the literal appears at the `if`; Bun does not
|
|
558
|
+
// propagate a module const's value into the branch. Every real production bundler (vite/esbuild/webpack/
|
|
559
|
+
// next, `bun build --production`) injects that define, turning each guard into `hookAuditRuntime &&
|
|
560
|
+
// false`, which folds away - dropping the whole audit and restoring the bare kernel size.
|
|
561
|
+
//
|
|
562
|
+
// `hookAuditRuntime` is the one shared flag: `typeof globalThis.Deno === "undefined"`. Deno ships a
|
|
563
|
+
// node-compat `process`, so `typeof process` does NOT gate it out there, yet reading `process.env`
|
|
564
|
+
// throws `NotCapable` without `--allow-env`. The flag short-circuits before the env read on Deno (audit
|
|
565
|
+
// off) while a production define still folds `<flag> && false` to `false` on every other runtime.
|
|
566
|
+
const hookAuditRuntime = typeof (globalThis as { Deno?: unknown }).Deno === "undefined"
|
|
567
|
+
|
|
568
|
+
// This module's own path, captured once from a sentinel stack. Every internal frame of an
|
|
569
|
+
// order-scoped hook push (the public method, its `use()`-bundle dispatcher, the recorder) lives here,
|
|
570
|
+
// so `captureCallerSite` skips anything starting with it and lands on the user's calling frame.
|
|
571
|
+
const SERVER_MODULE_PATH = /* @__PURE__ */ ((): string | undefined => {
|
|
572
|
+
const frame = new Error().stack?.split("\n")[1]
|
|
573
|
+
return frame?.match(/\(?([^()\s]+):\d+:\d+\)?\s*$/)?.[1]
|
|
574
|
+
})()
|
|
575
|
+
|
|
576
|
+
/** One `file:line:col` frame for the caller of an order-scoped hook method, for the unused-hook
|
|
577
|
+
* report. Only ever called behind the `unusedScopedHooks !== "off"` guard, so the `Error` (the sole
|
|
578
|
+
* non-trivial cost) is never constructed when the check is disabled. */
|
|
579
|
+
const captureCallerSite = (): string | undefined => {
|
|
580
|
+
const stack = new Error().stack
|
|
581
|
+
if (stack === undefined) return undefined
|
|
582
|
+
for (const line of stack.split("\n").slice(1)) {
|
|
583
|
+
const frame = line.match(/\(?([^()\s]+:\d+:\d+)\)?\s*$/)?.[1]
|
|
584
|
+
if (frame === undefined) continue
|
|
585
|
+
if (SERVER_MODULE_PATH !== undefined && frame.startsWith(`${SERVER_MODULE_PATH}:`)) continue
|
|
586
|
+
return frame
|
|
587
|
+
}
|
|
588
|
+
return undefined
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Per-server bookkeeping for the unused-order-scoped-hook audit. Deliberately NOT `Server` fields or
|
|
592
|
+
* methods: those emit class members (a field declaration, a method shell) that a bundler cannot remove
|
|
593
|
+
* even after their bodies fold away. Held here in a module WeakMap and reached only through the
|
|
594
|
+
* functions below, every call guarded by `process.env.NODE_ENV !== "production"`, so a production define
|
|
595
|
+
* folds the guards away, leaves this map and these functions unreferenced, and the bundler eliminates
|
|
596
|
+
* the whole audit - restoring the bare kernel size. */
|
|
597
|
+
interface HookAuditState {
|
|
598
|
+
readonly policy: "warn" | "error" | "off"
|
|
599
|
+
readonly hasCustomLogger: boolean
|
|
600
|
+
readonly sites: {
|
|
601
|
+
readonly kind: string
|
|
602
|
+
readonly routesAtPush: number
|
|
603
|
+
readonly site: string | undefined
|
|
604
|
+
}[]
|
|
605
|
+
checked: boolean
|
|
606
|
+
}
|
|
607
|
+
const hookAudit = /* @__PURE__ */ new WeakMap<object, HookAuditState>()
|
|
608
|
+
|
|
609
|
+
/** Start tracking one server's order-scoped hook pushes (once, from the constructor). */
|
|
610
|
+
const beginHookAudit = (
|
|
611
|
+
key: object,
|
|
612
|
+
policy: "warn" | "error" | "off",
|
|
613
|
+
hasCustomLogger: boolean,
|
|
614
|
+
): void => {
|
|
615
|
+
hookAudit.set(key, { policy, hasCustomLogger, sites: [], checked: false })
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** Record one order-scoped hook push: the route count at that moment (so `routesAtPush === total` at
|
|
619
|
+
* seal means no route was declared after it) and the caller's frame. */
|
|
620
|
+
const recordScopedHook = (key: object, routesAtPush: number, kind: string): void => {
|
|
621
|
+
const state = hookAudit.get(key)
|
|
622
|
+
if (state === undefined || state.policy === "off") return
|
|
623
|
+
state.sites.push({ kind, routesAtPush, site: captureCallerSite() })
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** Run the deadness check exactly once. A hook whose recorded route count equals the final route count
|
|
627
|
+
* had no route declared after it - unambiguously dead, since the scoping feature (a hook that covers
|
|
628
|
+
* only later routes) always leaves at least one such route. Zero-route apps are exempt (different
|
|
629
|
+
* mistake, and the warning would be noise). */
|
|
630
|
+
const sealHookAudit = (
|
|
631
|
+
key: object,
|
|
632
|
+
total: number,
|
|
633
|
+
logger: { warn(message: string): void },
|
|
634
|
+
): void => {
|
|
635
|
+
const state = hookAudit.get(key)
|
|
636
|
+
if (state === undefined || state.checked) return
|
|
637
|
+
state.checked = true
|
|
638
|
+
if (state.policy === "off" || total === 0) return
|
|
639
|
+
const dead = state.sites.filter((hook) => hook.routesAtPush === total)
|
|
640
|
+
if (dead.length === 0) return
|
|
641
|
+
const detail = dead.map((hook) => ` ${hook.kind}() at ${hook.site ?? "<unknown>"}`).join("\n")
|
|
642
|
+
const message =
|
|
643
|
+
`[nifra] ${dead.length} order-scoped hook(s) apply to no route - they were added after the ` +
|
|
644
|
+
`last route was registered, and register() snapshots the chain into each route as it is ` +
|
|
645
|
+
`declared:\n${detail}\nMove them before the routes they should cover. App-global hooks ` +
|
|
646
|
+
`(onRequest/onResponse) are not order-scoped and can be added at any time.`
|
|
647
|
+
if (state.policy === "error") throw new FrameworkError("UNUSED_SCOPED_HOOKS", message)
|
|
648
|
+
if (state.hasCustomLogger) logger.warn(message)
|
|
649
|
+
else console.warn(message)
|
|
650
|
+
}
|
|
651
|
+
|
|
579
652
|
/**
|
|
580
653
|
* The inline server. Routes are chainable and fully type-inferred. `derive`/
|
|
581
654
|
* `decorate` extend the handler context (`Ctx`) for routes defined *after* them,
|
|
@@ -677,7 +750,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
677
750
|
private staticResponseHeaders: StaticResponseHeaders | undefined
|
|
678
751
|
/** `wrapResponse` for the Web lanes: identity until static headers exist to fold into the
|
|
679
752
|
* framework's own error/404/timeout renders, which are built outside the header init. */
|
|
680
|
-
private wrapWebResponse: (response: Response) => Response
|
|
753
|
+
private wrapWebResponse: (response: Response | ResponseResult) => Response
|
|
681
754
|
private webResponseTimeout: () => Response
|
|
682
755
|
/** Body/raw response hooks need a framework-payload marker; keep that decision per app. */
|
|
683
756
|
private responseBodyTag: object | undefined
|
|
@@ -744,6 +817,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
744
817
|
this.defaultOnValidationError = options.onValidationError
|
|
745
818
|
this.bunServer = undefined
|
|
746
819
|
this.sealed = false
|
|
820
|
+
// Guarded so a production define strips the call, leaving `beginHookAudit`/`hookAudit` unreferenced
|
|
821
|
+
// for the bundler to eliminate; a non-bundled production run skips it the same way.
|
|
822
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
|
|
823
|
+
beginHookAudit(this, options.unusedScopedHooks ?? "warn", options.logger !== undefined)
|
|
824
|
+
}
|
|
747
825
|
this.derives = []
|
|
748
826
|
this.decorations = {}
|
|
749
827
|
this.beforeHandleHooks = []
|
|
@@ -812,17 +890,37 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
812
890
|
return this
|
|
813
891
|
}
|
|
814
892
|
|
|
815
|
-
/**
|
|
816
|
-
|
|
893
|
+
/**
|
|
894
|
+
* Add a per-request, computed context extension. Order-scoped: it is snapshotted into every route
|
|
895
|
+
* declared *after* this call, and reaches none declared before. One added after the last route
|
|
896
|
+
* (e.g. on the app returned by a route-registering factory) covers nothing - see
|
|
897
|
+
* {@link ServerOptions.unusedScopedHooks}.
|
|
898
|
+
*
|
|
899
|
+
* Returning a `status(...)` (or a `Response`) instead of an extension ends the request there - the
|
|
900
|
+
* guard shape, and the cheap one: it renders as plain data on the same lane a handler's return
|
|
901
|
+
* takes, where `throw new Response(...)` pays ~40% of the rejection lane to build the object. That
|
|
902
|
+
* branch is control flow, so it does not widen the derived context type.
|
|
903
|
+
*/
|
|
904
|
+
derive<D extends object>(
|
|
905
|
+
fn: (context: Context & Ctx) => MaybePromise<D | ResponseResult | Response>,
|
|
906
|
+
): Server<R, Ctx & D> {
|
|
817
907
|
this.assertConfigurable("derive()")
|
|
818
908
|
this.derives.push(fn as unknown as RawDerive)
|
|
909
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
910
|
+
recordScopedHook(this, this.catalog.size, "derive")
|
|
819
911
|
return this as unknown as Server<R, Ctx & D>
|
|
820
912
|
}
|
|
821
913
|
|
|
822
|
-
/**
|
|
914
|
+
/**
|
|
915
|
+
* Add a static context value. Order-scoped: captured by every route declared *after* this call, and
|
|
916
|
+
* by none before; one added after the last route reaches nothing
|
|
917
|
+
* (see {@link ServerOptions.unusedScopedHooks}).
|
|
918
|
+
*/
|
|
823
919
|
decorate<const K extends string, V>(key: K, value: V): Server<R, Ctx & Record<K, V>> {
|
|
824
920
|
this.assertConfigurable("decorate()")
|
|
825
921
|
this.decorations[key] = value
|
|
922
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
923
|
+
recordScopedHook(this, this.catalog.size, "decorate")
|
|
826
924
|
return this as unknown as Server<R, Ctx & Record<K, V>>
|
|
827
925
|
}
|
|
828
926
|
|
|
@@ -840,10 +938,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
840
938
|
return this
|
|
841
939
|
}
|
|
842
940
|
|
|
843
|
-
/**
|
|
941
|
+
/**
|
|
942
|
+
* Run after validation, before the handler; a non-`undefined` return short-circuits. Order-scoped:
|
|
943
|
+
* it is snapshotted into every route declared *after* this call and covers none declared before, so
|
|
944
|
+
* one added after the last route reaches nothing (see {@link ServerOptions.unusedScopedHooks}).
|
|
945
|
+
*/
|
|
844
946
|
beforeHandle(fn: (context: Context & Ctx) => MaybePromise<unknown>): this {
|
|
845
947
|
this.assertConfigurable("beforeHandle()")
|
|
846
948
|
this.beforeHandleHooks.push(fn as unknown as RawBeforeHandle)
|
|
949
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
950
|
+
recordScopedHook(this, this.catalog.size, "beforeHandle")
|
|
847
951
|
return this
|
|
848
952
|
}
|
|
849
953
|
|
|
@@ -851,10 +955,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
851
955
|
* Wrap the matched route lifecycle for subsequent routes. This is intentionally generic over the
|
|
852
956
|
* route output, so wrappers like async context storage do not force Node's direct JSON path through
|
|
853
957
|
* a Web `Response`. The first registered wrapper is outermost.
|
|
958
|
+
*
|
|
959
|
+
* Order-scoped: captured by every route declared *after* this call, none before; one added after the
|
|
960
|
+
* last route reaches nothing (see {@link ServerOptions.unusedScopedHooks}).
|
|
854
961
|
*/
|
|
855
962
|
around(fn: <T>(context: Context & Ctx, next: () => MaybePromise<T>) => MaybePromise<T>): this {
|
|
856
963
|
this.assertConfigurable("around()")
|
|
857
964
|
this.aroundHooks.push(fn as unknown as RawAround)
|
|
965
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
966
|
+
recordScopedHook(this, this.catalog.size, "around")
|
|
858
967
|
return this
|
|
859
968
|
}
|
|
860
969
|
|
|
@@ -879,6 +988,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
879
988
|
this.capabilityInterceptors.push(
|
|
880
989
|
Object.freeze({ interceptor, timeoutMs: Math.min(timeoutMs, 2_147_483_647) }),
|
|
881
990
|
)
|
|
991
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
992
|
+
recordScopedHook(this, this.catalog.size, "aroundCapability")
|
|
882
993
|
return this
|
|
883
994
|
}
|
|
884
995
|
|
|
@@ -892,17 +1003,25 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
892
1003
|
return this
|
|
893
1004
|
}
|
|
894
1005
|
|
|
895
|
-
/** Transform the handler's result before it is serialized. Order-scoped
|
|
1006
|
+
/** Transform the handler's result before it is serialized. Order-scoped: captured by routes declared
|
|
1007
|
+
* *after* this call, none before; one added after the last route reaches nothing (see
|
|
1008
|
+
* {@link ServerOptions.unusedScopedHooks}). */
|
|
896
1009
|
afterHandle(fn: (result: unknown, context: Context & Ctx) => MaybePromise<unknown>): this {
|
|
897
1010
|
this.assertConfigurable("afterHandle()")
|
|
898
1011
|
this.afterHandleHooks.push(fn as unknown as RawAfterHandle)
|
|
1012
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
1013
|
+
recordScopedHook(this, this.catalog.size, "afterHandle")
|
|
899
1014
|
return this
|
|
900
1015
|
}
|
|
901
1016
|
|
|
902
|
-
/** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500).
|
|
1017
|
+
/** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500).
|
|
1018
|
+
* Order-scoped: captured by routes declared *after* this call, none before; one added after the last
|
|
1019
|
+
* route reaches nothing (see {@link ServerOptions.unusedScopedHooks}). */
|
|
903
1020
|
onError(fn: (error: unknown, context: Context & Ctx) => MaybePromise<unknown>): this {
|
|
904
1021
|
this.assertConfigurable("onError()")
|
|
905
1022
|
this.onErrorHooks.push(fn as unknown as RawErrorHandler)
|
|
1023
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
1024
|
+
recordScopedHook(this, this.catalog.size, "onError")
|
|
906
1025
|
return this
|
|
907
1026
|
}
|
|
908
1027
|
|
|
@@ -947,11 +1066,27 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
947
1066
|
* hook owns part of the header state - into a hook that runs in the right order. */
|
|
948
1067
|
private addStaticResponseHeaders(record: Record<string, string>): this {
|
|
949
1068
|
if (this.onResponseHooks.length > 0) {
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1069
|
+
const observerHeaders = (
|
|
1070
|
+
this as unknown as Pick<ResponseObserverMethods, "onResponseHeaders">
|
|
1071
|
+
).onResponseHeaders
|
|
1072
|
+
if (observerHeaders !== undefined) {
|
|
1073
|
+
observerHeaders.call(this, (headers) => {
|
|
1074
|
+
for (const name of Object.keys(record)) {
|
|
1075
|
+
if (!headers.has(name)) headers.set(name, record[name] as string)
|
|
1076
|
+
}
|
|
1077
|
+
})
|
|
1078
|
+
return this
|
|
1079
|
+
}
|
|
1080
|
+
// Route the hook through the same seam the static tier uses. On a framework response (headers
|
|
1081
|
+
// stamped mutable) it applies the defaults in place and returns the SAME object, so the
|
|
1082
|
+
// serialized-body marker survives for later `onResponseBody`/`onResponseRaw` observers and the
|
|
1083
|
+
// Node writer; reconstructing a `Response` here would strip that marker and reclassify the Node
|
|
1084
|
+
// outcome from `json` to a generic `response`. A guarded foreign response takes its clone path.
|
|
1085
|
+
const statics = buildStaticResponseHeaders(record)
|
|
1086
|
+
this.onResponseHooks.push((response) => applyStaticResponseHeaders(response, statics))
|
|
1087
|
+
this.onNodeResponseHooks.push(undefined)
|
|
1088
|
+
this.nodeResponseHooksComplete = false
|
|
1089
|
+
return this
|
|
955
1090
|
}
|
|
956
1091
|
const merged =
|
|
957
1092
|
this.staticResponseHeaders === undefined
|
|
@@ -959,7 +1094,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
959
1094
|
: { ...this.staticResponseHeaders.record, ...record }
|
|
960
1095
|
this.staticResponseHeaders = buildStaticResponseHeaders(merged)
|
|
961
1096
|
const statics = this.staticResponseHeaders
|
|
962
|
-
this.wrapWebResponse = (response) =>
|
|
1097
|
+
this.wrapWebResponse = (response) =>
|
|
1098
|
+
applyStaticResponseHeaders(webResponseOf(response), statics)
|
|
963
1099
|
this.webResponseTimeout = () => applyStaticResponseHeaders(RESPONSE_TIMEOUT(), statics)
|
|
964
1100
|
return this
|
|
965
1101
|
}
|
|
@@ -972,61 +1108,18 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
972
1108
|
return this.responseBodyTag
|
|
973
1109
|
}
|
|
974
1110
|
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
/**
|
|
989
|
-
* Register a PORTABLE header-only response hook - one implementation, fast on every runtime.
|
|
990
|
-
*
|
|
991
|
-
* The hook receives a mutable case-insensitive header view, a minimal request view, and the
|
|
992
|
-
* status. On the Web serving paths it runs inside the normal `onResponse` walk against the
|
|
993
|
-
* response's own `Headers` (mutating in place - no clone). On Node it self-pairs as a native
|
|
994
|
-
* response hook against the outcome's plain header record, so registering one NEVER forces the
|
|
995
|
-
* Node adapter off its direct socket writer the way a full `onResponse(res: Response)` hook does.
|
|
996
|
-
* Prefer this over `onResponse` whenever the middleware only reads/writes headers.
|
|
997
|
-
*/
|
|
998
|
-
onResponseHeaders(fn: ResponseHeadersHook): this {
|
|
999
|
-
this.assertConfigurable("onResponseHeaders()")
|
|
1000
|
-
this.onResponseHooks.push(webResponseHeadersHook(fn))
|
|
1001
|
-
this.onNodeResponseHooks.push((response, req) =>
|
|
1002
|
-
fn(recordHeadersView(response), req, response.status),
|
|
1003
|
-
)
|
|
1004
|
-
return this
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
/**
|
|
1008
|
-
* Register a PORTABLE post-serialization body hook - the payload tier. The hook receives the
|
|
1009
|
-
* FINAL framework-serialized bytes (plus the mutable header view and status) and may return
|
|
1010
|
-
* replacement bytes; `undefined` keeps the body. On the Node direct writer the bytes come
|
|
1011
|
-
* straight off the outcome record; on the Web serving paths they ride the framework-built
|
|
1012
|
-
* Response as a tag, so no body stream is ever drained on any runtime. A handler-returned raw
|
|
1013
|
-
* `Response` (proxied fetch, SSE, streamed SSR) is skipped by contract - transforming those is
|
|
1014
|
-
* what the full `onResponse` hook is for.
|
|
1015
|
-
*/
|
|
1016
|
-
onResponseBody(fn: ResponseBodyHook): this {
|
|
1017
|
-
this.assertConfigurable("onResponseBody()")
|
|
1018
|
-
this.enableResponseBodyTagging()
|
|
1019
|
-
this.onResponseHooks.push(webResponseBodyHook(fn, this.responseBodyOwners))
|
|
1020
|
-
this.onNodeResponseHooks.push((response, req) => {
|
|
1021
|
-
const body = response.body
|
|
1022
|
-
if (body === null) return undefined
|
|
1023
|
-
const out = fn(body, recordHeadersView(response), req, response.status)
|
|
1024
|
-
if (out instanceof Promise)
|
|
1025
|
-
return out.then((replaced) => applyBodyReplacement(response, replaced))
|
|
1026
|
-
applyBodyReplacement(response, out)
|
|
1027
|
-
return undefined
|
|
1028
|
-
})
|
|
1029
|
-
return this
|
|
1111
|
+
private responseObserverMethods(): ResponseObserverMethods {
|
|
1112
|
+
const methods = this as unknown as Partial<ResponseObserverMethods>
|
|
1113
|
+
if (
|
|
1114
|
+
typeof methods.onResponseHeaders !== "function" ||
|
|
1115
|
+
typeof methods.onResponseBody !== "function" ||
|
|
1116
|
+
typeof methods.onResponseRaw !== "function"
|
|
1117
|
+
) {
|
|
1118
|
+
throw new TypeError(
|
|
1119
|
+
"response observation requires `.use(responseObserver())` or an observer-enabled middleware",
|
|
1120
|
+
)
|
|
1121
|
+
}
|
|
1122
|
+
return methods as ResponseObserverMethods
|
|
1030
1123
|
}
|
|
1031
1124
|
|
|
1032
1125
|
/** Observe the terminal response after all transformations. Observers are ordered and fail-open. */
|
|
@@ -1038,10 +1131,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1038
1131
|
return this
|
|
1039
1132
|
}
|
|
1040
1133
|
|
|
1134
|
+
/** Enable the opt-in portable response observer methods. */
|
|
1135
|
+
use(plugin: ResponseObserverPlugin): this & ResponseObserverMethods
|
|
1041
1136
|
/**
|
|
1042
1137
|
* Apply a type-**identity** plugin ({@link IdentityPlugin}, from {@link defineIdentityPlugin}) - it
|
|
1043
1138
|
* registers routes/hooks but doesn't change the types, so this returns `this` with the route registry
|
|
1044
|
-
* and context fully intact. This overload exists specifically so a
|
|
1139
|
+
* and context fully intact. This overload exists specifically so a named identity plugin (e.g.
|
|
1045
1140
|
* `@nifrajs/better-auth`) threads the registry: its `& { pluginName }` intersection would otherwise
|
|
1046
1141
|
* defeat the generic inference of the transforming overload below and collapse the result to `any`.
|
|
1047
1142
|
*/
|
|
@@ -1059,13 +1154,23 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1059
1154
|
* called with `this` and its result is returned, so an inline plugin's `derive`/`decorate` thread
|
|
1060
1155
|
* the added context to handlers defined after `use` (the overload is generic over the concrete
|
|
1061
1156
|
* `this`). A named plugin already applied is skipped (idempotent dedupe).
|
|
1157
|
+
*
|
|
1158
|
+
* If the plugin's return type is unpinned - `Server<any, any>`, as a hand-rolled `(app) => app` or a
|
|
1159
|
+
* `NifraPlugin<AnyServer, AnyServer>` infers (e.g. an auth plugin whose own types collapsed) - this
|
|
1160
|
+
* returns the non-callable {@link PluginTypeCollapsed} instead of silently widening the whole typed
|
|
1161
|
+
* client to `any`. `.get()`/`.post()` then fail right here rather than surfacing as `any` hundreds of
|
|
1162
|
+
* lines away. Fix at the plugin: build it with {@link defineIdentityPlugin}/{@link defineContextPlugin},
|
|
1163
|
+
* or pin its input server type. This mirrors the guard {@link definePlugin} already applies.
|
|
1062
1164
|
*/
|
|
1063
|
-
use<Out extends AnyServer>(
|
|
1165
|
+
use<Out extends AnyServer>(
|
|
1166
|
+
plugin: (app: this) => Out,
|
|
1167
|
+
): [ServerTypeUnpinned<Out>] extends [true] ? PluginTypeCollapsed : Out
|
|
1064
1168
|
/**
|
|
1065
1169
|
* Apply a {@link Middleware} bundle - wire each hook it provides to its lifecycle point. Returns
|
|
1066
1170
|
* `this` (no context-type merging); call it before the routes its `beforeHandle`/`afterHandle`
|
|
1067
|
-
* should cover (those are order-scoped; `onRequest`/`onResponse` are global). A
|
|
1068
|
-
*
|
|
1171
|
+
* should cover (those are order-scoped; `onRequest`/`onResponse` are global). A bundle applied after
|
|
1172
|
+
* the last route reaches nothing - {@link ServerOptions.unusedScopedHooks} logs that at seal. A named
|
|
1173
|
+
* bundle already applied is skipped (idempotent).
|
|
1069
1174
|
*/
|
|
1070
1175
|
use(mw: Middleware): this
|
|
1071
1176
|
use(arg: Middleware | ((app: this) => AnyServer)): AnyServer {
|
|
@@ -1102,6 +1207,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1102
1207
|
}
|
|
1103
1208
|
this.globalAssurance.push(...evidence.filter((item) => item.scope === "global"))
|
|
1104
1209
|
this.activeAssurance.push(...evidence.filter((item) => item.scope === "subsequent"))
|
|
1210
|
+
const responseObserver = (arg as unknown as Record<symbol, unknown>)[INSTALL_RESPONSE_OBSERVER]
|
|
1211
|
+
if (responseObserver !== undefined) {
|
|
1212
|
+
if (
|
|
1213
|
+
typeof responseObserver !== "object" ||
|
|
1214
|
+
responseObserver === null ||
|
|
1215
|
+
typeof (responseObserver as { install?: unknown }).install !== "function"
|
|
1216
|
+
) {
|
|
1217
|
+
throw new TypeError("response observer middleware has an invalid runtime")
|
|
1218
|
+
}
|
|
1219
|
+
this[INSTALL_RESPONSE_OBSERVER](responseObserver as ResponseObserverRuntime)
|
|
1220
|
+
}
|
|
1105
1221
|
if (arg.onRequest !== undefined) {
|
|
1106
1222
|
this.assertConfigurable("onRequest()")
|
|
1107
1223
|
this.onRequestHooks.push(arg.onRequest as RawOnRequest)
|
|
@@ -1125,9 +1241,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1125
1241
|
// Before the bundle's own hooks: a bundle declaring both means its static values are the
|
|
1126
1242
|
// defaults its hook may then override, which is the order a single bundle reads in.
|
|
1127
1243
|
if (arg.responseHeaders !== undefined) this.responseHeaders(arg.responseHeaders)
|
|
1128
|
-
if (arg.onResponseHeaders !== undefined)
|
|
1129
|
-
|
|
1130
|
-
if (arg.
|
|
1244
|
+
if (arg.onResponseHeaders !== undefined)
|
|
1245
|
+
this.responseObserverMethods().onResponseHeaders(arg.onResponseHeaders)
|
|
1246
|
+
if (arg.onResponseBody !== undefined)
|
|
1247
|
+
this.responseObserverMethods().onResponseBody(arg.onResponseBody)
|
|
1248
|
+
if (arg.onResponseRaw !== undefined)
|
|
1249
|
+
this.responseObserverMethods().onResponseRaw(arg.onResponseRaw)
|
|
1131
1250
|
if (arg.onResponseFinalized !== undefined) this.onResponseFinalized(arg.onResponseFinalized)
|
|
1132
1251
|
if (arg.onError !== undefined) this.onError(arg.onError)
|
|
1133
1252
|
return this
|
|
@@ -1395,6 +1514,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1395
1514
|
// Boot-time guard: the WS runtime is a subpath (`@nifrajs/core/ws`) so no-WebSocket apps don't
|
|
1396
1515
|
// bundle it. Registration is the loud, early failure point - never the first connection.
|
|
1397
1516
|
const runtime = requireWsRuntime(this.wsRuntime)
|
|
1517
|
+
if (handler.validateSend === true && handler.sendSchema === undefined) {
|
|
1518
|
+
throw new RouteConfigError(
|
|
1519
|
+
"INVALID_WS_SEND_VALIDATION",
|
|
1520
|
+
`route WS ${path}: validateSend requires sendSchema`,
|
|
1521
|
+
)
|
|
1522
|
+
}
|
|
1398
1523
|
this.topics ??= runtime.createTopics()
|
|
1399
1524
|
// A `messageSchema` wraps `message` with validation once, here - every adapter then dispatches
|
|
1400
1525
|
// already-validated, typed messages (Bun/Deno/Node/Workers) with no per-adapter code.
|
|
@@ -1475,6 +1600,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1475
1600
|
handler: (context: never) => unknown,
|
|
1476
1601
|
): CatalogRoute {
|
|
1477
1602
|
const pattern = compileRoutePattern(path)
|
|
1603
|
+
// Resolved here, once: `undefined` past this point means the route declared `"unlimited"`, NOT
|
|
1604
|
+
// "nothing declared". Readers fall back to UNLIMITED_BODY_BYTES accordingly.
|
|
1478
1605
|
let bodyLimit: number | undefined = this.maxBodyBytes
|
|
1479
1606
|
const invalidBodyLimit = (message: string): never => {
|
|
1480
1607
|
throw new RouteConfigError("INVALID_BODY_LIMIT", `route ${method} ${path}: ${message}`)
|
|
@@ -1489,6 +1616,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1489
1616
|
) {
|
|
1490
1617
|
invalidBodyLimit('bodyLimit: "unlimited" requires a non-empty bodyLimitReason')
|
|
1491
1618
|
}
|
|
1619
|
+
if (schema.body !== undefined) {
|
|
1620
|
+
invalidBodyLimit(
|
|
1621
|
+
'bodyLimit: "unlimited" cannot be used with a body schema; use a finite cap',
|
|
1622
|
+
)
|
|
1623
|
+
}
|
|
1624
|
+
if (schema.idempotency !== undefined) {
|
|
1625
|
+
invalidBodyLimit('bodyLimit: "unlimited" cannot be used with idempotency')
|
|
1626
|
+
}
|
|
1492
1627
|
bodyLimit = undefined
|
|
1493
1628
|
} else if (schema?.bodyLimit !== undefined) {
|
|
1494
1629
|
try {
|
|
@@ -1617,7 +1752,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1617
1752
|
handler as unknown as InternalHandler,
|
|
1618
1753
|
schema.body as StandardSchemaV1,
|
|
1619
1754
|
hasDecorations ? routeDecorations : undefined,
|
|
1620
|
-
bodyLimit ??
|
|
1755
|
+
bodyLimit ?? UNLIMITED_BODY_BYTES,
|
|
1621
1756
|
)
|
|
1622
1757
|
: undefined
|
|
1623
1758
|
const fusedWeb =
|
|
@@ -1626,14 +1761,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1626
1761
|
handler as unknown as InternalHandler,
|
|
1627
1762
|
hasDecorations ? routeDecorations : undefined,
|
|
1628
1763
|
isContextlessNoArgArrow(handler),
|
|
1629
|
-
bodyLimit ??
|
|
1764
|
+
bodyLimit ?? UNLIMITED_BODY_BYTES,
|
|
1630
1765
|
)
|
|
1631
1766
|
: fusedQuery
|
|
1632
1767
|
? this.buildFusedQueryWeb(
|
|
1633
1768
|
handler as unknown as InternalHandler,
|
|
1634
1769
|
hasDecorations ? routeDecorations : undefined,
|
|
1635
1770
|
schema.query as StandardSchemaV1,
|
|
1636
|
-
bodyLimit ??
|
|
1771
|
+
bodyLimit ?? UNLIMITED_BODY_BYTES,
|
|
1637
1772
|
)
|
|
1638
1773
|
: fusedBody
|
|
1639
1774
|
? this.buildFusedBodyWeb(fusedBodyRunner as FusedBodyRunner)
|
|
@@ -1749,7 +1884,18 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1749
1884
|
})
|
|
1750
1885
|
routeAssurance.push(Object.freeze(declared))
|
|
1751
1886
|
}
|
|
1752
|
-
|
|
1887
|
+
// Two ways a route proves its body is bounded, both enforced by the core at read time:
|
|
1888
|
+
// `schema.body` (the validated read is bounded) and an explicit finite `schema.bodyLimit` (the
|
|
1889
|
+
// transport cap shadows every reader, including a raw `c.req.body` stream that a Content-Length
|
|
1890
|
+
// middleware cannot bound).
|
|
1891
|
+
// `bodyLimit: "unlimited"` is rejected above for schema and idempotency routes. The remaining
|
|
1892
|
+
// exemption is schema-less streaming/upload work, so it publishes NOTHING: there is no finite
|
|
1893
|
+
// framework byte ceiling for assurance to attest and the application protocol owns that bound.
|
|
1894
|
+
// The server-wide `maxBodyBytes` default is not evidence either. It applies to every route
|
|
1895
|
+
// whether or not anyone thought about it, so publishing it would make the id pass everywhere and
|
|
1896
|
+
// prove nothing; this evidence marks a bound the route chose.
|
|
1897
|
+
const unlimitedBody = schema?.bodyLimit === "unlimited"
|
|
1898
|
+
if (!unlimitedBody && (schema?.body !== undefined || typeof schema?.bodyLimit === "number")) {
|
|
1753
1899
|
routeAssurance.push(
|
|
1754
1900
|
Object.freeze({
|
|
1755
1901
|
id: NIFRA_ASSURANCE_IDS.BODY_BOUNDED,
|
|
@@ -1809,6 +1955,29 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1809
1955
|
this.responseContractRuntime = runtime
|
|
1810
1956
|
}
|
|
1811
1957
|
|
|
1958
|
+
/** @internal Symbol-keyed install seam for the response observer plugin. */
|
|
1959
|
+
[INSTALL_RESPONSE_OBSERVER](runtime: ResponseObserverRuntime): ResponseObserverMethods {
|
|
1960
|
+
this.assertConfigurable("responseObserver()")
|
|
1961
|
+
const methods = runtime.install({
|
|
1962
|
+
assertConfigurable: (operation) => this.assertConfigurable(operation),
|
|
1963
|
+
addResponseHook: (web, node) => {
|
|
1964
|
+
this.onResponseHooks.push(web)
|
|
1965
|
+
this.onNodeResponseHooks.push(node)
|
|
1966
|
+
if (node === undefined) this.nodeResponseHooksComplete = false
|
|
1967
|
+
},
|
|
1968
|
+
enableResponseBodyTagging: () => {
|
|
1969
|
+
if (this.responseBodyTag === undefined) {
|
|
1970
|
+
this.responseBodyTag = Object.freeze({})
|
|
1971
|
+
this.responseBodyOwners.add(this.responseBodyTag)
|
|
1972
|
+
}
|
|
1973
|
+
return this.responseBodyTag
|
|
1974
|
+
},
|
|
1975
|
+
responseBodyOwners: () => this.responseBodyOwners,
|
|
1976
|
+
})
|
|
1977
|
+
Object.assign(this, methods)
|
|
1978
|
+
return methods
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1812
1981
|
/** @internal Symbol-keyed install seam for the `idempotency()` plugin. Off the public typed surface. */
|
|
1813
1982
|
[INSTALL_IDEMPOTENCY](runtime: IdempotencyRuntime): void {
|
|
1814
1983
|
this.assertConfigurable("idempotency()")
|
|
@@ -1964,7 +2133,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1964
2133
|
entry.handler,
|
|
1965
2134
|
entry.schema?.body as StandardSchemaV1,
|
|
1966
2135
|
entry.hasDecorations ? entry.decorations : undefined,
|
|
1967
|
-
entry.bodyLimit ??
|
|
2136
|
+
entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
|
|
1968
2137
|
)
|
|
1969
2138
|
: undefined
|
|
1970
2139
|
const fusedWeb =
|
|
@@ -1975,13 +2144,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1975
2144
|
entry.handler,
|
|
1976
2145
|
entry.hasDecorations ? entry.decorations : undefined,
|
|
1977
2146
|
entry.schema?.query as StandardSchemaV1,
|
|
1978
|
-
entry.bodyLimit ??
|
|
2147
|
+
entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
|
|
1979
2148
|
)
|
|
1980
2149
|
: this.buildFusedWeb(
|
|
1981
2150
|
entry.handler,
|
|
1982
2151
|
entry.hasDecorations ? entry.decorations : undefined,
|
|
1983
2152
|
isContextlessNoArgArrow(entry.handler),
|
|
1984
|
-
entry.bodyLimit ??
|
|
2153
|
+
entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
|
|
1985
2154
|
)
|
|
1986
2155
|
return {
|
|
1987
2156
|
...route,
|
|
@@ -2027,6 +2196,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2027
2196
|
source: RequestSource,
|
|
2028
2197
|
platform?: Platform<EnvOf<Ctx>>,
|
|
2029
2198
|
): MaybePromise<Response> {
|
|
2199
|
+
// Seal once on the first request so test servers (no `listen()`) still get the dead-hook check.
|
|
2200
|
+
// After that it is a single boolean read - the per-request cost the option promises to keep at zero.
|
|
2201
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
2202
|
+
sealHookAudit(this, this.catalog.size, this.logger)
|
|
2030
2203
|
// Off path (default): straight through - one property check, no closure, no promise.
|
|
2031
2204
|
if (this.capacityGate === undefined) return this.fetchSourceInner(source, platform)
|
|
2032
2205
|
return this.admitGated(requestOf(source), () => this.fetchSourceInner(source, platform))
|
|
@@ -2134,6 +2307,45 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2134
2307
|
return outcome
|
|
2135
2308
|
}
|
|
2136
2309
|
|
|
2310
|
+
/**
|
|
2311
|
+
* Select a mounted handler's native Node lane without materializing a Web Request. This is an
|
|
2312
|
+
* adapter seam, not a second lifecycle: any app-wide behavior that the ordinary mount path would
|
|
2313
|
+
* run makes the capability ineligible and the caller falls back to `resolveNodeSource`. Typed
|
|
2314
|
+
* routes keep precedence exactly as `routeAndRun` does, including the existing mount behavior for
|
|
2315
|
+
* a path whose registered route rejects this method.
|
|
2316
|
+
*/
|
|
2317
|
+
[RESOLVE_NODE_MOUNT](source: RequestSource): NativeMountSelection | undefined {
|
|
2318
|
+
if (
|
|
2319
|
+
this.onRequestHooks.length > 0 ||
|
|
2320
|
+
this.onResponseHooks.length > 0 ||
|
|
2321
|
+
this.onResponseFinalizedHooks.length > 0 ||
|
|
2322
|
+
this.capacityGate !== undefined ||
|
|
2323
|
+
this.staticResponseHeaders !== undefined ||
|
|
2324
|
+
this.requestTimeoutMs !== 0 ||
|
|
2325
|
+
this.acceptInboundDeadlines ||
|
|
2326
|
+
this.clientIpTrust !== undefined
|
|
2327
|
+
) {
|
|
2328
|
+
return undefined
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
const parts = source.urlParts ?? urlPartsOf(source.url)
|
|
2332
|
+
const match = this.catalog.find(source.method, parts.pathname)
|
|
2333
|
+
if (match.found) return undefined
|
|
2334
|
+
|
|
2335
|
+
for (const mount of this.fetchMounts) {
|
|
2336
|
+
if (!underMountPrefix(parts.pathname, mount.path)) continue
|
|
2337
|
+
const candidate = (mount.handler as unknown as Record<symbol, unknown>)[NODE_NATIVE_MOUNT]
|
|
2338
|
+
return typeof candidate === "function"
|
|
2339
|
+
? {
|
|
2340
|
+
handler: candidate as NativeMountHandler,
|
|
2341
|
+
path: mount.path,
|
|
2342
|
+
stripPrefix: mount.stripPrefix,
|
|
2343
|
+
}
|
|
2344
|
+
: undefined
|
|
2345
|
+
}
|
|
2346
|
+
return undefined
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2137
2349
|
/**
|
|
2138
2350
|
* Like {@link fetch}, but renders a plain-data result **without** building a Web `Response` - the
|
|
2139
2351
|
* `@nifrajs/node` adapter serializes the returned primitives straight to the socket, skipping the undici
|
|
@@ -2151,6 +2363,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2151
2363
|
platform?: Platform<EnvOf<Ctx>>,
|
|
2152
2364
|
suppliedRuntime?: NodeOutcomeRuntime,
|
|
2153
2365
|
): MaybePromise<NodeServeOutcome> {
|
|
2366
|
+
// Seal once on the Node-direct lane too - it can bypass `fetchSource` entirely (hookless outcome).
|
|
2367
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
2368
|
+
sealHookAudit(this, this.catalog.size, this.logger)
|
|
2154
2369
|
// A paired header-only native hook can preserve the Node-direct JSON/body outcome for successful
|
|
2155
2370
|
// responses. Arbitrary onResponse transforms need a real Web Response, but a buffered outcome can
|
|
2156
2371
|
// be materialized with a direct-write marker and return to the socket path when the hook mutates
|
|
@@ -2509,7 +2724,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2509
2724
|
source: RequestSource,
|
|
2510
2725
|
platform: Platform | undefined,
|
|
2511
2726
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
2512
|
-
wrapResponse: (response: Response) => T,
|
|
2727
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
2513
2728
|
onTimeout: () => T,
|
|
2514
2729
|
// True only from the Web `fetch` path - unlocks each route's fused lane, whose output type IS
|
|
2515
2730
|
// `Response` (`T = Response` there by construction; the node path always passes false).
|
|
@@ -2537,7 +2752,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2537
2752
|
source: RequestSource,
|
|
2538
2753
|
platform: Platform | undefined,
|
|
2539
2754
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
2540
|
-
wrapResponse: (response: Response) => T,
|
|
2755
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
2541
2756
|
onTimeout: () => T,
|
|
2542
2757
|
): MaybePromise<T> {
|
|
2543
2758
|
const hooks = this.onNodeRequestHooks
|
|
@@ -2571,7 +2786,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2571
2786
|
source: RequestSource,
|
|
2572
2787
|
platform: Platform | undefined,
|
|
2573
2788
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
2574
|
-
wrapResponse: (response: Response) => T,
|
|
2789
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
2575
2790
|
onTimeout: () => T,
|
|
2576
2791
|
): Promise<T> {
|
|
2577
2792
|
if (first !== undefined) return wrapResponse(first)
|
|
@@ -2599,7 +2814,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2599
2814
|
source: RequestSource,
|
|
2600
2815
|
platform: Platform | undefined,
|
|
2601
2816
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
2602
|
-
wrapResponse: (response: Response) => T,
|
|
2817
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
2603
2818
|
onTimeout: () => T,
|
|
2604
2819
|
webFast: boolean,
|
|
2605
2820
|
): MaybePromise<T> {
|
|
@@ -2646,7 +2861,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2646
2861
|
sourceAtAwait: RequestSource,
|
|
2647
2862
|
platform: Platform | undefined,
|
|
2648
2863
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
2649
|
-
wrapResponse: (response: Response) => T,
|
|
2864
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
2650
2865
|
onTimeout: () => T,
|
|
2651
2866
|
webFast: boolean,
|
|
2652
2867
|
): Promise<T> {
|
|
@@ -2740,7 +2955,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2740
2955
|
source: RequestSource,
|
|
2741
2956
|
platform: Platform | undefined,
|
|
2742
2957
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
2743
|
-
wrapResponse: (response: Response) => T,
|
|
2958
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
2744
2959
|
onTimeout: () => T,
|
|
2745
2960
|
webFast: boolean,
|
|
2746
2961
|
): MaybePromise<T> {
|
|
@@ -2792,17 +3007,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2792
3007
|
}
|
|
2793
3008
|
if (match.reason === "method-not-allowed") {
|
|
2794
3009
|
return wrapResponse(
|
|
2795
|
-
|
|
3010
|
+
// Lowercase on purpose: a plain render's headers go into the node outcome record verbatim,
|
|
3011
|
+
// and every other name in that record is lowercase (the Web lane normalizes either way).
|
|
3012
|
+
plainError(405, "method_not_allowed", { allow: match.allowed.join(", ") }),
|
|
2796
3013
|
)
|
|
2797
3014
|
}
|
|
2798
|
-
return wrapResponse(
|
|
3015
|
+
return wrapResponse(plainError(404, "not_found"))
|
|
2799
3016
|
}
|
|
2800
3017
|
|
|
2801
3018
|
// Inspect only captured values for escapes. Scanning the full pathname repeated work the router
|
|
2802
3019
|
// already did and made every plain dynamic route pay for unrelated static path bytes.
|
|
2803
3020
|
const params = match.params === EMPTY_PARAMS ? match.params : decodeRouteParams(match.params)
|
|
2804
3021
|
if (params === null) {
|
|
2805
|
-
return wrapResponse(
|
|
3022
|
+
return wrapResponse(plainError(400, "malformed_path"))
|
|
2806
3023
|
}
|
|
2807
3024
|
|
|
2808
3025
|
return this.runMatched(
|
|
@@ -2842,7 +3059,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2842
3059
|
params: Record<string, string>,
|
|
2843
3060
|
search: string | undefined,
|
|
2844
3061
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
2845
|
-
wrapResponse: (response: Response) => T,
|
|
3062
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
2846
3063
|
onTimeout: () => T,
|
|
2847
3064
|
webFast: boolean,
|
|
2848
3065
|
): MaybePromise<T> {
|
|
@@ -2873,7 +3090,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2873
3090
|
search,
|
|
2874
3091
|
wrapResponse,
|
|
2875
3092
|
{
|
|
2876
|
-
maxBodyBytes: entry.bodyLimit ??
|
|
3093
|
+
maxBodyBytes: entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
|
|
2877
3094
|
runLanes: (buffered, plat, ent, prm, srch) =>
|
|
2878
3095
|
this.idempotencyRunLanes(buffered, plat, ent as RouteEntry, prm, srch),
|
|
2879
3096
|
},
|
|
@@ -2900,7 +3117,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2900
3117
|
params: Record<string, string>,
|
|
2901
3118
|
search: string | undefined,
|
|
2902
3119
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
2903
|
-
wrapResponse: (response: Response) => T,
|
|
3120
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
2904
3121
|
onTimeout: () => T,
|
|
2905
3122
|
webFast: boolean,
|
|
2906
3123
|
): MaybePromise<T> {
|
|
@@ -2922,7 +3139,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2922
3139
|
? undefined
|
|
2923
3140
|
: admitDeadline(source.headers, this.deadlineAdmissionOptions)
|
|
2924
3141
|
if (admission !== undefined && !admission.ok) {
|
|
2925
|
-
return wrapResponse(
|
|
3142
|
+
return wrapResponse(plainError(admission.status, admission.reason))
|
|
2926
3143
|
}
|
|
2927
3144
|
const effectiveTimeoutMs = admission?.timeoutMs ?? 0
|
|
2928
3145
|
|
|
@@ -2981,7 +3198,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2981
3198
|
if (controller !== undefined && outcome instanceof Promise) {
|
|
2982
3199
|
const timedOut =
|
|
2983
3200
|
admission?.inherited === true
|
|
2984
|
-
? () => wrapResponse(
|
|
3201
|
+
? () => wrapResponse(plainError(504, "deadline_exceeded"))
|
|
2985
3202
|
: onTimeout
|
|
2986
3203
|
return this.withTimeout(
|
|
2987
3204
|
outcome,
|
|
@@ -3012,7 +3229,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3012
3229
|
budget: RequestBudget,
|
|
3013
3230
|
platform: Platform | undefined,
|
|
3014
3231
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
3015
|
-
wrapResponse: (response: Response) => T,
|
|
3232
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
3016
3233
|
): MaybePromise<T> {
|
|
3017
3234
|
let result: unknown
|
|
3018
3235
|
try {
|
|
@@ -3056,9 +3273,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3056
3273
|
signal: AbortSignal,
|
|
3057
3274
|
budget: RequestBudget,
|
|
3058
3275
|
platform: Platform | undefined,
|
|
3059
|
-
wrapResponse: (response: Response) => T,
|
|
3276
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
3060
3277
|
): T {
|
|
3061
3278
|
if (err instanceof Response) return wrapResponse(err)
|
|
3279
|
+
// Handed over as the plain data it is: `wrapResponse` renders it on the JSON lane (no `Response`
|
|
3280
|
+
// built on Node). There is no context here by construction, so there is no `c.set` to merge.
|
|
3281
|
+
if (isResponseResult(err)) return wrapResponse(err)
|
|
3062
3282
|
const ctx = new RequestContext(
|
|
3063
3283
|
source,
|
|
3064
3284
|
params,
|
|
@@ -3070,7 +3290,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3070
3290
|
this.protoPoisoning,
|
|
3071
3291
|
)
|
|
3072
3292
|
this.logRequestError(err, ctx)
|
|
3073
|
-
return wrapResponse(
|
|
3293
|
+
return wrapResponse(plainError(500, "internal_error"))
|
|
3074
3294
|
}
|
|
3075
3295
|
|
|
3076
3296
|
/**
|
|
@@ -3087,20 +3307,20 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3087
3307
|
entry: RouteEntry,
|
|
3088
3308
|
ctx: RawContext,
|
|
3089
3309
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
3090
|
-
wrapResponse: (response: Response) => T,
|
|
3310
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
3091
3311
|
): MaybePromise<T> {
|
|
3092
3312
|
let result: unknown
|
|
3093
3313
|
try {
|
|
3094
3314
|
if (entry.hasDecorations) Object.assign(ctx, entry.decorations)
|
|
3095
3315
|
result = entry.handler(ctx)
|
|
3096
3316
|
} catch (err) {
|
|
3097
|
-
return this.bareError(err, ctx, wrapResponse)
|
|
3317
|
+
return this.bareError(err, ctx, finalize, wrapResponse)
|
|
3098
3318
|
}
|
|
3099
3319
|
if (result instanceof Promise) {
|
|
3100
3320
|
// Async handler on an otherwise-bare route: finish on a microtask, with the same error handling.
|
|
3101
3321
|
return result.then(
|
|
3102
3322
|
(value) => finalize(value, responseSet(ctx)),
|
|
3103
|
-
(err) => this.bareError(err, ctx, wrapResponse),
|
|
3323
|
+
(err) => this.bareError(err, ctx, finalize, wrapResponse),
|
|
3104
3324
|
)
|
|
3105
3325
|
}
|
|
3106
3326
|
return finalize(result, responseSet(ctx))
|
|
@@ -3121,13 +3341,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3121
3341
|
contextless: boolean,
|
|
3122
3342
|
maxBodyBytes: number,
|
|
3123
3343
|
): FusedWebRunner {
|
|
3124
|
-
//
|
|
3125
|
-
//
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
this.
|
|
3129
|
-
|
|
3130
|
-
|
|
3344
|
+
// Same thrown-value contract as the generic lane (`bare-error-lane.ts` via `bareError`); the fused
|
|
3345
|
+
// lane only injects its renderer, so a thrown `status(...)` and the flat 500 fold in static headers
|
|
3346
|
+
// through `wrapWebResponse`. One source of truth for the error surface, no per-lane copy to drift.
|
|
3347
|
+
const logError = (err: unknown, ctx: RawContext): Response =>
|
|
3348
|
+
this.bareError(
|
|
3349
|
+
err,
|
|
3350
|
+
ctx,
|
|
3351
|
+
(result, set) => this.wrapWebResponse(toResponse(result, set)),
|
|
3352
|
+
this.wrapWebResponse,
|
|
3353
|
+
)
|
|
3131
3354
|
if (contextless && decorations === undefined) {
|
|
3132
3355
|
// `() => ...` can't observe the context - skip allocating one entirely (errors still build
|
|
3133
3356
|
// one for the structured log, exactly like runContextlessBare).
|
|
@@ -3257,20 +3480,21 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3257
3480
|
const logError = <T>(
|
|
3258
3481
|
err: unknown,
|
|
3259
3482
|
ctx: RawContext,
|
|
3260
|
-
|
|
3261
|
-
|
|
3483
|
+
finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
|
|
3484
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
3485
|
+
): T => this.bareError(err, ctx, finalize, wrapResponse)
|
|
3262
3486
|
|
|
3263
3487
|
const runHandler = <T>(
|
|
3264
3488
|
ctx: RawContext,
|
|
3265
3489
|
finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
|
|
3266
|
-
wrapResponse: (response: Response) => T,
|
|
3490
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
3267
3491
|
): MaybePromise<T> => {
|
|
3268
3492
|
if (decorations !== undefined) Object.assign(ctx, decorations)
|
|
3269
3493
|
let output: MaybePromise<HandlerResult>
|
|
3270
3494
|
try {
|
|
3271
3495
|
output = handler(ctx)
|
|
3272
3496
|
} catch (err) {
|
|
3273
|
-
return logError(err, ctx, wrapResponse)
|
|
3497
|
+
return logError(err, ctx, finalize, wrapResponse)
|
|
3274
3498
|
}
|
|
3275
3499
|
if (output instanceof Promise) {
|
|
3276
3500
|
return output.then(
|
|
@@ -3278,16 +3502,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3278
3502
|
try {
|
|
3279
3503
|
return finalize(result, responseSet(ctx), ctx)
|
|
3280
3504
|
} catch (err) {
|
|
3281
|
-
return logError(err, ctx, wrapResponse)
|
|
3505
|
+
return logError(err, ctx, finalize, wrapResponse)
|
|
3282
3506
|
}
|
|
3283
3507
|
},
|
|
3284
|
-
(err) => logError(err, ctx, wrapResponse),
|
|
3508
|
+
(err) => logError(err, ctx, finalize, wrapResponse),
|
|
3285
3509
|
)
|
|
3286
3510
|
}
|
|
3287
3511
|
try {
|
|
3288
3512
|
return finalize(output, responseSet(ctx), ctx)
|
|
3289
3513
|
} catch (err) {
|
|
3290
|
-
return logError(err, ctx, wrapResponse)
|
|
3514
|
+
return logError(err, ctx, finalize, wrapResponse)
|
|
3291
3515
|
}
|
|
3292
3516
|
}
|
|
3293
3517
|
|
|
@@ -3295,9 +3519,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3295
3519
|
result: StandardResult<unknown>,
|
|
3296
3520
|
ctx: RawContext,
|
|
3297
3521
|
finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
|
|
3298
|
-
wrapResponse: (response: Response) => T,
|
|
3522
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
3299
3523
|
): MaybePromise<T> => {
|
|
3300
|
-
if (result.issues !== undefined) return wrapResponse(
|
|
3524
|
+
if (result.issues !== undefined) return wrapResponse(plainValidationError(result.issues))
|
|
3301
3525
|
ctx.body = result.value
|
|
3302
3526
|
return runHandler(ctx, finalize, wrapResponse)
|
|
3303
3527
|
}
|
|
@@ -3306,13 +3530,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3306
3530
|
parsed: unknown,
|
|
3307
3531
|
ctx: RawContext,
|
|
3308
3532
|
finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
|
|
3309
|
-
wrapResponse: (response: Response) => T,
|
|
3533
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
3310
3534
|
): MaybePromise<T> => {
|
|
3311
3535
|
let validation: StandardResult<unknown> | Promise<StandardResult<unknown>>
|
|
3312
3536
|
try {
|
|
3313
3537
|
validation = bodySchema["~standard"].validate(parsed)
|
|
3314
3538
|
} catch (err) {
|
|
3315
|
-
return logError(err, ctx, wrapResponse)
|
|
3539
|
+
return logError(err, ctx, finalize, wrapResponse)
|
|
3316
3540
|
}
|
|
3317
3541
|
if (validation instanceof Promise) {
|
|
3318
3542
|
return validation.then(
|
|
@@ -3320,16 +3544,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3320
3544
|
try {
|
|
3321
3545
|
return runValidated(settled, ctx, finalize, wrapResponse)
|
|
3322
3546
|
} catch (err) {
|
|
3323
|
-
return logError(err, ctx, wrapResponse)
|
|
3547
|
+
return logError(err, ctx, finalize, wrapResponse)
|
|
3324
3548
|
}
|
|
3325
3549
|
},
|
|
3326
|
-
(err) => logError(err, ctx, wrapResponse),
|
|
3550
|
+
(err) => logError(err, ctx, finalize, wrapResponse),
|
|
3327
3551
|
)
|
|
3328
3552
|
}
|
|
3329
3553
|
try {
|
|
3330
3554
|
return runValidated(validation, ctx, finalize, wrapResponse)
|
|
3331
3555
|
} catch (err) {
|
|
3332
|
-
return logError(err, ctx, wrapResponse)
|
|
3556
|
+
return logError(err, ctx, finalize, wrapResponse)
|
|
3333
3557
|
}
|
|
3334
3558
|
}
|
|
3335
3559
|
|
|
@@ -3342,7 +3566,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3342
3566
|
platform: Platform | undefined,
|
|
3343
3567
|
nativeContext: boolean,
|
|
3344
3568
|
finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
|
|
3345
|
-
wrapResponse: (response: Response) => T,
|
|
3569
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
3346
3570
|
): MaybePromise<T> => {
|
|
3347
3571
|
const ctx = nativeContext
|
|
3348
3572
|
? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
|
|
@@ -3358,8 +3582,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3358
3582
|
)
|
|
3359
3583
|
const finish = (value: unknown): MaybePromise<T> =>
|
|
3360
3584
|
runParsed(value, ctx, finalize, wrapResponse)
|
|
3361
|
-
return
|
|
3362
|
-
|
|
3585
|
+
return readBodyFramed(
|
|
3586
|
+
source,
|
|
3587
|
+
maxBodyBytes,
|
|
3588
|
+
this.protoPoisoning,
|
|
3589
|
+
finish,
|
|
3590
|
+
wrapResponse,
|
|
3591
|
+
(err) => logError(err, ctx, finalize, wrapResponse),
|
|
3363
3592
|
)
|
|
3364
3593
|
}
|
|
3365
3594
|
}
|
|
@@ -3401,13 +3630,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3401
3630
|
querySchema: StandardSchemaV1,
|
|
3402
3631
|
maxBodyBytes: number,
|
|
3403
3632
|
): FusedWebRunner {
|
|
3404
|
-
//
|
|
3405
|
-
//
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
this.
|
|
3409
|
-
|
|
3410
|
-
|
|
3633
|
+
// Same thrown-value contract as the generic lane (`bare-error-lane.ts` via `bareError`); the fused
|
|
3634
|
+
// lane only injects its renderer, so a thrown `status(...)` and the flat 500 fold in static headers
|
|
3635
|
+
// through `wrapWebResponse`. One source of truth for the error surface, no per-lane copy to drift.
|
|
3636
|
+
const logError = (err: unknown, ctx: RawContext): Response =>
|
|
3637
|
+
this.bareError(
|
|
3638
|
+
err,
|
|
3639
|
+
ctx,
|
|
3640
|
+
(result, set) => this.wrapWebResponse(toResponse(result, set)),
|
|
3641
|
+
this.wrapWebResponse,
|
|
3642
|
+
)
|
|
3411
3643
|
const runHandler = (ctx: RawContext, value: unknown): MaybePromise<Response> => {
|
|
3412
3644
|
ctx.query = value
|
|
3413
3645
|
let result: unknown
|
|
@@ -3461,21 +3693,29 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3461
3693
|
return validation.then(
|
|
3462
3694
|
(settled) =>
|
|
3463
3695
|
settled.issues !== undefined
|
|
3464
|
-
? this.wrapWebResponse(
|
|
3696
|
+
? this.wrapWebResponse(plainValidationError(settled.issues))
|
|
3465
3697
|
: runHandler(ctx, settled.value),
|
|
3466
3698
|
(err) => logError(err, ctx),
|
|
3467
3699
|
)
|
|
3468
3700
|
}
|
|
3469
3701
|
if (validation.issues !== undefined)
|
|
3470
|
-
return this.wrapWebResponse(
|
|
3702
|
+
return this.wrapWebResponse(plainValidationError(validation.issues))
|
|
3471
3703
|
return runHandler(ctx, validation.value)
|
|
3472
3704
|
}
|
|
3473
3705
|
}
|
|
3474
3706
|
|
|
3475
|
-
private bareError<T>(
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3707
|
+
private bareError<T>(
|
|
3708
|
+
err: unknown,
|
|
3709
|
+
ctx: RawContext,
|
|
3710
|
+
finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
|
|
3711
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
3712
|
+
): T {
|
|
3713
|
+
// The thrown-value contract lives in `bare-error-lane.ts`, one source of truth for both this
|
|
3714
|
+
// lane and the fused body runner. `responseSet` and the logger are injected because they reach
|
|
3715
|
+
// into class state; the closure is allocated only on the throw path, never on the hot lane.
|
|
3716
|
+
return renderBareError(err, ctx, finalize, wrapResponse, responseSet, (e, c) =>
|
|
3717
|
+
this.logRequestError(e, c),
|
|
3718
|
+
)
|
|
3479
3719
|
}
|
|
3480
3720
|
|
|
3481
3721
|
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
@@ -3485,7 +3725,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3485
3725
|
ctx: RawContext,
|
|
3486
3726
|
run: () => MaybePromise<T>,
|
|
3487
3727
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
3488
|
-
wrapResponse: (response: Response) => T,
|
|
3728
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
3489
3729
|
): MaybePromise<T> {
|
|
3490
3730
|
let outcome: MaybePromise<T>
|
|
3491
3731
|
try {
|
|
@@ -3523,52 +3763,18 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3523
3763
|
source: RequestSource,
|
|
3524
3764
|
ctx: RawContext,
|
|
3525
3765
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
3526
|
-
wrapResponse: (response: Response) => T,
|
|
3766
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
3527
3767
|
): Promise<T> {
|
|
3528
|
-
return
|
|
3768
|
+
return readBodyFramed(
|
|
3529
3769
|
source,
|
|
3530
|
-
entry.bodyLimit ??
|
|
3770
|
+
entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
|
|
3771
|
+
this.protoPoisoning,
|
|
3531
3772
|
(parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse),
|
|
3532
3773
|
wrapResponse,
|
|
3533
3774
|
(err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
|
|
3534
3775
|
)
|
|
3535
3776
|
}
|
|
3536
3777
|
|
|
3537
|
-
/** Shared bounded body framing/parser. Both body lanes use the same trust-boundary checks; only
|
|
3538
|
-
* the validation + handler continuation differs. */
|
|
3539
|
-
private readBodyInput<T>(
|
|
3540
|
-
source: RequestSource,
|
|
3541
|
-
maxBodyBytes: number,
|
|
3542
|
-
onParsed: (parsed: unknown) => MaybePromise<T>,
|
|
3543
|
-
wrapResponse: (response: Response) => T,
|
|
3544
|
-
onError: (err: unknown) => MaybePromise<T>,
|
|
3545
|
-
): Promise<T> {
|
|
3546
|
-
const contentType = headerOf(source, "content-type") ?? ""
|
|
3547
|
-
if (contentType !== "application/json" && !contentType.includes("application/json")) {
|
|
3548
|
-
if (isUrlEncodedForm(contentType)) {
|
|
3549
|
-
return readBoundedForm(source, maxBodyBytes).then(
|
|
3550
|
-
(form) => (form instanceof Response ? wrapResponse(form) : onParsed(form)),
|
|
3551
|
-
onError,
|
|
3552
|
-
) as Promise<T>
|
|
3553
|
-
}
|
|
3554
|
-
return Promise.resolve(wrapResponse(jsonError(415, "unsupported_media_type")))
|
|
3555
|
-
}
|
|
3556
|
-
|
|
3557
|
-
// All JSON bodies go through readBoundedJson - the single enforcement point for the framed
|
|
3558
|
-
// fast path (post-read byte re-check), the streaming cap, and the prototype-poisoning guard.
|
|
3559
|
-
// A separate inlined native-json() fast path here would fork the trust boundary in two.
|
|
3560
|
-
try {
|
|
3561
|
-
return this.readBoundedJson(
|
|
3562
|
-
source,
|
|
3563
|
-
maxBodyBytes,
|
|
3564
|
-
(parsed) => (parsed instanceof Response ? wrapResponse(parsed) : onParsed(parsed)),
|
|
3565
|
-
onError,
|
|
3566
|
-
)
|
|
3567
|
-
} catch (err) {
|
|
3568
|
-
return Promise.resolve(onError(err))
|
|
3569
|
-
}
|
|
3570
|
-
}
|
|
3571
|
-
|
|
3572
3778
|
/** Validate + run the handler for the bodyOnly path - shared by the inline fast path and the
|
|
3573
3779
|
* streaming fallback. A method (not per-request closures) so the hot path allocates nothing
|
|
3574
3780
|
* beyond the one `.then` continuation. */
|
|
@@ -3577,7 +3783,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3577
3783
|
parsed: unknown,
|
|
3578
3784
|
ctx: RawContext,
|
|
3579
3785
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
3580
|
-
wrapResponse: (response: Response) => T,
|
|
3786
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
3581
3787
|
): MaybePromise<T> {
|
|
3582
3788
|
try {
|
|
3583
3789
|
const bodySchema = entry.schema!.body!
|
|
@@ -3612,7 +3818,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3612
3818
|
entry: RouteEntry,
|
|
3613
3819
|
ctx: RawContext,
|
|
3614
3820
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
3615
|
-
wrapResponse?: (response: Response) => T,
|
|
3821
|
+
wrapResponse?: (response: Response | ResponseResult) => T,
|
|
3616
3822
|
): MaybePromise<T> {
|
|
3617
3823
|
if (entry.hasDecorations) Object.assign(ctx, entry.decorations)
|
|
3618
3824
|
const handlerOutput = entry.handler(ctx)
|
|
@@ -3635,7 +3841,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3635
3841
|
recovery: unknown,
|
|
3636
3842
|
ctx: RawContext,
|
|
3637
3843
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
3638
|
-
wrapResponse: (response: Response) => T,
|
|
3844
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
3639
3845
|
originalIssues: ReadonlyArray<StandardIssue>,
|
|
3640
3846
|
kind: "body" | "query",
|
|
3641
3847
|
): MaybePromise<T> {
|
|
@@ -3647,12 +3853,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3647
3853
|
const validation = entry.schema.body["~standard"].validate(recovery)
|
|
3648
3854
|
if (validation instanceof Promise) {
|
|
3649
3855
|
return validation.then((settled) => {
|
|
3650
|
-
if (settled.issues !== undefined)
|
|
3856
|
+
if (settled.issues !== undefined)
|
|
3857
|
+
return wrapResponse(plainValidationError(settled.issues))
|
|
3651
3858
|
ctx.body = settled.value
|
|
3652
3859
|
return this.executeHandler(entry, ctx, finalize)
|
|
3653
3860
|
})
|
|
3654
3861
|
}
|
|
3655
|
-
if (validation.issues !== undefined)
|
|
3862
|
+
if (validation.issues !== undefined)
|
|
3863
|
+
return wrapResponse(plainValidationError(validation.issues))
|
|
3656
3864
|
ctx.body = validation.value
|
|
3657
3865
|
return this.executeHandler(entry, ctx, finalize)
|
|
3658
3866
|
}
|
|
@@ -3661,19 +3869,21 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3661
3869
|
if (validation instanceof Promise) {
|
|
3662
3870
|
return validation.then(
|
|
3663
3871
|
(settled) => {
|
|
3664
|
-
if (settled.issues !== undefined)
|
|
3872
|
+
if (settled.issues !== undefined)
|
|
3873
|
+
return wrapResponse(plainValidationError(settled.issues))
|
|
3665
3874
|
ctx.query = settled.value
|
|
3666
3875
|
return this.executeHandler(entry, ctx, finalize, wrapResponse)
|
|
3667
3876
|
},
|
|
3668
3877
|
(err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
|
|
3669
3878
|
)
|
|
3670
3879
|
}
|
|
3671
|
-
if (validation.issues !== undefined)
|
|
3880
|
+
if (validation.issues !== undefined)
|
|
3881
|
+
return wrapResponse(plainValidationError(validation.issues))
|
|
3672
3882
|
ctx.query = validation.value
|
|
3673
3883
|
return this.executeHandler(entry, ctx, finalize, wrapResponse)
|
|
3674
3884
|
}
|
|
3675
3885
|
}
|
|
3676
|
-
return wrapResponse(
|
|
3886
|
+
return wrapResponse(plainValidationError(originalIssues))
|
|
3677
3887
|
}
|
|
3678
3888
|
|
|
3679
3889
|
private applyBodyValidation<T>(
|
|
@@ -3681,7 +3891,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3681
3891
|
result: StandardResult<unknown>,
|
|
3682
3892
|
ctx: RawContext,
|
|
3683
3893
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
3684
|
-
wrapResponse: (response: Response) => T,
|
|
3894
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
3685
3895
|
): MaybePromise<T> {
|
|
3686
3896
|
if (result.issues !== undefined) {
|
|
3687
3897
|
const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError
|
|
@@ -3710,7 +3920,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3710
3920
|
"body",
|
|
3711
3921
|
)
|
|
3712
3922
|
}
|
|
3713
|
-
return wrapResponse(
|
|
3923
|
+
return wrapResponse(plainValidationError(result.issues))
|
|
3714
3924
|
}
|
|
3715
3925
|
ctx.body = result.value
|
|
3716
3926
|
return this.executeHandler(entry, ctx, finalize)
|
|
@@ -3722,7 +3932,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3722
3932
|
entry: RouteEntry,
|
|
3723
3933
|
ctx: RawContext,
|
|
3724
3934
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
3725
|
-
wrapResponse: (response: Response) => T,
|
|
3935
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
3726
3936
|
): MaybePromise<T> {
|
|
3727
3937
|
try {
|
|
3728
3938
|
// Call the validator directly for the raw StandardResult (read `.issues`/`.value`) - skip
|
|
@@ -3760,7 +3970,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3760
3970
|
result: StandardResult<unknown>,
|
|
3761
3971
|
ctx: RawContext,
|
|
3762
3972
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
3763
|
-
wrapResponse: (response: Response) => T,
|
|
3973
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
3764
3974
|
): MaybePromise<T> {
|
|
3765
3975
|
if (result.issues !== undefined) {
|
|
3766
3976
|
const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError
|
|
@@ -3789,7 +3999,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3789
3999
|
"query",
|
|
3790
4000
|
)
|
|
3791
4001
|
}
|
|
3792
|
-
return wrapResponse(
|
|
4002
|
+
return wrapResponse(plainValidationError(result.issues))
|
|
3793
4003
|
}
|
|
3794
4004
|
ctx.query = result.value
|
|
3795
4005
|
return this.executeHandler(entry, ctx, finalize, wrapResponse)
|
|
@@ -3931,7 +4141,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3931
4141
|
source: RequestSource,
|
|
3932
4142
|
ctx: RawContext,
|
|
3933
4143
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
3934
|
-
wrapResponse: (response: Response) => T,
|
|
4144
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
3935
4145
|
): MaybePromise<T> {
|
|
3936
4146
|
try {
|
|
3937
4147
|
const headersSchema = entry.schema?.headers
|
|
@@ -4004,7 +4214,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4004
4214
|
source: RequestSource,
|
|
4005
4215
|
ctx: RawContext,
|
|
4006
4216
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
4007
|
-
wrapResponse: (response: Response) => T,
|
|
4217
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
4008
4218
|
result: StandardResult<unknown>,
|
|
4009
4219
|
): MaybePromise<T> {
|
|
4010
4220
|
try {
|
|
@@ -4031,7 +4241,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4031
4241
|
source: RequestSource,
|
|
4032
4242
|
ctx: RawContext,
|
|
4033
4243
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
4034
|
-
wrapResponse: (response: Response) => T,
|
|
4244
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
4035
4245
|
): MaybePromise<T> {
|
|
4036
4246
|
const paramsSchema = entry.schema?.params
|
|
4037
4247
|
if (paramsSchema === undefined) {
|
|
@@ -4062,7 +4272,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4062
4272
|
entry: RouteEntry,
|
|
4063
4273
|
ctx: RawContext,
|
|
4064
4274
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
4065
|
-
wrapResponse: (response: Response) => T,
|
|
4275
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
4066
4276
|
): MaybePromise<T> {
|
|
4067
4277
|
try {
|
|
4068
4278
|
// The execution plan only selects this runner when the route has a query schema. Keeping the
|
|
@@ -4096,7 +4306,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4096
4306
|
source: RequestSource,
|
|
4097
4307
|
ctx: RawContext,
|
|
4098
4308
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
4099
|
-
wrapResponse: (response: Response) => T,
|
|
4309
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
4100
4310
|
): MaybePromise<T> {
|
|
4101
4311
|
return this.readAndValidateBody(source, entry, ctx).then(
|
|
4102
4312
|
(bodyError) =>
|
|
@@ -4115,7 +4325,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4115
4325
|
source: RequestSource,
|
|
4116
4326
|
ctx: RawContext,
|
|
4117
4327
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
4118
|
-
wrapResponse: (response: Response) => T,
|
|
4328
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
4119
4329
|
): MaybePromise<T> {
|
|
4120
4330
|
return this.readAndValidateBody(source, entry, ctx).then(
|
|
4121
4331
|
(bodyError) =>
|
|
@@ -4131,7 +4341,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4131
4341
|
entry: RouteEntry,
|
|
4132
4342
|
ctx: RawContext,
|
|
4133
4343
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
4134
|
-
wrapResponse: (response: Response) => T,
|
|
4344
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
4135
4345
|
): MaybePromise<T> {
|
|
4136
4346
|
const querySchema = entry.schema?.query
|
|
4137
4347
|
return querySchema === undefined
|
|
@@ -4143,7 +4353,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4143
4353
|
entry: RouteEntry,
|
|
4144
4354
|
ctx: RawContext,
|
|
4145
4355
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
4146
|
-
wrapResponse: (response: Response) => T,
|
|
4356
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
4147
4357
|
result: StandardResult<unknown>,
|
|
4148
4358
|
): MaybePromise<T> {
|
|
4149
4359
|
const queryError = this.applyLifecycleValidation(entry, result, ctx, "query")
|
|
@@ -4165,7 +4375,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4165
4375
|
source: RequestSource,
|
|
4166
4376
|
ctx: RawContext,
|
|
4167
4377
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
4168
|
-
wrapResponse: (response: Response) => T,
|
|
4378
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
4169
4379
|
result: StandardResult<unknown>,
|
|
4170
4380
|
): MaybePromise<T> {
|
|
4171
4381
|
try {
|
|
@@ -4187,8 +4397,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4187
4397
|
source: RequestSource,
|
|
4188
4398
|
ctx: RawContext,
|
|
4189
4399
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
4190
|
-
wrapResponse: (response: Response) => T,
|
|
4191
|
-
paramsError: MaybePromise<Response | undefined>,
|
|
4400
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
4401
|
+
paramsError: MaybePromise<Response | ResponseResult | undefined>,
|
|
4192
4402
|
): MaybePromise<T> {
|
|
4193
4403
|
if (paramsError instanceof Promise) {
|
|
4194
4404
|
return paramsError.then(
|
|
@@ -4212,8 +4422,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4212
4422
|
source: RequestSource,
|
|
4213
4423
|
ctx: RawContext,
|
|
4214
4424
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
4215
|
-
wrapResponse: (response: Response) => T,
|
|
4216
|
-
paramsError: Response | undefined,
|
|
4425
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
4426
|
+
paramsError: Response | ResponseResult | undefined,
|
|
4217
4427
|
): MaybePromise<T> {
|
|
4218
4428
|
if (paramsError !== undefined) return wrapResponse(paramsError)
|
|
4219
4429
|
if (entry.schema?.body !== undefined) {
|
|
@@ -4229,8 +4439,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4229
4439
|
entry: RouteEntry,
|
|
4230
4440
|
ctx: RawContext,
|
|
4231
4441
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
4232
|
-
wrapResponse: (response: Response) => T,
|
|
4233
|
-
bodyError: Response | undefined,
|
|
4442
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
4443
|
+
bodyError: Response | ResponseResult | undefined,
|
|
4234
4444
|
): MaybePromise<T> {
|
|
4235
4445
|
if (bodyError !== undefined) return wrapResponse(bodyError)
|
|
4236
4446
|
const querySchema = entry.schema?.query
|
|
@@ -4249,7 +4459,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4249
4459
|
entry: RouteEntry,
|
|
4250
4460
|
ctx: RawContext,
|
|
4251
4461
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
4252
|
-
wrapResponse: (response: Response) => T,
|
|
4462
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
4253
4463
|
result: StandardResult<unknown>,
|
|
4254
4464
|
): MaybePromise<T> {
|
|
4255
4465
|
try {
|
|
@@ -4270,8 +4480,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4270
4480
|
entry: RouteEntry,
|
|
4271
4481
|
ctx: RawContext,
|
|
4272
4482
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
4273
|
-
wrapResponse: (response: Response) => T,
|
|
4274
|
-
queryError: Response | undefined,
|
|
4483
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
4484
|
+
queryError: Response | ResponseResult | undefined,
|
|
4275
4485
|
): MaybePromise<T> {
|
|
4276
4486
|
if (queryError !== undefined) return wrapResponse(queryError)
|
|
4277
4487
|
return this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
|
|
@@ -4281,7 +4491,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4281
4491
|
entry: RouteEntry,
|
|
4282
4492
|
ctx: RawContext,
|
|
4283
4493
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
4284
|
-
wrapResponse: (response: Response) => T,
|
|
4494
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
4285
4495
|
): MaybePromise<T> {
|
|
4286
4496
|
if (entry.lifecycleHookLane === "derive-before") {
|
|
4287
4497
|
return this.runLifecycleDeriveBefore(entry, ctx, finalize, wrapResponse)
|
|
@@ -4293,6 +4503,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4293
4503
|
if (extension instanceof Promise) {
|
|
4294
4504
|
return this.continueLifecycleAfterDerive(entry, ctx, finalize, wrapResponse, i, extension)
|
|
4295
4505
|
}
|
|
4506
|
+
if (isDeriveEarlyExit(extension)) return finalize(extension, responseSet(ctx))
|
|
4296
4507
|
Object.assign(ctx, extension)
|
|
4297
4508
|
}
|
|
4298
4509
|
for (let i = 0; i < entry.beforeHandle.length; i++) {
|
|
@@ -4320,13 +4531,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4320
4531
|
entry: RouteEntry,
|
|
4321
4532
|
ctx: RawContext,
|
|
4322
4533
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
4323
|
-
wrapResponse: (response: Response) => T,
|
|
4534
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
4324
4535
|
): MaybePromise<T> {
|
|
4325
4536
|
try {
|
|
4326
4537
|
const extension = entry.derives[0]!(ctx)
|
|
4327
4538
|
if (extension instanceof Promise) {
|
|
4328
4539
|
return this.continueLifecycleAfterDerive(entry, ctx, finalize, wrapResponse, 0, extension)
|
|
4329
4540
|
}
|
|
4541
|
+
if (isDeriveEarlyExit(extension)) return finalize(extension, responseSet(ctx))
|
|
4330
4542
|
Object.assign(ctx, extension)
|
|
4331
4543
|
|
|
4332
4544
|
const outcome = entry.beforeHandle[0]!(ctx)
|
|
@@ -4354,7 +4566,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4354
4566
|
entry: RouteEntry,
|
|
4355
4567
|
ctx: RawContext,
|
|
4356
4568
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
4357
|
-
wrapResponse: (response: Response) => T,
|
|
4569
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
4358
4570
|
result: unknown,
|
|
4359
4571
|
): MaybePromise<T> {
|
|
4360
4572
|
try {
|
|
@@ -4368,15 +4580,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4368
4580
|
entry: RouteEntry,
|
|
4369
4581
|
ctx: RawContext,
|
|
4370
4582
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
4371
|
-
wrapResponse: (response: Response) => T,
|
|
4583
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
4372
4584
|
firstIndex: number,
|
|
4373
4585
|
first: Promise<unknown>,
|
|
4374
4586
|
): Promise<T> {
|
|
4375
4587
|
try {
|
|
4376
|
-
|
|
4588
|
+
const settled = await first
|
|
4589
|
+
if (isDeriveEarlyExit(settled)) return finalize(settled, responseSet(ctx))
|
|
4590
|
+
Object.assign(ctx, settled)
|
|
4377
4591
|
for (let i = firstIndex + 1; i < entry.derives.length; i++) {
|
|
4378
|
-
const
|
|
4379
|
-
|
|
4592
|
+
const raw = entry.derives[i]!(ctx)
|
|
4593
|
+
const extension = raw instanceof Promise ? await raw : raw
|
|
4594
|
+
if (isDeriveEarlyExit(extension)) return finalize(extension, responseSet(ctx))
|
|
4595
|
+
Object.assign(ctx, extension)
|
|
4380
4596
|
}
|
|
4381
4597
|
return await this.runLifecycleHooksAsync(entry, ctx, finalize, wrapResponse)
|
|
4382
4598
|
} catch (err) {
|
|
@@ -4388,7 +4604,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4388
4604
|
entry: RouteEntry,
|
|
4389
4605
|
ctx: RawContext,
|
|
4390
4606
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
4391
|
-
wrapResponse: (response: Response) => T,
|
|
4607
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
4392
4608
|
): Promise<T> {
|
|
4393
4609
|
try {
|
|
4394
4610
|
// Indexed, not `for...of`. An array iterator declared inside an async function stays live
|
|
@@ -4410,7 +4626,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4410
4626
|
entry: RouteEntry,
|
|
4411
4627
|
ctx: RawContext,
|
|
4412
4628
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
4413
|
-
wrapResponse: (response: Response) => T,
|
|
4629
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
4414
4630
|
firstIndex: number,
|
|
4415
4631
|
first: Promise<unknown>,
|
|
4416
4632
|
): Promise<T> {
|
|
@@ -4432,7 +4648,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4432
4648
|
entry: RouteEntry,
|
|
4433
4649
|
ctx: RawContext,
|
|
4434
4650
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
4435
|
-
wrapResponse: (response: Response) => T,
|
|
4651
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
4436
4652
|
): Promise<T> {
|
|
4437
4653
|
try {
|
|
4438
4654
|
const output = entry.handler(ctx)
|
|
@@ -4447,7 +4663,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4447
4663
|
entry: RouteEntry,
|
|
4448
4664
|
ctx: RawContext,
|
|
4449
4665
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
4450
|
-
wrapResponse: (response: Response) => T,
|
|
4666
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
4451
4667
|
initial: unknown,
|
|
4452
4668
|
): MaybePromise<T> {
|
|
4453
4669
|
try {
|
|
@@ -4476,7 +4692,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4476
4692
|
entry: RouteEntry,
|
|
4477
4693
|
ctx: RawContext,
|
|
4478
4694
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
4479
|
-
wrapResponse: (response: Response) => T,
|
|
4695
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
4480
4696
|
firstIndex: number,
|
|
4481
4697
|
first: Promise<unknown>,
|
|
4482
4698
|
): Promise<T> {
|
|
@@ -4496,7 +4712,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4496
4712
|
entry: RouteEntry,
|
|
4497
4713
|
ctx: RawContext,
|
|
4498
4714
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
4499
|
-
wrapResponse: (response: Response) => T,
|
|
4715
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
4500
4716
|
result: unknown,
|
|
4501
4717
|
): MaybePromise<T> {
|
|
4502
4718
|
try {
|
|
@@ -4518,7 +4734,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4518
4734
|
private finishContractOutcome<T>(
|
|
4519
4735
|
ctx: RawContext,
|
|
4520
4736
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
4521
|
-
wrapResponse: (response: Response) => T,
|
|
4737
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
4522
4738
|
outcome: {
|
|
4523
4739
|
readonly kind: "ok" | "warn" | "violation"
|
|
4524
4740
|
readonly value?: unknown
|
|
@@ -4531,7 +4747,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4531
4747
|
path: pathnameOf(ctx.req.url),
|
|
4532
4748
|
detail: outcome.message,
|
|
4533
4749
|
})
|
|
4534
|
-
return wrapResponse(
|
|
4750
|
+
return wrapResponse(plainError(500, "internal_error"))
|
|
4535
4751
|
}
|
|
4536
4752
|
if (outcome.kind === "warn") {
|
|
4537
4753
|
this.logger.warn("response contract", {
|
|
@@ -4543,19 +4759,44 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4543
4759
|
return finalize(outcome.value, responseSet(ctx))
|
|
4544
4760
|
}
|
|
4545
4761
|
|
|
4546
|
-
|
|
4762
|
+
/** Synchronous by design, and `MaybePromise` rather than `Promise` for the same reason: control flow
|
|
4763
|
+
* out of a lifecycle stage must not cost a promise. An `async` method allocates one and defers the
|
|
4764
|
+
* result a microtask even when every branch it takes returns immediately, which is the common case
|
|
4765
|
+
* here - a thrown `status(...)`/`Response`, or a route with no `onError` hook at all. Only the hook
|
|
4766
|
+
* loop, which may await, drops into {@link runErrorHooks}. */
|
|
4767
|
+
private handleLifecycleError<T>(
|
|
4547
4768
|
entry: RouteEntry,
|
|
4548
4769
|
err: unknown,
|
|
4549
4770
|
ctx: RawContext,
|
|
4550
4771
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
4551
|
-
wrapResponse: (response: Response) => T,
|
|
4552
|
-
):
|
|
4772
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
4773
|
+
): MaybePromise<T> {
|
|
4553
4774
|
// A *thrown* Response is deliberate control flow, not an error - a guard throws a redirect/401,
|
|
4554
4775
|
// an action throws an error page. Return it as-is (Remix/SvelteKit semantics); don't run onError
|
|
4555
4776
|
// or log it as a 500. This is what makes `throw redirect(...)` / `requireSession(...)` work from
|
|
4556
4777
|
// any handler or loader.
|
|
4557
4778
|
if (err instanceof Response) return wrapResponse(err)
|
|
4558
|
-
//
|
|
4779
|
+
// Same rule for a thrown `status(...)`, but through `finalize` rather than `wrapResponse`: the
|
|
4780
|
+
// value is still plain data, so the ordinary JSON lane renders it and no `Response` is built.
|
|
4781
|
+
if (isResponseResult(err)) return finalize(err, responseSet(ctx))
|
|
4782
|
+
if (entry.onError.length === 0) {
|
|
4783
|
+
// Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
|
|
4784
|
+
// (redacting) logger. Body-read failures and around-hook failures land here too.
|
|
4785
|
+
this.logRequestError(err, ctx)
|
|
4786
|
+
return wrapResponse(plainError(500, "internal_error"))
|
|
4787
|
+
}
|
|
4788
|
+
return this.runErrorHooks(entry, err, ctx, finalize, wrapResponse)
|
|
4789
|
+
}
|
|
4790
|
+
|
|
4791
|
+
/** The `onError` chain, split off {@link handleLifecycleError} so its `await` costs only the routes
|
|
4792
|
+
* that registered a hook. A hook may return a custom response; otherwise the default 500 stands. */
|
|
4793
|
+
private async runErrorHooks<T>(
|
|
4794
|
+
entry: RouteEntry,
|
|
4795
|
+
err: unknown,
|
|
4796
|
+
ctx: RawContext,
|
|
4797
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
4798
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
4799
|
+
): Promise<T> {
|
|
4559
4800
|
// Indexed for the same reason as the `beforeHandle` chain above: the iterator would outlive
|
|
4560
4801
|
// the `await` and stay heap-allocated on both engines.
|
|
4561
4802
|
for (let i = 0; i < entry.onError.length; i++) {
|
|
@@ -4563,51 +4804,35 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4563
4804
|
const handled = outcome instanceof Promise ? await outcome : outcome
|
|
4564
4805
|
if (handled !== undefined) return finalize(handled, responseSet(ctx))
|
|
4565
4806
|
}
|
|
4566
|
-
// Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
|
|
4567
|
-
// (redacting) logger. Body-read failures and around-hook failures land here too.
|
|
4568
4807
|
this.logRequestError(err, ctx)
|
|
4569
|
-
return wrapResponse(
|
|
4808
|
+
return wrapResponse(plainError(500, "internal_error"))
|
|
4570
4809
|
}
|
|
4571
4810
|
|
|
4572
4811
|
/** Log an unhandled request error to the (redacting) logger - shared by {@link runLifecycle} and the
|
|
4573
4812
|
* bare fast path ({@link bareError}) so both record the same fields. Never throws; never leaks. */
|
|
4574
4813
|
private logRequestError(err: unknown, ctx: RawContext): void {
|
|
4575
|
-
|
|
4576
|
-
// the app's call (`errorLogDetail`). The default keeps it: a 500 with no message and no stack is
|
|
4577
|
-
// an incident nobody can diagnose, and the framework already ships the narrower instrument for
|
|
4578
|
-
// the leak - a redacting logger with `valuePatterns`. `"none"` is there for an untrusted sink.
|
|
4579
|
-
const detail = this.errorLogDetail
|
|
4580
|
-
this.logger.error("unhandled request error", {
|
|
4581
|
-
method: ctx.req.method,
|
|
4582
|
-
path: pathnameOf(ctx.req.url),
|
|
4583
|
-
name: err instanceof Error ? err.name : "Error",
|
|
4584
|
-
// `detail`, not `message`: the logger uses `message` for its own first argument, so a field of
|
|
4585
|
-
// that name is silently overwritten and the thrown error's own text never reaches the sink. It
|
|
4586
|
-
// survived only incidentally inside `stack`, and was lost outright for a non-Error throw.
|
|
4587
|
-
...(detail === "none" ? {} : { detail: err instanceof Error ? err.message : String(err) }),
|
|
4588
|
-
...(detail === "full" && err instanceof Error ? { stack: err.stack } : {}),
|
|
4589
|
-
})
|
|
4814
|
+
emitRequestErrorLog(this.logger, this.errorLogDetail, err, ctx)
|
|
4590
4815
|
}
|
|
4591
4816
|
|
|
4592
4817
|
private async readAndValidateBody(
|
|
4593
4818
|
req: RequestSource,
|
|
4594
4819
|
entry: RouteEntry,
|
|
4595
4820
|
ctx: RawContext,
|
|
4596
|
-
): Promise<Response | undefined> {
|
|
4821
|
+
): Promise<Response | ResponseResult | undefined> {
|
|
4597
4822
|
const contentType = headerOf(req, "content-type") ?? ""
|
|
4598
4823
|
let parsed: unknown
|
|
4599
4824
|
if (contentType === "application/json" || contentType.includes("application/json")) {
|
|
4600
|
-
const json = await this.readBoundedJson(req, entry.bodyLimit ??
|
|
4601
|
-
if (json
|
|
4825
|
+
const json = await this.readBoundedJson(req, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
|
|
4826
|
+
if (isResponseResult(json)) return json
|
|
4602
4827
|
parsed = json
|
|
4603
4828
|
} else if (isUrlEncodedForm(contentType)) {
|
|
4604
|
-
const form = await readBoundedForm(req, entry.bodyLimit ??
|
|
4605
|
-
if (form
|
|
4829
|
+
const form = await readBoundedForm(req, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
|
|
4830
|
+
if (isResponseResult(form)) return form
|
|
4606
4831
|
parsed = form
|
|
4607
4832
|
} else {
|
|
4608
4833
|
// multipart/form-data (file uploads) stays 415 on the schema path by design - files don't
|
|
4609
4834
|
// fit a value schema; use a schema-less route + @nifrajs/uploads helpers for those.
|
|
4610
|
-
return
|
|
4835
|
+
return plainError(415, "unsupported_media_type")
|
|
4611
4836
|
}
|
|
4612
4837
|
const validation = entry.schema!.body!["~standard"].validate(parsed)
|
|
4613
4838
|
const result = validation instanceof Promise ? await validation : validation
|
|
@@ -4621,7 +4846,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4621
4846
|
result: StandardResult<unknown>,
|
|
4622
4847
|
ctx: RawContext,
|
|
4623
4848
|
kind: "body" | "query" | "params" | "headers",
|
|
4624
|
-
): MaybePromise<Response | undefined> {
|
|
4849
|
+
): MaybePromise<Response | ResponseResult | undefined> {
|
|
4625
4850
|
const assign = (value: unknown): void => {
|
|
4626
4851
|
if (kind === "body") ctx.body = value
|
|
4627
4852
|
else if (kind === "query") ctx.query = value
|
|
@@ -4633,7 +4858,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4633
4858
|
return undefined
|
|
4634
4859
|
}
|
|
4635
4860
|
const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError
|
|
4636
|
-
if (hook === undefined) return
|
|
4861
|
+
if (hook === undefined) return plainValidationError(result.issues)
|
|
4637
4862
|
const attempted = hook(result.issues, ctx as unknown as Context, kind)
|
|
4638
4863
|
if (attempted instanceof Promise) {
|
|
4639
4864
|
return attempted.then((recovery) =>
|
|
@@ -4649,8 +4874,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4649
4874
|
issues: ReadonlyArray<StandardIssue>,
|
|
4650
4875
|
recovery: unknown,
|
|
4651
4876
|
assign: (value: unknown) => void,
|
|
4652
|
-
): MaybePromise<Response | undefined> {
|
|
4653
|
-
if (recovery === undefined) return
|
|
4877
|
+
): MaybePromise<Response | ResponseResult | undefined> {
|
|
4878
|
+
if (recovery === undefined) return plainValidationError(issues)
|
|
4654
4879
|
if (recovery instanceof Response) return recovery
|
|
4655
4880
|
const schema =
|
|
4656
4881
|
kind === "body"
|
|
@@ -4663,12 +4888,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4663
4888
|
const retried = schema!["~standard"].validate(recovery)
|
|
4664
4889
|
if (retried instanceof Promise) {
|
|
4665
4890
|
return retried.then((settled) => {
|
|
4666
|
-
if (settled.issues !== undefined) return
|
|
4891
|
+
if (settled.issues !== undefined) return plainValidationError(settled.issues)
|
|
4667
4892
|
assign(settled.value)
|
|
4668
4893
|
return undefined
|
|
4669
4894
|
})
|
|
4670
4895
|
}
|
|
4671
|
-
if (retried.issues !== undefined) return
|
|
4896
|
+
if (retried.issues !== undefined) return plainValidationError(retried.issues)
|
|
4672
4897
|
assign(retried.value)
|
|
4673
4898
|
return undefined
|
|
4674
4899
|
}
|
|
@@ -4680,19 +4905,22 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4680
4905
|
* length-less body mid-stream once the running byte count exceeds the cap - so a lying
|
|
4681
4906
|
* or absent length can't force us to buffer an oversized payload.
|
|
4682
4907
|
*/
|
|
4683
|
-
private readBoundedJson(
|
|
4908
|
+
private readBoundedJson(
|
|
4909
|
+
req: RequestSource,
|
|
4910
|
+
maxBodyBytes?: number,
|
|
4911
|
+
): Promise<unknown | ResponseResult>
|
|
4684
4912
|
private readBoundedJson<T>(
|
|
4685
4913
|
req: RequestSource,
|
|
4686
4914
|
maxBodyBytes: number,
|
|
4687
|
-
onResult: (parsed: unknown |
|
|
4915
|
+
onResult: (parsed: unknown | ResponseResult) => T | Promise<T>,
|
|
4688
4916
|
onError: (error: unknown) => T | Promise<T>,
|
|
4689
4917
|
): Promise<T>
|
|
4690
4918
|
private readBoundedJson<T>(
|
|
4691
4919
|
req: RequestSource,
|
|
4692
4920
|
maxBodyBytes = this.maxBodyBytes,
|
|
4693
|
-
onResult?: (parsed: unknown |
|
|
4921
|
+
onResult?: (parsed: unknown | ResponseResult) => T | Promise<T>,
|
|
4694
4922
|
onError?: (error: unknown) => T | Promise<T>,
|
|
4695
|
-
): Promise<unknown |
|
|
4923
|
+
): Promise<unknown | ResponseResult | T> {
|
|
4696
4924
|
// Not `async`: this is a pass-through, and wrapping the lane's own promise in a coroutine
|
|
4697
4925
|
// frame costs an extra microtask hop on every JSON body.
|
|
4698
4926
|
return onResult === undefined
|
|
@@ -4873,6 +5101,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4873
5101
|
"listen() uses Bun.serve and runs only on Bun. Serve on Node with @nifrajs/node or on Deno with @nifrajs/deno, or hand app.fetch to any fetch-compatible runtime (Workers, etc.).",
|
|
4874
5102
|
)
|
|
4875
5103
|
}
|
|
5104
|
+
// Seal before Bun.serve: in `"error"` mode this throws instead of leaving a bound port behind.
|
|
5105
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
5106
|
+
sealHookAudit(this, this.catalog.size, this.logger)
|
|
4876
5107
|
// Bun's `Server` is the concrete handle; we expose the stable `RunningServer`
|
|
4877
5108
|
// subset so the public types don't depend on the ambient `Bun` global. The cast
|
|
4878
5109
|
// bridges them - Bun's `.port` is `number | undefined` (undefined only for unix
|