pi-unsloth-webtools 0.7.3 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -81,6 +81,11 @@ Port of Studio's `_fetch_page_text` / `_fetch_url_raw` pipeline:
81
81
  - Transient DNS failures (EAI_AGAIN, resolver timeouts, connection refusals) are retried once
82
82
  with a short backoff inside the same deadline, so a brief resolver hiccup does not fail a
83
83
  fetch; the deadline abort cuts a retry short when no budget remains.
84
+ - Proxy environment variables are honored when they name a SOCKS5 proxy: `HTTPS_PROXY` /
85
+ `HTTP_PROXY` / `ALL_PROXY` (with `NO_PROXY` exclusions) tunnel the pinned connection, so a
86
+ Tor-mode agent routes `web_fetch` and `web_search` url mode through its exit. `socks5h` is
87
+ treated like `socks5`: the host is resolved locally for the guard and the pinned IP is what the
88
+ proxy connects to. Other proxy schemes are ignored (direct connection).
84
89
  - GitHub repo root pages are rewritten to the unauthenticated README API
85
90
  (`Accept: application/vnd.github.raw+json`), falling back to the raw README URL
86
91
  (`raw.githubusercontent.com`, no API rate limit) and then to the HTML page on failure.
@@ -174,9 +179,11 @@ that need JavaScript to render:
174
179
  generic engine failures. The timeout budget bounds the entire sweep: per-engine
175
180
  timeouts shrink as the budget is consumed, so the reported timeout matches the
176
181
  worst-case wall time.
