@nifrajs/core 1.8.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 +5 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +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 +25 -0
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +275 -56
- package/dist/server/server.js.map +1 -1
- package/package.json +4 -1
- package/src/index.ts +1 -1
- package/src/server/context.ts +6 -0
- package/src/server/server.ts +456 -60
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. */
|
|
@@ -255,6 +271,18 @@ export interface ServerOptions {
|
|
|
255
271
|
readonly wsMaxPayloadBytes?: number
|
|
256
272
|
/** Per-request timeout (ms): a slower request gets a 503 and `ctx.signal` aborts. 0 disables (default). */
|
|
257
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
|
|
258
286
|
/** When `listen()`ing, install SIGTERM/SIGINT handlers that gracefully `stop()`. Default false. */
|
|
259
287
|
readonly gracefulSignals?: boolean
|
|
260
288
|
/** Structured logger for framework events (redacts secrets). Default: JSON to stderr. */
|
|
@@ -475,10 +503,15 @@ const TEXT_DECODER = new TextDecoder()
|
|
|
475
503
|
* reused at zero per-request cost.
|
|
476
504
|
*/
|
|
477
505
|
let neverAbortSignal: AbortSignal | undefined
|
|
506
|
+
let unboundedRequestBudget: RequestBudget | undefined
|
|
478
507
|
const getNeverAbortSignal = (): AbortSignal => {
|
|
479
508
|
neverAbortSignal ??= new AbortController().signal
|
|
480
509
|
return neverAbortSignal
|
|
481
510
|
}
|
|
511
|
+
const getUnboundedRequestBudget = (): RequestBudget => {
|
|
512
|
+
unboundedRequestBudget ??= createUnboundedRequestBudget(getNeverAbortSignal())
|
|
513
|
+
return unboundedRequestBudget
|
|
514
|
+
}
|
|
482
515
|
|
|
483
516
|
function jsonError(status: number, error: string, headers?: Record<string, string>): Response {
|
|
484
517
|
return Response.json(
|
|
@@ -679,6 +712,13 @@ function decodeParams(raw: Record<string, string>): Record<string, string> | nul
|
|
|
679
712
|
return out ?? raw
|
|
680
713
|
}
|
|
681
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
|
+
|
|
682
722
|
/** `ctx.set` carrying the lazy backings (`_headers`, `_cookies`) so `toResponse` can skip allocating
|
|
683
723
|
* anything when no handler touched `c.set.*`. Server-internal. */
|
|
684
724
|
type CtxSet = ResponseControls & {
|
|
@@ -742,51 +782,92 @@ function statusInit(init?: ResponseInit | number): ResponseInit | undefined {
|
|
|
742
782
|
}
|
|
743
783
|
|
|
744
784
|
class RequestContext implements RawContext {
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
readonly
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
private
|
|
753
|
-
|
|
754
|
-
private
|
|
755
|
-
private
|
|
756
|
-
private
|
|
757
|
-
private
|
|
758
|
-
|
|
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)
|
|
759
802
|
constructor(
|
|
760
803
|
source: RequestSource,
|
|
761
804
|
params: Record<string, string>,
|
|
762
|
-
search: string,
|
|
805
|
+
search: string | undefined,
|
|
763
806
|
signal: AbortSignal,
|
|
807
|
+
budget: RequestBudget,
|
|
764
808
|
platform: Platform | undefined,
|
|
765
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,
|
|
766
819
|
) {
|
|
767
820
|
this.source = source
|
|
768
821
|
this.params = params
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
this.
|
|
774
|
-
this.
|
|
775
|
-
this.
|
|
776
|
-
this.
|
|
777
|
-
this.
|
|
778
|
-
|
|
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)
|
|
779
839
|
}
|
|
780
840
|
|
|
781
841
|
[CONTEXT_SET](): CtxSet | undefined {
|
|
782
842
|
return this.setValue
|
|
783
843
|
}
|
|
784
844
|
|
|
845
|
+
get [CONTEXT_SEARCH](): string {
|
|
846
|
+
this.searchValue ??= searchOf(this.source.url)
|
|
847
|
+
return this.searchValue
|
|
848
|
+
}
|
|
849
|
+
|
|
785
850
|
get set(): CtxSet {
|
|
786
851
|
this.setValue ??= new LazyResponseControls()
|
|
787
852
|
return this.setValue
|
|
788
853
|
}
|
|
789
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
|
+
|
|
790
871
|
get req(): Request {
|
|
791
872
|
return requestOf(this.source)
|
|
792
873
|
}
|
|
@@ -922,8 +1003,9 @@ const JSON_CT_HEADERS = new Headers({
|
|
|
922
1003
|
})
|
|
923
1004
|
const JSON_INIT_200: ResponseInit = { status: 200, headers: JSON_CT_HEADERS }
|
|
924
1005
|
|
|
925
|
-
/** Fused-lane respond when
|
|
926
|
-
*
|
|
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. */
|
|
927
1009
|
function fusedRespondNoSet(result: unknown): Response {
|
|
928
1010
|
if (
|
|
929
1011
|
result !== undefined &&
|
|
@@ -932,8 +1014,7 @@ function fusedRespondNoSet(result: unknown): Response {
|
|
|
932
1014
|
result !== null &&
|
|
933
1015
|
!isResponseResult(result)
|
|
934
1016
|
) {
|
|
935
|
-
|
|
936
|
-
if (body !== undefined) return new Response(body, JSON_INIT_200)
|
|
1017
|
+
return Response.json(result)
|
|
937
1018
|
}
|
|
938
1019
|
return toResponse(result as HandlerResult, EMPTY_RESPONSE_CONTROLS)
|
|
939
1020
|
}
|
|
@@ -1118,6 +1199,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1118
1199
|
private readonly maxBodyBytes: number
|
|
1119
1200
|
private readonly wsMaxPayloadBytes: number
|
|
1120
1201
|
private readonly requestTimeoutMs: number
|
|
1202
|
+
private readonly acceptInboundDeadlines: boolean
|
|
1203
|
+
private readonly maxInboundDeadlineMs: number
|
|
1204
|
+
private readonly deadlineAdmissionOptions: DeadlineAdmissionOptions
|
|
1121
1205
|
private readonly gracefulSignals: boolean
|
|
1122
1206
|
private readonly logger: Logger
|
|
1123
1207
|
/** App-wide validation-error fallback; a route's own `schema.onValidationError` takes precedence. */
|
|
@@ -1165,6 +1249,18 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1165
1249
|
this.maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
|
|
1166
1250
|
this.wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? this.maxBodyBytes
|
|
1167
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
|
+
}
|
|
1168
1264
|
this.gracefulSignals = options.gracefulSignals ?? false
|
|
1169
1265
|
this.logger = options.logger ?? jsonLogger()
|
|
1170
1266
|
this.defaultOnValidationError = options.onValidationError
|
|
@@ -1784,6 +1880,32 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1784
1880
|
: this.applyOnResponseAndFinalize(outcome, this.takeResponseRequest(source))
|
|
1785
1881
|
}
|
|
1786
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
|
+
|
|
1787
1909
|
/**
|
|
1788
1910
|
* Like {@link fetch}, but renders a plain-data result **without** building a Web `Response` — the
|
|
1789
1911
|
* `@nifrajs/node` adapter serializes the returned primitives straight to the socket, skipping the undici
|
|
@@ -1838,10 +1960,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1838
1960
|
const url = urlPartsOf(req.url)
|
|
1839
1961
|
const match = this.wsRouter.find("GET", url.pathname)
|
|
1840
1962
|
if (!match.found) return WS_PASS // upgrade header, no WS route here → normal routing decides
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
: 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)
|
|
1845
1966
|
if (params === null) return { kind: "reject", response: jsonError(400, "malformed_path") }
|
|
1846
1967
|
const handler = match.payload.handler
|
|
1847
1968
|
// Non-null: wsRouteCount > 0 ⇒ ws() ran ⇒ the runtime created the registry.
|
|
@@ -1866,11 +1987,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1866
1987
|
if (handler.upgrade === undefined) {
|
|
1867
1988
|
return { kind: "upgrade", handler, data: undefined, pubsub }
|
|
1868
1989
|
}
|
|
1990
|
+
const upgradeSignal = getNeverAbortSignal()
|
|
1869
1991
|
const ctx = new RequestContext(
|
|
1870
1992
|
req,
|
|
1871
1993
|
params,
|
|
1872
1994
|
url.search,
|
|
1873
|
-
|
|
1995
|
+
upgradeSignal,
|
|
1996
|
+
createUnboundedRequestBudget(upgradeSignal),
|
|
1874
1997
|
platform,
|
|
1875
1998
|
this.maxBodyBytes,
|
|
1876
1999
|
)
|
|
@@ -2042,38 +2165,95 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2042
2165
|
return wrapResponse(jsonError(404, "not_found"))
|
|
2043
2166
|
}
|
|
2044
2167
|
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
: 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)
|
|
2049
2171
|
if (params === null) {
|
|
2050
2172
|
return wrapResponse(jsonError(400, "malformed_path"))
|
|
2051
2173
|
}
|
|
2052
2174
|
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
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.
|
|
2057
2224
|
let controller: AbortController | undefined
|
|
2058
2225
|
let signal = getNeverAbortSignal()
|
|
2059
|
-
if (
|
|
2226
|
+
if (effectiveTimeoutMs > 0) {
|
|
2060
2227
|
controller = new AbortController()
|
|
2061
2228
|
signal = controller.signal
|
|
2062
2229
|
}
|
|
2230
|
+
const budget =
|
|
2231
|
+
controller === undefined
|
|
2232
|
+
? getUnboundedRequestBudget()
|
|
2233
|
+
: createRequestBudget({ deadline: admission!.deadline as number, signal })
|
|
2063
2234
|
// A bare route runs synchronously (no schema/derives/hooks); everything else keeps the full async
|
|
2064
2235
|
// lifecycle. `runBare` returns a value directly for a sync handler — no promise.
|
|
2065
2236
|
let outcome: MaybePromise<T>
|
|
2066
2237
|
if (webFast && entry.fusedWeb !== undefined) {
|
|
2067
2238
|
// Fused Web lane (bare route, no around): one closure end to end. Sound cast — webFast is
|
|
2068
2239
|
// passed only by the `fetch` path, where T = Response by construction.
|
|
2069
|
-
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>
|
|
2070
2249
|
} else if (entry.contextlessBare) {
|
|
2071
2250
|
outcome = this.runContextlessBare(
|
|
2072
2251
|
entry,
|
|
2073
2252
|
source,
|
|
2074
2253
|
params,
|
|
2075
|
-
|
|
2254
|
+
search,
|
|
2076
2255
|
signal,
|
|
2256
|
+
budget,
|
|
2077
2257
|
platform,
|
|
2078
2258
|
finalize,
|
|
2079
2259
|
wrapResponse,
|
|
@@ -2084,8 +2264,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2084
2264
|
const ctx = new RequestContext(
|
|
2085
2265
|
source,
|
|
2086
2266
|
params,
|
|
2087
|
-
|
|
2267
|
+
search,
|
|
2088
2268
|
signal,
|
|
2269
|
+
budget,
|
|
2089
2270
|
platform,
|
|
2090
2271
|
this.maxBodyBytes,
|
|
2091
2272
|
)
|
|
@@ -2118,7 +2299,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2118
2299
|
// The request timeout only bounds work that is actually pending — a synchronous (bare) result is
|
|
2119
2300
|
// already complete and can't time out, so it's returned as-is (no 503 race, no promise).
|
|
2120
2301
|
if (controller !== undefined && outcome instanceof Promise) {
|
|
2121
|
-
|
|
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
|
+
)
|
|
2122
2312
|
}
|
|
2123
2313
|
return outcome
|
|
2124
2314
|
}
|
|
@@ -2129,8 +2319,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2129
2319
|
entry: RouteEntry,
|
|
2130
2320
|
source: RequestSource,
|
|
2131
2321
|
params: Record<string, string>,
|
|
2132
|
-
search: string,
|
|
2322
|
+
search: string | undefined,
|
|
2133
2323
|
signal: AbortSignal,
|
|
2324
|
+
budget: RequestBudget,
|
|
2134
2325
|
platform: Platform | undefined,
|
|
2135
2326
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
2136
2327
|
wrapResponse: (response: Response) => T,
|
|
@@ -2139,13 +2330,31 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2139
2330
|
try {
|
|
2140
2331
|
result = (entry.handler as unknown as ContextlessHandler)()
|
|
2141
2332
|
} catch (err) {
|
|
2142
|
-
return this.contextlessBareError(
|
|
2333
|
+
return this.contextlessBareError(
|
|
2334
|
+
err,
|
|
2335
|
+
source,
|
|
2336
|
+
params,
|
|
2337
|
+
search,
|
|
2338
|
+
signal,
|
|
2339
|
+
budget,
|
|
2340
|
+
platform,
|
|
2341
|
+
wrapResponse,
|
|
2342
|
+
)
|
|
2143
2343
|
}
|
|
2144
2344
|
if (result instanceof Promise) {
|
|
2145
2345
|
return result.then(
|
|
2146
2346
|
(value) => finalize(value, EMPTY_RESPONSE_CONTROLS),
|
|
2147
2347
|
(err) =>
|
|
2148
|
-
this.contextlessBareError(
|
|
2348
|
+
this.contextlessBareError(
|
|
2349
|
+
err,
|
|
2350
|
+
source,
|
|
2351
|
+
params,
|
|
2352
|
+
search,
|
|
2353
|
+
signal,
|
|
2354
|
+
budget,
|
|
2355
|
+
platform,
|
|
2356
|
+
wrapResponse,
|
|
2357
|
+
),
|
|
2149
2358
|
)
|
|
2150
2359
|
}
|
|
2151
2360
|
return finalize(result, EMPTY_RESPONSE_CONTROLS)
|
|
@@ -2155,13 +2364,22 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2155
2364
|
err: unknown,
|
|
2156
2365
|
source: RequestSource,
|
|
2157
2366
|
params: Record<string, string>,
|
|
2158
|
-
search: string,
|
|
2367
|
+
search: string | undefined,
|
|
2159
2368
|
signal: AbortSignal,
|
|
2369
|
+
budget: RequestBudget,
|
|
2160
2370
|
platform: Platform | undefined,
|
|
2161
2371
|
wrapResponse: (response: Response) => T,
|
|
2162
2372
|
): T {
|
|
2163
2373
|
if (err instanceof Response) return wrapResponse(err)
|
|
2164
|
-
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
|
+
)
|
|
2165
2383
|
this.logRequestError(err, ctx)
|
|
2166
2384
|
return wrapResponse(jsonError(500, "internal_error"))
|
|
2167
2385
|
}
|
|
@@ -2221,29 +2439,43 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2221
2439
|
// `() => ...` can't observe the context — skip allocating one entirely (errors still build
|
|
2222
2440
|
// one for the structured log, exactly like runContextlessBare).
|
|
2223
2441
|
const contextlessHandler = handler as unknown as ContextlessHandler
|
|
2224
|
-
return (source, params, search, signal, platform) => {
|
|
2442
|
+
return (source, params, search, signal, budget, platform, nativeContext) => {
|
|
2225
2443
|
let result: unknown
|
|
2226
2444
|
try {
|
|
2227
2445
|
result = contextlessHandler()
|
|
2228
2446
|
} catch (err) {
|
|
2229
2447
|
return logError(
|
|
2230
2448
|
err,
|
|
2231
|
-
|
|
2449
|
+
nativeContext
|
|
2450
|
+
? RequestContext.native(source, params, maxBodyBytes)
|
|
2451
|
+
: new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes),
|
|
2232
2452
|
)
|
|
2233
2453
|
}
|
|
2234
2454
|
if (result instanceof Promise) {
|
|
2235
2455
|
return result.then(fusedRespondNoSet, (err) =>
|
|
2236
2456
|
logError(
|
|
2237
2457
|
err,
|
|
2238
|
-
|
|
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
|
+
),
|
|
2239
2469
|
),
|
|
2240
2470
|
)
|
|
2241
2471
|
}
|
|
2242
2472
|
return fusedRespondNoSet(result)
|
|
2243
2473
|
}
|
|
2244
2474
|
}
|
|
2245
|
-
return (source, params, search, signal, platform) => {
|
|
2246
|
-
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)
|
|
2247
2479
|
if (decorations !== undefined) Object.assign(ctx, decorations)
|
|
2248
2480
|
let result: unknown
|
|
2249
2481
|
try {
|
|
@@ -2688,13 +2920,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2688
2920
|
work: Promise<T>,
|
|
2689
2921
|
controller: AbortController,
|
|
2690
2922
|
onTimeout: () => T,
|
|
2923
|
+
timeoutMs: number,
|
|
2691
2924
|
): Promise<T> {
|
|
2692
2925
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
2693
2926
|
const timeout = new Promise<T>((resolve) => {
|
|
2694
2927
|
timer = setTimeout(() => {
|
|
2695
2928
|
controller.abort()
|
|
2696
2929
|
resolve(onTimeout())
|
|
2697
|
-
},
|
|
2930
|
+
}, timeoutMs)
|
|
2698
2931
|
})
|
|
2699
2932
|
try {
|
|
2700
2933
|
return await Promise.race([work, timeout])
|
|
@@ -2853,6 +3086,163 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2853
3086
|
return readBoundedJsonSource(req, this.maxBodyBytes)
|
|
2854
3087
|
}
|
|
2855
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
|
+
|
|
2856
3246
|
/**
|
|
2857
3247
|
* Start a `Bun.serve` instance bound to `port` (use `0` for an ephemeral port).
|
|
2858
3248
|
*
|
|
@@ -2889,8 +3279,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2889
3279
|
? undefined
|
|
2890
3280
|
: (getWsRuntime() as WsRuntime).bunHandlers(this.topics as TopicRegistry)
|
|
2891
3281
|
const reusePort = options?.reusePort === true
|
|
3282
|
+
const nativeRoutes = wsHandlers === undefined ? this.buildBunNativeRoutes() : undefined
|
|
2892
3283
|
const running = (wsHandlers === undefined
|
|
2893
|
-
? Bun.serve({
|
|
3284
|
+
? Bun.serve({
|
|
3285
|
+
port,
|
|
3286
|
+
reusePort,
|
|
3287
|
+
...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
|
|
3288
|
+
fetch: (req: Request) => this.fetch(req),
|
|
3289
|
+
})
|
|
2894
3290
|
: Bun.serve<BunWsData>({
|
|
2895
3291
|
port,
|
|
2896
3292
|
reusePort,
|