@nifrajs/node 3.3.0 → 4.0.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 -0
- package/dist/generated/request-target.d.ts +17 -0
- package/dist/generated/request-target.d.ts.map +1 -0
- package/dist/generated/request-target.js +46 -0
- package/dist/generated/request-target.js.map +1 -0
- package/dist/index.d.ts +11 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +449 -108
- package/dist/index.js.map +1 -1
- package/package.json +4 -1
- package/src/generated/request-target.ts +49 -0
- package/src/index.ts +549 -102
package/src/index.ts
CHANGED
|
@@ -18,14 +18,18 @@ import {
|
|
|
18
18
|
type IncomingHttpHeaders,
|
|
19
19
|
type IncomingMessage,
|
|
20
20
|
type ServerResponse,
|
|
21
|
+
STATUS_CODES,
|
|
21
22
|
} from "node:http"
|
|
23
|
+
import { createServer as createHttpsServer } from "node:https"
|
|
22
24
|
import { extname, isAbsolute, relative, resolve, sep } from "node:path"
|
|
23
25
|
import type { Duplex, Readable } from "node:stream"
|
|
26
|
+
import type { TlsOptions } from "node:tls"
|
|
24
27
|
import { fileURLToPath } from "node:url"
|
|
25
28
|
// srvx's lazy spec-shaped Response - see nodeOutcomeToResponse for why the bridge uses it.
|
|
26
29
|
import { FastResponse } from "srvx/node"
|
|
27
30
|
import { NODE_BRIDGE_MARKER_KEYS } from "./generated/bridge-markers.ts"
|
|
28
31
|
import type { NodeServeOutcome } from "./generated/node-outcome.ts"
|
|
32
|
+
import { hasDotSegment, resolveDotSegments } from "./generated/request-target.ts"
|
|
29
33
|
import { claimableWebStream, claimNodeStream } from "./node-stream.ts"
|
|
30
34
|
|
|
31
35
|
/** The runtime platform a nifra app accepts as `fetch`'s 2nd arg - here, the observed socket peer. */
|
|
@@ -39,7 +43,10 @@ export interface FetchHandler {
|
|
|
39
43
|
/** Nifra apps also expose this WS-upgrade seam; present → this adapter serves `app.ws()` routes via
|
|
40
44
|
* the optional `ws` package (lazy-imported on the first upgrade). Absent (a plain `{ fetch }`
|
|
41
45
|
* handler) → HTTP only, and an upgrade request gets a 404. */
|
|
42
|
-
resolveWebSocketUpgrade?(
|
|
46
|
+
resolveWebSocketUpgrade?(
|
|
47
|
+
request: Request,
|
|
48
|
+
platform?: NodePlatform,
|
|
49
|
+
): WsUpgradeOutcome | Promise<WsUpgradeOutcome>
|
|
43
50
|
}
|
|
44
51
|
|
|
45
52
|
// --- WebSocket types: structurally mirrored from @nifrajs/core. The adapter stays dependency-free;
|
|
@@ -506,10 +513,17 @@ function appendCookiesToResponse(
|
|
|
506
513
|
response: Response,
|
|
507
514
|
cookies: readonly string[] | undefined,
|
|
508
515
|
): Response {
|
|
509
|
-
if (cookies
|
|
516
|
+
if (cookies === undefined || cookies.length === 0) return response
|
|
517
|
+
try {
|
|
510
518
|
for (const cookie of cookies) response.headers.append("set-cookie", cookie)
|
|
519
|
+
return response
|
|
520
|
+
} catch {
|
|
521
|
+
// Guarded `Headers` (`Response.redirect()`, a raw `fetch()` result) reject every write, so the
|
|
522
|
+
// first append throws with nothing applied and the cookies land on a mutable copy instead.
|
|
523
|
+
const clone = new Response(response.body, response)
|
|
524
|
+
for (const cookie of cookies) clone.headers.append("set-cookie", cookie)
|
|
525
|
+
return clone
|
|
511
526
|
}
|
|
512
|
-
return response
|
|
513
527
|
}
|
|
514
528
|
|
|
515
529
|
/** Early exits built outside the handler's finalizer - an `onRequest` hook's response, a mount's,
|
|
@@ -583,7 +597,12 @@ function nodeOutcomeToResponse(outcome: NodeServeOutcome): Response {
|
|
|
583
597
|
if (pairs[i]?.[0].toLowerCase() === "content-length") pairs.splice(i, 1)
|
|
584
598
|
}
|
|
585
599
|
}
|
|
586
|
-
const body =
|
|
600
|
+
const body =
|
|
601
|
+
isBodylessStatus(outcome.status) || outcome.body === null
|
|
602
|
+
? null
|
|
603
|
+
: typeof outcome.body === "string"
|
|
604
|
+
? outcome.body
|
|
605
|
+
: outcome.body.slice()
|
|
587
606
|
const response = new FastResponse(body, {
|
|
588
607
|
status: outcome.status,
|
|
589
608
|
headers: pairs,
|
|
@@ -652,11 +671,18 @@ export interface ServeOptions {
|
|
|
652
671
|
/**
|
|
653
672
|
* Protocol used when the adapter constructs `Request.url`.
|
|
654
673
|
*
|
|
655
|
-
*
|
|
656
|
-
* TLS termination can set `"https"` (or a trusted infra-aware function) so app code that reads
|
|
674
|
+
* The default is `"https"` when {@link ServeOptions.tls} is set and `"http"` otherwise. Deployments
|
|
675
|
+
* behind TLS termination can set `"https"` (or a trusted infra-aware function) so app code that reads
|
|
657
676
|
* `request.url` sees the public scheme. Forwarded headers are not trusted implicitly.
|
|
658
677
|
*/
|
|
659
678
|
readonly protocol?: RequestProtocolOption
|
|
679
|
+
/**
|
|
680
|
+
* Serve HTTPS directly, with no proxy in front: `{ cert, key }` as PEM text or the files' bytes
|
|
681
|
+
* (`readFileSync("cert.pem")`), plus any other `node:tls` server option (`passphrase`, `ca`,
|
|
682
|
+
* `requestCert` for client certificates, `minVersion`, `SNICallback`, ...). Unset, the server speaks
|
|
683
|
+
* plain HTTP, which is what a proxy or platform that terminates TLS expects.
|
|
684
|
+
*/
|
|
685
|
+
readonly tls?: TlsOptions
|
|
660
686
|
/** Reject requests whose normalized Host authority is not in this allowlist or callback result. */
|
|
661
687
|
readonly allowedHosts?: readonly string[] | ((host: string) => boolean)
|
|
662
688
|
/** Use this validated authority when constructing Request.url, ignoring the inbound Host value. */
|
|
@@ -833,6 +859,15 @@ interface StaticState {
|
|
|
833
859
|
readonly denyDotfiles: boolean
|
|
834
860
|
}
|
|
835
861
|
|
|
862
|
+
function staticHeadersOf(
|
|
863
|
+
input: Readonly<Record<string, string>> | undefined,
|
|
864
|
+
): Readonly<Record<string, string>> | undefined {
|
|
865
|
+
if (input === undefined) return undefined
|
|
866
|
+
const headers = Object.create(null) as Record<string, string>
|
|
867
|
+
for (const [name, value] of Object.entries(input)) headers[name.toLowerCase()] = value
|
|
868
|
+
return headers
|
|
869
|
+
}
|
|
870
|
+
|
|
836
871
|
function staticStateOf(options: ServeStaticOptions): StaticState {
|
|
837
872
|
const root = resolve(typeof options.dir === "string" ? options.dir : fileURLToPath(options.dir))
|
|
838
873
|
const raw = options.prefix ?? "/assets"
|
|
@@ -846,7 +881,7 @@ function staticStateOf(options: ServeStaticOptions): StaticState {
|
|
|
846
881
|
root,
|
|
847
882
|
prefix,
|
|
848
883
|
immutable: options.immutable !== false,
|
|
849
|
-
headers: options.headers,
|
|
884
|
+
headers: staticHeadersOf(options.headers),
|
|
850
885
|
denyDotfiles: options.dotfiles !== "allow",
|
|
851
886
|
}
|
|
852
887
|
}
|
|
@@ -901,10 +936,94 @@ function staticMatch(
|
|
|
901
936
|
return { file }
|
|
902
937
|
}
|
|
903
938
|
|
|
939
|
+
/** Strong validator from mtime + size (the nginx scheme): rewriting a file changes at least one. */
|
|
940
|
+
function staticEtag(size: number, mtimeMs: number): string {
|
|
941
|
+
return `"${Math.floor(mtimeMs).toString(16)}-${size.toString(16)}"`
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function weakEtag(value: string): string {
|
|
945
|
+
return value.startsWith("W/") ? value.slice(2) : value
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/** `If-None-Match` uses the weak comparison (RFC 9110 section 13.1.2): a `W/` prefix is ignored. */
|
|
949
|
+
function etagListMatches(header: string, etag: string): boolean {
|
|
950
|
+
const comparable = weakEtag(etag)
|
|
951
|
+
for (const raw of header.split(",")) {
|
|
952
|
+
const candidate = raw.trim()
|
|
953
|
+
if (candidate === "*" || weakEtag(candidate) === comparable) return true
|
|
954
|
+
}
|
|
955
|
+
return false
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/** Whether the client's cached copy is current. `If-None-Match` wins over `If-Modified-Since`, whose
|
|
959
|
+
* HTTP-date only carries whole seconds. */
|
|
960
|
+
function staticNotModified(
|
|
961
|
+
headers: IncomingHttpHeaders,
|
|
962
|
+
etag: string,
|
|
963
|
+
lastModified: number | undefined,
|
|
964
|
+
): boolean {
|
|
965
|
+
const ifNoneMatch = headers["if-none-match"]
|
|
966
|
+
if (typeof ifNoneMatch === "string") return etagListMatches(ifNoneMatch, etag)
|
|
967
|
+
if (lastModified === undefined) return false
|
|
968
|
+
const ifModifiedSince = headers["if-modified-since"]
|
|
969
|
+
if (typeof ifModifiedSince !== "string") return false
|
|
970
|
+
const since = Date.parse(ifModifiedSince)
|
|
971
|
+
return Number.isFinite(since) && Math.floor(lastModified / 1000) * 1000 <= since
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function staticIfRangeMatches(
|
|
975
|
+
value: string,
|
|
976
|
+
etag: string,
|
|
977
|
+
lastModified: number | undefined,
|
|
978
|
+
): boolean {
|
|
979
|
+
const item = value.trim()
|
|
980
|
+
if (item.startsWith('"') || item.startsWith("W/")) {
|
|
981
|
+
return !item.startsWith("W/") && !etag.startsWith("W/") && item === etag
|
|
982
|
+
}
|
|
983
|
+
// A date validator must match Last-Modified exactly (RFC 9110 section 13.1.5), not merely be later:
|
|
984
|
+
// a file swapped for one with an older mtime would otherwise splice foreign bytes into a resume.
|
|
985
|
+
if (lastModified === undefined) return false
|
|
986
|
+
const date = Date.parse(item)
|
|
987
|
+
return Number.isFinite(date) && Math.floor(lastModified / 1000) === Math.floor(date / 1000)
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* One `bytes=` range as inclusive offsets, `"unsatisfiable"` (416), or `undefined` to send the whole
|
|
992
|
+
* file: no header, a malformed or multi-range one (RFC 9110 lets a server ignore Range), or an
|
|
993
|
+
* `If-Range` validator that no longer matches the file.
|
|
994
|
+
*/
|
|
995
|
+
function staticByteRange(
|
|
996
|
+
headers: IncomingHttpHeaders,
|
|
997
|
+
size: number,
|
|
998
|
+
etag: string,
|
|
999
|
+
lastModified: number | undefined,
|
|
1000
|
+
): readonly [number, number] | "unsatisfiable" | undefined {
|
|
1001
|
+
const range = headers.range
|
|
1002
|
+
if (typeof range !== "string") return undefined
|
|
1003
|
+
const ifRange = headers["if-range"]
|
|
1004
|
+
if (typeof ifRange === "string" && !staticIfRangeMatches(ifRange, etag, lastModified)) {
|
|
1005
|
+
return undefined
|
|
1006
|
+
}
|
|
1007
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(range.trim())
|
|
1008
|
+
if (match === null) return undefined
|
|
1009
|
+
const first = match[1] as string
|
|
1010
|
+
const last = match[2] as string
|
|
1011
|
+
if (first === "") {
|
|
1012
|
+
if (last === "") return undefined
|
|
1013
|
+
const suffix = Number(last)
|
|
1014
|
+
return suffix === 0 || size === 0 ? "unsatisfiable" : [Math.max(0, size - suffix), size - 1]
|
|
1015
|
+
}
|
|
1016
|
+
const start = Number(first)
|
|
1017
|
+
if (start >= size) return "unsatisfiable"
|
|
1018
|
+
const end = last === "" ? size - 1 : Math.min(Number(last), size - 1)
|
|
1019
|
+
return end < start ? undefined : [start, end]
|
|
1020
|
+
}
|
|
1021
|
+
|
|
904
1022
|
async function readStatic(
|
|
905
1023
|
file: string,
|
|
906
1024
|
state: StaticState,
|
|
907
1025
|
method: string,
|
|
1026
|
+
requestHeaders: IncomingHttpHeaders,
|
|
908
1027
|
): Promise<NodeServeOutcome> {
|
|
909
1028
|
let handle: Awaited<ReturnType<typeof open>> | undefined
|
|
910
1029
|
try {
|
|
@@ -926,26 +1045,60 @@ async function readStatic(
|
|
|
926
1045
|
return { kind: "response", response: new Response("Not Found", { status: 404 }) }
|
|
927
1046
|
}
|
|
928
1047
|
const headers: Record<string, string> = { ...state.headers }
|
|
1048
|
+
if (state.immutable && headers["cache-control"] === undefined) {
|
|
1049
|
+
headers["cache-control"] = "public, max-age=31536000, immutable"
|
|
1050
|
+
}
|
|
1051
|
+
headers.etag ??= staticEtag(stat.size, stat.mtimeMs)
|
|
1052
|
+
headers["last-modified"] ??= stat.mtime.toUTCString()
|
|
1053
|
+
// Custom validators are response headers, not decoration: compare the exact values the client saw.
|
|
1054
|
+
const etag = headers.etag
|
|
1055
|
+
const parsedLastModified = Date.parse(headers["last-modified"])
|
|
1056
|
+
const lastModified = Number.isFinite(parsedLastModified) ? parsedLastModified : undefined
|
|
1057
|
+
// Revalidation: a current cached copy costs a header-only 304 instead of the whole file.
|
|
1058
|
+
if (staticNotModified(requestHeaders, etag, lastModified)) {
|
|
1059
|
+
await handle.close()
|
|
1060
|
+
delete headers["content-length"]
|
|
1061
|
+
delete headers["content-type"]
|
|
1062
|
+
return { kind: "response", response: new Response(null, { status: 304, headers }) }
|
|
1063
|
+
}
|
|
929
1064
|
headers["content-type"] =
|
|
930
1065
|
STATIC_CONTENT_TYPES[extname(file).toLowerCase()] ?? "application/octet-stream"
|
|
931
1066
|
// Never let a client sniff a served file into a more dangerous type (e.g. an .svg as active content).
|
|
932
1067
|
headers["x-content-type-options"] = "nosniff"
|
|
933
|
-
headers["
|
|
934
|
-
|
|
935
|
-
|
|
1068
|
+
headers["accept-ranges"] = "bytes"
|
|
1069
|
+
// Range applies to GET only (RFC 9110 section 14.2); HEAD reports the whole representation.
|
|
1070
|
+
const range =
|
|
1071
|
+
method === "GET" ? staticByteRange(requestHeaders, stat.size, etag, lastModified) : undefined
|
|
1072
|
+
if (range === "unsatisfiable") {
|
|
1073
|
+
await handle.close()
|
|
1074
|
+
headers["content-range"] = `bytes */${stat.size}`
|
|
1075
|
+
delete headers["content-length"]
|
|
1076
|
+
delete headers["content-type"]
|
|
1077
|
+
return { kind: "response", response: new Response(null, { status: 416, headers }) }
|
|
1078
|
+
}
|
|
1079
|
+
let status = 200
|
|
1080
|
+
if (range !== undefined) {
|
|
1081
|
+
status = 206
|
|
1082
|
+
headers["content-range"] = `bytes ${range[0]}-${range[1]}/${stat.size}`
|
|
1083
|
+
headers["content-length"] = String(range[1] - range[0] + 1)
|
|
1084
|
+
} else {
|
|
1085
|
+
headers["content-length"] = String(stat.size)
|
|
936
1086
|
}
|
|
937
1087
|
if (method === "HEAD") {
|
|
938
1088
|
await handle.close()
|
|
939
1089
|
return { kind: "response", response: new Response(null, { headers }) }
|
|
940
1090
|
}
|
|
941
|
-
const stream =
|
|
1091
|
+
const stream =
|
|
1092
|
+
range === undefined
|
|
1093
|
+
? handle.createReadStream()
|
|
1094
|
+
: handle.createReadStream({ start: range[0], end: range[1] })
|
|
942
1095
|
return {
|
|
943
1096
|
kind: "response",
|
|
944
1097
|
// Claimable rather than `Readable.toWeb`: served straight from disk to the socket when this
|
|
945
1098
|
// response reaches the writer untouched, and read as an ordinary Web stream by anything that
|
|
946
1099
|
// gets to it first (a middleware that rewrites or compresses the body, say), which refuses
|
|
947
1100
|
// the claim and takes the conversion instead.
|
|
948
|
-
response: new Response(claimableWebStream(stream), { headers }),
|
|
1101
|
+
response: new Response(claimableWebStream(stream), { status, headers }),
|
|
949
1102
|
}
|
|
950
1103
|
} catch {
|
|
951
1104
|
await handle?.close().catch(() => {})
|
|
@@ -991,37 +1144,74 @@ function activateAsyncContextFrame(): void {
|
|
|
991
1144
|
new AsyncLocalStorage()
|
|
992
1145
|
}
|
|
993
1146
|
|
|
1147
|
+
interface ActiveResponseState {
|
|
1148
|
+
readonly socket: { readonly destroyed: boolean; destroy(): void }
|
|
1149
|
+
active: number
|
|
1150
|
+
parserError: boolean
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
const ACTIVE_RESPONSE_STATE = Symbol("nifra.node.active-response-state")
|
|
1154
|
+
const ACTIVE_RESPONSE_RELEASED = Symbol("nifra.node.active-response-released")
|
|
1155
|
+
const ACTIVE_SOCKET_STATE = Symbol("nifra.node.active-socket-state")
|
|
1156
|
+
type TrackedSocket = {
|
|
1157
|
+
readonly destroyed: boolean
|
|
1158
|
+
destroy(): void
|
|
1159
|
+
once(event: "close", listener: (this: TrackedSocket) => void): unknown
|
|
1160
|
+
[ACTIVE_SOCKET_STATE]?: ActiveResponseState
|
|
1161
|
+
}
|
|
1162
|
+
type TrackedServerResponse = ServerResponse & {
|
|
1163
|
+
[ACTIVE_RESPONSE_STATE]?: ActiveResponseState
|
|
1164
|
+
[ACTIVE_RESPONSE_RELEASED]?: boolean
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/** Shared finish/close listener - response state carries the per-response release bit. */
|
|
1168
|
+
function releaseActiveResponse(this: ServerResponse): void {
|
|
1169
|
+
const response = this as TrackedServerResponse
|
|
1170
|
+
const state = response[ACTIVE_RESPONSE_STATE]
|
|
1171
|
+
if (state === undefined || response[ACTIVE_RESPONSE_RELEASED]) return
|
|
1172
|
+
response[ACTIVE_RESPONSE_RELEASED] = true
|
|
1173
|
+
state.active -= 1
|
|
1174
|
+
if (state.parserError && state.active === 0 && !state.socket.destroyed) {
|
|
1175
|
+
state.socket.destroy()
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
function clearActiveResponseState(this: TrackedSocket): void {
|
|
1180
|
+
const state = this[ACTIVE_SOCKET_STATE]
|
|
1181
|
+
if (state !== undefined) state.active = 0
|
|
1182
|
+
}
|
|
1183
|
+
|
|
994
1184
|
export function serve(app: FetchHandler, options: ServeOptions): Promise<NodeServer> {
|
|
995
1185
|
activateAsyncContextFrame()
|
|
996
1186
|
enableNodeDirect(app)
|
|
997
1187
|
if (options.fastResponse === true) installFastResponse()
|
|
998
1188
|
let inFlight = 0
|
|
999
1189
|
let closed = false
|
|
1000
|
-
|
|
1190
|
+
// Upgrade sockets an HTTP route answers instead: Node's server no longer tracks them, so stop()
|
|
1191
|
+
// drains and closes them itself.
|
|
1192
|
+
const answering = new Set<Duplex>()
|
|
1193
|
+
const protocol = protocolResolver(
|
|
1194
|
+
options.protocol ?? (options.tls === undefined ? undefined : "https"),
|
|
1195
|
+
)
|
|
1001
1196
|
const hostPolicy = hostPolicyOf(options)
|
|
1002
1197
|
const staticState = options.static !== undefined ? staticStateOf(options.static) : undefined
|
|
1003
1198
|
// Node otherwise destroys a socket as soon as its HTTP parser emits `clientError`. That can
|
|
1004
1199
|
// discard the response for an already-dispatched request when an understated Content-Length
|
|
1005
1200
|
// leaves surplus bytes that look like a malformed pipelined request. Keep the parser error
|
|
1006
1201
|
// connection-scoped and close only after active responses finish, preserving response ordering.
|
|
1007
|
-
const
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
current.delete(nodeRes)
|
|
1021
|
-
if (current.size === 0) activeResponses.delete(socket)
|
|
1022
|
-
}
|
|
1023
|
-
nodeRes.once("finish", releaseResponse)
|
|
1024
|
-
nodeRes.once("close", releaseResponse)
|
|
1202
|
+
const onRequest = (nodeReq: IncomingMessage, nodeRes: ServerResponse): void => {
|
|
1203
|
+
const socket = nodeReq.socket as TrackedSocket
|
|
1204
|
+
let responseState = socket[ACTIVE_SOCKET_STATE]
|
|
1205
|
+
if (responseState === undefined) {
|
|
1206
|
+
responseState = { socket, active: 0, parserError: false }
|
|
1207
|
+
socket[ACTIVE_SOCKET_STATE] = responseState
|
|
1208
|
+
socket.once("close", clearActiveResponseState)
|
|
1209
|
+
}
|
|
1210
|
+
responseState.active += 1
|
|
1211
|
+
;(nodeRes as TrackedServerResponse)[ACTIVE_RESPONSE_STATE] = responseState
|
|
1212
|
+
;(nodeRes as TrackedServerResponse)[ACTIVE_RESPONSE_RELEASED] = false
|
|
1213
|
+
nodeRes.once("finish", releaseActiveResponse)
|
|
1214
|
+
nodeRes.once("close", releaseActiveResponse)
|
|
1025
1215
|
inFlight += 1
|
|
1026
1216
|
try {
|
|
1027
1217
|
const handled = handle(app, nodeReq, nodeRes, protocol, staticState, hostPolicy)
|
|
@@ -1042,23 +1232,23 @@ export function serve(app: FetchHandler, options: ServeOptions): Promise<NodeSer
|
|
|
1042
1232
|
failWrite(nodeRes)
|
|
1043
1233
|
inFlight -= 1
|
|
1044
1234
|
}
|
|
1045
|
-
}
|
|
1235
|
+
}
|
|
1236
|
+
const resolveWs = app.resolveWebSocketUpgrade?.bind(app)
|
|
1237
|
+
// Only a WebSocket handshake leaves the HTTP parser; any other `Upgrade` (curl's h2c offer) is an
|
|
1238
|
+
// ordinary request. Node versions without the option send every upgrade to the `upgrade` event.
|
|
1239
|
+
const upgradeOptions =
|
|
1240
|
+
resolveWs === undefined ? {} : { shouldUpgradeCallback: isWebSocketUpgrade }
|
|
1241
|
+
const server =
|
|
1242
|
+
options.tls === undefined
|
|
1243
|
+
? createServer(upgradeOptions, onRequest)
|
|
1244
|
+
: createHttpsServer({ ...options.tls, ...upgradeOptions }, onRequest)
|
|
1046
1245
|
|
|
1047
1246
|
server.on("clientError", (_error, socket) => {
|
|
1048
1247
|
if (socket.destroyed) return
|
|
1049
|
-
const
|
|
1050
|
-
if (
|
|
1051
|
-
if (
|
|
1052
|
-
|
|
1053
|
-
const closeWhenDrained = (): void => {
|
|
1054
|
-
const current = activeResponses.get(socket)
|
|
1055
|
-
if (current === undefined || current.size === 0) socket.destroy()
|
|
1056
|
-
}
|
|
1057
|
-
for (const response of responses) {
|
|
1058
|
-
if (response.writableEnded || response.destroyed) closeWhenDrained()
|
|
1059
|
-
else response.once("finish", closeWhenDrained)
|
|
1060
|
-
response.once("close", closeWhenDrained)
|
|
1061
|
-
}
|
|
1248
|
+
const responseState = (socket as TrackedSocket)[ACTIVE_SOCKET_STATE]
|
|
1249
|
+
if (responseState !== undefined && responseState.active > 0) {
|
|
1250
|
+
if (responseState.parserError) return
|
|
1251
|
+
responseState.parserError = true
|
|
1062
1252
|
return
|
|
1063
1253
|
}
|
|
1064
1254
|
const body = "Bad Request"
|
|
@@ -1070,11 +1260,12 @@ export function serve(app: FetchHandler, options: ServeOptions): Promise<NodeSer
|
|
|
1070
1260
|
// WebSocket upgrades (a nifra app exposing the seam): handled on the http server's `upgrade` event via
|
|
1071
1261
|
// the optional `ws` package - lazy-imported (and the server lazily built) on the FIRST real WS
|
|
1072
1262
|
// upgrade, so a non-WS Node app never loads `ws`.
|
|
1073
|
-
const resolveWs = app.resolveWebSocketUpgrade?.bind(app)
|
|
1074
1263
|
if (resolveWs !== undefined) {
|
|
1075
1264
|
let wssPromise: Promise<WsServer | undefined> | undefined
|
|
1076
1265
|
server.on("upgrade", (nodeReq, socket, head) => {
|
|
1077
1266
|
void handleUpgrade(
|
|
1267
|
+
app,
|
|
1268
|
+
answering,
|
|
1078
1269
|
resolveWs,
|
|
1079
1270
|
protocol,
|
|
1080
1271
|
hostPolicy,
|
|
@@ -1105,10 +1296,11 @@ export function serve(app: FetchHandler, options: ServeOptions): Promise<NodeSer
|
|
|
1105
1296
|
}
|
|
1106
1297
|
server.close() // stop accepting new connections; existing requests continue
|
|
1107
1298
|
const deadline = Date.now() + drainMs
|
|
1108
|
-
while (inFlight > 0 && Date.now() < deadline) {
|
|
1299
|
+
while ((inFlight > 0 || answering.size > 0) && Date.now() < deadline) {
|
|
1109
1300
|
await new Promise((r) => setTimeout(r, DRAIN_POLL_MS))
|
|
1110
1301
|
}
|
|
1111
1302
|
server.closeAllConnections() // force-close stragglers + idle keep-alive sockets
|
|
1303
|
+
for (const socket of answering) socket.destroy()
|
|
1112
1304
|
}
|
|
1113
1305
|
|
|
1114
1306
|
return new Promise((resolve) => {
|
|
@@ -1157,7 +1349,7 @@ function runNodeSource(
|
|
|
1157
1349
|
}
|
|
1158
1350
|
}
|
|
1159
1351
|
|
|
1160
|
-
const request = toWebRequest(nodeReq, protocol, host)
|
|
1352
|
+
const request = toWebRequest(nodeReq, protocol, host, nodeRes)
|
|
1161
1353
|
const resolveNode = (app as Partial<NodeFastHandler>).resolveNode
|
|
1162
1354
|
if (typeof resolveNode === "function") {
|
|
1163
1355
|
try {
|
|
@@ -1189,6 +1381,30 @@ function runNodeSource(
|
|
|
1189
1381
|
}
|
|
1190
1382
|
}
|
|
1191
1383
|
|
|
1384
|
+
/**
|
|
1385
|
+
* Resolve the target's `.` / `..` segments and backslashes in place, before anything reads it. Node
|
|
1386
|
+
* keeps the target as the client sent it, where Bun and workerd deliver it parsed; resolving it here
|
|
1387
|
+
* routes `/users/../admin` to `/admin` on every runtime, and every later reader - static files,
|
|
1388
|
+
* mounts, the app, `c.req.url` - sees the one path.
|
|
1389
|
+
*/
|
|
1390
|
+
const ABSOLUTE_FORM = /^[a-z][a-z\d+.-]*:\/\//i
|
|
1391
|
+
|
|
1392
|
+
function resolveTarget(nodeReq: IncomingMessage): void {
|
|
1393
|
+
let target = nodeReq.url
|
|
1394
|
+
if (target === undefined) return
|
|
1395
|
+
// Absolute-form (`GET http://host/path`, RFC 9112 section 3.2.2) is routed as its origin-form path
|
|
1396
|
+
// with the Host header, as Bun does. Kept whole, hooks saw `http://hosthttp://...` while the
|
|
1397
|
+
// router matched the full URL against a `/*` route.
|
|
1398
|
+
if (target.charCodeAt(0) !== 47 && ABSOLUTE_FORM.test(target)) {
|
|
1399
|
+
let end = target.indexOf("//") + 2
|
|
1400
|
+
while (end < target.length && target[end] !== "/" && target[end] !== "?") end++
|
|
1401
|
+
const rest = target.slice(end)
|
|
1402
|
+
target = rest.charCodeAt(0) === 47 ? rest : `/${rest}`
|
|
1403
|
+
nodeReq.url = target
|
|
1404
|
+
}
|
|
1405
|
+
if (hasDotSegment(target)) nodeReq.url = resolveDotSegments(target)
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1192
1408
|
function handle(
|
|
1193
1409
|
app: FetchHandler,
|
|
1194
1410
|
nodeReq: IncomingMessage,
|
|
@@ -1197,6 +1413,7 @@ function handle(
|
|
|
1197
1413
|
staticState: StaticState | undefined,
|
|
1198
1414
|
hostPolicy: HostPolicy,
|
|
1199
1415
|
): void | Promise<void> {
|
|
1416
|
+
resolveTarget(nodeReq)
|
|
1200
1417
|
const host = requestHost(nodeReq, hostPolicy)
|
|
1201
1418
|
if (host === undefined) {
|
|
1202
1419
|
writeBadRequest(nodeRes)
|
|
@@ -1230,7 +1447,7 @@ function handle(
|
|
|
1230
1447
|
const matched = staticMatch(staticState, nodeReq.url ?? "/")
|
|
1231
1448
|
if (matched !== "pass") {
|
|
1232
1449
|
if ("reject" in matched) return writeResponseSafely(matched.reject, nodeRes, nodeReq.method)
|
|
1233
|
-
return readStatic(matched.file, staticState, nodeReq.method ?? "GET").then(
|
|
1450
|
+
return readStatic(matched.file, staticState, nodeReq.method ?? "GET", nodeReq.headers).then(
|
|
1234
1451
|
(outcome) => writeOutcomeSafely(outcome, nodeRes, nodeReq.method),
|
|
1235
1452
|
() => failWrite(nodeRes),
|
|
1236
1453
|
)
|
|
@@ -1244,7 +1461,7 @@ function handle(
|
|
|
1244
1461
|
const resolveNodeSource = (app as Partial<NodeFastHandler>).resolveNodeSource
|
|
1245
1462
|
const resolveNodeMount = (app as unknown as Record<symbol, unknown>)[RESOLVE_NODE_MOUNT]
|
|
1246
1463
|
if (typeof resolveNodeSource === "function" || typeof resolveNodeMount === "function") {
|
|
1247
|
-
const nodeSource = toNodeRequestSource(nodeReq, protocol, host)
|
|
1464
|
+
const nodeSource = toNodeRequestSource(nodeReq, protocol, host, nodeRes)
|
|
1248
1465
|
|
|
1249
1466
|
if (typeof resolveNodeMount === "function") {
|
|
1250
1467
|
let selection: NodeNativeMountSelection | undefined
|
|
@@ -1290,7 +1507,7 @@ function handle(
|
|
|
1290
1507
|
return runNodeSource(app, nodeSource, nodeReq, nodeRes, protocol, host, platform)
|
|
1291
1508
|
}
|
|
1292
1509
|
|
|
1293
|
-
const request = toWebRequest(nodeReq, protocol, host)
|
|
1510
|
+
const request = toWebRequest(nodeReq, protocol, host, nodeRes)
|
|
1294
1511
|
const resolveNode = (app as Partial<NodeFastHandler>).resolveNode
|
|
1295
1512
|
if (typeof resolveNode === "function") {
|
|
1296
1513
|
try {
|
|
@@ -1422,6 +1639,69 @@ function allHeaderKeysLowercase(record: Readonly<Record<string, unknown>>): bool
|
|
|
1422
1639
|
return true
|
|
1423
1640
|
}
|
|
1424
1641
|
|
|
1642
|
+
/**
|
|
1643
|
+
* Store one wire header on a literal record. A literal `__proto__` name must not go through plain
|
|
1644
|
+
* assignment - the inherited setter would silently swallow it - so it is defined as an own data
|
|
1645
|
+
* property instead; every other name takes the fast plain store. Same guard core's portable header
|
|
1646
|
+
* view makes over the same record, so a hook-set `__proto__` header round-trips to the wire
|
|
1647
|
+
* exactly as it did through the old normalization copy.
|
|
1648
|
+
*/
|
|
1649
|
+
function storeWireHeader(
|
|
1650
|
+
headers: Record<string, string | string[]>,
|
|
1651
|
+
name: string,
|
|
1652
|
+
value: string | string[],
|
|
1653
|
+
): void {
|
|
1654
|
+
if (name === "__proto__") {
|
|
1655
|
+
Object.defineProperty(headers, name, {
|
|
1656
|
+
value,
|
|
1657
|
+
writable: true,
|
|
1658
|
+
enumerable: true,
|
|
1659
|
+
configurable: true,
|
|
1660
|
+
})
|
|
1661
|
+
return
|
|
1662
|
+
}
|
|
1663
|
+
headers[name] = value
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
/** True when an existing header slot can be replaced without throwing or invoking an accessor. */
|
|
1667
|
+
function canReplaceHeaderSlot(record: Readonly<Record<string, unknown>>, name: string): boolean {
|
|
1668
|
+
const descriptor = Object.getOwnPropertyDescriptor(record, name)
|
|
1669
|
+
return descriptor === undefined || ("writable" in descriptor && descriptor.writable === true)
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
/**
|
|
1673
|
+
* A proven lowercase record can stay on the Node hot path when the writer only has to append its
|
|
1674
|
+
* own framing headers. Non-default framing names and explicit lengths take the copy path: the old
|
|
1675
|
+
* writer moved those names to the end of the record, and retaining that order keeps raw HTTP/1.1
|
|
1676
|
+
* snapshots stable. Core's default JSON type is the one exception - it appends that value before
|
|
1677
|
+
* this writer runs, so recognizing it lets the common hooked route retain the record and append
|
|
1678
|
+
* length in place. An application that explicitly supplies the same default value is indistinguishable
|
|
1679
|
+
* here and follows that same semantics-preserving fast path.
|
|
1680
|
+
*/
|
|
1681
|
+
function canWriteJsonHeadersInPlace(
|
|
1682
|
+
source: Readonly<Record<string, string | readonly string[]>>,
|
|
1683
|
+
cookies: readonly string[] | undefined,
|
|
1684
|
+
): source is Record<string, string | string[]> {
|
|
1685
|
+
try {
|
|
1686
|
+
if (!Object.isExtensible(source)) return false
|
|
1687
|
+
if (Object.hasOwn(source, "content-length")) return false
|
|
1688
|
+
if (Object.hasOwn(source, "content-type") && source["content-type"] !== JSON_CONTENT_TYPE) {
|
|
1689
|
+
return false
|
|
1690
|
+
}
|
|
1691
|
+
if (
|
|
1692
|
+
cookies !== undefined &&
|
|
1693
|
+
cookies.length > 0 &&
|
|
1694
|
+
!canReplaceHeaderSlot(source, "set-cookie")
|
|
1695
|
+
) {
|
|
1696
|
+
return false
|
|
1697
|
+
}
|
|
1698
|
+
return true
|
|
1699
|
+
} catch {
|
|
1700
|
+
// A proxy or a hostile descriptor should use the same isolated fallback as mixed-case input.
|
|
1701
|
+
return false
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1425
1705
|
/**
|
|
1426
1706
|
* Serialize a node-direct JSON outcome straight to the socket - no undici `Response`, no stream drain.
|
|
1427
1707
|
* Mirrors `Response.json(data, { status, headers })` byte-for-byte: user headers are lowercased to
|
|
@@ -1435,31 +1715,37 @@ function writeJsonOutcome(
|
|
|
1435
1715
|
): void {
|
|
1436
1716
|
// The outcome's record is the request's own (`c.set.headers`, already mutated by any native
|
|
1437
1717
|
// response hooks), and its writers - middleware twins and the framework's own additions - emit
|
|
1438
|
-
// lowercase names.
|
|
1439
|
-
// these keys, so re-walking them here is the same pass twice)
|
|
1440
|
-
// through that walk, by the scan below.
|
|
1441
|
-
//
|
|
1442
|
-
//
|
|
1443
|
-
//
|
|
1444
|
-
|
|
1718
|
+
// lowercase names. Whether a name needs folding is answered either by core's mark (the native
|
|
1719
|
+
// response walk already looked at these keys, so re-walking them here is the same pass twice)
|
|
1720
|
+
// or, when the record never went through that walk, by the scan below.
|
|
1721
|
+
//
|
|
1722
|
+
// A proven lowercase, extensible record is owned by this request. Keep it in place and append
|
|
1723
|
+
// lowercase framing names directly; the old uppercase rename paid two deletes and demoted the
|
|
1724
|
+
// record to dictionary mode. Mixed-case, frozen, and explicitly framed records take an isolated
|
|
1725
|
+
// copy below, preserving the old source order and protecting caller-owned objects.
|
|
1445
1726
|
const source = outcome.headers
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
// content-length, set-cookie - so no attacker-influenced name can reach it and the
|
|
1449
|
-
// null-prototype guard the normalization branch needs buys nothing here. It does cost: a
|
|
1450
|
-
// null-prototype object never enters V8's fast property mode, and Node's `_storeHeader` walks
|
|
1451
|
-
// this record key by key on every response (measured at over twice fastify's share of the same
|
|
1452
|
-
// frame on a bare route). A literal keeps it in fast mode.
|
|
1453
|
-
headers = {}
|
|
1454
|
-
} else if (
|
|
1727
|
+
const lowercase =
|
|
1728
|
+
source === undefined ||
|
|
1455
1729
|
(source as Record<symbol, unknown>)[LOWERCASE_HEADER_KEYS] === true ||
|
|
1456
1730
|
allHeaderKeysLowercase(source)
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1731
|
+
const inPlace =
|
|
1732
|
+
source === undefined || (lowercase && canWriteJsonHeadersInPlace(source, outcome.cookies))
|
|
1733
|
+
const headers: Record<string, string | string[]> =
|
|
1734
|
+
inPlace && source !== undefined ? (source as Record<string, string | string[]>) : {}
|
|
1735
|
+
let contentType: string | string[] | undefined
|
|
1736
|
+
let hasLength = false
|
|
1737
|
+
if (!inPlace && source !== undefined) {
|
|
1738
|
+
for (const key of Object.keys(source)) {
|
|
1739
|
+
const lower = lowercase ? key : key.toLowerCase()
|
|
1740
|
+
if (lower === "content-type") {
|
|
1741
|
+
contentType = source[key] as string | string[]
|
|
1742
|
+
continue
|
|
1743
|
+
}
|
|
1744
|
+
if (lower === "content-length") {
|
|
1745
|
+
hasLength = true
|
|
1746
|
+
continue
|
|
1747
|
+
}
|
|
1748
|
+
storeWireHeader(headers, lower, source[key] as string | string[])
|
|
1463
1749
|
}
|
|
1464
1750
|
}
|
|
1465
1751
|
// A `null` body is a 204/no-content render - `new Response(null)` carries no Content-Type, so we add
|
|
@@ -1468,25 +1754,23 @@ function writeJsonOutcome(
|
|
|
1468
1754
|
// without it Node falls back to chunked framing, which costs extra wire bytes and client parsing
|
|
1469
1755
|
// on every response (and no other runtime chunks a buffered JSON body).
|
|
1470
1756
|
if (outcome.body !== null) {
|
|
1471
|
-
if (
|
|
1472
|
-
|
|
1473
|
-
headers["
|
|
1474
|
-
|
|
1757
|
+
if (inPlace) {
|
|
1758
|
+
if (!Object.hasOwn(headers, "content-type")) headers["content-type"] = JSON_CONTENT_TYPE
|
|
1759
|
+
headers["content-length"] = String(Buffer.byteLength(outcome.body))
|
|
1760
|
+
} else {
|
|
1761
|
+
storeWireHeader(headers, "content-type", contentType ?? JSON_CONTENT_TYPE)
|
|
1762
|
+
storeWireHeader(headers, "content-length", String(Buffer.byteLength(outcome.body)))
|
|
1475
1763
|
}
|
|
1476
|
-
headers["Content-Length"] = String(Buffer.byteLength(outcome.body))
|
|
1477
|
-
delete headers["content-length"]
|
|
1478
1764
|
} else if (isBodylessStatus(outcome.status)) {
|
|
1479
|
-
// 204/205/304 never carry a payload;
|
|
1480
|
-
//
|
|
1481
|
-
|
|
1482
|
-
delete headers["Content-Length"]
|
|
1483
|
-
} else if (!isHead && headers["content-length"] === undefined) {
|
|
1765
|
+
// 204/205/304 never carry a payload; a user/native-hook length is dropped rather than copied,
|
|
1766
|
+
// so the direct writer cannot advertise bytes that will not ship.
|
|
1767
|
+
} else if (!isHead && !hasLength) {
|
|
1484
1768
|
// A body-less render at a status that MAY carry a body - a `redirect()`, above all. Node frames
|
|
1485
1769
|
// a `writeHead` + bare `end()` as chunked, so the shortest response the framework emits went out
|
|
1486
1770
|
// with a chunk terminator and no length, where every Web-native runtime sends `content-length: 0`.
|
|
1487
1771
|
// Declared here so the wire matches them. HEAD is excluded: its length describes the GET's body,
|
|
1488
1772
|
// which this lane does not know.
|
|
1489
|
-
headers["
|
|
1773
|
+
headers["content-length"] = "0"
|
|
1490
1774
|
}
|
|
1491
1775
|
if (outcome.cookies !== undefined && outcome.cookies.length > 0) {
|
|
1492
1776
|
headers["set-cookie"] = [...outcome.cookies]
|
|
@@ -1560,21 +1844,34 @@ function normalizeProtocol(value: string): RequestProtocol {
|
|
|
1560
1844
|
)
|
|
1561
1845
|
}
|
|
1562
1846
|
|
|
1563
|
-
|
|
1847
|
+
/** What a request's signal follows: the response it is answered on, or an upgrade's raw socket. */
|
|
1848
|
+
interface RequestEnd {
|
|
1849
|
+
readonly writableFinished: boolean
|
|
1850
|
+
readonly closed: boolean
|
|
1851
|
+
once(event: "close", listener: () => void): unknown
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
function toWebRequest(
|
|
1855
|
+
req: IncomingMessage,
|
|
1856
|
+
protocol: RequestProtocol,
|
|
1857
|
+
host: string,
|
|
1858
|
+
res?: RequestEnd,
|
|
1859
|
+
): Request {
|
|
1564
1860
|
const url = `${protocol}://${host}${req.url ?? "/"}`
|
|
1565
1861
|
const method = req.method ?? "GET"
|
|
1566
|
-
return makeWebRequest(req, method, url, headerRecordFromNode(req.headers))
|
|
1862
|
+
return makeWebRequest(req, method, url, headerRecordFromNode(req.headers), undefined, res)
|
|
1567
1863
|
}
|
|
1568
1864
|
|
|
1569
1865
|
function toNodeRequestSource(
|
|
1570
1866
|
req: IncomingMessage,
|
|
1571
1867
|
protocol: RequestProtocol,
|
|
1572
1868
|
host: string,
|
|
1869
|
+
res: ServerResponse,
|
|
1573
1870
|
): NodeRequestSource {
|
|
1574
1871
|
const method = req.method ?? "GET"
|
|
1575
1872
|
return method === "GET" || method === "HEAD"
|
|
1576
|
-
? new LeanNodeGetSource(req, method, protocol, host)
|
|
1577
|
-
: new LazyNodeRequestSource(req, method, protocol, host)
|
|
1873
|
+
? new LeanNodeGetSource(req, method, protocol, host, res)
|
|
1874
|
+
: new LazyNodeRequestSource(req, method, protocol, host, res)
|
|
1578
1875
|
}
|
|
1579
1876
|
|
|
1580
1877
|
function stripNodeMountPrefix(url: string, prefix: string): string {
|
|
@@ -1657,12 +1954,20 @@ class LazyNodeRequestSource implements NodeRequestSource {
|
|
|
1657
1954
|
private readonly nodeReq: IncomingMessage
|
|
1658
1955
|
private readonly protocol: RequestProtocol
|
|
1659
1956
|
private readonly host: string
|
|
1660
|
-
|
|
1661
|
-
|
|
1957
|
+
private readonly nodeRes: ServerResponse
|
|
1958
|
+
|
|
1959
|
+
constructor(
|
|
1960
|
+
nodeReq: IncomingMessage,
|
|
1961
|
+
method: string,
|
|
1962
|
+
protocol: RequestProtocol,
|
|
1963
|
+
host: string,
|
|
1964
|
+
nodeRes: ServerResponse,
|
|
1965
|
+
) {
|
|
1662
1966
|
this.nodeReq = nodeReq
|
|
1663
1967
|
this.method = method
|
|
1664
1968
|
this.protocol = protocol
|
|
1665
1969
|
this.host = host
|
|
1970
|
+
this.nodeRes = nodeRes
|
|
1666
1971
|
}
|
|
1667
1972
|
|
|
1668
1973
|
/** The absolute URL, built only when something reads it - routing uses `urlParts` instead. */
|
|
@@ -1752,13 +2057,25 @@ class LazyNodeRequestSource implements NodeRequestSource {
|
|
|
1752
2057
|
*/
|
|
1753
2058
|
private rawBodyBytes(): Promise<Uint8Array> {
|
|
1754
2059
|
if (this.consumedBody !== undefined) return Promise.resolve(this.consumedBody)
|
|
1755
|
-
const handed = this.
|
|
2060
|
+
const handed = this.handedBody()
|
|
1756
2061
|
if (handed != null) {
|
|
1757
2062
|
return new Response(handed).arrayBuffer().then((buffer) => new Uint8Array(buffer))
|
|
1758
2063
|
}
|
|
1759
2064
|
return this.readNodeBody()
|
|
1760
2065
|
}
|
|
1761
2066
|
|
|
2067
|
+
/**
|
|
2068
|
+
* The stream the Web `Request` was built on, or - once a hook's `req.clone()` teed it (which locks
|
|
2069
|
+
* the original) - the branch that request kept. Reading the locked original threw, so a middleware
|
|
2070
|
+
* that peeked at the body through a clone turned every downstream body read into a 500.
|
|
2071
|
+
*/
|
|
2072
|
+
private handedBody(): ReadableStream<Uint8Array> | undefined {
|
|
2073
|
+
const handed = this.bodyValue
|
|
2074
|
+
if (handed == null || !handed.locked) return handed ?? undefined
|
|
2075
|
+
const owner = this.requestValue as unknown as { readonly _real?: Request } | undefined
|
|
2076
|
+
return owner?._real?.body ?? handed
|
|
2077
|
+
}
|
|
2078
|
+
|
|
1762
2079
|
/**
|
|
1763
2080
|
* The raw body stream, never routed back through `this.request` - post-cap that getter is the
|
|
1764
2081
|
* shadowed one, which reads back through these very readers. An already-consumed body replays
|
|
@@ -1769,7 +2086,7 @@ class LazyNodeRequestSource implements NodeRequestSource {
|
|
|
1769
2086
|
const consumed = this.consumedBody
|
|
1770
2087
|
if (consumed !== undefined) return streamOfBytes(consumed)
|
|
1771
2088
|
this.bodyValue ??= claimableWebStream(this.nodeReq, "drain")
|
|
1772
|
-
return this.bodyValue
|
|
2089
|
+
return this.handedBody() ?? this.bodyValue
|
|
1773
2090
|
}
|
|
1774
2091
|
|
|
1775
2092
|
get request(): Request {
|
|
@@ -1785,13 +2102,14 @@ class LazyNodeRequestSource implements NodeRequestSource {
|
|
|
1785
2102
|
this.url,
|
|
1786
2103
|
headers,
|
|
1787
2104
|
this.consumedBody,
|
|
2105
|
+
this.nodeRes,
|
|
1788
2106
|
)
|
|
1789
2107
|
// Preserve one-shot body semantics if user code asks for `c.req` after nifra already
|
|
1790
2108
|
// consumed it.
|
|
1791
2109
|
void real.arrayBuffer().catch(() => {})
|
|
1792
2110
|
return real
|
|
1793
2111
|
}
|
|
1794
|
-
return makeWebRequest(this.nodeReq, this.method, this.url, headers, this.body)
|
|
2112
|
+
return makeWebRequest(this.nodeReq, this.method, this.url, headers, this.body, this.nodeRes)
|
|
1795
2113
|
},
|
|
1796
2114
|
) as unknown as Request
|
|
1797
2115
|
return this.requestValue
|
|
@@ -1862,12 +2180,20 @@ class LeanNodeGetSource implements NodeRequestSource {
|
|
|
1862
2180
|
private readonly nodeReq: IncomingMessage
|
|
1863
2181
|
private readonly protocol: RequestProtocol
|
|
1864
2182
|
private readonly host: string
|
|
1865
|
-
|
|
1866
|
-
|
|
2183
|
+
private readonly nodeRes: ServerResponse
|
|
2184
|
+
|
|
2185
|
+
constructor(
|
|
2186
|
+
nodeReq: IncomingMessage,
|
|
2187
|
+
method: string,
|
|
2188
|
+
protocol: RequestProtocol,
|
|
2189
|
+
host: string,
|
|
2190
|
+
nodeRes: ServerResponse,
|
|
2191
|
+
) {
|
|
1867
2192
|
this.nodeReq = nodeReq
|
|
1868
2193
|
this.method = method
|
|
1869
2194
|
this.protocol = protocol
|
|
1870
2195
|
this.host = host
|
|
2196
|
+
this.nodeRes = nodeRes
|
|
1871
2197
|
}
|
|
1872
2198
|
|
|
1873
2199
|
/** The absolute URL, built only when something reads it - routing uses `urlParts` instead. */
|
|
@@ -1908,7 +2234,7 @@ class LeanNodeGetSource implements NodeRequestSource {
|
|
|
1908
2234
|
this.method,
|
|
1909
2235
|
this.url,
|
|
1910
2236
|
() => this.headersValue ?? headerRecordFromNode(this.nodeReq.headers),
|
|
1911
|
-
(headers) => makeWebRequest(this.nodeReq, this.method, this.url, headers, null),
|
|
2237
|
+
(headers) => makeWebRequest(this.nodeReq, this.method, this.url, headers, null, this.nodeRes),
|
|
1912
2238
|
) as unknown as Request
|
|
1913
2239
|
return this.requestValue
|
|
1914
2240
|
}
|
|
@@ -1940,11 +2266,23 @@ function makeWebRequest(
|
|
|
1940
2266
|
url: string,
|
|
1941
2267
|
headers: Headers | Record<string, string>,
|
|
1942
2268
|
body?: ReadableStream<Uint8Array> | Uint8Array | null,
|
|
2269
|
+
res?: RequestEnd,
|
|
1943
2270
|
): Request {
|
|
1944
2271
|
const init: RequestInit & { duplex?: "half" } = { method, headers }
|
|
2272
|
+
if (res !== undefined) {
|
|
2273
|
+
// `signal` aborts when the client goes away before the response finished, as on Bun and Deno.
|
|
2274
|
+
const disconnect = new AbortController()
|
|
2275
|
+
init.signal = disconnect.signal
|
|
2276
|
+
const onClose = (): void => {
|
|
2277
|
+
if (!res.writableFinished) disconnect.abort()
|
|
2278
|
+
}
|
|
2279
|
+
if (res.closed) onClose()
|
|
2280
|
+
else res.once("close", onClose)
|
|
2281
|
+
}
|
|
1945
2282
|
if (method !== "GET" && method !== "HEAD") {
|
|
1946
2283
|
// Stream the body in; `duplex: "half"` is required for a streamed request body.
|
|
1947
|
-
init.body =
|
|
2284
|
+
init.body =
|
|
2285
|
+
body instanceof Uint8Array ? body.slice() : (body ?? claimableWebStream(req, "drain"))
|
|
1948
2286
|
init.duplex = "half"
|
|
1949
2287
|
}
|
|
1950
2288
|
return new Request(url, init)
|
|
@@ -2052,7 +2390,16 @@ const LazyWebRequest = /* @__PURE__ */ (() => {
|
|
|
2052
2390
|
return LazyWebRequest
|
|
2053
2391
|
})()
|
|
2054
2392
|
|
|
2055
|
-
|
|
2393
|
+
/** A writable end whose backpressure can be awaited: a response, or an upgrade socket answered as one. */
|
|
2394
|
+
interface DrainTarget {
|
|
2395
|
+
readonly destroyed: boolean
|
|
2396
|
+
readonly writableEnded: boolean
|
|
2397
|
+
readonly writable: boolean
|
|
2398
|
+
once(event: "drain" | "close" | "error", listener: () => void): unknown
|
|
2399
|
+
removeListener(event: "drain" | "close" | "error", listener: () => void): unknown
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
function waitForDrain(nodeRes: DrainTarget): Promise<boolean> {
|
|
2056
2403
|
if (nodeRes.destroyed || nodeRes.writableEnded || !nodeRes.writable) {
|
|
2057
2404
|
return Promise.resolve(false)
|
|
2058
2405
|
}
|
|
@@ -2100,6 +2447,9 @@ function writeNodeResponse(
|
|
|
2100
2447
|
if (isHead) {
|
|
2101
2448
|
nodeRes.writeHead(response.status)
|
|
2102
2449
|
nodeRes.end()
|
|
2450
|
+
// Nothing reads a HEAD answer's body. Cancelled, its source learns the exchange is over, as on
|
|
2451
|
+
// Bun and Deno; left alone, a streaming route's producer runs on with no reader.
|
|
2452
|
+
response.body?.cancel().catch(() => {})
|
|
2103
2453
|
return
|
|
2104
2454
|
}
|
|
2105
2455
|
const directBody = nodeResponseBody(response)
|
|
@@ -2205,6 +2555,26 @@ async function writeNodeResponseBody(
|
|
|
2205
2555
|
canDeclareLength: boolean,
|
|
2206
2556
|
): Promise<void> {
|
|
2207
2557
|
const reader = response.body!.getReader()
|
|
2558
|
+
// A client that leaves while the stream is idle never fails a write. Its departure cancels the
|
|
2559
|
+
// stream, as on Bun and Deno, so a producer waiting to send learns the exchange is over.
|
|
2560
|
+
const onClose = (): void => {
|
|
2561
|
+
reader.cancel().catch(() => {})
|
|
2562
|
+
}
|
|
2563
|
+
nodeRes.once("close", onClose)
|
|
2564
|
+
if (nodeRes.destroyed) onClose()
|
|
2565
|
+
try {
|
|
2566
|
+
await writeNodeResponseChunks(reader, response, nodeRes, canDeclareLength)
|
|
2567
|
+
} finally {
|
|
2568
|
+
nodeRes.removeListener("close", onClose)
|
|
2569
|
+
}
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
async function writeNodeResponseChunks(
|
|
2573
|
+
reader: BodyReader,
|
|
2574
|
+
response: Response,
|
|
2575
|
+
nodeRes: ServerResponse,
|
|
2576
|
+
canDeclareLength: boolean,
|
|
2577
|
+
): Promise<void> {
|
|
2208
2578
|
let first: BodyChunk
|
|
2209
2579
|
try {
|
|
2210
2580
|
first = await reader.read()
|
|
@@ -2337,7 +2707,12 @@ const WS_STATUS_TEXT: Readonly<Record<number, string>> = {
|
|
|
2337
2707
|
/** Resolve a Node `upgrade` event: run the nifra upgrade guard, then either reject (write an HTTP error
|
|
2338
2708
|
* to the raw socket) or perform the `ws` upgrade and wire the socket to the handler. */
|
|
2339
2709
|
async function handleUpgrade(
|
|
2340
|
-
|
|
2710
|
+
app: FetchHandler,
|
|
2711
|
+
answering: Set<Duplex>,
|
|
2712
|
+
resolveWs: (
|
|
2713
|
+
request: Request,
|
|
2714
|
+
platform?: NodePlatform,
|
|
2715
|
+
) => WsUpgradeOutcome | Promise<WsUpgradeOutcome>,
|
|
2341
2716
|
getProtocol: RequestProtocolResolver,
|
|
2342
2717
|
hostPolicy: HostPolicy,
|
|
2343
2718
|
nodeReq: IncomingMessage,
|
|
@@ -2345,20 +2720,49 @@ async function handleUpgrade(
|
|
|
2345
2720
|
head: Buffer,
|
|
2346
2721
|
getWss: (maxPayloadBytes?: number) => Promise<WsServer | undefined>,
|
|
2347
2722
|
): Promise<void> {
|
|
2723
|
+
// A peer can reset the socket while the guard is awaited; with no listener that error ends the process.
|
|
2724
|
+
socket.on("error", ignoreSocketError)
|
|
2725
|
+
resolveTarget(nodeReq)
|
|
2348
2726
|
let outcome: WsUpgradeOutcome
|
|
2727
|
+
let request: Request
|
|
2728
|
+
let platform: NodePlatform | undefined
|
|
2349
2729
|
try {
|
|
2350
2730
|
const host = requestHost(nodeReq, hostPolicy)
|
|
2351
2731
|
if (host === undefined) {
|
|
2352
2732
|
writeUpgradeRejection(socket, 400, "bad_request")
|
|
2353
2733
|
return
|
|
2354
2734
|
}
|
|
2355
|
-
|
|
2735
|
+
const peerAddress = nodeReq.socket.remoteAddress
|
|
2736
|
+
platform = peerAddress === undefined ? undefined : { clientIp: peerAddress }
|
|
2737
|
+
// Its signal follows the socket, so a route answered below stops when the client leaves.
|
|
2738
|
+
request = toWebRequest(nodeReq, getProtocol(nodeReq), host, socket)
|
|
2739
|
+
outcome = await resolveWs(request, platform)
|
|
2356
2740
|
} catch {
|
|
2357
2741
|
writeUpgradeRejection(socket, 500, "internal_error")
|
|
2358
2742
|
return
|
|
2359
2743
|
}
|
|
2360
2744
|
if (outcome.kind === "pass") {
|
|
2361
|
-
|
|
2745
|
+
// No WS route here: the HTTP route answers, as on Bun. Only a bodiless request can be served from
|
|
2746
|
+
// an upgrade socket - Node parses no body for one, so a POST here (Node without
|
|
2747
|
+
// `shouldUpgradeCallback`) keeps the 404.
|
|
2748
|
+
if (nodeReq.method !== "GET" && nodeReq.method !== "HEAD") {
|
|
2749
|
+
writeUpgradeRejection(socket, 404, "not_found")
|
|
2750
|
+
return
|
|
2751
|
+
}
|
|
2752
|
+
let response: Response
|
|
2753
|
+
try {
|
|
2754
|
+
response = await app.fetch(request, platform)
|
|
2755
|
+
} catch {
|
|
2756
|
+
writeUpgradeRejection(socket, 500, "internal_error")
|
|
2757
|
+
return
|
|
2758
|
+
}
|
|
2759
|
+
answering.add(socket)
|
|
2760
|
+
socket.once("close", () => answering.delete(socket))
|
|
2761
|
+
// Detached from Node's HTTP handling, the socket comes paused and nothing ends it on the client's
|
|
2762
|
+
// FIN. Reading it and ending on `end`, as Node's server does, lets a departure reach the signal.
|
|
2763
|
+
socket.once("end", () => socket.end())
|
|
2764
|
+
socket.resume()
|
|
2765
|
+
await writeSocketResponse(socket, response, nodeReq.method)
|
|
2362
2766
|
return
|
|
2363
2767
|
}
|
|
2364
2768
|
if (outcome.kind === "reject") {
|
|
@@ -2425,6 +2829,13 @@ function attachNodeWebSocket(
|
|
|
2425
2829
|
safe(() => handler.open?.(nifra)) // open: the socket is already established here
|
|
2426
2830
|
}
|
|
2427
2831
|
|
|
2832
|
+
function ignoreSocketError(): void {}
|
|
2833
|
+
|
|
2834
|
+
function isWebSocketUpgrade(nodeReq: IncomingMessage): boolean {
|
|
2835
|
+
// A handshake is a GET (RFC 6455 section 4.1); any other method is an ordinary request with a body.
|
|
2836
|
+
return nodeReq.method === "GET" && /\bwebsocket\b/i.test(nodeReq.headers.upgrade ?? "")
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2428
2839
|
/** Write a minimal JSON error response to a raw upgrade socket, then close it. */
|
|
2429
2840
|
function writeUpgradeRejection(socket: Duplex, status: number, error: string): void {
|
|
2430
2841
|
const body = JSON.stringify({ ok: false, error })
|
|
@@ -2448,3 +2859,39 @@ async function writeRejectionResponse(socket: Duplex, response: Response): Promi
|
|
|
2448
2859
|
socket.write(head + body)
|
|
2449
2860
|
socket.destroy()
|
|
2450
2861
|
}
|
|
2862
|
+
|
|
2863
|
+
/** Answer an upgrade socket with an ordinary response: the body streams as produced, paced by the
|
|
2864
|
+
* socket's backpressure and delimited by the close (RFC 9112 section 6.3), never buffered whole. */
|
|
2865
|
+
async function writeSocketResponse(
|
|
2866
|
+
socket: Duplex,
|
|
2867
|
+
response: Response,
|
|
2868
|
+
method: string,
|
|
2869
|
+
): Promise<void> {
|
|
2870
|
+
let head = `HTTP/1.1 ${response.status} ${response.statusText || STATUS_CODES[response.status] || "Unknown"}\r\n`
|
|
2871
|
+
response.headers.forEach((value, key) => {
|
|
2872
|
+
if (key !== "connection" && key !== "keep-alive" && key !== "transfer-encoding") {
|
|
2873
|
+
head += `${key}: ${value}\r\n`
|
|
2874
|
+
}
|
|
2875
|
+
})
|
|
2876
|
+
socket.write(`${head}Connection: close\r\n\r\n`)
|
|
2877
|
+
if (response.body === null || method === "HEAD") {
|
|
2878
|
+
await response.body?.cancel().catch(() => {})
|
|
2879
|
+
socket.end()
|
|
2880
|
+
return
|
|
2881
|
+
}
|
|
2882
|
+
const reader = response.body.getReader()
|
|
2883
|
+
try {
|
|
2884
|
+
for (;;) {
|
|
2885
|
+
const chunk = await reader.read()
|
|
2886
|
+
if (chunk.done) break
|
|
2887
|
+
if (!socket.write(chunk.value) && !(await waitForDrain(socket))) {
|
|
2888
|
+
await reader.cancel()
|
|
2889
|
+
return
|
|
2890
|
+
}
|
|
2891
|
+
}
|
|
2892
|
+
socket.end()
|
|
2893
|
+
} catch {
|
|
2894
|
+
socket.destroy()
|
|
2895
|
+
await reader.cancel().catch(() => {})
|
|
2896
|
+
}
|
|
2897
|
+
}
|