@nifrajs/core 5.5.0 → 5.6.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 +159 -0
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js.map +1 -1
- package/dist/evidence.d.ts +2 -0
- package/dist/evidence.d.ts.map +1 -1
- package/dist/evidence.js +2 -0
- package/dist/evidence.js.map +1 -1
- package/dist/file-type.d.ts +30 -0
- package/dist/file-type.d.ts.map +1 -0
- package/dist/file-type.js +118 -0
- package/dist/file-type.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/route-program.d.ts +11 -0
- package/dist/internal/route-program.d.ts.map +1 -1
- package/dist/internal/route-program.js +133 -85
- package/dist/internal/route-program.js.map +1 -1
- package/dist/reflection.d.ts +14 -1
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +34 -7
- package/dist/reflection.js.map +1 -1
- package/dist/response-observer.d.ts +1 -0
- package/dist/response-observer.d.ts.map +1 -1
- package/dist/response-observer.js +1 -0
- package/dist/response-observer.js.map +1 -1
- package/dist/server/bare-error-lane.d.ts +4 -1
- package/dist/server/bare-error-lane.d.ts.map +1 -1
- package/dist/server/bare-error-lane.js +9 -3
- package/dist/server/bare-error-lane.js.map +1 -1
- package/dist/server/before-handler.d.ts +10 -0
- package/dist/server/before-handler.d.ts.map +1 -0
- package/dist/server/before-handler.js +12 -0
- package/dist/server/before-handler.js.map +1 -0
- package/dist/server/body-lane.d.ts.map +1 -1
- package/dist/server/body-lane.js +8 -2
- package/dist/server/body-lane.js.map +1 -1
- package/dist/server/body-parser.d.ts.map +1 -1
- package/dist/server/body-parser.js +2 -5
- package/dist/server/body-parser.js.map +1 -1
- package/dist/server/body.d.ts +24 -8
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +86 -33
- package/dist/server/body.js.map +1 -1
- package/dist/server/core-copies.js +1 -1
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +6 -17
- package/dist/server/http.js.map +1 -1
- package/dist/server/multipart.d.ts +3 -0
- package/dist/server/multipart.d.ts.map +1 -1
- package/dist/server/multipart.js +226 -97
- package/dist/server/multipart.js.map +1 -1
- package/dist/server/proto-guard.d.ts.map +1 -1
- package/dist/server/proto-guard.js +39 -4
- package/dist/server/proto-guard.js.map +1 -1
- package/dist/server/query.d.ts.map +1 -1
- package/dist/server/query.js +8 -14
- package/dist/server/query.js.map +1 -1
- package/dist/server/repeated-prefix.d.ts +17 -0
- package/dist/server/repeated-prefix.d.ts.map +1 -0
- package/dist/server/repeated-prefix.js +17 -0
- package/dist/server/repeated-prefix.js.map +1 -0
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +1 -3
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/respond.d.ts +1 -3
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +21 -16
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +12 -2
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +135 -8
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/runtime-core.d.ts +7 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js +8 -1
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +6 -3
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +6 -2
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +239 -78
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse.d.ts +1 -1
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +5 -4
- package/dist/server/sse.js.map +1 -1
- package/dist/server/webhook.d.ts +1 -1
- package/dist/server/webhook.d.ts.map +1 -1
- package/dist/server/webhook.js +24 -19
- package/dist/server/webhook.js.map +1 -1
- package/dist/server/websocket.d.ts +18 -3
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +45 -5
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-bun.d.ts.map +1 -1
- package/dist/server/ws-bun.js +12 -3
- package/dist/server/ws-bun.js.map +1 -1
- package/dist/server.d.ts +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1 -0
- package/dist/server.js.map +1 -1
- package/dist/tool-cli.d.ts +64 -0
- package/dist/tool-cli.d.ts.map +1 -0
- package/dist/tool-cli.js +495 -0
- package/dist/tool-cli.js.map +1 -0
- package/package.json +11 -1
- package/src/assurance.ts +1 -0
- package/src/evidence.ts +4 -0
- package/src/file-type.ts +130 -0
- package/src/index.ts +2 -1
- package/src/internal/route-program.ts +261 -93
- package/src/reflection.ts +53 -7
- package/src/response-observer.ts +6 -0
- package/src/server/bare-error-lane.ts +9 -4
- package/src/server/before-handler.ts +14 -0
- package/src/server/body-lane.ts +19 -9
- package/src/server/body-parser.ts +1 -5
- package/src/server/body.ts +96 -36
- package/src/server/core-copies.ts +1 -1
- package/src/server/http.ts +6 -15
- package/src/server/multipart.ts +232 -100
- package/src/server/proto-guard.ts +39 -4
- package/src/server/query.ts +7 -13
- package/src/server/repeated-prefix.ts +25 -0
- package/src/server/request-context.ts +1 -3
- package/src/server/respond.ts +23 -15
- package/src/server/response-contract-lane.ts +130 -8
- package/src/server/runtime-core.ts +10 -1
- package/src/server/server-types.ts +6 -3
- package/src/server/server.ts +265 -76
- package/src/server/sse.ts +6 -5
- package/src/server/webhook.ts +29 -24
- package/src/server/websocket.ts +57 -8
- package/src/server/ws-bun.ts +17 -2
- package/src/server.ts +1 -0
- package/src/tool-cli.ts +584 -0
package/src/server/server.ts
CHANGED
|
@@ -50,7 +50,11 @@ import {
|
|
|
50
50
|
type RouteEntry,
|
|
51
51
|
} from "../internal/route-execution.ts"
|
|
52
52
|
import type { ProgramValidationKind, RouteProgramStage } from "../internal/route-program.ts"
|
|
53
|
-
import {
|
|
53
|
+
import {
|
|
54
|
+
authenticateMount,
|
|
55
|
+
compileRouteProgram,
|
|
56
|
+
executeRouteProgram,
|
|
57
|
+
} from "../internal/route-program.ts"
|
|
54
58
|
import { isSameOriginRequest } from "../internal/same-origin.ts"
|
|
55
59
|
import type { ResponseObserverPlugin } from "../response-observer.ts"
|
|
56
60
|
import {
|
|
@@ -67,8 +71,11 @@ import type {
|
|
|
67
71
|
} from "../schema/standard.ts"
|
|
68
72
|
import type { AuthenticationStage } from "./auth.ts"
|
|
69
73
|
import { emitRequestErrorLog, renderBareError } from "./bare-error-lane.ts"
|
|
74
|
+
import { LAST_BEFORE_HANDLE } from "./before-handler.ts"
|
|
70
75
|
import {
|
|
71
76
|
assertByteLimit,
|
|
77
|
+
clientBodyErrorCode,
|
|
78
|
+
hasTrustedBodyFraming,
|
|
72
79
|
markTransportCap,
|
|
73
80
|
markTrustedBodyFraming,
|
|
74
81
|
type RawBodyReaders,
|
|
@@ -378,6 +385,8 @@ interface FetchMount<Env = unknown> {
|
|
|
378
385
|
readonly fallbackOn404: boolean
|
|
379
386
|
readonly beforeRoutes: boolean
|
|
380
387
|
readonly order: number
|
|
388
|
+
/** The `authenticate()` stages registered before this mount, which gate it as they gate a route. */
|
|
389
|
+
readonly authStages: readonly RawAuthStage[]
|
|
381
390
|
/** Why this child is not analyzed (`MountOptions.opaque`), checked by reflection. */
|
|
382
391
|
readonly opaque: string | OpaqueMountDeclaration | undefined
|
|
383
392
|
/** The mounted app (`mount()` only), for reflection to tell a composed child from an opaque one. */
|
|
@@ -588,6 +597,8 @@ export {
|
|
|
588
597
|
export type { IdentityPlugin }
|
|
589
598
|
|
|
590
599
|
const DEFAULT_MAX_BODY_BYTES = 1_000_000
|
|
600
|
+
const BODY_RESPONSE = (response: Response | ResponseResult): Response | ResponseResult => response
|
|
601
|
+
const BODY_ERROR = (error: unknown): Promise<never> => Promise.reject(error)
|
|
591
602
|
const DEFAULT_DRAIN_MS = 10_000
|
|
592
603
|
const DRAIN_POLL_MS = 10
|
|
593
604
|
const STOP_HOOK_TIMEOUT_MS = 5_000
|
|
@@ -637,18 +648,104 @@ function hasUnroutedParam(params: Record<string, string>): boolean {
|
|
|
637
648
|
*/
|
|
638
649
|
const rawRequestTargets = typeof (globalThis as { Deno?: unknown }).Deno !== "undefined"
|
|
639
650
|
|
|
640
|
-
/**
|
|
641
|
-
|
|
651
|
+
/**
|
|
652
|
+
* Bun parses the target only when it can read the Host. Otherwise `Request.url` is the target as sent:
|
|
653
|
+
* relative for a Host with a space or a byte past ASCII or for no Host at all, the raw authority
|
|
654
|
+
* for a port past 65535, and `new URL(req.url)` throws in the app. So Bun is held to the Host check
|
|
655
|
+
* Deno runs.
|
|
656
|
+
*/
|
|
657
|
+
const unparsedBunTargets = typeof Bun !== "undefined"
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* `req` as Bun or workerd would have delivered it: the same request, its URL resolved as they parse it.
|
|
661
|
+
* `undefined` when the `Request` constructor refuses what the wire carried - a TRACE, CONNECT or TRACK,
|
|
662
|
+
* a lower-case `get` with a body - which {@link resolvedSource} routes instead.
|
|
663
|
+
*/
|
|
664
|
+
function withResolvedTarget(req: Request, url: string): Request | undefined {
|
|
665
|
+
let rebuilt: Request
|
|
666
|
+
try {
|
|
667
|
+
rebuilt = new Request(url, req)
|
|
668
|
+
} catch {
|
|
669
|
+
return undefined
|
|
670
|
+
}
|
|
671
|
+
// The same body, so the same framing: a runtime still delimited it.
|
|
672
|
+
if (hasTrustedBodyFraming(req)) markTrustedBodyFraming(rebuilt)
|
|
673
|
+
return rebuilt
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* A request no `Request` can be rebuilt from, as a source carrying its resolved URL: it is routed on that
|
|
678
|
+
* URL and gets the 404 or 405 the other runtimes give, where rebuilding it threw out of `fetch`. A
|
|
679
|
+
* handler that reads `c.req` gets the request as Deno delivered it.
|
|
680
|
+
*/
|
|
681
|
+
function resolvedSource(req: Request, url: string): RequestSource {
|
|
682
|
+
const source: RequestSource = {
|
|
683
|
+
method: req.method,
|
|
684
|
+
url,
|
|
685
|
+
get headers() {
|
|
686
|
+
return req.headers
|
|
687
|
+
},
|
|
688
|
+
header: (name) => headerOf(req, name),
|
|
689
|
+
get body() {
|
|
690
|
+
return req.body
|
|
691
|
+
},
|
|
692
|
+
arrayBuffer: () => req.arrayBuffer(),
|
|
693
|
+
json: () => req.json(),
|
|
694
|
+
bytes: () => req.bytes(),
|
|
695
|
+
request: req,
|
|
696
|
+
}
|
|
697
|
+
if (hasTrustedBodyFraming(req)) markTrustedBodyFraming(source)
|
|
698
|
+
return source
|
|
699
|
+
}
|
|
642
700
|
|
|
643
701
|
// A Host as RFC 9110 allows it: a reg-name or an IP literal, then an optional port. Deno builds
|
|
644
702
|
// `Request.url` by appending the target to the Host as received, so `Host: x/admin#` turned a
|
|
645
703
|
// request for `/public` into one for `/admin`. Node refuses such a Host before routing; so does this.
|
|
646
|
-
const HOST_AUTHORITY = /^(?:\[[0-9a-f:.%]+\]|[\w.-]+)(
|
|
704
|
+
const HOST_AUTHORITY = /^(?:\[[0-9a-f:.%]+\]|[\w.-]+)(?::(\d{1,5}))?$/i
|
|
705
|
+
|
|
706
|
+
// Clients repeat one Host on every request; comparing to the last valid one is ~7x cheaper than the regex.
|
|
707
|
+
let lastValidHost = ""
|
|
708
|
+
|
|
709
|
+
// A port past 65535, or an IPv6 literal no URL parser takes (`[1:2]`), is refused as Node refuses it:
|
|
710
|
+
// no URL can be built on either, so resolving a dot segment against one threw out of `fetch`.
|
|
711
|
+
const invalidHost = (host: string): boolean => {
|
|
712
|
+
if (host === lastValidHost) return false
|
|
713
|
+
const authority = HOST_AUTHORITY.exec(host)
|
|
714
|
+
if (authority === null) return true
|
|
715
|
+
if (authority[1] !== undefined && Number(authority[1]) > 65_535) return true
|
|
716
|
+
if (host.charCodeAt(0) === 91 /* [ */ && !URL.canParse(`http://${host}/`)) return true
|
|
717
|
+
lastValidHost = host
|
|
718
|
+
return false
|
|
719
|
+
}
|
|
647
720
|
|
|
648
|
-
|
|
649
|
-
|
|
721
|
+
/**
|
|
722
|
+
* What a request is routed on in place of `req.url`, before anything reads it: `true` refuses its
|
|
723
|
+
* Host (400), a URL replaces the target, `false` keeps it. Deno hands the target over as sent, so
|
|
724
|
+
* its dot segments are resolved; so does Bun when HTTP/1.0 leaves the Host out, and its route table
|
|
725
|
+
* matched `/files/..` raw. Such a request is served as `localhost`, as Node and Deno serve it.
|
|
726
|
+
* `headerOf`, not `req.headers`: on Deno that reads the Host alone instead of building every
|
|
727
|
+
* header.
|
|
728
|
+
*/
|
|
729
|
+
const requestTarget = (req: Request): string | boolean => {
|
|
730
|
+
if (!rawRequestTargets && !unparsedBunTargets) return false
|
|
650
731
|
const host = headerOf(req, "host")
|
|
651
|
-
|
|
732
|
+
if (host !== null && invalidHost(host)) {
|
|
733
|
+
if (rawRequestTargets) return true
|
|
734
|
+
// Bun served it anyway: it could not read the Host, so it left the target unparsed, or it
|
|
735
|
+
// built the URL on it - sent twice (joined with ", ", the URL on the first; RFC 9112 section
|
|
736
|
+
// 3.2 answers 400), percent-encoded (`%61.example` routed as `a.example`), or with a
|
|
737
|
+
// sub-delimiter (`a!b`). A request built in process keeps the URL its caller built, unless Bun
|
|
738
|
+
// would have built it so.
|
|
739
|
+
const routed = URL.parse(req.url)?.host
|
|
740
|
+
return routed === undefined || URL.parse(`//${host.split(", ")[0]}`, req.url)?.host === routed
|
|
741
|
+
}
|
|
742
|
+
if (rawRequestTargets) return needsResolving(req.url) && resolveDotSegments(req.url)
|
|
743
|
+
// A request built in process carries an absolute URL whatever its headers say.
|
|
744
|
+
return (
|
|
745
|
+
host === null &&
|
|
746
|
+
req.url.charCodeAt(0) === 47 /* / */ &&
|
|
747
|
+
resolveDotSegments(`http://localhost${req.url}`)
|
|
748
|
+
)
|
|
652
749
|
}
|
|
653
750
|
|
|
654
751
|
function normalizeMountPrefix(path: string): string {
|
|
@@ -881,8 +978,9 @@ interface HookAuditState {
|
|
|
881
978
|
readonly hasCustomLogger: boolean
|
|
882
979
|
readonly sites: {
|
|
883
980
|
readonly kind: string
|
|
884
|
-
/** Routes that captured the chain so far: merged routes excluded, they keep their own.
|
|
885
|
-
|
|
981
|
+
/** Routes that captured the chain so far: merged routes excluded, they keep their own. -1 once a
|
|
982
|
+
* mount captured it, which no route count matches. */
|
|
983
|
+
routesAtPush: number
|
|
886
984
|
readonly mergedAtPush: number
|
|
887
985
|
readonly site: string | undefined
|
|
888
986
|
}[]
|
|
@@ -964,6 +1062,19 @@ const recordScopedHook = (key: object, routesAtPush: number, kind: string): void
|
|
|
964
1062
|
})
|
|
965
1063
|
}
|
|
966
1064
|
|
|
1065
|
+
/** `next(value)` now, or once `value` settles. */
|
|
1066
|
+
const thenMaybe = <T, U>(
|
|
1067
|
+
value: MaybePromise<T>,
|
|
1068
|
+
next: (value: T) => MaybePromise<U>,
|
|
1069
|
+
): MaybePromise<U> => (value instanceof Promise ? value.then(next) : next(value))
|
|
1070
|
+
|
|
1071
|
+
/** A mount registered after an `authenticate()` stage runs it, so the stage covers something. */
|
|
1072
|
+
const noteAuthenticatedMount = (key: object): void => {
|
|
1073
|
+
const state = hookAudit.get(key)
|
|
1074
|
+
if (state === undefined) return
|
|
1075
|
+
for (const site of state.sites) if (site.kind === "authenticate") site.routesAtPush = -1
|
|
1076
|
+
}
|
|
1077
|
+
|
|
967
1078
|
/** Count routes a `merge()` adopted: they never run this server's order-scoped hooks, so a hook
|
|
968
1079
|
* followed only by merges covers nothing. */
|
|
969
1080
|
const noteMergedRoutes = (key: object, count: number): void => {
|
|
@@ -1242,15 +1353,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1242
1353
|
/** Body/raw response hooks need a framework-payload marker; keep that decision per app. */
|
|
1243
1354
|
private responseBodyTag: object | undefined
|
|
1244
1355
|
private readonly responseBodyOwners: Set<object>
|
|
1356
|
+
// General lifecycles need no extra response bookkeeping when neither controls nor observers
|
|
1357
|
+
// use it. Share the fused renderer after the lifecycle, including on body/write routes.
|
|
1245
1358
|
private readonly finalizeResponse = (result: unknown, set: CtxSet, ctx?: RawContext): Response =>
|
|
1246
|
-
|
|
1247
|
-
result
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1359
|
+
set === EMPTY_RESPONSE_CONTROLS && this.onResponseHooks.length === 0
|
|
1360
|
+
? fusedRespondNoSet(result, this.responseBodyTag, this.staticResponseHeaders, true)
|
|
1361
|
+
: toResponse(
|
|
1362
|
+
result as HandlerResult,
|
|
1363
|
+
set,
|
|
1364
|
+
this.responseBodyTag,
|
|
1365
|
+
this.staticResponseHeaders,
|
|
1366
|
+
// Response hooks write next, and they run for this same request object.
|
|
1367
|
+
ctx !== undefined && this.onResponseHooks.length > 0 ? ctx[CONTEXT_REQUEST] : undefined,
|
|
1368
|
+
)
|
|
1254
1369
|
private readonly responseRequests: WeakMap<Request, Request>
|
|
1255
1370
|
/** A request the WebSocket handshake check ran the onRequest hooks on and then passed to HTTP →
|
|
1256
1371
|
* what the hooks returned, so `fetch` routes that instead of running the hooks a second time. */
|
|
@@ -1396,7 +1511,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1396
1511
|
/**
|
|
1397
1512
|
* Mount a legacy fetch handler under a literal path prefix. Mounted handlers sit behind typed
|
|
1398
1513
|
* routes, so routes can move one at a time while the remaining legacy surface stays live.
|
|
1399
|
-
* Mounted responses are outside Nifra's typed route and response-contract checks.
|
|
1514
|
+
* Mounted responses are outside Nifra's typed route and response-contract checks. An
|
|
1515
|
+
* `authenticate()` stage registered before the mount, or a required auth plugin built on one
|
|
1516
|
+
* (`bearer()`, `jwt()`, `basicAuth()`, `apiKey()`), refuses a request before the handler sees it;
|
|
1517
|
+
* mount before the stage to keep the legacy surface public.
|
|
1400
1518
|
*
|
|
1401
1519
|
* It carries HTTP only: a WebSocket upgrade under the prefix reaches the handler as an ordinary
|
|
1402
1520
|
* request and never becomes a socket. Serve that path's sockets with `app.ws()` routes or a
|
|
@@ -1418,7 +1536,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1418
1536
|
beforeRoutes: false,
|
|
1419
1537
|
order: this.mountOrder++,
|
|
1420
1538
|
opaque: options.opaque,
|
|
1539
|
+
authStages: [...this.authStages],
|
|
1421
1540
|
}
|
|
1541
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production" && this.authStages.length > 0)
|
|
1542
|
+
noteAuthenticatedMount(this)
|
|
1422
1543
|
this.fetchMounts.push(mount)
|
|
1423
1544
|
this.fetchMounts.sort(compareMounts)
|
|
1424
1545
|
return this
|
|
@@ -1474,7 +1595,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1474
1595
|
order: this.mountOrder++,
|
|
1475
1596
|
opaque: options.opaque,
|
|
1476
1597
|
app,
|
|
1598
|
+
authStages: [...this.authStages],
|
|
1477
1599
|
}
|
|
1600
|
+
if (hookAuditRuntime && process.env.NODE_ENV !== "production" && this.authStages.length > 0)
|
|
1601
|
+
noteAuthenticatedMount(this)
|
|
1478
1602
|
// Mounts run before routes: a request under this prefix that no mount on the way strips comes back
|
|
1479
1603
|
// here unchanged until the stack overflows, which takes the process down. Dev-time only, like the
|
|
1480
1604
|
// hook audit: a production define folds this away, and the first dev request would hit it anyway.
|
|
@@ -1533,7 +1657,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1533
1657
|
* (see {@link ServerOptions.unusedScopedHooks}).
|
|
1534
1658
|
*/
|
|
1535
1659
|
/**
|
|
1536
|
-
* Register a dedicated authentication stage for subsequent routes
|
|
1660
|
+
* Register a dedicated authentication stage for subsequent routes, and for the `mount()` and
|
|
1661
|
+
* `mountFetch()` handlers registered after it: a request it refuses never reaches them.
|
|
1537
1662
|
*
|
|
1538
1663
|
* Unlike `derive`, this stage receives a body-blind authentication capsule and is compiled before
|
|
1539
1664
|
* route validation. Keeping it separate prevents an unrelated derive from accidentally becoming a
|
|
@@ -1603,7 +1728,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1603
1728
|
fn: H,
|
|
1604
1729
|
): Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>> {
|
|
1605
1730
|
this.assertConfigurable("beforeHandle()")
|
|
1606
|
-
this.beforeHandleHooks
|
|
1731
|
+
const hooks = this.beforeHandleHooks
|
|
1732
|
+
let at = hooks.length
|
|
1733
|
+
if (!LAST_BEFORE_HANDLE.has(fn)) {
|
|
1734
|
+
while (at > 0 && LAST_BEFORE_HANDLE.has(hooks[at - 1]!)) at--
|
|
1735
|
+
}
|
|
1736
|
+
hooks.splice(at, 0, fn as unknown as RawBeforeHandle)
|
|
1607
1737
|
if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
|
|
1608
1738
|
recordScopedHook(this, this.catalog.size + this.wsRoutes.length, "beforeHandle")
|
|
1609
1739
|
tagHook(fn, "beforeHandle", this)
|
|
@@ -3223,9 +3353,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
3223
3353
|
fetch(req: Request, platform?: Platform<EnvOf<Ctx>>): MaybePromise<Response> {
|
|
3224
3354
|
// Deno's `Request.url` is the target as sent; Bun, workerd and every `new Request()` parse it.
|
|
3225
3355
|
// Route what the parsed URL says, so `/users/../admin` is `/admin` on every runtime.
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3356
|
+
const target = requestTarget(req)
|
|
3357
|
+
if (target === true) return jsonError(400, "invalid_host")
|
|
3358
|
+
if (target !== false) {
|
|
3359
|
+
const rebuilt = withResolvedTarget(req, target)
|
|
3360
|
+
if (rebuilt === undefined) return this.fetchSource(resolvedSource(req, target), platform)
|
|
3361
|
+
req = rebuilt
|
|
3229
3362
|
}
|
|
3230
3363
|
// An edge deployment whose only ingress is this method declares its requests runtime-framed at
|
|
3231
3364
|
// construction; everything else stays on the delivered-byte check (see `trustBodyFraming`).
|
|
@@ -3387,7 +3520,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
3387
3520
|
// whatever their prefix lengths. A 404 fallthrough chain stays on the ordinary path.
|
|
3388
3521
|
const mount =
|
|
3389
3522
|
this.firstMountUnder(parts.pathname, true) ?? this.firstMountUnder(parts.pathname, false)
|
|
3390
|
-
if (mount === undefined || mount.fallbackOn404) return undefined
|
|
3523
|
+
if (mount === undefined || mount.fallbackOn404 || mount.authStages.length > 0) return undefined
|
|
3391
3524
|
const candidate = (mount.handler as unknown as Record<symbol, unknown>)[NODE_NATIVE_MOUNT]
|
|
3392
3525
|
return typeof candidate === "function"
|
|
3393
3526
|
? {
|
|
@@ -3724,11 +3857,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
3724
3857
|
// The adapter answers a pass by handing this same object to `fetch`.
|
|
3725
3858
|
const received = req
|
|
3726
3859
|
// The handshake routes the path `fetch` would: Host checked, dot segments resolved (see `fetch`).
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3860
|
+
const target = requestTarget(req)
|
|
3861
|
+
if (target === true) {
|
|
3862
|
+
return { kind: "reject", response: this.wrapWebResponse(jsonError(400, "invalid_host")) }
|
|
3863
|
+
}
|
|
3864
|
+
if (target !== false) {
|
|
3865
|
+
// A GET carrying a body cannot be rebuilt; `fetch` routes it as the ordinary request it is.
|
|
3866
|
+
const rebuilt = withResolvedTarget(req, target)
|
|
3867
|
+
if (rebuilt === undefined) return WS_PASS
|
|
3868
|
+
req = rebuilt
|
|
3732
3869
|
}
|
|
3733
3870
|
// The handshake's caller is the one `fetch` derives: hooks and `upgrade()` see the trusted IP.
|
|
3734
3871
|
platform = this.trustedPlatform(req, platform)
|
|
@@ -3844,18 +3981,28 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
3844
3981
|
}
|
|
3845
3982
|
}
|
|
3846
3983
|
if (selected === undefined) return WS_PASS
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
childRequest =
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
3984
|
+
const mount = selected
|
|
3985
|
+
const upgrade = (): MaybePromise<WebSocketUpgradeOutcome> => {
|
|
3986
|
+
let childRequest = request
|
|
3987
|
+
if (mount.stripPrefix) {
|
|
3988
|
+
childRequest = stripMountPrefix(request, mount.path)
|
|
3989
|
+
mount.wsPassed ??= new WeakMap()
|
|
3990
|
+
mount.wsPassed.set(request, childRequest)
|
|
3991
|
+
}
|
|
3992
|
+
try {
|
|
3993
|
+
return mount.resolveWebSocketUpgrade!(childRequest, platform)
|
|
3994
|
+
} catch {
|
|
3995
|
+
return refuse(jsonError(500, "internal_error"))
|
|
3996
|
+
}
|
|
3858
3997
|
}
|
|
3998
|
+
const child =
|
|
3999
|
+
mount.authStages.length === 0
|
|
4000
|
+
? upgrade()
|
|
4001
|
+
: thenMaybe(
|
|
4002
|
+
authenticateMount(mount.authStages, request, platform, this.logger),
|
|
4003
|
+
(refusal): MaybePromise<WebSocketUpgradeOutcome> =>
|
|
4004
|
+
refusal === undefined ? upgrade() : refuse(refusal),
|
|
4005
|
+
)
|
|
3859
4006
|
const settle = (outcome: WebSocketUpgradeOutcome): MaybePromise<WebSocketUpgradeOutcome> =>
|
|
3860
4007
|
outcome.kind === "pass" ||
|
|
3861
4008
|
(outcome.kind === "reject" && outcome.response.status === 404 && selected!.fallbackOn404)
|
|
@@ -4252,8 +4399,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4252
4399
|
onTimeout: () => T,
|
|
4253
4400
|
webFast: boolean,
|
|
4254
4401
|
): MaybePromise<T> {
|
|
4255
|
-
//
|
|
4256
|
-
//
|
|
4402
|
+
// Native delimiter searches keep path/query splitting out of a character-by-character JS loop.
|
|
4403
|
+
// Keep the slices in locals instead of allocating the public helper's pathname/search pair.
|
|
4257
4404
|
let pathname: string
|
|
4258
4405
|
let search: string
|
|
4259
4406
|
// Read the source's split ONCE. `urlParts` is an accessor on the Node request sources that
|
|
@@ -4272,20 +4419,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4272
4419
|
pathname = "/"
|
|
4273
4420
|
search = ""
|
|
4274
4421
|
} else {
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
if (c === 63 /* ? */ && searchStart === -1) {
|
|
4281
|
-
pathEnd = i
|
|
4282
|
-
searchStart = i
|
|
4283
|
-
} else if (c === 35 /* # */) {
|
|
4284
|
-
if (searchStart === -1) pathEnd = i
|
|
4285
|
-
searchEnd = i
|
|
4286
|
-
break
|
|
4287
|
-
}
|
|
4288
|
-
}
|
|
4422
|
+
const fragment = rawUrl.indexOf("#", start)
|
|
4423
|
+
const searchEnd = fragment === -1 ? rawUrl.length : fragment
|
|
4424
|
+
const question = rawUrl.indexOf("?", start)
|
|
4425
|
+
const searchStart = question !== -1 && question < searchEnd ? question : -1
|
|
4426
|
+
const pathEnd = searchStart === -1 ? searchEnd : searchStart
|
|
4289
4427
|
pathname = rawUrl.slice(start, pathEnd)
|
|
4290
4428
|
search = searchStart === -1 ? "" : rawUrl.slice(searchStart, searchEnd)
|
|
4291
4429
|
}
|
|
@@ -4377,10 +4515,20 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4377
4515
|
}
|
|
4378
4516
|
}
|
|
4379
4517
|
if (mount === undefined) return fallback ?? jsonError(404, "not_found")
|
|
4380
|
-
const
|
|
4381
|
-
|
|
4382
|
-
|
|
4383
|
-
|
|
4518
|
+
const selected = mount
|
|
4519
|
+
const run = (): MaybePromise<Response> => {
|
|
4520
|
+
const childRequest = selected.stripPrefix
|
|
4521
|
+
? (selected.wsPassed?.get(request) ?? stripMountPrefix(request, selected.path))
|
|
4522
|
+
: request
|
|
4523
|
+
return selected.handler(childRequest, platform)
|
|
4524
|
+
}
|
|
4525
|
+
const outcome =
|
|
4526
|
+
mount.authStages.length === 0
|
|
4527
|
+
? run()
|
|
4528
|
+
: thenMaybe(
|
|
4529
|
+
authenticateMount(mount.authStages, request, platform, this.logger),
|
|
4530
|
+
(refusal) => refusal ?? run(),
|
|
4531
|
+
)
|
|
4384
4532
|
if (!replayable || !mount.fallbackOn404) return outcome
|
|
4385
4533
|
if (outcome instanceof Promise) {
|
|
4386
4534
|
return outcome.then((response) =>
|
|
@@ -5857,7 +6005,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
5857
6005
|
finalize,
|
|
5858
6006
|
responseSet,
|
|
5859
6007
|
(e, c) => this.logRequestError(e, c),
|
|
5860
|
-
|
|
6008
|
+
wrapResponse,
|
|
5861
6009
|
)
|
|
5862
6010
|
}
|
|
5863
6011
|
|
|
@@ -5993,6 +6141,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
5993
6141
|
wrapResponse,
|
|
5994
6142
|
(err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
|
|
5995
6143
|
entry.schema?.body,
|
|
6144
|
+
this.formOrigins,
|
|
5996
6145
|
)
|
|
5997
6146
|
}
|
|
5998
6147
|
|
|
@@ -6365,7 +6514,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
6365
6514
|
const checked = contract.runtime.check(contract.definition, result, ctx)
|
|
6366
6515
|
if (checked instanceof Promise) {
|
|
6367
6516
|
return checked.then(
|
|
6368
|
-
(outcome) =>
|
|
6517
|
+
(outcome) => {
|
|
6518
|
+
// The render runs outside the try below: a throw (a BigInt, a cycle) must still reach onError.
|
|
6519
|
+
try {
|
|
6520
|
+
return this.finishContractOutcome(ctx, finalize, wrapResponse, outcome)
|
|
6521
|
+
} catch (err) {
|
|
6522
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
|
|
6523
|
+
}
|
|
6524
|
+
},
|
|
6369
6525
|
(err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
|
|
6370
6526
|
)
|
|
6371
6527
|
}
|
|
@@ -6428,6 +6584,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
6428
6584
|
if (err instanceof Response || isResponseResult(err))
|
|
6429
6585
|
return this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, err)
|
|
6430
6586
|
if (entry.onError.length === 0) {
|
|
6587
|
+
const clientCode = clientBodyErrorCode(err)
|
|
6588
|
+
if (clientCode !== undefined) return wrapResponse(plainError(400, clientCode))
|
|
6431
6589
|
// Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
|
|
6432
6590
|
// (redacting) logger. Body-read failures and around-hook failures land here too.
|
|
6433
6591
|
this.logRequestError(err, ctx)
|
|
@@ -6516,6 +6674,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
6516
6674
|
return wrapResponse(plainError(500, "internal_error"))
|
|
6517
6675
|
}
|
|
6518
6676
|
}
|
|
6677
|
+
const clientCode = clientBodyErrorCode(err)
|
|
6678
|
+
if (clientCode !== undefined) return wrapResponse(plainError(400, clientCode))
|
|
6519
6679
|
this.logRequestError(err, ctx)
|
|
6520
6680
|
return fallback === undefined ? wrapResponse(plainError(500, "internal_error")) : fallback()
|
|
6521
6681
|
}
|
|
@@ -6538,18 +6698,23 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
6538
6698
|
// second continuation, while retaining the exact bounded-read, content-type, and poisoning
|
|
6539
6699
|
// contracts shared by every body route.
|
|
6540
6700
|
const validate = (parsed: unknown): MaybePromise<Response | ResponseResult | undefined> => {
|
|
6541
|
-
|
|
6542
|
-
|
|
6543
|
-
|
|
6544
|
-
|
|
6701
|
+
// Reject outside the parser's JSON catch so a custom validator throw reaches onError.
|
|
6702
|
+
try {
|
|
6703
|
+
const validation = bodySchema["~standard"].validate(parsed)
|
|
6704
|
+
return validation instanceof Promise
|
|
6705
|
+
? validation.then((result) => this.applyLifecycleValidation(entry, result, ctx, "body"))
|
|
6706
|
+
: this.applyLifecycleValidation(entry, validation, ctx, "body")
|
|
6707
|
+
} catch (error) {
|
|
6708
|
+
return Promise.reject(error)
|
|
6709
|
+
}
|
|
6545
6710
|
}
|
|
6546
6711
|
return readBodyFramed(
|
|
6547
6712
|
req,
|
|
6548
6713
|
bodyLimit ?? UNLIMITED_BODY_BYTES,
|
|
6549
6714
|
this.protoPoisoning,
|
|
6550
6715
|
validate,
|
|
6551
|
-
|
|
6552
|
-
|
|
6716
|
+
BODY_RESPONSE,
|
|
6717
|
+
BODY_ERROR,
|
|
6553
6718
|
bodySchema,
|
|
6554
6719
|
this.formOrigins,
|
|
6555
6720
|
)
|
|
@@ -6563,15 +6728,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
6563
6728
|
ctx: RawContext,
|
|
6564
6729
|
kind: ProgramValidationKind,
|
|
6565
6730
|
): MaybePromise<Response | ResponseResult | undefined> {
|
|
6566
|
-
const assign = (value: unknown): void => {
|
|
6567
|
-
;(ctx as unknown as Record<ProgramValidationKind, unknown>)[kind] = value
|
|
6568
|
-
}
|
|
6569
6731
|
if (result.issues === undefined) {
|
|
6570
|
-
|
|
6732
|
+
// biome-ignore lint/plugin/noChainedTypeAssertions lint/plugin/requireSafetyCommentForTypeAssertion: RawContext carries every ProgramValidationKind slot; this instruction assigns its validated value before handlers run.
|
|
6733
|
+
;(ctx as unknown as Record<ProgramValidationKind, unknown>)[kind] = result.value
|
|
6571
6734
|
return undefined
|
|
6572
6735
|
}
|
|
6573
6736
|
const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError
|
|
6574
6737
|
if (hook === undefined) return plainValidationError(result.issues)
|
|
6738
|
+
const assign = (value: unknown): void => {
|
|
6739
|
+
;(ctx as unknown as Record<ProgramValidationKind, unknown>)[kind] = value
|
|
6740
|
+
}
|
|
6575
6741
|
const attempted = hook(result.issues, ctx as unknown as Context, kind)
|
|
6576
6742
|
if (attempted instanceof Promise) {
|
|
6577
6743
|
return attempted.then((recovery) =>
|
|
@@ -6618,14 +6784,34 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
6618
6784
|
// the HTTP framing, so the JSON lane may retain its native fused `json()` parse without weakening
|
|
6619
6785
|
// the portable/adapted source contract (those sources are never marked).
|
|
6620
6786
|
const markFramed = (request: Request): void => markTrustedBodyFraming(request)
|
|
6621
|
-
// The
|
|
6787
|
+
// The direct native lanes bypass `runMatched`, so the route's transport byte cap must be
|
|
6622
6788
|
// marked here too - otherwise a Bun `listen()` fused route would leave direct `c.req` body
|
|
6623
|
-
// reads uncapped. The
|
|
6789
|
+
// reads uncapped. The fallback branches go through `fetchMatched` -> `runMatched`, which marks.
|
|
6624
6790
|
const bodyLimit =
|
|
6625
6791
|
entry.schema?.body === undefined || entry.program.authBeforeValidation
|
|
6626
6792
|
? entry.bodyLimit
|
|
6627
6793
|
: undefined
|
|
6628
|
-
|
|
6794
|
+
// The complete plan already preserves general validation, hooks, recovery, around, and ledgers.
|
|
6795
|
+
// When native admission is safe, use it directly even if the route has no closed-shape fusion.
|
|
6796
|
+
// Idempotency still needs runMatched's request preparation and is excluded from the table.
|
|
6797
|
+
const inner: FusedWebRunner | undefined =
|
|
6798
|
+
fused ??
|
|
6799
|
+
(signal && budget
|
|
6800
|
+
? (source, params, search, activeSignal, activeBudget, platform, nativeContext) =>
|
|
6801
|
+
entry.execution.run(
|
|
6802
|
+
this,
|
|
6803
|
+
entry,
|
|
6804
|
+
source,
|
|
6805
|
+
params,
|
|
6806
|
+
search,
|
|
6807
|
+
activeSignal,
|
|
6808
|
+
activeBudget,
|
|
6809
|
+
platform,
|
|
6810
|
+
nativeContext,
|
|
6811
|
+
this.finalizeResponse,
|
|
6812
|
+
this.wrapWebResponse,
|
|
6813
|
+
)
|
|
6814
|
+
: undefined)
|
|
6629
6815
|
const capped: FusedWebRunner | undefined =
|
|
6630
6816
|
inner === undefined || bodyLimit === undefined
|
|
6631
6817
|
? inner
|
|
@@ -6808,7 +6994,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
6808
6994
|
}
|
|
6809
6995
|
const paramNames = pattern.paramNames
|
|
6810
6996
|
const fused = mayUseFusedNative ? entry.execution.fusedWeb : undefined
|
|
6811
|
-
|
|
6997
|
+
const handler = this.compileBunNativeHandler(
|
|
6812
6998
|
entry,
|
|
6813
6999
|
paramNames,
|
|
6814
7000
|
fused,
|
|
@@ -6817,6 +7003,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
6817
7003
|
peer,
|
|
6818
7004
|
fallback,
|
|
6819
7005
|
)
|
|
7006
|
+
// The table matched the target as sent; one `fetch` refuses or rewrites goes through it.
|
|
7007
|
+
tableAt(path, pattern.segments)[method as Method] = (request, server) =>
|
|
7008
|
+
requestTarget(request) ? fallback(request, server) : handler(request, server)
|
|
6820
7009
|
count += 1
|
|
6821
7010
|
if (paramNames.length > 0) {
|
|
6822
7011
|
below.add(pattern.segments.length + method)
|
package/src/server/sse.ts
CHANGED
|
@@ -66,7 +66,7 @@ export interface SSEInit {
|
|
|
66
66
|
* Bytes of sent events one client may leave unread, default 1 MiB. Past it the client is dropped:
|
|
67
67
|
* `signal` aborts, the queued frames are released, and later sends are no-ops, so a reader that
|
|
68
68
|
* stalls cannot hold server memory without limit. Raise it for a burst larger than this sent before
|
|
69
|
-
* the client can read; `Infinity` turns the limit off.
|
|
69
|
+
* the client can read. A single frame over the cap is also refused; `Infinity` turns the limit off.
|
|
70
70
|
*/
|
|
71
71
|
readonly maxBufferedBytes?: number
|
|
72
72
|
}
|
|
@@ -158,9 +158,10 @@ export function sse(
|
|
|
158
158
|
const stream: SSEStream = {
|
|
159
159
|
send(message) {
|
|
160
160
|
if (closed) return
|
|
161
|
-
|
|
162
|
-
//
|
|
163
|
-
|
|
161
|
+
const frame = encoder.encode(formatMessage(message))
|
|
162
|
+
// Check the incoming frame too: a lone oversized event has no later send to trigger
|
|
163
|
+
// overflow detection. Erroring the body releases its queue and stops the producer.
|
|
164
|
+
if (frame.byteLength > (controller.desiredSize ?? 0)) {
|
|
164
165
|
closed = true
|
|
165
166
|
teardown()
|
|
166
167
|
const reason = new Error("sse_client_too_slow")
|
|
@@ -168,7 +169,7 @@ export function sse(
|
|
|
168
169
|
controller.error(reason)
|
|
169
170
|
return
|
|
170
171
|
}
|
|
171
|
-
controller.enqueue(
|
|
172
|
+
controller.enqueue(frame)
|
|
172
173
|
},
|
|
173
174
|
close,
|
|
174
175
|
signal: stopped.signal,
|