@nifrajs/node 3.5.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. */
@@ -509,10 +513,17 @@ function appendCookiesToResponse(
509
513
  response: Response,
510
514
  cookies: readonly string[] | undefined,
511
515
  ): Response {
512
- if (cookies !== undefined) {
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 = 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()
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
- * `@nifrajs/node` creates a plain Node `http` server, so the safe default is `"http"`. Deployments behind
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["content-length"] = String(stat.size)
937
- if (state.immutable && headers["cache-control"] === undefined) {
938
- 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)
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 = handle.createReadStream()
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
- 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
+ )
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 activeResponses = new WeakMap<object, Set<ServerResponse>>()
1011
- const parserErrorSockets = new WeakSet<object>()
1012
- const server = createServer((nodeReq, nodeRes) => {
1013
- const socket = nodeReq.socket
1014
- let responses = activeResponses.get(socket)
1015
- if (responses === undefined) {
1016
- responses = new Set<ServerResponse>()
1017
- activeResponses.set(socket, responses)
1018
- }
1019
- responses.add(nodeRes)
1020
- const releaseResponse = (): void => {
1021
- const current = activeResponses.get(socket)
1022
- if (current === undefined) return
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 responses = activeResponses.get(socket)
1053
- if (responses !== undefined && responses.size > 0) {
1054
- if (parserErrorSockets.has(socket)) return
1055
- parserErrorSockets.add(socket)
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. Confirmed either by core's mark (the native response walk already looked at
1442
- // these keys, so re-walking them here is the same pass twice) or, when the record never went
1443
- // through that walk, by the scan below. Either way the record is then used as-is and the additions
1444
- // below mutate it in place; nothing reads it after the write. Only a mixed-case key (a user's
1445
- // hand-set `X-Foo`) pays the normalization copy, keeping the wire byte-identical to undici's
1446
- // `Headers` lowercasing on every other runtime.
1447
- 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.
1448
1726
  const source = outcome.headers
1449
- if (source === undefined) {
1450
- // A route that set no headers gets a record the FRAMEWORK alone fills - content-type,
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
- headers = source as Record<string, string | string[]>
1462
- } else {
1463
- headers = Object.create(null) as Record<string, string | string[]>
1464
- for (const [key, value] of Object.entries(source)) {
1465
- 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[])
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 (headers["content-type"] === undefined) headers["Content-Type"] = JSON_CONTENT_TYPE
1475
- else {
1476
- headers["Content-Type"] = headers["content-type"]
1477
- 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)))
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; discard a user/native-hook length even when the body is
1483
- // already represented as null so the direct writer cannot advertise bytes that will not ship.
1484
- delete headers["content-length"]
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["Content-Length"] = "0"
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
- 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 {
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
- 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
+ ) {
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.bodyValue
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
- 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
+ ) {
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 = body ?? claimableWebStream(req, "drain")
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
- 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> {
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
- const platform = peerAddress === undefined ? undefined : { clientIp: peerAddress }
2363
- outcome = await resolveWs(toWebRequest(nodeReq, getProtocol(nodeReq), host), platform)
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
- 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)
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
+ }