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 +26 -9
- package/index.ts +61 -19
- package/package.json +2 -1
- package/proxy.ts +413 -0
- package/web-fetch.ts +22 -5
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
|
|
171
|
-
|
|
172
|
-
|
|
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`
|
|
215
|
-
|
|
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
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
643
|
+
port,
|
|
642
644
|
path: url.pathname + url.search,
|
|
643
645
|
headers: opts.headers,
|
|
644
646
|
timeout: opts.inactivityMs,
|
|
645
|
-
|
|
646
|
-
|
|
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) &&
|