@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
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Node-stream escape hatch: a Web `ReadableStream` over a Node `Readable` that can hand the
|
|
3
|
+
* Node stream back, so two adjacent Node-native layers do not have to round-trip bytes through Web
|
|
4
|
+
* objects just because the contract between them is Web-shaped.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists: `@nifrajs/node` receives a Node `IncomingMessage` and must present a Web
|
|
7
|
+
* `Request`; `@nifrajs/proxy/undici` receives that `Request` and must hand undici a Node stream
|
|
8
|
+
* again. Measured on a pinned-core Linux rig, that round trip - not hygiene, not routing, not the
|
|
9
|
+
* adapter - is the entire remaining distance to `@fastify/reply-from`, which never leaves Node
|
|
10
|
+
* streams: -17% on GET (one conversion) and -34% on POST (two, since the request body converts on
|
|
11
|
+
* the way out as well).
|
|
12
|
+
*
|
|
13
|
+
* Why it cannot be `Readable.toWeb`: that consumes its source EAGERLY. Wrapping a `Readable` and
|
|
14
|
+
* then never reading the wrapper still leaves the source read (`readableDidRead === true`), so
|
|
15
|
+
* handing the raw stream onward afterwards loses the body outright. The wrapper here touches its
|
|
16
|
+
* source only on the first `pull`, which is what makes the hand-back sound.
|
|
17
|
+
*
|
|
18
|
+
* The seam is a `Symbol.for` key because the two packages that use it share no runtime dependency
|
|
19
|
+
* (`@nifrajs/proxy` is dependency-free by design, so it carries its own copy of this file). Both
|
|
20
|
+
* sides must agree on {@link NODE_STREAM_CLAIM} and on the shape below, and nothing else.
|
|
21
|
+
*
|
|
22
|
+
* Claiming is ONE-SHOT and refused once the Web view has been touched, which is the property that
|
|
23
|
+
* makes it safe: a body can never be split between the two views, and a layer that already started
|
|
24
|
+
* reading the Web stream silently keeps the ordinary path instead of forwarding a truncated body.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { Readable } from "node:stream"
|
|
28
|
+
|
|
29
|
+
/** Shared with `@nifrajs/proxy/undici`'s copy of this module - the value is the contract. */
|
|
30
|
+
export const NODE_STREAM_CLAIM = Symbol.for("nifra.node.stream-claim")
|
|
31
|
+
|
|
32
|
+
interface NodeStreamClaim {
|
|
33
|
+
/** The backing Node stream, or `null` if the Web view was already read, cancelled, or claimed. */
|
|
34
|
+
claim(): Readable | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type ClaimableStream = ReadableStream<Uint8Array> & {
|
|
38
|
+
readonly [NODE_STREAM_CLAIM]?: NodeStreamClaim
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* What cancelling the Web view should do to the Node stream under it.
|
|
43
|
+
*
|
|
44
|
+
* `"destroy"` releases the source immediately, which is what a response body wants: a client that
|
|
45
|
+
* walked away from a file or an upstream response must not cost another byte of it.
|
|
46
|
+
*
|
|
47
|
+
* `"drain"` lets the source run to its own end and destroys it then, which is what a REQUEST body
|
|
48
|
+
* wants, and is `Readable.toWeb`'s behaviour rather than a new policy. Destroying an
|
|
49
|
+
* `IncomingMessage` destroys its socket, so cancelling a half-read upload synchronously tears the
|
|
50
|
+
* connection down before the handler can answer - a body rejected by the size cap reaches the client
|
|
51
|
+
* as a connection reset instead of the `413` that explains it. Draining leaves the socket writable
|
|
52
|
+
* long enough for the response to go out. It does NOT mean reading the rest of a hostile upload:
|
|
53
|
+
* once that response completes, Node destroys the socket itself because the request is incomplete,
|
|
54
|
+
* so an over-cap body stops arriving within roughly the cap either way (measured: a 64 MB body
|
|
55
|
+
* against a 1 MB cap transfers about 2 MB before the connection closes, on this path and on
|
|
56
|
+
* `Readable.toWeb` alike).
|
|
57
|
+
*/
|
|
58
|
+
export type CancelPolicy = "destroy" | "drain"
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Hard ceiling on a post-cancel drain. Draining exists only to let the early response (a 413 cap
|
|
62
|
+
* rejection) flush on a clean FIN; it is never meant to read a hostile upload to its end. Node
|
|
63
|
+
* normally tears the socket down once that response completes, which ends the drain well before this
|
|
64
|
+
* - but a slow client that keeps both directions alive could otherwise trickle bytes forever and
|
|
65
|
+
* pin the connection. Past this many drained bytes the response has long since left, so destroying
|
|
66
|
+
* (an RST) no longer risks discarding it; the ceiling just caps the pathological case.
|
|
67
|
+
*/
|
|
68
|
+
const DRAIN_BYTE_CAP = 8 * 1024 * 1024
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A Web view of `source` that defers to it lazily and can be traded back in for it.
|
|
72
|
+
*
|
|
73
|
+
* `highWaterMark: 0` keeps construction free of a speculative pull - a stream that is going to be
|
|
74
|
+
* claimed must not have read a byte first.
|
|
75
|
+
*/
|
|
76
|
+
export function claimableWebStream(
|
|
77
|
+
source: Readable,
|
|
78
|
+
onCancel: CancelPolicy = "destroy",
|
|
79
|
+
): ReadableStream<Uint8Array> {
|
|
80
|
+
let iterator: AsyncIterator<Buffer | Uint8Array> | undefined
|
|
81
|
+
let surrendered = false
|
|
82
|
+
|
|
83
|
+
const stream = new ReadableStream<Uint8Array>(
|
|
84
|
+
{
|
|
85
|
+
pull(controller): Promise<void> {
|
|
86
|
+
if (surrendered) {
|
|
87
|
+
// Unreachable through `claimNodeStream` (it refuses a locked stream and the claim is
|
|
88
|
+
// one-shot), so reaching it means someone kept a reader across the hand-off. Fail loudly
|
|
89
|
+
// rather than serve a body that is missing whatever the other consumer already took.
|
|
90
|
+
const error = new Error(
|
|
91
|
+
"[nifra] this request body was handed to a Node-native consumer; the Web stream over it can no longer be read",
|
|
92
|
+
)
|
|
93
|
+
controller.error(error)
|
|
94
|
+
return Promise.reject(error)
|
|
95
|
+
}
|
|
96
|
+
iterator ??= source[Symbol.asyncIterator]() as AsyncIterator<Buffer | Uint8Array>
|
|
97
|
+
return iterator.next().then(
|
|
98
|
+
({ done, value }) => {
|
|
99
|
+
if (done === true) {
|
|
100
|
+
controller.close()
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
// `Buffer` is a `Uint8Array`, so a binary-mode stream needs no conversion. An
|
|
104
|
+
// object-mode stream has no business being an HTTP body.
|
|
105
|
+
if (!(value instanceof Uint8Array)) {
|
|
106
|
+
throw new TypeError("[nifra] request body stream yielded a non-binary chunk")
|
|
107
|
+
}
|
|
108
|
+
controller.enqueue(value)
|
|
109
|
+
},
|
|
110
|
+
(error: unknown) => {
|
|
111
|
+
controller.error(error)
|
|
112
|
+
throw error
|
|
113
|
+
},
|
|
114
|
+
)
|
|
115
|
+
},
|
|
116
|
+
cancel(reason): void {
|
|
117
|
+
if (surrendered) return
|
|
118
|
+
surrendered = true
|
|
119
|
+
const error = reason instanceof Error ? reason : undefined
|
|
120
|
+
if (onCancel === "destroy" || source.destroyed) {
|
|
121
|
+
source.destroy(error)
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
// Drain policy: let the source run to its end so the socket closes on a clean FIN, never the
|
|
125
|
+
// RST that destroying a half-read upload sends. The early response (a 413 cap rejection) has to
|
|
126
|
+
// reach the client, and on Linux an RST discards it from the client's receive buffer before it
|
|
127
|
+
// is read - so a body rejected by the cap arrives as a connection reset instead of the 413 that
|
|
128
|
+
// explains it. Draining also relieves the read-side backpressure that otherwise pins the socket
|
|
129
|
+
// at exactly the cap: the response can then flush, the client reads the 413, abandons its
|
|
130
|
+
// upload, and closes - which ends the drain a little past the cap rather than at the full body.
|
|
131
|
+
const finish = (): void => {
|
|
132
|
+
if (!source.destroyed) source.destroy()
|
|
133
|
+
}
|
|
134
|
+
// A source that is already being async-iterated cannot be drained with `resume()`: the iterator
|
|
135
|
+
// holds a `readable` listener, and while one is attached `resume()` is a no-op (as is Node's own
|
|
136
|
+
// post-response `_dump()`). That is the trap this path fell into - the socket never drained, the
|
|
137
|
+
// 413 never flushed, and the connection deadlocked until it reset. Such a source is drained by
|
|
138
|
+
// pumping its own iterator; one never read (no listener yet) takes the plain `resume()`.
|
|
139
|
+
if (iterator !== undefined) {
|
|
140
|
+
const it = iterator
|
|
141
|
+
let drained = 0
|
|
142
|
+
const pump = (): void => {
|
|
143
|
+
it.next().then((step) => {
|
|
144
|
+
if (step.done === true) {
|
|
145
|
+
finish()
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
// Bounded drain: past DRAIN_BYTE_CAP stop pulling and destroy, so a slow client cannot
|
|
149
|
+
// hold the socket open by trickling an over-cap body forever.
|
|
150
|
+
drained += (step.value as Uint8Array).byteLength
|
|
151
|
+
if (drained > DRAIN_BYTE_CAP) {
|
|
152
|
+
finish()
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
pump()
|
|
156
|
+
}, finish)
|
|
157
|
+
}
|
|
158
|
+
// The client abandoning its upload (or any socket teardown) ends the drain even mid-pull.
|
|
159
|
+
source.once("close", finish)
|
|
160
|
+
pump()
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
// The source stays live after this returns, so it keeps the right to emit `error` - and an
|
|
164
|
+
// unhandled `error` on a Node stream terminates the process. A client that resets the
|
|
165
|
+
// connection mid-drain is an ordinary event here, not a fault worth propagating: the read
|
|
166
|
+
// was already abandoned.
|
|
167
|
+
source.on("error", () => {
|
|
168
|
+
if (!source.destroyed) source.destroy()
|
|
169
|
+
})
|
|
170
|
+
// Both, because only one of them is reached: a source that runs out ends, and one whose
|
|
171
|
+
// socket is torn down by the completed response closes without ending.
|
|
172
|
+
source.once("end", () => source.destroy())
|
|
173
|
+
source.once("close", () => source.destroy())
|
|
174
|
+
// Bounded drain (see the pump above): a `data` listener flows the source to discard it, the
|
|
175
|
+
// same as `resume()`, but also counts bytes so an over-cap trickle is destroyed at the cap
|
|
176
|
+
// instead of held open indefinitely.
|
|
177
|
+
let drained = 0
|
|
178
|
+
source.on("data", (chunk: Buffer) => {
|
|
179
|
+
drained += chunk.byteLength
|
|
180
|
+
if (drained > DRAIN_BYTE_CAP && !source.destroyed) source.destroy()
|
|
181
|
+
})
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
{ highWaterMark: 0 },
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
const claim: NodeStreamClaim = {
|
|
188
|
+
claim(): Readable | null {
|
|
189
|
+
// `iterator !== undefined` means a pull already happened, so bytes are gone; `destroyed`
|
|
190
|
+
// means there is nothing left to hand over.
|
|
191
|
+
if (surrendered || iterator !== undefined || source.destroyed) return null
|
|
192
|
+
surrendered = true
|
|
193
|
+
return source
|
|
194
|
+
},
|
|
195
|
+
}
|
|
196
|
+
Object.defineProperty(stream, NODE_STREAM_CLAIM, { value: claim, enumerable: false })
|
|
197
|
+
return stream
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Trade a Web stream back for the Node stream underneath it, or `null` when that is not sound -
|
|
202
|
+
* a foreign stream, one someone holds a reader on, or one that has already been read or claimed.
|
|
203
|
+
* Every `null` means "use the ordinary Web path", never "fail".
|
|
204
|
+
*/
|
|
205
|
+
export function claimNodeStream(body: ReadableStream<Uint8Array> | null): Readable | null {
|
|
206
|
+
if (body === null || body.locked) return null
|
|
207
|
+
const holder = (body as ClaimableStream)[NODE_STREAM_CLAIM]
|
|
208
|
+
if (holder === undefined || typeof holder.claim !== "function") return null
|
|
209
|
+
const claimed = holder.claim()
|
|
210
|
+
// A `Symbol.for` key is reachable by anything in the process, so the value is not taken on trust.
|
|
211
|
+
return claimed instanceof Readable ? claimed : null
|
|
212
|
+
}
|