mirror of
https://github.com/earendil-works/pi.git
synced 2026-06-18 15:54:04 +08:00
refactor(ai): replace proxy agent dependencies
Vendor HTTP(S) proxy env resolution and agents for Bedrock/Codex. SOCKS and PAC proxy URLs now fail explicitly.
This commit is contained in:
Generated
-219
@@ -3637,10 +3637,6 @@
|
||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||
}
|
||||
},
|
||||
"node_modules/@tootallnate/quickjs-emscripten": {
|
||||
"version": "0.23.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"dev": true,
|
||||
@@ -3975,16 +3971,6 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-types": {
|
||||
"version": "0.13.4",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"license": "MIT",
|
||||
@@ -4010,13 +3996,6 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/basic-ftp": {
|
||||
"version": "5.3.1",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bignumber.js": {
|
||||
"version": "9.3.1",
|
||||
"license": "MIT",
|
||||
@@ -4369,18 +4348,6 @@
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/degenerator": {
|
||||
"version": "5.0.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ast-types": "^0.13.4",
|
||||
"escodegen": "^2.1.0",
|
||||
"esprima": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"dev": true,
|
||||
@@ -4496,43 +4463,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/escodegen": {
|
||||
"version": "2.1.0",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"esprima": "^4.0.1",
|
||||
"estraverse": "^5.2.0",
|
||||
"esutils": "^2.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"escodegen": "bin/escodegen.js",
|
||||
"esgenerate": "bin/esgenerate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"source-map": "~0.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/esprima": {
|
||||
"version": "4.0.1",
|
||||
"license": "BSD-2-Clause",
|
||||
"bin": {
|
||||
"esparse": "bin/esparse.js",
|
||||
"esvalidate": "bin/esvalidate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/estraverse": {
|
||||
"version": "5.3.0",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"dev": true,
|
||||
@@ -4541,13 +4471,6 @@
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esutils": {
|
||||
"version": "2.0.3",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/execa": {
|
||||
"version": "1.0.0",
|
||||
"dev": true,
|
||||
@@ -4774,25 +4697,6 @@
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/get-uri": {
|
||||
"version": "6.0.5",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"basic-ftp": "^5.0.2",
|
||||
"data-uri-to-buffer": "^6.0.2",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/get-uri/node_modules/data-uri-to-buffer": {
|
||||
"version": "6.0.2",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/github-from-package": {
|
||||
"version": "0.0.0",
|
||||
"dev": true,
|
||||
@@ -4889,17 +4793,6 @@
|
||||
"version": "0.0.9",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"license": "MIT",
|
||||
@@ -4972,13 +4865,6 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.2.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-core-module": {
|
||||
"version": "2.16.2",
|
||||
"dev": true,
|
||||
@@ -5593,13 +5479,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/netmask": {
|
||||
"version": "2.1.1",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nice-try": {
|
||||
"version": "1.0.5",
|
||||
"dev": true,
|
||||
@@ -5722,34 +5601,6 @@
|
||||
"version": "0.12.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pac-proxy-agent": {
|
||||
"version": "7.2.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tootallnate/quickjs-emscripten": "^0.23.0",
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "^4.3.4",
|
||||
"get-uri": "^6.0.1",
|
||||
"http-proxy-agent": "^7.0.0",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"pac-resolver": "^7.0.1",
|
||||
"socks-proxy-agent": "^8.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/pac-resolver": {
|
||||
"version": "7.0.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"degenerator": "^5.0.0",
|
||||
"netmask": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"license": "(MIT AND Zlib)"
|
||||
@@ -5951,34 +5802,6 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-agent": {
|
||||
"version": "6.5.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "^4.3.4",
|
||||
"http-proxy-agent": "^7.0.1",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"lru-cache": "^7.14.1",
|
||||
"pac-proxy-agent": "^7.1.0",
|
||||
"proxy-from-env": "^1.1.0",
|
||||
"socks-proxy-agent": "^8.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-agent/node_modules/lru-cache": {
|
||||
"version": "7.18.3",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.4",
|
||||
"dev": true,
|
||||
@@ -6321,46 +6144,6 @@
|
||||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/smart-buffer": {
|
||||
"version": "4.2.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6.0.0",
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socks": {
|
||||
"version": "2.8.8",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "^10.1.1",
|
||||
"smart-buffer": "^4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.0.0",
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socks-proxy-agent": {
|
||||
"version": "8.0.5",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "^4.3.4",
|
||||
"socks": "^2.8.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"dev": true,
|
||||
@@ -7107,8 +6890,6 @@
|
||||
"@mistralai/mistralai": "^2.2.0",
|
||||
"openai": "6.26.0",
|
||||
"partial-json": "^0.1.7",
|
||||
"proxy-agent": "^6.5.0",
|
||||
"proxy-from-env": "^1.1.0",
|
||||
"typebox": "^1.1.24"
|
||||
},
|
||||
"bin": {
|
||||
|
||||
@@ -75,8 +75,6 @@
|
||||
"@mistralai/mistralai": "^2.2.0",
|
||||
"openai": "6.26.0",
|
||||
"partial-json": "^0.1.7",
|
||||
"proxy-agent": "^6.5.0",
|
||||
"proxy-from-env": "^1.1.0",
|
||||
"typebox": "^1.1.24"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type ToolConfiguration,
|
||||
ToolResultStatus,
|
||||
} from "@aws-sdk/client-bedrock-runtime";
|
||||
import { NodeHttpHandler } from "@smithy/node-http-handler";
|
||||
import type { DocumentType } from "@smithy/types";
|
||||
import { calculateCost } from "../models.js";
|
||||
import type {
|
||||
@@ -42,6 +43,8 @@ import type {
|
||||
} from "../types.js";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.js";
|
||||
import { parseStreamingJson } from "../utils/json-parse.js";
|
||||
import { createEnvProxyAgents } from "../utils/node-proxy-agent.js";
|
||||
import { hasProxyEnvironment } from "../utils/proxy-env.js";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
|
||||
import { adjustMaxTokensForThinking, buildBaseOptions, clampReasoning } from "./simple-options.js";
|
||||
import { transformMessages } from "./transform-messages.js";
|
||||
@@ -156,30 +159,14 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
process.env.HTTP_PROXY ||
|
||||
process.env.HTTPS_PROXY ||
|
||||
process.env.NO_PROXY ||
|
||||
process.env.http_proxy ||
|
||||
process.env.https_proxy ||
|
||||
process.env.no_proxy
|
||||
) {
|
||||
const nodeHttpHandler = await import("@smithy/node-http-handler");
|
||||
const proxyAgent = await import("proxy-agent");
|
||||
|
||||
const agent = new proxyAgent.ProxyAgent();
|
||||
|
||||
if (hasProxyEnvironment()) {
|
||||
// Bedrock runtime uses NodeHttp2Handler by default since v3.798.0, which is based
|
||||
// on `http2` module and has no support for http agent.
|
||||
// Use NodeHttpHandler to support http agent.
|
||||
config.requestHandler = new nodeHttpHandler.NodeHttpHandler({
|
||||
httpAgent: agent,
|
||||
httpsAgent: agent,
|
||||
});
|
||||
// Use NodeHttpHandler to support HTTP(S) proxy agents.
|
||||
config.requestHandler = new NodeHttpHandler(createEnvProxyAgents());
|
||||
} else if (process.env.AWS_BEDROCK_FORCE_HTTP1 === "1") {
|
||||
// Some custom endpoints require HTTP/1.1 instead of HTTP/2
|
||||
const nodeHttpHandler = await import("@smithy/node-http-handler");
|
||||
config.requestHandler = new nodeHttpHandler.NodeHttpHandler();
|
||||
config.requestHandler = new NodeHttpHandler();
|
||||
}
|
||||
} else {
|
||||
// Non-Node environment (browser): fall back to us-east-1 since
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
} from "../utils/diagnostics.js";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.js";
|
||||
import { headersToRecord } from "../utils/headers.js";
|
||||
import { getProxyForUrl, hasProxyEnvironment } from "../utils/proxy-env.js";
|
||||
import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.js";
|
||||
import { buildBaseOptions } from "./simple-options.js";
|
||||
|
||||
@@ -717,13 +718,7 @@ async function getWebSocketConstructor(): Promise<WebSocketConstructor | null> {
|
||||
|
||||
// bun doesn't respect http proxy envs, ref: https://github.com/oven-sh/bun/issues/15489
|
||||
// TODO: remove this when bun supports proxy envs in websocket.
|
||||
if (
|
||||
process?.versions?.bun &&
|
||||
(process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy)
|
||||
) {
|
||||
const m = await dynamicImport("proxy-from-env");
|
||||
const getProxyForUrl = (m as { getProxyForUrl: (url: string | object | URL) => string }).getProxyForUrl;
|
||||
|
||||
if (process?.versions?.bun && hasProxyEnvironment()) {
|
||||
_cachedWebsocket = class extends WebSocket {
|
||||
constructor(url: string | URL, options?: string | string[] | Record<string, unknown>) {
|
||||
let _opts: Record<string, unknown> = {};
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
import * as http from "node:http";
|
||||
import * as https from "node:https";
|
||||
import * as net from "node:net";
|
||||
import type { Duplex } from "node:stream";
|
||||
import * as tls from "node:tls";
|
||||
import { getProxyForUrl } from "./proxy-env.js";
|
||||
|
||||
type ProxyResolver = (url: string) => string;
|
||||
type ConnectionCallback = (error: Error | null, socket: Duplex) => void;
|
||||
|
||||
interface AgentWithAddRequest {
|
||||
addRequest(req: http.ClientRequest, options: http.RequestOptions): void;
|
||||
}
|
||||
|
||||
type ProxyRequestOptions = http.RequestOptions & {
|
||||
servername?: string;
|
||||
};
|
||||
|
||||
export interface EnvHttpProxyAgentOptions extends http.AgentOptions {
|
||||
getProxyForUrl?: ProxyResolver;
|
||||
}
|
||||
|
||||
export interface EnvHttpsProxyAgentOptions extends https.AgentOptions {
|
||||
getProxyForUrl?: ProxyResolver;
|
||||
}
|
||||
|
||||
export interface EnvProxyAgentsOptions extends http.AgentOptions, https.AgentOptions {
|
||||
getProxyForUrl?: ProxyResolver;
|
||||
}
|
||||
|
||||
export interface EnvProxyAgents {
|
||||
httpAgent: http.Agent;
|
||||
httpsAgent: https.Agent;
|
||||
}
|
||||
|
||||
export const UNSUPPORTED_PROXY_PROTOCOL_MESSAGE =
|
||||
"Unsupported proxy protocol. SOCKS and PAC proxy URLs are no longer supported; use an HTTP or HTTPS proxy URL.";
|
||||
|
||||
const MAX_CONNECT_RESPONSE_BYTES = 64 * 1024;
|
||||
|
||||
function toPort(value: string | number | null | undefined, defaultPort: number): number {
|
||||
if (typeof value === "number") return value;
|
||||
if (typeof value === "string" && value.length > 0) return Number.parseInt(value, 10);
|
||||
return defaultPort;
|
||||
}
|
||||
|
||||
function stripPort(host: string): string {
|
||||
if (host.startsWith("[")) {
|
||||
const end = host.indexOf("]");
|
||||
return end === -1 ? host : host.slice(1, end);
|
||||
}
|
||||
|
||||
const firstColon = host.indexOf(":");
|
||||
const lastColon = host.lastIndexOf(":");
|
||||
if (firstColon !== -1 && firstColon === lastColon) {
|
||||
const maybePort = host.slice(lastColon + 1);
|
||||
if (/^\d+$/.test(maybePort)) return host.slice(0, lastColon);
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
function getHostname(options: ProxyRequestOptions): string {
|
||||
const value = options.hostname || options.host || "localhost";
|
||||
return stripPort(String(value));
|
||||
}
|
||||
|
||||
function formatHost(hostname: string): string {
|
||||
return net.isIPv6(hostname) ? `[${hostname}]` : hostname;
|
||||
}
|
||||
|
||||
function buildTargetUrl(protocol: "http:" | "https:", options: ProxyRequestOptions): string {
|
||||
const defaultPort = protocol === "https:" ? 443 : 80;
|
||||
const hostname = getHostname(options);
|
||||
const port = toPort(options.port, defaultPort);
|
||||
const portSuffix = port === defaultPort ? "" : `:${port}`;
|
||||
return `${protocol}//${formatHost(hostname)}${portSuffix}`;
|
||||
}
|
||||
|
||||
function callConnectionCallbackWithError(callback: ConnectionCallback, error: Error): void {
|
||||
(callback as (error: Error) => void)(error);
|
||||
}
|
||||
|
||||
function addRequest(agent: http.Agent, req: http.ClientRequest, options: http.RequestOptions): void {
|
||||
(http.Agent.prototype as unknown as AgentWithAddRequest).addRequest.call(agent, req, options);
|
||||
}
|
||||
|
||||
function parseProxyUrl(proxy: string): URL {
|
||||
const proxyUrl = new URL(proxy);
|
||||
if (proxyUrl.protocol !== "http:" && proxyUrl.protocol !== "https:") {
|
||||
throw new Error(`${UNSUPPORTED_PROXY_PROTOCOL_MESSAGE} Got ${proxyUrl.protocol}`);
|
||||
}
|
||||
return proxyUrl;
|
||||
}
|
||||
|
||||
function getProxyAuthorization(proxyUrl: URL): string | undefined {
|
||||
if (!proxyUrl.username && !proxyUrl.password) return undefined;
|
||||
const auth = `${decodeURIComponent(proxyUrl.username)}:${decodeURIComponent(proxyUrl.password)}`;
|
||||
return `Basic ${Buffer.from(auth).toString("base64")}`;
|
||||
}
|
||||
|
||||
function setProxyRequestHeaders(req: http.ClientRequest, proxyUrl: URL, keepAlive: boolean): void {
|
||||
const authorization = getProxyAuthorization(proxyUrl);
|
||||
if (authorization) {
|
||||
req.setHeader("Proxy-Authorization", authorization);
|
||||
}
|
||||
if (!req.hasHeader("Proxy-Connection")) {
|
||||
req.setHeader("Proxy-Connection", keepAlive ? "Keep-Alive" : "close");
|
||||
}
|
||||
}
|
||||
|
||||
function hasLeadingUrlScheme(value: string): boolean {
|
||||
return /^[a-z][a-z\d+.-]*:\/\//i.test(value);
|
||||
}
|
||||
|
||||
function getHttpRequestUrl(req: http.ClientRequest, options: ProxyRequestOptions): string {
|
||||
const hostHeader = req.getHeader("host");
|
||||
const host = Array.isArray(hostHeader) ? hostHeader[0] : hostHeader;
|
||||
const fallbackHost = formatHost(getHostname(options));
|
||||
const baseHost = typeof host === "string" && host.length > 0 ? host : fallbackHost;
|
||||
const url = hasLeadingUrlScheme(req.path) ? new URL(req.path) : new URL(req.path, `http://${baseHost}`);
|
||||
const port = toPort(options.port, 80);
|
||||
if (!url.port && port !== 80) {
|
||||
url.port = String(port);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function getProxyConnectionOptions(proxyUrl: URL): net.NetConnectOpts & tls.ConnectionOptions {
|
||||
const host = proxyUrl.hostname.replace(/^\[|\]$/g, "");
|
||||
const port = proxyUrl.port ? Number.parseInt(proxyUrl.port, 10) : proxyUrl.protocol === "https:" ? 443 : 80;
|
||||
return {
|
||||
ALPNProtocols: ["http/1.1"],
|
||||
host,
|
||||
port,
|
||||
...(proxyUrl.protocol === "https:" && !net.isIP(host) ? { servername: host } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function openProxySocket(proxyUrl: URL): Promise<net.Socket> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const connectOptions = getProxyConnectionOptions(proxyUrl);
|
||||
const socket: net.Socket =
|
||||
proxyUrl.protocol === "https:" ? tls.connect(connectOptions) : net.connect(connectOptions);
|
||||
|
||||
const cleanup = () => {
|
||||
socket.removeListener("connect", onConnect);
|
||||
socket.removeListener("secureConnect", onConnect);
|
||||
socket.removeListener("error", onError);
|
||||
};
|
||||
const onConnect = () => {
|
||||
cleanup();
|
||||
resolve(socket);
|
||||
};
|
||||
const onError = (error: Error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
|
||||
socket.once(proxyUrl.protocol === "https:" ? "secureConnect" : "connect", onConnect);
|
||||
socket.once("error", onError);
|
||||
});
|
||||
}
|
||||
|
||||
interface ProxyConnectResponse {
|
||||
statusCode: number;
|
||||
statusText: string;
|
||||
headers: http.IncomingHttpHeaders;
|
||||
}
|
||||
|
||||
function parseProxyConnectResponse(socket: net.Socket): Promise<ProxyConnectResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const buffers: Buffer[] = [];
|
||||
let buffersLength = 0;
|
||||
|
||||
const cleanup = () => {
|
||||
socket.removeListener("data", onData);
|
||||
socket.removeListener("end", onEnd);
|
||||
socket.removeListener("error", onError);
|
||||
};
|
||||
const onError = (error: Error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
const onEnd = () => {
|
||||
cleanup();
|
||||
reject(new Error("Proxy connection ended before receiving CONNECT response"));
|
||||
};
|
||||
const onData = (chunk: Buffer) => {
|
||||
buffers.push(chunk);
|
||||
buffersLength += chunk.length;
|
||||
if (buffersLength > MAX_CONNECT_RESPONSE_BYTES) {
|
||||
cleanup();
|
||||
reject(new Error("Proxy CONNECT response exceeded the maximum header size"));
|
||||
return;
|
||||
}
|
||||
|
||||
const buffered = Buffer.concat(buffers, buffersLength);
|
||||
const endOfHeaders = buffered.indexOf("\r\n\r\n");
|
||||
if (endOfHeaders === -1) return;
|
||||
|
||||
cleanup();
|
||||
const extra = buffered.subarray(endOfHeaders + 4);
|
||||
if (extra.length > 0) {
|
||||
socket.unshift(extra);
|
||||
}
|
||||
|
||||
try {
|
||||
resolve(parseProxyConnectHeaders(buffered.subarray(0, endOfHeaders).toString("ascii")));
|
||||
} catch (error) {
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
};
|
||||
|
||||
socket.on("data", onData);
|
||||
socket.once("end", onEnd);
|
||||
socket.once("error", onError);
|
||||
});
|
||||
}
|
||||
|
||||
function parseProxyConnectHeaders(headerText: string): ProxyConnectResponse {
|
||||
const [statusLine, ...headerLines] = headerText.split("\r\n");
|
||||
const statusMatch = statusLine?.match(/^HTTP\/\d(?:\.\d)?\s+(\d{3})(?:\s+(.*))?$/);
|
||||
if (!statusMatch) {
|
||||
throw new Error("Invalid proxy CONNECT response status line");
|
||||
}
|
||||
|
||||
const headers: http.IncomingHttpHeaders = {};
|
||||
for (const line of headerLines) {
|
||||
if (!line) continue;
|
||||
const colon = line.indexOf(":");
|
||||
if (colon === -1) throw new Error(`Invalid proxy CONNECT response header: ${line}`);
|
||||
const key = line.slice(0, colon).toLowerCase();
|
||||
const value = line.slice(colon + 1).trimStart();
|
||||
const current = headers[key];
|
||||
if (typeof current === "string") {
|
||||
headers[key] = [current, value];
|
||||
} else if (Array.isArray(current)) {
|
||||
current.push(value);
|
||||
} else {
|
||||
headers[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: Number.parseInt(statusMatch[1], 10),
|
||||
statusText: statusMatch[2] || "",
|
||||
headers,
|
||||
};
|
||||
}
|
||||
|
||||
function buildConnectPayload(targetHost: string, targetPort: number, proxyUrl: URL, keepAlive: boolean): string {
|
||||
const host = formatHost(targetHost);
|
||||
const headers: Record<string, string> = {
|
||||
Host: `${host}:${targetPort}`,
|
||||
"Proxy-Connection": keepAlive ? "Keep-Alive" : "close",
|
||||
};
|
||||
const authorization = getProxyAuthorization(proxyUrl);
|
||||
if (authorization) {
|
||||
headers["Proxy-Authorization"] = authorization;
|
||||
}
|
||||
|
||||
let payload = `CONNECT ${host}:${targetPort} HTTP/1.1\r\n`;
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
payload += `${name}: ${value}\r\n`;
|
||||
}
|
||||
return `${payload}\r\n`;
|
||||
}
|
||||
|
||||
function buildTlsOptions(options: ProxyRequestOptions, socket: net.Socket): tls.ConnectionOptions {
|
||||
const {
|
||||
host: _host,
|
||||
hostname: _hostname,
|
||||
path: _path,
|
||||
port: _port,
|
||||
protocol: _protocol,
|
||||
socketPath: _socketPath,
|
||||
...tlsOptions
|
||||
} = options;
|
||||
const targetHost = getHostname(options);
|
||||
return {
|
||||
...tlsOptions,
|
||||
socket,
|
||||
...(options.servername === undefined && !net.isIP(targetHost) ? { servername: targetHost } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function connectHttpsThroughProxy(
|
||||
proxyUrl: URL,
|
||||
options: ProxyRequestOptions,
|
||||
keepAlive: boolean,
|
||||
): Promise<tls.TLSSocket> {
|
||||
const socket = await openProxySocket(proxyUrl);
|
||||
let upgradedToTls = false;
|
||||
|
||||
try {
|
||||
const targetHost = getHostname(options);
|
||||
const targetPort = toPort(options.port, 443);
|
||||
const responsePromise = parseProxyConnectResponse(socket);
|
||||
socket.write(buildConnectPayload(targetHost, targetPort, proxyUrl, keepAlive));
|
||||
const response = await responsePromise;
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
const statusText = response.statusText ? ` ${response.statusText}` : "";
|
||||
throw new Error(`Proxy CONNECT failed with status ${response.statusCode}${statusText}`);
|
||||
}
|
||||
|
||||
const tlsSocket = tls.connect(buildTlsOptions(options, socket));
|
||||
upgradedToTls = true;
|
||||
return tlsSocket;
|
||||
} catch (error) {
|
||||
if (!upgradedToTls) {
|
||||
socket.destroy();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export class EnvHttpProxyAgent extends http.Agent {
|
||||
private readonly keepAliveEnabled: boolean;
|
||||
private readonly resolveProxy: ProxyResolver;
|
||||
|
||||
constructor(options: EnvHttpProxyAgentOptions = {}) {
|
||||
const { getProxyForUrl: resolver, ...agentOptions } = options;
|
||||
super(agentOptions);
|
||||
this.keepAliveEnabled = options.keepAlive ?? false;
|
||||
this.resolveProxy = resolver || getProxyForUrl;
|
||||
}
|
||||
|
||||
addRequest(req: http.ClientRequest, options: ProxyRequestOptions): void {
|
||||
let proxyUrl: URL | undefined;
|
||||
try {
|
||||
const proxy = this.resolveProxy(buildTargetUrl("http:", options));
|
||||
proxyUrl = proxy ? parseProxyUrl(proxy) : undefined;
|
||||
} catch (error) {
|
||||
const requestError = error instanceof Error ? error : new Error(String(error));
|
||||
queueMicrotask(() => {
|
||||
req.emit("error", requestError);
|
||||
req.destroy();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (proxyUrl) {
|
||||
req.path = getHttpRequestUrl(req, options);
|
||||
setProxyRequestHeaders(req, proxyUrl, this.keepAliveEnabled);
|
||||
}
|
||||
|
||||
addRequest(this, req, options);
|
||||
}
|
||||
|
||||
override createConnection(options: ProxyRequestOptions, callback?: ConnectionCallback): Duplex | null | undefined {
|
||||
try {
|
||||
const proxy = this.resolveProxy(buildTargetUrl("http:", options));
|
||||
if (!proxy) {
|
||||
return super.createConnection(options, callback);
|
||||
}
|
||||
|
||||
const proxyUrl = parseProxyUrl(proxy);
|
||||
return proxyUrl.protocol === "https:"
|
||||
? tls.connect(getProxyConnectionOptions(proxyUrl))
|
||||
: net.connect(getProxyConnectionOptions(proxyUrl));
|
||||
} catch (error) {
|
||||
if (callback) {
|
||||
callConnectionCallbackWithError(callback, error instanceof Error ? error : new Error(String(error)));
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class EnvHttpsProxyAgent extends https.Agent {
|
||||
private readonly keepAliveEnabled: boolean;
|
||||
private readonly resolveProxy: ProxyResolver;
|
||||
|
||||
constructor(options: EnvHttpsProxyAgentOptions = {}) {
|
||||
const { getProxyForUrl: resolver, ...agentOptions } = options;
|
||||
super(agentOptions);
|
||||
this.keepAliveEnabled = options.keepAlive ?? false;
|
||||
this.resolveProxy = resolver || getProxyForUrl;
|
||||
}
|
||||
|
||||
override createConnection(options: ProxyRequestOptions, callback?: ConnectionCallback): Duplex | null | undefined {
|
||||
try {
|
||||
const proxy = this.resolveProxy(buildTargetUrl("https:", options));
|
||||
if (!proxy) {
|
||||
return super.createConnection(options, callback);
|
||||
}
|
||||
|
||||
const proxyUrl = parseProxyUrl(proxy);
|
||||
if (!callback) {
|
||||
throw new Error("A callback is required for HTTPS proxy connections");
|
||||
}
|
||||
|
||||
connectHttpsThroughProxy(proxyUrl, options, this.keepAliveEnabled).then(
|
||||
(socket) => callback(null, socket),
|
||||
(error: unknown) =>
|
||||
callConnectionCallbackWithError(callback, error instanceof Error ? error : new Error(String(error))),
|
||||
);
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
if (callback) {
|
||||
callConnectionCallbackWithError(callback, error instanceof Error ? error : new Error(String(error)));
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createEnvProxyAgents(options: EnvProxyAgentsOptions = {}): EnvProxyAgents {
|
||||
return {
|
||||
httpAgent: new EnvHttpProxyAgent(options),
|
||||
httpsAgent: new EnvHttpsProxyAgent(options),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* Adapted from proxy-from-env.
|
||||
*
|
||||
* The MIT License
|
||||
*
|
||||
* Copyright (C) 2016-2018 Rob Wu <rob@robwu.nl>
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
* of the Software, and to permit persons to whom the Software is furnished to do
|
||||
* so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
const DEFAULT_PORTS: Record<string, number> = {
|
||||
ftp: 21,
|
||||
gopher: 70,
|
||||
http: 80,
|
||||
https: 443,
|
||||
ws: 80,
|
||||
wss: 443,
|
||||
};
|
||||
|
||||
const PROXY_ENV_KEYS = [
|
||||
"npm_config_http_proxy",
|
||||
"http_proxy",
|
||||
"npm_config_https_proxy",
|
||||
"https_proxy",
|
||||
"npm_config_ws_proxy",
|
||||
"ws_proxy",
|
||||
"npm_config_wss_proxy",
|
||||
"wss_proxy",
|
||||
"npm_config_proxy",
|
||||
"all_proxy",
|
||||
] as const;
|
||||
|
||||
function getEnv(key: string): string {
|
||||
if (typeof process === "undefined") return "";
|
||||
return process.env[key.toLowerCase()] || process.env[key.toUpperCase()] || "";
|
||||
}
|
||||
|
||||
function getDefaultPort(protocol: string): number {
|
||||
return DEFAULT_PORTS[protocol] || 0;
|
||||
}
|
||||
|
||||
function parseUrl(value: string | URL): URL | undefined {
|
||||
try {
|
||||
return typeof value === "string" ? new URL(value) : value;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldProxy(hostname: string, port: number): boolean {
|
||||
const noProxy = (getEnv("npm_config_no_proxy") || getEnv("no_proxy")).toLowerCase();
|
||||
if (!noProxy) return true;
|
||||
if (noProxy === "*") return false;
|
||||
|
||||
const lowerHostname = hostname.toLowerCase();
|
||||
return noProxy.split(/[,\s]/).every((entry) => {
|
||||
if (!entry) return true;
|
||||
|
||||
const match = entry.match(/^(.+):(\d+)$/);
|
||||
let entryHostname = match ? match[1] : entry;
|
||||
const entryPort = match ? Number.parseInt(match[2], 10) : 0;
|
||||
if (entryPort && entryPort !== port) return true;
|
||||
|
||||
if (!/^[.*]/.test(entryHostname)) {
|
||||
return lowerHostname !== entryHostname;
|
||||
}
|
||||
|
||||
if (entryHostname.startsWith("*")) {
|
||||
entryHostname = entryHostname.slice(1);
|
||||
}
|
||||
return !lowerHostname.endsWith(entryHostname);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the HTTP proxy URL for a target URL from standard proxy environment variables.
|
||||
*
|
||||
* Matches proxy-from-env precedence for the env vars Pi has historically supported:
|
||||
* npm_config_<scheme>_proxy, <scheme>_proxy, npm_config_proxy, all_proxy, and no_proxy.
|
||||
*/
|
||||
export function getProxyForUrl(value: string | URL): string {
|
||||
const parsedUrl = parseUrl(value);
|
||||
if (!parsedUrl?.protocol || !parsedUrl.host) return "";
|
||||
|
||||
const protocol = parsedUrl.protocol.slice(0, -1);
|
||||
const hostname = parsedUrl.host.replace(/:\d*$/, "").toLowerCase();
|
||||
if (!hostname) return "";
|
||||
|
||||
const port = parsedUrl.port ? Number.parseInt(parsedUrl.port, 10) : getDefaultPort(protocol);
|
||||
if (!shouldProxy(hostname, port)) return "";
|
||||
|
||||
let proxy =
|
||||
getEnv(`npm_config_${protocol}_proxy`) ||
|
||||
getEnv(`${protocol}_proxy`) ||
|
||||
getEnv("npm_config_proxy") ||
|
||||
getEnv("all_proxy");
|
||||
if (proxy && !proxy.includes("://")) {
|
||||
proxy = `${protocol}://${proxy}`;
|
||||
}
|
||||
return proxy;
|
||||
}
|
||||
|
||||
export function hasProxyEnvironment(): boolean {
|
||||
if (typeof process === "undefined") return false;
|
||||
return PROXY_ENV_KEYS.some((key) => getEnv(key).length > 0);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { once } from "node:events";
|
||||
import * as http from "node:http";
|
||||
import * as https from "node:https";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import * as net from "node:net";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { EnvHttpProxyAgent, EnvHttpsProxyAgent } from "../src/utils/node-proxy-agent.js";
|
||||
|
||||
const TEST_KEY = `-----BEGIN PRIVATE KEY-----
|
||||
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC6BQBGN7QX+PBv
|
||||
gVIxRkVmk/1e1v+njs6TusQrPFfiGFqx4HqQ8aavyo24v5dHsLHcltxP4u8Ryt9/
|
||||
Fo5sBhpaVCTvbGkUyfv8HUz2m9gSEKHxyRA3d0W2JlSiFqPzv0JhapxLjRfOdxc5
|
||||
8FPx4MqJDQDnExcemTmvBCtyiWL/xmwh//cqm1j3H52ri+7W6olaGkil45xGKE0A
|
||||
8IgMvG+VgzTNnmKSeYRGeWVAW4RdjVRQcntDCImUWDGAh1P3hxsoz9C5ehhvJbQD
|
||||
vXM8c/XRMAaOQ/iGxpjqs/jxRZZpceZkYYMjOKpP1gJfEwVYxOWKKQ9eDSgJrf2N
|
||||
iIOqYPzfAgMBAAECggEBAKRRNJvSKsieQq0MvDEAlN92zHKRUoWNdVczvINAq5Tx
|
||||
1HOaCkqs24QfVa8jtptCLurfbD85A9tgrKoTn702auLtvy6rQet2PJvIuiqBIY1b
|
||||
meH9rNCCEFdFZq9DdpIimZ150hQ+zw+3TRIGA/D+k4oarXhw+ZQy36yE72geig/K
|
||||
kjgWoSX6ry78AOtlKnJGUJqgyzW/220ljzuJSW3Ry6aTDRUMkUi8k8z6o8Ku8YnM
|
||||
PIW3eN9zh3qAsT/OGf2UFE1bnSj1tKXTqXPvt5h+9XiZ3rvQreyQW9Ns0kNJCHSZ
|
||||
R775WeI3LpEhq6kFCcEGTozCLjgZ9eLI8zk7fLFF0KECgYEA3aVsqczZ3Py+3jtO
|
||||
vbwCb5jJ+VmF4moC7FMvaXp6v+Cy35D5LKTnCvELJRChe83jzzH23m16nZQMT+cp
|
||||
3yq+H1m6jgXhzbgDXwri/dngVpjLmoBp/P421q6Sx8OhYokRVhRwLlB9hqHnq0It
|
||||
59uKUEyZ1/+xtrqpeYrzAUc1We8CgYEA1tn3auc3toKt+dE5Zxw9HtmCe9FQ/VUX
|
||||
MbzYP0bXTl/JcwKulYPODhT8Q2ihspea88GCFqHt0MULPFD7hnaW0d9G9DBg3xH2
|
||||
dqKJnZ7vl8+PdN5mhumjhPaST5av6v40mP8EjOkerW5UzkbTorfRKFCesZlEhd0d
|
||||
I01jvSdePBECgYBPuw0uu7D3TLgAS0dU+0fJCyZEm06NFuN8TaQ1hkiXs2XFBGqO
|
||||
A7fU+Mawi537YiH7y5Zphupfuvz+1UH7tG1165ovMrB6hyI3Uzw6YuDPZeF/74ew
|
||||
6WWirmPAln/8aSAiXfHIx02QW7dxpLnMuO21WwjQaXttJxKF1VT69bmcYwKBgQCb
|
||||
UFYbYapUtYMu5KCqeS917ab1+wqhF7H8spdgpsVeUsA98+JhEzcR1vnFgQ3jHNVX
|
||||
ALwZwFU6ZjcJE0HAolnEvbN9MrvUhhe1CyqQVyS8ib8arOtQ+/TJWbXK8xOYvMsp
|
||||
DrErbBpRJUEJHQpAxsDcc+tEV5fBbWZy7q77PkpRAQKBgGd3cYOMIyB3EbFpZTEc
|
||||
suv6urzMY+ezHWVdDTwzb2O9TaPUY8ovF0qjp0aD5u+zeUfZ8Sg3XVP0ceriLubw
|
||||
bIBazJUHN2zO8W1iMSnQ1TqbHn02IR/ySMJbE1BH2KGEDKbSIcRPMNNVqJx+Civ+
|
||||
5r0i4ZclQDLWp6VaabdukwJ4
|
||||
-----END PRIVATE KEY-----`;
|
||||
|
||||
const TEST_CERT = `-----BEGIN CERTIFICATE-----
|
||||
MIICpDCCAYwCCQCAip8jY3MesjANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAls
|
||||
b2NhbGhvc3QwHhcNMjYwNTEzMTIwNTQwWhcNMzYwNTEwMTIwNTQwWjAUMRIwEAYD
|
||||
VQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC6
|
||||
BQBGN7QX+PBvgVIxRkVmk/1e1v+njs6TusQrPFfiGFqx4HqQ8aavyo24v5dHsLHc
|
||||
ltxP4u8Ryt9/Fo5sBhpaVCTvbGkUyfv8HUz2m9gSEKHxyRA3d0W2JlSiFqPzv0Jh
|
||||
apxLjRfOdxc58FPx4MqJDQDnExcemTmvBCtyiWL/xmwh//cqm1j3H52ri+7W6ola
|
||||
Gkil45xGKE0A8IgMvG+VgzTNnmKSeYRGeWVAW4RdjVRQcntDCImUWDGAh1P3hxso
|
||||
z9C5ehhvJbQDvXM8c/XRMAaOQ/iGxpjqs/jxRZZpceZkYYMjOKpP1gJfEwVYxOWK
|
||||
KQ9eDSgJrf2NiIOqYPzfAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAAtg3Gs68TVQ
|
||||
EGPGCnjoR7CFET9/yyaxlehI4djJsAHK3CM82lFLcu64NzkmbirSLzFbU2eW/LcY
|
||||
R/er/QML1L6t0cUOhIA9sM0nMakZ0gPHZwRAHTYySIUU3qfLPobSQbKLpCfErpzp
|
||||
sfcIpR93fxHQUTx58D2GwwBCFhTzaH4dBdykArzuMXZ8Ywd+btD3HVPF9ARZIFBp
|
||||
R0voLArplJfZYx9P73nbSqfN1pycE1iTlvsjbsrihuR/3hKOJfOEkIu242hasvVw
|
||||
R+xvp78sGTgV8McCWqM7uuDg41Iiv9osEgVP81TxpcoNksWuJVduEl9gX/vXxglC
|
||||
0YhuPJ0qWj8=
|
||||
-----END CERTIFICATE-----`;
|
||||
|
||||
const servers: Array<http.Server | https.Server> = [];
|
||||
const sockets = new Set<net.Socket>();
|
||||
|
||||
async function listen(server: http.Server | https.Server): Promise<number> {
|
||||
server.on("connection", (socket) => {
|
||||
sockets.add(socket);
|
||||
socket.once("close", () => sockets.delete(socket));
|
||||
});
|
||||
server.listen(0, "127.0.0.1");
|
||||
servers.push(server);
|
||||
await once(server, "listening");
|
||||
return (server.address() as AddressInfo).port;
|
||||
}
|
||||
|
||||
function closeServer(server: http.Server | https.Server): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function requestText(
|
||||
client: typeof http | typeof https,
|
||||
options: http.RequestOptions | https.RequestOptions,
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = client.request(options, (response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
response.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
response.once("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
||||
});
|
||||
req.once("error", reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const socket of sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
sockets.clear();
|
||||
await Promise.all(servers.splice(0).map((server) => closeServer(server)));
|
||||
});
|
||||
|
||||
describe("node proxy agents", () => {
|
||||
it("sends HTTP requests through an HTTP proxy", async () => {
|
||||
const target = http.createServer((_request, response) => {
|
||||
response.end("direct");
|
||||
});
|
||||
const targetPort = await listen(target);
|
||||
|
||||
let proxiedUrl: string | undefined;
|
||||
let proxyAuthorization: string | string[] | undefined;
|
||||
const proxy = http.createServer((request, response) => {
|
||||
proxiedUrl = request.url;
|
||||
proxyAuthorization = request.headers["proxy-authorization"];
|
||||
response.end("proxied");
|
||||
});
|
||||
const proxyPort = await listen(proxy);
|
||||
|
||||
const agent = new EnvHttpProxyAgent({
|
||||
getProxyForUrl: () => `http://user:pass@127.0.0.1:${proxyPort}`,
|
||||
});
|
||||
|
||||
const text = await requestText(http, {
|
||||
agent,
|
||||
hostname: "127.0.0.1",
|
||||
path: "/callback?next=https://example.com/after",
|
||||
port: targetPort,
|
||||
});
|
||||
agent.destroy();
|
||||
|
||||
expect(text).toBe("proxied");
|
||||
expect(proxiedUrl).toBe(`http://127.0.0.1:${targetPort}/callback?next=https://example.com/after`);
|
||||
expect(proxyAuthorization).toBe(`Basic ${Buffer.from("user:pass").toString("base64")}`);
|
||||
});
|
||||
|
||||
it("tunnels HTTPS requests through an HTTP proxy", async () => {
|
||||
const target = https.createServer({ key: TEST_KEY, cert: TEST_CERT }, (_request, response) => {
|
||||
response.end("secure target");
|
||||
});
|
||||
const targetPort = await listen(target);
|
||||
|
||||
let connectUrl: string | undefined;
|
||||
let proxyAuthorization: string | string[] | undefined;
|
||||
const proxy = http.createServer();
|
||||
proxy.on("connect", (request, clientSocket, head) => {
|
||||
connectUrl = request.url;
|
||||
proxyAuthorization = request.headers["proxy-authorization"];
|
||||
|
||||
const serverSocket = net.connect(targetPort, "127.0.0.1", () => {
|
||||
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||
if (head.length > 0) serverSocket.write(head);
|
||||
serverSocket.pipe(clientSocket);
|
||||
clientSocket.pipe(serverSocket);
|
||||
});
|
||||
serverSocket.once("error", (error) => clientSocket.destroy(error));
|
||||
clientSocket.once("error", () => serverSocket.destroy());
|
||||
});
|
||||
const proxyPort = await listen(proxy);
|
||||
|
||||
const agent = new EnvHttpsProxyAgent({
|
||||
getProxyForUrl: () => `http://user:pass@127.0.0.1:${proxyPort}`,
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
|
||||
const text = await requestText(https, {
|
||||
agent,
|
||||
hostname: "127.0.0.1",
|
||||
path: "/secure",
|
||||
port: targetPort,
|
||||
});
|
||||
agent.destroy();
|
||||
|
||||
expect(text).toBe("secure target");
|
||||
expect(connectUrl).toBe(`127.0.0.1:${targetPort}`);
|
||||
expect(proxyAuthorization).toBe(`Basic ${Buffer.from("user:pass").toString("base64")}`);
|
||||
});
|
||||
|
||||
it("closes the proxy socket when CONNECT parsing fails", async () => {
|
||||
const proxy = http.createServer();
|
||||
proxy.on("connect", (_request, clientSocket) => {
|
||||
clientSocket.write("not-http\r\n\r\n");
|
||||
});
|
||||
const proxyPort = await listen(proxy);
|
||||
|
||||
let destroyedProxySocket = false;
|
||||
const originalDestroy = net.Socket.prototype.destroy;
|
||||
net.Socket.prototype.destroy = function patchedDestroy(this: net.Socket, error?: Error): net.Socket {
|
||||
if (this.remotePort === proxyPort) {
|
||||
destroyedProxySocket = true;
|
||||
}
|
||||
return originalDestroy.call(this, error);
|
||||
};
|
||||
|
||||
const agent = new EnvHttpsProxyAgent({
|
||||
getProxyForUrl: () => `http://127.0.0.1:${proxyPort}`,
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
requestText(https, {
|
||||
agent,
|
||||
hostname: "127.0.0.1",
|
||||
path: "/secure",
|
||||
port: 443,
|
||||
}),
|
||||
).rejects.toThrow(/Invalid proxy CONNECT response status line/);
|
||||
expect(destroyedProxySocket).toBe(true);
|
||||
} finally {
|
||||
net.Socket.prototype.destroy = originalDestroy;
|
||||
agent.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects SOCKS and PAC proxy URLs explicitly", async () => {
|
||||
const agent = new EnvHttpProxyAgent({
|
||||
getProxyForUrl: () => "socks5://127.0.0.1:1080",
|
||||
});
|
||||
|
||||
await expect(
|
||||
requestText(http, {
|
||||
agent,
|
||||
hostname: "127.0.0.1",
|
||||
path: "/",
|
||||
port: 9,
|
||||
}),
|
||||
).rejects.toThrow(/SOCKS and PAC proxy URLs are no longer supported/);
|
||||
agent.destroy();
|
||||
|
||||
const pacAgent = new EnvHttpProxyAgent({
|
||||
getProxyForUrl: () => "pac+http://127.0.0.1/proxy.pac",
|
||||
});
|
||||
|
||||
await expect(
|
||||
requestText(http, {
|
||||
agent: pacAgent,
|
||||
hostname: "127.0.0.1",
|
||||
path: "/",
|
||||
port: 9,
|
||||
}),
|
||||
).rejects.toThrow(/SOCKS and PAC proxy URLs are no longer supported/);
|
||||
pacAgent.destroy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { getProxyForUrl, hasProxyEnvironment } from "../src/utils/proxy-env.js";
|
||||
|
||||
const PROXY_ENV_KEYS = [
|
||||
"HTTP_PROXY",
|
||||
"http_proxy",
|
||||
"HTTPS_PROXY",
|
||||
"https_proxy",
|
||||
"ALL_PROXY",
|
||||
"all_proxy",
|
||||
"NO_PROXY",
|
||||
"no_proxy",
|
||||
"npm_config_http_proxy",
|
||||
"npm_config_https_proxy",
|
||||
"npm_config_proxy",
|
||||
"npm_config_no_proxy",
|
||||
];
|
||||
|
||||
const savedEnv = new Map<string, string | undefined>();
|
||||
|
||||
function clearProxyEnv(): void {
|
||||
for (const key of PROXY_ENV_KEYS) {
|
||||
if (!savedEnv.has(key)) savedEnv.set(key, process.env[key]);
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
|
||||
function setProxyEnv(env: Record<string, string>): void {
|
||||
clearProxyEnv();
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of PROXY_ENV_KEYS) {
|
||||
const value = savedEnv.get(key);
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
savedEnv.clear();
|
||||
});
|
||||
|
||||
describe("proxy env resolution", () => {
|
||||
it("uses scheme-specific proxy variables", () => {
|
||||
setProxyEnv({ HTTPS_PROXY: "http://proxy.example:8080" });
|
||||
|
||||
expect(getProxyForUrl("https://api.example/v1")).toBe("http://proxy.example:8080");
|
||||
expect(hasProxyEnvironment()).toBe(true);
|
||||
});
|
||||
|
||||
it("adds the target scheme when a proxy URL has no scheme", () => {
|
||||
setProxyEnv({ http_proxy: "proxy.example:8080" });
|
||||
|
||||
expect(getProxyForUrl("http://api.example/v1")).toBe("http://proxy.example:8080");
|
||||
});
|
||||
|
||||
it("honors NO_PROXY exact hosts, suffixes, wildcards, and ports", () => {
|
||||
setProxyEnv({
|
||||
HTTPS_PROXY: "http://proxy.example:8080",
|
||||
NO_PROXY: "api.example,.internal,*.corp,other.example:8443",
|
||||
});
|
||||
|
||||
expect(getProxyForUrl("https://api.example/v1")).toBe("");
|
||||
expect(getProxyForUrl("https://service.internal/v1")).toBe("");
|
||||
expect(getProxyForUrl("https://build.corp/v1")).toBe("");
|
||||
expect(getProxyForUrl("https://other.example:443/v1")).toBe("http://proxy.example:8080");
|
||||
expect(getProxyForUrl("https://other.example:8443/v1")).toBe("");
|
||||
});
|
||||
|
||||
it("falls back to ALL_PROXY", () => {
|
||||
setProxyEnv({ ALL_PROXY: "http://proxy.example:8080" });
|
||||
|
||||
expect(getProxyForUrl("https://api.example/v1")).toBe("http://proxy.example:8080");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user