tunnelfetch 1.0.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.
Files changed (96) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +617 -0
  3. package/README.zh-CN.md +470 -0
  4. package/package.json +74 -0
  5. package/src/client/cookies.js +429 -0
  6. package/src/client/decode.js +346 -0
  7. package/src/client/redirect.js +249 -0
  8. package/src/client.js +704 -0
  9. package/src/errors.js +181 -0
  10. package/src/http1/chunked.js +289 -0
  11. package/src/http1/index.js +10 -0
  12. package/src/http1/request.js +143 -0
  13. package/src/http1/response.js +493 -0
  14. package/src/http2/connection.js +1170 -0
  15. package/src/http2/constants.js +129 -0
  16. package/src/http2/frames.js +291 -0
  17. package/src/http2/hpack.js +420 -0
  18. package/src/http2/huffman.js +203 -0
  19. package/src/http2/index.js +21 -0
  20. package/src/index.js +46 -0
  21. package/src/pool.js +256 -0
  22. package/src/proxy/direct.js +62 -0
  23. package/src/proxy/http-connect.js +206 -0
  24. package/src/proxy/index.js +197 -0
  25. package/src/proxy/socks5.js +344 -0
  26. package/src/tls/aead.js +263 -0
  27. package/src/tls/connect.js +407 -0
  28. package/src/tls/constants.js +334 -0
  29. package/src/tls/extensions.js +376 -0
  30. package/src/tls/handshake-messages.js +901 -0
  31. package/src/tls/handshake.js +568 -0
  32. package/src/tls/handshake12.js +507 -0
  33. package/src/tls/index.js +44 -0
  34. package/src/tls/keyschedule.js +473 -0
  35. package/src/tls/record.js +872 -0
  36. package/src/tls/tickets.js +145 -0
  37. package/src/tls/transcript.js +101 -0
  38. package/src/tls/wire.js +224 -0
  39. package/src/transport.js +296 -0
  40. package/src/trust/der.js +551 -0
  41. package/src/trust/index.js +375 -0
  42. package/src/trust/name.js +235 -0
  43. package/src/trust/ocsp.js +759 -0
  44. package/src/trust/path.js +595 -0
  45. package/src/trust/roots.js +454 -0
  46. package/src/trust/x509.js +902 -0
  47. package/src/util/bytes.js +470 -0
  48. package/src/util/deadline.js +266 -0
  49. package/src/warmup-fixture.js +85 -0
  50. package/src/warmup.js +243 -0
  51. package/types/client/cookies.d.ts +159 -0
  52. package/types/client/decode.d.ts +54 -0
  53. package/types/client/redirect.d.ts +96 -0
  54. package/types/client.d.ts +323 -0
  55. package/types/errors.d.ts +141 -0
  56. package/types/http1/chunked.d.ts +48 -0
  57. package/types/http1/index.d.ts +3 -0
  58. package/types/http1/request.d.ts +44 -0
  59. package/types/http1/response.d.ts +183 -0
  60. package/types/http2/connection.d.ts +282 -0
  61. package/types/http2/constants.d.ts +95 -0
  62. package/types/http2/frames.d.ts +116 -0
  63. package/types/http2/hpack.d.ts +99 -0
  64. package/types/http2/huffman.d.ts +21 -0
  65. package/types/http2/index.d.ts +5 -0
  66. package/types/index.d.ts +17 -0
  67. package/types/pool.d.ts +135 -0
  68. package/types/proxy/direct.d.ts +26 -0
  69. package/types/proxy/http-connect.d.ts +37 -0
  70. package/types/proxy/index.d.ts +62 -0
  71. package/types/proxy/socks5.d.ts +47 -0
  72. package/types/tls/aead.d.ts +67 -0
  73. package/types/tls/connect.d.ts +280 -0
  74. package/types/tls/constants.d.ts +275 -0
  75. package/types/tls/extensions.d.ts +195 -0
  76. package/types/tls/handshake-messages.d.ts +430 -0
  77. package/types/tls/handshake.d.ts +90 -0
  78. package/types/tls/handshake12.d.ts +35 -0
  79. package/types/tls/index.d.ts +9 -0
  80. package/types/tls/keyschedule.d.ts +272 -0
  81. package/types/tls/record.d.ts +361 -0
  82. package/types/tls/tickets.d.ts +66 -0
  83. package/types/tls/transcript.d.ts +52 -0
  84. package/types/tls/wire.d.ts +106 -0
  85. package/types/transport.d.ts +222 -0
  86. package/types/trust/der.d.ts +239 -0
  87. package/types/trust/index.d.ts +194 -0
  88. package/types/trust/name.d.ts +33 -0
  89. package/types/trust/ocsp.d.ts +138 -0
  90. package/types/trust/path.d.ts +139 -0
  91. package/types/trust/roots.d.ts +36 -0
  92. package/types/trust/x509.d.ts +401 -0
  93. package/types/util/bytes.d.ts +183 -0
  94. package/types/util/deadline.d.ts +133 -0
  95. package/types/warmup-fixture.d.ts +11 -0
  96. package/types/warmup.d.ts +45 -0
