@nifrajs/core 2.11.0 → 2.12.1
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/dist/assurance.d.ts +61 -3
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +152 -5
- package/dist/assurance.js.map +1 -1
- package/dist/capabilities.d.ts +9 -1
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +5 -1
- package/dist/capabilities.js.map +1 -1
- package/dist/diff.d.ts +2 -1
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +3 -1
- package/dist/diff.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/evidence.d.ts +89 -0
- package/dist/evidence.d.ts.map +1 -0
- package/dist/evidence.js +170 -0
- package/dist/evidence.js.map +1 -0
- package/dist/execution-policy.d.ts +23 -0
- package/dist/execution-policy.d.ts.map +1 -0
- package/dist/execution-policy.js +28 -0
- package/dist/execution-policy.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/route-assurance.d.ts +19 -0
- package/dist/internal/route-assurance.d.ts.map +1 -1
- package/dist/internal/route-assurance.js +38 -5
- package/dist/internal/route-assurance.js.map +1 -1
- package/dist/internal/route-execution.d.ts +18 -0
- package/dist/internal/route-execution.d.ts.map +1 -1
- package/dist/internal/route-execution.js +67 -1
- package/dist/internal/route-execution.js.map +1 -1
- package/dist/manifest.d.ts +5 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +22 -4
- package/dist/manifest.js.map +1 -1
- package/dist/reflection.d.ts +5 -0
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +13 -4
- package/dist/reflection.js.map +1 -1
- package/dist/replay.d.ts +26 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +63 -0
- package/dist/replay.js.map +1 -0
- package/dist/schema/standard.d.ts +2 -0
- package/dist/schema/standard.d.ts.map +1 -1
- package/dist/schema/standard.js +13 -0
- package/dist/schema/standard.js.map +1 -1
- package/dist/server/body.d.ts +49 -6
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +226 -8
- package/dist/server/body.js.map +1 -1
- package/dist/server/context.d.ts +20 -3
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/contract.d.ts +15 -3
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js +22 -0
- package/dist/server/contract.js.map +1 -1
- package/dist/server/cookies.d.ts +26 -5
- package/dist/server/cookies.d.ts.map +1 -1
- package/dist/server/cookies.js +76 -9
- package/dist/server/cookies.js.map +1 -1
- package/dist/server/edge.d.ts.map +1 -1
- package/dist/server/edge.js +11 -0
- package/dist/server/edge.js.map +1 -1
- package/dist/server/header-case.d.ts +9 -0
- package/dist/server/header-case.d.ts.map +1 -0
- package/dist/server/header-case.js +54 -0
- package/dist/server/header-case.js.map +1 -0
- package/dist/server/headers.d.ts +8 -0
- package/dist/server/headers.d.ts.map +1 -0
- package/dist/server/headers.js +13 -0
- package/dist/server/headers.js.map +1 -0
- package/dist/server/node-outcome-hook.d.ts.map +1 -1
- package/dist/server/node-outcome-hook.js +7 -11
- package/dist/server/node-outcome-hook.js.map +1 -1
- package/dist/server/node-outcome.d.ts +8 -1
- package/dist/server/node-outcome.d.ts.map +1 -1
- package/dist/server/node-outcome.js +10 -3
- package/dist/server/node-outcome.js.map +1 -1
- package/dist/server/plugin.d.ts +66 -7
- package/dist/server/plugin.d.ts.map +1 -1
- package/dist/server/plugin.js +14 -10
- package/dist/server/plugin.js.map +1 -1
- package/dist/server/proto-guard.d.ts +30 -0
- package/dist/server/proto-guard.d.ts.map +1 -0
- package/dist/server/proto-guard.js +116 -0
- package/dist/server/proto-guard.js.map +1 -0
- package/dist/server/registry.d.ts +6 -2
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +24 -6
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +157 -30
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/respond.d.ts +2 -2
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +34 -8
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +1 -1
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +45 -13
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/response-hooks.d.ts +10 -0
- package/dist/server/response-hooks.d.ts.map +1 -0
- package/dist/server/response-hooks.js +52 -0
- package/dist/server/response-hooks.js.map +1 -0
- package/dist/server/runtime-core.d.ts +12 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js +8 -0
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +60 -6
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +80 -23
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +429 -213
- package/dist/server/server.js.map +1 -1
- package/dist/server/static-headers.d.ts +14 -3
- package/dist/server/static-headers.d.ts.map +1 -1
- package/dist/server/static-headers.js +28 -6
- package/dist/server/static-headers.js.map +1 -1
- package/dist/server/transport-lane.d.ts +8 -0
- package/dist/server/transport-lane.d.ts.map +1 -1
- package/dist/server/transport-lane.js +12 -8
- package/dist/server/transport-lane.js.map +1 -1
- package/dist/server/websocket.d.ts +3 -0
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +14 -0
- package/dist/server/websocket.js.map +1 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +2 -2
- package/dist/server.js.map +1 -1
- package/dist/tool-catalog.d.ts +21 -0
- package/dist/tool-catalog.d.ts.map +1 -0
- package/dist/tool-catalog.js +16 -0
- package/dist/tool-catalog.js.map +1 -0
- package/dist/tool-contract.d.ts +238 -0
- package/dist/tool-contract.d.ts.map +1 -0
- package/dist/tool-contract.js +569 -0
- package/dist/tool-contract.js.map +1 -0
- package/package.json +26 -1
- package/src/assurance.ts +236 -6
- package/src/capabilities.ts +14 -1
- package/src/diff.ts +5 -2
- package/src/errors.ts +1 -0
- package/src/evidence.ts +319 -0
- package/src/execution-policy.ts +57 -0
- package/src/index.ts +1 -1
- package/src/internal/route-assurance.ts +67 -4
- package/src/internal/route-execution.ts +276 -1
- package/src/manifest.ts +27 -5
- package/src/reflection.ts +24 -4
- package/src/replay.ts +96 -0
- package/src/schema/standard.ts +16 -0
- package/src/server/body.ts +275 -7
- package/src/server/context.ts +23 -3
- package/src/server/contract.ts +43 -3
- package/src/server/cookies.ts +80 -10
- package/src/server/edge.ts +10 -0
- package/src/server/header-case.ts +54 -0
- package/src/server/headers.ts +11 -0
- package/src/server/node-outcome-hook.ts +6 -11
- package/src/server/node-outcome.ts +10 -2
- package/src/server/plugin.ts +87 -8
- package/src/server/proto-guard.ts +118 -0
- package/src/server/registry.ts +6 -2
- package/src/server/request-context.ts +242 -26
- package/src/server/respond.ts +35 -4
- package/src/server/response-contract-lane.ts +6 -17
- package/src/server/response-hooks.ts +51 -0
- package/src/server/runtime-core.ts +14 -0
- package/src/server/server-types.ts +66 -6
- package/src/server/server.ts +672 -287
- package/src/server/static-headers.ts +27 -3
- package/src/server/transport-lane.ts +19 -8
- package/src/server/websocket.ts +18 -2
- package/src/server.ts +7 -0
- package/src/tool-catalog.ts +40 -0
- package/src/tool-contract.ts +896 -0
package/src/server/server.ts
CHANGED
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
} from "../budget.ts"
|
|
9
9
|
import type { EffectLifecycleObserver } from "../effect-lifecycle.ts"
|
|
10
10
|
import { FrameworkError, RouteConfigError } from "../errors.ts"
|
|
11
|
+
// Type-only (erased at build): the package root re-exports this module, so a value import would cycle.
|
|
12
|
+
import type { Version } from "../index.ts"
|
|
11
13
|
import {
|
|
12
14
|
type AroundCapabilityOptions,
|
|
13
15
|
CAPABILITY_GUARD,
|
|
@@ -26,22 +28,19 @@ import {
|
|
|
26
28
|
validEvidenceId,
|
|
27
29
|
} from "../internal/route-assurance.ts"
|
|
28
30
|
import { type CatalogRoute, RouteCatalog } from "../internal/route-catalog.ts"
|
|
29
|
-
import
|
|
30
|
-
|
|
31
|
-
FusedBodyRunner,
|
|
32
|
-
FusedWebRunner,
|
|
33
|
-
InternalHandler,
|
|
34
|
-
RawAfterHandle,
|
|
35
|
-
RawAround,
|
|
36
|
-
RawBeforeHandle,
|
|
37
|
-
RawDerive,
|
|
38
|
-
RawErrorHandler,
|
|
39
|
-
RouteEntry,
|
|
40
|
-
RouteExecutionPlan,
|
|
41
|
-
RouteExecutionRunner,
|
|
31
|
+
import {
|
|
32
|
+
compileRouteExecutionPlan,
|
|
33
|
+
type FusedBodyRunner,
|
|
34
|
+
type FusedWebRunner,
|
|
35
|
+
type InternalHandler,
|
|
36
|
+
type RawAfterHandle,
|
|
37
|
+
type RawAround,
|
|
38
|
+
type RawBeforeHandle,
|
|
39
|
+
type RawDerive,
|
|
40
|
+
type RawErrorHandler,
|
|
41
|
+
type RouteEntry,
|
|
42
42
|
} from "../internal/route-execution.ts"
|
|
43
43
|
import { isSameOriginRequest } from "../internal/same-origin.ts"
|
|
44
|
-
import type { RequestLedger } from "../ledger.ts"
|
|
45
44
|
import { compileRoutePattern, decodeRouteParams } from "../router/pattern.ts"
|
|
46
45
|
import { EMPTY_PARAMS, type Method, Router } from "../router/router.ts"
|
|
47
46
|
import type {
|
|
@@ -50,9 +49,15 @@ import type {
|
|
|
50
49
|
StandardResult,
|
|
51
50
|
StandardSchemaV1,
|
|
52
51
|
} from "../schema/standard.ts"
|
|
53
|
-
import { assertByteLimit,
|
|
52
|
+
import { assertByteLimit, markTransportCap, markTrustedBodyFraming } from "./body.ts"
|
|
54
53
|
import { type ClientIpTrust, resolveClientIp } from "./client-ip.ts"
|
|
55
54
|
import type { Context, Platform, ResponseControls, RouteSchema } from "./context.ts"
|
|
55
|
+
import {
|
|
56
|
+
hasLowercaseHeaderKeysMark,
|
|
57
|
+
headerKeysAllLowercase,
|
|
58
|
+
markLowercaseHeaderKeys,
|
|
59
|
+
} from "./header-case.ts"
|
|
60
|
+
import { headerObjectOf } from "./headers.ts"
|
|
56
61
|
import { jsonError, pathnameOf, type UrlParts, urlPartsOf } from "./http.ts"
|
|
57
62
|
import { type NodeServeOutcome, withStaticNodeHeaders } from "./node-outcome.ts"
|
|
58
63
|
import {
|
|
@@ -81,15 +86,14 @@ import {
|
|
|
81
86
|
fusedRespond,
|
|
82
87
|
fusedRespondNoSet,
|
|
83
88
|
knownMutableHeaders,
|
|
84
|
-
markTaggedResponse,
|
|
85
89
|
rememberMutableHeaders,
|
|
86
90
|
taggedResponseBody,
|
|
87
|
-
taggedResponseOwner,
|
|
88
91
|
toResponse,
|
|
89
92
|
} from "./respond.ts"
|
|
90
93
|
// Type-only: erased, so the kernel never pulls the lane's implementation into a bundle that does not
|
|
91
94
|
// install the plugin. The value side arrives through the symbol-keyed install seam.
|
|
92
95
|
import type { ResponseContractRuntime } from "./response-contract-lane.ts"
|
|
96
|
+
import { applyBodyReplacement, withReplacedBody } from "./response-hooks.ts"
|
|
93
97
|
import {
|
|
94
98
|
CONTEXT_SEARCH,
|
|
95
99
|
CONTEXT_SET,
|
|
@@ -116,56 +120,6 @@ export type {
|
|
|
116
120
|
ResponseHeadersView,
|
|
117
121
|
}
|
|
118
122
|
|
|
119
|
-
function isBodylessStatus(status: number): boolean {
|
|
120
|
-
return status === 204 || status === 205 || status === 304
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** Apply a body hook's return to the native response context (bytes, or a structured replacement). */
|
|
124
|
-
function applyBodyReplacement(
|
|
125
|
-
response: NodeResponseContext,
|
|
126
|
-
replaced: string | Uint8Array | ResponseBodyReplacement | undefined,
|
|
127
|
-
): void {
|
|
128
|
-
if (replaced === undefined) return
|
|
129
|
-
if (typeof replaced === "string" || replaced instanceof Uint8Array) {
|
|
130
|
-
response.body = isBodylessStatus(response.status) ? null : replaced
|
|
131
|
-
return
|
|
132
|
-
}
|
|
133
|
-
if (replaced.body !== undefined) response.body = replaced.body
|
|
134
|
-
if (replaced.status !== undefined) response.status = replaced.status
|
|
135
|
-
if (isBodylessStatus(response.status)) response.body = null
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** Swap a tagged Response's body for a hook's replacement, re-tagging so later body hooks (and the
|
|
139
|
-
* Node fallback's direct writer) see the new bytes; explicit lengths are dropped so framing is
|
|
140
|
-
* re-derived from what actually ships. */
|
|
141
|
-
function withReplacedBody(
|
|
142
|
-
response: Response,
|
|
143
|
-
replaced: string | Uint8Array | ResponseBodyReplacement | undefined,
|
|
144
|
-
): Response {
|
|
145
|
-
if (replaced === undefined) return response
|
|
146
|
-
const originalBody = taggedResponseBody(response)
|
|
147
|
-
let body: string | Uint8Array | null
|
|
148
|
-
let status = response.status
|
|
149
|
-
if (typeof replaced === "string" || replaced instanceof Uint8Array) {
|
|
150
|
-
body = replaced
|
|
151
|
-
} else {
|
|
152
|
-
body = replaced.body !== undefined ? replaced.body : (originalBody ?? null)
|
|
153
|
-
if (replaced.status !== undefined) status = replaced.status
|
|
154
|
-
if (body === (originalBody ?? null) && status === response.status) {
|
|
155
|
-
return response
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
if (isBodylessStatus(status)) body = null
|
|
159
|
-
const headers = new Headers(response.headers)
|
|
160
|
-
headers.delete("content-length")
|
|
161
|
-
const next = new Response(body as ConstructorParameters<typeof Response>[0], {
|
|
162
|
-
status,
|
|
163
|
-
headers,
|
|
164
|
-
})
|
|
165
|
-
if (body !== null) markTaggedResponse(next, body, taggedResponseOwner(response))
|
|
166
|
-
return next
|
|
167
|
-
}
|
|
168
|
-
|
|
169
123
|
const HEADER_MUTABILITY_PROBE = "x-nifra-header-probe"
|
|
170
124
|
const GUARDED_RESPONSE_HEADERS = new WeakSet<Headers>()
|
|
171
125
|
|
|
@@ -269,7 +223,7 @@ import {
|
|
|
269
223
|
import type { EffectLedgerRuntime, ResolvedEffectLedger } from "./ledger-lane.ts"
|
|
270
224
|
import { jsonLogger, type Logger } from "./logger.ts"
|
|
271
225
|
import type { McpRuntime } from "./mcp-hook.ts"
|
|
272
|
-
import type { IdentityPlugin } from "./plugin.ts"
|
|
226
|
+
import type { ContextPlugin, IdentityPlugin } from "./plugin.ts"
|
|
273
227
|
import type {
|
|
274
228
|
AddRoute,
|
|
275
229
|
EmptyRegistry,
|
|
@@ -281,15 +235,18 @@ import type {
|
|
|
281
235
|
import type {
|
|
282
236
|
AdmissionController,
|
|
283
237
|
AdmissionDecision,
|
|
238
|
+
FetchHandler,
|
|
284
239
|
McpPromptDescriptor,
|
|
285
240
|
McpResourceDescriptor,
|
|
286
241
|
Middleware,
|
|
242
|
+
MountFetchOptions,
|
|
287
243
|
PromptArgument,
|
|
288
244
|
PromptMessage,
|
|
289
245
|
ResponseFinalization,
|
|
290
246
|
RouteDescriptor,
|
|
291
247
|
RunningServer,
|
|
292
248
|
ServerOptions,
|
|
249
|
+
StopHook,
|
|
293
250
|
ToolAnnotations,
|
|
294
251
|
} from "./server-types.ts"
|
|
295
252
|
import type { SSEInit, TypedSSEStream } from "./sse.ts"
|
|
@@ -323,6 +280,8 @@ export interface RequestSource {
|
|
|
323
280
|
readonly body: ReadableStream<Uint8Array> | null
|
|
324
281
|
arrayBuffer(): Promise<ArrayBuffer>
|
|
325
282
|
json(): Promise<unknown>
|
|
283
|
+
/** Optional runtime-native exact-byte reader (Bun's Request.bytes, when available). */
|
|
284
|
+
bytes?(): Promise<Uint8Array>
|
|
326
285
|
/** Present only when materializing a `Request` is non-trivial (the Node lazy source); for a real
|
|
327
286
|
* `Request` passed as the source it's absent and {@link requestOf} returns the source itself. */
|
|
328
287
|
readonly request?: Request
|
|
@@ -352,6 +311,7 @@ export interface RawContext {
|
|
|
352
311
|
// Writable: the lifecycle replaces it with the validated/coerced value when a `params` schema is
|
|
353
312
|
// declared (handlers still see it `readonly` via the public `Context` interface).
|
|
354
313
|
params: Record<string, string>
|
|
314
|
+
headers: Record<string, string>
|
|
355
315
|
query: unknown
|
|
356
316
|
readonly cookies: Readonly<Record<string, string>>
|
|
357
317
|
body: unknown
|
|
@@ -386,6 +346,14 @@ interface BunUpgradeServer {
|
|
|
386
346
|
requestIP(request: Request): { readonly address: string } | null
|
|
387
347
|
}
|
|
388
348
|
|
|
349
|
+
type MountedFetchHandler = (request: Request, platform?: Platform) => MaybePromise<Response>
|
|
350
|
+
|
|
351
|
+
interface FetchMount {
|
|
352
|
+
readonly path: string
|
|
353
|
+
readonly handler: MountedFetchHandler
|
|
354
|
+
readonly stripPrefix: boolean
|
|
355
|
+
}
|
|
356
|
+
|
|
389
357
|
/** The socket peer Bun observed, as a `Platform` for the request lifecycle (`undefined` if unknown).
|
|
390
358
|
* Typed structurally on `requestIP` alone so any Bun `Server` (WS or not) satisfies it. */
|
|
391
359
|
function bunPeerPlatform(
|
|
@@ -470,15 +438,18 @@ export type Handler<
|
|
|
470
438
|
export type {
|
|
471
439
|
AdmissionController,
|
|
472
440
|
AdmissionDecision,
|
|
441
|
+
FetchHandler,
|
|
473
442
|
McpPromptDescriptor,
|
|
474
443
|
McpResourceDescriptor,
|
|
475
444
|
Middleware,
|
|
445
|
+
MountFetchOptions,
|
|
476
446
|
PromptArgument,
|
|
477
447
|
PromptMessage,
|
|
478
448
|
ResponseFinalization,
|
|
479
449
|
RouteDescriptor,
|
|
480
450
|
RunningServer,
|
|
481
451
|
ServerOptions,
|
|
452
|
+
StopHook,
|
|
482
453
|
ToolAnnotations,
|
|
483
454
|
}
|
|
484
455
|
|
|
@@ -490,16 +461,21 @@ export type AnyServer = Server<any, any>
|
|
|
490
461
|
// Plugin definers + their types now live in `./plugin.ts`; re-exported here so `.use()` callers and
|
|
491
462
|
// existing importers keep resolving them from the server module.
|
|
492
463
|
export {
|
|
464
|
+
type ContextPlugin,
|
|
465
|
+
type DefinePluginResult,
|
|
466
|
+
defineContextPlugin,
|
|
493
467
|
defineIdentityPlugin,
|
|
494
468
|
definePlugin,
|
|
495
469
|
defineRouterPlugin,
|
|
496
470
|
type NifraPlugin,
|
|
471
|
+
type PluginTypeCollapsed,
|
|
497
472
|
} from "./plugin.ts"
|
|
498
473
|
export type { IdentityPlugin }
|
|
499
474
|
|
|
500
475
|
const DEFAULT_MAX_BODY_BYTES = 1_000_000
|
|
501
476
|
const DEFAULT_DRAIN_MS = 10_000
|
|
502
477
|
const DRAIN_POLL_MS = 10
|
|
478
|
+
const STOP_HOOK_TIMEOUT_MS = 5_000
|
|
503
479
|
|
|
504
480
|
/** Same-origin check for a WebSocket handshake (CSWSH default). {@link isSameOriginRequest} is the one
|
|
505
481
|
* owner, shared with the server-function mount in `@nifrajs/web` - the two used to answer differently
|
|
@@ -528,6 +504,32 @@ function hasReplacementParam(params: Record<string, string>): boolean {
|
|
|
528
504
|
return false
|
|
529
505
|
}
|
|
530
506
|
|
|
507
|
+
function normalizeMountPrefix(path: string): string {
|
|
508
|
+
if (!path.startsWith("/") || path.includes("?") || path.includes("#")) {
|
|
509
|
+
throw new TypeError("mountFetch path must be an absolute pathname without a query or hash")
|
|
510
|
+
}
|
|
511
|
+
const withoutWildcard = path.endsWith("/*") ? path.slice(0, -2) : path
|
|
512
|
+
if (withoutWildcard.includes("*") || withoutWildcard.includes(":")) {
|
|
513
|
+
throw new TypeError("mountFetch path must be a literal prefix, optionally ending in /*")
|
|
514
|
+
}
|
|
515
|
+
if (withoutWildcard.length === 0) return "/"
|
|
516
|
+
return withoutWildcard.length > 1 && withoutWildcard.endsWith("/")
|
|
517
|
+
? withoutWildcard.slice(0, -1)
|
|
518
|
+
: withoutWildcard
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function underMountPrefix(pathname: string, prefix: string): boolean {
|
|
522
|
+
return prefix === "/" || pathname === prefix || pathname.startsWith(`${prefix}/`)
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function stripMountPrefix(request: Request, prefix: string): Request {
|
|
526
|
+
if (prefix === "/") return request
|
|
527
|
+
const url = new URL(request.url)
|
|
528
|
+
const rest = url.pathname.slice(prefix.length)
|
|
529
|
+
url.pathname = rest === "" ? "/" : rest
|
|
530
|
+
return new Request(url.href, request)
|
|
531
|
+
}
|
|
532
|
+
|
|
531
533
|
/** `ctx.set` carrying the lazy backings (`_headers`, `_cookies`) so `toResponse` can skip allocating
|
|
532
534
|
* anything when no handler touched `c.set.*`. Server-internal. */
|
|
533
535
|
export type CtxSet = ResponseControls & {
|
|
@@ -549,6 +551,17 @@ function isContextlessNoArgArrow(handler: (context: never) => unknown): boolean
|
|
|
549
551
|
}
|
|
550
552
|
}
|
|
551
553
|
|
|
554
|
+
/** `major.minor` of a full version literal - patch releases never change the typed surface. */
|
|
555
|
+
type FeatureVersionOf<V extends string> = V extends `${infer Major}.${infer Minor}.${string}`
|
|
556
|
+
? `${Major}.${Minor}`
|
|
557
|
+
: V
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* The feature version of the `@nifrajs/core` copy that declared these types, derived from the single
|
|
561
|
+
* `VERSION` literal (type-only import - erased, so no runtime cycle with the package root).
|
|
562
|
+
*/
|
|
563
|
+
export type NifraFeatureVersion = FeatureVersionOf<Version>
|
|
564
|
+
|
|
552
565
|
// Stable module-level finalizers so `fetch`/`resolveNode` allocate no per-request closures.
|
|
553
566
|
const IDENTITY_RESPONSE = (response: Response): Response => response
|
|
554
567
|
const RESPONSE_TIMEOUT = (): Response => jsonError(503, "request_timeout")
|
|
@@ -562,6 +575,16 @@ const RESPONSE_TIMEOUT = (): Response => jsonError(503, "request_timeout")
|
|
|
562
575
|
* .get("/me", (c) => c.user) // c.user + c.db are typed
|
|
563
576
|
*/
|
|
564
577
|
export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
578
|
+
/**
|
|
579
|
+
* Which copy of `@nifrajs/core` declared this type. Two copies in one build (a linked sibling repo,
|
|
580
|
+
* a hoisting split) produce two unrelated `Server` types whose assignment error is a wall of
|
|
581
|
+
* missing internals; the brand puts the feature version in the type itself, so `typeof app` on
|
|
582
|
+
* hover - and `NifraFeatureVersion` in an assertion - says which core an app is talking to.
|
|
583
|
+
* `nifra doctor` is the diagnosis that names both install paths.
|
|
584
|
+
*
|
|
585
|
+
* `declare` = type-only: no field is emitted, nothing is allocated per server.
|
|
586
|
+
*/
|
|
587
|
+
declare readonly __nifraCoreVersion: NifraFeatureVersion
|
|
565
588
|
private readonly catalog: RouteCatalog
|
|
566
589
|
/** WebSocket routes, matched separately at upgrade time (a GET + `Upgrade: websocket`). */
|
|
567
590
|
private readonly wsRouter: Router<WsEntry>
|
|
@@ -571,6 +594,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
571
594
|
* no-WebSocket app never constructs (or bundles) it. */
|
|
572
595
|
private topics: TopicRegistry | undefined
|
|
573
596
|
private readonly maxBodyBytes: number
|
|
597
|
+
private readonly protoPoisoning: "reject" | "strip" | "ignore"
|
|
598
|
+
/** `trustBodyFraming`: mark every `app.fetch` request as runtime-framed (see ServerOptions). */
|
|
599
|
+
private readonly trustBodyFraming: boolean
|
|
574
600
|
private readonly wsMaxPayloadBytes: number
|
|
575
601
|
private readonly requestTimeoutMs: number
|
|
576
602
|
/** Installed by the `responseContract()` plugin; `undefined` = not installed, which is the default
|
|
@@ -582,6 +608,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
582
608
|
private readonly maxInboundDeadlineMs: number
|
|
583
609
|
private readonly deadlineAdmissionOptions: DeadlineAdmissionOptions
|
|
584
610
|
private readonly gracefulSignals: boolean
|
|
611
|
+
private readonly stopHooks: StopHook[]
|
|
612
|
+
private readonly fetchMounts: FetchMount[]
|
|
585
613
|
/** Capacity-admission gate; `undefined` = off (the request path pays nothing). */
|
|
586
614
|
private readonly capacityGate: AdmissionController | undefined
|
|
587
615
|
private readonly onCapabilityUse: ((event: CapabilityUseEvent) => void) | undefined
|
|
@@ -602,6 +630,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
602
630
|
/** Installed WebSocket runtime for `.ws()` routes; `undefined` until `.use(websocket())`. */
|
|
603
631
|
private wsRuntime: WsRuntime | undefined
|
|
604
632
|
private readonly logger: Logger
|
|
633
|
+
/** How much of an unhandled error {@link logRequestError} records. See `ServerOptions.errorLogDetail`. */
|
|
634
|
+
private readonly errorLogDetail: "full" | "message" | "none"
|
|
605
635
|
/** App-wide validation-error fallback; a route's own `schema.onValidationError` takes precedence. */
|
|
606
636
|
private readonly defaultOnValidationError?: RouteSchema["onValidationError"]
|
|
607
637
|
private bunServer: RunningServer | undefined
|
|
@@ -620,6 +650,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
620
650
|
private readonly onNodeResponseHooks: Array<NodeResponseHook | undefined>
|
|
621
651
|
/** Registration-time eligibility for the Node response twin lane. */
|
|
622
652
|
private nodeResponseHooksComplete: boolean
|
|
653
|
+
/**
|
|
654
|
+
* True once a bundle registers a RAW `onNodeResponse` twin - one handed the outcome's header
|
|
655
|
+
* record itself rather than the case-normalizing view. Their names are contractually the wire
|
|
656
|
+
* spelling, but registration cannot PROVE it, so such an app forgoes the all-lowercase mark and
|
|
657
|
+
* every reader re-derives the answer, exactly as before the mark existed.
|
|
658
|
+
*/
|
|
659
|
+
private hasRawNodeResponseHook: boolean
|
|
623
660
|
private readonly onResponseFinalizedHooks: RawOnResponseFinalized[]
|
|
624
661
|
/**
|
|
625
662
|
* Statically declared response headers, merged and prebuilt at registration; `undefined` (the
|
|
@@ -658,6 +695,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
658
695
|
this.topics = undefined
|
|
659
696
|
const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
|
|
660
697
|
assertByteLimit(maxBodyBytes, "maxBodyBytes")
|
|
698
|
+
this.protoPoisoning = options.protoPoisoning ?? "reject"
|
|
699
|
+
this.trustBodyFraming = options.trustBodyFraming ?? false
|
|
661
700
|
const wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? maxBodyBytes
|
|
662
701
|
assertByteLimit(wsMaxPayloadBytes, "wsMaxPayloadBytes")
|
|
663
702
|
this.maxBodyBytes = maxBodyBytes
|
|
@@ -677,6 +716,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
677
716
|
throw new RangeError("maxInboundDeadlineMs must be a finite positive number")
|
|
678
717
|
}
|
|
679
718
|
this.gracefulSignals = options.gracefulSignals ?? false
|
|
719
|
+
this.stopHooks = []
|
|
720
|
+
this.fetchMounts = []
|
|
680
721
|
this.capacityGate = options.admission
|
|
681
722
|
this.onCapabilityUse = options.onCapabilityUse
|
|
682
723
|
this.capabilityInterceptors = []
|
|
@@ -689,6 +730,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
689
730
|
this.responseContractRuntime = undefined
|
|
690
731
|
this.idempotencyRuntime = undefined
|
|
691
732
|
this.logger = options.logger ?? jsonLogger()
|
|
733
|
+
this.errorLogDetail = options.errorLogDetail ?? "full"
|
|
692
734
|
this.defaultOnValidationError = options.onValidationError
|
|
693
735
|
this.bunServer = undefined
|
|
694
736
|
this.sealed = false
|
|
@@ -704,6 +746,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
704
746
|
this.onResponseHooks = []
|
|
705
747
|
this.onNodeResponseHooks = []
|
|
706
748
|
this.nodeResponseHooksComplete = true
|
|
749
|
+
this.hasRawNodeResponseHook = false
|
|
707
750
|
this.onResponseFinalizedHooks = []
|
|
708
751
|
this.staticResponseHeaders = undefined
|
|
709
752
|
this.wrapWebResponse = IDENTITY_RESPONSE
|
|
@@ -729,6 +772,36 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
729
772
|
}
|
|
730
773
|
}
|
|
731
774
|
|
|
775
|
+
/** Register a callback awaited after graceful drain and server stop. Hung callbacks are bounded. */
|
|
776
|
+
onStop(fn: StopHook): this {
|
|
777
|
+
this.assertConfigurable("onStop()")
|
|
778
|
+
if (typeof fn !== "function") throw new TypeError("onStop callback must be a function")
|
|
779
|
+
this.stopHooks.push(fn)
|
|
780
|
+
return this
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Mount a legacy fetch handler under a literal path prefix. Mounted handlers sit behind typed
|
|
785
|
+
* routes, so routes can move one at a time while the remaining legacy surface stays live.
|
|
786
|
+
* Mounted responses are outside Nifra's typed route and response-contract checks.
|
|
787
|
+
*/
|
|
788
|
+
mountFetch(
|
|
789
|
+
path: string,
|
|
790
|
+
handler: FetchHandler<EnvOf<Ctx>>,
|
|
791
|
+
options: MountFetchOptions = {},
|
|
792
|
+
): this {
|
|
793
|
+
this.assertConfigurable("mountFetch()")
|
|
794
|
+
if (typeof handler !== "function") throw new TypeError("mountFetch handler must be a function")
|
|
795
|
+
const mount: FetchMount = {
|
|
796
|
+
path: normalizeMountPrefix(path),
|
|
797
|
+
handler: handler as MountedFetchHandler,
|
|
798
|
+
stripPrefix: options.stripPrefix === true,
|
|
799
|
+
}
|
|
800
|
+
this.fetchMounts.push(mount)
|
|
801
|
+
this.fetchMounts.sort((a, b) => b.path.length - a.path.length)
|
|
802
|
+
return this
|
|
803
|
+
}
|
|
804
|
+
|
|
732
805
|
/** Add a per-request, computed context extension for subsequent routes. */
|
|
733
806
|
derive<D extends object>(fn: (context: Context & Ctx) => MaybePromise<D>): Server<R, Ctx & D> {
|
|
734
807
|
this.assertConfigurable("derive()")
|
|
@@ -963,6 +1036,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
963
1036
|
* defeat the generic inference of the transforming overload below and collapse the result to `any`.
|
|
964
1037
|
*/
|
|
965
1038
|
use(plugin: IdentityPlugin): this
|
|
1039
|
+
/**
|
|
1040
|
+
* Apply a **context** plugin ({@link ContextPlugin}, from {@link defineContextPlugin}) - it adds `D`
|
|
1041
|
+
* to the handler context and changes nothing else, so the route registry `R` and the existing context
|
|
1042
|
+
* `Ctx` thread through untouched. Like the identity overload above, this exists because the generic
|
|
1043
|
+
* `(app: this) => Out` overload below cannot infer through a *generic* plugin signature: it erases the
|
|
1044
|
+
* plugin's own type parameters to their constraints, which silently widens `R` to `Registry`.
|
|
1045
|
+
*/
|
|
1046
|
+
use<D extends object>(plugin: ContextPlugin<D>): Server<R, Ctx & D>
|
|
966
1047
|
/**
|
|
967
1048
|
* Apply a **plugin function** - `(app) => app`, typically built with {@link definePlugin}. It's
|
|
968
1049
|
* called with `this` and its result is returned, so an inline plugin's `derive`/`decorate` thread
|
|
@@ -1027,6 +1108,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1027
1108
|
this.onResponseHooks.push(arg.onResponse)
|
|
1028
1109
|
this.onNodeResponseHooks.push(arg.onNodeResponse)
|
|
1029
1110
|
if (arg.onNodeResponse === undefined) this.nodeResponseHooksComplete = false
|
|
1111
|
+
else this.hasRawNodeResponseHook = true
|
|
1030
1112
|
} else if (arg.onNodeResponse !== undefined) {
|
|
1031
1113
|
throw new TypeError("onNodeResponse() requires a paired onResponse() hook")
|
|
1032
1114
|
}
|
|
@@ -1383,6 +1465,29 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1383
1465
|
handler: (context: never) => unknown,
|
|
1384
1466
|
): CatalogRoute {
|
|
1385
1467
|
const pattern = compileRoutePattern(path)
|
|
1468
|
+
let bodyLimit: number | undefined = this.maxBodyBytes
|
|
1469
|
+
const invalidBodyLimit = (message: string): never => {
|
|
1470
|
+
throw new RouteConfigError("INVALID_BODY_LIMIT", `route ${method} ${path}: ${message}`)
|
|
1471
|
+
}
|
|
1472
|
+
if (schema?.bodyLimitReason !== undefined && schema.bodyLimit !== "unlimited") {
|
|
1473
|
+
invalidBodyLimit('bodyLimitReason is only valid with bodyLimit: "unlimited"')
|
|
1474
|
+
}
|
|
1475
|
+
if (schema?.bodyLimit === "unlimited") {
|
|
1476
|
+
if (
|
|
1477
|
+
typeof schema.bodyLimitReason !== "string" ||
|
|
1478
|
+
schema.bodyLimitReason.trim().length === 0
|
|
1479
|
+
) {
|
|
1480
|
+
invalidBodyLimit('bodyLimit: "unlimited" requires a non-empty bodyLimitReason')
|
|
1481
|
+
}
|
|
1482
|
+
bodyLimit = undefined
|
|
1483
|
+
} else if (schema?.bodyLimit !== undefined) {
|
|
1484
|
+
try {
|
|
1485
|
+
assertByteLimit(schema.bodyLimit, "route bodyLimit")
|
|
1486
|
+
} catch {
|
|
1487
|
+
invalidBodyLimit('bodyLimit must be a non-negative safe integer or "unlimited"')
|
|
1488
|
+
}
|
|
1489
|
+
bodyLimit = schema.bodyLimit
|
|
1490
|
+
}
|
|
1386
1491
|
const capabilities = normalizeRouteCapabilities(schema?.capabilities)
|
|
1387
1492
|
const handlerAssurance = assuranceDeclarationsOf(handler as unknown as object)
|
|
1388
1493
|
const invalidHandlerScope = handlerAssurance.find(
|
|
@@ -1444,6 +1549,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1444
1549
|
: undefined
|
|
1445
1550
|
const bare =
|
|
1446
1551
|
schema?.params === undefined &&
|
|
1552
|
+
schema?.headers === undefined &&
|
|
1447
1553
|
schema?.body === undefined &&
|
|
1448
1554
|
schema?.query === undefined &&
|
|
1449
1555
|
idempotent === undefined &&
|
|
@@ -1465,6 +1571,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1465
1571
|
schema?.query !== undefined &&
|
|
1466
1572
|
schema.body === undefined &&
|
|
1467
1573
|
schema.params === undefined &&
|
|
1574
|
+
schema.headers === undefined &&
|
|
1468
1575
|
schema.onValidationError === undefined &&
|
|
1469
1576
|
this.defaultOnValidationError === undefined &&
|
|
1470
1577
|
idempotent === undefined &&
|
|
@@ -1479,6 +1586,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1479
1586
|
schema?.body !== undefined &&
|
|
1480
1587
|
schema.query === undefined &&
|
|
1481
1588
|
schema.params === undefined &&
|
|
1589
|
+
schema.headers === undefined &&
|
|
1482
1590
|
this.derives.length === 0 &&
|
|
1483
1591
|
this.beforeHandleHooks.length === 0 &&
|
|
1484
1592
|
this.afterHandleHooks.length === 0 &&
|
|
@@ -1499,6 +1607,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1499
1607
|
handler as unknown as InternalHandler,
|
|
1500
1608
|
schema.body as StandardSchemaV1,
|
|
1501
1609
|
hasDecorations ? routeDecorations : undefined,
|
|
1610
|
+
bodyLimit ?? this.maxBodyBytes,
|
|
1502
1611
|
)
|
|
1503
1612
|
: undefined
|
|
1504
1613
|
const fusedWeb =
|
|
@@ -1507,12 +1616,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1507
1616
|
handler as unknown as InternalHandler,
|
|
1508
1617
|
hasDecorations ? routeDecorations : undefined,
|
|
1509
1618
|
isContextlessNoArgArrow(handler),
|
|
1619
|
+
bodyLimit ?? this.maxBodyBytes,
|
|
1510
1620
|
)
|
|
1511
1621
|
: fusedQuery
|
|
1512
1622
|
? this.buildFusedQueryWeb(
|
|
1513
1623
|
handler as unknown as InternalHandler,
|
|
1514
1624
|
hasDecorations ? routeDecorations : undefined,
|
|
1515
1625
|
schema.query as StandardSchemaV1,
|
|
1626
|
+
bodyLimit ?? this.maxBodyBytes,
|
|
1516
1627
|
)
|
|
1517
1628
|
: fusedBody
|
|
1518
1629
|
? this.buildFusedBodyWeb(fusedBodyRunner as FusedBodyRunner)
|
|
@@ -1528,6 +1639,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1528
1639
|
schema?.body === undefined &&
|
|
1529
1640
|
schema?.query !== undefined &&
|
|
1530
1641
|
schema.params === undefined &&
|
|
1642
|
+
schema.headers === undefined &&
|
|
1531
1643
|
this.derives.length === 0 &&
|
|
1532
1644
|
this.beforeHandleHooks.length === 0 &&
|
|
1533
1645
|
this.afterHandleHooks.length === 0 &&
|
|
@@ -1539,7 +1651,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1539
1651
|
// so the request path never re-checks params/body presence. Parameter-schema routes retain the
|
|
1540
1652
|
// generic lifecycle runner until their more involved recovery matrix is selected explicitly.
|
|
1541
1653
|
const lifecycleLane =
|
|
1542
|
-
lane !== "lifecycle" || schema?.params !== undefined
|
|
1654
|
+
lane !== "lifecycle" || schema?.params !== undefined || schema?.headers !== undefined
|
|
1543
1655
|
? undefined
|
|
1544
1656
|
: schema?.body !== undefined
|
|
1545
1657
|
? schema.query !== undefined
|
|
@@ -1562,21 +1674,23 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1562
1674
|
this.onErrorHooks.length === 0
|
|
1563
1675
|
? "derive-before"
|
|
1564
1676
|
: undefined
|
|
1565
|
-
const execution =
|
|
1677
|
+
const execution = compileRouteExecutionPlan({
|
|
1566
1678
|
lane,
|
|
1567
1679
|
contextless,
|
|
1568
|
-
this.aroundHooks.length > 0,
|
|
1569
|
-
ledgered !== undefined,
|
|
1680
|
+
hasAround: this.aroundHooks.length > 0,
|
|
1681
|
+
hasLedger: ledgered !== undefined,
|
|
1570
1682
|
lifecycleLane,
|
|
1571
1683
|
fusedWeb,
|
|
1572
|
-
fusedBodyRunner,
|
|
1573
|
-
|
|
1574
|
-
|
|
1684
|
+
fusedBody: fusedBodyRunner,
|
|
1685
|
+
fusedLane:
|
|
1686
|
+
fusedWeb === undefined ? undefined : fusedQuery ? "query" : fusedBody ? "body" : "bare",
|
|
1687
|
+
})
|
|
1575
1688
|
const registeredEntry: RouteEntry = {
|
|
1576
1689
|
// (context: never) => unknown -> InternalHandler: the framework invokes it
|
|
1577
1690
|
// with the concrete RawContext the typed handler expects, so this is sound.
|
|
1578
1691
|
handler: handler as unknown as InternalHandler,
|
|
1579
1692
|
schema,
|
|
1693
|
+
bodyLimit,
|
|
1580
1694
|
idempotent,
|
|
1581
1695
|
ledgered,
|
|
1582
1696
|
responseContract: contracted,
|
|
@@ -1598,6 +1712,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1598
1712
|
...(schema?.family === true ? { family: true } : {}),
|
|
1599
1713
|
}
|
|
1600
1714
|
const routeAssurance: AssuranceDeclaration[] = [...this.activeAssurance, ...handlerAssurance]
|
|
1715
|
+
if (contracted?.runtime.mode === "enforce" && schema?.response !== undefined) {
|
|
1716
|
+
routeAssurance.push(
|
|
1717
|
+
Object.freeze({
|
|
1718
|
+
id: NIFRA_ASSURANCE_IDS.RESPONSE_CONTRACT,
|
|
1719
|
+
source: "response-contract",
|
|
1720
|
+
scope: "plugin",
|
|
1721
|
+
}),
|
|
1722
|
+
)
|
|
1723
|
+
}
|
|
1601
1724
|
// Inline `schema.assurance`: the route DECLARES its enforcement evidence adjacent to the handler, so an
|
|
1602
1725
|
// in-handler-guarded route satisfies a policy `require:` clause without a `withRouteAssurance` middleware
|
|
1603
1726
|
// rewrite. Each id becomes route-scoped `declared` evidence (invalid ids fail closed at registration).
|
|
@@ -1607,7 +1730,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1607
1730
|
`route assurance: invalid evidence id ${JSON.stringify(id)} on ${method} ${path} (use lowercase dot/dash segments)`,
|
|
1608
1731
|
)
|
|
1609
1732
|
}
|
|
1610
|
-
|
|
1733
|
+
const declared = { id, source: "declared", scope: "plugin" } as AssuranceDeclaration
|
|
1734
|
+
Object.defineProperty(declared, "provenance", {
|
|
1735
|
+
value: "declared",
|
|
1736
|
+
enumerable: false,
|
|
1737
|
+
configurable: false,
|
|
1738
|
+
writable: false,
|
|
1739
|
+
})
|
|
1740
|
+
routeAssurance.push(Object.freeze(declared))
|
|
1611
1741
|
}
|
|
1612
1742
|
if (schema?.body !== undefined) {
|
|
1613
1743
|
routeAssurance.push(
|
|
@@ -1639,132 +1769,6 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1639
1769
|
}
|
|
1640
1770
|
}
|
|
1641
1771
|
|
|
1642
|
-
/** Collapse route-invariant lifecycle decisions into one runner at registration. The request path
|
|
1643
|
-
* performs no eligibility ladder: it supplies request state to this already-selected plan. */
|
|
1644
|
-
private compileExecutionPlan(
|
|
1645
|
-
lane: "bare" | "body" | "query" | "lifecycle",
|
|
1646
|
-
contextless: boolean,
|
|
1647
|
-
hasAround: boolean,
|
|
1648
|
-
hasLedger: boolean,
|
|
1649
|
-
lifecycleLane: "hooks" | "query" | "body" | "body-query" | undefined,
|
|
1650
|
-
fusedWeb: FusedWebRunner | undefined,
|
|
1651
|
-
fusedBody: FusedBodyRunner | undefined,
|
|
1652
|
-
fusedLane: "bare" | "body" | "query" | undefined,
|
|
1653
|
-
): RouteExecutionPlan {
|
|
1654
|
-
if (contextless) {
|
|
1655
|
-
const run: RouteExecutionRunner = (
|
|
1656
|
-
runtime,
|
|
1657
|
-
entry,
|
|
1658
|
-
source,
|
|
1659
|
-
params,
|
|
1660
|
-
search,
|
|
1661
|
-
signal,
|
|
1662
|
-
budget,
|
|
1663
|
-
platform,
|
|
1664
|
-
_nativeContext,
|
|
1665
|
-
finalize,
|
|
1666
|
-
wrapResponse,
|
|
1667
|
-
) =>
|
|
1668
|
-
runtime.runContextlessBare(
|
|
1669
|
-
entry,
|
|
1670
|
-
source,
|
|
1671
|
-
params,
|
|
1672
|
-
search,
|
|
1673
|
-
signal,
|
|
1674
|
-
budget,
|
|
1675
|
-
platform,
|
|
1676
|
-
finalize,
|
|
1677
|
-
wrapResponse,
|
|
1678
|
-
)
|
|
1679
|
-
return Object.freeze({ run, fusedWeb, fusedBody, fusedLane })
|
|
1680
|
-
}
|
|
1681
|
-
|
|
1682
|
-
let inner: ContextRouteRunner
|
|
1683
|
-
switch (lane) {
|
|
1684
|
-
case "bare":
|
|
1685
|
-
inner = (runtime, entry, _source, ctx, finalize, wrapResponse) =>
|
|
1686
|
-
runtime.runBare(entry, ctx, finalize, wrapResponse)
|
|
1687
|
-
break
|
|
1688
|
-
case "body":
|
|
1689
|
-
inner = (runtime, entry, source, ctx, finalize, wrapResponse) =>
|
|
1690
|
-
runtime.runBodyOnly(entry, source, ctx, finalize, wrapResponse)
|
|
1691
|
-
break
|
|
1692
|
-
case "query":
|
|
1693
|
-
inner = (runtime, entry, _source, ctx, finalize, wrapResponse) =>
|
|
1694
|
-
runtime.runQueryOnly(entry, ctx, finalize, wrapResponse)
|
|
1695
|
-
break
|
|
1696
|
-
default:
|
|
1697
|
-
switch (lifecycleLane) {
|
|
1698
|
-
case "hooks":
|
|
1699
|
-
inner = (runtime, entry, _source, ctx, finalize, wrapResponse) =>
|
|
1700
|
-
runtime.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
|
|
1701
|
-
break
|
|
1702
|
-
case "query":
|
|
1703
|
-
inner = (runtime, entry, _source, ctx, finalize, wrapResponse) =>
|
|
1704
|
-
runtime.runLifecycleQuery(entry, ctx, finalize, wrapResponse)
|
|
1705
|
-
break
|
|
1706
|
-
case "body":
|
|
1707
|
-
inner = (runtime, entry, source, ctx, finalize, wrapResponse) =>
|
|
1708
|
-
runtime.runLifecycleBody(entry, source, ctx, finalize, wrapResponse)
|
|
1709
|
-
break
|
|
1710
|
-
case "body-query":
|
|
1711
|
-
inner = (runtime, entry, source, ctx, finalize, wrapResponse) =>
|
|
1712
|
-
runtime.runLifecycleBodyQuery(entry, source, ctx, finalize, wrapResponse)
|
|
1713
|
-
break
|
|
1714
|
-
default:
|
|
1715
|
-
inner = (runtime, entry, source, ctx, finalize, wrapResponse) =>
|
|
1716
|
-
runtime.runLifecycle(entry, source, ctx, finalize, wrapResponse)
|
|
1717
|
-
}
|
|
1718
|
-
}
|
|
1719
|
-
const execute: ContextRouteRunner = hasAround
|
|
1720
|
-
? (runtime, entry, source, ctx, finalize, wrapResponse) =>
|
|
1721
|
-
runtime.runWithAround(
|
|
1722
|
-
entry,
|
|
1723
|
-
ctx,
|
|
1724
|
-
() => inner(runtime, entry, source, ctx, finalize, wrapResponse),
|
|
1725
|
-
finalize,
|
|
1726
|
-
wrapResponse,
|
|
1727
|
-
)
|
|
1728
|
-
: inner
|
|
1729
|
-
const run: RouteExecutionRunner = (
|
|
1730
|
-
runtime,
|
|
1731
|
-
entry,
|
|
1732
|
-
source,
|
|
1733
|
-
params,
|
|
1734
|
-
search,
|
|
1735
|
-
signal,
|
|
1736
|
-
budget,
|
|
1737
|
-
platform,
|
|
1738
|
-
nativeContext,
|
|
1739
|
-
finalize,
|
|
1740
|
-
wrapResponse,
|
|
1741
|
-
) => {
|
|
1742
|
-
const ctx = nativeContext
|
|
1743
|
-
? RequestContext.native(source, params, search, runtime.maxBodyBytes, platform)
|
|
1744
|
-
: new RequestContext(source, params, search, signal, budget, platform, runtime.maxBodyBytes)
|
|
1745
|
-
let ledger: RequestLedger | undefined
|
|
1746
|
-
// The runtime is always present when a route resolved a ledger (enforced at registration).
|
|
1747
|
-
const ledgerRuntime = runtime.effectLedgerRuntime
|
|
1748
|
-
if (hasLedger && ledgerRuntime !== undefined) {
|
|
1749
|
-
const resolved = entry.ledgered as ResolvedEffectLedger
|
|
1750
|
-
ledger = ledgerRuntime.create(resolved)
|
|
1751
|
-
ledgerRuntime.attach(ctx, ledger)
|
|
1752
|
-
}
|
|
1753
|
-
let outcome = execute(runtime, entry, source, ctx, finalize, wrapResponse)
|
|
1754
|
-
if (ledger !== undefined && ledgerRuntime !== undefined) {
|
|
1755
|
-
const active = ledger
|
|
1756
|
-
const resolved = entry.ledgered as ResolvedEffectLedger
|
|
1757
|
-
outcome = (outcome instanceof Promise ? outcome : Promise.resolve(outcome)).then((value) =>
|
|
1758
|
-
ledgerRuntime.settle(active, resolved, value, (fields) =>
|
|
1759
|
-
runtime.logger.error("effect ledger sink failed", fields),
|
|
1760
|
-
),
|
|
1761
|
-
)
|
|
1762
|
-
}
|
|
1763
|
-
return outcome
|
|
1764
|
-
}
|
|
1765
|
-
return Object.freeze({ run, fusedWeb, fusedBody, fusedLane })
|
|
1766
|
-
}
|
|
1767
|
-
|
|
1768
1772
|
/** The idempotency lane's bridge back into the normal matched lanes, resolved to a concrete Response
|
|
1769
1773
|
* (the lane buffers the body, then replays it through the route's real validation + handler). */
|
|
1770
1774
|
private idempotencyRunLanes(
|
|
@@ -1837,10 +1841,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1837
1841
|
*
|
|
1838
1842
|
* Semantics: merged routes keep the chains captured where they were DEFINED - the group's
|
|
1839
1843
|
* `derive`/`decorate`/`beforeHandle`/`afterHandle`/`onError`/`around` apply to its routes
|
|
1840
|
-
* exactly as they did standalone, so a group wires its own plugins. The
|
|
1841
|
-
*
|
|
1842
|
-
*
|
|
1843
|
-
*
|
|
1844
|
+
* exactly as they did standalone, so a group wires its own plugins. The same locality rule
|
|
1845
|
+
* covers the group's `onRequest` hooks: they run only for requests that route to the GROUP's
|
|
1846
|
+
* routes (a `bodyLimit()` mounted on an uploads group must not start gating the whole app
|
|
1847
|
+
* because the app composed it in), and the group's global assurance follows its hooks onto
|
|
1848
|
+
* exactly those routes. A group with hooks but NO routes is a middleware bundle - its hooks
|
|
1849
|
+
* can only mean app-wide intent, so they are appended globally, unchanged. Response-side hooks
|
|
1850
|
+
* (`onResponse`/`onResponseFinalized`) are appended to this server's. This server's
|
|
1851
|
+
* route-scoped chains do NOT retroactively wrap merged routes (order-scoped, like routes
|
|
1852
|
+
* registered before a `derive`). Fail closed: a path+method collision throws
|
|
1844
1853
|
* `RouteConfigError` at merge time, and a group with WebSocket routes is refused (register
|
|
1845
1854
|
* those on the parent).
|
|
1846
1855
|
*/
|
|
@@ -1853,7 +1862,23 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1853
1862
|
"merge() does not carry WebSocket routes - register .ws() routes on the parent server",
|
|
1854
1863
|
)
|
|
1855
1864
|
}
|
|
1856
|
-
|
|
1865
|
+
const sourceRoutes = source.catalog.entries()
|
|
1866
|
+
// Hooks and global assurance stay LOCAL to a group that has routes; a route-less group is a
|
|
1867
|
+
// middleware bundle whose hooks can only mean app-wide intent, so it keeps the global append.
|
|
1868
|
+
const scoped = sourceRoutes.length > 0
|
|
1869
|
+
// A group's global assurance rides its (route-scoped) hooks: folded into each merged route's
|
|
1870
|
+
// own evidence rather than the parent's global list, so `routes()` never claims the group's
|
|
1871
|
+
// enforcement for parent routes the group's hooks do not see.
|
|
1872
|
+
const foldedAssurance =
|
|
1873
|
+
scoped && source.globalAssurance.length > 0
|
|
1874
|
+
? (route: CatalogRoute): CatalogRoute => ({
|
|
1875
|
+
...route,
|
|
1876
|
+
assurance: [...route.assurance, ...source.globalAssurance],
|
|
1877
|
+
})
|
|
1878
|
+
: (route: CatalogRoute): CatalogRoute => route
|
|
1879
|
+
this.catalog.addBatch(
|
|
1880
|
+
sourceRoutes.map((route) => this.bindFusedRuntime(foldedAssurance(route))),
|
|
1881
|
+
)
|
|
1857
1882
|
// Resolved idempotency/ledger route entries carry their own store/sink configuration, while the
|
|
1858
1883
|
// runtime object supplies the generic execution machinery. Preserve a group's installed runtime
|
|
1859
1884
|
// when the parent has none so merging cannot silently disable a safety lane. If the parent already
|
|
@@ -1866,8 +1891,33 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1866
1891
|
this.nodeOutcomeRuntime ??= source.nodeOutcomeRuntime
|
|
1867
1892
|
this.sseRuntime ??= source.sseRuntime
|
|
1868
1893
|
this.wsRuntime ??= source.wsRuntime
|
|
1869
|
-
|
|
1870
|
-
|
|
1894
|
+
if (scoped && source.onRequestHooks.length > 0) {
|
|
1895
|
+
// Snapshot the group's routes into a dedicated matcher: the guard must reflect what was
|
|
1896
|
+
// MERGED, not whatever the group's own catalog grows into afterwards. One probe against it
|
|
1897
|
+
// gates each group hook to requests the group would serve; everything else passes untouched.
|
|
1898
|
+
const scope = new RouteCatalog()
|
|
1899
|
+
scope.addBatch(sourceRoutes)
|
|
1900
|
+
this.onRequestHooks.push(
|
|
1901
|
+
...source.onRequestHooks.map(
|
|
1902
|
+
(hook): RawOnRequest =>
|
|
1903
|
+
(req, platform) =>
|
|
1904
|
+
scope.find(req.method, pathnameOf(req.url)).found ? hook(req, platform) : undefined,
|
|
1905
|
+
),
|
|
1906
|
+
)
|
|
1907
|
+
// An `undefined` slot marks an unpaired hook (position-aligned with `onRequestHooks`) and
|
|
1908
|
+
// must stay `undefined` - wrapping it would fabricate a Node twin that never existed.
|
|
1909
|
+
this.onNodeRequestHooks.push(
|
|
1910
|
+
...source.onNodeRequestHooks.map((hook): NodeRequestHook | undefined =>
|
|
1911
|
+
hook === undefined
|
|
1912
|
+
? undefined
|
|
1913
|
+
: (req, platform) =>
|
|
1914
|
+
scope.find(req.method, pathnameOf(req.url)).found ? hook(req, platform) : undefined,
|
|
1915
|
+
),
|
|
1916
|
+
)
|
|
1917
|
+
} else {
|
|
1918
|
+
this.onRequestHooks.push(...source.onRequestHooks)
|
|
1919
|
+
this.onNodeRequestHooks.push(...source.onNodeRequestHooks)
|
|
1920
|
+
}
|
|
1871
1921
|
this.nodeRequestHooksComplete &&= source.nodeRequestHooksComplete
|
|
1872
1922
|
// The group's static declarations came before its own response hooks, so they are folded in
|
|
1873
1923
|
// first - and fold themselves into a hook here if this server already has one (same ordering
|
|
@@ -1878,13 +1928,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1878
1928
|
this.onResponseHooks.push(...source.onResponseHooks)
|
|
1879
1929
|
this.onNodeResponseHooks.push(...source.onNodeResponseHooks)
|
|
1880
1930
|
this.nodeResponseHooksComplete &&= source.nodeResponseHooksComplete
|
|
1931
|
+
this.hasRawNodeResponseHook ||= source.hasRawNodeResponseHook
|
|
1881
1932
|
this.onResponseFinalizedHooks.push(...source.onResponseFinalizedHooks)
|
|
1882
1933
|
if (source.responseBodyTag !== undefined) {
|
|
1883
1934
|
const owner = this.enableResponseBodyTagging()
|
|
1884
1935
|
this.responseBodyOwners.add(source.responseBodyTag)
|
|
1885
1936
|
source.responseBodyOwners.add(owner)
|
|
1886
1937
|
}
|
|
1887
|
-
this.globalAssurance.push(...source.globalAssurance)
|
|
1938
|
+
if (!scoped) this.globalAssurance.push(...source.globalAssurance)
|
|
1888
1939
|
this.mcpResourceList.push(...source.mcpResourceList)
|
|
1889
1940
|
this.mcpPromptList.push(...source.mcpPromptList)
|
|
1890
1941
|
return this as unknown as Server<R & R2, Ctx>
|
|
@@ -1903,6 +1954,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1903
1954
|
entry.handler,
|
|
1904
1955
|
entry.schema?.body as StandardSchemaV1,
|
|
1905
1956
|
entry.hasDecorations ? entry.decorations : undefined,
|
|
1957
|
+
entry.bodyLimit ?? this.maxBodyBytes,
|
|
1906
1958
|
)
|
|
1907
1959
|
: undefined
|
|
1908
1960
|
const fusedWeb =
|
|
@@ -1913,11 +1965,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1913
1965
|
entry.handler,
|
|
1914
1966
|
entry.hasDecorations ? entry.decorations : undefined,
|
|
1915
1967
|
entry.schema?.query as StandardSchemaV1,
|
|
1968
|
+
entry.bodyLimit ?? this.maxBodyBytes,
|
|
1916
1969
|
)
|
|
1917
1970
|
: this.buildFusedWeb(
|
|
1918
1971
|
entry.handler,
|
|
1919
1972
|
entry.hasDecorations ? entry.decorations : undefined,
|
|
1920
1973
|
isContextlessNoArgArrow(entry.handler),
|
|
1974
|
+
entry.bodyLimit ?? this.maxBodyBytes,
|
|
1921
1975
|
)
|
|
1922
1976
|
return {
|
|
1923
1977
|
...route,
|
|
@@ -1951,6 +2005,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1951
2005
|
* Bun/Node/Deno omit it (then `c.env` is `undefined` and `c.waitUntil` runs fire-and-forget).
|
|
1952
2006
|
*/
|
|
1953
2007
|
fetch(req: Request, platform?: Platform<EnvOf<Ctx>>): MaybePromise<Response> {
|
|
2008
|
+
// An edge deployment whose only ingress is this method declares its requests runtime-framed at
|
|
2009
|
+
// construction; everything else stays on the delivered-byte check (see `trustBodyFraming`).
|
|
2010
|
+
if (this.trustBodyFraming) markTrustedBodyFraming(req)
|
|
1954
2011
|
// A real `Request` satisfies `RequestSource`, so it's passed straight through - no per-request
|
|
1955
2012
|
// wrapper allocation on the Web/Bun hot path.
|
|
1956
2013
|
return this.fetchSource(req, platform)
|
|
@@ -2126,12 +2183,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2126
2183
|
// header or body hook reads them through its view), and on the no-hook path too (which returns
|
|
2127
2184
|
// the outcome straight to the writer without the finish step below).
|
|
2128
2185
|
const statics = this.staticResponseHeaders
|
|
2186
|
+
// The fold also publishes the all-lowercase proof, which is why it is worth doing here rather
|
|
2187
|
+
// than in the finish step: it is the only stage the HOOKLESS lane passes through, and that lane
|
|
2188
|
+
// returns straight to the writer below without ever reaching `finishNodeResponse`.
|
|
2189
|
+
const markLowercase = !this.hasRawNodeResponseHook
|
|
2129
2190
|
const outcome =
|
|
2130
2191
|
statics === undefined
|
|
2131
2192
|
? resolved
|
|
2132
2193
|
: resolved instanceof Promise
|
|
2133
|
-
? resolved.then((settled) => withStaticNodeHeaders(settled, statics))
|
|
2134
|
-
: withStaticNodeHeaders(resolved, statics)
|
|
2194
|
+
? resolved.then((settled) => withStaticNodeHeaders(settled, statics, markLowercase))
|
|
2195
|
+
: withStaticNodeHeaders(resolved, statics, markLowercase)
|
|
2135
2196
|
if (webResponseHooks) {
|
|
2136
2197
|
return outcome instanceof Promise
|
|
2137
2198
|
? outcome.then((settled) => this.finishNodeWebResponse(settled, source, runtime))
|
|
@@ -2182,13 +2243,24 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2182
2243
|
}
|
|
2183
2244
|
|
|
2184
2245
|
let headers = outcome.headers as Record<string, string | readonly string[]> | undefined
|
|
2246
|
+
// ONE pass over the names, here, before any twin runs. The same question is asked three times per
|
|
2247
|
+
// request downstream - the Content-Type lookup just below, the header view's alias index, and the
|
|
2248
|
+
// `@nifrajs/node` writer's normalization gate - and each used to walk the record itself. An app
|
|
2249
|
+
// declaring static headers has already had it answered by the fold in `resolveNode`, for free, so
|
|
2250
|
+
// the pass runs only for the records nothing looked at yet.
|
|
2251
|
+
const lowercaseKeys =
|
|
2252
|
+
headers === undefined ||
|
|
2253
|
+
hasLowercaseHeaderKeysMark(headers) ||
|
|
2254
|
+
headerKeysAllLowercase(headers)
|
|
2185
2255
|
if (outcome.kind === "json" && outcome.body !== null) {
|
|
2186
2256
|
// The json render adds its Content-Type at WRITE time, so a body hook checking content types
|
|
2187
2257
|
// would see nothing. Materialize the writer's own value into the hook-visible record - same
|
|
2188
2258
|
// string the writer would emit, so the wire is unchanged.
|
|
2189
2259
|
const hasType =
|
|
2190
2260
|
headers !== undefined &&
|
|
2191
|
-
|
|
2261
|
+
(lowercaseKeys
|
|
2262
|
+
? headers["content-type"] !== undefined
|
|
2263
|
+
: Object.keys(headers).some((key) => key.toLowerCase() === "content-type"))
|
|
2192
2264
|
if (!hasType) {
|
|
2193
2265
|
const defaultContentType = runtime.jsonContentType ?? "application/json;charset=utf-8"
|
|
2194
2266
|
if (headers === undefined) {
|
|
@@ -2202,6 +2274,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2202
2274
|
}
|
|
2203
2275
|
}
|
|
2204
2276
|
}
|
|
2277
|
+
// Publish the proof for the view and the direct writer - but only for an app whose every twin
|
|
2278
|
+
// normalizes case. A raw `onNodeResponse` twin writes the record straight, past the view, so a
|
|
2279
|
+
// mark set before it ran could be made to lie, and a lying mark would ship a mixed-case name that
|
|
2280
|
+
// `Headers` lowercases on every other runtime. Those apps keep the per-reader scans.
|
|
2281
|
+
if (lowercaseKeys && headers !== undefined && !this.hasRawNodeResponseHook) {
|
|
2282
|
+
markLowercaseHeaderKeys(headers)
|
|
2283
|
+
}
|
|
2205
2284
|
const context: NodeResponseContext = {
|
|
2206
2285
|
status: outcome.status,
|
|
2207
2286
|
headers,
|
|
@@ -2336,7 +2415,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2336
2415
|
return { kind: "reject", response: jsonError(403, "forbidden_origin") }
|
|
2337
2416
|
}
|
|
2338
2417
|
if (handler.upgrade === undefined) {
|
|
2339
|
-
return {
|
|
2418
|
+
return {
|
|
2419
|
+
kind: "upgrade",
|
|
2420
|
+
handler,
|
|
2421
|
+
data: undefined,
|
|
2422
|
+
pubsub,
|
|
2423
|
+
attach,
|
|
2424
|
+
maxPayloadBytes: this.wsMaxPayloadBytes,
|
|
2425
|
+
}
|
|
2340
2426
|
}
|
|
2341
2427
|
const upgradeSignal = getNeverAbortSignal()
|
|
2342
2428
|
const ctx = new RequestContext(
|
|
@@ -2347,11 +2433,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2347
2433
|
createUnboundedRequestBudget(upgradeSignal),
|
|
2348
2434
|
platform,
|
|
2349
2435
|
this.maxBodyBytes,
|
|
2436
|
+
this.protoPoisoning,
|
|
2350
2437
|
)
|
|
2351
2438
|
const settle = (value: unknown): WebSocketUpgradeOutcome =>
|
|
2352
2439
|
value instanceof Response
|
|
2353
2440
|
? { kind: "reject", response: value }
|
|
2354
|
-
: {
|
|
2441
|
+
: {
|
|
2442
|
+
kind: "upgrade",
|
|
2443
|
+
handler,
|
|
2444
|
+
data: value,
|
|
2445
|
+
pubsub,
|
|
2446
|
+
attach,
|
|
2447
|
+
maxPayloadBytes: this.wsMaxPayloadBytes,
|
|
2448
|
+
}
|
|
2355
2449
|
try {
|
|
2356
2450
|
const result = handler.upgrade(ctx as unknown as WebSocketContext<EnvOf<Ctx>>)
|
|
2357
2451
|
return result instanceof Promise
|
|
@@ -2680,6 +2774,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2680
2774
|
}
|
|
2681
2775
|
const match = this.catalog.find(source.method, pathname)
|
|
2682
2776
|
if (!match.found) {
|
|
2777
|
+
const mounted = this.fetchMount(pathname, source, platform)
|
|
2778
|
+
if (mounted !== undefined) {
|
|
2779
|
+
return mounted instanceof Promise
|
|
2780
|
+
? mounted.then((response) => wrapResponse(response))
|
|
2781
|
+
: wrapResponse(mounted)
|
|
2782
|
+
}
|
|
2683
2783
|
if (match.reason === "method-not-allowed") {
|
|
2684
2784
|
return wrapResponse(
|
|
2685
2785
|
jsonError(405, "method_not_allowed", { Allow: match.allowed.join(", ") }),
|
|
@@ -2708,6 +2808,22 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2708
2808
|
)
|
|
2709
2809
|
}
|
|
2710
2810
|
|
|
2811
|
+
private fetchMount(
|
|
2812
|
+
pathname: string,
|
|
2813
|
+
source: RequestSource,
|
|
2814
|
+
platform: Platform | undefined,
|
|
2815
|
+
): MaybePromise<Response> | undefined {
|
|
2816
|
+
for (const mount of this.fetchMounts) {
|
|
2817
|
+
if (!underMountPrefix(pathname, mount.path)) continue
|
|
2818
|
+
const request = requestOf(source)
|
|
2819
|
+
return mount.handler(
|
|
2820
|
+
mount.stripPrefix ? stripMountPrefix(request, mount.path) : request,
|
|
2821
|
+
platform,
|
|
2822
|
+
)
|
|
2823
|
+
}
|
|
2824
|
+
return undefined
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2711
2827
|
/** Run a route that has already been matched by the runtime or Nifra's portable router. */
|
|
2712
2828
|
private runMatched<T>(
|
|
2713
2829
|
source: RequestSource,
|
|
@@ -2720,6 +2836,20 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2720
2836
|
onTimeout: () => T,
|
|
2721
2837
|
webFast: boolean,
|
|
2722
2838
|
): MaybePromise<T> {
|
|
2839
|
+
// The route's transport byte cap: one property write here; the capped shadowing of direct
|
|
2840
|
+
// `c.req` body readers happens lazily at `c.req` access (`applyTransportCap`), so a route that
|
|
2841
|
+
// never direct-reads pays nothing. Framework readers bypass the shadow (`rawBodySourceOf`) and
|
|
2842
|
+
// keep their own caps, so `c.boundedBody(explicit)` still overrides upward.
|
|
2843
|
+
// `bodyLimit: "unlimited"` (undefined here) skips the cap entirely.
|
|
2844
|
+
// A schema route consumes and validates the body through `readBodyInput` before any derive or
|
|
2845
|
+
// handler can reach `c.req`; that lane already enforces `entry.bodyLimit`. Installing the full
|
|
2846
|
+
// direct-reader cap on `c.req` here would allocate bound readers, closures, and a stream wrapper
|
|
2847
|
+
// for a body that is already consumed. Keep the lazy transport cap for raw-body routes, where a
|
|
2848
|
+
// user read is the only framework-owned body boundary.
|
|
2849
|
+
if (entry.bodyLimit !== undefined && entry.schema?.body === undefined) {
|
|
2850
|
+
const method = source.method
|
|
2851
|
+
if (method !== "GET" && method !== "HEAD") markTransportCap(source, entry.bodyLimit)
|
|
2852
|
+
}
|
|
2723
2853
|
// An idempotency route runs its dedupe lane first; on a fresh key it delegates to the normal lanes
|
|
2724
2854
|
// (with the body buffered). All non-idempotent routes skip straight to the lanes - no added cost.
|
|
2725
2855
|
// The runtime is always present when a route resolved idempotency (enforced at registration).
|
|
@@ -2733,7 +2863,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2733
2863
|
search,
|
|
2734
2864
|
wrapResponse,
|
|
2735
2865
|
{
|
|
2736
|
-
maxBodyBytes: this.maxBodyBytes,
|
|
2866
|
+
maxBodyBytes: entry.bodyLimit ?? this.maxBodyBytes,
|
|
2737
2867
|
runLanes: (buffered, plat, ent, prm, srch) =>
|
|
2738
2868
|
this.idempotencyRunLanes(buffered, plat, ent as RouteEntry, prm, srch),
|
|
2739
2869
|
},
|
|
@@ -2861,6 +2991,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2861
2991
|
|
|
2862
2992
|
/** The narrowest bare route: a syntactic `() => ...` handler cannot observe the context argument, so
|
|
2863
2993
|
* successful requests can skip allocating `RequestContext`. Errors still allocate one for logging. */
|
|
2994
|
+
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2995
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2864
2996
|
private runContextlessBare<T>(
|
|
2865
2997
|
entry: RouteEntry,
|
|
2866
2998
|
source: RequestSource,
|
|
@@ -2925,6 +3057,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2925
3057
|
budget,
|
|
2926
3058
|
platform,
|
|
2927
3059
|
this.maxBodyBytes,
|
|
3060
|
+
this.protoPoisoning,
|
|
2928
3061
|
)
|
|
2929
3062
|
this.logRequestError(err, ctx)
|
|
2930
3063
|
return wrapResponse(jsonError(500, "internal_error"))
|
|
@@ -2938,6 +3071,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2938
3071
|
* (a thrown `Response` is control flow; anything else is a logged flat 500). This is where nifra skips
|
|
2939
3072
|
* the per-request async-frame tax - the same win codegen routers get, but without `eval`.
|
|
2940
3073
|
*/
|
|
3074
|
+
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
3075
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2941
3076
|
private runBare<T>(
|
|
2942
3077
|
entry: RouteEntry,
|
|
2943
3078
|
ctx: RawContext,
|
|
@@ -2974,6 +3109,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2974
3109
|
handler: InternalHandler,
|
|
2975
3110
|
decorations: Record<PropertyKey, unknown> | undefined,
|
|
2976
3111
|
contextless: boolean,
|
|
3112
|
+
maxBodyBytes: number,
|
|
2977
3113
|
): FusedWebRunner {
|
|
2978
3114
|
// The fused lanes return to the runtime directly, so the framework's own error renders fold in
|
|
2979
3115
|
// static headers here rather than through the shared `wrapResponse` seam.
|
|
@@ -2994,7 +3130,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2994
3130
|
return logError(
|
|
2995
3131
|
err,
|
|
2996
3132
|
nativeContext
|
|
2997
|
-
? RequestContext.native(
|
|
3133
|
+
? RequestContext.native(
|
|
3134
|
+
source,
|
|
3135
|
+
params,
|
|
3136
|
+
search,
|
|
3137
|
+
maxBodyBytes,
|
|
3138
|
+
platform,
|
|
3139
|
+
this.protoPoisoning,
|
|
3140
|
+
)
|
|
2998
3141
|
: new RequestContext(
|
|
2999
3142
|
source,
|
|
3000
3143
|
params,
|
|
@@ -3002,18 +3145,32 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3002
3145
|
signal,
|
|
3003
3146
|
budget,
|
|
3004
3147
|
platform,
|
|
3005
|
-
|
|
3148
|
+
maxBodyBytes,
|
|
3149
|
+
this.protoPoisoning,
|
|
3006
3150
|
),
|
|
3007
3151
|
)
|
|
3008
3152
|
}
|
|
3009
3153
|
if (result instanceof Promise) {
|
|
3010
3154
|
return result.then(
|
|
3011
|
-
(value) =>
|
|
3155
|
+
(value) =>
|
|
3156
|
+
fusedRespondNoSet(
|
|
3157
|
+
value,
|
|
3158
|
+
this.responseBodyTag,
|
|
3159
|
+
this.staticResponseHeaders,
|
|
3160
|
+
this.onResponseHooks.length === 0,
|
|
3161
|
+
),
|
|
3012
3162
|
(err) =>
|
|
3013
3163
|
logError(
|
|
3014
3164
|
err,
|
|
3015
3165
|
nativeContext
|
|
3016
|
-
? RequestContext.native(
|
|
3166
|
+
? RequestContext.native(
|
|
3167
|
+
source,
|
|
3168
|
+
params,
|
|
3169
|
+
search,
|
|
3170
|
+
maxBodyBytes,
|
|
3171
|
+
platform,
|
|
3172
|
+
this.protoPoisoning,
|
|
3173
|
+
)
|
|
3017
3174
|
: new RequestContext(
|
|
3018
3175
|
source,
|
|
3019
3176
|
params,
|
|
@@ -3021,18 +3178,33 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3021
3178
|
signal,
|
|
3022
3179
|
budget,
|
|
3023
3180
|
platform,
|
|
3024
|
-
|
|
3181
|
+
maxBodyBytes,
|
|
3182
|
+
this.protoPoisoning,
|
|
3025
3183
|
),
|
|
3026
3184
|
),
|
|
3027
3185
|
)
|
|
3028
3186
|
}
|
|
3029
|
-
return fusedRespondNoSet(
|
|
3187
|
+
return fusedRespondNoSet(
|
|
3188
|
+
result,
|
|
3189
|
+
this.responseBodyTag,
|
|
3190
|
+
this.staticResponseHeaders,
|
|
3191
|
+
this.onResponseHooks.length === 0,
|
|
3192
|
+
)
|
|
3030
3193
|
}
|
|
3031
3194
|
}
|
|
3032
3195
|
return (source, params, search, signal, budget, platform, nativeContext) => {
|
|
3033
3196
|
const ctx = nativeContext
|
|
3034
|
-
? RequestContext.native(source, params, search,
|
|
3035
|
-
: new RequestContext(
|
|
3197
|
+
? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
|
|
3198
|
+
: new RequestContext(
|
|
3199
|
+
source,
|
|
3200
|
+
params,
|
|
3201
|
+
search,
|
|
3202
|
+
signal,
|
|
3203
|
+
budget,
|
|
3204
|
+
platform,
|
|
3205
|
+
maxBodyBytes,
|
|
3206
|
+
this.protoPoisoning,
|
|
3207
|
+
)
|
|
3036
3208
|
if (decorations !== undefined) Object.assign(ctx, decorations)
|
|
3037
3209
|
let result: unknown
|
|
3038
3210
|
try {
|
|
@@ -3042,11 +3214,24 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3042
3214
|
}
|
|
3043
3215
|
if (result instanceof Promise) {
|
|
3044
3216
|
return result.then(
|
|
3045
|
-
(value) =>
|
|
3217
|
+
(value) =>
|
|
3218
|
+
fusedRespond(
|
|
3219
|
+
value,
|
|
3220
|
+
ctx,
|
|
3221
|
+
this.responseBodyTag,
|
|
3222
|
+
this.staticResponseHeaders,
|
|
3223
|
+
this.onResponseHooks.length === 0,
|
|
3224
|
+
),
|
|
3046
3225
|
(err) => logError(err, ctx),
|
|
3047
3226
|
)
|
|
3048
3227
|
}
|
|
3049
|
-
return fusedRespond(
|
|
3228
|
+
return fusedRespond(
|
|
3229
|
+
result,
|
|
3230
|
+
ctx,
|
|
3231
|
+
this.responseBodyTag,
|
|
3232
|
+
this.staticResponseHeaders,
|
|
3233
|
+
this.onResponseHooks.length === 0,
|
|
3234
|
+
)
|
|
3050
3235
|
}
|
|
3051
3236
|
}
|
|
3052
3237
|
|
|
@@ -3057,6 +3242,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3057
3242
|
handler: InternalHandler,
|
|
3058
3243
|
bodySchema: StandardSchemaV1,
|
|
3059
3244
|
decorations: Record<PropertyKey, unknown> | undefined,
|
|
3245
|
+
maxBodyBytes: number,
|
|
3060
3246
|
): FusedBodyRunner {
|
|
3061
3247
|
const logError = <T>(
|
|
3062
3248
|
err: unknown,
|
|
@@ -3149,11 +3335,20 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3149
3335
|
wrapResponse: (response: Response) => T,
|
|
3150
3336
|
): MaybePromise<T> => {
|
|
3151
3337
|
const ctx = nativeContext
|
|
3152
|
-
? RequestContext.native(source, params, search,
|
|
3153
|
-
: new RequestContext(
|
|
3338
|
+
? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
|
|
3339
|
+
: new RequestContext(
|
|
3340
|
+
source,
|
|
3341
|
+
params,
|
|
3342
|
+
search,
|
|
3343
|
+
signal,
|
|
3344
|
+
budget,
|
|
3345
|
+
platform,
|
|
3346
|
+
maxBodyBytes,
|
|
3347
|
+
this.protoPoisoning,
|
|
3348
|
+
)
|
|
3154
3349
|
const finish = (value: unknown): MaybePromise<T> =>
|
|
3155
3350
|
runParsed(value, ctx, finalize, wrapResponse)
|
|
3156
|
-
return this.readBodyInput(source, finish, wrapResponse, (err) =>
|
|
3351
|
+
return this.readBodyInput(source, maxBodyBytes, finish, wrapResponse, (err) =>
|
|
3157
3352
|
logError(err, ctx, wrapResponse),
|
|
3158
3353
|
)
|
|
3159
3354
|
}
|
|
@@ -3172,7 +3367,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3172
3367
|
platform,
|
|
3173
3368
|
nativeContext,
|
|
3174
3369
|
(result, _set, ctx) =>
|
|
3175
|
-
fusedRespond(
|
|
3370
|
+
fusedRespond(
|
|
3371
|
+
result,
|
|
3372
|
+
ctx,
|
|
3373
|
+
this.responseBodyTag,
|
|
3374
|
+
this.staticResponseHeaders,
|
|
3375
|
+
this.onResponseHooks.length === 0,
|
|
3376
|
+
),
|
|
3176
3377
|
this.wrapWebResponse,
|
|
3177
3378
|
) as MaybePromise<Response>
|
|
3178
3379
|
}
|
|
@@ -3188,6 +3389,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3188
3389
|
handler: InternalHandler,
|
|
3189
3390
|
decorations: Record<PropertyKey, unknown> | undefined,
|
|
3190
3391
|
querySchema: StandardSchemaV1,
|
|
3392
|
+
maxBodyBytes: number,
|
|
3191
3393
|
): FusedWebRunner {
|
|
3192
3394
|
// The fused lanes return to the runtime directly, so the framework's own error renders fold in
|
|
3193
3395
|
// static headers here rather than through the shared `wrapResponse` seam.
|
|
@@ -3206,16 +3408,38 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3206
3408
|
}
|
|
3207
3409
|
if (result instanceof Promise) {
|
|
3208
3410
|
return result.then(
|
|
3209
|
-
(settled) =>
|
|
3411
|
+
(settled) =>
|
|
3412
|
+
fusedRespond(
|
|
3413
|
+
settled,
|
|
3414
|
+
ctx,
|
|
3415
|
+
this.responseBodyTag,
|
|
3416
|
+
this.staticResponseHeaders,
|
|
3417
|
+
this.onResponseHooks.length === 0,
|
|
3418
|
+
),
|
|
3210
3419
|
(err) => logError(err, ctx),
|
|
3211
3420
|
)
|
|
3212
3421
|
}
|
|
3213
|
-
return fusedRespond(
|
|
3422
|
+
return fusedRespond(
|
|
3423
|
+
result,
|
|
3424
|
+
ctx,
|
|
3425
|
+
this.responseBodyTag,
|
|
3426
|
+
this.staticResponseHeaders,
|
|
3427
|
+
this.onResponseHooks.length === 0,
|
|
3428
|
+
)
|
|
3214
3429
|
}
|
|
3215
3430
|
return (source, params, search, signal, budget, platform, nativeContext) => {
|
|
3216
3431
|
const ctx = nativeContext
|
|
3217
|
-
? RequestContext.native(source, params, search,
|
|
3218
|
-
: new RequestContext(
|
|
3432
|
+
? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
|
|
3433
|
+
: new RequestContext(
|
|
3434
|
+
source,
|
|
3435
|
+
params,
|
|
3436
|
+
search,
|
|
3437
|
+
signal,
|
|
3438
|
+
budget,
|
|
3439
|
+
platform,
|
|
3440
|
+
maxBodyBytes,
|
|
3441
|
+
this.protoPoisoning,
|
|
3442
|
+
)
|
|
3219
3443
|
if (decorations !== undefined) Object.assign(ctx, decorations)
|
|
3220
3444
|
let validation: MaybePromise<StandardResult<unknown>>
|
|
3221
3445
|
try {
|
|
@@ -3244,6 +3468,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3244
3468
|
return wrapResponse(jsonError(500, "internal_error"))
|
|
3245
3469
|
}
|
|
3246
3470
|
|
|
3471
|
+
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
3472
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
|
|
3247
3473
|
private runWithAround<T>(
|
|
3248
3474
|
entry: RouteEntry,
|
|
3249
3475
|
ctx: RawContext,
|
|
@@ -3280,6 +3506,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3280
3506
|
return dispatch(0)
|
|
3281
3507
|
}
|
|
3282
3508
|
|
|
3509
|
+
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
3510
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
|
|
3283
3511
|
private runBodyOnly<T>(
|
|
3284
3512
|
entry: RouteEntry,
|
|
3285
3513
|
source: RequestSource,
|
|
@@ -3289,6 +3517,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3289
3517
|
): Promise<T> {
|
|
3290
3518
|
return this.readBodyInput(
|
|
3291
3519
|
source,
|
|
3520
|
+
entry.bodyLimit ?? this.maxBodyBytes,
|
|
3292
3521
|
(parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse),
|
|
3293
3522
|
wrapResponse,
|
|
3294
3523
|
(err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
|
|
@@ -3299,6 +3528,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3299
3528
|
* the validation + handler continuation differs. */
|
|
3300
3529
|
private readBodyInput<T>(
|
|
3301
3530
|
source: RequestSource,
|
|
3531
|
+
maxBodyBytes: number,
|
|
3302
3532
|
onParsed: (parsed: unknown) => MaybePromise<T>,
|
|
3303
3533
|
wrapResponse: (response: Response) => T,
|
|
3304
3534
|
onError: (err: unknown) => MaybePromise<T>,
|
|
@@ -3306,7 +3536,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3306
3536
|
const contentType = headerOf(source, "content-type") ?? ""
|
|
3307
3537
|
if (contentType !== "application/json" && !contentType.includes("application/json")) {
|
|
3308
3538
|
if (isUrlEncodedForm(contentType)) {
|
|
3309
|
-
return readBoundedForm(source,
|
|
3539
|
+
return readBoundedForm(source, maxBodyBytes).then(
|
|
3310
3540
|
(form) => (form instanceof Response ? wrapResponse(form) : onParsed(form)),
|
|
3311
3541
|
onError,
|
|
3312
3542
|
) as Promise<T>
|
|
@@ -3314,24 +3544,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3314
3544
|
return Promise.resolve(wrapResponse(jsonError(415, "unsupported_media_type")))
|
|
3315
3545
|
}
|
|
3316
3546
|
|
|
3317
|
-
//
|
|
3318
|
-
//
|
|
3319
|
-
|
|
3320
|
-
if (declared !== null) {
|
|
3321
|
-
const length = parseContentLength(declared)
|
|
3322
|
-
if (length === undefined) {
|
|
3323
|
-
return Promise.resolve(wrapResponse(jsonError(400, "invalid_content_length")))
|
|
3324
|
-
}
|
|
3325
|
-
if (length > this.maxBodyBytes) {
|
|
3326
|
-
return Promise.resolve(wrapResponse(jsonError(413, "payload_too_large")))
|
|
3327
|
-
}
|
|
3328
|
-
if (headerOf(source, "transfer-encoding") === null) {
|
|
3329
|
-
return source.json().then(onParsed, () => wrapResponse(jsonError(400, "invalid_json")))
|
|
3330
|
-
}
|
|
3331
|
-
}
|
|
3332
|
-
|
|
3547
|
+
// All JSON bodies go through readBoundedJson - the single enforcement point for the framed
|
|
3548
|
+
// fast path (post-read byte re-check), the streaming cap, and the prototype-poisoning guard.
|
|
3549
|
+
// A separate inlined native-json() fast path here would fork the trust boundary in two.
|
|
3333
3550
|
try {
|
|
3334
|
-
return this.readBoundedJson(
|
|
3551
|
+
return this.readBoundedJson(
|
|
3552
|
+
source,
|
|
3553
|
+
maxBodyBytes,
|
|
3335
3554
|
(parsed) => (parsed instanceof Response ? wrapResponse(parsed) : onParsed(parsed)),
|
|
3336
3555
|
onError,
|
|
3337
3556
|
)
|
|
@@ -3487,6 +3706,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3487
3706
|
return this.executeHandler(entry, ctx, finalize)
|
|
3488
3707
|
}
|
|
3489
3708
|
|
|
3709
|
+
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
3710
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
|
|
3490
3711
|
private runQueryOnly<T>(
|
|
3491
3712
|
entry: RouteEntry,
|
|
3492
3713
|
ctx: RawContext,
|
|
@@ -3693,6 +3914,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3693
3914
|
* node-direct primitives); an early/error `Response` (validation, thrown, 500) through `wrapResponse`.
|
|
3694
3915
|
*/
|
|
3695
3916
|
/** Synchronous until a validator, lifecycle hook, handler, or contract check actually returns a Promise. */
|
|
3917
|
+
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
3918
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
|
|
3696
3919
|
private runLifecycle<T>(
|
|
3697
3920
|
entry: RouteEntry,
|
|
3698
3921
|
source: RequestSource,
|
|
@@ -3701,6 +3924,32 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3701
3924
|
wrapResponse: (response: Response) => T,
|
|
3702
3925
|
): MaybePromise<T> {
|
|
3703
3926
|
try {
|
|
3927
|
+
const headersSchema = entry.schema?.headers
|
|
3928
|
+
if (headersSchema !== undefined) {
|
|
3929
|
+
const validation = headersSchema["~standard"].validate(headerObjectOf(source.headers))
|
|
3930
|
+
if (validation instanceof Promise) {
|
|
3931
|
+
return validation.then(
|
|
3932
|
+
(result) =>
|
|
3933
|
+
this.runLifecycleAfterHeadersResult(
|
|
3934
|
+
entry,
|
|
3935
|
+
source,
|
|
3936
|
+
ctx,
|
|
3937
|
+
finalize,
|
|
3938
|
+
wrapResponse,
|
|
3939
|
+
result,
|
|
3940
|
+
),
|
|
3941
|
+
(err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
|
|
3942
|
+
)
|
|
3943
|
+
}
|
|
3944
|
+
return this.runLifecycleAfterHeadersResult(
|
|
3945
|
+
entry,
|
|
3946
|
+
source,
|
|
3947
|
+
ctx,
|
|
3948
|
+
finalize,
|
|
3949
|
+
wrapResponse,
|
|
3950
|
+
validation,
|
|
3951
|
+
)
|
|
3952
|
+
}
|
|
3704
3953
|
const paramsSchema = entry.schema?.params
|
|
3705
3954
|
if (paramsSchema !== undefined) {
|
|
3706
3955
|
const validation = paramsSchema["~standard"].validate(ctx.params)
|
|
@@ -3740,6 +3989,64 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3740
3989
|
}
|
|
3741
3990
|
}
|
|
3742
3991
|
|
|
3992
|
+
private runLifecycleAfterHeadersResult<T>(
|
|
3993
|
+
entry: RouteEntry,
|
|
3994
|
+
source: RequestSource,
|
|
3995
|
+
ctx: RawContext,
|
|
3996
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
3997
|
+
wrapResponse: (response: Response) => T,
|
|
3998
|
+
result: StandardResult<unknown>,
|
|
3999
|
+
): MaybePromise<T> {
|
|
4000
|
+
try {
|
|
4001
|
+
const headersError = this.applyLifecycleValidation(entry, result, ctx, "headers")
|
|
4002
|
+
if (headersError instanceof Promise) {
|
|
4003
|
+
return headersError.then(
|
|
4004
|
+
(error) =>
|
|
4005
|
+
error === undefined
|
|
4006
|
+
? this.runLifecycleAfterHeaders(entry, source, ctx, finalize, wrapResponse)
|
|
4007
|
+
: wrapResponse(error),
|
|
4008
|
+
(err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
|
|
4009
|
+
)
|
|
4010
|
+
}
|
|
4011
|
+
return headersError === undefined
|
|
4012
|
+
? this.runLifecycleAfterHeaders(entry, source, ctx, finalize, wrapResponse)
|
|
4013
|
+
: wrapResponse(headersError)
|
|
4014
|
+
} catch (err) {
|
|
4015
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
|
|
4016
|
+
}
|
|
4017
|
+
}
|
|
4018
|
+
|
|
4019
|
+
private runLifecycleAfterHeaders<T>(
|
|
4020
|
+
entry: RouteEntry,
|
|
4021
|
+
source: RequestSource,
|
|
4022
|
+
ctx: RawContext,
|
|
4023
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
4024
|
+
wrapResponse: (response: Response) => T,
|
|
4025
|
+
): MaybePromise<T> {
|
|
4026
|
+
const paramsSchema = entry.schema?.params
|
|
4027
|
+
if (paramsSchema === undefined) {
|
|
4028
|
+
if (entry.schema?.body === undefined)
|
|
4029
|
+
return this.runQueryAndLifecycle(entry, ctx, finalize, wrapResponse)
|
|
4030
|
+
return this.runLifecycleAfterParams(entry, source, ctx, finalize, wrapResponse, undefined)
|
|
4031
|
+
}
|
|
4032
|
+
const validation = paramsSchema["~standard"].validate(ctx.params)
|
|
4033
|
+
if (validation instanceof Promise) {
|
|
4034
|
+
return validation.then(
|
|
4035
|
+
(result) =>
|
|
4036
|
+
this.runLifecycleAfterParamsResult(entry, source, ctx, finalize, wrapResponse, result),
|
|
4037
|
+
(err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
|
|
4038
|
+
)
|
|
4039
|
+
}
|
|
4040
|
+
return this.runLifecycleAfterParamsResult(
|
|
4041
|
+
entry,
|
|
4042
|
+
source,
|
|
4043
|
+
ctx,
|
|
4044
|
+
finalize,
|
|
4045
|
+
wrapResponse,
|
|
4046
|
+
validation,
|
|
4047
|
+
)
|
|
4048
|
+
}
|
|
4049
|
+
|
|
3743
4050
|
/** Registration-specialized query lifecycle: query validation (including recovery) → hooks. */
|
|
3744
4051
|
private runLifecycleQuery<T>(
|
|
3745
4052
|
entry: RouteEntry,
|
|
@@ -3772,6 +4079,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3772
4079
|
}
|
|
3773
4080
|
|
|
3774
4081
|
/** Registration-specialized body lifecycle: bounded body validation (including recovery) → hooks. */
|
|
4082
|
+
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
4083
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
|
|
3775
4084
|
private runLifecycleBody<T>(
|
|
3776
4085
|
entry: RouteEntry,
|
|
3777
4086
|
source: RequestSource,
|
|
@@ -3789,6 +4098,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3789
4098
|
}
|
|
3790
4099
|
|
|
3791
4100
|
/** Registration-specialized body + query lifecycle: bounded body validation → query validation → hooks. */
|
|
4101
|
+
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
4102
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
|
|
3792
4103
|
private runLifecycleBodyQuery<T>(
|
|
3793
4104
|
entry: RouteEntry,
|
|
3794
4105
|
source: RequestSource,
|
|
@@ -4070,8 +4381,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4070
4381
|
wrapResponse: (response: Response) => T,
|
|
4071
4382
|
): Promise<T> {
|
|
4072
4383
|
try {
|
|
4073
|
-
|
|
4074
|
-
|
|
4384
|
+
// Indexed, not `for...of`. An array iterator declared inside an async function stays live
|
|
4385
|
+
// across the `await` below, so neither engine can sink it - unlike the sync lanes above,
|
|
4386
|
+
// where V8 elides it. Measured cost of the iterator form: 43ns per hook on Bun, ~9ns on
|
|
4387
|
+
// Node, on a chain whose hooks return synchronously (the common shape).
|
|
4388
|
+
for (let i = 0; i < entry.beforeHandle.length; i++) {
|
|
4389
|
+
const outcome = entry.beforeHandle[i]!(ctx)
|
|
4075
4390
|
const early = outcome instanceof Promise ? await outcome : outcome
|
|
4076
4391
|
if (early !== undefined) return finalize(early, responseSet(ctx))
|
|
4077
4392
|
}
|
|
@@ -4231,8 +4546,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4231
4546
|
// any handler or loader.
|
|
4232
4547
|
if (err instanceof Response) return wrapResponse(err)
|
|
4233
4548
|
// onError hooks may return a custom response; otherwise the default 500 stands.
|
|
4234
|
-
for
|
|
4235
|
-
|
|
4549
|
+
// Indexed for the same reason as the `beforeHandle` chain above: the iterator would outlive
|
|
4550
|
+
// the `await` and stay heap-allocated on both engines.
|
|
4551
|
+
for (let i = 0; i < entry.onError.length; i++) {
|
|
4552
|
+
const outcome = entry.onError[i]!(err, ctx)
|
|
4236
4553
|
const handled = outcome instanceof Promise ? await outcome : outcome
|
|
4237
4554
|
if (handled !== undefined) return finalize(handled, responseSet(ctx))
|
|
4238
4555
|
}
|
|
@@ -4245,6 +4562,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4245
4562
|
/** Log an unhandled request error to the (redacting) logger - shared by {@link runLifecycle} and the
|
|
4246
4563
|
* bare fast path ({@link bareError}) so both record the same fields. Never throws; never leaks. */
|
|
4247
4564
|
private logRequestError(err: unknown, ctx: RawContext): void {
|
|
4565
|
+
// An error's own text can quote the input that produced it, so how much of it reaches the sink is
|
|
4566
|
+
// the app's call (`errorLogDetail`). The default keeps it: a 500 with no message and no stack is
|
|
4567
|
+
// an incident nobody can diagnose, and the framework already ships the narrower instrument for
|
|
4568
|
+
// the leak - a redacting logger with `valuePatterns`. `"none"` is there for an untrusted sink.
|
|
4569
|
+
const detail = this.errorLogDetail
|
|
4248
4570
|
this.logger.error("unhandled request error", {
|
|
4249
4571
|
method: ctx.req.method,
|
|
4250
4572
|
path: pathnameOf(ctx.req.url),
|
|
@@ -4252,8 +4574,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4252
4574
|
// `detail`, not `message`: the logger uses `message` for its own first argument, so a field of
|
|
4253
4575
|
// that name is silently overwritten and the thrown error's own text never reaches the sink. It
|
|
4254
4576
|
// survived only incidentally inside `stack`, and was lost outright for a non-Error throw.
|
|
4255
|
-
detail: err instanceof Error ? err.message : String(err),
|
|
4256
|
-
|
|
4577
|
+
...(detail === "none" ? {} : { detail: err instanceof Error ? err.message : String(err) }),
|
|
4578
|
+
...(detail === "full" && err instanceof Error ? { stack: err.stack } : {}),
|
|
4257
4579
|
})
|
|
4258
4580
|
}
|
|
4259
4581
|
|
|
@@ -4265,11 +4587,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4265
4587
|
const contentType = headerOf(req, "content-type") ?? ""
|
|
4266
4588
|
let parsed: unknown
|
|
4267
4589
|
if (contentType === "application/json" || contentType.includes("application/json")) {
|
|
4268
|
-
const json = await this.readBoundedJson(req)
|
|
4590
|
+
const json = await this.readBoundedJson(req, entry.bodyLimit ?? this.maxBodyBytes)
|
|
4269
4591
|
if (json instanceof Response) return json
|
|
4270
4592
|
parsed = json
|
|
4271
4593
|
} else if (isUrlEncodedForm(contentType)) {
|
|
4272
|
-
const form = await readBoundedForm(req, this.maxBodyBytes)
|
|
4594
|
+
const form = await readBoundedForm(req, entry.bodyLimit ?? this.maxBodyBytes)
|
|
4273
4595
|
if (form instanceof Response) return form
|
|
4274
4596
|
parsed = form
|
|
4275
4597
|
} else {
|
|
@@ -4288,11 +4610,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4288
4610
|
entry: RouteEntry,
|
|
4289
4611
|
result: StandardResult<unknown>,
|
|
4290
4612
|
ctx: RawContext,
|
|
4291
|
-
kind: "body" | "query" | "params",
|
|
4613
|
+
kind: "body" | "query" | "params" | "headers",
|
|
4292
4614
|
): MaybePromise<Response | undefined> {
|
|
4293
4615
|
const assign = (value: unknown): void => {
|
|
4294
4616
|
if (kind === "body") ctx.body = value
|
|
4295
4617
|
else if (kind === "query") ctx.query = value
|
|
4618
|
+
else if (kind === "headers") ctx.headers = value as Record<string, string>
|
|
4296
4619
|
else ctx.params = value as Record<string, string>
|
|
4297
4620
|
}
|
|
4298
4621
|
if (result.issues === undefined) {
|
|
@@ -4312,7 +4635,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4312
4635
|
|
|
4313
4636
|
private finishLifecycleValidationRecovery(
|
|
4314
4637
|
entry: RouteEntry,
|
|
4315
|
-
kind: "body" | "query" | "params",
|
|
4638
|
+
kind: "body" | "query" | "params" | "headers",
|
|
4316
4639
|
issues: ReadonlyArray<StandardIssue>,
|
|
4317
4640
|
recovery: unknown,
|
|
4318
4641
|
assign: (value: unknown) => void,
|
|
@@ -4324,7 +4647,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4324
4647
|
? entry.schema?.body
|
|
4325
4648
|
: kind === "query"
|
|
4326
4649
|
? entry.schema?.query
|
|
4327
|
-
:
|
|
4650
|
+
: kind === "params"
|
|
4651
|
+
? entry.schema?.params
|
|
4652
|
+
: entry.schema?.headers
|
|
4328
4653
|
const retried = schema!["~standard"].validate(recovery)
|
|
4329
4654
|
if (retried instanceof Promise) {
|
|
4330
4655
|
return retried.then((settled) => {
|
|
@@ -4339,21 +4664,30 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4339
4664
|
}
|
|
4340
4665
|
|
|
4341
4666
|
/**
|
|
4342
|
-
* Read the body
|
|
4343
|
-
* `Content-Length` over the cap *before* buffering,
|
|
4344
|
-
*
|
|
4345
|
-
*
|
|
4346
|
-
*
|
|
4347
|
-
* Fast path: when a non-chunked request carries a `Content-Length` within the cap,
|
|
4348
|
-
* a native `req.json()` is already bounded - under HTTP/1.1 + HTTP/2 framing the
|
|
4349
|
-
* runtime delivers at most `Content-Length` bytes - so we skip the manual stream
|
|
4350
|
-
* loop and a separate text decode. It trusts the wire
|
|
4351
|
-
* *framing*, not the header value: nifra only ever receives framed Requests from the
|
|
4352
|
-
* runtime's HTTP server, never a hand-built one with a mismatched length. Chunked or
|
|
4353
|
-
* length-less bodies fall through to the streaming byte-cap guard below.
|
|
4667
|
+
* Read + parse the JSON body, capped at `maxBodyBytes` and screened by the server's
|
|
4668
|
+
* `protoPoisoning` policy. Rejects a `Content-Length` over the cap *before* buffering,
|
|
4669
|
+
* re-checks what was actually delivered after the read, and aborts a chunked or
|
|
4670
|
+
* length-less body mid-stream once the running byte count exceeds the cap - so a lying
|
|
4671
|
+
* or absent length can't force us to buffer an oversized payload.
|
|
4354
4672
|
*/
|
|
4355
|
-
private
|
|
4356
|
-
|
|
4673
|
+
private readBoundedJson(req: RequestSource, maxBodyBytes?: number): Promise<unknown | Response>
|
|
4674
|
+
private readBoundedJson<T>(
|
|
4675
|
+
req: RequestSource,
|
|
4676
|
+
maxBodyBytes: number,
|
|
4677
|
+
onResult: (parsed: unknown | Response) => T | Promise<T>,
|
|
4678
|
+
onError: (error: unknown) => T | Promise<T>,
|
|
4679
|
+
): Promise<T>
|
|
4680
|
+
private readBoundedJson<T>(
|
|
4681
|
+
req: RequestSource,
|
|
4682
|
+
maxBodyBytes = this.maxBodyBytes,
|
|
4683
|
+
onResult?: (parsed: unknown | Response) => T | Promise<T>,
|
|
4684
|
+
onError?: (error: unknown) => T | Promise<T>,
|
|
4685
|
+
): Promise<unknown | Response | T> {
|
|
4686
|
+
// Not `async`: this is a pass-through, and wrapping the lane's own promise in a coroutine
|
|
4687
|
+
// frame costs an extra microtask hop on every JSON body.
|
|
4688
|
+
return onResult === undefined
|
|
4689
|
+
? readBoundedJsonSource(req, maxBodyBytes, this.protoPoisoning)
|
|
4690
|
+
: readBoundedJsonSource(req, maxBodyBytes, this.protoPoisoning, onResult, onError!)
|
|
4357
4691
|
}
|
|
4358
4692
|
|
|
4359
4693
|
/** Adapt one route's compiled execution plan to Bun's already-matched request shape. Route
|
|
@@ -4365,25 +4699,51 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4365
4699
|
signal: AbortSignal | undefined,
|
|
4366
4700
|
budget: RequestBudget | undefined,
|
|
4367
4701
|
): BunNativeHandler {
|
|
4702
|
+
// This callback is reached only from Bun's compiled native route table. Bun has already parsed
|
|
4703
|
+
// the HTTP framing, so the JSON lane may retain its native fused `json()` parse without weakening
|
|
4704
|
+
// the portable/adapted source contract (those sources are never marked).
|
|
4705
|
+
const markFramed = (request: Request): void => markTrustedBodyFraming(request)
|
|
4706
|
+
// The fused native lane bypasses `runMatched`, so the route's transport byte cap must be
|
|
4707
|
+
// marked here too - otherwise a Bun `listen()` fused route would leave direct `c.req` body
|
|
4708
|
+
// reads uncapped. The non-fused branches go through `fetchMatched` -> `runMatched`, which marks.
|
|
4709
|
+
const bodyLimit = entry.schema?.body === undefined ? entry.bodyLimit : undefined
|
|
4710
|
+
const inner = fused
|
|
4711
|
+
const capped: FusedWebRunner | undefined =
|
|
4712
|
+
inner === undefined || bodyLimit === undefined
|
|
4713
|
+
? inner
|
|
4714
|
+
: (source, params, search, fusedSignal, fusedBudget, platform, nativeContext) => {
|
|
4715
|
+
const method = source.method
|
|
4716
|
+
if (method !== "GET" && method !== "HEAD") markTransportCap(source, bodyLimit)
|
|
4717
|
+
return inner(source, params, search, fusedSignal, fusedBudget, platform, nativeContext)
|
|
4718
|
+
}
|
|
4368
4719
|
if (paramNames.length === 0) {
|
|
4369
|
-
if (
|
|
4370
|
-
return (request) =>
|
|
4720
|
+
if (capped === undefined) {
|
|
4721
|
+
return (request) => {
|
|
4722
|
+
markFramed(request)
|
|
4723
|
+
return this.fetchMatched(request, entry, EMPTY_PARAMS)
|
|
4724
|
+
}
|
|
4371
4725
|
}
|
|
4372
4726
|
if (this.acceptInboundDeadlines) {
|
|
4373
|
-
return (request) =>
|
|
4374
|
-
request
|
|
4727
|
+
return (request) => {
|
|
4728
|
+
markFramed(request)
|
|
4729
|
+
return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
|
|
4375
4730
|
? this.fetchMatched(request, entry, EMPTY_PARAMS)
|
|
4376
|
-
:
|
|
4731
|
+
: capped(request, EMPTY_PARAMS, undefined, signal!, budget!, undefined, true)
|
|
4732
|
+
}
|
|
4733
|
+
}
|
|
4734
|
+
return (request) => {
|
|
4735
|
+
markFramed(request)
|
|
4736
|
+
return capped(request, EMPTY_PARAMS, undefined, signal!, budget!, undefined, true)
|
|
4377
4737
|
}
|
|
4378
|
-
return (request) => fused(request, EMPTY_PARAMS, undefined, signal!, budget!, undefined, true)
|
|
4379
4738
|
}
|
|
4380
4739
|
|
|
4381
4740
|
const malformed =
|
|
4382
4741
|
paramNames.length === 1
|
|
4383
4742
|
? (params: Record<string, string>) => params[paramNames[0]!]?.includes("\uFFFD") === true
|
|
4384
4743
|
: hasReplacementParam
|
|
4385
|
-
if (
|
|
4744
|
+
if (capped === undefined) {
|
|
4386
4745
|
return (request) => {
|
|
4746
|
+
markFramed(request)
|
|
4387
4747
|
const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
|
|
4388
4748
|
if (malformed(params)) return this.fetchSource(request)
|
|
4389
4749
|
return this.fetchMatched(request, entry, params)
|
|
@@ -4391,17 +4751,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4391
4751
|
}
|
|
4392
4752
|
if (this.acceptInboundDeadlines) {
|
|
4393
4753
|
return (request) => {
|
|
4754
|
+
markFramed(request)
|
|
4394
4755
|
const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
|
|
4395
4756
|
if (malformed(params)) return this.fetchSource(request)
|
|
4396
4757
|
return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
|
|
4397
4758
|
? this.fetchMatched(request, entry, params)
|
|
4398
|
-
:
|
|
4759
|
+
: capped(request, params, undefined, signal!, budget!, undefined, true)
|
|
4399
4760
|
}
|
|
4400
4761
|
}
|
|
4401
4762
|
return (request) => {
|
|
4763
|
+
markFramed(request)
|
|
4402
4764
|
const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
|
|
4403
4765
|
if (malformed(params)) return this.fetchSource(request)
|
|
4404
|
-
return
|
|
4766
|
+
return capped(request, params, undefined, signal!, budget!, undefined, true)
|
|
4405
4767
|
}
|
|
4406
4768
|
}
|
|
4407
4769
|
|
|
@@ -4476,10 +4838,21 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4476
4838
|
* `"127.0.0.1"` to bind loopback only - an admin surface, a sidecar, or anything that must not be
|
|
4477
4839
|
* reachable off the box. Omitting it when you meant to restrict is a real exposure, so it is a
|
|
4478
4840
|
* first-class option rather than something a caller has to drop down to `Bun.serve` for.
|
|
4841
|
+
*
|
|
4842
|
+
* `idleTimeoutSec` is how long Bun tolerates a connection with no bytes moving before it closes
|
|
4843
|
+
* the socket. Bun's default is **10 seconds**, and a request whose handler is still working sends
|
|
4844
|
+
* nothing - so any endpoint slower than 10s (a render, an export, a long upstream call) has its
|
|
4845
|
+
* connection cut mid-flight regardless of `requestTimeoutMs`. Apps with such endpoints must raise
|
|
4846
|
+
* this above their slowest expected response, which is why it is a first-class option and not a
|
|
4847
|
+
* reason to drop down to `Bun.serve`. `0` disables the timeout entirely; max 255.
|
|
4479
4848
|
*/
|
|
4480
4849
|
listen(
|
|
4481
4850
|
port: number,
|
|
4482
|
-
options?: {
|
|
4851
|
+
options?: {
|
|
4852
|
+
readonly reusePort?: boolean
|
|
4853
|
+
readonly hostname?: string
|
|
4854
|
+
readonly idleTimeoutSec?: number
|
|
4855
|
+
},
|
|
4483
4856
|
): RunningServer {
|
|
4484
4857
|
if (typeof Bun === "undefined") {
|
|
4485
4858
|
// listen() is the one Bun-specific seam. Off Bun, fail loud + actionable rather
|
|
@@ -4509,12 +4882,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4509
4882
|
// Spread rather than pass `hostname: undefined` - Bun treats an explicit undefined as a value
|
|
4510
4883
|
// on some option paths, and omitting is what selects its 0.0.0.0 default.
|
|
4511
4884
|
const bind = options?.hostname === undefined ? {} : { hostname: options.hostname }
|
|
4885
|
+
// Same reasoning as `bind`: omit rather than pass undefined, so Bun's own default applies.
|
|
4886
|
+
const idle =
|
|
4887
|
+
options?.idleTimeoutSec === undefined ? {} : { idleTimeout: options.idleTimeoutSec }
|
|
4512
4888
|
const nativeRoutes = wsHandlers === undefined ? this.buildBunNativeRoutes() : undefined
|
|
4513
4889
|
const running = (wsHandlers === undefined
|
|
4514
4890
|
? Bun.serve({
|
|
4515
4891
|
port,
|
|
4516
4892
|
reusePort,
|
|
4517
4893
|
...bind,
|
|
4894
|
+
...idle,
|
|
4518
4895
|
...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
|
|
4519
4896
|
fetch: (req: Request, server) =>
|
|
4520
4897
|
this.fetch(req, bunPeerPlatform(server, req) as Platform<EnvOf<Ctx>>),
|
|
@@ -4523,6 +4900,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4523
4900
|
port,
|
|
4524
4901
|
reusePort,
|
|
4525
4902
|
...bind,
|
|
4903
|
+
...idle,
|
|
4526
4904
|
fetch: (req, server) => this.bunFetchWithWebSocket(req, server),
|
|
4527
4905
|
// Bun's `ServerWebSocket<BunWsData>` is runtime-compatible with the handlers' structural
|
|
4528
4906
|
// `BunSocket` view (kept local so `Bun.*` types never leak into the published .d.ts); the
|
|
@@ -4562,6 +4940,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4562
4940
|
await Bun.sleep(DRAIN_POLL_MS)
|
|
4563
4941
|
}
|
|
4564
4942
|
server.stop(server.pendingRequests > 0) // force-close iff stragglers remain past the deadline
|
|
4943
|
+
if (this.stopHooks.length === 0) return
|
|
4944
|
+
const callbacks = this.stopHooks.map((hook) => Promise.resolve().then(hook))
|
|
4945
|
+
const settled = Promise.allSettled(callbacks)
|
|
4946
|
+
await Promise.race([
|
|
4947
|
+
settled,
|
|
4948
|
+
new Promise<void>((resolve) => setTimeout(resolve, STOP_HOOK_TIMEOUT_MS)),
|
|
4949
|
+
])
|
|
4565
4950
|
}
|
|
4566
4951
|
|
|
4567
4952
|
private installSignalHandlers(): void {
|