@nifrajs/core 2.2.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.js +4 -4
- package/dist/router/router.js +3 -3
- 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 +12 -0
- package/dist/transport-codec.d.ts.map +1 -1
- package/dist/transport-codec.js +19 -4
- 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 +4 -4
- package/src/router/router.ts +5 -5
- 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 +24 -5
package/src/server/server.ts
CHANGED
|
@@ -25,13 +25,24 @@ import {
|
|
|
25
25
|
NIFRA_ASSURANCE_IDS,
|
|
26
26
|
validEvidenceId,
|
|
27
27
|
} from "../internal/route-assurance.ts"
|
|
28
|
+
import { type CatalogRoute, RouteCatalog } from "../internal/route-catalog.ts"
|
|
29
|
+
import type {
|
|
30
|
+
ContextRouteRunner,
|
|
31
|
+
FusedWebRunner,
|
|
32
|
+
InternalHandler,
|
|
33
|
+
RawAfterHandle,
|
|
34
|
+
RawAround,
|
|
35
|
+
RawBeforeHandle,
|
|
36
|
+
RawDerive,
|
|
37
|
+
RawErrorHandler,
|
|
38
|
+
RouteEntry,
|
|
39
|
+
RouteExecutionPlan,
|
|
40
|
+
RouteExecutionRunner,
|
|
41
|
+
} from "../internal/route-execution.ts"
|
|
42
|
+
import { isSameOriginRequest } from "../internal/same-origin.ts"
|
|
28
43
|
import type { RequestLedger } from "../ledger.ts"
|
|
29
|
-
import {
|
|
30
|
-
|
|
31
|
-
compileRoutePattern,
|
|
32
|
-
decodeRouteParams,
|
|
33
|
-
} from "../router/pattern.ts"
|
|
34
|
-
import { EMPTY_PARAMS, type Method, Router, type RouterMatch } from "../router/router.ts"
|
|
44
|
+
import { compileRoutePattern, decodeRouteParams } from "../router/pattern.ts"
|
|
45
|
+
import { EMPTY_PARAMS, type Method, Router } from "../router/router.ts"
|
|
35
46
|
import type {
|
|
36
47
|
InferOutput,
|
|
37
48
|
StandardIssue,
|
|
@@ -53,6 +64,9 @@ import {
|
|
|
53
64
|
} from "./query.ts"
|
|
54
65
|
import { RequestContext, readBoundedJsonSource } from "./request-context.ts"
|
|
55
66
|
import { fusedRespond, fusedRespondNoSet, toResponse } from "./respond.ts"
|
|
67
|
+
// Type-only: erased, so the kernel never pulls the lane's implementation into a bundle that does not
|
|
68
|
+
// install the plugin. The value side arrives through the symbol-keyed install seam.
|
|
69
|
+
import type { ResponseContractRuntime } from "./response-contract-lane.ts"
|
|
56
70
|
import {
|
|
57
71
|
CONTEXT_SEARCH,
|
|
58
72
|
CONTEXT_SET,
|
|
@@ -68,12 +82,13 @@ import {
|
|
|
68
82
|
// so existing importers keep resolving it from the server module.
|
|
69
83
|
export type { NodeServeOutcome }
|
|
70
84
|
|
|
71
|
-
import type { IdempotencyRuntime
|
|
85
|
+
import type { IdempotencyRuntime } from "./idempotency-lane.ts"
|
|
72
86
|
import {
|
|
73
87
|
INSTALL_EFFECT_LEDGER,
|
|
74
88
|
INSTALL_IDEMPOTENCY,
|
|
75
89
|
INSTALL_MCP,
|
|
76
90
|
INSTALL_NODE_DIRECT,
|
|
91
|
+
INSTALL_RESPONSE_CONTRACT,
|
|
77
92
|
INSTALL_SSE,
|
|
78
93
|
INSTALL_WS,
|
|
79
94
|
} from "./install.ts"
|
|
@@ -106,7 +121,6 @@ import type {
|
|
|
106
121
|
import type { SSEInit, TypedSSEStream } from "./sse.ts"
|
|
107
122
|
import type { SseRuntime } from "./sse-hook.ts"
|
|
108
123
|
import type {
|
|
109
|
-
StandardWebSocket,
|
|
110
124
|
TopicRegistry,
|
|
111
125
|
WebSocketContext,
|
|
112
126
|
WebSocketHandler,
|
|
@@ -119,10 +133,10 @@ export type MaybePromise<T> = T | Promise<T>
|
|
|
119
133
|
|
|
120
134
|
/**
|
|
121
135
|
* Internal request view. A real Web `Request` already satisfies this shape, so Web/edge runtimes pass
|
|
122
|
-
* their `Request` **directly** (zero wrapper allocation on the hot path
|
|
136
|
+
* their `Request` **directly** (zero wrapper allocation on the hot path - `request` is simply absent and
|
|
123
137
|
* {@link requestOf} returns the source itself). Node's adapter passes a *lazy* source whose `request`
|
|
124
138
|
* getter builds an undici `Request` only when user code reads `c.req`, an onRequest/onResponse hook
|
|
125
|
-
* needs it, or a body helper consumes it
|
|
139
|
+
* needs it, or a body helper consumes it - so the common Node request never pays for a `Request` build.
|
|
126
140
|
*/
|
|
127
141
|
export interface RequestSource {
|
|
128
142
|
readonly method: string
|
|
@@ -176,154 +190,15 @@ export interface RawContext {
|
|
|
176
190
|
readonly boundedJson: <T = unknown>(maxBytes?: number) => Promise<T>
|
|
177
191
|
}
|
|
178
192
|
|
|
179
|
-
type InternalHandler = (ctx: RawContext) => MaybePromise<HandlerResult>
|
|
180
|
-
|
|
181
193
|
/** Broad shape so the implementation signature is compatible with both typed overloads. */
|
|
182
194
|
type ErasedHandler = (ctx: never) => MaybePromise<HandlerResult>
|
|
183
195
|
|
|
184
|
-
/** A `derive` computes per-request context extensions; stored path-erased. */
|
|
185
|
-
type RawDerive = (ctx: RawContext) => MaybePromise<object>
|
|
186
|
-
type RawBeforeHandle = (ctx: RawContext) => MaybePromise<unknown>
|
|
187
|
-
type RawAfterHandle = (result: unknown, ctx: RawContext) => MaybePromise<unknown>
|
|
188
|
-
type RawErrorHandler = (error: unknown, ctx: RawContext) => MaybePromise<unknown>
|
|
189
|
-
type RawAround = <T>(ctx: RawContext, next: () => MaybePromise<T>) => MaybePromise<T>
|
|
190
196
|
export type OnRequestResult = Response | Request | undefined
|
|
191
197
|
type RawOnRequest = (req: Request, platform?: Platform) => MaybePromise<OnRequestResult>
|
|
192
198
|
type RawOnResponse = (response: Response, req: Request) => MaybePromise<Response>
|
|
193
199
|
type RawOnResponseFinalized = (outcome: ResponseFinalization, req: Request) => MaybePromise<void>
|
|
194
200
|
|
|
195
|
-
|
|
196
|
-
runtime: Server<R, Ctx>,
|
|
197
|
-
entry: RouteEntry,
|
|
198
|
-
source: RequestSource,
|
|
199
|
-
params: Record<string, string>,
|
|
200
|
-
search: string | undefined,
|
|
201
|
-
signal: AbortSignal,
|
|
202
|
-
budget: RequestBudget,
|
|
203
|
-
platform: Platform | undefined,
|
|
204
|
-
finalize: (result: unknown, set: CtxSet) => T,
|
|
205
|
-
wrapResponse: (response: Response) => T,
|
|
206
|
-
) => MaybePromise<T>
|
|
207
|
-
|
|
208
|
-
type ContextRouteRunner = <T, R extends Registry, Ctx>(
|
|
209
|
-
runtime: Server<R, Ctx>,
|
|
210
|
-
entry: RouteEntry,
|
|
211
|
-
source: RequestSource,
|
|
212
|
-
ctx: RawContext,
|
|
213
|
-
finalize: (result: unknown, set: CtxSet) => T,
|
|
214
|
-
wrapResponse: (response: Response) => T,
|
|
215
|
-
) => MaybePromise<T>
|
|
216
|
-
|
|
217
|
-
/** Registration-compiled route behavior. Every adapter invokes the same runner; the optional fused
|
|
218
|
-
* renderer is only a response-format specialization of that same selected route semantics. */
|
|
219
|
-
interface RouteExecutionPlan {
|
|
220
|
-
readonly run: RouteExecutionRunner
|
|
221
|
-
readonly fusedWeb: FusedWebRunner | undefined
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
interface RouteEntry {
|
|
225
|
-
readonly handler: InternalHandler
|
|
226
|
-
readonly schema: RouteSchema | undefined
|
|
227
|
-
/** Resolved idempotency config; `undefined` = off (the dedupe lane is never entered). */
|
|
228
|
-
readonly idempotent: ResolvedIdempotency | undefined
|
|
229
|
-
/** Resolved effect-ledger wiring; `undefined` = off (no per-request ledger, no settle step). */
|
|
230
|
-
readonly ledgered: ResolvedEffectLedger | undefined
|
|
231
|
-
/** Per-request context extensions captured at registration (order-scoped). */
|
|
232
|
-
readonly derives: ReadonlyArray<RawDerive>
|
|
233
|
-
/** Static context extensions captured at registration. */
|
|
234
|
-
readonly decorations: Record<PropertyKey, unknown>
|
|
235
|
-
/** Whether {@link decorations} has any keys — precomputed so the hot path skips a no-op
|
|
236
|
-
* `Object.assign` on the (common) no-decoration route. */
|
|
237
|
-
readonly hasDecorations: boolean
|
|
238
|
-
/** Lifecycle hooks captured at registration (order-scoped). */
|
|
239
|
-
readonly beforeHandle: ReadonlyArray<RawBeforeHandle>
|
|
240
|
-
readonly afterHandle: ReadonlyArray<RawAfterHandle>
|
|
241
|
-
readonly onError: ReadonlyArray<RawErrorHandler>
|
|
242
|
-
/** Wraps the matched route lifecycle. Empty for the common no-around path. */
|
|
243
|
-
readonly around: ReadonlyArray<RawAround>
|
|
244
|
-
/** The single immutable execution decision consumed by portable, Node-direct, and Bun-native paths. */
|
|
245
|
-
readonly execution: RouteExecutionPlan
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/** One canonical runtime route fact. The catalog owns matching, reflection, assurance, tool metadata,
|
|
249
|
-
* replay, and native compilation input so batch registration has one commit point. */
|
|
250
|
-
interface CatalogRoute {
|
|
251
|
-
readonly method: Method
|
|
252
|
-
readonly path: string
|
|
253
|
-
readonly pattern: CompiledRoutePattern
|
|
254
|
-
readonly entry: RouteEntry
|
|
255
|
-
readonly descriptor: RouteDescriptor
|
|
256
|
-
readonly assurance: readonly AssuranceDeclaration[]
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/**
|
|
260
|
-
* Runtime route catalog. Single-route registration mutates directly; multi-route registration replays
|
|
261
|
-
* the existing catalog plus the candidate batch into a staged router, then swaps the complete state only
|
|
262
|
-
* after every route validates. Failed `implement()`/`merge()` batches therefore leave matching and
|
|
263
|
-
* reflection unchanged.
|
|
264
|
-
*/
|
|
265
|
-
class RouteCatalog {
|
|
266
|
-
private matcher = new Router<RouteEntry>()
|
|
267
|
-
private records: CatalogRoute[] = []
|
|
268
|
-
/** Allocation-free reflection view for the common no-assurance case. Derived only at commit time. */
|
|
269
|
-
private descriptors: RouteDescriptor[] = []
|
|
270
|
-
private assurancePresent = false
|
|
271
|
-
|
|
272
|
-
add(route: CatalogRoute): void {
|
|
273
|
-
this.matcher.add(route.method, route.pattern, route.entry)
|
|
274
|
-
this.records.push(route)
|
|
275
|
-
this.descriptors.push(route.descriptor)
|
|
276
|
-
if (route.assurance.length > 0) this.assurancePresent = true
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
addBatch(routes: readonly CatalogRoute[]): void {
|
|
280
|
-
if (routes.length === 0) return
|
|
281
|
-
const nextRecords = this.records.concat(routes)
|
|
282
|
-
const nextDescriptors = this.descriptors.concat(routes.map(({ descriptor }) => descriptor))
|
|
283
|
-
const nextAssurancePresent =
|
|
284
|
-
this.assurancePresent || routes.some((route) => route.assurance.length > 0)
|
|
285
|
-
const staged = new Router<RouteEntry>()
|
|
286
|
-
for (const route of this.records) staged.add(route.method, route.pattern, route.entry)
|
|
287
|
-
for (const route of routes) staged.add(route.method, route.pattern, route.entry)
|
|
288
|
-
this.matcher = staged
|
|
289
|
-
this.records = nextRecords
|
|
290
|
-
this.descriptors = nextDescriptors
|
|
291
|
-
this.assurancePresent = nextAssurancePresent
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
find(method: string, path: string): RouterMatch<RouteEntry> {
|
|
295
|
-
return this.matcher.find(method, path)
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
entries(): readonly CatalogRoute[] {
|
|
299
|
-
return this.records
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
routeDescriptors(): ReadonlyArray<RouteDescriptor> {
|
|
303
|
-
return this.descriptors
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
lastDescriptor(): RouteDescriptor | undefined {
|
|
307
|
-
return this.records[this.records.length - 1]?.descriptor
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
hasAssurance(): boolean {
|
|
311
|
-
return this.assurancePresent
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/** The fused Web lane: same inputs `routeAndRun` would hand the generic path, a `Response` out. */
|
|
316
|
-
type FusedWebRunner = (
|
|
317
|
-
source: RequestSource,
|
|
318
|
-
params: Record<string, string>,
|
|
319
|
-
search: string | undefined,
|
|
320
|
-
signal: AbortSignal,
|
|
321
|
-
budget: RequestBudget,
|
|
322
|
-
platform: Platform | undefined,
|
|
323
|
-
nativeContext: boolean,
|
|
324
|
-
) => MaybePromise<Response>
|
|
325
|
-
|
|
326
|
-
/** A registered WebSocket route — just its handler; matching reuses {@link Router} under the GET verb. */
|
|
201
|
+
/** A registered WebSocket route - just its handler; matching reuses {@link Router} under the GET verb. */
|
|
327
202
|
interface WsEntry {
|
|
328
203
|
readonly handler: WebSocketHandler
|
|
329
204
|
}
|
|
@@ -383,7 +258,7 @@ function requireMcpRuntime(runtime: McpRuntime | undefined): McpRuntime {
|
|
|
383
258
|
}
|
|
384
259
|
|
|
385
260
|
/** The handler's permitted return type. When the route declares a `response` schema, the return is
|
|
386
|
-
* constrained to the contract's type (or a raw `Response`)
|
|
261
|
+
* constrained to the contract's type (or a raw `Response`) - so the implementation can't drift from the
|
|
387
262
|
* declared contract. Without a `response` schema it's unconstrained (`HandlerResult`), exactly as before. */
|
|
388
263
|
type ResponseOf<S extends RouteSchema> = S extends { response: infer R extends StandardSchemaV1 }
|
|
389
264
|
? InferOutput<R> | Response
|
|
@@ -435,16 +310,10 @@ const DEFAULT_MAX_BODY_BYTES = 1_000_000
|
|
|
435
310
|
const DEFAULT_DRAIN_MS = 10_000
|
|
436
311
|
const DRAIN_POLL_MS = 10
|
|
437
312
|
|
|
438
|
-
/** Same-origin check for a WebSocket handshake (CSWSH default)
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
|
|
442
|
-
try {
|
|
443
|
-
return new URL(origin).host === new URL(req.url).host
|
|
444
|
-
} catch {
|
|
445
|
-
return false
|
|
446
|
-
}
|
|
447
|
-
}
|
|
313
|
+
/** Same-origin check for a WebSocket handshake (CSWSH default). {@link isSameOriginRequest} is the one
|
|
314
|
+
* owner, shared with the server-function mount in `@nifrajs/web` - the two used to answer differently
|
|
315
|
+
* for the same request, so a browser that could open a socket was told its POST was cross-origin. */
|
|
316
|
+
const wsSameOrigin = isSameOriginRequest
|
|
448
317
|
|
|
449
318
|
function validationError(issues: ReadonlyArray<StandardIssue>): Response {
|
|
450
319
|
const serialized = issues.map((issue) => {
|
|
@@ -472,7 +341,7 @@ function hasReplacementParam(params: Record<string, string>): boolean {
|
|
|
472
341
|
* anything when no handler touched `c.set.*`. Server-internal. */
|
|
473
342
|
export type CtxSet = ResponseControls & {
|
|
474
343
|
_headers?: Record<string, string>
|
|
475
|
-
/** Accumulated `Set-Cookie` values
|
|
344
|
+
/** Accumulated `Set-Cookie` values - a list, since a `Record` would collapse multiple cookies. */
|
|
476
345
|
_cookies?: string[]
|
|
477
346
|
}
|
|
478
347
|
|
|
@@ -507,12 +376,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
507
376
|
private readonly wsRouter: Router<WsEntry>
|
|
508
377
|
private wsRouteCount: number
|
|
509
378
|
/** In-process pub/sub backing `ws.subscribe(topic)` + `app.publish(topic, data)` (single-instance).
|
|
510
|
-
* Created by the first `app.ws()` via the `@nifrajs/core/ws` runtime
|
|
379
|
+
* Created by the first `app.ws()` via the `@nifrajs/core/ws` runtime - `undefined` until then, so a
|
|
511
380
|
* no-WebSocket app never constructs (or bundles) it. */
|
|
512
381
|
private topics: TopicRegistry | undefined
|
|
513
382
|
private readonly maxBodyBytes: number
|
|
514
383
|
private readonly wsMaxPayloadBytes: number
|
|
515
384
|
private readonly requestTimeoutMs: number
|
|
385
|
+
/** Installed by the `responseContract()` plugin; `undefined` = not installed, which is the default
|
|
386
|
+
* and the state in which a declared `response` schema stays a compile-time contract only. */
|
|
387
|
+
private responseContractRuntime: ResponseContractRuntime | undefined
|
|
516
388
|
/** Opt-in caller-IP trust declaration; `undefined` = socket peer only, no forwarded header believed. */
|
|
517
389
|
private readonly clientIpTrust: ClientIpTrust | undefined
|
|
518
390
|
private readonly acceptInboundDeadlines: boolean
|
|
@@ -553,7 +425,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
553
425
|
private readonly onResponseHooks: RawOnResponse[]
|
|
554
426
|
private readonly onResponseFinalizedHooks: RawOnResponseFinalized[]
|
|
555
427
|
private readonly responseRequests: WeakMap<Request, Request>
|
|
556
|
-
/** Names of plugins/middleware already applied via `use`
|
|
428
|
+
/** Names of plugins/middleware already applied via `use` - for idempotent dedupe. */
|
|
557
429
|
private readonly appliedPlugins: Set<string>
|
|
558
430
|
/** Order-scoped evidence captured by routes registered after an assured plugin. */
|
|
559
431
|
private readonly activeAssurance: AssuranceDeclaration[]
|
|
@@ -593,6 +465,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
593
465
|
this.effectLedgerRuntime = undefined
|
|
594
466
|
// The idempotency runtime is installed by `.use(idempotency())`; a bare app never imports it, so the
|
|
595
467
|
// dedupe machinery tree-shakes out. A route that declares idempotency without it is a build error.
|
|
468
|
+
this.responseContractRuntime = undefined
|
|
596
469
|
this.idempotencyRuntime = undefined
|
|
597
470
|
this.logger = options.logger ?? jsonLogger()
|
|
598
471
|
this.defaultOnValidationError = options.onValidationError
|
|
@@ -716,7 +589,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
716
589
|
return this
|
|
717
590
|
}
|
|
718
591
|
|
|
719
|
-
/** Transform every outgoing response
|
|
592
|
+
/** Transform every outgoing response - success, error, 404, 405, short-circuit. Global. */
|
|
720
593
|
onResponse(fn: (response: Response, req: Request) => MaybePromise<Response>): this {
|
|
721
594
|
this.assertConfigurable("onResponse()")
|
|
722
595
|
this.onResponseHooks.push(fn)
|
|
@@ -733,7 +606,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
733
606
|
}
|
|
734
607
|
|
|
735
608
|
/**
|
|
736
|
-
* Apply a type-**identity** plugin ({@link IdentityPlugin}, from {@link defineIdentityPlugin})
|
|
609
|
+
* Apply a type-**identity** plugin ({@link IdentityPlugin}, from {@link defineIdentityPlugin}) - it
|
|
737
610
|
* registers routes/hooks but doesn't change the types, so this returns `this` with the route registry
|
|
738
611
|
* and context fully intact. This overload exists specifically so a *named* identity plugin (e.g.
|
|
739
612
|
* `@nifrajs/better-auth`) threads the registry: its `& { pluginName }` intersection would otherwise
|
|
@@ -741,14 +614,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
741
614
|
*/
|
|
742
615
|
use(plugin: IdentityPlugin): this
|
|
743
616
|
/**
|
|
744
|
-
* Apply a **plugin function**
|
|
617
|
+
* Apply a **plugin function** - `(app) => app`, typically built with {@link definePlugin}. It's
|
|
745
618
|
* called with `this` and its result is returned, so an inline plugin's `derive`/`decorate` thread
|
|
746
619
|
* the added context to handlers defined after `use` (the overload is generic over the concrete
|
|
747
620
|
* `this`). A named plugin already applied is skipped (idempotent dedupe).
|
|
748
621
|
*/
|
|
749
622
|
use<Out extends AnyServer>(plugin: (app: this) => Out): Out
|
|
750
623
|
/**
|
|
751
|
-
* Apply a {@link Middleware} bundle
|
|
624
|
+
* Apply a {@link Middleware} bundle - wire each hook it provides to its lifecycle point. Returns
|
|
752
625
|
* `this` (no context-type merging); call it before the routes its `beforeHandle`/`afterHandle`
|
|
753
626
|
* should cover (those are order-scoped; `onRequest`/`onResponse` are global). A named bundle already
|
|
754
627
|
* applied is skipped (idempotent).
|
|
@@ -816,11 +689,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
816
689
|
}
|
|
817
690
|
|
|
818
691
|
/**
|
|
819
|
-
* Register a **typed SSE route**
|
|
692
|
+
* Register a **typed SSE route** - a GET endpoint streaming `text/event-stream` whose event
|
|
820
693
|
* payloads are contracted by `schema.sse`. The handler receives the validated context plus a
|
|
821
694
|
* {@link TypedSSEStream}: `stream.send(event)` is compile-time-checked against the schema and
|
|
822
695
|
* JSON-serialized into the SSE `data:` field. The typed client sees the marker and grows a
|
|
823
|
-
* `.subscribe(onEvent)` for the route with the same payload type
|
|
696
|
+
* `.subscribe(onEvent)` for the route with the same payload type - end-to-end typed streaming.
|
|
824
697
|
*
|
|
825
698
|
* import { streaming } from "@nifrajs/core/sse" // .use(streaming()) enables .sse()
|
|
826
699
|
* const app = server().use(streaming()).sse("/feed", { sse: t.object({ id: t.integer(), title: t.string() }) },
|
|
@@ -982,7 +855,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
982
855
|
)
|
|
983
856
|
this.register("POST", plan.path, plan.schema, plan.run as (context: never) => unknown)
|
|
984
857
|
// Tag the just-registered descriptor as an MCP tool. `tool` is readonly on RouteDescriptor (an
|
|
985
|
-
// introspection field), so write it through a narrow mutable view
|
|
858
|
+
// introspection field), so write it through a narrow mutable view - not `any`.
|
|
986
859
|
const lastRoute = this.catalog.lastDescriptor()
|
|
987
860
|
if (lastRoute) {
|
|
988
861
|
;(lastRoute as { tool?: RouteDescriptor["tool"] }).tool = plan.descriptor
|
|
@@ -991,7 +864,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
991
864
|
}
|
|
992
865
|
|
|
993
866
|
/**
|
|
994
|
-
* Declare an MCP **resource**
|
|
867
|
+
* Declare an MCP **resource** - read-only data an agent can fetch through `nifra mcp` (app config, a
|
|
995
868
|
* generated document, …). `read` runs in the app process, so capture whatever app state it needs in the
|
|
996
869
|
* closure. `uri` is the MCP resource identifier (e.g. `"myapp://config"`). The sibling of {@link tool}
|
|
997
870
|
* for the resource half of MCP.
|
|
@@ -1007,7 +880,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1007
880
|
}
|
|
1008
881
|
|
|
1009
882
|
/**
|
|
1010
|
-
* Declare an MCP **prompt**
|
|
883
|
+
* Declare an MCP **prompt** - a reusable prompt template an agent can fetch through `nifra mcp`.
|
|
1011
884
|
* `handler` receives the caller's arguments and returns the rendered messages.
|
|
1012
885
|
*/
|
|
1013
886
|
prompt(
|
|
@@ -1020,12 +893,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1020
893
|
return this
|
|
1021
894
|
}
|
|
1022
895
|
|
|
1023
|
-
/** The MCP resources declared via {@link resource}
|
|
896
|
+
/** The MCP resources declared via {@link resource} - enumerated by `nifra mcp`. */
|
|
1024
897
|
mcpResources(): readonly McpResourceDescriptor[] {
|
|
1025
898
|
return this.mcpResourceList
|
|
1026
899
|
}
|
|
1027
900
|
|
|
1028
|
-
/** The MCP prompts declared via {@link prompt}
|
|
901
|
+
/** The MCP prompts declared via {@link prompt} - enumerated by `nifra mcp`. */
|
|
1029
902
|
mcpPrompts(): readonly McpPromptDescriptor[] {
|
|
1030
903
|
return this.mcpPromptList
|
|
1031
904
|
}
|
|
@@ -1034,7 +907,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1034
907
|
* Register a **WebSocket** route. The connection upgrades on a `GET` to `path` carrying
|
|
1035
908
|
* `Upgrade: websocket`; the optional `handler.upgrade(c)` runs in the request context first and may
|
|
1036
909
|
* reject (return a `Response`) or seed per-connection `ws.data`. WebSockets are served by the
|
|
1037
|
-
* adapter (`listen()`, `@nifrajs/node`, `@nifrajs/deno`, `toFetchHandler`)
|
|
910
|
+
* adapter (`listen()`, `@nifrajs/node`, `@nifrajs/deno`, `toFetchHandler`) - not by bare `app.fetch`, which
|
|
1038
911
|
* has no socket (a WS path through `app.fetch` is a normal HTTP response).
|
|
1039
912
|
*
|
|
1040
913
|
* The route also enters the type-level registry (under the pseudo-method `"WS"`), so the typed
|
|
@@ -1058,10 +931,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1058
931
|
: Server<AddRoute<R, "WS", Path, WsRouteInfoFor<Path, Schema, Send>>, Ctx> {
|
|
1059
932
|
this.assertConfigurable("ws()")
|
|
1060
933
|
// Boot-time guard: the WS runtime is a subpath (`@nifrajs/core/ws`) so no-WebSocket apps don't
|
|
1061
|
-
// bundle it. Registration is the loud, early failure point
|
|
934
|
+
// bundle it. Registration is the loud, early failure point - never the first connection.
|
|
1062
935
|
const runtime = requireWsRuntime(this.wsRuntime)
|
|
1063
936
|
this.topics ??= runtime.createTopics()
|
|
1064
|
-
// A `messageSchema` wraps `message` with validation once, here
|
|
937
|
+
// A `messageSchema` wraps `message` with validation once, here - every adapter then dispatches
|
|
1065
938
|
// already-validated, typed messages (Bun/Deno/Node/Workers) with no per-adapter code.
|
|
1066
939
|
this.wsRouter.add("GET", path, {
|
|
1067
940
|
handler: runtime.wrapHandler(handler as WebSocketHandler),
|
|
@@ -1072,11 +945,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1072
945
|
|
|
1073
946
|
/**
|
|
1074
947
|
* Broadcast `data` to every WebSocket connection subscribed to `topic` (via `ws.subscribe(topic)`).
|
|
1075
|
-
* In-process and **single-instance** (see {@link TopicRegistry})
|
|
948
|
+
* In-process and **single-instance** (see {@link TopicRegistry}) - a multi-instance deploy must bridge
|
|
1076
949
|
* an external fan-out (Redis, a Durable Object) to this. A no-op when nobody is subscribed.
|
|
1077
950
|
*/
|
|
1078
951
|
publish(topic: string, data: string | ArrayBufferView | ArrayBuffer): void {
|
|
1079
|
-
// No `app.ws()` yet ⇒ no registry and necessarily no subscribers
|
|
952
|
+
// No `app.ws()` yet ⇒ no registry and necessarily no subscribers - a publish is a no-op anyway.
|
|
1080
953
|
this.topics?.publish(topic, data)
|
|
1081
954
|
}
|
|
1082
955
|
|
|
@@ -1103,7 +976,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1103
976
|
|
|
1104
977
|
/**
|
|
1105
978
|
* Low-level route registration shared by the inline builder and `implement()`.
|
|
1106
|
-
* Captures the server's current `derive`/`decorate` chain into the route
|
|
979
|
+
* Captures the server's current `derive`/`decorate` chain into the route - this
|
|
1107
980
|
* is the "compiled", order-scoped per-route chain.
|
|
1108
981
|
*/
|
|
1109
982
|
register(
|
|
@@ -1170,7 +1043,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1170
1043
|
}
|
|
1171
1044
|
const hasDecorations = Reflect.ownKeys(routeDecorations).length > 0
|
|
1172
1045
|
// An idempotency route runs a dedupe lane that must buffer the body and capture the response, so it
|
|
1173
|
-
// never takes the fused/native fast path
|
|
1046
|
+
// never takes the fused/native fast path - force it onto the portable matched lane (which routes
|
|
1174
1047
|
// through `fetchMatched`, where the dedupe wrapper lives). Fail closed: a route may not declare
|
|
1175
1048
|
// idempotency unless the idempotency runtime is installed, so the safety gate can never be silently
|
|
1176
1049
|
// dropped by a missing plugin.
|
|
@@ -1183,18 +1056,27 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1183
1056
|
const idempotent = this.idempotencyRuntime?.resolve(schema, authenticated, this.maxBodyBytes)
|
|
1184
1057
|
// A ledgered route (capabilities declared + `.use(effectLedger())`) needs a per-request
|
|
1185
1058
|
// context to carry the ledger and a settle step to seal + sink it, so it too leaves the
|
|
1186
|
-
// fused/contextless fast path. Resolved per route, at registration
|
|
1059
|
+
// fused/contextless fast path. Resolved per route, at registration - like the capability guard.
|
|
1187
1060
|
const ledgered: ResolvedEffectLedger | undefined = this.effectLedgerRuntime?.resolve(
|
|
1188
1061
|
capabilities,
|
|
1189
1062
|
method,
|
|
1190
1063
|
path,
|
|
1191
1064
|
)
|
|
1065
|
+
// A checked response schema needs the handler's VALUE before it becomes bytes, and the fused and
|
|
1066
|
+
// native lanes exist precisely to skip that step. So a contracted route leaves them - the same
|
|
1067
|
+
// trade an idempotent route makes above, and the reason this is opt-in per server.
|
|
1068
|
+
const runtime = this.responseContractRuntime
|
|
1069
|
+
const contracted =
|
|
1070
|
+
runtime !== undefined && schema?.response !== undefined
|
|
1071
|
+
? { runtime, schema: schema.response }
|
|
1072
|
+
: undefined
|
|
1192
1073
|
const bare =
|
|
1193
1074
|
schema?.params === undefined &&
|
|
1194
1075
|
schema?.body === undefined &&
|
|
1195
1076
|
schema?.query === undefined &&
|
|
1196
1077
|
idempotent === undefined &&
|
|
1197
1078
|
ledgered === undefined &&
|
|
1079
|
+
contracted === undefined &&
|
|
1198
1080
|
this.derives.length === 0 &&
|
|
1199
1081
|
this.beforeHandleHooks.length === 0 &&
|
|
1200
1082
|
this.afterHandleHooks.length === 0 &&
|
|
@@ -1208,9 +1090,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1208
1090
|
)
|
|
1209
1091
|
: undefined
|
|
1210
1092
|
const contextless = bare && this.aroundHooks.length === 0 && isContextlessNoArgArrow(handler)
|
|
1093
|
+
// The body and query lanes finalize the handler's result themselves, so a contracted route takes
|
|
1094
|
+
// the general lifecycle lane instead - one place owns the check rather than three.
|
|
1211
1095
|
const lane = bare
|
|
1212
1096
|
? "bare"
|
|
1213
|
-
:
|
|
1097
|
+
: contracted === undefined &&
|
|
1098
|
+
schema?.body !== undefined &&
|
|
1214
1099
|
schema.query === undefined &&
|
|
1215
1100
|
schema.params === undefined &&
|
|
1216
1101
|
this.derives.length === 0 &&
|
|
@@ -1218,7 +1103,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1218
1103
|
this.afterHandleHooks.length === 0 &&
|
|
1219
1104
|
this.onErrorHooks.length === 0
|
|
1220
1105
|
? "body"
|
|
1221
|
-
:
|
|
1106
|
+
: contracted === undefined &&
|
|
1107
|
+
schema?.body === undefined &&
|
|
1222
1108
|
schema?.query !== undefined &&
|
|
1223
1109
|
schema.params === undefined &&
|
|
1224
1110
|
this.derives.length === 0 &&
|
|
@@ -1241,6 +1127,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1241
1127
|
schema,
|
|
1242
1128
|
idempotent,
|
|
1243
1129
|
ledgered,
|
|
1130
|
+
responseContract: contracted,
|
|
1244
1131
|
derives: [...this.derives],
|
|
1245
1132
|
decorations: routeDecorations,
|
|
1246
1133
|
hasDecorations,
|
|
@@ -1431,6 +1318,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1431
1318
|
)
|
|
1432
1319
|
}
|
|
1433
1320
|
|
|
1321
|
+
/** @internal Symbol-keyed install seam for the `responseContract()` plugin. Off the public typed surface. */
|
|
1322
|
+
[INSTALL_RESPONSE_CONTRACT](runtime: ResponseContractRuntime): void {
|
|
1323
|
+
this.assertConfigurable("responseContract()")
|
|
1324
|
+
this.responseContractRuntime = runtime
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1434
1327
|
/** @internal Symbol-keyed install seam for the `idempotency()` plugin. Off the public typed surface. */
|
|
1435
1328
|
[INSTALL_IDEMPOTENCY](runtime: IdempotencyRuntime): void {
|
|
1436
1329
|
this.assertConfigurable("idempotency()")
|
|
@@ -1462,16 +1355,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1462
1355
|
}
|
|
1463
1356
|
|
|
1464
1357
|
/**
|
|
1465
|
-
* Merge another server's routes into this one
|
|
1358
|
+
* Merge another server's routes into this one - the composition escape hatch for large apps.
|
|
1466
1359
|
*
|
|
1467
1360
|
* WHY: the fluent chain accumulates one type-alias level per route, and TypeScript resolves
|
|
1468
|
-
* that stack in one recursion
|
|
1361
|
+
* that stack in one recursion - a single chain hits TS2589 at ~95 routes. Groups keep every
|
|
1469
1362
|
* chain short: build each domain (`listings`, `agents`, …) as its own `server()` (its registry
|
|
1470
|
-
* resolves independently), then `app.merge(listings).merge(agents)`
|
|
1363
|
+
* resolves independently), then `app.merge(listings).merge(agents)` - each merge adds ONE level
|
|
1471
1364
|
* regardless of group size. 300+ routes stay fully typed (see many-routes.test-d.ts). The
|
|
1472
1365
|
* other escape hatch is contract-first `implement()`, whose registry is a single object type.
|
|
1473
1366
|
*
|
|
1474
|
-
* Semantics: merged routes keep the chains captured where they were DEFINED
|
|
1367
|
+
* Semantics: merged routes keep the chains captured where they were DEFINED - the group's
|
|
1475
1368
|
* `derive`/`decorate`/`beforeHandle`/`afterHandle`/`onError`/`around` apply to its routes
|
|
1476
1369
|
* exactly as they did standalone, so a group wires its own plugins. The group's request-level
|
|
1477
1370
|
* hooks (`onRequest`/`onResponse`/`onResponseFinalized`) are appended to this server's. This
|
|
@@ -1486,7 +1379,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1486
1379
|
if (source.wsRouteCount > 0) {
|
|
1487
1380
|
throw new RouteConfigError(
|
|
1488
1381
|
"INVALID_PATH",
|
|
1489
|
-
"merge() does not carry WebSocket routes
|
|
1382
|
+
"merge() does not carry WebSocket routes - register .ws() routes on the parent server",
|
|
1490
1383
|
)
|
|
1491
1384
|
}
|
|
1492
1385
|
this.catalog.addBatch(source.catalog.entries().map((route) => this.bindFusedRuntime(route)))
|
|
@@ -1495,6 +1388,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1495
1388
|
// when the parent has none so merging cannot silently disable a safety lane. If the parent already
|
|
1496
1389
|
// has a runtime, either implementation can execute every resolved entry because route-specific
|
|
1497
1390
|
// options were pinned during registration.
|
|
1391
|
+
this.responseContractRuntime ??= source.responseContractRuntime
|
|
1498
1392
|
this.idempotencyRuntime ??= source.idempotencyRuntime
|
|
1499
1393
|
this.effectLedgerRuntime ??= source.effectLedgerRuntime
|
|
1500
1394
|
this.mcpRuntime ??= source.mcpRuntime
|
|
@@ -1547,12 +1441,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1547
1441
|
}
|
|
1548
1442
|
|
|
1549
1443
|
/**
|
|
1550
|
-
* Resolve a `Request` to a `Response`
|
|
1444
|
+
* Resolve a `Request` to a `Response` - the whole lifecycle, testable without a port. The
|
|
1551
1445
|
* optional `platform` carries edge inputs (`env`, `waitUntil`); edge adapters pass it, and
|
|
1552
1446
|
* Bun/Node/Deno omit it (then `c.env` is `undefined` and `c.waitUntil` runs fire-and-forget).
|
|
1553
1447
|
*/
|
|
1554
1448
|
fetch(req: Request, platform?: Platform<EnvOf<Ctx>>): MaybePromise<Response> {
|
|
1555
|
-
// A real `Request` satisfies `RequestSource`, so it's passed straight through
|
|
1449
|
+
// A real `Request` satisfies `RequestSource`, so it's passed straight through - no per-request
|
|
1556
1450
|
// wrapper allocation on the Web/Bun hot path.
|
|
1557
1451
|
return this.fetchSource(req, platform)
|
|
1558
1452
|
}
|
|
@@ -1561,7 +1455,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1561
1455
|
source: RequestSource,
|
|
1562
1456
|
platform?: Platform<EnvOf<Ctx>>,
|
|
1563
1457
|
): MaybePromise<Response> {
|
|
1564
|
-
// Off path (default): straight through
|
|
1458
|
+
// Off path (default): straight through - one property check, no closure, no promise.
|
|
1565
1459
|
if (this.capacityGate === undefined) return this.fetchSourceInner(source, platform)
|
|
1566
1460
|
return this.admitGated(requestOf(source), () => this.fetchSourceInner(source, platform))
|
|
1567
1461
|
}
|
|
@@ -1585,7 +1479,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1585
1479
|
if (this.onResponseHooks.length === 0 && this.onResponseFinalizedHooks.length === 0) {
|
|
1586
1480
|
return outcome
|
|
1587
1481
|
}
|
|
1588
|
-
// onResponse sees every response
|
|
1482
|
+
// onResponse sees every response - success, validation error, 404/405, timeout, onRequest
|
|
1589
1483
|
// short-circuit; normalize to a promise, then thread through the hooks.
|
|
1590
1484
|
return outcome instanceof Promise
|
|
1591
1485
|
? outcome.then((response) =>
|
|
@@ -1632,7 +1526,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1632
1526
|
/**
|
|
1633
1527
|
* Run `produce` under the capacity gate: admit → run → release exactly once when the response is
|
|
1634
1528
|
* produced (or the run throws). Only reached when {@link capacityGate} is set, so the off path pays
|
|
1635
|
-
* nothing. The slot is held for the duration of handler execution, not the streaming of the body
|
|
1529
|
+
* nothing. The slot is held for the duration of handler execution, not the streaming of the body -
|
|
1636
1530
|
* capacity here bounds concurrent *work*, matching how in-flight is counted.
|
|
1637
1531
|
*/
|
|
1638
1532
|
private admitGated(req: Request, produce: () => MaybePromise<Response>): MaybePromise<Response> {
|
|
@@ -1660,7 +1554,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1660
1554
|
release()
|
|
1661
1555
|
throw error
|
|
1662
1556
|
}
|
|
1663
|
-
// Release the slot once the response settles
|
|
1557
|
+
// Release the slot once the response settles - on resolve OR reject - via `finally`, which passes
|
|
1664
1558
|
// the value/rejection through unchanged. (A single settle hook, rather than separate then-arms: the
|
|
1665
1559
|
// request pipeline resolves handler errors to a Response, so a rejection arm would be unreachable.)
|
|
1666
1560
|
if (outcome instanceof Promise) return outcome.finally(release)
|
|
@@ -1669,11 +1563,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1669
1563
|
}
|
|
1670
1564
|
|
|
1671
1565
|
/**
|
|
1672
|
-
* Like {@link fetch}, but renders a plain-data result **without** building a Web `Response`
|
|
1566
|
+
* Like {@link fetch}, but renders a plain-data result **without** building a Web `Response` - the
|
|
1673
1567
|
* `@nifrajs/node` adapter serializes the returned primitives straight to the socket, skipping the undici
|
|
1674
1568
|
* `Response` build + body drain (the bulk of the Node bridge cost, measured ≈4µs/req). A handler that
|
|
1675
1569
|
* returns a `Response`, an error/short-circuit, or any registered `onResponse` hook falls back to the
|
|
1676
|
-
* full Web path (`{ kind: "response" }`), so behavior is identical
|
|
1570
|
+
* full Web path (`{ kind: "response" }`), so behavior is identical - only the common JSON-data case is
|
|
1677
1571
|
* faster. Same lifecycle as {@link fetch} (body cap, validation, hooks all run); only the final
|
|
1678
1572
|
* render differs.
|
|
1679
1573
|
*/
|
|
@@ -1686,7 +1580,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1686
1580
|
platform?: Platform<EnvOf<Ctx>>,
|
|
1687
1581
|
suppliedRuntime?: NodeOutcomeRuntime,
|
|
1688
1582
|
): MaybePromise<NodeServeOutcome> {
|
|
1689
|
-
// onResponse hooks transform a Response, and the capacity gate wraps the Web response path
|
|
1583
|
+
// onResponse hooks transform a Response, and the capacity gate wraps the Web response path - both
|
|
1690
1584
|
// force the Web path here (the gated `fetchSource` admits/sheds/releases); wrap its result.
|
|
1691
1585
|
if (
|
|
1692
1586
|
this.onResponseHooks.length > 0 ||
|
|
@@ -1719,7 +1613,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1719
1613
|
}
|
|
1720
1614
|
|
|
1721
1615
|
/**
|
|
1722
|
-
* Resolve a WebSocket upgrade
|
|
1616
|
+
* Resolve a WebSocket upgrade - the seam every serving adapter uses. Returns `pass` (not a WS
|
|
1723
1617
|
* upgrade for a registered route → handle as normal HTTP), `reject` (a WS route matched but
|
|
1724
1618
|
* `upgrade()` rejected, or the path was malformed → return `response`), or `upgrade` (perform the
|
|
1725
1619
|
* runtime upgrade, then dispatch the native socket's events to `handler`, seeding `ws.data` with
|
|
@@ -1754,7 +1648,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1754
1648
|
: origin !== null && handler.allowedOrigins.includes(origin)
|
|
1755
1649
|
if (!allowed) return { kind: "reject", response: jsonError(403, "forbidden_origin") }
|
|
1756
1650
|
} else if (origin !== null && !wsSameOrigin(origin, req)) {
|
|
1757
|
-
// Secure default (no explicit `allowedOrigins`): reject a CROSS-ORIGIN browser handshake
|
|
1651
|
+
// Secure default (no explicit `allowedOrigins`): reject a CROSS-ORIGIN browser handshake - the
|
|
1758
1652
|
// CSWSH case, since browsers send cookies on WS handshakes and don't apply CORS. Non-browser
|
|
1759
1653
|
// clients send no `Origin` and pass; same-origin browsers pass. Set `allowedOrigins` to permit
|
|
1760
1654
|
// specific cross-origin clients (or `() => true` for a genuinely public socket).
|
|
@@ -1814,7 +1708,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1814
1708
|
* result + `set` into the output `T` (`toResponse` → a Web `Response`; `toNodeOutcome` → node-direct
|
|
1815
1709
|
* primitives), `wrapResponse` lifts an early/error `Response` into that same `T`, and `onTimeout`
|
|
1816
1710
|
* produces the 503. The Web `fetch` and `resolveNode` are thin callers over this one routing +
|
|
1817
|
-
* context + lifecycle implementation
|
|
1711
|
+
* context + lifecycle implementation - no duplication across the trust boundary.
|
|
1818
1712
|
*/
|
|
1819
1713
|
/** Apply the `clientIp` trust declaration to the adapter's raw socket peer, returning a platform
|
|
1820
1714
|
* whose `clientIp` is the derived caller. Only called when a trust declaration is configured. */
|
|
@@ -1832,7 +1726,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1832
1726
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
1833
1727
|
wrapResponse: (response: Response) => T,
|
|
1834
1728
|
onTimeout: () => T,
|
|
1835
|
-
// True only from the Web `fetch` path
|
|
1729
|
+
// True only from the Web `fetch` path - unlocks each route's fused lane, whose output type IS
|
|
1836
1730
|
// `Response` (`T = Response` there by construction; the node path always passes false).
|
|
1837
1731
|
webFast: boolean,
|
|
1838
1732
|
): MaybePromise<T> {
|
|
@@ -1851,7 +1745,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1851
1745
|
|
|
1852
1746
|
/**
|
|
1853
1747
|
* onRequest short-circuit path. Synchronous as long as every hook returns synchronously (the
|
|
1854
|
-
* common case
|
|
1748
|
+
* common case - e.g. CORS returning `undefined` for a non-preflight request): an `async` version
|
|
1855
1749
|
* here put EVERY request of any app with one onRequest hook onto the promise machinery, profiled
|
|
1856
1750
|
* at ~13% of a realistic request. The first hook that returns a Promise hands the REMAINING
|
|
1857
1751
|
* hooks to the async continuation; behavior is identical.
|
|
@@ -1895,7 +1789,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1895
1789
|
}
|
|
1896
1790
|
|
|
1897
1791
|
/** Async tail of {@link runWithOnRequest}: applies the first awaited hook's outcome, then runs
|
|
1898
|
-
* the remaining hooks (awaiting freely
|
|
1792
|
+
* the remaining hooks (awaiting freely - we're already async here). */
|
|
1899
1793
|
private async continueOnRequest<T>(
|
|
1900
1794
|
first: OnRequestResult,
|
|
1901
1795
|
nextIndex: number,
|
|
@@ -1990,7 +1884,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1990
1884
|
webFast: boolean,
|
|
1991
1885
|
): MaybePromise<T> {
|
|
1992
1886
|
// An idempotency route runs its dedupe lane first; on a fresh key it delegates to the normal lanes
|
|
1993
|
-
// (with the body buffered). All non-idempotent routes skip straight to the lanes
|
|
1887
|
+
// (with the body buffered). All non-idempotent routes skip straight to the lanes - no added cost.
|
|
1994
1888
|
// The runtime is always present when a route resolved idempotency (enforced at registration).
|
|
1995
1889
|
if (entry.idempotent !== undefined && this.idempotencyRuntime !== undefined) {
|
|
1996
1890
|
return this.idempotencyRuntime.run(
|
|
@@ -2091,7 +1985,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2091
1985
|
finalize,
|
|
2092
1986
|
wrapResponse,
|
|
2093
1987
|
)
|
|
2094
|
-
// The request timeout only bounds work that is actually pending
|
|
1988
|
+
// The request timeout only bounds work that is actually pending - a synchronous (bare) result is
|
|
2095
1989
|
// already complete and can't time out, so it's returned as-is (no 503 race, no promise).
|
|
2096
1990
|
if (controller !== undefined && outcome instanceof Promise) {
|
|
2097
1991
|
const timedOut =
|
|
@@ -2187,11 +2081,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2187
2081
|
|
|
2188
2082
|
/**
|
|
2189
2083
|
* The synchronous fast path selected by a route's execution plan: apply static decorations, call the
|
|
2190
|
-
* handler, render the result
|
|
2084
|
+
* handler, render the result - **no `await`** unless the handler itself returns a promise. It mirrors
|
|
2191
2085
|
* the bare slice of {@link runLifecycle} (which a bare route would otherwise no-op through) and shares
|
|
2192
2086
|
* {@link logRequestError}; a bare route has no `onError` hooks, so error handling is fully synchronous
|
|
2193
2087
|
* (a thrown `Response` is control flow; anything else is a logged flat 500). This is where nifra skips
|
|
2194
|
-
* the per-request async-frame tax
|
|
2088
|
+
* the per-request async-frame tax - the same win codegen routers get, but without `eval`.
|
|
2195
2089
|
*/
|
|
2196
2090
|
private runBare<T>(
|
|
2197
2091
|
entry: RouteEntry,
|
|
@@ -2216,12 +2110,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2216
2110
|
return finalize(result, responseSet(ctx))
|
|
2217
2111
|
}
|
|
2218
2112
|
|
|
2219
|
-
/** Bare-route error rendering
|
|
2113
|
+
/** Bare-route error rendering - identical to {@link runLifecycle}'s catch minus the (absent) onError
|
|
2220
2114
|
* loop: a thrown `Response` is returned as deliberate control flow; anything else is logged + 500. */
|
|
2221
2115
|
/**
|
|
2222
2116
|
* Build a route's fused Web renderer. Composition happens once at
|
|
2223
2117
|
* registration; the returned closure is what every request to the route runs. Behavior is
|
|
2224
|
-
* byte-identical to the generic `runBare`/`runContextlessBare` + `toResponse` pair
|
|
2118
|
+
* byte-identical to the generic `runBare`/`runContextlessBare` + `toResponse` pair - same
|
|
2225
2119
|
* decoration order, same error routing (thrown `Response` = control flow; anything else logs and
|
|
2226
2120
|
* 500s), same respond semantics (the lifecycle parity suite pins it).
|
|
2227
2121
|
*/
|
|
@@ -2236,7 +2130,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2236
2130
|
return jsonError(500, "internal_error")
|
|
2237
2131
|
}
|
|
2238
2132
|
if (contextless && decorations === undefined) {
|
|
2239
|
-
// `() => ...` can't observe the context
|
|
2133
|
+
// `() => ...` can't observe the context - skip allocating one entirely (errors still build
|
|
2240
2134
|
// one for the structured log, exactly like runContextlessBare).
|
|
2241
2135
|
const contextlessHandler = handler as unknown as ContextlessHandler
|
|
2242
2136
|
return (source, params, search, signal, budget, platform, nativeContext) => {
|
|
@@ -2365,10 +2259,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2365
2259
|
}
|
|
2366
2260
|
|
|
2367
2261
|
// Inline fast path (profiled): a framed, in-cap, non-chunked body parses with the native
|
|
2368
|
-
// `req.json()` directly
|
|
2262
|
+
// `req.json()` directly - one promise, one `.then` closure per request. The generic
|
|
2369
2263
|
// `readBoundedJson` (an extra async-fn frame + per-request `finish`/`applyValidation`
|
|
2370
2264
|
// closures) is kept for the chunked / length-less / oversized cases it exists for.
|
|
2371
|
-
// Semantics are identical to readBoundedJsonSource
|
|
2265
|
+
// Semantics are identical to readBoundedJsonSource - same checks, same error codes.
|
|
2372
2266
|
const declared = headerOf(source, "content-length")
|
|
2373
2267
|
if (declared !== null) {
|
|
2374
2268
|
const length = parseContentLength(declared)
|
|
@@ -2401,7 +2295,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2401
2295
|
}
|
|
2402
2296
|
}
|
|
2403
2297
|
|
|
2404
|
-
/** Validate + run the handler for the bodyOnly path
|
|
2298
|
+
/** Validate + run the handler for the bodyOnly path - shared by the inline fast path and the
|
|
2405
2299
|
* streaming fallback. A method (not per-request closures) so the hot path allocates nothing
|
|
2406
2300
|
* beyond the one `.then` continuation. */
|
|
2407
2301
|
private finishBodyOnly<T>(
|
|
@@ -2555,7 +2449,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2555
2449
|
wrapResponse: (response: Response) => T,
|
|
2556
2450
|
): MaybePromise<T> {
|
|
2557
2451
|
try {
|
|
2558
|
-
// Call the validator directly for the raw StandardResult (read `.issues`/`.value`)
|
|
2452
|
+
// Call the validator directly for the raw StandardResult (read `.issues`/`.value`) - skip
|
|
2559
2453
|
// `validateStandard`'s per-request wrapper-object allocation, mirroring the bodyOnly path.
|
|
2560
2454
|
const validation = entry.schema!.query!["~standard"].validate(
|
|
2561
2455
|
queryObjectOf(ctx[CONTEXT_SEARCH]),
|
|
@@ -2627,7 +2521,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2627
2521
|
|
|
2628
2522
|
/**
|
|
2629
2523
|
* Thread the response through each global `onResponse` hook. Stays SYNCHRONOUS until a hook
|
|
2630
|
-
* actually returns a Promise
|
|
2524
|
+
* actually returns a Promise - an `async` version forced a promise + microtask on EVERY response
|
|
2631
2525
|
* of any app with an onResponse hook (cors/securityHeaders/etag/timing all use onResponse), the
|
|
2632
2526
|
* same ~13%/req tax the onRequest walk was de-async'd to avoid. The first async hook hands the
|
|
2633
2527
|
* rest to {@link continueOnResponse}.
|
|
@@ -2722,7 +2616,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2722
2616
|
/**
|
|
2723
2617
|
* Bound the response time. On timeout we abort `ctx.signal` (so cancellation-aware
|
|
2724
2618
|
* handlers can bail) and return 503; the in-flight work keeps running but its
|
|
2725
|
-
* result is discarded
|
|
2619
|
+
* result is discarded - JS can't forcibly cancel a promise.
|
|
2726
2620
|
*/
|
|
2727
2621
|
private async withTimeout<T>(
|
|
2728
2622
|
work: Promise<T>,
|
|
@@ -2783,7 +2677,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2783
2677
|
}
|
|
2784
2678
|
|
|
2785
2679
|
// Context extensions: static decorations, then per-request derives.
|
|
2786
|
-
// Each hook below only awaits when it actually returns a Promise
|
|
2680
|
+
// Each hook below only awaits when it actually returns a Promise - a sync hook
|
|
2787
2681
|
// skips the microtask tick (a fast-path; ~64 ns/hook).
|
|
2788
2682
|
if (entry.hasDecorations) Object.assign(ctx, entry.decorations) // skip the no-op on bare routes
|
|
2789
2683
|
// The `.length` guards skip iterator setup for the common no-hook route (most
|
|
@@ -2816,6 +2710,36 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2816
2710
|
}
|
|
2817
2711
|
}
|
|
2818
2712
|
|
|
2713
|
+
// The response contract is checked LAST, after afterHandle, so it holds what actually ships
|
|
2714
|
+
// rather than what the handler happened to return. Only reached by a contracted route: the
|
|
2715
|
+
// registration decision above kept those off the fused/native lanes for exactly this step.
|
|
2716
|
+
const contract = entry.responseContract
|
|
2717
|
+
if (contract !== undefined) {
|
|
2718
|
+
const checked = contract.runtime.check(contract.schema, result)
|
|
2719
|
+
const outcome = checked instanceof Promise ? await checked : checked
|
|
2720
|
+
if (outcome.kind === "violation") {
|
|
2721
|
+
// The handler broke a contract it declared itself, so this is a server fault, not the
|
|
2722
|
+
// caller's. Fail closed: the undeclared payload is never sent, and the detail goes to the
|
|
2723
|
+
// (redacting) logger rather than to the client.
|
|
2724
|
+
this.logger.error("response contract violation", {
|
|
2725
|
+
method: ctx.req.method,
|
|
2726
|
+
path: pathnameOf(ctx.req.url),
|
|
2727
|
+
// `detail`, not `message`: the logger uses `message` for its own first argument, so a
|
|
2728
|
+
// field of that name is silently overwritten and the diagnosis disappears.
|
|
2729
|
+
detail: outcome.message,
|
|
2730
|
+
})
|
|
2731
|
+
return wrapResponse(jsonError(500, "internal_error"))
|
|
2732
|
+
}
|
|
2733
|
+
if (outcome.kind === "warn") {
|
|
2734
|
+
this.logger.warn("response contract", {
|
|
2735
|
+
method: ctx.req.method,
|
|
2736
|
+
path: pathnameOf(ctx.req.url),
|
|
2737
|
+
detail: outcome.message,
|
|
2738
|
+
})
|
|
2739
|
+
}
|
|
2740
|
+
result = outcome.value
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2819
2743
|
return finalize(result, responseSet(ctx))
|
|
2820
2744
|
} catch (err) {
|
|
2821
2745
|
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
|
|
@@ -2829,7 +2753,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2829
2753
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
2830
2754
|
wrapResponse: (response: Response) => T,
|
|
2831
2755
|
): Promise<T> {
|
|
2832
|
-
// A *thrown* Response is deliberate control flow, not an error
|
|
2756
|
+
// A *thrown* Response is deliberate control flow, not an error - a guard throws a redirect/401,
|
|
2833
2757
|
// an action throws an error page. Return it as-is (Remix/SvelteKit semantics); don't run onError
|
|
2834
2758
|
// or log it as a 500. This is what makes `throw redirect(...)` / `requireSession(...)` work from
|
|
2835
2759
|
// any handler or loader.
|
|
@@ -2846,7 +2770,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2846
2770
|
return wrapResponse(jsonError(500, "internal_error"))
|
|
2847
2771
|
}
|
|
2848
2772
|
|
|
2849
|
-
/** Log an unhandled request error to the (redacting) logger
|
|
2773
|
+
/** Log an unhandled request error to the (redacting) logger - shared by {@link runLifecycle} and the
|
|
2850
2774
|
* bare fast path ({@link bareError}) so both record the same fields. Never throws; never leaks. */
|
|
2851
2775
|
private logRequestError(err: unknown, ctx: RawContext): void {
|
|
2852
2776
|
this.logger.error("unhandled request error", {
|
|
@@ -2874,7 +2798,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2874
2798
|
if (form instanceof Response) return form
|
|
2875
2799
|
parsed = form
|
|
2876
2800
|
} else {
|
|
2877
|
-
// multipart/form-data (file uploads) stays 415 on the schema path by design
|
|
2801
|
+
// multipart/form-data (file uploads) stays 415 on the schema path by design - files don't
|
|
2878
2802
|
// fit a value schema; use a schema-less route + @nifrajs/uploads helpers for those.
|
|
2879
2803
|
return jsonError(415, "unsupported_media_type")
|
|
2880
2804
|
}
|
|
@@ -2922,12 +2846,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2922
2846
|
/**
|
|
2923
2847
|
* Read the body as text, capped at `maxBodyBytes`. Rejects (`null`) on a
|
|
2924
2848
|
* `Content-Length` over the cap *before* buffering, and aborts mid-stream once the
|
|
2925
|
-
* running byte count exceeds it
|
|
2849
|
+
* running byte count exceeds it - so a lying or absent length can't force us to
|
|
2926
2850
|
* buffer an oversized payload.
|
|
2927
2851
|
*
|
|
2928
2852
|
* Fast path: when a non-chunked request carries a `Content-Length` within the cap,
|
|
2929
|
-
* a native `req.json()` is already bounded
|
|
2930
|
-
* runtime delivers at most `Content-Length` bytes
|
|
2853
|
+
* a native `req.json()` is already bounded - under HTTP/1.1 + HTTP/2 framing the
|
|
2854
|
+
* runtime delivers at most `Content-Length` bytes - so we skip the manual stream
|
|
2931
2855
|
* loop and a separate text decode. It trusts the wire
|
|
2932
2856
|
* *framing*, not the header value: nifra only ever receives framed Requests from the
|
|
2933
2857
|
* runtime's HTTP server, never a hand-built one with a mismatched length. Chunked or
|
|
@@ -2992,7 +2916,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2992
2916
|
* capture semantics; static and `:param` routes take the native lane. */
|
|
2993
2917
|
private buildBunNativeRoutes(): BunNativeRoutes | undefined {
|
|
2994
2918
|
// A `clientIp` trust declaration must run the resolver in `dispatch`, which the fused native lane
|
|
2995
|
-
// bypasses
|
|
2919
|
+
// bypasses - so an app that declares trust routes through the fetch lane (where `c.clientIp`
|
|
2996
2920
|
// resolves) instead of Bun's native table. The allocation-free default keeps native fusion.
|
|
2997
2921
|
if (
|
|
2998
2922
|
this.onRequestHooks.length > 0 ||
|
|
@@ -3038,7 +2962,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3038
2962
|
* Start a `Bun.serve` instance bound to `port` (use `0` for an ephemeral port).
|
|
3039
2963
|
*
|
|
3040
2964
|
* `reusePort` sets `SO_REUSEPORT` so **multiple processes can bind the same port** and the kernel
|
|
3041
|
-
* load-balances connections across them
|
|
2965
|
+
* load-balances connections across them - the standard way to use every core (Bun is
|
|
3042
2966
|
* single-threaded per process). Spawn one process per core, each calling
|
|
3043
2967
|
* `app.listen(PORT, { reusePort: true })`; see `examples/cluster.ts`. Every process must opt in,
|
|
3044
2968
|
* and all of them must be the same app. Linux balances ~evenly; macOS accepts the flag but may
|
|
@@ -3064,14 +2988,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3064
2988
|
}
|
|
3065
2989
|
// Bun's `Server` is the concrete handle; we expose the stable `RunningServer`
|
|
3066
2990
|
// subset so the public types don't depend on the ambient `Bun` global. The cast
|
|
3067
|
-
// bridges them
|
|
2991
|
+
// bridges them - Bun's `.port` is `number | undefined` (undefined only for unix
|
|
3068
2992
|
// sockets, never a TCP `listen`) and its `.stop` returns a promise we don't await.
|
|
3069
|
-
// Pass only the request
|
|
2993
|
+
// Pass only the request - Bun's `fetch` 2nd arg is the Bun `Server`, not our `platform`.
|
|
3070
2994
|
// With WS routes, hand Bun a `websocket` config + a fetch that upgrades matching requests (the
|
|
3071
2995
|
// `server` 2nd arg is how Bun exposes `upgrade`); otherwise the lean request-only fetch. The
|
|
3072
|
-
// `websocket` handlers are one shared dispatcher
|
|
2996
|
+
// `websocket` handlers are one shared dispatcher - each connection's `ws.data.handler` is the
|
|
3073
2997
|
// matched route's handler, set by `server.upgrade`.
|
|
3074
|
-
// With WS routes, the dispatcher comes from the installed `.use(websocket())` runtime
|
|
2998
|
+
// With WS routes, the dispatcher comes from the installed `.use(websocket())` runtime - non-null
|
|
3075
2999
|
// because wsRouteCount > 0 means ws() ran, and ws() requires the runtime at registration.
|
|
3076
3000
|
const wsHandlers =
|
|
3077
3001
|
this.wsRouteCount === 0
|
|
@@ -3116,7 +3040,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3116
3040
|
|
|
3117
3041
|
/**
|
|
3118
3042
|
* Gracefully stop: wait for in-flight requests to finish (up to `drainMs`), then
|
|
3119
|
-
* issue a single terminal stop
|
|
3043
|
+
* issue a single terminal stop - graceful if everything drained, forced if
|
|
3120
3044
|
* stragglers remain. Safe to call when not listening.
|
|
3121
3045
|
*
|
|
3122
3046
|
* The Bun semantics: poll `pendingRequests` (awaiting
|
|
@@ -3137,7 +3061,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3137
3061
|
}
|
|
3138
3062
|
|
|
3139
3063
|
private installSignalHandlers(): void {
|
|
3140
|
-
// Drain, then let the process exit naturally
|
|
3064
|
+
// Drain, then let the process exit naturally - the stopped server no longer
|
|
3141
3065
|
// holds the event loop open. Opt-in (`gracefulSignals`), so taking over the
|
|
3142
3066
|
// signals is consented; we don't force `process.exit`.
|
|
3143
3067
|
const onSignal = (): void => {
|
|
@@ -3149,7 +3073,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3149
3073
|
}
|
|
3150
3074
|
|
|
3151
3075
|
/**
|
|
3152
|
-
* Create a new {@link Server}. Pass an `Env` to type the platform bindings
|
|
3076
|
+
* Create a new {@link Server}. Pass an `Env` to type the platform bindings - `server<Env>()` makes
|
|
3153
3077
|
* `c.env: Env` in every handler + middleware, and types the `env` argument of `app.fetch` /
|
|
3154
3078
|
* `toFetchHandler`. Omit it and `c.env` is `unknown` (validate/cast before use).
|
|
3155
3079
|
*/
|
|
@@ -3157,138 +3081,7 @@ export function server<Env = unknown>(
|
|
|
3157
3081
|
options?: ServerOptions,
|
|
3158
3082
|
): Server<EmptyRegistry, { readonly env: Env }> {
|
|
3159
3083
|
// `Env` is a phantom type-level marker: the runtime `env` arrives via `app.fetch(req, { env })` at
|
|
3160
|
-
// request time, not stored on the builder
|
|
3084
|
+
// request time, not stored on the builder - so seed the context type with a cast (as `derive`/
|
|
3161
3085
|
// `decorate` do for their `Ctx` extensions).
|
|
3162
3086
|
return new Server(options) as unknown as Server<EmptyRegistry, { readonly env: Env }>
|
|
3163
3087
|
}
|
|
3164
|
-
|
|
3165
|
-
/** A Cloudflare Workers-style execution context (the `fetch` 3rd arg). Structural — only
|
|
3166
|
-
* `waitUntil` is used; declared here so `@nifrajs/core` needs no Workers type dependency. */
|
|
3167
|
-
export interface ExecutionContext {
|
|
3168
|
-
waitUntil(promise: Promise<unknown>): void
|
|
3169
|
-
}
|
|
3170
|
-
|
|
3171
|
-
/** A Cloudflare Workers-style scheduled (cron) controller. Structural — no Workers type dependency. */
|
|
3172
|
-
export interface ScheduledController {
|
|
3173
|
-
/** Epoch ms the run was scheduled for. */
|
|
3174
|
-
readonly scheduledTime: number
|
|
3175
|
-
/** The matching cron expression from `wrangler.toml` `[triggers]`. */
|
|
3176
|
-
readonly cron: string
|
|
3177
|
-
/** Tell the platform not to retry this run on failure. */
|
|
3178
|
-
noRetry(): void
|
|
3179
|
-
}
|
|
3180
|
-
|
|
3181
|
-
/** A nifra cron handler: the platform controller + the same typed `env`/`waitUntil` nifra threads into
|
|
3182
|
-
* request handlers. Schedule background work with `waitUntil` so it outlives the trigger. */
|
|
3183
|
-
export type ScheduledHandler<Env = unknown> = (
|
|
3184
|
-
controller: ScheduledController,
|
|
3185
|
-
context: { readonly env: Env; waitUntil(promise: Promise<unknown>): void },
|
|
3186
|
-
) => MaybePromise<void>
|
|
3187
|
-
|
|
3188
|
-
/**
|
|
3189
|
-
* Adapt a nifra app to an edge "ExportedHandler" — use it as a Cloudflare Workers (or any
|
|
3190
|
-
* `fetch(request, env, ctx)` runtime) default export. It threads `env` + `ctx.waitUntil` into the
|
|
3191
|
-
* nifra Context, so handlers read `c.env` and schedule background work via `c.waitUntil`:
|
|
3192
|
-
*
|
|
3193
|
-
* export default toFetchHandler(app)
|
|
3194
|
-
*
|
|
3195
|
-
* Pass `{ scheduled }` to also export a Workers cron handler (for a `[triggers]` schedule) — it
|
|
3196
|
-
* receives the platform controller plus the same typed `env`/`waitUntil`:
|
|
3197
|
-
*
|
|
3198
|
-
* export default toFetchHandler(app, {
|
|
3199
|
-
* scheduled: (controller, { env, waitUntil }) =>
|
|
3200
|
-
* waitUntil(env.KV.put("last-run", String(controller.scheduledTime))),
|
|
3201
|
-
* })
|
|
3202
|
-
*
|
|
3203
|
-
* No Workers-only deps — `app.fetch` stays a portable Web-standard handler; this is the thin
|
|
3204
|
-
* shim from the platform's 3-arg `fetch`/`scheduled` to it.
|
|
3205
|
-
*/
|
|
3206
|
-
/** Cloudflare's `WebSocketPair` — feature-detected (absent off Workers). Yields `{ 0: client, 1: server }`. */
|
|
3207
|
-
type WebSocketPairCtor = new () => {
|
|
3208
|
-
readonly 0: unknown
|
|
3209
|
-
readonly 1: StandardWebSocket & { accept(): void }
|
|
3210
|
-
}
|
|
3211
|
-
|
|
3212
|
-
/** Structural view of a Cloudflare Durable Object namespace binding — keeps `@cloudflare/workers-types`
|
|
3213
|
-
* out of `@nifrajs/core`. The real `DurableObjectNamespace` satisfies it. */
|
|
3214
|
-
export interface DurableObjectNamespaceLike {
|
|
3215
|
-
idFromName(name: string): unknown
|
|
3216
|
-
get(id: unknown): { fetch(request: Request): Promise<Response> }
|
|
3217
|
-
}
|
|
3218
|
-
|
|
3219
|
-
/** The single hub Durable Object id nifra routes WS upgrades to (one hub per app — see `@nifrajs/workers`). */
|
|
3220
|
-
const NIFRA_WS_HUB_ID = "nifra-ws-hub"
|
|
3221
|
-
|
|
3222
|
-
export function toFetchHandler<Env = unknown>(
|
|
3223
|
-
app: {
|
|
3224
|
-
fetch(request: Request, platform?: Platform<Env>): MaybePromise<Response>
|
|
3225
|
-
resolveWebSocketUpgrade?(
|
|
3226
|
-
request: Request,
|
|
3227
|
-
platform?: Platform<Env>,
|
|
3228
|
-
): MaybePromise<WebSocketUpgradeOutcome>
|
|
3229
|
-
},
|
|
3230
|
-
options?: {
|
|
3231
|
-
scheduled?: ScheduledHandler<Env>
|
|
3232
|
-
/**
|
|
3233
|
-
* Route WebSocket upgrades to a Durable Object that holds the connections and runs the app's
|
|
3234
|
-
* pub/sub — enabling cross-connection broadcast (`app.publish`) on Cloudflare Workers, where a
|
|
3235
|
-
* stateless isolate can't. Pass the DO namespace binding from `env`; pair with `@nifrajs/workers`'
|
|
3236
|
-
* `createWebSocketHub(app)` (the DO class) + a `wrangler.toml` binding. Without this, WS upgrades use
|
|
3237
|
-
* a per-connection `WebSocketPair` (no broadcast).
|
|
3238
|
-
*/
|
|
3239
|
-
webSocketHub?: (env: Env) => DurableObjectNamespaceLike
|
|
3240
|
-
},
|
|
3241
|
-
): {
|
|
3242
|
-
fetch(request: Request, env: Env, ctx: ExecutionContext): MaybePromise<Response>
|
|
3243
|
-
scheduled?(controller: ScheduledController, env: Env, ctx: ExecutionContext): MaybePromise<void>
|
|
3244
|
-
} {
|
|
3245
|
-
const scheduled = options?.scheduled
|
|
3246
|
-
const webSocketHub = options?.webSocketHub
|
|
3247
|
-
const resolveWs = app.resolveWebSocketUpgrade?.bind(app)
|
|
3248
|
-
return {
|
|
3249
|
-
fetch: (request, env, ctx) => {
|
|
3250
|
-
const platform: Platform<Env> = { env, waitUntil: (promise) => ctx.waitUntil(promise) }
|
|
3251
|
-
// WebSocket broadcast on Workers: route upgrades to the hub Durable Object (it holds the
|
|
3252
|
-
// connections + runs the app's pub/sub, so `app.publish` reaches every client). The hub itself
|
|
3253
|
-
// resolves the route and rejects non-WS paths. See `@nifrajs/workers`' `createWebSocketHub`.
|
|
3254
|
-
if (
|
|
3255
|
-
webSocketHub !== undefined &&
|
|
3256
|
-
request.headers.get("upgrade")?.toLowerCase() === "websocket"
|
|
3257
|
-
) {
|
|
3258
|
-
const ns = webSocketHub(env)
|
|
3259
|
-
return ns.get(ns.idFromName(NIFRA_WS_HUB_ID)).fetch(request)
|
|
3260
|
-
}
|
|
3261
|
-
// Workers WebSockets: a WS upgrade for a registered route becomes a `WebSocketPair` + a 101.
|
|
3262
|
-
// Feature-detected, so non-Workers edge runtimes (which lack `WebSocketPair` — e.g. Deno Deploy
|
|
3263
|
-
// uses `@nifrajs/deno`'s `Deno.upgradeWebSocket`) simply fall through to the normal `fetch`.
|
|
3264
|
-
const Pair = (globalThis as { WebSocketPair?: WebSocketPairCtor }).WebSocketPair
|
|
3265
|
-
if (resolveWs !== undefined && Pair !== undefined) {
|
|
3266
|
-
const accept = (outcome: WebSocketUpgradeOutcome): MaybePromise<Response> => {
|
|
3267
|
-
if (outcome.kind === "pass") return app.fetch(request, platform)
|
|
3268
|
-
if (outcome.kind === "reject") return outcome.response
|
|
3269
|
-
const pair = new Pair()
|
|
3270
|
-
const server = pair[1]
|
|
3271
|
-
server.accept()
|
|
3272
|
-
// The outcome carries the installed runtime's `attach`, so `toFetchHandler` (a standalone
|
|
3273
|
-
// function, no access to the app's private runtime) wires the socket without a static WS import.
|
|
3274
|
-
outcome.attach(server, outcome.handler, outcome.data, {
|
|
3275
|
-
openNow: true,
|
|
3276
|
-
pubsub: outcome.pubsub,
|
|
3277
|
-
})
|
|
3278
|
-
// `webSocket` is a Workers-only `ResponseInit` field (absent from the standard type), and a
|
|
3279
|
-
// 101 status is only valid on the Workers runtime — both gated by the `Pair` feature check.
|
|
3280
|
-
return new Response(null, { status: 101, webSocket: pair[0] } as unknown as ResponseInit)
|
|
3281
|
-
}
|
|
3282
|
-
const out = resolveWs(request, platform)
|
|
3283
|
-
return out instanceof Promise ? out.then(accept) : accept(out)
|
|
3284
|
-
}
|
|
3285
|
-
return app.fetch(request, platform)
|
|
3286
|
-
},
|
|
3287
|
-
...(scheduled !== undefined
|
|
3288
|
-
? {
|
|
3289
|
-
scheduled: (controller: ScheduledController, env: Env, ctx: ExecutionContext) =>
|
|
3290
|
-
scheduled(controller, { env, waitUntil: (promise) => ctx.waitUntil(promise) }),
|
|
3291
|
-
}
|
|
3292
|
-
: {}),
|
|
3293
|
-
}
|
|
3294
|
-
}
|