@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/src/parse.ts ADDED
@@ -0,0 +1,246 @@
1
+ import { describeLocation, ProxyParseError } from "./errors.js";
2
+ import type { ProxyParseOptions } from "./types.js";
3
+
4
+ /** Schemes that can actually be dialled by `makeProxyDispatcher`. */
5
+ export const SUPPORTED_PROXY_PROTOCOLS = ["http", "https", "socks4", "socks5"] as const;
6
+
7
+ export type ProxyProtocol = (typeof SUPPORTED_PROXY_PROTOCOLS)[number];
8
+
9
+ const SUPPORTED_PROTOCOL_SET = new Set<string>(
10
+ SUPPORTED_PROXY_PROTOCOLS.map((scheme) => `${scheme}:`),
11
+ );
12
+
13
+ /**
14
+ * Residential providers commonly export schemeless lists, leaving the caller to
15
+ * decide whether to dial them over SOCKS or HTTP. SOCKS5 is the safer default:
16
+ * providers that support both advertise SOCKS5, and an HTTP-only endpoint fails
17
+ * loudly on connect rather than silently downgrading.
18
+ */
19
+ const DEFAULT_PROXY_PROTOCOL: ProxyProtocol = "socks5";
20
+
21
+ const PORT_PATTERN = /^\d+$/u;
22
+ const SCHEME_PATTERN = /^[a-z0-9+.-]+:\/\//iu;
23
+
24
+ /**
25
+ * Placeholder rows that ship inside example proxy files. A list still holding
26
+ * these has not been filled in, and connecting to `host:port` produces a
27
+ * confusing DNS error rather than an obvious "you forgot to configure this".
28
+ */
29
+ const PLACEHOLDER_PROXIES = new Set([
30
+ "socks5://user:pass@host:port",
31
+ "socks5://host:port:user:pass",
32
+ "http://user:pass@host:port",
33
+ "http://host:port:user:pass",
34
+ "login:password@ip:port",
35
+ "ip:port@login:password",
36
+ "login:password:ip:port",
37
+ "ip:port:login:password",
38
+ ]);
39
+
40
+ const PLACEHOLDER_FRAGMENTS = [
41
+ "user:pass@host:port",
42
+ "login:password@ip:port",
43
+ "ip:port@login:password",
44
+ ];
45
+
46
+ /** True when a line is one of the template entries rather than a real proxy. */
47
+ export function isPlaceholderProxy(proxy: string): boolean {
48
+ const normalized = proxy.trim().toLowerCase();
49
+ return (
50
+ PLACEHOLDER_PROXIES.has(normalized) ||
51
+ PLACEHOLDER_FRAGMENTS.some((fragment) => normalized.includes(fragment))
52
+ );
53
+ }
54
+
55
+ /**
56
+ * A proxy reduced to `host:port`, safe to put in a log line or an error
57
+ * message. Credentials in a proxy URL are as sensitive as any other password
58
+ * and must never reach a log file.
59
+ */
60
+ export function maskProxy(proxy: string): string {
61
+ try {
62
+ return new URL(proxy).host;
63
+ } catch {
64
+ return proxy;
65
+ }
66
+ }
67
+
68
+ /** Fisher-Yates. Spreads load across a pool instead of hammering the first entry. */
69
+ export function shuffleProxies<T>(proxies: readonly T[]): T[] {
70
+ const copy = [...proxies];
71
+ for (let index = copy.length - 1; index > 0; index -= 1) {
72
+ const swapIndex = Math.floor(Math.random() * (index + 1));
73
+ [copy[index], copy[swapIndex]] = [copy[swapIndex]!, copy[index]!];
74
+ }
75
+ return copy;
76
+ }
77
+
78
+ function resolveProtocol(defaultProtocol: ProxyParseOptions["defaultProtocol"]): string {
79
+ const scheme = (
80
+ typeof defaultProtocol === "function" ? defaultProtocol() : defaultProtocol
81
+ )?.toLowerCase();
82
+
83
+ if (scheme === undefined) {
84
+ return DEFAULT_PROXY_PROTOCOL;
85
+ }
86
+
87
+ if (!SUPPORTED_PROTOCOL_SET.has(`${scheme}:`)) {
88
+ throw new ProxyParseError(
89
+ `Unsupported proxy protocol "${scheme}". Supported schemes are ${SUPPORTED_PROXY_PROTOCOLS.join(", ")}.`,
90
+ );
91
+ }
92
+
93
+ return scheme;
94
+ }
95
+
96
+ /**
97
+ * Parses `scheme://host:port:username:password`, the shape several providers
98
+ * hand out when they prefix their colon-separated export with a scheme. The
99
+ * credentials go through `encodeURIComponent` because provider usernames
100
+ * routinely carry `_`, `-` and session markers, and passwords can contain
101
+ * characters that would otherwise break `new URL`.
102
+ */
103
+ function normalizeProviderProxyUrl(proxy: string): string | undefined {
104
+ const match = /^(https?|socks[45]):\/\/(.+)$/iu.exec(proxy);
105
+ if (!match) {
106
+ return undefined;
107
+ }
108
+
109
+ const [, scheme, authority] = match;
110
+ if (!scheme || !authority) {
111
+ return undefined;
112
+ }
113
+
114
+ const [host, port, username, ...passwordParts] = authority.split(":");
115
+ const password = passwordParts.join(":");
116
+ if (!host || !port || !username || !password || !PORT_PATTERN.test(port)) {
117
+ return undefined;
118
+ }
119
+
120
+ return `${scheme.toLowerCase()}://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${host}:${port}`;
121
+ }
122
+
123
+ /**
124
+ * Parses the four schemeless shapes providers export, told apart by which
125
+ * field looks like a port:
126
+ * login:password@ip:port
127
+ * ip:port@login:password
128
+ * login:password:ip:port
129
+ * ip:port:login:password
130
+ * The scheme is supplied separately - see `defaultProtocol`.
131
+ */
132
+ export function parseSchemelessProxy(
133
+ proxy: string,
134
+ ): { host: string; port: string; username: string; password: string } | undefined {
135
+ // Anything carrying a scheme prefix is not a schemeless provider line.
136
+ if (SCHEME_PATTERN.test(proxy)) {
137
+ return undefined;
138
+ }
139
+
140
+ if (proxy.includes("@")) {
141
+ const atIndex = proxy.indexOf("@");
142
+ const left = proxy.slice(0, atIndex).split(":");
143
+ const right = proxy.slice(atIndex + 1).split(":");
144
+ if (left.length !== 2 || right.length !== 2) {
145
+ return undefined;
146
+ }
147
+
148
+ const [leftFirst, leftSecond] = left as [string, string];
149
+ const [rightFirst, rightSecond] = right as [string, string];
150
+
151
+ // ip:port@login:password
152
+ if (PORT_PATTERN.test(leftSecond) && !PORT_PATTERN.test(rightSecond)) {
153
+ return { host: leftFirst, port: leftSecond, username: rightFirst, password: rightSecond };
154
+ }
155
+
156
+ // login:password@ip:port
157
+ if (PORT_PATTERN.test(rightSecond)) {
158
+ return { host: rightFirst, port: rightSecond, username: leftFirst, password: leftSecond };
159
+ }
160
+
161
+ return undefined;
162
+ }
163
+
164
+ const parts = proxy.split(":");
165
+ if (parts.length !== 4) {
166
+ return undefined;
167
+ }
168
+
169
+ const [first, second, third, fourth] = parts as [string, string, string, string];
170
+
171
+ // ip:port:login:password
172
+ if (PORT_PATTERN.test(second) && !PORT_PATTERN.test(fourth)) {
173
+ return { host: first, port: second, username: third, password: fourth };
174
+ }
175
+
176
+ // login:password:ip:port
177
+ if (PORT_PATTERN.test(fourth)) {
178
+ return { host: third, port: fourth, username: first, password: second };
179
+ }
180
+
181
+ return undefined;
182
+ }
183
+
184
+ function normalizeSchemelessProxyUrl(
185
+ proxy: string,
186
+ defaultProtocol: ProxyParseOptions["defaultProtocol"],
187
+ ): string | undefined {
188
+ const parsed = parseSchemelessProxy(proxy);
189
+ if (!parsed) {
190
+ return undefined;
191
+ }
192
+
193
+ const { host, port, username, password } = parsed;
194
+ return `${resolveProtocol(defaultProtocol)}://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${host}:${port}`;
195
+ }
196
+
197
+ function assertSupportedProxyUrl(
198
+ proxy: string,
199
+ location: { source?: string; lineNumber?: number },
200
+ ): void {
201
+ let url: URL;
202
+ try {
203
+ url = new URL(proxy);
204
+ } catch {
205
+ throw new ProxyParseError(
206
+ `Invalid proxy URL${describeLocation(location)}. Use user:pass@host:port URL format or provider host:port:user:pass format.`,
207
+ location,
208
+ );
209
+ }
210
+
211
+ if (!SUPPORTED_PROTOCOL_SET.has(url.protocol) || !url.hostname) {
212
+ throw new ProxyParseError(
213
+ `Invalid proxy URL${describeLocation(location)}. Supported schemes are ${SUPPORTED_PROXY_PROTOCOLS.join(", ")}.`,
214
+ location,
215
+ );
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Turns any supported proxy notation into a dialable URL, leaving an already
221
+ * valid URL untouched. Throws a `ProxyParseError` naming the source and line
222
+ * when nothing matches.
223
+ */
224
+ export function normalizeProxy(
225
+ proxy: string,
226
+ options: ProxyParseOptions & { lineNumber?: number } = {},
227
+ ): string {
228
+ const location = { source: options.source, lineNumber: options.lineNumber };
229
+ const trimmed = proxy.trim();
230
+
231
+ try {
232
+ assertSupportedProxyUrl(trimmed, location);
233
+ return trimmed;
234
+ } catch (error) {
235
+ const normalized =
236
+ normalizeProviderProxyUrl(trimmed) ??
237
+ normalizeSchemelessProxyUrl(trimmed, options.defaultProtocol);
238
+
239
+ if (!normalized) {
240
+ throw error;
241
+ }
242
+
243
+ assertSupportedProxyUrl(normalized, location);
244
+ return normalized;
245
+ }
246
+ }
package/src/types.ts ADDED
@@ -0,0 +1,71 @@
1
+ import type { SecureVersion } from "node:tls";
2
+ import type { Agent, ProxyAgent } from "undici";
3
+
4
+ /**
5
+ * An undici dispatcher that routes requests through a proxy. SOCKS proxies get
6
+ * a plain `Agent` with a custom `connect`; HTTP/HTTPS proxies get a `ProxyAgent`.
7
+ */
8
+ export type ProxyDispatcher = Agent | ProxyAgent;
9
+
10
+ /** A `fetch` bound to a particular proxy (or to no proxy at all). */
11
+ export type ProxyFetch = (url: string, options?: RequestInit) => Promise<Response>;
12
+
13
+ /**
14
+ * Anywhere this library would otherwise be silent about something worth
15
+ * knowing - a transport retry, for instance - it calls into this instead of
16
+ * writing to the console, so the host application keeps control of its output.
17
+ * Every method is optional; missing ones are simply not called.
18
+ */
19
+ export interface ProxyLogger {
20
+ debug?(message: string): void;
21
+ warn?(message: string): void;
22
+ }
23
+
24
+ export interface FetcherOptions {
25
+ /**
26
+ * Wall-clock budget for a single request, applied via `AbortSignal.timeout`
27
+ * whenever the caller has not supplied a signal of its own.
28
+ */
29
+ timeoutMs?: number;
30
+ /** How long the SOCKS handshake may take before the connection is abandoned. */
31
+ socksConnectTimeoutMs?: number;
32
+ /** Lowest TLS version accepted when tunnelling through a SOCKS proxy. */
33
+ minTlsVersion?: SecureVersion;
34
+ /**
35
+ * Bun's `fetch` cannot tunnel SOCKS, and silently ignoring that would send
36
+ * traffic from the machine's own address while the caller believes it is
37
+ * proxied. By default a SOCKS proxy under Bun throws instead. Set this to
38
+ * fall back to the undici dispatcher path - correct on Node, a no-op on Bun.
39
+ */
40
+ allowSocksUnderBun?: boolean;
41
+ logger?: ProxyLogger;
42
+ }
43
+
44
+ export interface ProxyParseOptions {
45
+ /**
46
+ * Scheme applied to schemeless provider lines such as `1.2.3.4:8080:user:pass`.
47
+ * Pass a function to resolve it lazily - useful when it comes from an
48
+ * environment variable that may be set after this module is imported.
49
+ * Defaults to `socks5`.
50
+ */
51
+ defaultProtocol?: string | (() => string);
52
+ /**
53
+ * Where these proxies came from - a file path, a URL, an env var name. Used
54
+ * only to make error messages point somewhere useful.
55
+ */
56
+ source?: string;
57
+ }
58
+
59
+ export interface ProxyListOptions extends ProxyParseOptions {
60
+ /**
61
+ * Lines starting with this prefix are ignored. Defaults to `#`. Set to an
62
+ * empty string to treat every non-blank line as a proxy.
63
+ */
64
+ commentPrefix?: string;
65
+ /**
66
+ * Throw when the list still contains the placeholder entries that ship in
67
+ * example files (`user:pass@host:port` and friends). Defaults to `true`:
68
+ * running against a template is a configuration mistake, not a proxy list.
69
+ */
70
+ rejectPlaceholders?: boolean;
71
+ }