@nifrajs/proxy 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.
@@ -0,0 +1,174 @@
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
+ * This file is a verbatim copy of `@nifrajs/node`'s - importing it would give this package a
20
+ * runtime dependency, and only the symbol and the shape below actually have to match. It is
21
+ * reachable only through the `./undici` subpath, so the base package stays runtime-portable.
22
+ *
23
+ * Claiming is ONE-SHOT and refused once the Web view has been touched, which is the property that
24
+ * makes it safe: a body can never be split between the two views, and a layer that already started
25
+ * reading the Web stream silently keeps the ordinary path instead of forwarding a truncated body.
26
+ */
27
+
28
+ import { Readable } from "node:stream"
29
+
30
+ /** Shared with `@nifrajs/node`'s copy of this module - the value is the contract. */
31
+ export const NODE_STREAM_CLAIM = Symbol.for("nifra.node.stream-claim")
32
+
33
+ interface NodeStreamClaim {
34
+ /** The backing Node stream, or `null` if the Web view was already read, cancelled, or claimed. */
35
+ claim(): Readable | null
36
+ }
37
+
38
+ type ClaimableStream = ReadableStream<Uint8Array> & {
39
+ readonly [NODE_STREAM_CLAIM]?: NodeStreamClaim
40
+ }
41
+
42
+ /**
43
+ * What cancelling the Web view should do to the Node stream under it.
44
+ *
45
+ * `"destroy"` releases the source immediately, which is what a response body wants: a client that
46
+ * walked away from a file or an upstream response must not cost another byte of it.
47
+ *
48
+ * `"drain"` lets the source run to its own end and destroys it then, which is what a REQUEST body
49
+ * wants, and is `Readable.toWeb`'s behaviour rather than a new policy. Destroying an
50
+ * `IncomingMessage` destroys its socket, so cancelling a half-read upload synchronously tears the
51
+ * connection down before the handler can answer - a body rejected by the size cap reaches the client
52
+ * as a connection reset instead of the `413` that explains it. Draining leaves the socket writable
53
+ * long enough for the response to go out. It does NOT mean reading the rest of a hostile upload:
54
+ * once that response completes, Node destroys the socket itself because the request is incomplete,
55
+ * so an over-cap body stops arriving within roughly the cap either way (measured: a 64 MB body
56
+ * against a 1 MB cap transfers about 2 MB before the connection closes, on this path and on
57
+ * `Readable.toWeb` alike).
58
+ */
59
+ export type CancelPolicy = "destroy" | "drain"
60
+
61
+ /**
62
+ * Hard ceiling on a post-cancel drain. Draining exists only to let the early response (a 413 cap
63
+ * rejection) flush on a clean FIN; it is never meant to read a hostile upload to its end. Node
64
+ * normally tears the socket down once that response completes, which ends the drain well before this
65
+ * - but a slow client that keeps both directions alive could otherwise trickle bytes forever and
66
+ * pin the connection. Past this many drained bytes the response has long since left, so destroying
67
+ * (an RST) no longer risks discarding it; the ceiling just caps the pathological case.
68
+ */
69
+ const DRAIN_BYTE_CAP = 8 * 1024 * 1024
70
+
71
+ /**
72
+ * A Web view of `source` that defers to it lazily and can be traded back in for it.
73
+ *
74
+ * `highWaterMark: 0` keeps construction free of a speculative pull - a stream that is going to be
75
+ * claimed must not have read a byte first.
76
+ */
77
+ export function claimableWebStream(
78
+ source: Readable,
79
+ onCancel: CancelPolicy = "destroy",
80
+ ): ReadableStream<Uint8Array> {
81
+ let iterator: AsyncIterator<Buffer | Uint8Array> | undefined
82
+ let surrendered = false
83
+
84
+ const stream = new ReadableStream<Uint8Array>(
85
+ {
86
+ pull(controller): Promise<void> {
87
+ if (surrendered) {
88
+ // Unreachable through `claimNodeStream` (it refuses a locked stream and the claim is
89
+ // one-shot), so reaching it means someone kept a reader across the hand-off. Fail loudly
90
+ // rather than serve a body that is missing whatever the other consumer already took.
91
+ const error = new Error(
92
+ "[nifra] this request body was handed to a Node-native consumer; the Web stream over it can no longer be read",
93
+ )
94
+ controller.error(error)
95
+ return Promise.reject(error)
96
+ }
97
+ iterator ??= source[Symbol.asyncIterator]() as AsyncIterator<Buffer | Uint8Array>
98
+ return iterator.next().then(
99
+ ({ done, value }) => {
100
+ if (done === true) {
101
+ controller.close()
102
+ return
103
+ }
104
+ // `Buffer` is a `Uint8Array`, so a binary-mode stream needs no conversion. An
105
+ // object-mode stream has no business being an HTTP body.
106
+ if (!(value instanceof Uint8Array)) {
107
+ throw new TypeError("[nifra] request body stream yielded a non-binary chunk")
108
+ }
109
+ controller.enqueue(value)
110
+ },
111
+ (error: unknown) => {
112
+ controller.error(error)
113
+ throw error
114
+ },
115
+ )
116
+ },
117
+ cancel(reason): void {
118
+ if (surrendered) return
119
+ surrendered = true
120
+ const error = reason instanceof Error ? reason : undefined
121
+ if (onCancel === "destroy" || source.destroyed) {
122
+ source.destroy(error)
123
+ return
124
+ }
125
+ // The source stays live after this returns, so it keeps the right to emit `error` - and an
126
+ // unhandled `error` on a Node stream terminates the process. A client that resets the
127
+ // connection mid-drain is an ordinary event here, not a fault worth propagating: the read
128
+ // was already abandoned.
129
+ source.on("error", () => {
130
+ if (!source.destroyed) source.destroy()
131
+ })
132
+ // Both, because only one of them is reached: a source that runs out ends, and one whose
133
+ // socket is torn down by the completed response closes without ending.
134
+ source.once("end", () => source.destroy())
135
+ source.once("close", () => source.destroy())
136
+ // Bounded drain: a `data` listener flows the source to discard it, the same as `resume()`,
137
+ // but also counts bytes so an over-cap trickle is destroyed at the cap rather than held open
138
+ // indefinitely.
139
+ let drained = 0
140
+ source.on("data", (chunk: Buffer) => {
141
+ drained += chunk.byteLength
142
+ if (drained > DRAIN_BYTE_CAP && !source.destroyed) source.destroy()
143
+ })
144
+ },
145
+ },
146
+ { highWaterMark: 0 },
147
+ )
148
+
149
+ const claim: NodeStreamClaim = {
150
+ claim(): Readable | null {
151
+ // `iterator !== undefined` means a pull already happened, so bytes are gone; `destroyed`
152
+ // means there is nothing left to hand over.
153
+ if (surrendered || iterator !== undefined || source.destroyed) return null
154
+ surrendered = true
155
+ return source
156
+ },
157
+ }
158
+ Object.defineProperty(stream, NODE_STREAM_CLAIM, { value: claim, enumerable: false })
159
+ return stream
160
+ }
161
+
162
+ /**
163
+ * Trade a Web stream back for the Node stream underneath it, or `null` when that is not sound -
164
+ * a foreign stream, one someone holds a reader on, or one that has already been read or claimed.
165
+ * Every `null` means "use the ordinary Web path", never "fail".
166
+ */
167
+ export function claimNodeStream(body: ReadableStream<Uint8Array> | null): Readable | null {
168
+ if (body === null || body.locked) return null
169
+ const holder = (body as ClaimableStream)[NODE_STREAM_CLAIM]
170
+ if (holder === undefined || typeof holder.claim !== "function") return null
171
+ const claimed = holder.claim()
172
+ // A `Symbol.for` key is reachable by anything in the process, so the value is not taken on trust.
173
+ return claimed instanceof Readable ? claimed : null
174
+ }
package/src/undici.ts ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * undici-backed transport for `createProxy` - the fast upstream path on Node.
3
+ *
4
+ * `fetch()` is a spec-compliant wrapper over the same client. Measured against a local origin at 50
5
+ * concurrent connections, going straight to undici's dispatcher API instead is roughly 2.5x the
6
+ * throughput on GET and 2.2x on POST; on Bun there is no such gap, so this is a Node remedy and the
7
+ * default `fetch` transport stays the portable one.
8
+ *
9
+ * The security obligations in {@link ProxyTransport} are met here as follows: undici does not follow
10
+ * redirects unless asked to, so a 3xx is relayed and the upstream can never steer the next hop; the
11
+ * URL is passed through untouched, so the dialed host is still the one `createProxy` computed; and
12
+ * TLS verification is left alone, with no option offered to weaken it. Sanitised headers are
13
+ * forwarded exactly as handed over.
14
+ *
15
+ * The redirect guarantee is the one a caller can break: undici 8 moved redirect following into an
16
+ * opt-in interceptor (undici 7 spelled it `maxRedirections`, defaulting to none). Composing a
17
+ * redirect interceptor into a `dispatcher` passed here would defeat it, and this transport cannot
18
+ * detect that. Do not.
19
+ *
20
+ * ```ts
21
+ * import { createProxy } from "@nifrajs/proxy"
22
+ * import { undiciTransport } from "@nifrajs/proxy/undici"
23
+ *
24
+ * const proxy = createProxy({ upstream: "http://127.0.0.1:8081", transport: undiciTransport() })
25
+ * ```
26
+ *
27
+ * Requires `undici` (>= 7) to be installed; it is an optional peer, so the base package stays
28
+ * dependency-free for everyone who does not opt in.
29
+ */
30
+
31
+ import { Readable } from "node:stream"
32
+ import { type Dispatcher, request as undiciRequest } from "undici"
33
+ import type { NativeProxyTransport, ProxyTransport, ProxyUpstreamResponse } from "./index.ts"
34
+ import { claimableWebStream, claimNodeStream } from "./node-stream.ts"
35
+
36
+ const NODE_NATIVE_TRANSPORT = Symbol.for("@nifrajs/proxy/node-native-transport")
37
+
38
+ export interface UndiciTransportOptions {
39
+ /**
40
+ * Connection pool to dial through - an undici `Agent`, `Pool`, or any other `Dispatcher`. Omit to
41
+ * use undici's global dispatcher. Supply one to tune `connections` per origin, which is the knob
42
+ * that matters under load. It must not compose a redirect interceptor: this proxy relays 3xx
43
+ * rather than following it, and a dispatcher that follows would hand the upstream the next hop.
44
+ */
45
+ readonly dispatcher?: Dispatcher
46
+ /**
47
+ * Milliseconds of silence tolerated *within* the response body before undici destroys it.
48
+ * `createProxy`'s own `timeoutMs` only covers the wait for response headers - after the status is
49
+ * relayed a `504` is no longer sendable - so this is what protects against a body that starts and
50
+ * then stalls. Default `30_000`; `0` disables it.
51
+ */
52
+ readonly bodyTimeoutMs?: number
53
+ }
54
+
55
+ /**
56
+ * Statuses that MUST NOT carry a body. `fetch` reports `response.body === null` for these, but
57
+ * undici hands back a real (empty) stream, and `new Response(stream, { status: 204 })` throws. The
58
+ * stream is dumped rather than dropped, because an unread undici body holds its socket open.
59
+ */
60
+ const NULL_BODY_STATUS: ReadonlySet<number> = new Set([101, 204, 205, 304])
61
+
62
+ function toHeaders(raw: Readonly<Record<string, string | string[] | undefined>>): Headers {
63
+ const out = new Headers()
64
+ for (const [name, value] of Object.entries(raw)) {
65
+ if (value === undefined) continue
66
+ if (Array.isArray(value)) {
67
+ for (const entry of value) out.append(name, entry)
68
+ } else {
69
+ out.append(name, value)
70
+ }
71
+ }
72
+ return out
73
+ }
74
+
75
+ function requestBody(body: ReadableStream<Uint8Array> | null): Readable | null {
76
+ if (body === null) return null
77
+ return claimNodeStream(body) ?? Readable.fromWeb(body)
78
+ }
79
+
80
+ /** Create an undici-backed {@link ProxyTransport}. */
81
+ export function undiciTransport(options: UndiciTransportOptions = {}): ProxyTransport {
82
+ // Under Bun the `undici` specifier resolves to a built-in shim whose response bodies have no
83
+ // `dump()` and whose request bodies undici cannot iterate - every POST and every bodiless status
84
+ // would come back as a flat 502. Fail at construction instead: there is nothing to gain anyway,
85
+ // since Bun's own fetch measures level with a raw client, and the default transport is faster.
86
+ if (typeof (globalThis as { readonly Bun?: unknown }).Bun !== "undefined") {
87
+ throw new Error(
88
+ "[nifra/proxy] undiciTransport() is a Node remedy - under Bun the `undici` specifier resolves to a built-in shim this cannot drive, and Bun's own fetch shows no gap. Leave `transport` unset there.",
89
+ )
90
+ }
91
+ const bodyTimeout = options.bodyTimeoutMs ?? 30_000
92
+ if (!Number.isFinite(bodyTimeout) || bodyTimeout < 0) {
93
+ throw new Error("[nifra/proxy] bodyTimeoutMs must be a non-negative number")
94
+ }
95
+ const dispatcher = options.dispatcher
96
+
97
+ const transport: ProxyTransport = async (target, request): Promise<ProxyUpstreamResponse> => {
98
+ // Flat name/value pairs preserve what `Headers` already merged, without a second object.
99
+ const headers: string[] = []
100
+ for (const [name, value] of request.headers) headers.push(name, value)
101
+
102
+ const response = await undiciRequest(target, {
103
+ method: request.method as Dispatcher.HttpMethod,
104
+ headers,
105
+ // Node's own stream type, so undici never has to guess how to consume it. When the adapter
106
+ // upstream of us built that Web stream over a Node one and nothing has read it, take the
107
+ // Node stream back instead of converting: the client's body then reaches undici without ever
108
+ // being repackaged. `claimNodeStream` returns null for any stream that is foreign, held, or
109
+ // already read, and the conversion below is what happens then.
110
+ body: requestBody(request.body),
111
+ signal: request.signal,
112
+ bodyTimeout,
113
+ ...(dispatcher === undefined ? {} : { dispatcher }),
114
+ })
115
+
116
+ if (NULL_BODY_STATUS.has(response.statusCode) || request.method === "HEAD") {
117
+ await response.body.dump()
118
+ return {
119
+ status: response.statusCode,
120
+ statusText: "",
121
+ headers: toHeaders(response.headers),
122
+ body: null,
123
+ // No body to describe, but undici never decodes, so the contract stays consistent.
124
+ bodyEncoded: true,
125
+ }
126
+ }
127
+
128
+ return {
129
+ status: response.statusCode,
130
+ statusText: "",
131
+ headers: toHeaders(response.headers),
132
+ // Claimable rather than `Readable.toWeb`: an adapter that writes this response to a Node
133
+ // socket (`@nifrajs/node`) can pipe the upstream stream straight through, and anything else
134
+ // reads it as the ordinary Web stream it is.
135
+ body: claimableWebStream(response.body),
136
+ // undici's `request` does not decode `Content-Encoding`, so the body is the upstream's exact
137
+ // bytes and its encoding/length headers must survive the relay for the client to decode.
138
+ bodyEncoded: true,
139
+ }
140
+ }
141
+
142
+ const nativeTransport: NativeProxyTransport = async (target, request) => {
143
+ const response = await undiciRequest(target, {
144
+ method: request.method as Dispatcher.HttpMethod,
145
+ headers: Array.from(request.headers),
146
+ // This is the original IncomingMessage from @nifrajs/node. Keeping it as a Node stream is
147
+ // the point of this lane: no Web stream wrapper is constructed on either side of the proxy.
148
+ body: request.body === null ? null : (request.body as never),
149
+ signal: request.signal,
150
+ headersTimeout: request.headersTimeout,
151
+ bodyTimeout,
152
+ ...(dispatcher === undefined ? {} : { dispatcher }),
153
+ })
154
+ return {
155
+ status: response.statusCode,
156
+ headers: response.headers as Readonly<Record<string, string | readonly string[] | undefined>>,
157
+ body: response.body,
158
+ dump: () => response.body.dump(),
159
+ }
160
+ }
161
+
162
+ Object.defineProperty(transport, NODE_NATIVE_TRANSPORT, { value: nativeTransport })
163
+ return transport
164
+ }