Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# Change Log
Notable changes will be documented here.

## [0.42.0]
- Add `interceptors` option to `createFetchPatch` for composing additional undici interceptors (e.g. RFC 9111 cache) on the patched `fetch` at construction time ([microsoft/vscode-proxy-agent#100](https://github.com/microsoft/vscode-proxy-agent/pull/100))

## [0.41.0]
- Surface network errors with proxies ([microsoft/vscode#298236](https://github.com/microsoft/vscode/issues/298236))
- Support `testCertificates` request option for adding test certificates via request options instead of global state
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@vscode/proxy-agent",
"version": "0.41.0",
"version": "0.42.0",
"description": "NodeJS http(s) agent implementation for VS Code",
"main": "out/index.js",
"types": "out/index.d.ts",
Expand Down
26 changes: 23 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,10 @@ export interface SecureContextOptionsPatch {
testCertificates?: (string | Buffer)[];
}

export interface CreateFetchPatchOptions {
interceptors?: readonly undici.Dispatcher.DispatcherComposeInterceptor[];
}

export function createNetPatch(params: ProxyAgentParams, originals: typeof net) {
return {
connect: patchNetConnect(params, originals.connect),
Expand Down Expand Up @@ -610,7 +614,23 @@ function patchCreateSecureContext(original: typeof tls.createSecureContext): typ
};
}

export function createFetchPatch(params: ProxyAgentParams, originalFetch: typeof globalThis.fetch, resolveProxyURL: (url: string) => Promise<string | undefined>) {
export function createFetchPatch(params: ProxyAgentParams, originalFetch: typeof globalThis.fetch, resolveProxyURL: (url: string) => Promise<string | undefined>, options?: CreateFetchPatchOptions) {
const interceptors = options?.interceptors;
const hasInterceptors = !!interceptors && interceptors.length > 0;
// Caches the composed dispatcher per base agent so all requests share the
// same composed dispatcher.
const composedAgentCache = hasInterceptors ? new WeakMap<undici.Dispatcher, undici.Dispatcher>() : undefined;
function withInterceptors(agent: undici.Dispatcher | undefined): undici.Dispatcher | undefined {
if (!agent || !composedAgentCache) {
return agent;
}
let composed = composedAgentCache.get(agent);
if (!composed) {
composed = agent.compose(...interceptors!);
composedAgentCache.set(agent, composed);
}
return composed;
}
return async function patchedFetch(input: string | URL | Request, init?: RequestInit) {
if (!params.isAdditionalFetchSupportEnabled()) {
return originalFetch(input, init);
Expand All @@ -637,14 +657,14 @@ export function createFetchPatch(params: ProxyAgentParams, originalFetch: typeof
if (!proxyURL) {
const modifiedInit = {
...init,
dispatcher: getAgent(agentOptions.dispatcher, allowH2, requestCA, addCerts),
dispatcher: withInterceptors(getAgent(agentOptions.dispatcher, allowH2, requestCA, addCerts)),
};
return originalFetch(input, modifiedInit);
}

const modifiedInit = {
...init,
dispatcher: getProxyAgent(params, agentOptions.dispatcher, proxyURL, allowH2, requestCA, proxyCA, addCerts),
dispatcher: withInterceptors(getProxyAgent(params, agentOptions.dispatcher, proxyURL, allowH2, requestCA, proxyCA, addCerts)),
};
return originalFetch(input, modifiedInit);
};
Expand Down
84 changes: 84 additions & 0 deletions tests/test-client/src/direct.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,4 +351,88 @@ describe('Direct client', function () {
assert.strictEqual(res.status, 200);
assert.strictEqual((await res.json()).status, 'OK HTTP2!');
});

describe('fetch interceptor composition', function () {
function makeRecordingInterceptor() {
let calls = 0;
let lastPath: string | undefined;
const interceptor: undici.Dispatcher.DispatcherComposeInterceptor = (dispatch) => (opts, handler) => {
calls++;
lastPath = opts.path;
return dispatch(opts, handler);
};
return {
interceptor,
get calls() { return calls; },
get lastPath() { return lastPath; },
};
}

it('composes user-supplied interceptors on the direct (CA-injection) path', async function () {
const { resolveProxyURL } = vpa.createProxyResolver(directProxyAgentParamsV1);
const recorder = makeRecordingInterceptor();
const patchedFetch = (vpa.createFetchPatch as any)(directProxyAgentParamsV1, globalThis.fetch, resolveProxyURL, {
interceptors: [recorder.interceptor],
});

const res = await patchedFetch('https://test-https-server/test-path');

assert.strictEqual(res.status, 200);
assert.strictEqual((await res.json()).status, 'OK!');
assert.strictEqual(recorder.calls, 1, 'recording interceptor should observe the request');
assert.strictEqual(recorder.lastPath, '/test-path');
});

it('reuses the composed dispatcher across requests with a stable interceptors array', async function () {
const { resolveProxyURL } = vpa.createProxyResolver(directProxyAgentParamsV1);
const capturedDispatchers: unknown[] = [];
const interceptors = [makeRecordingInterceptor().interceptor];
const spyFetch: typeof globalThis.fetch = (input, init) => {
capturedDispatchers.push((init as any)?.dispatcher);
return globalThis.fetch(input, init);
};
const patchedFetch = (vpa.createFetchPatch as any)(directProxyAgentParamsV1, spyFetch, resolveProxyURL, {
interceptors,
});

await patchedFetch('https://test-https-server/test-path');
await patchedFetch('https://test-https-server/test-path');

assert.strictEqual(capturedDispatchers.length, 2);
assert.ok(capturedDispatchers[0], 'first request should carry a composed dispatcher');
assert.strictEqual(capturedDispatchers[0], capturedDispatchers[1], 'composed dispatcher should be reused for the same (agent, interceptors) pair');
});

it('composes undici.interceptors.cache so a cacheable response is served from cache on the second fetch', async function () {
const { resolveProxyURL } = vpa.createProxyResolver(directProxyAgentParamsV1);
const cacheInterceptor = undici.interceptors.cache({
store: new (undici as any).cacheStores.MemoryCacheStore(),
type: 'private',
});
const patchedFetch = (vpa.createFetchPatch as any)(directProxyAgentParamsV1, globalThis.fetch, resolveProxyURL, {
interceptors: [cacheInterceptor],
});
const url = 'https://test-https-server/test-cacheable';

const first = await patchedFetch(url);
assert.strictEqual(first.status, 200);
assert.strictEqual((await first.json()).status, 'OK CACHEABLE!');
assert.strictEqual(first.headers.get('age'), null, 'first response should not carry an Age header');

const second = await patchedFetch(url);
assert.strictEqual(second.status, 200);
assert.strictEqual((await second.json()).status, 'OK CACHEABLE!');
assert.notStrictEqual(second.headers.get('age'), null, 'second response should be served from the cache (Age header)');
});

it('falls back to no interceptors when createFetchPatch options omit interceptors', async function () {
const { resolveProxyURL } = vpa.createProxyResolver(directProxyAgentParamsV1);
const patchedFetch = vpa.createFetchPatch(directProxyAgentParamsV1, globalThis.fetch, resolveProxyURL);

const res = await patchedFetch('https://test-https-server/test-path');

assert.strictEqual(res.status, 200);
assert.strictEqual((await res.json()).status, 'OK!');
});
});
});
65 changes: 65 additions & 0 deletions tests/test-client/src/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,71 @@ describe('Proxied client', function () {
vpa.resetCaches();
}
});

describe('fetch interceptor composition', function () {
it('composes user-supplied interceptors on the proxy path', async function () {
const { resolveProxyURL } = vpa.createProxyResolver(proxiedProxyAgentParamsV1);
let calls = 0;
let lastPath: string | undefined;
const recorder: undici.Dispatcher.DispatcherComposeInterceptor = (dispatch) => (opts, handler) => {
calls++;
lastPath = opts.path;
return dispatch(opts, handler);
};
const patchedFetch = (vpa.createFetchPatch as any)(proxiedProxyAgentParamsV1, globalThis.fetch, resolveProxyURL, {
interceptors: [recorder],
});

const res = await patchedFetch('https://test-https-server/test-path');

assert.strictEqual(res.status, 200);
assert.strictEqual((await res.json()).status, 'OK!');
assert.strictEqual(calls, 1, 'recording interceptor should observe the request even when tunneled through a proxy');
assert.strictEqual(lastPath, '/test-path');
});

it('reuses the composed dispatcher across requests with a stable interceptors array (proxy path)', async function () {
const { resolveProxyURL } = vpa.createProxyResolver(proxiedProxyAgentParamsV1);
const capturedDispatchers: unknown[] = [];
const interceptors: undici.Dispatcher.DispatcherComposeInterceptor[] = [
(dispatch) => (opts, handler) => dispatch(opts, handler),
];
const spyFetch: typeof globalThis.fetch = (input, init) => {
capturedDispatchers.push((init as any)?.dispatcher);
return globalThis.fetch(input, init);
};
const patchedFetch = (vpa.createFetchPatch as any)(proxiedProxyAgentParamsV1, spyFetch, resolveProxyURL, {
interceptors,
});

await patchedFetch('https://test-https-server/test-path');
await patchedFetch('https://test-https-server/test-path');

assert.strictEqual(capturedDispatchers.length, 2);
assert.ok(capturedDispatchers[0], 'first proxied request should carry a composed dispatcher');
assert.strictEqual(capturedDispatchers[0], capturedDispatchers[1], 'composed proxy dispatcher should be reused for the same (proxy agent, interceptors) pair');
});

it('composes undici.interceptors.cache through the proxy', async function () {
const { resolveProxyURL } = vpa.createProxyResolver(proxiedProxyAgentParamsV1);
const cacheInterceptor = undici.interceptors.cache({
store: new (undici as any).cacheStores.MemoryCacheStore(),
type: 'private',
});
const patchedFetch = (vpa.createFetchPatch as any)(proxiedProxyAgentParamsV1, globalThis.fetch, resolveProxyURL, {
interceptors: [cacheInterceptor],
});
const url = 'https://test-https-server/test-cacheable';

const first = await patchedFetch(url);
assert.strictEqual(first.status, 200);
assert.strictEqual(first.headers.get('age'), null);

const second = await patchedFetch(url);
assert.strictEqual(second.status, 200);
assert.notStrictEqual(second.headers.get('age'), null, 'second response should be served from the cache when interceptors are composed on the proxy dispatcher');
});
});
});

// From microsoft/vscode's proxyResolver.ts:
Expand Down
9 changes: 9 additions & 0 deletions tests/test-https-server/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,15 @@ http {
}';
add_header Content-Type application/json;
}

# Cache-friendly endpoint used by the fetch interceptor composition
# tests. The response is deterministic and stamped with explicit
# freshness.
location = /test-cacheable {
add_header Content-Type application/json;
add_header Cache-Control "public, max-age=120";
return 200 '{"status":"OK CACHEABLE!"}';
}
}

server {
Expand Down
Loading