@nifrajs/core 5.3.0 → 5.5.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/CHANGELOG.md +65 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/route-lanes.d.ts.map +1 -1
- package/dist/internal/route-lanes.js +2 -1
- package/dist/internal/route-lanes.js.map +1 -1
- package/dist/internal/route-program.d.ts +5 -0
- package/dist/internal/route-program.d.ts.map +1 -1
- package/dist/internal/route-program.js +36 -15
- package/dist/internal/route-program.js.map +1 -1
- package/dist/range.d.ts.map +1 -1
- package/dist/range.js +62 -2
- package/dist/range.js.map +1 -1
- package/dist/router/router.d.ts +3 -2
- package/dist/router/router.d.ts.map +1 -1
- package/dist/router/router.js +22 -6
- package/dist/router/router.js.map +1 -1
- package/dist/security/baseline.d.ts.map +1 -1
- package/dist/security/baseline.js +4 -3
- package/dist/security/baseline.js.map +1 -1
- package/dist/server/body-lane.d.ts.map +1 -1
- package/dist/server/body-lane.js +4 -3
- package/dist/server/body-lane.js.map +1 -1
- package/dist/server/body.d.ts +2 -0
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +5 -0
- package/dist/server/body.js.map +1 -1
- package/dist/server/cookies.d.ts.map +1 -1
- package/dist/server/cookies.js +9 -2
- package/dist/server/cookies.js.map +1 -1
- package/dist/server/core-copies.js +1 -1
- package/dist/server/idempotency-lane.d.ts +11 -12
- package/dist/server/idempotency-lane.d.ts.map +1 -1
- package/dist/server/idempotency-lane.js +301 -151
- package/dist/server/idempotency-lane.js.map +1 -1
- package/dist/server/methods.d.ts +3 -2
- package/dist/server/methods.d.ts.map +1 -1
- package/dist/server/methods.js.map +1 -1
- package/dist/server/multipart.d.ts.map +1 -1
- package/dist/server/multipart.js +42 -13
- package/dist/server/multipart.js.map +1 -1
- package/dist/server/node-outcome.d.ts.map +1 -1
- package/dist/server/node-outcome.js +2 -2
- package/dist/server/node-outcome.js.map +1 -1
- package/dist/server/query.d.ts +1 -1
- package/dist/server/query.d.ts.map +1 -1
- package/dist/server/query.js +13 -5
- package/dist/server/query.js.map +1 -1
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +3 -3
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/respond.d.ts +17 -0
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +81 -3
- package/dist/server/respond.js.map +1 -1
- package/dist/server/runtime-core.d.ts +15 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js +41 -1
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server.d.ts +14 -5
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +187 -62
- package/dist/server/server.js.map +1 -1
- package/dist/server/transport-lane.d.ts +2 -2
- package/dist/server/transport-lane.d.ts.map +1 -1
- package/dist/server/transport-lane.js +20 -20
- package/dist/server/transport-lane.js.map +1 -1
- package/dist/static-file.d.ts +127 -0
- package/dist/static-file.d.ts.map +1 -0
- package/dist/static-file.js +269 -0
- package/dist/static-file.js.map +1 -0
- package/package.json +6 -1
- package/src/index.ts +1 -1
- package/src/internal/route-lanes.ts +18 -16
- package/src/internal/route-program.ts +76 -12
- package/src/range.ts +61 -2
- package/src/router/router.ts +26 -5
- package/src/security/baseline.ts +4 -3
- package/src/server/body-lane.ts +4 -4
- package/src/server/body.ts +6 -0
- package/src/server/cookies.ts +7 -2
- package/src/server/core-copies.ts +1 -1
- package/src/server/idempotency-lane.ts +343 -209
- package/src/server/methods.ts +3 -2
- package/src/server/multipart.ts +62 -14
- package/src/server/node-outcome.ts +2 -1
- package/src/server/query.ts +12 -5
- package/src/server/request-context.ts +6 -3
- package/src/server/respond.ts +108 -6
- package/src/server/runtime-core.ts +54 -1
- package/src/server/server.ts +257 -102
- package/src/server/transport-lane.ts +28 -22
- package/src/static-file.ts +362 -0
package/src/server/server.ts
CHANGED
|
@@ -53,7 +53,11 @@ import type { ProgramValidationKind, RouteProgramStage } from "../internal/route
|
|
|
53
53
|
import { compileRouteProgram, executeRouteProgram } from "../internal/route-program.ts"
|
|
54
54
|
import { isSameOriginRequest } from "../internal/same-origin.ts"
|
|
55
55
|
import type { ResponseObserverPlugin } from "../response-observer.ts"
|
|
56
|
-
import {
|
|
56
|
+
import {
|
|
57
|
+
decodeRouteParams,
|
|
58
|
+
expandOptionalParams,
|
|
59
|
+
type RoutePatternSegment,
|
|
60
|
+
} from "../router/pattern.ts"
|
|
57
61
|
import { EMPTY_PARAMS, METHODS, type Method, Router } from "../router/router.ts"
|
|
58
62
|
import type {
|
|
59
63
|
InferOutput,
|
|
@@ -117,8 +121,11 @@ import { needsResolving, resolveDotSegments } from "./request-target.ts"
|
|
|
117
121
|
import {
|
|
118
122
|
applyStaticResponseHeaders,
|
|
119
123
|
buildStaticResponseHeaders,
|
|
124
|
+
claimHandedOver,
|
|
120
125
|
fusedRespond,
|
|
121
126
|
fusedRespondNoSet,
|
|
127
|
+
handedBackResponse,
|
|
128
|
+
recordResidentTexts,
|
|
122
129
|
toResponse,
|
|
123
130
|
writableResponse,
|
|
124
131
|
} from "./respond.ts"
|
|
@@ -403,6 +410,9 @@ function bunPeerPlatform(server: BunPeerServer, req: Request): Platform {
|
|
|
403
410
|
|
|
404
411
|
type BunNativeHandler = (request: Request, server: BunPeerServer) => MaybePromise<Response>
|
|
405
412
|
type BunNativeMethodTable = Partial<Record<Method, BunNativeHandler>>
|
|
413
|
+
// Bun's route table has a slot for each of these; a QUERY entry is ignored and the request falls
|
|
414
|
+
// through to the table's fetch, so QUERY routes stay on the portable dispatcher.
|
|
415
|
+
const BUN_TABLE_METHODS: readonly Method[] = METHODS.filter((method) => method !== "QUERY")
|
|
406
416
|
type BunNativeRoutes = Record<string, BunNativeMethodTable>
|
|
407
417
|
type BunRequestWithParams = Request & { readonly params?: Record<string, string> }
|
|
408
418
|
|
|
@@ -431,6 +441,21 @@ function outranks(
|
|
|
431
441
|
return ahead && other.length === route.length
|
|
432
442
|
}
|
|
433
443
|
|
|
444
|
+
/**
|
|
445
|
+
* The key Bun's route table holds `path` under. Bun refuses one with a character outside printable
|
|
446
|
+
* ASCII, so such a path is spelled as the portable router matches it: percent-encoded static
|
|
447
|
+
* segments and `:name` parameters. `segments` holds static and `:param` segments only.
|
|
448
|
+
*/
|
|
449
|
+
function bunTablePath(path: string, segments: readonly RoutePatternSegment[]): string {
|
|
450
|
+
if (!/[^ -~]/.test(path)) return path
|
|
451
|
+
let key = ""
|
|
452
|
+
for (const segment of segments) {
|
|
453
|
+
if (segment.kind === "static") key += `/${segment.value}`
|
|
454
|
+
else if (segment.kind === "param") key += `/:${segment.name}`
|
|
455
|
+
}
|
|
456
|
+
return key
|
|
457
|
+
}
|
|
458
|
+
|
|
434
459
|
const WS_PASS: WebSocketUpgradeOutcome = { kind: "pass" }
|
|
435
460
|
|
|
436
461
|
const DEFAULT_WS_UPGRADE_TIMEOUT_MS = 10_000
|
|
@@ -620,8 +645,9 @@ const withResolvedTarget = (req: Request): Request => new Request(resolveDotSegm
|
|
|
620
645
|
// request for `/public` into one for `/admin`. Node refuses such a Host before routing; so does this.
|
|
621
646
|
const HOST_AUTHORITY = /^(?:\[[0-9a-f:.%]+\]|[\w.-]+)(?::\d{1,5})?$/i
|
|
622
647
|
|
|
648
|
+
// `headerOf`, not `req.headers`: on Deno that reads the Host alone instead of building every header.
|
|
623
649
|
const invalidHost = (req: Request): boolean => {
|
|
624
|
-
const host = req
|
|
650
|
+
const host = headerOf(req, "host")
|
|
625
651
|
return host !== null && !HOST_AUTHORITY.test(host)
|
|
626
652
|
}
|
|
627
653
|
|
|
@@ -795,6 +821,16 @@ const SERVER_RUNTIMES = [
|
|
|
795
821
|
const IDENTITY_RESPONSE = (response: Response | ResponseResult): Response => webResponseOf(response)
|
|
796
822
|
const RESPONSE_TIMEOUT = (): Response => jsonError(503, "request_timeout")
|
|
797
823
|
|
|
824
|
+
/** An `onRequest` hook's answer, held to the single-use check a handler's result gets. */
|
|
825
|
+
function earlyAnswer<T>(
|
|
826
|
+
answer: Response | ResponseResult,
|
|
827
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
828
|
+
): T {
|
|
829
|
+
return answer instanceof Response && !claimHandedOver(answer)
|
|
830
|
+
? wrapResponse(plainError(500, "internal_error"))
|
|
831
|
+
: wrapResponse(answer)
|
|
832
|
+
}
|
|
833
|
+
|
|
798
834
|
// The unused-order-scoped-hook audit (recorder, seal check, stack capture, message) is a dev-time
|
|
799
835
|
// diagnostic. Every guard below keeps the INLINE literal `process.env.NODE_ENV !== "production"` at the
|
|
800
836
|
// branch - never a shared const holding the folded boolean - because a bundler's `process.env.NODE_ENV`
|
|
@@ -861,6 +897,9 @@ interface HookAuditState {
|
|
|
861
897
|
checked: boolean
|
|
862
898
|
}
|
|
863
899
|
const hookAudit = /* @__PURE__ */ new WeakMap<object, HookAuditState>()
|
|
900
|
+
// Audits begun and not yet sealed. Zero outside development, so a request tests it before the
|
|
901
|
+
// NODE_ENV literal and never reads `process.env` (a native call of ~130 ns on Node).
|
|
902
|
+
let unsealedHookAudits = 0
|
|
864
903
|
|
|
865
904
|
/** Whether `from` reaches `target` through mounts that keep the path and take a request under `path`.
|
|
866
905
|
* Module-level, not a `Server` method, so a production bundle drops it with its dev-only caller. */
|
|
@@ -892,6 +931,7 @@ const beginHookAudit = (
|
|
|
892
931
|
policy: "warn" | "error" | "off",
|
|
893
932
|
hasCustomLogger: boolean,
|
|
894
933
|
): void => {
|
|
934
|
+
unsealedHookAudits++
|
|
895
935
|
hookAudit.set(key, {
|
|
896
936
|
policy,
|
|
897
937
|
hasCustomLogger,
|
|
@@ -1007,6 +1047,7 @@ const sealHookAudit = (
|
|
|
1007
1047
|
const state = hookAudit.get(key)
|
|
1008
1048
|
if (state === undefined || state.checked) return
|
|
1009
1049
|
state.checked = true
|
|
1050
|
+
unsealedHookAudits--
|
|
1010
1051
|
if (state.policy === "off" || total + sockets === 0) return
|
|
1011
1052
|
const own = total - state.merged
|
|
1012
1053
|
const dead = state.sites.filter(
|
|
@@ -1150,6 +1191,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1150
1191
|
/** App-wide validation-error fallback; a route's own `schema.onValidationError` takes precedence. */
|
|
1151
1192
|
private readonly defaultOnValidationError?: RouteSchema["onValidationError"]
|
|
1152
1193
|
private bunServer: RunningServer | undefined
|
|
1194
|
+
// Bun's `pendingRequests` leaves out upgraded sockets, so `stop()` closes these itself.
|
|
1195
|
+
private bunSockets: Set<{ close(code?: number): void }> | undefined
|
|
1196
|
+
private signalHandler: (() => void) | undefined
|
|
1153
1197
|
private sealed: boolean
|
|
1154
1198
|
private readonly derives: RawDerive[]
|
|
1155
1199
|
/** Dedicated authentication stages captured by subsequent routes. */
|
|
@@ -1535,7 +1579,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1535
1579
|
/**
|
|
1536
1580
|
* Run before routing on the raw request. Return a `Response` or a `status()` result to
|
|
1537
1581
|
* short-circuit, or a replacement `Request` to continue routing with a rewritten method/URL/headers.
|
|
1538
|
-
* Global.
|
|
1582
|
+
* Global. A throw or rejection goes to every registered {@link onError} (route-less context), then
|
|
1583
|
+
* the logged default 500.
|
|
1539
1584
|
*/
|
|
1540
1585
|
onRequest(
|
|
1541
1586
|
fn: (req: Request, platform?: Platform<EnvOf<Ctx>>) => MaybePromise<OnRequestResult>,
|
|
@@ -1639,8 +1684,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1639
1684
|
|
|
1640
1685
|
/** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500, or 503
|
|
1641
1686
|
* `auth_unavailable` for a failed {@link authenticate} stage). Order-scoped: captured by routes
|
|
1642
|
-
* declared *after* this call, none before; one added after the last route reaches
|
|
1643
|
-
* {@link ServerOptions.unusedScopedHooks}). */
|
|
1687
|
+
* declared *after* this call, none before; one added after the last route reaches no route (see
|
|
1688
|
+
* {@link ServerOptions.unusedScopedHooks}). Every one answers an {@link onRequest} failure. */
|
|
1644
1689
|
onError<H extends (error: unknown, context: Context & Ctx) => MaybePromise<unknown>>(
|
|
1645
1690
|
fn: H,
|
|
1646
1691
|
): Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>> {
|
|
@@ -1745,6 +1790,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1745
1790
|
if (this.responseBodyTag === undefined) {
|
|
1746
1791
|
this.responseBodyTag = Object.freeze({})
|
|
1747
1792
|
this.responseBodyOwners.add(this.responseBodyTag)
|
|
1793
|
+
recordResidentTexts()
|
|
1748
1794
|
}
|
|
1749
1795
|
return this.responseBodyTag
|
|
1750
1796
|
}
|
|
@@ -2636,6 +2682,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2636
2682
|
if (this.responseBodyTag === undefined) {
|
|
2637
2683
|
this.responseBodyTag = Object.freeze({})
|
|
2638
2684
|
this.responseBodyOwners.add(this.responseBodyTag)
|
|
2685
|
+
recordResidentTexts()
|
|
2639
2686
|
}
|
|
2640
2687
|
return this.responseBodyTag
|
|
2641
2688
|
},
|
|
@@ -3193,8 +3240,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
3193
3240
|
platform?: Platform<EnvOf<Ctx>>,
|
|
3194
3241
|
): MaybePromise<Response> {
|
|
3195
3242
|
// Seal once on the first request so test servers (no `listen()`) still get the dead-hook check.
|
|
3196
|
-
// After that it is
|
|
3197
|
-
if (
|
|
3243
|
+
// After that it is one integer compare; the literal stays so a production define drops the branch.
|
|
3244
|
+
if (unsealedHookAudits !== 0 && process.env.NODE_ENV !== "production")
|
|
3198
3245
|
sealHookAudit(this, this.catalog.size, this.wsRoutes.length, this.logger)
|
|
3199
3246
|
// Off path (default): straight through - one property check, no closure, no promise.
|
|
3200
3247
|
if (this.capacityGate === undefined) return this.fetchSourceInner(source, platform)
|
|
@@ -3317,6 +3364,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
3317
3364
|
* a path whose registered route rejects this method.
|
|
3318
3365
|
*/
|
|
3319
3366
|
[RESOLVE_NODE_MOUNT](source: RequestSource): NativeMountSelection | undefined {
|
|
3367
|
+
// Node asks before every dispatch; an app with no mounts cannot select a native handler.
|
|
3368
|
+
if (this.fetchMounts.length === 0) return undefined
|
|
3320
3369
|
if (
|
|
3321
3370
|
this.onRequestHooks.length > 0 ||
|
|
3322
3371
|
this.onResponseHooks.length > 0 ||
|
|
@@ -3378,7 +3427,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
3378
3427
|
suppliedRuntime?: NodeOutcomeRuntime,
|
|
3379
3428
|
): MaybePromise<NodeServeOutcome> {
|
|
3380
3429
|
// Seal once on the Node-direct lane too - it can bypass `fetchSource` entirely (hookless outcome).
|
|
3381
|
-
if (
|
|
3430
|
+
if (unsealedHookAudits !== 0 && process.env.NODE_ENV !== "production")
|
|
3382
3431
|
sealHookAudit(this, this.catalog.size, this.wsRoutes.length, this.logger)
|
|
3383
3432
|
// A paired header-only native hook can preserve the Node-direct JSON/body outcome for successful
|
|
3384
3433
|
// responses. Arbitrary onResponse transforms need a real Web Response, but a buffered outcome can
|
|
@@ -3665,10 +3714,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
3665
3714
|
req: Request,
|
|
3666
3715
|
platform?: Platform<EnvOf<Ctx>>,
|
|
3667
3716
|
): MaybePromise<WebSocketUpgradeOutcome> {
|
|
3668
|
-
// Before any header is read
|
|
3669
|
-
// costs
|
|
3717
|
+
// Before any header is read, and then through `headerOf`: Deno builds a request's whole header
|
|
3718
|
+
// list when `headers` is read, which costs every plain request of an app with sockets measurably.
|
|
3670
3719
|
if (this.wsRoutes.length === 0 && !this.mountsTakeUpgrades()) return WS_PASS
|
|
3671
|
-
if (req
|
|
3720
|
+
if (headerOf(req, "upgrade")?.toLowerCase() !== "websocket") return WS_PASS
|
|
3672
3721
|
// A WebSocket handshake is a GET (RFC 6455 section 4.1). Any other method with an Upgrade header
|
|
3673
3722
|
// is an ordinary request: its HTTP route answers it, and normal routing refuses an unknown token.
|
|
3674
3723
|
if (req.method !== "GET") return WS_PASS
|
|
@@ -3842,18 +3891,31 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
3842
3891
|
this.wsHookedPasses.set(received, request)
|
|
3843
3892
|
return resolveWithDeadline(request)
|
|
3844
3893
|
}
|
|
3894
|
+
// The same boundary as an HTTP request's hooks; the answer is already wrapped, so it is not refused.
|
|
3895
|
+
const failed = (err: unknown): MaybePromise<WebSocketUpgradeOutcome> => {
|
|
3896
|
+
const answer = this.onRequestFailure(
|
|
3897
|
+
err,
|
|
3898
|
+
req,
|
|
3899
|
+
platform,
|
|
3900
|
+
this.finalizeResponse,
|
|
3901
|
+
this.wrapWebResponse,
|
|
3902
|
+
)
|
|
3903
|
+
return answer instanceof Promise
|
|
3904
|
+
? answer.then((response) => ({ kind: "reject", response }))
|
|
3905
|
+
: { kind: "reject", response: answer }
|
|
3906
|
+
}
|
|
3845
3907
|
let hooked: MaybePromise<Request | Response>
|
|
3846
3908
|
try {
|
|
3847
3909
|
hooked = this.runWebSocketRequestHooks(req, platform)
|
|
3848
|
-
} catch {
|
|
3849
|
-
return
|
|
3910
|
+
} catch (err) {
|
|
3911
|
+
return failed(err)
|
|
3850
3912
|
}
|
|
3851
3913
|
if (!(hooked instanceof Promise)) {
|
|
3852
3914
|
return hooked instanceof Response ? refuse(hooked) : resolveHooked(hooked)
|
|
3853
3915
|
}
|
|
3854
3916
|
const pending = hooked.then(
|
|
3855
3917
|
(result) => (result instanceof Response ? refuse(result) : resolveHooked(result)),
|
|
3856
|
-
|
|
3918
|
+
failed,
|
|
3857
3919
|
)
|
|
3858
3920
|
return bounded(
|
|
3859
3921
|
Promise.resolve(pending).then((result) => result),
|
|
@@ -3962,22 +4024,29 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
3962
4024
|
const request = this.nodeRequestContextOf(source)
|
|
3963
4025
|
for (let i = 0; i < hooks.length; i++) {
|
|
3964
4026
|
const hook = hooks[i] as NodeRequestHook
|
|
3965
|
-
|
|
4027
|
+
let outcome: MaybePromise<Response | undefined>
|
|
4028
|
+
try {
|
|
4029
|
+
outcome = hook(request, platform)
|
|
4030
|
+
} catch (err) {
|
|
4031
|
+
return this.onRequestFailure(err, source, platform, finalize, wrapResponse)
|
|
4032
|
+
}
|
|
3966
4033
|
if (outcome instanceof Promise) {
|
|
3967
|
-
return outcome.then(
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
4034
|
+
return outcome.then(
|
|
4035
|
+
(early) =>
|
|
4036
|
+
this.continueNodeRequest(
|
|
4037
|
+
early,
|
|
4038
|
+
i + 1,
|
|
4039
|
+
request,
|
|
4040
|
+
source,
|
|
4041
|
+
platform,
|
|
4042
|
+
finalize,
|
|
4043
|
+
wrapResponse,
|
|
4044
|
+
onTimeout,
|
|
4045
|
+
),
|
|
4046
|
+
(err) => this.onRequestFailure(err, source, platform, finalize, wrapResponse),
|
|
3978
4047
|
)
|
|
3979
4048
|
}
|
|
3980
|
-
if (outcome !== undefined) return
|
|
4049
|
+
if (outcome !== undefined) return earlyAnswer(outcome, wrapResponse)
|
|
3981
4050
|
}
|
|
3982
4051
|
return this.routeAndRun(source, platform, finalize, wrapResponse, onTimeout, false)
|
|
3983
4052
|
}
|
|
@@ -3992,12 +4061,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
3992
4061
|
wrapResponse: (response: Response | ResponseResult) => T,
|
|
3993
4062
|
onTimeout: () => T,
|
|
3994
4063
|
): Promise<T> {
|
|
3995
|
-
if (first !== undefined) return
|
|
4064
|
+
if (first !== undefined) return earlyAnswer(first, wrapResponse)
|
|
3996
4065
|
for (let i = nextIndex; i < this.onNodeRequestHooks.length; i++) {
|
|
3997
4066
|
const hook = this.onNodeRequestHooks[i] as NodeRequestHook
|
|
3998
|
-
|
|
3999
|
-
|
|
4000
|
-
|
|
4067
|
+
let early: Response | undefined
|
|
4068
|
+
try {
|
|
4069
|
+
const outcome = hook(request, platform)
|
|
4070
|
+
early = outcome instanceof Promise ? await outcome : outcome
|
|
4071
|
+
} catch (err) {
|
|
4072
|
+
return this.onRequestFailure(err, source, platform, finalize, wrapResponse)
|
|
4073
|
+
}
|
|
4074
|
+
if (early !== undefined) return earlyAnswer(early, wrapResponse)
|
|
4001
4075
|
}
|
|
4002
4076
|
return this.routeAndRun(source, platform, finalize, wrapResponse, onTimeout, false)
|
|
4003
4077
|
}
|
|
@@ -4030,20 +4104,27 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4030
4104
|
let current: RequestSource = source
|
|
4031
4105
|
for (let i = 0; i < hooks.length; i++) {
|
|
4032
4106
|
const seen = requestOf(current)
|
|
4033
|
-
|
|
4107
|
+
let outcome: MaybePromise<OnRequestResult>
|
|
4108
|
+
try {
|
|
4109
|
+
outcome = (hooks[i] as RawOnRequest)(seen, platform)
|
|
4110
|
+
} catch (err) {
|
|
4111
|
+
return this.onRequestFailure(err, current, platform, finalize, wrapResponse)
|
|
4112
|
+
}
|
|
4034
4113
|
if (outcome instanceof Promise) {
|
|
4035
|
-
return outcome.then(
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4039
|
-
|
|
4040
|
-
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4114
|
+
return outcome.then(
|
|
4115
|
+
(early) =>
|
|
4116
|
+
this.continueOnRequest(
|
|
4117
|
+
early,
|
|
4118
|
+
i + 1,
|
|
4119
|
+
originalRequest,
|
|
4120
|
+
current,
|
|
4121
|
+
platform,
|
|
4122
|
+
finalize,
|
|
4123
|
+
wrapResponse,
|
|
4124
|
+
onTimeout,
|
|
4125
|
+
webFast,
|
|
4126
|
+
),
|
|
4127
|
+
(err) => this.onRequestFailure(err, current, platform, finalize, wrapResponse),
|
|
4047
4128
|
)
|
|
4048
4129
|
}
|
|
4049
4130
|
if (outcome instanceof Request) {
|
|
@@ -4052,7 +4133,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4052
4133
|
if (outcome !== originalRequest) this.responseRequests.set(originalRequest, outcome)
|
|
4053
4134
|
continue
|
|
4054
4135
|
}
|
|
4055
|
-
if (outcome !== undefined) return
|
|
4136
|
+
if (outcome !== undefined) return earlyAnswer(outcome, wrapResponse)
|
|
4056
4137
|
}
|
|
4057
4138
|
return this.routeAndRun(current, platform, finalize, wrapResponse, onTimeout, webFast)
|
|
4058
4139
|
}
|
|
@@ -4080,12 +4161,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4080
4161
|
current = early
|
|
4081
4162
|
if (early !== originalRequest) this.responseRequests.set(originalRequest, early)
|
|
4082
4163
|
} else if (early !== undefined) {
|
|
4083
|
-
return
|
|
4164
|
+
return earlyAnswer(early, wrapResponse)
|
|
4084
4165
|
}
|
|
4085
4166
|
if (index >= this.onRequestHooks.length) break
|
|
4086
4167
|
seen = requestOf(current)
|
|
4087
|
-
|
|
4088
|
-
|
|
4168
|
+
try {
|
|
4169
|
+
const outcome = (this.onRequestHooks[index] as RawOnRequest)(seen, platform)
|
|
4170
|
+
early = outcome instanceof Promise ? await outcome : outcome
|
|
4171
|
+
} catch (err) {
|
|
4172
|
+
return this.onRequestFailure(err, current, platform, finalize, wrapResponse)
|
|
4173
|
+
}
|
|
4089
4174
|
index++
|
|
4090
4175
|
}
|
|
4091
4176
|
return this.routeAndRun(current, platform, finalize, wrapResponse, onTimeout, webFast)
|
|
@@ -4337,34 +4422,26 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4337
4422
|
const method = source.method
|
|
4338
4423
|
if (method !== "GET" && method !== "HEAD") markTransportCap(source, entry.bodyLimit)
|
|
4339
4424
|
}
|
|
4340
|
-
// An idempotency route
|
|
4341
|
-
//
|
|
4425
|
+
// An idempotency route meets its dedupe gate inside the program, so the gate runs after
|
|
4426
|
+
// authentication and under the request deadline. Its lanes render a `Response`, which the gate
|
|
4427
|
+
// stores. Non-idempotent routes skip straight to the lanes - no added cost.
|
|
4342
4428
|
// The runtime is always present when a route resolved idempotency (enforced at registration).
|
|
4343
4429
|
if (entry.idempotent !== undefined && this.idempotencyRuntime !== undefined) {
|
|
4344
|
-
return this.idempotencyRuntime
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
prm,
|
|
4360
|
-
srch,
|
|
4361
|
-
this.finalizeResponse,
|
|
4362
|
-
this.wrapWebResponse,
|
|
4363
|
-
this.webResponseTimeout,
|
|
4364
|
-
false,
|
|
4365
|
-
),
|
|
4366
|
-
},
|
|
4367
|
-
)
|
|
4430
|
+
return this.idempotencyRuntime
|
|
4431
|
+
.run(source, platform, (prepared) =>
|
|
4432
|
+
this.runMatchedLanes(
|
|
4433
|
+
prepared,
|
|
4434
|
+
platform,
|
|
4435
|
+
entry,
|
|
4436
|
+
params,
|
|
4437
|
+
search,
|
|
4438
|
+
this.finalizeResponse,
|
|
4439
|
+
this.wrapWebResponse,
|
|
4440
|
+
this.webResponseTimeout,
|
|
4441
|
+
false,
|
|
4442
|
+
),
|
|
4443
|
+
)
|
|
4444
|
+
.then(wrapResponse)
|
|
4368
4445
|
}
|
|
4369
4446
|
return this.runMatchedLanes(
|
|
4370
4447
|
source,
|
|
@@ -6166,7 +6243,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
6166
6243
|
const handed = current
|
|
6167
6244
|
return next.then((settled) => this.continueOnResponse(settled, i + 1, req, handed))
|
|
6168
6245
|
}
|
|
6169
|
-
current = next === current ? next :
|
|
6246
|
+
current = next === current ? next : handedBackResponse(next, req)
|
|
6170
6247
|
}
|
|
6171
6248
|
return current
|
|
6172
6249
|
}
|
|
@@ -6242,11 +6319,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
6242
6319
|
req: Request,
|
|
6243
6320
|
handed: Response,
|
|
6244
6321
|
): Promise<Response> {
|
|
6245
|
-
let current = response === handed ? response :
|
|
6322
|
+
let current = response === handed ? response : handedBackResponse(response, req)
|
|
6246
6323
|
for (let i = nextIndex; i < this.onResponseHooks.length; i++) {
|
|
6247
6324
|
const next = (this.onResponseHooks[i] as RawOnResponse)(current, req)
|
|
6248
6325
|
const settled = next instanceof Promise ? await next : next
|
|
6249
|
-
current = settled === current ? settled :
|
|
6326
|
+
current = settled === current ? settled : handedBackResponse(settled, req)
|
|
6250
6327
|
}
|
|
6251
6328
|
return current
|
|
6252
6329
|
}
|
|
@@ -6356,13 +6433,52 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
6356
6433
|
this.logRequestError(err, ctx)
|
|
6357
6434
|
return fallback === undefined ? wrapResponse(plainError(500, "internal_error")) : fallback()
|
|
6358
6435
|
}
|
|
6359
|
-
return this.runErrorHooks(
|
|
6436
|
+
return this.runErrorHooks(
|
|
6437
|
+
entry.onError,
|
|
6438
|
+
entry.responseContract,
|
|
6439
|
+
err,
|
|
6440
|
+
ctx,
|
|
6441
|
+
finalize,
|
|
6442
|
+
wrapResponse,
|
|
6443
|
+
fallback,
|
|
6444
|
+
)
|
|
6445
|
+
}
|
|
6446
|
+
|
|
6447
|
+
/** An `onRequest` hook threw or rejected. No route has matched, so every `onError` the app holds
|
|
6448
|
+
* answers it, with a route-less context; otherwise the logged default 500, as for a handler. */
|
|
6449
|
+
private onRequestFailure<T>(
|
|
6450
|
+
err: unknown,
|
|
6451
|
+
source: RequestSource,
|
|
6452
|
+
platform: Platform | undefined,
|
|
6453
|
+
finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
|
|
6454
|
+
wrapResponse: (response: Response | ResponseResult) => T,
|
|
6455
|
+
): MaybePromise<T> {
|
|
6456
|
+
if (err instanceof Response || isResponseResult(err)) return wrapResponse(err)
|
|
6457
|
+
const ctx = new RequestContext(
|
|
6458
|
+
source,
|
|
6459
|
+
EMPTY_PARAMS,
|
|
6460
|
+
undefined,
|
|
6461
|
+
getNeverAbortSignal(),
|
|
6462
|
+
getUnboundedRequestBudget(),
|
|
6463
|
+
platform,
|
|
6464
|
+
this.maxBodyBytes,
|
|
6465
|
+
this.protoPoisoning,
|
|
6466
|
+
)
|
|
6467
|
+
if (this.onErrorHooks.length === 0) {
|
|
6468
|
+
this.logRequestError(err, ctx)
|
|
6469
|
+
return this.internalErrorResponse(wrapResponse)
|
|
6470
|
+
}
|
|
6471
|
+
Object.assign(ctx, this.decorations)
|
|
6472
|
+
return this.runErrorHooks(this.onErrorHooks, undefined, err, ctx, finalize, wrapResponse)
|
|
6360
6473
|
}
|
|
6361
6474
|
|
|
6362
6475
|
/** The `onError` chain, split off {@link handleLifecycleError} so its `await` costs only the routes
|
|
6363
|
-
* that registered a hook. A hook may return a custom response; otherwise the default 500 stands.
|
|
6476
|
+
* that registered a hook. A hook may return a custom response; otherwise the default 500 stands.
|
|
6477
|
+
* Never rejects: nothing outside it re-enters `onError`, so a hook that throws, or an answer that
|
|
6478
|
+
* cannot be rendered, is logged and answered here. */
|
|
6364
6479
|
private async runErrorHooks<T>(
|
|
6365
|
-
|
|
6480
|
+
hooks: readonly RawErrorHandler[],
|
|
6481
|
+
contract: RouteEntry["responseContract"],
|
|
6366
6482
|
err: unknown,
|
|
6367
6483
|
ctx: RawContext,
|
|
6368
6484
|
finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
|
|
@@ -6371,19 +6487,32 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
6371
6487
|
): Promise<T> {
|
|
6372
6488
|
// Indexed for the same reason as the `beforeHandle` chain above: the iterator would outlive
|
|
6373
6489
|
// the `await` and stay heap-allocated on both engines.
|
|
6374
|
-
for (let i = 0; i <
|
|
6375
|
-
|
|
6376
|
-
|
|
6490
|
+
for (let i = 0; i < hooks.length; i++) {
|
|
6491
|
+
let handled: unknown
|
|
6492
|
+
try {
|
|
6493
|
+
const outcome = hooks[i]!(err, ctx)
|
|
6494
|
+
handled = outcome instanceof Promise ? await outcome : outcome
|
|
6495
|
+
} catch (hookError) {
|
|
6496
|
+
// A thrown Response or status() is the hook's answer, as from beforeHandle.
|
|
6497
|
+
if (!(hookError instanceof Response) && !isResponseResult(hookError)) {
|
|
6498
|
+
this.logRequestError(err, ctx)
|
|
6499
|
+
this.logRequestError(hookError, ctx)
|
|
6500
|
+
return fallback === undefined
|
|
6501
|
+
? wrapResponse(plainError(500, "internal_error"))
|
|
6502
|
+
: fallback()
|
|
6503
|
+
}
|
|
6504
|
+
handled = hookError
|
|
6505
|
+
}
|
|
6377
6506
|
if (handled === undefined) continue
|
|
6378
|
-
|
|
6379
|
-
if (contract === undefined || (handled !== ctx.jsonReply && !isResponseResult(handled)))
|
|
6380
|
-
return finalize(handled, responseSet(ctx), ctx)
|
|
6381
|
-
// Checked here, not through finishLifecycleContract: a throwing schema must not re-enter onError.
|
|
6507
|
+
// Awaited so an asynchronous finalize's rejection lands in the catch too.
|
|
6382
6508
|
try {
|
|
6509
|
+
if (contract === undefined || (handled !== ctx.jsonReply && !isResponseResult(handled)))
|
|
6510
|
+
return await finalize(handled, responseSet(ctx), ctx)
|
|
6511
|
+
// Checked here, not through finishLifecycleContract: a throwing schema must not re-enter onError.
|
|
6383
6512
|
const checked = await contract.runtime.check(contract.definition, handled, ctx)
|
|
6384
|
-
return this.finishContractOutcome(ctx, finalize, wrapResponse, checked)
|
|
6385
|
-
} catch (
|
|
6386
|
-
this.logRequestError(
|
|
6513
|
+
return await this.finishContractOutcome(ctx, finalize, wrapResponse, checked)
|
|
6514
|
+
} catch (finishError) {
|
|
6515
|
+
this.logRequestError(finishError, ctx)
|
|
6387
6516
|
return wrapResponse(plainError(500, "internal_error"))
|
|
6388
6517
|
}
|
|
6389
6518
|
}
|
|
@@ -6652,9 +6781,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
6652
6781
|
// Where Bun could pass a request down: the methods served natively on a path with a parameter,
|
|
6653
6782
|
// as segment count + method. Two table paths match one request only at the same segment count.
|
|
6654
6783
|
const below = new Set<string>()
|
|
6655
|
-
const tableAt = (
|
|
6656
|
-
|
|
6657
|
-
|
|
6784
|
+
const tableAt = (
|
|
6785
|
+
path: string,
|
|
6786
|
+
segments: readonly RoutePatternSegment[],
|
|
6787
|
+
): BunNativeMethodTable => {
|
|
6788
|
+
const key = bunTablePath(path, segments)
|
|
6789
|
+
routes[key] ??= Object.create(null) as BunNativeMethodTable
|
|
6790
|
+
return routes[key]
|
|
6658
6791
|
}
|
|
6659
6792
|
for (let i = 0; i < all.length; i++) {
|
|
6660
6793
|
const { method, path, pattern, entry } = all[i]!
|
|
@@ -6667,7 +6800,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
6667
6800
|
if (
|
|
6668
6801
|
run === undefined ||
|
|
6669
6802
|
entry.idempotent !== undefined ||
|
|
6670
|
-
!
|
|
6803
|
+
!BUN_TABLE_METHODS.includes(method as Method) ||
|
|
6671
6804
|
(preflightOnFallback && method === "OPTIONS") ||
|
|
6672
6805
|
under.get(run)?.some((other) => outranks(other.pattern.segments, pattern.segments))
|
|
6673
6806
|
) {
|
|
@@ -6675,7 +6808,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
6675
6808
|
}
|
|
6676
6809
|
const paramNames = pattern.paramNames
|
|
6677
6810
|
const fused = mayUseFusedNative ? entry.execution.fusedWeb : undefined
|
|
6678
|
-
tableAt(path)[method as Method] = this.compileBunNativeHandler(
|
|
6811
|
+
tableAt(path, pattern.segments)[method as Method] = this.compileBunNativeHandler(
|
|
6679
6812
|
entry,
|
|
6680
6813
|
paramNames,
|
|
6681
6814
|
fused,
|
|
@@ -6706,8 +6839,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
6706
6839
|
for (let i = 0; i < all.length; i++) {
|
|
6707
6840
|
if (lead[i] === undefined) continue
|
|
6708
6841
|
const { path, pattern } = all[i]!
|
|
6709
|
-
for (const method of
|
|
6710
|
-
if (below.has(pattern.segments.length + method))
|
|
6842
|
+
for (const method of BUN_TABLE_METHODS) {
|
|
6843
|
+
if (below.has(pattern.segments.length + method))
|
|
6844
|
+
tableAt(path, pattern.segments)[method] ??= fallback
|
|
6711
6845
|
}
|
|
6712
6846
|
}
|
|
6713
6847
|
return routes
|
|
@@ -6826,6 +6960,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
6826
6960
|
}
|
|
6827
6961
|
const nativeRoutes =
|
|
6828
6962
|
wsHandlers === undefined ? this.buildBunNativeRoutes(fallback, peer) : undefined
|
|
6963
|
+
const sockets = new Set<{ close(code?: number): void }>()
|
|
6964
|
+
this.bunSockets = sockets
|
|
6829
6965
|
const running = (wsHandlers === undefined
|
|
6830
6966
|
? Bun.serve({
|
|
6831
6967
|
...listening,
|
|
@@ -6843,9 +6979,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
6843
6979
|
// closes an over-cap connection before the handler runs. Default 1 MB (maxBodyBytes). One
|
|
6844
6980
|
// limit per listener: the largest cap among this app and the apps mounted on it.
|
|
6845
6981
|
maxPayloadLength: wsHandlers.maxPayloadLength,
|
|
6846
|
-
open: (ws) =>
|
|
6982
|
+
open: (ws) => {
|
|
6983
|
+
sockets.add(ws)
|
|
6984
|
+
wsHandlers.open(ws)
|
|
6985
|
+
// An upgrade accepted while `stop()` drains gets the close the earlier sockets got.
|
|
6986
|
+
if (this.bunServer !== running) ws.close(1001)
|
|
6987
|
+
},
|
|
6847
6988
|
message: (ws, message) => wsHandlers.message(ws, message),
|
|
6848
|
-
close: (ws, code, reason) =>
|
|
6989
|
+
close: (ws, code, reason) => {
|
|
6990
|
+
sockets.delete(ws)
|
|
6991
|
+
wsHandlers.close(ws, code, reason)
|
|
6992
|
+
},
|
|
6849
6993
|
},
|
|
6850
6994
|
})) as unknown as RunningServer
|
|
6851
6995
|
this.bunServer = running
|
|
@@ -6873,17 +7017,27 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
6873
7017
|
* `stop()`'s promise drops in-flight requests), and decide graceful-vs-forced in
|
|
6874
7018
|
* ONE call (Bun can't escalate an already-graceful `stop()` to a forced close).
|
|
6875
7019
|
* New connections may be accepted during the drain window; in a real deploy the
|
|
6876
|
-
* load balancer has already stopped routing here, and `drainMs` bounds it.
|
|
7020
|
+
* load balancer has already stopped routing here, and `drainMs` bounds it. Open WebSockets
|
|
7021
|
+
* get a 1001 close at once and the same window to finish it, before any `onStop` hook runs.
|
|
6877
7022
|
*/
|
|
6878
7023
|
async stop({ drainMs = DEFAULT_DRAIN_MS }: { drainMs?: number } = {}): Promise<void> {
|
|
7024
|
+
const onSignal = this.signalHandler
|
|
7025
|
+
if (onSignal !== undefined) {
|
|
7026
|
+
this.signalHandler = undefined
|
|
7027
|
+
process.off("SIGTERM", onSignal).off("SIGINT", onSignal)
|
|
7028
|
+
}
|
|
6879
7029
|
const server = this.bunServer
|
|
6880
7030
|
if (server === undefined) return
|
|
6881
7031
|
this.bunServer = undefined
|
|
7032
|
+
const sockets = this.bunSockets
|
|
7033
|
+
this.bunSockets = undefined
|
|
7034
|
+
if (sockets !== undefined) for (const ws of sockets) ws.close(1001)
|
|
7035
|
+
const busy = (): boolean => server.pendingRequests + (sockets?.size ?? 0) > 0
|
|
6882
7036
|
const deadline = Date.now() + drainMs
|
|
6883
|
-
while (
|
|
7037
|
+
while (busy() && Date.now() < deadline) {
|
|
6884
7038
|
await Bun.sleep(DRAIN_POLL_MS)
|
|
6885
7039
|
}
|
|
6886
|
-
server.stop(
|
|
7040
|
+
server.stop(busy()) // force-close iff stragglers remain past the deadline
|
|
6887
7041
|
if (this.stopHooks.length === 0) return
|
|
6888
7042
|
const callbacks = this.stopHooks.map((hook) => Promise.resolve().then(hook))
|
|
6889
7043
|
const settled = Promise.allSettled(callbacks)
|
|
@@ -6900,6 +7054,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
6900
7054
|
const onSignal = (): void => {
|
|
6901
7055
|
void this.stop(drain)
|
|
6902
7056
|
}
|
|
7057
|
+
this.signalHandler = onSignal
|
|
6903
7058
|
process.once("SIGTERM", onSignal)
|
|
6904
7059
|
process.once("SIGINT", onSignal)
|
|
6905
7060
|
}
|