@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/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?(request: Request): WsUpgradeOutcome | Promise<WsUpgradeOutcome>
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 !== undefined) {
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 = isBodylessStatus(outcome.status) ? null : outcome.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
- * `@nifrajs/node` creates a plain Node `http` server, so the safe default is `"http"`. Deployments behind
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["content-length"] = String(stat.size)
934
- if (state.immutable && headers["cache-control"] === undefined) {
935
- headers["cache-control"] = "public, max-age=31536000, immutable"
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 = handle.createReadStream()
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
- const protocol = protocolResolver(options.protocol)
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 activeResponses = new WeakMap<object, Set<ServerResponse>>()
1008
- const parserErrorSockets = new WeakSet<object>()
1009
- const server = createServer((nodeReq, nodeRes) => {
1010
- const socket = nodeReq.socket
1011
- let responses = activeResponses.get(socket)
1012
- if (responses === undefined) {
1013
- responses = new Set<ServerResponse>()
1014
- activeResponses.set(socket, responses)
1015
- }
1016
- responses.add(nodeRes)
1017
- const releaseResponse = (): void => {
1018
- const current = activeResponses.get(socket)
1019
- if (current === undefined) return
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 responses = activeResponses.get(socket)
1050
- if (responses !== undefined && responses.size > 0) {
1051
- if (parserErrorSockets.has(socket)) return
1052
- parserErrorSockets.add(socket)
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. Confirmed either by core's mark (the native response walk already looked at
1439
- // these keys, so re-walking them here is the same pass twice) or, when the record never went
1440
- // through that walk, by the scan below. Either way the record is then used as-is and the additions
1441
- // below mutate it in place; nothing reads it after the write. Only a mixed-case key (a user's
1442
- // hand-set `X-Foo`) pays the normalization copy, keeping the wire byte-identical to undici's
1443
- // `Headers` lowercasing on every other runtime.
1444
- let headers: Record<string, string | string[]>
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
- if (source === undefined) {
1447
- // A route that set no headers gets a record the FRAMEWORK alone fills - content-type,
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
- headers = source as Record<string, string | string[]>
1459
- } else {
1460
- headers = Object.create(null) as Record<string, string | string[]>
1461
- for (const [key, value] of Object.entries(source)) {
1462
- headers[key.toLowerCase()] = value as string | string[]
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 (headers["content-type"] === undefined) headers["Content-Type"] = JSON_CONTENT_TYPE
1472
- else {
1473
- headers["Content-Type"] = headers["content-type"]
1474
- delete headers["content-type"]
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; discard a user/native-hook length even when the body is
1480
- // already represented as null so the direct writer cannot advertise bytes that will not ship.
1481
- delete headers["content-length"]
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["Content-Length"] = "0"
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
- function toWebRequest(req: IncomingMessage, protocol: RequestProtocol, host: string): Request {
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
- constructor(nodeReq: IncomingMessage, method: string, protocol: RequestProtocol, host: string) {
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.bodyValue
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
- constructor(nodeReq: IncomingMessage, method: string, protocol: RequestProtocol, host: string) {
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 = body ?? claimableWebStream(req, "drain")
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
- function waitForDrain(nodeRes: ServerResponse): Promise<boolean> {
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
- resolveWs: (request: Request) => WsUpgradeOutcome | Promise<WsUpgradeOutcome>,
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
- outcome = await resolveWs(toWebRequest(nodeReq, getProtocol(nodeReq), host))
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
- writeUpgradeRejection(socket, 404, "not_found") // upgrade to a path with no WS route
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
+ }