Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(proxy): support no_proxy #109

Merged
merged 3 commits into from
Dec 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ Sometimes you want to explicitly use none native (`node-fetch`) implementation o

You have two ways to do this:

- Set `FORCE_NODE_FETCH` environment variable before starting the application.
- Set the `FORCE_NODE_FETCH` environment variable before starting the application.
- Import from `node-fetch-native/node`

## Polyfill support
Expand All @@ -111,7 +111,9 @@ Node.js has no built-in support for HTTP Proxies for fetch (see [nodejs/undici#1

This package bundles a compact and simple proxy-supported solution for both Node.js versions without native fetch using [HTTP Agent](https://github.com/TooTallNate/proxy-agents/tree/main/packages/proxy-agent) and versions with native fetch using [Undici Proxy Agent](https://undici.nodejs.org/#/docs/api/ProxyAgent).

By default, `https_proxy`, `http_proxy`, `HTTPS_PROXY`, and `HTTP_PROXY` environment variables will be checked and used (in order) for the proxy and if not any of them are set, the proxy will be disabled.
By default, `https_proxy`, `http_proxy`, `HTTPS_PROXY`, and `HTTP_PROXY` environment variables will be checked and used (in order) for the proxy and if not any of them are set, the proxy will be disabled. You can override it using the `url` option passed to `createFetch` and `createProxy` utils.

By default, `no_proxy` and `NO_PROXY` environment variables will be checked and used for the (comma-separated) list of hosts to ignore the proxy for. You can override it using the `noProxy` option passed to `createFetch` and `createProxy` utils. The entries starting with a dot will be used to check the domain and also any subdomain.

> [!NOTE]
> Using export conditions, this utility adds proxy support for Node.js and for other runtimes, it will simply return native fetch.
Expand All @@ -131,7 +133,7 @@ console.log(await fetch("https://icanhazip.com").then((r) => r.text());

### `createFetch` utility

You can use `createFetch` utility to intantiate a `fetch` instance with custom proxy options.
You can use the `createFetch` utility to instantiate a `fetch` instance with custom proxy options.

```ts
import { createFetch } from "node-fetch-native/proxy";
Expand Down
18 changes: 17 additions & 1 deletion lib/proxy.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,23 @@ import type * as http from "node:http";
import type * as https from "node:https";
import type * as undici from "undici";

export type ProxyOptions = { url?: string };
export type ProxyOptions = {
/**
* HTTP(s) Proxy URL
*
* Default is read from `https_proxy`, `http_proxy`, `HTTPS_PROXY` or `HTTP_PROXY` environment variables
* */
url?: string;

/**
* List of hosts to skip proxy for (comma separated or array of strings)
*
* Default is read from `no_proxy` or `NO_PROXY` environment variables
*
* Hots starting with a leading dot, like `.foo.com` are also matched against domain and all its subdomains like `bar.foo.com`
*/
noProxy?: string | string[];
};

export declare const createProxy: (opts?: ProxyOptions) => {
agent: http.Agent | https.Agent | undefined;
Expand Down
86 changes: 61 additions & 25 deletions src/proxy.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as http from "node:http";
import * as https from "node:https";
import { URL } from "node:url";
import { ProxyAgent as UndiciProxyAgent } from "undici";
import { Agent as _UndiciAgent, ProxyAgent as _UndiciProxyAgent } from "undici";
import { Agent, AgentConnectOpts } from "agent-base";
import { HttpProxyAgent } from "http-proxy-agent";
import { HttpsProxyAgent } from "https-proxy-agent";
Expand All @@ -23,10 +23,16 @@ export function createProxy(opts: ProxyOptions = {}) {
};
}

const nodeAgent = new NodeProxyAgent({ uri });
const _noProxy = opts.noProxy || process.env.no_proxy || process.env.NO_PROXY;
const noProxy = typeof _noProxy === "string" ? _noProxy.split(",") : _noProxy;

const nodeAgent = new NodeProxyAgent({ uri, noProxy });

// https://undici.nodejs.org/#/docs/api/ProxyAgent
const undiciAgent = new UndiciProxyAgent({ uri });
const undiciAgent = new UndiciProxyAgent({
uri,
noProxy,
});

return {
agent: nodeAgent,
Expand All @@ -45,9 +51,47 @@ export const fetch = createFetch({});
// Utils
// ----------------------------------------------

export function debug(...args: any[]) {
if (process.env.debug) {
debug("[node-fetch-native] [proxy]", ...args);
function debug(...args: any[]) {
if (process.env.DEBUG) {
console.debug("[node-fetch-native] [proxy]", ...args);
}
}

function bypassProxy(host: string, noProxy: string[]) {
if (!noProxy) {
return false;
}
for (const _host of noProxy) {
if (_host === host || (_host[0] === "." && host.endsWith(_host.slice(1)))) {
return true;
}
}
return false;
}

// ----------------------------------------------
// Undici Agent
// ----------------------------------------------

// https://github.com/nodejs/undici/blob/main/lib/proxy-agent.js

class UndiciProxyAgent extends _UndiciProxyAgent {
_agent: _UndiciAgent;

constructor(
private _options: _UndiciProxyAgent.Options & { noProxy: string[] },
) {
super(_options);
this._agent = new _UndiciAgent();
}

dispatch(options, handler): boolean {
const hostname = new URL(options.origin).hostname;
if (bypassProxy(hostname, this._options.noProxy)) {
debug(`Bypassing proxy for: ${hostname}`);
return this._agent.dispatch(options, handler);
}
return super.dispatch(options, handler);
}
}

Expand All @@ -73,15 +117,14 @@ function isValidProtocol(v: string): v is ValidProtocol {
return (PROTOCOLS as readonly string[]).includes(v);
}

export class NodeProxyAgent extends Agent {
class NodeProxyAgent extends Agent {
cache: Map<string, Agent> = new Map();

httpAgent: http.Agent;
httpsAgent: http.Agent;

constructor(private proxyOptions: { uri: string }) {
constructor(private _options: { uri: string; noProxy: string[] }) {
super({});
debug("Creating new ProxyAgent instance: %o", proxyOptions);
this.httpAgent = new http.Agent({});
this.httpsAgent = new https.Agent({});
}
Expand All @@ -94,33 +137,26 @@ export class NodeProxyAgent extends Agent {
? (isWebSocket ? "wss:" : "https:")
: (isWebSocket ? "ws:" : "http:");

const host = req.getHeader("host");
const url = new URL(req.path, `${protocol}//${host}`).href;
const proxy = this.proxyOptions.uri;
const host = req.getHeader("host") as string;

if (!proxy) {
debug("Proxy not enabled for URL: %o", url);
if (bypassProxy(host, this._options.noProxy)) {
return opts.secureEndpoint ? this.httpsAgent : this.httpAgent;
}

debug("Request URL: %o", url);
debug("Proxy URL: %o", proxy);

// Attempt to get a cached `http.Agent` instance first
const cacheKey = `${protocol}+${proxy}`;
const cacheKey = `${protocol}+${this._options.uri}`;
let agent = this.cache.get(cacheKey);
if (agent) {
debug("Cache hit for proxy URL: %o", proxy);
} else {
const proxyUrl = new URL(proxy);
if (!agent) {
const proxyUrl = new URL(this._options.uri);
const proxyProto = proxyUrl.protocol.replace(":", "");
if (!isValidProtocol(proxyProto)) {
throw new Error(`Unsupported protocol for proxy URL: ${proxy}`);
throw new Error(
`Unsupported protocol for proxy URL: ${this._options.uri}`,
);
}
const Ctor =
proxies[proxyProto][opts.secureEndpoint || isWebSocket ? 1 : 0];
// @ts-expect-error meh…
agent = new Ctor(proxy, this.connectOpts);
agent = new (Ctor as any)(this._options.uri, this._options);
this.cache.set(cacheKey, agent);
}

Expand Down