Skip to content

Commit 81c5bc2

Browse files
committed
Add Tier A proxy extensions: ky, wretch, make-fetch-happen, needle, typed-rest-client
- Move ProxyResponse to lib/core/proxy-response.js; node-fetch unwraps Request for ky/wretch - New subpath exports with optional peer deps and TypeScript definitions - Integration tests aligned with existing harness (PROXY_URL) - Defer urllib: notes/urllib-integration-deferred.md (undici Dispatcher) Made-with: Cursor
1 parent 899d48c commit 81c5bc2

17 files changed

Lines changed: 4261 additions & 54 deletions

lib/core/proxy-response.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* Wraps a fetch Response and exposes CONNECT proxy response headers.
3+
*/
4+
5+
export class ProxyResponse {
6+
/**
7+
* @param {import('node-fetch').Response} response
8+
* @param {Map<string, string>|null|undefined} proxyHeaders
9+
*/
10+
constructor(response, proxyHeaders) {
11+
this._response = response;
12+
this.proxyHeaders = proxyHeaders || new Map();
13+
14+
this.ok = response.ok;
15+
this.status = response.status;
16+
this.statusText = response.statusText;
17+
this.headers = response.headers;
18+
this.url = response.url;
19+
this.redirected = response.redirected;
20+
this.type = response.type;
21+
this.body = response.body;
22+
this.bodyUsed = response.bodyUsed;
23+
}
24+
25+
async text() {
26+
return this._response.text();
27+
}
28+
29+
async json() {
30+
return this._response.json();
31+
}
32+
33+
async blob() {
34+
return this._response.blob();
35+
}
36+
37+
async arrayBuffer() {
38+
return this._response.arrayBuffer();
39+
}
40+
41+
async formData() {
42+
return this._response.formData();
43+
}
44+
45+
clone() {
46+
return new ProxyResponse(this._response.clone(), this.proxyHeaders);
47+
}
48+
}

lib/ky-proxy.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* ky extension for proxy header support.
3+
*
4+
* Uses a custom `fetch` backed by node-fetch + ProxyHeadersAgent.
5+
*/
6+
7+
import { createProxyFetch } from './node-fetch-proxy.js';
8+
9+
/**
10+
* Create a ky instance with proxy header support.
11+
*
12+
* @param {Object} options - Configuration
13+
* @param {string} options.proxy - Proxy URL
14+
* @param {Object} [options.proxyHeaders] - Headers to send on CONNECT
15+
* @param {Function} [options.onProxyConnect] - CONNECT callback
16+
* @param {Object} [options.kyOptions] - Options passed to ky.create()
17+
* @returns {Promise<import('ky').KyInstance>}
18+
*/
19+
export async function createProxyKy(options) {
20+
const { proxy, proxyHeaders = {}, onProxyConnect, kyOptions = {} } = options;
21+
22+
if (!proxy) {
23+
throw new Error('proxy option is required');
24+
}
25+
26+
let ky;
27+
try {
28+
ky = (await import('ky')).default;
29+
} catch {
30+
throw new Error('ky is required. Install it with: npm install ky');
31+
}
32+
33+
const fetch = createProxyFetch({ proxy, proxyHeaders, onProxyConnect });
34+
35+
return ky.create({
36+
...kyOptions,
37+
fetch,
38+
});
39+
}

lib/make-fetch-happen-proxy.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* make-fetch-happen extension for proxy header support.
3+
*
4+
* Passes a ProxyHeadersAgent via opts.agent; @npmcli/agent returns it as-is when set.
5+
*/
6+
7+
import makeFetchHappen from 'make-fetch-happen';
8+
import { ProxyHeadersAgent } from './core/proxy-headers-agent.js';
9+
import { ProxyResponse } from './core/proxy-response.js';
10+
11+
function wrapFetchWithProxyResponse(fetchImpl, agent) {
12+
const wrapped = (url, opts = {}) =>
13+
fetchImpl(url, opts).then((res) => new ProxyResponse(res, agent.lastProxyHeaders));
14+
15+
wrapped.defaults = (defaultUrl, defaultOptions = {}) => {
16+
const inner = fetchImpl.defaults(defaultUrl, defaultOptions);
17+
return wrapFetchWithProxyResponse(inner, agent);
18+
};
19+
20+
wrapped.proxyAgent = agent;
21+
return wrapped;
22+
}
23+
24+
/**
25+
* Create a make-fetch-happen fetch function with proxy header support.
26+
*
27+
* @param {Object} options - Configuration
28+
* @param {string} options.proxy - Proxy URL
29+
* @param {Object} [options.proxyHeaders] - Headers to send on CONNECT
30+
* @param {Function} [options.onProxyConnect] - CONNECT callback
31+
* @param {Object} [options.defaults] - Extra options for make-fetch-happen.defaults()
32+
* @returns {Function} Fetch function with .defaults() and .proxyAgent
33+
*/
34+
export function createProxyMakeFetchHappen(options) {
35+
const { proxy, proxyHeaders = {}, onProxyConnect, ...makeFetchHappenOptions } = options;
36+
37+
if (!proxy) {
38+
throw new Error('proxy option is required');
39+
}
40+
41+
const agent = new ProxyHeadersAgent(proxy, {
42+
proxyHeaders,
43+
onProxyConnect,
44+
});
45+
46+
const inner = makeFetchHappen.defaults({
47+
...makeFetchHappenOptions,
48+
agent,
49+
});
50+
51+
return wrapFetchWithProxyResponse(inner, agent);
52+
}

