pi-unsloth-webtools 0.7.2 → 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
@@ -42,7 +42,8 @@ Mirrors Unsloth Studio's `web_search` tool:
42
42
  `Snippet:` blocks separated by `---`, ending with the hint to pass `{"url": "<URL>"}` to
43
43
  read a full page.
44
44
  - Accepts an optional `url` parameter; when given, fetches that page's text instead of
45
- searching (optionally truncated with `maxChars`).
45
+ searching (optionally truncated with `maxChars`). An HTTP 403 on that fetch falls back
46
+ to `web_render` when the tool is enabled.
46
47
  - Rate-limit, timeout, and empty-result messages mirror Studio's `_search_failure_message`.
47
48
  - Transient engine failures (network errors or null responses) are retried once with a short
48
49
  backoff inside the same timeout budget (a retry that cannot fit in the remaining budget is
@@ -80,6 +81,11 @@ Port of Studio's `_fetch_page_text` / `_fetch_url_raw` pipeline:
80
81
  - Transient DNS failures (EAI_AGAIN, resolver timeouts, connection refusals) are retried once
81
82
  with a short backoff inside the same deadline, so a brief resolver hiccup does not fail a
82
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).
83
89
  - GitHub repo root pages are rewritten to the unauthenticated README API
84
90
  (`Accept: application/vnd.github.raw+json`), falling back to the raw README URL
85
91
  (`raw.githubusercontent.com`, no API rate limit) and then to the HTML page on failure.
@@ -119,6 +125,9 @@ Port of Studio's `_fetch_page_text` / `_fetch_url_raw` pipeline:
119
125
  `Date:` (`article:published_time` / `dc.date` / `date`) and `Site:` (`og:site_name` /
120
126
  `application-name`) lines are added when declared, so the model can judge recency and
121
127
  provenance.
128
+ - A direct fetch refused with HTTP 403 is retried through `web_render` automatically when that
129
+ tool is enabled; the rendered page is prefixed with a note saying so. When `web_render` is
130
+ disabled or the render also fails, the original `Failed to fetch URL: HTTP 403 ...` is returned.
122
131
 
123
132
  ### web_render
124
133
 
