@nifrajs/core 2.11.0 → 2.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assurance.d.ts +61 -3
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +152 -5
- package/dist/assurance.js.map +1 -1
- package/dist/capabilities.d.ts +9 -1
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +5 -1
- package/dist/capabilities.js.map +1 -1
- package/dist/diff.d.ts +2 -1
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +3 -1
- package/dist/diff.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/evidence.d.ts +89 -0
- package/dist/evidence.d.ts.map +1 -0
- package/dist/evidence.js +170 -0
- package/dist/evidence.js.map +1 -0
- package/dist/execution-policy.d.ts +23 -0
- package/dist/execution-policy.d.ts.map +1 -0
- package/dist/execution-policy.js +28 -0
- package/dist/execution-policy.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/route-assurance.d.ts +19 -0
- package/dist/internal/route-assurance.d.ts.map +1 -1
- package/dist/internal/route-assurance.js +38 -5
- package/dist/internal/route-assurance.js.map +1 -1
- package/dist/internal/route-execution.d.ts +18 -0
- package/dist/internal/route-execution.d.ts.map +1 -1
- package/dist/internal/route-execution.js +67 -1
- package/dist/internal/route-execution.js.map +1 -1
- package/dist/manifest.d.ts +5 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +22 -4
- package/dist/manifest.js.map +1 -1
- package/dist/reflection.d.ts +5 -0
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +13 -4
- package/dist/reflection.js.map +1 -1
- package/dist/replay.d.ts +26 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +63 -0
- package/dist/replay.js.map +1 -0
- package/dist/schema/standard.d.ts +2 -0
- package/dist/schema/standard.d.ts.map +1 -1
- package/dist/schema/standard.js +13 -0
- package/dist/schema/standard.js.map +1 -1
- package/dist/server/body.d.ts +49 -6
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +226 -8
- package/dist/server/body.js.map +1 -1
- package/dist/server/context.d.ts +20 -3
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/contract.d.ts +15 -3
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js +22 -0
- package/dist/server/contract.js.map +1 -1
- package/dist/server/cookies.d.ts +26 -5
- package/dist/server/cookies.d.ts.map +1 -1
- package/dist/server/cookies.js +76 -9
- package/dist/server/cookies.js.map +1 -1
- package/dist/server/edge.d.ts.map +1 -1
- package/dist/server/edge.js +11 -0
- package/dist/server/edge.js.map +1 -1
- package/dist/server/header-case.d.ts +9 -0
- package/dist/server/header-case.d.ts.map +1 -0
- package/dist/server/header-case.js +54 -0
- package/dist/server/header-case.js.map +1 -0
- package/dist/server/headers.d.ts +8 -0
- package/dist/server/headers.d.ts.map +1 -0
- package/dist/server/headers.js +13 -0
- package/dist/server/headers.js.map +1 -0
- package/dist/server/node-outcome-hook.d.ts.map +1 -1
- package/dist/server/node-outcome-hook.js +7 -11
- package/dist/server/node-outcome-hook.js.map +1 -1
- package/dist/server/node-outcome.d.ts +8 -1
- package/dist/server/node-outcome.d.ts.map +1 -1
- package/dist/server/node-outcome.js +10 -3
- package/dist/server/node-outcome.js.map +1 -1
- package/dist/server/plugin.d.ts +66 -7
- package/dist/server/plugin.d.ts.map +1 -1
- package/dist/server/plugin.js +14 -10
- package/dist/server/plugin.js.map +1 -1
- package/dist/server/proto-guard.d.ts +30 -0
- package/dist/server/proto-guard.d.ts.map +1 -0
- package/dist/server/proto-guard.js +116 -0
- package/dist/server/proto-guard.js.map +1 -0
- package/dist/server/registry.d.ts +6 -2
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +24 -6
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +157 -30
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/respond.d.ts +2 -2
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +34 -8
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +1 -1
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +45 -13
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/response-hooks.d.ts +10 -0
- package/dist/server/response-hooks.d.ts.map +1 -0
- package/dist/server/response-hooks.js +52 -0
- package/dist/server/response-hooks.js.map +1 -0
- package/dist/server/runtime-core.d.ts +12 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js +8 -0
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +60 -6
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +80 -23
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +429 -213
- package/dist/server/server.js.map +1 -1
- package/dist/server/static-headers.d.ts +14 -3
- package/dist/server/static-headers.d.ts.map +1 -1
- package/dist/server/static-headers.js +28 -6
- package/dist/server/static-headers.js.map +1 -1
- package/dist/server/transport-lane.d.ts +8 -0
- package/dist/server/transport-lane.d.ts.map +1 -1
- package/dist/server/transport-lane.js +12 -8
- package/dist/server/transport-lane.js.map +1 -1
- package/dist/server/websocket.d.ts +3 -0
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +14 -0
- package/dist/server/websocket.js.map +1 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +2 -2
- package/dist/server.js.map +1 -1
- package/dist/tool-catalog.d.ts +21 -0
- package/dist/tool-catalog.d.ts.map +1 -0
- package/dist/tool-catalog.js +16 -0
- package/dist/tool-catalog.js.map +1 -0
- package/dist/tool-contract.d.ts +238 -0
- package/dist/tool-contract.d.ts.map +1 -0
- package/dist/tool-contract.js +569 -0
- package/dist/tool-contract.js.map +1 -0
- package/package.json +26 -1
- package/src/assurance.ts +236 -6
- package/src/capabilities.ts +14 -1
- package/src/diff.ts +5 -2
- package/src/errors.ts +1 -0
- package/src/evidence.ts +319 -0
- package/src/execution-policy.ts +57 -0
- package/src/index.ts +1 -1
- package/src/internal/route-assurance.ts +67 -4
- package/src/internal/route-execution.ts +276 -1
- package/src/manifest.ts +27 -5
- package/src/reflection.ts +24 -4
- package/src/replay.ts +96 -0
- package/src/schema/standard.ts +16 -0
- package/src/server/body.ts +275 -7
- package/src/server/context.ts +23 -3
- package/src/server/contract.ts +43 -3
- package/src/server/cookies.ts +80 -10
- package/src/server/edge.ts +10 -0
- package/src/server/header-case.ts +54 -0
- package/src/server/headers.ts +11 -0
- package/src/server/node-outcome-hook.ts +6 -11
- package/src/server/node-outcome.ts +10 -2
- package/src/server/plugin.ts +87 -8
- package/src/server/proto-guard.ts +118 -0
- package/src/server/registry.ts +6 -2
- package/src/server/request-context.ts +242 -26
- package/src/server/respond.ts +35 -4
- package/src/server/response-contract-lane.ts +6 -17
- package/src/server/response-hooks.ts +51 -0
- package/src/server/runtime-core.ts +14 -0
- package/src/server/server-types.ts +66 -6
- package/src/server/server.ts +672 -287
- package/src/server/static-headers.ts +27 -3
- package/src/server/transport-lane.ts +19 -8
- package/src/server/websocket.ts +18 -2
- package/src/server.ts +7 -0
- package/src/tool-catalog.ts +40 -0
- package/src/tool-contract.ts +896 -0
package/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,38 @@ 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
|
+
// A schema route consumes and validates the body through `readBodyInput` before any derive or
|
|
1947
|
+
// handler can reach `c.req`; that lane already enforces `entry.bodyLimit`. Installing the full
|
|
1948
|
+
// direct-reader cap on `c.req` here would allocate bound readers, closures, and a stream wrapper
|
|
1949
|
+
// for a body that is already consumed. Keep the lazy transport cap for raw-body routes, where a
|
|
1950
|
+
// user read is the only framework-owned body boundary.
|
|
1951
|
+
if (entry.bodyLimit !== undefined && entry.schema?.body === undefined) {
|
|
1952
|
+
const method = source.method;
|
|
1953
|
+
if (method !== "GET" && method !== "HEAD")
|
|
1954
|
+
markTransportCap(source, entry.bodyLimit);
|
|
1955
|
+
}
|
|
1834
1956
|
// An idempotency route runs its dedupe lane first; on a fresh key it delegates to the normal lanes
|
|
1835
1957
|
// (with the body buffered). All non-idempotent routes skip straight to the lanes - no added cost.
|
|
1836
1958
|
// The runtime is always present when a route resolved idempotency (enforced at registration).
|
|
1837
1959
|
if (entry.idempotent !== undefined && this.idempotencyRuntime !== undefined) {
|
|
1838
1960
|
return this.idempotencyRuntime.run(entry.idempotent, requestOf(source), platform, entry, params, search, wrapResponse, {
|
|
1839
|
-
maxBodyBytes: this.maxBodyBytes,
|
|
1961
|
+
maxBodyBytes: entry.bodyLimit ?? this.maxBodyBytes,
|
|
1840
1962
|
runLanes: (buffered, plat, ent, prm, srch) => this.idempotencyRunLanes(buffered, plat, ent, prm, srch),
|
|
1841
1963
|
});
|
|
1842
1964
|
}
|
|
@@ -1900,6 +2022,8 @@ export class Server {
|
|
|
1900
2022
|
}
|
|
1901
2023
|
/** The narrowest bare route: a syntactic `() => ...` handler cannot observe the context argument, so
|
|
1902
2024
|
* successful requests can skip allocating `RequestContext`. Errors still allocate one for logging. */
|
|
2025
|
+
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2026
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
|
|
1903
2027
|
runContextlessBare(entry, source, params, search, signal, budget, platform, finalize, wrapResponse) {
|
|
1904
2028
|
let result;
|
|
1905
2029
|
try {
|
|
@@ -1916,7 +2040,7 @@ export class Server {
|
|
|
1916
2040
|
contextlessBareError(err, source, params, search, signal, budget, platform, wrapResponse) {
|
|
1917
2041
|
if (err instanceof Response)
|
|
1918
2042
|
return wrapResponse(err);
|
|
1919
|
-
const ctx = new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes);
|
|
2043
|
+
const ctx = new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes, this.protoPoisoning);
|
|
1920
2044
|
this.logRequestError(err, ctx);
|
|
1921
2045
|
return wrapResponse(jsonError(500, "internal_error"));
|
|
1922
2046
|
}
|
|
@@ -1928,6 +2052,8 @@ export class Server {
|
|
|
1928
2052
|
* (a thrown `Response` is control flow; anything else is a logged flat 500). This is where nifra skips
|
|
1929
2053
|
* the per-request async-frame tax - the same win codegen routers get, but without `eval`.
|
|
1930
2054
|
*/
|
|
2055
|
+
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2056
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
|
|
1931
2057
|
runBare(entry, ctx, finalize, wrapResponse) {
|
|
1932
2058
|
let result;
|
|
1933
2059
|
try {
|
|
@@ -1953,7 +2079,7 @@ export class Server {
|
|
|
1953
2079
|
* decoration order, same error routing (thrown `Response` = control flow; anything else logs and
|
|
1954
2080
|
* 500s), same respond semantics (the lifecycle parity suite pins it).
|
|
1955
2081
|
*/
|
|
1956
|
-
buildFusedWeb(handler, decorations, contextless) {
|
|
2082
|
+
buildFusedWeb(handler, decorations, contextless, maxBodyBytes) {
|
|
1957
2083
|
// The fused lanes return to the runtime directly, so the framework's own error renders fold in
|
|
1958
2084
|
// static headers here rather than through the shared `wrapResponse` seam.
|
|
1959
2085
|
const logError = (err, ctx) => {
|
|
@@ -1973,21 +2099,21 @@ export class Server {
|
|
|
1973
2099
|
}
|
|
1974
2100
|
catch (err) {
|
|
1975
2101
|
return logError(err, nativeContext
|
|
1976
|
-
? RequestContext.native(source, params, search,
|
|
1977
|
-
: new RequestContext(source, params, search, signal, budget, platform, this.
|
|
2102
|
+
? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
|
|
2103
|
+
: new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning));
|
|
1978
2104
|
}
|
|
1979
2105
|
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.
|
|
2106
|
+
return result.then((value) => fusedRespondNoSet(value, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0), (err) => logError(err, nativeContext
|
|
2107
|
+
? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
|
|
2108
|
+
: new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning)));
|
|
1983
2109
|
}
|
|
1984
|
-
return fusedRespondNoSet(result, this.responseBodyTag, this.staticResponseHeaders);
|
|
2110
|
+
return fusedRespondNoSet(result, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0);
|
|
1985
2111
|
};
|
|
1986
2112
|
}
|
|
1987
2113
|
return (source, params, search, signal, budget, platform, nativeContext) => {
|
|
1988
2114
|
const ctx = nativeContext
|
|
1989
|
-
? RequestContext.native(source, params, search,
|
|
1990
|
-
: new RequestContext(source, params, search, signal, budget, platform, this.
|
|
2115
|
+
? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
|
|
2116
|
+
: new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
|
|
1991
2117
|
if (decorations !== undefined)
|
|
1992
2118
|
Object.assign(ctx, decorations);
|
|
1993
2119
|
let result;
|
|
@@ -1998,15 +2124,15 @@ export class Server {
|
|
|
1998
2124
|
return logError(err, ctx);
|
|
1999
2125
|
}
|
|
2000
2126
|
if (result instanceof Promise) {
|
|
2001
|
-
return result.then((value) => fusedRespond(value, ctx, this.responseBodyTag, this.staticResponseHeaders), (err) => logError(err, ctx));
|
|
2127
|
+
return result.then((value) => fusedRespond(value, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0), (err) => logError(err, ctx));
|
|
2002
2128
|
}
|
|
2003
|
-
return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders);
|
|
2129
|
+
return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0);
|
|
2004
2130
|
};
|
|
2005
2131
|
}
|
|
2006
2132
|
/** Compile the eligible body-only route's parser → validator → handler continuation once. The
|
|
2007
2133
|
* bounded parser remains shared with the generic lane; only the route-invariant entry lookups and
|
|
2008
2134
|
* lifecycle dispatch disappear from the common synchronous-validator/synchronous-handler case. */
|
|
2009
|
-
buildFusedBodyRunner(handler, bodySchema, decorations) {
|
|
2135
|
+
buildFusedBodyRunner(handler, bodySchema, decorations, maxBodyBytes) {
|
|
2010
2136
|
const logError = (err, ctx, wrapResponse) => this.bareError(err, ctx, wrapResponse);
|
|
2011
2137
|
const runHandler = (ctx, finalize, wrapResponse) => {
|
|
2012
2138
|
if (decorations !== undefined)
|
|
@@ -2068,16 +2194,16 @@ export class Server {
|
|
|
2068
2194
|
};
|
|
2069
2195
|
return (source, params, search, signal, budget, platform, nativeContext, finalize, wrapResponse) => {
|
|
2070
2196
|
const ctx = nativeContext
|
|
2071
|
-
? RequestContext.native(source, params, search,
|
|
2072
|
-
: new RequestContext(source, params, search, signal, budget, platform, this.
|
|
2197
|
+
? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
|
|
2198
|
+
: new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
|
|
2073
2199
|
const finish = (value) => runParsed(value, ctx, finalize, wrapResponse);
|
|
2074
|
-
return this.readBodyInput(source, finish, wrapResponse, (err) => logError(err, ctx, wrapResponse));
|
|
2200
|
+
return this.readBodyInput(source, maxBodyBytes, finish, wrapResponse, (err) => logError(err, ctx, wrapResponse));
|
|
2075
2201
|
};
|
|
2076
2202
|
}
|
|
2077
2203
|
/** Web adapter wrapper for the shared body runner. Node passes its native finalizer directly through
|
|
2078
2204
|
* the execution plan, while Web needs the live context to preserve lazy `c.set` controls. */
|
|
2079
2205
|
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);
|
|
2206
|
+
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
2207
|
}
|
|
2082
2208
|
/** The fused Web lane for a route whose only lifecycle step is a query schema: parse + validate +
|
|
2083
2209
|
* handler + respond in one closure, no lifecycle promise when the validator and handler are sync.
|
|
@@ -2086,7 +2212,7 @@ export class Server {
|
|
|
2086
2212
|
* `validationError(issues)` (recovery hooks disqualify the route from this lane), a thrown
|
|
2087
2213
|
* `Response` passes through, anything else logs and returns a flat 500, and an async validator
|
|
2088
2214
|
* falls to a then-chain with the same steps. */
|
|
2089
|
-
buildFusedQueryWeb(handler, decorations, querySchema) {
|
|
2215
|
+
buildFusedQueryWeb(handler, decorations, querySchema, maxBodyBytes) {
|
|
2090
2216
|
// The fused lanes return to the runtime directly, so the framework's own error renders fold in
|
|
2091
2217
|
// static headers here rather than through the shared `wrapResponse` seam.
|
|
2092
2218
|
const logError = (err, ctx) => {
|
|
@@ -2105,14 +2231,14 @@ export class Server {
|
|
|
2105
2231
|
return logError(err, ctx);
|
|
2106
2232
|
}
|
|
2107
2233
|
if (result instanceof Promise) {
|
|
2108
|
-
return result.then((settled) => fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders), (err) => logError(err, ctx));
|
|
2234
|
+
return result.then((settled) => fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0), (err) => logError(err, ctx));
|
|
2109
2235
|
}
|
|
2110
|
-
return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders);
|
|
2236
|
+
return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0);
|
|
2111
2237
|
};
|
|
2112
2238
|
return (source, params, search, signal, budget, platform, nativeContext) => {
|
|
2113
2239
|
const ctx = nativeContext
|
|
2114
|
-
? RequestContext.native(source, params, search,
|
|
2115
|
-
: new RequestContext(source, params, search, signal, budget, platform, this.
|
|
2240
|
+
? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
|
|
2241
|
+
: new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
|
|
2116
2242
|
if (decorations !== undefined)
|
|
2117
2243
|
Object.assign(ctx, decorations);
|
|
2118
2244
|
let validation;
|
|
@@ -2138,6 +2264,8 @@ export class Server {
|
|
|
2138
2264
|
this.logRequestError(err, ctx);
|
|
2139
2265
|
return wrapResponse(jsonError(500, "internal_error"));
|
|
2140
2266
|
}
|
|
2267
|
+
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2268
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2141
2269
|
runWithAround(entry, ctx, run, finalize, wrapResponse) {
|
|
2142
2270
|
let outcome;
|
|
2143
2271
|
try {
|
|
@@ -2165,36 +2293,26 @@ export class Server {
|
|
|
2165
2293
|
};
|
|
2166
2294
|
return dispatch(0);
|
|
2167
2295
|
}
|
|
2296
|
+
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2297
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2168
2298
|
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));
|
|
2299
|
+
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
2300
|
}
|
|
2171
2301
|
/** Shared bounded body framing/parser. Both body lanes use the same trust-boundary checks; only
|
|
2172
2302
|
* the validation + handler continuation differs. */
|
|
2173
|
-
readBodyInput(source, onParsed, wrapResponse, onError) {
|
|
2303
|
+
readBodyInput(source, maxBodyBytes, onParsed, wrapResponse, onError) {
|
|
2174
2304
|
const contentType = headerOf(source, "content-type") ?? "";
|
|
2175
2305
|
if (contentType !== "application/json" && !contentType.includes("application/json")) {
|
|
2176
2306
|
if (isUrlEncodedForm(contentType)) {
|
|
2177
|
-
return readBoundedForm(source,
|
|
2307
|
+
return readBoundedForm(source, maxBodyBytes).then((form) => (form instanceof Response ? wrapResponse(form) : onParsed(form)), onError);
|
|
2178
2308
|
}
|
|
2179
2309
|
return Promise.resolve(wrapResponse(jsonError(415, "unsupported_media_type")));
|
|
2180
2310
|
}
|
|
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
|
-
}
|
|
2311
|
+
// All JSON bodies go through readBoundedJson - the single enforcement point for the framed
|
|
2312
|
+
// fast path (post-read byte re-check), the streaming cap, and the prototype-poisoning guard.
|
|
2313
|
+
// A separate inlined native-json() fast path here would fork the trust boundary in two.
|
|
2196
2314
|
try {
|
|
2197
|
-
return this.readBoundedJson(source
|
|
2315
|
+
return this.readBoundedJson(source, maxBodyBytes, (parsed) => (parsed instanceof Response ? wrapResponse(parsed) : onParsed(parsed)), onError);
|
|
2198
2316
|
}
|
|
2199
2317
|
catch (err) {
|
|
2200
2318
|
return Promise.resolve(onError(err));
|
|
@@ -2296,6 +2414,8 @@ export class Server {
|
|
|
2296
2414
|
ctx.body = result.value;
|
|
2297
2415
|
return this.executeHandler(entry, ctx, finalize);
|
|
2298
2416
|
}
|
|
2417
|
+
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2418
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2299
2419
|
runQueryOnly(entry, ctx, finalize, wrapResponse) {
|
|
2300
2420
|
try {
|
|
2301
2421
|
// Call the validator directly for the raw StandardResult (read `.issues`/`.value`) - skip
|
|
@@ -2446,8 +2566,18 @@ export class Server {
|
|
|
2446
2566
|
* node-direct primitives); an early/error `Response` (validation, thrown, 500) through `wrapResponse`.
|
|
2447
2567
|
*/
|
|
2448
2568
|
/** Synchronous until a validator, lifecycle hook, handler, or contract check actually returns a Promise. */
|
|
2569
|
+
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2570
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2449
2571
|
runLifecycle(entry, source, ctx, finalize, wrapResponse) {
|
|
2450
2572
|
try {
|
|
2573
|
+
const headersSchema = entry.schema?.headers;
|
|
2574
|
+
if (headersSchema !== undefined) {
|
|
2575
|
+
const validation = headersSchema["~standard"].validate(headerObjectOf(source.headers));
|
|
2576
|
+
if (validation instanceof Promise) {
|
|
2577
|
+
return validation.then((result) => this.runLifecycleAfterHeadersResult(entry, source, ctx, finalize, wrapResponse, result), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
|
|
2578
|
+
}
|
|
2579
|
+
return this.runLifecycleAfterHeadersResult(entry, source, ctx, finalize, wrapResponse, validation);
|
|
2580
|
+
}
|
|
2451
2581
|
const paramsSchema = entry.schema?.params;
|
|
2452
2582
|
if (paramsSchema !== undefined) {
|
|
2453
2583
|
const validation = paramsSchema["~standard"].validate(ctx.params);
|
|
@@ -2469,6 +2599,35 @@ export class Server {
|
|
|
2469
2599
|
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
|
|
2470
2600
|
}
|
|
2471
2601
|
}
|
|
2602
|
+
runLifecycleAfterHeadersResult(entry, source, ctx, finalize, wrapResponse, result) {
|
|
2603
|
+
try {
|
|
2604
|
+
const headersError = this.applyLifecycleValidation(entry, result, ctx, "headers");
|
|
2605
|
+
if (headersError instanceof Promise) {
|
|
2606
|
+
return headersError.then((error) => error === undefined
|
|
2607
|
+
? this.runLifecycleAfterHeaders(entry, source, ctx, finalize, wrapResponse)
|
|
2608
|
+
: wrapResponse(error), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
|
|
2609
|
+
}
|
|
2610
|
+
return headersError === undefined
|
|
2611
|
+
? this.runLifecycleAfterHeaders(entry, source, ctx, finalize, wrapResponse)
|
|
2612
|
+
: wrapResponse(headersError);
|
|
2613
|
+
}
|
|
2614
|
+
catch (err) {
|
|
2615
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
runLifecycleAfterHeaders(entry, source, ctx, finalize, wrapResponse) {
|
|
2619
|
+
const paramsSchema = entry.schema?.params;
|
|
2620
|
+
if (paramsSchema === undefined) {
|
|
2621
|
+
if (entry.schema?.body === undefined)
|
|
2622
|
+
return this.runQueryAndLifecycle(entry, ctx, finalize, wrapResponse);
|
|
2623
|
+
return this.runLifecycleAfterParams(entry, source, ctx, finalize, wrapResponse, undefined);
|
|
2624
|
+
}
|
|
2625
|
+
const validation = paramsSchema["~standard"].validate(ctx.params);
|
|
2626
|
+
if (validation instanceof Promise) {
|
|
2627
|
+
return validation.then((result) => this.runLifecycleAfterParamsResult(entry, source, ctx, finalize, wrapResponse, result), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
|
|
2628
|
+
}
|
|
2629
|
+
return this.runLifecycleAfterParamsResult(entry, source, ctx, finalize, wrapResponse, validation);
|
|
2630
|
+
}
|
|
2472
2631
|
/** Registration-specialized query lifecycle: query validation (including recovery) → hooks. */
|
|
2473
2632
|
runLifecycleQuery(entry, ctx, finalize, wrapResponse) {
|
|
2474
2633
|
try {
|
|
@@ -2492,12 +2651,16 @@ export class Server {
|
|
|
2492
2651
|
}
|
|
2493
2652
|
}
|
|
2494
2653
|
/** Registration-specialized body lifecycle: bounded body validation (including recovery) → hooks. */
|
|
2654
|
+
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2655
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2495
2656
|
runLifecycleBody(entry, source, ctx, finalize, wrapResponse) {
|
|
2496
2657
|
return this.readAndValidateBody(source, entry, ctx).then((bodyError) => bodyError === undefined
|
|
2497
2658
|
? this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
|
|
2498
2659
|
: wrapResponse(bodyError), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
|
|
2499
2660
|
}
|
|
2500
2661
|
/** Registration-specialized body + query lifecycle: bounded body validation → query validation → hooks. */
|
|
2662
|
+
// @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2663
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
|
|
2501
2664
|
runLifecycleBodyQuery(entry, source, ctx, finalize, wrapResponse) {
|
|
2502
2665
|
return this.readAndValidateBody(source, entry, ctx).then((bodyError) => bodyError === undefined
|
|
2503
2666
|
? this.runLifecycleQuery(entry, ctx, finalize, wrapResponse)
|
|
@@ -2652,8 +2815,12 @@ export class Server {
|
|
|
2652
2815
|
}
|
|
2653
2816
|
async runLifecycleHooksAsync(entry, ctx, finalize, wrapResponse) {
|
|
2654
2817
|
try {
|
|
2655
|
-
|
|
2656
|
-
|
|
2818
|
+
// Indexed, not `for...of`. An array iterator declared inside an async function stays live
|
|
2819
|
+
// across the `await` below, so neither engine can sink it - unlike the sync lanes above,
|
|
2820
|
+
// where V8 elides it. Measured cost of the iterator form: 43ns per hook on Bun, ~9ns on
|
|
2821
|
+
// Node, on a chain whose hooks return synchronously (the common shape).
|
|
2822
|
+
for (let i = 0; i < entry.beforeHandle.length; i++) {
|
|
2823
|
+
const outcome = entry.beforeHandle[i](ctx);
|
|
2657
2824
|
const early = outcome instanceof Promise ? await outcome : outcome;
|
|
2658
2825
|
if (early !== undefined)
|
|
2659
2826
|
return finalize(early, responseSet(ctx));
|
|
@@ -2761,8 +2928,10 @@ export class Server {
|
|
|
2761
2928
|
if (err instanceof Response)
|
|
2762
2929
|
return wrapResponse(err);
|
|
2763
2930
|
// onError hooks may return a custom response; otherwise the default 500 stands.
|
|
2764
|
-
for
|
|
2765
|
-
|
|
2931
|
+
// Indexed for the same reason as the `beforeHandle` chain above: the iterator would outlive
|
|
2932
|
+
// the `await` and stay heap-allocated on both engines.
|
|
2933
|
+
for (let i = 0; i < entry.onError.length; i++) {
|
|
2934
|
+
const outcome = entry.onError[i](err, ctx);
|
|
2766
2935
|
const handled = outcome instanceof Promise ? await outcome : outcome;
|
|
2767
2936
|
if (handled !== undefined)
|
|
2768
2937
|
return finalize(handled, responseSet(ctx));
|
|
@@ -2775,6 +2944,11 @@ export class Server {
|
|
|
2775
2944
|
/** Log an unhandled request error to the (redacting) logger - shared by {@link runLifecycle} and the
|
|
2776
2945
|
* bare fast path ({@link bareError}) so both record the same fields. Never throws; never leaks. */
|
|
2777
2946
|
logRequestError(err, ctx) {
|
|
2947
|
+
// An error's own text can quote the input that produced it, so how much of it reaches the sink is
|
|
2948
|
+
// the app's call (`errorLogDetail`). The default keeps it: a 500 with no message and no stack is
|
|
2949
|
+
// an incident nobody can diagnose, and the framework already ships the narrower instrument for
|
|
2950
|
+
// the leak - a redacting logger with `valuePatterns`. `"none"` is there for an untrusted sink.
|
|
2951
|
+
const detail = this.errorLogDetail;
|
|
2778
2952
|
this.logger.error("unhandled request error", {
|
|
2779
2953
|
method: ctx.req.method,
|
|
2780
2954
|
path: pathnameOf(ctx.req.url),
|
|
@@ -2782,21 +2956,21 @@ export class Server {
|
|
|
2782
2956
|
// `detail`, not `message`: the logger uses `message` for its own first argument, so a field of
|
|
2783
2957
|
// that name is silently overwritten and the thrown error's own text never reaches the sink. It
|
|
2784
2958
|
// survived only incidentally inside `stack`, and was lost outright for a non-Error throw.
|
|
2785
|
-
detail: err instanceof Error ? err.message : String(err),
|
|
2786
|
-
|
|
2959
|
+
...(detail === "none" ? {} : { detail: err instanceof Error ? err.message : String(err) }),
|
|
2960
|
+
...(detail === "full" && err instanceof Error ? { stack: err.stack } : {}),
|
|
2787
2961
|
});
|
|
2788
2962
|
}
|
|
2789
2963
|
async readAndValidateBody(req, entry, ctx) {
|
|
2790
2964
|
const contentType = headerOf(req, "content-type") ?? "";
|
|
2791
2965
|
let parsed;
|
|
2792
2966
|
if (contentType === "application/json" || contentType.includes("application/json")) {
|
|
2793
|
-
const json = await this.readBoundedJson(req);
|
|
2967
|
+
const json = await this.readBoundedJson(req, entry.bodyLimit ?? this.maxBodyBytes);
|
|
2794
2968
|
if (json instanceof Response)
|
|
2795
2969
|
return json;
|
|
2796
2970
|
parsed = json;
|
|
2797
2971
|
}
|
|
2798
2972
|
else if (isUrlEncodedForm(contentType)) {
|
|
2799
|
-
const form = await readBoundedForm(req, this.maxBodyBytes);
|
|
2973
|
+
const form = await readBoundedForm(req, entry.bodyLimit ?? this.maxBodyBytes);
|
|
2800
2974
|
if (form instanceof Response)
|
|
2801
2975
|
return form;
|
|
2802
2976
|
parsed = form;
|
|
@@ -2818,6 +2992,8 @@ export class Server {
|
|
|
2818
2992
|
ctx.body = value;
|
|
2819
2993
|
else if (kind === "query")
|
|
2820
2994
|
ctx.query = value;
|
|
2995
|
+
else if (kind === "headers")
|
|
2996
|
+
ctx.headers = value;
|
|
2821
2997
|
else
|
|
2822
2998
|
ctx.params = value;
|
|
2823
2999
|
};
|
|
@@ -2843,7 +3019,9 @@ export class Server {
|
|
|
2843
3019
|
? entry.schema?.body
|
|
2844
3020
|
: kind === "query"
|
|
2845
3021
|
? entry.schema?.query
|
|
2846
|
-
:
|
|
3022
|
+
: kind === "params"
|
|
3023
|
+
? entry.schema?.params
|
|
3024
|
+
: entry.schema?.headers;
|
|
2847
3025
|
const retried = schema["~standard"].validate(recovery);
|
|
2848
3026
|
if (retried instanceof Promise) {
|
|
2849
3027
|
return retried.then((settled) => {
|
|
@@ -2858,42 +3036,59 @@ export class Server {
|
|
|
2858
3036
|
assign(retried.value);
|
|
2859
3037
|
return undefined;
|
|
2860
3038
|
}
|
|
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);
|
|
3039
|
+
readBoundedJson(req, maxBodyBytes = this.maxBodyBytes, onResult, onError) {
|
|
3040
|
+
// Not `async`: this is a pass-through, and wrapping the lane's own promise in a coroutine
|
|
3041
|
+
// frame costs an extra microtask hop on every JSON body.
|
|
3042
|
+
return onResult === undefined
|
|
3043
|
+
? readBoundedJsonSource(req, maxBodyBytes, this.protoPoisoning)
|
|
3044
|
+
: readBoundedJsonSource(req, maxBodyBytes, this.protoPoisoning, onResult, onError);
|
|
2877
3045
|
}
|
|
2878
3046
|
/** Adapt one route's compiled execution plan to Bun's already-matched request shape. Route
|
|
2879
3047
|
* semantics remain in the plan; this closure only supplies native params and deadline fallback. */
|
|
2880
3048
|
compileBunNativeHandler(entry, paramNames, fused, signal, budget) {
|
|
3049
|
+
// This callback is reached only from Bun's compiled native route table. Bun has already parsed
|
|
3050
|
+
// the HTTP framing, so the JSON lane may retain its native fused `json()` parse without weakening
|
|
3051
|
+
// the portable/adapted source contract (those sources are never marked).
|
|
3052
|
+
const markFramed = (request) => markTrustedBodyFraming(request);
|
|
3053
|
+
// The fused native lane bypasses `runMatched`, so the route's transport byte cap must be
|
|
3054
|
+
// marked here too - otherwise a Bun `listen()` fused route would leave direct `c.req` body
|
|
3055
|
+
// reads uncapped. The non-fused branches go through `fetchMatched` -> `runMatched`, which marks.
|
|
3056
|
+
const bodyLimit = entry.schema?.body === undefined ? entry.bodyLimit : undefined;
|
|
3057
|
+
const inner = fused;
|
|
3058
|
+
const capped = inner === undefined || bodyLimit === undefined
|
|
3059
|
+
? inner
|
|
3060
|
+
: (source, params, search, fusedSignal, fusedBudget, platform, nativeContext) => {
|
|
3061
|
+
const method = source.method;
|
|
3062
|
+
if (method !== "GET" && method !== "HEAD")
|
|
3063
|
+
markTransportCap(source, bodyLimit);
|
|
3064
|
+
return inner(source, params, search, fusedSignal, fusedBudget, platform, nativeContext);
|
|
3065
|
+
};
|
|
2881
3066
|
if (paramNames.length === 0) {
|
|
2882
|
-
if (
|
|
2883
|
-
return (request) =>
|
|
3067
|
+
if (capped === undefined) {
|
|
3068
|
+
return (request) => {
|
|
3069
|
+
markFramed(request);
|
|
3070
|
+
return this.fetchMatched(request, entry, EMPTY_PARAMS);
|
|
3071
|
+
};
|
|
2884
3072
|
}
|
|
2885
3073
|
if (this.acceptInboundDeadlines) {
|
|
2886
|
-
return (request) =>
|
|
2887
|
-
|
|
2888
|
-
|
|
3074
|
+
return (request) => {
|
|
3075
|
+
markFramed(request);
|
|
3076
|
+
return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
|
|
3077
|
+
? this.fetchMatched(request, entry, EMPTY_PARAMS)
|
|
3078
|
+
: capped(request, EMPTY_PARAMS, undefined, signal, budget, undefined, true);
|
|
3079
|
+
};
|
|
2889
3080
|
}
|
|
2890
|
-
return (request) =>
|
|
3081
|
+
return (request) => {
|
|
3082
|
+
markFramed(request);
|
|
3083
|
+
return capped(request, EMPTY_PARAMS, undefined, signal, budget, undefined, true);
|
|
3084
|
+
};
|
|
2891
3085
|
}
|
|
2892
3086
|
const malformed = paramNames.length === 1
|
|
2893
3087
|
? (params) => params[paramNames[0]]?.includes("\uFFFD") === true
|
|
2894
3088
|
: hasReplacementParam;
|
|
2895
|
-
if (
|
|
3089
|
+
if (capped === undefined) {
|
|
2896
3090
|
return (request) => {
|
|
3091
|
+
markFramed(request);
|
|
2897
3092
|
const params = request.params ?? EMPTY_PARAMS;
|
|
2898
3093
|
if (malformed(params))
|
|
2899
3094
|
return this.fetchSource(request);
|
|
@@ -2902,19 +3097,21 @@ export class Server {
|
|
|
2902
3097
|
}
|
|
2903
3098
|
if (this.acceptInboundDeadlines) {
|
|
2904
3099
|
return (request) => {
|
|
3100
|
+
markFramed(request);
|
|
2905
3101
|
const params = request.params ?? EMPTY_PARAMS;
|
|
2906
3102
|
if (malformed(params))
|
|
2907
3103
|
return this.fetchSource(request);
|
|
2908
3104
|
return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
|
|
2909
3105
|
? this.fetchMatched(request, entry, params)
|
|
2910
|
-
:
|
|
3106
|
+
: capped(request, params, undefined, signal, budget, undefined, true);
|
|
2911
3107
|
};
|
|
2912
3108
|
}
|
|
2913
3109
|
return (request) => {
|
|
3110
|
+
markFramed(request);
|
|
2914
3111
|
const params = request.params ?? EMPTY_PARAMS;
|
|
2915
3112
|
if (malformed(params))
|
|
2916
3113
|
return this.fetchSource(request);
|
|
2917
|
-
return
|
|
3114
|
+
return capped(request, params, undefined, signal, budget, undefined, true);
|
|
2918
3115
|
};
|
|
2919
3116
|
}
|
|
2920
3117
|
/** Compile portable route registrations into Bun's native route table. Apps with request-rewrite
|
|
@@ -2980,6 +3177,13 @@ export class Server {
|
|
|
2980
3177
|
* `"127.0.0.1"` to bind loopback only - an admin surface, a sidecar, or anything that must not be
|
|
2981
3178
|
* reachable off the box. Omitting it when you meant to restrict is a real exposure, so it is a
|
|
2982
3179
|
* first-class option rather than something a caller has to drop down to `Bun.serve` for.
|
|
3180
|
+
*
|
|
3181
|
+
* `idleTimeoutSec` is how long Bun tolerates a connection with no bytes moving before it closes
|
|
3182
|
+
* the socket. Bun's default is **10 seconds**, and a request whose handler is still working sends
|
|
3183
|
+
* nothing - so any endpoint slower than 10s (a render, an export, a long upstream call) has its
|
|
3184
|
+
* connection cut mid-flight regardless of `requestTimeoutMs`. Apps with such endpoints must raise
|
|
3185
|
+
* this above their slowest expected response, which is why it is a first-class option and not a
|
|
3186
|
+
* reason to drop down to `Bun.serve`. `0` disables the timeout entirely; max 255.
|
|
2983
3187
|
*/
|
|
2984
3188
|
listen(port, options) {
|
|
2985
3189
|
if (typeof Bun === "undefined") {
|
|
@@ -3006,12 +3210,15 @@ export class Server {
|
|
|
3006
3210
|
// Spread rather than pass `hostname: undefined` - Bun treats an explicit undefined as a value
|
|
3007
3211
|
// on some option paths, and omitting is what selects its 0.0.0.0 default.
|
|
3008
3212
|
const bind = options?.hostname === undefined ? {} : { hostname: options.hostname };
|
|
3213
|
+
// Same reasoning as `bind`: omit rather than pass undefined, so Bun's own default applies.
|
|
3214
|
+
const idle = options?.idleTimeoutSec === undefined ? {} : { idleTimeout: options.idleTimeoutSec };
|
|
3009
3215
|
const nativeRoutes = wsHandlers === undefined ? this.buildBunNativeRoutes() : undefined;
|
|
3010
3216
|
const running = (wsHandlers === undefined
|
|
3011
3217
|
? Bun.serve({
|
|
3012
3218
|
port,
|
|
3013
3219
|
reusePort,
|
|
3014
3220
|
...bind,
|
|
3221
|
+
...idle,
|
|
3015
3222
|
...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
|
|
3016
3223
|
fetch: (req, server) => this.fetch(req, bunPeerPlatform(server, req)),
|
|
3017
3224
|
})
|
|
@@ -3019,6 +3226,7 @@ export class Server {
|
|
|
3019
3226
|
port,
|
|
3020
3227
|
reusePort,
|
|
3021
3228
|
...bind,
|
|
3229
|
+
...idle,
|
|
3022
3230
|
fetch: (req, server) => this.bunFetchWithWebSocket(req, server),
|
|
3023
3231
|
// Bun's `ServerWebSocket<BunWsData>` is runtime-compatible with the handlers' structural
|
|
3024
3232
|
// `BunSocket` view (kept local so `Bun.*` types never leak into the published .d.ts); the
|
|
@@ -3059,6 +3267,14 @@ export class Server {
|
|
|
3059
3267
|
await Bun.sleep(DRAIN_POLL_MS);
|
|
3060
3268
|
}
|
|
3061
3269
|
server.stop(server.pendingRequests > 0); // force-close iff stragglers remain past the deadline
|
|
3270
|
+
if (this.stopHooks.length === 0)
|
|
3271
|
+
return;
|
|
3272
|
+
const callbacks = this.stopHooks.map((hook) => Promise.resolve().then(hook));
|
|
3273
|
+
const settled = Promise.allSettled(callbacks);
|
|
3274
|
+
await Promise.race([
|
|
3275
|
+
settled,
|
|
3276
|
+
new Promise((resolve) => setTimeout(resolve, STOP_HOOK_TIMEOUT_MS)),
|
|
3277
|
+
]);
|
|
3062
3278
|
}
|
|
3063
3279
|
installSignalHandlers() {
|
|
3064
3280
|
// Drain, then let the process exit naturally - the stopped server no longer
|