lib/needle-proxy.js

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* needle extension for proxy header support.
3+
*/
4+
5+
import { createRequire } from 'module';
6+
import { ProxyHeadersAgent } from './core/proxy-headers-agent.js';
7+
8+
const require = createRequire(import.meta.url);
9+
10+
function mergeProxyHeadersIntoResponse(res, map) {
11+
if (!res || !map) return;
12+
for (const [key, value] of map) {
13+
if (res.headers[key] == null) {
14+
res.headers[key] = value;
15+
}
16+
}
17+
}
18+
19+
/**
20+
* Perform a GET with proxy headers (promise-based).
21+
*
22+
* @param {string} url - Target URL (HTTPS recommended)
23+
* @param {Object} options - Options
24+
* @param {string} options.proxy - Proxy URL
25+
* @param {Object} [options.proxyHeaders] - CONNECT headers
26+
* @param {Function} [options.onProxyConnect] - CONNECT callback
27+
* @param {Object} [options.needleOptions] - Extra needle options
28+
* @returns {Promise<import('needle').NeedleResponse>}
29+
*/
30+
export async function proxyNeedleGet(url, options = {}) {
31+
const { proxy, proxyHeaders = {}, onProxyConnect, needleOptions = {} } = options;
32+
33+
if (!proxy) {
34+
throw new Error('proxy option is required');
35+
}
36+
37+
const needle = require('needle');
38+
39+
const agent = new ProxyHeadersAgent(proxy, {
40+
proxyHeaders,
41+
onProxyConnect,
42+
});
43+
44+
return new Promise((resolve, reject) => {
45+
needle.get(
46+
url,
47+
{
48+
...needleOptions,
49+
agent,
50+
proxy: null,
51+
use_proxy_from_env_var: false,
52+
},
53+
(err, res) => {
54+
if (err) {
55+
reject(err);
56+
return;
57+
}
58+
mergeProxyHeadersIntoResponse(res, agent.lastProxyHeaders);
59+
res.proxyAgent = agent;
60+
resolve(res);
61+
},
62+
);
63+
});
64+
}
65+
66+
/**
67+
* Create a small API bound to one proxy configuration.
68+
*
69+
* @param {Object} options - Same as proxyNeedleGet base options (without url)
70+
* @returns {{ get: Function, proxyAgent: ProxyHeadersAgent }}
71+
*/
72+
export function createProxyNeedle(options) {
73+
const { proxy, proxyHeaders = {}, onProxyConnect, needleOptions = {} } = options;
74+
75+
if (!proxy) {
76+
throw new Error('proxy option is required');
77+
}
78+
79+
const agent = new ProxyHeadersAgent(proxy, {
80+
proxyHeaders,
81+
onProxyConnect,
82+
});
83+
84+
const needle = require('needle');
85+
86+
return {
87+
proxyAgent: agent,
88+
get(url, opts = {}) {
89+
return new Promise((resolve, reject) => {
90+
needle.get(
91+
url,
92+
{
93+
...needleOptions,
94+
...opts,
95+
agent,
96+
proxy: null,
97+
use_proxy_from_env_var: false,
98+
},
99+
(err, res) => {
100+
if (err) {
101+
reject(err);
102+
return;
103+
}
104+
mergeProxyHeadersIntoResponse(res, agent.lastProxyHeaders);
105+
res.proxyAgent = agent;
106+
resolve(res);
107+
},
108+
);
109+
});
110+
},
111+
};
112+
}

lib/node-fetch-proxy.js

Lines changed: 18 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -6,50 +6,7 @@
66
*/
77

88
import { ProxyHeadersAgent } from './core/proxy-headers-agent.js';
9-
10-
/**
11-
* Extended Response class that includes proxy headers.
12-
*/
13-
class ProxyResponse {
14-
constructor(response, proxyHeaders) {
15-
this._response = response;
16-
this.proxyHeaders = proxyHeaders || new Map();
17-
18-
this.ok = response.ok;
19-
this.status = response.status;
20-
this.statusText = response.statusText;
21-
this.headers = response.headers;
22-
this.url = response.url;
23-
this.redirected = response.redirected;
24-
this.type = response.type;
25-
this.body = response.body;
26-
this.bodyUsed = response.bodyUsed;
27-
}
28-
29-
async text() {
30-
return this._response.text();
31-
}
32-
33-
async json() {
34-
return this._response.json();
35-
}
36-
37-
async blob() {
38-
return this._response.blob();
39-
}
40-
41-
async arrayBuffer() {
42-
return this._response.arrayBuffer();
43-
}
44-
45-
async formData() {
46-
return this._response.formData();
47-
}
48-
49-
clone() {
50-
return new ProxyResponse(this._response.clone(), this.proxyHeaders);
51-
}
52-
}
9+
import { ProxyResponse } from './core/proxy-response.js';
5310

5411
/**
5512
* Fetch with proxy header support.
@@ -89,10 +46,22 @@ export async function proxyFetch(url, options = {}) {
8946
onProxyConnect,
9047
});
9148

92-
const response = await fetch(url, {
93-
...fetchOptions,
94-
agent,
95-
});
49+
let requestUrl = url;
50+
let init = { ...fetchOptions, agent };
51+
52+
if (typeof Request !== 'undefined' && url instanceof Request) {
53+
const merged = new Request(url, fetchOptions);
54+
requestUrl = merged.url;
55+
init = {
56+
method: merged.method,
57+
headers: merged.headers,
58+
body: merged.body,
59+
redirect: merged.redirect,
60+
agent,
61+
};
62+
}
63+
64+
const response = await fetch(requestUrl, init);
9665

9766
return new ProxyResponse(response, agent.lastProxyHeaders);
9867
}
@@ -126,4 +95,4 @@ export function createProxyFetch(options) {
12695
};
12796
}
12897

129-
export { ProxyResponse };
98+
export { ProxyResponse } from './core/proxy-response.js';

0 commit comments

Comments
 (0)