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
@@ -0,0 +1,296 @@
1
+ // Transport assembly: URL in, byte duplex out.
2
+ //
3
+ // This is where the proxy tunnel and the userland TLS stack are stacked, and where the decision
4
+ // about who verifies the peer is made. That decision is the reason this package exists, so it is
5
+ // stated once, here, rather than being implied by the call graph:
6
+ //
7
+ // The platform's socket API will happily complete a TLS handshake for us, but the identity it
8
+ // checks is the hostname handed to connect(). Inside a proxy tunnel that hostname is the PROXY,
9
+ // so the platform verifies the wrong party — and it exposes no peer certificate, so we cannot
10
+ // check the right one afterwards either. Every proxied https connection therefore runs the
11
+ // userland handshake, with the trust decision made by this package.
12
+ //
13
+ // The socket factory is injected. Nothing in src/ imports a runtime-specific module, which keeps
14
+ // every layer testable over an in-memory pipe and keeps the package portable.
15
+
16
+ import { ConfigError, codes } from './errors.js';
17
+ import { openTunnel, parseProxy } from './proxy/index.js';
18
+ import { connectTls } from './tls/connect.js';
19
+ import { verifyChain } from './trust/index.js';
20
+ import { DeadlineController } from './util/deadline.js';
21
+
22
+ const DEFAULT_PORT = { 'http:': 80, 'https:': 443 };
23
+
24
+ /**
25
+ * A request URL reduced to what the transport dials.
26
+ * @typedef {object} TransportTarget
27
+ * @property {URL} url
28
+ * @property {string} hostname IPv6 unbracketed, as the socket API and SOCKS5 want it
29
+ * @property {number} port explicit port, or the scheme default
30
+ * @property {boolean} secure whether the scheme is https:
31
+ */
32
+
33
+ /**
34
+ * Split a request URL into what the transport needs. Throws ConfigError on any scheme other
35
+ * than http: and https:.
36
+ * @param {string | URL} input
37
+ * @returns {TransportTarget}
38
+ */
39
+ export function targetFromUrl(input) {
40
+ const url = input instanceof URL ? input : new URL(String(input));
41
+ const port = url.port ? Number(url.port) : DEFAULT_PORT[url.protocol];
42
+ if (!port) {
43
+ throw new ConfigError(
44
+ codes.CONFIG_INVALID,
45
+ `unsupported URL scheme "${url.protocol}"; only http: and https: are supported`,
46
+ { scheme: url.protocol },
47
+ );
48
+ }
49
+ // URL keeps IPv6 hosts bracketed; the socket API and SOCKS5 both want the bare address.
50
+ const hostname = url.hostname.replace(/^\[|\]$/g, '');
51
+ return { url, hostname, port, secure: url.protocol === 'https:' };
52
+ }
53
+
54
+ /**
55
+ * What a Response's `tunnelfetch` detail reports about the connection, before the HTTP layer
56
+ * adds the per-response httpVersion and framing.
57
+ * @typedef {object} ConnectionInfo
58
+ * @property {string} url
59
+ * @property {boolean} proxied
60
+ * @property {string | null} proxy the proxy actually used, credentials omitted
61
+ * @property {import('./tls/connect.js').TlsSessionInfo | null} tls null for cleartext
62
+ */
63
+
64
+ /**
65
+ * A live connection carrying application bytes: the duplex, its provenance, and the deadline
66
+ * controller that governs it. This is what the pool stores and what sendAndReceive consumes.
67
+ * @typedef {object} Connection
68
+ * @property {ReadableStream<Uint8Array>} readable
69
+ * @property {WritableStream<Uint8Array>} writable
70
+ * @property {() => Promise<void> | void} close
71
+ * @property {ConnectionInfo} info
72
+ * @property {import('./trust/index.js').ParsedCertificate | null} [peerCertificate] TLS only;
73
+ * null when trust mode 'none' accepted a leaf it could not parse
74
+ * @property {DeadlineController} deadlines
75
+ * @property {boolean} ownsDeadlines whether this call created the controller (and must dispose
76
+ * it) rather than borrowing the caller's
77
+ */
78
+
79
+ /**
80
+ * @typedef {object} OpenConnectionOptions
81
+ * @property {string | URL} url
82
+ * @property {import('./proxy/index.js').ConnectFn} connect injected socket factory
83
+ * @property {string | import('./proxy/index.js').ProxyConfig | null} [proxy]
84
+ * @property {import('./trust/index.js').TrustConfig} [trust] the `verify=`-style knob, passed
85
+ * through to the trust layer
86
+ * @property {DeadlineController} [deadlines] borrow the request's controller; omitting it makes
87
+ * this call own (and dispose) a fresh one
88
+ * @property {import('./tls/connect.js').TlsOptions} [tls] handshake options
89
+ * @property {string[]} [alpn] the ALPN protocol list to offer, newest/most-preferred first.
90
+ * Kept separate from `tls` so offering `h2` does not read as a user-supplied TLS option (which
91
+ * would disable native-fetch delegation and enter the pool key). `tls.alpn` still wins if set.
92
+ * @property {{ offer?: import('./tls/connect.js').ResumptionOffer | null,
93
+ * onTicket?: (t: import('./tls/connect.js').CapturedTicket) => void }} [resumption]
94
+ * session-resumption wiring, injected per connection by the Client. Separate from `tls` for
95
+ * the same reason `alpn` is: it must neither disable native-fetch delegation nor enter the
96
+ * pool key — it is not caller configuration, it is state the Client derived FROM the pool key.
97
+ * @property {import('./tls/connect.js').TlsDeps} [deps] injectable randomness/keygen for
98
+ * reproducible handshakes
99
+ * @property {AbortSignal} [signal]
100
+ * @property {{ maxProxyReplyBytes?: number }} [limits] cap on the proxy CONNECT reply head
101
+ * @property {number} [now] epoch ms override for certificate validity
102
+ */
103
+
104
+ /**
105
+ * Open a connection carrying application bytes for `url`. Throws (ProxyError, TlsError,
106
+ * CertificateError, TimeoutError, ConfigError) rather than resolving with a failure value.
107
+ *
108
+ * @param {OpenConnectionOptions} args
109
+ * @returns {Promise<Connection>}
110
+ */
111
+ export async function openConnection({
112
+ url,
113
+ connect,
114
+ proxy = null,
115
+ trust = { mode: 'system' },
116
+ deadlines,
117
+ tls = {},
118
+ alpn,
119
+ resumption,
120
+ deps = {},
121
+ signal,
122
+ limits = {},
123
+ now,
124
+ }) {
125
+ const target = targetFromUrl(url);
126
+ const proxyConfig = parseProxy(proxy);
127
+ const owns = !deadlines;
128
+ const dl = deadlines ?? new DeadlineController({}, { signal });
129
+
130
+ try {
131
+ dl.beginPhase('connect');
132
+ const tunnel = await dl.race(
133
+ openTunnel({
134
+ proxy: proxyConfig,
135
+ target: { hostname: target.hostname, port: target.port },
136
+ connect,
137
+ signal: dl.signal,
138
+ limits,
139
+ }),
140
+ );
141
+ dl.endPhase();
142
+
143
+ if (!target.secure) {
144
+ return {
145
+ readable: tunnel.readable,
146
+ writable: tunnel.writable,
147
+ close: () => tunnel.close?.(),
148
+ info: {
149
+ url: target.url.href,
150
+ proxied: tunnel.proxied,
151
+ proxy: proxyConfig ? `${proxyConfig.protocol}://${proxyConfig.hostname}:${proxyConfig.port}` : null,
152
+ tls: null,
153
+ },
154
+ deadlines: dl,
155
+ ownsDeadlines: owns,
156
+ };
157
+ }
158
+
159
+ dl.beginPhase('handshake');
160
+ let session;
161
+ try {
162
+ session = await dl.race(
163
+ // connectTls offers TLS 1.3 and 1.2 in one ClientHello and follows the server's pick on
164
+ // this same connection. There is no reconnect-and-retry-lower path anywhere: a failure
165
+ // at any version is a failure, because a retry loop is exactly the downgrade lever the
166
+ // RFC 8446 protections exist to deny an attacker. `tls.versions` narrows the offer.
167
+ connectTls({
168
+ transport: tunnel,
169
+ hostname: target.hostname,
170
+ // Bound to this request's trust configuration, so a caller who asked for pinning gets
171
+ // pinning on this connection and nothing else can quietly substitute a laxer policy.
172
+ // The driver's third argument carries the stapled OCSP response when the server sent
173
+ // one; it is judged inside verifyChain under this same trust config.
174
+ verifyPeer: (chain, hostname, details) =>
175
+ verifyChain({ chain, hostname, trust, now, ocspResponse: details?.ocspResponse ?? null }),
176
+ // `alpn` is offered here rather than folded into `tls` so that the ALPN offer never
177
+ // counts as a user TLS option; `tls.alpn` still overrides it when explicitly set.
178
+ // Resumption wiring joins the options the same way, and only when present, so a
179
+ // Client without a ticket produces byte-identical options to before the feature.
180
+ options: assembleTlsOptions(tls, alpn, resumption),
181
+ deps,
182
+ }),
183
+ );
184
+ } catch (err) {
185
+ await safeClose(tunnel);
186
+ throw err;
187
+ }
188
+ dl.endPhase();
189
+
190
+ return {
191
+ readable: session.readable,
192
+ writable: session.writable,
193
+ close: async () => {
194
+ try {
195
+ await session.close();
196
+ } finally {
197
+ await safeClose(tunnel);
198
+ }
199
+ },
200
+ info: {
201
+ url: target.url.href,
202
+ proxied: tunnel.proxied,
203
+ proxy: proxyConfig ? `${proxyConfig.protocol}://${proxyConfig.hostname}:${proxyConfig.port}` : null,
204
+ tls: session.info,
205
+ },
206
+ peerCertificate: session.peer,
207
+ deadlines: dl,
208
+ ownsDeadlines: owns,
209
+ };
210
+ } catch (err) {
211
+ if (owns) dl.dispose();
212
+ throw err;
213
+ }
214
+ }
215
+
216
+ async function safeClose(duplex) {
217
+ try {
218
+ await duplex?.close?.();
219
+ } catch {
220
+ /* already gone */
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Fold the per-connection injections (ALPN offer, resumption wiring) into the caller's TLS
226
+ * options without ever mutating them. The caller's object is what the pool key and the
227
+ * delegation decision were computed from; growing keys on it here would silently change both.
228
+ * @param {import('./tls/connect.js').TlsOptions} tls
229
+ * @param {string[] | undefined} alpn
230
+ * @param {{ offer?: object | null, onTicket?: Function } | undefined} resumption
231
+ * @returns {import('./tls/connect.js').TlsOptions}
232
+ */
233
+ function assembleTlsOptions(tls, alpn, resumption) {
234
+ const offer = resumption?.offer ?? null;
235
+ const onTicket = resumption?.onTicket ?? null;
236
+ if (!offer && !onTicket && !(alpn && !tls.alpn)) return tls;
237
+ const out = { ...tls };
238
+ if (alpn && !tls.alpn) out.alpn = alpn;
239
+ if (offer) out.psk = offer;
240
+ if (onTicket) out.onSessionTicket = onTicket;
241
+ return out;
242
+ }
243
+
244
+ /**
245
+ * The delegation decision, with the disqualifying reason spelled out so a caller's error can
246
+ * quote it. Discriminated on `ok` so `reason` is a string exactly when there is one.
247
+ * @typedef {{ ok: true, reason: null } | { ok: false, reason: string }} NativeFetchVerdict
248
+ */
249
+
250
+ /**
251
+ * @typedef {object} NativeFetchQuery
252
+ * @property {import('./proxy/index.js').ProxyConfig | string | null} [proxy]
253
+ * @property {import('./trust/index.js').TrustConfig | null} [trust]
254
+ * @property {import('./tls/connect.js').TlsOptions | null} [tls]
255
+ * @property {boolean} [forceTunnel]
256
+ */
257
+
258
+ /**
259
+ * Can the platform's own fetch() satisfy this request in full?
260
+ *
261
+ * Delegating to the native implementation when it can is strictly better — it is faster, does not
262
+ * burn metered CPU, speaks HTTP/2 and /3, and reaches origins our raw sockets are forbidden from
263
+ * dialling. But delegation must be decided on CAPABILITY, not merely on "is there a proxy":
264
+ * quietly satisfying a request that asked for a pinned certificate by handing it to an
265
+ * implementation using a different trust store would answer a security question the caller did
266
+ * not ask. So anything the native path cannot honour disqualifies it.
267
+ *
268
+ * @param {NativeFetchQuery} query
269
+ * @returns {NativeFetchVerdict}
270
+ */
271
+ export function nativeFetchCanServe({ proxy, trust, tls, forceTunnel }) {
272
+ if (forceTunnel) return { ok: false, reason: 'forceTunnel was requested' };
273
+ if (proxy) return { ok: false, reason: 'a proxy was configured and fetch() has no proxy option' };
274
+ const mode = trust?.mode ?? 'system';
275
+ if (mode !== 'system') {
276
+ return {
277
+ ok: false,
278
+ reason: `trust mode "${mode}" cannot be expressed to the platform's fetch(), which exposes ` +
279
+ 'no certificate hooks',
280
+ };
281
+ }
282
+ if (trust?.revocation !== undefined && trust.revocation !== 'staple') {
283
+ // 'require-staple' is a strictness the platform's fetch cannot promise (it exposes no OCSP
284
+ // hooks), and an INVALID value must reach the tunnel path where the trust layer refuses it
285
+ // loudly — delegation would swallow the config error whole. Only the default posture (spelled
286
+ // or not) is delegatable, matching how an unspelled default has always been.
287
+ return {
288
+ ok: false,
289
+ reason: `trust.revocation "${trust.revocation}" cannot be honoured by the platform's fetch()`,
290
+ };
291
+ }
292
+ if (tls && Object.keys(tls).length > 0) {
293
+ return { ok: false, reason: 'TLS options were supplied that fetch() cannot honour' };
294
+ }
295
+ return { ok: true, reason: null };
296
+ }