177
- - Proxies: Studio routes through environment proxies; this port's direct fetch always connects
178
- directly with DNS pinning (deliberately out of scope). The search and `web_render` paths use the
179
- process-wide `fetch`, so an agent-level proxy dispatcher does apply to them — see
182
+ - Proxies: Studio routes through environment proxies; this port resolves and pins the target IP and
183
+ tunnels that connection through `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` when the proxy is a
184
+ SOCKS5 proxy (`NO_PROXY` exclusions respected; DNS stays local for the guard). Other proxy
185
+ schemes fall back to a direct connection. The search and `web_render` paths use the process-wide
186
+ `fetch`, so an agent-level proxy dispatcher applies there too — see
180
187
  [Companion: rotating exit IPs](#companion-rotating-exit-ips).
181
188
  - Dedup and titles: the aggregator keys on canonicalized hrefs (`utm_*`/tracking parameters
182
189
  and fragments stripped, then the URL re-serialized); fetched HTML pages are prefixed with
@@ -218,8 +225,10 @@ the current Tor exit, and Jina rate-limits keyless Reader requests per outgoing
218
225
  pi install npm:pi-unsloth-webtools npm:pi-tor-proxy
219
226
  ```
220
227
 
221
- `web_fetch` is not routed: it connects directly through `node:http`/`node:https` with a pinned,
222
- validated IP and ignores the proxy variables (see the proxy note under Known differences).
228
+ `web_fetch` and `web_search` url mode also route: they resolve and pin the target IP, then tunnel
229
+ the connection through the SOCKS5 proxy named by `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` (with
230
+ `NO_PROXY` exclusions, so localhost and local files stay direct). DNS is still resolved locally for
231
+ the SSRF guard, and the proxy connects to that pinned IP.
223
232
  Caveats: Tor mode supports Linux and macOS only, adds latency, and many search engines and
224
233
  Cloudflare-fronted services challenge or block Tor exits, so cycling helps with per-IP limits but
225
234
  is not a guarantee.
@@ -274,7 +283,7 @@ On non-Windows platforms the directory honors `XDG_CONFIG_HOME` when set (fallin
274
283
 
275
284
  Tool params always win over file defaults. Search dedup also strips default ports, so `https://example.com:443/a` and `https://example.com/a` collapse.
276
285
 
277
- Environment overrides: `PI_UNSLOTH_CACHE_DIR` changes the fetch cache directory, `PI_UNSLOTH_WEBTOOLS_STATS` opts into append-only sweep stats JSONL, `PI_CODING_AGENT_DIR` / `PI_AGENT_DIR` change the global settings directory, and `JINA_API_KEY` supplies the `web_render` key when no settings key is set. Cache entries live 1 hour and stale copies are served only after a network failure.
286
+ Environment overrides: `PI_UNSLOTH_CACHE_DIR` changes the fetch cache directory, `PI_UNSLOTH_WEBTOOLS_STATS` opts into append-only sweep stats JSONL, `PI_CODING_AGENT_DIR` / `PI_AGENT_DIR` change the global settings directory, and `JINA_API_KEY` supplies the `web_render` key when no settings key is set. Cache entries live 1 hour and stale copies are served only after a network failure. SOCKS5 proxies named by `HTTPS_PROXY`, `HTTP_PROXY`, or `ALL_PROXY` are honored on every fetch (`NO_PROXY` exclusions apply).
278
287
 
279
288
  ## Troubleshooting
280
289
 
@@ -292,6 +301,7 @@ Match on the exact prefix. Do not retry blocked hosts with spelling tricks.
292
301
  | Local file blocked | `Blocked: the URL has an invalid hostname or port.` for paths | Local files are disabled: remove `allowLocalFiles: false` to read `file://`, absolute, `~/`, or `./` paths. |
293
302
  | File read failed | `Failed to read file: ...` | Check the path exists and is a regular file. |
294
303
  | HTTP failure | `Failed to fetch URL: HTTP ...` | Fix the URL. A 404 automatically tries a Wayback snapshot; a 403 retries through `web_render` when enabled. |
304
+ | Proxy failure | `Failed to fetch URL: SOCKS5 proxy ...` | The SOCKS5 proxy refused or failed (for example Tor is stopping). Check the proxy, or unset the proxy variables for a direct fetch. |
295
305
  | Non-text / binary | `(non-text content:` / `(binary content,` | Not readable as text by design. |
296
306
  | PDF without text | `(PDF contains no extractable text)` / `(PDF content could not be read as text...)` | Scanned or encrypted PDF. |
297
307
  | Download cap hit | `... (page truncated at the download limit)` | Raw fetch hit 512 KiB (10 MiB for PDFs). |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-unsloth-webtools",
3
- "version": "0.7.3",
3
+ "version": "0.7.4",
4
4
  "type": "module",
5
5
  "description": "Pi extension: web_search and web_fetch tools that began as a port of the Unsloth Studio codebase and now diverge from it (multi-engine search, opt-in SSRF guard, HTML-to-Markdown extraction)",
6
6
  "main": "index.ts",
@@ -33,6 +33,7 @@
33
33
  "engines.ts",
34
34
  "entities.ts",
35
35
  "pdf.ts",
36
+ "proxy.ts",
36
37
  "user-agents.ts",
37
38
  "cache.ts",
38
39
  "settings.ts",
package/proxy.ts ADDED
@@ -0,0 +1,413 @@
1
+ import http from "node:http";
2
+ import https from "node:https";
3
+ import net from "node:net";
4
+ import tls from "node:tls";
5
+ import type { Duplex } from "node:stream";
6
+
7
+ export interface SocksProxy {
8
+ host: string;
9
+ port: number;
10
+ username: string | null;
11
+ password: string | null;
12
+ }
13
+
14
+ export interface TunnelOptions {
15
+ proxy: SocksProxy;
16
+ ip: string;
17
+ family: number;
18
+ port: number;
19
+ servername: string;
20
+ timeoutMs: number;
21
+ signal?: AbortSignal;
22
+ }
23
+
24
+ interface Budget {
25
+ timeoutMs: number;
26
+ signal?: AbortSignal;
27
+ }
28
+
29
+ const HTTPS_PROXY_VARS = ["HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"];
30
+ const HTTP_PROXY_VARS = ["HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"];
31
+ const NO_PROXY_VARS = ["NO_PROXY", "no_proxy"];
32
+ const SOCKS_PROTOCOLS = new Set(["socks5:", "socks5h:"]);
33
+ const DEFAULT_SOCKS_PORT = 1080;
34
+ const SOCKS_VERSION = 0x05;
35
+ const SOCKS_AUTH_NONE = 0x00;
36
+ const SOCKS_AUTH_USER_PASSWORD = 0x02;
37
+ const SOCKS_AUTH_REJECTED = 0xff;
38
+ const SOCKS_COMMAND_CONNECT = 0x01;
39
+ const SOCKS_ATYP_IPV4 = 0x01;
40
+ const SOCKS_ATYP_DOMAIN = 0x03;
41
+ const SOCKS_ATYP_IPV6 = 0x04;
42
+ const SOCKS_REPLY_MESSAGES: Record<number, string> = {
43
+ 1: "general failure",
44
+ 2: "connection not allowed",
45
+ 3: "network unreachable",
46
+ 4: "host unreachable",
47
+ 5: "connection refused",
48
+ 6: "TTL expired",
49
+ 7: "command not supported",
50
+ 8: "address type not supported",
51
+ };
52
+
53
+ function firstEnv(names: readonly string[]): string | null {
54
+ for (const name of names) {
55
+ const value = process.env[name];
56
+ if (typeof value === "string" && value.trim()) return value.trim();
57
+ }
58
+ return null;
59
+ }
60
+
61
+ function decodeCredential(value: string): string {
62
+ try {
63
+ return decodeURIComponent(value);
64
+ } catch {
65
+ return value;
66
+ }
67
+ }
68
+
69
+ export function parseSocksProxy(value: string | null | undefined): SocksProxy | null {
70
+ if (!value) return null;
71
+ let parsed: URL;
72
+ try {
73
+ parsed = new URL(value);
74
+ } catch {
75
+ return null;
76
+ }
77
+ if (!SOCKS_PROTOCOLS.has(parsed.protocol)) return null;
78
+ if (!parsed.hostname) return null;
79
+ const port = parsed.port ? Number(parsed.port) : DEFAULT_SOCKS_PORT;
80
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
81
+ const username = parsed.username ? decodeCredential(parsed.username) : "";
82
+ const password = parsed.password ? decodeCredential(parsed.password) : "";
83
+ return { host: parsed.hostname, port, username: username || null, password: password || null };
84
+ }
85
+
86
+ function noProxyEntries(): string[] {
87
+ const raw = firstEnv(NO_PROXY_VARS);
88
+ if (!raw) return [];
89
+ return raw.split(/[\s,]+/).filter((entry) => entry.length > 0);
90
+ }
91
+
92
+ function splitHostPort(entry: string): { host: string; port: number | null } {
93
+ if (entry.startsWith("[")) {
94
+ const end = entry.indexOf("]");
95
+ if (end === -1) return { host: entry, port: null };
96
+ const host = entry.slice(1, end);
97
+ const rest = entry.slice(end + 1);
98
+ if (rest.startsWith(":") && /^\d+$/.test(rest.slice(1))) return { host, port: Number(rest.slice(1)) };
99
+ return { host, port: null };
100
+ }
101
+ const colon = entry.lastIndexOf(":");
102
+ if (colon > 0 && entry.indexOf(":") === colon && /^\d+$/.test(entry.slice(colon + 1))) {
103
+ return { host: entry.slice(0, colon), port: Number(entry.slice(colon + 1)) };
104
+ }
105
+ return { host: entry, port: null };
106
+ }
107
+
108
+ function bypassesProxy(hostname: string, port: number): boolean {
109
+ const host = hostname.toLowerCase();
110
+ for (const rawEntry of noProxyEntries()) {
111
+ const entry = rawEntry.toLowerCase();
112
+ if (entry === "*") return true;
113
+ const { host: rawHost, port: entryPort } = splitHostPort(entry);
114
+ if (entryPort !== null && entryPort !== port) continue;
115
+ const pattern = rawHost.replace(/^\./, "").replace(/\.$/, "");
116
+ if (!pattern) continue;
117
+ if (host === pattern) return true;
118
+ if (!net.isIP(pattern) && host.endsWith(`.${pattern}`)) return true;
119
+ }
120
+ return false;
121
+ }
122
+
123
+ export function socksProxyForUrl(url: URL): SocksProxy | null {
124
+ const proxy = parseSocksProxy(firstEnv(url.protocol === "https:" ? HTTPS_PROXY_VARS : HTTP_PROXY_VARS));
125
+ if (!proxy) return null;
126
+ const port = url.port ? Number(url.port) : url.protocol === "https:" ? 443 : 80;
127
+ if (bypassesProxy(url.hostname, port)) return null;
128
+ return proxy;
129
+ }
130
+
131
+ function readExact(socket: net.Socket, length: number, budget: Budget): Promise<Buffer> {
132
+ return new Promise((resolve, reject) => {
133
+ if (budget.signal?.aborted) {
134
+ reject(new Error("cancelled"));
135
+ return;
136
+ }
137
+ let settled = false;
138
+ let timer: NodeJS.Timeout | undefined;
139
+ function cleanup(): void {
140
+ if (timer) clearTimeout(timer);
141
+ budget.signal?.removeEventListener("abort", onAbort);
142
+ socket.removeListener("readable", onReadable);
143
+ socket.removeListener("error", onError);
144
+ socket.removeListener("close", onClose);
145
+ }
146
+ function finish(error: Error | null, data?: Buffer): void {
147
+ if (settled) return;
148
+ settled = true;
149
+ cleanup();
150
+ if (error) {
151
+ socket.destroy();
152
+ reject(error);
153
+ } else {
154
+ resolve(data!);
155
+ }
156
+ }
157
+ function onReadable(): void {
158
+ const chunk = socket.read(length) as Buffer | null;
159
+ if (chunk === null) return;
160
+ finish(null, chunk);
161
+ }
162
+ function onError(error: Error): void {
163
+ finish(error);
164
+ }
165
+ function onClose(): void {
166
+ finish(new Error("SOCKS5 proxy closed the connection"));
167
+ }
168
+ function onAbort(): void {
169
+ finish(new Error("cancelled"));
170
+ }
171
+ timer = setTimeout(() => finish(new Error("timed out")), budget.timeoutMs);
172
+ budget.signal?.addEventListener("abort", onAbort, { once: true });
173
+ socket.on("readable", onReadable);
174
+ socket.on("error", onError);
175
+ socket.on("close", onClose);
176
+ onReadable();
177
+ });
178
+ }
179
+
180
+ function writeAll(socket: net.Socket, data: Buffer, budget: Budget): Promise<void> {
181
+ return new Promise((resolve, reject) => {
182
+ if (budget.signal?.aborted) {
183
+ reject(new Error("cancelled"));
184
+ return;
185
+ }
186
+ let settled = false;
187
+ let timer: NodeJS.Timeout | undefined;
188
+ function cleanup(): void {
189
+ if (timer) clearTimeout(timer);
190
+ budget.signal?.removeEventListener("abort", onAbort);
191
+ socket.removeListener("error", onError);
192
+ }
193
+ function finish(error: Error | null): void {
194
+ if (settled) return;
195
+ settled = true;
196
+ cleanup();
197
+ if (error) {
198
+ socket.destroy();
199
+ reject(error);
200
+ } else {
201
+ resolve();
202
+ }
203
+ }
204
+ function onError(error: Error): void {
205
+ finish(error);
206
+ }
207
+ function onAbort(): void {
208
+ finish(new Error("cancelled"));
209
+ }
210
+ timer = setTimeout(() => finish(new Error("timed out")), budget.timeoutMs);
211
+ budget.signal?.addEventListener("abort", onAbort, { once: true });
212
+ socket.once("error", onError);
213
+ socket.write(data, (error: Error | null | undefined) => finish(error ?? null));
214
+ });
215
+ }
216
+
217
+ function connectProxy(proxy: SocksProxy, budget: Budget): Promise<net.Socket> {
218
+ return new Promise((resolve, reject) => {
219
+ if (budget.signal?.aborted) {
220
+ reject(new Error("cancelled"));
221
+ return;
222
+ }
223
+ const socket = net.connect({ host: proxy.host, port: proxy.port });
224
+ let settled = false;
225
+ let timer: NodeJS.Timeout | undefined;
226
+ function cleanup(): void {
227
+ if (timer) clearTimeout(timer);
228
+ budget.signal?.removeEventListener("abort", onAbort);
229
+ socket.removeListener("connect", onConnect);
230
+ socket.removeListener("error", onError);
231
+ socket.removeListener("timeout", onTimeout);
232
+ socket.setTimeout(0);
233
+ }
234
+ function finish(error: Error | null): void {
235
+ if (settled) return;
236
+ settled = true;
237
+ cleanup();
238
+ if (error) {
239
+ socket.destroy();
240
+ reject(error);
241
+ } else {
242
+ resolve(socket);
243
+ }
244
+ }
245
+ function onConnect(): void {
246
+ finish(null);
247
+ }
248
+ function onError(error: Error): void {
249
+ finish(error);
250
+ }
251
+ function onTimeout(): void {
252
+ finish(new Error("timed out"));
253
+ }
254
+ function onAbort(): void {
255
+ finish(new Error("cancelled"));
256
+ }
257
+ socket.once("connect", onConnect);
258
+ socket.once("error", onError);
259
+ socket.setTimeout(budget.timeoutMs, onTimeout);
260
+ budget.signal?.addEventListener("abort", onAbort, { once: true });
261
+ timer = setTimeout(() => finish(new Error("timed out")), budget.timeoutMs);
262
+ });
263
+ }
264
+
265
+ async function authenticate(socket: net.Socket, proxy: SocksProxy, budget: Budget): Promise<void> {
266
+ const username = Buffer.from(proxy.username ?? "", "utf8");
267
+ const password = Buffer.from(proxy.password ?? "", "utf8");
268
+ if (username.length > 255 || password.length > 255) throw new Error("SOCKS5 proxy credentials are too long");
269
+ await writeAll(
270
+ socket,
271
+ Buffer.concat([Buffer.from([0x01, username.length]), username, Buffer.from([password.length]), password]),
272
+ budget,
273
+ );
274
+ const reply = await readExact(socket, 2, budget);
275
+ if (reply[0] !== 0x01 || reply[1] !== 0x00) throw new Error("SOCKS5 proxy authentication failed");
276
+ }
277
+
278
+ function ipv4Bytes(ip: string): Buffer {
279
+ const parts = ip.split(".");
280
+ if (parts.length !== 4) throw new Error(`Invalid IPv4 address: ${ip}`);
281
+ const bytes = Buffer.alloc(4);
282
+ parts.forEach((part, index) => {
283
+ const value = Number(part);
284
+ if (!Number.isInteger(value) || value < 0 || value > 255) throw new Error(`Invalid IPv4 address: ${ip}`);
285
+ bytes[index] = value;
286
+ });
287
+ return bytes;
288
+ }
289
+
290
+ function ipv6Bytes(ip: string): Buffer {
291
+ const halves = ip.split("::");
292
+ if (halves.length > 2) throw new Error(`Invalid IPv6 address: ${ip}`);
293
+ const toGroups = (part: string) => (part ? part.split(":") : []);
294
+ const head = toGroups(halves[0]);
295
+ const tail = halves.length === 2 ? toGroups(halves[1]) : [];
296
+ for (const group of [...head, ...tail]) {
297
+ if (!/^[0-9a-fA-F]{1,4}$/.test(group)) throw new Error(`Invalid IPv6 address: ${ip}`);
298
+ }
299
+ const missing = 8 - head.length - tail.length;
300
+ if (halves.length === 2 ? missing < 1 : missing !== 0) throw new Error(`Invalid IPv6 address: ${ip}`);
301
+ const groups = [...head, ...Array.from({ length: missing }, () => "0"), ...tail];
302
+ const bytes = Buffer.alloc(16);
303
+ groups.forEach((group, index) => bytes.writeUInt16BE(Number.parseInt(group, 16), index * 2));
304
+ return bytes;
305
+ }
306
+
307
+ function addressBytes(ip: string, family: number): { atyp: number; bytes: Buffer } {
308
+ if (family === 6 || ip.includes(":")) return { atyp: SOCKS_ATYP_IPV6, bytes: ipv6Bytes(ip) };
309
+ return { atyp: SOCKS_ATYP_IPV4, bytes: ipv4Bytes(ip) };
310
+ }
311
+
312
+ async function sendConnect(socket: net.Socket, options: TunnelOptions, budget: Budget): Promise<void> {
313
+ const address = addressBytes(options.ip, options.family);
314
+ const port = Buffer.alloc(2);
315
+ port.writeUInt16BE(options.port);
316
+ await writeAll(
317
+ socket,
318
+ Buffer.concat([Buffer.from([SOCKS_VERSION, SOCKS_COMMAND_CONNECT, 0x00, address.atyp]), address.bytes, port]),
319
+ budget,
320
+ );
321
+ const head = await readExact(socket, 4, budget);
322
+ if (head[0] !== SOCKS_VERSION) throw new Error("SOCKS5 proxy returned an invalid version");
323
+ if (head[1] !== 0x00) {
324
+ const reason = SOCKS_REPLY_MESSAGES[head[1]] ?? `reply ${head[1]}`;
325
+ throw new Error(`SOCKS5 proxy connect failed: ${reason}`);
326
+ }
327
+ const atyp = head[3];
328
+ if (atyp === SOCKS_ATYP_IPV4) {
329
+ await readExact(socket, 6, budget);
330
+ } else if (atyp === SOCKS_ATYP_IPV6) {
331
+ await readExact(socket, 18, budget);
332
+ } else if (atyp === SOCKS_ATYP_DOMAIN) {
333
+ const length = (await readExact(socket, 1, budget))[0];
334
+ await readExact(socket, length + 2, budget);
335
+ } else {
336
+ throw new Error("SOCKS5 proxy returned an invalid address type");
337
+ }
338
+ }
339
+
340
+ async function socksHandshake(socket: net.Socket, options: TunnelOptions, budget: Budget): Promise<void> {
341
+ const methods = options.proxy.username !== null ? [SOCKS_AUTH_USER_PASSWORD] : [SOCKS_AUTH_NONE];
342
+ await writeAll(socket, Buffer.from([SOCKS_VERSION, methods.length, ...methods]), budget);
343
+ const greeting = await readExact(socket, 2, budget);
344
+ if (greeting[0] !== SOCKS_VERSION) throw new Error("SOCKS5 proxy returned an invalid version");
345
+ const method = greeting[1];
346
+ if (method === SOCKS_AUTH_REJECTED) throw new Error("SOCKS5 proxy rejected all authentication methods");
347
+ if (method === SOCKS_AUTH_USER_PASSWORD) {
348
+ await authenticate(socket, options.proxy, budget);
349
+ } else if (method !== SOCKS_AUTH_NONE) {
350
+ throw new Error(`SOCKS5 proxy selected an unsupported authentication method (${method})`);
351
+ }
352
+ await sendConnect(socket, options, budget);
353
+ }
354
+
355
+ export async function openTunnel(options: TunnelOptions): Promise<net.Socket> {
356
+ const budget: Budget = { timeoutMs: options.timeoutMs, signal: options.signal };
357
+ const socket = await connectProxy(options.proxy, budget);
358
+ try {
359
+ await socksHandshake(socket, options, budget);
360
+ return socket;
361
+ } catch (error) {
362
+ socket.destroy();
363
+ throw error;
364
+ }
365
+ }
366
+
367
+ function tlsWrap(socket: net.Socket, servername: string): tls.TLSSocket {
368
+ if (net.isIP(servername)) return tls.connect({ socket, host: servername });
369
+ return tls.connect({ socket, servername });
370
+ }
371
+
372
+ function tunnelCreateConnection(
373
+ open: () => Promise<Duplex>,
374
+ callback?: (err: Error | null, stream: Duplex) => void,
375
+ ): Duplex | null | undefined {
376
+ if (typeof callback !== "function") return undefined;
377
+ open().then(
378
+ (stream) => callback(null, stream),
379
+ (error: unknown) => callback(error instanceof Error ? error : new Error(String(error)), undefined as unknown as Duplex),
380
+ );
381
+ return undefined;
382
+ }
383
+
384
+ export function tunnelAgent(url: URL, options: TunnelOptions): http.Agent {
385
+ const open = (): Promise<Duplex> =>
386
+ openTunnel(options).then((socket) => (url.protocol === "https:" ? tlsWrap(socket, options.servername) : socket));
387
+ if (url.protocol === "https:") {
388
+ class SecureTunnelAgent extends https.Agent {
389
+ constructor() {
390
+ super({ keepAlive: false });
391
+ }
392
+ override createConnection(
393
+ _options: http.ClientRequestArgs,
394
+ callback?: (err: Error | null, stream: Duplex) => void,
395
+ ): Duplex | null | undefined {
396
+ return tunnelCreateConnection(open, callback);
397
+ }
398
+ }
399
+ return new SecureTunnelAgent();
400
+ }
401
+ class PlainTunnelAgent extends http.Agent {
402
+ constructor() {
403
+ super({ keepAlive: false });
404
+ }
405
+ override createConnection(
406
+ _options: http.ClientRequestArgs,
407
+ callback?: (err: Error | null, stream: Duplex) => void,
408
+ ): Duplex | null | undefined {
409
+ return tunnelCreateConnection(open, callback);
410
+ }
411
+ }
412
+ return new PlainTunnelAgent();
413
+ }
package/web-fetch.ts CHANGED
@@ -26,6 +26,7 @@ import type { AttrDict } from "./html-to-md.ts";
26
26
  import { INVALID_CHARREFS } from "./entities.ts";
27
27
  import { getCached, isFresh, setCached, staleNotice } from "./cache.ts";
28
28
  import { extractPdfText } from "./pdf.ts";
29
+ import { socksProxyForUrl, tunnelAgent } from "./proxy.ts";
29
30
  import { randomUserAgent } from "./user-agents.ts";
30
31
 
31
32
  const MAX_FETCH_BYTES = 512 * 1024;
@@ -635,23 +636,37 @@ export function requestHop(opts: HopOptions): Promise<HopResponse> {
635
636
  return new Promise((resolve, reject) => {
636
637
  const url = opts.url;
637
638
  const transport = url.protocol === "https:" ? https : http;
639
+ const port = url.port ? Number(url.port) : url.protocol === "https:" ? 443 : 80;
638
640
  const options: https.RequestOptions = {
639
641
  method: "GET",
640
642
  host: url.hostname,
641
- port: url.port ? Number(url.port) : url.protocol === "https:" ? 443 : 80,
643
+ port,
642
644
  path: url.pathname + url.search,
643
645
  headers: opts.headers,
644
646
  timeout: opts.inactivityMs,
645
- servername: url.protocol === "https:" ? url.hostname : undefined,
646
- lookup: ((_hostname: string, _options: unknown, _callback: unknown) => {
647
+ };
648
+ const proxy = socksProxyForUrl(url);
649
+ if (proxy) {
650
+ options.agent = tunnelAgent(url, {
651
+ proxy,
652
+ ip: opts.pinnedIp,
653
+ family: opts.family,
654
+ port,
655
+ servername: url.hostname,
656
+ timeoutMs: Math.max(1, opts.inactivityMs),
657
+ signal: opts.signal,
658
+ });
659
+ } else {
660
+ options.servername = url.protocol === "https:" ? url.hostname : undefined;
661
+ options.lookup = ((_hostname: string, _options: unknown, _callback: unknown) => {
647
662
  const done = (typeof _options === "function" ? _options : _callback) as (err: unknown, address: unknown, family?: unknown) => void;
648
663
  if ((_options as { all?: boolean } | undefined)?.all) {
649
664
  done(null, [{ address: opts.pinnedIp, family: opts.family }]);
650
665
  } else {
651
666
  done(null, opts.pinnedIp, opts.family);
652
667
  }
653
- }) as typeof options.lookup,
654
- };
668
+ }) as typeof options.lookup;
669
+ }
655
670
  let settled = false;
656
671
  let resRef: IncomingMessage | null = null;
657
672
  let decoderRef: Transform | null = null;
@@ -898,6 +913,8 @@ export async function fetchUrlRaw(
898
913
  signal,
899
914
  });
900
915
  } catch (err) {
916
+ const message = err instanceof Error ? err.message : "";
917
+ if (message === "cancelled" || message === "timed out") return emptyResult(fetchErrorMessage(err));
901
918
  if (
902
919
  !(err instanceof FetchCancelledError) &&
903
920
  !(err instanceof FetchTimeoutError) &&