@nifrajs/node 3.5.0 → 4.0.2
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 +10 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +448 -109
- package/dist/index.js.map +1 -1
- package/package.json +4 -1
- package/src/generated/request-target.ts +49 -0
- package/src/index.ts +540 -101
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. */
|
|
@@ -509,10 +513,17 @@ function appendCookiesToResponse(
|
|
|
509
513
|
response: Response,
|
|
510
514
|
cookies: readonly string[] | undefined,
|
|
511
515
|
): Response {
|
|
512
|
-
if (cookies
|
|
516
|
+
if (cookies === undefined || cookies.length === 0) return response
|
|
517
|
+
try {
|
|
513
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
|
|
514
526
|
}
|
|
515
|
-
return response
|
|
516
527
|
}
|
|
517
528
|
|
|
518
529
|
/** Early exits built outside the handler's finalizer - an `onRequest` hook's response, a mount's,
|
|
@@ -586,7 +597,12 @@ function nodeOutcomeToResponse(outcome: NodeServeOutcome): Response {
|
|
|
586
597
|
if (pairs[i]?.[0].toLowerCase() === "content-length") pairs.splice(i, 1)
|
|
587
598
|
}
|
|
588
599
|
}
|
|
589
|
-
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()
|
|
590
606
|
const response = new FastResponse(body, {
|
|
591
607
|
status: outcome.status,
|
|
592
608
|
headers: pairs,
|
|
@@ -655,11 +671,18 @@ export interface ServeOptions {
|
|
|
655
671
|
/**
|
|
656
672
|
* Protocol used when the adapter constructs `Request.url`.
|
|
657
673
|
*
|
|
658
|
-
*
|
|
659
|
-
* 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
|
|
660
676
|
* `request.url` sees the public scheme. Forwarded headers are not trusted implicitly.
|
|
661
677
|
*/
|
|
662
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
|
|
663
686
|
/** Reject requests whose normalized Host authority is not in this allowlist or callback result. */
|
|
664
687
|
readonly allowedHosts?: readonly string[] | ((host: string) => boolean)
|
|
665
688
|
/** Use this validated authority when constructing Request.url, ignoring the inbound Host value. */
|
|
@@ -836,6 +859,15 @@ interface StaticState {
|
|
|
836
859
|
readonly denyDotfiles: boolean
|
|
837
860
|
}
|
|
838
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
|
+
|
|
839
871
|
function staticStateOf(options: ServeStaticOptions): StaticState {
|
|
840
872
|
const root = resolve(typeof options.dir === "string" ? options.dir : fileURLToPath(options.dir))
|
|
841
873
|
const raw = options.prefix ?? "/assets"
|
|
@@ -849,7 +881,7 @@ function staticStateOf(options: ServeStaticOptions): StaticState {
|
|
|
849
881
|
root,
|
|
850
882
|
prefix,
|
|
851
883
|
immutable: options.immutable !== false,
|
|
852
|
-
headers: options.headers,
|
|
884
|
+
headers: staticHeadersOf(options.headers),
|
|
853
885
|
denyDotfiles: options.dotfiles !== "allow",
|
|
854
886
|
}
|
|
855
887
|
}
|
|
@@ -904,10 +936,94 @@ function staticMatch(
|
|
|
904
936
|
return { file }
|
|
905
937
|
}
|
|
906
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
|
+
|
|
907
1022
|
async function readStatic(
|
|
908
1023
|
file: string,
|
|
909
1024
|
state: StaticState,
|
|
910
1025
|
method: string,
|
|
1026
|
+
requestHeaders: IncomingHttpHeaders,
|
|
911
1027
|
): Promise<NodeServeOutcome> {
|
|
912
1028
|
let handle: Awaited<ReturnType<typeof open>> | undefined
|
|
913
1029
|
try {
|
|
@@ -929,26 +1045,60 @@ async function readStatic(
|
|
|
929
1045
|
return { kind: "response", response: new Response("Not Found", { status: 404 }) }
|
|
930
1046
|
}
|
|
931
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
|
+
}
|
|
932
1064
|
headers["content-type"] =
|
|
933
1065
|
STATIC_CONTENT_TYPES[extname(file).toLowerCase()] ?? "application/octet-stream"
|
|
934
1066
|
// Never let a client sniff a served file into a more dangerous type (e.g. an .svg as active content).
|
|
935
1067
|
headers["x-content-type-options"] = "nosniff"
|
|
936
|
-
headers["
|
|
937
|
-
|
|
938
|
-
|
|
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)
|
|
939
1086
|
}
|
|
940
1087
|
if (method === "HEAD") {
|
|
941
1088
|
await handle.close()
|
|
942
1089
|
return { kind: "response", response: new Response(null, { headers }) }
|
|
943
1090
|
}
|
|
944
|
-
const stream =
|
|
1091
|
+
const stream =
|
|
1092
|
+
range === undefined
|
|
1093
|
+
? handle.createReadStream()
|
|
1094
|
+
: handle.createReadStream({ start: range[0], end: range[1] })
|
|
945
1095
|
return {
|
|
946
1096
|
kind: "response",
|
|
947
1097
|
// Claimable rather than `Readable.toWeb`: served straight from disk to the socket when this
|
|
948
1098
|
// response reaches the writer untouched, and read as an ordinary Web stream by anything that
|
|
949
1099
|
// gets to it first (a middleware that rewrites or compresses the body, say), which refuses
|
|
950
1100
|
// the claim and takes the conversion instead.
|
|
951
|
-
response: new Response(claimableWebStream(stream), { headers }),
|
|
1101
|
+
response: new Response(claimableWebStream(stream), { status, headers }),
|
|
952
1102
|
}
|
|
953
1103
|
} catch {
|
|
954
1104
|
await handle?.close().catch(() => {})
|
|
@@ -994,37 +1144,74 @@ function activateAsyncContextFrame(): void {
|
|
|
994
1144
|
new AsyncLocalStorage()
|
|
995
1145
|
}
|
|
996
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
|
+
|
|
997
1184
|
export function serve(app: FetchHandler, options: ServeOptions): Promise<NodeServer> {
|
|
998
1185
|
activateAsyncContextFrame()
|
|
999
1186
|
enableNodeDirect(app)
|
|
1000
1187
|
if (options.fastResponse === true) installFastResponse()
|
|
1001
1188
|
let inFlight = 0
|
|
1002
1189
|
let closed = false
|
|
1003
|
-
|
|
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
|
+
)
|
|
1004
1196
|
const hostPolicy = hostPolicyOf(options)
|
|
1005
1197
|
const staticState = options.static !== undefined ? staticStateOf(options.static) : undefined
|
|
1006
1198
|
// Node otherwise destroys a socket as soon as its HTTP parser emits `clientError`. That can
|
|
1007
1199
|
// discard the response for an already-dispatched request when an understated Content-Length
|
|
1008
1200
|
// leaves surplus bytes that look like a malformed pipelined request. Keep the parser error
|
|
1009
1201
|
// connection-scoped and close only after active responses finish, preserving response ordering.
|
|
1010
|
-
const
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
current.delete(nodeRes)
|
|
1024
|
-
if (current.size === 0) activeResponses.delete(socket)
|
|
1025
|
-
}
|
|
1026
|
-
nodeRes.once("finish", releaseResponse)
|
|
1027
|
-
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)
|
|
1028
1215
|
inFlight += 1
|
|
1029
1216
|
try {
|
|
1030
1217
|
const handled = handle(app, nodeReq, nodeRes, protocol, staticState, hostPolicy)
|
|
@@ -1045,23 +1232,23 @@ export function serve(app: FetchHandler, options: ServeOptions): Promise<NodeSer
|
|
|
1045
1232
|
failWrite(nodeRes)
|
|
1046
1233
|
inFlight -= 1
|
|
1047
1234
|
}
|
|
1048
|
-
}
|
|
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)
|
|
1049
1245
|
|
|
1050
1246
|
server.on("clientError", (_error, socket) => {
|
|
1051
1247
|
if (socket.destroyed) return
|
|
1052
|
-
const
|
|
1053
|
-
if (
|
|
1054
|
-
if (
|
|
1055
|
-
|
|
1056
|
-
const closeWhenDrained = (): void => {
|
|
1057
|
-
const current = activeResponses.get(socket)
|
|
1058
|
-
if (current === undefined || current.size === 0) socket.destroy()
|
|
1059
|
-
}
|
|
1060
|
-
for (const response of responses) {
|
|
1061
|
-
if (response.writableEnded || response.destroyed) closeWhenDrained()
|
|
1062
|
-
else response.once("finish", closeWhenDrained)
|
|
1063
|
-
response.once("close", closeWhenDrained)
|
|
1064
|
-
}
|
|
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
|
|
1065
1252
|
return
|
|
1066
1253
|
}
|
|
1067
1254
|
const body = "Bad Request"
|
|
@@ -1073,11 +1260,12 @@ export function serve(app: FetchHandler, options: ServeOptions): Promise<NodeSer
|
|
|
1073
1260
|
// WebSocket upgrades (a nifra app exposing the seam): handled on the http server's `upgrade` event via
|
|
1074
1261
|
// the optional `ws` package - lazy-imported (and the server lazily built) on the FIRST real WS
|
|
1075
1262
|
// upgrade, so a non-WS Node app never loads `ws`.
|
|
1076
|
-
const resolveWs = app.resolveWebSocketUpgrade?.bind(app)
|
|
1077
1263
|
if (resolveWs !== undefined) {
|
|
1078
1264
|
let wssPromise: Promise<WsServer | undefined> | undefined
|
|
1079
1265
|
server.on("upgrade", (nodeReq, socket, head) => {
|
|
1080
1266
|
void handleUpgrade(
|
|
1267
|
+
app,
|
|
1268
|
+
answering,
|
|
1081
1269
|
resolveWs,
|
|
1082
1270
|
protocol,
|
|
1083
1271
|
hostPolicy,
|
|
@@ -1108,10 +1296,11 @@ export function serve(app: FetchHandler, options: ServeOptions): Promise<NodeSer
|
|
|
1108
1296
|
}
|
|
1109
1297
|
server.close() // stop accepting new connections; existing requests continue
|
|
1110
1298
|
const deadline = Date.now() + drainMs
|
|
1111
|
-
while (inFlight > 0 && Date.now() < deadline) {
|
|
1299
|
+
while ((inFlight > 0 || answering.size > 0) && Date.now() < deadline) {
|
|
1112
1300
|
await new Promise((r) => setTimeout(r, DRAIN_POLL_MS))
|
|
1113
1301
|
}
|
|
1114
1302
|
server.closeAllConnections() // force-close stragglers + idle keep-alive sockets
|
|
1303
|
+
for (const socket of answering) socket.destroy()
|
|
1115
1304
|
}
|
|
1116
1305
|
|
|
1117
1306
|
return new Promise((resolve) => {
|
|
@@ -1160,7 +1349,7 @@ function runNodeSource(
|
|
|
1160
1349
|
}
|
|
1161
1350
|
}
|
|
1162
1351
|
|
|
1163
|
-
const request = toWebRequest(nodeReq, protocol, host)
|
|
1352
|
+
const request = toWebRequest(nodeReq, protocol, host, nodeRes)
|
|
1164
1353
|
const resolveNode = (app as Partial<NodeFastHandler>).resolveNode
|
|
1165
1354
|
if (typeof resolveNode === "function") {
|
|
1166
1355
|
try {
|
|
@@ -1192,6 +1381,30 @@ function runNodeSource(
|
|
|
1192
1381
|
}
|
|
1193
1382
|
}
|
|
1194
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
|
+
|
|
1195
1408
|
function handle(
|
|
1196
1409
|
app: FetchHandler,
|
|
1197
1410
|
nodeReq: IncomingMessage,
|
|
@@ -1200,6 +1413,7 @@ function handle(
|
|
|
1200
1413
|
staticState: StaticState | undefined,
|
|
1201
1414
|
hostPolicy: HostPolicy,
|
|
1202
1415
|
): void | Promise<void> {
|
|
1416
|
+
resolveTarget(nodeReq)
|
|
1203
1417
|
const host = requestHost(nodeReq, hostPolicy)
|
|
1204
1418
|
if (host === undefined) {
|
|
1205
1419
|
writeBadRequest(nodeRes)
|
|
@@ -1233,7 +1447,7 @@ function handle(
|
|
|
1233
1447
|
const matched = staticMatch(staticState, nodeReq.url ?? "/")
|
|
1234
1448
|
if (matched !== "pass") {
|
|
1235
1449
|
if ("reject" in matched) return writeResponseSafely(matched.reject, nodeRes, nodeReq.method)
|
|
1236
|
-
return readStatic(matched.file, staticState, nodeReq.method ?? "GET").then(
|
|
1450
|
+
return readStatic(matched.file, staticState, nodeReq.method ?? "GET", nodeReq.headers).then(
|
|
1237
1451
|
(outcome) => writeOutcomeSafely(outcome, nodeRes, nodeReq.method),
|
|
1238
1452
|
() => failWrite(nodeRes),
|
|
1239
1453
|
)
|
|
@@ -1247,7 +1461,7 @@ function handle(
|
|
|
1247
1461
|
const resolveNodeSource = (app as Partial<NodeFastHandler>).resolveNodeSource
|
|
1248
1462
|
const resolveNodeMount = (app as unknown as Record<symbol, unknown>)[RESOLVE_NODE_MOUNT]
|
|
1249
1463
|
if (typeof resolveNodeSource === "function" || typeof resolveNodeMount === "function") {
|
|
1250
|
-
const nodeSource = toNodeRequestSource(nodeReq, protocol, host)
|
|
1464
|
+
const nodeSource = toNodeRequestSource(nodeReq, protocol, host, nodeRes)
|
|
1251
1465
|
|
|
1252
1466
|
if (typeof resolveNodeMount === "function") {
|
|
1253
1467
|
let selection: NodeNativeMountSelection | undefined
|
|
@@ -1293,7 +1507,7 @@ function handle(
|
|
|
1293
1507
|
return runNodeSource(app, nodeSource, nodeReq, nodeRes, protocol, host, platform)
|
|
1294
1508
|
}
|
|
1295
1509
|
|
|
1296
|
-
const request = toWebRequest(nodeReq, protocol, host)
|
|
1510
|
+
const request = toWebRequest(nodeReq, protocol, host, nodeRes)
|
|
1297
1511
|
const resolveNode = (app as Partial<NodeFastHandler>).resolveNode
|
|
1298
1512
|
if (typeof resolveNode === "function") {
|
|
1299
1513
|
try {
|
|
@@ -1425,6 +1639,69 @@ function allHeaderKeysLowercase(record: Readonly<Record<string, unknown>>): bool
|
|
|
1425
1639
|
return true
|
|
1426
1640
|
}
|
|
1427
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
|
+
|
|
1428
1705
|
/**
|
|
1429
1706
|
* Serialize a node-direct JSON outcome straight to the socket - no undici `Response`, no stream drain.
|
|
1430
1707
|
* Mirrors `Response.json(data, { status, headers })` byte-for-byte: user headers are lowercased to
|
|
@@ -1438,31 +1715,37 @@ function writeJsonOutcome(
|
|
|
1438
1715
|
): void {
|
|
1439
1716
|
// The outcome's record is the request's own (`c.set.headers`, already mutated by any native
|
|
1440
1717
|
// response hooks), and its writers - middleware twins and the framework's own additions - emit
|
|
1441
|
-
// lowercase names.
|
|
1442
|
-
// these keys, so re-walking them here is the same pass twice)
|
|
1443
|
-
// through that walk, by the scan below.
|
|
1444
|
-
//
|
|
1445
|
-
//
|
|
1446
|
-
//
|
|
1447
|
-
|
|
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.
|
|
1448
1726
|
const source = outcome.headers
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
// content-length, set-cookie - so no attacker-influenced name can reach it and the
|
|
1452
|
-
// null-prototype guard the normalization branch needs buys nothing here. It does cost: a
|
|
1453
|
-
// null-prototype object never enters V8's fast property mode, and Node's `_storeHeader` walks
|
|
1454
|
-
// this record key by key on every response (measured at over twice fastify's share of the same
|
|
1455
|
-
// frame on a bare route). A literal keeps it in fast mode.
|
|
1456
|
-
headers = {}
|
|
1457
|
-
} else if (
|
|
1727
|
+
const lowercase =
|
|
1728
|
+
source === undefined ||
|
|
1458
1729
|
(source as Record<symbol, unknown>)[LOWERCASE_HEADER_KEYS] === true ||
|
|
1459
1730
|
allHeaderKeysLowercase(source)
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
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[])
|
|
1466
1749
|
}
|
|
1467
1750
|
}
|
|
1468
1751
|
// A `null` body is a 204/no-content render - `new Response(null)` carries no Content-Type, so we add
|
|
@@ -1471,25 +1754,23 @@ function writeJsonOutcome(
|
|
|
1471
1754
|
// without it Node falls back to chunked framing, which costs extra wire bytes and client parsing
|
|
1472
1755
|
// on every response (and no other runtime chunks a buffered JSON body).
|
|
1473
1756
|
if (outcome.body !== null) {
|
|
1474
|
-
if (
|
|
1475
|
-
|
|
1476
|
-
headers["
|
|
1477
|
-
|
|
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)))
|
|
1478
1763
|
}
|
|
1479
|
-
headers["Content-Length"] = String(Buffer.byteLength(outcome.body))
|
|
1480
|
-
delete headers["content-length"]
|
|
1481
1764
|
} else if (isBodylessStatus(outcome.status)) {
|
|
1482
|
-
// 204/205/304 never carry a payload;
|
|
1483
|
-
//
|
|
1484
|
-
|
|
1485
|
-
delete headers["Content-Length"]
|
|
1486
|
-
} 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) {
|
|
1487
1768
|
// A body-less render at a status that MAY carry a body - a `redirect()`, above all. Node frames
|
|
1488
1769
|
// a `writeHead` + bare `end()` as chunked, so the shortest response the framework emits went out
|
|
1489
1770
|
// with a chunk terminator and no length, where every Web-native runtime sends `content-length: 0`.
|
|
1490
1771
|
// Declared here so the wire matches them. HEAD is excluded: its length describes the GET's body,
|
|
1491
1772
|
// which this lane does not know.
|
|
1492
|
-
headers["
|
|
1773
|
+
headers["content-length"] = "0"
|
|
1493
1774
|
}
|
|
1494
1775
|
if (outcome.cookies !== undefined && outcome.cookies.length > 0) {
|
|
1495
1776
|
headers["set-cookie"] = [...outcome.cookies]
|
|
@@ -1563,21 +1844,34 @@ function normalizeProtocol(value: string): RequestProtocol {
|
|
|
1563
1844
|
)
|
|
1564
1845
|
}
|
|
1565
1846
|
|
|
1566
|
-
|
|
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 {
|
|
1567
1860
|
const url = `${protocol}://${host}${req.url ?? "/"}`
|
|
1568
1861
|
const method = req.method ?? "GET"
|
|
1569
|
-
return makeWebRequest(req, method, url, headerRecordFromNode(req.headers))
|
|
1862
|
+
return makeWebRequest(req, method, url, headerRecordFromNode(req.headers), undefined, res)
|
|
1570
1863
|
}
|
|
1571
1864
|
|
|
1572
1865
|
function toNodeRequestSource(
|
|
1573
1866
|
req: IncomingMessage,
|
|
1574
1867
|
protocol: RequestProtocol,
|
|
1575
1868
|
host: string,
|
|
1869
|
+
res: ServerResponse,
|
|
1576
1870
|
): NodeRequestSource {
|
|
1577
1871
|
const method = req.method ?? "GET"
|
|
1578
1872
|
return method === "GET" || method === "HEAD"
|
|
1579
|
-
? new LeanNodeGetSource(req, method, protocol, host)
|
|
1580
|
-
: new LazyNodeRequestSource(req, method, protocol, host)
|
|
1873
|
+
? new LeanNodeGetSource(req, method, protocol, host, res)
|
|
1874
|
+
: new LazyNodeRequestSource(req, method, protocol, host, res)
|
|
1581
1875
|
}
|
|
1582
1876
|
|
|
1583
1877
|
function stripNodeMountPrefix(url: string, prefix: string): string {
|
|
@@ -1660,12 +1954,20 @@ class LazyNodeRequestSource implements NodeRequestSource {
|
|
|
1660
1954
|
private readonly nodeReq: IncomingMessage
|
|
1661
1955
|
private readonly protocol: RequestProtocol
|
|
1662
1956
|
private readonly host: string
|
|
1663
|
-
|
|
1664
|
-
|
|
1957
|
+
private readonly nodeRes: ServerResponse
|
|
1958
|
+
|
|
1959
|
+
constructor(
|
|
1960
|
+
nodeReq: IncomingMessage,
|
|
1961
|
+
method: string,
|
|
1962
|
+
protocol: RequestProtocol,
|
|
1963
|
+
host: string,
|
|
1964
|
+
nodeRes: ServerResponse,
|
|
1965
|
+
) {
|
|
1665
1966
|
this.nodeReq = nodeReq
|
|
1666
1967
|
this.method = method
|
|
1667
1968
|
this.protocol = protocol
|
|
1668
1969
|
this.host = host
|
|
1970
|
+
this.nodeRes = nodeRes
|
|
1669
1971
|
}
|
|
1670
1972
|
|
|
1671
1973
|
/** The absolute URL, built only when something reads it - routing uses `urlParts` instead. */
|
|
@@ -1755,13 +2057,25 @@ class LazyNodeRequestSource implements NodeRequestSource {
|
|
|
1755
2057
|
*/
|
|
1756
2058
|
private rawBodyBytes(): Promise<Uint8Array> {
|
|
1757
2059
|
if (this.consumedBody !== undefined) return Promise.resolve(this.consumedBody)
|
|
1758
|
-
const handed = this.
|
|
2060
|
+
const handed = this.handedBody()
|
|
1759
2061
|
if (handed != null) {
|
|
1760
2062
|
return new Response(handed).arrayBuffer().then((buffer) => new Uint8Array(buffer))
|
|
1761
2063
|
}
|
|
1762
2064
|
return this.readNodeBody()
|
|
1763
2065
|
}
|
|
1764
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
|
+
|
|
1765
2079
|
/**
|
|
1766
2080
|
* The raw body stream, never routed back through `this.request` - post-cap that getter is the
|
|
1767
2081
|
* shadowed one, which reads back through these very readers. An already-consumed body replays
|
|
@@ -1772,7 +2086,7 @@ class LazyNodeRequestSource implements NodeRequestSource {
|
|
|
1772
2086
|
const consumed = this.consumedBody
|
|
1773
2087
|
if (consumed !== undefined) return streamOfBytes(consumed)
|
|
1774
2088
|
this.bodyValue ??= claimableWebStream(this.nodeReq, "drain")
|
|
1775
|
-
return this.bodyValue
|
|
2089
|
+
return this.handedBody() ?? this.bodyValue
|
|
1776
2090
|
}
|
|
1777
2091
|
|
|
1778
2092
|
get request(): Request {
|
|
@@ -1788,13 +2102,14 @@ class LazyNodeRequestSource implements NodeRequestSource {
|
|
|
1788
2102
|
this.url,
|
|
1789
2103
|
headers,
|
|
1790
2104
|
this.consumedBody,
|
|
2105
|
+
this.nodeRes,
|
|
1791
2106
|
)
|
|
1792
2107
|
// Preserve one-shot body semantics if user code asks for `c.req` after nifra already
|
|
1793
2108
|
// consumed it.
|
|
1794
2109
|
void real.arrayBuffer().catch(() => {})
|
|
1795
2110
|
return real
|
|
1796
2111
|
}
|
|
1797
|
-
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)
|
|
1798
2113
|
},
|
|
1799
2114
|
) as unknown as Request
|
|
1800
2115
|
return this.requestValue
|
|
@@ -1865,12 +2180,20 @@ class LeanNodeGetSource implements NodeRequestSource {
|
|
|
1865
2180
|
private readonly nodeReq: IncomingMessage
|
|
1866
2181
|
private readonly protocol: RequestProtocol
|
|
1867
2182
|
private readonly host: string
|
|
1868
|
-
|
|
1869
|
-
|
|
2183
|
+
private readonly nodeRes: ServerResponse
|
|
2184
|
+
|
|
2185
|
+
constructor(
|
|
2186
|
+
nodeReq: IncomingMessage,
|
|
2187
|
+
method: string,
|
|
2188
|
+
protocol: RequestProtocol,
|
|
2189
|
+
host: string,
|
|
2190
|
+
nodeRes: ServerResponse,
|
|
2191
|
+
) {
|
|
1870
2192
|
this.nodeReq = nodeReq
|
|
1871
2193
|
this.method = method
|
|
1872
2194
|
this.protocol = protocol
|
|
1873
2195
|
this.host = host
|
|
2196
|
+
this.nodeRes = nodeRes
|
|
1874
2197
|
}
|
|
1875
2198
|
|
|
1876
2199
|
/** The absolute URL, built only when something reads it - routing uses `urlParts` instead. */
|
|
@@ -1911,7 +2234,7 @@ class LeanNodeGetSource implements NodeRequestSource {
|
|
|
1911
2234
|
this.method,
|
|
1912
2235
|
this.url,
|
|
1913
2236
|
() => this.headersValue ?? headerRecordFromNode(this.nodeReq.headers),
|
|
1914
|
-
(headers) => makeWebRequest(this.nodeReq, this.method, this.url, headers, null),
|
|
2237
|
+
(headers) => makeWebRequest(this.nodeReq, this.method, this.url, headers, null, this.nodeRes),
|
|
1915
2238
|
) as unknown as Request
|
|
1916
2239
|
return this.requestValue
|
|
1917
2240
|
}
|
|
@@ -1943,11 +2266,23 @@ function makeWebRequest(
|
|
|
1943
2266
|
url: string,
|
|
1944
2267
|
headers: Headers | Record<string, string>,
|
|
1945
2268
|
body?: ReadableStream<Uint8Array> | Uint8Array | null,
|
|
2269
|
+
res?: RequestEnd,
|
|
1946
2270
|
): Request {
|
|
1947
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
|
+
}
|
|
1948
2282
|
if (method !== "GET" && method !== "HEAD") {
|
|
1949
2283
|
// Stream the body in; `duplex: "half"` is required for a streamed request body.
|
|
1950
|
-
init.body =
|
|
2284
|
+
init.body =
|
|
2285
|
+
body instanceof Uint8Array ? body.slice() : (body ?? claimableWebStream(req, "drain"))
|
|
1951
2286
|
init.duplex = "half"
|
|
1952
2287
|
}
|
|
1953
2288
|
return new Request(url, init)
|
|
@@ -2055,7 +2390,16 @@ const LazyWebRequest = /* @__PURE__ */ (() => {
|
|
|
2055
2390
|
return LazyWebRequest
|
|
2056
2391
|
})()
|
|
2057
2392
|
|
|
2058
|
-
|
|
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> {
|
|
2059
2403
|
if (nodeRes.destroyed || nodeRes.writableEnded || !nodeRes.writable) {
|
|
2060
2404
|
return Promise.resolve(false)
|
|
2061
2405
|
}
|
|
@@ -2103,6 +2447,9 @@ function writeNodeResponse(
|
|
|
2103
2447
|
if (isHead) {
|
|
2104
2448
|
nodeRes.writeHead(response.status)
|
|
2105
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(() => {})
|
|
2106
2453
|
return
|
|
2107
2454
|
}
|
|
2108
2455
|
const directBody = nodeResponseBody(response)
|
|
@@ -2208,6 +2555,26 @@ async function writeNodeResponseBody(
|
|
|
2208
2555
|
canDeclareLength: boolean,
|
|
2209
2556
|
): Promise<void> {
|
|
2210
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> {
|
|
2211
2578
|
let first: BodyChunk
|
|
2212
2579
|
try {
|
|
2213
2580
|
first = await reader.read()
|
|
@@ -2340,6 +2707,8 @@ const WS_STATUS_TEXT: Readonly<Record<number, string>> = {
|
|
|
2340
2707
|
/** Resolve a Node `upgrade` event: run the nifra upgrade guard, then either reject (write an HTTP error
|
|
2341
2708
|
* to the raw socket) or perform the `ws` upgrade and wire the socket to the handler. */
|
|
2342
2709
|
async function handleUpgrade(
|
|
2710
|
+
app: FetchHandler,
|
|
2711
|
+
answering: Set<Duplex>,
|
|
2343
2712
|
resolveWs: (
|
|
2344
2713
|
request: Request,
|
|
2345
2714
|
platform?: NodePlatform,
|
|
@@ -2351,7 +2720,12 @@ async function handleUpgrade(
|
|
|
2351
2720
|
head: Buffer,
|
|
2352
2721
|
getWss: (maxPayloadBytes?: number) => Promise<WsServer | undefined>,
|
|
2353
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)
|
|
2354
2726
|
let outcome: WsUpgradeOutcome
|
|
2727
|
+
let request: Request
|
|
2728
|
+
let platform: NodePlatform | undefined
|
|
2355
2729
|
try {
|
|
2356
2730
|
const host = requestHost(nodeReq, hostPolicy)
|
|
2357
2731
|
if (host === undefined) {
|
|
@@ -2359,14 +2733,36 @@ async function handleUpgrade(
|
|
|
2359
2733
|
return
|
|
2360
2734
|
}
|
|
2361
2735
|
const peerAddress = nodeReq.socket.remoteAddress
|
|
2362
|
-
|
|
2363
|
-
|
|
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)
|
|
2364
2740
|
} catch {
|
|
2365
2741
|
writeUpgradeRejection(socket, 500, "internal_error")
|
|
2366
2742
|
return
|
|
2367
2743
|
}
|
|
2368
2744
|
if (outcome.kind === "pass") {
|
|
2369
|
-
|
|
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)
|
|
2370
2766
|
return
|
|
2371
2767
|
}
|
|
2372
2768
|
if (outcome.kind === "reject") {
|
|
@@ -2433,6 +2829,13 @@ function attachNodeWebSocket(
|
|
|
2433
2829
|
safe(() => handler.open?.(nifra)) // open: the socket is already established here
|
|
2434
2830
|
}
|
|
2435
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
|
+
|
|
2436
2839
|
/** Write a minimal JSON error response to a raw upgrade socket, then close it. */
|
|
2437
2840
|
function writeUpgradeRejection(socket: Duplex, status: number, error: string): void {
|
|
2438
2841
|
const body = JSON.stringify({ ok: false, error })
|
|
@@ -2456,3 +2859,39 @@ async function writeRejectionResponse(socket: Duplex, response: Response): Promi
|
|
|
2456
2859
|
socket.write(head + body)
|
|
2457
2860
|
socket.destroy()
|
|
2458
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
|
+
}
|