@nifrajs/node 2.14.1 → 3.2.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 +428 -52
- 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 +509 -64
- 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(() => {})
|
|
@@ -913,23 +1000,73 @@ export function serve(app: FetchHandler, options: ServeOptions): Promise<NodeSer
|
|
|
913
1000
|
const protocol = protocolResolver(options.protocol)
|
|
914
1001
|
const hostPolicy = hostPolicyOf(options)
|
|
915
1002
|
const staticState = options.static !== undefined ? staticStateOf(options.static) : undefined
|
|
1003
|
+
// Node otherwise destroys a socket as soon as its HTTP parser emits `clientError`. That can
|
|
1004
|
+
// discard the response for an already-dispatched request when an understated Content-Length
|
|
1005
|
+
// leaves surplus bytes that look like a malformed pipelined request. Keep the parser error
|
|
1006
|
+
// 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>()
|
|
916
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)
|
|
917
1025
|
inFlight += 1
|
|
918
1026
|
try {
|
|
919
1027
|
const handled = handle(app, nodeReq, nodeRes, protocol, staticState, hostPolicy)
|
|
920
1028
|
if (handled instanceof Promise) {
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
1029
|
+
// `catch` before `finally`: a `finally` alone forwards the rejection to a promise nothing
|
|
1030
|
+
// observes, and Node terminates the process on an unhandled rejection by default. Every
|
|
1031
|
+
// writer already ends its own failures (`failWrite`), so anything arriving here is a fault
|
|
1032
|
+
// no response can still be built from - the connection is the only thing left to end.
|
|
1033
|
+
void handled
|
|
1034
|
+
.catch(() => failWrite(nodeRes))
|
|
1035
|
+
.finally(() => {
|
|
1036
|
+
inFlight -= 1
|
|
1037
|
+
})
|
|
924
1038
|
return
|
|
925
1039
|
}
|
|
926
1040
|
inFlight -= 1
|
|
927
1041
|
} catch {
|
|
928
|
-
|
|
1042
|
+
failWrite(nodeRes)
|
|
929
1043
|
inFlight -= 1
|
|
930
1044
|
}
|
|
931
1045
|
})
|
|
932
1046
|
|
|
1047
|
+
server.on("clientError", (_error, socket) => {
|
|
1048
|
+
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
|
+
}
|
|
1062
|
+
return
|
|
1063
|
+
}
|
|
1064
|
+
const body = "Bad Request"
|
|
1065
|
+
socket.end(
|
|
1066
|
+
`HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}`,
|
|
1067
|
+
)
|
|
1068
|
+
})
|
|
1069
|
+
|
|
933
1070
|
// WebSocket upgrades (a nifra app exposing the seam): handled on the http server's `upgrade` event via
|
|
934
1071
|
// the optional `ws` package - lazy-imported (and the server lazily built) on the FIRST real WS
|
|
935
1072
|
// upgrade, so a non-WS Node app never loads `ws`.
|
|
@@ -992,6 +1129,66 @@ export function serve(app: FetchHandler, options: ServeOptions): Promise<NodeSer
|
|
|
992
1129
|
})
|
|
993
1130
|
}
|
|
994
1131
|
|
|
1132
|
+
/** Run the existing Node-direct/Web bridge after a native mount either was not selected or declined
|
|
1133
|
+
* because its optional transport was unavailable. Kept as a top-level helper so ordinary requests
|
|
1134
|
+
* do not allocate a per-request closure merely to preserve the fallback. */
|
|
1135
|
+
function runNodeSource(
|
|
1136
|
+
app: FetchHandler,
|
|
1137
|
+
source: NodeRequestSource,
|
|
1138
|
+
nodeReq: IncomingMessage,
|
|
1139
|
+
nodeRes: ServerResponse,
|
|
1140
|
+
protocol: RequestProtocol,
|
|
1141
|
+
host: string,
|
|
1142
|
+
platform: NodePlatform | undefined,
|
|
1143
|
+
): void | Promise<void> {
|
|
1144
|
+
const resolveNodeSource = (app as Partial<NodeFastHandler>).resolveNodeSource
|
|
1145
|
+
if (typeof resolveNodeSource === "function") {
|
|
1146
|
+
try {
|
|
1147
|
+
const outcome = resolveNodeSource.call(app, source, platform, NODE_OUTCOME_RUNTIME)
|
|
1148
|
+
return outcome instanceof Promise
|
|
1149
|
+
? outcome.then(
|
|
1150
|
+
(settled) => writeOutcomeSafely(settled, nodeRes, nodeReq.method),
|
|
1151
|
+
() => failWrite(nodeRes),
|
|
1152
|
+
)
|
|
1153
|
+
: writeOutcomeSafely(outcome, nodeRes, nodeReq.method)
|
|
1154
|
+
} catch {
|
|
1155
|
+
failWrite(nodeRes)
|
|
1156
|
+
return
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
const request = toWebRequest(nodeReq, protocol, host)
|
|
1161
|
+
const resolveNode = (app as Partial<NodeFastHandler>).resolveNode
|
|
1162
|
+
if (typeof resolveNode === "function") {
|
|
1163
|
+
try {
|
|
1164
|
+
const outcome = resolveNode.call(app, request, platform)
|
|
1165
|
+
return outcome instanceof Promise
|
|
1166
|
+
? outcome.then(
|
|
1167
|
+
(settled) => writeOutcomeSafely(settled, nodeRes, nodeReq.method),
|
|
1168
|
+
() => failWrite(nodeRes),
|
|
1169
|
+
)
|
|
1170
|
+
: writeOutcomeSafely(outcome, nodeRes, nodeReq.method)
|
|
1171
|
+
} catch {
|
|
1172
|
+
failWrite(nodeRes)
|
|
1173
|
+
return
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
try {
|
|
1178
|
+
const response = app.fetch(request, platform)
|
|
1179
|
+
return response instanceof Promise
|
|
1180
|
+
? response.then(
|
|
1181
|
+
(settled) => writeResponseSafely(settled, nodeRes, nodeReq.method),
|
|
1182
|
+
() => failWrite(nodeRes),
|
|
1183
|
+
)
|
|
1184
|
+
: writeResponseSafely(response, nodeRes, nodeReq.method)
|
|
1185
|
+
} catch {
|
|
1186
|
+
// The app should never throw (nifra returns a 500), but never leak a stack to the wire.
|
|
1187
|
+
failWrite(nodeRes)
|
|
1188
|
+
return
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
995
1192
|
function handle(
|
|
996
1193
|
app: FetchHandler,
|
|
997
1194
|
nodeReq: IncomingMessage,
|
|
@@ -1032,38 +1229,65 @@ function handle(
|
|
|
1032
1229
|
if (staticState !== undefined && (nodeReq.method === "GET" || nodeReq.method === "HEAD")) {
|
|
1033
1230
|
const matched = staticMatch(staticState, nodeReq.url ?? "/")
|
|
1034
1231
|
if (matched !== "pass") {
|
|
1035
|
-
if ("reject" in matched) return
|
|
1232
|
+
if ("reject" in matched) return writeResponseSafely(matched.reject, nodeRes, nodeReq.method)
|
|
1036
1233
|
return readStatic(matched.file, staticState, nodeReq.method ?? "GET").then(
|
|
1037
|
-
(outcome) =>
|
|
1038
|
-
() =>
|
|
1234
|
+
(outcome) => writeOutcomeSafely(outcome, nodeRes, nodeReq.method),
|
|
1235
|
+
() => failWrite(nodeRes),
|
|
1039
1236
|
)
|
|
1040
1237
|
}
|
|
1041
1238
|
}
|
|
1042
1239
|
|
|
1043
|
-
//
|
|
1044
|
-
//
|
|
1045
|
-
//
|
|
1046
|
-
//
|
|
1047
|
-
// behavior is identical. A plain `{ fetch }` handler (no `resolveNode`) uses the Web path too.
|
|
1240
|
+
// A mounted handler may advertise a native Node lane. Core has already checked route precedence
|
|
1241
|
+
// and every global lifecycle gate before returning this selection. The handler writes directly to
|
|
1242
|
+
// `nodeRes`; `false` means its optional transport was unavailable, so the same untouched source
|
|
1243
|
+
// continues through the ordinary Web/direct path below.
|
|
1048
1244
|
const resolveNodeSource = (app as Partial<NodeFastHandler>).resolveNodeSource
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1245
|
+
const resolveNodeMount = (app as unknown as Record<symbol, unknown>)[RESOLVE_NODE_MOUNT]
|
|
1246
|
+
if (typeof resolveNodeSource === "function" || typeof resolveNodeMount === "function") {
|
|
1247
|
+
const nodeSource = toNodeRequestSource(nodeReq, protocol, host)
|
|
1248
|
+
|
|
1249
|
+
if (typeof resolveNodeMount === "function") {
|
|
1250
|
+
let selection: NodeNativeMountSelection | undefined
|
|
1251
|
+
try {
|
|
1252
|
+
selection = (
|
|
1253
|
+
resolveNodeMount as (
|
|
1254
|
+
source: NodeRequestSource,
|
|
1255
|
+
platform?: NodePlatform,
|
|
1256
|
+
) => NodeNativeMountSelection | undefined
|
|
1257
|
+
).call(app, nodeSource, platform)
|
|
1258
|
+
} catch {
|
|
1259
|
+
writeInternalError(nodeRes)
|
|
1260
|
+
return
|
|
1261
|
+
}
|
|
1262
|
+
if (selection !== undefined) {
|
|
1263
|
+
const nativeRequest: NodeNativeMountRequest = {
|
|
1264
|
+
method: nodeReq.method ?? "GET",
|
|
1265
|
+
url: selection.stripPrefix
|
|
1266
|
+
? stripNodeMountPrefix(`${protocol}://${host}${nodeReq.url ?? "/"}`, selection.path)
|
|
1267
|
+
: `${protocol}://${host}${nodeReq.url ?? "/"}`,
|
|
1268
|
+
headers: nodeReq.headers,
|
|
1269
|
+
raw: nodeReq,
|
|
1270
|
+
}
|
|
1271
|
+
try {
|
|
1272
|
+
const outcome = selection.handler(nativeRequest, nodeRes, platform)
|
|
1273
|
+
if (outcome instanceof Promise) {
|
|
1274
|
+
return outcome.then(
|
|
1275
|
+
(handled) =>
|
|
1276
|
+
handled === false
|
|
1277
|
+
? runNodeSource(app, nodeSource, nodeReq, nodeRes, protocol, host, platform)
|
|
1278
|
+
: undefined,
|
|
1279
|
+
() => failWrite(nodeRes),
|
|
1280
|
+
)
|
|
1281
|
+
}
|
|
1282
|
+
if (outcome !== false) return
|
|
1283
|
+
} catch {
|
|
1284
|
+
failWrite(nodeRes)
|
|
1285
|
+
return
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1066
1288
|
}
|
|
1289
|
+
|
|
1290
|
+
return runNodeSource(app, nodeSource, nodeReq, nodeRes, protocol, host, platform)
|
|
1067
1291
|
}
|
|
1068
1292
|
|
|
1069
1293
|
const request = toWebRequest(nodeReq, protocol, host)
|
|
@@ -1073,12 +1297,12 @@ function handle(
|
|
|
1073
1297
|
const outcome = resolveNode.call(app, request, platform)
|
|
1074
1298
|
return outcome instanceof Promise
|
|
1075
1299
|
? outcome.then(
|
|
1076
|
-
(settled) =>
|
|
1077
|
-
() =>
|
|
1300
|
+
(settled) => writeOutcomeSafely(settled, nodeRes, nodeReq.method),
|
|
1301
|
+
() => failWrite(nodeRes),
|
|
1078
1302
|
)
|
|
1079
|
-
:
|
|
1303
|
+
: writeOutcomeSafely(outcome, nodeRes, nodeReq.method)
|
|
1080
1304
|
} catch {
|
|
1081
|
-
|
|
1305
|
+
failWrite(nodeRes)
|
|
1082
1306
|
return
|
|
1083
1307
|
}
|
|
1084
1308
|
}
|
|
@@ -1087,13 +1311,13 @@ function handle(
|
|
|
1087
1311
|
const response = app.fetch(request, platform)
|
|
1088
1312
|
return response instanceof Promise
|
|
1089
1313
|
? response.then(
|
|
1090
|
-
(settled) =>
|
|
1091
|
-
() =>
|
|
1314
|
+
(settled) => writeResponseSafely(settled, nodeRes, nodeReq.method),
|
|
1315
|
+
() => failWrite(nodeRes),
|
|
1092
1316
|
)
|
|
1093
|
-
:
|
|
1317
|
+
: writeResponseSafely(response, nodeRes, nodeReq.method)
|
|
1094
1318
|
} catch {
|
|
1095
1319
|
// The app should never throw (nifra returns a 500), but never leak a stack to the wire.
|
|
1096
|
-
|
|
1320
|
+
failWrite(nodeRes)
|
|
1097
1321
|
return
|
|
1098
1322
|
}
|
|
1099
1323
|
}
|
|
@@ -1121,6 +1345,62 @@ function writeInternalError(nodeRes: ServerResponse): void {
|
|
|
1121
1345
|
nodeRes.end(INTERNAL_ERROR_BODY)
|
|
1122
1346
|
}
|
|
1123
1347
|
|
|
1348
|
+
/**
|
|
1349
|
+
* End a request whose WRITE failed, from anywhere a write can fail.
|
|
1350
|
+
*
|
|
1351
|
+
* `writeHead` throws on an invalid status or a header value carrying CR/LF - reachable whenever an
|
|
1352
|
+
* app reflects request data into `status(...)`/`c.set.headers`, which on the Web lane the `Headers`
|
|
1353
|
+
* constructor rejects instead. It also throws once the head is already out. Both have to end here:
|
|
1354
|
+
* on the async lanes the write runs inside a `.then` callback whose rejection nothing downstream
|
|
1355
|
+
* catches (`serve` only observes `finally`), and Node's default for an unhandled rejection is to
|
|
1356
|
+
* terminate the process - a route-shaped input turning into a server-wide DoS.
|
|
1357
|
+
*
|
|
1358
|
+
* A 500 while the head is unsent, otherwise a destroy: there is no way to correct a response whose
|
|
1359
|
+
* status line already shipped, and a half-written body must not be left for the client to parse.
|
|
1360
|
+
*/
|
|
1361
|
+
function failWrite(nodeRes: ServerResponse): void {
|
|
1362
|
+
try {
|
|
1363
|
+
if (!nodeRes.headersSent) {
|
|
1364
|
+
writeInternalError(nodeRes)
|
|
1365
|
+
return
|
|
1366
|
+
}
|
|
1367
|
+
} catch {
|
|
1368
|
+
// The socket went away between the check and the write; fall through to the destroy.
|
|
1369
|
+
}
|
|
1370
|
+
nodeRes.destroy()
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
/** {@link writeNodeOutcome} with {@link failWrite} behind it, sync throw and async rejection alike. */
|
|
1374
|
+
function writeOutcomeSafely(
|
|
1375
|
+
outcome: NodeServeOutcome,
|
|
1376
|
+
nodeRes: ServerResponse,
|
|
1377
|
+
method?: string,
|
|
1378
|
+
): void | Promise<void> {
|
|
1379
|
+
try {
|
|
1380
|
+
const written = writeNodeOutcome(outcome, nodeRes, method)
|
|
1381
|
+
return written instanceof Promise ? written.catch(() => failWrite(nodeRes)) : written
|
|
1382
|
+
} catch {
|
|
1383
|
+
failWrite(nodeRes)
|
|
1384
|
+
return
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
/** {@link writeNodeResponse} with {@link failWrite} behind it - same contract as
|
|
1389
|
+
* {@link writeOutcomeSafely}, for the lanes that carry a `Response`. */
|
|
1390
|
+
function writeResponseSafely(
|
|
1391
|
+
response: Response,
|
|
1392
|
+
nodeRes: ServerResponse,
|
|
1393
|
+
method?: string,
|
|
1394
|
+
): void | Promise<void> {
|
|
1395
|
+
try {
|
|
1396
|
+
const written = writeNodeResponse(response, nodeRes, method)
|
|
1397
|
+
return written instanceof Promise ? written.catch(() => failWrite(nodeRes)) : written
|
|
1398
|
+
} catch {
|
|
1399
|
+
failWrite(nodeRes)
|
|
1400
|
+
return
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1124
1404
|
function writeBadRequest(nodeRes: ServerResponse): void {
|
|
1125
1405
|
const body = "Bad Request"
|
|
1126
1406
|
nodeRes.writeHead(400, {
|
|
@@ -1188,12 +1468,25 @@ function writeJsonOutcome(
|
|
|
1188
1468
|
// without it Node falls back to chunked framing, which costs extra wire bytes and client parsing
|
|
1189
1469
|
// on every response (and no other runtime chunks a buffered JSON body).
|
|
1190
1470
|
if (outcome.body !== null) {
|
|
1191
|
-
if (headers["content-type"] === undefined) headers["
|
|
1192
|
-
|
|
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"]
|
|
1475
|
+
}
|
|
1476
|
+
headers["Content-Length"] = String(Buffer.byteLength(outcome.body))
|
|
1477
|
+
delete headers["content-length"]
|
|
1193
1478
|
} else if (isBodylessStatus(outcome.status)) {
|
|
1194
1479
|
// 204/205/304 never carry a payload; discard a user/native-hook length even when the body is
|
|
1195
1480
|
// already represented as null so the direct writer cannot advertise bytes that will not ship.
|
|
1196
1481
|
delete headers["content-length"]
|
|
1482
|
+
delete headers["Content-Length"]
|
|
1483
|
+
} else if (!isHead && headers["content-length"] === undefined) {
|
|
1484
|
+
// A body-less render at a status that MAY carry a body - a `redirect()`, above all. Node frames
|
|
1485
|
+
// a `writeHead` + bare `end()` as chunked, so the shortest response the framework emits went out
|
|
1486
|
+
// with a chunk terminator and no length, where every Web-native runtime sends `content-length: 0`.
|
|
1487
|
+
// Declared here so the wire matches them. HEAD is excluded: its length describes the GET's body,
|
|
1488
|
+
// which this lane does not know.
|
|
1489
|
+
headers["Content-Length"] = "0"
|
|
1197
1490
|
}
|
|
1198
1491
|
if (outcome.cookies !== undefined && outcome.cookies.length > 0) {
|
|
1199
1492
|
headers["set-cookie"] = [...outcome.cookies]
|
|
@@ -1284,6 +1577,14 @@ function toNodeRequestSource(
|
|
|
1284
1577
|
: new LazyNodeRequestSource(req, method, protocol, host)
|
|
1285
1578
|
}
|
|
1286
1579
|
|
|
1580
|
+
function stripNodeMountPrefix(url: string, prefix: string): string {
|
|
1581
|
+
if (prefix === "/") return url
|
|
1582
|
+
const target = new URL(url)
|
|
1583
|
+
const rest = target.pathname.slice(prefix.length)
|
|
1584
|
+
target.pathname = rest === "" ? "/" : rest
|
|
1585
|
+
return target.href
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1287
1588
|
/**
|
|
1288
1589
|
* Split an origin-form request target ("/path?q#frag") into pathname + search without synthesizing
|
|
1289
1590
|
* an absolute URL first. Mirrors `@nifrajs/core`'s `urlPartsOf` for the origin-form case (kept in
|
|
@@ -1390,7 +1691,7 @@ class LazyNodeRequestSource implements NodeRequestSource {
|
|
|
1390
1691
|
get body(): ReadableStream<Uint8Array> | null {
|
|
1391
1692
|
if (this.method === "GET" || this.method === "HEAD") return null
|
|
1392
1693
|
if (this.consumedBody !== undefined) return this.request.body
|
|
1393
|
-
this.bodyValue ??=
|
|
1694
|
+
this.bodyValue ??= claimableWebStream(this.nodeReq, "drain")
|
|
1394
1695
|
return this.bodyValue
|
|
1395
1696
|
}
|
|
1396
1697
|
|
|
@@ -1467,7 +1768,7 @@ class LazyNodeRequestSource implements NodeRequestSource {
|
|
|
1467
1768
|
if (this.method === "GET" || this.method === "HEAD") return null
|
|
1468
1769
|
const consumed = this.consumedBody
|
|
1469
1770
|
if (consumed !== undefined) return streamOfBytes(consumed)
|
|
1470
|
-
this.bodyValue ??=
|
|
1771
|
+
this.bodyValue ??= claimableWebStream(this.nodeReq, "drain")
|
|
1471
1772
|
return this.bodyValue
|
|
1472
1773
|
}
|
|
1473
1774
|
|
|
@@ -1643,7 +1944,7 @@ function makeWebRequest(
|
|
|
1643
1944
|
const init: RequestInit & { duplex?: "half" } = { method, headers }
|
|
1644
1945
|
if (method !== "GET" && method !== "HEAD") {
|
|
1645
1946
|
// Stream the body in; `duplex: "half"` is required for a streamed request body.
|
|
1646
|
-
init.body = body ?? (
|
|
1947
|
+
init.body = body ?? claimableWebStream(req, "drain")
|
|
1647
1948
|
init.duplex = "half"
|
|
1648
1949
|
}
|
|
1649
1950
|
return new Request(url, init)
|
|
@@ -1796,30 +2097,170 @@ function writeNodeResponse(
|
|
|
1796
2097
|
// forEach into a fresh plain object plus a second cookie-specific pass. Must run before
|
|
1797
2098
|
// `writeHead`: headers are already flushed by the time `writeHead` returns.
|
|
1798
2099
|
nodeRes.setHeaders(response.headers)
|
|
1799
|
-
nodeRes.writeHead(response.status)
|
|
1800
2100
|
if (isHead) {
|
|
2101
|
+
nodeRes.writeHead(response.status)
|
|
1801
2102
|
nodeRes.end()
|
|
1802
2103
|
return
|
|
1803
2104
|
}
|
|
1804
2105
|
const directBody = nodeResponseBody(response)
|
|
1805
2106
|
if (directBody !== undefined) {
|
|
2107
|
+
// Node computes the length itself for `end(data)` while the header is still unflushed, so this
|
|
2108
|
+
// lane already declares one. The three below do not get that for free.
|
|
2109
|
+
nodeRes.writeHead(response.status)
|
|
1806
2110
|
nodeRes.end(directBody)
|
|
1807
2111
|
return
|
|
1808
2112
|
}
|
|
2113
|
+
// `end()` with no data is framed as chunked instead, so a body-less `Response` - a hand-rolled
|
|
2114
|
+
// redirect, above all - went out with a chunk terminator and no length where every Web-native
|
|
2115
|
+
// runtime sends `content-length: 0`. Declared before `writeHead`, the last point a header can
|
|
2116
|
+
// still be added. Excluded: HEAD, whose length describes the GET's body that this lane does not
|
|
2117
|
+
// know; a status that cannot carry a body; and a length the caller set for itself.
|
|
2118
|
+
const canDeclareLength =
|
|
2119
|
+
!isBodylessStatus(response.status) && !response.headers.has("content-length")
|
|
1809
2120
|
if (response.body === null) {
|
|
2121
|
+
if (canDeclareLength) nodeRes.setHeader("content-length", "0")
|
|
2122
|
+
nodeRes.writeHead(response.status)
|
|
1810
2123
|
if (!nodeRes.destroyed && !nodeRes.writableEnded && nodeRes.writable) nodeRes.end()
|
|
1811
2124
|
return
|
|
1812
2125
|
}
|
|
1813
|
-
|
|
2126
|
+
// A body that is a Web view over a Node stream (an upstream response relayed by
|
|
2127
|
+
// `@nifrajs/proxy/undici`, say) goes to the socket as the Node stream it already is, skipping the
|
|
2128
|
+
// per-chunk trip through Web objects that the reader loop below pays for. Length is the upstream's
|
|
2129
|
+
// business: it either forwarded a `content-length` or is genuinely streaming.
|
|
2130
|
+
const nodeBody = response.bodyUsed ? null : claimNodeStream(response.body)
|
|
2131
|
+
if (nodeBody !== null) {
|
|
2132
|
+
nodeRes.writeHead(response.status)
|
|
2133
|
+
return pipeNodeResponseBody(nodeBody, nodeRes)
|
|
2134
|
+
}
|
|
2135
|
+
return writeNodeResponseBody(response, nodeRes, canDeclareLength)
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
/**
|
|
2139
|
+
* Send a claimed Node body straight to the socket.
|
|
2140
|
+
*
|
|
2141
|
+
* `pipe` rather than `pipeline`: the status line is already flushed by here, so the only teardown
|
|
2142
|
+
* still available is a destroy on both ends, and `pipeline`'s per-request `eos` machinery costs
|
|
2143
|
+
* more than it buys at this point - it measured as a net loss against the Web reader loop it
|
|
2144
|
+
* replaces, where plain `pipe` measured as a win on both GET and POST.
|
|
2145
|
+
*
|
|
2146
|
+
* The two destroys below are belt-and-braces, not the mechanism: for a body claimed from
|
|
2147
|
+
* `@nifrajs/proxy`, the request's abort signal already tears the upstream down when the client
|
|
2148
|
+
* hangs up, and an upstream that dies mid-body already reaches the client as a broken transfer
|
|
2149
|
+
* rather than a short `200`. They are kept because `pipe` itself guarantees neither, and the claim
|
|
2150
|
+
* seam is open to any transport - including one that wires no signal.
|
|
2151
|
+
*/
|
|
2152
|
+
function pipeNodeResponseBody(body: Readable, nodeRes: ServerResponse): Promise<void> {
|
|
2153
|
+
return new Promise<void>((resolve) => {
|
|
2154
|
+
let settled = false
|
|
2155
|
+
const done = (): void => {
|
|
2156
|
+
if (settled) return
|
|
2157
|
+
settled = true
|
|
2158
|
+
if (!body.destroyed) body.destroy()
|
|
2159
|
+
resolve()
|
|
2160
|
+
}
|
|
2161
|
+
// Attached before anything here can destroy `body`, and with `on` rather than `once`, because
|
|
2162
|
+
// destroying an upstream body emits `error` (undici raises `RequestAbortedError` for a request
|
|
2163
|
+
// cut short) and an unhandled `error` on a Node stream terminates the process. Every path below
|
|
2164
|
+
// ends in a destroy, including the ones that run after this listener has already fired once.
|
|
2165
|
+
body.on("error", () => {
|
|
2166
|
+
// Only meaningful before the response completes; afterwards there is nothing left to abort.
|
|
2167
|
+
if (!settled && !nodeRes.destroyed && !nodeRes.writableEnded) nodeRes.destroy()
|
|
2168
|
+
done()
|
|
2169
|
+
})
|
|
2170
|
+
// The client is already gone: `pipe` would wait on `close`/`finish` that have both fired.
|
|
2171
|
+
if (nodeRes.destroyed || nodeRes.writableEnded || !nodeRes.writable) {
|
|
2172
|
+
done()
|
|
2173
|
+
return
|
|
2174
|
+
}
|
|
2175
|
+
nodeRes.once("close", done)
|
|
2176
|
+
nodeRes.once("finish", done)
|
|
2177
|
+
body.pipe(nodeRes)
|
|
2178
|
+
})
|
|
1814
2179
|
}
|
|
1815
2180
|
|
|
1816
|
-
|
|
2181
|
+
/** The two Web-stream shapes the body lane below needs, named structurally: this package builds
|
|
2182
|
+
* without the DOM lib, so `ReadableStreamReadResult` is not a name it can refer to. */
|
|
2183
|
+
type BodyChunk =
|
|
2184
|
+
| { readonly done: false; readonly value: Uint8Array }
|
|
2185
|
+
| { readonly done: true; readonly value?: undefined }
|
|
2186
|
+
type BodyReader = { read(): Promise<BodyChunk>; cancel(): Promise<void> }
|
|
2187
|
+
|
|
2188
|
+
/**
|
|
2189
|
+
* Send a Web `ReadableStream` body to the socket, declaring a length when the body turns out to be
|
|
2190
|
+
* one already-complete chunk.
|
|
2191
|
+
*
|
|
2192
|
+
* `new Response("hi")` hands its bytes over as a stream, exactly as a live producer does, so the
|
|
2193
|
+
* framing decision looks identical from here and Node picks chunked for both. The two are told apart
|
|
2194
|
+
* by reading one chunk and then giving the stream a microtask to say it is done: a source-backed body
|
|
2195
|
+
* - a string, a `Uint8Array`, a `Blob` - has already enqueued everything and closes inside it, while
|
|
2196
|
+
* a producer still generating does not.
|
|
2197
|
+
*
|
|
2198
|
+
* What is held is one chunk, never the whole body, so a large or endless stream is unaffected in
|
|
2199
|
+
* memory. What is spent is a microtask, and it costs a streaming response no bytes on the wire: Node
|
|
2200
|
+
* does not flush the header until the first write either way, so nothing was going out during it.
|
|
2201
|
+
*/
|
|
2202
|
+
async function writeNodeResponseBody(
|
|
2203
|
+
response: Response,
|
|
2204
|
+
nodeRes: ServerResponse,
|
|
2205
|
+
canDeclareLength: boolean,
|
|
2206
|
+
): Promise<void> {
|
|
1817
2207
|
const reader = response.body!.getReader()
|
|
2208
|
+
let first: BodyChunk
|
|
2209
|
+
try {
|
|
2210
|
+
first = await reader.read()
|
|
2211
|
+
} catch {
|
|
2212
|
+
await reader.cancel().catch(() => {})
|
|
2213
|
+
if (!nodeRes.headersSent) writeInternalError(nodeRes)
|
|
2214
|
+
else nodeRes.destroy()
|
|
2215
|
+
return
|
|
2216
|
+
}
|
|
2217
|
+
if (first.done) {
|
|
2218
|
+
if (canDeclareLength) nodeRes.setHeader("content-length", "0")
|
|
2219
|
+
nodeRes.writeHead(response.status)
|
|
2220
|
+
if (!nodeRes.destroyed && !nodeRes.writableEnded && nodeRes.writable) nodeRes.end()
|
|
2221
|
+
return
|
|
2222
|
+
}
|
|
2223
|
+
// Attached with `then` rather than awaited: the point is to observe whether it has settled by the
|
|
2224
|
+
// end of this turn, not to wait for it. The rejection handler is only here so a body that fails
|
|
2225
|
+
// between the two reads is not an unhandled rejection - the drain below awaits the same promise
|
|
2226
|
+
// and is where that failure is actually handled.
|
|
2227
|
+
const pending = reader.read()
|
|
2228
|
+
let settled: BodyChunk | undefined
|
|
2229
|
+
pending.then(
|
|
2230
|
+
(value) => {
|
|
2231
|
+
settled = value
|
|
2232
|
+
},
|
|
2233
|
+
() => {},
|
|
2234
|
+
)
|
|
2235
|
+
// A microtask, not a turn of the event loop. It is enough - a source-backed body has already
|
|
2236
|
+
// enqueued everything, so its close is a microtask away on Node and two on Bun, both of which this
|
|
2237
|
+
// covers - and it is short enough to leave the socket's write order alone: yielding a whole turn
|
|
2238
|
+
// here wedged a pipelined keep-alive connection under Bun's `node:http` shim, where the second
|
|
2239
|
+
// response never reached the wire.
|
|
2240
|
+
await new Promise<void>((resolve) => queueMicrotask(resolve))
|
|
2241
|
+
if (settled?.done === true && canDeclareLength && ArrayBuffer.isView(first.value)) {
|
|
2242
|
+
nodeRes.setHeader("content-length", String(first.value.byteLength))
|
|
2243
|
+
nodeRes.writeHead(response.status)
|
|
2244
|
+
if (!nodeRes.destroyed && !nodeRes.writableEnded && nodeRes.writable) nodeRes.end(first.value)
|
|
2245
|
+
return
|
|
2246
|
+
}
|
|
2247
|
+
nodeRes.writeHead(response.status)
|
|
2248
|
+
return drainWebResponseBody(reader, nodeRes, first.value, pending)
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
/** The streaming half of {@link writeNodeResponseBody}, resumed from the chunk and the outstanding
|
|
2252
|
+
* read the length probe already took off the stream. */
|
|
2253
|
+
async function drainWebResponseBody(
|
|
2254
|
+
reader: BodyReader,
|
|
2255
|
+
nodeRes: ServerResponse,
|
|
2256
|
+
firstChunk: Uint8Array,
|
|
2257
|
+
pending: Promise<BodyChunk>,
|
|
2258
|
+
): Promise<void> {
|
|
2259
|
+
let chunk: Uint8Array | undefined = firstChunk
|
|
2260
|
+
let outstanding: Promise<BodyChunk> | undefined = pending
|
|
1818
2261
|
try {
|
|
1819
2262
|
for (;;) {
|
|
1820
|
-
|
|
1821
|
-
if (done) break
|
|
1822
|
-
if (!nodeRes.write(value)) {
|
|
2263
|
+
if (!nodeRes.write(chunk)) {
|
|
1823
2264
|
if (nodeRes.destroyed || nodeRes.writableEnded || !nodeRes.writable) {
|
|
1824
2265
|
try {
|
|
1825
2266
|
await reader.cancel()
|
|
@@ -1834,6 +2275,10 @@ async function writeNodeResponseBody(response: Response, nodeRes: ServerResponse
|
|
|
1834
2275
|
return
|
|
1835
2276
|
}
|
|
1836
2277
|
}
|
|
2278
|
+
const result = await (outstanding ?? reader.read())
|
|
2279
|
+
outstanding = undefined
|
|
2280
|
+
if (result.done) break
|
|
2281
|
+
chunk = result.value
|
|
1837
2282
|
}
|
|
1838
2283
|
} catch {
|
|
1839
2284
|
await reader.cancel().catch(() => {})
|