@nifrajs/core 2.1.0 → 2.3.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 +2 -2
- package/dist/assurance.d.ts +48 -4
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +87 -9
- package/dist/assurance.js.map +1 -1
- package/dist/binary.d.ts +54 -0
- package/dist/binary.d.ts.map +1 -0
- package/dist/binary.js +90 -0
- package/dist/binary.js.map +1 -0
- package/dist/budget.d.ts +1 -1
- package/dist/budget.js +1 -1
- package/dist/capabilities.d.ts +19 -2
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +60 -8
- package/dist/capabilities.js.map +1 -1
- package/dist/classification.d.ts +1 -1
- package/dist/classification.js +1 -1
- package/dist/diff.d.ts +6 -6
- package/dist/diff.js +6 -6
- package/dist/durable-adapters.d.ts.map +1 -1
- package/dist/durable-adapters.js +36 -70
- package/dist/durable-adapters.js.map +1 -1
- package/dist/durable-execution.d.ts +2 -2
- package/dist/durable-execution.js +2 -2
- package/dist/idempotency.d.ts +3 -3
- package/dist/idempotency.js +4 -4
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/internal/brand.d.ts +1 -1
- package/dist/internal/brand.js +1 -1
- package/dist/internal/route-catalog.d.ts +58 -0
- package/dist/internal/route-catalog.d.ts.map +1 -0
- package/dist/internal/route-catalog.js +58 -0
- package/dist/internal/route-catalog.js.map +1 -0
- package/dist/internal/route-execution.d.ts +75 -0
- package/dist/internal/route-execution.d.ts.map +1 -0
- package/dist/internal/route-execution.js +2 -0
- package/dist/internal/route-execution.js.map +1 -0
- package/dist/internal/same-origin.d.ts +29 -0
- package/dist/internal/same-origin.d.ts.map +1 -0
- package/dist/internal/same-origin.js +47 -0
- package/dist/internal/same-origin.js.map +1 -0
- package/dist/internal/secret.d.ts +1 -1
- package/dist/internal/secret.js +1 -1
- package/dist/ledger.d.ts +9 -9
- package/dist/ledger.js +5 -5
- package/dist/reflection.d.ts +1 -1
- package/dist/router/pattern.d.ts +26 -1
- package/dist/router/pattern.d.ts.map +1 -1
- package/dist/router/pattern.js +164 -8
- package/dist/router/pattern.js.map +1 -1
- package/dist/router/router.d.ts.map +1 -1
- package/dist/router/router.js +52 -4
- package/dist/router/router.js.map +1 -1
- package/dist/schema/standard.d.ts +2 -2
- package/dist/schema/standard.js +2 -2
- package/dist/seo.d.ts +3 -3
- package/dist/seo.js +4 -4
- package/dist/server/body.d.ts +2 -2
- package/dist/server/body.js +2 -2
- package/dist/server/context.d.ts +18 -18
- package/dist/server/contract.d.ts +7 -7
- package/dist/server/contract.js +3 -3
- package/dist/server/cookies.d.ts +4 -4
- package/dist/server/cookies.js +8 -8
- package/dist/server/edge.d.ts +83 -0
- package/dist/server/edge.d.ts.map +1 -0
- package/dist/server/edge.js +71 -0
- package/dist/server/edge.js.map +1 -0
- 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 +7 -7
- package/dist/server/logger.js +4 -4
- package/dist/server/registry.d.ts +5 -5
- package/dist/server/response-contract-lane.d.ts +91 -0
- package/dist/server/response-contract-lane.d.ts.map +1 -0
- package/dist/server/response-contract-lane.js +81 -0
- package/dist/server/response-contract-lane.js.map +1 -0
- package/dist/server/server.d.ts +52 -91
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +130 -186
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse.d.ts +4 -4
- package/dist/server/sse.js +7 -7
- package/dist/server/webhook.d.ts +1 -1
- package/dist/server/webhook.js +5 -5
- package/dist/server/websocket.d.ts +19 -19
- package/dist/server/websocket.js +9 -9
- package/dist/server/ws-bun.d.ts +4 -4
- package/dist/server/ws-bun.js +3 -3
- package/dist/server.d.ts +3 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3 -1
- package/dist/server.js.map +1 -1
- package/dist/transport-codec.d.ts +13 -1
- package/dist/transport-codec.d.ts.map +1 -1
- package/dist/transport-codec.js +48 -9
- package/dist/transport-codec.js.map +1 -1
- package/package.json +12 -2
- package/src/assurance.ts +154 -8
- package/src/binary.ts +128 -0
- package/src/budget.ts +1 -1
- package/src/capabilities.ts +71 -9
- package/src/classification.ts +1 -1
- package/src/diff.ts +8 -8
- package/src/durable-adapters.ts +40 -70
- package/src/durable-execution.ts +2 -2
- package/src/idempotency.ts +4 -4
- package/src/index.ts +3 -3
- package/src/internal/brand.ts +1 -1
- package/src/internal/route-catalog.ts +94 -0
- package/src/internal/route-execution.ts +106 -0
- package/src/internal/same-origin.ts +43 -0
- package/src/internal/secret.ts +1 -1
- package/src/ledger.ts +11 -11
- package/src/reflection.ts +1 -1
- package/src/router/pattern.ts +179 -8
- package/src/router/router.ts +80 -6
- package/src/schema/standard.ts +2 -2
- package/src/seo.ts +6 -6
- package/src/server/body.ts +2 -2
- package/src/server/context.ts +18 -18
- package/src/server/contract.ts +13 -13
- package/src/server/cookies.ts +8 -8
- package/src/server/edge.ts +150 -0
- package/src/server/install.ts +5 -0
- package/src/server/logger.ts +8 -8
- package/src/server/registry.ts +5 -5
- package/src/server/response-contract-lane.ts +164 -0
- package/src/server/server.ts +159 -366
- package/src/server/sse.ts +11 -11
- package/src/server/webhook.ts +5 -5
- package/src/server/websocket.ts +22 -22
- package/src/server/ws-bun.ts +4 -4
- package/src/server.ts +8 -5
- package/src/transport-codec.ts +63 -10
package/dist/server/server.js
CHANGED
|
@@ -2,7 +2,9 @@ import { admitDeadline, createRequestBudget, createUnboundedRequestBudget, NIFRA
|
|
|
2
2
|
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
|
-
import {
|
|
5
|
+
import { RouteCatalog } from "../internal/route-catalog.js";
|
|
6
|
+
import { isSameOriginRequest } from "../internal/same-origin.js";
|
|
7
|
+
import { compileRoutePattern, decodeRouteParams } from "../router/pattern.js";
|
|
6
8
|
import { EMPTY_PARAMS, Router } from "../router/router.js";
|
|
7
9
|
import { parseContentLength } from "./body.js";
|
|
8
10
|
import { resolveClientIp } from "./client-ip.js";
|
|
@@ -11,61 +13,10 @@ import { isUrlEncodedForm, queryObjectOf, readBoundedForm, searchOf, } from "./q
|
|
|
11
13
|
import { RequestContext, readBoundedJsonSource } from "./request-context.js";
|
|
12
14
|
import { fusedRespond, fusedRespondNoSet, toResponse } from "./respond.js";
|
|
13
15
|
import { CONTEXT_SEARCH, CONTEXT_SET, EMPTY_RESPONSE_CONTROLS, getNeverAbortSignal, getUnboundedRequestBudget, headerOf, requestOf, } from "./runtime-core.js";
|
|
14
|
-
import { INSTALL_EFFECT_LEDGER, INSTALL_IDEMPOTENCY, INSTALL_MCP, INSTALL_NODE_DIRECT, INSTALL_SSE, INSTALL_WS, } from "./install.js";
|
|
16
|
+
import { INSTALL_EFFECT_LEDGER, INSTALL_IDEMPOTENCY, INSTALL_MCP, INSTALL_NODE_DIRECT, INSTALL_RESPONSE_CONTRACT, INSTALL_SSE, INSTALL_WS, } from "./install.js";
|
|
15
17
|
import { jsonLogger } from "./logger.js";
|
|
16
18
|
const functionToString = Function.prototype.toString;
|
|
17
19
|
const CONTEXTLESS_ARROW = /^(?:async\s*)?\(\s*\)\s*(?::[\s\S]*?)?=>/;
|
|
18
|
-
/**
|
|
19
|
-
* Runtime route catalog. Single-route registration mutates directly; multi-route registration replays
|
|
20
|
-
* the existing catalog plus the candidate batch into a staged router, then swaps the complete state only
|
|
21
|
-
* after every route validates. Failed `implement()`/`merge()` batches therefore leave matching and
|
|
22
|
-
* reflection unchanged.
|
|
23
|
-
*/
|
|
24
|
-
class RouteCatalog {
|
|
25
|
-
matcher = new Router();
|
|
26
|
-
records = [];
|
|
27
|
-
/** Allocation-free reflection view for the common no-assurance case. Derived only at commit time. */
|
|
28
|
-
descriptors = [];
|
|
29
|
-
assurancePresent = false;
|
|
30
|
-
add(route) {
|
|
31
|
-
this.matcher.add(route.method, route.pattern, route.entry);
|
|
32
|
-
this.records.push(route);
|
|
33
|
-
this.descriptors.push(route.descriptor);
|
|
34
|
-
if (route.assurance.length > 0)
|
|
35
|
-
this.assurancePresent = true;
|
|
36
|
-
}
|
|
37
|
-
addBatch(routes) {
|
|
38
|
-
if (routes.length === 0)
|
|
39
|
-
return;
|
|
40
|
-
const nextRecords = this.records.concat(routes);
|
|
41
|
-
const nextDescriptors = this.descriptors.concat(routes.map(({ descriptor }) => descriptor));
|
|
42
|
-
const nextAssurancePresent = this.assurancePresent || routes.some((route) => route.assurance.length > 0);
|
|
43
|
-
const staged = new Router();
|
|
44
|
-
for (const route of this.records)
|
|
45
|
-
staged.add(route.method, route.pattern, route.entry);
|
|
46
|
-
for (const route of routes)
|
|
47
|
-
staged.add(route.method, route.pattern, route.entry);
|
|
48
|
-
this.matcher = staged;
|
|
49
|
-
this.records = nextRecords;
|
|
50
|
-
this.descriptors = nextDescriptors;
|
|
51
|
-
this.assurancePresent = nextAssurancePresent;
|
|
52
|
-
}
|
|
53
|
-
find(method, path) {
|
|
54
|
-
return this.matcher.find(method, path);
|
|
55
|
-
}
|
|
56
|
-
entries() {
|
|
57
|
-
return this.records;
|
|
58
|
-
}
|
|
59
|
-
routeDescriptors() {
|
|
60
|
-
return this.descriptors;
|
|
61
|
-
}
|
|
62
|
-
lastDescriptor() {
|
|
63
|
-
return this.records[this.records.length - 1]?.descriptor;
|
|
64
|
-
}
|
|
65
|
-
hasAssurance() {
|
|
66
|
-
return this.assurancePresent;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
20
|
/** The socket peer Bun observed, as a `Platform` for the request lifecycle (`undefined` if unknown).
|
|
70
21
|
* Typed structurally on `requestIP` alone so any Bun `Server` (WS or not) satisfies it. */
|
|
71
22
|
function bunPeerPlatform(server, req) {
|
|
@@ -98,17 +49,10 @@ export { defineIdentityPlugin, definePlugin, defineRouterPlugin, } from "./plugi
|
|
|
98
49
|
const DEFAULT_MAX_BODY_BYTES = 1_000_000;
|
|
99
50
|
const DEFAULT_DRAIN_MS = 10_000;
|
|
100
51
|
const DRAIN_POLL_MS = 10;
|
|
101
|
-
/** Same-origin check for a WebSocket handshake (CSWSH default)
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
|
|
105
|
-
try {
|
|
106
|
-
return new URL(origin).host === new URL(req.url).host;
|
|
107
|
-
}
|
|
108
|
-
catch {
|
|
109
|
-
return false;
|
|
110
|
-
}
|
|
111
|
-
}
|
|
52
|
+
/** Same-origin check for a WebSocket handshake (CSWSH default). {@link isSameOriginRequest} is the one
|
|
53
|
+
* owner, shared with the server-function mount in `@nifrajs/web` - the two used to answer differently
|
|
54
|
+
* for the same request, so a browser that could open a socket was told its POST was cross-origin. */
|
|
55
|
+
const wsSameOrigin = isSameOriginRequest;
|
|
112
56
|
function validationError(issues) {
|
|
113
57
|
const serialized = issues.map((issue) => {
|
|
114
58
|
const path = issue.path?.map((seg) => String(typeof seg === "object" ? seg.key : seg));
|
|
@@ -159,12 +103,15 @@ export class Server {
|
|
|
159
103
|
wsRouter;
|
|
160
104
|
wsRouteCount;
|
|
161
105
|
/** In-process pub/sub backing `ws.subscribe(topic)` + `app.publish(topic, data)` (single-instance).
|
|
162
|
-
* Created by the first `app.ws()` via the `@nifrajs/core/ws` runtime
|
|
106
|
+
* Created by the first `app.ws()` via the `@nifrajs/core/ws` runtime - `undefined` until then, so a
|
|
163
107
|
* no-WebSocket app never constructs (or bundles) it. */
|
|
164
108
|
topics;
|
|
165
109
|
maxBodyBytes;
|
|
166
110
|
wsMaxPayloadBytes;
|
|
167
111
|
requestTimeoutMs;
|
|
112
|
+
/** Installed by the `responseContract()` plugin; `undefined` = not installed, which is the default
|
|
113
|
+
* and the state in which a declared `response` schema stays a compile-time contract only. */
|
|
114
|
+
responseContractRuntime;
|
|
168
115
|
/** Opt-in caller-IP trust declaration; `undefined` = socket peer only, no forwarded header believed. */
|
|
169
116
|
clientIpTrust;
|
|
170
117
|
acceptInboundDeadlines;
|
|
@@ -205,7 +152,7 @@ export class Server {
|
|
|
205
152
|
onResponseHooks;
|
|
206
153
|
onResponseFinalizedHooks;
|
|
207
154
|
responseRequests;
|
|
208
|
-
/** Names of plugins/middleware already applied via `use`
|
|
155
|
+
/** Names of plugins/middleware already applied via `use` - for idempotent dedupe. */
|
|
209
156
|
appliedPlugins;
|
|
210
157
|
/** Order-scoped evidence captured by routes registered after an assured plugin. */
|
|
211
158
|
activeAssurance;
|
|
@@ -245,6 +192,7 @@ export class Server {
|
|
|
245
192
|
this.effectLedgerRuntime = undefined;
|
|
246
193
|
// The idempotency runtime is installed by `.use(idempotency())`; a bare app never imports it, so the
|
|
247
194
|
// dedupe machinery tree-shakes out. A route that declares idempotency without it is a build error.
|
|
195
|
+
this.responseContractRuntime = undefined;
|
|
248
196
|
this.idempotencyRuntime = undefined;
|
|
249
197
|
this.logger = options.logger ?? jsonLogger();
|
|
250
198
|
this.defaultOnValidationError = options.onValidationError;
|
|
@@ -347,7 +295,7 @@ export class Server {
|
|
|
347
295
|
this.onErrorHooks.push(fn);
|
|
348
296
|
return this;
|
|
349
297
|
}
|
|
350
|
-
/** Transform every outgoing response
|
|
298
|
+
/** Transform every outgoing response - success, error, 404, 405, short-circuit. Global. */
|
|
351
299
|
onResponse(fn) {
|
|
352
300
|
this.assertConfigurable("onResponse()");
|
|
353
301
|
this.onResponseHooks.push(fn);
|
|
@@ -417,11 +365,11 @@ export class Server {
|
|
|
417
365
|
return this.route("GET", path, schemaOrHandler, handler);
|
|
418
366
|
}
|
|
419
367
|
/**
|
|
420
|
-
* Register a **typed SSE route**
|
|
368
|
+
* Register a **typed SSE route** - a GET endpoint streaming `text/event-stream` whose event
|
|
421
369
|
* payloads are contracted by `schema.sse`. The handler receives the validated context plus a
|
|
422
370
|
* {@link TypedSSEStream}: `stream.send(event)` is compile-time-checked against the schema and
|
|
423
371
|
* JSON-serialized into the SSE `data:` field. The typed client sees the marker and grows a
|
|
424
|
-
* `.subscribe(onEvent)` for the route with the same payload type
|
|
372
|
+
* `.subscribe(onEvent)` for the route with the same payload type - end-to-end typed streaming.
|
|
425
373
|
*
|
|
426
374
|
* import { streaming } from "@nifrajs/core/sse" // .use(streaming()) enables .sse()
|
|
427
375
|
* const app = server().use(streaming()).sse("/feed", { sse: t.object({ id: t.integer(), title: t.string() }) },
|
|
@@ -457,7 +405,7 @@ export class Server {
|
|
|
457
405
|
const plan = requireMcpRuntime(this.mcpRuntime).tool(name, config, handler);
|
|
458
406
|
this.register("POST", plan.path, plan.schema, plan.run);
|
|
459
407
|
// Tag the just-registered descriptor as an MCP tool. `tool` is readonly on RouteDescriptor (an
|
|
460
|
-
// introspection field), so write it through a narrow mutable view
|
|
408
|
+
// introspection field), so write it through a narrow mutable view - not `any`.
|
|
461
409
|
const lastRoute = this.catalog.lastDescriptor();
|
|
462
410
|
if (lastRoute) {
|
|
463
411
|
;
|
|
@@ -466,7 +414,7 @@ export class Server {
|
|
|
466
414
|
return this;
|
|
467
415
|
}
|
|
468
416
|
/**
|
|
469
|
-
* Declare an MCP **resource**
|
|
417
|
+
* Declare an MCP **resource** - read-only data an agent can fetch through `nifra mcp` (app config, a
|
|
470
418
|
* generated document, …). `read` runs in the app process, so capture whatever app state it needs in the
|
|
471
419
|
* closure. `uri` is the MCP resource identifier (e.g. `"myapp://config"`). The sibling of {@link tool}
|
|
472
420
|
* for the resource half of MCP.
|
|
@@ -477,7 +425,7 @@ export class Server {
|
|
|
477
425
|
return this;
|
|
478
426
|
}
|
|
479
427
|
/**
|
|
480
|
-
* Declare an MCP **prompt**
|
|
428
|
+
* Declare an MCP **prompt** - a reusable prompt template an agent can fetch through `nifra mcp`.
|
|
481
429
|
* `handler` receives the caller's arguments and returns the rendered messages.
|
|
482
430
|
*/
|
|
483
431
|
prompt(name, config, handler) {
|
|
@@ -485,11 +433,11 @@ export class Server {
|
|
|
485
433
|
this.mcpPromptList.push(requireMcpRuntime(this.mcpRuntime).prompt(name, config, handler));
|
|
486
434
|
return this;
|
|
487
435
|
}
|
|
488
|
-
/** The MCP resources declared via {@link resource}
|
|
436
|
+
/** The MCP resources declared via {@link resource} - enumerated by `nifra mcp`. */
|
|
489
437
|
mcpResources() {
|
|
490
438
|
return this.mcpResourceList;
|
|
491
439
|
}
|
|
492
|
-
/** The MCP prompts declared via {@link prompt}
|
|
440
|
+
/** The MCP prompts declared via {@link prompt} - enumerated by `nifra mcp`. */
|
|
493
441
|
mcpPrompts() {
|
|
494
442
|
return this.mcpPromptList;
|
|
495
443
|
}
|
|
@@ -497,7 +445,7 @@ export class Server {
|
|
|
497
445
|
* Register a **WebSocket** route. The connection upgrades on a `GET` to `path` carrying
|
|
498
446
|
* `Upgrade: websocket`; the optional `handler.upgrade(c)` runs in the request context first and may
|
|
499
447
|
* reject (return a `Response`) or seed per-connection `ws.data`. WebSockets are served by the
|
|
500
|
-
* adapter (`listen()`, `@nifrajs/node`, `@nifrajs/deno`, `toFetchHandler`)
|
|
448
|
+
* adapter (`listen()`, `@nifrajs/node`, `@nifrajs/deno`, `toFetchHandler`) - not by bare `app.fetch`, which
|
|
501
449
|
* has no socket (a WS path through `app.fetch` is a normal HTTP response).
|
|
502
450
|
*
|
|
503
451
|
* The route also enters the type-level registry (under the pseudo-method `"WS"`), so the typed
|
|
@@ -511,10 +459,10 @@ export class Server {
|
|
|
511
459
|
ws(path, handler) {
|
|
512
460
|
this.assertConfigurable("ws()");
|
|
513
461
|
// Boot-time guard: the WS runtime is a subpath (`@nifrajs/core/ws`) so no-WebSocket apps don't
|
|
514
|
-
// bundle it. Registration is the loud, early failure point
|
|
462
|
+
// bundle it. Registration is the loud, early failure point - never the first connection.
|
|
515
463
|
const runtime = requireWsRuntime(this.wsRuntime);
|
|
516
464
|
this.topics ??= runtime.createTopics();
|
|
517
|
-
// A `messageSchema` wraps `message` with validation once, here
|
|
465
|
+
// A `messageSchema` wraps `message` with validation once, here - every adapter then dispatches
|
|
518
466
|
// already-validated, typed messages (Bun/Deno/Node/Workers) with no per-adapter code.
|
|
519
467
|
this.wsRouter.add("GET", path, {
|
|
520
468
|
handler: runtime.wrapHandler(handler),
|
|
@@ -524,11 +472,11 @@ export class Server {
|
|
|
524
472
|
}
|
|
525
473
|
/**
|
|
526
474
|
* Broadcast `data` to every WebSocket connection subscribed to `topic` (via `ws.subscribe(topic)`).
|
|
527
|
-
* In-process and **single-instance** (see {@link TopicRegistry})
|
|
475
|
+
* In-process and **single-instance** (see {@link TopicRegistry}) - a multi-instance deploy must bridge
|
|
528
476
|
* an external fan-out (Redis, a Durable Object) to this. A no-op when nobody is subscribed.
|
|
529
477
|
*/
|
|
530
478
|
publish(topic, data) {
|
|
531
|
-
// No `app.ws()` yet ⇒ no registry and necessarily no subscribers
|
|
479
|
+
// No `app.ws()` yet ⇒ no registry and necessarily no subscribers - a publish is a no-op anyway.
|
|
532
480
|
this.topics?.publish(topic, data);
|
|
533
481
|
}
|
|
534
482
|
route(method, path, schemaOrHandler, handler) {
|
|
@@ -549,7 +497,7 @@ export class Server {
|
|
|
549
497
|
}
|
|
550
498
|
/**
|
|
551
499
|
* Low-level route registration shared by the inline builder and `implement()`.
|
|
552
|
-
* Captures the server's current `derive`/`decorate` chain into the route
|
|
500
|
+
* Captures the server's current `derive`/`decorate` chain into the route - this
|
|
553
501
|
* is the "compiled", order-scoped per-route chain.
|
|
554
502
|
*/
|
|
555
503
|
register(method, path, schema, handler) {
|
|
@@ -578,7 +526,7 @@ export class Server {
|
|
|
578
526
|
}
|
|
579
527
|
const hasDecorations = Reflect.ownKeys(routeDecorations).length > 0;
|
|
580
528
|
// An idempotency route runs a dedupe lane that must buffer the body and capture the response, so it
|
|
581
|
-
// never takes the fused/native fast path
|
|
529
|
+
// never takes the fused/native fast path - force it onto the portable matched lane (which routes
|
|
582
530
|
// through `fetchMatched`, where the dedupe wrapper lives). Fail closed: a route may not declare
|
|
583
531
|
// idempotency unless the idempotency runtime is installed, so the safety gate can never be silently
|
|
584
532
|
// dropped by a missing plugin.
|
|
@@ -588,13 +536,21 @@ export class Server {
|
|
|
588
536
|
const idempotent = this.idempotencyRuntime?.resolve(schema, authenticated, this.maxBodyBytes);
|
|
589
537
|
// A ledgered route (capabilities declared + `.use(effectLedger())`) needs a per-request
|
|
590
538
|
// context to carry the ledger and a settle step to seal + sink it, so it too leaves the
|
|
591
|
-
// fused/contextless fast path. Resolved per route, at registration
|
|
539
|
+
// fused/contextless fast path. Resolved per route, at registration - like the capability guard.
|
|
592
540
|
const ledgered = this.effectLedgerRuntime?.resolve(capabilities, method, path);
|
|
541
|
+
// A checked response schema needs the handler's VALUE before it becomes bytes, and the fused and
|
|
542
|
+
// native lanes exist precisely to skip that step. So a contracted route leaves them - the same
|
|
543
|
+
// trade an idempotent route makes above, and the reason this is opt-in per server.
|
|
544
|
+
const runtime = this.responseContractRuntime;
|
|
545
|
+
const contracted = runtime !== undefined && schema?.response !== undefined
|
|
546
|
+
? { runtime, schema: schema.response }
|
|
547
|
+
: undefined;
|
|
593
548
|
const bare = schema?.params === undefined &&
|
|
594
549
|
schema?.body === undefined &&
|
|
595
550
|
schema?.query === undefined &&
|
|
596
551
|
idempotent === undefined &&
|
|
597
552
|
ledgered === undefined &&
|
|
553
|
+
contracted === undefined &&
|
|
598
554
|
this.derives.length === 0 &&
|
|
599
555
|
this.beforeHandleHooks.length === 0 &&
|
|
600
556
|
this.afterHandleHooks.length === 0 &&
|
|
@@ -603,9 +559,12 @@ export class Server {
|
|
|
603
559
|
? this.buildFusedWeb(handler, hasDecorations ? routeDecorations : undefined, isContextlessNoArgArrow(handler))
|
|
604
560
|
: undefined;
|
|
605
561
|
const contextless = bare && this.aroundHooks.length === 0 && isContextlessNoArgArrow(handler);
|
|
562
|
+
// The body and query lanes finalize the handler's result themselves, so a contracted route takes
|
|
563
|
+
// the general lifecycle lane instead - one place owns the check rather than three.
|
|
606
564
|
const lane = bare
|
|
607
565
|
? "bare"
|
|
608
|
-
:
|
|
566
|
+
: contracted === undefined &&
|
|
567
|
+
schema?.body !== undefined &&
|
|
609
568
|
schema.query === undefined &&
|
|
610
569
|
schema.params === undefined &&
|
|
611
570
|
this.derives.length === 0 &&
|
|
@@ -613,7 +572,8 @@ export class Server {
|
|
|
613
572
|
this.afterHandleHooks.length === 0 &&
|
|
614
573
|
this.onErrorHooks.length === 0
|
|
615
574
|
? "body"
|
|
616
|
-
:
|
|
575
|
+
: contracted === undefined &&
|
|
576
|
+
schema?.body === undefined &&
|
|
617
577
|
schema?.query !== undefined &&
|
|
618
578
|
schema.params === undefined &&
|
|
619
579
|
this.derives.length === 0 &&
|
|
@@ -630,6 +590,7 @@ export class Server {
|
|
|
630
590
|
schema,
|
|
631
591
|
idempotent,
|
|
632
592
|
ledgered,
|
|
593
|
+
responseContract: contracted,
|
|
633
594
|
derives: [...this.derives],
|
|
634
595
|
decorations: routeDecorations,
|
|
635
596
|
hasDecorations,
|
|
@@ -730,6 +691,11 @@ export class Server {
|
|
|
730
691
|
idempotencyRunLanes(buffered, platform, entry, params, search) {
|
|
731
692
|
return Promise.resolve(this.runMatchedLanes(buffered, platform, entry, params, search, toResponse, IDENTITY_RESPONSE, RESPONSE_TIMEOUT, false));
|
|
732
693
|
}
|
|
694
|
+
/** @internal Symbol-keyed install seam for the `responseContract()` plugin. Off the public typed surface. */
|
|
695
|
+
[INSTALL_RESPONSE_CONTRACT](runtime) {
|
|
696
|
+
this.assertConfigurable("responseContract()");
|
|
697
|
+
this.responseContractRuntime = runtime;
|
|
698
|
+
}
|
|
733
699
|
/** @internal Symbol-keyed install seam for the `idempotency()` plugin. Off the public typed surface. */
|
|
734
700
|
[INSTALL_IDEMPOTENCY](runtime) {
|
|
735
701
|
this.assertConfigurable("idempotency()");
|
|
@@ -756,16 +722,16 @@ export class Server {
|
|
|
756
722
|
this.wsRuntime = runtime;
|
|
757
723
|
}
|
|
758
724
|
/**
|
|
759
|
-
* Merge another server's routes into this one
|
|
725
|
+
* Merge another server's routes into this one - the composition escape hatch for large apps.
|
|
760
726
|
*
|
|
761
727
|
* WHY: the fluent chain accumulates one type-alias level per route, and TypeScript resolves
|
|
762
|
-
* that stack in one recursion
|
|
728
|
+
* that stack in one recursion - a single chain hits TS2589 at ~95 routes. Groups keep every
|
|
763
729
|
* chain short: build each domain (`listings`, `agents`, …) as its own `server()` (its registry
|
|
764
|
-
* resolves independently), then `app.merge(listings).merge(agents)`
|
|
730
|
+
* resolves independently), then `app.merge(listings).merge(agents)` - each merge adds ONE level
|
|
765
731
|
* regardless of group size. 300+ routes stay fully typed (see many-routes.test-d.ts). The
|
|
766
732
|
* other escape hatch is contract-first `implement()`, whose registry is a single object type.
|
|
767
733
|
*
|
|
768
|
-
* Semantics: merged routes keep the chains captured where they were DEFINED
|
|
734
|
+
* Semantics: merged routes keep the chains captured where they were DEFINED - the group's
|
|
769
735
|
* `derive`/`decorate`/`beforeHandle`/`afterHandle`/`onError`/`around` apply to its routes
|
|
770
736
|
* exactly as they did standalone, so a group wires its own plugins. The group's request-level
|
|
771
737
|
* hooks (`onRequest`/`onResponse`/`onResponseFinalized`) are appended to this server's. This
|
|
@@ -778,7 +744,7 @@ export class Server {
|
|
|
778
744
|
this.assertConfigurable("merge()");
|
|
779
745
|
const source = other;
|
|
780
746
|
if (source.wsRouteCount > 0) {
|
|
781
|
-
throw new RouteConfigError("INVALID_PATH", "merge() does not carry WebSocket routes
|
|
747
|
+
throw new RouteConfigError("INVALID_PATH", "merge() does not carry WebSocket routes - register .ws() routes on the parent server");
|
|
782
748
|
}
|
|
783
749
|
this.catalog.addBatch(source.catalog.entries().map((route) => this.bindFusedRuntime(route)));
|
|
784
750
|
// Resolved idempotency/ledger route entries carry their own store/sink configuration, while the
|
|
@@ -786,6 +752,7 @@ export class Server {
|
|
|
786
752
|
// when the parent has none so merging cannot silently disable a safety lane. If the parent already
|
|
787
753
|
// has a runtime, either implementation can execute every resolved entry because route-specific
|
|
788
754
|
// options were pinned during registration.
|
|
755
|
+
this.responseContractRuntime ??= source.responseContractRuntime;
|
|
789
756
|
this.idempotencyRuntime ??= source.idempotencyRuntime;
|
|
790
757
|
this.effectLedgerRuntime ??= source.effectLedgerRuntime;
|
|
791
758
|
this.mcpRuntime ??= source.mcpRuntime;
|
|
@@ -832,17 +799,17 @@ export class Server {
|
|
|
832
799
|
});
|
|
833
800
|
}
|
|
834
801
|
/**
|
|
835
|
-
* Resolve a `Request` to a `Response`
|
|
802
|
+
* Resolve a `Request` to a `Response` - the whole lifecycle, testable without a port. The
|
|
836
803
|
* optional `platform` carries edge inputs (`env`, `waitUntil`); edge adapters pass it, and
|
|
837
804
|
* Bun/Node/Deno omit it (then `c.env` is `undefined` and `c.waitUntil` runs fire-and-forget).
|
|
838
805
|
*/
|
|
839
806
|
fetch(req, platform) {
|
|
840
|
-
// A real `Request` satisfies `RequestSource`, so it's passed straight through
|
|
807
|
+
// A real `Request` satisfies `RequestSource`, so it's passed straight through - no per-request
|
|
841
808
|
// wrapper allocation on the Web/Bun hot path.
|
|
842
809
|
return this.fetchSource(req, platform);
|
|
843
810
|
}
|
|
844
811
|
fetchSource(source, platform) {
|
|
845
|
-
// Off path (default): straight through
|
|
812
|
+
// Off path (default): straight through - one property check, no closure, no promise.
|
|
846
813
|
if (this.capacityGate === undefined)
|
|
847
814
|
return this.fetchSourceInner(source, platform);
|
|
848
815
|
return this.admitGated(requestOf(source), () => this.fetchSourceInner(source, platform));
|
|
@@ -856,7 +823,7 @@ export class Server {
|
|
|
856
823
|
if (this.onResponseHooks.length === 0 && this.onResponseFinalizedHooks.length === 0) {
|
|
857
824
|
return outcome;
|
|
858
825
|
}
|
|
859
|
-
// onResponse sees every response
|
|
826
|
+
// onResponse sees every response - success, validation error, 404/405, timeout, onRequest
|
|
860
827
|
// short-circuit; normalize to a promise, then thread through the hooks.
|
|
861
828
|
return outcome instanceof Promise
|
|
862
829
|
? outcome.then((response) => this.applyOnResponseAndFinalize(response, this.takeResponseRequest(source)))
|
|
@@ -881,7 +848,7 @@ export class Server {
|
|
|
881
848
|
/**
|
|
882
849
|
* Run `produce` under the capacity gate: admit → run → release exactly once when the response is
|
|
883
850
|
* produced (or the run throws). Only reached when {@link capacityGate} is set, so the off path pays
|
|
884
|
-
* nothing. The slot is held for the duration of handler execution, not the streaming of the body
|
|
851
|
+
* nothing. The slot is held for the duration of handler execution, not the streaming of the body -
|
|
885
852
|
* capacity here bounds concurrent *work*, matching how in-flight is counted.
|
|
886
853
|
*/
|
|
887
854
|
admitGated(req, produce) {
|
|
@@ -908,7 +875,7 @@ export class Server {
|
|
|
908
875
|
release();
|
|
909
876
|
throw error;
|
|
910
877
|
}
|
|
911
|
-
// Release the slot once the response settles
|
|
878
|
+
// Release the slot once the response settles - on resolve OR reject - via `finally`, which passes
|
|
912
879
|
// the value/rejection through unchanged. (A single settle hook, rather than separate then-arms: the
|
|
913
880
|
// request pipeline resolves handler errors to a Response, so a rejection arm would be unreachable.)
|
|
914
881
|
if (outcome instanceof Promise)
|
|
@@ -917,11 +884,11 @@ export class Server {
|
|
|
917
884
|
return outcome;
|
|
918
885
|
}
|
|
919
886
|
/**
|
|
920
|
-
* Like {@link fetch}, but renders a plain-data result **without** building a Web `Response`
|
|
887
|
+
* Like {@link fetch}, but renders a plain-data result **without** building a Web `Response` - the
|
|
921
888
|
* `@nifrajs/node` adapter serializes the returned primitives straight to the socket, skipping the undici
|
|
922
889
|
* `Response` build + body drain (the bulk of the Node bridge cost, measured ≈4µs/req). A handler that
|
|
923
890
|
* returns a `Response`, an error/short-circuit, or any registered `onResponse` hook falls back to the
|
|
924
|
-
* full Web path (`{ kind: "response" }`), so behavior is identical
|
|
891
|
+
* full Web path (`{ kind: "response" }`), so behavior is identical - only the common JSON-data case is
|
|
925
892
|
* faster. Same lifecycle as {@link fetch} (body cap, validation, hooks all run); only the final
|
|
926
893
|
* render differs.
|
|
927
894
|
*/
|
|
@@ -929,7 +896,7 @@ export class Server {
|
|
|
929
896
|
return this.resolveNodeSource(req, platform);
|
|
930
897
|
}
|
|
931
898
|
resolveNodeSource(source, platform, suppliedRuntime) {
|
|
932
|
-
// onResponse hooks transform a Response, and the capacity gate wraps the Web response path
|
|
899
|
+
// onResponse hooks transform a Response, and the capacity gate wraps the Web response path - both
|
|
933
900
|
// force the Web path here (the gated `fetchSource` admits/sheds/releases); wrap its result.
|
|
934
901
|
if (this.onResponseHooks.length > 0 ||
|
|
935
902
|
this.onResponseFinalizedHooks.length > 0 ||
|
|
@@ -949,7 +916,7 @@ export class Server {
|
|
|
949
916
|
return this.dispatch(source, platform, runtime.toOutcome, runtime.fromResponse, runtime.timeout, false);
|
|
950
917
|
}
|
|
951
918
|
/**
|
|
952
|
-
* Resolve a WebSocket upgrade
|
|
919
|
+
* Resolve a WebSocket upgrade - the seam every serving adapter uses. Returns `pass` (not a WS
|
|
953
920
|
* upgrade for a registered route → handle as normal HTTP), `reject` (a WS route matched but
|
|
954
921
|
* `upgrade()` rejected, or the path was malformed → return `response`), or `upgrade` (perform the
|
|
955
922
|
* runtime upgrade, then dispatch the native socket's events to `handler`, seeding `ws.data` with
|
|
@@ -986,7 +953,7 @@ export class Server {
|
|
|
986
953
|
return { kind: "reject", response: jsonError(403, "forbidden_origin") };
|
|
987
954
|
}
|
|
988
955
|
else if (origin !== null && !wsSameOrigin(origin, req)) {
|
|
989
|
-
// Secure default (no explicit `allowedOrigins`): reject a CROSS-ORIGIN browser handshake
|
|
956
|
+
// Secure default (no explicit `allowedOrigins`): reject a CROSS-ORIGIN browser handshake - the
|
|
990
957
|
// CSWSH case, since browsers send cookies on WS handshakes and don't apply CORS. Non-browser
|
|
991
958
|
// clients send no `Origin` and pass; same-origin browsers pass. Set `allowedOrigins` to permit
|
|
992
959
|
// specific cross-origin clients (or `() => true` for a genuinely public socket).
|
|
@@ -1034,7 +1001,7 @@ export class Server {
|
|
|
1034
1001
|
* result + `set` into the output `T` (`toResponse` → a Web `Response`; `toNodeOutcome` → node-direct
|
|
1035
1002
|
* primitives), `wrapResponse` lifts an early/error `Response` into that same `T`, and `onTimeout`
|
|
1036
1003
|
* produces the 503. The Web `fetch` and `resolveNode` are thin callers over this one routing +
|
|
1037
|
-
* context + lifecycle implementation
|
|
1004
|
+
* context + lifecycle implementation - no duplication across the trust boundary.
|
|
1038
1005
|
*/
|
|
1039
1006
|
/** Apply the `clientIp` trust declaration to the adapter's raw socket peer, returning a platform
|
|
1040
1007
|
* whose `clientIp` is the derived caller. Only called when a trust declaration is configured. */
|
|
@@ -1043,7 +1010,7 @@ export class Server {
|
|
|
1043
1010
|
return { ...platform, clientIp: derived };
|
|
1044
1011
|
}
|
|
1045
1012
|
dispatch(source, platform, finalize, wrapResponse, onTimeout,
|
|
1046
|
-
// True only from the Web `fetch` path
|
|
1013
|
+
// True only from the Web `fetch` path - unlocks each route's fused lane, whose output type IS
|
|
1047
1014
|
// `Response` (`T = Response` there by construction; the node path always passes false).
|
|
1048
1015
|
webFast) {
|
|
1049
1016
|
// Resolve the trust declaration into the platform's `clientIp` ONCE, here at the shared funnel, so
|
|
@@ -1059,7 +1026,7 @@ export class Server {
|
|
|
1059
1026
|
}
|
|
1060
1027
|
/**
|
|
1061
1028
|
* onRequest short-circuit path. Synchronous as long as every hook returns synchronously (the
|
|
1062
|
-
* common case
|
|
1029
|
+
* common case - e.g. CORS returning `undefined` for a non-preflight request): an `async` version
|
|
1063
1030
|
* here put EVERY request of any app with one onRequest hook onto the promise machinery, profiled
|
|
1064
1031
|
* at ~13% of a realistic request. The first hook that returns a Promise hands the REMAINING
|
|
1065
1032
|
* hooks to the async continuation; behavior is identical.
|
|
@@ -1085,7 +1052,7 @@ export class Server {
|
|
|
1085
1052
|
return this.routeAndRun(current, platform, finalize, wrapResponse, onTimeout, webFast);
|
|
1086
1053
|
}
|
|
1087
1054
|
/** Async tail of {@link runWithOnRequest}: applies the first awaited hook's outcome, then runs
|
|
1088
|
-
* the remaining hooks (awaiting freely
|
|
1055
|
+
* the remaining hooks (awaiting freely - we're already async here). */
|
|
1089
1056
|
async continueOnRequest(first, nextIndex, originalRequest, sourceAtAwait, platform, finalize, wrapResponse, onTimeout, webFast) {
|
|
1090
1057
|
let current = sourceAtAwait;
|
|
1091
1058
|
let early = first;
|
|
@@ -1140,7 +1107,7 @@ export class Server {
|
|
|
1140
1107
|
/** Run a route that has already been matched by the runtime or Nifra's portable router. */
|
|
1141
1108
|
runMatched(source, platform, entry, params, search, finalize, wrapResponse, onTimeout, webFast) {
|
|
1142
1109
|
// An idempotency route runs its dedupe lane first; on a fresh key it delegates to the normal lanes
|
|
1143
|
-
// (with the body buffered). All non-idempotent routes skip straight to the lanes
|
|
1110
|
+
// (with the body buffered). All non-idempotent routes skip straight to the lanes - no added cost.
|
|
1144
1111
|
// The runtime is always present when a route resolved idempotency (enforced at registration).
|
|
1145
1112
|
if (entry.idempotent !== undefined && this.idempotencyRuntime !== undefined) {
|
|
1146
1113
|
return this.idempotencyRuntime.run(entry.idempotent, requestOf(source), platform, entry, params, search, wrapResponse, {
|
|
@@ -1188,7 +1155,7 @@ export class Server {
|
|
|
1188
1155
|
const outcome = webFast && plan.fusedWeb !== undefined
|
|
1189
1156
|
? plan.fusedWeb(source, params, search, signal, budget, platform, false)
|
|
1190
1157
|
: plan.run(this, entry, source, params, search, signal, budget, platform, finalize, wrapResponse);
|
|
1191
|
-
// The request timeout only bounds work that is actually pending
|
|
1158
|
+
// The request timeout only bounds work that is actually pending - a synchronous (bare) result is
|
|
1192
1159
|
// already complete and can't time out, so it's returned as-is (no 503 race, no promise).
|
|
1193
1160
|
if (controller !== undefined && outcome instanceof Promise) {
|
|
1194
1161
|
const timedOut = admission?.inherited === true
|
|
@@ -1227,11 +1194,11 @@ export class Server {
|
|
|
1227
1194
|
}
|
|
1228
1195
|
/**
|
|
1229
1196
|
* The synchronous fast path selected by a route's execution plan: apply static decorations, call the
|
|
1230
|
-
* handler, render the result
|
|
1197
|
+
* handler, render the result - **no `await`** unless the handler itself returns a promise. It mirrors
|
|
1231
1198
|
* the bare slice of {@link runLifecycle} (which a bare route would otherwise no-op through) and shares
|
|
1232
1199
|
* {@link logRequestError}; a bare route has no `onError` hooks, so error handling is fully synchronous
|
|
1233
1200
|
* (a thrown `Response` is control flow; anything else is a logged flat 500). This is where nifra skips
|
|
1234
|
-
* the per-request async-frame tax
|
|
1201
|
+
* the per-request async-frame tax - the same win codegen routers get, but without `eval`.
|
|
1235
1202
|
*/
|
|
1236
1203
|
runBare(entry, ctx, finalize, wrapResponse) {
|
|
1237
1204
|
let result;
|
|
@@ -1249,12 +1216,12 @@ export class Server {
|
|
|
1249
1216
|
}
|
|
1250
1217
|
return finalize(result, responseSet(ctx));
|
|
1251
1218
|
}
|
|
1252
|
-
/** Bare-route error rendering
|
|
1219
|
+
/** Bare-route error rendering - identical to {@link runLifecycle}'s catch minus the (absent) onError
|
|
1253
1220
|
* loop: a thrown `Response` is returned as deliberate control flow; anything else is logged + 500. */
|
|
1254
1221
|
/**
|
|
1255
1222
|
* Build a route's fused Web renderer. Composition happens once at
|
|
1256
1223
|
* registration; the returned closure is what every request to the route runs. Behavior is
|
|
1257
|
-
* byte-identical to the generic `runBare`/`runContextlessBare` + `toResponse` pair
|
|
1224
|
+
* byte-identical to the generic `runBare`/`runContextlessBare` + `toResponse` pair - same
|
|
1258
1225
|
* decoration order, same error routing (thrown `Response` = control flow; anything else logs and
|
|
1259
1226
|
* 500s), same respond semantics (the lifecycle parity suite pins it).
|
|
1260
1227
|
*/
|
|
@@ -1266,7 +1233,7 @@ export class Server {
|
|
|
1266
1233
|
return jsonError(500, "internal_error");
|
|
1267
1234
|
};
|
|
1268
1235
|
if (contextless && decorations === undefined) {
|
|
1269
|
-
// `() => ...` can't observe the context
|
|
1236
|
+
// `() => ...` can't observe the context - skip allocating one entirely (errors still build
|
|
1270
1237
|
// one for the structured log, exactly like runContextlessBare).
|
|
1271
1238
|
const contextlessHandler = handler;
|
|
1272
1239
|
return (source, params, search, signal, budget, platform, nativeContext) => {
|
|
@@ -1355,10 +1322,10 @@ export class Server {
|
|
|
1355
1322
|
return Promise.resolve(wrapResponse(jsonError(415, "unsupported_media_type")));
|
|
1356
1323
|
}
|
|
1357
1324
|
// Inline fast path (profiled): a framed, in-cap, non-chunked body parses with the native
|
|
1358
|
-
// `req.json()` directly
|
|
1325
|
+
// `req.json()` directly - one promise, one `.then` closure per request. The generic
|
|
1359
1326
|
// `readBoundedJson` (an extra async-fn frame + per-request `finish`/`applyValidation`
|
|
1360
1327
|
// closures) is kept for the chunked / length-less / oversized cases it exists for.
|
|
1361
|
-
// Semantics are identical to readBoundedJsonSource
|
|
1328
|
+
// Semantics are identical to readBoundedJsonSource - same checks, same error codes.
|
|
1362
1329
|
const declared = headerOf(source, "content-length");
|
|
1363
1330
|
if (declared !== null) {
|
|
1364
1331
|
const length = parseContentLength(declared);
|
|
@@ -1386,7 +1353,7 @@ export class Server {
|
|
|
1386
1353
|
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
|
|
1387
1354
|
}
|
|
1388
1355
|
}
|
|
1389
|
-
/** Validate + run the handler for the bodyOnly path
|
|
1356
|
+
/** Validate + run the handler for the bodyOnly path - shared by the inline fast path and the
|
|
1390
1357
|
* streaming fallback. A method (not per-request closures) so the hot path allocates nothing
|
|
1391
1358
|
* beyond the one `.then` continuation. */
|
|
1392
1359
|
finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse) {
|
|
@@ -1484,7 +1451,7 @@ export class Server {
|
|
|
1484
1451
|
}
|
|
1485
1452
|
runQueryOnly(entry, ctx, finalize, wrapResponse) {
|
|
1486
1453
|
try {
|
|
1487
|
-
// Call the validator directly for the raw StandardResult (read `.issues`/`.value`)
|
|
1454
|
+
// Call the validator directly for the raw StandardResult (read `.issues`/`.value`) - skip
|
|
1488
1455
|
// `validateStandard`'s per-request wrapper-object allocation, mirroring the bodyOnly path.
|
|
1489
1456
|
const validation = entry.schema.query["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]));
|
|
1490
1457
|
if (validation instanceof Promise) {
|
|
@@ -1525,7 +1492,7 @@ export class Server {
|
|
|
1525
1492
|
}
|
|
1526
1493
|
/**
|
|
1527
1494
|
* Thread the response through each global `onResponse` hook. Stays SYNCHRONOUS until a hook
|
|
1528
|
-
* actually returns a Promise
|
|
1495
|
+
* actually returns a Promise - an `async` version forced a promise + microtask on EVERY response
|
|
1529
1496
|
* of any app with an onResponse hook (cors/securityHeaders/etag/timing all use onResponse), the
|
|
1530
1497
|
* same ~13%/req tax the onRequest walk was de-async'd to avoid. The first async hook hands the
|
|
1531
1498
|
* rest to {@link continueOnResponse}.
|
|
@@ -1604,7 +1571,7 @@ export class Server {
|
|
|
1604
1571
|
/**
|
|
1605
1572
|
* Bound the response time. On timeout we abort `ctx.signal` (so cancellation-aware
|
|
1606
1573
|
* handlers can bail) and return 503; the in-flight work keeps running but its
|
|
1607
|
-
* result is discarded
|
|
1574
|
+
* result is discarded - JS can't forcibly cancel a promise.
|
|
1608
1575
|
*/
|
|
1609
1576
|
async withTimeout(work, controller, onTimeout, timeoutMs) {
|
|
1610
1577
|
let timer;
|
|
@@ -1655,7 +1622,7 @@ export class Server {
|
|
|
1655
1622
|
return wrapResponse(queryError);
|
|
1656
1623
|
}
|
|
1657
1624
|
// Context extensions: static decorations, then per-request derives.
|
|
1658
|
-
// Each hook below only awaits when it actually returns a Promise
|
|
1625
|
+
// Each hook below only awaits when it actually returns a Promise - a sync hook
|
|
1659
1626
|
// skips the microtask tick (a fast-path; ~64 ns/hook).
|
|
1660
1627
|
if (entry.hasDecorations)
|
|
1661
1628
|
Object.assign(ctx, entry.decorations); // skip the no-op on bare routes
|
|
@@ -1686,6 +1653,35 @@ export class Server {
|
|
|
1686
1653
|
result = transformed instanceof Promise ? await transformed : transformed;
|
|
1687
1654
|
}
|
|
1688
1655
|
}
|
|
1656
|
+
// The response contract is checked LAST, after afterHandle, so it holds what actually ships
|
|
1657
|
+
// rather than what the handler happened to return. Only reached by a contracted route: the
|
|
1658
|
+
// registration decision above kept those off the fused/native lanes for exactly this step.
|
|
1659
|
+
const contract = entry.responseContract;
|
|
1660
|
+
if (contract !== undefined) {
|
|
1661
|
+
const checked = contract.runtime.check(contract.schema, result);
|
|
1662
|
+
const outcome = checked instanceof Promise ? await checked : checked;
|
|
1663
|
+
if (outcome.kind === "violation") {
|
|
1664
|
+
// The handler broke a contract it declared itself, so this is a server fault, not the
|
|
1665
|
+
// caller's. Fail closed: the undeclared payload is never sent, and the detail goes to the
|
|
1666
|
+
// (redacting) logger rather than to the client.
|
|
1667
|
+
this.logger.error("response contract violation", {
|
|
1668
|
+
method: ctx.req.method,
|
|
1669
|
+
path: pathnameOf(ctx.req.url),
|
|
1670
|
+
// `detail`, not `message`: the logger uses `message` for its own first argument, so a
|
|
1671
|
+
// field of that name is silently overwritten and the diagnosis disappears.
|
|
1672
|
+
detail: outcome.message,
|
|
1673
|
+
});
|
|
1674
|
+
return wrapResponse(jsonError(500, "internal_error"));
|
|
1675
|
+
}
|
|
1676
|
+
if (outcome.kind === "warn") {
|
|
1677
|
+
this.logger.warn("response contract", {
|
|
1678
|
+
method: ctx.req.method,
|
|
1679
|
+
path: pathnameOf(ctx.req.url),
|
|
1680
|
+
detail: outcome.message,
|
|
1681
|
+
});
|
|
1682
|
+
}
|
|
1683
|
+
result = outcome.value;
|
|
1684
|
+
}
|
|
1689
1685
|
return finalize(result, responseSet(ctx));
|
|
1690
1686
|
}
|
|
1691
1687
|
catch (err) {
|
|
@@ -1693,7 +1689,7 @@ export class Server {
|
|
|
1693
1689
|
}
|
|
1694
1690
|
}
|
|
1695
1691
|
async handleLifecycleError(entry, err, ctx, finalize, wrapResponse) {
|
|
1696
|
-
// A *thrown* Response is deliberate control flow, not an error
|
|
1692
|
+
// A *thrown* Response is deliberate control flow, not an error - a guard throws a redirect/401,
|
|
1697
1693
|
// an action throws an error page. Return it as-is (Remix/SvelteKit semantics); don't run onError
|
|
1698
1694
|
// or log it as a 500. This is what makes `throw redirect(...)` / `requireSession(...)` work from
|
|
1699
1695
|
// any handler or loader.
|
|
@@ -1711,7 +1707,7 @@ export class Server {
|
|
|
1711
1707
|
this.logRequestError(err, ctx);
|
|
1712
1708
|
return wrapResponse(jsonError(500, "internal_error"));
|
|
1713
1709
|
}
|
|
1714
|
-
/** Log an unhandled request error to the (redacting) logger
|
|
1710
|
+
/** Log an unhandled request error to the (redacting) logger - shared by {@link runLifecycle} and the
|
|
1715
1711
|
* bare fast path ({@link bareError}) so both record the same fields. Never throws; never leaks. */
|
|
1716
1712
|
logRequestError(err, ctx) {
|
|
1717
1713
|
this.logger.error("unhandled request error", {
|
|
@@ -1738,7 +1734,7 @@ export class Server {
|
|
|
1738
1734
|
parsed = form;
|
|
1739
1735
|
}
|
|
1740
1736
|
else {
|
|
1741
|
-
// multipart/form-data (file uploads) stays 415 on the schema path by design
|
|
1737
|
+
// multipart/form-data (file uploads) stays 415 on the schema path by design - files don't
|
|
1742
1738
|
// fit a value schema; use a schema-less route + @nifrajs/uploads helpers for those.
|
|
1743
1739
|
return jsonError(415, "unsupported_media_type");
|
|
1744
1740
|
}
|
|
@@ -1785,12 +1781,12 @@ export class Server {
|
|
|
1785
1781
|
/**
|
|
1786
1782
|
* Read the body as text, capped at `maxBodyBytes`. Rejects (`null`) on a
|
|
1787
1783
|
* `Content-Length` over the cap *before* buffering, and aborts mid-stream once the
|
|
1788
|
-
* running byte count exceeds it
|
|
1784
|
+
* running byte count exceeds it - so a lying or absent length can't force us to
|
|
1789
1785
|
* buffer an oversized payload.
|
|
1790
1786
|
*
|
|
1791
1787
|
* Fast path: when a non-chunked request carries a `Content-Length` within the cap,
|
|
1792
|
-
* a native `req.json()` is already bounded
|
|
1793
|
-
* runtime delivers at most `Content-Length` bytes
|
|
1788
|
+
* a native `req.json()` is already bounded - under HTTP/1.1 + HTTP/2 framing the
|
|
1789
|
+
* runtime delivers at most `Content-Length` bytes - so we skip the manual stream
|
|
1794
1790
|
* loop and a separate text decode. It trusts the wire
|
|
1795
1791
|
* *framing*, not the header value: nifra only ever receives framed Requests from the
|
|
1796
1792
|
* runtime's HTTP server, never a hand-built one with a mismatched length. Chunked or
|
|
@@ -1847,7 +1843,7 @@ export class Server {
|
|
|
1847
1843
|
* capture semantics; static and `:param` routes take the native lane. */
|
|
1848
1844
|
buildBunNativeRoutes() {
|
|
1849
1845
|
// A `clientIp` trust declaration must run the resolver in `dispatch`, which the fused native lane
|
|
1850
|
-
// bypasses
|
|
1846
|
+
// bypasses - so an app that declares trust routes through the fetch lane (where `c.clientIp`
|
|
1851
1847
|
// resolves) instead of Bun's native table. The allocation-free default keeps native fusion.
|
|
1852
1848
|
if (this.onRequestHooks.length > 0 ||
|
|
1853
1849
|
this.wsRouteCount > 0 ||
|
|
@@ -1883,7 +1879,7 @@ export class Server {
|
|
|
1883
1879
|
* Start a `Bun.serve` instance bound to `port` (use `0` for an ephemeral port).
|
|
1884
1880
|
*
|
|
1885
1881
|
* `reusePort` sets `SO_REUSEPORT` so **multiple processes can bind the same port** and the kernel
|
|
1886
|
-
* load-balances connections across them
|
|
1882
|
+
* load-balances connections across them - the standard way to use every core (Bun is
|
|
1887
1883
|
* single-threaded per process). Spawn one process per core, each calling
|
|
1888
1884
|
* `app.listen(PORT, { reusePort: true })`; see `examples/cluster.ts`. Every process must opt in,
|
|
1889
1885
|
* and all of them must be the same app. Linux balances ~evenly; macOS accepts the flag but may
|
|
@@ -1903,14 +1899,14 @@ export class Server {
|
|
|
1903
1899
|
}
|
|
1904
1900
|
// Bun's `Server` is the concrete handle; we expose the stable `RunningServer`
|
|
1905
1901
|
// subset so the public types don't depend on the ambient `Bun` global. The cast
|
|
1906
|
-
// bridges them
|
|
1902
|
+
// bridges them - Bun's `.port` is `number | undefined` (undefined only for unix
|
|
1907
1903
|
// sockets, never a TCP `listen`) and its `.stop` returns a promise we don't await.
|
|
1908
|
-
// Pass only the request
|
|
1904
|
+
// Pass only the request - Bun's `fetch` 2nd arg is the Bun `Server`, not our `platform`.
|
|
1909
1905
|
// With WS routes, hand Bun a `websocket` config + a fetch that upgrades matching requests (the
|
|
1910
1906
|
// `server` 2nd arg is how Bun exposes `upgrade`); otherwise the lean request-only fetch. The
|
|
1911
|
-
// `websocket` handlers are one shared dispatcher
|
|
1907
|
+
// `websocket` handlers are one shared dispatcher - each connection's `ws.data.handler` is the
|
|
1912
1908
|
// matched route's handler, set by `server.upgrade`.
|
|
1913
|
-
// With WS routes, the dispatcher comes from the installed `.use(websocket())` runtime
|
|
1909
|
+
// With WS routes, the dispatcher comes from the installed `.use(websocket())` runtime - non-null
|
|
1914
1910
|
// because wsRouteCount > 0 means ws() ran, and ws() requires the runtime at registration.
|
|
1915
1911
|
const wsHandlers = this.wsRouteCount === 0
|
|
1916
1912
|
? undefined
|
|
@@ -1953,7 +1949,7 @@ export class Server {
|
|
|
1953
1949
|
}
|
|
1954
1950
|
/**
|
|
1955
1951
|
* Gracefully stop: wait for in-flight requests to finish (up to `drainMs`), then
|
|
1956
|
-
* issue a single terminal stop
|
|
1952
|
+
* issue a single terminal stop - graceful if everything drained, forced if
|
|
1957
1953
|
* stragglers remain. Safe to call when not listening.
|
|
1958
1954
|
*
|
|
1959
1955
|
* The Bun semantics: poll `pendingRequests` (awaiting
|
|
@@ -1974,7 +1970,7 @@ export class Server {
|
|
|
1974
1970
|
server.stop(server.pendingRequests > 0); // force-close iff stragglers remain past the deadline
|
|
1975
1971
|
}
|
|
1976
1972
|
installSignalHandlers() {
|
|
1977
|
-
// Drain, then let the process exit naturally
|
|
1973
|
+
// Drain, then let the process exit naturally - the stopped server no longer
|
|
1978
1974
|
// holds the event loop open. Opt-in (`gracefulSignals`), so taking over the
|
|
1979
1975
|
// signals is consented; we don't force `process.exit`.
|
|
1980
1976
|
const onSignal = () => {
|
|
@@ -1985,66 +1981,14 @@ export class Server {
|
|
|
1985
1981
|
}
|
|
1986
1982
|
}
|
|
1987
1983
|
/**
|
|
1988
|
-
* Create a new {@link Server}. Pass an `Env` to type the platform bindings
|
|
1984
|
+
* Create a new {@link Server}. Pass an `Env` to type the platform bindings - `server<Env>()` makes
|
|
1989
1985
|
* `c.env: Env` in every handler + middleware, and types the `env` argument of `app.fetch` /
|
|
1990
1986
|
* `toFetchHandler`. Omit it and `c.env` is `unknown` (validate/cast before use).
|
|
1991
1987
|
*/
|
|
1992
1988
|
export function server(options) {
|
|
1993
1989
|
// `Env` is a phantom type-level marker: the runtime `env` arrives via `app.fetch(req, { env })` at
|
|
1994
|
-
// request time, not stored on the builder
|
|
1990
|
+
// request time, not stored on the builder - so seed the context type with a cast (as `derive`/
|
|
1995
1991
|
// `decorate` do for their `Ctx` extensions).
|
|
1996
1992
|
return new Server(options);
|
|
1997
1993
|
}
|
|
1998
|
-
/** The single hub Durable Object id nifra routes WS upgrades to (one hub per app — see `@nifrajs/workers`). */
|
|
1999
|
-
const NIFRA_WS_HUB_ID = "nifra-ws-hub";
|
|
2000
|
-
export function toFetchHandler(app, options) {
|
|
2001
|
-
const scheduled = options?.scheduled;
|
|
2002
|
-
const webSocketHub = options?.webSocketHub;
|
|
2003
|
-
const resolveWs = app.resolveWebSocketUpgrade?.bind(app);
|
|
2004
|
-
return {
|
|
2005
|
-
fetch: (request, env, ctx) => {
|
|
2006
|
-
const platform = { env, waitUntil: (promise) => ctx.waitUntil(promise) };
|
|
2007
|
-
// WebSocket broadcast on Workers: route upgrades to the hub Durable Object (it holds the
|
|
2008
|
-
// connections + runs the app's pub/sub, so `app.publish` reaches every client). The hub itself
|
|
2009
|
-
// resolves the route and rejects non-WS paths. See `@nifrajs/workers`' `createWebSocketHub`.
|
|
2010
|
-
if (webSocketHub !== undefined &&
|
|
2011
|
-
request.headers.get("upgrade")?.toLowerCase() === "websocket") {
|
|
2012
|
-
const ns = webSocketHub(env);
|
|
2013
|
-
return ns.get(ns.idFromName(NIFRA_WS_HUB_ID)).fetch(request);
|
|
2014
|
-
}
|
|
2015
|
-
// Workers WebSockets: a WS upgrade for a registered route becomes a `WebSocketPair` + a 101.
|
|
2016
|
-
// Feature-detected, so non-Workers edge runtimes (which lack `WebSocketPair` — e.g. Deno Deploy
|
|
2017
|
-
// uses `@nifrajs/deno`'s `Deno.upgradeWebSocket`) simply fall through to the normal `fetch`.
|
|
2018
|
-
const Pair = globalThis.WebSocketPair;
|
|
2019
|
-
if (resolveWs !== undefined && Pair !== undefined) {
|
|
2020
|
-
const accept = (outcome) => {
|
|
2021
|
-
if (outcome.kind === "pass")
|
|
2022
|
-
return app.fetch(request, platform);
|
|
2023
|
-
if (outcome.kind === "reject")
|
|
2024
|
-
return outcome.response;
|
|
2025
|
-
const pair = new Pair();
|
|
2026
|
-
const server = pair[1];
|
|
2027
|
-
server.accept();
|
|
2028
|
-
// The outcome carries the installed runtime's `attach`, so `toFetchHandler` (a standalone
|
|
2029
|
-
// function, no access to the app's private runtime) wires the socket without a static WS import.
|
|
2030
|
-
outcome.attach(server, outcome.handler, outcome.data, {
|
|
2031
|
-
openNow: true,
|
|
2032
|
-
pubsub: outcome.pubsub,
|
|
2033
|
-
});
|
|
2034
|
-
// `webSocket` is a Workers-only `ResponseInit` field (absent from the standard type), and a
|
|
2035
|
-
// 101 status is only valid on the Workers runtime — both gated by the `Pair` feature check.
|
|
2036
|
-
return new Response(null, { status: 101, webSocket: pair[0] });
|
|
2037
|
-
};
|
|
2038
|
-
const out = resolveWs(request, platform);
|
|
2039
|
-
return out instanceof Promise ? out.then(accept) : accept(out);
|
|
2040
|
-
}
|
|
2041
|
-
return app.fetch(request, platform);
|
|
2042
|
-
},
|
|
2043
|
-
...(scheduled !== undefined
|
|
2044
|
-
? {
|
|
2045
|
-
scheduled: (controller, env, ctx) => scheduled(controller, { env, waitUntil: (promise) => ctx.waitUntil(promise) }),
|
|
2046
|
-
}
|
|
2047
|
-
: {}),
|
|
2048
|
-
};
|
|
2049
|
-
}
|
|
2050
1994
|
//# sourceMappingURL=server.js.map
|