@jameskh/proxywire 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/parse.js ADDED
@@ -0,0 +1,185 @@
1
+ import { describeLocation, ProxyParseError } from "./errors.js";
2
+ /** Schemes that can actually be dialled by `makeProxyDispatcher`. */
3
+ export const SUPPORTED_PROXY_PROTOCOLS = ["http", "https", "socks4", "socks5"];
4
+ const SUPPORTED_PROTOCOL_SET = new Set(SUPPORTED_PROXY_PROTOCOLS.map((scheme) => `${scheme}:`));
5
+ /**
6
+ * Residential providers commonly export schemeless lists, leaving the caller to
7
+ * decide whether to dial them over SOCKS or HTTP. SOCKS5 is the safer default:
8
+ * providers that support both advertise SOCKS5, and an HTTP-only endpoint fails
9
+ * loudly on connect rather than silently downgrading.
10
+ */
11
+ const DEFAULT_PROXY_PROTOCOL = "socks5";
12
+ const PORT_PATTERN = /^\d+$/u;
13
+ const SCHEME_PATTERN = /^[a-z0-9+.-]+:\/\//iu;
14
+ /**
15
+ * Placeholder rows that ship inside example proxy files. A list still holding
16
+ * these has not been filled in, and connecting to `host:port` produces a
17
+ * confusing DNS error rather than an obvious "you forgot to configure this".
18
+ */
19
+ const PLACEHOLDER_PROXIES = new Set([
20
+ "socks5://user:pass@host:port",
21
+ "socks5://host:port:user:pass",
22
+ "http://user:pass@host:port",
23
+ "http://host:port:user:pass",
24
+ "login:password@ip:port",
25
+ "ip:port@login:password",
26
+ "login:password:ip:port",
27
+ "ip:port:login:password",
28
+ ]);
29
+ const PLACEHOLDER_FRAGMENTS = [
30
+ "user:pass@host:port",
31
+ "login:password@ip:port",
32
+ "ip:port@login:password",
33
+ ];
34
+ /** True when a line is one of the template entries rather than a real proxy. */
35
+ export function isPlaceholderProxy(proxy) {
36
+ const normalized = proxy.trim().toLowerCase();
37
+ return (PLACEHOLDER_PROXIES.has(normalized) ||
38
+ PLACEHOLDER_FRAGMENTS.some((fragment) => normalized.includes(fragment)));
39
+ }
40
+ /**
41
+ * A proxy reduced to `host:port`, safe to put in a log line or an error
42
+ * message. Credentials in a proxy URL are as sensitive as any other password
43
+ * and must never reach a log file.
44
+ */
45
+ export function maskProxy(proxy) {
46
+ try {
47
+ return new URL(proxy).host;
48
+ }
49
+ catch {
50
+ return proxy;
51
+ }
52
+ }
53
+ /** Fisher-Yates. Spreads load across a pool instead of hammering the first entry. */
54
+ export function shuffleProxies(proxies) {
55
+ const copy = [...proxies];
56
+ for (let index = copy.length - 1; index > 0; index -= 1) {
57
+ const swapIndex = Math.floor(Math.random() * (index + 1));
58
+ [copy[index], copy[swapIndex]] = [copy[swapIndex], copy[index]];
59
+ }
60
+ return copy;
61
+ }
62
+ function resolveProtocol(defaultProtocol) {
63
+ const scheme = (typeof defaultProtocol === "function" ? defaultProtocol() : defaultProtocol)?.toLowerCase();
64
+ if (scheme === undefined) {
65
+ return DEFAULT_PROXY_PROTOCOL;
66
+ }
67
+ if (!SUPPORTED_PROTOCOL_SET.has(`${scheme}:`)) {
68
+ throw new ProxyParseError(`Unsupported proxy protocol "${scheme}". Supported schemes are ${SUPPORTED_PROXY_PROTOCOLS.join(", ")}.`);
69
+ }
70
+ return scheme;
71
+ }
72
+ /**
73
+ * Parses `scheme://host:port:username:password`, the shape several providers
74
+ * hand out when they prefix their colon-separated export with a scheme. The
75
+ * credentials go through `encodeURIComponent` because provider usernames
76
+ * routinely carry `_`, `-` and session markers, and passwords can contain
77
+ * characters that would otherwise break `new URL`.
78
+ */
79
+ function normalizeProviderProxyUrl(proxy) {
80
+ const match = /^(https?|socks[45]):\/\/(.+)$/iu.exec(proxy);
81
+ if (!match) {
82
+ return undefined;
83
+ }
84
+ const [, scheme, authority] = match;
85
+ if (!scheme || !authority) {
86
+ return undefined;
87
+ }
88
+ const [host, port, username, ...passwordParts] = authority.split(":");
89
+ const password = passwordParts.join(":");
90
+ if (!host || !port || !username || !password || !PORT_PATTERN.test(port)) {
91
+ return undefined;
92
+ }
93
+ return `${scheme.toLowerCase()}://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${host}:${port}`;
94
+ }
95
+ /**
96
+ * Parses the four schemeless shapes providers export, told apart by which
97
+ * field looks like a port:
98
+ * login:password@ip:port
99
+ * ip:port@login:password
100
+ * login:password:ip:port
101
+ * ip:port:login:password
102
+ * The scheme is supplied separately - see `defaultProtocol`.
103
+ */
104
+ export function parseSchemelessProxy(proxy) {
105
+ // Anything carrying a scheme prefix is not a schemeless provider line.
106
+ if (SCHEME_PATTERN.test(proxy)) {
107
+ return undefined;
108
+ }
109
+ if (proxy.includes("@")) {
110
+ const atIndex = proxy.indexOf("@");
111
+ const left = proxy.slice(0, atIndex).split(":");
112
+ const right = proxy.slice(atIndex + 1).split(":");
113
+ if (left.length !== 2 || right.length !== 2) {
114
+ return undefined;
115
+ }
116
+ const [leftFirst, leftSecond] = left;
117
+ const [rightFirst, rightSecond] = right;
118
+ // ip:port@login:password
119
+ if (PORT_PATTERN.test(leftSecond) && !PORT_PATTERN.test(rightSecond)) {
120
+ return { host: leftFirst, port: leftSecond, username: rightFirst, password: rightSecond };
121
+ }
122
+ // login:password@ip:port
123
+ if (PORT_PATTERN.test(rightSecond)) {
124
+ return { host: rightFirst, port: rightSecond, username: leftFirst, password: leftSecond };
125
+ }
126
+ return undefined;
127
+ }
128
+ const parts = proxy.split(":");
129
+ if (parts.length !== 4) {
130
+ return undefined;
131
+ }
132
+ const [first, second, third, fourth] = parts;
133
+ // ip:port:login:password
134
+ if (PORT_PATTERN.test(second) && !PORT_PATTERN.test(fourth)) {
135
+ return { host: first, port: second, username: third, password: fourth };
136
+ }
137
+ // login:password:ip:port
138
+ if (PORT_PATTERN.test(fourth)) {
139
+ return { host: third, port: fourth, username: first, password: second };
140
+ }
141
+ return undefined;
142
+ }
143
+ function normalizeSchemelessProxyUrl(proxy, defaultProtocol) {
144
+ const parsed = parseSchemelessProxy(proxy);
145
+ if (!parsed) {
146
+ return undefined;
147
+ }
148
+ const { host, port, username, password } = parsed;
149
+ return `${resolveProtocol(defaultProtocol)}://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${host}:${port}`;
150
+ }
151
+ function assertSupportedProxyUrl(proxy, location) {
152
+ let url;
153
+ try {
154
+ url = new URL(proxy);
155
+ }
156
+ catch {
157
+ throw new ProxyParseError(`Invalid proxy URL${describeLocation(location)}. Use user:pass@host:port URL format or provider host:port:user:pass format.`, location);
158
+ }
159
+ if (!SUPPORTED_PROTOCOL_SET.has(url.protocol) || !url.hostname) {
160
+ throw new ProxyParseError(`Invalid proxy URL${describeLocation(location)}. Supported schemes are ${SUPPORTED_PROXY_PROTOCOLS.join(", ")}.`, location);
161
+ }
162
+ }
163
+ /**
164
+ * Turns any supported proxy notation into a dialable URL, leaving an already
165
+ * valid URL untouched. Throws a `ProxyParseError` naming the source and line
166
+ * when nothing matches.
167
+ */
168
+ export function normalizeProxy(proxy, options = {}) {
169
+ const location = { source: options.source, lineNumber: options.lineNumber };
170
+ const trimmed = proxy.trim();
171
+ try {
172
+ assertSupportedProxyUrl(trimmed, location);
173
+ return trimmed;
174
+ }
175
+ catch (error) {
176
+ const normalized = normalizeProviderProxyUrl(trimmed) ??
177
+ normalizeSchemelessProxyUrl(trimmed, options.defaultProtocol);
178
+ if (!normalized) {
179
+ throw error;
180
+ }
181
+ assertSupportedProxyUrl(normalized, location);
182
+ return normalized;
183
+ }
184
+ }
185
+ //# sourceMappingURL=parse.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"parse.js","sourceRoot":"","sources":["../src/parse.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,66 @@
1
+ import type { SecureVersion } from "node:tls";
2
+ import type { Agent, ProxyAgent } from "undici";
3
+ /**
4
+ * An undici dispatcher that routes requests through a proxy. SOCKS proxies get
5
+ * a plain `Agent` with a custom `connect`; HTTP/HTTPS proxies get a `ProxyAgent`.
6
+ */
7
+ export type ProxyDispatcher = Agent | ProxyAgent;
8
+ /** A `fetch` bound to a particular proxy (or to no proxy at all). */
9
+ export type ProxyFetch = (url: string, options?: RequestInit) => Promise<Response>;
10
+ /**
11
+ * Anywhere this library would otherwise be silent about something worth
12
+ * knowing - a transport retry, for instance - it calls into this instead of
13
+ * writing to the console, so the host application keeps control of its output.
14
+ * Every method is optional; missing ones are simply not called.
15
+ */
16
+ export interface ProxyLogger {
17
+ debug?(message: string): void;
18
+ warn?(message: string): void;
19
+ }
20
+ export interface FetcherOptions {
21
+ /**
22
+ * Wall-clock budget for a single request, applied via `AbortSignal.timeout`
23
+ * whenever the caller has not supplied a signal of its own.
24
+ */
25
+ timeoutMs?: number;
26
+ /** How long the SOCKS handshake may take before the connection is abandoned. */
27
+ socksConnectTimeoutMs?: number;
28
+ /** Lowest TLS version accepted when tunnelling through a SOCKS proxy. */
29
+ minTlsVersion?: SecureVersion;
30
+ /**
31
+ * Bun's `fetch` cannot tunnel SOCKS, and silently ignoring that would send
32
+ * traffic from the machine's own address while the caller believes it is
33
+ * proxied. By default a SOCKS proxy under Bun throws instead. Set this to
34
+ * fall back to the undici dispatcher path - correct on Node, a no-op on Bun.
35
+ */
36
+ allowSocksUnderBun?: boolean;
37
+ logger?: ProxyLogger;
38
+ }
39
+ export interface ProxyParseOptions {
40
+ /**
41
+ * Scheme applied to schemeless provider lines such as `1.2.3.4:8080:user:pass`.
42
+ * Pass a function to resolve it lazily - useful when it comes from an
43
+ * environment variable that may be set after this module is imported.
44
+ * Defaults to `socks5`.
45
+ */
46
+ defaultProtocol?: string | (() => string);
47
+ /**
48
+ * Where these proxies came from - a file path, a URL, an env var name. Used
49
+ * only to make error messages point somewhere useful.
50
+ */
51
+ source?: string;
52
+ }
53
+ export interface ProxyListOptions extends ProxyParseOptions {
54
+ /**
55
+ * Lines starting with this prefix are ignored. Defaults to `#`. Set to an
56
+ * empty string to treat every non-blank line as a proxy.
57
+ */
58
+ commentPrefix?: string;
59
+ /**
60
+ * Throw when the list still contains the placeholder entries that ship in
61
+ * example files (`user:pass@host:port` and friends). Defaults to `true`:
62
+ * running against a template is a configuration mistake, not a proxy list.
63
+ */
64
+ rejectPlaceholders?: boolean;
65
+ }
66
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAC9C,OAAO,KAAK,EAAE,KAAK,EAAE,UAAU,EAAE,MAAM,QAAQ,CAAC;AAEhD;;;GAGG;AACH,MAAM,MAAM,eAAe,GAAG,KAAK,GAAG,UAAU,CAAC;AAEjD,qEAAqE;AACrE,MAAM,MAAM,UAAU,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;AAEnF;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC1B,KAAK,CAAC,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,IAAI,CAAC,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAED,MAAM,WAAW,cAAc;IAC7B;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gFAAgF;IAChF,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,yEAAyE;IACzE,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B;;;;;OAKG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,MAAM,WAAW,iBAAiB;IAChC;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,GAAG,CAAC,MAAM,MAAM,CAAC,CAAC;IAC1C;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,gBAAiB,SAAQ,iBAAiB;IACzD;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAC;CAC9B"}
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@jameskh/proxywire",
3
+ "version": "1.0.0",
4
+ "description": "Proxy list parsing and proxy-aware fetch for Node and Bun. Normalizes provider export formats, builds HTTP/SOCKS dispatchers, and returns a fetch that actually goes through the proxy on both runtimes.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "keywords": [
11
+ "proxy",
12
+ "socks5",
13
+ "socks4",
14
+ "http-proxy",
15
+ "fetch",
16
+ "undici",
17
+ "dispatcher",
18
+ "bun",
19
+ "proxy-list"
20
+ ],
21
+ "exports": {
22
+ ".": {
23
+ "bun": "./src/index.ts",
24
+ "types": "./dist/index.d.ts",
25
+ "import": "./dist/index.js",
26
+ "default": "./dist/index.js"
27
+ }
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "src",
32
+ "!src/**/*.test.ts",
33
+ "README.md",
34
+ "LICENSE"
35
+ ],
36
+ "scripts": {
37
+ "build": "tsc -p tsconfig.build.json",
38
+ "test": "bun test",
39
+ "typecheck": "tsc --noEmit",
40
+ "prepublishOnly": "bun run typecheck && bun test && bun run build"
41
+ },
42
+ "dependencies": {
43
+ "socks": "^2.8.0",
44
+ "undici": "^8.3.0"
45
+ },
46
+ "devDependencies": {
47
+ "@types/bun": "^1.3.14",
48
+ "@types/node": "^26.2.0",
49
+ "typescript": "^6.0.3"
50
+ },
51
+ "engines": {
52
+ "node": ">=18"
53
+ },
54
+ "main": "./dist/index.js",
55
+ "types": "./dist/index.d.ts",
56
+ "sideEffects": false
57
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,61 @@
1
+ /** Thrown when a proxy string cannot be understood, with the offending location. */
2
+ export class ProxyParseError extends Error {
3
+ readonly source?: string;
4
+ readonly lineNumber?: number;
5
+
6
+ constructor(message: string, location: { source?: string; lineNumber?: number } = {}) {
7
+ super(message);
8
+ this.name = "ProxyParseError";
9
+ this.source = location.source;
10
+ this.lineNumber = location.lineNumber;
11
+ }
12
+ }
13
+
14
+ /** Renders `in <source> on line <n>` for error messages, omitting what is unknown. */
15
+ export function describeLocation(location: { source?: string; lineNumber?: number }): string {
16
+ const parts: string[] = [];
17
+ if (location.source) {
18
+ parts.push(` in ${location.source}`);
19
+ }
20
+
21
+ if (location.lineNumber !== undefined) {
22
+ parts.push(` on line ${location.lineNumber}`);
23
+ }
24
+
25
+ return parts.join("");
26
+ }
27
+
28
+ /**
29
+ * Flattens an error and its `cause` chain into one readable line.
30
+ *
31
+ * `fetch` throws a bare `TypeError: fetch failed` for every underlying network
32
+ * failure - DNS, TLS, proxy handshake, timeout, refused connection - and puts
33
+ * the real reason in `cause`. Without walking that chain there is no way to
34
+ * tell a dead proxy from a bad credential from a slow host, which is exactly
35
+ * the distinction anyone debugging a proxy pool needs.
36
+ */
37
+ export function describeError(error: unknown): string {
38
+ const parts: string[] = [];
39
+ let current: unknown = error;
40
+ const seen = new Set<unknown>();
41
+
42
+ while (current && !seen.has(current)) {
43
+ seen.add(current);
44
+
45
+ if (current instanceof Error) {
46
+ const code = (current as NodeJS.ErrnoException).code;
47
+ const detail =
48
+ code && !current.message.includes(code) ? `${current.message} (${code})` : current.message;
49
+ if (!parts.includes(detail)) {
50
+ parts.push(detail);
51
+ }
52
+
53
+ current = current.cause;
54
+ } else {
55
+ parts.push(String(current));
56
+ break;
57
+ }
58
+ }
59
+
60
+ return parts.length > 0 ? parts.join(" -> ") : "Unknown error";
61
+ }
package/src/fetcher.ts ADDED
@@ -0,0 +1,185 @@
1
+ import * as tls from "node:tls";
2
+ import { SocksClient } from "socks";
3
+ import { Agent, fetch as undiciFetch, ProxyAgent } from "undici";
4
+ import { describeError } from "./errors.js";
5
+ import { maskProxy } from "./parse.js";
6
+ import type { FetcherOptions, ProxyDispatcher, ProxyFetch } from "./types.js";
7
+
8
+ const DEFAULT_TIMEOUT_MS = 30_000;
9
+ const DEFAULT_SOCKS_CONNECT_TIMEOUT_MS = 15_000;
10
+ const DEFAULT_SOCKS_PORT = 1080;
11
+ const DEFAULT_DESTINATION_PORT = 443;
12
+
13
+ /** True when running under Bun rather than Node. */
14
+ export const IS_BUN = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined";
15
+
16
+ /**
17
+ * Builds an undici dispatcher for a proxy URL.
18
+ *
19
+ * SOCKS gets a plain `Agent` whose `connect` opens the SOCKS tunnel by hand and
20
+ * then starts TLS over it, because undici has no SOCKS support of its own.
21
+ * HTTP/HTTPS proxies go to `ProxyAgent`, which already speaks CONNECT.
22
+ */
23
+ export function makeProxyDispatcher(proxy: string, options: FetcherOptions = {}): ProxyDispatcher {
24
+ if (proxy.startsWith("socks5://") || proxy.startsWith("socks4://")) {
25
+ const url = new URL(proxy);
26
+ const proxyHost = url.hostname;
27
+ const proxyPort = Number(url.port) || DEFAULT_SOCKS_PORT;
28
+ const proxyType = proxy.startsWith("socks5://") ? 5 : 4;
29
+ const auth = url.username
30
+ ? {
31
+ username: decodeURIComponent(url.username),
32
+ password: decodeURIComponent(url.password),
33
+ }
34
+ : undefined;
35
+
36
+ return new Agent({
37
+ connect: async (connectOptions, callback) => {
38
+ try {
39
+ const destinationPort = Number(connectOptions.port) || DEFAULT_DESTINATION_PORT;
40
+ const { socket } = await SocksClient.createConnection({
41
+ proxy: {
42
+ host: proxyHost,
43
+ port: proxyPort,
44
+ type: proxyType as 4 | 5,
45
+ ...(auth
46
+ ? {
47
+ userId: auth.username,
48
+ password: auth.password,
49
+ }
50
+ : {}),
51
+ },
52
+ command: "connect",
53
+ destination: {
54
+ host: connectOptions.hostname,
55
+ port: destinationPort,
56
+ },
57
+ timeout: options.socksConnectTimeoutMs ?? DEFAULT_SOCKS_CONNECT_TIMEOUT_MS,
58
+ });
59
+
60
+ const tlsSocket = tls.connect({
61
+ socket: socket as tls.TLSSocket,
62
+ servername: connectOptions.hostname,
63
+ minVersion: options.minTlsVersion ?? "TLSv1.2",
64
+ });
65
+
66
+ tlsSocket.on("secureConnect", () => callback(null, tlsSocket));
67
+ tlsSocket.on("error", (error: Error) => callback(error, null));
68
+ } catch (error) {
69
+ callback(error as Error, null);
70
+ }
71
+ },
72
+ });
73
+ }
74
+
75
+ return new ProxyAgent(proxy);
76
+ }
77
+
78
+ /** Applies the request budget, but never overrides a signal the caller supplied. */
79
+ export function withRequestTimeout(options: RequestInit, timeoutMs: number): RequestInit {
80
+ return options.signal ? options : { ...options, signal: AbortSignal.timeout(timeoutMs) };
81
+ }
82
+
83
+ /**
84
+ * Runs `run` again once if it fails.
85
+ *
86
+ * Proxy pools drop the occasional connection for reasons that have nothing to
87
+ * do with the request - a rotating exit going away mid-handshake, most often -
88
+ * and a single immediate retry turns most of those into a success.
89
+ */
90
+ export async function retryOnTransportFailure<T>(
91
+ describe: string,
92
+ run: () => Promise<T>,
93
+ options: FetcherOptions = {},
94
+ ): Promise<T> {
95
+ try {
96
+ return await run();
97
+ } catch (error) {
98
+ const detail = describeError(error);
99
+ options.logger?.debug?.(
100
+ `${describe} failed at the transport level, retrying once - ${detail.slice(0, 120)}`,
101
+ );
102
+ return run();
103
+ }
104
+ }
105
+
106
+ export interface Fetcher {
107
+ /** A `fetch` that routes through the proxy this fetcher was built for. */
108
+ fetch: ProxyFetch;
109
+ /** The dispatcher backing it, absent when proxyless or on Bun's native path. */
110
+ dispatcher?: ProxyDispatcher;
111
+ /** Releases the dispatcher's socket pool. Safe to call more than once. */
112
+ close(): Promise<void>;
113
+ }
114
+
115
+ /**
116
+ * A `fetch` bound to one proxy, or an unproxied one when `proxy` is omitted.
117
+ *
118
+ * The two runtimes need different mechanisms and this is the whole reason the
119
+ * function exists. Bun ignores undici's `dispatcher` option outright, so a
120
+ * request aimed at a dead proxy still succeeds and reports the machine's own
121
+ * address - every "proxied" call silently goes out unproxied. Bun's own `fetch`
122
+ * takes a `proxy` option that it does honour, so that path is used there, and
123
+ * the dispatcher path is kept for Node.
124
+ */
125
+ export function makeFetcher(proxy?: string, options: FetcherOptions = {}): Fetcher {
126
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
127
+
128
+ if (proxy && IS_BUN && !options.allowSocksUnderBun) {
129
+ if (!/^https?:\/\//iu.test(proxy)) {
130
+ // Bun's fetch answers UnsupportedProxyProtocol for socks, and the
131
+ // dispatcher fallback below does nothing there, so refuse rather than
132
+ // run unproxied without saying so.
133
+ throw new Error(
134
+ `SOCKS proxies are not supported when running under Bun (${maskProxy(proxy)}). ` +
135
+ `Use an http:// proxy, or set allowSocksUnderBun to opt out of this check.`,
136
+ );
137
+ }
138
+
139
+ const fetcher: ProxyFetch = async (url, requestOptions = {}) =>
140
+ fetch(url, { ...withRequestTimeout(requestOptions, timeoutMs), proxy } as RequestInit & {
141
+ proxy: string;
142
+ });
143
+
144
+ return { fetch: fetcher, close: async () => {} };
145
+ }
146
+
147
+ const dispatcher = proxy ? makeProxyDispatcher(proxy, options) : undefined;
148
+
149
+ const fetcher: ProxyFetch = async (url, requestOptions = {}) => {
150
+ const withTimeout = withRequestTimeout(requestOptions, timeoutMs);
151
+ if (dispatcher) {
152
+ // On Node the global fetch is backed by whatever undici ships inside the
153
+ // running binary, which is often a different major from the "undici"
154
+ // package the dispatcher came from, and the handler interfaces are not
155
+ // compatible across majors. Using undici's own fetch keeps the
156
+ // dispatcher and the implementation on the same version.
157
+ const response = await undiciFetch(url, { ...withTimeout, dispatcher } as Parameters<
158
+ typeof undiciFetch
159
+ >[1]);
160
+ return response as unknown as Response;
161
+ }
162
+
163
+ return fetch(url, withTimeout);
164
+ };
165
+
166
+ return { fetch: fetcher, dispatcher, close: () => closeDispatcher(dispatcher) };
167
+ }
168
+
169
+ /**
170
+ * Releases a dispatcher's connection pool.
171
+ *
172
+ * Without this every request cycle leaks an `Agent`/`ProxyAgent` and its
173
+ * sockets, which exhausts file descriptors in any long-running process.
174
+ */
175
+ export async function closeDispatcher(dispatcher?: ProxyDispatcher): Promise<void> {
176
+ if (!dispatcher) {
177
+ return;
178
+ }
179
+
180
+ try {
181
+ await dispatcher.close();
182
+ } catch {
183
+ // Already closed, or could not close cleanly - nothing actionable either way.
184
+ }
185
+ }
package/src/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ export { describeError, ProxyParseError } from "./errors.js";
2
+ export {
3
+ closeDispatcher,
4
+ IS_BUN,
5
+ makeFetcher,
6
+ makeProxyDispatcher,
7
+ retryOnTransportFailure,
8
+ withRequestTimeout,
9
+ } from "./fetcher.js";
10
+ export type { Fetcher } from "./fetcher.js";
11
+ export { loadProxyFile, parseProxyList } from "./list.js";
12
+ export {
13
+ isPlaceholderProxy,
14
+ maskProxy,
15
+ normalizeProxy,
16
+ parseSchemelessProxy,
17
+ shuffleProxies,
18
+ SUPPORTED_PROXY_PROTOCOLS,
19
+ } from "./parse.js";
20
+ export type { ProxyProtocol } from "./parse.js";
21
+ export type {
22
+ FetcherOptions,
23
+ ProxyDispatcher,
24
+ ProxyFetch,
25
+ ProxyListOptions,
26
+ ProxyLogger,
27
+ ProxyParseOptions,
28
+ } from "./types.js";
package/src/list.ts ADDED
@@ -0,0 +1,58 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { ProxyParseError } from "./errors.js";
3
+ import { isPlaceholderProxy, normalizeProxy } from "./parse.js";
4
+ import type { ProxyListOptions } from "./types.js";
5
+
6
+ const DEFAULT_COMMENT_PREFIX = "#";
7
+
8
+ /**
9
+ * Parses a newline-separated proxy list into dialable URLs.
10
+ *
11
+ * Line numbers are captured before blanks and comments are filtered out, so an
12
+ * error still points at the right line of the original text. Comments are
13
+ * skipped for a practical reason: example files are usually all comments, and
14
+ * the placeholder text inside them would otherwise read as proxies and trip the
15
+ * placeholder check. Skipping them also lets a real list carry notes.
16
+ */
17
+ export function parseProxyList(text: string, options: ProxyListOptions = {}): string[] {
18
+ const commentPrefix = options.commentPrefix ?? DEFAULT_COMMENT_PREFIX;
19
+
20
+ const entries = text
21
+ .split(/\r?\n/u)
22
+ .map((line, index) => ({ proxy: line.trim(), lineNumber: index + 1 }))
23
+ .filter(
24
+ ({ proxy }) =>
25
+ proxy.length > 0 && (commentPrefix === "" || !proxy.startsWith(commentPrefix)),
26
+ );
27
+
28
+ if (entries.length === 0) {
29
+ return [];
30
+ }
31
+
32
+ if (options.rejectPlaceholders !== false) {
33
+ const placeholders = entries.filter(({ proxy }) => isPlaceholderProxy(proxy));
34
+ if (placeholders.length > 0) {
35
+ const location = { source: options.source, lineNumber: placeholders[0]!.lineNumber };
36
+ throw new ProxyParseError(
37
+ `Placeholder proxies detected${options.source ? ` in ${options.source}` : ""}. ` +
38
+ `Replace the placeholder entries with real proxies.`,
39
+ location,
40
+ );
41
+ }
42
+ }
43
+
44
+ return entries.map(({ proxy, lineNumber }) => normalizeProxy(proxy, { ...options, lineNumber }));
45
+ }
46
+
47
+ /**
48
+ * Reads and parses a proxy list file, returning an empty list when the file is
49
+ * absent. A missing file means "no proxies configured", which is a valid state
50
+ * for anything that treats proxying as optional.
51
+ */
52
+ export function loadProxyFile(path: string, options: ProxyListOptions = {}): string[] {
53
+ if (!existsSync(path)) {
54
+ return [];
55
+ }
56
+
57
+ return parseProxyList(readFileSync(path, "utf8"), { source: path, ...options });
58
+ }