@nifrajs/core 2.11.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/dist/server/server.js
CHANGED
|
@@ -3,69 +3,23 @@ import { FrameworkError, RouteConfigError } from "../errors.js";
|
|
|
3
3
|
import { CAPABILITY_GUARD, createCapabilityGuard, DEFAULT_CAPABILITY_INTERCEPTOR_TIMEOUT_MS, normalizeRouteCapabilities, } from "../internal/capability-runtime.js";
|
|
4
4
|
import { assuranceDeclarationsOf, assuranceEvidenceFor, NIFRA_ASSURANCE_IDS, validEvidenceId, } from "../internal/route-assurance.js";
|
|
5
5
|
import { RouteCatalog } from "../internal/route-catalog.js";
|
|
6
|
+
import { compileRouteExecutionPlan, } from "../internal/route-execution.js";
|
|
6
7
|
import { isSameOriginRequest } from "../internal/same-origin.js";
|
|
7
8
|
import { compileRoutePattern, decodeRouteParams } from "../router/pattern.js";
|
|
8
9
|
import { EMPTY_PARAMS, Router } from "../router/router.js";
|
|
9
|
-
import { assertByteLimit,
|
|
10
|
+
import { assertByteLimit, markTransportCap, markTrustedBodyFraming } from "./body.js";
|
|
10
11
|
import { resolveClientIp } from "./client-ip.js";
|
|
12
|
+
import { hasLowercaseHeaderKeysMark, headerKeysAllLowercase, markLowercaseHeaderKeys, } from "./header-case.js";
|
|
13
|
+
import { headerObjectOf } from "./headers.js";
|
|
11
14
|
import { jsonError, pathnameOf, urlPartsOf } from "./http.js";
|
|
12
15
|
import { withStaticNodeHeaders } from "./node-outcome.js";
|
|
13
16
|
import { recordHeadersView, } from "./node-outcome-hook.js";
|
|
14
17
|
import { isUrlEncodedForm, queryObjectOf, readBoundedForm, searchOf, } from "./query.js";
|
|
15
18
|
import { RequestContext, readBoundedJsonSource } from "./request-context.js";
|
|
16
|
-
import { applyStaticResponseHeaders, buildStaticResponseHeaders, fusedRespond, fusedRespondNoSet, knownMutableHeaders,
|
|
19
|
+
import { applyStaticResponseHeaders, buildStaticResponseHeaders, fusedRespond, fusedRespondNoSet, knownMutableHeaders, rememberMutableHeaders, taggedResponseBody, toResponse, } from "./respond.js";
|
|
20
|
+
import { applyBodyReplacement, withReplacedBody } from "./response-hooks.js";
|
|
17
21
|
import { CONTEXT_SEARCH, CONTEXT_SET, EMPTY_RESPONSE_CONTROLS, getNeverAbortSignal, getUnboundedRequestBudget, headerOf, requestOf, } from "./runtime-core.js";
|
|
18
22
|
import { normalizeStaticResponseHeaders } from "./static-headers.js";
|
|
19
|
-
function isBodylessStatus(status) {
|
|
20
|
-
return status === 204 || status === 205 || status === 304;
|
|
21
|
-
}
|
|
22
|
-
/** Apply a body hook's return to the native response context (bytes, or a structured replacement). */
|
|
23
|
-
function applyBodyReplacement(response, replaced) {
|
|
24
|
-
if (replaced === undefined)
|
|
25
|
-
return;
|
|
26
|
-
if (typeof replaced === "string" || replaced instanceof Uint8Array) {
|
|
27
|
-
response.body = isBodylessStatus(response.status) ? null : replaced;
|
|
28
|
-
return;
|
|
29
|
-
}
|
|
30
|
-
if (replaced.body !== undefined)
|
|
31
|
-
response.body = replaced.body;
|
|
32
|
-
if (replaced.status !== undefined)
|
|
33
|
-
response.status = replaced.status;
|
|
34
|
-
if (isBodylessStatus(response.status))
|
|
35
|
-
response.body = null;
|
|
36
|
-
}
|
|
37
|
-
/** Swap a tagged Response's body for a hook's replacement, re-tagging so later body hooks (and the
|
|
38
|
-
* Node fallback's direct writer) see the new bytes; explicit lengths are dropped so framing is
|
|
39
|
-
* re-derived from what actually ships. */
|
|
40
|
-
function withReplacedBody(response, replaced) {
|
|
41
|
-
if (replaced === undefined)
|
|
42
|
-
return response;
|
|
43
|
-
const originalBody = taggedResponseBody(response);
|
|
44
|
-
let body;
|
|
45
|
-
let status = response.status;
|
|
46
|
-
if (typeof replaced === "string" || replaced instanceof Uint8Array) {
|
|
47
|
-
body = replaced;
|
|
48
|
-
}
|
|
49
|
-
else {
|
|
50
|
-
body = replaced.body !== undefined ? replaced.body : (originalBody ?? null);
|
|
51
|
-
if (replaced.status !== undefined)
|
|
52
|
-
status = replaced.status;
|
|
53
|
-
if (body === (originalBody ?? null) && status === response.status) {
|
|
54
|
-
return response;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
if (isBodylessStatus(status))
|
|
58
|
-
body = null;
|
|
59
|
-
const headers = new Headers(response.headers);
|
|
60
|
-
headers.delete("content-length");
|
|
61
|
-
const next = new Response(body, {
|
|
62
|
-
status,
|
|
63
|
-
headers,
|
|
64
|
-
});
|
|
65
|
-
if (body !== null)
|
|
66
|
-
markTaggedResponse(next, body, taggedResponseOwner(response));
|
|
67
|
-
return next;
|
|
68
|
-
}
|
|
69
23
|
const HEADER_MUTABILITY_PROBE = "x-nifra-header-probe";
|
|
70
24
|
const GUARDED_RESPONSE_HEADERS = new WeakSet();
|
|
71
25
|
/**
|
|
@@ -199,10 +153,11 @@ function requireMcpRuntime(runtime) {
|
|
|
199
153
|
}
|
|
200
154
|
// Plugin definers + their types now live in `./plugin.ts`; re-exported here so `.use()` callers and
|
|
201
155
|
// existing importers keep resolving them from the server module.
|
|
202
|
-
export { defineIdentityPlugin, definePlugin, defineRouterPlugin, } from "./plugin.js";
|
|
156
|
+
export { defineContextPlugin, defineIdentityPlugin, definePlugin, defineRouterPlugin, } from "./plugin.js";
|
|
203
157
|
const DEFAULT_MAX_BODY_BYTES = 1_000_000;
|
|
204
158
|
const DEFAULT_DRAIN_MS = 10_000;
|
|
205
159
|
const DRAIN_POLL_MS = 10;
|
|
160
|
+
const STOP_HOOK_TIMEOUT_MS = 5_000;
|
|
206
161
|
/** Same-origin check for a WebSocket handshake (CSWSH default). {@link isSameOriginRequest} is the one
|
|
207
162
|
* owner, shared with the server-function mount in `@nifrajs/web` - the two used to answer differently
|
|
208
163
|
* for the same request, so a browser that could open a socket was told its POST was cross-origin. */
|
|
@@ -227,6 +182,31 @@ function hasReplacementParam(params) {
|
|
|
227
182
|
}
|
|
228
183
|
return false;
|
|
229
184
|
}
|
|
185
|
+
function normalizeMountPrefix(path) {
|
|
186
|
+
if (!path.startsWith("/") || path.includes("?") || path.includes("#")) {
|
|
187
|
+
throw new TypeError("mountFetch path must be an absolute pathname without a query or hash");
|
|
188
|
+
}
|
|
189
|
+
const withoutWildcard = path.endsWith("/*") ? path.slice(0, -2) : path;
|
|
190
|
+
if (withoutWildcard.includes("*") || withoutWildcard.includes(":")) {
|
|
191
|
+
throw new TypeError("mountFetch path must be a literal prefix, optionally ending in /*");
|
|
192
|
+
}
|
|
193
|
+
if (withoutWildcard.length === 0)
|
|
194
|
+
return "/";
|
|
195
|
+
return withoutWildcard.length > 1 && withoutWildcard.endsWith("/")
|
|
196
|
+
? withoutWildcard.slice(0, -1)
|
|
197
|
+
: withoutWildcard;
|
|
198
|
+
}
|
|
199
|
+
function underMountPrefix(pathname, prefix) {
|
|
200
|
+
return prefix === "/" || pathname === prefix || pathname.startsWith(`${prefix}/`);
|
|
201
|
+
}
|
|
202
|
+
function stripMountPrefix(request, prefix) {
|
|
203
|
+
if (prefix === "/")
|
|
204
|
+
return request;
|
|
205
|
+
const url = new URL(request.url);
|
|
206
|
+
const rest = url.pathname.slice(prefix.length);
|
|
207
|
+
url.pathname = rest === "" ? "/" : rest;
|
|
208
|
+
return new Request(url.href, request);
|
|
209
|
+
}
|
|
230
210
|
function responseSet(ctx) {
|
|
231
211
|
return ctx[CONTEXT_SET]() ?? EMPTY_RESPONSE_CONTROLS;
|
|
232
212
|
}
|
|
@@ -261,6 +241,9 @@ export class Server {
|
|
|
261
241
|
* no-WebSocket app never constructs (or bundles) it. */
|
|
262
242
|
topics;
|
|
263
243
|
maxBodyBytes;
|
|
244
|
+
protoPoisoning;
|
|
245
|
+
/** `trustBodyFraming`: mark every `app.fetch` request as runtime-framed (see ServerOptions). */
|
|
246
|
+
trustBodyFraming;
|
|
264
247
|
wsMaxPayloadBytes;
|
|
265
248
|
requestTimeoutMs;
|
|
266
249
|
/** Installed by the `responseContract()` plugin; `undefined` = not installed, which is the default
|
|
@@ -272,6 +255,8 @@ export class Server {
|
|
|
272
255
|
maxInboundDeadlineMs;
|
|
273
256
|
deadlineAdmissionOptions;
|
|
274
257
|
gracefulSignals;
|
|
258
|
+
stopHooks;
|
|
259
|
+
fetchMounts;
|
|
275
260
|
/** Capacity-admission gate; `undefined` = off (the request path pays nothing). */
|
|
276
261
|
capacityGate;
|
|
277
262
|
onCapabilityUse;
|
|
@@ -292,6 +277,8 @@ export class Server {
|
|
|
292
277
|
/** Installed WebSocket runtime for `.ws()` routes; `undefined` until `.use(websocket())`. */
|
|
293
278
|
wsRuntime;
|
|
294
279
|
logger;
|
|
280
|
+
/** How much of an unhandled error {@link logRequestError} records. See `ServerOptions.errorLogDetail`. */
|
|
281
|
+
errorLogDetail;
|
|
295
282
|
/** App-wide validation-error fallback; a route's own `schema.onValidationError` takes precedence. */
|
|
296
283
|
defaultOnValidationError;
|
|
297
284
|
bunServer;
|
|
@@ -310,6 +297,13 @@ export class Server {
|
|
|
310
297
|
onNodeResponseHooks;
|
|
311
298
|
/** Registration-time eligibility for the Node response twin lane. */
|
|
312
299
|
nodeResponseHooksComplete;
|
|
300
|
+
/**
|
|
301
|
+
* True once a bundle registers a RAW `onNodeResponse` twin - one handed the outcome's header
|
|
302
|
+
* record itself rather than the case-normalizing view. Their names are contractually the wire
|
|
303
|
+
* spelling, but registration cannot PROVE it, so such an app forgoes the all-lowercase mark and
|
|
304
|
+
* every reader re-derives the answer, exactly as before the mark existed.
|
|
305
|
+
*/
|
|
306
|
+
hasRawNodeResponseHook;
|
|
313
307
|
onResponseFinalizedHooks;
|
|
314
308
|
/**
|
|
315
309
|
* Statically declared response headers, merged and prebuilt at registration; `undefined` (the
|
|
@@ -347,6 +341,8 @@ export class Server {
|
|
|
347
341
|
this.topics = undefined;
|
|
348
342
|
const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
349
343
|
assertByteLimit(maxBodyBytes, "maxBodyBytes");
|
|
344
|
+
this.protoPoisoning = options.protoPoisoning ?? "reject";
|
|
345
|
+
this.trustBodyFraming = options.trustBodyFraming ?? false;
|
|
350
346
|
const wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? maxBodyBytes;
|
|
351
347
|
assertByteLimit(wsMaxPayloadBytes, "wsMaxPayloadBytes");
|
|
352
348
|
this.maxBodyBytes = maxBodyBytes;
|
|
@@ -366,6 +362,8 @@ export class Server {
|
|
|
366
362
|
throw new RangeError("maxInboundDeadlineMs must be a finite positive number");
|
|
367
363
|
}
|
|
368
364
|
this.gracefulSignals = options.gracefulSignals ?? false;
|
|
365
|
+
this.stopHooks = [];
|
|
366
|
+
this.fetchMounts = [];
|
|
369
367
|
this.capacityGate = options.admission;
|
|
370
368
|
this.onCapabilityUse = options.onCapabilityUse;
|
|
371
369
|
this.capabilityInterceptors = [];
|
|
@@ -378,6 +376,7 @@ export class Server {
|
|
|
378
376
|
this.responseContractRuntime = undefined;
|
|
379
377
|
this.idempotencyRuntime = undefined;
|
|
380
378
|
this.logger = options.logger ?? jsonLogger();
|
|
379
|
+
this.errorLogDetail = options.errorLogDetail ?? "full";
|
|
381
380
|
this.defaultOnValidationError = options.onValidationError;
|
|
382
381
|
this.bunServer = undefined;
|
|
383
382
|
this.sealed = false;
|
|
@@ -393,6 +392,7 @@ export class Server {
|
|
|
393
392
|
this.onResponseHooks = [];
|
|
394
393
|
this.onNodeResponseHooks = [];
|
|
395
394
|
this.nodeResponseHooksComplete = true;
|
|
395
|
+
this.hasRawNodeResponseHook = false;
|
|
396
396
|
this.onResponseFinalizedHooks = [];
|
|
397
397
|
this.staticResponseHeaders = undefined;
|
|
398
398
|
this.wrapWebResponse = IDENTITY_RESPONSE;
|
|
@@ -413,6 +413,32 @@ export class Server {
|
|
|
413
413
|
throw new FrameworkError("SERVER_SEALED", `server configuration is sealed after listen(); call ${operation} before listen()`);
|
|
414
414
|
}
|
|
415
415
|
}
|
|
416
|
+
/** Register a callback awaited after graceful drain and server stop. Hung callbacks are bounded. */
|
|
417
|
+
onStop(fn) {
|
|
418
|
+
this.assertConfigurable("onStop()");
|
|
419
|
+
if (typeof fn !== "function")
|
|
420
|
+
throw new TypeError("onStop callback must be a function");
|
|
421
|
+
this.stopHooks.push(fn);
|
|
422
|
+
return this;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Mount a legacy fetch handler under a literal path prefix. Mounted handlers sit behind typed
|
|
426
|
+
* routes, so routes can move one at a time while the remaining legacy surface stays live.
|
|
427
|
+
* Mounted responses are outside Nifra's typed route and response-contract checks.
|
|
428
|
+
*/
|
|
429
|
+
mountFetch(path, handler, options = {}) {
|
|
430
|
+
this.assertConfigurable("mountFetch()");
|
|
431
|
+
if (typeof handler !== "function")
|
|
432
|
+
throw new TypeError("mountFetch handler must be a function");
|
|
433
|
+
const mount = {
|
|
434
|
+
path: normalizeMountPrefix(path),
|
|
435
|
+
handler: handler,
|
|
436
|
+
stripPrefix: options.stripPrefix === true,
|
|
437
|
+
};
|
|
438
|
+
this.fetchMounts.push(mount);
|
|
439
|
+
this.fetchMounts.sort((a, b) => b.path.length - a.path.length);
|
|
440
|
+
return this;
|
|
441
|
+
}
|
|
416
442
|
/** Add a per-request, computed context extension for subsequent routes. */
|
|
417
443
|
derive(fn) {
|
|
418
444
|
this.assertConfigurable("derive()");
|
|
@@ -672,6 +698,8 @@ export class Server {
|
|
|
672
698
|
this.onNodeResponseHooks.push(arg.onNodeResponse);
|
|
673
699
|
if (arg.onNodeResponse === undefined)
|
|
674
700
|
this.nodeResponseHooksComplete = false;
|
|
701
|
+
else
|
|
702
|
+
this.hasRawNodeResponseHook = true;
|
|
675
703
|
}
|
|
676
704
|
else if (arg.onNodeResponse !== undefined) {
|
|
677
705
|
throw new TypeError("onNodeResponse() requires a paired onResponse() hook");
|
|
@@ -844,6 +872,29 @@ export class Server {
|
|
|
844
872
|
}
|
|
845
873
|
prepareRoute(method, path, schema, handler) {
|
|
846
874
|
const pattern = compileRoutePattern(path);
|
|
875
|
+
let bodyLimit = this.maxBodyBytes;
|
|
876
|
+
const invalidBodyLimit = (message) => {
|
|
877
|
+
throw new RouteConfigError("INVALID_BODY_LIMIT", `route ${method} ${path}: ${message}`);
|
|
878
|
+
};
|
|
879
|
+
if (schema?.bodyLimitReason !== undefined && schema.bodyLimit !== "unlimited") {
|
|
880
|
+
invalidBodyLimit('bodyLimitReason is only valid with bodyLimit: "unlimited"');
|
|
881
|
+
}
|
|
882
|
+
if (schema?.bodyLimit === "unlimited") {
|
|
883
|
+
if (typeof schema.bodyLimitReason !== "string" ||
|
|
884
|
+
schema.bodyLimitReason.trim().length === 0) {
|
|
885
|
+
invalidBodyLimit('bodyLimit: "unlimited" requires a non-empty bodyLimitReason');
|
|
886
|
+
}
|
|
887
|
+
bodyLimit = undefined;
|
|
888
|
+
}
|
|
889
|
+
else if (schema?.bodyLimit !== undefined) {
|
|
890
|
+
try {
|
|
891
|
+
assertByteLimit(schema.bodyLimit, "route bodyLimit");
|
|
892
|
+
}
|
|
893
|
+
catch {
|
|
894
|
+
invalidBodyLimit('bodyLimit must be a non-negative safe integer or "unlimited"');
|
|
895
|
+
}
|
|
896
|
+
bodyLimit = schema.bodyLimit;
|
|
897
|
+
}
|
|
847
898
|
const capabilities = normalizeRouteCapabilities(schema?.capabilities);
|
|
848
899
|
const handlerAssurance = assuranceDeclarationsOf(handler);
|
|
849
900
|
const invalidHandlerScope = handlerAssurance.find((declaration) => declaration.scope !== "plugin");
|
|
@@ -879,6 +930,7 @@ export class Server {
|
|
|
879
930
|
? { runtime, schema: schema.response }
|
|
880
931
|
: undefined;
|
|
881
932
|
const bare = schema?.params === undefined &&
|
|
933
|
+
schema?.headers === undefined &&
|
|
882
934
|
schema?.body === undefined &&
|
|
883
935
|
schema?.query === undefined &&
|
|
884
936
|
idempotent === undefined &&
|
|
@@ -899,6 +951,7 @@ export class Server {
|
|
|
899
951
|
schema?.query !== undefined &&
|
|
900
952
|
schema.body === undefined &&
|
|
901
953
|
schema.params === undefined &&
|
|
954
|
+
schema.headers === undefined &&
|
|
902
955
|
schema.onValidationError === undefined &&
|
|
903
956
|
this.defaultOnValidationError === undefined &&
|
|
904
957
|
idempotent === undefined &&
|
|
@@ -912,6 +965,7 @@ export class Server {
|
|
|
912
965
|
schema?.body !== undefined &&
|
|
913
966
|
schema.query === undefined &&
|
|
914
967
|
schema.params === undefined &&
|
|
968
|
+
schema.headers === undefined &&
|
|
915
969
|
this.derives.length === 0 &&
|
|
916
970
|
this.beforeHandleHooks.length === 0 &&
|
|
917
971
|
this.afterHandleHooks.length === 0 &&
|
|
@@ -927,12 +981,12 @@ export class Server {
|
|
|
927
981
|
// handler continuation once. The runner keeps the bounded parser and all error semantics while
|
|
928
982
|
// avoiding the generic entry/schema/lifecycle dispatch on Bun, Deno, and Node-direct.
|
|
929
983
|
const fusedBodyRunner = fusedBody
|
|
930
|
-
? this.buildFusedBodyRunner(handler, schema.body, hasDecorations ? routeDecorations : undefined)
|
|
984
|
+
? this.buildFusedBodyRunner(handler, schema.body, hasDecorations ? routeDecorations : undefined, bodyLimit ?? this.maxBodyBytes)
|
|
931
985
|
: undefined;
|
|
932
986
|
const fusedWeb = bare && this.aroundHooks.length === 0
|
|
933
|
-
? this.buildFusedWeb(handler, hasDecorations ? routeDecorations : undefined, isContextlessNoArgArrow(handler))
|
|
987
|
+
? this.buildFusedWeb(handler, hasDecorations ? routeDecorations : undefined, isContextlessNoArgArrow(handler), bodyLimit ?? this.maxBodyBytes)
|
|
934
988
|
: fusedQuery
|
|
935
|
-
? this.buildFusedQueryWeb(handler, hasDecorations ? routeDecorations : undefined, schema.query)
|
|
989
|
+
? this.buildFusedQueryWeb(handler, hasDecorations ? routeDecorations : undefined, schema.query, bodyLimit ?? this.maxBodyBytes)
|
|
936
990
|
: fusedBody
|
|
937
991
|
? this.buildFusedBodyWeb(fusedBodyRunner)
|
|
938
992
|
: undefined;
|
|
@@ -947,6 +1001,7 @@ export class Server {
|
|
|
947
1001
|
schema?.body === undefined &&
|
|
948
1002
|
schema?.query !== undefined &&
|
|
949
1003
|
schema.params === undefined &&
|
|
1004
|
+
schema.headers === undefined &&
|
|
950
1005
|
this.derives.length === 0 &&
|
|
951
1006
|
this.beforeHandleHooks.length === 0 &&
|
|
952
1007
|
this.afterHandleHooks.length === 0 &&
|
|
@@ -957,7 +1012,7 @@ export class Server {
|
|
|
957
1012
|
// plus an optional query or body schema. Select their complete validation stage at registration
|
|
958
1013
|
// so the request path never re-checks params/body presence. Parameter-schema routes retain the
|
|
959
1014
|
// generic lifecycle runner until their more involved recovery matrix is selected explicitly.
|
|
960
|
-
const lifecycleLane = lane !== "lifecycle" || schema?.params !== undefined
|
|
1015
|
+
const lifecycleLane = lane !== "lifecycle" || schema?.params !== undefined || schema?.headers !== undefined
|
|
961
1016
|
? undefined
|
|
962
1017
|
: schema?.body !== undefined
|
|
963
1018
|
? schema.query !== undefined
|
|
@@ -979,12 +1034,22 @@ export class Server {
|
|
|
979
1034
|
this.onErrorHooks.length === 0
|
|
980
1035
|
? "derive-before"
|
|
981
1036
|
: undefined;
|
|
982
|
-
const execution =
|
|
1037
|
+
const execution = compileRouteExecutionPlan({
|
|
1038
|
+
lane,
|
|
1039
|
+
contextless,
|
|
1040
|
+
hasAround: this.aroundHooks.length > 0,
|
|
1041
|
+
hasLedger: ledgered !== undefined,
|
|
1042
|
+
lifecycleLane,
|
|
1043
|
+
fusedWeb,
|
|
1044
|
+
fusedBody: fusedBodyRunner,
|
|
1045
|
+
fusedLane: fusedWeb === undefined ? undefined : fusedQuery ? "query" : fusedBody ? "body" : "bare",
|
|
1046
|
+
});
|
|
983
1047
|
const registeredEntry = {
|
|
984
1048
|
// (context: never) => unknown -> InternalHandler: the framework invokes it
|
|
985
1049
|
// with the concrete RawContext the typed handler expects, so this is sound.
|
|
986
1050
|
handler: handler,
|
|
987
1051
|
schema,
|
|
1052
|
+
bodyLimit,
|
|
988
1053
|
idempotent,
|
|
989
1054
|
ledgered,
|
|
990
1055
|
responseContract: contracted,
|
|
@@ -1006,6 +1071,13 @@ export class Server {
|
|
|
1006
1071
|
...(schema?.family === true ? { family: true } : {}),
|
|
1007
1072
|
};
|
|
1008
1073
|
const routeAssurance = [...this.activeAssurance, ...handlerAssurance];
|
|
1074
|
+
if (contracted?.runtime.mode === "enforce" && schema?.response !== undefined) {
|
|
1075
|
+
routeAssurance.push(Object.freeze({
|
|
1076
|
+
id: NIFRA_ASSURANCE_IDS.RESPONSE_CONTRACT,
|
|
1077
|
+
source: "response-contract",
|
|
1078
|
+
scope: "plugin",
|
|
1079
|
+
}));
|
|
1080
|
+
}
|
|
1009
1081
|
// Inline `schema.assurance`: the route DECLARES its enforcement evidence adjacent to the handler, so an
|
|
1010
1082
|
// in-handler-guarded route satisfies a policy `require:` clause without a `withRouteAssurance` middleware
|
|
1011
1083
|
// rewrite. Each id becomes route-scoped `declared` evidence (invalid ids fail closed at registration).
|
|
@@ -1013,7 +1085,14 @@ export class Server {
|
|
|
1013
1085
|
if (!validEvidenceId(id)) {
|
|
1014
1086
|
throw new Error(`route assurance: invalid evidence id ${JSON.stringify(id)} on ${method} ${path} (use lowercase dot/dash segments)`);
|
|
1015
1087
|
}
|
|
1016
|
-
|
|
1088
|
+
const declared = { id, source: "declared", scope: "plugin" };
|
|
1089
|
+
Object.defineProperty(declared, "provenance", {
|
|
1090
|
+
value: "declared",
|
|
1091
|
+
enumerable: false,
|
|
1092
|
+
configurable: false,
|
|
1093
|
+
writable: false,
|
|
1094
|
+
});
|
|
1095
|
+
routeAssurance.push(Object.freeze(declared));
|
|
1017
1096
|
}
|
|
1018
1097
|
if (schema?.body !== undefined) {
|
|
1019
1098
|
routeAssurance.push(Object.freeze({
|
|
@@ -1040,67 +1119,6 @@ export class Server {
|
|
|
1040
1119
|
assurance: Object.freeze(routeAssurance),
|
|
1041
1120
|
};
|
|
1042
1121
|
}
|
|
1043
|
-
/** Collapse route-invariant lifecycle decisions into one runner at registration. The request path
|
|
1044
|
-
* performs no eligibility ladder: it supplies request state to this already-selected plan. */
|
|
1045
|
-
compileExecutionPlan(lane, contextless, hasAround, hasLedger, lifecycleLane, fusedWeb, fusedBody, fusedLane) {
|
|
1046
|
-
if (contextless) {
|
|
1047
|
-
const run = (runtime, entry, source, params, search, signal, budget, platform, _nativeContext, finalize, wrapResponse) => runtime.runContextlessBare(entry, source, params, search, signal, budget, platform, finalize, wrapResponse);
|
|
1048
|
-
return Object.freeze({ run, fusedWeb, fusedBody, fusedLane });
|
|
1049
|
-
}
|
|
1050
|
-
let inner;
|
|
1051
|
-
switch (lane) {
|
|
1052
|
-
case "bare":
|
|
1053
|
-
inner = (runtime, entry, _source, ctx, finalize, wrapResponse) => runtime.runBare(entry, ctx, finalize, wrapResponse);
|
|
1054
|
-
break;
|
|
1055
|
-
case "body":
|
|
1056
|
-
inner = (runtime, entry, source, ctx, finalize, wrapResponse) => runtime.runBodyOnly(entry, source, ctx, finalize, wrapResponse);
|
|
1057
|
-
break;
|
|
1058
|
-
case "query":
|
|
1059
|
-
inner = (runtime, entry, _source, ctx, finalize, wrapResponse) => runtime.runQueryOnly(entry, ctx, finalize, wrapResponse);
|
|
1060
|
-
break;
|
|
1061
|
-
default:
|
|
1062
|
-
switch (lifecycleLane) {
|
|
1063
|
-
case "hooks":
|
|
1064
|
-
inner = (runtime, entry, _source, ctx, finalize, wrapResponse) => runtime.runLifecycleHooks(entry, ctx, finalize, wrapResponse);
|
|
1065
|
-
break;
|
|
1066
|
-
case "query":
|
|
1067
|
-
inner = (runtime, entry, _source, ctx, finalize, wrapResponse) => runtime.runLifecycleQuery(entry, ctx, finalize, wrapResponse);
|
|
1068
|
-
break;
|
|
1069
|
-
case "body":
|
|
1070
|
-
inner = (runtime, entry, source, ctx, finalize, wrapResponse) => runtime.runLifecycleBody(entry, source, ctx, finalize, wrapResponse);
|
|
1071
|
-
break;
|
|
1072
|
-
case "body-query":
|
|
1073
|
-
inner = (runtime, entry, source, ctx, finalize, wrapResponse) => runtime.runLifecycleBodyQuery(entry, source, ctx, finalize, wrapResponse);
|
|
1074
|
-
break;
|
|
1075
|
-
default:
|
|
1076
|
-
inner = (runtime, entry, source, ctx, finalize, wrapResponse) => runtime.runLifecycle(entry, source, ctx, finalize, wrapResponse);
|
|
1077
|
-
}
|
|
1078
|
-
}
|
|
1079
|
-
const execute = hasAround
|
|
1080
|
-
? (runtime, entry, source, ctx, finalize, wrapResponse) => runtime.runWithAround(entry, ctx, () => inner(runtime, entry, source, ctx, finalize, wrapResponse), finalize, wrapResponse)
|
|
1081
|
-
: inner;
|
|
1082
|
-
const run = (runtime, entry, source, params, search, signal, budget, platform, nativeContext, finalize, wrapResponse) => {
|
|
1083
|
-
const ctx = nativeContext
|
|
1084
|
-
? RequestContext.native(source, params, search, runtime.maxBodyBytes, platform)
|
|
1085
|
-
: new RequestContext(source, params, search, signal, budget, platform, runtime.maxBodyBytes);
|
|
1086
|
-
let ledger;
|
|
1087
|
-
// The runtime is always present when a route resolved a ledger (enforced at registration).
|
|
1088
|
-
const ledgerRuntime = runtime.effectLedgerRuntime;
|
|
1089
|
-
if (hasLedger && ledgerRuntime !== undefined) {
|
|
1090
|
-
const resolved = entry.ledgered;
|
|
1091
|
-
ledger = ledgerRuntime.create(resolved);
|
|
1092
|
-
ledgerRuntime.attach(ctx, ledger);
|
|
1093
|
-
}
|
|
1094
|
-
let outcome = execute(runtime, entry, source, ctx, finalize, wrapResponse);
|
|
1095
|
-
if (ledger !== undefined && ledgerRuntime !== undefined) {
|
|
1096
|
-
const active = ledger;
|
|
1097
|
-
const resolved = entry.ledgered;
|
|
1098
|
-
outcome = (outcome instanceof Promise ? outcome : Promise.resolve(outcome)).then((value) => ledgerRuntime.settle(active, resolved, value, (fields) => runtime.logger.error("effect ledger sink failed", fields)));
|
|
1099
|
-
}
|
|
1100
|
-
return outcome;
|
|
1101
|
-
};
|
|
1102
|
-
return Object.freeze({ run, fusedWeb, fusedBody, fusedLane });
|
|
1103
|
-
}
|
|
1104
1122
|
/** The idempotency lane's bridge back into the normal matched lanes, resolved to a concrete Response
|
|
1105
1123
|
* (the lane buffers the body, then replays it through the route's real validation + handler). */
|
|
1106
1124
|
idempotencyRunLanes(buffered, platform, entry, params, search) {
|
|
@@ -1148,10 +1166,15 @@ export class Server {
|
|
|
1148
1166
|
*
|
|
1149
1167
|
* Semantics: merged routes keep the chains captured where they were DEFINED - the group's
|
|
1150
1168
|
* `derive`/`decorate`/`beforeHandle`/`afterHandle`/`onError`/`around` apply to its routes
|
|
1151
|
-
* exactly as they did standalone, so a group wires its own plugins. The
|
|
1152
|
-
*
|
|
1153
|
-
*
|
|
1154
|
-
*
|
|
1169
|
+
* exactly as they did standalone, so a group wires its own plugins. The same locality rule
|
|
1170
|
+
* covers the group's `onRequest` hooks: they run only for requests that route to the GROUP's
|
|
1171
|
+
* routes (a `bodyLimit()` mounted on an uploads group must not start gating the whole app
|
|
1172
|
+
* because the app composed it in), and the group's global assurance follows its hooks onto
|
|
1173
|
+
* exactly those routes. A group with hooks but NO routes is a middleware bundle - its hooks
|
|
1174
|
+
* can only mean app-wide intent, so they are appended globally, unchanged. Response-side hooks
|
|
1175
|
+
* (`onResponse`/`onResponseFinalized`) are appended to this server's. This server's
|
|
1176
|
+
* route-scoped chains do NOT retroactively wrap merged routes (order-scoped, like routes
|
|
1177
|
+
* registered before a `derive`). Fail closed: a path+method collision throws
|
|
1155
1178
|
* `RouteConfigError` at merge time, and a group with WebSocket routes is refused (register
|
|
1156
1179
|
* those on the parent).
|
|
1157
1180
|
*/
|
|
@@ -1161,7 +1184,20 @@ export class Server {
|
|
|
1161
1184
|
if (source.wsRouteCount > 0) {
|
|
1162
1185
|
throw new RouteConfigError("INVALID_PATH", "merge() does not carry WebSocket routes - register .ws() routes on the parent server");
|
|
1163
1186
|
}
|
|
1164
|
-
|
|
1187
|
+
const sourceRoutes = source.catalog.entries();
|
|
1188
|
+
// Hooks and global assurance stay LOCAL to a group that has routes; a route-less group is a
|
|
1189
|
+
// middleware bundle whose hooks can only mean app-wide intent, so it keeps the global append.
|
|
1190
|
+
const scoped = sourceRoutes.length > 0;
|
|
1191
|
+
// A group's global assurance rides its (route-scoped) hooks: folded into each merged route's
|
|
1192
|
+
// own evidence rather than the parent's global list, so `routes()` never claims the group's
|
|
1193
|
+
// enforcement for parent routes the group's hooks do not see.
|
|
1194
|
+
const foldedAssurance = scoped && source.globalAssurance.length > 0
|
|
1195
|
+
? (route) => ({
|
|
1196
|
+
...route,
|
|
1197
|
+
assurance: [...route.assurance, ...source.globalAssurance],
|
|
1198
|
+
})
|
|
1199
|
+
: (route) => route;
|
|
1200
|
+
this.catalog.addBatch(sourceRoutes.map((route) => this.bindFusedRuntime(foldedAssurance(route))));
|
|
1165
1201
|
// Resolved idempotency/ledger route entries carry their own store/sink configuration, while the
|
|
1166
1202
|
// runtime object supplies the generic execution machinery. Preserve a group's installed runtime
|
|
1167
1203
|
// when the parent has none so merging cannot silently disable a safety lane. If the parent already
|
|
@@ -1174,8 +1210,23 @@ export class Server {
|
|
|
1174
1210
|
this.nodeOutcomeRuntime ??= source.nodeOutcomeRuntime;
|
|
1175
1211
|
this.sseRuntime ??= source.sseRuntime;
|
|
1176
1212
|
this.wsRuntime ??= source.wsRuntime;
|
|
1177
|
-
|
|
1178
|
-
|
|
1213
|
+
if (scoped && source.onRequestHooks.length > 0) {
|
|
1214
|
+
// Snapshot the group's routes into a dedicated matcher: the guard must reflect what was
|
|
1215
|
+
// MERGED, not whatever the group's own catalog grows into afterwards. One probe against it
|
|
1216
|
+
// gates each group hook to requests the group would serve; everything else passes untouched.
|
|
1217
|
+
const scope = new RouteCatalog();
|
|
1218
|
+
scope.addBatch(sourceRoutes);
|
|
1219
|
+
this.onRequestHooks.push(...source.onRequestHooks.map((hook) => (req, platform) => scope.find(req.method, pathnameOf(req.url)).found ? hook(req, platform) : undefined));
|
|
1220
|
+
// An `undefined` slot marks an unpaired hook (position-aligned with `onRequestHooks`) and
|
|
1221
|
+
// must stay `undefined` - wrapping it would fabricate a Node twin that never existed.
|
|
1222
|
+
this.onNodeRequestHooks.push(...source.onNodeRequestHooks.map((hook) => hook === undefined
|
|
1223
|
+
? undefined
|
|
1224
|
+
: (req, platform) => scope.find(req.method, pathnameOf(req.url)).found ? hook(req, platform) : undefined));
|
|
1225
|
+
}
|
|
1226
|
+
else {
|
|
1227
|
+
this.onRequestHooks.push(...source.onRequestHooks);
|
|
1228
|
+
this.onNodeRequestHooks.push(...source.onNodeRequestHooks);
|
|
1229
|
+
}
|
|
1179
1230
|
this.nodeRequestHooksComplete &&= source.nodeRequestHooksComplete;
|
|
1180
1231
|
// The group's static declarations came before its own response hooks, so they are folded in
|
|
1181
1232
|
// first - and fold themselves into a hook here if this server already has one (same ordering
|
|
@@ -1186,13 +1237,15 @@ export class Server {
|
|
|
1186
1237
|
this.onResponseHooks.push(...source.onResponseHooks);
|
|
1187
1238
|
this.onNodeResponseHooks.push(...source.onNodeResponseHooks);
|
|
1188
1239
|
this.nodeResponseHooksComplete &&= source.nodeResponseHooksComplete;
|
|
1240
|
+
this.hasRawNodeResponseHook ||= source.hasRawNodeResponseHook;
|
|
1189
1241
|
this.onResponseFinalizedHooks.push(...source.onResponseFinalizedHooks);
|
|
1190
1242
|
if (source.responseBodyTag !== undefined) {
|
|
1191
1243
|
const owner = this.enableResponseBodyTagging();
|
|
1192
1244
|
this.responseBodyOwners.add(source.responseBodyTag);
|
|
1193
1245
|
source.responseBodyOwners.add(owner);
|
|
1194
1246
|
}
|
|
1195
|
-
|
|
1247
|
+
if (!scoped)
|
|
1248
|
+
this.globalAssurance.push(...source.globalAssurance);
|
|
1196
1249
|
this.mcpResourceList.push(...source.mcpResourceList);
|
|
1197
1250
|
this.mcpPromptList.push(...source.mcpPromptList);
|
|
1198
1251
|
return this;
|
|
@@ -1206,13 +1259,13 @@ export class Server {
|
|
|
1206
1259
|
// Rebuild with the SAME builder that produced the closure - rebinding a query-fused route as
|
|
1207
1260
|
// bare would silently drop its validation.
|
|
1208
1261
|
const fusedBody = entry.execution.fusedLane === "body"
|
|
1209
|
-
? this.buildFusedBodyRunner(entry.handler, entry.schema?.body, entry.hasDecorations ? entry.decorations : undefined)
|
|
1262
|
+
? this.buildFusedBodyRunner(entry.handler, entry.schema?.body, entry.hasDecorations ? entry.decorations : undefined, entry.bodyLimit ?? this.maxBodyBytes)
|
|
1210
1263
|
: undefined;
|
|
1211
1264
|
const fusedWeb = entry.execution.fusedLane === "body"
|
|
1212
1265
|
? this.buildFusedBodyWeb(fusedBody)
|
|
1213
1266
|
: entry.execution.fusedLane === "query"
|
|
1214
|
-
? this.buildFusedQueryWeb(entry.handler, entry.hasDecorations ? entry.decorations : undefined, entry.schema?.query)
|
|
1215
|
-
: this.buildFusedWeb(entry.handler, entry.hasDecorations ? entry.decorations : undefined, isContextlessNoArgArrow(entry.handler));
|
|
1267
|
+
? this.buildFusedQueryWeb(entry.handler, entry.hasDecorations ? entry.decorations : undefined, entry.schema?.query, entry.bodyLimit ?? this.maxBodyBytes)
|
|
1268
|
+
: this.buildFusedWeb(entry.handler, entry.hasDecorations ? entry.decorations : undefined, isContextlessNoArgArrow(entry.handler), entry.bodyLimit ?? this.maxBodyBytes);
|
|
1216
1269
|
return {
|
|
1217
1270
|
...route,
|
|
1218
1271
|
entry: {
|
|
@@ -1243,6 +1296,10 @@ export class Server {
|
|
|
1243
1296
|
* Bun/Node/Deno omit it (then `c.env` is `undefined` and `c.waitUntil` runs fire-and-forget).
|
|
1244
1297
|
*/
|
|
1245
1298
|
fetch(req, platform) {
|
|
1299
|
+
// An edge deployment whose only ingress is this method declares its requests runtime-framed at
|
|
1300
|
+
// construction; everything else stays on the delivered-byte check (see `trustBodyFraming`).
|
|
1301
|
+
if (this.trustBodyFraming)
|
|
1302
|
+
markTrustedBodyFraming(req);
|
|
1246
1303
|
// A real `Request` satisfies `RequestSource`, so it's passed straight through - no per-request
|
|
1247
1304
|
// wrapper allocation on the Web/Bun hot path.
|
|
1248
1305
|
return this.fetchSource(req, platform);
|
|
@@ -1365,11 +1422,15 @@ export class Server {
|
|
|
1365
1422
|
// header or body hook reads them through its view), and on the no-hook path too (which returns
|
|
1366
1423
|
// the outcome straight to the writer without the finish step below).
|
|
1367
1424
|
const statics = this.staticResponseHeaders;
|
|
1425
|
+
// The fold also publishes the all-lowercase proof, which is why it is worth doing here rather
|
|
1426
|
+
// than in the finish step: it is the only stage the HOOKLESS lane passes through, and that lane
|
|
1427
|
+
// returns straight to the writer below without ever reaching `finishNodeResponse`.
|
|
1428
|
+
const markLowercase = !this.hasRawNodeResponseHook;
|
|
1368
1429
|
const outcome = statics === undefined
|
|
1369
1430
|
? resolved
|
|
1370
1431
|
: resolved instanceof Promise
|
|
1371
|
-
? resolved.then((settled) => withStaticNodeHeaders(settled, statics))
|
|
1372
|
-
: withStaticNodeHeaders(resolved, statics);
|
|
1432
|
+
? resolved.then((settled) => withStaticNodeHeaders(settled, statics, markLowercase))
|
|
1433
|
+
: withStaticNodeHeaders(resolved, statics, markLowercase);
|
|
1373
1434
|
if (webResponseHooks) {
|
|
1374
1435
|
return outcome instanceof Promise
|
|
1375
1436
|
? outcome.then((settled) => this.finishNodeWebResponse(settled, source, runtime))
|
|
@@ -1410,12 +1471,22 @@ export class Server {
|
|
|
1410
1471
|
: runtime.fromResponse(transformed);
|
|
1411
1472
|
}
|
|
1412
1473
|
let headers = outcome.headers;
|
|
1474
|
+
// ONE pass over the names, here, before any twin runs. The same question is asked three times per
|
|
1475
|
+
// request downstream - the Content-Type lookup just below, the header view's alias index, and the
|
|
1476
|
+
// `@nifrajs/node` writer's normalization gate - and each used to walk the record itself. An app
|
|
1477
|
+
// declaring static headers has already had it answered by the fold in `resolveNode`, for free, so
|
|
1478
|
+
// the pass runs only for the records nothing looked at yet.
|
|
1479
|
+
const lowercaseKeys = headers === undefined ||
|
|
1480
|
+
hasLowercaseHeaderKeysMark(headers) ||
|
|
1481
|
+
headerKeysAllLowercase(headers);
|
|
1413
1482
|
if (outcome.kind === "json" && outcome.body !== null) {
|
|
1414
1483
|
// The json render adds its Content-Type at WRITE time, so a body hook checking content types
|
|
1415
1484
|
// would see nothing. Materialize the writer's own value into the hook-visible record - same
|
|
1416
1485
|
// string the writer would emit, so the wire is unchanged.
|
|
1417
1486
|
const hasType = headers !== undefined &&
|
|
1418
|
-
|
|
1487
|
+
(lowercaseKeys
|
|
1488
|
+
? headers["content-type"] !== undefined
|
|
1489
|
+
: Object.keys(headers).some((key) => key.toLowerCase() === "content-type"));
|
|
1419
1490
|
if (!hasType) {
|
|
1420
1491
|
const defaultContentType = runtime.jsonContentType ?? "application/json;charset=utf-8";
|
|
1421
1492
|
if (headers === undefined) {
|
|
@@ -1430,6 +1501,13 @@ export class Server {
|
|
|
1430
1501
|
}
|
|
1431
1502
|
}
|
|
1432
1503
|
}
|
|
1504
|
+
// Publish the proof for the view and the direct writer - but only for an app whose every twin
|
|
1505
|
+
// normalizes case. A raw `onNodeResponse` twin writes the record straight, past the view, so a
|
|
1506
|
+
// mark set before it ran could be made to lie, and a lying mark would ship a mixed-case name that
|
|
1507
|
+
// `Headers` lowercases on every other runtime. Those apps keep the per-reader scans.
|
|
1508
|
+
if (lowercaseKeys && headers !== undefined && !this.hasRawNodeResponseHook) {
|
|
1509
|
+
markLowercaseHeaderKeys(headers);
|
|
1510
|
+
}
|
|
1433
1511
|
const context = {
|
|
1434
1512
|
status: outcome.status,
|
|
1435
1513
|
headers,
|
|
@@ -1551,13 +1629,27 @@ export class Server {
|
|
|
1551
1629
|
return { kind: "reject", response: jsonError(403, "forbidden_origin") };
|
|
1552
1630
|
}
|
|
1553
1631
|
if (handler.upgrade === undefined) {
|
|
1554
|
-
return {
|
|
1632
|
+
return {
|
|
1633
|
+
kind: "upgrade",
|
|
1634
|
+
handler,
|
|
1635
|
+
data: undefined,
|
|
1636
|
+
pubsub,
|
|
1637
|
+
attach,
|
|
1638
|
+
maxPayloadBytes: this.wsMaxPayloadBytes,
|
|
1639
|
+
};
|
|
1555
1640
|
}
|
|
1556
1641
|
const upgradeSignal = getNeverAbortSignal();
|
|
1557
|
-
const ctx = new RequestContext(req, params, url.search, upgradeSignal, createUnboundedRequestBudget(upgradeSignal), platform, this.maxBodyBytes);
|
|
1642
|
+
const ctx = new RequestContext(req, params, url.search, upgradeSignal, createUnboundedRequestBudget(upgradeSignal), platform, this.maxBodyBytes, this.protoPoisoning);
|
|
1558
1643
|
const settle = (value) => value instanceof Response
|
|
1559
1644
|
? { kind: "reject", response: value }
|
|
1560
|
-
: {
|
|
1645
|
+
: {
|
|
1646
|
+
kind: "upgrade",
|
|
1647
|
+
handler,
|
|
1648
|
+
data: value,
|
|
1649
|
+
pubsub,
|
|
1650
|
+
attach,
|
|
1651
|
+
maxPayloadBytes: this.wsMaxPayloadBytes,
|
|
1652
|
+
};
|
|
1561
1653
|
try {
|
|
1562
1654
|
const result = handler.upgrade(ctx);
|
|
1563
1655
|
return result instanceof Promise
|
|
@@ -1816,6 +1908,12 @@ export class Server {
|
|
|
1816
1908
|
}
|
|
1817
1909
|
const match = this.catalog.find(source.method, pathname);
|
|
1818
1910
|
if (!match.found) {
|
|
1911
|
+
const mounted = this.fetchMount(pathname, source, platform);
|
|
1912
|
+
if (mounted !== undefined) {
|
|
1913
|
+
return mounted instanceof Promise
|
|
1914
|
+
? mounted.then((response) => wrapResponse(response))
|
|
1915
|
+
: wrapResponse(mounted);
|
|
1916
|
+
}
|
|
1819
1917
|
if (match.reason === "method-not-allowed") {
|
|
1820
1918
|
return wrapResponse(jsonError(405, "method_not_allowed", { Allow: match.allowed.join(", ") }));
|
|
1821
1919
|
}
|
|
@@ -1829,14 +1927,33 @@ export class Server {
|
|
|
1829
1927
|
}
|
|
1830
1928
|
return this.runMatched(source, platform, match.payload, params, search, finalize, wrapResponse, onTimeout, webFast);
|
|
1831
1929
|
}
|
|
1930
|
+
fetchMount(pathname, source, platform) {
|
|
1931
|
+
for (const mount of this.fetchMounts) {
|
|
1932
|
+
if (!underMountPrefix(pathname, mount.path))
|
|
1933
|
+
continue;
|
|
1934
|
+
const request = requestOf(source);
|
|
1935
|
+
return mount.handler(mount.stripPrefix ? stripMountPrefix(request, mount.path) : request, platform);
|
|
1936
|
+
}
|
|
1937
|
+
return undefined;
|
|
1938
|
+
}
|
|
1832
1939
|
/** Run a route that has already been matched by the runtime or Nifra's portable router. */
|
|
1833
1940
|
runMatched(source, platform, entry, params, search, finalize, wrapResponse, onTimeout, webFast) {
|
|
1941
|
+
// The route's transport byte cap: one property write here; the capped shadowing of direct
|
|
1942
|
+
// `c.req` body readers happens lazily at `c.req` access (`applyTransportCap`), so a route that
|
|
1943
|
+
// never direct-reads pays nothing. Framework readers bypass the shadow (`rawBodySourceOf`) and
|
|
1944
|
+
// keep their own caps, so `c.boundedBody(explicit)` still overrides upward.
|
|
1945
|
+
// `bodyLimit: "unlimited"` (undefined here) skips the cap entirely.
|
|
1946
|
+
if (entry.bodyLimit !== undefined) {
|
|
1947
|
+
const method = source.method;
|
|
1948
|
+
if (method !== "GET" && method !== "HEAD")
|
|
1949
|
+
markTransportCap(source, entry.bodyLimit);
|
|
1950
|
+
}
|
|
1834
1951
|
// An idempotency route runs its dedupe lane first; on a fresh key it delegates to the normal lanes
|
|
1835
1952
|
// (with the body buffered). All non-idempotent routes skip straight to the lanes - no added cost.
|
|
1836
1953
|
// The runtime is always present when a route resolved idempotency (enforced at registration).
|
|
1837
1954
|
if (entry.idempotent !== undefined && this.idempotencyRuntime !== undefined) {
|
|
1838
1955
|
return this.idempotencyRuntime.run(entry.idempotent, requestOf(source), platform, entry, params, search, wrapResponse, {
|
|
1839
|
-
maxBodyBytes: this.maxBodyBytes,
|
|
1956
|
+
maxBodyBytes: entry.bodyLimit ?? this.maxBodyBytes,
|
|
1840
1957
|
runLanes: (buffered, plat, ent, prm, srch) => this.idempotencyRunLanes(buffered, plat, ent, prm, srch),
|
|
1841
1958
|
});
|
|
1842
1959
|
}
|
|
@@ -1900,6 +2017,8 @@ export class Server {
|
|
|
1900
2017
|
}
|
|
1901
2018
|
/** The narrowest bare route: a syntactic `() => ...` handler cannot observe the context argument, so
|
|
1902
2019
|
* successful requests can skip allocating `RequestContext`. Errors still allocate one for logging. */
|
|
2020
|
+
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2021
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
|
|
1903
2022
|
runContextlessBare(entry, source, params, search, signal, budget, platform, finalize, wrapResponse) {
|
|
1904
2023
|
let result;
|
|
1905
2024
|
try {
|
|
@@ -1916,7 +2035,7 @@ export class Server {
|
|
|
1916
2035
|
contextlessBareError(err, source, params, search, signal, budget, platform, wrapResponse) {
|
|
1917
2036
|
if (err instanceof Response)
|
|
1918
2037
|
return wrapResponse(err);
|
|
1919
|
-
const ctx = new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes);
|
|
2038
|
+
const ctx = new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes, this.protoPoisoning);
|
|
1920
2039
|
this.logRequestError(err, ctx);
|
|
1921
2040
|
return wrapResponse(jsonError(500, "internal_error"));
|
|
1922
2041
|
}
|
|
@@ -1928,6 +2047,8 @@ export class Server {
|
|
|
1928
2047
|
* (a thrown `Response` is control flow; anything else is a logged flat 500). This is where nifra skips
|
|
1929
2048
|
* the per-request async-frame tax - the same win codegen routers get, but without `eval`.
|
|
1930
2049
|
*/
|
|
2050
|
+
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2051
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
|
|
1931
2052
|
runBare(entry, ctx, finalize, wrapResponse) {
|
|
1932
2053
|
let result;
|
|
1933
2054
|
try {
|
|
@@ -1953,7 +2074,7 @@ export class Server {
|
|
|
1953
2074
|
* decoration order, same error routing (thrown `Response` = control flow; anything else logs and
|
|
1954
2075
|
* 500s), same respond semantics (the lifecycle parity suite pins it).
|
|
1955
2076
|
*/
|
|
1956
|
-
buildFusedWeb(handler, decorations, contextless) {
|
|
2077
|
+
buildFusedWeb(handler, decorations, contextless, maxBodyBytes) {
|
|
1957
2078
|
// The fused lanes return to the runtime directly, so the framework's own error renders fold in
|
|
1958
2079
|
// static headers here rather than through the shared `wrapResponse` seam.
|
|
1959
2080
|
const logError = (err, ctx) => {
|
|
@@ -1973,21 +2094,21 @@ export class Server {
|
|
|
1973
2094
|
}
|
|
1974
2095
|
catch (err) {
|
|
1975
2096
|
return logError(err, nativeContext
|
|
1976
|
-
? RequestContext.native(source, params, search,
|
|
1977
|
-
: new RequestContext(source, params, search, signal, budget, platform, this.
|
|
2097
|
+
? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
|
|
2098
|
+
: new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning));
|
|
1978
2099
|
}
|
|
1979
2100
|
if (result instanceof Promise) {
|
|
1980
|
-
return result.then((value) => fusedRespondNoSet(value, this.responseBodyTag, this.staticResponseHeaders), (err) => logError(err, nativeContext
|
|
1981
|
-
? RequestContext.native(source, params, search,
|
|
1982
|
-
: new RequestContext(source, params, search, signal, budget, platform, this.
|
|
2101
|
+
return result.then((value) => fusedRespondNoSet(value, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0), (err) => logError(err, nativeContext
|
|
2102
|
+
? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
|
|
2103
|
+
: new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning)));
|
|
1983
2104
|
}
|
|
1984
|
-
return fusedRespondNoSet(result, this.responseBodyTag, this.staticResponseHeaders);
|
|
2105
|
+
return fusedRespondNoSet(result, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0);
|
|
1985
2106
|
};
|
|
1986
2107
|
}
|
|
1987
2108
|
return (source, params, search, signal, budget, platform, nativeContext) => {
|
|
1988
2109
|
const ctx = nativeContext
|
|
1989
|
-
? RequestContext.native(source, params, search,
|
|
1990
|
-
: new RequestContext(source, params, search, signal, budget, platform, this.
|
|
2110
|
+
? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
|
|
2111
|
+
: new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
|
|
1991
2112
|
if (decorations !== undefined)
|
|
1992
2113
|
Object.assign(ctx, decorations);
|
|
1993
2114
|
let result;
|
|
@@ -1998,15 +2119,15 @@ export class Server {
|
|
|
1998
2119
|
return logError(err, ctx);
|
|
1999
2120
|
}
|
|
2000
2121
|
if (result instanceof Promise) {
|
|
2001
|
-
return result.then((value) => fusedRespond(value, ctx, this.responseBodyTag, this.staticResponseHeaders), (err) => logError(err, ctx));
|
|
2122
|
+
return result.then((value) => fusedRespond(value, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0), (err) => logError(err, ctx));
|
|
2002
2123
|
}
|
|
2003
|
-
return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders);
|
|
2124
|
+
return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0);
|
|
2004
2125
|
};
|
|
2005
2126
|
}
|
|
2006
2127
|
/** Compile the eligible body-only route's parser → validator → handler continuation once. The
|
|
2007
2128
|
* bounded parser remains shared with the generic lane; only the route-invariant entry lookups and
|
|
2008
2129
|
* lifecycle dispatch disappear from the common synchronous-validator/synchronous-handler case. */
|
|
2009
|
-
buildFusedBodyRunner(handler, bodySchema, decorations) {
|
|
2130
|
+
buildFusedBodyRunner(handler, bodySchema, decorations, maxBodyBytes) {
|
|
2010
2131
|
const logError = (err, ctx, wrapResponse) => this.bareError(err, ctx, wrapResponse);
|
|
2011
2132
|
const runHandler = (ctx, finalize, wrapResponse) => {
|
|
2012
2133
|
if (decorations !== undefined)
|
|
@@ -2068,16 +2189,16 @@ export class Server {
|
|
|
2068
2189
|
};
|
|
2069
2190
|
return (source, params, search, signal, budget, platform, nativeContext, finalize, wrapResponse) => {
|
|
2070
2191
|
const ctx = nativeContext
|
|
2071
|
-
? RequestContext.native(source, params, search,
|
|
2072
|
-
: new RequestContext(source, params, search, signal, budget, platform, this.
|
|
2192
|
+
? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
|
|
2193
|
+
: new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
|
|
2073
2194
|
const finish = (value) => runParsed(value, ctx, finalize, wrapResponse);
|
|
2074
|
-
return this.readBodyInput(source, finish, wrapResponse, (err) => logError(err, ctx, wrapResponse));
|
|
2195
|
+
return this.readBodyInput(source, maxBodyBytes, finish, wrapResponse, (err) => logError(err, ctx, wrapResponse));
|
|
2075
2196
|
};
|
|
2076
2197
|
}
|
|
2077
2198
|
/** Web adapter wrapper for the shared body runner. Node passes its native finalizer directly through
|
|
2078
2199
|
* the execution plan, while Web needs the live context to preserve lazy `c.set` controls. */
|
|
2079
2200
|
buildFusedBodyWeb(body) {
|
|
2080
|
-
return (source, params, search, signal, budget, platform, nativeContext) => body(source, params, search, signal, budget, platform, nativeContext, (result, _set, ctx) => fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders), this.wrapWebResponse);
|
|
2201
|
+
return (source, params, search, signal, budget, platform, nativeContext) => body(source, params, search, signal, budget, platform, nativeContext, (result, _set, ctx) => fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0), this.wrapWebResponse);
|
|
2081
2202
|
}
|
|
2082
2203
|
/** The fused Web lane for a route whose only lifecycle step is a query schema: parse + validate +
|
|
2083
2204
|
* handler + respond in one closure, no lifecycle promise when the validator and handler are sync.
|
|
@@ -2086,7 +2207,7 @@ export class Server {
|
|
|
2086
2207
|
* `validationError(issues)` (recovery hooks disqualify the route from this lane), a thrown
|
|
2087
2208
|
* `Response` passes through, anything else logs and returns a flat 500, and an async validator
|
|
2088
2209
|
* falls to a then-chain with the same steps. */
|
|
2089
|
-
buildFusedQueryWeb(handler, decorations, querySchema) {
|
|
2210
|
+
buildFusedQueryWeb(handler, decorations, querySchema, maxBodyBytes) {
|
|
2090
2211
|
// The fused lanes return to the runtime directly, so the framework's own error renders fold in
|
|
2091
2212
|
// static headers here rather than through the shared `wrapResponse` seam.
|
|
2092
2213
|
const logError = (err, ctx) => {
|
|
@@ -2105,14 +2226,14 @@ export class Server {
|
|
|
2105
2226
|
return logError(err, ctx);
|
|
2106
2227
|
}
|
|
2107
2228
|
if (result instanceof Promise) {
|
|
2108
|
-
return result.then((settled) => fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders), (err) => logError(err, ctx));
|
|
2229
|
+
return result.then((settled) => fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0), (err) => logError(err, ctx));
|
|
2109
2230
|
}
|
|
2110
|
-
return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders);
|
|
2231
|
+
return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0);
|
|
2111
2232
|
};
|
|
2112
2233
|
return (source, params, search, signal, budget, platform, nativeContext) => {
|
|
2113
2234
|
const ctx = nativeContext
|
|
2114
|
-
? RequestContext.native(source, params, search,
|
|
2115
|
-
: new RequestContext(source, params, search, signal, budget, platform, this.
|
|
2235
|
+
? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
|
|
2236
|
+
: new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
|
|
2116
2237
|
if (decorations !== undefined)
|
|
2117
2238
|
Object.assign(ctx, decorations);
|
|
2118
2239
|
let validation;
|
|
@@ -2138,6 +2259,8 @@ export class Server {
|
|
|
2138
2259
|
this.logRequestError(err, ctx);
|
|
2139
2260
|
return wrapResponse(jsonError(500, "internal_error"));
|
|
2140
2261
|
}
|
|
2262
|
+
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2263
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2141
2264
|
runWithAround(entry, ctx, run, finalize, wrapResponse) {
|
|
2142
2265
|
let outcome;
|
|
2143
2266
|
try {
|
|
@@ -2165,36 +2288,26 @@ export class Server {
|
|
|
2165
2288
|
};
|
|
2166
2289
|
return dispatch(0);
|
|
2167
2290
|
}
|
|
2291
|
+
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2292
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2168
2293
|
runBodyOnly(entry, source, ctx, finalize, wrapResponse) {
|
|
2169
|
-
return this.readBodyInput(source, (parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse), wrapResponse, (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
|
|
2294
|
+
return this.readBodyInput(source, entry.bodyLimit ?? this.maxBodyBytes, (parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse), wrapResponse, (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
|
|
2170
2295
|
}
|
|
2171
2296
|
/** Shared bounded body framing/parser. Both body lanes use the same trust-boundary checks; only
|
|
2172
2297
|
* the validation + handler continuation differs. */
|
|
2173
|
-
readBodyInput(source, onParsed, wrapResponse, onError) {
|
|
2298
|
+
readBodyInput(source, maxBodyBytes, onParsed, wrapResponse, onError) {
|
|
2174
2299
|
const contentType = headerOf(source, "content-type") ?? "";
|
|
2175
2300
|
if (contentType !== "application/json" && !contentType.includes("application/json")) {
|
|
2176
2301
|
if (isUrlEncodedForm(contentType)) {
|
|
2177
|
-
return readBoundedForm(source,
|
|
2302
|
+
return readBoundedForm(source, maxBodyBytes).then((form) => (form instanceof Response ? wrapResponse(form) : onParsed(form)), onError);
|
|
2178
2303
|
}
|
|
2179
2304
|
return Promise.resolve(wrapResponse(jsonError(415, "unsupported_media_type")));
|
|
2180
2305
|
}
|
|
2181
|
-
//
|
|
2182
|
-
//
|
|
2183
|
-
|
|
2184
|
-
if (declared !== null) {
|
|
2185
|
-
const length = parseContentLength(declared);
|
|
2186
|
-
if (length === undefined) {
|
|
2187
|
-
return Promise.resolve(wrapResponse(jsonError(400, "invalid_content_length")));
|
|
2188
|
-
}
|
|
2189
|
-
if (length > this.maxBodyBytes) {
|
|
2190
|
-
return Promise.resolve(wrapResponse(jsonError(413, "payload_too_large")));
|
|
2191
|
-
}
|
|
2192
|
-
if (headerOf(source, "transfer-encoding") === null) {
|
|
2193
|
-
return source.json().then(onParsed, () => wrapResponse(jsonError(400, "invalid_json")));
|
|
2194
|
-
}
|
|
2195
|
-
}
|
|
2306
|
+
// All JSON bodies go through readBoundedJson - the single enforcement point for the framed
|
|
2307
|
+
// fast path (post-read byte re-check), the streaming cap, and the prototype-poisoning guard.
|
|
2308
|
+
// A separate inlined native-json() fast path here would fork the trust boundary in two.
|
|
2196
2309
|
try {
|
|
2197
|
-
return this.readBoundedJson(source
|
|
2310
|
+
return this.readBoundedJson(source, maxBodyBytes, (parsed) => (parsed instanceof Response ? wrapResponse(parsed) : onParsed(parsed)), onError);
|
|
2198
2311
|
}
|
|
2199
2312
|
catch (err) {
|
|
2200
2313
|
return Promise.resolve(onError(err));
|
|
@@ -2296,6 +2409,8 @@ export class Server {
|
|
|
2296
2409
|
ctx.body = result.value;
|
|
2297
2410
|
return this.executeHandler(entry, ctx, finalize);
|
|
2298
2411
|
}
|
|
2412
|
+
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2413
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2299
2414
|
runQueryOnly(entry, ctx, finalize, wrapResponse) {
|
|
2300
2415
|
try {
|
|
2301
2416
|
// Call the validator directly for the raw StandardResult (read `.issues`/`.value`) - skip
|
|
@@ -2446,8 +2561,18 @@ export class Server {
|
|
|
2446
2561
|
* node-direct primitives); an early/error `Response` (validation, thrown, 500) through `wrapResponse`.
|
|
2447
2562
|
*/
|
|
2448
2563
|
/** Synchronous until a validator, lifecycle hook, handler, or contract check actually returns a Promise. */
|
|
2564
|
+
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2565
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2449
2566
|
runLifecycle(entry, source, ctx, finalize, wrapResponse) {
|
|
2450
2567
|
try {
|
|
2568
|
+
const headersSchema = entry.schema?.headers;
|
|
2569
|
+
if (headersSchema !== undefined) {
|
|
2570
|
+
const validation = headersSchema["~standard"].validate(headerObjectOf(source.headers));
|
|
2571
|
+
if (validation instanceof Promise) {
|
|
2572
|
+
return validation.then((result) => this.runLifecycleAfterHeadersResult(entry, source, ctx, finalize, wrapResponse, result), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
|
|
2573
|
+
}
|
|
2574
|
+
return this.runLifecycleAfterHeadersResult(entry, source, ctx, finalize, wrapResponse, validation);
|
|
2575
|
+
}
|
|
2451
2576
|
const paramsSchema = entry.schema?.params;
|
|
2452
2577
|
if (paramsSchema !== undefined) {
|
|
2453
2578
|
const validation = paramsSchema["~standard"].validate(ctx.params);
|
|
@@ -2469,6 +2594,35 @@ export class Server {
|
|
|
2469
2594
|
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
|
|
2470
2595
|
}
|
|
2471
2596
|
}
|
|
2597
|
+
runLifecycleAfterHeadersResult(entry, source, ctx, finalize, wrapResponse, result) {
|
|
2598
|
+
try {
|
|
2599
|
+
const headersError = this.applyLifecycleValidation(entry, result, ctx, "headers");
|
|
2600
|
+
if (headersError instanceof Promise) {
|
|
2601
|
+
return headersError.then((error) => error === undefined
|
|
2602
|
+
? this.runLifecycleAfterHeaders(entry, source, ctx, finalize, wrapResponse)
|
|
2603
|
+
: wrapResponse(error), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
|
|
2604
|
+
}
|
|
2605
|
+
return headersError === undefined
|
|
2606
|
+
? this.runLifecycleAfterHeaders(entry, source, ctx, finalize, wrapResponse)
|
|
2607
|
+
: wrapResponse(headersError);
|
|
2608
|
+
}
|
|
2609
|
+
catch (err) {
|
|
2610
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
runLifecycleAfterHeaders(entry, source, ctx, finalize, wrapResponse) {
|
|
2614
|
+
const paramsSchema = entry.schema?.params;
|
|
2615
|
+
if (paramsSchema === undefined) {
|
|
2616
|
+
if (entry.schema?.body === undefined)
|
|
2617
|
+
return this.runQueryAndLifecycle(entry, ctx, finalize, wrapResponse);
|
|
2618
|
+
return this.runLifecycleAfterParams(entry, source, ctx, finalize, wrapResponse, undefined);
|
|
2619
|
+
}
|
|
2620
|
+
const validation = paramsSchema["~standard"].validate(ctx.params);
|
|
2621
|
+
if (validation instanceof Promise) {
|
|
2622
|
+
return validation.then((result) => this.runLifecycleAfterParamsResult(entry, source, ctx, finalize, wrapResponse, result), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
|
|
2623
|
+
}
|
|
2624
|
+
return this.runLifecycleAfterParamsResult(entry, source, ctx, finalize, wrapResponse, validation);
|
|
2625
|
+
}
|
|
2472
2626
|
/** Registration-specialized query lifecycle: query validation (including recovery) → hooks. */
|
|
2473
2627
|
runLifecycleQuery(entry, ctx, finalize, wrapResponse) {
|
|
2474
2628
|
try {
|
|
@@ -2492,12 +2646,16 @@ export class Server {
|
|
|
2492
2646
|
}
|
|
2493
2647
|
}
|
|
2494
2648
|
/** Registration-specialized body lifecycle: bounded body validation (including recovery) → hooks. */
|
|
2649
|
+
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2650
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2495
2651
|
runLifecycleBody(entry, source, ctx, finalize, wrapResponse) {
|
|
2496
2652
|
return this.readAndValidateBody(source, entry, ctx).then((bodyError) => bodyError === undefined
|
|
2497
2653
|
? this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
|
|
2498
2654
|
: wrapResponse(bodyError), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
|
|
2499
2655
|
}
|
|
2500
2656
|
/** Registration-specialized body + query lifecycle: bounded body validation → query validation → hooks. */
|
|
2657
|
+
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2658
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2501
2659
|
runLifecycleBodyQuery(entry, source, ctx, finalize, wrapResponse) {
|
|
2502
2660
|
return this.readAndValidateBody(source, entry, ctx).then((bodyError) => bodyError === undefined
|
|
2503
2661
|
? this.runLifecycleQuery(entry, ctx, finalize, wrapResponse)
|
|
@@ -2652,8 +2810,12 @@ export class Server {
|
|
|
2652
2810
|
}
|
|
2653
2811
|
async runLifecycleHooksAsync(entry, ctx, finalize, wrapResponse) {
|
|
2654
2812
|
try {
|
|
2655
|
-
|
|
2656
|
-
|
|
2813
|
+
// Indexed, not `for...of`. An array iterator declared inside an async function stays live
|
|
2814
|
+
// across the `await` below, so neither engine can sink it - unlike the sync lanes above,
|
|
2815
|
+
// where V8 elides it. Measured cost of the iterator form: 43ns per hook on Bun, ~9ns on
|
|
2816
|
+
// Node, on a chain whose hooks return synchronously (the common shape).
|
|
2817
|
+
for (let i = 0; i < entry.beforeHandle.length; i++) {
|
|
2818
|
+
const outcome = entry.beforeHandle[i](ctx);
|
|
2657
2819
|
const early = outcome instanceof Promise ? await outcome : outcome;
|
|
2658
2820
|
if (early !== undefined)
|
|
2659
2821
|
return finalize(early, responseSet(ctx));
|
|
@@ -2761,8 +2923,10 @@ export class Server {
|
|
|
2761
2923
|
if (err instanceof Response)
|
|
2762
2924
|
return wrapResponse(err);
|
|
2763
2925
|
// onError hooks may return a custom response; otherwise the default 500 stands.
|
|
2764
|
-
for
|
|
2765
|
-
|
|
2926
|
+
// Indexed for the same reason as the `beforeHandle` chain above: the iterator would outlive
|
|
2927
|
+
// the `await` and stay heap-allocated on both engines.
|
|
2928
|
+
for (let i = 0; i < entry.onError.length; i++) {
|
|
2929
|
+
const outcome = entry.onError[i](err, ctx);
|
|
2766
2930
|
const handled = outcome instanceof Promise ? await outcome : outcome;
|
|
2767
2931
|
if (handled !== undefined)
|
|
2768
2932
|
return finalize(handled, responseSet(ctx));
|
|
@@ -2775,6 +2939,11 @@ export class Server {
|
|
|
2775
2939
|
/** Log an unhandled request error to the (redacting) logger - shared by {@link runLifecycle} and the
|
|
2776
2940
|
* bare fast path ({@link bareError}) so both record the same fields. Never throws; never leaks. */
|
|
2777
2941
|
logRequestError(err, ctx) {
|
|
2942
|
+
// An error's own text can quote the input that produced it, so how much of it reaches the sink is
|
|
2943
|
+
// the app's call (`errorLogDetail`). The default keeps it: a 500 with no message and no stack is
|
|
2944
|
+
// an incident nobody can diagnose, and the framework already ships the narrower instrument for
|
|
2945
|
+
// the leak - a redacting logger with `valuePatterns`. `"none"` is there for an untrusted sink.
|
|
2946
|
+
const detail = this.errorLogDetail;
|
|
2778
2947
|
this.logger.error("unhandled request error", {
|
|
2779
2948
|
method: ctx.req.method,
|
|
2780
2949
|
path: pathnameOf(ctx.req.url),
|
|
@@ -2782,21 +2951,21 @@ export class Server {
|
|
|
2782
2951
|
// `detail`, not `message`: the logger uses `message` for its own first argument, so a field of
|
|
2783
2952
|
// that name is silently overwritten and the thrown error's own text never reaches the sink. It
|
|
2784
2953
|
// survived only incidentally inside `stack`, and was lost outright for a non-Error throw.
|
|
2785
|
-
detail: err instanceof Error ? err.message : String(err),
|
|
2786
|
-
|
|
2954
|
+
...(detail === "none" ? {} : { detail: err instanceof Error ? err.message : String(err) }),
|
|
2955
|
+
...(detail === "full" && err instanceof Error ? { stack: err.stack } : {}),
|
|
2787
2956
|
});
|
|
2788
2957
|
}
|
|
2789
2958
|
async readAndValidateBody(req, entry, ctx) {
|
|
2790
2959
|
const contentType = headerOf(req, "content-type") ?? "";
|
|
2791
2960
|
let parsed;
|
|
2792
2961
|
if (contentType === "application/json" || contentType.includes("application/json")) {
|
|
2793
|
-
const json = await this.readBoundedJson(req);
|
|
2962
|
+
const json = await this.readBoundedJson(req, entry.bodyLimit ?? this.maxBodyBytes);
|
|
2794
2963
|
if (json instanceof Response)
|
|
2795
2964
|
return json;
|
|
2796
2965
|
parsed = json;
|
|
2797
2966
|
}
|
|
2798
2967
|
else if (isUrlEncodedForm(contentType)) {
|
|
2799
|
-
const form = await readBoundedForm(req, this.maxBodyBytes);
|
|
2968
|
+
const form = await readBoundedForm(req, entry.bodyLimit ?? this.maxBodyBytes);
|
|
2800
2969
|
if (form instanceof Response)
|
|
2801
2970
|
return form;
|
|
2802
2971
|
parsed = form;
|
|
@@ -2818,6 +2987,8 @@ export class Server {
|
|
|
2818
2987
|
ctx.body = value;
|
|
2819
2988
|
else if (kind === "query")
|
|
2820
2989
|
ctx.query = value;
|
|
2990
|
+
else if (kind === "headers")
|
|
2991
|
+
ctx.headers = value;
|
|
2821
2992
|
else
|
|
2822
2993
|
ctx.params = value;
|
|
2823
2994
|
};
|
|
@@ -2843,7 +3014,9 @@ export class Server {
|
|
|
2843
3014
|
? entry.schema?.body
|
|
2844
3015
|
: kind === "query"
|
|
2845
3016
|
? entry.schema?.query
|
|
2846
|
-
:
|
|
3017
|
+
: kind === "params"
|
|
3018
|
+
? entry.schema?.params
|
|
3019
|
+
: entry.schema?.headers;
|
|
2847
3020
|
const retried = schema["~standard"].validate(recovery);
|
|
2848
3021
|
if (retried instanceof Promise) {
|
|
2849
3022
|
return retried.then((settled) => {
|
|
@@ -2858,42 +3031,59 @@ export class Server {
|
|
|
2858
3031
|
assign(retried.value);
|
|
2859
3032
|
return undefined;
|
|
2860
3033
|
}
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
* Fast path: when a non-chunked request carries a `Content-Length` within the cap,
|
|
2868
|
-
* a native `req.json()` is already bounded - under HTTP/1.1 + HTTP/2 framing the
|
|
2869
|
-
* runtime delivers at most `Content-Length` bytes - so we skip the manual stream
|
|
2870
|
-
* loop and a separate text decode. It trusts the wire
|
|
2871
|
-
* *framing*, not the header value: nifra only ever receives framed Requests from the
|
|
2872
|
-
* runtime's HTTP server, never a hand-built one with a mismatched length. Chunked or
|
|
2873
|
-
* length-less bodies fall through to the streaming byte-cap guard below.
|
|
2874
|
-
*/
|
|
2875
|
-
async readBoundedJson(req) {
|
|
2876
|
-
return readBoundedJsonSource(req, this.maxBodyBytes);
|
|
3034
|
+
readBoundedJson(req, maxBodyBytes = this.maxBodyBytes, onResult, onError) {
|
|
3035
|
+
// Not `async`: this is a pass-through, and wrapping the lane's own promise in a coroutine
|
|
3036
|
+
// frame costs an extra microtask hop on every JSON body.
|
|
3037
|
+
return onResult === undefined
|
|
3038
|
+
? readBoundedJsonSource(req, maxBodyBytes, this.protoPoisoning)
|
|
3039
|
+
: readBoundedJsonSource(req, maxBodyBytes, this.protoPoisoning, onResult, onError);
|
|
2877
3040
|
}
|
|
2878
3041
|
/** Adapt one route's compiled execution plan to Bun's already-matched request shape. Route
|
|
2879
3042
|
* semantics remain in the plan; this closure only supplies native params and deadline fallback. */
|
|
2880
3043
|
compileBunNativeHandler(entry, paramNames, fused, signal, budget) {
|
|
3044
|
+
// This callback is reached only from Bun's compiled native route table. Bun has already parsed
|
|
3045
|
+
// the HTTP framing, so the JSON lane may retain its native fused `json()` parse without weakening
|
|
3046
|
+
// the portable/adapted source contract (those sources are never marked).
|
|
3047
|
+
const markFramed = (request) => markTrustedBodyFraming(request);
|
|
3048
|
+
// The fused native lane bypasses `runMatched`, so the route's transport byte cap must be
|
|
3049
|
+
// marked here too - otherwise a Bun `listen()` fused route would leave direct `c.req` body
|
|
3050
|
+
// reads uncapped. The non-fused branches go through `fetchMatched` -> `runMatched`, which marks.
|
|
3051
|
+
const bodyLimit = entry.bodyLimit;
|
|
3052
|
+
const inner = fused;
|
|
3053
|
+
const capped = inner === undefined || bodyLimit === undefined
|
|
3054
|
+
? inner
|
|
3055
|
+
: (source, params, search, fusedSignal, fusedBudget, platform, nativeContext) => {
|
|
3056
|
+
const method = source.method;
|
|
3057
|
+
if (method !== "GET" && method !== "HEAD")
|
|
3058
|
+
markTransportCap(source, bodyLimit);
|
|
3059
|
+
return inner(source, params, search, fusedSignal, fusedBudget, platform, nativeContext);
|
|
3060
|
+
};
|
|
2881
3061
|
if (paramNames.length === 0) {
|
|
2882
|
-
if (
|
|
2883
|
-
return (request) =>
|
|
3062
|
+
if (capped === undefined) {
|
|
3063
|
+
return (request) => {
|
|
3064
|
+
markFramed(request);
|
|
3065
|
+
return this.fetchMatched(request, entry, EMPTY_PARAMS);
|
|
3066
|
+
};
|
|
2884
3067
|
}
|
|
2885
3068
|
if (this.acceptInboundDeadlines) {
|
|
2886
|
-
return (request) =>
|
|
2887
|
-
|
|
2888
|
-
|
|
3069
|
+
return (request) => {
|
|
3070
|
+
markFramed(request);
|
|
3071
|
+
return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
|
|
3072
|
+
? this.fetchMatched(request, entry, EMPTY_PARAMS)
|
|
3073
|
+
: capped(request, EMPTY_PARAMS, undefined, signal, budget, undefined, true);
|
|
3074
|
+
};
|
|
2889
3075
|
}
|
|
2890
|
-
return (request) =>
|
|
3076
|
+
return (request) => {
|
|
3077
|
+
markFramed(request);
|
|
3078
|
+
return capped(request, EMPTY_PARAMS, undefined, signal, budget, undefined, true);
|
|
3079
|
+
};
|
|
2891
3080
|
}
|
|
2892
3081
|
const malformed = paramNames.length === 1
|
|
2893
3082
|
? (params) => params[paramNames[0]]?.includes("\uFFFD") === true
|
|
2894
3083
|
: hasReplacementParam;
|
|
2895
|
-
if (
|
|
3084
|
+
if (capped === undefined) {
|
|
2896
3085
|
return (request) => {
|
|
3086
|
+
markFramed(request);
|
|
2897
3087
|
const params = request.params ?? EMPTY_PARAMS;
|
|
2898
3088
|
if (malformed(params))
|
|
2899
3089
|
return this.fetchSource(request);
|
|
@@ -2902,19 +3092,21 @@ export class Server {
|
|
|
2902
3092
|
}
|
|
2903
3093
|
if (this.acceptInboundDeadlines) {
|
|
2904
3094
|
return (request) => {
|
|
3095
|
+
markFramed(request);
|
|
2905
3096
|
const params = request.params ?? EMPTY_PARAMS;
|
|
2906
3097
|
if (malformed(params))
|
|
2907
3098
|
return this.fetchSource(request);
|
|
2908
3099
|
return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
|
|
2909
3100
|
? this.fetchMatched(request, entry, params)
|
|
2910
|
-
:
|
|
3101
|
+
: capped(request, params, undefined, signal, budget, undefined, true);
|
|
2911
3102
|
};
|
|
2912
3103
|
}
|
|
2913
3104
|
return (request) => {
|
|
3105
|
+
markFramed(request);
|
|
2914
3106
|
const params = request.params ?? EMPTY_PARAMS;
|
|
2915
3107
|
if (malformed(params))
|
|
2916
3108
|
return this.fetchSource(request);
|
|
2917
|
-
return
|
|
3109
|
+
return capped(request, params, undefined, signal, budget, undefined, true);
|
|
2918
3110
|
};
|
|
2919
3111
|
}
|
|
2920
3112
|
/** Compile portable route registrations into Bun's native route table. Apps with request-rewrite
|
|
@@ -2980,6 +3172,13 @@ export class Server {
|
|
|
2980
3172
|
* `"127.0.0.1"` to bind loopback only - an admin surface, a sidecar, or anything that must not be
|
|
2981
3173
|
* reachable off the box. Omitting it when you meant to restrict is a real exposure, so it is a
|
|
2982
3174
|
* first-class option rather than something a caller has to drop down to `Bun.serve` for.
|
|
3175
|
+
*
|
|
3176
|
+
* `idleTimeoutSec` is how long Bun tolerates a connection with no bytes moving before it closes
|
|
3177
|
+
* the socket. Bun's default is **10 seconds**, and a request whose handler is still working sends
|
|
3178
|
+
* nothing - so any endpoint slower than 10s (a render, an export, a long upstream call) has its
|
|
3179
|
+
* connection cut mid-flight regardless of `requestTimeoutMs`. Apps with such endpoints must raise
|
|
3180
|
+
* this above their slowest expected response, which is why it is a first-class option and not a
|
|
3181
|
+
* reason to drop down to `Bun.serve`. `0` disables the timeout entirely; max 255.
|
|
2983
3182
|
*/
|
|
2984
3183
|
listen(port, options) {
|
|
2985
3184
|
if (typeof Bun === "undefined") {
|
|
@@ -3006,12 +3205,15 @@ export class Server {
|
|
|
3006
3205
|
// Spread rather than pass `hostname: undefined` - Bun treats an explicit undefined as a value
|
|
3007
3206
|
// on some option paths, and omitting is what selects its 0.0.0.0 default.
|
|
3008
3207
|
const bind = options?.hostname === undefined ? {} : { hostname: options.hostname };
|
|
3208
|
+
// Same reasoning as `bind`: omit rather than pass undefined, so Bun's own default applies.
|
|
3209
|
+
const idle = options?.idleTimeoutSec === undefined ? {} : { idleTimeout: options.idleTimeoutSec };
|
|
3009
3210
|
const nativeRoutes = wsHandlers === undefined ? this.buildBunNativeRoutes() : undefined;
|
|
3010
3211
|
const running = (wsHandlers === undefined
|
|
3011
3212
|
? Bun.serve({
|
|
3012
3213
|
port,
|
|
3013
3214
|
reusePort,
|
|
3014
3215
|
...bind,
|
|
3216
|
+
...idle,
|
|
3015
3217
|
...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
|
|
3016
3218
|
fetch: (req, server) => this.fetch(req, bunPeerPlatform(server, req)),
|
|
3017
3219
|
})
|
|
@@ -3019,6 +3221,7 @@ export class Server {
|
|
|
3019
3221
|
port,
|
|
3020
3222
|
reusePort,
|
|
3021
3223
|
...bind,
|
|
3224
|
+
...idle,
|
|
3022
3225
|
fetch: (req, server) => this.bunFetchWithWebSocket(req, server),
|
|
3023
3226
|
// Bun's `ServerWebSocket<BunWsData>` is runtime-compatible with the handlers' structural
|
|
3024
3227
|
// `BunSocket` view (kept local so `Bun.*` types never leak into the published .d.ts); the
|
|
@@ -3059,6 +3262,14 @@ export class Server {
|
|
|
3059
3262
|
await Bun.sleep(DRAIN_POLL_MS);
|
|
3060
3263
|
}
|
|
3061
3264
|
server.stop(server.pendingRequests > 0); // force-close iff stragglers remain past the deadline
|
|
3265
|
+
if (this.stopHooks.length === 0)
|
|
3266
|
+
return;
|
|
3267
|
+
const callbacks = this.stopHooks.map((hook) => Promise.resolve().then(hook));
|
|
3268
|
+
const settled = Promise.allSettled(callbacks);
|
|
3269
|
+
await Promise.race([
|
|
3270
|
+
settled,
|
|
3271
|
+
new Promise((resolve) => setTimeout(resolve, STOP_HOOK_TIMEOUT_MS)),
|
|
3272
|
+
]);
|
|
3062
3273
|
}
|
|
3063
3274
|
installSignalHandlers() {
|
|
3064
3275
|
// Drain, then let the process exit naturally - the stopped server no longer
|