@nifrajs/core 1.7.0 → 1.9.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 +25 -1
- package/dist/assurance.d.ts +80 -0
- package/dist/assurance.d.ts.map +1 -0
- package/dist/assurance.js +162 -0
- package/dist/assurance.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/route-assurance.d.ts +36 -0
- package/dist/internal/route-assurance.d.ts.map +1 -0
- package/dist/internal/route-assurance.js +118 -0
- package/dist/internal/route-assurance.js.map +1 -0
- package/dist/reflection.d.ts +2 -0
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +12 -1
- package/dist/reflection.js.map +1 -1
- package/dist/server/context.d.ts +6 -0
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/registry.d.ts +14 -8
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/server.d.ts +32 -0
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +332 -61
- package/dist/server/server.js.map +1 -1
- package/package.json +9 -1
- package/src/assurance.ts +263 -0
- package/src/index.ts +2 -1
- package/src/internal/route-assurance.ts +166 -0
- package/src/reflection.ts +13 -1
- package/src/server/context.ts +6 -0
- package/src/server/registry.ts +14 -8
- package/src/server/server.ts +521 -65
package/src/server/server.ts
CHANGED
|
@@ -1,4 +1,19 @@
|
|
|
1
|
+
import {
|
|
2
|
+
admitDeadline,
|
|
3
|
+
createRequestBudget,
|
|
4
|
+
createUnboundedRequestBudget,
|
|
5
|
+
type DeadlineAdmissionOptions,
|
|
6
|
+
NIFRA_DEADLINE_HEADER,
|
|
7
|
+
type RequestBudget,
|
|
8
|
+
} from "@nifrajs/budget"
|
|
1
9
|
import { FrameworkError, RouteConfigError } from "../errors.ts"
|
|
10
|
+
import {
|
|
11
|
+
type AssuranceDeclaration,
|
|
12
|
+
type AssuranceEvidence,
|
|
13
|
+
assuranceDeclarationsOf,
|
|
14
|
+
assuranceEvidenceFor,
|
|
15
|
+
NIFRA_ASSURANCE_IDS,
|
|
16
|
+
} from "../internal/route-assurance.ts"
|
|
2
17
|
import { EMPTY_PARAMS, type Method, Router } from "../router/router.ts"
|
|
3
18
|
import type {
|
|
4
19
|
InferOutput,
|
|
@@ -108,6 +123,7 @@ interface RawContext {
|
|
|
108
123
|
readonly [CONTEXT_SET]: () => CtxSet | undefined
|
|
109
124
|
readonly [CONTEXT_SEARCH]: string
|
|
110
125
|
readonly signal: AbortSignal
|
|
126
|
+
readonly budget: RequestBudget
|
|
111
127
|
readonly env: unknown
|
|
112
128
|
readonly waitUntil: (promise: Promise<unknown>) => void
|
|
113
129
|
readonly boundedBody: (maxBytes?: number) => Promise<Uint8Array>
|
|
@@ -185,9 +201,11 @@ interface RouteEntry {
|
|
|
185
201
|
type FusedWebRunner = (
|
|
186
202
|
source: RequestSource,
|
|
187
203
|
params: Record<string, string>,
|
|
188
|
-
search: string,
|
|
204
|
+
search: string | undefined,
|
|
189
205
|
signal: AbortSignal,
|
|
206
|
+
budget: RequestBudget,
|
|
190
207
|
platform: Platform | undefined,
|
|
208
|
+
nativeContext: boolean,
|
|
191
209
|
) => MaybePromise<Response>
|
|
192
210
|
|
|
193
211
|
/** A registered WebSocket route — just its handler; matching reuses {@link Router} under the GET verb. */
|
|
@@ -200,6 +218,11 @@ interface BunUpgradeServer {
|
|
|
200
218
|
upgrade(request: Request, options?: { data?: BunWsData }): boolean
|
|
201
219
|
}
|
|
202
220
|
|
|
221
|
+
type BunNativeHandler = (request: Request) => MaybePromise<Response>
|
|
222
|
+
type BunNativeMethodTable = Partial<Record<Method, BunNativeHandler>>
|
|
223
|
+
type BunNativeRoutes = Record<string, BunNativeMethodTable>
|
|
224
|
+
type BunRequestWithParams = Request & { readonly params?: Record<string, string> }
|
|
225
|
+
|
|
203
226
|
const WS_PASS: WebSocketUpgradeOutcome = { kind: "pass" }
|
|
204
227
|
|
|
205
228
|
/** `app.ws()` (and everything downstream of it) needs the runtime `@nifrajs/core/ws` registers. */
|
|
@@ -248,6 +271,18 @@ export interface ServerOptions {
|
|
|
248
271
|
readonly wsMaxPayloadBytes?: number
|
|
249
272
|
/** Per-request timeout (ms): a slower request gets a 503 and `ctx.signal` aborts. 0 disables (default). */
|
|
250
273
|
readonly requestTimeoutMs?: number
|
|
274
|
+
/**
|
|
275
|
+
* Admit the public `x-nifra-deadline` header and let it shorten local work. Disabled by default:
|
|
276
|
+
* services that participate in trusted cross-service deadline propagation opt in explicitly,
|
|
277
|
+
* while ordinary/public HTTP routes pay no header-admission tax and ignore hostile client values.
|
|
278
|
+
*/
|
|
279
|
+
readonly acceptInboundDeadlines?: boolean
|
|
280
|
+
/**
|
|
281
|
+
* Maximum time accepted from an inbound `x-nifra-deadline`, in milliseconds. An incoming absolute
|
|
282
|
+
* deadline may shorten this cap (and `requestTimeoutMs`, when configured), never extend it.
|
|
283
|
+
* Default 30_000; applies only when `acceptInboundDeadlines` is enabled and the header is present.
|
|
284
|
+
*/
|
|
285
|
+
readonly maxInboundDeadlineMs?: number
|
|
251
286
|
/** When `listen()`ing, install SIGTERM/SIGINT handlers that gracefully `stop()`. Default false. */
|
|
252
287
|
readonly gracefulSignals?: boolean
|
|
253
288
|
/** Structured logger for framework events (redacts secrets). Default: JSON to stderr. */
|
|
@@ -289,6 +324,8 @@ export interface RouteDescriptor {
|
|
|
289
324
|
readonly method: Method
|
|
290
325
|
readonly path: string
|
|
291
326
|
readonly schema: RouteSchema | undefined
|
|
327
|
+
/** Effective enforcement evidence, populated only during reflection/introspection. */
|
|
328
|
+
readonly assurance?: readonly AssuranceEvidence[]
|
|
292
329
|
readonly tool?: {
|
|
293
330
|
readonly name: string
|
|
294
331
|
readonly description: string
|
|
@@ -466,10 +503,15 @@ const TEXT_DECODER = new TextDecoder()
|
|
|
466
503
|
* reused at zero per-request cost.
|
|
467
504
|
*/
|
|
468
505
|
let neverAbortSignal: AbortSignal | undefined
|
|
506
|
+
let unboundedRequestBudget: RequestBudget | undefined
|
|
469
507
|
const getNeverAbortSignal = (): AbortSignal => {
|
|
470
508
|
neverAbortSignal ??= new AbortController().signal
|
|
471
509
|
return neverAbortSignal
|
|
472
510
|
}
|
|
511
|
+
const getUnboundedRequestBudget = (): RequestBudget => {
|
|
512
|
+
unboundedRequestBudget ??= createUnboundedRequestBudget(getNeverAbortSignal())
|
|
513
|
+
return unboundedRequestBudget
|
|
514
|
+
}
|
|
473
515
|
|
|
474
516
|
function jsonError(status: number, error: string, headers?: Record<string, string>): Response {
|
|
475
517
|
return Response.json(
|
|
@@ -670,6 +712,13 @@ function decodeParams(raw: Record<string, string>): Record<string, string> | nul
|
|
|
670
712
|
return out ?? raw
|
|
671
713
|
}
|
|
672
714
|
|
|
715
|
+
function hasReplacementParam(params: Record<string, string>): boolean {
|
|
716
|
+
for (const key in params) {
|
|
717
|
+
if (params[key]!.includes("\uFFFD")) return true
|
|
718
|
+
}
|
|
719
|
+
return false
|
|
720
|
+
}
|
|
721
|
+
|
|
673
722
|
/** `ctx.set` carrying the lazy backings (`_headers`, `_cookies`) so `toResponse` can skip allocating
|
|
674
723
|
* anything when no handler touched `c.set.*`. Server-internal. */
|
|
675
724
|
type CtxSet = ResponseControls & {
|
|
@@ -733,51 +782,92 @@ function statusInit(init?: ResponseInit | number): ResponseInit | undefined {
|
|
|
733
782
|
}
|
|
734
783
|
|
|
735
784
|
class RequestContext implements RawContext {
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
readonly
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
private
|
|
744
|
-
|
|
745
|
-
private
|
|
746
|
-
private
|
|
747
|
-
private
|
|
748
|
-
private
|
|
749
|
-
|
|
785
|
+
// `declare` keeps TypeScript's class-field emit from first writing `undefined` to every slot; the
|
|
786
|
+
// constructor initializes only the eager request state, while lazy fields remain absent until used.
|
|
787
|
+
declare readonly params: Record<string, string>
|
|
788
|
+
declare body: unknown
|
|
789
|
+
private declare searchValue: string | undefined
|
|
790
|
+
private declare signalValue: AbortSignal | undefined
|
|
791
|
+
private declare budgetValue: RequestBudget | undefined
|
|
792
|
+
private declare platformValue: Platform | undefined
|
|
793
|
+
|
|
794
|
+
private declare setValue: CtxSet | undefined
|
|
795
|
+
private declare queryValue: unknown
|
|
796
|
+
private declare queryReady: boolean
|
|
797
|
+
private declare cookiesValue: Readonly<Record<string, string>> | undefined
|
|
798
|
+
private declare readonly source: RequestSource
|
|
799
|
+
private declare readonly maxBodyBytes: number
|
|
800
|
+
|
|
801
|
+
constructor(source: RequestSource, params: Record<string, string>, maxBodyBytes: number)
|
|
750
802
|
constructor(
|
|
751
803
|
source: RequestSource,
|
|
752
804
|
params: Record<string, string>,
|
|
753
|
-
search: string,
|
|
805
|
+
search: string | undefined,
|
|
754
806
|
signal: AbortSignal,
|
|
807
|
+
budget: RequestBudget,
|
|
755
808
|
platform: Platform | undefined,
|
|
756
809
|
maxBodyBytes: number,
|
|
810
|
+
)
|
|
811
|
+
constructor(
|
|
812
|
+
source: RequestSource,
|
|
813
|
+
params: Record<string, string>,
|
|
814
|
+
searchOrMaxBodyBytes: string | number | undefined,
|
|
815
|
+
signal?: AbortSignal,
|
|
816
|
+
budget?: RequestBudget,
|
|
817
|
+
platform?: Platform,
|
|
818
|
+
maxBodyBytes?: number,
|
|
757
819
|
) {
|
|
758
820
|
this.source = source
|
|
759
821
|
this.params = params
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
this.
|
|
765
|
-
this.
|
|
766
|
-
this.
|
|
767
|
-
this.
|
|
768
|
-
this.
|
|
769
|
-
|
|
822
|
+
if (typeof searchOrMaxBodyBytes === "number") {
|
|
823
|
+
this.maxBodyBytes = searchOrMaxBodyBytes
|
|
824
|
+
return
|
|
825
|
+
}
|
|
826
|
+
if (searchOrMaxBodyBytes !== undefined) this.searchValue = searchOrMaxBodyBytes
|
|
827
|
+
this.signalValue = signal
|
|
828
|
+
this.budgetValue = budget
|
|
829
|
+
if (platform !== undefined) this.platformValue = platform
|
|
830
|
+
this.maxBodyBytes = maxBodyBytes as number
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
static native(
|
|
834
|
+
source: RequestSource,
|
|
835
|
+
params: Record<string, string>,
|
|
836
|
+
maxBodyBytes: number,
|
|
837
|
+
): RequestContext {
|
|
838
|
+
return new RequestContext(source, params, maxBodyBytes)
|
|
770
839
|
}
|
|
771
840
|
|
|
772
841
|
[CONTEXT_SET](): CtxSet | undefined {
|
|
773
842
|
return this.setValue
|
|
774
843
|
}
|
|
775
844
|
|
|
845
|
+
get [CONTEXT_SEARCH](): string {
|
|
846
|
+
this.searchValue ??= searchOf(this.source.url)
|
|
847
|
+
return this.searchValue
|
|
848
|
+
}
|
|
849
|
+
|
|
776
850
|
get set(): CtxSet {
|
|
777
851
|
this.setValue ??= new LazyResponseControls()
|
|
778
852
|
return this.setValue
|
|
779
853
|
}
|
|
780
854
|
|
|
855
|
+
get signal(): AbortSignal {
|
|
856
|
+
return this.signalValue ?? getNeverAbortSignal()
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
get budget(): RequestBudget {
|
|
860
|
+
return this.budgetValue ?? getUnboundedRequestBudget()
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
get env(): unknown {
|
|
864
|
+
return this.platformValue?.env
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
get waitUntil(): (promise: Promise<unknown>) => void {
|
|
868
|
+
return this.platformValue?.waitUntil ?? fallbackWaitUntil
|
|
869
|
+
}
|
|
870
|
+
|
|
781
871
|
get req(): Request {
|
|
782
872
|
return requestOf(this.source)
|
|
783
873
|
}
|
|
@@ -913,8 +1003,9 @@ const JSON_CT_HEADERS = new Headers({
|
|
|
913
1003
|
})
|
|
914
1004
|
const JSON_INIT_200: ResponseInit = { status: 200, headers: JSON_CT_HEADERS }
|
|
915
1005
|
|
|
916
|
-
/** Fused-lane respond when
|
|
917
|
-
*
|
|
1006
|
+
/** Fused-lane respond when `c.set` is untouched. Bun 1.3's native `Response.json` now beats the
|
|
1007
|
+
* older hand-inlined stringify + Response construction on this lane while preserving the exact
|
|
1008
|
+
* body/content-type contract; keep the generic fallback for non-JSON values. */
|
|
918
1009
|
function fusedRespondNoSet(result: unknown): Response {
|
|
919
1010
|
if (
|
|
920
1011
|
result !== undefined &&
|
|
@@ -923,8 +1014,7 @@ function fusedRespondNoSet(result: unknown): Response {
|
|
|
923
1014
|
result !== null &&
|
|
924
1015
|
!isResponseResult(result)
|
|
925
1016
|
) {
|
|
926
|
-
|
|
927
|
-
if (body !== undefined) return new Response(body, JSON_INIT_200)
|
|
1017
|
+
return Response.json(result)
|
|
928
1018
|
}
|
|
929
1019
|
return toResponse(result as HandlerResult, EMPTY_RESPONSE_CONTROLS)
|
|
930
1020
|
}
|
|
@@ -1109,6 +1199,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1109
1199
|
private readonly maxBodyBytes: number
|
|
1110
1200
|
private readonly wsMaxPayloadBytes: number
|
|
1111
1201
|
private readonly requestTimeoutMs: number
|
|
1202
|
+
private readonly acceptInboundDeadlines: boolean
|
|
1203
|
+
private readonly maxInboundDeadlineMs: number
|
|
1204
|
+
private readonly deadlineAdmissionOptions: DeadlineAdmissionOptions
|
|
1112
1205
|
private readonly gracefulSignals: boolean
|
|
1113
1206
|
private readonly logger: Logger
|
|
1114
1207
|
/** App-wide validation-error fallback; a route's own `schema.onValidationError` takes precedence. */
|
|
@@ -1126,6 +1219,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1126
1219
|
private readonly responseRequests: WeakMap<Request, Request>
|
|
1127
1220
|
/** Names of plugins/middleware already applied via `use` — for idempotent dedupe. */
|
|
1128
1221
|
private readonly appliedPlugins: Set<string>
|
|
1222
|
+
/** Order-scoped evidence captured by routes registered after an assured plugin. */
|
|
1223
|
+
private readonly activeAssurance: AssuranceDeclaration[]
|
|
1224
|
+
/** App-wide evidence from global hooks; applies retroactively to every route. */
|
|
1225
|
+
private readonly globalAssurance: AssuranceDeclaration[]
|
|
1129
1226
|
/** App-declared MCP resources / prompts (via {@link resource} / {@link prompt}), read by `nifra mcp`. */
|
|
1130
1227
|
private readonly mcpResourceList: McpResourceDescriptor[]
|
|
1131
1228
|
private readonly mcpPromptList: McpPromptDescriptor[]
|
|
@@ -1140,6 +1237,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1140
1237
|
readonly path: string
|
|
1141
1238
|
readonly entry: RouteEntry
|
|
1142
1239
|
readonly descriptor: RouteDescriptor
|
|
1240
|
+
readonly assurance: readonly AssuranceDeclaration[]
|
|
1143
1241
|
}>
|
|
1144
1242
|
|
|
1145
1243
|
constructor(options: ServerOptions = {}) {
|
|
@@ -1151,6 +1249,18 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1151
1249
|
this.maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
|
|
1152
1250
|
this.wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? this.maxBodyBytes
|
|
1153
1251
|
this.requestTimeoutMs = options.requestTimeoutMs ?? 0
|
|
1252
|
+
this.acceptInboundDeadlines = options.acceptInboundDeadlines ?? false
|
|
1253
|
+
this.maxInboundDeadlineMs = options.maxInboundDeadlineMs ?? 30_000
|
|
1254
|
+
this.deadlineAdmissionOptions = Object.freeze({
|
|
1255
|
+
localTimeoutMs: this.requestTimeoutMs,
|
|
1256
|
+
maxInboundDeadlineMs: this.maxInboundDeadlineMs,
|
|
1257
|
+
})
|
|
1258
|
+
if (!Number.isFinite(this.requestTimeoutMs) || this.requestTimeoutMs < 0) {
|
|
1259
|
+
throw new RangeError("requestTimeoutMs must be a finite non-negative number")
|
|
1260
|
+
}
|
|
1261
|
+
if (!Number.isFinite(this.maxInboundDeadlineMs) || this.maxInboundDeadlineMs <= 0) {
|
|
1262
|
+
throw new RangeError("maxInboundDeadlineMs must be a finite positive number")
|
|
1263
|
+
}
|
|
1154
1264
|
this.gracefulSignals = options.gracefulSignals ?? false
|
|
1155
1265
|
this.logger = options.logger ?? jsonLogger()
|
|
1156
1266
|
this.defaultOnValidationError = options.onValidationError
|
|
@@ -1166,6 +1276,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1166
1276
|
this.onResponseFinalizedHooks = []
|
|
1167
1277
|
this.responseRequests = new WeakMap()
|
|
1168
1278
|
this.appliedPlugins = new Set()
|
|
1279
|
+
this.activeAssurance = []
|
|
1280
|
+
this.globalAssurance = []
|
|
1169
1281
|
this.mcpResourceList = []
|
|
1170
1282
|
this.mcpPromptList = []
|
|
1171
1283
|
this.rawEntries = []
|
|
@@ -1263,12 +1375,32 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1263
1375
|
if (this.appliedPlugins.has(name)) return this // idempotent: already applied
|
|
1264
1376
|
this.appliedPlugins.add(name)
|
|
1265
1377
|
}
|
|
1266
|
-
|
|
1378
|
+
const evidence = assuranceDeclarationsOf(arg)
|
|
1379
|
+
const pluginOnly = evidence.filter((item) => item.scope === "plugin")
|
|
1380
|
+
this.globalAssurance.push(...evidence.filter((item) => item.scope === "global"))
|
|
1381
|
+
this.activeAssurance.push(...evidence.filter((item) => item.scope === "subsequent"))
|
|
1382
|
+
this.activeAssurance.push(...pluginOnly)
|
|
1383
|
+
try {
|
|
1384
|
+
return arg(this)
|
|
1385
|
+
} finally {
|
|
1386
|
+
// Remove only this plugin's temporary evidence. Nested assured plugins may deliberately leave
|
|
1387
|
+
// subsequent evidence active, so truncating the whole array would lose real ordering semantics.
|
|
1388
|
+
for (const item of pluginOnly) {
|
|
1389
|
+
const index = this.activeAssurance.indexOf(item)
|
|
1390
|
+
if (index !== -1) this.activeAssurance.splice(index, 1)
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1267
1393
|
}
|
|
1268
1394
|
if (arg.name !== undefined) {
|
|
1269
1395
|
if (this.appliedPlugins.has(arg.name)) return this
|
|
1270
1396
|
this.appliedPlugins.add(arg.name)
|
|
1271
1397
|
}
|
|
1398
|
+
const evidence = assuranceDeclarationsOf(arg)
|
|
1399
|
+
if (evidence.some((item) => item.scope === "plugin")) {
|
|
1400
|
+
throw new Error('route assurance: scope "plugin" may only annotate a plugin function')
|
|
1401
|
+
}
|
|
1402
|
+
this.globalAssurance.push(...evidence.filter((item) => item.scope === "global"))
|
|
1403
|
+
this.activeAssurance.push(...evidence.filter((item) => item.scope === "subsequent"))
|
|
1272
1404
|
if (arg.onRequest !== undefined) this.onRequest(arg.onRequest)
|
|
1273
1405
|
if (arg.around !== undefined) this.around(arg.around)
|
|
1274
1406
|
if (arg.beforeHandle !== undefined) this.beforeHandle(arg.beforeHandle)
|
|
@@ -1633,7 +1765,23 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1633
1765
|
this.router.add(method, path, entry)
|
|
1634
1766
|
const descriptor: RouteDescriptor = { method, path, schema }
|
|
1635
1767
|
this.routeList.push(descriptor)
|
|
1636
|
-
|
|
1768
|
+
const routeAssurance: AssuranceDeclaration[] = [...this.activeAssurance]
|
|
1769
|
+
if (schema?.body !== undefined) {
|
|
1770
|
+
routeAssurance.push(
|
|
1771
|
+
Object.freeze({
|
|
1772
|
+
id: NIFRA_ASSURANCE_IDS.BODY_BOUNDED,
|
|
1773
|
+
source: "route-schema",
|
|
1774
|
+
scope: "plugin",
|
|
1775
|
+
}),
|
|
1776
|
+
)
|
|
1777
|
+
}
|
|
1778
|
+
this.rawEntries.push({
|
|
1779
|
+
method,
|
|
1780
|
+
path,
|
|
1781
|
+
entry,
|
|
1782
|
+
descriptor,
|
|
1783
|
+
assurance: Object.freeze(routeAssurance),
|
|
1784
|
+
})
|
|
1637
1785
|
}
|
|
1638
1786
|
|
|
1639
1787
|
/**
|
|
@@ -1663,14 +1811,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1663
1811
|
"merge() does not carry WebSocket routes — register .ws() routes on the parent server",
|
|
1664
1812
|
)
|
|
1665
1813
|
}
|
|
1666
|
-
for (const { method, path, entry, descriptor } of source.rawEntries) {
|
|
1814
|
+
for (const { method, path, entry, descriptor, assurance } of source.rawEntries) {
|
|
1667
1815
|
this.router.add(method, path, entry) // throws RouteConfigError on a duplicate — fail closed
|
|
1668
1816
|
this.routeList.push(descriptor) // shared ref on purpose: carries later .tool() tagging
|
|
1669
|
-
this.rawEntries.push({ method, path, entry, descriptor })
|
|
1817
|
+
this.rawEntries.push({ method, path, entry, descriptor, assurance })
|
|
1670
1818
|
}
|
|
1671
1819
|
this.onRequestHooks.push(...source.onRequestHooks)
|
|
1672
1820
|
this.onResponseHooks.push(...source.onResponseHooks)
|
|
1673
1821
|
this.onResponseFinalizedHooks.push(...source.onResponseFinalizedHooks)
|
|
1822
|
+
this.globalAssurance.push(...source.globalAssurance)
|
|
1674
1823
|
this.mcpResourceList.push(...source.mcpResourceList)
|
|
1675
1824
|
this.mcpPromptList.push(...source.mcpPromptList)
|
|
1676
1825
|
return this as unknown as Server<R & R2, Ctx>
|
|
@@ -1682,7 +1831,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1682
1831
|
* longer holds the original patterns.
|
|
1683
1832
|
*/
|
|
1684
1833
|
routes(): ReadonlyArray<RouteDescriptor> {
|
|
1685
|
-
|
|
1834
|
+
if (this.activeAssurance.length === 0 && this.globalAssurance.length === 0) {
|
|
1835
|
+
const anyRouteEvidence = this.rawEntries.some((entry) => entry.assurance.length > 0)
|
|
1836
|
+
if (!anyRouteEvidence) return this.routeList
|
|
1837
|
+
}
|
|
1838
|
+
return this.rawEntries.map(({ method, path, descriptor, assurance }) => {
|
|
1839
|
+
const effective = assuranceEvidenceFor([...assurance, ...this.globalAssurance], method, path)
|
|
1840
|
+
return effective.length > 0 ? { ...descriptor, assurance: effective } : descriptor
|
|
1841
|
+
})
|
|
1686
1842
|
}
|
|
1687
1843
|
|
|
1688
1844
|
/**
|
|
@@ -1724,6 +1880,32 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1724
1880
|
: this.applyOnResponseAndFinalize(outcome, this.takeResponseRequest(source))
|
|
1725
1881
|
}
|
|
1726
1882
|
|
|
1883
|
+
/** Web response path when Bun already matched the route. The lifecycle and response hooks remain
|
|
1884
|
+
* exactly the same as {@link fetchSource}; only portable URL scanning + trie lookup are skipped. */
|
|
1885
|
+
private fetchMatched(
|
|
1886
|
+
source: RequestSource,
|
|
1887
|
+
entry: RouteEntry,
|
|
1888
|
+
params: Record<string, string>,
|
|
1889
|
+
): MaybePromise<Response> {
|
|
1890
|
+
const outcome = this.runMatched(
|
|
1891
|
+
source,
|
|
1892
|
+
undefined,
|
|
1893
|
+
entry,
|
|
1894
|
+
params,
|
|
1895
|
+
undefined,
|
|
1896
|
+
toResponse,
|
|
1897
|
+
IDENTITY_RESPONSE,
|
|
1898
|
+
RESPONSE_TIMEOUT,
|
|
1899
|
+
true,
|
|
1900
|
+
)
|
|
1901
|
+
if (this.onResponseHooks.length === 0 && this.onResponseFinalizedHooks.length === 0) {
|
|
1902
|
+
return outcome
|
|
1903
|
+
}
|
|
1904
|
+
return outcome instanceof Promise
|
|
1905
|
+
? outcome.then((response) => this.applyOnResponseAndFinalize(response, requestOf(source)))
|
|
1906
|
+
: this.applyOnResponseAndFinalize(outcome, requestOf(source))
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1727
1909
|
/**
|
|
1728
1910
|
* Like {@link fetch}, but renders a plain-data result **without** building a Web `Response` — the
|
|
1729
1911
|
* `@nifrajs/node` adapter serializes the returned primitives straight to the socket, skipping the undici
|
|
@@ -1778,10 +1960,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1778
1960
|
const url = urlPartsOf(req.url)
|
|
1779
1961
|
const match = this.wsRouter.find("GET", url.pathname)
|
|
1780
1962
|
if (!match.found) return WS_PASS // upgrade header, no WS route here → normal routing decides
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
: decodeParams(match.params)
|
|
1963
|
+
// Inspect only captured values for escapes. Scanning the full pathname repeated work the router
|
|
1964
|
+
// already did and made every plain dynamic route pay for unrelated static path bytes.
|
|
1965
|
+
const params = match.params === EMPTY_PARAMS ? match.params : decodeParams(match.params)
|
|
1785
1966
|
if (params === null) return { kind: "reject", response: jsonError(400, "malformed_path") }
|
|
1786
1967
|
const handler = match.payload.handler
|
|
1787
1968
|
// Non-null: wsRouteCount > 0 ⇒ ws() ran ⇒ the runtime created the registry.
|
|
@@ -1806,11 +1987,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1806
1987
|
if (handler.upgrade === undefined) {
|
|
1807
1988
|
return { kind: "upgrade", handler, data: undefined, pubsub }
|
|
1808
1989
|
}
|
|
1990
|
+
const upgradeSignal = getNeverAbortSignal()
|
|
1809
1991
|
const ctx = new RequestContext(
|
|
1810
1992
|
req,
|
|
1811
1993
|
params,
|
|
1812
1994
|
url.search,
|
|
1813
|
-
|
|
1995
|
+
upgradeSignal,
|
|
1996
|
+
createUnboundedRequestBudget(upgradeSignal),
|
|
1814
1997
|
platform,
|
|
1815
1998
|
this.maxBodyBytes,
|
|
1816
1999
|
)
|
|
@@ -1982,38 +2165,95 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1982
2165
|
return wrapResponse(jsonError(404, "not_found"))
|
|
1983
2166
|
}
|
|
1984
2167
|
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
: decodeParams(match.params)
|
|
2168
|
+
// Inspect only captured values for escapes. Scanning the full pathname repeated work the router
|
|
2169
|
+
// already did and made every plain dynamic route pay for unrelated static path bytes.
|
|
2170
|
+
const params = match.params === EMPTY_PARAMS ? match.params : decodeParams(match.params)
|
|
1989
2171
|
if (params === null) {
|
|
1990
2172
|
return wrapResponse(jsonError(400, "malformed_path"))
|
|
1991
2173
|
}
|
|
1992
2174
|
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
2175
|
+
return this.runMatched(
|
|
2176
|
+
source,
|
|
2177
|
+
platform,
|
|
2178
|
+
match.payload,
|
|
2179
|
+
params,
|
|
2180
|
+
url.search,
|
|
2181
|
+
finalize,
|
|
2182
|
+
wrapResponse,
|
|
2183
|
+
onTimeout,
|
|
2184
|
+
webFast,
|
|
2185
|
+
)
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
/** Run a route that has already been matched by the runtime or Nifra's portable router. */
|
|
2189
|
+
private runMatched<T>(
|
|
2190
|
+
source: RequestSource,
|
|
2191
|
+
platform: Platform | undefined,
|
|
2192
|
+
entry: RouteEntry,
|
|
2193
|
+
params: Record<string, string>,
|
|
2194
|
+
search: string | undefined,
|
|
2195
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
2196
|
+
wrapResponse: (response: Response) => T,
|
|
2197
|
+
onTimeout: () => T,
|
|
2198
|
+
webFast: boolean,
|
|
2199
|
+
): MaybePromise<T> {
|
|
2200
|
+
// Translate the absolute wire deadline once, clamp it to local policy, then use the resulting
|
|
2201
|
+
// duration for both c.signal and c.budget. A client can only shorten work, never extend it.
|
|
2202
|
+
// Most requests have neither a local timeout nor a propagated deadline. Detect that case with
|
|
2203
|
+
// one header lookup and skip policy validation, wall-clock sampling, and admission objects. A
|
|
2204
|
+
// present wire deadline still goes through the full fail-closed parser/clamp below.
|
|
2205
|
+
const admission = !this.acceptInboundDeadlines
|
|
2206
|
+
? this.requestTimeoutMs === 0
|
|
2207
|
+
? undefined
|
|
2208
|
+
: {
|
|
2209
|
+
ok: true as const,
|
|
2210
|
+
inherited: false,
|
|
2211
|
+
timeoutMs: this.requestTimeoutMs,
|
|
2212
|
+
deadline: Math.floor(Date.now() + this.requestTimeoutMs),
|
|
2213
|
+
}
|
|
2214
|
+
: this.requestTimeoutMs === 0 && headerOf(source, NIFRA_DEADLINE_HEADER) === null
|
|
2215
|
+
? undefined
|
|
2216
|
+
: admitDeadline(source.headers, this.deadlineAdmissionOptions)
|
|
2217
|
+
if (admission !== undefined && !admission.ok) {
|
|
2218
|
+
return wrapResponse(jsonError(admission.status, admission.reason))
|
|
2219
|
+
}
|
|
2220
|
+
const effectiveTimeoutMs = admission?.timeoutMs ?? 0
|
|
2221
|
+
|
|
2222
|
+
// Only allocate a controller for a finite budget; the historical no-timeout path remains
|
|
2223
|
+
// allocation-light and exposes an unbounded budget that is never propagated on the wire.
|
|
1997
2224
|
let controller: AbortController | undefined
|
|
1998
2225
|
let signal = getNeverAbortSignal()
|
|
1999
|
-
if (
|
|
2226
|
+
if (effectiveTimeoutMs > 0) {
|
|
2000
2227
|
controller = new AbortController()
|
|
2001
2228
|
signal = controller.signal
|
|
2002
2229
|
}
|
|
2230
|
+
const budget =
|
|
2231
|
+
controller === undefined
|
|
2232
|
+
? getUnboundedRequestBudget()
|
|
2233
|
+
: createRequestBudget({ deadline: admission!.deadline as number, signal })
|
|
2003
2234
|
// A bare route runs synchronously (no schema/derives/hooks); everything else keeps the full async
|
|
2004
2235
|
// lifecycle. `runBare` returns a value directly for a sync handler — no promise.
|
|
2005
2236
|
let outcome: MaybePromise<T>
|
|
2006
2237
|
if (webFast && entry.fusedWeb !== undefined) {
|
|
2007
2238
|
// Fused Web lane (bare route, no around): one closure end to end. Sound cast — webFast is
|
|
2008
2239
|
// passed only by the `fetch` path, where T = Response by construction.
|
|
2009
|
-
outcome = entry.fusedWeb(
|
|
2240
|
+
outcome = entry.fusedWeb(
|
|
2241
|
+
source,
|
|
2242
|
+
params,
|
|
2243
|
+
search,
|
|
2244
|
+
signal,
|
|
2245
|
+
budget,
|
|
2246
|
+
platform,
|
|
2247
|
+
false,
|
|
2248
|
+
) as MaybePromise<T>
|
|
2010
2249
|
} else if (entry.contextlessBare) {
|
|
2011
2250
|
outcome = this.runContextlessBare(
|
|
2012
2251
|
entry,
|
|
2013
2252
|
source,
|
|
2014
2253
|
params,
|
|
2015
|
-
|
|
2254
|
+
search,
|
|
2016
2255
|
signal,
|
|
2256
|
+
budget,
|
|
2017
2257
|
platform,
|
|
2018
2258
|
finalize,
|
|
2019
2259
|
wrapResponse,
|
|
@@ -2024,8 +2264,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2024
2264
|
const ctx = new RequestContext(
|
|
2025
2265
|
source,
|
|
2026
2266
|
params,
|
|
2027
|
-
|
|
2267
|
+
search,
|
|
2028
2268
|
signal,
|
|
2269
|
+
budget,
|
|
2029
2270
|
platform,
|
|
2030
2271
|
this.maxBodyBytes,
|
|
2031
2272
|
)
|
|
@@ -2058,7 +2299,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2058
2299
|
// The request timeout only bounds work that is actually pending — a synchronous (bare) result is
|
|
2059
2300
|
// already complete and can't time out, so it's returned as-is (no 503 race, no promise).
|
|
2060
2301
|
if (controller !== undefined && outcome instanceof Promise) {
|
|
2061
|
-
|
|
2302
|
+
const timedOut =
|
|
2303
|
+
admission?.inherited === true
|
|
2304
|
+
? () => wrapResponse(jsonError(504, "deadline_exceeded"))
|
|
2305
|
+
: onTimeout
|
|
2306
|
+
return this.withTimeout(
|
|
2307
|
+
outcome,
|
|
2308
|
+
controller,
|
|
2309
|
+
timedOut,
|
|
2310
|
+
Math.max(0, Math.ceil(budget.remaining())),
|
|
2311
|
+
)
|
|
2062
2312
|
}
|
|
2063
2313
|
return outcome
|
|
2064
2314
|
}
|
|
@@ -2069,8 +2319,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2069
2319
|
entry: RouteEntry,
|
|
2070
2320
|
source: RequestSource,
|
|
2071
2321
|
params: Record<string, string>,
|
|
2072
|
-
search: string,
|
|
2322
|
+
search: string | undefined,
|
|
2073
2323
|
signal: AbortSignal,
|
|
2324
|
+
budget: RequestBudget,
|
|
2074
2325
|
platform: Platform | undefined,
|
|
2075
2326
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
2076
2327
|
wrapResponse: (response: Response) => T,
|
|
@@ -2079,13 +2330,31 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2079
2330
|
try {
|
|
2080
2331
|
result = (entry.handler as unknown as ContextlessHandler)()
|
|
2081
2332
|
} catch (err) {
|
|
2082
|
-
return this.contextlessBareError(
|
|
2333
|
+
return this.contextlessBareError(
|
|
2334
|
+
err,
|
|
2335
|
+
source,
|
|
2336
|
+
params,
|
|
2337
|
+
search,
|
|
2338
|
+
signal,
|
|
2339
|
+
budget,
|
|
2340
|
+
platform,
|
|
2341
|
+
wrapResponse,
|
|
2342
|
+
)
|
|
2083
2343
|
}
|
|
2084
2344
|
if (result instanceof Promise) {
|
|
2085
2345
|
return result.then(
|
|
2086
2346
|
(value) => finalize(value, EMPTY_RESPONSE_CONTROLS),
|
|
2087
2347
|
(err) =>
|
|
2088
|
-
this.contextlessBareError(
|
|
2348
|
+
this.contextlessBareError(
|
|
2349
|
+
err,
|
|
2350
|
+
source,
|
|
2351
|
+
params,
|
|
2352
|
+
search,
|
|
2353
|
+
signal,
|
|
2354
|
+
budget,
|
|
2355
|
+
platform,
|
|
2356
|
+
wrapResponse,
|
|
2357
|
+
),
|
|
2089
2358
|
)
|
|
2090
2359
|
}
|
|
2091
2360
|
return finalize(result, EMPTY_RESPONSE_CONTROLS)
|
|
@@ -2095,13 +2364,22 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2095
2364
|
err: unknown,
|
|
2096
2365
|
source: RequestSource,
|
|
2097
2366
|
params: Record<string, string>,
|
|
2098
|
-
search: string,
|
|
2367
|
+
search: string | undefined,
|
|
2099
2368
|
signal: AbortSignal,
|
|
2369
|
+
budget: RequestBudget,
|
|
2100
2370
|
platform: Platform | undefined,
|
|
2101
2371
|
wrapResponse: (response: Response) => T,
|
|
2102
2372
|
): T {
|
|
2103
2373
|
if (err instanceof Response) return wrapResponse(err)
|
|
2104
|
-
const ctx = new RequestContext(
|
|
2374
|
+
const ctx = new RequestContext(
|
|
2375
|
+
source,
|
|
2376
|
+
params,
|
|
2377
|
+
search,
|
|
2378
|
+
signal,
|
|
2379
|
+
budget,
|
|
2380
|
+
platform,
|
|
2381
|
+
this.maxBodyBytes,
|
|
2382
|
+
)
|
|
2105
2383
|
this.logRequestError(err, ctx)
|
|
2106
2384
|
return wrapResponse(jsonError(500, "internal_error"))
|
|
2107
2385
|
}
|
|
@@ -2161,29 +2439,43 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2161
2439
|
// `() => ...` can't observe the context — skip allocating one entirely (errors still build
|
|
2162
2440
|
// one for the structured log, exactly like runContextlessBare).
|
|
2163
2441
|
const contextlessHandler = handler as unknown as ContextlessHandler
|
|
2164
|
-
return (source, params, search, signal, platform) => {
|
|
2442
|
+
return (source, params, search, signal, budget, platform, nativeContext) => {
|
|
2165
2443
|
let result: unknown
|
|
2166
2444
|
try {
|
|
2167
2445
|
result = contextlessHandler()
|
|
2168
2446
|
} catch (err) {
|
|
2169
2447
|
return logError(
|
|
2170
2448
|
err,
|
|
2171
|
-
|
|
2449
|
+
nativeContext
|
|
2450
|
+
? RequestContext.native(source, params, maxBodyBytes)
|
|
2451
|
+
: new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes),
|
|
2172
2452
|
)
|
|
2173
2453
|
}
|
|
2174
2454
|
if (result instanceof Promise) {
|
|
2175
2455
|
return result.then(fusedRespondNoSet, (err) =>
|
|
2176
2456
|
logError(
|
|
2177
2457
|
err,
|
|
2178
|
-
|
|
2458
|
+
nativeContext
|
|
2459
|
+
? RequestContext.native(source, params, maxBodyBytes)
|
|
2460
|
+
: new RequestContext(
|
|
2461
|
+
source,
|
|
2462
|
+
params,
|
|
2463
|
+
search,
|
|
2464
|
+
signal,
|
|
2465
|
+
budget,
|
|
2466
|
+
platform,
|
|
2467
|
+
maxBodyBytes,
|
|
2468
|
+
),
|
|
2179
2469
|
),
|
|
2180
2470
|
)
|
|
2181
2471
|
}
|
|
2182
2472
|
return fusedRespondNoSet(result)
|
|
2183
2473
|
}
|
|
2184
2474
|
}
|
|
2185
|
-
return (source, params, search, signal, platform) => {
|
|
2186
|
-
const ctx =
|
|
2475
|
+
return (source, params, search, signal, budget, platform, nativeContext) => {
|
|
2476
|
+
const ctx = nativeContext
|
|
2477
|
+
? RequestContext.native(source, params, maxBodyBytes)
|
|
2478
|
+
: new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes)
|
|
2187
2479
|
if (decorations !== undefined) Object.assign(ctx, decorations)
|
|
2188
2480
|
let result: unknown
|
|
2189
2481
|
try {
|
|
@@ -2628,13 +2920,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2628
2920
|
work: Promise<T>,
|
|
2629
2921
|
controller: AbortController,
|
|
2630
2922
|
onTimeout: () => T,
|
|
2923
|
+
timeoutMs: number,
|
|
2631
2924
|
): Promise<T> {
|
|
2632
2925
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
2633
2926
|
const timeout = new Promise<T>((resolve) => {
|
|
2634
2927
|
timer = setTimeout(() => {
|
|
2635
2928
|
controller.abort()
|
|
2636
2929
|
resolve(onTimeout())
|
|
2637
|
-
},
|
|
2930
|
+
}, timeoutMs)
|
|
2638
2931
|
})
|
|
2639
2932
|
try {
|
|
2640
2933
|
return await Promise.race([work, timeout])
|
|
@@ -2793,6 +3086,163 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2793
3086
|
return readBoundedJsonSource(req, this.maxBodyBytes)
|
|
2794
3087
|
}
|
|
2795
3088
|
|
|
3089
|
+
/** Compile portable route registrations into Bun's native route table. Apps with request-rewrite
|
|
3090
|
+
* hooks or WebSockets retain the single portable dispatcher because those features must run before
|
|
3091
|
+
* route selection/upgrade. Named wildcards also stay on the fallback until Bun exposes their raw
|
|
3092
|
+
* capture semantics; static and `:param` routes take the native lane. */
|
|
3093
|
+
private buildBunNativeRoutes(): BunNativeRoutes | undefined {
|
|
3094
|
+
if (this.onRequestHooks.length > 0 || this.wsRouteCount > 0) return undefined
|
|
3095
|
+
|
|
3096
|
+
const routes: BunNativeRoutes = Object.create(null) as BunNativeRoutes
|
|
3097
|
+
const mayUseFusedNative =
|
|
3098
|
+
this.requestTimeoutMs === 0 &&
|
|
3099
|
+
this.onResponseHooks.length === 0 &&
|
|
3100
|
+
this.onResponseFinalizedHooks.length === 0
|
|
3101
|
+
const unboundedSignal = mayUseFusedNative ? getNeverAbortSignal() : undefined
|
|
3102
|
+
const unboundedBudget = mayUseFusedNative ? getUnboundedRequestBudget() : undefined
|
|
3103
|
+
let count = 0
|
|
3104
|
+
for (const { method, path, entry } of this.rawEntries) {
|
|
3105
|
+
if (path.includes("*")) continue
|
|
3106
|
+
let methods = routes[path]
|
|
3107
|
+
if (methods === undefined) {
|
|
3108
|
+
methods = Object.create(null) as BunNativeMethodTable
|
|
3109
|
+
routes[path] = methods
|
|
3110
|
+
}
|
|
3111
|
+
const paramNames = path
|
|
3112
|
+
.split("/")
|
|
3113
|
+
.filter((segment) => segment.charCodeAt(0) === 58 /* : */)
|
|
3114
|
+
.map((segment) => segment.slice(1))
|
|
3115
|
+
const fused = mayUseFusedNative ? entry.fusedWeb : undefined
|
|
3116
|
+
|
|
3117
|
+
if (fused !== undefined) {
|
|
3118
|
+
// Deliberately choose deadline-aware vs zero-admission closures HERE instead of branching on
|
|
3119
|
+
// `acceptInboundDeadlines` per request. JSC can then flatten the default closure through the
|
|
3120
|
+
// handler + Response.json; retaining that route-invariant branch cost ~7% on a dynamic GET.
|
|
3121
|
+
if (paramNames.length === 0) {
|
|
3122
|
+
methods[method] = this.acceptInboundDeadlines
|
|
3123
|
+
? (request) =>
|
|
3124
|
+
request.headers.get(NIFRA_DEADLINE_HEADER) !== null
|
|
3125
|
+
? this.fetchMatched(request, entry, EMPTY_PARAMS)
|
|
3126
|
+
: fused(
|
|
3127
|
+
request,
|
|
3128
|
+
EMPTY_PARAMS,
|
|
3129
|
+
undefined,
|
|
3130
|
+
unboundedSignal as AbortSignal,
|
|
3131
|
+
unboundedBudget as RequestBudget,
|
|
3132
|
+
undefined,
|
|
3133
|
+
true,
|
|
3134
|
+
)
|
|
3135
|
+
: (request) =>
|
|
3136
|
+
fused(
|
|
3137
|
+
request,
|
|
3138
|
+
EMPTY_PARAMS,
|
|
3139
|
+
undefined,
|
|
3140
|
+
unboundedSignal as AbortSignal,
|
|
3141
|
+
unboundedBudget as RequestBudget,
|
|
3142
|
+
undefined,
|
|
3143
|
+
true,
|
|
3144
|
+
)
|
|
3145
|
+
} else if (paramNames.length === 1) {
|
|
3146
|
+
const paramName = paramNames[0]!
|
|
3147
|
+
const handler = entry.handler
|
|
3148
|
+
const decorations = entry.hasDecorations ? entry.decorations : undefined
|
|
3149
|
+
const fail = (error: unknown, ctx: RawContext): Response => {
|
|
3150
|
+
if (error instanceof Response) return error
|
|
3151
|
+
this.logRequestError(error, ctx)
|
|
3152
|
+
return jsonError(500, "internal_error")
|
|
3153
|
+
}
|
|
3154
|
+
methods[method] = this.acceptInboundDeadlines
|
|
3155
|
+
? (request) => {
|
|
3156
|
+
const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
|
|
3157
|
+
if (params[paramName]?.includes("\uFFFD") === true) return this.fetchSource(request)
|
|
3158
|
+
if (request.headers.get(NIFRA_DEADLINE_HEADER) !== null) {
|
|
3159
|
+
return this.fetchMatched(request, entry, params)
|
|
3160
|
+
}
|
|
3161
|
+
const ctx = RequestContext.native(request, params, this.maxBodyBytes)
|
|
3162
|
+
if (decorations !== undefined) Object.assign(ctx, decorations)
|
|
3163
|
+
let result: unknown
|
|
3164
|
+
try {
|
|
3165
|
+
result = handler(ctx)
|
|
3166
|
+
} catch (error) {
|
|
3167
|
+
return fail(error, ctx)
|
|
3168
|
+
}
|
|
3169
|
+
return result instanceof Promise
|
|
3170
|
+
? result.then(
|
|
3171
|
+
(value) => fusedRespond(value, ctx),
|
|
3172
|
+
(error) => fail(error, ctx),
|
|
3173
|
+
)
|
|
3174
|
+
: fusedRespond(result, ctx)
|
|
3175
|
+
}
|
|
3176
|
+
: (request) => {
|
|
3177
|
+
const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
|
|
3178
|
+
if (params[paramName]?.includes("\uFFFD") === true) return this.fetchSource(request)
|
|
3179
|
+
const ctx = RequestContext.native(request, params, this.maxBodyBytes)
|
|
3180
|
+
if (decorations !== undefined) Object.assign(ctx, decorations)
|
|
3181
|
+
let result: unknown
|
|
3182
|
+
try {
|
|
3183
|
+
result = handler(ctx)
|
|
3184
|
+
} catch (error) {
|
|
3185
|
+
return fail(error, ctx)
|
|
3186
|
+
}
|
|
3187
|
+
return result instanceof Promise
|
|
3188
|
+
? result.then(
|
|
3189
|
+
(value) => fusedRespond(value, ctx),
|
|
3190
|
+
(error) => fail(error, ctx),
|
|
3191
|
+
)
|
|
3192
|
+
: fusedRespond(result, ctx)
|
|
3193
|
+
}
|
|
3194
|
+
} else {
|
|
3195
|
+
methods[method] = this.acceptInboundDeadlines
|
|
3196
|
+
? (request) => {
|
|
3197
|
+
const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
|
|
3198
|
+
if (hasReplacementParam(params)) return this.fetchSource(request)
|
|
3199
|
+
return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
|
|
3200
|
+
? this.fetchMatched(request, entry, params)
|
|
3201
|
+
: fused(
|
|
3202
|
+
request,
|
|
3203
|
+
params,
|
|
3204
|
+
undefined,
|
|
3205
|
+
unboundedSignal as AbortSignal,
|
|
3206
|
+
unboundedBudget as RequestBudget,
|
|
3207
|
+
undefined,
|
|
3208
|
+
true,
|
|
3209
|
+
)
|
|
3210
|
+
}
|
|
3211
|
+
: (request) => {
|
|
3212
|
+
const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
|
|
3213
|
+
if (hasReplacementParam(params)) return this.fetchSource(request)
|
|
3214
|
+
return fused(
|
|
3215
|
+
request,
|
|
3216
|
+
params,
|
|
3217
|
+
undefined,
|
|
3218
|
+
unboundedSignal as AbortSignal,
|
|
3219
|
+
unboundedBudget as RequestBudget,
|
|
3220
|
+
undefined,
|
|
3221
|
+
true,
|
|
3222
|
+
)
|
|
3223
|
+
}
|
|
3224
|
+
}
|
|
3225
|
+
} else if (paramNames.length === 0) {
|
|
3226
|
+
methods[method] = (request) => this.fetchMatched(request, entry, EMPTY_PARAMS)
|
|
3227
|
+
} else if (paramNames.length === 1) {
|
|
3228
|
+
const paramName = paramNames[0]!
|
|
3229
|
+
methods[method] = (request) => {
|
|
3230
|
+
const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
|
|
3231
|
+
if (params[paramName]?.includes("\uFFFD") === true) return this.fetchSource(request)
|
|
3232
|
+
return this.fetchMatched(request, entry, params)
|
|
3233
|
+
}
|
|
3234
|
+
} else {
|
|
3235
|
+
methods[method] = (request) => {
|
|
3236
|
+
const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
|
|
3237
|
+
if (hasReplacementParam(params)) return this.fetchSource(request)
|
|
3238
|
+
return this.fetchMatched(request, entry, params)
|
|
3239
|
+
}
|
|
3240
|
+
}
|
|
3241
|
+
count += 1
|
|
3242
|
+
}
|
|
3243
|
+
return count === 0 ? undefined : routes
|
|
3244
|
+
}
|
|
3245
|
+
|
|
2796
3246
|
/**
|
|
2797
3247
|
* Start a `Bun.serve` instance bound to `port` (use `0` for an ephemeral port).
|
|
2798
3248
|
*
|
|
@@ -2829,8 +3279,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2829
3279
|
? undefined
|
|
2830
3280
|
: (getWsRuntime() as WsRuntime).bunHandlers(this.topics as TopicRegistry)
|
|
2831
3281
|
const reusePort = options?.reusePort === true
|
|
3282
|
+
const nativeRoutes = wsHandlers === undefined ? this.buildBunNativeRoutes() : undefined
|
|
2832
3283
|
const running = (wsHandlers === undefined
|
|
2833
|
-
? Bun.serve({
|
|
3284
|
+
? Bun.serve({
|
|
3285
|
+
port,
|
|
3286
|
+
reusePort,
|
|
3287
|
+
...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
|
|
3288
|
+
fetch: (req: Request) => this.fetch(req),
|
|
3289
|
+
})
|
|
2834
3290
|
: Bun.serve<BunWsData>({
|
|
2835
3291
|
port,
|
|
2836
3292
|
reusePort,
|