@stacksjs/rpx 0.11.19 → 0.11.20
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/dist/bin/cli.js +193 -191
- package/dist/cert-inspect.d.ts +5 -3
- package/dist/{chunk-83dqq28c.js → chunk-bs9e342s.js} +1 -1
- package/dist/chunk-vxj1ckdm.js +176 -0
- package/dist/https.d.ts +1 -0
- package/dist/index.js +7 -7
- package/dist/proxy-pool.d.ts +5 -0
- package/dist/start.d.ts +31 -8
- package/dist/types.d.ts +3 -0
- package/package.json +6 -7
- package/dist/chunk-rs8gqpax.js +0 -174
- package/src/cert-inspect.ts +0 -69
- package/src/colors.ts +0 -13
- package/src/config.ts +0 -45
- package/src/daemon-runner.ts +0 -180
- package/src/daemon.ts +0 -1209
- package/src/dns-state.ts +0 -116
- package/src/dns.ts +0 -568
- package/src/host-match.ts +0 -52
- package/src/host-routes.ts +0 -147
- package/src/hosts.ts +0 -283
- package/src/https.ts +0 -905
- package/src/index.ts +0 -161
- package/src/logger.ts +0 -19
- package/src/macos-trust.ts +0 -175
- package/src/on-demand.ts +0 -264
- package/src/origin-guard.ts +0 -127
- package/src/port-manager.ts +0 -183
- package/src/process-manager.ts +0 -164
- package/src/proxy-handler.ts +0 -387
- package/src/proxy-pool.ts +0 -1003
- package/src/registry.ts +0 -366
- package/src/sni.ts +0 -93
- package/src/start.ts +0 -1421
- package/src/static-files.ts +0 -201
- package/src/types.ts +0 -267
- package/src/utils.ts +0 -243
package/src/proxy-pool.ts
DELETED
|
@@ -1,1003 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pooled raw-socket HTTP/1.1 client for the proxy hot path.
|
|
3
|
-
*
|
|
4
|
-
* `fetch()` is convenient but churns upstream connections under load: even with
|
|
5
|
-
* a concurrency cap, Bun's fetch opens and closes connections far faster than the
|
|
6
|
-
* OS recycles ephemeral ports, they pile into TIME_WAIT, and throughput collapses
|
|
7
|
-
* by ~15x (verified: ~11k TIME_WAIT sockets, 45% errors at c=400). nginx avoids
|
|
8
|
-
* this with a small, *reused* keepalive pool. This module gives rpx the same
|
|
9
|
-
* model: persistent upstream sockets per `host:port`, reused across requests, so
|
|
10
|
-
* the proxy stays flat under load instead of falling over.
|
|
11
|
-
*
|
|
12
|
-
* It is deliberately scoped to the common case (plain-HTTP upstream, ordinary
|
|
13
|
-
* request/response). Anything unusual — streaming/large request uploads, `Expect`,
|
|
14
|
-
* protocol upgrades — throws {@link FALLBACK} so the caller can defer to the
|
|
15
|
-
* proven `fetch()` path. Correctness first; the fast path is the optimization.
|
|
16
|
-
*/
|
|
17
|
-
// `connect` is a Bun runtime builtin. A static value-import (`from 'bun'`) trips
|
|
18
|
-
// the declaration/bundle step at publish time ("Browser build cannot import Bun
|
|
19
|
-
// builtin: 'bun'"), so reach it through the `Bun` global instead — identical at
|
|
20
|
-
// runtime (rpx only ever runs under Bun), but invisible to the bundler.
|
|
21
|
-
const { connect } = Bun
|
|
22
|
-
|
|
23
|
-
/** Sentinel thrown when the pooled path declines a request; caller uses fetch(). */
|
|
24
|
-
export const FALLBACK: unique symbol = Symbol('rpx.pool.fallback')
|
|
25
|
-
|
|
26
|
-
/** Marker for "a reused socket closed before/at the start of the response" — retryable. */
|
|
27
|
-
const STALE = Symbol('rpx.pool.stale')
|
|
28
|
-
|
|
29
|
-
/** Thrown when the upstream stalls past the configured timeout; caller maps to 504. */
|
|
30
|
-
export const TIMEOUT: unique symbol = Symbol('rpx.pool.timeout')
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Thrown when every connection to an upstream is busy and the wait for a free
|
|
34
|
-
* slot exceeded {@link queueWaitMs}, or the waiter queue is already at its cap.
|
|
35
|
-
* The caller maps it to a 503. This is the backstop that keeps a saturated or
|
|
36
|
-
* stalled upstream from making the *listener* appear wedged: instead of parking
|
|
37
|
-
* a request forever with no response (the production incident), rpx fails it
|
|
38
|
-
* fast and loud so the listener keeps answering every other request.
|
|
39
|
-
*/
|
|
40
|
-
export const POOL_BUSY: unique symbol = Symbol('rpx.pool.busy')
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Max time (ms) a request waits for a free connection slot when an upstream is
|
|
44
|
-
* at its connection cap, from `RPX_QUEUE_WAIT_MS` (default 30s). On expiry the
|
|
45
|
-
* request gets a 503 rather than hanging indefinitely. This bound is what makes
|
|
46
|
-
* a leaked/stalled slot a localized 503 instead of a global wedge.
|
|
47
|
-
*/
|
|
48
|
-
function queueWaitMs(): number {
|
|
49
|
-
const v = Number.parseInt(process.env.RPX_QUEUE_WAIT_MS ?? '', 10)
|
|
50
|
-
return Number.isFinite(v) && v > 0 ? v : 30_000
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Hard ceiling on queued waiters per upstream, from `RPX_MAX_QUEUED` (default
|
|
55
|
-
* `maxTotal * 8`). Beyond this, requests are rejected with 503 immediately
|
|
56
|
-
* rather than appended — so a flood (or a fully wedged upstream) can't grow the
|
|
57
|
-
* waiter array without bound and exhaust memory on top of the saturation.
|
|
58
|
-
*/
|
|
59
|
-
function maxQueued(maxTotal: number): number {
|
|
60
|
-
const v = Number.parseInt(process.env.RPX_MAX_QUEUED ?? '', 10)
|
|
61
|
-
return Number.isFinite(v) && v > 0 ? v : maxTotal * 8
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Upstream inactivity timeout in seconds, from `RPX_UPSTREAM_TIMEOUT`. `0`
|
|
66
|
-
* (the default) disables it — rpx commonly fronts dev servers doing SSE/HMR/
|
|
67
|
-
* long-poll, where an inactivity timeout would sever legitimately-quiet streams.
|
|
68
|
-
* The timer resets on every byte, so a streaming response that emits data
|
|
69
|
-
* periodically never trips it; only a fully-stalled upstream does. Set a value
|
|
70
|
-
* (e.g. `RPX_UPSTREAM_TIMEOUT=60`) in production to bound hung upstreams.
|
|
71
|
-
*/
|
|
72
|
-
function upstreamTimeoutSeconds(): number {
|
|
73
|
-
const v = Number.parseInt(process.env.RPX_UPSTREAM_TIMEOUT ?? '', 10)
|
|
74
|
-
return Number.isFinite(v) && v > 0 ? v : 0
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** Largest request body we will buffer in-memory to keep a request retry-safe. */
|
|
78
|
-
const MAX_BUFFERED_BODY = 1024 * 1024 // 1 MB
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Max open connections per upstream, from `RPX_MAX_UPSTREAM_CONNS` (default 256).
|
|
82
|
-
* Requests beyond this queue for a free connection rather than opening more — the
|
|
83
|
-
* ceiling that keeps a flood from churning sockets into TIME_WAIT. Raise it for
|
|
84
|
-
* higher peak parallelism, lower it to cap upstream load.
|
|
85
|
-
*/
|
|
86
|
-
function maxTotalConns(): number {
|
|
87
|
-
const v = Number.parseInt(process.env.RPX_MAX_UPSTREAM_CONNS ?? '', 10)
|
|
88
|
-
return Number.isFinite(v) && v > 0 ? v : 256
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** How long an idle pooled connection is kept before it is closed (nginx's keepalive_timeout). */
|
|
92
|
-
const IDLE_TIMEOUT_MS = 30_000
|
|
93
|
-
|
|
94
|
-
/** Initial per-connection read buffer; grows on demand for larger header blocks. */
|
|
95
|
-
const INITIAL_BUF = 16384
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Hard cap on the response header block. A buggy or malicious upstream that
|
|
99
|
-
* streams bytes without ever sending the terminating `\r\n\r\n` would otherwise
|
|
100
|
-
* grow the per-connection buffer unbounded (doubling on each read) until the
|
|
101
|
-
* proxy OOMs. If the header end isn't found within this many bytes, the
|
|
102
|
-
* connection is torn down and the request fails with 502. 256 KB is far above
|
|
103
|
-
* any legitimate header block.
|
|
104
|
-
*/
|
|
105
|
-
const MAX_HEADER_BYTES = 256 * 1024
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Bodies at least this large take the zero-copy fast path: hand the Response a
|
|
109
|
-
* *view* of the read buffer and give the connection a fresh (smaller) buffer,
|
|
110
|
-
* rather than copying the body out. Below it, a plain slice is cheaper than
|
|
111
|
-
* allocating a replacement buffer.
|
|
112
|
-
*/
|
|
113
|
-
const BODY_HANDOFF_THRESHOLD = 8192
|
|
114
|
-
/** Replacement buffer size after a zero-copy hand-off (just needs the next headers). */
|
|
115
|
-
const HANDOFF_BUF = 4096
|
|
116
|
-
|
|
117
|
-
const encoder = new TextEncoder()
|
|
118
|
-
const decoder = new TextDecoder()
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Request headers never forwarded verbatim: HTTP/1.1 framing headers (we own the
|
|
122
|
-
* connection lifecycle) plus the `x-forwarded-*` set, which the caller always
|
|
123
|
-
* supplies as overrides — so client-sent copies must not be passed through.
|
|
124
|
-
*/
|
|
125
|
-
const STRIP_REQUEST = new Set([
|
|
126
|
-
'host',
|
|
127
|
-
'connection',
|
|
128
|
-
'keep-alive',
|
|
129
|
-
'proxy-connection',
|
|
130
|
-
'transfer-encoding',
|
|
131
|
-
'x-forwarded-for',
|
|
132
|
-
'x-forwarded-proto',
|
|
133
|
-
'x-forwarded-host',
|
|
134
|
-
])
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* One pooled upstream socket. A connection serves a single request at a time
|
|
138
|
-
* (HTTP/1.1, no pipelining); it is checked out of the pool for the duration of a
|
|
139
|
-
* request and returned once the response body is fully read.
|
|
140
|
-
*/
|
|
141
|
-
class Conn {
|
|
142
|
-
socket: import('bun').Socket<undefined> | null = null
|
|
143
|
-
buf: Uint8Array = new Uint8Array(INITIAL_BUF)
|
|
144
|
-
len = 0 // bytes filled
|
|
145
|
-
pos = 0 // bytes consumed by the parser
|
|
146
|
-
closed = false
|
|
147
|
-
/** True until the first request has been written — lets the pool retry stale reuse. */
|
|
148
|
-
fresh = true
|
|
149
|
-
/** Set when the socket's inactivity timeout fired — surfaces as a 504, not a 502. */
|
|
150
|
-
timedOut = false
|
|
151
|
-
/** Epoch ms when this connection was last returned to the idle set (for sweeping). */
|
|
152
|
-
idleSince = 0
|
|
153
|
-
/** When set, push() routes body bytes here (streaming a content-length body). */
|
|
154
|
-
bodyQueue: Uint8Array[] | null = null
|
|
155
|
-
/** Body bytes still expected from the socket while {@link bodyQueue} is active. */
|
|
156
|
-
bodyRemaining = 0
|
|
157
|
-
/** Resolves the in-flight reader when new bytes arrive or the socket closes. */
|
|
158
|
-
private waiter: (() => void) | null = null
|
|
159
|
-
/** Resolves the in-flight writer when the socket's send buffer drains. */
|
|
160
|
-
drainWaiter: (() => void) | null = null
|
|
161
|
-
|
|
162
|
-
/** Write `bytes` in full, awaiting `drain` when the send buffer backs up. */
|
|
163
|
-
async writeAll(bytes: Uint8Array): Promise<void> {
|
|
164
|
-
const socket = this.socket!
|
|
165
|
-
let off = socket.write(bytes)
|
|
166
|
-
while (off < bytes.length) {
|
|
167
|
-
if (this.closed)
|
|
168
|
-
throw STALE
|
|
169
|
-
// Send buffer full — wait for drain before writing the remainder.
|
|
170
|
-
await new Promise<void>((resolve) => { this.drainWaiter = resolve })
|
|
171
|
-
off += socket.write(bytes.subarray(off))
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
wakeDrain(): void {
|
|
176
|
-
const w = this.drainWaiter
|
|
177
|
-
if (w) {
|
|
178
|
-
this.drainWaiter = null
|
|
179
|
-
w()
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
push(chunk: Uint8Array): void {
|
|
184
|
-
// Body-streaming mode: route body bytes straight to the per-connection queue
|
|
185
|
-
// the ReadableStream drains, instead of copying into `buf` and slicing back
|
|
186
|
-
// out — halving body copies on the hot HTML/asset path. Bun reuses `chunk`
|
|
187
|
-
// after this callback, so the slice (one necessary copy) keeps the bytes.
|
|
188
|
-
if (this.bodyQueue) {
|
|
189
|
-
const n = chunk.length <= this.bodyRemaining ? chunk.length : this.bodyRemaining
|
|
190
|
-
if (n > 0)
|
|
191
|
-
this.bodyQueue.push(chunk.slice(0, n))
|
|
192
|
-
this.bodyRemaining -= n
|
|
193
|
-
if (n < chunk.length) // bytes past the body (pipelined/over-long) — keep for finish logic
|
|
194
|
-
this.appendToBuf(chunk.subarray(n))
|
|
195
|
-
this.wake()
|
|
196
|
-
return
|
|
197
|
-
}
|
|
198
|
-
this.appendToBuf(chunk)
|
|
199
|
-
this.wake()
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
private appendToBuf(chunk: Uint8Array): void {
|
|
203
|
-
const need = this.len + chunk.length
|
|
204
|
-
if (need > this.buf.length) {
|
|
205
|
-
const grown = new Uint8Array(Math.max(this.buf.length * 2, need))
|
|
206
|
-
grown.set(this.buf.subarray(0, this.len))
|
|
207
|
-
this.buf = grown
|
|
208
|
-
}
|
|
209
|
-
this.buf.set(chunk, this.len)
|
|
210
|
-
this.len = need
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
markClosed(): void {
|
|
214
|
-
this.closed = true
|
|
215
|
-
// Release both a pending reader and a pending writer — neither can make
|
|
216
|
-
// progress on a closed socket.
|
|
217
|
-
this.wake()
|
|
218
|
-
this.wakeDrain()
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/** The socket's inactivity timeout fired: flag it (→ 504) and tear it down. */
|
|
222
|
-
markTimedOut(): void {
|
|
223
|
-
this.timedOut = true
|
|
224
|
-
this.markClosed()
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
private wake(): void {
|
|
228
|
-
const w = this.waiter
|
|
229
|
-
if (w) {
|
|
230
|
-
this.waiter = null
|
|
231
|
-
w()
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* Resolve once the buffer has grown past `seen` bytes (i.e. *new* data has
|
|
237
|
-
* arrived) or the socket has closed. Callers pass the length they have already
|
|
238
|
-
* inspected — crucial for the header/chunk-line scanners, which don't advance
|
|
239
|
-
* `pos` while searching: a plain "resolve if len>pos" would busy-spin on
|
|
240
|
-
* microtasks (starving the I/O callback) when a line arrives split across TCP
|
|
241
|
-
* segments. Waiting for `len > seen` instead yields to the event loop.
|
|
242
|
-
*/
|
|
243
|
-
waitForData(seen: number): Promise<void> {
|
|
244
|
-
if (this.len > seen || this.closed)
|
|
245
|
-
return Promise.resolve()
|
|
246
|
-
return new Promise<void>((resolve) => { this.waiter = resolve })
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/** Resolve once the body queue has a chunk to drain or the socket has closed. */
|
|
250
|
-
waitForBody(): Promise<void> {
|
|
251
|
-
if ((this.bodyQueue !== null && this.bodyQueue.length > 0) || this.closed)
|
|
252
|
-
return Promise.resolve()
|
|
253
|
-
return new Promise<void>((resolve) => { this.waiter = resolve })
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/** Drop already-consumed bytes so the buffer doesn't grow unbounded across reuse. */
|
|
257
|
-
compact(): void {
|
|
258
|
-
if (this.pos === 0)
|
|
259
|
-
return
|
|
260
|
-
if (this.pos === this.len) {
|
|
261
|
-
this.pos = 0
|
|
262
|
-
this.len = 0
|
|
263
|
-
return
|
|
264
|
-
}
|
|
265
|
-
this.buf.copyWithin(0, this.pos, this.len)
|
|
266
|
-
this.len -= this.pos
|
|
267
|
-
this.pos = 0
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
destroy(): void {
|
|
271
|
-
this.closed = true
|
|
272
|
-
try { this.socket?.end() }
|
|
273
|
-
catch { /* already gone */ }
|
|
274
|
-
this.socket = null
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
/**
|
|
279
|
-
* A bounded keepalive connection pool for a single upstream `host:port`.
|
|
280
|
-
*
|
|
281
|
-
* It caps the *total* number of open connections at `maxTotal` and **queues**
|
|
282
|
-
* requests that arrive while every connection is busy, handing each released
|
|
283
|
-
* connection straight to the next waiter. Connections are only ever closed on
|
|
284
|
-
* error or after sitting idle past the timeout — never on the hot release path.
|
|
285
|
-
*
|
|
286
|
-
* Bounding the total (rather than just the idle set) is what makes it
|
|
287
|
-
* collapse-safe: a fixed set of connections is reused indefinitely, so there is
|
|
288
|
-
* no per-request churn to pile sockets into TIME_WAIT and exhaust ephemeral
|
|
289
|
-
* ports under a flood. The price is that at concurrency above `maxTotal`,
|
|
290
|
-
* throughput is bounded by the pool size instead of growing unbounded — exactly
|
|
291
|
-
* the trade nginx makes, and the reason it stays up under load.
|
|
292
|
-
*/
|
|
293
|
-
class UpstreamPool {
|
|
294
|
-
private idle: Conn[] = []
|
|
295
|
-
/** Requests waiting for a connection when all `maxTotal` are busy. */
|
|
296
|
-
private waiters: Array<(c: Conn | null) => void> = []
|
|
297
|
-
/** Open connections (idle + checked-out). Capped at {@link maxTotal}. */
|
|
298
|
-
private open = 0
|
|
299
|
-
/** Lazily-started interval that closes connections idle past {@link IDLE_TIMEOUT_MS}. */
|
|
300
|
-
private sweeper: ReturnType<typeof setInterval> | null = null
|
|
301
|
-
/** Max ms a request waits for a free slot before getting {@link POOL_BUSY}. */
|
|
302
|
-
private readonly queueWaitMs = queueWaitMs()
|
|
303
|
-
/** Hard ceiling on {@link waiters} length; beyond it, reject with {@link POOL_BUSY}. */
|
|
304
|
-
private readonly maxWaiters: number
|
|
305
|
-
|
|
306
|
-
constructor(private host: string, private port: number, private maxTotal: number) {
|
|
307
|
-
this.maxWaiters = maxQueued(maxTotal)
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
dial(): Promise<Conn> {
|
|
311
|
-
const conn = new Conn()
|
|
312
|
-
return connect({
|
|
313
|
-
hostname: this.host,
|
|
314
|
-
port: this.port,
|
|
315
|
-
socket: {
|
|
316
|
-
data: (_s, chunk) => conn.push(chunk),
|
|
317
|
-
drain: () => conn.wakeDrain(),
|
|
318
|
-
close: () => conn.markClosed(),
|
|
319
|
-
end: () => conn.markClosed(),
|
|
320
|
-
error: () => conn.markClosed(),
|
|
321
|
-
connectError: () => conn.markClosed(),
|
|
322
|
-
timeout: () => conn.markTimedOut(),
|
|
323
|
-
},
|
|
324
|
-
}).then((socket) => {
|
|
325
|
-
const s = socket as unknown as {
|
|
326
|
-
setNoDelay?: (v: boolean) => void
|
|
327
|
-
timeout?: (seconds: number) => void
|
|
328
|
-
}
|
|
329
|
-
// Disable Nagle: proxy writes a complete request in one shot and wants it
|
|
330
|
-
// on the wire immediately, not coalesced.
|
|
331
|
-
s.setNoDelay?.(true)
|
|
332
|
-
// Bound a fully-stalled upstream (opt-in; resets on every byte).
|
|
333
|
-
const t = upstreamTimeoutSeconds()
|
|
334
|
-
if (t > 0)
|
|
335
|
-
s.timeout?.(t)
|
|
336
|
-
conn.socket = socket as unknown as import('bun').Socket<undefined>
|
|
337
|
-
return conn
|
|
338
|
-
})
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/** Pop a live idle connection synchronously (the hot path), or null if none. */
|
|
342
|
-
acquireIdleSync(): Conn | null {
|
|
343
|
-
while (this.idle.length) {
|
|
344
|
-
const c = this.idle.pop()!
|
|
345
|
-
if (!c.closed) {
|
|
346
|
-
// Re-ref the socket: it is now active and must keep the loop alive while
|
|
347
|
-
// we await the upstream response.
|
|
348
|
-
c.socket?.ref()
|
|
349
|
-
c.pos = 0
|
|
350
|
-
c.len = 0
|
|
351
|
-
return c
|
|
352
|
-
}
|
|
353
|
-
this.open-- // a dead idle connection no longer counts toward the cap
|
|
354
|
-
}
|
|
355
|
-
return null
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
/**
|
|
359
|
-
* Get a connection when the idle set is empty: dial a fresh one if under the
|
|
360
|
-
* cap, otherwise wait for one to be released. Only awaited off the hot path.
|
|
361
|
-
*/
|
|
362
|
-
async acquireOrDial(): Promise<Conn> {
|
|
363
|
-
if (this.open >= this.maxTotal) {
|
|
364
|
-
// Reject immediately once the queue is full — never let it grow unbounded.
|
|
365
|
-
if (this.waiters.length >= this.maxWaiters)
|
|
366
|
-
throw POOL_BUSY
|
|
367
|
-
const c = await this.waitForSlot()
|
|
368
|
-
if (c === POOL_BUSY)
|
|
369
|
-
throw POOL_BUSY
|
|
370
|
-
if (c) {
|
|
371
|
-
c.pos = 0
|
|
372
|
-
c.len = 0
|
|
373
|
-
return c
|
|
374
|
-
}
|
|
375
|
-
// Woken with null — a slot was freed by a closed connection; dial below.
|
|
376
|
-
}
|
|
377
|
-
this.open++
|
|
378
|
-
try {
|
|
379
|
-
return await this.dial()
|
|
380
|
-
}
|
|
381
|
-
catch (err) {
|
|
382
|
-
this.open--
|
|
383
|
-
this.wakeWaiter()
|
|
384
|
-
throw err
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
/**
|
|
389
|
-
* Wait for a connection to be released to us, bounded by {@link queueWaitMs}.
|
|
390
|
-
* Resolves with a reused {@link Conn}, `null` (a slot freed up — dial a fresh
|
|
391
|
-
* one), or {@link POOL_BUSY} on timeout. On timeout the waiter is removed from
|
|
392
|
-
* the queue so a freed slot is never handed to a request that already gave up.
|
|
393
|
-
*/
|
|
394
|
-
private waitForSlot(): Promise<Conn | null | typeof POOL_BUSY> {
|
|
395
|
-
return new Promise((resolve) => {
|
|
396
|
-
let settled = false
|
|
397
|
-
const finish = (v: Conn | null | typeof POOL_BUSY): void => {
|
|
398
|
-
if (settled)
|
|
399
|
-
return
|
|
400
|
-
settled = true
|
|
401
|
-
clearTimeout(timer)
|
|
402
|
-
resolve(v)
|
|
403
|
-
}
|
|
404
|
-
// The resolver stored in `waiters` clears the timer on a real wake-up.
|
|
405
|
-
const waiter = (c: Conn | null): void => finish(c)
|
|
406
|
-
const timer = setTimeout(() => {
|
|
407
|
-
const i = this.waiters.indexOf(waiter)
|
|
408
|
-
if (i !== -1)
|
|
409
|
-
this.waiters.splice(i, 1)
|
|
410
|
-
finish(POOL_BUSY)
|
|
411
|
-
}, this.queueWaitMs)
|
|
412
|
-
timer.unref?.()
|
|
413
|
-
this.waiters.push(waiter)
|
|
414
|
-
})
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
/** Hand a healthy connection to the next waiter, or return it to the idle set. */
|
|
418
|
-
release(conn: Conn): void {
|
|
419
|
-
if (conn.closed) {
|
|
420
|
-
this.open--
|
|
421
|
-
this.wakeWaiter()
|
|
422
|
-
return
|
|
423
|
-
}
|
|
424
|
-
conn.compact()
|
|
425
|
-
conn.fresh = false
|
|
426
|
-
const waiter = this.waiters.shift()
|
|
427
|
-
if (waiter) {
|
|
428
|
-
// Reuse directly for a queued request — connection stays checked out.
|
|
429
|
-
conn.socket?.ref()
|
|
430
|
-
waiter(conn)
|
|
431
|
-
return
|
|
432
|
-
}
|
|
433
|
-
conn.idleSince = Date.now()
|
|
434
|
-
// An idle pooled socket must not keep the process alive on its own; a single
|
|
435
|
-
// periodic sweep (not a per-request timer) expires connections idle past the
|
|
436
|
-
// timeout, mirroring nginx keepalive_timeout without per-request overhead.
|
|
437
|
-
conn.socket?.unref()
|
|
438
|
-
this.idle.push(conn)
|
|
439
|
-
this.ensureSweeper()
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
/** Permanently drop a connection (protocol error / unreusable framing). */
|
|
443
|
-
destroy(conn: Conn): void {
|
|
444
|
-
conn.destroy()
|
|
445
|
-
this.open--
|
|
446
|
-
this.wakeWaiter()
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
/** A slot freed up — wake one waiter to dial a fresh connection. */
|
|
450
|
-
private wakeWaiter(): void {
|
|
451
|
-
const waiter = this.waiters.shift()
|
|
452
|
-
if (waiter)
|
|
453
|
-
waiter(null)
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
/** Start the idle sweeper on first use; it self-stops once the pool drains. */
|
|
457
|
-
private ensureSweeper(): void {
|
|
458
|
-
if (this.sweeper)
|
|
459
|
-
return
|
|
460
|
-
this.sweeper = setInterval(() => this.sweep(), IDLE_TIMEOUT_MS)
|
|
461
|
-
// Don't let the sweeper keep the process alive.
|
|
462
|
-
this.sweeper.unref?.()
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
/** Close connections idle longer than the timeout; stop sweeping when empty. */
|
|
466
|
-
private sweep(): void {
|
|
467
|
-
const cutoff = Date.now() - IDLE_TIMEOUT_MS
|
|
468
|
-
if (this.idle.length) {
|
|
469
|
-
const survivors: Conn[] = []
|
|
470
|
-
for (const c of this.idle) {
|
|
471
|
-
if (c.closed || c.idleSince <= cutoff) {
|
|
472
|
-
c.destroy()
|
|
473
|
-
this.open--
|
|
474
|
-
}
|
|
475
|
-
else {
|
|
476
|
-
survivors.push(c)
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
this.idle = survivors
|
|
480
|
-
}
|
|
481
|
-
if (this.idle.length === 0 && this.sweeper) {
|
|
482
|
-
clearInterval(this.sweeper)
|
|
483
|
-
this.sweeper = null
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
const pools = new Map<string, UpstreamPool>()
|
|
489
|
-
|
|
490
|
-
/** Get (or create) the pool for `host:port`. */
|
|
491
|
-
function poolFor(hostPort: string, maxPerHost: number): UpstreamPool {
|
|
492
|
-
let pool = pools.get(hostPort)
|
|
493
|
-
if (!pool) {
|
|
494
|
-
const idx = hostPort.lastIndexOf(':')
|
|
495
|
-
const host = idx === -1 ? hostPort : hostPort.slice(0, idx)
|
|
496
|
-
const port = idx === -1 ? 80 : Number(hostPort.slice(idx + 1))
|
|
497
|
-
pool = new UpstreamPool(host, port, maxPerHost)
|
|
498
|
-
pools.set(hostPort, pool)
|
|
499
|
-
}
|
|
500
|
-
return pool
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
interface ParsedHead {
|
|
504
|
-
status: number
|
|
505
|
-
headerEnd: number // index of the start of CRLFCRLF
|
|
506
|
-
headers: Array<[string, string]>
|
|
507
|
-
contentLength: number // -1 if absent
|
|
508
|
-
chunked: boolean
|
|
509
|
-
closeConn: boolean // upstream asked to close (or HTTP/1.0)
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
/** Scan for the end of the header block (\r\n\r\n). Returns the index of \r or -1. */
|
|
513
|
-
function findHeaderEnd(buf: Uint8Array, len: number, from: number): number {
|
|
514
|
-
for (let i = from + 3; i < len; i++) {
|
|
515
|
-
if (buf[i] === 10 && buf[i - 1] === 13 && buf[i - 2] === 10 && buf[i - 3] === 13)
|
|
516
|
-
return i - 3
|
|
517
|
-
}
|
|
518
|
-
return -1
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
/** Parse a complete response header block sitting in `buf[start..headerEnd)`. */
|
|
522
|
-
function parseHead(buf: Uint8Array, start: number, headerEnd: number): ParsedHead {
|
|
523
|
-
const text = decoder.decode(buf.subarray(start, headerEnd))
|
|
524
|
-
const firstEol = text.indexOf('\r\n')
|
|
525
|
-
const statusLine = firstEol === -1 ? text : text.slice(0, firstEol)
|
|
526
|
-
// "HTTP/1.1 200 OK" → status is the token after the first space.
|
|
527
|
-
const sp = statusLine.indexOf(' ')
|
|
528
|
-
const status = Number.parseInt(statusLine.slice(sp + 1, sp + 5), 10)
|
|
529
|
-
const http10 = statusLine.startsWith('HTTP/1.0')
|
|
530
|
-
|
|
531
|
-
const headers: Array<[string, string]> = []
|
|
532
|
-
let contentLength = -1
|
|
533
|
-
let chunked = false
|
|
534
|
-
let closeConn = http10
|
|
535
|
-
let keepAliveSeen = false
|
|
536
|
-
|
|
537
|
-
let pos = firstEol === -1 ? text.length : firstEol + 2
|
|
538
|
-
while (pos < text.length) {
|
|
539
|
-
let eol = text.indexOf('\r\n', pos)
|
|
540
|
-
if (eol === -1)
|
|
541
|
-
eol = text.length
|
|
542
|
-
const line = text.slice(pos, eol)
|
|
543
|
-
pos = eol + 2
|
|
544
|
-
if (line === '')
|
|
545
|
-
continue
|
|
546
|
-
const colon = line.indexOf(':')
|
|
547
|
-
if (colon === -1)
|
|
548
|
-
continue
|
|
549
|
-
const name = line.slice(0, colon)
|
|
550
|
-
const value = line.slice(colon + 1).trim()
|
|
551
|
-
// Only the framing/strip headers (content-length, transfer-encoding,
|
|
552
|
-
// connection, keep-alive) start with c/t/k — so skip the toLowerCase for
|
|
553
|
-
// every other header (date, server, content-type-pass-through, etc.).
|
|
554
|
-
const first = name.charCodeAt(0) | 0x20
|
|
555
|
-
if (first === 99 || first === 116 || first === 107) {
|
|
556
|
-
const lower = name.toLowerCase()
|
|
557
|
-
if (lower === 'content-length') {
|
|
558
|
-
contentLength = Number.parseInt(value, 10)
|
|
559
|
-
continue
|
|
560
|
-
}
|
|
561
|
-
if (lower === 'transfer-encoding') {
|
|
562
|
-
if (value.toLowerCase().includes('chunked'))
|
|
563
|
-
chunked = true
|
|
564
|
-
continue
|
|
565
|
-
}
|
|
566
|
-
if (lower === 'connection') {
|
|
567
|
-
const v = value.toLowerCase()
|
|
568
|
-
if (v.includes('close'))
|
|
569
|
-
closeConn = true
|
|
570
|
-
if (v.includes('keep-alive'))
|
|
571
|
-
keepAliveSeen = true
|
|
572
|
-
continue
|
|
573
|
-
}
|
|
574
|
-
if (lower === 'keep-alive')
|
|
575
|
-
continue
|
|
576
|
-
}
|
|
577
|
-
headers.push([name, value])
|
|
578
|
-
}
|
|
579
|
-
if (http10 && keepAliveSeen)
|
|
580
|
-
closeConn = false
|
|
581
|
-
|
|
582
|
-
return { status, headerEnd, headers, contentLength, chunked, closeConn }
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
/**
|
|
586
|
-
* Whether a response with this status / method carries no body (RFC 7230 §3.3.3).
|
|
587
|
-
* Interim 1xx responses are handled separately (skipped) before this is reached.
|
|
588
|
-
*/
|
|
589
|
-
function isBodyless(status: number, isHead: boolean): boolean {
|
|
590
|
-
return isHead || status === 204 || status === 304
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
export interface PoolRequest {
|
|
594
|
-
/** Upstream `host:port`. */
|
|
595
|
-
hostPort: string
|
|
596
|
-
method: string
|
|
597
|
-
/** Path + query (origin-form), e.g. `/api/x?y=1`. */
|
|
598
|
-
path: string
|
|
599
|
-
/** The original client request headers, forwarded minus framing/override keys. */
|
|
600
|
-
reqHeaders: Headers
|
|
601
|
-
/** Value for the upstream `x-forwarded-host` (the client-facing hostname). */
|
|
602
|
-
forwardedHost: string
|
|
603
|
-
/** When set (changeOrigin), the `origin` header value; also drops the client's. */
|
|
604
|
-
originOverride?: string
|
|
605
|
-
/** Request body stream, or null. */
|
|
606
|
-
body: ReadableStream<Uint8Array> | null
|
|
607
|
-
/** Max idle connections kept per upstream. */
|
|
608
|
-
maxPerHost?: number
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
/**
|
|
612
|
-
* Forward a request through the pooled transport and return the upstream
|
|
613
|
-
* {@link Response}. Throws {@link FALLBACK} for cases it intentionally declines
|
|
614
|
-
* (large/streaming uploads, `Expect`, upgrades) so the caller can use `fetch()`.
|
|
615
|
-
*/
|
|
616
|
-
export async function proxyViaPool(reqOpts: PoolRequest): Promise<Response> {
|
|
617
|
-
const { hostPort, method, path, reqHeaders, forwardedHost, originOverride, body } = reqOpts
|
|
618
|
-
const isHead = method === 'HEAD'
|
|
619
|
-
|
|
620
|
-
// Decline anything that needs request-time negotiation or a hijacked socket.
|
|
621
|
-
if (reqHeaders.get('expect') || reqHeaders.get('upgrade'))
|
|
622
|
-
throw FALLBACK
|
|
623
|
-
|
|
624
|
-
// Materialize the request body up-front (keeps retries safe) — but only when
|
|
625
|
-
// it's small. Large/streaming uploads go through fetch(), which handles
|
|
626
|
-
// backpressure properly.
|
|
627
|
-
let bodyBytes: Uint8Array | null = null
|
|
628
|
-
if (body) {
|
|
629
|
-
// Only handle bodies with a known, modest Content-Length. Streaming or
|
|
630
|
-
// unknown-length uploads are declined *before* the stream is touched, so the
|
|
631
|
-
// caller can still hand the untouched body to fetch().
|
|
632
|
-
const lenHeader = reqHeaders.get('content-length')
|
|
633
|
-
const declared = lenHeader ? Number.parseInt(lenHeader, 10) : Number.NaN
|
|
634
|
-
if (Number.isNaN(declared) || declared > MAX_BUFFERED_BODY)
|
|
635
|
-
throw FALLBACK
|
|
636
|
-
bodyBytes = await readBodyCapped(body)
|
|
637
|
-
if (bodyBytes === null)
|
|
638
|
-
throw FALLBACK // exceeded cap while reading (Content-Length under-declared)
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
const head = serializeRequest(method, path, reqHeaders, hostPort, forwardedHost, originOverride, bodyBytes)
|
|
642
|
-
// Send the head and (buffered) body as one write so the common request is a
|
|
643
|
-
// single syscall with no body-write await.
|
|
644
|
-
let payload = head
|
|
645
|
-
if (bodyBytes && bodyBytes.length) {
|
|
646
|
-
payload = new Uint8Array(head.length + bodyBytes.length)
|
|
647
|
-
payload.set(head)
|
|
648
|
-
payload.set(bodyBytes, head.length)
|
|
649
|
-
}
|
|
650
|
-
const pool = poolFor(hostPort, reqOpts.maxPerHost ?? maxTotalConns())
|
|
651
|
-
|
|
652
|
-
// One transparent retry: a reused keepalive socket may have been closed by the
|
|
653
|
-
// upstream between requests; if it dies before we read any response, retry on a
|
|
654
|
-
// fresh connection (always safe — the body is buffered).
|
|
655
|
-
for (let attempt = 0; ; attempt++) {
|
|
656
|
-
// Reuse a pooled connection synchronously when one is available (no await on
|
|
657
|
-
// the hot path); otherwise dial a fresh one or queue for a free slot.
|
|
658
|
-
const conn = pool.acquireIdleSync() ?? await pool.acquireOrDial()
|
|
659
|
-
try {
|
|
660
|
-
const written = conn.socket!.write(payload)
|
|
661
|
-
if (written < payload.length)
|
|
662
|
-
await conn.writeAll(payload.subarray(written))
|
|
663
|
-
return await readResponse(pool, conn, isHead)
|
|
664
|
-
}
|
|
665
|
-
catch (err) {
|
|
666
|
-
const retryable = !conn.fresh && attempt === 0 && err === STALE
|
|
667
|
-
pool.destroy(conn)
|
|
668
|
-
if (retryable)
|
|
669
|
-
continue
|
|
670
|
-
if (err === STALE)
|
|
671
|
-
throw new Error('upstream closed connection')
|
|
672
|
-
throw err
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
/** Block until the full response header block (\r\n\r\n) is buffered. */
|
|
678
|
-
async function waitForHead(conn: Conn): Promise<number> {
|
|
679
|
-
let headerEnd = findHeaderEnd(conn.buf, conn.len, conn.pos)
|
|
680
|
-
while (headerEnd === -1) {
|
|
681
|
-
if (conn.closed) {
|
|
682
|
-
if (conn.timedOut)
|
|
683
|
-
throw TIMEOUT
|
|
684
|
-
// Nothing (or only a partial head) arrived — treat as a stale socket.
|
|
685
|
-
if (conn.len === conn.pos)
|
|
686
|
-
throw STALE
|
|
687
|
-
throw new Error('upstream closed mid-header')
|
|
688
|
-
}
|
|
689
|
-
// Bound the header block: an upstream that streams bytes without ever
|
|
690
|
-
// sending `\r\n\r\n` would otherwise grow `buf` unbounded until OOM.
|
|
691
|
-
if (conn.len - conn.pos > MAX_HEADER_BYTES)
|
|
692
|
-
throw new Error('upstream header block too large')
|
|
693
|
-
await conn.waitForData(conn.len)
|
|
694
|
-
headerEnd = findHeaderEnd(conn.buf, conn.len, conn.pos)
|
|
695
|
-
}
|
|
696
|
-
return headerEnd
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
/** Read the response head + body from `conn`, returning a streaming Response. */
|
|
700
|
-
async function readResponse(pool: UpstreamPool, conn: Conn, isHead: boolean): Promise<Response> {
|
|
701
|
-
// Skip any interim 1xx responses (e.g. an unsolicited `103 Early Hints`); they
|
|
702
|
-
// are header-only and precede the real response on the same connection. (`101`
|
|
703
|
-
// can't occur here — we decline upgrades up front.)
|
|
704
|
-
let head: ParsedHead
|
|
705
|
-
for (;;) {
|
|
706
|
-
const headerEnd = await waitForHead(conn)
|
|
707
|
-
head = parseHead(conn.buf, conn.pos, headerEnd)
|
|
708
|
-
conn.pos = headerEnd + 4
|
|
709
|
-
if (head.status >= 100 && head.status < 200)
|
|
710
|
-
continue
|
|
711
|
-
break
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
// Pass the [name, value][] array straight to Response as HeadersInit — no
|
|
715
|
-
// intermediate Headers allocation on the hot path.
|
|
716
|
-
const responseHeaders = head.headers
|
|
717
|
-
|
|
718
|
-
if (isBodyless(head.status, isHead)) {
|
|
719
|
-
finishConn(pool, conn, head.closeConn)
|
|
720
|
-
return new Response(null, { status: head.status, headers: responseHeaders })
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
if (head.chunked)
|
|
724
|
-
return new Response(chunkedStream(pool, conn, head.closeConn), { status: head.status, headers: responseHeaders })
|
|
725
|
-
|
|
726
|
-
if (head.contentLength >= 0) {
|
|
727
|
-
// Fast path: the whole body is already buffered — hand back bytes directly,
|
|
728
|
-
// no ReadableStream machinery. (`finishConn` destroys the connection instead
|
|
729
|
-
// of pooling it if there are leftover bytes after the body.)
|
|
730
|
-
const available = conn.len - conn.pos
|
|
731
|
-
if (available >= head.contentLength) {
|
|
732
|
-
const bodyEnd = conn.pos + head.contentLength
|
|
733
|
-
const leftover = conn.len > bodyEnd
|
|
734
|
-
let out: Uint8Array | null = null
|
|
735
|
-
if (head.contentLength >= BODY_HANDOFF_THRESHOLD) {
|
|
736
|
-
// Large body: hand over a view and swap in a fresh (small) buffer, so we
|
|
737
|
-
// don't copy the whole body just to wrap it in a Response.
|
|
738
|
-
out = conn.buf.subarray(conn.pos, bodyEnd)
|
|
739
|
-
conn.buf = new Uint8Array(HANDOFF_BUF)
|
|
740
|
-
}
|
|
741
|
-
else if (head.contentLength > 0) {
|
|
742
|
-
out = conn.buf.slice(conn.pos, bodyEnd) // small body: a copy beats a fresh alloc
|
|
743
|
-
}
|
|
744
|
-
conn.pos = 0
|
|
745
|
-
conn.len = 0
|
|
746
|
-
// Leftover bytes after the body ⇒ pipelined/over-long ⇒ unsafe to reuse.
|
|
747
|
-
if (head.closeConn || leftover)
|
|
748
|
-
pool.destroy(conn)
|
|
749
|
-
else
|
|
750
|
-
pool.release(conn)
|
|
751
|
-
return new Response(out, { status: head.status, headers: responseHeaders })
|
|
752
|
-
}
|
|
753
|
-
return new Response(fixedLengthStream(pool, conn, head.contentLength, head.closeConn), {
|
|
754
|
-
status: head.status,
|
|
755
|
-
headers: responseHeaders,
|
|
756
|
-
})
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
// No framing info: body runs until the connection closes; not reusable.
|
|
760
|
-
return new Response(untilCloseStream(conn), { status: head.status, headers: responseHeaders })
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
/**
|
|
764
|
-
* Return a connection to the pool, or destroy it when it can't be safely reused:
|
|
765
|
-
* the upstream asked to close, or there are leftover bytes after the body (a
|
|
766
|
-
* pipelined/over-long response we don't support — reusing it would mis-frame the
|
|
767
|
-
* next response).
|
|
768
|
-
*/
|
|
769
|
-
function finishConn(pool: UpstreamPool, conn: Conn, closeConn: boolean): void {
|
|
770
|
-
if (closeConn || conn.pos !== conn.len)
|
|
771
|
-
pool.destroy(conn)
|
|
772
|
-
else
|
|
773
|
-
pool.release(conn)
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
/**
|
|
777
|
-
* Stream a Content-Length body via the connection's body queue. The already-
|
|
778
|
-
* buffered prefix is copied out once; every later socket chunk is copied
|
|
779
|
-
* straight into the queue by {@link Conn.push} (no second copy through `buf`),
|
|
780
|
-
* which is the dominant saving when proxying HTML/asset-sized responses.
|
|
781
|
-
*/
|
|
782
|
-
function fixedLengthStream(pool: UpstreamPool, conn: Conn, contentLength: number, closeConn: boolean): ReadableStream<Uint8Array> {
|
|
783
|
-
const buffered = conn.len - conn.pos // body bytes that arrived with the headers
|
|
784
|
-
const first = buffered > 0 ? conn.buf.slice(conn.pos, conn.len) : null
|
|
785
|
-
// Switch into body-streaming mode: clear the read buffer (its body bytes are
|
|
786
|
-
// captured in `first`); subsequent socket data flows to bodyQueue.
|
|
787
|
-
conn.pos = 0
|
|
788
|
-
conn.len = 0
|
|
789
|
-
conn.bodyQueue = []
|
|
790
|
-
conn.bodyRemaining = contentLength - buffered
|
|
791
|
-
|
|
792
|
-
const finish = (controller: ReadableStreamDefaultController<Uint8Array>): void => {
|
|
793
|
-
conn.bodyQueue = null
|
|
794
|
-
// Bytes past the body landed back in `buf` ⇒ pipelined/over-long ⇒ don't reuse.
|
|
795
|
-
if (closeConn || conn.len > 0)
|
|
796
|
-
pool.destroy(conn)
|
|
797
|
-
else
|
|
798
|
-
pool.release(conn)
|
|
799
|
-
controller.close()
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
return new ReadableStream<Uint8Array>({
|
|
803
|
-
start(controller) {
|
|
804
|
-
if (first)
|
|
805
|
-
controller.enqueue(first)
|
|
806
|
-
if (conn.bodyRemaining === 0 && conn.bodyQueue!.length === 0)
|
|
807
|
-
finish(controller)
|
|
808
|
-
},
|
|
809
|
-
async pull(controller) {
|
|
810
|
-
for (;;) {
|
|
811
|
-
const q = conn.bodyQueue
|
|
812
|
-
if (q && q.length > 0) {
|
|
813
|
-
controller.enqueue(q.shift()!)
|
|
814
|
-
if (conn.bodyRemaining === 0 && q.length === 0)
|
|
815
|
-
finish(controller)
|
|
816
|
-
return
|
|
817
|
-
}
|
|
818
|
-
if (conn.bodyRemaining === 0) {
|
|
819
|
-
if (conn.bodyQueue)
|
|
820
|
-
finish(controller)
|
|
821
|
-
return
|
|
822
|
-
}
|
|
823
|
-
if (conn.closed) {
|
|
824
|
-
conn.bodyQueue = null
|
|
825
|
-
pool.destroy(conn)
|
|
826
|
-
controller.error(new Error('upstream closed mid-body'))
|
|
827
|
-
return
|
|
828
|
-
}
|
|
829
|
-
await conn.waitForBody()
|
|
830
|
-
}
|
|
831
|
-
},
|
|
832
|
-
cancel() {
|
|
833
|
-
conn.bodyQueue = null
|
|
834
|
-
pool.destroy(conn)
|
|
835
|
-
},
|
|
836
|
-
})
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
/** Stream a body that runs until the upstream closes the connection (never reused). */
|
|
840
|
-
function untilCloseStream(conn: Conn): ReadableStream<Uint8Array> {
|
|
841
|
-
return new ReadableStream<Uint8Array>({
|
|
842
|
-
async pull(controller) {
|
|
843
|
-
for (;;) {
|
|
844
|
-
if (conn.len > conn.pos) {
|
|
845
|
-
controller.enqueue(conn.buf.slice(conn.pos, conn.len))
|
|
846
|
-
conn.pos = conn.len
|
|
847
|
-
return
|
|
848
|
-
}
|
|
849
|
-
if (conn.closed) {
|
|
850
|
-
conn.destroy()
|
|
851
|
-
controller.close()
|
|
852
|
-
return
|
|
853
|
-
}
|
|
854
|
-
await conn.waitForData(conn.len)
|
|
855
|
-
}
|
|
856
|
-
},
|
|
857
|
-
cancel() {
|
|
858
|
-
conn.destroy()
|
|
859
|
-
},
|
|
860
|
-
})
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
/**
|
|
864
|
-
* Decode a `Transfer-Encoding: chunked` body and stream the decoded bytes. Bun
|
|
865
|
-
* re-frames the outgoing response to the client, so we hand it the dechunked
|
|
866
|
-
* payload (with `transfer-encoding` already stripped from the headers).
|
|
867
|
-
*/
|
|
868
|
-
function chunkedStream(pool: UpstreamPool, conn: Conn, closeConn: boolean): ReadableStream<Uint8Array> {
|
|
869
|
-
let remaining = 0 // bytes left in the current chunk
|
|
870
|
-
let needTrailerCrlf = false // consume the CRLF after a chunk's data
|
|
871
|
-
|
|
872
|
-
// Read the next CRLF-terminated line starting at conn.pos (chunk-size / trailer).
|
|
873
|
-
async function readLine(): Promise<string> {
|
|
874
|
-
for (;;) {
|
|
875
|
-
for (let i = conn.pos; i + 1 < conn.len; i++) {
|
|
876
|
-
if (conn.buf[i] === 13 && conn.buf[i + 1] === 10) {
|
|
877
|
-
const line = decoder.decode(conn.buf.subarray(conn.pos, i))
|
|
878
|
-
conn.pos = i + 2
|
|
879
|
-
return line
|
|
880
|
-
}
|
|
881
|
-
}
|
|
882
|
-
if (conn.closed)
|
|
883
|
-
throw new Error('upstream closed mid-chunk-header')
|
|
884
|
-
await conn.waitForData(conn.len)
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
return new ReadableStream<Uint8Array>({
|
|
889
|
-
async pull(controller) {
|
|
890
|
-
for (;;) {
|
|
891
|
-
if (remaining > 0) {
|
|
892
|
-
if (conn.len === conn.pos) {
|
|
893
|
-
if (conn.closed) {
|
|
894
|
-
pool.destroy(conn)
|
|
895
|
-
controller.error(new Error('upstream closed mid-chunk'))
|
|
896
|
-
return
|
|
897
|
-
}
|
|
898
|
-
await conn.waitForData(conn.len)
|
|
899
|
-
continue
|
|
900
|
-
}
|
|
901
|
-
const take = Math.min(remaining, conn.len - conn.pos)
|
|
902
|
-
controller.enqueue(conn.buf.slice(conn.pos, conn.pos + take))
|
|
903
|
-
conn.pos += take
|
|
904
|
-
remaining -= take
|
|
905
|
-
if (remaining === 0)
|
|
906
|
-
needTrailerCrlf = true
|
|
907
|
-
return
|
|
908
|
-
}
|
|
909
|
-
if (needTrailerCrlf) {
|
|
910
|
-
await readLine() // the empty CRLF closing the chunk data
|
|
911
|
-
needTrailerCrlf = false
|
|
912
|
-
}
|
|
913
|
-
const sizeLine = await readLine()
|
|
914
|
-
const semi = sizeLine.indexOf(';')
|
|
915
|
-
const size = Number.parseInt(semi === -1 ? sizeLine : sizeLine.slice(0, semi), 16)
|
|
916
|
-
if (size === 0) {
|
|
917
|
-
// Consume optional trailers up to the final blank line.
|
|
918
|
-
for (;;) {
|
|
919
|
-
const t = await readLine()
|
|
920
|
-
if (t === '')
|
|
921
|
-
break
|
|
922
|
-
}
|
|
923
|
-
finishConn(pool, conn, closeConn)
|
|
924
|
-
controller.close()
|
|
925
|
-
return
|
|
926
|
-
}
|
|
927
|
-
remaining = size
|
|
928
|
-
}
|
|
929
|
-
},
|
|
930
|
-
cancel() {
|
|
931
|
-
pool.destroy(conn)
|
|
932
|
-
},
|
|
933
|
-
})
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
/**
|
|
937
|
-
* Serialize the request line + headers. The forwarded headers (host,
|
|
938
|
-
* x-forwarded-*, optional origin) are written inline — no per-request overrides
|
|
939
|
-
* array — and their keys are skipped in the client-header passthrough so values
|
|
940
|
-
* aren't duplicated. Content-Length is injected when a buffered body has no
|
|
941
|
-
* declared length.
|
|
942
|
-
*/
|
|
943
|
-
function serializeRequest(
|
|
944
|
-
method: string,
|
|
945
|
-
path: string,
|
|
946
|
-
reqHeaders: Headers,
|
|
947
|
-
hostValue: string,
|
|
948
|
-
forwardedHost: string,
|
|
949
|
-
originOverride: string | undefined,
|
|
950
|
-
bodyBytes: Uint8Array | null,
|
|
951
|
-
): Uint8Array {
|
|
952
|
-
let head = `${method} ${path} HTTP/1.1\r\nhost: ${hostValue}\r\n`
|
|
953
|
-
+ `x-forwarded-for: 127.0.0.1\r\nx-forwarded-proto: https\r\n`
|
|
954
|
-
+ `x-forwarded-host: ${forwardedHost}\r\n`
|
|
955
|
-
if (originOverride !== undefined)
|
|
956
|
-
head += `origin: ${originOverride}\r\n`
|
|
957
|
-
|
|
958
|
-
let sawContentLength = false
|
|
959
|
-
for (const [name, value] of reqHeaders) {
|
|
960
|
-
const lower = name.toLowerCase()
|
|
961
|
-
if (STRIP_REQUEST.has(lower))
|
|
962
|
-
continue
|
|
963
|
-
if (originOverride !== undefined && lower === 'origin')
|
|
964
|
-
continue
|
|
965
|
-
if (lower === 'content-length')
|
|
966
|
-
sawContentLength = true
|
|
967
|
-
head += `${name}: ${value}\r\n`
|
|
968
|
-
}
|
|
969
|
-
head += 'connection: keep-alive\r\n'
|
|
970
|
-
if (bodyBytes && !sawContentLength)
|
|
971
|
-
head += `content-length: ${bodyBytes.length}\r\n`
|
|
972
|
-
head += '\r\n'
|
|
973
|
-
return encoder.encode(head)
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
/** Read a request body fully, returning null if it exceeds {@link MAX_BUFFERED_BODY}. */
|
|
977
|
-
async function readBodyCapped(body: ReadableStream<Uint8Array>): Promise<Uint8Array | null> {
|
|
978
|
-
const reader = body.getReader()
|
|
979
|
-
const chunks: Uint8Array[] = []
|
|
980
|
-
let total = 0
|
|
981
|
-
for (;;) {
|
|
982
|
-
const { done, value } = await reader.read()
|
|
983
|
-
if (done)
|
|
984
|
-
break
|
|
985
|
-
if (value) {
|
|
986
|
-
total += value.length
|
|
987
|
-
if (total > MAX_BUFFERED_BODY) {
|
|
988
|
-
reader.cancel().catch(() => {})
|
|
989
|
-
return null
|
|
990
|
-
}
|
|
991
|
-
chunks.push(value)
|
|
992
|
-
}
|
|
993
|
-
}
|
|
994
|
-
if (chunks.length === 1)
|
|
995
|
-
return chunks[0]
|
|
996
|
-
const out = new Uint8Array(total)
|
|
997
|
-
let off = 0
|
|
998
|
-
for (const c of chunks) {
|
|
999
|
-
out.set(c, off)
|
|
1000
|
-
off += c.length
|
|
1001
|
-
}
|
|
1002
|
-
return out
|
|
1003
|
-
}
|