package/src/pool.js ADDED
@@ -0,0 +1,256 @@
1
+ // Connection pool for HTTP/1.1 keep-alive.
2
+ //
3
+ // Scope, stated up front because it is unusual: on the target runtime a socket cannot outlive the
4
+ // request that created it — I/O objects do not cross request contexts — so this pool is
5
+ // per-Client and deliberately not a global. The win it exists for is the crawl-shaped workload:
6
+ // one invocation fetching thirty pages from one host pays one TLS handshake instead of thirty,
7
+ // and a userland handshake is the single most expensive thing this package does.
8
+ //
9
+ // The rule that makes reuse safe, and the one that is worth getting paranoid about:
10
+ //
11
+ // A connection returns to the pool ONLY after its response body has been read to the exact end
12
+ // the framing declared. Not "the caller stopped reading", not "it looked finished" — the body's
13
+ // own completion signal. Handing back a socket with unread bytes on it means the next request
14
+ // reads the tail of the previous response and attributes it to the wrong request, which is the
15
+ // worst class of bug an HTTP client can have: it corrupts data silently, under load, and only
16
+ // sometimes.
17
+ //
18
+ // The second rule follows from the first: a body framed by connection close has no determinate
19
+ // end, so such a connection is never eligible, whatever else is true of it.
20
+
21
+ import { TunnelFetchError, codes } from './errors.js';
22
+
23
+ /**
24
+ * Everything that decides whether two requests may share a socket. Mirrors what openConnection
25
+ * consumed to build the connection, because anything that influenced the connection must
26
+ * influence the key.
27
+ * @typedef {object} PoolKeyInput
28
+ * @property {string} scheme the URL protocol, colon included ('http:' | 'https:')
29
+ * @property {string} hostname
30
+ * @property {number} port
31
+ * @property {import('./proxy/index.js').ProxyConfig | null | undefined} proxy
32
+ * @property {import('./trust/index.js').TrustConfig | null | undefined} trust
33
+ * @property {import('./tls/connect.js').TlsOptions | null | undefined} tls
34
+ */
35
+
36
+ /**
37
+ * The trust configuration is part of the key. Two requests to the same origin under different
38
+ * verification policies must not share a connection — the peer was validated under one policy and
39
+ * silently reusing it satisfies the other policy without ever having checked it.
40
+ *
41
+ * @param {PoolKeyInput} input
42
+ * @returns {string}
43
+ */
44
+ export function poolKey({ scheme, hostname, port, proxy, trust, tls }) {
45
+ const proxyPart = proxy
46
+ ? `${proxy.protocol}://${proxy.username ? `${proxy.username}@` : ''}${proxy.hostname}:${proxy.port}`
47
+ : 'direct';
48
+ const trustPart = trustFingerprint(trust);
49
+ const tlsPart = tls && Object.keys(tls).length ? JSON.stringify(sortedEntries(tls)) : '-';
50
+ return `${scheme}//${hostname}:${port}|${proxyPart}|${trustPart}|${tlsPart}`;
51
+ }
52
+
53
+ function trustFingerprint(trust) {
54
+ const mode = trust?.mode ?? 'system';
55
+ if (mode === 'system') return 'system';
56
+ if (mode === 'none') return 'none';
57
+ if (mode === 'custom') {
58
+ // Two different callbacks are two different policies and we cannot compare functions, so a
59
+ // custom policy never shares a connection. Correct, and cheap: custom trust is rare.
60
+ return `custom:${customCounter(trust.verify)}`;
61
+ }
62
+ if (mode === 'pinned') return `pinned:${[...(trust.pins ?? [])].sort().join(',')}`;
63
+ if (mode === 'anchors') return `anchors:${(trust.anchors ?? []).length}:${anchorDigest(trust.anchors)}`;
64
+ return `unknown:${mode}`;
65
+ }
66
+
67
+ const customIds = new WeakMap();
68
+ let customSeq = 0;
69
+ function customCounter(fn) {
70
+ if (typeof fn !== 'function') return 'invalid';
71
+ let id = customIds.get(fn);
72
+ if (id === undefined) {
73
+ id = ++customSeq;
74
+ customIds.set(fn, id);
75
+ }
76
+ return id;
77
+ }
78
+
79
+ const anchorDigests = new WeakMap();
80
+
81
+ /**
82
+ * Order-sensitive digest of anchor material.
83
+ *
84
+ * Every byte is hashed. An earlier version sampled every 97th byte to save work and two anchor
85
+ * sets of equal length that differed only in the middle produced the same key — which would have
86
+ * let a connection validated against one anchor set serve a request that asked for a different
87
+ * one. Cheap-and-approximate is the wrong trade when the output decides whether two security
88
+ * policies are the same policy. The result is memoised per array, so the cost is paid once.
89
+ */
90
+ function anchorDigest(anchors = []) {
91
+ const cached = typeof anchors === 'object' ? anchorDigests.get(anchors) : undefined;
92
+ if (cached !== undefined) return cached;
93
+
94
+ let h = 0x811c9dc5;
95
+ const mix = (byte) => {
96
+ h = Math.imul(h ^ byte, 0x01000193) >>> 0;
97
+ };
98
+ for (const a of anchors) {
99
+ const isText = typeof a === 'string';
100
+ const len = isText ? a.length : (a?.byteLength ?? 0);
101
+ // Length is folded in separately so concatenation cannot forge an equal digest.
102
+ mix(len & 0xff);
103
+ mix((len >>> 8) & 0xff);
104
+ mix((len >>> 16) & 0xff);
105
+ if (isText) for (let i = 0; i < len; i++) mix(a.charCodeAt(i) & 0xff);
106
+ else for (let i = 0; i < len; i++) mix(a[i]);
107
+ mix(0xff); // separator, so ['ab','c'] and ['a','bc'] differ
108
+ }
109
+ const out = h.toString(16);
110
+ if (typeof anchors === 'object') anchorDigests.set(anchors, out);
111
+ return out;
112
+ }
113
+
114
+ const sortedEntries = (o) => Object.entries(o).sort(([a], [b]) => (a < b ? -1 : 1));
115
+
116
+ /**
117
+ * What the pool stores: the connection object openConnection resolves to. The pool itself only
118
+ * ever calls `close?.()`, but naming the real type keeps take() useful to a caller.
119
+ * @typedef {import('./transport.js').Connection} PooledConnection
120
+ */
121
+
122
+ /**
123
+ * @typedef {object} PoolOptions
124
+ * @property {number} [maxPerKey] idle connections kept per key, default 6
125
+ * @property {number} [maxTotal] idle connections kept across all keys, default 24
126
+ */
127
+
128
+ /**
129
+ * Running counters, never reset. `discarded` includes connections refused at release time;
130
+ * `evicted` counts victims pushed out by a newer release under a full pool.
131
+ * @typedef {object} PoolStats
132
+ * @property {number} hits
133
+ * @property {number} misses
134
+ * @property {number} released
135
+ * @property {number} discarded
136
+ * @property {number} evicted
137
+ */
138
+
139
+ export class ConnectionPool {
140
+ /**
141
+ * @param {PoolOptions} [opts]
142
+ */
143
+ constructor({ maxPerKey = 6, maxTotal = 24 } = {}) {
144
+ /** @type {Map<string, Array<{conn: PooledConnection}>>} */
145
+ this._idle = new Map();
146
+ this._total = 0;
147
+ this._maxPerKey = maxPerKey;
148
+ this._maxTotal = maxTotal;
149
+ this._closed = false;
150
+ /** @type {PoolStats} */
151
+ this.stats = { hits: 0, misses: 0, released: 0, discarded: 0, evicted: 0 };
152
+ }
153
+
154
+ get idleCount() {
155
+ return this._total;
156
+ }
157
+
158
+ /**
159
+ * Take an idle connection for `key`, or null. Most-recently-used first: it is likeliest live.
160
+ * @param {string} key
161
+ * @returns {PooledConnection | null}
162
+ */
163
+ take(key) {
164
+ this._assertOpen();
165
+ const list = this._idle.get(key);
166
+ if (!list || list.length === 0) {
167
+ this.stats.misses++;
168
+ return null;
169
+ }
170
+ const entry = list.pop();
171
+ if (list.length === 0) this._idle.delete(key);
172
+ this._total--;
173
+ this.stats.hits++;
174
+ return entry.conn;
175
+ }
176
+
177
+ /**
178
+ * Offer a connection back. Callers must have proven the body reached its declared end; this
179
+ * method cannot verify that and deliberately does not pretend to — `eligible` is the caller's
180
+ * assertion, and the one place it is computed is the HTTP framing layer.
181
+ * Idle entries carry no age, deliberately. Ageing them out would only narrow the window in
182
+ * which a peer reaps a socket we still believe in, never close it — the peer can hang up at any
183
+ * instant, including the one after the check. What actually makes reuse safe is the recovery in
184
+ * sendAndReceive(): a reused connection that ends without producing one response byte is proof
185
+ * the request was never seen, and it is re-sent on a fresh connection. An age field would look
186
+ * like a second line of defence while being neither necessary nor sufficient.
187
+ *
188
+ * @param {string} key
189
+ * @param {PooledConnection} conn
190
+ * @param {boolean} eligible
191
+ * @returns {boolean} whether the connection was retained
192
+ */
193
+ release(key, conn, eligible) {
194
+ if (this._closed || !eligible) {
195
+ this.stats.discarded++;
196
+ void discard(conn);
197
+ return false;
198
+ }
199
+ const list = this._idle.get(key) ?? [];
200
+ if (list.length >= this._maxPerKey || this._total >= this._maxTotal) {
201
+ // Drop the oldest rather than refusing the newest: the newest is the one just proven alive.
202
+ const victim = list.shift() ?? null;
203
+ if (victim) {
204
+ this._total--;
205
+ this.stats.evicted++;
206
+ void discard(victim.conn);
207
+ } else {
208
+ this.stats.discarded++;
209
+ void discard(conn);
210
+ return false;
211
+ }
212
+ }
213
+ list.push({ conn });
214
+ this._idle.set(key, list);
215
+ this._total++;
216
+ this.stats.released++;
217
+ return true;
218
+ }
219
+
220
+ /**
221
+ * Close and forget one connection that must not be reused.
222
+ * @param {PooledConnection} conn
223
+ * @returns {Promise<void>}
224
+ */
225
+ discard(conn) {
226
+ this.stats.discarded++;
227
+ return discard(conn);
228
+ }
229
+
230
+ /**
231
+ * Close everything. A Client that is done must call this or sockets leak for the isolate.
232
+ * @returns {Promise<void>}
233
+ */
234
+ async closeAll() {
235
+ this._closed = true;
236
+ const all = [];
237
+ for (const list of this._idle.values()) for (const e of list) all.push(e.conn);
238
+ this._idle.clear();
239
+ this._total = 0;
240
+ await Promise.all(all.map(discard));
241
+ }
242
+
243
+ _assertOpen() {
244
+ if (this._closed) {
245
+ throw new TunnelFetchError(codes.POOL_CLOSED, 'the connection pool has been closed');
246
+ }
247
+ }
248
+ }
249
+
250
+ async function discard(conn) {
251
+ try {
252
+ await conn?.close?.();
253
+ } catch {
254
+ /* a connection being discarded is already suspect; its close failing changes nothing */
255
+ }
256
+ }
@@ -0,0 +1,62 @@
1
+ // No proxy: the socket is the tunnel.
2
+ //
3
+ // Worth stating plainly, because it shapes what this package is for: on the target runtime a
4
+ // direct connection cannot reach a large fraction of the public web at all — the platform refuses
5
+ // outbound TCP to its own address ranges, and a great many sites sit behind them. Direct mode is
6
+ // therefore useful for hosts that are demonstrably not behind the platform, and for exercising the
7
+ // TLS stack in tests; for everything else the proxy is not an optimisation, it is the only route.
8
+
9
+ import { ProxyError, codes } from '../errors.js';
10
+
11
+ /**
12
+ * @typedef {object} DirectOptions
13
+ * @property {{ hostname: string, port: number }} target
14
+ * @property {import('./index.js').ConnectFn} connect injected socket factory
15
+ * @property {AbortSignal} [signal]
16
+ */
17
+
18
+ /**
19
+ * Dial the target itself. Resolves with the raw socket duplex; a refused or failed dial throws
20
+ * ProxyError (PROXY_UNREACHABLE) quoting the runtime's own message, which is the best
21
+ * diagnostic a caller will get.
22
+ *
23
+ * @param {DirectOptions} args
24
+ * @returns {Promise<import('./index.js').Duplex>}
25
+ */
26
+ export async function openDirect({ target, connect, signal }) {
27
+ signal?.throwIfAborted?.();
28
+ let socket;
29
+ try {
30
+ socket = connect({ hostname: target.hostname, port: target.port }, {
31
+ secureTransport: 'off',
32
+ allowHalfOpen: false,
33
+ });
34
+ } catch (cause) {
35
+ throw new ProxyError(
36
+ codes.PROXY_UNREACHABLE,
37
+ `could not open a socket to ${target.hostname}:${target.port}: ${cause?.message ?? cause}`,
38
+ { target: `${target.hostname}:${target.port}` },
39
+ );
40
+ }
41
+ if (socket.opened) {
42
+ try {
43
+ await socket.opened;
44
+ } catch (cause) {
45
+ // A failed dial still leaves a socket object behind; not closing it leaks one per attempt,
46
+ // which for a crawl retrying against an unreachable host adds up inside a single isolate.
47
+ try {
48
+ await socket.close?.();
49
+ } catch {
50
+ /* it never opened; close failing tells us nothing new */
51
+ }
52
+ // The runtime's refusal messages are the most useful diagnostic a caller will get here, so
53
+ // they are quoted rather than replaced.
54
+ throw new ProxyError(
55
+ codes.PROXY_UNREACHABLE,
56
+ `connection to ${target.hostname}:${target.port} failed: ${cause?.message ?? cause}`,
57
+ { target: `${target.hostname}:${target.port}` },
58
+ );
59
+ }
60
+ }
61
+ return socket;
62
+ }
@@ -0,0 +1,206 @@
1
+ // HTTP CONNECT tunnelling (RFC 9110 s9.3.6; wire syntax RFC 9112).
2
+ //
3
+ // Two details here are not theory — they were measured against a live proxy while building this:
4
+ //
5
+ // * The reply may be `HTTP/1.0 200 OK` even though we sent an HTTP/1.1 request. Matching on the
6
+ // version or the reason phrase would break against real deployments; only the status code is
7
+ // meaningful, and any 2xx opens the tunnel.
8
+ // * The reply header block can be as short as 19 bytes and the server may put tunnel payload in
9
+ // the SAME read as the terminating CRLFCRLF. Those bytes belong to the peer, not to us. Losing
10
+ // them silently truncates the first TLS record, which surfaces much later as an inexplicable
11
+ // handshake failure, so the reader that consumed the header block is handed on intact rather
12
+ // than being discarded.
13
+
14
+ import { ProxyError, LimitError, codes } from '../errors.js';
15
+ import { ByteReader, ByteWriter, latin1, utf8 } from '../util/bytes.js';
16
+
17
+ const CRLFCRLF = utf8('\r\n\r\n');
18
+ const MAX_REPLY_HEADER = 32 * 1024;
19
+
20
+ /**
21
+ * The tunnel a proxy module hands back: the byte duplex plus the underlying socket, kept so a
22
+ * caller that must tear down the transport can reach past the wrapping streams.
23
+ * @typedef {import('./index.js').Duplex & { socket: import('./index.js').Duplex }} ProxyTunnel
24
+ */
25
+
26
+ /**
27
+ * @typedef {object} HttpConnectOptions
28
+ * @property {import('./index.js').ProxyConfig} proxy protocol 'http' or 'https'
29
+ * @property {{ hostname: string, port: number }} target
30
+ * @property {import('./index.js').ConnectFn} connect injected socket factory
31
+ * @property {AbortSignal} [signal]
32
+ * @property {{ maxProxyReplyBytes?: number }} [limits] CONNECT reply head cap, default 32768
33
+ */
34
+
35
+ /** RFC 7617: credentials are UTF-8 before base64, and btoa only accepts code units below 256. */
36
+ function basicCredentials(username, password) {
37
+ const raw = utf8(`${username}:${password ?? ''}`);
38
+ return btoa(latin1(raw));
39
+ }
40
+
41
+ /** IPv6 literals must be bracketed in an authority, or the port cannot be told from the address. */
42
+ function authority(hostname, port) {
43
+ return hostname.includes(':') ? `[${hostname}]:${port}` : `${hostname}:${port}`;
44
+ }
45
+
46
+ /**
47
+ * Establish a CONNECT tunnel through an http/https proxy. Resolves with the tunnel duplex;
48
+ * every refusal (non-2xx, 407 with or without credentials, malformed reply) throws a
49
+ * ProxyError naming what the proxy answered.
50
+ *
51
+ * @param {HttpConnectOptions} args
52
+ * @returns {Promise<ProxyTunnel>}
53
+ */
54
+ export async function openHttpConnect({ proxy, target, connect, signal, limits = {} }) {
55
+ signal?.throwIfAborted?.();
56
+ const overTls = proxy.protocol === 'https';
57
+ const where = `${proxy.hostname}:${proxy.port}`;
58
+
59
+ let socket;
60
+ try {
61
+ socket = connect(
62
+ { hostname: proxy.hostname, port: proxy.port },
63
+ // For an https proxy the runtime does TLS to the proxy itself. That is the one hop where
64
+ // the platform's certificate check asks the right question: the identity it verifies is the
65
+ // hostname handed to connect(), which here IS the proxy. (It follows that an https proxy
66
+ // addressed by bare IP will fail that check, since public certificates carry no IP SAN.)
67
+ { secureTransport: overTls ? 'on' : 'starttls', allowHalfOpen: false },
68
+ );
69
+ } catch (cause) {
70
+ throw new ProxyError(
71
+ codes.PROXY_UNREACHABLE,
72
+ `could not open a socket to proxy ${where}: ${cause?.message ?? cause}`,
73
+ { proxy: where },
74
+ );
75
+ }
76
+
77
+ let reader;
78
+ let writer;
79
+ try {
80
+ if (socket.opened) await socket.opened;
81
+
82
+ writer = new ByteWriter(socket.writable);
83
+ await writer.write(utf8(buildConnectRequest(proxy, target)));
84
+ writer.releaseLock();
85
+
86
+ reader = new ByteReader(socket.readable);
87
+ const max = limits.maxProxyReplyBytes ?? MAX_REPLY_HEADER;
88
+ let block;
89
+ try {
90
+ block = await reader.readUntil(CRLFCRLF, max, 'proxy CONNECT reply');
91
+ } catch (cause) {
92
+ if (cause instanceof LimitError) throw cause;
93
+ throw new ProxyError(
94
+ codes.PROXY_PROTOCOL,
95
+ `proxy ${where} closed or misbehaved before completing its CONNECT reply: ${cause?.message ?? cause}`,
96
+ { proxy: where },
97
+ );
98
+ }
99
+ const reply = parseReply(latin1(block), where);
100
+
101
+ if (reply.status < 200 || reply.status > 299) {
102
+ throw replyError(reply, proxy, target, where);
103
+ }
104
+ // Anything the proxy sent after the blank line is already tunnel payload. It stays buffered in
105
+ // `reader`, which becomes the tunnel's read side, so it cannot be dropped.
106
+ return tunnelFrom(socket, reader);
107
+ } catch (err) {
108
+ try {
109
+ writer?.releaseLock();
110
+ await reader?.cancel(err);
111
+ await socket.close?.();
112
+ } catch {
113
+ /* the socket may already be unusable; the original error is what matters */
114
+ }
115
+ throw err;
116
+ }
117
+ }
118
+
119
+ function buildConnectRequest(proxy, target) {
120
+ const host = authority(target.hostname, target.port);
121
+ const lines = [`CONNECT ${host} HTTP/1.1`, `Host: ${host}`];
122
+ if (proxy.username) {
123
+ lines.push(`Proxy-Authorization: Basic ${basicCredentials(proxy.username, proxy.password)}`);
124
+ }
125
+ // Some proxies still key off the pre-standard hop header; sending it is harmless and avoids a
126
+ // class of proxy that closes the tunnel after one request without it.
127
+ lines.push('Proxy-Connection: keep-alive');
128
+ return `${lines.join('\r\n')}\r\n\r\n`;
129
+ }
130
+
131
+ function parseReply(text, where) {
132
+ const [statusLine, ...headerLines] = text.split('\r\n');
133
+ const m = /^HTTP\/(\d)\.(\d) (\d{3})(?: (.*))?$/.exec(statusLine ?? '');
134
+ if (!m) {
135
+ throw new ProxyError(
136
+ codes.PROXY_PROTOCOL,
137
+ `proxy ${where} did not answer CONNECT with an HTTP status line (got ${JSON.stringify(
138
+ (statusLine ?? '').slice(0, 80),
139
+ )})`,
140
+ { proxy: where, statusLine },
141
+ );
142
+ }
143
+ const headers = new Map();
144
+ for (const line of headerLines) {
145
+ if (line === '') break;
146
+ const i = line.indexOf(':');
147
+ if (i <= 0) continue; // a malformed header in an otherwise valid reply is not worth failing on
148
+ headers.set(line.slice(0, i).toLowerCase().trim(), line.slice(i + 1).trim());
149
+ }
150
+ return {
151
+ httpVersion: `${m[1]}.${m[2]}`,
152
+ status: Number(m[3]),
153
+ reason: m[4] ?? '',
154
+ headers,
155
+ statusLine,
156
+ };
157
+ }
158
+
159
+ function replyError(reply, proxy, target, where) {
160
+ const to = authority(target.hostname, target.port);
161
+ if (reply.status === 407) {
162
+ const challenge = reply.headers.get('proxy-authenticate') ?? '(none sent)';
163
+ // Naming the scheme the proxy asked for is the whole value of this error: "auth failed" does
164
+ // not tell anyone whether they typed the password wrong or the proxy wants Digest/NTLM.
165
+ return proxy.username
166
+ ? new ProxyError(
167
+ codes.PROXY_AUTH_FAILED,
168
+ `proxy ${where} rejected the supplied credentials for user "${proxy.username}" ` +
169
+ `(407 ${reply.reason}); it offers: ${challenge}`,
170
+ { proxy: where, challenge, status: 407 },
171
+ )
172
+ : new ProxyError(
173
+ codes.PROXY_AUTH_REQUIRED,
174
+ `proxy ${where} requires authentication (407 ${reply.reason}); it offers: ${challenge}`,
175
+ { proxy: where, challenge, status: 407 },
176
+ );
177
+ }
178
+ return new ProxyError(
179
+ codes.PROXY_CONNECT_REFUSED,
180
+ `proxy ${where} refused CONNECT to ${to}: ${reply.status} ${reply.reason}`.trimEnd(),
181
+ { proxy: where, target: to, status: reply.status, reason: reply.reason },
182
+ );
183
+ }
184
+
185
+ /**
186
+ * Wrap the still-buffered reader as the tunnel's readable so bytes that arrived alongside the
187
+ * CONNECT reply are delivered first, in order.
188
+ */
189
+ function tunnelFrom(socket, reader) {
190
+ return {
191
+ readable: new ReadableStream({
192
+ async pull(controller) {
193
+ const chunk = await reader.readSome();
194
+ if (chunk === null) controller.close();
195
+ else controller.enqueue(chunk);
196
+ },
197
+ cancel(reason) {
198
+ return reader.cancel(reason);
199
+ },
200
+ }),
201
+ writable: socket.writable,
202
+ opened: socket.opened,
203
+ close: () => socket.close?.(),
204
+ socket,
205
+ };
206
+ }