@@ -137,6 +146,7 @@ that need JavaScript to render:
137
146
  [Companion: rotating exit IPs](#companion-rotating-exit-ips).
138
147
  - Enabled by default. Disable it with `/webtools-config` (`webRenderEnabled` in
139
148
  `~/.config/pi-unsloth-webtools/config.json`), which deactivates the tool for the session.
149
+ - Also used automatically when a `web_fetch` or `web_search` url-mode fetch is refused with HTTP 403.
140
150
 
141
151
  ## Known differences from Studio
142
152
 
@@ -148,6 +158,8 @@ that need JavaScript to render:
148
158
  - Third-party rendering: the extra `web_render` tool asks the Jina Reader (`r.jina.ai`) to fetch
149
159
  the page, so the target URL leaves the machine. Studio has no third-party rendering path. This
150
160
  path always refuses local files and non-public addresses, regardless of the local-access settings.
161
+ A direct fetch refused with HTTP 403 is retried through it automatically when enabled, so those
162
+ targets also leave the machine in that case.
151
163
  - PDF styling: MuPDF.js exposes one font per line, so mixed-style lines style the
152
164
  whole line instead of per-span; superscript, subscript, underline, strikeout, and
153
165
  highlight markers are not emitted. Tables use a conservative text-grid detector:
@@ -167,9 +179,11 @@ that need JavaScript to render:
167
179
  generic engine failures. The timeout budget bounds the entire sweep: per-engine
168
180
  timeouts shrink as the budget is consumed, so the reported timeout matches the
169
181
  worst-case wall time.
170
- - Proxies: Studio routes through environment proxies; this port's direct fetch always connects
171
- directly with DNS pinning (deliberately out of scope). The search and `web_render` paths use the
172
- 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
173
187
  [Companion: rotating exit IPs](#companion-rotating-exit-ips).
174
188
  - Dedup and titles: the aggregator keys on canonicalized hrefs (`utm_*`/tracking parameters
175
189
  and fragments stripped, then the URL re-serialized); fetched HTML pages are prefixed with
@@ -211,8 +225,10 @@ the current Tor exit, and Jina rate-limits keyless Reader requests per outgoing
211
225
  pi install npm:pi-unsloth-webtools npm:pi-tor-proxy
212
226
  ```
213
227
 
214
- `web_fetch` is not routed: it connects directly through `node:http`/`node:https` with a pinned,
215
- 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.
216
232
  Caveats: Tor mode supports Linux and macOS only, adds latency, and many search engines and
217
233
  Cloudflare-fronted services challenge or block Tor exits, so cycling helps with per-IP limits but
218
234
  is not a guarantee.
@@ -260,14 +276,14 @@ first changed:
260
276
 
261
277
  | Key | Default | Description |
262
278
  |---|---|---|
263
- | `webRenderEnabled` | `true` | When `false`, the `web_render` tool is deactivated for the session; `web_search` and `web_fetch` are unaffected |
279
+ | `webRenderEnabled` | `true` | When `false`, the `web_render` tool is deactivated for the session and the automatic HTTP 403 fallback in `web_fetch` / `web_search` url mode is disabled |
264
280
 
265
281
  On non-Windows platforms the directory honors `XDG_CONFIG_HOME` when set (falling back to
266
282
  `~/.config`); on Windows it always uses `~/.config`.
267
283
 
268
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.
269
285
 
270
- 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).
271
287
 
272
288
  ## Troubleshooting
273
289
 
@@ -284,7 +300,8 @@ Match on the exact prefix. Do not retry blocked hosts with spelling tricks.
284
300
  | Private address blocked | `Blocked: refusing to fetch the non-public address ...` | The SSRF guard is active (`allowPrivateAddresses: false`); remove it or set `true` to reach localhost/LAN, and write the scheme explicitly (`http://localhost:3000`). |
285
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. |
286
302
  | File read failed | `Failed to read file: ...` | Check the path exists and is a regular file. |
287
- | HTTP failure | `Failed to fetch URL: HTTP ...` | Fix the URL. A 404 automatically tries a Wayback snapshot. |
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. |
288
305
  | Non-text / binary | `(non-text content:` / `(binary content,` | Not readable as text by design. |
289
306
  | PDF without text | `(PDF contains no extractable text)` / `(PDF content could not be read as text...)` | Scanned or encrypted PDF. |
290
307
  | Download cap hit | `... (page truncated at the download limit)` | Raw fetch hit 512 KiB (10 MiB for PDFs). |
package/index.ts CHANGED
@@ -6,7 +6,7 @@ import { SEARCH_TIMEOUT_MS, webSearch as defaultWebSearch } from "./web-search.t
6
6
  import { DEFAULT_FETCH_TIMEOUT_MS, fetchPageText as defaultFetchPageText } from "./web-fetch.ts";
7
7
  import { renderPageText as defaultRenderPageText } from "./web-render.ts";
8
8
  import { loadDefaultFetchSettings, loadDefaultFetchTimeoutMs, loadJinaApiKey } from "./settings.ts";
9
- import { readConfigWithStatus, toggleWebRender } from "./config.ts";
9
+ import { readConfig, readConfigWithStatus, toggleWebRender } from "./config.ts";
10
10
  import { WebToolsConfigOverlay } from "./config-ui.ts";
11
11
 
12
12
  function toolCallLine(theme: Theme, name: string, detail: string) {
@@ -35,6 +35,12 @@ async function fetchDefaults(cwd: string | undefined, params: { timeoutMs?: unkn
35
35
  };
36
36
  }
37
37
 
38
+ function fetchWasForbidden(text: string): boolean {
39
+ return /^Failed to fetch URL: HTTP 403\b/m.test(text);
40
+ }
41
+
42
+ const FORBIDDEN_FALLBACK_NOTE = "Direct fetch failed with HTTP 403; rendered via the Jina Reader instead.";
43
+
38
44
  const WebSearchParams = Type.Object({
39
45
  query: Type.Optional(
40
46
  Type.String({ description: "The search query" }),
@@ -100,19 +106,46 @@ export interface WebToolsDeps {
100
106
  fetchPageText?: typeof defaultFetchPageText;
101
107
  webSearch?: typeof defaultWebSearch;
102
108
  renderPageText?: typeof defaultRenderPageText;
109
+ webRenderEnabled?: () => Promise<boolean>;
103
110
  }
104
111
 
105
112
  export function createWebTools(deps: WebToolsDeps = {}) {
106
113
  const fetchPageText = deps.fetchPageText ?? defaultFetchPageText;
107
114
  const webSearch = deps.webSearch ?? defaultWebSearch;
108
115
  const renderPageText = deps.renderPageText ?? defaultRenderPageText;
116
+ const webRenderEnabled =
117
+ deps.webRenderEnabled ?? (async () => (await readConfig()).webRenderEnabled !== false);
118
+
119
+ const renderOnForbidden = async (
120
+ url: string,
121
+ failedText: string,
122
+ options: { timeoutMs: number; maxChars?: number; signal?: AbortSignal; cwd?: string },
123
+ ): Promise<string | null> => {
124
+ if (!fetchWasForbidden(failedText)) return null;
125
+ try {
126
+ if (!(await webRenderEnabled())) return null;
127
+ const apiKey = await loadJinaApiKey(options.cwd);
128
+ const rendered = await renderPageText(url, {
129
+ timeoutMs: options.timeoutMs,
130
+ maxChars: options.maxChars,
131
+ signal: options.signal,
132
+ apiKey,
133
+ });
134
+ if (rendered.startsWith("Failed to render URL:") || rendered.startsWith("Blocked:")) return null;
135
+ return FORBIDDEN_FALLBACK_NOTE + "\n\n" + rendered;
136
+ } catch {
137
+ return null;
138
+ }
139
+ };
140
+
109
141
  return {
110
142
  webSearchTool: defineTool({
111
143
  name: "web_search",
112
144
  label: "Web Search",
113
145
  description:
114
146
  "Search the web and fetch page content. Returns snippets for all results. " +
115
- "Use the url parameter to fetch full page text from a specific URL.",
147
+ "Use the url parameter to fetch full page text from a specific URL. " +
148
+ "A direct fetch refused with HTTP 403 falls back to web_render when that tool is enabled.",
116
149
  promptSnippet: "Search the web and fetch page content",
117
150
  promptGuidelines: [
118
151
  'Use web_search with the url parameter (e.g. {"url": "<URL>"}) to read the full text of a page found in search results.',
@@ -129,21 +162,20 @@ export function createWebTools(deps: WebToolsDeps = {}) {
129
162
  onUpdate?.({ content: [{ type: "text", text: `Fetching ${url}...` }], details: {} });
130
163
  const cwd = (_ctx as ExtensionContext | undefined)?.cwd;
131
164
  const { timeoutMs, maxChars, allowPrivateAddresses, allowLocalFiles } = await fetchDefaults(cwd, params);
132
- return {
133
- content: [
134
- {
135
- type: "text",
136
- text: await fetchPageText(url, {
137
- timeoutMs,
138
- signal: signal ?? undefined,
139
- maxChars,
140
- allowPrivateAddresses,
141
- allowLocalFiles,
142
- }),
143
- },
144
- ],
145
- details: {},
146
- };
165
+ const text = await fetchPageText(url, {
166
+ timeoutMs,
167
+ signal: signal ?? undefined,
168
+ maxChars,
169
+ allowPrivateAddresses,
170
+ allowLocalFiles,
171
+ });
172
+ const rendered = await renderOnForbidden(url, text, {
173
+ timeoutMs,
174
+ maxChars,
175
+ signal: signal ?? undefined,
176
+ cwd,
177
+ });
178
+ return { content: [{ type: "text", text: rendered ?? text }], details: {} };
147
179
  }
148
180
  onUpdate?.({ content: [{ type: "text", text: "Searching the web..." }], details: {} });
149
181
  const timeoutParam = positiveNumber(params.timeoutMs);
@@ -168,7 +200,11 @@ export function createWebTools(deps: WebToolsDeps = {}) {
168
200
  "README API, so the README is returned instead of the repo page's UI chrome. " +
169
201
  "Private/loopback/link-local targets and local files (file:// URLs, absolute, ~/ or ./ paths, including " +
170
202
  "PDFs) are supported by default; opt out with webFetch.allowPrivateAddresses: false or " +
171
- "webFetch.allowLocalFiles: false in settings. The download size is capped.",
203
+ "webFetch.allowLocalFiles: false in settings. The download size is capped. " +
204
+ "A direct fetch refused with HTTP 403 falls back to web_render (Jina Reader) when that tool is enabled.",
205
+ promptGuidelines: [
206
+ "web_fetch automatically retries HTTP 403 responses through web_render (Jina Reader); do not call web_render again for the same URL after a 403.",
207
+ ],
172
208
  promptSnippet: "Fetch a web page and return readable text content",
173
209
  parameters: WebFetchParams,
174
210
  renderCall(args, theme) {
@@ -185,7 +221,13 @@ export function createWebTools(deps: WebToolsDeps = {}) {
185
221
  allowPrivateAddresses,
186
222
  allowLocalFiles,
187
223
  });
188
- return { content: [{ type: "text", text }], details: {} };
224
+ const rendered = await renderOnForbidden(params.url, text, {
225
+ timeoutMs,
226
+ maxChars,
227
+ signal: signal ?? undefined,
228
+ cwd,
229
+ });
230
+ return { content: [{ type: "text", text: rendered ?? text }], details: {} };
189
231
  },
190
232
  }),
191
233
  webRenderTool: defineTool({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-unsloth-webtools",
3
- "version": "0.7.2",
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) &&