@stacksjs/rpx 0.11.18 → 0.11.19

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,1003 @@
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
+ }