@nifrajs/proxy 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 +1 -1
- package/dist/index.d.ts +132 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +601 -47
- package/dist/index.js.map +1 -1
- package/dist/node-stream.d.ts +61 -0
- package/dist/node-stream.d.ts.map +1 -0
- package/dist/node-stream.js +135 -0
- package/dist/node-stream.js.map +1 -0
- package/dist/undici.d.ts +50 -0
- package/dist/undici.d.ts.map +1 -0
- package/dist/undici.js +138 -0
- package/dist/undici.js.map +1 -0
- package/package.json +17 -2
- package/src/index.ts +814 -51
- package/src/node-stream.ts +174 -0
- package/src/undici.ts +164 -0
|
@@ -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
|
+
}
|