Skip to content

Commit

Permalink
feat(proxy): support no_proxy (#109)
Browse files Browse the repository at this point in the history
  • Loading branch information
pi0 committed Dec 24, 2023
1 parent 742d27e commit d2d7588
Show file tree
Hide file tree
Showing 3 changed files with 83 additions and 29 deletions.
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

0 comments on commit d2d7588

Please sign in to comment.