@nifrajs/node 2.13.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/dist/generated/bridge-markers.d.ts +11 -0
- package/dist/generated/bridge-markers.d.ts.map +1 -0
- package/dist/generated/bridge-markers.js +14 -0
- package/dist/generated/bridge-markers.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +394 -61
- package/dist/index.js.map +1 -1
- package/dist/node-stream.d.ts +60 -0
- package/dist/node-stream.d.ts.map +1 -0
- package/dist/node-stream.js +174 -0
- package/dist/node-stream.js.map +1 -0
- package/package.json +1 -1
- package/src/generated/bridge-markers.ts +14 -0
- package/src/index.ts +481 -69
- package/src/node-stream.ts +212 -0
package/src/index.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* `Request`/`Response`, plus a Bun-`listen()`-style graceful `stop()`.
|
|
12
12
|
*/
|
|
13
13
|
import { AsyncLocalStorage } from "node:async_hooks"
|
|
14
|
+
import { constants as FS } from "node:fs"
|
|
14
15
|
import { open, realpath } from "node:fs/promises"
|
|
15
16
|
import {
|
|
16
17
|
createServer,
|
|
@@ -19,11 +20,13 @@ import {
|
|
|
19
20
|
type ServerResponse,
|
|
20
21
|
} from "node:http"
|
|
21
22
|
import { extname, isAbsolute, relative, resolve, sep } from "node:path"
|
|
22
|
-
import {
|
|
23
|
+
import type { Duplex, Readable } from "node:stream"
|
|
23
24
|
import { fileURLToPath } from "node:url"
|
|
24
25
|
// srvx's lazy spec-shaped Response - see nodeOutcomeToResponse for why the bridge uses it.
|
|
25
26
|
import { FastResponse } from "srvx/node"
|
|
27
|
+
import { NODE_BRIDGE_MARKER_KEYS } from "./generated/bridge-markers.ts"
|
|
26
28
|
import type { NodeServeOutcome } from "./generated/node-outcome.ts"
|
|
29
|
+
import { claimableWebStream, claimNodeStream } from "./node-stream.ts"
|
|
27
30
|
|
|
28
31
|
/** The runtime platform a nifra app accepts as `fetch`'s 2nd arg - here, the observed socket peer. */
|
|
29
32
|
interface NodePlatform {
|
|
@@ -126,6 +129,27 @@ interface NodeRequestSource {
|
|
|
126
129
|
rawBodyReaders?(): NodeRawBodyReaders
|
|
127
130
|
}
|
|
128
131
|
|
|
132
|
+
/** The request/response view handed to a mounted handler's optional native lane. It deliberately
|
|
133
|
+
* keeps the raw IncomingMessage so an upstream client receives the original Node stream. */
|
|
134
|
+
interface NodeNativeMountRequest {
|
|
135
|
+
readonly method: string
|
|
136
|
+
readonly url: string
|
|
137
|
+
readonly headers: IncomingHttpHeaders
|
|
138
|
+
readonly raw: IncomingMessage
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
type NodeNativeMountHandler = (
|
|
142
|
+
request: NodeNativeMountRequest,
|
|
143
|
+
response: ServerResponse,
|
|
144
|
+
platform?: NodePlatform,
|
|
145
|
+
) => undefined | false | Promise<undefined | false>
|
|
146
|
+
|
|
147
|
+
interface NodeNativeMountSelection {
|
|
148
|
+
readonly handler: NodeNativeMountHandler
|
|
149
|
+
readonly path: string
|
|
150
|
+
readonly stripPrefix: boolean
|
|
151
|
+
}
|
|
152
|
+
|
|
129
153
|
/**
|
|
130
154
|
* Core's `RawBodyReaders`: the pre-cap reader surface the transport cap buffers through. Mirrored
|
|
131
155
|
* structurally here rather than imported, matching how the rest of this adapter states core's
|
|
@@ -149,7 +173,7 @@ interface NodeContextSet {
|
|
|
149
173
|
interface NodeOutcomeRuntime {
|
|
150
174
|
toOutcome(result: unknown, set: NodeContextSet): NodeServeOutcome
|
|
151
175
|
toResponse(outcome: NodeServeOutcome): Response
|
|
152
|
-
fromResponse(response: Response): NodeServeOutcome
|
|
176
|
+
fromResponse(response: Response | NodeResponseResult): NodeServeOutcome
|
|
153
177
|
timeout(): NodeServeOutcome
|
|
154
178
|
/** The Content-Type the json render writes implicitly - surfaced to core's native hook walk. */
|
|
155
179
|
readonly jsonContentType?: string
|
|
@@ -169,6 +193,8 @@ interface NodeFastHandler extends FetchHandler {
|
|
|
169
193
|
): NodeServeOutcome | Promise<NodeServeOutcome>
|
|
170
194
|
}
|
|
171
195
|
|
|
196
|
+
const RESOLVE_NODE_MOUNT = Symbol.for(NODE_BRIDGE_MARKER_KEYS.resolveNodeMount)
|
|
197
|
+
|
|
172
198
|
/**
|
|
173
199
|
* The `Content-Type` the host runtime's `Response.json` emits - Node's undici uses `application/json`,
|
|
174
200
|
* Bun uses `application/json;charset=utf-8`. Probed once at module load (zero per-request cost) so the
|
|
@@ -178,12 +204,12 @@ const JSON_CONTENT_TYPE = Response.json(0).headers.get("content-type") ?? "appli
|
|
|
178
204
|
|
|
179
205
|
const INTERNAL_ERROR_BODY = '{"ok":false,"error":"internal_error"}'
|
|
180
206
|
const EMPTY_BUFFER = Buffer.alloc(0)
|
|
181
|
-
const NODE_RESPONSE_BODY = Symbol.for(
|
|
182
|
-
const RESPONSE_RESULT = Symbol.for(
|
|
207
|
+
const NODE_RESPONSE_BODY = Symbol.for(NODE_BRIDGE_MARKER_KEYS.responseBody)
|
|
208
|
+
const RESPONSE_RESULT = Symbol.for(NODE_BRIDGE_MARKER_KEYS.responseResult)
|
|
183
209
|
/** Core's proof that a header record's names are already the lowercase wire spelling - set once per
|
|
184
210
|
* request by the native response walk, which had to look at the same keys anyway. Declared by key
|
|
185
211
|
* rather than imported: the same cross-package convention as the two marks above. */
|
|
186
|
-
const LOWERCASE_HEADER_KEYS = Symbol.for(
|
|
212
|
+
const LOWERCASE_HEADER_KEYS = Symbol.for(NODE_BRIDGE_MARKER_KEYS.lowercaseHeaderKeys)
|
|
187
213
|
|
|
188
214
|
function isBodylessStatus(status: number): boolean {
|
|
189
215
|
return status === 204 || status === 205 || status === 304
|
|
@@ -204,6 +230,20 @@ function normalizeBodylessResponse(response: Response): Response {
|
|
|
204
230
|
const NODE_OUTCOME_RUNTIME: NodeOutcomeRuntime = {
|
|
205
231
|
toOutcome(result, set) {
|
|
206
232
|
if (isResponseResult(result)) {
|
|
233
|
+
// A plain render never builds a `Response` - same `kind: "json"` lane a handler's return takes.
|
|
234
|
+
const plain = result.plain
|
|
235
|
+
if (plain !== undefined) {
|
|
236
|
+
return {
|
|
237
|
+
kind: "json",
|
|
238
|
+
status: plain.status,
|
|
239
|
+
headers: plainRenderHeaders(plain.headers, set._headers),
|
|
240
|
+
cookies: set._cookies,
|
|
241
|
+
body:
|
|
242
|
+
plain.body === undefined || isBodylessStatus(plain.status)
|
|
243
|
+
? null
|
|
244
|
+
: JSON.stringify(plain.body),
|
|
245
|
+
}
|
|
246
|
+
}
|
|
207
247
|
const body = result.toNodeBody?.()
|
|
208
248
|
if (body !== undefined) {
|
|
209
249
|
return {
|
|
@@ -245,6 +285,25 @@ interface NodeResponseResult {
|
|
|
245
285
|
readonly headers: Readonly<Record<string, string | readonly string[]>> | undefined
|
|
246
286
|
readonly body: string | Uint8Array
|
|
247
287
|
}
|
|
288
|
+
/** Core's `PlainRender` - a response still in value form, rendered without building a `Response`.
|
|
289
|
+
* Structural, like the rest of this interface: the marker is a `Symbol.for`, so a value from any
|
|
290
|
+
* copy of core matches. */
|
|
291
|
+
readonly plain?: {
|
|
292
|
+
readonly status: number
|
|
293
|
+
readonly headers?: Readonly<Record<string, string>>
|
|
294
|
+
readonly body: unknown
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Mirror of core's `plainRenderHeaders`: the render's own headers on top of `c.set.headers`, always
|
|
299
|
+
* a fresh record when it has any, because the writers below mutate what they are handed and a
|
|
300
|
+
* `status(...)` value is commonly hoisted and reused across requests. */
|
|
301
|
+
function plainRenderHeaders(
|
|
302
|
+
own: Readonly<Record<string, string>> | undefined,
|
|
303
|
+
ambient: Record<string, string> | undefined,
|
|
304
|
+
): Record<string, string> | undefined {
|
|
305
|
+
if (own === undefined) return ambient
|
|
306
|
+
return ambient === undefined ? { ...own } : { ...ambient, ...own }
|
|
248
307
|
}
|
|
249
308
|
|
|
250
309
|
function isResponseResult(value: unknown): value is NodeResponseResult {
|
|
@@ -453,8 +512,28 @@ function appendCookiesToResponse(
|
|
|
453
512
|
return response
|
|
454
513
|
}
|
|
455
514
|
|
|
456
|
-
|
|
457
|
-
|
|
515
|
+
/** Early exits built outside the handler's finalizer - an `onRequest` hook's response, a mount's,
|
|
516
|
+
* every framework error render. A plain-data carrier takes the `kind: "json"` lane a handler's plain
|
|
517
|
+
* return takes, with no `Response` built and none drained; there is no `c.set` at these sites, which
|
|
518
|
+
* is why they are wrapped rather than finalized. */
|
|
519
|
+
function nodeOutcomeFromResponse(result: Response | NodeResponseResult): NodeServeOutcome {
|
|
520
|
+
if (!(result instanceof Response)) {
|
|
521
|
+
const plain = result.plain
|
|
522
|
+
if (plain !== undefined) {
|
|
523
|
+
return {
|
|
524
|
+
kind: "json",
|
|
525
|
+
status: plain.status,
|
|
526
|
+
headers: plain.headers === undefined ? undefined : { ...plain.headers },
|
|
527
|
+
cookies: undefined,
|
|
528
|
+
body:
|
|
529
|
+
plain.body === undefined || isBodylessStatus(plain.status)
|
|
530
|
+
? null
|
|
531
|
+
: JSON.stringify(plain.body),
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return nodeOutcomeFromResponse(result.toResponse())
|
|
535
|
+
}
|
|
536
|
+
const response = normalizeBodylessResponse(result)
|
|
458
537
|
if (!response.bodyUsed) {
|
|
459
538
|
const body = (response as { readonly [NODE_RESPONSE_BODY]?: unknown })[NODE_RESPONSE_BODY]
|
|
460
539
|
if (typeof body === "string" || body instanceof Uint8Array) {
|
|
@@ -829,19 +908,23 @@ async function readStatic(
|
|
|
829
908
|
): Promise<NodeServeOutcome> {
|
|
830
909
|
let handle: Awaited<ReturnType<typeof open>> | undefined
|
|
831
910
|
try {
|
|
832
|
-
handle = await open(file, "r")
|
|
833
|
-
const stat = await handle.stat()
|
|
834
|
-
if (!stat.isFile()) {
|
|
835
|
-
await handle.close()
|
|
836
|
-
return { kind: "response", response: new Response("Not Found", { status: 404 }) }
|
|
837
|
-
}
|
|
838
911
|
// Defense-in-depth: the lexical `..` guard in staticMatch can't catch a symlink INSIDE root that
|
|
839
|
-
// points outside it.
|
|
912
|
+
// points outside it. Resolve first, check containment on the RESOLVED name, and open that -
|
|
913
|
+
// never open the requested path and re-resolve it afterwards. A local attacker who can write
|
|
914
|
+
// inside the served tree wins that ordering: swap a link between the open and the lookup and the
|
|
915
|
+
// descriptor already streaming refers to an external file while the lookup answers with a
|
|
916
|
+
// contained path. `O_NOFOLLOW` closes the remainder on the final component - `realpath` returned
|
|
917
|
+
// a name with no links left in it, so one appearing before the open is an attack, not a layout.
|
|
840
918
|
const [realFile, realRoot] = await Promise.all([realpath(file), realpath(state.root)])
|
|
841
919
|
if (realFile !== realRoot && !realFile.startsWith(realRoot + sep)) {
|
|
842
|
-
await handle.close()
|
|
843
920
|
return { kind: "response", response: new Response("Forbidden", { status: 403 }) }
|
|
844
921
|
}
|
|
922
|
+
handle = await open(realFile, FS.O_RDONLY | FS.O_NOFOLLOW)
|
|
923
|
+
const stat = await handle.stat()
|
|
924
|
+
if (!stat.isFile()) {
|
|
925
|
+
await handle.close()
|
|
926
|
+
return { kind: "response", response: new Response("Not Found", { status: 404 }) }
|
|
927
|
+
}
|
|
845
928
|
const headers: Record<string, string> = { ...state.headers }
|
|
846
929
|
headers["content-type"] =
|
|
847
930
|
STATIC_CONTENT_TYPES[extname(file).toLowerCase()] ?? "application/octet-stream"
|
|
@@ -858,7 +941,11 @@ async function readStatic(
|
|
|
858
941
|
const stream = handle.createReadStream()
|
|
859
942
|
return {
|
|
860
943
|
kind: "response",
|
|
861
|
-
|
|
944
|
+
// Claimable rather than `Readable.toWeb`: served straight from disk to the socket when this
|
|
945
|
+
// response reaches the writer untouched, and read as an ordinary Web stream by anything that
|
|
946
|
+
// gets to it first (a middleware that rewrites or compresses the body, say), which refuses
|
|
947
|
+
// the claim and takes the conversion instead.
|
|
948
|
+
response: new Response(claimableWebStream(stream), { headers }),
|
|
862
949
|
}
|
|
863
950
|
} catch {
|
|
864
951
|
await handle?.close().catch(() => {})
|
|
@@ -918,14 +1005,20 @@ export function serve(app: FetchHandler, options: ServeOptions): Promise<NodeSer
|
|
|
918
1005
|
try {
|
|
919
1006
|
const handled = handle(app, nodeReq, nodeRes, protocol, staticState, hostPolicy)
|
|
920
1007
|
if (handled instanceof Promise) {
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
1008
|
+
// `catch` before `finally`: a `finally` alone forwards the rejection to a promise nothing
|
|
1009
|
+
// observes, and Node terminates the process on an unhandled rejection by default. Every
|
|
1010
|
+
// writer already ends its own failures (`failWrite`), so anything arriving here is a fault
|
|
1011
|
+
// no response can still be built from - the connection is the only thing left to end.
|
|
1012
|
+
void handled
|
|
1013
|
+
.catch(() => failWrite(nodeRes))
|
|
1014
|
+
.finally(() => {
|
|
1015
|
+
inFlight -= 1
|
|
1016
|
+
})
|
|
924
1017
|
return
|
|
925
1018
|
}
|
|
926
1019
|
inFlight -= 1
|
|
927
1020
|
} catch {
|
|
928
|
-
|
|
1021
|
+
failWrite(nodeRes)
|
|
929
1022
|
inFlight -= 1
|
|
930
1023
|
}
|
|
931
1024
|
})
|
|
@@ -992,6 +1085,66 @@ export function serve(app: FetchHandler, options: ServeOptions): Promise<NodeSer
|
|
|
992
1085
|
})
|
|
993
1086
|
}
|
|
994
1087
|
|
|
1088
|
+
/** Run the existing Node-direct/Web bridge after a native mount either was not selected or declined
|
|
1089
|
+
* because its optional transport was unavailable. Kept as a top-level helper so ordinary requests
|
|
1090
|
+
* do not allocate a per-request closure merely to preserve the fallback. */
|
|
1091
|
+
function runNodeSource(
|
|
1092
|
+
app: FetchHandler,
|
|
1093
|
+
source: NodeRequestSource,
|
|
1094
|
+
nodeReq: IncomingMessage,
|
|
1095
|
+
nodeRes: ServerResponse,
|
|
1096
|
+
protocol: RequestProtocol,
|
|
1097
|
+
host: string,
|
|
1098
|
+
platform: NodePlatform | undefined,
|
|
1099
|
+
): void | Promise<void> {
|
|
1100
|
+
const resolveNodeSource = (app as Partial<NodeFastHandler>).resolveNodeSource
|
|
1101
|
+
if (typeof resolveNodeSource === "function") {
|
|
1102
|
+
try {
|
|
1103
|
+
const outcome = resolveNodeSource.call(app, source, platform, NODE_OUTCOME_RUNTIME)
|
|
1104
|
+
return outcome instanceof Promise
|
|
1105
|
+
? outcome.then(
|
|
1106
|
+
(settled) => writeOutcomeSafely(settled, nodeRes, nodeReq.method),
|
|
1107
|
+
() => failWrite(nodeRes),
|
|
1108
|
+
)
|
|
1109
|
+
: writeOutcomeSafely(outcome, nodeRes, nodeReq.method)
|
|
1110
|
+
} catch {
|
|
1111
|
+
failWrite(nodeRes)
|
|
1112
|
+
return
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
const request = toWebRequest(nodeReq, protocol, host)
|
|
1117
|
+
const resolveNode = (app as Partial<NodeFastHandler>).resolveNode
|
|
1118
|
+
if (typeof resolveNode === "function") {
|
|
1119
|
+
try {
|
|
1120
|
+
const outcome = resolveNode.call(app, request, platform)
|
|
1121
|
+
return outcome instanceof Promise
|
|
1122
|
+
? outcome.then(
|
|
1123
|
+
(settled) => writeOutcomeSafely(settled, nodeRes, nodeReq.method),
|
|
1124
|
+
() => failWrite(nodeRes),
|
|
1125
|
+
)
|
|
1126
|
+
: writeOutcomeSafely(outcome, nodeRes, nodeReq.method)
|
|
1127
|
+
} catch {
|
|
1128
|
+
failWrite(nodeRes)
|
|
1129
|
+
return
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
try {
|
|
1134
|
+
const response = app.fetch(request, platform)
|
|
1135
|
+
return response instanceof Promise
|
|
1136
|
+
? response.then(
|
|
1137
|
+
(settled) => writeResponseSafely(settled, nodeRes, nodeReq.method),
|
|
1138
|
+
() => failWrite(nodeRes),
|
|
1139
|
+
)
|
|
1140
|
+
: writeResponseSafely(response, nodeRes, nodeReq.method)
|
|
1141
|
+
} catch {
|
|
1142
|
+
// The app should never throw (nifra returns a 500), but never leak a stack to the wire.
|
|
1143
|
+
failWrite(nodeRes)
|
|
1144
|
+
return
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
995
1148
|
function handle(
|
|
996
1149
|
app: FetchHandler,
|
|
997
1150
|
nodeReq: IncomingMessage,
|
|
@@ -1032,38 +1185,65 @@ function handle(
|
|
|
1032
1185
|
if (staticState !== undefined && (nodeReq.method === "GET" || nodeReq.method === "HEAD")) {
|
|
1033
1186
|
const matched = staticMatch(staticState, nodeReq.url ?? "/")
|
|
1034
1187
|
if (matched !== "pass") {
|
|
1035
|
-
if ("reject" in matched) return
|
|
1188
|
+
if ("reject" in matched) return writeResponseSafely(matched.reject, nodeRes, nodeReq.method)
|
|
1036
1189
|
return readStatic(matched.file, staticState, nodeReq.method ?? "GET").then(
|
|
1037
|
-
(outcome) =>
|
|
1038
|
-
() =>
|
|
1190
|
+
(outcome) => writeOutcomeSafely(outcome, nodeRes, nodeReq.method),
|
|
1191
|
+
() => failWrite(nodeRes),
|
|
1039
1192
|
)
|
|
1040
1193
|
}
|
|
1041
1194
|
}
|
|
1042
1195
|
|
|
1043
|
-
//
|
|
1044
|
-
//
|
|
1045
|
-
//
|
|
1046
|
-
//
|
|
1047
|
-
// behavior is identical. A plain `{ fetch }` handler (no `resolveNode`) uses the Web path too.
|
|
1196
|
+
// A mounted handler may advertise a native Node lane. Core has already checked route precedence
|
|
1197
|
+
// and every global lifecycle gate before returning this selection. The handler writes directly to
|
|
1198
|
+
// `nodeRes`; `false` means its optional transport was unavailable, so the same untouched source
|
|
1199
|
+
// continues through the ordinary Web/direct path below.
|
|
1048
1200
|
const resolveNodeSource = (app as Partial<NodeFastHandler>).resolveNodeSource
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1201
|
+
const resolveNodeMount = (app as unknown as Record<symbol, unknown>)[RESOLVE_NODE_MOUNT]
|
|
1202
|
+
if (typeof resolveNodeSource === "function" || typeof resolveNodeMount === "function") {
|
|
1203
|
+
const nodeSource = toNodeRequestSource(nodeReq, protocol, host)
|
|
1204
|
+
|
|
1205
|
+
if (typeof resolveNodeMount === "function") {
|
|
1206
|
+
let selection: NodeNativeMountSelection | undefined
|
|
1207
|
+
try {
|
|
1208
|
+
selection = (
|
|
1209
|
+
resolveNodeMount as (
|
|
1210
|
+
source: NodeRequestSource,
|
|
1211
|
+
platform?: NodePlatform,
|
|
1212
|
+
) => NodeNativeMountSelection | undefined
|
|
1213
|
+
).call(app, nodeSource, platform)
|
|
1214
|
+
} catch {
|
|
1215
|
+
writeInternalError(nodeRes)
|
|
1216
|
+
return
|
|
1217
|
+
}
|
|
1218
|
+
if (selection !== undefined) {
|
|
1219
|
+
const nativeRequest: NodeNativeMountRequest = {
|
|
1220
|
+
method: nodeReq.method ?? "GET",
|
|
1221
|
+
url: selection.stripPrefix
|
|
1222
|
+
? stripNodeMountPrefix(`${protocol}://${host}${nodeReq.url ?? "/"}`, selection.path)
|
|
1223
|
+
: `${protocol}://${host}${nodeReq.url ?? "/"}`,
|
|
1224
|
+
headers: nodeReq.headers,
|
|
1225
|
+
raw: nodeReq,
|
|
1226
|
+
}
|
|
1227
|
+
try {
|
|
1228
|
+
const outcome = selection.handler(nativeRequest, nodeRes, platform)
|
|
1229
|
+
if (outcome instanceof Promise) {
|
|
1230
|
+
return outcome.then(
|
|
1231
|
+
(handled) =>
|
|
1232
|
+
handled === false
|
|
1233
|
+
? runNodeSource(app, nodeSource, nodeReq, nodeRes, protocol, host, platform)
|
|
1234
|
+
: undefined,
|
|
1235
|
+
() => failWrite(nodeRes),
|
|
1236
|
+
)
|
|
1237
|
+
}
|
|
1238
|
+
if (outcome !== false) return
|
|
1239
|
+
} catch {
|
|
1240
|
+
failWrite(nodeRes)
|
|
1241
|
+
return
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1066
1244
|
}
|
|
1245
|
+
|
|
1246
|
+
return runNodeSource(app, nodeSource, nodeReq, nodeRes, protocol, host, platform)
|
|
1067
1247
|
}
|
|
1068
1248
|
|
|
1069
1249
|
const request = toWebRequest(nodeReq, protocol, host)
|
|
@@ -1073,12 +1253,12 @@ function handle(
|
|
|
1073
1253
|
const outcome = resolveNode.call(app, request, platform)
|
|
1074
1254
|
return outcome instanceof Promise
|
|
1075
1255
|
? outcome.then(
|
|
1076
|
-
(settled) =>
|
|
1077
|
-
() =>
|
|
1256
|
+
(settled) => writeOutcomeSafely(settled, nodeRes, nodeReq.method),
|
|
1257
|
+
() => failWrite(nodeRes),
|
|
1078
1258
|
)
|
|
1079
|
-
:
|
|
1259
|
+
: writeOutcomeSafely(outcome, nodeRes, nodeReq.method)
|
|
1080
1260
|
} catch {
|
|
1081
|
-
|
|
1261
|
+
failWrite(nodeRes)
|
|
1082
1262
|
return
|
|
1083
1263
|
}
|
|
1084
1264
|
}
|
|
@@ -1087,13 +1267,13 @@ function handle(
|
|
|
1087
1267
|
const response = app.fetch(request, platform)
|
|
1088
1268
|
return response instanceof Promise
|
|
1089
1269
|
? response.then(
|
|
1090
|
-
(settled) =>
|
|
1091
|
-
() =>
|
|
1270
|
+
(settled) => writeResponseSafely(settled, nodeRes, nodeReq.method),
|
|
1271
|
+
() => failWrite(nodeRes),
|
|
1092
1272
|
)
|
|
1093
|
-
:
|
|
1273
|
+
: writeResponseSafely(response, nodeRes, nodeReq.method)
|
|
1094
1274
|
} catch {
|
|
1095
1275
|
// The app should never throw (nifra returns a 500), but never leak a stack to the wire.
|
|
1096
|
-
|
|
1276
|
+
failWrite(nodeRes)
|
|
1097
1277
|
return
|
|
1098
1278
|
}
|
|
1099
1279
|
}
|
|
@@ -1101,16 +1281,18 @@ function handle(
|
|
|
1101
1281
|
function writeNodeOutcome(
|
|
1102
1282
|
outcome: NodeServeOutcome,
|
|
1103
1283
|
nodeRes: ServerResponse,
|
|
1284
|
+
method?: string,
|
|
1104
1285
|
): void | Promise<void> {
|
|
1286
|
+
const isHead = method?.toUpperCase() === "HEAD"
|
|
1105
1287
|
if (outcome.kind === "json") {
|
|
1106
|
-
writeJsonOutcome(outcome, nodeRes)
|
|
1288
|
+
writeJsonOutcome(outcome, nodeRes, isHead)
|
|
1107
1289
|
return
|
|
1108
1290
|
}
|
|
1109
1291
|
if (outcome.kind === "body") {
|
|
1110
|
-
writeBodyOutcome(outcome, nodeRes)
|
|
1292
|
+
writeBodyOutcome(outcome, nodeRes, isHead)
|
|
1111
1293
|
return
|
|
1112
1294
|
}
|
|
1113
|
-
return writeNodeResponse(outcome.response, nodeRes)
|
|
1295
|
+
return writeNodeResponse(outcome.response, nodeRes, method)
|
|
1114
1296
|
}
|
|
1115
1297
|
|
|
1116
1298
|
/** A flat 500 with no leaked detail - the adapter's last-resort guard if a handler throws. */
|
|
@@ -1119,6 +1301,62 @@ function writeInternalError(nodeRes: ServerResponse): void {
|
|
|
1119
1301
|
nodeRes.end(INTERNAL_ERROR_BODY)
|
|
1120
1302
|
}
|
|
1121
1303
|
|
|
1304
|
+
/**
|
|
1305
|
+
* End a request whose WRITE failed, from anywhere a write can fail.
|
|
1306
|
+
*
|
|
1307
|
+
* `writeHead` throws on an invalid status or a header value carrying CR/LF - reachable whenever an
|
|
1308
|
+
* app reflects request data into `status(...)`/`c.set.headers`, which on the Web lane the `Headers`
|
|
1309
|
+
* constructor rejects instead. It also throws once the head is already out. Both have to end here:
|
|
1310
|
+
* on the async lanes the write runs inside a `.then` callback whose rejection nothing downstream
|
|
1311
|
+
* catches (`serve` only observes `finally`), and Node's default for an unhandled rejection is to
|
|
1312
|
+
* terminate the process - a route-shaped input turning into a server-wide DoS.
|
|
1313
|
+
*
|
|
1314
|
+
* A 500 while the head is unsent, otherwise a destroy: there is no way to correct a response whose
|
|
1315
|
+
* status line already shipped, and a half-written body must not be left for the client to parse.
|
|
1316
|
+
*/
|
|
1317
|
+
function failWrite(nodeRes: ServerResponse): void {
|
|
1318
|
+
try {
|
|
1319
|
+
if (!nodeRes.headersSent) {
|
|
1320
|
+
writeInternalError(nodeRes)
|
|
1321
|
+
return
|
|
1322
|
+
}
|
|
1323
|
+
} catch {
|
|
1324
|
+
// The socket went away between the check and the write; fall through to the destroy.
|
|
1325
|
+
}
|
|
1326
|
+
nodeRes.destroy()
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/** {@link writeNodeOutcome} with {@link failWrite} behind it, sync throw and async rejection alike. */
|
|
1330
|
+
function writeOutcomeSafely(
|
|
1331
|
+
outcome: NodeServeOutcome,
|
|
1332
|
+
nodeRes: ServerResponse,
|
|
1333
|
+
method?: string,
|
|
1334
|
+
): void | Promise<void> {
|
|
1335
|
+
try {
|
|
1336
|
+
const written = writeNodeOutcome(outcome, nodeRes, method)
|
|
1337
|
+
return written instanceof Promise ? written.catch(() => failWrite(nodeRes)) : written
|
|
1338
|
+
} catch {
|
|
1339
|
+
failWrite(nodeRes)
|
|
1340
|
+
return
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/** {@link writeNodeResponse} with {@link failWrite} behind it - same contract as
|
|
1345
|
+
* {@link writeOutcomeSafely}, for the lanes that carry a `Response`. */
|
|
1346
|
+
function writeResponseSafely(
|
|
1347
|
+
response: Response,
|
|
1348
|
+
nodeRes: ServerResponse,
|
|
1349
|
+
method?: string,
|
|
1350
|
+
): void | Promise<void> {
|
|
1351
|
+
try {
|
|
1352
|
+
const written = writeNodeResponse(response, nodeRes, method)
|
|
1353
|
+
return written instanceof Promise ? written.catch(() => failWrite(nodeRes)) : written
|
|
1354
|
+
} catch {
|
|
1355
|
+
failWrite(nodeRes)
|
|
1356
|
+
return
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1122
1360
|
function writeBadRequest(nodeRes: ServerResponse): void {
|
|
1123
1361
|
const body = "Bad Request"
|
|
1124
1362
|
nodeRes.writeHead(400, {
|
|
@@ -1149,6 +1387,7 @@ function allHeaderKeysLowercase(record: Readonly<Record<string, unknown>>): bool
|
|
|
1149
1387
|
function writeJsonOutcome(
|
|
1150
1388
|
outcome: Extract<NodeServeOutcome, { kind: "json" }>,
|
|
1151
1389
|
nodeRes: ServerResponse,
|
|
1390
|
+
isHead = false,
|
|
1152
1391
|
): void {
|
|
1153
1392
|
// The outcome's record is the request's own (`c.set.headers`, already mutated by any native
|
|
1154
1393
|
// response hooks), and its writers - middleware twins and the framework's own additions - emit
|
|
@@ -1191,13 +1430,23 @@ function writeJsonOutcome(
|
|
|
1191
1430
|
// 204/205/304 never carry a payload; discard a user/native-hook length even when the body is
|
|
1192
1431
|
// already represented as null so the direct writer cannot advertise bytes that will not ship.
|
|
1193
1432
|
delete headers["content-length"]
|
|
1433
|
+
} else if (!isHead && headers["content-length"] === undefined) {
|
|
1434
|
+
// A body-less render at a status that MAY carry a body - a `redirect()`, above all. Node frames
|
|
1435
|
+
// a `writeHead` + bare `end()` as chunked, so the shortest response the framework emits went out
|
|
1436
|
+
// with a chunk terminator and no length, where every Web-native runtime sends `content-length: 0`.
|
|
1437
|
+
// Declared here so the wire matches them. HEAD is excluded: its length describes the GET's body,
|
|
1438
|
+
// which this lane does not know.
|
|
1439
|
+
headers["content-length"] = "0"
|
|
1194
1440
|
}
|
|
1195
1441
|
if (outcome.cookies !== undefined && outcome.cookies.length > 0) {
|
|
1196
1442
|
headers["set-cookie"] = [...outcome.cookies]
|
|
1197
1443
|
}
|
|
1198
1444
|
nodeRes.writeHead(outcome.status, headers)
|
|
1199
|
-
if (outcome.body
|
|
1200
|
-
|
|
1445
|
+
if (isHead || outcome.body === null) {
|
|
1446
|
+
nodeRes.end()
|
|
1447
|
+
} else {
|
|
1448
|
+
nodeRes.end(outcome.body)
|
|
1449
|
+
}
|
|
1201
1450
|
}
|
|
1202
1451
|
|
|
1203
1452
|
/**
|
|
@@ -1208,6 +1457,7 @@ function writeJsonOutcome(
|
|
|
1208
1457
|
function writeBodyOutcome(
|
|
1209
1458
|
outcome: Extract<NodeServeOutcome, { kind: "body" }>,
|
|
1210
1459
|
nodeRes: ServerResponse,
|
|
1460
|
+
isHead = false,
|
|
1211
1461
|
): void {
|
|
1212
1462
|
// Body outcomes already carry response-normalized header names and values. Node consumes the same
|
|
1213
1463
|
// mutable runtime shapes (`string` / `string[]`); avoid cloning this record and every Set-Cookie
|
|
@@ -1236,7 +1486,8 @@ function writeBodyOutcome(
|
|
|
1236
1486
|
}
|
|
1237
1487
|
nodeRes.writeHead(outcome.status, headers)
|
|
1238
1488
|
}
|
|
1239
|
-
nodeRes.end(
|
|
1489
|
+
if (isHead) nodeRes.end()
|
|
1490
|
+
else nodeRes.end(outcome.body)
|
|
1240
1491
|
}
|
|
1241
1492
|
|
|
1242
1493
|
const byteLengthOf = (body: string | Uint8Array): number =>
|
|
@@ -1276,6 +1527,14 @@ function toNodeRequestSource(
|
|
|
1276
1527
|
: new LazyNodeRequestSource(req, method, protocol, host)
|
|
1277
1528
|
}
|
|
1278
1529
|
|
|
1530
|
+
function stripNodeMountPrefix(url: string, prefix: string): string {
|
|
1531
|
+
if (prefix === "/") return url
|
|
1532
|
+
const target = new URL(url)
|
|
1533
|
+
const rest = target.pathname.slice(prefix.length)
|
|
1534
|
+
target.pathname = rest === "" ? "/" : rest
|
|
1535
|
+
return target.href
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1279
1538
|
/**
|
|
1280
1539
|
* Split an origin-form request target ("/path?q#frag") into pathname + search without synthesizing
|
|
1281
1540
|
* an absolute URL first. Mirrors `@nifrajs/core`'s `urlPartsOf` for the origin-form case (kept in
|
|
@@ -1382,7 +1641,7 @@ class LazyNodeRequestSource implements NodeRequestSource {
|
|
|
1382
1641
|
get body(): ReadableStream<Uint8Array> | null {
|
|
1383
1642
|
if (this.method === "GET" || this.method === "HEAD") return null
|
|
1384
1643
|
if (this.consumedBody !== undefined) return this.request.body
|
|
1385
|
-
this.bodyValue ??=
|
|
1644
|
+
this.bodyValue ??= claimableWebStream(this.nodeReq, "drain")
|
|
1386
1645
|
return this.bodyValue
|
|
1387
1646
|
}
|
|
1388
1647
|
|
|
@@ -1459,7 +1718,7 @@ class LazyNodeRequestSource implements NodeRequestSource {
|
|
|
1459
1718
|
if (this.method === "GET" || this.method === "HEAD") return null
|
|
1460
1719
|
const consumed = this.consumedBody
|
|
1461
1720
|
if (consumed !== undefined) return streamOfBytes(consumed)
|
|
1462
|
-
this.bodyValue ??=
|
|
1721
|
+
this.bodyValue ??= claimableWebStream(this.nodeReq, "drain")
|
|
1463
1722
|
return this.bodyValue
|
|
1464
1723
|
}
|
|
1465
1724
|
|
|
@@ -1635,7 +1894,7 @@ function makeWebRequest(
|
|
|
1635
1894
|
const init: RequestInit & { duplex?: "half" } = { method, headers }
|
|
1636
1895
|
if (method !== "GET" && method !== "HEAD") {
|
|
1637
1896
|
// Stream the body in; `duplex: "half"` is required for a streamed request body.
|
|
1638
|
-
init.body = body ?? (
|
|
1897
|
+
init.body = body ?? claimableWebStream(req, "drain")
|
|
1639
1898
|
init.duplex = "half"
|
|
1640
1899
|
}
|
|
1641
1900
|
return new Request(url, init)
|
|
@@ -1775,7 +2034,12 @@ function waitForDrain(nodeRes: ServerResponse): Promise<boolean> {
|
|
|
1775
2034
|
})
|
|
1776
2035
|
}
|
|
1777
2036
|
|
|
1778
|
-
function writeNodeResponse(
|
|
2037
|
+
function writeNodeResponse(
|
|
2038
|
+
response: Response,
|
|
2039
|
+
nodeRes: ServerResponse,
|
|
2040
|
+
method?: string,
|
|
2041
|
+
): void | Promise<void> {
|
|
2042
|
+
const isHead = method?.toUpperCase() === "HEAD"
|
|
1779
2043
|
// `ServerResponse.setHeaders` (Node 18.14+) takes the Headers object directly and iterates its
|
|
1780
2044
|
// native Symbol.iterator, which - unlike `Headers.forEach`/`.get()` - never comma-joins repeated
|
|
1781
2045
|
// `Set-Cookie` values (Node's own implementation carries the identical correctness note this
|
|
@@ -1783,26 +2047,170 @@ function writeNodeResponse(response: Response, nodeRes: ServerResponse): void |
|
|
|
1783
2047
|
// forEach into a fresh plain object plus a second cookie-specific pass. Must run before
|
|
1784
2048
|
// `writeHead`: headers are already flushed by the time `writeHead` returns.
|
|
1785
2049
|
nodeRes.setHeaders(response.headers)
|
|
1786
|
-
|
|
2050
|
+
if (isHead) {
|
|
2051
|
+
nodeRes.writeHead(response.status)
|
|
2052
|
+
nodeRes.end()
|
|
2053
|
+
return
|
|
2054
|
+
}
|
|
1787
2055
|
const directBody = nodeResponseBody(response)
|
|
1788
2056
|
if (directBody !== undefined) {
|
|
2057
|
+
// Node computes the length itself for `end(data)` while the header is still unflushed, so this
|
|
2058
|
+
// lane already declares one. The three below do not get that for free.
|
|
2059
|
+
nodeRes.writeHead(response.status)
|
|
1789
2060
|
nodeRes.end(directBody)
|
|
1790
2061
|
return
|
|
1791
2062
|
}
|
|
2063
|
+
// `end()` with no data is framed as chunked instead, so a body-less `Response` - a hand-rolled
|
|
2064
|
+
// redirect, above all - went out with a chunk terminator and no length where every Web-native
|
|
2065
|
+
// runtime sends `content-length: 0`. Declared before `writeHead`, the last point a header can
|
|
2066
|
+
// still be added. Excluded: HEAD, whose length describes the GET's body that this lane does not
|
|
2067
|
+
// know; a status that cannot carry a body; and a length the caller set for itself.
|
|
2068
|
+
const canDeclareLength =
|
|
2069
|
+
!isBodylessStatus(response.status) && !response.headers.has("content-length")
|
|
1792
2070
|
if (response.body === null) {
|
|
2071
|
+
if (canDeclareLength) nodeRes.setHeader("content-length", "0")
|
|
2072
|
+
nodeRes.writeHead(response.status)
|
|
1793
2073
|
if (!nodeRes.destroyed && !nodeRes.writableEnded && nodeRes.writable) nodeRes.end()
|
|
1794
2074
|
return
|
|
1795
2075
|
}
|
|
1796
|
-
|
|
2076
|
+
// A body that is a Web view over a Node stream (an upstream response relayed by
|
|
2077
|
+
// `@nifrajs/proxy/undici`, say) goes to the socket as the Node stream it already is, skipping the
|
|
2078
|
+
// per-chunk trip through Web objects that the reader loop below pays for. Length is the upstream's
|
|
2079
|
+
// business: it either forwarded a `content-length` or is genuinely streaming.
|
|
2080
|
+
const nodeBody = response.bodyUsed ? null : claimNodeStream(response.body)
|
|
2081
|
+
if (nodeBody !== null) {
|
|
2082
|
+
nodeRes.writeHead(response.status)
|
|
2083
|
+
return pipeNodeResponseBody(nodeBody, nodeRes)
|
|
2084
|
+
}
|
|
2085
|
+
return writeNodeResponseBody(response, nodeRes, canDeclareLength)
|
|
1797
2086
|
}
|
|
1798
2087
|
|
|
1799
|
-
|
|
2088
|
+
/**
|
|
2089
|
+
* Send a claimed Node body straight to the socket.
|
|
2090
|
+
*
|
|
2091
|
+
* `pipe` rather than `pipeline`: the status line is already flushed by here, so the only teardown
|
|
2092
|
+
* still available is a destroy on both ends, and `pipeline`'s per-request `eos` machinery costs
|
|
2093
|
+
* more than it buys at this point - it measured as a net loss against the Web reader loop it
|
|
2094
|
+
* replaces, where plain `pipe` measured as a win on both GET and POST.
|
|
2095
|
+
*
|
|
2096
|
+
* The two destroys below are belt-and-braces, not the mechanism: for a body claimed from
|
|
2097
|
+
* `@nifrajs/proxy`, the request's abort signal already tears the upstream down when the client
|
|
2098
|
+
* hangs up, and an upstream that dies mid-body already reaches the client as a broken transfer
|
|
2099
|
+
* rather than a short `200`. They are kept because `pipe` itself guarantees neither, and the claim
|
|
2100
|
+
* seam is open to any transport - including one that wires no signal.
|
|
2101
|
+
*/
|
|
2102
|
+
function pipeNodeResponseBody(body: Readable, nodeRes: ServerResponse): Promise<void> {
|
|
2103
|
+
return new Promise<void>((resolve) => {
|
|
2104
|
+
let settled = false
|
|
2105
|
+
const done = (): void => {
|
|
2106
|
+
if (settled) return
|
|
2107
|
+
settled = true
|
|
2108
|
+
if (!body.destroyed) body.destroy()
|
|
2109
|
+
resolve()
|
|
2110
|
+
}
|
|
2111
|
+
// Attached before anything here can destroy `body`, and with `on` rather than `once`, because
|
|
2112
|
+
// destroying an upstream body emits `error` (undici raises `RequestAbortedError` for a request
|
|
2113
|
+
// cut short) and an unhandled `error` on a Node stream terminates the process. Every path below
|
|
2114
|
+
// ends in a destroy, including the ones that run after this listener has already fired once.
|
|
2115
|
+
body.on("error", () => {
|
|
2116
|
+
// Only meaningful before the response completes; afterwards there is nothing left to abort.
|
|
2117
|
+
if (!settled && !nodeRes.destroyed && !nodeRes.writableEnded) nodeRes.destroy()
|
|
2118
|
+
done()
|
|
2119
|
+
})
|
|
2120
|
+
// The client is already gone: `pipe` would wait on `close`/`finish` that have both fired.
|
|
2121
|
+
if (nodeRes.destroyed || nodeRes.writableEnded || !nodeRes.writable) {
|
|
2122
|
+
done()
|
|
2123
|
+
return
|
|
2124
|
+
}
|
|
2125
|
+
nodeRes.once("close", done)
|
|
2126
|
+
nodeRes.once("finish", done)
|
|
2127
|
+
body.pipe(nodeRes)
|
|
2128
|
+
})
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
/** The two Web-stream shapes the body lane below needs, named structurally: this package builds
|
|
2132
|
+
* without the DOM lib, so `ReadableStreamReadResult` is not a name it can refer to. */
|
|
2133
|
+
type BodyChunk =
|
|
2134
|
+
| { readonly done: false; readonly value: Uint8Array }
|
|
2135
|
+
| { readonly done: true; readonly value?: undefined }
|
|
2136
|
+
type BodyReader = { read(): Promise<BodyChunk>; cancel(): Promise<void> }
|
|
2137
|
+
|
|
2138
|
+
/**
|
|
2139
|
+
* Send a Web `ReadableStream` body to the socket, declaring a length when the body turns out to be
|
|
2140
|
+
* one already-complete chunk.
|
|
2141
|
+
*
|
|
2142
|
+
* `new Response("hi")` hands its bytes over as a stream, exactly as a live producer does, so the
|
|
2143
|
+
* framing decision looks identical from here and Node picks chunked for both. The two are told apart
|
|
2144
|
+
* by reading one chunk and then giving the stream a microtask to say it is done: a source-backed body
|
|
2145
|
+
* - a string, a `Uint8Array`, a `Blob` - has already enqueued everything and closes inside it, while
|
|
2146
|
+
* a producer still generating does not.
|
|
2147
|
+
*
|
|
2148
|
+
* What is held is one chunk, never the whole body, so a large or endless stream is unaffected in
|
|
2149
|
+
* memory. What is spent is a microtask, and it costs a streaming response no bytes on the wire: Node
|
|
2150
|
+
* does not flush the header until the first write either way, so nothing was going out during it.
|
|
2151
|
+
*/
|
|
2152
|
+
async function writeNodeResponseBody(
|
|
2153
|
+
response: Response,
|
|
2154
|
+
nodeRes: ServerResponse,
|
|
2155
|
+
canDeclareLength: boolean,
|
|
2156
|
+
): Promise<void> {
|
|
1800
2157
|
const reader = response.body!.getReader()
|
|
2158
|
+
let first: BodyChunk
|
|
2159
|
+
try {
|
|
2160
|
+
first = await reader.read()
|
|
2161
|
+
} catch {
|
|
2162
|
+
await reader.cancel().catch(() => {})
|
|
2163
|
+
if (!nodeRes.headersSent) writeInternalError(nodeRes)
|
|
2164
|
+
else nodeRes.destroy()
|
|
2165
|
+
return
|
|
2166
|
+
}
|
|
2167
|
+
if (first.done) {
|
|
2168
|
+
if (canDeclareLength) nodeRes.setHeader("content-length", "0")
|
|
2169
|
+
nodeRes.writeHead(response.status)
|
|
2170
|
+
if (!nodeRes.destroyed && !nodeRes.writableEnded && nodeRes.writable) nodeRes.end()
|
|
2171
|
+
return
|
|
2172
|
+
}
|
|
2173
|
+
// Attached with `then` rather than awaited: the point is to observe whether it has settled by the
|
|
2174
|
+
// end of this turn, not to wait for it. The rejection handler is only here so a body that fails
|
|
2175
|
+
// between the two reads is not an unhandled rejection - the drain below awaits the same promise
|
|
2176
|
+
// and is where that failure is actually handled.
|
|
2177
|
+
const pending = reader.read()
|
|
2178
|
+
let settled: BodyChunk | undefined
|
|
2179
|
+
pending.then(
|
|
2180
|
+
(value) => {
|
|
2181
|
+
settled = value
|
|
2182
|
+
},
|
|
2183
|
+
() => {},
|
|
2184
|
+
)
|
|
2185
|
+
// A microtask, not a turn of the event loop. It is enough - a source-backed body has already
|
|
2186
|
+
// enqueued everything, so its close is a microtask away on Node and two on Bun, both of which this
|
|
2187
|
+
// covers - and it is short enough to leave the socket's write order alone: yielding a whole turn
|
|
2188
|
+
// here wedged a pipelined keep-alive connection under Bun's `node:http` shim, where the second
|
|
2189
|
+
// response never reached the wire.
|
|
2190
|
+
await new Promise<void>((resolve) => queueMicrotask(resolve))
|
|
2191
|
+
if (settled?.done === true && canDeclareLength && ArrayBuffer.isView(first.value)) {
|
|
2192
|
+
nodeRes.setHeader("content-length", String(first.value.byteLength))
|
|
2193
|
+
nodeRes.writeHead(response.status)
|
|
2194
|
+
if (!nodeRes.destroyed && !nodeRes.writableEnded && nodeRes.writable) nodeRes.end(first.value)
|
|
2195
|
+
return
|
|
2196
|
+
}
|
|
2197
|
+
nodeRes.writeHead(response.status)
|
|
2198
|
+
return drainWebResponseBody(reader, nodeRes, first.value, pending)
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
/** The streaming half of {@link writeNodeResponseBody}, resumed from the chunk and the outstanding
|
|
2202
|
+
* read the length probe already took off the stream. */
|
|
2203
|
+
async function drainWebResponseBody(
|
|
2204
|
+
reader: BodyReader,
|
|
2205
|
+
nodeRes: ServerResponse,
|
|
2206
|
+
firstChunk: Uint8Array,
|
|
2207
|
+
pending: Promise<BodyChunk>,
|
|
2208
|
+
): Promise<void> {
|
|
2209
|
+
let chunk: Uint8Array | undefined = firstChunk
|
|
2210
|
+
let outstanding: Promise<BodyChunk> | undefined = pending
|
|
1801
2211
|
try {
|
|
1802
2212
|
for (;;) {
|
|
1803
|
-
|
|
1804
|
-
if (done) break
|
|
1805
|
-
if (!nodeRes.write(value)) {
|
|
2213
|
+
if (!nodeRes.write(chunk)) {
|
|
1806
2214
|
if (nodeRes.destroyed || nodeRes.writableEnded || !nodeRes.writable) {
|
|
1807
2215
|
try {
|
|
1808
2216
|
await reader.cancel()
|
|
@@ -1817,6 +2225,10 @@ async function writeNodeResponseBody(response: Response, nodeRes: ServerResponse
|
|
|
1817
2225
|
return
|
|
1818
2226
|
}
|
|
1819
2227
|
}
|
|
2228
|
+
const result = await (outstanding ?? reader.read())
|
|
2229
|
+
outstanding = undefined
|
|
2230
|
+
if (result.done) break
|
|
2231
|
+
chunk = result.value
|
|
1820
2232
|
}
|
|
1821
2233
|
} catch {
|
|
1822
2234
|
await reader.cancel().catch(() => {})
|