@nifrajs/core 2.10.0 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +424 -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 +667 -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,15 @@ 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
|
+
if (entry.bodyLimit !== undefined) {
|
|
2845
|
+
const method = source.method
|
|
2846
|
+
if (method !== "GET" && method !== "HEAD") markTransportCap(source, entry.bodyLimit)
|
|
2847
|
+
}
|
|
2723
2848
|
// An idempotency route runs its dedupe lane first; on a fresh key it delegates to the normal lanes
|
|
2724
2849
|
// (with the body buffered). All non-idempotent routes skip straight to the lanes - no added cost.
|
|
2725
2850
|
// The runtime is always present when a route resolved idempotency (enforced at registration).
|
|
@@ -2733,7 +2858,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2733
2858
|
search,
|
|
2734
2859
|
wrapResponse,
|
|
2735
2860
|
{
|
|
2736
|
-
maxBodyBytes: this.maxBodyBytes,
|
|
2861
|
+
maxBodyBytes: entry.bodyLimit ?? this.maxBodyBytes,
|
|
2737
2862
|
runLanes: (buffered, plat, ent, prm, srch) =>
|
|
2738
2863
|
this.idempotencyRunLanes(buffered, plat, ent as RouteEntry, prm, srch),
|
|
2739
2864
|
},
|
|
@@ -2861,6 +2986,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2861
2986
|
|
|
2862
2987
|
/** The narrowest bare route: a syntactic `() => ...` handler cannot observe the context argument, so
|
|
2863
2988
|
* successful requests can skip allocating `RequestContext`. Errors still allocate one for logging. */
|
|
2989
|
+
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2990
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2864
2991
|
private runContextlessBare<T>(
|
|
2865
2992
|
entry: RouteEntry,
|
|
2866
2993
|
source: RequestSource,
|
|
@@ -2925,6 +3052,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2925
3052
|
budget,
|
|
2926
3053
|
platform,
|
|
2927
3054
|
this.maxBodyBytes,
|
|
3055
|
+
this.protoPoisoning,
|
|
2928
3056
|
)
|
|
2929
3057
|
this.logRequestError(err, ctx)
|
|
2930
3058
|
return wrapResponse(jsonError(500, "internal_error"))
|
|
@@ -2938,6 +3066,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2938
3066
|
* (a thrown `Response` is control flow; anything else is a logged flat 500). This is where nifra skips
|
|
2939
3067
|
* the per-request async-frame tax - the same win codegen routers get, but without `eval`.
|
|
2940
3068
|
*/
|
|
3069
|
+
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
3070
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2941
3071
|
private runBare<T>(
|
|
2942
3072
|
entry: RouteEntry,
|
|
2943
3073
|
ctx: RawContext,
|
|
@@ -2974,6 +3104,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2974
3104
|
handler: InternalHandler,
|
|
2975
3105
|
decorations: Record<PropertyKey, unknown> | undefined,
|
|
2976
3106
|
contextless: boolean,
|
|
3107
|
+
maxBodyBytes: number,
|
|
2977
3108
|
): FusedWebRunner {
|
|
2978
3109
|
// The fused lanes return to the runtime directly, so the framework's own error renders fold in
|
|
2979
3110
|
// static headers here rather than through the shared `wrapResponse` seam.
|
|
@@ -2994,7 +3125,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2994
3125
|
return logError(
|
|
2995
3126
|
err,
|
|
2996
3127
|
nativeContext
|
|
2997
|
-
? RequestContext.native(
|
|
3128
|
+
? RequestContext.native(
|
|
3129
|
+
source,
|
|
3130
|
+
params,
|
|
3131
|
+
search,
|
|
3132
|
+
maxBodyBytes,
|
|
3133
|
+
platform,
|
|
3134
|
+
this.protoPoisoning,
|
|
3135
|
+
)
|
|
2998
3136
|
: new RequestContext(
|
|
2999
3137
|
source,
|
|
3000
3138
|
params,
|
|
@@ -3002,18 +3140,32 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3002
3140
|
signal,
|
|
3003
3141
|
budget,
|
|
3004
3142
|
platform,
|
|
3005
|
-
|
|
3143
|
+
maxBodyBytes,
|
|
3144
|
+
this.protoPoisoning,
|
|
3006
3145
|
),
|
|
3007
3146
|
)
|
|
3008
3147
|
}
|
|
3009
3148
|
if (result instanceof Promise) {
|
|
3010
3149
|
return result.then(
|
|
3011
|
-
(value) =>
|
|
3150
|
+
(value) =>
|
|
3151
|
+
fusedRespondNoSet(
|
|
3152
|
+
value,
|
|
3153
|
+
this.responseBodyTag,
|
|
3154
|
+
this.staticResponseHeaders,
|
|
3155
|
+
this.onResponseHooks.length === 0,
|
|
3156
|
+
),
|
|
3012
3157
|
(err) =>
|
|
3013
3158
|
logError(
|
|
3014
3159
|
err,
|
|
3015
3160
|
nativeContext
|
|
3016
|
-
? RequestContext.native(
|
|
3161
|
+
? RequestContext.native(
|
|
3162
|
+
source,
|
|
3163
|
+
params,
|
|
3164
|
+
search,
|
|
3165
|
+
maxBodyBytes,
|
|
3166
|
+
platform,
|
|
3167
|
+
this.protoPoisoning,
|
|
3168
|
+
)
|
|
3017
3169
|
: new RequestContext(
|
|
3018
3170
|
source,
|
|
3019
3171
|
params,
|
|
@@ -3021,18 +3173,33 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3021
3173
|
signal,
|
|
3022
3174
|
budget,
|
|
3023
3175
|
platform,
|
|
3024
|
-
|
|
3176
|
+
maxBodyBytes,
|
|
3177
|
+
this.protoPoisoning,
|
|
3025
3178
|
),
|
|
3026
3179
|
),
|
|
3027
3180
|
)
|
|
3028
3181
|
}
|
|
3029
|
-
return fusedRespondNoSet(
|
|
3182
|
+
return fusedRespondNoSet(
|
|
3183
|
+
result,
|
|
3184
|
+
this.responseBodyTag,
|
|
3185
|
+
this.staticResponseHeaders,
|
|
3186
|
+
this.onResponseHooks.length === 0,
|
|
3187
|
+
)
|
|
3030
3188
|
}
|
|
3031
3189
|
}
|
|
3032
3190
|
return (source, params, search, signal, budget, platform, nativeContext) => {
|
|
3033
3191
|
const ctx = nativeContext
|
|
3034
|
-
? RequestContext.native(source, params, search,
|
|
3035
|
-
: new RequestContext(
|
|
3192
|
+
? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
|
|
3193
|
+
: new RequestContext(
|
|
3194
|
+
source,
|
|
3195
|
+
params,
|
|
3196
|
+
search,
|
|
3197
|
+
signal,
|
|
3198
|
+
budget,
|
|
3199
|
+
platform,
|
|
3200
|
+
maxBodyBytes,
|
|
3201
|
+
this.protoPoisoning,
|
|
3202
|
+
)
|
|
3036
3203
|
if (decorations !== undefined) Object.assign(ctx, decorations)
|
|
3037
3204
|
let result: unknown
|
|
3038
3205
|
try {
|
|
@@ -3042,11 +3209,24 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3042
3209
|
}
|
|
3043
3210
|
if (result instanceof Promise) {
|
|
3044
3211
|
return result.then(
|
|
3045
|
-
(value) =>
|
|
3212
|
+
(value) =>
|
|
3213
|
+
fusedRespond(
|
|
3214
|
+
value,
|
|
3215
|
+
ctx,
|
|
3216
|
+
this.responseBodyTag,
|
|
3217
|
+
this.staticResponseHeaders,
|
|
3218
|
+
this.onResponseHooks.length === 0,
|
|
3219
|
+
),
|
|
3046
3220
|
(err) => logError(err, ctx),
|
|
3047
3221
|
)
|
|
3048
3222
|
}
|
|
3049
|
-
return fusedRespond(
|
|
3223
|
+
return fusedRespond(
|
|
3224
|
+
result,
|
|
3225
|
+
ctx,
|
|
3226
|
+
this.responseBodyTag,
|
|
3227
|
+
this.staticResponseHeaders,
|
|
3228
|
+
this.onResponseHooks.length === 0,
|
|
3229
|
+
)
|
|
3050
3230
|
}
|
|
3051
3231
|
}
|
|
3052
3232
|
|
|
@@ -3057,6 +3237,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3057
3237
|
handler: InternalHandler,
|
|
3058
3238
|
bodySchema: StandardSchemaV1,
|
|
3059
3239
|
decorations: Record<PropertyKey, unknown> | undefined,
|
|
3240
|
+
maxBodyBytes: number,
|
|
3060
3241
|
): FusedBodyRunner {
|
|
3061
3242
|
const logError = <T>(
|
|
3062
3243
|
err: unknown,
|
|
@@ -3149,11 +3330,20 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3149
3330
|
wrapResponse: (response: Response) => T,
|
|
3150
3331
|
): MaybePromise<T> => {
|
|
3151
3332
|
const ctx = nativeContext
|
|
3152
|
-
? RequestContext.native(source, params, search,
|
|
3153
|
-
: new RequestContext(
|
|
3333
|
+
? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
|
|
3334
|
+
: new RequestContext(
|
|
3335
|
+
source,
|
|
3336
|
+
params,
|
|
3337
|
+
search,
|
|
3338
|
+
signal,
|
|
3339
|
+
budget,
|
|
3340
|
+
platform,
|
|
3341
|
+
maxBodyBytes,
|
|
3342
|
+
this.protoPoisoning,
|
|
3343
|
+
)
|
|
3154
3344
|
const finish = (value: unknown): MaybePromise<T> =>
|
|
3155
3345
|
runParsed(value, ctx, finalize, wrapResponse)
|
|
3156
|
-
return this.readBodyInput(source, finish, wrapResponse, (err) =>
|
|
3346
|
+
return this.readBodyInput(source, maxBodyBytes, finish, wrapResponse, (err) =>
|
|
3157
3347
|
logError(err, ctx, wrapResponse),
|
|
3158
3348
|
)
|
|
3159
3349
|
}
|
|
@@ -3172,7 +3362,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3172
3362
|
platform,
|
|
3173
3363
|
nativeContext,
|
|
3174
3364
|
(result, _set, ctx) =>
|
|
3175
|
-
fusedRespond(
|
|
3365
|
+
fusedRespond(
|
|
3366
|
+
result,
|
|
3367
|
+
ctx,
|
|
3368
|
+
this.responseBodyTag,
|
|
3369
|
+
this.staticResponseHeaders,
|
|
3370
|
+
this.onResponseHooks.length === 0,
|
|
3371
|
+
),
|
|
3176
3372
|
this.wrapWebResponse,
|
|
3177
3373
|
) as MaybePromise<Response>
|
|
3178
3374
|
}
|
|
@@ -3188,6 +3384,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3188
3384
|
handler: InternalHandler,
|
|
3189
3385
|
decorations: Record<PropertyKey, unknown> | undefined,
|
|
3190
3386
|
querySchema: StandardSchemaV1,
|
|
3387
|
+
maxBodyBytes: number,
|
|
3191
3388
|
): FusedWebRunner {
|
|
3192
3389
|
// The fused lanes return to the runtime directly, so the framework's own error renders fold in
|
|
3193
3390
|
// static headers here rather than through the shared `wrapResponse` seam.
|
|
@@ -3206,16 +3403,38 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3206
3403
|
}
|
|
3207
3404
|
if (result instanceof Promise) {
|
|
3208
3405
|
return result.then(
|
|
3209
|
-
(settled) =>
|
|
3406
|
+
(settled) =>
|
|
3407
|
+
fusedRespond(
|
|
3408
|
+
settled,
|
|
3409
|
+
ctx,
|
|
3410
|
+
this.responseBodyTag,
|
|
3411
|
+
this.staticResponseHeaders,
|
|
3412
|
+
this.onResponseHooks.length === 0,
|
|
3413
|
+
),
|
|
3210
3414
|
(err) => logError(err, ctx),
|
|
3211
3415
|
)
|
|
3212
3416
|
}
|
|
3213
|
-
return fusedRespond(
|
|
3417
|
+
return fusedRespond(
|
|
3418
|
+
result,
|
|
3419
|
+
ctx,
|
|
3420
|
+
this.responseBodyTag,
|
|
3421
|
+
this.staticResponseHeaders,
|
|
3422
|
+
this.onResponseHooks.length === 0,
|
|
3423
|
+
)
|
|
3214
3424
|
}
|
|
3215
3425
|
return (source, params, search, signal, budget, platform, nativeContext) => {
|
|
3216
3426
|
const ctx = nativeContext
|
|
3217
|
-
? RequestContext.native(source, params, search,
|
|
3218
|
-
: new RequestContext(
|
|
3427
|
+
? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
|
|
3428
|
+
: new RequestContext(
|
|
3429
|
+
source,
|
|
3430
|
+
params,
|
|
3431
|
+
search,
|
|
3432
|
+
signal,
|
|
3433
|
+
budget,
|
|
3434
|
+
platform,
|
|
3435
|
+
maxBodyBytes,
|
|
3436
|
+
this.protoPoisoning,
|
|
3437
|
+
)
|
|
3219
3438
|
if (decorations !== undefined) Object.assign(ctx, decorations)
|
|
3220
3439
|
let validation: MaybePromise<StandardResult<unknown>>
|
|
3221
3440
|
try {
|
|
@@ -3244,6 +3463,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3244
3463
|
return wrapResponse(jsonError(500, "internal_error"))
|
|
3245
3464
|
}
|
|
3246
3465
|
|
|
3466
|
+
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
3467
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
|
|
3247
3468
|
private runWithAround<T>(
|
|
3248
3469
|
entry: RouteEntry,
|
|
3249
3470
|
ctx: RawContext,
|
|
@@ -3280,6 +3501,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3280
3501
|
return dispatch(0)
|
|
3281
3502
|
}
|
|
3282
3503
|
|
|
3504
|
+
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
3505
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
|
|
3283
3506
|
private runBodyOnly<T>(
|
|
3284
3507
|
entry: RouteEntry,
|
|
3285
3508
|
source: RequestSource,
|
|
@@ -3289,6 +3512,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3289
3512
|
): Promise<T> {
|
|
3290
3513
|
return this.readBodyInput(
|
|
3291
3514
|
source,
|
|
3515
|
+
entry.bodyLimit ?? this.maxBodyBytes,
|
|
3292
3516
|
(parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse),
|
|
3293
3517
|
wrapResponse,
|
|
3294
3518
|
(err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
|
|
@@ -3299,6 +3523,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3299
3523
|
* the validation + handler continuation differs. */
|
|
3300
3524
|
private readBodyInput<T>(
|
|
3301
3525
|
source: RequestSource,
|
|
3526
|
+
maxBodyBytes: number,
|
|
3302
3527
|
onParsed: (parsed: unknown) => MaybePromise<T>,
|
|
3303
3528
|
wrapResponse: (response: Response) => T,
|
|
3304
3529
|
onError: (err: unknown) => MaybePromise<T>,
|
|
@@ -3306,7 +3531,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3306
3531
|
const contentType = headerOf(source, "content-type") ?? ""
|
|
3307
3532
|
if (contentType !== "application/json" && !contentType.includes("application/json")) {
|
|
3308
3533
|
if (isUrlEncodedForm(contentType)) {
|
|
3309
|
-
return readBoundedForm(source,
|
|
3534
|
+
return readBoundedForm(source, maxBodyBytes).then(
|
|
3310
3535
|
(form) => (form instanceof Response ? wrapResponse(form) : onParsed(form)),
|
|
3311
3536
|
onError,
|
|
3312
3537
|
) as Promise<T>
|
|
@@ -3314,24 +3539,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3314
3539
|
return Promise.resolve(wrapResponse(jsonError(415, "unsupported_media_type")))
|
|
3315
3540
|
}
|
|
3316
3541
|
|
|
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
|
-
|
|
3542
|
+
// All JSON bodies go through readBoundedJson - the single enforcement point for the framed
|
|
3543
|
+
// fast path (post-read byte re-check), the streaming cap, and the prototype-poisoning guard.
|
|
3544
|
+
// A separate inlined native-json() fast path here would fork the trust boundary in two.
|
|
3333
3545
|
try {
|
|
3334
|
-
return this.readBoundedJson(
|
|
3546
|
+
return this.readBoundedJson(
|
|
3547
|
+
source,
|
|
3548
|
+
maxBodyBytes,
|
|
3335
3549
|
(parsed) => (parsed instanceof Response ? wrapResponse(parsed) : onParsed(parsed)),
|
|
3336
3550
|
onError,
|
|
3337
3551
|
)
|
|
@@ -3487,6 +3701,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3487
3701
|
return this.executeHandler(entry, ctx, finalize)
|
|
3488
3702
|
}
|
|
3489
3703
|
|
|
3704
|
+
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
3705
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
|
|
3490
3706
|
private runQueryOnly<T>(
|
|
3491
3707
|
entry: RouteEntry,
|
|
3492
3708
|
ctx: RawContext,
|
|
@@ -3693,6 +3909,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3693
3909
|
* node-direct primitives); an early/error `Response` (validation, thrown, 500) through `wrapResponse`.
|
|
3694
3910
|
*/
|
|
3695
3911
|
/** Synchronous until a validator, lifecycle hook, handler, or contract check actually returns a Promise. */
|
|
3912
|
+
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
3913
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
|
|
3696
3914
|
private runLifecycle<T>(
|
|
3697
3915
|
entry: RouteEntry,
|
|
3698
3916
|
source: RequestSource,
|
|
@@ -3701,6 +3919,32 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3701
3919
|
wrapResponse: (response: Response) => T,
|
|
3702
3920
|
): MaybePromise<T> {
|
|
3703
3921
|
try {
|
|
3922
|
+
const headersSchema = entry.schema?.headers
|
|
3923
|
+
if (headersSchema !== undefined) {
|
|
3924
|
+
const validation = headersSchema["~standard"].validate(headerObjectOf(source.headers))
|
|
3925
|
+
if (validation instanceof Promise) {
|
|
3926
|
+
return validation.then(
|
|
3927
|
+
(result) =>
|
|
3928
|
+
this.runLifecycleAfterHeadersResult(
|
|
3929
|
+
entry,
|
|
3930
|
+
source,
|
|
3931
|
+
ctx,
|
|
3932
|
+
finalize,
|
|
3933
|
+
wrapResponse,
|
|
3934
|
+
result,
|
|
3935
|
+
),
|
|
3936
|
+
(err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
|
|
3937
|
+
)
|
|
3938
|
+
}
|
|
3939
|
+
return this.runLifecycleAfterHeadersResult(
|
|
3940
|
+
entry,
|
|
3941
|
+
source,
|
|
3942
|
+
ctx,
|
|
3943
|
+
finalize,
|
|
3944
|
+
wrapResponse,
|
|
3945
|
+
validation,
|
|
3946
|
+
)
|
|
3947
|
+
}
|
|
3704
3948
|
const paramsSchema = entry.schema?.params
|
|
3705
3949
|
if (paramsSchema !== undefined) {
|
|
3706
3950
|
const validation = paramsSchema["~standard"].validate(ctx.params)
|
|
@@ -3740,6 +3984,64 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3740
3984
|
}
|
|
3741
3985
|
}
|
|
3742
3986
|
|
|
3987
|
+
private runLifecycleAfterHeadersResult<T>(
|
|
3988
|
+
entry: RouteEntry,
|
|
3989
|
+
source: RequestSource,
|
|
3990
|
+
ctx: RawContext,
|
|
3991
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
3992
|
+
wrapResponse: (response: Response) => T,
|
|
3993
|
+
result: StandardResult<unknown>,
|
|
3994
|
+
): MaybePromise<T> {
|
|
3995
|
+
try {
|
|
3996
|
+
const headersError = this.applyLifecycleValidation(entry, result, ctx, "headers")
|
|
3997
|
+
if (headersError instanceof Promise) {
|
|
3998
|
+
return headersError.then(
|
|
3999
|
+
(error) =>
|
|
4000
|
+
error === undefined
|
|
4001
|
+
? this.runLifecycleAfterHeaders(entry, source, ctx, finalize, wrapResponse)
|
|
4002
|
+
: wrapResponse(error),
|
|
4003
|
+
(err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
|
|
4004
|
+
)
|
|
4005
|
+
}
|
|
4006
|
+
return headersError === undefined
|
|
4007
|
+
? this.runLifecycleAfterHeaders(entry, source, ctx, finalize, wrapResponse)
|
|
4008
|
+
: wrapResponse(headersError)
|
|
4009
|
+
} catch (err) {
|
|
4010
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
|
|
4011
|
+
}
|
|
4012
|
+
}
|
|
4013
|
+
|
|
4014
|
+
private runLifecycleAfterHeaders<T>(
|
|
4015
|
+
entry: RouteEntry,
|
|
4016
|
+
source: RequestSource,
|
|
4017
|
+
ctx: RawContext,
|
|
4018
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
4019
|
+
wrapResponse: (response: Response) => T,
|
|
4020
|
+
): MaybePromise<T> {
|
|
4021
|
+
const paramsSchema = entry.schema?.params
|
|
4022
|
+
if (paramsSchema === undefined) {
|
|
4023
|
+
if (entry.schema?.body === undefined)
|
|
4024
|
+
return this.runQueryAndLifecycle(entry, ctx, finalize, wrapResponse)
|
|
4025
|
+
return this.runLifecycleAfterParams(entry, source, ctx, finalize, wrapResponse, undefined)
|
|
4026
|
+
}
|
|
4027
|
+
const validation = paramsSchema["~standard"].validate(ctx.params)
|
|
4028
|
+
if (validation instanceof Promise) {
|
|
4029
|
+
return validation.then(
|
|
4030
|
+
(result) =>
|
|
4031
|
+
this.runLifecycleAfterParamsResult(entry, source, ctx, finalize, wrapResponse, result),
|
|
4032
|
+
(err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
|
|
4033
|
+
)
|
|
4034
|
+
}
|
|
4035
|
+
return this.runLifecycleAfterParamsResult(
|
|
4036
|
+
entry,
|
|
4037
|
+
source,
|
|
4038
|
+
ctx,
|
|
4039
|
+
finalize,
|
|
4040
|
+
wrapResponse,
|
|
4041
|
+
validation,
|
|
4042
|
+
)
|
|
4043
|
+
}
|
|
4044
|
+
|
|
3743
4045
|
/** Registration-specialized query lifecycle: query validation (including recovery) → hooks. */
|
|
3744
4046
|
private runLifecycleQuery<T>(
|
|
3745
4047
|
entry: RouteEntry,
|
|
@@ -3772,6 +4074,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3772
4074
|
}
|
|
3773
4075
|
|
|
3774
4076
|
/** Registration-specialized body lifecycle: bounded body validation (including recovery) → hooks. */
|
|
4077
|
+
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
4078
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
|
|
3775
4079
|
private runLifecycleBody<T>(
|
|
3776
4080
|
entry: RouteEntry,
|
|
3777
4081
|
source: RequestSource,
|
|
@@ -3789,6 +4093,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3789
4093
|
}
|
|
3790
4094
|
|
|
3791
4095
|
/** Registration-specialized body + query lifecycle: bounded body validation → query validation → hooks. */
|
|
4096
|
+
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
4097
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
|
|
3792
4098
|
private runLifecycleBodyQuery<T>(
|
|
3793
4099
|
entry: RouteEntry,
|
|
3794
4100
|
source: RequestSource,
|
|
@@ -4070,8 +4376,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4070
4376
|
wrapResponse: (response: Response) => T,
|
|
4071
4377
|
): Promise<T> {
|
|
4072
4378
|
try {
|
|
4073
|
-
|
|
4074
|
-
|
|
4379
|
+
// Indexed, not `for...of`. An array iterator declared inside an async function stays live
|
|
4380
|
+
// across the `await` below, so neither engine can sink it - unlike the sync lanes above,
|
|
4381
|
+
// where V8 elides it. Measured cost of the iterator form: 43ns per hook on Bun, ~9ns on
|
|
4382
|
+
// Node, on a chain whose hooks return synchronously (the common shape).
|
|
4383
|
+
for (let i = 0; i < entry.beforeHandle.length; i++) {
|
|
4384
|
+
const outcome = entry.beforeHandle[i]!(ctx)
|
|
4075
4385
|
const early = outcome instanceof Promise ? await outcome : outcome
|
|
4076
4386
|
if (early !== undefined) return finalize(early, responseSet(ctx))
|
|
4077
4387
|
}
|
|
@@ -4231,8 +4541,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4231
4541
|
// any handler or loader.
|
|
4232
4542
|
if (err instanceof Response) return wrapResponse(err)
|
|
4233
4543
|
// onError hooks may return a custom response; otherwise the default 500 stands.
|
|
4234
|
-
for
|
|
4235
|
-
|
|
4544
|
+
// Indexed for the same reason as the `beforeHandle` chain above: the iterator would outlive
|
|
4545
|
+
// the `await` and stay heap-allocated on both engines.
|
|
4546
|
+
for (let i = 0; i < entry.onError.length; i++) {
|
|
4547
|
+
const outcome = entry.onError[i]!(err, ctx)
|
|
4236
4548
|
const handled = outcome instanceof Promise ? await outcome : outcome
|
|
4237
4549
|
if (handled !== undefined) return finalize(handled, responseSet(ctx))
|
|
4238
4550
|
}
|
|
@@ -4245,6 +4557,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4245
4557
|
/** Log an unhandled request error to the (redacting) logger - shared by {@link runLifecycle} and the
|
|
4246
4558
|
* bare fast path ({@link bareError}) so both record the same fields. Never throws; never leaks. */
|
|
4247
4559
|
private logRequestError(err: unknown, ctx: RawContext): void {
|
|
4560
|
+
// An error's own text can quote the input that produced it, so how much of it reaches the sink is
|
|
4561
|
+
// the app's call (`errorLogDetail`). The default keeps it: a 500 with no message and no stack is
|
|
4562
|
+
// an incident nobody can diagnose, and the framework already ships the narrower instrument for
|
|
4563
|
+
// the leak - a redacting logger with `valuePatterns`. `"none"` is there for an untrusted sink.
|
|
4564
|
+
const detail = this.errorLogDetail
|
|
4248
4565
|
this.logger.error("unhandled request error", {
|
|
4249
4566
|
method: ctx.req.method,
|
|
4250
4567
|
path: pathnameOf(ctx.req.url),
|
|
@@ -4252,8 +4569,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4252
4569
|
// `detail`, not `message`: the logger uses `message` for its own first argument, so a field of
|
|
4253
4570
|
// that name is silently overwritten and the thrown error's own text never reaches the sink. It
|
|
4254
4571
|
// survived only incidentally inside `stack`, and was lost outright for a non-Error throw.
|
|
4255
|
-
detail: err instanceof Error ? err.message : String(err),
|
|
4256
|
-
|
|
4572
|
+
...(detail === "none" ? {} : { detail: err instanceof Error ? err.message : String(err) }),
|
|
4573
|
+
...(detail === "full" && err instanceof Error ? { stack: err.stack } : {}),
|
|
4257
4574
|
})
|
|
4258
4575
|
}
|
|
4259
4576
|
|
|
@@ -4265,11 +4582,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4265
4582
|
const contentType = headerOf(req, "content-type") ?? ""
|
|
4266
4583
|
let parsed: unknown
|
|
4267
4584
|
if (contentType === "application/json" || contentType.includes("application/json")) {
|
|
4268
|
-
const json = await this.readBoundedJson(req)
|
|
4585
|
+
const json = await this.readBoundedJson(req, entry.bodyLimit ?? this.maxBodyBytes)
|
|
4269
4586
|
if (json instanceof Response) return json
|
|
4270
4587
|
parsed = json
|
|
4271
4588
|
} else if (isUrlEncodedForm(contentType)) {
|
|
4272
|
-
const form = await readBoundedForm(req, this.maxBodyBytes)
|
|
4589
|
+
const form = await readBoundedForm(req, entry.bodyLimit ?? this.maxBodyBytes)
|
|
4273
4590
|
if (form instanceof Response) return form
|
|
4274
4591
|
parsed = form
|
|
4275
4592
|
} else {
|
|
@@ -4288,11 +4605,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4288
4605
|
entry: RouteEntry,
|
|
4289
4606
|
result: StandardResult<unknown>,
|
|
4290
4607
|
ctx: RawContext,
|
|
4291
|
-
kind: "body" | "query" | "params",
|
|
4608
|
+
kind: "body" | "query" | "params" | "headers",
|
|
4292
4609
|
): MaybePromise<Response | undefined> {
|
|
4293
4610
|
const assign = (value: unknown): void => {
|
|
4294
4611
|
if (kind === "body") ctx.body = value
|
|
4295
4612
|
else if (kind === "query") ctx.query = value
|
|
4613
|
+
else if (kind === "headers") ctx.headers = value as Record<string, string>
|
|
4296
4614
|
else ctx.params = value as Record<string, string>
|
|
4297
4615
|
}
|
|
4298
4616
|
if (result.issues === undefined) {
|
|
@@ -4312,7 +4630,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4312
4630
|
|
|
4313
4631
|
private finishLifecycleValidationRecovery(
|
|
4314
4632
|
entry: RouteEntry,
|
|
4315
|
-
kind: "body" | "query" | "params",
|
|
4633
|
+
kind: "body" | "query" | "params" | "headers",
|
|
4316
4634
|
issues: ReadonlyArray<StandardIssue>,
|
|
4317
4635
|
recovery: unknown,
|
|
4318
4636
|
assign: (value: unknown) => void,
|
|
@@ -4324,7 +4642,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4324
4642
|
? entry.schema?.body
|
|
4325
4643
|
: kind === "query"
|
|
4326
4644
|
? entry.schema?.query
|
|
4327
|
-
:
|
|
4645
|
+
: kind === "params"
|
|
4646
|
+
? entry.schema?.params
|
|
4647
|
+
: entry.schema?.headers
|
|
4328
4648
|
const retried = schema!["~standard"].validate(recovery)
|
|
4329
4649
|
if (retried instanceof Promise) {
|
|
4330
4650
|
return retried.then((settled) => {
|
|
@@ -4339,21 +4659,30 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4339
4659
|
}
|
|
4340
4660
|
|
|
4341
4661
|
/**
|
|
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.
|
|
4662
|
+
* Read + parse the JSON body, capped at `maxBodyBytes` and screened by the server's
|
|
4663
|
+
* `protoPoisoning` policy. Rejects a `Content-Length` over the cap *before* buffering,
|
|
4664
|
+
* re-checks what was actually delivered after the read, and aborts a chunked or
|
|
4665
|
+
* length-less body mid-stream once the running byte count exceeds the cap - so a lying
|
|
4666
|
+
* or absent length can't force us to buffer an oversized payload.
|
|
4354
4667
|
*/
|
|
4355
|
-
private
|
|
4356
|
-
|
|
4668
|
+
private readBoundedJson(req: RequestSource, maxBodyBytes?: number): Promise<unknown | Response>
|
|
4669
|
+
private readBoundedJson<T>(
|
|
4670
|
+
req: RequestSource,
|
|
4671
|
+
maxBodyBytes: number,
|
|
4672
|
+
onResult: (parsed: unknown | Response) => T | Promise<T>,
|
|
4673
|
+
onError: (error: unknown) => T | Promise<T>,
|
|
4674
|
+
): Promise<T>
|
|
4675
|
+
private readBoundedJson<T>(
|
|
4676
|
+
req: RequestSource,
|
|
4677
|
+
maxBodyBytes = this.maxBodyBytes,
|
|
4678
|
+
onResult?: (parsed: unknown | Response) => T | Promise<T>,
|
|
4679
|
+
onError?: (error: unknown) => T | Promise<T>,
|
|
4680
|
+
): Promise<unknown | Response | T> {
|
|
4681
|
+
// Not `async`: this is a pass-through, and wrapping the lane's own promise in a coroutine
|
|
4682
|
+
// frame costs an extra microtask hop on every JSON body.
|
|
4683
|
+
return onResult === undefined
|
|
4684
|
+
? readBoundedJsonSource(req, maxBodyBytes, this.protoPoisoning)
|
|
4685
|
+
: readBoundedJsonSource(req, maxBodyBytes, this.protoPoisoning, onResult, onError!)
|
|
4357
4686
|
}
|
|
4358
4687
|
|
|
4359
4688
|
/** Adapt one route's compiled execution plan to Bun's already-matched request shape. Route
|
|
@@ -4365,25 +4694,51 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4365
4694
|
signal: AbortSignal | undefined,
|
|
4366
4695
|
budget: RequestBudget | undefined,
|
|
4367
4696
|
): BunNativeHandler {
|
|
4697
|
+
// This callback is reached only from Bun's compiled native route table. Bun has already parsed
|
|
4698
|
+
// the HTTP framing, so the JSON lane may retain its native fused `json()` parse without weakening
|
|
4699
|
+
// the portable/adapted source contract (those sources are never marked).
|
|
4700
|
+
const markFramed = (request: Request): void => markTrustedBodyFraming(request)
|
|
4701
|
+
// The fused native lane bypasses `runMatched`, so the route's transport byte cap must be
|
|
4702
|
+
// marked here too - otherwise a Bun `listen()` fused route would leave direct `c.req` body
|
|
4703
|
+
// reads uncapped. The non-fused branches go through `fetchMatched` -> `runMatched`, which marks.
|
|
4704
|
+
const bodyLimit = entry.bodyLimit
|
|
4705
|
+
const inner = fused
|
|
4706
|
+
const capped: FusedWebRunner | undefined =
|
|
4707
|
+
inner === undefined || bodyLimit === undefined
|
|
4708
|
+
? inner
|
|
4709
|
+
: (source, params, search, fusedSignal, fusedBudget, platform, nativeContext) => {
|
|
4710
|
+
const method = source.method
|
|
4711
|
+
if (method !== "GET" && method !== "HEAD") markTransportCap(source, bodyLimit)
|
|
4712
|
+
return inner(source, params, search, fusedSignal, fusedBudget, platform, nativeContext)
|
|
4713
|
+
}
|
|
4368
4714
|
if (paramNames.length === 0) {
|
|
4369
|
-
if (
|
|
4370
|
-
return (request) =>
|
|
4715
|
+
if (capped === undefined) {
|
|
4716
|
+
return (request) => {
|
|
4717
|
+
markFramed(request)
|
|
4718
|
+
return this.fetchMatched(request, entry, EMPTY_PARAMS)
|
|
4719
|
+
}
|
|
4371
4720
|
}
|
|
4372
4721
|
if (this.acceptInboundDeadlines) {
|
|
4373
|
-
return (request) =>
|
|
4374
|
-
request
|
|
4722
|
+
return (request) => {
|
|
4723
|
+
markFramed(request)
|
|
4724
|
+
return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
|
|
4375
4725
|
? this.fetchMatched(request, entry, EMPTY_PARAMS)
|
|
4376
|
-
:
|
|
4726
|
+
: capped(request, EMPTY_PARAMS, undefined, signal!, budget!, undefined, true)
|
|
4727
|
+
}
|
|
4728
|
+
}
|
|
4729
|
+
return (request) => {
|
|
4730
|
+
markFramed(request)
|
|
4731
|
+
return capped(request, EMPTY_PARAMS, undefined, signal!, budget!, undefined, true)
|
|
4377
4732
|
}
|
|
4378
|
-
return (request) => fused(request, EMPTY_PARAMS, undefined, signal!, budget!, undefined, true)
|
|
4379
4733
|
}
|
|
4380
4734
|
|
|
4381
4735
|
const malformed =
|
|
4382
4736
|
paramNames.length === 1
|
|
4383
4737
|
? (params: Record<string, string>) => params[paramNames[0]!]?.includes("\uFFFD") === true
|
|
4384
4738
|
: hasReplacementParam
|
|
4385
|
-
if (
|
|
4739
|
+
if (capped === undefined) {
|
|
4386
4740
|
return (request) => {
|
|
4741
|
+
markFramed(request)
|
|
4387
4742
|
const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
|
|
4388
4743
|
if (malformed(params)) return this.fetchSource(request)
|
|
4389
4744
|
return this.fetchMatched(request, entry, params)
|
|
@@ -4391,17 +4746,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4391
4746
|
}
|
|
4392
4747
|
if (this.acceptInboundDeadlines) {
|
|
4393
4748
|
return (request) => {
|
|
4749
|
+
markFramed(request)
|
|
4394
4750
|
const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
|
|
4395
4751
|
if (malformed(params)) return this.fetchSource(request)
|
|
4396
4752
|
return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
|
|
4397
4753
|
? this.fetchMatched(request, entry, params)
|
|
4398
|
-
:
|
|
4754
|
+
: capped(request, params, undefined, signal!, budget!, undefined, true)
|
|
4399
4755
|
}
|
|
4400
4756
|
}
|
|
4401
4757
|
return (request) => {
|
|
4758
|
+
markFramed(request)
|
|
4402
4759
|
const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
|
|
4403
4760
|
if (malformed(params)) return this.fetchSource(request)
|
|
4404
|
-
return
|
|
4761
|
+
return capped(request, params, undefined, signal!, budget!, undefined, true)
|
|
4405
4762
|
}
|
|
4406
4763
|
}
|
|
4407
4764
|
|
|
@@ -4476,10 +4833,21 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4476
4833
|
* `"127.0.0.1"` to bind loopback only - an admin surface, a sidecar, or anything that must not be
|
|
4477
4834
|
* reachable off the box. Omitting it when you meant to restrict is a real exposure, so it is a
|
|
4478
4835
|
* first-class option rather than something a caller has to drop down to `Bun.serve` for.
|
|
4836
|
+
*
|
|
4837
|
+
* `idleTimeoutSec` is how long Bun tolerates a connection with no bytes moving before it closes
|
|
4838
|
+
* the socket. Bun's default is **10 seconds**, and a request whose handler is still working sends
|
|
4839
|
+
* nothing - so any endpoint slower than 10s (a render, an export, a long upstream call) has its
|
|
4840
|
+
* connection cut mid-flight regardless of `requestTimeoutMs`. Apps with such endpoints must raise
|
|
4841
|
+
* this above their slowest expected response, which is why it is a first-class option and not a
|
|
4842
|
+
* reason to drop down to `Bun.serve`. `0` disables the timeout entirely; max 255.
|
|
4479
4843
|
*/
|
|
4480
4844
|
listen(
|
|
4481
4845
|
port: number,
|
|
4482
|
-
options?: {
|
|
4846
|
+
options?: {
|
|
4847
|
+
readonly reusePort?: boolean
|
|
4848
|
+
readonly hostname?: string
|
|
4849
|
+
readonly idleTimeoutSec?: number
|
|
4850
|
+
},
|
|
4483
4851
|
): RunningServer {
|
|
4484
4852
|
if (typeof Bun === "undefined") {
|
|
4485
4853
|
// listen() is the one Bun-specific seam. Off Bun, fail loud + actionable rather
|
|
@@ -4509,12 +4877,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4509
4877
|
// Spread rather than pass `hostname: undefined` - Bun treats an explicit undefined as a value
|
|
4510
4878
|
// on some option paths, and omitting is what selects its 0.0.0.0 default.
|
|
4511
4879
|
const bind = options?.hostname === undefined ? {} : { hostname: options.hostname }
|
|
4880
|
+
// Same reasoning as `bind`: omit rather than pass undefined, so Bun's own default applies.
|
|
4881
|
+
const idle =
|
|
4882
|
+
options?.idleTimeoutSec === undefined ? {} : { idleTimeout: options.idleTimeoutSec }
|
|
4512
4883
|
const nativeRoutes = wsHandlers === undefined ? this.buildBunNativeRoutes() : undefined
|
|
4513
4884
|
const running = (wsHandlers === undefined
|
|
4514
4885
|
? Bun.serve({
|
|
4515
4886
|
port,
|
|
4516
4887
|
reusePort,
|
|
4517
4888
|
...bind,
|
|
4889
|
+
...idle,
|
|
4518
4890
|
...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
|
|
4519
4891
|
fetch: (req: Request, server) =>
|
|
4520
4892
|
this.fetch(req, bunPeerPlatform(server, req) as Platform<EnvOf<Ctx>>),
|
|
@@ -4523,6 +4895,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4523
4895
|
port,
|
|
4524
4896
|
reusePort,
|
|
4525
4897
|
...bind,
|
|
4898
|
+
...idle,
|
|
4526
4899
|
fetch: (req, server) => this.bunFetchWithWebSocket(req, server),
|
|
4527
4900
|
// Bun's `ServerWebSocket<BunWsData>` is runtime-compatible with the handlers' structural
|
|
4528
4901
|
// `BunSocket` view (kept local so `Bun.*` types never leak into the published .d.ts); the
|
|
@@ -4562,6 +4935,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
4562
4935
|
await Bun.sleep(DRAIN_POLL_MS)
|
|
4563
4936
|
}
|
|
4564
4937
|
server.stop(server.pendingRequests > 0) // force-close iff stragglers remain past the deadline
|
|
4938
|
+
if (this.stopHooks.length === 0) return
|
|
4939
|
+
const callbacks = this.stopHooks.map((hook) => Promise.resolve().then(hook))
|
|
4940
|
+
const settled = Promise.allSettled(callbacks)
|
|
4941
|
+
await Promise.race([
|
|
4942
|
+
settled,
|
|
4943
|
+
new Promise<void>((resolve) => setTimeout(resolve, STOP_HOOK_TIMEOUT_MS)),
|
|
4944
|
+
])
|
|
4565
4945
|
}
|
|
4566
4946
|
|
|
4567
4947
|
private installSignalHandlers(): void {
|