@nifrajs/core 1.8.0 → 1.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/server/context.d.ts +6 -0
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/server.d.ts +67 -0
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +339 -58
- package/dist/server/server.js.map +1 -1
- package/package.json +4 -1
- package/src/index.ts +3 -1
- package/src/server/context.ts +6 -0
- package/src/server/server.ts +562 -62
package/src/server/server.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
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"
|
|
2
10
|
import {
|
|
3
11
|
type AssuranceDeclaration,
|
|
@@ -115,6 +123,7 @@ interface RawContext {
|
|
|
115
123
|
readonly [CONTEXT_SET]: () => CtxSet | undefined
|
|
116
124
|
readonly [CONTEXT_SEARCH]: string
|
|
117
125
|
readonly signal: AbortSignal
|
|
126
|
+
readonly budget: RequestBudget
|
|
118
127
|
readonly env: unknown
|
|
119
128
|
readonly waitUntil: (promise: Promise<unknown>) => void
|
|
120
129
|
readonly boundedBody: (maxBytes?: number) => Promise<Uint8Array>
|
|
@@ -192,9 +201,11 @@ interface RouteEntry {
|
|
|
192
201
|
type FusedWebRunner = (
|
|
193
202
|
source: RequestSource,
|
|
194
203
|
params: Record<string, string>,
|
|
195
|
-
search: string,
|
|
204
|
+
search: string | undefined,
|
|
196
205
|
signal: AbortSignal,
|
|
206
|
+
budget: RequestBudget,
|
|
197
207
|
platform: Platform | undefined,
|
|
208
|
+
nativeContext: boolean,
|
|
198
209
|
) => MaybePromise<Response>
|
|
199
210
|
|
|
200
211
|
/** A registered WebSocket route — just its handler; matching reuses {@link Router} under the GET verb. */
|
|
@@ -207,6 +218,11 @@ interface BunUpgradeServer {
|
|
|
207
218
|
upgrade(request: Request, options?: { data?: BunWsData }): boolean
|
|
208
219
|
}
|
|
209
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
|
+
|
|
210
226
|
const WS_PASS: WebSocketUpgradeOutcome = { kind: "pass" }
|
|
211
227
|
|
|
212
228
|
/** `app.ws()` (and everything downstream of it) needs the runtime `@nifrajs/core/ws` registers. */
|
|
@@ -238,6 +254,27 @@ export type Handler<
|
|
|
238
254
|
Ctx = EmptyContext,
|
|
239
255
|
> = (ctx: Context<Path, S> & Ctx) => MaybePromise<ResponseOf<S>>
|
|
240
256
|
|
|
257
|
+
/**
|
|
258
|
+
* The outcome of a capacity-admission decision. `admitted` requests carry a `release` the server calls
|
|
259
|
+
* exactly once when the response is finalized; a shed request carries a ready `429` Response.
|
|
260
|
+
*/
|
|
261
|
+
export type AdmissionDecision =
|
|
262
|
+
| { readonly admitted: true; release(): void }
|
|
263
|
+
| { readonly admitted: false; readonly response: Response }
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* A capacity-admission gate. Decides, per request, whether the instance has capacity to run it now —
|
|
267
|
+
* bounding *concurrency*, which rate limits (frequency) and deadlines (duration) do not. Provide an
|
|
268
|
+
* implementation (see `@nifrajs/middleware`'s `createAdmissionController`) as {@link ServerOptions.admission}.
|
|
269
|
+
*
|
|
270
|
+
* Enabling it trades the fused native-route lane for native matching + this gate (the gate cannot be a
|
|
271
|
+
* per-request `onRequest` hook without disabling native routes entirely); when unset, the request path
|
|
272
|
+
* is untouched.
|
|
273
|
+
*/
|
|
274
|
+
export interface AdmissionController {
|
|
275
|
+
admit(req: Request): AdmissionDecision | Promise<AdmissionDecision>
|
|
276
|
+
}
|
|
277
|
+
|
|
241
278
|
export interface ServerOptions {
|
|
242
279
|
/**
|
|
243
280
|
* Max request body size (bytes), enforced **only when a route declares a body schema** — the cap
|
|
@@ -255,8 +292,27 @@ export interface ServerOptions {
|
|
|
255
292
|
readonly wsMaxPayloadBytes?: number
|
|
256
293
|
/** Per-request timeout (ms): a slower request gets a 503 and `ctx.signal` aborts. 0 disables (default). */
|
|
257
294
|
readonly requestTimeoutMs?: number
|
|
295
|
+
/**
|
|
296
|
+
* Admit the public `x-nifra-deadline` header and let it shorten local work. Disabled by default:
|
|
297
|
+
* services that participate in trusted cross-service deadline propagation opt in explicitly,
|
|
298
|
+
* while ordinary/public HTTP routes pay no header-admission tax and ignore hostile client values.
|
|
299
|
+
*/
|
|
300
|
+
readonly acceptInboundDeadlines?: boolean
|
|
301
|
+
/**
|
|
302
|
+
* Maximum time accepted from an inbound `x-nifra-deadline`, in milliseconds. An incoming absolute
|
|
303
|
+
* deadline may shorten this cap (and `requestTimeoutMs`, when configured), never extend it.
|
|
304
|
+
* Default 30_000; applies only when `acceptInboundDeadlines` is enabled and the header is present.
|
|
305
|
+
*/
|
|
306
|
+
readonly maxInboundDeadlineMs?: number
|
|
258
307
|
/** When `listen()`ing, install SIGTERM/SIGINT handlers that gracefully `stop()`. Default false. */
|
|
259
308
|
readonly gracefulSignals?: boolean
|
|
309
|
+
/**
|
|
310
|
+
* Capacity-admission gate (see {@link AdmissionController}). Bounds concurrent in-flight work so a
|
|
311
|
+
* healthy instance sheds load (`429` + `Retry-After`) instead of accepting more than it can finish.
|
|
312
|
+
* Off by default. Enabling it disables the fused native-route fast path (requests route through the
|
|
313
|
+
* shared matched lane where the gate runs); when unset, the request path pays nothing.
|
|
314
|
+
*/
|
|
315
|
+
readonly admission?: AdmissionController
|
|
260
316
|
/** Structured logger for framework events (redacts secrets). Default: JSON to stderr. */
|
|
261
317
|
readonly logger?: Logger
|
|
262
318
|
/**
|
|
@@ -475,10 +531,15 @@ const TEXT_DECODER = new TextDecoder()
|
|
|
475
531
|
* reused at zero per-request cost.
|
|
476
532
|
*/
|
|
477
533
|
let neverAbortSignal: AbortSignal | undefined
|
|
534
|
+
let unboundedRequestBudget: RequestBudget | undefined
|
|
478
535
|
const getNeverAbortSignal = (): AbortSignal => {
|
|
479
536
|
neverAbortSignal ??= new AbortController().signal
|
|
480
537
|
return neverAbortSignal
|
|
481
538
|
}
|
|
539
|
+
const getUnboundedRequestBudget = (): RequestBudget => {
|
|
540
|
+
unboundedRequestBudget ??= createUnboundedRequestBudget(getNeverAbortSignal())
|
|
541
|
+
return unboundedRequestBudget
|
|
542
|
+
}
|
|
482
543
|
|
|
483
544
|
function jsonError(status: number, error: string, headers?: Record<string, string>): Response {
|
|
484
545
|
return Response.json(
|
|
@@ -679,6 +740,13 @@ function decodeParams(raw: Record<string, string>): Record<string, string> | nul
|
|
|
679
740
|
return out ?? raw
|
|
680
741
|
}
|
|
681
742
|
|
|
743
|
+
function hasReplacementParam(params: Record<string, string>): boolean {
|
|
744
|
+
for (const key in params) {
|
|
745
|
+
if (params[key]!.includes("\uFFFD")) return true
|
|
746
|
+
}
|
|
747
|
+
return false
|
|
748
|
+
}
|
|
749
|
+
|
|
682
750
|
/** `ctx.set` carrying the lazy backings (`_headers`, `_cookies`) so `toResponse` can skip allocating
|
|
683
751
|
* anything when no handler touched `c.set.*`. Server-internal. */
|
|
684
752
|
type CtxSet = ResponseControls & {
|
|
@@ -742,51 +810,92 @@ function statusInit(init?: ResponseInit | number): ResponseInit | undefined {
|
|
|
742
810
|
}
|
|
743
811
|
|
|
744
812
|
class RequestContext implements RawContext {
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
readonly
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
private
|
|
753
|
-
|
|
754
|
-
private
|
|
755
|
-
private
|
|
756
|
-
private
|
|
757
|
-
private
|
|
758
|
-
|
|
813
|
+
// `declare` keeps TypeScript's class-field emit from first writing `undefined` to every slot; the
|
|
814
|
+
// constructor initializes only the eager request state, while lazy fields remain absent until used.
|
|
815
|
+
declare readonly params: Record<string, string>
|
|
816
|
+
declare body: unknown
|
|
817
|
+
private declare searchValue: string | undefined
|
|
818
|
+
private declare signalValue: AbortSignal | undefined
|
|
819
|
+
private declare budgetValue: RequestBudget | undefined
|
|
820
|
+
private declare platformValue: Platform | undefined
|
|
821
|
+
|
|
822
|
+
private declare setValue: CtxSet | undefined
|
|
823
|
+
private declare queryValue: unknown
|
|
824
|
+
private declare queryReady: boolean
|
|
825
|
+
private declare cookiesValue: Readonly<Record<string, string>> | undefined
|
|
826
|
+
private declare readonly source: RequestSource
|
|
827
|
+
private declare readonly maxBodyBytes: number
|
|
828
|
+
|
|
829
|
+
constructor(source: RequestSource, params: Record<string, string>, maxBodyBytes: number)
|
|
759
830
|
constructor(
|
|
760
831
|
source: RequestSource,
|
|
761
832
|
params: Record<string, string>,
|
|
762
|
-
search: string,
|
|
833
|
+
search: string | undefined,
|
|
763
834
|
signal: AbortSignal,
|
|
835
|
+
budget: RequestBudget,
|
|
764
836
|
platform: Platform | undefined,
|
|
765
837
|
maxBodyBytes: number,
|
|
838
|
+
)
|
|
839
|
+
constructor(
|
|
840
|
+
source: RequestSource,
|
|
841
|
+
params: Record<string, string>,
|
|
842
|
+
searchOrMaxBodyBytes: string | number | undefined,
|
|
843
|
+
signal?: AbortSignal,
|
|
844
|
+
budget?: RequestBudget,
|
|
845
|
+
platform?: Platform,
|
|
846
|
+
maxBodyBytes?: number,
|
|
766
847
|
) {
|
|
767
848
|
this.source = source
|
|
768
849
|
this.params = params
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
this.
|
|
774
|
-
this.
|
|
775
|
-
this.
|
|
776
|
-
this.
|
|
777
|
-
this.
|
|
778
|
-
|
|
850
|
+
if (typeof searchOrMaxBodyBytes === "number") {
|
|
851
|
+
this.maxBodyBytes = searchOrMaxBodyBytes
|
|
852
|
+
return
|
|
853
|
+
}
|
|
854
|
+
if (searchOrMaxBodyBytes !== undefined) this.searchValue = searchOrMaxBodyBytes
|
|
855
|
+
this.signalValue = signal
|
|
856
|
+
this.budgetValue = budget
|
|
857
|
+
if (platform !== undefined) this.platformValue = platform
|
|
858
|
+
this.maxBodyBytes = maxBodyBytes as number
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
static native(
|
|
862
|
+
source: RequestSource,
|
|
863
|
+
params: Record<string, string>,
|
|
864
|
+
maxBodyBytes: number,
|
|
865
|
+
): RequestContext {
|
|
866
|
+
return new RequestContext(source, params, maxBodyBytes)
|
|
779
867
|
}
|
|
780
868
|
|
|
781
869
|
[CONTEXT_SET](): CtxSet | undefined {
|
|
782
870
|
return this.setValue
|
|
783
871
|
}
|
|
784
872
|
|
|
873
|
+
get [CONTEXT_SEARCH](): string {
|
|
874
|
+
this.searchValue ??= searchOf(this.source.url)
|
|
875
|
+
return this.searchValue
|
|
876
|
+
}
|
|
877
|
+
|
|
785
878
|
get set(): CtxSet {
|
|
786
879
|
this.setValue ??= new LazyResponseControls()
|
|
787
880
|
return this.setValue
|
|
788
881
|
}
|
|
789
882
|
|
|
883
|
+
get signal(): AbortSignal {
|
|
884
|
+
return this.signalValue ?? getNeverAbortSignal()
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
get budget(): RequestBudget {
|
|
888
|
+
return this.budgetValue ?? getUnboundedRequestBudget()
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
get env(): unknown {
|
|
892
|
+
return this.platformValue?.env
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
get waitUntil(): (promise: Promise<unknown>) => void {
|
|
896
|
+
return this.platformValue?.waitUntil ?? fallbackWaitUntil
|
|
897
|
+
}
|
|
898
|
+
|
|
790
899
|
get req(): Request {
|
|
791
900
|
return requestOf(this.source)
|
|
792
901
|
}
|
|
@@ -922,8 +1031,9 @@ const JSON_CT_HEADERS = new Headers({
|
|
|
922
1031
|
})
|
|
923
1032
|
const JSON_INIT_200: ResponseInit = { status: 200, headers: JSON_CT_HEADERS }
|
|
924
1033
|
|
|
925
|
-
/** Fused-lane respond when
|
|
926
|
-
*
|
|
1034
|
+
/** Fused-lane respond when `c.set` is untouched. Bun 1.3's native `Response.json` now beats the
|
|
1035
|
+
* older hand-inlined stringify + Response construction on this lane while preserving the exact
|
|
1036
|
+
* body/content-type contract; keep the generic fallback for non-JSON values. */
|
|
927
1037
|
function fusedRespondNoSet(result: unknown): Response {
|
|
928
1038
|
if (
|
|
929
1039
|
result !== undefined &&
|
|
@@ -932,8 +1042,7 @@ function fusedRespondNoSet(result: unknown): Response {
|
|
|
932
1042
|
result !== null &&
|
|
933
1043
|
!isResponseResult(result)
|
|
934
1044
|
) {
|
|
935
|
-
|
|
936
|
-
if (body !== undefined) return new Response(body, JSON_INIT_200)
|
|
1045
|
+
return Response.json(result)
|
|
937
1046
|
}
|
|
938
1047
|
return toResponse(result as HandlerResult, EMPTY_RESPONSE_CONTROLS)
|
|
939
1048
|
}
|
|
@@ -1118,7 +1227,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1118
1227
|
private readonly maxBodyBytes: number
|
|
1119
1228
|
private readonly wsMaxPayloadBytes: number
|
|
1120
1229
|
private readonly requestTimeoutMs: number
|
|
1230
|
+
private readonly acceptInboundDeadlines: boolean
|
|
1231
|
+
private readonly maxInboundDeadlineMs: number
|
|
1232
|
+
private readonly deadlineAdmissionOptions: DeadlineAdmissionOptions
|
|
1121
1233
|
private readonly gracefulSignals: boolean
|
|
1234
|
+
/** Capacity-admission gate; `undefined` = off (the request path pays nothing). */
|
|
1235
|
+
private readonly capacityGate: AdmissionController | undefined
|
|
1122
1236
|
private readonly logger: Logger
|
|
1123
1237
|
/** App-wide validation-error fallback; a route's own `schema.onValidationError` takes precedence. */
|
|
1124
1238
|
private readonly defaultOnValidationError?: RouteSchema["onValidationError"]
|
|
@@ -1165,7 +1279,20 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1165
1279
|
this.maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
|
|
1166
1280
|
this.wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? this.maxBodyBytes
|
|
1167
1281
|
this.requestTimeoutMs = options.requestTimeoutMs ?? 0
|
|
1282
|
+
this.acceptInboundDeadlines = options.acceptInboundDeadlines ?? false
|
|
1283
|
+
this.maxInboundDeadlineMs = options.maxInboundDeadlineMs ?? 30_000
|
|
1284
|
+
this.deadlineAdmissionOptions = Object.freeze({
|
|
1285
|
+
localTimeoutMs: this.requestTimeoutMs,
|
|
1286
|
+
maxInboundDeadlineMs: this.maxInboundDeadlineMs,
|
|
1287
|
+
})
|
|
1288
|
+
if (!Number.isFinite(this.requestTimeoutMs) || this.requestTimeoutMs < 0) {
|
|
1289
|
+
throw new RangeError("requestTimeoutMs must be a finite non-negative number")
|
|
1290
|
+
}
|
|
1291
|
+
if (!Number.isFinite(this.maxInboundDeadlineMs) || this.maxInboundDeadlineMs <= 0) {
|
|
1292
|
+
throw new RangeError("maxInboundDeadlineMs must be a finite positive number")
|
|
1293
|
+
}
|
|
1168
1294
|
this.gracefulSignals = options.gracefulSignals ?? false
|
|
1295
|
+
this.capacityGate = options.admission
|
|
1169
1296
|
this.logger = options.logger ?? jsonLogger()
|
|
1170
1297
|
this.defaultOnValidationError = options.onValidationError
|
|
1171
1298
|
this.bunServer = undefined
|
|
@@ -1759,6 +1886,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1759
1886
|
private fetchSource(
|
|
1760
1887
|
source: RequestSource,
|
|
1761
1888
|
platform?: Platform<EnvOf<Ctx>>,
|
|
1889
|
+
): MaybePromise<Response> {
|
|
1890
|
+
// Off path (default): straight through — one property check, no closure, no promise.
|
|
1891
|
+
if (this.capacityGate === undefined) return this.fetchSourceInner(source, platform)
|
|
1892
|
+
return this.admitGated(requestOf(source), () => this.fetchSourceInner(source, platform))
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
private fetchSourceInner(
|
|
1896
|
+
source: RequestSource,
|
|
1897
|
+
platform?: Platform<EnvOf<Ctx>>,
|
|
1762
1898
|
): MaybePromise<Response> {
|
|
1763
1899
|
// Non-`async` on purpose: `dispatch` may return a `Response` *synchronously* (the bare-route fast
|
|
1764
1900
|
// path, see RouteEntry.bare), and an `async fetch` would wrap every such result in a redundant
|
|
@@ -1784,6 +1920,88 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1784
1920
|
: this.applyOnResponseAndFinalize(outcome, this.takeResponseRequest(source))
|
|
1785
1921
|
}
|
|
1786
1922
|
|
|
1923
|
+
/** Web response path when Bun already matched the route. The lifecycle and response hooks remain
|
|
1924
|
+
* exactly the same as {@link fetchSource}; only portable URL scanning + trie lookup are skipped. */
|
|
1925
|
+
private fetchMatched(
|
|
1926
|
+
source: RequestSource,
|
|
1927
|
+
entry: RouteEntry,
|
|
1928
|
+
params: Record<string, string>,
|
|
1929
|
+
): MaybePromise<Response> {
|
|
1930
|
+
if (this.capacityGate === undefined) return this.fetchMatchedInner(source, entry, params)
|
|
1931
|
+
return this.admitGated(requestOf(source), () => this.fetchMatchedInner(source, entry, params))
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
private fetchMatchedInner(
|
|
1935
|
+
source: RequestSource,
|
|
1936
|
+
entry: RouteEntry,
|
|
1937
|
+
params: Record<string, string>,
|
|
1938
|
+
): MaybePromise<Response> {
|
|
1939
|
+
const outcome = this.runMatched(
|
|
1940
|
+
source,
|
|
1941
|
+
undefined,
|
|
1942
|
+
entry,
|
|
1943
|
+
params,
|
|
1944
|
+
undefined,
|
|
1945
|
+
toResponse,
|
|
1946
|
+
IDENTITY_RESPONSE,
|
|
1947
|
+
RESPONSE_TIMEOUT,
|
|
1948
|
+
true,
|
|
1949
|
+
)
|
|
1950
|
+
if (this.onResponseHooks.length === 0 && this.onResponseFinalizedHooks.length === 0) {
|
|
1951
|
+
return outcome
|
|
1952
|
+
}
|
|
1953
|
+
return outcome instanceof Promise
|
|
1954
|
+
? outcome.then((response) => this.applyOnResponseAndFinalize(response, requestOf(source)))
|
|
1955
|
+
: this.applyOnResponseAndFinalize(outcome, requestOf(source))
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
/**
|
|
1959
|
+
* Run `produce` under the capacity gate: admit → run → release exactly once when the response is
|
|
1960
|
+
* produced (or the run throws). Only reached when {@link capacityGate} is set, so the off path pays
|
|
1961
|
+
* nothing. The slot is held for the duration of handler execution, not the streaming of the body —
|
|
1962
|
+
* capacity here bounds concurrent *work*, matching how in-flight is counted.
|
|
1963
|
+
*/
|
|
1964
|
+
private admitGated(req: Request, produce: () => MaybePromise<Response>): MaybePromise<Response> {
|
|
1965
|
+
const decision = (this.capacityGate as AdmissionController).admit(req)
|
|
1966
|
+
return decision instanceof Promise
|
|
1967
|
+
? decision.then((settled) => this.runAdmitted(settled, produce))
|
|
1968
|
+
: this.runAdmitted(decision, produce)
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
private runAdmitted(
|
|
1972
|
+
decision: AdmissionDecision,
|
|
1973
|
+
produce: () => MaybePromise<Response>,
|
|
1974
|
+
): MaybePromise<Response> {
|
|
1975
|
+
if (!decision.admitted) return decision.response // shed: ready 429, no slot held
|
|
1976
|
+
let released = false
|
|
1977
|
+
const release = (): void => {
|
|
1978
|
+
if (released) return
|
|
1979
|
+
released = true
|
|
1980
|
+
decision.release()
|
|
1981
|
+
}
|
|
1982
|
+
let outcome: MaybePromise<Response>
|
|
1983
|
+
try {
|
|
1984
|
+
outcome = produce()
|
|
1985
|
+
} catch (error) {
|
|
1986
|
+
release()
|
|
1987
|
+
throw error
|
|
1988
|
+
}
|
|
1989
|
+
if (outcome instanceof Promise) {
|
|
1990
|
+
return outcome.then(
|
|
1991
|
+
(response) => {
|
|
1992
|
+
release()
|
|
1993
|
+
return response
|
|
1994
|
+
},
|
|
1995
|
+
(error) => {
|
|
1996
|
+
release()
|
|
1997
|
+
throw error
|
|
1998
|
+
},
|
|
1999
|
+
)
|
|
2000
|
+
}
|
|
2001
|
+
release()
|
|
2002
|
+
return outcome
|
|
2003
|
+
}
|
|
2004
|
+
|
|
1787
2005
|
/**
|
|
1788
2006
|
* Like {@link fetch}, but renders a plain-data result **without** building a Web `Response` — the
|
|
1789
2007
|
* `@nifrajs/node` adapter serializes the returned primitives straight to the socket, skipping the undici
|
|
@@ -1801,8 +2019,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1801
2019
|
source: RequestSource,
|
|
1802
2020
|
platform?: Platform<EnvOf<Ctx>>,
|
|
1803
2021
|
): MaybePromise<NodeServeOutcome> {
|
|
1804
|
-
// onResponse hooks transform a Response,
|
|
1805
|
-
|
|
2022
|
+
// onResponse hooks transform a Response, and the capacity gate wraps the Web response path — both
|
|
2023
|
+
// force the Web path here (the gated `fetchSource` admits/sheds/releases); wrap its result.
|
|
2024
|
+
if (
|
|
2025
|
+
this.onResponseHooks.length > 0 ||
|
|
2026
|
+
this.onResponseFinalizedHooks.length > 0 ||
|
|
2027
|
+
this.capacityGate !== undefined
|
|
2028
|
+
) {
|
|
1806
2029
|
const response = this.fetchSource(source, platform)
|
|
1807
2030
|
return response instanceof Promise
|
|
1808
2031
|
? response.then((settled) => ({ kind: "response", response: settled }))
|
|
@@ -1838,10 +2061,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1838
2061
|
const url = urlPartsOf(req.url)
|
|
1839
2062
|
const match = this.wsRouter.find("GET", url.pathname)
|
|
1840
2063
|
if (!match.found) return WS_PASS // upgrade header, no WS route here → normal routing decides
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
: decodeParams(match.params)
|
|
2064
|
+
// Inspect only captured values for escapes. Scanning the full pathname repeated work the router
|
|
2065
|
+
// already did and made every plain dynamic route pay for unrelated static path bytes.
|
|
2066
|
+
const params = match.params === EMPTY_PARAMS ? match.params : decodeParams(match.params)
|
|
1845
2067
|
if (params === null) return { kind: "reject", response: jsonError(400, "malformed_path") }
|
|
1846
2068
|
const handler = match.payload.handler
|
|
1847
2069
|
// Non-null: wsRouteCount > 0 ⇒ ws() ran ⇒ the runtime created the registry.
|
|
@@ -1866,11 +2088,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1866
2088
|
if (handler.upgrade === undefined) {
|
|
1867
2089
|
return { kind: "upgrade", handler, data: undefined, pubsub }
|
|
1868
2090
|
}
|
|
2091
|
+
const upgradeSignal = getNeverAbortSignal()
|
|
1869
2092
|
const ctx = new RequestContext(
|
|
1870
2093
|
req,
|
|
1871
2094
|
params,
|
|
1872
2095
|
url.search,
|
|
1873
|
-
|
|
2096
|
+
upgradeSignal,
|
|
2097
|
+
createUnboundedRequestBudget(upgradeSignal),
|
|
1874
2098
|
platform,
|
|
1875
2099
|
this.maxBodyBytes,
|
|
1876
2100
|
)
|
|
@@ -2042,38 +2266,95 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2042
2266
|
return wrapResponse(jsonError(404, "not_found"))
|
|
2043
2267
|
}
|
|
2044
2268
|
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
: decodeParams(match.params)
|
|
2269
|
+
// Inspect only captured values for escapes. Scanning the full pathname repeated work the router
|
|
2270
|
+
// already did and made every plain dynamic route pay for unrelated static path bytes.
|
|
2271
|
+
const params = match.params === EMPTY_PARAMS ? match.params : decodeParams(match.params)
|
|
2049
2272
|
if (params === null) {
|
|
2050
2273
|
return wrapResponse(jsonError(400, "malformed_path"))
|
|
2051
2274
|
}
|
|
2052
2275
|
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2276
|
+
return this.runMatched(
|
|
2277
|
+
source,
|
|
2278
|
+
platform,
|
|
2279
|
+
match.payload,
|
|
2280
|
+
params,
|
|
2281
|
+
url.search,
|
|
2282
|
+
finalize,
|
|
2283
|
+
wrapResponse,
|
|
2284
|
+
onTimeout,
|
|
2285
|
+
webFast,
|
|
2286
|
+
)
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
/** Run a route that has already been matched by the runtime or Nifra's portable router. */
|
|
2290
|
+
private runMatched<T>(
|
|
2291
|
+
source: RequestSource,
|
|
2292
|
+
platform: Platform | undefined,
|
|
2293
|
+
entry: RouteEntry,
|
|
2294
|
+
params: Record<string, string>,
|
|
2295
|
+
search: string | undefined,
|
|
2296
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
2297
|
+
wrapResponse: (response: Response) => T,
|
|
2298
|
+
onTimeout: () => T,
|
|
2299
|
+
webFast: boolean,
|
|
2300
|
+
): MaybePromise<T> {
|
|
2301
|
+
// Translate the absolute wire deadline once, clamp it to local policy, then use the resulting
|
|
2302
|
+
// duration for both c.signal and c.budget. A client can only shorten work, never extend it.
|
|
2303
|
+
// Most requests have neither a local timeout nor a propagated deadline. Detect that case with
|
|
2304
|
+
// one header lookup and skip policy validation, wall-clock sampling, and admission objects. A
|
|
2305
|
+
// present wire deadline still goes through the full fail-closed parser/clamp below.
|
|
2306
|
+
const admission = !this.acceptInboundDeadlines
|
|
2307
|
+
? this.requestTimeoutMs === 0
|
|
2308
|
+
? undefined
|
|
2309
|
+
: {
|
|
2310
|
+
ok: true as const,
|
|
2311
|
+
inherited: false,
|
|
2312
|
+
timeoutMs: this.requestTimeoutMs,
|
|
2313
|
+
deadline: Math.floor(Date.now() + this.requestTimeoutMs),
|
|
2314
|
+
}
|
|
2315
|
+
: this.requestTimeoutMs === 0 && headerOf(source, NIFRA_DEADLINE_HEADER) === null
|
|
2316
|
+
? undefined
|
|
2317
|
+
: admitDeadline(source.headers, this.deadlineAdmissionOptions)
|
|
2318
|
+
if (admission !== undefined && !admission.ok) {
|
|
2319
|
+
return wrapResponse(jsonError(admission.status, admission.reason))
|
|
2320
|
+
}
|
|
2321
|
+
const effectiveTimeoutMs = admission?.timeoutMs ?? 0
|
|
2322
|
+
|
|
2323
|
+
// Only allocate a controller for a finite budget; the historical no-timeout path remains
|
|
2324
|
+
// allocation-light and exposes an unbounded budget that is never propagated on the wire.
|
|
2057
2325
|
let controller: AbortController | undefined
|
|
2058
2326
|
let signal = getNeverAbortSignal()
|
|
2059
|
-
if (
|
|
2327
|
+
if (effectiveTimeoutMs > 0) {
|
|
2060
2328
|
controller = new AbortController()
|
|
2061
2329
|
signal = controller.signal
|
|
2062
2330
|
}
|
|
2331
|
+
const budget =
|
|
2332
|
+
controller === undefined
|
|
2333
|
+
? getUnboundedRequestBudget()
|
|
2334
|
+
: createRequestBudget({ deadline: admission!.deadline as number, signal })
|
|
2063
2335
|
// A bare route runs synchronously (no schema/derives/hooks); everything else keeps the full async
|
|
2064
2336
|
// lifecycle. `runBare` returns a value directly for a sync handler — no promise.
|
|
2065
2337
|
let outcome: MaybePromise<T>
|
|
2066
2338
|
if (webFast && entry.fusedWeb !== undefined) {
|
|
2067
2339
|
// Fused Web lane (bare route, no around): one closure end to end. Sound cast — webFast is
|
|
2068
2340
|
// passed only by the `fetch` path, where T = Response by construction.
|
|
2069
|
-
outcome = entry.fusedWeb(
|
|
2341
|
+
outcome = entry.fusedWeb(
|
|
2342
|
+
source,
|
|
2343
|
+
params,
|
|
2344
|
+
search,
|
|
2345
|
+
signal,
|
|
2346
|
+
budget,
|
|
2347
|
+
platform,
|
|
2348
|
+
false,
|
|
2349
|
+
) as MaybePromise<T>
|
|
2070
2350
|
} else if (entry.contextlessBare) {
|
|
2071
2351
|
outcome = this.runContextlessBare(
|
|
2072
2352
|
entry,
|
|
2073
2353
|
source,
|
|
2074
2354
|
params,
|
|
2075
|
-
|
|
2355
|
+
search,
|
|
2076
2356
|
signal,
|
|
2357
|
+
budget,
|
|
2077
2358
|
platform,
|
|
2078
2359
|
finalize,
|
|
2079
2360
|
wrapResponse,
|
|
@@ -2084,8 +2365,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2084
2365
|
const ctx = new RequestContext(
|
|
2085
2366
|
source,
|
|
2086
2367
|
params,
|
|
2087
|
-
|
|
2368
|
+
search,
|
|
2088
2369
|
signal,
|
|
2370
|
+
budget,
|
|
2089
2371
|
platform,
|
|
2090
2372
|
this.maxBodyBytes,
|
|
2091
2373
|
)
|
|
@@ -2118,7 +2400,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2118
2400
|
// The request timeout only bounds work that is actually pending — a synchronous (bare) result is
|
|
2119
2401
|
// already complete and can't time out, so it's returned as-is (no 503 race, no promise).
|
|
2120
2402
|
if (controller !== undefined && outcome instanceof Promise) {
|
|
2121
|
-
|
|
2403
|
+
const timedOut =
|
|
2404
|
+
admission?.inherited === true
|
|
2405
|
+
? () => wrapResponse(jsonError(504, "deadline_exceeded"))
|
|
2406
|
+
: onTimeout
|
|
2407
|
+
return this.withTimeout(
|
|
2408
|
+
outcome,
|
|
2409
|
+
controller,
|
|
2410
|
+
timedOut,
|
|
2411
|
+
Math.max(0, Math.ceil(budget.remaining())),
|
|
2412
|
+
)
|
|
2122
2413
|
}
|
|
2123
2414
|
return outcome
|
|
2124
2415
|
}
|
|
@@ -2129,8 +2420,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2129
2420
|
entry: RouteEntry,
|
|
2130
2421
|
source: RequestSource,
|
|
2131
2422
|
params: Record<string, string>,
|
|
2132
|
-
search: string,
|
|
2423
|
+
search: string | undefined,
|
|
2133
2424
|
signal: AbortSignal,
|
|
2425
|
+
budget: RequestBudget,
|
|
2134
2426
|
platform: Platform | undefined,
|
|
2135
2427
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
2136
2428
|
wrapResponse: (response: Response) => T,
|
|
@@ -2139,13 +2431,31 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2139
2431
|
try {
|
|
2140
2432
|
result = (entry.handler as unknown as ContextlessHandler)()
|
|
2141
2433
|
} catch (err) {
|
|
2142
|
-
return this.contextlessBareError(
|
|
2434
|
+
return this.contextlessBareError(
|
|
2435
|
+
err,
|
|
2436
|
+
source,
|
|
2437
|
+
params,
|
|
2438
|
+
search,
|
|
2439
|
+
signal,
|
|
2440
|
+
budget,
|
|
2441
|
+
platform,
|
|
2442
|
+
wrapResponse,
|
|
2443
|
+
)
|
|
2143
2444
|
}
|
|
2144
2445
|
if (result instanceof Promise) {
|
|
2145
2446
|
return result.then(
|
|
2146
2447
|
(value) => finalize(value, EMPTY_RESPONSE_CONTROLS),
|
|
2147
2448
|
(err) =>
|
|
2148
|
-
this.contextlessBareError(
|
|
2449
|
+
this.contextlessBareError(
|
|
2450
|
+
err,
|
|
2451
|
+
source,
|
|
2452
|
+
params,
|
|
2453
|
+
search,
|
|
2454
|
+
signal,
|
|
2455
|
+
budget,
|
|
2456
|
+
platform,
|
|
2457
|
+
wrapResponse,
|
|
2458
|
+
),
|
|
2149
2459
|
)
|
|
2150
2460
|
}
|
|
2151
2461
|
return finalize(result, EMPTY_RESPONSE_CONTROLS)
|
|
@@ -2155,13 +2465,22 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2155
2465
|
err: unknown,
|
|
2156
2466
|
source: RequestSource,
|
|
2157
2467
|
params: Record<string, string>,
|
|
2158
|
-
search: string,
|
|
2468
|
+
search: string | undefined,
|
|
2159
2469
|
signal: AbortSignal,
|
|
2470
|
+
budget: RequestBudget,
|
|
2160
2471
|
platform: Platform | undefined,
|
|
2161
2472
|
wrapResponse: (response: Response) => T,
|
|
2162
2473
|
): T {
|
|
2163
2474
|
if (err instanceof Response) return wrapResponse(err)
|
|
2164
|
-
const ctx = new RequestContext(
|
|
2475
|
+
const ctx = new RequestContext(
|
|
2476
|
+
source,
|
|
2477
|
+
params,
|
|
2478
|
+
search,
|
|
2479
|
+
signal,
|
|
2480
|
+
budget,
|
|
2481
|
+
platform,
|
|
2482
|
+
this.maxBodyBytes,
|
|
2483
|
+
)
|
|
2165
2484
|
this.logRequestError(err, ctx)
|
|
2166
2485
|
return wrapResponse(jsonError(500, "internal_error"))
|
|
2167
2486
|
}
|
|
@@ -2221,29 +2540,43 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2221
2540
|
// `() => ...` can't observe the context — skip allocating one entirely (errors still build
|
|
2222
2541
|
// one for the structured log, exactly like runContextlessBare).
|
|
2223
2542
|
const contextlessHandler = handler as unknown as ContextlessHandler
|
|
2224
|
-
return (source, params, search, signal, platform) => {
|
|
2543
|
+
return (source, params, search, signal, budget, platform, nativeContext) => {
|
|
2225
2544
|
let result: unknown
|
|
2226
2545
|
try {
|
|
2227
2546
|
result = contextlessHandler()
|
|
2228
2547
|
} catch (err) {
|
|
2229
2548
|
return logError(
|
|
2230
2549
|
err,
|
|
2231
|
-
|
|
2550
|
+
nativeContext
|
|
2551
|
+
? RequestContext.native(source, params, maxBodyBytes)
|
|
2552
|
+
: new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes),
|
|
2232
2553
|
)
|
|
2233
2554
|
}
|
|
2234
2555
|
if (result instanceof Promise) {
|
|
2235
2556
|
return result.then(fusedRespondNoSet, (err) =>
|
|
2236
2557
|
logError(
|
|
2237
2558
|
err,
|
|
2238
|
-
|
|
2559
|
+
nativeContext
|
|
2560
|
+
? RequestContext.native(source, params, maxBodyBytes)
|
|
2561
|
+
: new RequestContext(
|
|
2562
|
+
source,
|
|
2563
|
+
params,
|
|
2564
|
+
search,
|
|
2565
|
+
signal,
|
|
2566
|
+
budget,
|
|
2567
|
+
platform,
|
|
2568
|
+
maxBodyBytes,
|
|
2569
|
+
),
|
|
2239
2570
|
),
|
|
2240
2571
|
)
|
|
2241
2572
|
}
|
|
2242
2573
|
return fusedRespondNoSet(result)
|
|
2243
2574
|
}
|
|
2244
2575
|
}
|
|
2245
|
-
return (source, params, search, signal, platform) => {
|
|
2246
|
-
const ctx =
|
|
2576
|
+
return (source, params, search, signal, budget, platform, nativeContext) => {
|
|
2577
|
+
const ctx = nativeContext
|
|
2578
|
+
? RequestContext.native(source, params, maxBodyBytes)
|
|
2579
|
+
: new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes)
|
|
2247
2580
|
if (decorations !== undefined) Object.assign(ctx, decorations)
|
|
2248
2581
|
let result: unknown
|
|
2249
2582
|
try {
|
|
@@ -2688,13 +3021,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2688
3021
|
work: Promise<T>,
|
|
2689
3022
|
controller: AbortController,
|
|
2690
3023
|
onTimeout: () => T,
|
|
3024
|
+
timeoutMs: number,
|
|
2691
3025
|
): Promise<T> {
|
|
2692
3026
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
2693
3027
|
const timeout = new Promise<T>((resolve) => {
|
|
2694
3028
|
timer = setTimeout(() => {
|
|
2695
3029
|
controller.abort()
|
|
2696
3030
|
resolve(onTimeout())
|
|
2697
|
-
},
|
|
3031
|
+
}, timeoutMs)
|
|
2698
3032
|
})
|
|
2699
3033
|
try {
|
|
2700
3034
|
return await Promise.race([work, timeout])
|
|
@@ -2853,6 +3187,166 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2853
3187
|
return readBoundedJsonSource(req, this.maxBodyBytes)
|
|
2854
3188
|
}
|
|
2855
3189
|
|
|
3190
|
+
/** Compile portable route registrations into Bun's native route table. Apps with request-rewrite
|
|
3191
|
+
* hooks or WebSockets retain the single portable dispatcher because those features must run before
|
|
3192
|
+
* route selection/upgrade. Named wildcards also stay on the fallback until Bun exposes their raw
|
|
3193
|
+
* capture semantics; static and `:param` routes take the native lane. */
|
|
3194
|
+
private buildBunNativeRoutes(): BunNativeRoutes | undefined {
|
|
3195
|
+
if (this.onRequestHooks.length > 0 || this.wsRouteCount > 0) return undefined
|
|
3196
|
+
|
|
3197
|
+
const routes: BunNativeRoutes = Object.create(null) as BunNativeRoutes
|
|
3198
|
+
const mayUseFusedNative =
|
|
3199
|
+
this.requestTimeoutMs === 0 &&
|
|
3200
|
+
this.onResponseHooks.length === 0 &&
|
|
3201
|
+
this.onResponseFinalizedHooks.length === 0 &&
|
|
3202
|
+
// The capacity gate must wrap every request; the fused lane bypasses fetchMatched, so enabling
|
|
3203
|
+
// admission drops fusion (native matching stays) and routes through the gated matched lane.
|
|
3204
|
+
this.capacityGate === undefined
|
|
3205
|
+
const unboundedSignal = mayUseFusedNative ? getNeverAbortSignal() : undefined
|
|
3206
|
+
const unboundedBudget = mayUseFusedNative ? getUnboundedRequestBudget() : undefined
|
|
3207
|
+
let count = 0
|
|
3208
|
+
for (const { method, path, entry } of this.rawEntries) {
|
|
3209
|
+
if (path.includes("*")) continue
|
|
3210
|
+
let methods = routes[path]
|
|
3211
|
+
if (methods === undefined) {
|
|
3212
|
+
methods = Object.create(null) as BunNativeMethodTable
|
|
3213
|
+
routes[path] = methods
|
|
3214
|
+
}
|
|
3215
|
+
const paramNames = path
|
|
3216
|
+
.split("/")
|
|
3217
|
+
.filter((segment) => segment.charCodeAt(0) === 58 /* : */)
|
|
3218
|
+
.map((segment) => segment.slice(1))
|
|
3219
|
+
const fused = mayUseFusedNative ? entry.fusedWeb : undefined
|
|
3220
|
+
|
|
3221
|
+
if (fused !== undefined) {
|
|
3222
|
+
// Deliberately choose deadline-aware vs zero-admission closures HERE instead of branching on
|
|
3223
|
+
// `acceptInboundDeadlines` per request. JSC can then flatten the default closure through the
|
|
3224
|
+
// handler + Response.json; retaining that route-invariant branch cost ~7% on a dynamic GET.
|
|
3225
|
+
if (paramNames.length === 0) {
|
|
3226
|
+
methods[method] = this.acceptInboundDeadlines
|
|
3227
|
+
? (request) =>
|
|
3228
|
+
request.headers.get(NIFRA_DEADLINE_HEADER) !== null
|
|
3229
|
+
? this.fetchMatched(request, entry, EMPTY_PARAMS)
|
|
3230
|
+
: fused(
|
|
3231
|
+
request,
|
|
3232
|
+
EMPTY_PARAMS,
|
|
3233
|
+
undefined,
|
|
3234
|
+
unboundedSignal as AbortSignal,
|
|
3235
|
+
unboundedBudget as RequestBudget,
|
|
3236
|
+
undefined,
|
|
3237
|
+
true,
|
|
3238
|
+
)
|
|
3239
|
+
: (request) =>
|
|
3240
|
+
fused(
|
|
3241
|
+
request,
|
|
3242
|
+
EMPTY_PARAMS,
|
|
3243
|
+
undefined,
|
|
3244
|
+
unboundedSignal as AbortSignal,
|
|
3245
|
+
unboundedBudget as RequestBudget,
|
|
3246
|
+
undefined,
|
|
3247
|
+
true,
|
|
3248
|
+
)
|
|
3249
|
+
} else if (paramNames.length === 1) {
|
|
3250
|
+
const paramName = paramNames[0]!
|
|
3251
|
+
const handler = entry.handler
|
|
3252
|
+
const decorations = entry.hasDecorations ? entry.decorations : undefined
|
|
3253
|
+
const fail = (error: unknown, ctx: RawContext): Response => {
|
|
3254
|
+
if (error instanceof Response) return error
|
|
3255
|
+
this.logRequestError(error, ctx)
|
|
3256
|
+
return jsonError(500, "internal_error")
|
|
3257
|
+
}
|
|
3258
|
+
methods[method] = this.acceptInboundDeadlines
|
|
3259
|
+
? (request) => {
|
|
3260
|
+
const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
|
|
3261
|
+
if (params[paramName]?.includes("\uFFFD") === true) return this.fetchSource(request)
|
|
3262
|
+
if (request.headers.get(NIFRA_DEADLINE_HEADER) !== null) {
|
|
3263
|
+
return this.fetchMatched(request, entry, params)
|
|
3264
|
+
}
|
|
3265
|
+
const ctx = RequestContext.native(request, params, this.maxBodyBytes)
|
|
3266
|
+
if (decorations !== undefined) Object.assign(ctx, decorations)
|
|
3267
|
+
let result: unknown
|
|
3268
|
+
try {
|
|
3269
|
+
result = handler(ctx)
|
|
3270
|
+
} catch (error) {
|
|
3271
|
+
return fail(error, ctx)
|
|
3272
|
+
}
|
|
3273
|
+
return result instanceof Promise
|
|
3274
|
+
? result.then(
|
|
3275
|
+
(value) => fusedRespond(value, ctx),
|
|
3276
|
+
(error) => fail(error, ctx),
|
|
3277
|
+
)
|
|
3278
|
+
: fusedRespond(result, ctx)
|
|
3279
|
+
}
|
|
3280
|
+
: (request) => {
|
|
3281
|
+
const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
|
|
3282
|
+
if (params[paramName]?.includes("\uFFFD") === true) return this.fetchSource(request)
|
|
3283
|
+
const ctx = RequestContext.native(request, params, this.maxBodyBytes)
|
|
3284
|
+
if (decorations !== undefined) Object.assign(ctx, decorations)
|
|
3285
|
+
let result: unknown
|
|
3286
|
+
try {
|
|
3287
|
+
result = handler(ctx)
|
|
3288
|
+
} catch (error) {
|
|
3289
|
+
return fail(error, ctx)
|
|
3290
|
+
}
|
|
3291
|
+
return result instanceof Promise
|
|
3292
|
+
? result.then(
|
|
3293
|
+
(value) => fusedRespond(value, ctx),
|
|
3294
|
+
(error) => fail(error, ctx),
|
|
3295
|
+
)
|
|
3296
|
+
: fusedRespond(result, ctx)
|
|
3297
|
+
}
|
|
3298
|
+
} else {
|
|
3299
|
+
methods[method] = this.acceptInboundDeadlines
|
|
3300
|
+
? (request) => {
|
|
3301
|
+
const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
|
|
3302
|
+
if (hasReplacementParam(params)) return this.fetchSource(request)
|
|
3303
|
+
return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
|
|
3304
|
+
? this.fetchMatched(request, entry, params)
|
|
3305
|
+
: fused(
|
|
3306
|
+
request,
|
|
3307
|
+
params,
|
|
3308
|
+
undefined,
|
|
3309
|
+
unboundedSignal as AbortSignal,
|
|
3310
|
+
unboundedBudget as RequestBudget,
|
|
3311
|
+
undefined,
|
|
3312
|
+
true,
|
|
3313
|
+
)
|
|
3314
|
+
}
|
|
3315
|
+
: (request) => {
|
|
3316
|
+
const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
|
|
3317
|
+
if (hasReplacementParam(params)) return this.fetchSource(request)
|
|
3318
|
+
return fused(
|
|
3319
|
+
request,
|
|
3320
|
+
params,
|
|
3321
|
+
undefined,
|
|
3322
|
+
unboundedSignal as AbortSignal,
|
|
3323
|
+
unboundedBudget as RequestBudget,
|
|
3324
|
+
undefined,
|
|
3325
|
+
true,
|
|
3326
|
+
)
|
|
3327
|
+
}
|
|
3328
|
+
}
|
|
3329
|
+
} else if (paramNames.length === 0) {
|
|
3330
|
+
methods[method] = (request) => this.fetchMatched(request, entry, EMPTY_PARAMS)
|
|
3331
|
+
} else if (paramNames.length === 1) {
|
|
3332
|
+
const paramName = paramNames[0]!
|
|
3333
|
+
methods[method] = (request) => {
|
|
3334
|
+
const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
|
|
3335
|
+
if (params[paramName]?.includes("\uFFFD") === true) return this.fetchSource(request)
|
|
3336
|
+
return this.fetchMatched(request, entry, params)
|
|
3337
|
+
}
|
|
3338
|
+
} else {
|
|
3339
|
+
methods[method] = (request) => {
|
|
3340
|
+
const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
|
|
3341
|
+
if (hasReplacementParam(params)) return this.fetchSource(request)
|
|
3342
|
+
return this.fetchMatched(request, entry, params)
|
|
3343
|
+
}
|
|
3344
|
+
}
|
|
3345
|
+
count += 1
|
|
3346
|
+
}
|
|
3347
|
+
return count === 0 ? undefined : routes
|
|
3348
|
+
}
|
|
3349
|
+
|
|
2856
3350
|
/**
|
|
2857
3351
|
* Start a `Bun.serve` instance bound to `port` (use `0` for an ephemeral port).
|
|
2858
3352
|
*
|
|
@@ -2889,8 +3383,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2889
3383
|
? undefined
|
|
2890
3384
|
: (getWsRuntime() as WsRuntime).bunHandlers(this.topics as TopicRegistry)
|
|
2891
3385
|
const reusePort = options?.reusePort === true
|
|
3386
|
+
const nativeRoutes = wsHandlers === undefined ? this.buildBunNativeRoutes() : undefined
|
|
2892
3387
|
const running = (wsHandlers === undefined
|
|
2893
|
-
? Bun.serve({
|
|
3388
|
+
? Bun.serve({
|
|
3389
|
+
port,
|
|
3390
|
+
reusePort,
|
|
3391
|
+
...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
|
|
3392
|
+
fetch: (req: Request) => this.fetch(req),
|
|
3393
|
+
})
|
|
2894
3394
|
: Bun.serve<BunWsData>({
|
|
2895
3395
|
port,
|
|
2896
3396
|
reusePort,
|