diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1982d41..dadfc9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,3 @@ jobs: - name: Check run: pnpm check - - - name: Check generated output - run: git diff --exit-code -- dist diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 5d56bb1..e2430fb 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -50,6 +50,3 @@ jobs: - name: Dry-run package run: pnpm package:dry-run - - - name: Check generated output - run: git diff --exit-code -- dist diff --git a/dist/connect.d.ts b/dist/connect.d.ts deleted file mode 100644 index 3396fa1..0000000 --- a/dist/connect.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -import net from "node:net"; -import tls from "node:tls"; -import { type ProxylineTlsOptions } from "./shared.js"; -export type OpenProxyConnectTunnelOptions = Readonly<{ - proxyUrl: string | URL; - proxyTls?: ProxylineTlsOptions; - targetHost: string; - targetPort: number; - timeoutMs?: number; -}>; -type ProxySocket = net.Socket | tls.TLSSocket; -export declare function formatConnectAuthority(targetHost: string, targetPort: number): string; -export declare function openProxyConnectTunnel(options: OpenProxyConnectTunnelOptions): Promise; -export {}; -//# sourceMappingURL=connect.d.ts.map \ No newline at end of file diff --git a/dist/connect.d.ts.map b/dist/connect.d.ts.map deleted file mode 100644 index f1204a7..0000000 --- a/dist/connect.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"connect.d.ts","sourceRoot":"","sources":["../src/connect.ts"],"names":[],"mappings":"AAAA,OAAO,GAAG,MAAM,UAAU,CAAC;AAC3B,OAAO,GAAG,MAAM,UAAU,CAAC;AAC3B,OAAO,EAAkB,KAAK,mBAAmB,EAAqC,MAAM,aAAa,CAAC;AAE1G,MAAM,MAAM,6BAA6B,GAAG,QAAQ,CAAC;IACnD,QAAQ,EAAE,MAAM,GAAG,GAAG,CAAC;IACvB,QAAQ,CAAC,EAAE,mBAAmB,CAAC;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC,CAAC;AAMH,KAAK,WAAW,GAAG,GAAG,CAAC,MAAM,GAAG,GAAG,CAAC,SAAS,CAAC;AAsB9C,wBAAgB,sBAAsB,CAAC,UAAU,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAqBrF;AAkDD,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,6BAA6B,GACrC,OAAO,CAAC,WAAW,CAAC,CA8GtB"} \ No newline at end of file diff --git a/dist/connect.js b/dist/connect.js deleted file mode 100644 index 2e451b6..0000000 --- a/dist/connect.js +++ /dev/null @@ -1,182 +0,0 @@ -import net from "node:net"; -import tls from "node:tls"; -import { ProxylineError, redactProxyUrl, resolveProxyTlsCa } from "./shared.js"; -const MAX_CONNECT_RESPONSE_HEADER_BYTES = 16 * 1024; -const INVALID_CONNECT_AUTHORITY_PATTERN = /[\u0000-\u0020\u007f]/; -const INVALID_CONNECT_HOST_DELIMITER_PATTERN = /[/:?#@\\]/; -function resolveProxyHost(proxy) { - return (proxy.hostname || proxy.host).replace(/^\[|\]$/g, ""); -} -function resolveProxyPort(proxy) { - if (proxy.port) { - return Number(proxy.port); - } - return proxy.protocol === "https:" ? 443 : 80; -} -function resolveProxyAuthorization(proxy) { - if (!proxy.username && !proxy.password) { - return undefined; - } - const username = decodeURIComponent(proxy.username); - const password = decodeURIComponent(proxy.password); - return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`; -} -export function formatConnectAuthority(targetHost, targetPort) { - if (!Number.isInteger(targetPort) || targetPort < 1 || targetPort > 65_535) { - throw new ProxylineError("INVALID_CONNECT_TARGET", `Invalid CONNECT target port: ${targetPort}`); - } - if (!targetHost || INVALID_CONNECT_AUTHORITY_PATTERN.test(targetHost)) { - throw new ProxylineError("INVALID_CONNECT_TARGET", "CONNECT target host is empty or unsafe."); - } - const unbracketedHost = targetHost.startsWith("[") && targetHost.endsWith("]") - ? targetHost.slice(1, -1) - : targetHost; - if (net.isIP(unbracketedHost) === 6) { - return `[${unbracketedHost}]:${targetPort}`; - } - if (targetHost.includes("[") || targetHost.includes("]")) { - throw new ProxylineError("INVALID_CONNECT_TARGET", "CONNECT target host has invalid brackets."); - } - if (targetHost.includes(":") || INVALID_CONNECT_HOST_DELIMITER_PATTERN.test(targetHost)) { - throw new ProxylineError("INVALID_CONNECT_TARGET", "CONNECT target host is not a host name."); - } - return `${targetHost}:${targetPort}`; -} -function connectToProxy(proxy, proxyTls) { - const host = resolveProxyHost(proxy); - const connectOptions = { - host, - port: resolveProxyPort(proxy), - }; - if (proxy.protocol === "https:") { - const ca = resolveProxyTlsCa(proxyTls); - const servername = net.isIP(host) === 0 ? host : undefined; - return tls.connect({ - ...connectOptions, - ALPNProtocols: ["http/1.1"], - ...(servername !== undefined ? { servername } : {}), - ...(ca !== undefined ? { ca } : {}), - }); - } - if (proxy.protocol === "http:") { - return net.connect(connectOptions); - } - throw new ProxylineError("UNSUPPORTED_PROXY_PROTOCOL", `CONNECT tunnels support http:// and https:// proxy endpoints: ${proxy.protocol}`); -} -function assertSupportedConnectProxyProtocol(proxy) { - if (proxy.protocol !== "http:" && proxy.protocol !== "https:") { - throw new ProxylineError("UNSUPPORTED_PROXY_PROTOCOL", `CONNECT tunnels support http:// and https:// proxy endpoints: ${proxy.protocol}`); - } -} -function writeConnectRequest(socket, proxy, target) { - const headers = [`CONNECT ${target} HTTP/1.1`, `Host: ${target}`, "Proxy-Connection: Keep-Alive"]; - const authorization = resolveProxyAuthorization(proxy); - if (authorization !== undefined) { - headers.push(`Proxy-Authorization: ${authorization}`); - } - socket.write([...headers, "", ""].join("\r\n")); -} -function failConnect(proxy, error) { - const message = error instanceof Error ? error.message : String(error); - return new ProxylineError("CONNECT_FAILED", `Proxy CONNECT failed via ${redactProxyUrl(proxy)}: ${message}`); -} -export async function openProxyConnectTunnel(options) { - const proxy = options.proxyUrl instanceof URL ? new URL(options.proxyUrl.href) : new URL(options.proxyUrl); - assertSupportedConnectProxyProtocol(proxy); - const target = formatConnectAuthority(options.targetHost, options.targetPort); - return await new Promise((resolve, reject) => { - let settled = false; - let responseBuffer = Buffer.alloc(0); - let timeout; - let socket; - const cleanup = () => { - if (timeout !== undefined) { - clearTimeout(timeout); - timeout = undefined; - } - socket?.off("data", onData); - socket?.off("error", onError); - socket?.off("end", onClosed); - socket?.off("close", onClosed); - socket?.off("connect", onConnected); - socket?.off("secureConnect", onConnected); - }; - const fail = (error) => { - if (settled) { - return; - } - settled = true; - cleanup(); - socket?.destroy(); - reject(failConnect(proxy, error)); - }; - const succeed = (connectedSocket, tunneledBytes) => { - if (settled) { - connectedSocket.destroy(); - return; - } - settled = true; - cleanup(); - if (tunneledBytes !== undefined && tunneledBytes.length > 0) { - connectedSocket.unshift(tunneledBytes); - } - resolve(connectedSocket); - }; - const onConnected = () => { - if (socket === undefined) { - fail(new Error("proxy socket missing after connect")); - return; - } - writeConnectRequest(socket, proxy, target); - }; - const onData = (chunk) => { - responseBuffer = Buffer.concat([responseBuffer, chunk]); - const headerEnd = responseBuffer.indexOf("\r\n\r\n"); - if (headerEnd === -1) { - if (responseBuffer.length > MAX_CONNECT_RESPONSE_HEADER_BYTES) { - fail(new Error(`proxy CONNECT response headers exceeded ${MAX_CONNECT_RESPONSE_HEADER_BYTES} bytes`)); - } - return; - } - const bodyOffset = headerEnd + 4; - if (bodyOffset > MAX_CONNECT_RESPONSE_HEADER_BYTES) { - fail(new Error(`proxy CONNECT response headers exceeded ${MAX_CONNECT_RESPONSE_HEADER_BYTES} bytes`)); - return; - } - const responseHeader = responseBuffer.subarray(0, bodyOffset).toString("latin1"); - const statusLine = responseHeader.split("\r\n", 1)[0] ?? ""; - if (!/^HTTP\/1\.[01] 2\d\d\b/.test(statusLine)) { - fail(new Error(statusLine || "proxy returned an invalid CONNECT response")); - return; - } - if (socket === undefined) { - fail(new Error("proxy socket missing after CONNECT response")); - return; - } - const tunneledBytes = responseBuffer.length > bodyOffset ? responseBuffer.subarray(bodyOffset) : undefined; - succeed(socket, tunneledBytes); - }; - const onError = (error) => { - fail(error); - }; - const onClosed = () => { - fail(new Error("proxy socket closed before CONNECT response")); - }; - try { - if (options.timeoutMs !== undefined && options.timeoutMs > 0) { - timeout = setTimeout(() => { - fail(new Error(`proxy CONNECT timed out after ${Math.trunc(options.timeoutMs ?? 0)}ms`)); - }, Math.trunc(options.timeoutMs)); - } - socket = connectToProxy(proxy, options.proxyTls); - socket.once(proxy.protocol === "https:" ? "secureConnect" : "connect", onConnected); - socket.on("data", onData); - socket.once("error", onError); - socket.once("end", onClosed); - socket.once("close", onClosed); - } - catch (error) { - fail(error); - } - }); -} diff --git a/dist/env.d.ts b/dist/env.d.ts deleted file mode 100644 index 7e65aba..0000000 --- a/dist/env.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { ProxyResolver } from "./types.js"; -export type ProxyEnvKey = "HTTP_PROXY" | "HTTPS_PROXY" | "ALL_PROXY" | "NO_PROXY" | "http_proxy" | "https_proxy" | "all_proxy" | "no_proxy"; -type LowerProxyEnvKey = "http_proxy" | "https_proxy" | "all_proxy" | "no_proxy"; -export type ProxyEnvSnapshot = Readonly>; -export declare const EMPTY_PROXY_ENV: ProxyEnvSnapshot; -export declare function readProxyEnv(): ProxyEnvSnapshot; -export declare function readProxyEnvValue(env: ProxyEnvSnapshot, key: LowerProxyEnvKey): string | undefined; -export declare function proxyUrlWithDefaultScheme(proxyUrl: string): string; -export declare function resolveAmbientProxyForUrl(url: string | URL, env: ProxyEnvSnapshot): string | undefined; -export declare function createAmbientProxyResolver(env: ProxyEnvSnapshot): ProxyResolver; -export {}; -//# sourceMappingURL=env.d.ts.map \ No newline at end of file diff --git a/dist/env.d.ts.map b/dist/env.d.ts.map deleted file mode 100644 index 4e4ba62..0000000 --- a/dist/env.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"env.d.ts","sourceRoot":"","sources":["../src/env.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD,MAAM,MAAM,WAAW,GACnB,YAAY,GACZ,aAAa,GACb,WAAW,GACX,UAAU,GACV,YAAY,GACZ,aAAa,GACb,WAAW,GACX,UAAU,CAAC;AAEf,KAAK,gBAAgB,GAAG,YAAY,GAAG,aAAa,GAAG,WAAW,GAAG,UAAU,CAAC;AAEhF,MAAM,MAAM,gBAAgB,GAAG,QAAQ,CAAC,MAAM,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;AAEjF,eAAO,MAAM,eAAe,EAAE,gBAS7B,CAAC;AAEF,wBAAgB,YAAY,IAAI,gBAAgB,CAW/C;AAoBD,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,gBAAgB,EACrB,GAAG,EAAE,gBAAgB,GACpB,MAAM,GAAG,SAAS,CAEpB;AAED,wBAAgB,yBAAyB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAElE;AAuHD,wBAAgB,yBAAyB,CACvC,GAAG,EAAE,MAAM,GAAG,GAAG,EACjB,GAAG,EAAE,gBAAgB,GACpB,MAAM,GAAG,SAAS,CAsBpB;AAED,wBAAgB,0BAA0B,CAAC,GAAG,EAAE,gBAAgB,GAAG,aAAa,CAmC/E"} \ No newline at end of file diff --git a/dist/env.js b/dist/env.js deleted file mode 100644 index 226e5ba..0000000 --- a/dist/env.js +++ /dev/null @@ -1,202 +0,0 @@ -import { formatUrl, redactProxyUrl } from "./shared.js"; -export const EMPTY_PROXY_ENV = { - HTTP_PROXY: undefined, - HTTPS_PROXY: undefined, - ALL_PROXY: undefined, - NO_PROXY: undefined, - http_proxy: undefined, - https_proxy: undefined, - all_proxy: undefined, - no_proxy: undefined, -}; -export function readProxyEnv() { - return { - HTTP_PROXY: process.env.HTTP_PROXY, - HTTPS_PROXY: process.env.HTTPS_PROXY, - ALL_PROXY: process.env.ALL_PROXY, - NO_PROXY: process.env.NO_PROXY, - http_proxy: process.env.http_proxy, - https_proxy: process.env.https_proxy, - all_proxy: process.env.all_proxy, - no_proxy: process.env.no_proxy, - }; -} -function normalizeEnvValue(value) { - const trimmed = value?.trim(); - return trimmed ? trimmed : undefined; -} -function upperProxyEnvKey(key) { - switch (key) { - case "http_proxy": - return "HTTP_PROXY"; - case "https_proxy": - return "HTTPS_PROXY"; - case "all_proxy": - return "ALL_PROXY"; - case "no_proxy": - return "NO_PROXY"; - } -} -export function readProxyEnvValue(env, key) { - return normalizeEnvValue(env[key]) ?? normalizeEnvValue(env[upperProxyEnvKey(key)]); -} -export function proxyUrlWithDefaultScheme(proxyUrl) { - return proxyUrl.includes("://") ? proxyUrl : `http://${proxyUrl}`; -} -function normalizeAmbientProxyUrl(proxyUrl) { - if (proxyUrl === undefined) { - return undefined; - } - try { - const url = new URL(proxyUrlWithDefaultScheme(proxyUrl)); - return url.protocol === "http:" || url.protocol === "https:" ? url.href : undefined; - } - catch { - return undefined; - } -} -function defaultPort(protocol) { - if (protocol === "http:" || protocol === "ws:") { - return 80; - } - if (protocol === "https:" || protocol === "wss:") { - return 443; - } - return 0; -} -function matchesNoProxy(url, env) { - const rawNoProxy = readProxyEnvValue(env, "no_proxy")?.toLowerCase(); - if (!rawNoProxy) { - return false; - } - if (rawNoProxy === "*") { - return true; - } - const hostname = normalizeNoProxyHost(url.hostname); - const port = Number.parseInt(url.port, 10) || defaultPort(url.protocol); - for (const rawEntry of rawNoProxy.split(/[,\s]/)) { - if (!rawEntry) { - continue; - } - const { host: parsedHost, port: entryPort } = parseNoProxyEntry(rawEntry); - let entryHost = normalizeNoProxyHost(parsedHost); - if (entryPort && entryPort !== port) { - continue; - } - if (!/^[.*]/.test(entryHost)) { - if (hostname === entryHost) { - return true; - } - continue; - } - if (entryHost.startsWith("*")) { - entryHost = entryHost.slice(1); - } - if (entryHost.startsWith(".") && - (hostname === entryHost.slice(1) || hostname.endsWith(entryHost))) { - return true; - } - if (!entryHost.startsWith(".") && hostname.endsWith(entryHost)) { - return true; - } - } - return false; -} -function normalizeNoProxyHost(hostname) { - const normalized = hostname.trim().toLowerCase().replace(/\.+$/, ""); - return normalized.startsWith("[") && normalized.endsWith("]") - ? normalized.slice(1, -1) - : normalized; -} -function parseNoProxyEntry(entry) { - const bracketedIpv6 = entry.match(/^\[([^\]]+)\](?::(\d+))?$/); - if (bracketedIpv6) { - return { - host: bracketedIpv6[1] ?? "", - port: bracketedIpv6[2] ? Number.parseInt(bracketedIpv6[2], 10) : 0, - }; - } - const lastColon = entry.lastIndexOf(":"); - const hasSingleColon = lastColon !== -1 && entry.indexOf(":") === lastColon; - if (hasSingleColon) { - const possiblePort = entry.slice(lastColon + 1); - if (/^\d+$/.test(possiblePort)) { - return { - host: entry.slice(0, lastColon), - port: Number.parseInt(possiblePort, 10), - }; - } - } - return { host: entry, port: 0 }; -} -function proxyEnvKeyForProtocol(protocol) { - if (protocol === "http:" || protocol === "ws:") { - return "http_proxy"; - } - if (protocol === "https:" || protocol === "wss:") { - return "https_proxy"; - } - return undefined; -} -function supportsProxyForUrlProtocol(protocol) { - return protocol === "http:" || protocol === "https:" || protocol === "ws:" || protocol === "wss:"; -} -function resolveAmbientProxyEnvValue(env, key) { - return normalizeAmbientProxyUrl(readProxyEnvValue(env, key)); -} -export function resolveAmbientProxyForUrl(url, env) { - let parsedUrl; - try { - parsedUrl = url instanceof URL ? new URL(url.href) : new URL(url); - } - catch { - return undefined; - } - const protocol = parsedUrl.protocol; - if (!supportsProxyForUrlProtocol(protocol)) { - return undefined; - } - if (matchesNoProxy(parsedUrl, env)) { - return undefined; - } - const protocolProxyKey = proxyEnvKeyForProtocol(protocol); - if (protocolProxyKey === undefined) { - return undefined; - } - return (resolveAmbientProxyEnvValue(env, protocolProxyKey) ?? - resolveAmbientProxyEnvValue(env, "all_proxy")); -} -export function createAmbientProxyResolver(env) { - const configuredProxy = resolveAmbientProxyEnvValue(env, "http_proxy") ?? - resolveAmbientProxyEnvValue(env, "https_proxy") ?? - resolveAmbientProxyEnvValue(env, "all_proxy"); - return { - active: configuredProxy !== undefined, - describeProxy: () => configuredProxy - ? redactProxyUrl(proxyUrlWithDefaultScheme(configuredProxy)) - : undefined, - explain: (url, surface) => { - const formattedUrl = formatUrl(url); - const parsedUrl = new URL(formattedUrl); - const proxyUrl = resolveAmbientProxyForUrl(formattedUrl, env); - if (proxyUrl !== undefined) { - return { - kind: "proxied", - reason: "ambient-proxy-active", - surface, - url: formattedUrl, - proxyUrl: redactProxyUrl(proxyUrl), - }; - } - return { - kind: "direct", - reason: supportsProxyForUrlProtocol(parsedUrl.protocol) && matchesNoProxy(parsedUrl, env) - ? "no-proxy-match" - : "ambient-proxy-not-configured", - surface, - url: formattedUrl, - }; - }, - getProxyForUrl: (url) => resolveAmbientProxyForUrl(url, env) ?? "", - }; -} diff --git a/dist/index.d.ts b/dist/index.d.ts deleted file mode 100644 index 7b4ffe3..0000000 --- a/dist/index.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { openProxyConnectTunnel, type OpenProxyConnectTunnelOptions } from "./connect.js"; -export { installGlobalProxy, installProxyline } from "./runtime.js"; -export { ProxylineError, redactProxyUrl, resolveProxyTlsCa, type ProxylineTlsOptions, } from "./shared.js"; -export type { ExplainOptions, ProxylineDecision, ProxylineEvent, ProxylineHandle, ProxylineMode, ProxylineOptions, ProxylineSurface, } from "./types.js"; -//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/dist/index.d.ts.map b/dist/index.d.ts.map deleted file mode 100644 index 4e587f0..0000000 --- a/dist/index.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,sBAAsB,EAAE,KAAK,6BAA6B,EAAE,MAAM,cAAc,CAAC;AAC1F,OAAO,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACpE,OAAO,EACL,cAAc,EACd,cAAc,EACd,iBAAiB,EACjB,KAAK,mBAAmB,GACzB,MAAM,aAAa,CAAC;AACrB,YAAY,EACV,cAAc,EACd,iBAAiB,EACjB,cAAc,EACd,eAAe,EACf,aAAa,EACb,gBAAgB,EAChB,gBAAgB,GACjB,MAAM,YAAY,CAAC"} \ No newline at end of file diff --git a/dist/index.js b/dist/index.js deleted file mode 100644 index 9321267..0000000 --- a/dist/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export { openProxyConnectTunnel } from "./connect.js"; -export { installGlobalProxy, installProxyline } from "./runtime.js"; -export { ProxylineError, redactProxyUrl, resolveProxyTlsCa, } from "./shared.js"; diff --git a/dist/node-http.d.ts b/dist/node-http.d.ts deleted file mode 100644 index 06ccc0f..0000000 --- a/dist/node-http.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -import http from "node:http"; -import https from "node:https"; -import { ProxyAgent as NodeProxyAgent } from "proxy-agent"; -import type { ProxyResolver } from "./types.js"; -export type NodeHttpRequestOptions = http.RequestOptions & https.RequestOptions & { - agent?: http.Agent | false; -}; -type NodeHttpMethod = typeof http.request; -type NodeAgentFactory = (options: NodeHttpRequestOptions) => http.Agent; -export declare const CALLER_AGENT_TLS_OPTION_KEYS: readonly ["ca", "cert", "ciphers", "clientCertEngine", "crl", "dhparam", "ecdhCurve", "honorCipherOrder", "key", "maxVersion", "minVersion", "passphrase", "pfx", "rejectUnauthorized", "secureOptions", "secureProtocol", "sessionIdContext"]; -export type NodeHttpStackSnapshot = { - httpRequest: typeof http.request; - httpGet: typeof http.get; - httpGlobalAgent: typeof http.globalAgent; - httpsRequest: typeof https.request; - httpsGet: typeof https.get; - httpsGlobalAgent: typeof https.globalAgent; -}; -export declare function bindNodeHttpMethod(originalMethod: TMethod, createAgent: NodeAgentFactory): TMethod; -export declare function createNodeProxyAgent(resolver: ProxyResolver, proxyCa: string | undefined): NodeProxyAgent; -export declare function createDirectNodeAgent(): NodeProxyAgent; -export {}; -//# sourceMappingURL=node-http.d.ts.map \ No newline at end of file diff --git a/dist/node-http.d.ts.map b/dist/node-http.d.ts.map deleted file mode 100644 index c2fd7f0..0000000 --- a/dist/node-http.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"node-http.d.ts","sourceRoot":"","sources":["../src/node-http.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,KAAK,MAAM,YAAY,CAAC;AAE/B,OAAO,EAAE,UAAU,IAAI,cAAc,EAAE,MAAM,aAAa,CAAC;AAC3D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD,MAAM,MAAM,sBAAsB,GAAG,IAAI,CAAC,cAAc,GAAG,KAAK,CAAC,cAAc,GAAG;IAChF,KAAK,CAAC,EAAE,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;CAC5B,CAAC;AAEF,KAAK,cAAc,GAAG,OAAO,IAAI,CAAC,OAAO,CAAC;AAC1C,KAAK,gBAAgB,GAAG,CAAC,OAAO,EAAE,sBAAsB,KAAK,IAAI,CAAC,KAAK,CAAC;AAMxE,eAAO,MAAM,4BAA4B,gPAkB/B,CAAC;AAEX,MAAM,MAAM,qBAAqB,GAAG;IAClC,WAAW,EAAE,OAAO,IAAI,CAAC,OAAO,CAAC;IACjC,OAAO,EAAE,OAAO,IAAI,CAAC,GAAG,CAAC;IACzB,eAAe,EAAE,OAAO,IAAI,CAAC,WAAW,CAAC;IACzC,YAAY,EAAE,OAAO,KAAK,CAAC,OAAO,CAAC;IACnC,QAAQ,EAAE,OAAO,KAAK,CAAC,GAAG,CAAC;IAC3B,gBAAgB,EAAE,OAAO,KAAK,CAAC,WAAW,CAAC;CAC5C,CAAC;AA6DF,wBAAgB,kBAAkB,CAAC,OAAO,SAAS,cAAc,EAC/D,cAAc,EAAE,OAAO,EACvB,WAAW,EAAE,gBAAgB,GAC5B,OAAO,CAsCT;AAED,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,aAAa,EACvB,OAAO,EAAE,MAAM,GAAG,SAAS,GAC1B,cAAc,CAOhB;AAED,wBAAgB,qBAAqB,IAAI,cAAc,CAMtD"} \ No newline at end of file diff --git a/dist/node-http.js b/dist/node-http.js deleted file mode 100644 index 281836f..0000000 --- a/dist/node-http.js +++ /dev/null @@ -1,126 +0,0 @@ -import http from "node:http"; -import https from "node:https"; -import net from "node:net"; -import { ProxyAgent as NodeProxyAgent } from "proxy-agent"; -export const CALLER_AGENT_TLS_OPTION_KEYS = [ - "ca", - "cert", - "ciphers", - "clientCertEngine", - "crl", - "dhparam", - "ecdhCurve", - "honorCipherOrder", - "key", - "maxVersion", - "minVersion", - "passphrase", - "pfx", - "rejectUnauthorized", - "secureOptions", - "secureProtocol", - "sessionIdContext", -]; -function copyNodeHttpOptions(value) { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - return {}; - } - return { ...value }; -} -function readAgentOptions(agent) { - if (agent === undefined || agent === false) { - return undefined; - } - return agent.options; -} -function preserveCallerAgentOptions(options) { - const agentOptions = readAgentOptions(options.agent); - if (agentOptions === undefined) { - return; - } - for (const key of CALLER_AGENT_TLS_OPTION_KEYS) { - const value = agentOptions[key]; - if (value !== undefined && options[key] === undefined) { - options[key] = value; - } - } -} -function inferDestinationHostname(url, options) { - if (url !== undefined) { - return url instanceof URL ? url.hostname : new URL(url).hostname; - } - if (typeof options.hostname === "string") { - return options.hostname; - } - if (typeof options.host === "string") { - return options.host.replace(/:\d*$/, ""); - } - return undefined; -} -function preserveDestinationTlsIdentity(url, options) { - if (options.servername !== undefined) { - return; - } - const hostname = inferDestinationHostname(url, options); - if (!hostname) { - return; - } - if (net.isIP(hostname) === 0) { - options.servername = hostname; - } -} -export function bindNodeHttpMethod(originalMethod, createAgent) { - return ((...args) => { - let url; - let options; - let callback; - const firstArg = args[0]; - if (typeof firstArg === "string" || firstArg instanceof URL) { - url = firstArg; - if (typeof args[1] === "function") { - options = {}; - callback = args[1]; - } - else { - options = copyNodeHttpOptions(args[1]); - callback = args[2]; - } - } - else { - options = copyNodeHttpOptions(firstArg); - callback = args[1]; - } - preserveCallerAgentOptions(options); - preserveDestinationTlsIdentity(url, options); - const agent = createAgent(options); - options.agent = agent; - delete options.createConnection; - if (url !== undefined) { - const request = originalMethod(url, options, callback); - request.once("close", () => { - agent.destroy(); - }); - return request; - } - const request = originalMethod(options, callback); - request.once("close", () => { - agent.destroy(); - }); - return request; - }); -} -export function createNodeProxyAgent(resolver, proxyCa) { - return new NodeProxyAgent({ - ...(proxyCa !== undefined ? { ca: proxyCa } : {}), - getProxyForUrl: resolver.getProxyForUrl, - httpAgent: new http.Agent(), - httpsAgent: new https.Agent(), - }); -} -export function createDirectNodeAgent() { - return new NodeProxyAgent({ - getProxyForUrl: () => "", - httpAgent: new http.Agent(), - httpsAgent: new https.Agent(), - }); -} diff --git a/dist/runtime.d.ts b/dist/runtime.d.ts deleted file mode 100644 index 3ad4f44..0000000 --- a/dist/runtime.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { ProxylineHandle, ProxylineOptions } from "./types.js"; -export declare function installProxyline(options: ProxylineOptions): ProxylineHandle; -export declare const installGlobalProxy: typeof installProxyline; -//# sourceMappingURL=runtime.d.ts.map \ No newline at end of file diff --git a/dist/runtime.d.ts.map b/dist/runtime.d.ts.map deleted file mode 100644 index 84b0d80..0000000 --- a/dist/runtime.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":"AAmCA,OAAO,KAAK,EAEV,eAAe,EACf,gBAAgB,EAEjB,MAAM,YAAY,CAAC;AAudpB,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,gBAAgB,GAAG,eAAe,CAqF3E;AAED,eAAO,MAAM,kBAAkB,yBAAmB,CAAC"} \ No newline at end of file diff --git a/dist/runtime.js b/dist/runtime.js deleted file mode 100644 index 66c08d4..0000000 --- a/dist/runtime.js +++ /dev/null @@ -1,423 +0,0 @@ -import http from "node:http"; -import https from "node:https"; -import { Agent as UndiciAgent, Dispatcher, FormData as UndiciFormData, Headers as UndiciHeaders, Request as UndiciRequest, Response as UndiciResponse, errors as undiciErrors, fetch as undiciFetch, getGlobalDispatcher, ProxyAgent as UndiciProxyAgent, setGlobalDispatcher, } from "undici"; -import { createAmbientProxyResolver, EMPTY_PROXY_ENV, resolveAmbientProxyForUrl, readProxyEnv, } from "./env.js"; -import { bindNodeHttpMethod, createDirectNodeAgent, createNodeProxyAgent, } from "./node-http.js"; -import { formatUrl, ProxylineError, redactProxyUrl, resolveProxyTlsCa, } from "./shared.js"; -let activeRuntime; -// Node's global fetch types come from bundled undici-types, while the runtime -// implementation intentionally delegates to this package's undici dependency. -const proxylineHeaders = UndiciHeaders; -const proxylineRequest = UndiciRequest; -const proxylineResponse = UndiciResponse; -const proxylineFormData = UndiciFormData; -function getRequestDispatcher(request) { - for (const symbol of Object.getOwnPropertySymbols(request)) { - if (symbol.description !== "dispatcher") { - continue; - } - return Reflect.get(request, symbol); - } - return undefined; -} -function isFetchRequestLike(value) { - if (typeof value !== "object" || value === null) { - return false; - } - const record = value; - return (typeof record.url === "string" && - typeof record.method === "string" && - typeof record.arrayBuffer === "function" && - record.headers !== undefined); -} -async function createProxylineRequestFromRequestLike(request, options) { - const init = { - headers: request.headers, - method: request.method, - }; - if (request.cache !== undefined) { - init.cache = request.cache; - } - if (request.credentials !== undefined) { - init.credentials = request.credentials; - } - if (request.integrity !== undefined) { - init.integrity = request.integrity; - } - if (request.keepalive !== undefined) { - init.keepalive = request.keepalive; - } - if (request.mode !== undefined) { - init.mode = request.mode; - } - if (request.redirect !== undefined) { - init.redirect = request.redirect; - } - if (request.referrer !== undefined) { - init.referrer = request.referrer; - } - if (request.referrerPolicy !== undefined) { - init.referrerPolicy = request.referrerPolicy; - } - if (options.preserveDispatcher) { - const dispatcher = getRequestDispatcher(request); - if (dispatcher !== undefined) { - Reflect.set(init, "dispatcher", dispatcher); - } - } - if (request.signal !== undefined) { - init.signal = request.signal; - } - if (options.includeBody && - request.body !== null && - request.method !== "GET" && - request.method !== "HEAD") { - init.body = request.body; - init.duplex = "half"; - } - const requestUnknown = Reflect.construct(proxylineRequest, [request.url, init]); - if (!(requestUnknown instanceof proxylineRequest)) { - throw new TypeError("Proxyline failed to normalize a fetch Request."); - } - return requestUnknown; -} -function requestInitOverridesBody(init) { - if (typeof init !== "object" || init === null) { - return false; - } - return "body" in init; -} -async function normalizeFetchInput(input, init, options) { - if ((input instanceof proxylineRequest && options.preserveDispatcher) || !isFetchRequestLike(input)) { - return input; - } - return await createProxylineRequestFromRequestLike(input, { - includeBody: !requestInitOverridesBody(init), - preserveDispatcher: options.preserveDispatcher, - }); -} -function stripFetchDispatcher(init) { - if (typeof init !== "object" || init === null) { - return init; - } - const sanitized = Object.create(init); - Reflect.defineProperty(sanitized, "dispatcher", { - configurable: true, - enumerable: true, - value: undefined, - writable: true, - }); - return sanitized; -} -const proxylineFetch = async (input, init) => { - const managedMode = activeRuntime?.mode === "managed"; - const normalizedInput = await normalizeFetchInput(input, init, { - preserveDispatcher: !managedMode, - }); - const normalizedInit = managedMode ? stripFetchDispatcher(init) : init; - const response = await Reflect.apply(undiciFetch, undefined, normalizedInit === undefined ? [normalizedInput] : [normalizedInput, normalizedInit]); - if (!(response instanceof proxylineResponse)) { - throw new TypeError("Proxyline fetch returned a non-Response value."); - } - return response; -}; -function normalizeProxyUrl(value) { - if (value === undefined) { - return undefined; - } - const url = value instanceof URL ? new URL(value.href) : new URL(value); - if (url.protocol !== "http:" && url.protocol !== "https:") { - throw new ProxylineError("UNSUPPORTED_PROXY_PROTOCOL", `Proxyline only supports http:// and https:// proxy endpoints in this slice: ${url.protocol}`); - } - return url; -} -function emit(onEvent, event) { - onEvent?.(event); -} -function isProxyableUrlProtocol(protocol) { - return protocol === "http:" || - protocol === "https:" || - protocol === "ws:" || - protocol === "wss:"; -} -function createManagedProxyResolver(proxyUrl) { - const redactedProxyUrl = redactProxyUrl(proxyUrl); - return { - active: true, - describeProxy: () => redactedProxyUrl, - explain: (url, surface) => { - const formattedUrl = formatUrl(url); - if (!isProxyableUrlProtocol(new URL(url).protocol)) { - return { - kind: "direct", - reason: "managed-proxy-unsupported-url-scheme", - surface, - url: formattedUrl, - }; - } - return { - kind: "proxied", - reason: "managed-proxy-active", - surface, - url: formattedUrl, - proxyUrl: redactedProxyUrl, - }; - }, - getProxyForUrl: (url) => { - const protocol = new URL(url).protocol; - return isProxyableUrlProtocol(protocol) ? proxyUrl.href : ""; - }, - }; -} -function createUndiciProxyDispatcher(options, proxyCa) { - if (options.mode === "ambient") { - if (!options.active) { - return new UndiciAgent(); - } - return new AmbientUndiciDispatcher(options.env, proxyCa); - } - return new UndiciProxyAgent({ - uri: options.proxyUrl, - ...(proxyCa !== undefined ? { proxyTls: { ca: proxyCa } } : {}), - }); -} -class AmbientUndiciDispatcher extends Dispatcher { - #directDispatcher = new UndiciAgent(); - #env; - #proxyCa; - #proxyDispatchers = new Map(); - #closedError; - constructor(env, proxyCa) { - super(); - this.#env = env; - this.#proxyCa = proxyCa; - } - dispatch(options, handler) { - if (this.#closedError !== undefined) { - if (handler.onError === undefined) { - throw this.#closedError; - } - handler.onError(this.#closedError); - return false; - } - const url = resolveUndiciDispatchUrl(options); - const proxyUrl = url === undefined ? undefined : resolveAmbientProxyForUrl(url, this.#env); - const dispatcher = proxyUrl === undefined ? this.#directDispatcher : this.#proxyDispatcher(proxyUrl); - return dispatcher.dispatch(options, handler); - } - close(callback) { - const closing = this.#closeAll(); - if (callback === undefined) { - return closing; - } - closing.then(callback, callback); - } - destroy(errorOrCallback, callback) { - const error = typeof errorOrCallback === "function" ? null : errorOrCallback ?? null; - const destroyCallback = typeof errorOrCallback === "function" ? errorOrCallback : callback; - const destroying = this.#destroyAll(error); - if (destroyCallback === undefined) { - return destroying; - } - destroying.then(destroyCallback, destroyCallback); - } - #proxyDispatcher(proxyUrl) { - const existing = this.#proxyDispatchers.get(proxyUrl); - if (existing !== undefined) { - return existing; - } - const dispatcher = new UndiciProxyAgent({ - uri: proxyUrl, - ...(this.#proxyCa !== undefined ? { proxyTls: { ca: this.#proxyCa } } : {}), - }); - this.#proxyDispatchers.set(proxyUrl, dispatcher); - return dispatcher; - } - async #closeAll() { - this.#closedError ??= new undiciErrors.ClientClosedError(); - const proxyDispatchers = [...this.#proxyDispatchers.values()]; - this.#proxyDispatchers.clear(); - await Promise.all([ - this.#directDispatcher.close(), - ...proxyDispatchers.map((dispatcher) => dispatcher.close()), - ]); - } - async #destroyAll(error) { - this.#closedError ??= error ?? new undiciErrors.ClientDestroyedError(); - const proxyDispatchers = [...this.#proxyDispatchers.values()]; - this.#proxyDispatchers.clear(); - await Promise.all([ - this.#directDispatcher.destroy(error), - ...proxyDispatchers.map((dispatcher) => dispatcher.destroy(error)), - ]); - } -} -function resolveUndiciDispatchUrl(options) { - if (options.origin !== undefined) { - const origin = options.origin.toString().replace(/\/$/, ""); - const path = options.path.startsWith("/") ? options.path : `/${options.path}`; - return new URL(`${origin}${path}`).href; - } - try { - return new URL(options.path).href; - } - catch { - return undefined; - } -} -function restoreNodeHttpSnapshot(snapshot) { - http.request = snapshot.httpRequest; - http.get = snapshot.httpGet; - http.globalAgent = snapshot.httpGlobalAgent; - https.request = snapshot.httpsRequest; - https.get = snapshot.httpsGet; - https.globalAgent = snapshot.httpsGlobalAgent; -} -function installRuntime(resolver, dispatcherOptions, proxyCa) { - if (activeRuntime !== undefined) { - throw new ProxylineError("RUNTIME_ALREADY_ACTIVE", "Proxyline already has an active runtime."); - } - const snapshot = { - httpRequest: http.request, - httpGet: http.get, - httpGlobalAgent: http.globalAgent, - httpsRequest: https.request, - httpsGet: https.get, - httpsGlobalAgent: https.globalAgent, - }; - const nodeAgent = createNodeProxyAgent(resolver, proxyCa); - const originalDispatcher = getGlobalDispatcher(); - const originalFetch = globalThis.fetch; - const originalFormData = globalThis.FormData; - const originalHeaders = globalThis.Headers; - const originalRequest = globalThis.Request; - const originalResponse = globalThis.Response; - const installedDispatcher = createUndiciProxyDispatcher(dispatcherOptions, proxyCa); - const runtime = { - installedDispatcher, - mode: dispatcherOptions.mode, - nodeAgent, - originalDispatcher, - originalFetch, - originalFormData, - originalHeaders, - originalRequest, - originalResponse, - snapshot, - }; - activeRuntime = runtime; - try { - http.globalAgent = nodeAgent; - https.globalAgent = nodeAgent; - http.request = bindNodeHttpMethod(snapshot.httpRequest, () => createNodeProxyAgent(resolver, proxyCa)); - http.get = bindNodeHttpMethod(snapshot.httpGet, () => createNodeProxyAgent(resolver, proxyCa)); - https.request = bindNodeHttpMethod(snapshot.httpsRequest, () => createNodeProxyAgent(resolver, proxyCa)); - https.get = bindNodeHttpMethod(snapshot.httpsGet, () => createNodeProxyAgent(resolver, proxyCa)); - setGlobalDispatcher(installedDispatcher); - globalThis.fetch = proxylineFetch; - globalThis.FormData = proxylineFormData; - globalThis.Headers = proxylineHeaders; - globalThis.Request = proxylineRequest; - globalThis.Response = proxylineResponse; - } - catch (error) { - restoreNodeHttpSnapshot(snapshot); - setGlobalDispatcher(originalDispatcher); - globalThis.fetch = originalFetch; - globalThis.FormData = originalFormData; - globalThis.Headers = originalHeaders; - globalThis.Request = originalRequest; - globalThis.Response = originalResponse; - activeRuntime = undefined; - void installedDispatcher.destroy(); - nodeAgent.destroy(); - throw error; - } - return runtime; -} -function stopRuntime(runtime) { - if (activeRuntime !== runtime) { - return; - } - restoreNodeHttpSnapshot(runtime.snapshot); - setGlobalDispatcher(runtime.originalDispatcher); - globalThis.fetch = runtime.originalFetch; - globalThis.FormData = runtime.originalFormData; - globalThis.Headers = runtime.originalHeaders; - globalThis.Request = runtime.originalRequest; - globalThis.Response = runtime.originalResponse; - void runtime.installedDispatcher.destroy(); - runtime.nodeAgent.destroy(); - activeRuntime = undefined; -} -export function installProxyline(options) { - const proxyUrl = options.mode === "managed" ? normalizeProxyUrl(options.proxyUrl) : undefined; - if (options.mode === "managed" && proxyUrl === undefined) { - throw new ProxylineError("MANAGED_PROXY_URL_REQUIRED", "Proxyline managed mode requires an explicit proxyUrl."); - } - let stopped = false; - const proxyCa = resolveProxyTlsCa(options.proxyTls); - const ambientEnv = proxyUrl === undefined ? readProxyEnv() : undefined; - const resolver = proxyUrl !== undefined - ? createManagedProxyResolver(proxyUrl) - : createAmbientProxyResolver(ambientEnv ?? EMPTY_PROXY_ENV); - const redactedProxyUrl = resolver.describeProxy(); - const hasActiveProxy = resolver.active; - const runtime = hasActiveProxy - ? installRuntime(resolver, proxyUrl !== undefined - ? { mode: "managed", proxyUrl: proxyUrl.href } - : { mode: "ambient", env: ambientEnv ?? EMPTY_PROXY_ENV, active: hasActiveProxy }, proxyCa) - : undefined; - emit(options.onEvent, { - type: "runtime.installed", - mode: options.mode, - active: hasActiveProxy, - ...(redactedProxyUrl ? { proxyUrl: redactedProxyUrl } : {}), - }); - const handle = { - mode: options.mode, - active: hasActiveProxy, - ...(redactedProxyUrl ? { proxyUrl: redactedProxyUrl } : {}), - createNodeAgent: () => { - if (!hasActiveProxy || stopped) { - return createDirectNodeAgent(); - } - return createNodeProxyAgent(resolver, proxyCa); - }, - createUndiciDispatcher: () => stopped - ? new UndiciAgent() - : createUndiciProxyDispatcher(proxyUrl !== undefined - ? { mode: "managed", proxyUrl: proxyUrl.href } - : { mode: "ambient", env: ambientEnv ?? EMPTY_PROXY_ENV, active: hasActiveProxy }, proxyCa), - createWebSocketAgent: () => { - if (!hasActiveProxy || stopped) { - return createDirectNodeAgent(); - } - return createNodeProxyAgent(resolver, proxyCa); - }, - explain: (url, explainOptions) => { - const decision = stopped - ? { - kind: "direct", - reason: "runtime-stopped", - surface: explainOptions?.surface ?? "unknown", - url: formatUrl(url), - } - : resolver.explain(url, explainOptions?.surface ?? "unknown"); - emit(options.onEvent, { type: "decision", decision }); - return decision; - }, - stop: () => { - if (stopped) { - return; - } - stopped = true; - if (runtime !== undefined) { - stopRuntime(runtime); - } - emit(options.onEvent, { type: "runtime.stopped", mode: options.mode }); - }, - }; - return handle; -} -export const installGlobalProxy = installProxyline; diff --git a/dist/shared.d.ts b/dist/shared.d.ts deleted file mode 100644 index 0101a06..0000000 --- a/dist/shared.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -export type ProxylineTlsOptions = Readonly<{ - ca?: string; - caFile?: string; -}>; -export declare class ProxylineError extends Error { - readonly code: string; - constructor(code: string, message: string); -} -export declare function resolveProxyTlsCa(options: ProxylineTlsOptions | undefined): string | undefined; -export declare function formatUrl(value: string | URL): string; -export declare function redactProxyUrl(value: string | URL): string; -//# sourceMappingURL=shared.d.ts.map \ No newline at end of file diff --git a/dist/shared.d.ts.map b/dist/shared.d.ts.map deleted file mode 100644 index 01e0dce..0000000 --- a/dist/shared.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../src/shared.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,mBAAmB,GAAG,QAAQ,CAAC;IACzC,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC,CAAC;AAEH,qBAAa,cAAe,SAAQ,KAAK;IACvC,SAAgB,IAAI,EAAE,MAAM,CAAC;gBAEV,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM;CAKjD;AAED,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,mBAAmB,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAW9F;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,GAAG,GAAG,MAAM,CAErD;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,GAAG,GAAG,MAAM,CAO1D"} \ No newline at end of file diff --git a/dist/shared.js b/dist/shared.js deleted file mode 100644 index bcd72d1..0000000 --- a/dist/shared.js +++ /dev/null @@ -1,32 +0,0 @@ -import fs from "node:fs"; -export class ProxylineError extends Error { - code; - constructor(code, message) { - super(message); - this.name = "ProxylineError"; - this.code = code; - } -} -export function resolveProxyTlsCa(options) { - if (!options) { - return undefined; - } - if (options.ca !== undefined) { - return options.ca; - } - if (options.caFile !== undefined) { - return fs.readFileSync(options.caFile, "utf8"); - } - return undefined; -} -export function formatUrl(value) { - return value instanceof URL ? value.href : new URL(value).href; -} -export function redactProxyUrl(value) { - const url = value instanceof URL ? new URL(value.href) : new URL(value); - url.username = ""; - url.password = ""; - url.search = ""; - url.hash = ""; - return url.href; -} diff --git a/dist/types.d.ts b/dist/types.d.ts deleted file mode 100644 index de455d7..0000000 --- a/dist/types.d.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { Agent as HttpAgent } from "node:http"; -import type { Dispatcher } from "undici"; -import type { ProxylineTlsOptions } from "./shared.js"; -export type ProxylineMode = "managed" | "ambient"; -export type ProxylineSurface = "node-http" | "node-https" | "undici" | "websocket" | "connect" | "unknown"; -export type ProxylineOptions = Readonly<{ - mode: ProxylineMode; - proxyUrl?: string | URL; - proxyTls?: ProxylineTlsOptions; - onEvent?: (event: ProxylineEvent) => void; -}>; -export type ProxylineDecision = Readonly<{ - kind: "proxied" | "direct" | "blocked"; - reason: string; - surface: ProxylineSurface; - url: string; - proxyUrl?: string; -}>; -export type ProxylineEvent = Readonly<{ - type: "runtime.installed"; - mode: ProxylineMode; - active: boolean; - proxyUrl?: string; -}> | Readonly<{ - type: "runtime.stopped"; - mode: ProxylineMode; -}> | Readonly<{ - type: "decision"; - decision: ProxylineDecision; -}> | Readonly<{ - type: "warning"; - code: string; - message: string; -}>; -export type ExplainOptions = Readonly<{ - surface?: ProxylineSurface; -}>; -export type ProxylineHandle = Readonly<{ - mode: ProxylineMode; - active: boolean; - proxyUrl?: string; - createNodeAgent: () => HttpAgent; - createUndiciDispatcher: () => Dispatcher; - createWebSocketAgent: () => HttpAgent; - explain: (url: string | URL, options?: ExplainOptions) => ProxylineDecision; - stop: () => void; -}>; -export type ProxyResolver = Readonly<{ - active: boolean; - describeProxy: () => string | undefined; - explain: (url: string | URL, surface: ProxylineSurface) => ProxylineDecision; - getProxyForUrl: (url: string) => string; -}>; -//# sourceMappingURL=types.d.ts.map \ No newline at end of file diff --git a/dist/types.d.ts.map b/dist/types.d.ts.map deleted file mode 100644 index 1438534..0000000 --- a/dist/types.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,IAAI,SAAS,EAAE,MAAM,WAAW,CAAC;AACpD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,QAAQ,CAAC;AACzC,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAEvD,MAAM,MAAM,aAAa,GAAG,SAAS,GAAG,SAAS,CAAC;AAElD,MAAM,MAAM,gBAAgB,GACxB,WAAW,GACX,YAAY,GACZ,QAAQ,GACR,WAAW,GACX,SAAS,GACT,SAAS,CAAC;AAEd,MAAM,MAAM,gBAAgB,GAAG,QAAQ,CAAC;IACtC,IAAI,EAAE,aAAa,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,GAAG,GAAG,CAAC;IACxB,QAAQ,CAAC,EAAE,mBAAmB,CAAC;IAC/B,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAC;CAC3C,CAAC,CAAC;AAEH,MAAM,MAAM,iBAAiB,GAAG,QAAQ,CAAC;IACvC,IAAI,EAAE,SAAS,GAAG,QAAQ,GAAG,SAAS,CAAC;IACvC,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,gBAAgB,CAAC;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC,CAAC;AAEH,MAAM,MAAM,cAAc,GACtB,QAAQ,CAAC;IACP,IAAI,EAAE,mBAAmB,CAAC;IAC1B,IAAI,EAAE,aAAa,CAAC;IACpB,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC,GACF,QAAQ,CAAC;IACP,IAAI,EAAE,iBAAiB,CAAC;IACxB,IAAI,EAAE,aAAa,CAAC;CACrB,CAAC,GACF,QAAQ,CAAC;IACP,IAAI,EAAE,UAAU,CAAC;IACjB,QAAQ,EAAE,iBAAiB,CAAC;CAC7B,CAAC,GACF,QAAQ,CAAC;IACP,IAAI,EAAE,SAAS,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC,CAAC;AAEP,MAAM,MAAM,cAAc,GAAG,QAAQ,CAAC;IACpC,OAAO,CAAC,EAAE,gBAAgB,CAAC;CAC5B,CAAC,CAAC;AAEH,MAAM,MAAM,eAAe,GAAG,QAAQ,CAAC;IACrC,IAAI,EAAE,aAAa,CAAC;IACpB,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,eAAe,EAAE,MAAM,SAAS,CAAC;IACjC,sBAAsB,EAAE,MAAM,UAAU,CAAC;IACzC,oBAAoB,EAAE,MAAM,SAAS,CAAC;IACtC,OAAO,EAAE,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,EAAE,OAAO,CAAC,EAAE,cAAc,KAAK,iBAAiB,CAAC;IAC5E,IAAI,EAAE,MAAM,IAAI,CAAC;CAClB,CAAC,CAAC;AAEH,MAAM,MAAM,aAAa,GAAG,QAAQ,CAAC;IACnC,MAAM,EAAE,OAAO,CAAC;IAChB,aAAa,EAAE,MAAM,MAAM,GAAG,SAAS,CAAC;IACxC,OAAO,EAAE,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,EAAE,OAAO,EAAE,gBAAgB,KAAK,iBAAiB,CAAC;IAC7E,cAAc,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC;CACzC,CAAC,CAAC"} \ No newline at end of file diff --git a/dist/types.js b/dist/types.js deleted file mode 100644 index cb0ff5c..0000000 --- a/dist/types.js +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/package.json b/package.json index 7796acd..cf42131 100644 --- a/package.json +++ b/package.json @@ -34,13 +34,13 @@ ], "scripts": { "build": "tsc -p tsconfig.build.json", - "check": "pnpm typecheck && pnpm test", + "check": "pnpm build && tsc --noEmit && tsx --test test/index.test.ts test/e2e.test.ts test/package.test.ts", "docs:build": "node scripts/build-docs-site.mjs", "package:dry-run": "pnpm pack --dry-run", "prepack": "node scripts/prepack-build.mjs", "publish:dry-run": "pnpm publish --dry-run --access public", "test": "pnpm build && tsx --test test/index.test.ts test/e2e.test.ts test/package.test.ts", - "typecheck": "tsc --noEmit" + "typecheck": "pnpm build && tsc --noEmit" }, "devDependencies": { "@types/node": "^20.19.40", diff --git a/src/index.ts b/src/index.ts index 65e2621..10efb2a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,8 @@ export { } from "./shared.js"; export type { ExplainOptions, + ProxylineBypassPolicy, + ProxylineBypassRequest, ProxylineDecision, ProxylineEvent, ProxylineHandle, diff --git a/src/runtime.ts b/src/runtime.ts index c6a240d..9ad13b5 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -34,10 +34,12 @@ import { resolveProxyTlsCa, } from "./shared.js"; import type { + ProxylineBypassPolicy, ProxylineEvent, ProxylineHandle, ProxylineOptions, ProxyResolver, + ProxylineSurface, } from "./types.js"; type RuntimeInstall = { @@ -255,7 +257,21 @@ function isProxyableUrlProtocol(protocol: string): boolean { protocol === "wss:"; } -function createManagedProxyResolver(proxyUrl: URL): ProxyResolver { +function shouldBypassManagedProxy( + bypassPolicy: ProxylineBypassPolicy | undefined, + url: string | URL, + surface: ProxylineSurface, +): boolean { + if (bypassPolicy === undefined) { + return false; + } + return bypassPolicy({ surface, url: formatUrl(url) }); +} + +function createManagedProxyResolver( + proxyUrl: URL, + bypassPolicy: ProxylineBypassPolicy | undefined, +): ProxyResolver { const redactedProxyUrl = redactProxyUrl(proxyUrl); return { active: true, @@ -270,6 +286,14 @@ function createManagedProxyResolver(proxyUrl: URL): ProxyResolver { url: formattedUrl, }; } + if (shouldBypassManagedProxy(bypassPolicy, url, surface)) { + return { + kind: "direct", + reason: "managed-proxy-bypass-policy", + surface, + url: formattedUrl, + }; + } return { kind: "proxied", reason: "managed-proxy-active", @@ -280,14 +304,17 @@ function createManagedProxyResolver(proxyUrl: URL): ProxyResolver { }, getProxyForUrl: (url) => { const protocol = new URL(url).protocol; - return isProxyableUrlProtocol(protocol) ? proxyUrl.href : ""; + return isProxyableUrlProtocol(protocol) && + !shouldBypassManagedProxy(bypassPolicy, url, "unknown") + ? proxyUrl.href + : ""; }, }; } function createUndiciProxyDispatcher( options: - | { mode: "managed"; proxyUrl: string } + | { mode: "managed"; resolver: ProxyResolver } | { mode: "ambient"; env: ProxyEnvSnapshot; active: boolean }, proxyCa: string | undefined, ): Dispatcher { @@ -297,10 +324,98 @@ function createUndiciProxyDispatcher( } return new AmbientUndiciDispatcher(options.env, proxyCa); } - return new UndiciProxyAgent({ - uri: options.proxyUrl, - ...(proxyCa !== undefined ? { proxyTls: { ca: proxyCa } } : {}), - }); + return new ManagedUndiciDispatcher(options.resolver, proxyCa); +} + +class ManagedUndiciDispatcher extends Dispatcher { + readonly #directDispatcher = new UndiciAgent(); + readonly #proxyCa: string | undefined; + readonly #proxyDispatchers = new Map(); + readonly #resolver: ProxyResolver; + #closedError: Error | undefined; + + public constructor(resolver: ProxyResolver, proxyCa: string | undefined) { + super(); + this.#resolver = resolver; + this.#proxyCa = proxyCa; + } + + public override dispatch( + options: Dispatcher.DispatchOptions, + handler: Dispatcher.DispatchHandler, + ): boolean { + if (this.#closedError !== undefined) { + if (handler.onError === undefined) { + throw this.#closedError; + } + handler.onError(this.#closedError); + return false; + } + const url = resolveUndiciDispatchUrl(options); + const proxyUrl = url === undefined ? "" : this.#resolver.getProxyForUrl(url); + const dispatcher = proxyUrl === "" ? this.#directDispatcher : this.#proxyDispatcher(proxyUrl); + return dispatcher.dispatch(options, handler); + } + + public override close(callback: () => void): void; + public override close(): Promise; + public override close(callback?: () => void): Promise | void { + const closing = this.#closeAll(); + if (callback === undefined) { + return closing; + } + closing.then(callback, callback); + } + + public override destroy(): Promise; + public override destroy(error: Error | null): Promise; + public override destroy(callback: () => void): void; + public override destroy(error: Error | null, callback: () => void): void; + public override destroy( + errorOrCallback?: Error | null | (() => void), + callback?: () => void, + ): Promise | void { + const error = typeof errorOrCallback === "function" ? null : errorOrCallback ?? null; + const destroyCallback = typeof errorOrCallback === "function" ? errorOrCallback : callback; + const destroying = this.#destroyAll(error); + if (destroyCallback === undefined) { + return destroying; + } + destroying.then(destroyCallback, destroyCallback); + } + + #proxyDispatcher(proxyUrl: string): UndiciProxyAgent { + const existing = this.#proxyDispatchers.get(proxyUrl); + if (existing !== undefined) { + return existing; + } + const dispatcher = new UndiciProxyAgent({ + uri: proxyUrl, + ...(this.#proxyCa !== undefined ? { proxyTls: { ca: this.#proxyCa } } : {}), + }); + this.#proxyDispatchers.set(proxyUrl, dispatcher); + return dispatcher; + } + + async #closeAll(): Promise { + this.#closedError ??= new undiciErrors.ClientClosedError(); + const proxyDispatchers = [...this.#proxyDispatchers.values()]; + this.#proxyDispatchers.clear(); + await Promise.all([ + this.#directDispatcher.close(), + ...proxyDispatchers.map((dispatcher) => dispatcher.close()), + ]); + } + + async #destroyAll(error: Error | null): Promise { + this.#closedError ??= error ?? new undiciErrors.ClientDestroyedError(); + const proxyDispatchers = [...this.#proxyDispatchers.values()]; + this.#proxyDispatchers.clear(); + await Promise.all([ + this.#directDispatcher.destroy(error), + ...proxyDispatchers.map((dispatcher) => dispatcher.destroy(error)), + ]); + } } class AmbientUndiciDispatcher extends Dispatcher { @@ -420,7 +535,7 @@ function restoreNodeHttpSnapshot(snapshot: NodeHttpStackSnapshot): void { function installRuntime( resolver: ProxyResolver, dispatcherOptions: - | { mode: "managed"; proxyUrl: string } + | { mode: "managed"; resolver: ProxyResolver } | { mode: "ambient"; env: ProxyEnvSnapshot; active: boolean }, proxyCa: string | undefined, ): RuntimeInstall { @@ -523,7 +638,7 @@ export function installProxyline(options: ProxylineOptions): ProxylineHandle { const ambientEnv = proxyUrl === undefined ? readProxyEnv() : undefined; const resolver = proxyUrl !== undefined - ? createManagedProxyResolver(proxyUrl) + ? createManagedProxyResolver(proxyUrl, options.bypassPolicy) : createAmbientProxyResolver(ambientEnv ?? EMPTY_PROXY_ENV); const redactedProxyUrl = resolver.describeProxy(); const hasActiveProxy = resolver.active; @@ -531,7 +646,7 @@ export function installProxyline(options: ProxylineOptions): ProxylineHandle { ? installRuntime( resolver, proxyUrl !== undefined - ? { mode: "managed", proxyUrl: proxyUrl.href } + ? { mode: "managed", resolver } : { mode: "ambient", env: ambientEnv ?? EMPTY_PROXY_ENV, active: hasActiveProxy }, proxyCa, ) @@ -558,7 +673,7 @@ export function installProxyline(options: ProxylineOptions): ProxylineHandle { ? new UndiciAgent() : createUndiciProxyDispatcher( proxyUrl !== undefined - ? { mode: "managed", proxyUrl: proxyUrl.href } + ? { mode: "managed", resolver } : { mode: "ambient", env: ambientEnv ?? EMPTY_PROXY_ENV, active: hasActiveProxy }, proxyCa, ), diff --git a/src/types.ts b/src/types.ts index 2486014..898c64b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,6 +16,7 @@ export type ProxylineOptions = Readonly<{ mode: ProxylineMode; proxyUrl?: string | URL; proxyTls?: ProxylineTlsOptions; + bypassPolicy?: ProxylineBypassPolicy; onEvent?: (event: ProxylineEvent) => void; }>; @@ -27,6 +28,13 @@ export type ProxylineDecision = Readonly<{ proxyUrl?: string; }>; +export type ProxylineBypassRequest = Readonly<{ + surface: ProxylineSurface; + url: string; +}>; + +export type ProxylineBypassPolicy = (request: ProxylineBypassRequest) => boolean; + export type ProxylineEvent = | Readonly<{ type: "runtime.installed"; diff --git a/test/e2e.test.ts b/test/e2e.test.ts index ec516cf..91cab9e 100644 --- a/test/e2e.test.ts +++ b/test/e2e.test.ts @@ -804,6 +804,29 @@ test("managed mode routes undici fetch through the lab proxy", async () => { } }); +test("managed mode bypass policy sends matching node and undici traffic direct", async () => { + const lab = await startProxyLab(); + const targetHost = new URL(lab.targetUrl).host; + const proxy = installGlobalProxy({ + mode: "managed", + proxyUrl: lab.proxyUrl, + bypassPolicy: ({ url }) => new URL(url).host === targetHost, + }); + try { + const nodeDenied = await readHttp(`${lab.targetUrl}/denied`); + const undiciDenied = await fetch(`${lab.targetUrl}/denied`); + + assert.equal(nodeDenied.status, 200); + assert.equal(nodeDenied.body, "target denied endpoint reached unexpectedly\n"); + assert.equal(undiciDenied.status, 200); + assert.equal(await undiciDenied.text(), "target denied endpoint reached unexpectedly\n"); + assert.equal(lab.events.length, 0); + } finally { + proxy.stop(); + await lab.close(); + } +}); + test("managed mode trusts an HTTPS proxy endpoint with scoped CA", async () => { const lab = await startProxyLab({ secureProxy: true }); const proxyCa = lab.proxyCa; diff --git a/test/index.test.ts b/test/index.test.ts index cd13d05..62825ba 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -96,6 +96,24 @@ test("managed mode explains unsupported URL schemes as direct", () => { } }); +test("managed mode explains bypass policy matches as direct", () => { + const proxy = installGlobalProxy({ + mode: "managed", + proxyUrl: "https://proxy.example:8443", + bypassPolicy: ({ url }) => new URL(url).hostname === "gateway.localhost", + }); + + try { + const decision = proxy.explain("ws://gateway.localhost:18789/", { surface: "websocket" }); + + assert.equal(decision.kind, "direct"); + assert.equal(decision.reason, "managed-proxy-bypass-policy"); + assert.equal(decision.proxyUrl, undefined); + } finally { + proxy.stop(); + } +}); + test("ambient mode can be inactive and explain direct routing", () => { const proxy = installProxyline({ mode: "ambient" });