@nifrajs/core 4.0.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -2
- package/dist/assurance.d.ts +71 -5
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +90 -25
- package/dist/assurance.js.map +1 -1
- package/dist/capabilities.d.ts +6 -0
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +52 -0
- package/dist/capabilities.js.map +1 -1
- package/dist/durable-effect.js +1 -1
- package/dist/durable-effect.js.map +1 -1
- package/dist/durable-saga.d.ts +6 -0
- package/dist/durable-saga.d.ts.map +1 -1
- package/dist/durable-saga.js +83 -10
- package/dist/durable-saga.js.map +1 -1
- package/dist/edge-kit.d.ts +7 -2
- package/dist/edge-kit.d.ts.map +1 -1
- package/dist/edge-kit.js +7 -2
- package/dist/edge-kit.js.map +1 -1
- package/dist/effect-scope.d.ts.map +1 -1
- package/dist/effect-scope.js +16 -5
- package/dist/effect-scope.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 +2 -0
- package/dist/evidence.d.ts.map +1 -1
- package/dist/evidence.js +9 -1
- package/dist/evidence.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/internal/durable-shared.d.ts +1 -2
- package/dist/internal/durable-shared.d.ts.map +1 -1
- package/dist/internal/durable-shared.js +25 -44
- package/dist/internal/durable-shared.js.map +1 -1
- package/dist/internal/durable-types.d.ts +3 -0
- package/dist/internal/durable-types.d.ts.map +1 -1
- package/dist/internal/hook-origins.d.ts +67 -0
- package/dist/internal/hook-origins.d.ts.map +1 -0
- package/dist/internal/hook-origins.js +86 -0
- package/dist/internal/hook-origins.js.map +1 -0
- package/dist/internal/route-assurance.d.ts +6 -3
- package/dist/internal/route-assurance.d.ts.map +1 -1
- package/dist/internal/route-assurance.js +29 -14
- package/dist/internal/route-assurance.js.map +1 -1
- package/dist/internal/route-program.d.ts +1 -1
- package/dist/internal/route-program.d.ts.map +1 -1
- package/dist/internal/route-program.js +59 -16
- package/dist/internal/route-program.js.map +1 -1
- package/dist/reconciliation-worker.d.ts.map +1 -1
- package/dist/reconciliation-worker.js +12 -2
- package/dist/reconciliation-worker.js.map +1 -1
- package/dist/reflection.d.ts +53 -2
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +352 -5
- package/dist/reflection.js.map +1 -1
- package/dist/router/pattern.d.ts +1 -1
- package/dist/router/pattern.d.ts.map +1 -1
- package/dist/router/pattern.js +6 -3
- package/dist/router/pattern.js.map +1 -1
- package/dist/security/baseline.d.ts +3 -0
- package/dist/security/baseline.d.ts.map +1 -1
- package/dist/security/baseline.js +9 -5
- package/dist/security/baseline.js.map +1 -1
- package/dist/server/auth.d.ts +32 -3
- package/dist/server/auth.d.ts.map +1 -1
- package/dist/server/auth.js +23 -0
- package/dist/server/auth.js.map +1 -1
- package/dist/server/body.d.ts +7 -0
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +34 -1
- package/dist/server/body.js.map +1 -1
- package/dist/server/client-ip.d.ts +3 -0
- package/dist/server/client-ip.d.ts.map +1 -1
- package/dist/server/client-ip.js.map +1 -1
- package/dist/server/context.d.ts +2 -1
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/idempotency-lane.d.ts +6 -3
- package/dist/server/idempotency-lane.d.ts.map +1 -1
- package/dist/server/idempotency-lane.js +38 -17
- package/dist/server/idempotency-lane.js.map +1 -1
- package/dist/server/install.d.ts +2 -0
- package/dist/server/install.d.ts.map +1 -1
- package/dist/server/install.js +2 -0
- package/dist/server/install.js.map +1 -1
- package/dist/server/logger.d.ts.map +1 -1
- package/dist/server/logger.js +3 -1
- package/dist/server/logger.js.map +1 -1
- package/dist/server/methods.d.ts +1 -0
- package/dist/server/methods.d.ts.map +1 -1
- package/dist/server/methods.js +1 -0
- package/dist/server/methods.js.map +1 -1
- package/dist/server/node-outcome.d.ts.map +1 -1
- package/dist/server/node-outcome.js +29 -11
- package/dist/server/node-outcome.js.map +1 -1
- package/dist/server/platform-adapters.d.ts.map +1 -1
- package/dist/server/platform-adapters.js +3 -2
- package/dist/server/platform-adapters.js.map +1 -1
- package/dist/server/request-context.d.ts +2 -1
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +4 -1
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/respond.d.ts +44 -11
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +192 -79
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-observer-runtime.d.ts.map +1 -1
- package/dist/server/response-observer-runtime.js +10 -41
- package/dist/server/response-observer-runtime.js.map +1 -1
- package/dist/server/runtime-core.d.ts +2 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js +2 -0
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +29 -5
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +44 -31
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +444 -321
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse.d.ts +11 -1
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +18 -3
- package/dist/server/sse.js.map +1 -1
- package/dist/server/transport-lane.d.ts +7 -1
- package/dist/server/transport-lane.d.ts.map +1 -1
- package/dist/server/transport-lane.js.map +1 -1
- package/dist/server/webhook.d.ts +5 -2
- package/dist/server/webhook.d.ts.map +1 -1
- package/dist/server/webhook.js +17 -1
- package/dist/server/webhook.js.map +1 -1
- package/dist/server/websocket.d.ts +22 -14
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +21 -0
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-hook.d.ts +7 -1
- package/dist/server/ws-hook.d.ts.map +1 -1
- package/dist/server.d.ts +3 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +2 -1
- package/dist/server.js.map +1 -1
- package/dist/single-copy.d.ts +6 -0
- package/dist/single-copy.d.ts.map +1 -1
- package/dist/single-copy.js +20 -1
- package/dist/single-copy.js.map +1 -1
- package/dist/tool-contract.d.ts.map +1 -1
- package/dist/tool-contract.js +71 -40
- package/dist/tool-contract.js.map +1 -1
- package/dist/ws.d.ts.map +1 -1
- package/dist/ws.js +3 -1
- package/dist/ws.js.map +1 -1
- package/package.json +1 -1
- package/src/assurance.ts +180 -31
- package/src/capabilities.ts +59 -0
- package/src/durable-effect.ts +1 -1
- package/src/durable-saga.ts +94 -10
- package/src/edge-kit.ts +7 -2
- package/src/effect-scope.ts +15 -5
- package/src/errors.ts +1 -0
- package/src/evidence.ts +12 -3
- package/src/index.ts +6 -1
- package/src/internal/durable-shared.ts +25 -37
- package/src/internal/durable-types.ts +3 -0
- package/src/internal/hook-origins.ts +153 -0
- package/src/internal/route-assurance.ts +32 -18
- package/src/internal/route-program.ts +98 -44
- package/src/reconciliation-worker.ts +10 -2
- package/src/reflection.ts +455 -4
- package/src/router/pattern.ts +6 -3
- package/src/security/baseline.ts +10 -5
- package/src/server/auth.ts +45 -3
- package/src/server/body.ts +34 -1
- package/src/server/client-ip.ts +3 -0
- package/src/server/context.ts +2 -1
- package/src/server/idempotency-lane.ts +64 -22
- package/src/server/install.ts +3 -0
- package/src/server/logger.ts +3 -1
- package/src/server/methods.ts +1 -0
- package/src/server/node-outcome.ts +34 -12
- package/src/server/platform-adapters.ts +3 -2
- package/src/server/request-context.ts +5 -0
- package/src/server/respond.ts +222 -69
- package/src/server/response-observer-runtime.ts +16 -38
- package/src/server/runtime-core.ts +2 -0
- package/src/server/server-types.ts +29 -5
- package/src/server/server.ts +659 -530
- package/src/server/sse.ts +84 -55
- package/src/server/transport-lane.ts +7 -1
- package/src/server/webhook.ts +22 -3
- package/src/server/websocket.ts +44 -5
- package/src/server/ws-hook.ts +5 -0
- package/src/server.ts +5 -0
- package/src/single-copy.ts +20 -1
- package/src/tool-contract.ts +67 -39
- package/src/ws.ts +4 -0
package/dist/server/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { admitDeadline, createRequestBudget, createUnboundedRequestBudget, NIFRA_DEADLINE_HEADER, } from "../budget.js";
|
|
2
2
|
import { FrameworkError, RouteConfigError } from "../errors.js";
|
|
3
3
|
import { DEFAULT_CAPABILITY_INTERCEPTOR_TIMEOUT_MS, } from "../internal/capability-runtime.js";
|
|
4
|
+
import { adoptStaticTags, enterPlugin, leavePlugin, tagAdoption, tagHook, tagScoped, tagStatic, } from "../internal/hook-origins.js";
|
|
4
5
|
import { assuranceDeclarationsOf, assuranceEvidenceFor, } from "../internal/route-assurance.js";
|
|
5
6
|
import { RouteCatalog } from "../internal/route-catalog.js";
|
|
6
7
|
import { compileRouteOptions } from "../internal/route-compiler.js";
|
|
@@ -21,12 +22,12 @@ import { queryObjectOf, searchOf } from "./query.js";
|
|
|
21
22
|
import { PLATFORM_PEER, RequestContext, readBodyFramed, } from "./request-context.js";
|
|
22
23
|
import { recordRequestReplacement } from "./request-lineage.js";
|
|
23
24
|
import { hasDotSegment, resolveDotSegments } from "./request-target.js";
|
|
24
|
-
import { applyStaticResponseHeaders, buildStaticResponseHeaders, fusedRespond, fusedRespondNoSet, toResponse, } from "./respond.js";
|
|
25
|
-
import { CONTEXT_SEARCH, CONTEXT_SET, EMPTY_RESPONSE_CONTROLS, getNeverAbortSignal, getUnboundedRequestBudget, headerOf, isResponseResult, requestOf, } from "./runtime-core.js";
|
|
25
|
+
import { applyStaticResponseHeaders, buildStaticResponseHeaders, fusedRespond, fusedRespondNoSet, toResponse, writableResponse, } from "./respond.js";
|
|
26
|
+
import { CONTEXT_REQUEST, CONTEXT_SEARCH, CONTEXT_SET, EMPTY_RESPONSE_CONTROLS, getNeverAbortSignal, getUnboundedRequestBudget, headerOf, isResponseResult, requestOf, } from "./runtime-core.js";
|
|
26
27
|
import { normalizeStaticResponseHeaders } from "./static-headers.js";
|
|
27
28
|
import { plainValidationError } from "./validation.js";
|
|
28
29
|
import { NIFRA_BACKEND_WS_RUNTIME } from "../mount.js";
|
|
29
|
-
import { GET_WS_RUNTIME, INSTALL_EFFECT_LEDGER, INSTALL_IDEMPOTENCY, INSTALL_MCP, INSTALL_NODE_DIRECT, INSTALL_NOT_FOUND, INSTALL_RESPONSE_CONTRACT, INSTALL_RESPONSE_OBSERVER, INSTALL_SSE, INSTALL_WS, NODE_NATIVE_MOUNT, RESOLVE_NODE_MOUNT, } from "./install.js";
|
|
30
|
+
import { GET_WS_RUNTIME, INSTALL_EFFECT_LEDGER, INSTALL_IDEMPOTENCY, INSTALL_MCP, INSTALL_NODE_DIRECT, INSTALL_NOT_FOUND, INSTALL_RESPONSE_CONTRACT, INSTALL_RESPONSE_OBSERVER, INSTALL_SSE, INSTALL_WS, NODE_NATIVE_MOUNT, REPORT_ERROR, RESOLVE_NODE_MOUNT, } from "./install.js";
|
|
30
31
|
import { jsonLogger } from "./logger.js";
|
|
31
32
|
// CORS uses this marker to say its request hook only handles OPTIONS preflight. Bun can keep
|
|
32
33
|
// ordinary registered routes on its native table and send unmatched OPTIONS requests through the
|
|
@@ -214,11 +215,16 @@ const underPrefix = (prefix) => {
|
|
|
214
215
|
};
|
|
215
216
|
};
|
|
216
217
|
/** A merged server's hook scope: exactly the routes MERGED, snapshotted into a dedicated matcher so
|
|
217
|
-
* the guard never reflects whatever the source's own catalog grows into afterwards.
|
|
218
|
+
* the guard never reflects whatever the source's own catalog grows into afterwards. An `OPTIONS`
|
|
219
|
+
* request to a path they serve is in scope too: a CORS preflight names the path, not a route, and
|
|
220
|
+
* the group's `cors()` is the only hook that can answer it. */
|
|
218
221
|
const servedBy = (routes) => {
|
|
219
222
|
const scope = new RouteCatalog();
|
|
220
223
|
scope.addBatch(routes);
|
|
221
|
-
return (req) =>
|
|
224
|
+
return (req) => {
|
|
225
|
+
const match = scope.find(req.method, pathnameOf(req.url));
|
|
226
|
+
return match.found || (req.method === "OPTIONS" && match.reason === "method-not-allowed");
|
|
227
|
+
};
|
|
222
228
|
};
|
|
223
229
|
/** Gate adopted hooks to `inScope` (`undefined`: adopt them unchanged). The request is a request
|
|
224
230
|
* hook's first argument (`reqAt` 0) and every response-side hook's second; out of scope a hook
|
|
@@ -305,30 +311,67 @@ const captureCallerSite = () => {
|
|
|
305
311
|
return undefined;
|
|
306
312
|
};
|
|
307
313
|
const hookAudit = /* @__PURE__ */ new WeakMap();
|
|
314
|
+
/** Whether `from` reaches `target` through mounts that keep the path and take a request under `path`.
|
|
315
|
+
* Module-level, not a `Server` method, so a production bundle drops it with its dev-only caller. */
|
|
316
|
+
function mountLeadsBack(from, target, path, mountsOf, seen) {
|
|
317
|
+
if (from === target)
|
|
318
|
+
return true;
|
|
319
|
+
if (!(from instanceof Server) || seen.has(from))
|
|
320
|
+
return false;
|
|
321
|
+
seen.add(from);
|
|
322
|
+
const under = (prefix) => (prefix === "/" ? "/" : `${prefix}/`);
|
|
323
|
+
return mountsOf(from).some((mount) => !mount.stripPrefix &&
|
|
324
|
+
(under(path).startsWith(under(mount.path)) || under(mount.path).startsWith(under(path))) &&
|
|
325
|
+
mountLeadsBack(mount.app, target, path, mountsOf, seen));
|
|
326
|
+
}
|
|
327
|
+
/** Set by `@nifrajs/web`'s `createWebApp` on its app once the page routes are declared. */
|
|
328
|
+
const PAGES_DECLARED = /* @__PURE__ */ Symbol.for("nifra.web.pagesDeclared");
|
|
308
329
|
/** Start tracking one server's order-scoped hook pushes (once, from the constructor). */
|
|
309
330
|
const beginHookAudit = (key, policy, hasCustomLogger) => {
|
|
310
331
|
hookAudit.set(key, { policy, hasCustomLogger, sites: [], checked: false });
|
|
311
332
|
};
|
|
312
333
|
/** Record one order-scoped hook push: the route count at that moment (so `routesAtPush === total` at
|
|
313
|
-
* seal means no route was declared after it) and the caller's frame.
|
|
334
|
+
* seal means no route was declared after it) and the caller's frame. Hooks a WebSocket handshake runs
|
|
335
|
+
* count sockets as routes; `around` and `afterHandle` wrap a response, so they count HTTP routes only. */
|
|
314
336
|
const recordScopedHook = (key, routesAtPush, kind) => {
|
|
337
|
+
// Not a dead hook when routes follow it, but every page already copied its chain without it.
|
|
338
|
+
if (PAGES_DECLARED in key) {
|
|
339
|
+
const site = captureCallerSite();
|
|
340
|
+
throw new FrameworkError("HOOK_AFTER_PAGES", `${kind}()${site === undefined ? "" : ` at ${site}`} was added after createWebApp declared the page routes, so no page runs it: each route keeps the order-scoped hooks that existed when it was declared. Apply it in createWebApp({ use: (app) => ... }) to cover the pages, or inside app.group(prefix, ...) to scope it to the routes declared there.`);
|
|
341
|
+
}
|
|
315
342
|
const state = hookAudit.get(key);
|
|
316
343
|
if (state === undefined || state.policy === "off")
|
|
317
344
|
return;
|
|
318
345
|
state.sites.push({ kind, routesAtPush, site: captureCallerSite() });
|
|
319
346
|
};
|
|
347
|
+
/** Hook provenance for an adoption (dev only, see `internal/hook-origins.ts`): which scope each
|
|
348
|
+
* wrapper just appended to a hook list gates its hook by. `pairs` holds each source list with the target list it
|
|
349
|
+
* was appended to, read right after the append so the wrappers are the target list's tail. */
|
|
350
|
+
const tagAdopted = (target, source, prefix, routes, pairs) => {
|
|
351
|
+
tagAdoption(target, source, prefix);
|
|
352
|
+
const scope = prefix !== undefined
|
|
353
|
+
? { prefix }
|
|
354
|
+
: { routes: routes.map((route) => ({ method: route.method, path: route.path })) };
|
|
355
|
+
for (const [originals, appended] of pairs) {
|
|
356
|
+
if (originals.length > 0)
|
|
357
|
+
tagScoped(originals, appended.slice(-originals.length), scope);
|
|
358
|
+
}
|
|
359
|
+
};
|
|
320
360
|
/** Run the deadness check exactly once. A hook whose recorded route count equals the final route count
|
|
321
361
|
* had no route declared after it - unambiguously dead, since the scoping feature (a hook that covers
|
|
322
362
|
* only later routes) always leaves at least one such route. Zero-route apps are exempt (different
|
|
323
363
|
* mistake, and the warning would be noise). */
|
|
324
|
-
const sealHookAudit = (key, total, logger) => {
|
|
364
|
+
const sealHookAudit = (key, total, sockets, logger) => {
|
|
325
365
|
const state = hookAudit.get(key);
|
|
326
366
|
if (state === undefined || state.checked)
|
|
327
367
|
return;
|
|
328
368
|
state.checked = true;
|
|
329
|
-
if (state.policy === "off" || total === 0)
|
|
369
|
+
if (state.policy === "off" || total + sockets === 0)
|
|
330
370
|
return;
|
|
331
|
-
const dead = state.sites.filter((hook) => hook.routesAtPush ===
|
|
371
|
+
const dead = state.sites.filter((hook) => hook.routesAtPush ===
|
|
372
|
+
(hook.kind === "around" || hook.kind === "aroundCapability" || hook.kind === "afterHandle"
|
|
373
|
+
? total
|
|
374
|
+
: total + sockets));
|
|
332
375
|
if (dead.length === 0)
|
|
333
376
|
return;
|
|
334
377
|
const detail = dead.map((hook) => ` ${hook.kind}() at ${hook.site ?? "<unknown>"}`).join("\n");
|
|
@@ -355,7 +398,9 @@ export class Server {
|
|
|
355
398
|
catalog;
|
|
356
399
|
/** WebSocket routes, matched separately at upgrade time (a GET + `Upgrade: websocket`). */
|
|
357
400
|
wsRouter;
|
|
358
|
-
|
|
401
|
+
/** Each socket path, its declared capabilities, the evidence declarations its handshake captured
|
|
402
|
+
* and the chain it runs; reflection reads it. */
|
|
403
|
+
wsRoutes;
|
|
359
404
|
/** `wsChanges` when `wsMountsLive` was last worked out; -1 until then. */
|
|
360
405
|
wsMountsCheckedAt = -1;
|
|
361
406
|
/** Whether an app mounted here takes WebSocket upgrades, as of `wsMountsCheckedAt`. */
|
|
@@ -463,8 +508,13 @@ export class Server {
|
|
|
463
508
|
/** Body/raw response hooks need a framework-payload marker; keep that decision per app. */
|
|
464
509
|
responseBodyTag;
|
|
465
510
|
responseBodyOwners;
|
|
466
|
-
finalizeResponse = (result, set) => toResponse(result, set, this.responseBodyTag, this.staticResponseHeaders
|
|
511
|
+
finalizeResponse = (result, set, ctx) => toResponse(result, set, this.responseBodyTag, this.staticResponseHeaders,
|
|
512
|
+
// Response hooks write next, and they run for this same request object.
|
|
513
|
+
ctx !== undefined && this.onResponseHooks.length > 0 ? ctx[CONTEXT_REQUEST] : undefined);
|
|
467
514
|
responseRequests;
|
|
515
|
+
/** A request the WebSocket handshake check ran the onRequest hooks on and then passed to HTTP →
|
|
516
|
+
* what the hooks returned, so `fetch` routes that instead of running the hooks a second time. */
|
|
517
|
+
wsHookedPasses;
|
|
468
518
|
/** Original source → request observed by generic request hooks, including in-place mutations. */
|
|
469
519
|
responseSources;
|
|
470
520
|
/** Memoized NodeRequestContext per plain-`Request` source - see {@link nodeRequestContextOf}. */
|
|
@@ -488,7 +538,7 @@ export class Server {
|
|
|
488
538
|
this.routePrefix = "";
|
|
489
539
|
this.catalog = new RouteCatalog();
|
|
490
540
|
this.wsRouter = new Router();
|
|
491
|
-
this.
|
|
541
|
+
this.wsRoutes = [];
|
|
492
542
|
this.topics = undefined;
|
|
493
543
|
const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
494
544
|
assertByteLimit(maxBodyBytes, "maxBodyBytes");
|
|
@@ -500,7 +550,13 @@ export class Server {
|
|
|
500
550
|
this.wsMaxPayloadBytes = wsMaxPayloadBytes;
|
|
501
551
|
this.requestTimeoutMs = options.requestTimeoutMs ?? 0;
|
|
502
552
|
this.wsUpgradeTimeoutMs = options.wsUpgradeTimeoutMs ?? DEFAULT_WS_UPGRADE_TIMEOUT_MS;
|
|
503
|
-
|
|
553
|
+
const trust = options.clientIp;
|
|
554
|
+
if (trust !== undefined &&
|
|
555
|
+
!("header" in trust) &&
|
|
556
|
+
!(Number.isSafeInteger(trust.trustedHops) && trust.trustedHops >= 0)) {
|
|
557
|
+
throw new RangeError("clientIp trustedHops must be a non-negative integer");
|
|
558
|
+
}
|
|
559
|
+
this.clientIpTrust = trust;
|
|
504
560
|
this.acceptInboundDeadlines = options.acceptInboundDeadlines ?? false;
|
|
505
561
|
this.maxInboundDeadlineMs = options.maxInboundDeadlineMs ?? 30_000;
|
|
506
562
|
this.deadlineAdmissionOptions = Object.freeze({
|
|
@@ -516,7 +572,8 @@ export class Server {
|
|
|
516
572
|
if (!Number.isFinite(this.maxInboundDeadlineMs) || this.maxInboundDeadlineMs <= 0) {
|
|
517
573
|
throw new RangeError("maxInboundDeadlineMs must be a finite positive number");
|
|
518
574
|
}
|
|
519
|
-
this.gracefulSignals =
|
|
575
|
+
this.gracefulSignals =
|
|
576
|
+
options.gracefulSignals === true ? {} : options.gracefulSignals || undefined;
|
|
520
577
|
this.stopHooks = [];
|
|
521
578
|
this.fetchMounts = [];
|
|
522
579
|
this.mountOrder = 0;
|
|
@@ -565,6 +622,7 @@ export class Server {
|
|
|
565
622
|
this.responseBodyTag = undefined;
|
|
566
623
|
this.responseBodyOwners = new Set();
|
|
567
624
|
this.responseRequests = new WeakMap();
|
|
625
|
+
this.wsHookedPasses = undefined;
|
|
568
626
|
this.responseSources = new WeakMap();
|
|
569
627
|
this.nodeContexts = new WeakMap();
|
|
570
628
|
this.appliedPlugins = new Map();
|
|
@@ -659,6 +717,15 @@ export class Server {
|
|
|
659
717
|
opaque: options.opaque,
|
|
660
718
|
app,
|
|
661
719
|
};
|
|
720
|
+
// Mounts run before routes: a request under this prefix that no mount on the way strips comes back
|
|
721
|
+
// here unchanged until the stack overflows, which takes the process down. Dev-time only, like the
|
|
722
|
+
// hook audit: a production define folds this away, and the first dev request would hit it anyway.
|
|
723
|
+
if (hookAuditRuntime &&
|
|
724
|
+
process.env.NODE_ENV !== "production" &&
|
|
725
|
+
!mount.stripPrefix &&
|
|
726
|
+
mountLeadsBack(app, this, mount.path, (server) => server.fetchMounts, new Set())) {
|
|
727
|
+
throw new RouteConfigError("MOUNT_CYCLE", `mount ${mount.path}: the app leads back to this one without stripPrefix`);
|
|
728
|
+
}
|
|
662
729
|
this.fetchMounts.push(mount);
|
|
663
730
|
this.fetchMounts.sort(compareMounts);
|
|
664
731
|
this.preRouteMountCount += 1;
|
|
@@ -679,8 +746,10 @@ export class Server {
|
|
|
679
746
|
derive(fn) {
|
|
680
747
|
this.assertConfigurable("derive()");
|
|
681
748
|
this.derives.push(fn);
|
|
682
|
-
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
683
|
-
recordScopedHook(this, this.catalog.size, "derive");
|
|
749
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
|
|
750
|
+
recordScopedHook(this, this.catalog.size + this.wsRoutes.length, "derive");
|
|
751
|
+
tagHook(fn, "derive", this);
|
|
752
|
+
}
|
|
684
753
|
return this;
|
|
685
754
|
}
|
|
686
755
|
/**
|
|
@@ -710,22 +779,27 @@ export class Server {
|
|
|
710
779
|
throw new TypeError('authenticate() stage mode must be "sync" or "async"');
|
|
711
780
|
}
|
|
712
781
|
this.authStages.push(stage);
|
|
782
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
783
|
+
tagHook(stage, "authenticate", this);
|
|
713
784
|
return this;
|
|
714
785
|
}
|
|
715
786
|
decorate(key, value) {
|
|
716
787
|
this.assertConfigurable("decorate()");
|
|
717
788
|
this.decorations[key] = value;
|
|
718
789
|
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
719
|
-
recordScopedHook(this, this.catalog.size, "decorate");
|
|
790
|
+
recordScopedHook(this, this.catalog.size + this.wsRoutes.length, "decorate");
|
|
720
791
|
return this;
|
|
721
792
|
}
|
|
722
793
|
/**
|
|
723
|
-
* Run before routing on the raw request. Return a `Response`
|
|
724
|
-
* `Request` to continue routing with a rewritten method/URL/headers.
|
|
794
|
+
* Run before routing on the raw request. Return a `Response` or a `status()` result to
|
|
795
|
+
* short-circuit, or a replacement `Request` to continue routing with a rewritten method/URL/headers.
|
|
796
|
+
* Global.
|
|
725
797
|
*/
|
|
726
798
|
onRequest(fn) {
|
|
727
799
|
this.assertConfigurable("onRequest()");
|
|
728
800
|
this.onRequestHooks.push(fn);
|
|
801
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
802
|
+
tagHook(fn, "onRequest", this);
|
|
729
803
|
this.onNodeRequestHooks.push(undefined);
|
|
730
804
|
this.nodeRequestHooksComplete = false;
|
|
731
805
|
this.bunNativeRequestHooksSafe = false;
|
|
@@ -739,8 +813,10 @@ export class Server {
|
|
|
739
813
|
beforeHandle(fn) {
|
|
740
814
|
this.assertConfigurable("beforeHandle()");
|
|
741
815
|
this.beforeHandleHooks.push(fn);
|
|
742
|
-
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
743
|
-
recordScopedHook(this, this.catalog.size, "beforeHandle");
|
|
816
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
|
|
817
|
+
recordScopedHook(this, this.catalog.size + this.wsRoutes.length, "beforeHandle");
|
|
818
|
+
tagHook(fn, "beforeHandle", this);
|
|
819
|
+
}
|
|
744
820
|
return this;
|
|
745
821
|
}
|
|
746
822
|
/**
|
|
@@ -754,8 +830,10 @@ export class Server {
|
|
|
754
830
|
around(fn) {
|
|
755
831
|
this.assertConfigurable("around()");
|
|
756
832
|
this.aroundHooks.push(fn);
|
|
757
|
-
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
833
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
|
|
758
834
|
recordScopedHook(this, this.catalog.size, "around");
|
|
835
|
+
tagHook(fn, "around", this);
|
|
836
|
+
}
|
|
759
837
|
return this;
|
|
760
838
|
}
|
|
761
839
|
/**
|
|
@@ -793,8 +871,10 @@ export class Server {
|
|
|
793
871
|
afterHandle(fn) {
|
|
794
872
|
this.assertConfigurable("afterHandle()");
|
|
795
873
|
this.afterHandleHooks.push(fn);
|
|
796
|
-
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
874
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
|
|
797
875
|
recordScopedHook(this, this.catalog.size, "afterHandle");
|
|
876
|
+
tagHook(fn, "afterHandle", this);
|
|
877
|
+
}
|
|
798
878
|
return this;
|
|
799
879
|
}
|
|
800
880
|
/** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500).
|
|
@@ -803,14 +883,18 @@ export class Server {
|
|
|
803
883
|
onError(fn) {
|
|
804
884
|
this.assertConfigurable("onError()");
|
|
805
885
|
this.onErrorHooks.push(fn);
|
|
806
|
-
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
807
|
-
recordScopedHook(this, this.catalog.size, "onError");
|
|
886
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
|
|
887
|
+
recordScopedHook(this, this.catalog.size + this.wsRoutes.length, "onError");
|
|
888
|
+
tagHook(fn, "onError", this);
|
|
889
|
+
}
|
|
808
890
|
return this;
|
|
809
891
|
}
|
|
810
892
|
/** Transform every outgoing response - success, error, 404, 405, short-circuit. Global. */
|
|
811
893
|
onResponse(fn) {
|
|
812
894
|
this.assertConfigurable("onResponse()");
|
|
813
895
|
this.onResponseHooks.push(fn);
|
|
896
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
897
|
+
tagHook(fn, "onResponse", this);
|
|
814
898
|
this.onNodeResponseHooks.push(undefined);
|
|
815
899
|
this.nodeResponseHooksComplete = false;
|
|
816
900
|
this.bunNativeResponseHeadersOnly = false;
|
|
@@ -842,6 +926,10 @@ export class Server {
|
|
|
842
926
|
*/
|
|
843
927
|
responseHeaders(record) {
|
|
844
928
|
this.assertConfigurable("responseHeaders()");
|
|
929
|
+
if (hookAuditRuntime &&
|
|
930
|
+
process.env.NODE_ENV !== "production" &&
|
|
931
|
+
this.onResponseHooks.length === 0)
|
|
932
|
+
tagStatic(this, Object.keys(record), record);
|
|
845
933
|
return this.addStaticResponseHeaders(normalizeStaticResponseHeaders(record));
|
|
846
934
|
}
|
|
847
935
|
/** Wire an already-validated lowercase record into the static tier, or - once a dynamic response
|
|
@@ -858,13 +946,15 @@ export class Server {
|
|
|
858
946
|
});
|
|
859
947
|
return this;
|
|
860
948
|
}
|
|
861
|
-
// Route the hook through the same seam the static tier uses. On a framework response
|
|
862
|
-
//
|
|
863
|
-
//
|
|
864
|
-
//
|
|
865
|
-
//
|
|
949
|
+
// Route the hook through the same seam the static tier uses. On a framework response with a
|
|
950
|
+
// body it applies the defaults in place and returns the SAME object, so the serialized-body
|
|
951
|
+
// marker survives for later `onResponseBody`/`onResponseRaw` observers and the Node writer;
|
|
952
|
+
// reconstructing a `Response` here would strip that marker and reclassify the Node outcome from
|
|
953
|
+
// `json` to a generic `response`. A body-less or guarded response is copied, marker included.
|
|
866
954
|
const statics = buildStaticResponseHeaders(record, this.inheritedStatics);
|
|
867
|
-
this.onResponseHooks.push((response) => applyStaticResponseHeaders(response, statics));
|
|
955
|
+
this.onResponseHooks.push((response, req) => applyStaticResponseHeaders(response, statics, req));
|
|
956
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
957
|
+
tagHook(this.onResponseHooks.at(-1), "responseHeaders", this);
|
|
868
958
|
this.onNodeResponseHooks.push(undefined);
|
|
869
959
|
this.nodeResponseHooksComplete = false;
|
|
870
960
|
return this;
|
|
@@ -898,6 +988,8 @@ export class Server {
|
|
|
898
988
|
onResponseFinalized(fn) {
|
|
899
989
|
this.assertConfigurable("onResponseFinalized()");
|
|
900
990
|
this.onResponseFinalizedHooks.push(fn);
|
|
991
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
992
|
+
tagHook(fn, "onResponseFinalized", this);
|
|
901
993
|
return this;
|
|
902
994
|
}
|
|
903
995
|
use(input) {
|
|
@@ -925,10 +1017,14 @@ export class Server {
|
|
|
925
1017
|
this.activeAssurance.push(...pluginOnly);
|
|
926
1018
|
const inherited = this.inheritedPlugins;
|
|
927
1019
|
this.inheritedPlugins = undefined;
|
|
1020
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
1021
|
+
enterPlugin(name);
|
|
928
1022
|
try {
|
|
929
1023
|
return arg(this);
|
|
930
1024
|
}
|
|
931
1025
|
finally {
|
|
1026
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
1027
|
+
leavePlugin();
|
|
932
1028
|
this.inheritedPlugins = inherited;
|
|
933
1029
|
// Remove only this plugin's temporary evidence. Nested assured plugins may deliberately leave
|
|
934
1030
|
// subsequent evidence active, so truncating the whole array would lose real ordering semantics.
|
|
@@ -957,6 +1053,8 @@ export class Server {
|
|
|
957
1053
|
if (arg.onRequest !== undefined) {
|
|
958
1054
|
this.assertConfigurable("onRequest()");
|
|
959
1055
|
this.onRequestHooks.push(arg.onRequest);
|
|
1056
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
1057
|
+
tagHook(arg.onRequest, "onRequest", this);
|
|
960
1058
|
this.onNodeRequestHooks.push(arg.onNodeRequest);
|
|
961
1059
|
if (arg.onNodeRequest === undefined)
|
|
962
1060
|
this.nodeRequestHooksComplete = false;
|
|
@@ -976,6 +1074,8 @@ export class Server {
|
|
|
976
1074
|
if (arg.onResponse !== undefined) {
|
|
977
1075
|
this.assertConfigurable("onResponse()");
|
|
978
1076
|
this.onResponseHooks.push(arg.onResponse);
|
|
1077
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
1078
|
+
tagHook(arg.onResponse, "onResponse", this);
|
|
979
1079
|
this.onNodeResponseHooks.push(arg.onNodeResponse);
|
|
980
1080
|
if (arg.onNodeResponse === undefined)
|
|
981
1081
|
this.nodeResponseHooksComplete = false;
|
|
@@ -1014,8 +1114,10 @@ export class Server {
|
|
|
1014
1114
|
return this.route("GET", path, schemaOrHandler, handler);
|
|
1015
1115
|
}
|
|
1016
1116
|
/**
|
|
1017
|
-
* Register a **typed SSE route** -
|
|
1018
|
-
* payloads are contracted by `schema.sse`.
|
|
1117
|
+
* Register a **typed SSE route** - an endpoint streaming `text/event-stream` whose event
|
|
1118
|
+
* payloads are contracted by `schema.sse`. It is a GET route, or a POST route when the schema
|
|
1119
|
+
* declares a `body` (a GET carries none): the body validates as on any POST, and the client sends
|
|
1120
|
+
* it with `.subscribe(onEvent, { body })` - the shape of a chat stream that posts its prompt. The handler receives the validated context plus a
|
|
1019
1121
|
* {@link TypedSSEStream}: `stream.send(event)` is compile-time-checked against the schema and
|
|
1020
1122
|
* JSON-serialized into the SSE `data:` field. The typed client sees the marker and grows a
|
|
1021
1123
|
* `.subscribe(onEvent)` for the route with the same payload type - end-to-end typed streaming.
|
|
@@ -1030,13 +1132,13 @@ export class Server {
|
|
|
1030
1132
|
*
|
|
1031
1133
|
* // client: const off = api.feed.subscribe((post) => console.log(post.title))
|
|
1032
1134
|
*
|
|
1033
|
-
* `init` passes through to the underlying {@link sse} helper (`keepAlive`,
|
|
1034
|
-
* connection closes when the handler resolves, `stream.close()` runs, or the
|
|
1035
|
-
* (`stream.signal`). Query/body schemas validate exactly as on any other route.
|
|
1135
|
+
* `init` passes through to the underlying {@link sse} helper (`keepAlive`, `maxBufferedBytes`,
|
|
1136
|
+
* extra headers). The connection closes when the handler resolves, `stream.close()` runs, or the
|
|
1137
|
+
* client disconnects (`stream.signal`). Query/body schemas validate exactly as on any other route.
|
|
1036
1138
|
*/
|
|
1037
1139
|
sse(path, schema, run, init) {
|
|
1038
1140
|
const handler = (context) => requireSseRuntime(this.sseRuntime).response(context, (stream) => run(context, stream), init);
|
|
1039
|
-
return this.route("GET", path, schema, handler);
|
|
1141
|
+
return this.route(schema.body ? "POST" : "GET", path, schema, handler);
|
|
1040
1142
|
}
|
|
1041
1143
|
post(path, schemaOrHandler, handler) {
|
|
1042
1144
|
return this.route("POST", path, schemaOrHandler, handler);
|
|
@@ -1092,8 +1194,11 @@ export class Server {
|
|
|
1092
1194
|
}
|
|
1093
1195
|
/**
|
|
1094
1196
|
* Register a **WebSocket** route. The connection upgrades on a `GET` to `path` carrying
|
|
1095
|
-
* `Upgrade: websocket
|
|
1096
|
-
*
|
|
1197
|
+
* `Upgrade: websocket`. The handshake runs the chain an HTTP `GET` registered here would: the
|
|
1198
|
+
* `authenticate()` stages, `derive`s and `beforeHandle`s added before this call, then the optional
|
|
1199
|
+
* `handler.upgrade(c)`. Any of them may reject (a `Response`, a `status(...)`, a failed auth stage);
|
|
1200
|
+
* `upgrade()` may seed per-connection `ws.data`. To keep a socket public, register it before the
|
|
1201
|
+
* guard. WebSockets are served by the
|
|
1097
1202
|
* adapter (`listen()`, `@nifrajs/node`, `@nifrajs/deno`, `toFetchHandler`) - not by bare `app.fetch`, which
|
|
1098
1203
|
* has no socket (a WS path through `app.fetch` is a normal HTTP response).
|
|
1099
1204
|
*
|
|
@@ -1120,10 +1225,20 @@ export class Server {
|
|
|
1120
1225
|
this.topics ??= runtime.createTopics();
|
|
1121
1226
|
// A `messageSchema` wraps `message` with validation once, here - every adapter then dispatches
|
|
1122
1227
|
// already-validated, typed messages (Bun/Deno/Node/Workers) with no per-adapter code.
|
|
1123
|
-
const
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1228
|
+
const wrapped = runtime.wrapHandler(handler, this.protoPoisoning);
|
|
1229
|
+
const upgrade = runtime.upgradeStep(wrapped);
|
|
1230
|
+
for (const form of expandOptionalParams(path)) {
|
|
1231
|
+
// The chain an HTTP `GET` to `form` would capture now, ending in `upgrade()` instead of a
|
|
1232
|
+
// handler. `afterHandle` and `around` are left out: they wrap a response, and an accepted
|
|
1233
|
+
// handshake has none.
|
|
1234
|
+
const { entry, assurance, descriptor } = this.prepareRoute("WS", form, wrapped.capabilities === undefined ? undefined : { capabilities: wrapped.capabilities }, upgrade);
|
|
1235
|
+
const chain = { ...entry, afterHandle: [] };
|
|
1236
|
+
this.wsRouter.add("GET", form, {
|
|
1237
|
+
handler: wrapped,
|
|
1238
|
+
chain: { ...chain, program: compileRouteProgram({ ...chain, bodySchema: undefined }) },
|
|
1239
|
+
});
|
|
1240
|
+
this.wsRoutes.push({ path: form, capabilities: descriptor.capabilities, assurance, chain });
|
|
1241
|
+
}
|
|
1127
1242
|
wsChanges += 1;
|
|
1128
1243
|
return this;
|
|
1129
1244
|
}
|
|
@@ -1315,11 +1430,6 @@ export class Server {
|
|
|
1315
1430
|
assurance: routeAssurance,
|
|
1316
1431
|
};
|
|
1317
1432
|
}
|
|
1318
|
-
/** The idempotency lane's bridge back into the normal matched lanes, resolved to a concrete Response
|
|
1319
|
-
* (the lane buffers the body, then replays it through the route's real validation + handler). */
|
|
1320
|
-
idempotencyRunLanes(buffered, platform, entry, params, search) {
|
|
1321
|
-
return Promise.resolve(this.runMatchedLanes(buffered, platform, entry, params, search, this.finalizeResponse, this.wrapWebResponse, this.webResponseTimeout, false));
|
|
1322
|
-
}
|
|
1323
1433
|
/** @internal Symbol-keyed install seam for the `responseContract()` plugin. Off the public typed surface. */
|
|
1324
1434
|
[INSTALL_RESPONSE_CONTRACT](runtime) {
|
|
1325
1435
|
this.assertConfigurable("responseContract()");
|
|
@@ -1332,6 +1442,8 @@ export class Server {
|
|
|
1332
1442
|
assertConfigurable: (operation) => this.assertConfigurable(operation),
|
|
1333
1443
|
addResponseHook: (web, node, headerOnly = false) => {
|
|
1334
1444
|
this.onResponseHooks.push(web);
|
|
1445
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
1446
|
+
tagHook(web, headerOnly ? "responseHeaders" : "onResponse", this);
|
|
1335
1447
|
this.onNodeResponseHooks.push(node);
|
|
1336
1448
|
if (node === undefined)
|
|
1337
1449
|
this.nodeResponseHooksComplete = false;
|
|
@@ -1370,6 +1482,11 @@ export class Server {
|
|
|
1370
1482
|
this.assertConfigurable("nodeDirect()");
|
|
1371
1483
|
this.nodeOutcomeRuntime = runtime;
|
|
1372
1484
|
}
|
|
1485
|
+
/** @internal An adapter reports a failure no response hook saw (a hook that threw past `fetch`, a
|
|
1486
|
+
* response the runtime refused to write), logged as Bun's `listen()` logs one. */
|
|
1487
|
+
[REPORT_ERROR](error) {
|
|
1488
|
+
emitRequestErrorLog(this.logger, this.errorLogDetail, error, undefined);
|
|
1489
|
+
}
|
|
1373
1490
|
/** @internal Symbol-keyed install seam for the `streaming()` plugin. Off the public typed surface. */
|
|
1374
1491
|
[INSTALL_SSE](runtime) {
|
|
1375
1492
|
this.assertConfigurable("streaming()");
|
|
@@ -1388,7 +1505,7 @@ export class Server {
|
|
|
1388
1505
|
* counts. `seen` ends a mount cycle (an app mounted under itself to alias a prefix).
|
|
1389
1506
|
*/
|
|
1390
1507
|
[GET_WS_RUNTIME](seen) {
|
|
1391
|
-
return this.
|
|
1508
|
+
return this.wsRoutes.length > 0 ? this.wsRuntime : this.mountedWsRuntime(seen);
|
|
1392
1509
|
}
|
|
1393
1510
|
/** The `[GET_WS_RUNTIME]` answer for the apps mounted here alone, whatever this app's own routes. */
|
|
1394
1511
|
mountedWsRuntime(seen = new Set()) {
|
|
@@ -1429,15 +1546,15 @@ export class Server {
|
|
|
1429
1546
|
* `derive`/`decorate`/`beforeHandle`/`afterHandle`/`onError`/`around` apply to its routes
|
|
1430
1547
|
* exactly as they did standalone, so a group wires its own plugins. The same locality rule
|
|
1431
1548
|
* covers the group's `onRequest` hooks: they run only for requests that route to the GROUP's
|
|
1432
|
-
* routes (a `bodyLimit()` mounted on an uploads group must not start gating the whole app
|
|
1549
|
+
* routes, and for an `OPTIONS` preflight to a path they serve (a `bodyLimit()` mounted on an uploads group must not start gating the whole app
|
|
1433
1550
|
* because the app composed it in), and the group's global assurance follows its hooks onto
|
|
1434
1551
|
* exactly those routes. A group with hooks but NO routes is a middleware bundle - its hooks
|
|
1435
1552
|
* can only mean app-wide intent, so they are appended globally, unchanged. Response-side hooks
|
|
1436
1553
|
* (`onResponse`/`onResponseFinalized`) are appended to this server's. This server's
|
|
1437
1554
|
* route-scoped chains do NOT retroactively wrap merged routes (order-scoped, like routes
|
|
1438
1555
|
* registered before a `derive`). Fail closed: a path+method collision throws
|
|
1439
|
-
* `RouteConfigError` at merge time, and a group with WebSocket routes is refused
|
|
1440
|
-
* those on the parent).
|
|
1556
|
+
* `RouteConfigError` at merge time, and a group with WebSocket routes or mounts is refused
|
|
1557
|
+
* (register those on the parent).
|
|
1441
1558
|
*/
|
|
1442
1559
|
merge(other) {
|
|
1443
1560
|
this.assertConfigurable("merge()");
|
|
@@ -1452,8 +1569,8 @@ export class Server {
|
|
|
1452
1569
|
throw new RouteConfigError("INVALID_PATH", "merge() inside a group cannot prefix compiled routes - merge on the parent");
|
|
1453
1570
|
}
|
|
1454
1571
|
const source = other;
|
|
1455
|
-
if (source.
|
|
1456
|
-
throw new RouteConfigError("INVALID_PATH", "merge() does not carry WebSocket routes - register
|
|
1572
|
+
if (source.wsRoutes.length > 0 || source.fetchMounts.length > 0) {
|
|
1573
|
+
throw new RouteConfigError("INVALID_PATH", "merge() does not carry WebSocket routes or mounts - register them on the parent server");
|
|
1457
1574
|
}
|
|
1458
1575
|
this.adopt(source, undefined, 0);
|
|
1459
1576
|
return this;
|
|
@@ -1534,13 +1651,13 @@ export class Server {
|
|
|
1534
1651
|
if (built !== scope) {
|
|
1535
1652
|
throw new TypeError(`${label} builder must return its group synchronously`);
|
|
1536
1653
|
}
|
|
1537
|
-
if (scope.
|
|
1654
|
+
if (scope.wsRoutes.length > 0 ||
|
|
1538
1655
|
scope.fetchMounts.length > 0 ||
|
|
1539
1656
|
scope.catalog.entries().some((route) => route.descriptor.tool !== undefined)) {
|
|
1540
1657
|
throw new RouteConfigError("INVALID_PATH", `${label} cannot hold ws(), mount, or MCP tool routes - add them to the parent`);
|
|
1541
1658
|
}
|
|
1542
1659
|
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
1543
|
-
sealHookAudit(scope, scope.catalog.size, this.logger);
|
|
1660
|
+
sealHookAudit(scope, scope.catalog.size, scope.wsRoutes.length, this.logger);
|
|
1544
1661
|
this.adopt(scope, fullPrefix, this.globalAssurance.length);
|
|
1545
1662
|
return this;
|
|
1546
1663
|
}
|
|
@@ -1594,13 +1711,17 @@ export class Server {
|
|
|
1594
1711
|
// The group's static declarations came before its own response hooks, so they are folded in
|
|
1595
1712
|
// first - and fold themselves into a hook here if this server already has one (same ordering
|
|
1596
1713
|
// rule as a direct `responseHeaders()` call).
|
|
1714
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
1715
|
+
adoptStaticTags(this, source, this.onResponseHooks.length > 0);
|
|
1597
1716
|
this.addStaticResponseHeaders({ ...statics.record });
|
|
1598
1717
|
}
|
|
1599
1718
|
else {
|
|
1600
1719
|
// The static tier is folded into EVERY response this server builds, so a group's static
|
|
1601
1720
|
// headers cannot join it. A hook ahead of the group's own (the sealed scope is discarded
|
|
1602
1721
|
// after adoption) keeps their meaning - defaults a value already on the response wins over.
|
|
1603
|
-
source.onResponseHooks.unshift((response) => applyStaticResponseHeaders(response, statics));
|
|
1722
|
+
source.onResponseHooks.unshift((response, req) => applyStaticResponseHeaders(response, statics, req));
|
|
1723
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
1724
|
+
tagHook(source.onResponseHooks[0], "responseHeaders", source, true);
|
|
1604
1725
|
source.onNodeResponseHooks.unshift(undefined);
|
|
1605
1726
|
source.nodeResponseHooksComplete = false;
|
|
1606
1727
|
}
|
|
@@ -1608,6 +1729,17 @@ export class Server {
|
|
|
1608
1729
|
this.onResponseHooks.push(...scopeHooks(source.onResponseHooks, responseScope, 1, true));
|
|
1609
1730
|
this.onNodeResponseHooks.push(...scopeHooks(source.onNodeResponseHooks, responseScope, 1));
|
|
1610
1731
|
this.onResponseFinalizedHooks.push(...scopeHooks(source.onResponseFinalizedHooks, responseScope, 1));
|
|
1732
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
|
|
1733
|
+
// Read before anything else is appended: the wrappers are still each list's tail.
|
|
1734
|
+
const pairs = [];
|
|
1735
|
+
if (inScope !== undefined)
|
|
1736
|
+
pairs.push([source.onRequestHooks, this.onRequestHooks]);
|
|
1737
|
+
if (responseScope !== undefined) {
|
|
1738
|
+
pairs.push([source.onResponseHooks, this.onResponseHooks]);
|
|
1739
|
+
pairs.push([source.onResponseFinalizedHooks, this.onResponseFinalizedHooks]);
|
|
1740
|
+
}
|
|
1741
|
+
tagAdopted(this, source, prefix, sourceRoutes, pairs);
|
|
1742
|
+
}
|
|
1611
1743
|
this.nodeResponseHooksComplete &&= source.nodeResponseHooksComplete;
|
|
1612
1744
|
this.bunNativeResponseHeadersOnly &&= source.bunNativeResponseHeadersOnly;
|
|
1613
1745
|
this.hasRawNodeResponseHook ||= source.hasRawNodeResponseHook;
|
|
@@ -1724,7 +1856,7 @@ export class Server {
|
|
|
1724
1856
|
// Seal once on the first request so test servers (no `listen()`) still get the dead-hook check.
|
|
1725
1857
|
// After that it is a single boolean read - the per-request cost the option promises to keep at zero.
|
|
1726
1858
|
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
1727
|
-
sealHookAudit(this, this.catalog.size, this.logger);
|
|
1859
|
+
sealHookAudit(this, this.catalog.size, this.wsRoutes.length, this.logger);
|
|
1728
1860
|
// Off path (default): straight through - one property check, no closure, no promise.
|
|
1729
1861
|
if (this.capacityGate === undefined)
|
|
1730
1862
|
return this.fetchSourceInner(source, platform);
|
|
@@ -1857,7 +1989,7 @@ export class Server {
|
|
|
1857
1989
|
resolveNodeSource(source, platform, suppliedRuntime) {
|
|
1858
1990
|
// Seal once on the Node-direct lane too - it can bypass `fetchSource` entirely (hookless outcome).
|
|
1859
1991
|
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
1860
|
-
sealHookAudit(this, this.catalog.size, this.logger);
|
|
1992
|
+
sealHookAudit(this, this.catalog.size, this.wsRoutes.length, this.logger);
|
|
1861
1993
|
// A paired header-only native hook can preserve the Node-direct JSON/body outcome for successful
|
|
1862
1994
|
// responses. Arbitrary onResponse transforms need a real Web Response, but a buffered outcome can
|
|
1863
1995
|
// be materialized with a direct-write marker and return to the socket path when the hook mutates
|
|
@@ -2075,7 +2207,7 @@ export class Server {
|
|
|
2075
2207
|
continue;
|
|
2076
2208
|
}
|
|
2077
2209
|
if (outcome !== undefined)
|
|
2078
|
-
return outcome;
|
|
2210
|
+
return webResponseOf(outcome);
|
|
2079
2211
|
}
|
|
2080
2212
|
return current;
|
|
2081
2213
|
}
|
|
@@ -2086,7 +2218,7 @@ export class Server {
|
|
|
2086
2218
|
if (outcome instanceof Request)
|
|
2087
2219
|
next = outcome;
|
|
2088
2220
|
else if (outcome !== undefined)
|
|
2089
|
-
return outcome;
|
|
2221
|
+
return webResponseOf(outcome);
|
|
2090
2222
|
if (i >= this.onRequestHooks.length)
|
|
2091
2223
|
return next;
|
|
2092
2224
|
const hook = this.onRequestHooks[i];
|
|
@@ -2098,7 +2230,7 @@ export class Server {
|
|
|
2098
2230
|
resolveWebSocketUpgrade(req, platform) {
|
|
2099
2231
|
// Before any header is read: Deno only materializes a request's headers when asked, and that
|
|
2100
2232
|
// costs a plain request measurably.
|
|
2101
|
-
if (this.
|
|
2233
|
+
if (this.wsRoutes.length === 0 && !this.mountsTakeUpgrades())
|
|
2102
2234
|
return WS_PASS;
|
|
2103
2235
|
if (req.headers.get("upgrade")?.toLowerCase() !== "websocket")
|
|
2104
2236
|
return WS_PASS;
|
|
@@ -2106,6 +2238,8 @@ export class Server {
|
|
|
2106
2238
|
// is an ordinary request: its HTTP route answers it, and normal routing refuses an unknown token.
|
|
2107
2239
|
if (req.method !== "GET")
|
|
2108
2240
|
return WS_PASS;
|
|
2241
|
+
// The adapter answers a pass by handing this same object to `fetch`.
|
|
2242
|
+
const received = req;
|
|
2109
2243
|
// The handshake routes the path `fetch` would: dot segments resolved (see `fetch`).
|
|
2110
2244
|
if (rawRequestTargets && hasDotSegment(req.url))
|
|
2111
2245
|
req = withResolvedTarget(req);
|
|
@@ -2119,11 +2253,12 @@ export class Server {
|
|
|
2119
2253
|
const controller = timeoutMs > 0 ? new AbortController() : undefined;
|
|
2120
2254
|
const signal = controller?.signal ?? getNeverAbortSignal();
|
|
2121
2255
|
const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : undefined;
|
|
2122
|
-
|
|
2123
|
-
const
|
|
2256
|
+
// A refusal carries the static response headers an HTTP answer would; a guard's already does.
|
|
2257
|
+
const refuse = (response) => ({
|
|
2124
2258
|
kind: "reject",
|
|
2125
|
-
response:
|
|
2259
|
+
response: this.wrapWebResponse(response),
|
|
2126
2260
|
});
|
|
2261
|
+
const onTimeout = () => refuse(jsonError(503, "request_timeout"));
|
|
2127
2262
|
const bounded = (work, onDone) => controller === undefined
|
|
2128
2263
|
? work
|
|
2129
2264
|
: this.withTimeout(work, controller, onDone, Math.max(0, deadline - Date.now()));
|
|
@@ -2136,11 +2271,11 @@ export class Server {
|
|
|
2136
2271
|
// already did and made every plain dynamic route pay for unrelated static path bytes.
|
|
2137
2272
|
const params = match.params === EMPTY_PARAMS ? match.params : decodeRouteParams(match.params);
|
|
2138
2273
|
if (params === null)
|
|
2139
|
-
return
|
|
2140
|
-
const handler = match.payload
|
|
2141
|
-
// Non-null:
|
|
2274
|
+
return refuse(jsonError(400, "malformed_path"));
|
|
2275
|
+
const { handler, chain } = match.payload;
|
|
2276
|
+
// Non-null: a socket route ⇒ ws() ran ⇒ `.use(websocket())` installed the runtime + registry.
|
|
2142
2277
|
const pubsub = this.topics;
|
|
2143
|
-
const
|
|
2278
|
+
const runtime = this.wsRuntime;
|
|
2144
2279
|
// CSWSH guard, before any per-connection work or the user's upgrade(): reject a disallowed
|
|
2145
2280
|
// Origin with 403. Browsers don't CORS-protect WS handshakes but do send cookies, so this
|
|
2146
2281
|
// blocks cross-site authenticated sockets when the route opts in via `allowedOrigins`.
|
|
@@ -2150,52 +2285,45 @@ export class Server {
|
|
|
2150
2285
|
? handler.allowedOrigins(origin)
|
|
2151
2286
|
: origin !== null && handler.allowedOrigins.includes(origin);
|
|
2152
2287
|
if (!allowed)
|
|
2153
|
-
return
|
|
2288
|
+
return refuse(jsonError(403, "forbidden_origin"));
|
|
2154
2289
|
}
|
|
2155
2290
|
else if (origin !== null && !wsSameOrigin(origin, request)) {
|
|
2156
2291
|
// Secure default (no explicit `allowedOrigins`): reject a CROSS-ORIGIN browser handshake - the
|
|
2157
2292
|
// CSWSH case, since browsers send cookies on WS handshakes and don't apply CORS. Non-browser
|
|
2158
2293
|
// clients send no `Origin` and pass; same-origin browsers pass. Set `allowedOrigins` to permit
|
|
2159
2294
|
// specific cross-origin clients (or `() => true` for a genuinely public socket).
|
|
2160
|
-
return
|
|
2161
|
-
}
|
|
2162
|
-
if (handler.upgrade === undefined) {
|
|
2163
|
-
return {
|
|
2164
|
-
kind: "upgrade",
|
|
2165
|
-
handler,
|
|
2166
|
-
data: undefined,
|
|
2167
|
-
pubsub,
|
|
2168
|
-
attach,
|
|
2169
|
-
maxPayloadBytes: this.wsMaxPayloadBytes,
|
|
2170
|
-
};
|
|
2295
|
+
return refuse(jsonError(403, "forbidden_origin"));
|
|
2171
2296
|
}
|
|
2172
2297
|
const budget = controller === undefined
|
|
2173
2298
|
? createUnboundedRequestBudget(signal)
|
|
2174
2299
|
: createRequestBudget({ deadline: deadline, signal });
|
|
2175
2300
|
const ctx = new RequestContext(request, params, url.search, signal, budget, platform, this.maxBodyBytes, this.protoPoisoning);
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2301
|
+
// Rendered as the same chain on an HTTP `GET` renders its early results.
|
|
2302
|
+
const finalize = (result, set) => {
|
|
2303
|
+
const accepted = runtime.accepted(result);
|
|
2304
|
+
return accepted === undefined
|
|
2305
|
+
? { kind: "reject", response: this.finalizeResponse(result, set) }
|
|
2306
|
+
: {
|
|
2307
|
+
kind: "upgrade",
|
|
2308
|
+
handler,
|
|
2309
|
+
data: accepted.data,
|
|
2310
|
+
pubsub,
|
|
2311
|
+
attach: runtime.attach,
|
|
2312
|
+
maxPayloadBytes: this.wsMaxPayloadBytes,
|
|
2313
|
+
};
|
|
2314
|
+
};
|
|
2315
|
+
const wrap = (response) => finalize(response, EMPTY_RESPONSE_CONTROLS);
|
|
2316
|
+
let outcome;
|
|
2186
2317
|
try {
|
|
2187
|
-
|
|
2188
|
-
if (!(result instanceof Promise))
|
|
2189
|
-
return settle(result);
|
|
2190
|
-
const pending = result.then(settle, () => ({
|
|
2191
|
-
kind: "reject",
|
|
2192
|
-
response: jsonError(500, "internal_error"),
|
|
2193
|
-
}));
|
|
2194
|
-
return bounded(pending, onTimeout);
|
|
2318
|
+
outcome = executeRouteProgram(this, chain, chain.program, request, ctx, finalize, wrap);
|
|
2195
2319
|
}
|
|
2196
2320
|
catch {
|
|
2197
|
-
return
|
|
2321
|
+
return refuse(jsonError(500, "internal_error"));
|
|
2198
2322
|
}
|
|
2323
|
+
if (!(outcome instanceof Promise))
|
|
2324
|
+
return outcome;
|
|
2325
|
+
const pending = outcome.then(undefined, () => refuse(jsonError(500, "internal_error")));
|
|
2326
|
+
return bounded(pending, onTimeout);
|
|
2199
2327
|
};
|
|
2200
2328
|
const resolveMounted = (request) => {
|
|
2201
2329
|
// Native pub/sub was chosen because no mounted app took upgrades when `listen()` ran. One that
|
|
@@ -2217,20 +2345,25 @@ export class Server {
|
|
|
2217
2345
|
}
|
|
2218
2346
|
if (selected === undefined)
|
|
2219
2347
|
return WS_PASS;
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2348
|
+
let childRequest = request;
|
|
2349
|
+
if (selected.stripPrefix) {
|
|
2350
|
+
childRequest = stripMountPrefix(request, selected.path);
|
|
2351
|
+
selected.wsPassed ??= new WeakMap();
|
|
2352
|
+
selected.wsPassed.set(request, childRequest);
|
|
2353
|
+
}
|
|
2223
2354
|
let child;
|
|
2224
2355
|
try {
|
|
2225
2356
|
child = selected.resolveWebSocketUpgrade(childRequest, platform);
|
|
2226
2357
|
}
|
|
2227
2358
|
catch {
|
|
2228
|
-
return
|
|
2359
|
+
return refuse(jsonError(500, "internal_error"));
|
|
2229
2360
|
}
|
|
2230
2361
|
const settle = (outcome) => outcome.kind === "pass" ||
|
|
2231
2362
|
(outcome.kind === "reject" && outcome.response.status === 404 && selected.fallbackOn404)
|
|
2232
2363
|
? dispatch(selectedIndex + 1)
|
|
2233
|
-
: outcome
|
|
2364
|
+
: outcome.kind === "reject"
|
|
2365
|
+
? refuse(outcome.response)
|
|
2366
|
+
: outcome;
|
|
2234
2367
|
return child instanceof Promise ? child.then(settle) : settle(child);
|
|
2235
2368
|
};
|
|
2236
2369
|
return dispatch(0);
|
|
@@ -2248,21 +2381,24 @@ export class Server {
|
|
|
2248
2381
|
};
|
|
2249
2382
|
if (this.onRequestHooks.length === 0)
|
|
2250
2383
|
return resolveWithDeadline(req);
|
|
2384
|
+
// On a pass the adapter hands `received` to `fetch`, which then routes these results. An upgrade
|
|
2385
|
+
// or a rejection never reaches `fetch`, so the entry dies with the request.
|
|
2386
|
+
const resolveHooked = (request) => {
|
|
2387
|
+
this.wsHookedPasses ??= new WeakMap();
|
|
2388
|
+
this.wsHookedPasses.set(received, request);
|
|
2389
|
+
return resolveWithDeadline(request);
|
|
2390
|
+
};
|
|
2251
2391
|
let hooked;
|
|
2252
2392
|
try {
|
|
2253
2393
|
hooked = this.runWebSocketRequestHooks(req, platform);
|
|
2254
2394
|
}
|
|
2255
2395
|
catch {
|
|
2256
|
-
return
|
|
2396
|
+
return refuse(jsonError(500, "internal_error"));
|
|
2257
2397
|
}
|
|
2258
2398
|
if (!(hooked instanceof Promise)) {
|
|
2259
|
-
return hooked instanceof Response
|
|
2260
|
-
? { kind: "reject", response: hooked }
|
|
2261
|
-
: resolveWithDeadline(hooked);
|
|
2399
|
+
return hooked instanceof Response ? refuse(hooked) : resolveHooked(hooked);
|
|
2262
2400
|
}
|
|
2263
|
-
const pending = hooked.then((result) => result instanceof Response
|
|
2264
|
-
? { kind: "reject", response: result }
|
|
2265
|
-
: resolveWithDeadline(result), () => ({ kind: "reject", response: jsonError(500, "internal_error") }));
|
|
2401
|
+
const pending = hooked.then((result) => (result instanceof Response ? refuse(result) : resolveHooked(result)), () => refuse(jsonError(500, "internal_error")));
|
|
2266
2402
|
return bounded(Promise.resolve(pending).then((result) => result), onTimeout);
|
|
2267
2403
|
}
|
|
2268
2404
|
/** Bun `fetch` when WS routes exist: try a WS upgrade first, else run the normal HTTP lifecycle.
|
|
@@ -2319,6 +2455,13 @@ export class Server {
|
|
|
2319
2455
|
if (this.onRequestHooks.length === 0 || !isRoutableMethod(source.method)) {
|
|
2320
2456
|
return this.routeAndRun(source, resolved, finalize, wrapResponse, onTimeout, webFast);
|
|
2321
2457
|
}
|
|
2458
|
+
const hooked = this.wsHookedPasses?.get(source);
|
|
2459
|
+
if (hooked !== undefined) {
|
|
2460
|
+
const request = requestOf(source);
|
|
2461
|
+
this.wsHookedPasses?.delete(request);
|
|
2462
|
+
this.responseRequests.set(request, hooked);
|
|
2463
|
+
return this.routeAndRun(hooked, resolved, finalize, wrapResponse, onTimeout, webFast);
|
|
2464
|
+
}
|
|
2322
2465
|
if (!webFast && this.canUseNodeRequestHooks()) {
|
|
2323
2466
|
return this.runWithNodeRequest(source, resolved, finalize, wrapResponse, onTimeout);
|
|
2324
2467
|
}
|
|
@@ -2595,7 +2738,9 @@ export class Server {
|
|
|
2595
2738
|
}
|
|
2596
2739
|
if (mount === undefined)
|
|
2597
2740
|
return fallback ?? jsonError(404, "not_found");
|
|
2598
|
-
const childRequest = mount.stripPrefix
|
|
2741
|
+
const childRequest = mount.stripPrefix
|
|
2742
|
+
? (mount.wsPassed?.get(request) ?? stripMountPrefix(request, mount.path))
|
|
2743
|
+
: request;
|
|
2599
2744
|
const outcome = mount.handler(childRequest, platform);
|
|
2600
2745
|
if (!replayable || !mount.fallbackOn404)
|
|
2601
2746
|
return outcome;
|
|
@@ -2631,7 +2776,7 @@ export class Server {
|
|
|
2631
2776
|
if (entry.idempotent !== undefined && this.idempotencyRuntime !== undefined) {
|
|
2632
2777
|
return this.idempotencyRuntime.run(entry.idempotent, requestOf(source), platform, entry, params, search, wrapResponse, {
|
|
2633
2778
|
maxBodyBytes: entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
|
|
2634
|
-
runLanes: (buffered, plat, ent, prm, srch) => this.
|
|
2779
|
+
runLanes: (buffered, plat, ent, prm, srch) => this.runMatchedLanes(buffered, plat, ent, prm, srch, this.finalizeResponse, this.wrapWebResponse, this.webResponseTimeout, false),
|
|
2635
2780
|
});
|
|
2636
2781
|
}
|
|
2637
2782
|
return this.runMatchedLanes(source, platform, entry, params, search, finalize, wrapResponse, onTimeout, webFast);
|
|
@@ -2672,11 +2817,20 @@ export class Server {
|
|
|
2672
2817
|
: createRequestBudget({ deadline: admission.deadline, signal });
|
|
2673
2818
|
const plan = entry.execution;
|
|
2674
2819
|
const nativeContext = controller === undefined;
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2820
|
+
let outcome;
|
|
2821
|
+
try {
|
|
2822
|
+
outcome =
|
|
2823
|
+
webFast && plan.fusedWeb !== undefined
|
|
2824
|
+
? plan.fusedWeb(source, params, search, signal, budget, platform, nativeContext)
|
|
2825
|
+
: !webFast && plan.fusedBody !== undefined
|
|
2826
|
+
? plan.fusedBody(source, params, search, signal, budget, platform, false, finalize, wrapResponse)
|
|
2827
|
+
: plan.run(this, entry, source, params, search, signal, budget, platform, nativeContext, finalize, wrapResponse);
|
|
2828
|
+
}
|
|
2829
|
+
catch (err) {
|
|
2830
|
+
// A lane's synchronous render throw (a BigInt, a header value with a line break): the lanes
|
|
2831
|
+
// guard only their async continuations, since a `try` around a fused render slows it.
|
|
2832
|
+
return this.contextlessBareError(err, source, params, search, signal, budget, platform, wrapResponse);
|
|
2833
|
+
}
|
|
2680
2834
|
// The request timeout only bounds work that is actually pending - a synchronous (bare) result is
|
|
2681
2835
|
// already complete and can't time out, so it's returned as-is (no 503 race, no promise).
|
|
2682
2836
|
if (controller !== undefined && outcome instanceof Promise) {
|
|
@@ -2704,7 +2858,14 @@ export class Server {
|
|
|
2704
2858
|
return this.contextlessBareError(err, source, params, search, signal, budget, platform, wrapResponse);
|
|
2705
2859
|
}
|
|
2706
2860
|
if (result instanceof Promise) {
|
|
2707
|
-
return result.then((value) =>
|
|
2861
|
+
return result.then((value) => {
|
|
2862
|
+
try {
|
|
2863
|
+
return finalize(value, EMPTY_RESPONSE_CONTROLS);
|
|
2864
|
+
}
|
|
2865
|
+
catch (err) {
|
|
2866
|
+
return this.contextlessBareError(err, source, params, search, signal, budget, platform, wrapResponse);
|
|
2867
|
+
}
|
|
2868
|
+
}, (err) => this.contextlessBareError(err, source, params, search, signal, budget, platform, wrapResponse));
|
|
2708
2869
|
}
|
|
2709
2870
|
return finalize(result, EMPTY_RESPONSE_CONTROLS);
|
|
2710
2871
|
}
|
|
@@ -2740,9 +2901,17 @@ export class Server {
|
|
|
2740
2901
|
}
|
|
2741
2902
|
if (result instanceof Promise) {
|
|
2742
2903
|
// Async handler on an otherwise-bare route: finish on a microtask, with the same error handling.
|
|
2743
|
-
return result.then((value) =>
|
|
2904
|
+
return result.then((value) => {
|
|
2905
|
+
// A synchronous finalize throw reaches runMatchedLanes; this one would reject.
|
|
2906
|
+
try {
|
|
2907
|
+
return finalize(value, responseSet(ctx), ctx);
|
|
2908
|
+
}
|
|
2909
|
+
catch (err) {
|
|
2910
|
+
return this.bareError(err, ctx, finalize, wrapResponse);
|
|
2911
|
+
}
|
|
2912
|
+
}, (err) => this.bareError(err, ctx, finalize, wrapResponse));
|
|
2744
2913
|
}
|
|
2745
|
-
return finalize(result, responseSet(ctx));
|
|
2914
|
+
return finalize(result, responseSet(ctx), ctx);
|
|
2746
2915
|
}
|
|
2747
2916
|
/** Bare-route error rendering - identical to the compiled route program's catch minus the (absent) onError
|
|
2748
2917
|
* loop: a thrown `Response` is returned as deliberate control flow; anything else is logged + 500. */
|
|
@@ -2762,22 +2931,29 @@ export class Server {
|
|
|
2762
2931
|
// `() => ...` can't observe the context - skip allocating one entirely (errors still build
|
|
2763
2932
|
// one for the structured log, exactly like runContextlessBare).
|
|
2764
2933
|
const contextlessHandler = handler;
|
|
2934
|
+
const fail = (err, source, params, search, signal, budget, platform, nativeContext) => logError(err, nativeContext
|
|
2935
|
+
? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
|
|
2936
|
+
: new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning));
|
|
2765
2937
|
return (source, params, search, signal, budget, platform, nativeContext) => {
|
|
2766
2938
|
let result;
|
|
2767
2939
|
try {
|
|
2768
2940
|
result = contextlessHandler();
|
|
2769
2941
|
}
|
|
2770
2942
|
catch (err) {
|
|
2771
|
-
return
|
|
2772
|
-
? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
|
|
2773
|
-
: new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning));
|
|
2943
|
+
return fail(err, source, params, search, signal, budget, platform, nativeContext);
|
|
2774
2944
|
}
|
|
2775
2945
|
if (result instanceof Promise) {
|
|
2776
|
-
return result.then((value) =>
|
|
2777
|
-
|
|
2778
|
-
|
|
2946
|
+
return result.then((value) => {
|
|
2947
|
+
// A synchronous render throw reaches runMatchedLanes; this one would reject.
|
|
2948
|
+
try {
|
|
2949
|
+
return fusedRespondNoSet(value, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0, this.onResponseHooks.length === 0 ? undefined : requestOf(source));
|
|
2950
|
+
}
|
|
2951
|
+
catch (err) {
|
|
2952
|
+
return fail(err, source, params, search, signal, budget, platform, nativeContext);
|
|
2953
|
+
}
|
|
2954
|
+
}, (err) => fail(err, source, params, search, signal, budget, platform, nativeContext));
|
|
2779
2955
|
}
|
|
2780
|
-
return fusedRespondNoSet(result, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0);
|
|
2956
|
+
return fusedRespondNoSet(result, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0, this.onResponseHooks.length === 0 ? undefined : requestOf(source));
|
|
2781
2957
|
};
|
|
2782
2958
|
}
|
|
2783
2959
|
return (source, params, search, signal, budget, platform, nativeContext) => {
|
|
@@ -2794,11 +2970,21 @@ export class Server {
|
|
|
2794
2970
|
return logError(err, ctx);
|
|
2795
2971
|
}
|
|
2796
2972
|
if (result instanceof Promise) {
|
|
2797
|
-
return result.then((value) =>
|
|
2973
|
+
return result.then((value) => this.respondFused(value, ctx, this.onResponseHooks.length === 0), (err) => logError(err, ctx));
|
|
2798
2974
|
}
|
|
2799
2975
|
return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0);
|
|
2800
2976
|
};
|
|
2801
2977
|
}
|
|
2978
|
+
/** A fused Web lane's render in an async continuation, where a result that cannot be rendered
|
|
2979
|
+
* must fail like a handler throw; a synchronous render throw reaches {@link runMatchedLanes}. */
|
|
2980
|
+
respondFused(result, ctx, unstamped) {
|
|
2981
|
+
try {
|
|
2982
|
+
return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders, unstamped);
|
|
2983
|
+
}
|
|
2984
|
+
catch (err) {
|
|
2985
|
+
return this.bareError(err, ctx, (value, set) => this.wrapWebResponse(toResponse(value, set)), this.wrapWebResponse);
|
|
2986
|
+
}
|
|
2987
|
+
}
|
|
2802
2988
|
/** Compile the eligible body-only route's parser → validator → handler continuation once. The
|
|
2803
2989
|
* bounded parser remains shared with the generic lane; only the route-invariant entry lookups and
|
|
2804
2990
|
* lifecycle dispatch disappear from the common synchronous-validator/synchronous-handler case. */
|
|
@@ -2897,9 +3083,9 @@ export class Server {
|
|
|
2897
3083
|
return logError(err, ctx);
|
|
2898
3084
|
}
|
|
2899
3085
|
if (result instanceof Promise) {
|
|
2900
|
-
return result.then((settled) =>
|
|
3086
|
+
return result.then((settled) => this.respondFused(settled, ctx, this.onResponseHooks.length === 0), (err) => logError(err, ctx));
|
|
2901
3087
|
}
|
|
2902
|
-
return
|
|
3088
|
+
return this.respondFused(result, ctx, this.onResponseHooks.length === 0);
|
|
2903
3089
|
};
|
|
2904
3090
|
return (source, params, search, signal, budget, platform, nativeContext) => {
|
|
2905
3091
|
const ctx = nativeContext
|
|
@@ -2941,173 +3127,88 @@ export class Server {
|
|
|
2941
3127
|
* before → handler in one frame.
|
|
2942
3128
|
*/
|
|
2943
3129
|
buildFusedDeriveBefore(handler, derive, before, querySchema, maxBodyBytes) {
|
|
3130
|
+
return this.buildFusedDeriveBeforeAfter(handler, derive, before, undefined, querySchema, maxBodyBytes);
|
|
3131
|
+
}
|
|
3132
|
+
// Both hook variants share one registered chain; request contexts stay in arguments.
|
|
3133
|
+
buildFusedDeriveBeforeAfter(handler, derive, before, after, querySchema, maxBodyBytes) {
|
|
2944
3134
|
const logError = (err, ctx) => this.bareError(err, ctx, (result, set) => this.wrapWebResponse(toResponse(result, set)), this.wrapWebResponse);
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
let result;
|
|
2951
|
-
try {
|
|
2952
|
-
result = handler(ctx);
|
|
2953
|
-
}
|
|
2954
|
-
catch (err) {
|
|
2955
|
-
return logError(err, ctx);
|
|
2956
|
-
}
|
|
2957
|
-
if (result instanceof Promise) {
|
|
2958
|
-
return result.then((value) => fusedRespond(value, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0), (err) => logError(err, ctx));
|
|
2959
|
-
}
|
|
2960
|
-
return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0);
|
|
2961
|
-
};
|
|
2962
|
-
const runBefore = () => {
|
|
2963
|
-
let early;
|
|
2964
|
-
try {
|
|
2965
|
-
early = before(ctx);
|
|
2966
|
-
}
|
|
2967
|
-
catch (err) {
|
|
2968
|
-
return logError(err, ctx);
|
|
2969
|
-
}
|
|
2970
|
-
if (early instanceof Promise) {
|
|
2971
|
-
return early.then((settled) => {
|
|
2972
|
-
if (settled === undefined)
|
|
2973
|
-
return runHandler();
|
|
2974
|
-
return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders);
|
|
2975
|
-
}, (err) => logError(err, ctx));
|
|
2976
|
-
}
|
|
2977
|
-
if (early === undefined)
|
|
2978
|
-
return runHandler();
|
|
2979
|
-
return fusedRespond(early, ctx, this.responseBodyTag, this.staticResponseHeaders);
|
|
2980
|
-
};
|
|
2981
|
-
const runDerive = () => {
|
|
2982
|
-
let derived;
|
|
2983
|
-
try {
|
|
2984
|
-
derived = derive(ctx);
|
|
2985
|
-
}
|
|
2986
|
-
catch (err) {
|
|
2987
|
-
return logError(err, ctx);
|
|
2988
|
-
}
|
|
2989
|
-
if (derived instanceof Promise) {
|
|
2990
|
-
return derived.then((settled) => {
|
|
2991
|
-
if (isResponseResult(settled) || settled instanceof Response) {
|
|
2992
|
-
return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders);
|
|
2993
|
-
}
|
|
2994
|
-
Object.assign(ctx, settled);
|
|
2995
|
-
return runBefore();
|
|
2996
|
-
}, (err) => logError(err, ctx));
|
|
2997
|
-
}
|
|
2998
|
-
if (isResponseResult(derived) || derived instanceof Response) {
|
|
2999
|
-
return fusedRespond(derived, ctx, this.responseBodyTag, this.staticResponseHeaders);
|
|
3000
|
-
}
|
|
3001
|
-
Object.assign(ctx, derived);
|
|
3002
|
-
return runBefore();
|
|
3003
|
-
};
|
|
3004
|
-
if (querySchema === undefined)
|
|
3005
|
-
return runDerive();
|
|
3006
|
-
let validation;
|
|
3135
|
+
// Continuations capture route state once; each request supplies its own context.
|
|
3136
|
+
const runAfter = (result, ctx) => {
|
|
3137
|
+
if (after === undefined)
|
|
3138
|
+
return this.respondFused(result, ctx, this.onResponseHooks.length === 0);
|
|
3139
|
+
let transformed;
|
|
3007
3140
|
try {
|
|
3008
|
-
|
|
3141
|
+
transformed = after(result, ctx);
|
|
3009
3142
|
}
|
|
3010
3143
|
catch (err) {
|
|
3011
3144
|
return logError(err, ctx);
|
|
3012
3145
|
}
|
|
3013
|
-
if (
|
|
3014
|
-
return
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3146
|
+
if (transformed instanceof Promise) {
|
|
3147
|
+
return transformed.then((value) => this.respondFused(value, ctx, this.onResponseHooks.length === 0), (err) => logError(err, ctx));
|
|
3148
|
+
}
|
|
3149
|
+
return this.respondFused(transformed, ctx, this.onResponseHooks.length === 0);
|
|
3150
|
+
};
|
|
3151
|
+
const runHandler = (ctx) => {
|
|
3152
|
+
let result;
|
|
3153
|
+
try {
|
|
3154
|
+
result = handler(ctx);
|
|
3155
|
+
}
|
|
3156
|
+
catch (err) {
|
|
3157
|
+
return logError(err, ctx);
|
|
3158
|
+
}
|
|
3159
|
+
if (result instanceof Promise) {
|
|
3160
|
+
return result.then((value) => runAfter(value, ctx), (err) => logError(err, ctx));
|
|
3161
|
+
}
|
|
3162
|
+
return runAfter(result, ctx);
|
|
3163
|
+
};
|
|
3164
|
+
const runBefore = (ctx) => {
|
|
3165
|
+
let early;
|
|
3166
|
+
try {
|
|
3167
|
+
early = before(ctx);
|
|
3168
|
+
}
|
|
3169
|
+
catch (err) {
|
|
3170
|
+
return logError(err, ctx);
|
|
3171
|
+
}
|
|
3172
|
+
if (early instanceof Promise) {
|
|
3173
|
+
return early.then((settled) => {
|
|
3174
|
+
if (settled === undefined)
|
|
3175
|
+
return runHandler(ctx);
|
|
3176
|
+
return this.respondFused(settled, ctx);
|
|
3019
3177
|
}, (err) => logError(err, ctx));
|
|
3020
3178
|
}
|
|
3021
|
-
if (
|
|
3022
|
-
return
|
|
3023
|
-
|
|
3024
|
-
|
|
3179
|
+
if (early === undefined)
|
|
3180
|
+
return runHandler(ctx);
|
|
3181
|
+
return this.respondFused(early, ctx);
|
|
3182
|
+
};
|
|
3183
|
+
const runDerive = (ctx) => {
|
|
3184
|
+
let derived;
|
|
3185
|
+
try {
|
|
3186
|
+
derived = derive(ctx);
|
|
3187
|
+
}
|
|
3188
|
+
catch (err) {
|
|
3189
|
+
return logError(err, ctx);
|
|
3190
|
+
}
|
|
3191
|
+
if (derived instanceof Promise) {
|
|
3192
|
+
return derived.then((settled) => {
|
|
3193
|
+
if (isResponseResult(settled) || settled instanceof Response) {
|
|
3194
|
+
return this.respondFused(settled, ctx);
|
|
3195
|
+
}
|
|
3196
|
+
Object.assign(ctx, settled);
|
|
3197
|
+
return runBefore(ctx);
|
|
3198
|
+
}, (err) => logError(err, ctx));
|
|
3199
|
+
}
|
|
3200
|
+
if (isResponseResult(derived) || derived instanceof Response) {
|
|
3201
|
+
return this.respondFused(derived, ctx);
|
|
3202
|
+
}
|
|
3203
|
+
Object.assign(ctx, derived);
|
|
3204
|
+
return runBefore(ctx);
|
|
3025
3205
|
};
|
|
3026
|
-
}
|
|
3027
|
-
/**
|
|
3028
|
-
* Same shape as `buildFusedDeriveBefore`, plus one `afterHandle` step. The closure runs (validate) →
|
|
3029
|
-
* derive → before → handler → after in one frame, the shape the realistic middleware route lands on
|
|
3030
|
-
* when it also wants a response transform (e.g. attaching a request id to a successful payload's
|
|
3031
|
-
* headers). `LifecycleExecutionLane` and `selectRouteLanes` guarantee `afterHandle === 1`, the
|
|
3032
|
-
* throw contract is the same as the generic program, and the `after` step is the final transform +
|
|
3033
|
-
* respond the generic program already does.
|
|
3034
|
-
*/
|
|
3035
|
-
buildFusedDeriveBeforeAfter(handler, derive, before, after, querySchema, maxBodyBytes) {
|
|
3036
|
-
const logError = (err, ctx) => this.bareError(err, ctx, (result, set) => this.wrapWebResponse(toResponse(result, set)), this.wrapWebResponse);
|
|
3037
3206
|
return (source, params, search, signal, budget, platform, nativeContext) => {
|
|
3038
3207
|
const ctx = nativeContext
|
|
3039
3208
|
? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
|
|
3040
3209
|
: new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
|
|
3041
|
-
const runAfter = (result) => {
|
|
3042
|
-
let transformed;
|
|
3043
|
-
try {
|
|
3044
|
-
transformed = after(result, ctx);
|
|
3045
|
-
}
|
|
3046
|
-
catch (err) {
|
|
3047
|
-
return logError(err, ctx);
|
|
3048
|
-
}
|
|
3049
|
-
if (transformed instanceof Promise) {
|
|
3050
|
-
return transformed.then((value) => fusedRespond(value, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0), (err) => logError(err, ctx));
|
|
3051
|
-
}
|
|
3052
|
-
return fusedRespond(transformed, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0);
|
|
3053
|
-
};
|
|
3054
|
-
const runHandler = () => {
|
|
3055
|
-
let result;
|
|
3056
|
-
try {
|
|
3057
|
-
result = handler(ctx);
|
|
3058
|
-
}
|
|
3059
|
-
catch (err) {
|
|
3060
|
-
return logError(err, ctx);
|
|
3061
|
-
}
|
|
3062
|
-
if (result instanceof Promise) {
|
|
3063
|
-
return result.then((value) => runAfter(value), (err) => logError(err, ctx));
|
|
3064
|
-
}
|
|
3065
|
-
return runAfter(result);
|
|
3066
|
-
};
|
|
3067
|
-
const runBefore = () => {
|
|
3068
|
-
let early;
|
|
3069
|
-
try {
|
|
3070
|
-
early = before(ctx);
|
|
3071
|
-
}
|
|
3072
|
-
catch (err) {
|
|
3073
|
-
return logError(err, ctx);
|
|
3074
|
-
}
|
|
3075
|
-
if (early instanceof Promise) {
|
|
3076
|
-
return early.then((settled) => {
|
|
3077
|
-
if (settled === undefined)
|
|
3078
|
-
return runHandler();
|
|
3079
|
-
return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders);
|
|
3080
|
-
}, (err) => logError(err, ctx));
|
|
3081
|
-
}
|
|
3082
|
-
if (early === undefined)
|
|
3083
|
-
return runHandler();
|
|
3084
|
-
return fusedRespond(early, ctx, this.responseBodyTag, this.staticResponseHeaders);
|
|
3085
|
-
};
|
|
3086
|
-
const runDerive = () => {
|
|
3087
|
-
let derived;
|
|
3088
|
-
try {
|
|
3089
|
-
derived = derive(ctx);
|
|
3090
|
-
}
|
|
3091
|
-
catch (err) {
|
|
3092
|
-
return logError(err, ctx);
|
|
3093
|
-
}
|
|
3094
|
-
if (derived instanceof Promise) {
|
|
3095
|
-
return derived.then((settled) => {
|
|
3096
|
-
if (isResponseResult(settled) || settled instanceof Response) {
|
|
3097
|
-
return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders);
|
|
3098
|
-
}
|
|
3099
|
-
Object.assign(ctx, settled);
|
|
3100
|
-
return runBefore();
|
|
3101
|
-
}, (err) => logError(err, ctx));
|
|
3102
|
-
}
|
|
3103
|
-
if (isResponseResult(derived) || derived instanceof Response) {
|
|
3104
|
-
return fusedRespond(derived, ctx, this.responseBodyTag, this.staticResponseHeaders);
|
|
3105
|
-
}
|
|
3106
|
-
Object.assign(ctx, derived);
|
|
3107
|
-
return runBefore();
|
|
3108
|
-
};
|
|
3109
3210
|
if (querySchema === undefined)
|
|
3110
|
-
return runDerive();
|
|
3211
|
+
return runDerive(ctx);
|
|
3111
3212
|
let validation;
|
|
3112
3213
|
try {
|
|
3113
3214
|
validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]));
|
|
@@ -3120,13 +3221,13 @@ export class Server {
|
|
|
3120
3221
|
if (settled.issues !== undefined)
|
|
3121
3222
|
return this.wrapWebResponse(plainValidationError(settled.issues));
|
|
3122
3223
|
ctx.query = settled.value;
|
|
3123
|
-
return runDerive();
|
|
3224
|
+
return runDerive(ctx);
|
|
3124
3225
|
}, (err) => logError(err, ctx));
|
|
3125
3226
|
}
|
|
3126
3227
|
if (validation.issues !== undefined)
|
|
3127
3228
|
return this.wrapWebResponse(plainValidationError(validation.issues));
|
|
3128
3229
|
ctx.query = validation.value;
|
|
3129
|
-
return runDerive();
|
|
3230
|
+
return runDerive(ctx);
|
|
3130
3231
|
};
|
|
3131
3232
|
}
|
|
3132
3233
|
/**
|
|
@@ -3150,7 +3251,7 @@ export class Server {
|
|
|
3150
3251
|
const runHandlerValidated = () => {
|
|
3151
3252
|
const runAfter = (result) => {
|
|
3152
3253
|
if (after === undefined) {
|
|
3153
|
-
return
|
|
3254
|
+
return this.respondFused(result, ctx, this.onResponseHooks.length === 0);
|
|
3154
3255
|
}
|
|
3155
3256
|
let transformed;
|
|
3156
3257
|
try {
|
|
@@ -3160,9 +3261,9 @@ export class Server {
|
|
|
3160
3261
|
return logError(err, ctx);
|
|
3161
3262
|
}
|
|
3162
3263
|
if (transformed instanceof Promise) {
|
|
3163
|
-
return transformed.then((value) =>
|
|
3264
|
+
return transformed.then((value) => this.respondFused(value, ctx, this.onResponseHooks.length === 0), (err) => logError(err, ctx));
|
|
3164
3265
|
}
|
|
3165
|
-
return
|
|
3266
|
+
return this.respondFused(transformed, ctx, this.onResponseHooks.length === 0);
|
|
3166
3267
|
};
|
|
3167
3268
|
const runHandler = () => {
|
|
3168
3269
|
let result;
|
|
@@ -3189,12 +3290,12 @@ export class Server {
|
|
|
3189
3290
|
return early.then((settled) => {
|
|
3190
3291
|
if (settled === undefined)
|
|
3191
3292
|
return runHandler();
|
|
3192
|
-
return
|
|
3293
|
+
return this.respondFused(settled, ctx);
|
|
3193
3294
|
}, (err) => logError(err, ctx));
|
|
3194
3295
|
}
|
|
3195
3296
|
if (early === undefined)
|
|
3196
3297
|
return runHandler();
|
|
3197
|
-
return
|
|
3298
|
+
return this.respondFused(early, ctx);
|
|
3198
3299
|
};
|
|
3199
3300
|
let derived;
|
|
3200
3301
|
try {
|
|
@@ -3206,14 +3307,14 @@ export class Server {
|
|
|
3206
3307
|
if (derived instanceof Promise) {
|
|
3207
3308
|
return derived.then((settled) => {
|
|
3208
3309
|
if (isResponseResult(settled) || settled instanceof Response) {
|
|
3209
|
-
return
|
|
3310
|
+
return this.respondFused(settled, ctx);
|
|
3210
3311
|
}
|
|
3211
3312
|
Object.assign(ctx, settled);
|
|
3212
3313
|
return runBefore();
|
|
3213
3314
|
}, (err) => logError(err, ctx));
|
|
3214
3315
|
}
|
|
3215
3316
|
if (isResponseResult(derived) || derived instanceof Response) {
|
|
3216
|
-
return
|
|
3317
|
+
return this.respondFused(derived, ctx);
|
|
3217
3318
|
}
|
|
3218
3319
|
Object.assign(ctx, derived);
|
|
3219
3320
|
return runBefore();
|
|
@@ -3638,14 +3739,14 @@ export class Server {
|
|
|
3638
3739
|
Object.assign(ctx, entry.decorations);
|
|
3639
3740
|
const handlerOutput = entry.handler(ctx);
|
|
3640
3741
|
if (handlerOutput instanceof Promise) {
|
|
3641
|
-
return handlerOutput.then((value) => finalize(value, responseSet(ctx)), (err) => {
|
|
3742
|
+
return handlerOutput.then((value) => finalize(value, responseSet(ctx), ctx), (err) => {
|
|
3642
3743
|
if (wrapResponse) {
|
|
3643
3744
|
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
|
|
3644
3745
|
}
|
|
3645
3746
|
throw err;
|
|
3646
3747
|
});
|
|
3647
3748
|
}
|
|
3648
|
-
return finalize(handlerOutput, responseSet(ctx));
|
|
3749
|
+
return finalize(handlerOutput, responseSet(ctx), ctx);
|
|
3649
3750
|
}
|
|
3650
3751
|
handleValidationErrorRecovery(entry, recovery, ctx, finalize, wrapResponse, originalIssues, kind) {
|
|
3651
3752
|
if (recovery !== undefined) {
|
|
@@ -3751,13 +3852,16 @@ export class Server {
|
|
|
3751
3852
|
*/
|
|
3752
3853
|
applyOnResponse(response, req) {
|
|
3753
3854
|
const hooks = this.onResponseHooks;
|
|
3754
|
-
|
|
3855
|
+
// Hooks write onto the response they are handed (cookies, CORS, request ids), so every response
|
|
3856
|
+
// entering the chain - and any other one a hook hands back - is first made this request's own.
|
|
3857
|
+
let current = writableResponse(response, req);
|
|
3755
3858
|
for (let i = 0; i < hooks.length; i++) {
|
|
3756
3859
|
const next = hooks[i](current, req);
|
|
3757
3860
|
if (next instanceof Promise) {
|
|
3758
|
-
|
|
3861
|
+
const handed = current;
|
|
3862
|
+
return next.then((settled) => this.continueOnResponse(settled, i + 1, req, handed));
|
|
3759
3863
|
}
|
|
3760
|
-
current = next;
|
|
3864
|
+
current = next === current ? next : writableResponse(next, req);
|
|
3761
3865
|
}
|
|
3762
3866
|
return current;
|
|
3763
3867
|
}
|
|
@@ -3816,11 +3920,12 @@ export class Server {
|
|
|
3816
3920
|
return pending;
|
|
3817
3921
|
}
|
|
3818
3922
|
/** Async tail of {@link applyOnResponse}: runs the remaining hooks once one has gone async. */
|
|
3819
|
-
async continueOnResponse(response, nextIndex, req) {
|
|
3820
|
-
let current = response;
|
|
3923
|
+
async continueOnResponse(response, nextIndex, req, handed) {
|
|
3924
|
+
let current = response === handed ? response : writableResponse(response, req);
|
|
3821
3925
|
for (let i = nextIndex; i < this.onResponseHooks.length; i++) {
|
|
3822
3926
|
const next = this.onResponseHooks[i](current, req);
|
|
3823
|
-
|
|
3927
|
+
const settled = next instanceof Promise ? await next : next;
|
|
3928
|
+
current = settled === current ? settled : writableResponse(settled, req);
|
|
3824
3929
|
}
|
|
3825
3930
|
return current;
|
|
3826
3931
|
}
|
|
@@ -3849,7 +3954,7 @@ export class Server {
|
|
|
3849
3954
|
try {
|
|
3850
3955
|
const contract = entry.responseContract;
|
|
3851
3956
|
if (contract === undefined)
|
|
3852
|
-
return finalize(result, responseSet(ctx));
|
|
3957
|
+
return finalize(result, responseSet(ctx), ctx);
|
|
3853
3958
|
const checked = contract.runtime.check(contract.definition, result, ctx);
|
|
3854
3959
|
if (checked instanceof Promise) {
|
|
3855
3960
|
return checked.then((outcome) => this.finishContractOutcome(ctx, finalize, wrapResponse, outcome), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
|
|
@@ -3876,7 +3981,7 @@ export class Server {
|
|
|
3876
3981
|
detail: outcome.message,
|
|
3877
3982
|
});
|
|
3878
3983
|
}
|
|
3879
|
-
return finalize(outcome.value, responseSet(ctx));
|
|
3984
|
+
return finalize(outcome.value, responseSet(ctx), ctx);
|
|
3880
3985
|
}
|
|
3881
3986
|
/** Synchronous by design, and `MaybePromise` rather than `Promise` for the same reason: control flow
|
|
3882
3987
|
* out of a lifecycle stage must not cost a promise. An `async` method allocates one and defers the
|
|
@@ -3889,11 +3994,10 @@ export class Server {
|
|
|
3889
3994
|
// SvelteKit semantics): as built, plus the cookies the request queued, so a session set or
|
|
3890
3995
|
// cleared before `throw redirect(...)` / `requireSession(...)` ships. Don't run onError or log
|
|
3891
3996
|
// it as a 500.
|
|
3892
|
-
if (err instanceof Response)
|
|
3893
|
-
return finalize(err, responseSet(ctx));
|
|
3894
|
-
// Same rule for a thrown `status(...)`,
|
|
3895
|
-
|
|
3896
|
-
if (isResponseResult(err))
|
|
3997
|
+
if (err instanceof Response && err !== ctx.jsonReply)
|
|
3998
|
+
return finalize(err, responseSet(ctx), ctx);
|
|
3999
|
+
// Same rule for a thrown `status(...)` or `c.json(...)`, which stay held to the route's contract.
|
|
4000
|
+
if (err instanceof Response || isResponseResult(err))
|
|
3897
4001
|
return this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, err);
|
|
3898
4002
|
if (entry.onError.length === 0) {
|
|
3899
4003
|
// Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
|
|
@@ -3911,8 +4015,20 @@ export class Server {
|
|
|
3911
4015
|
for (let i = 0; i < entry.onError.length; i++) {
|
|
3912
4016
|
const outcome = entry.onError[i](err, ctx);
|
|
3913
4017
|
const handled = outcome instanceof Promise ? await outcome : outcome;
|
|
3914
|
-
if (handled
|
|
3915
|
-
|
|
4018
|
+
if (handled === undefined)
|
|
4019
|
+
continue;
|
|
4020
|
+
const contract = entry.responseContract;
|
|
4021
|
+
if (contract === undefined || (handled !== ctx.jsonReply && !isResponseResult(handled)))
|
|
4022
|
+
return finalize(handled, responseSet(ctx), ctx);
|
|
4023
|
+
// Checked here, not through finishLifecycleContract: a throwing schema must not re-enter onError.
|
|
4024
|
+
try {
|
|
4025
|
+
const checked = await contract.runtime.check(contract.definition, handled, ctx);
|
|
4026
|
+
return this.finishContractOutcome(ctx, finalize, wrapResponse, checked);
|
|
4027
|
+
}
|
|
4028
|
+
catch (checkError) {
|
|
4029
|
+
this.logRequestError(checkError, ctx);
|
|
4030
|
+
return wrapResponse(plainError(500, "internal_error"));
|
|
4031
|
+
}
|
|
3916
4032
|
}
|
|
3917
4033
|
this.logRequestError(err, ctx);
|
|
3918
4034
|
return wrapResponse(plainError(500, "internal_error"));
|
|
@@ -4003,7 +4119,14 @@ export class Server {
|
|
|
4003
4119
|
return this.applyOnResponseAndFinalize(response, request);
|
|
4004
4120
|
};
|
|
4005
4121
|
const runNative = (request, params) => {
|
|
4006
|
-
|
|
4122
|
+
let outcome;
|
|
4123
|
+
try {
|
|
4124
|
+
outcome = capped(request, params, undefined, signal, budget, peer, true);
|
|
4125
|
+
}
|
|
4126
|
+
catch (err) {
|
|
4127
|
+
// A synchronous render throw, as in runMatchedLanes.
|
|
4128
|
+
outcome = this.contextlessBareError(err, request, params, undefined, signal, budget, peer, this.wrapWebResponse);
|
|
4129
|
+
}
|
|
4007
4130
|
return outcome instanceof Promise
|
|
4008
4131
|
? outcome.then((response) => finishNative(request, response))
|
|
4009
4132
|
: finishNative(request, outcome);
|
|
@@ -4020,7 +4143,7 @@ export class Server {
|
|
|
4020
4143
|
markFramed(request);
|
|
4021
4144
|
return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
|
|
4022
4145
|
? this.fetchMatched(request, entry, EMPTY_PARAMS, peer)
|
|
4023
|
-
:
|
|
4146
|
+
: runNative(request, EMPTY_PARAMS);
|
|
4024
4147
|
};
|
|
4025
4148
|
}
|
|
4026
4149
|
return (request) => {
|
|
@@ -4048,7 +4171,7 @@ export class Server {
|
|
|
4048
4171
|
return fallback(request, server);
|
|
4049
4172
|
return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
|
|
4050
4173
|
? this.fetchMatched(request, entry, params, peer)
|
|
4051
|
-
:
|
|
4174
|
+
: runNative(request, params);
|
|
4052
4175
|
};
|
|
4053
4176
|
}
|
|
4054
4177
|
return (request, server) => {
|
|
@@ -4072,7 +4195,7 @@ export class Server {
|
|
|
4072
4195
|
// bypasses - so an app that declares trust routes through the fetch lane (where `c.clientIp`
|
|
4073
4196
|
// resolves) instead of Bun's native table. The allocation-free default keeps native fusion.
|
|
4074
4197
|
if ((this.onRequestHooks.length > 0 && !this.bunNativeRequestHooksSafe) ||
|
|
4075
|
-
this.
|
|
4198
|
+
this.wsRoutes.length > 0 ||
|
|
4076
4199
|
this.preRouteMountCount > 0 ||
|
|
4077
4200
|
this.clientIpTrust !== undefined) {
|
|
4078
4201
|
return undefined;
|
|
@@ -4208,7 +4331,7 @@ export class Server {
|
|
|
4208
4331
|
}
|
|
4209
4332
|
// Seal before Bun.serve: in `"error"` mode this throws instead of leaving a bound port behind.
|
|
4210
4333
|
if (hookAuditRuntime && process.env.NODE_ENV !== "production")
|
|
4211
|
-
sealHookAudit(this, this.catalog.size, this.logger);
|
|
4334
|
+
sealHookAudit(this, this.catalog.size, this.wsRoutes.length, this.logger);
|
|
4212
4335
|
// Bun's `Server` is the concrete handle; we expose the stable `RunningServer`
|
|
4213
4336
|
// subset so the public types don't depend on the ambient `Bun` global. The cast
|
|
4214
4337
|
// bridges them - Bun's `.port` is `number | undefined` (undefined only for unix
|
|
@@ -4219,7 +4342,7 @@ export class Server {
|
|
|
4219
4342
|
// `websocket` handlers are one shared dispatcher - each connection's `ws.data.handler` is the
|
|
4220
4343
|
// matched route's handler, set by `server.upgrade`.
|
|
4221
4344
|
// With WS routes, the dispatcher comes from the installed `.use(websocket())` runtime - non-null
|
|
4222
|
-
// because
|
|
4345
|
+
// because a socket route means ws() ran, and ws() requires the runtime at registration.
|
|
4223
4346
|
// Native pub/sub when the app has WS routes and none validate outbound frames: `ws.subscribe` and
|
|
4224
4347
|
// `app.publish` go through Bun's own (uWebSockets) broadcast instead of the JS registry loop.
|
|
4225
4348
|
// A mounted app counts only while it has a WebSocket route (see `[GET_WS_RUNTIME]`), so composing
|
|
@@ -4232,7 +4355,7 @@ export class Server {
|
|
|
4232
4355
|
// upgrades owns a different TopicRegistry, so force the per-connection JS dispatcher and carry
|
|
4233
4356
|
// that registry in the upgrade outcome instead of accidentally broadcasting child sockets through
|
|
4234
4357
|
// the parent.
|
|
4235
|
-
const nativePubsub = this.
|
|
4358
|
+
const nativePubsub = this.wsRoutes.length > 0 && !this.mountsTakeUpgrades() && !this.wsHasValidatedSend;
|
|
4236
4359
|
const wsHandlers = wsRuntime?.bunHandlers(this.topics ?? wsRuntime.createTopics(), nativePubsub);
|
|
4237
4360
|
// Spread rather than pass `hostname: undefined` (or an undefined idle timeout or TLS) - Bun treats
|
|
4238
4361
|
// an explicit undefined as a value on some option paths, and omitting is what selects its own
|
|
@@ -4299,7 +4422,7 @@ export class Server {
|
|
|
4299
4422
|
}
|
|
4300
4423
|
this.sealed = true;
|
|
4301
4424
|
if (this.gracefulSignals)
|
|
4302
|
-
this.installSignalHandlers();
|
|
4425
|
+
this.installSignalHandlers(this.gracefulSignals);
|
|
4303
4426
|
return running;
|
|
4304
4427
|
}
|
|
4305
4428
|
/**
|
|
@@ -4332,12 +4455,12 @@ export class Server {
|
|
|
4332
4455
|
new Promise((resolve) => setTimeout(resolve, STOP_HOOK_TIMEOUT_MS)),
|
|
4333
4456
|
]);
|
|
4334
4457
|
}
|
|
4335
|
-
installSignalHandlers() {
|
|
4458
|
+
installSignalHandlers(drain) {
|
|
4336
4459
|
// Drain, then let the process exit naturally - the stopped server no longer
|
|
4337
4460
|
// holds the event loop open. Opt-in (`gracefulSignals`), so taking over the
|
|
4338
4461
|
// signals is consented; we don't force `process.exit`.
|
|
4339
4462
|
const onSignal = () => {
|
|
4340
|
-
void this.stop();
|
|
4463
|
+
void this.stop(drain);
|
|
4341
4464
|
};
|
|
4342
4465
|
process.once("SIGTERM", onSignal);
|
|
4343
4466
|
process.once("SIGINT", onSignal);
|