diff --git a/CHANGELOG.md b/CHANGELOG.md index 073c08a..0c9d87b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package-lock.json b/package-lock.json index ca06342..45daea5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vscode/proxy-agent", - "version": "0.41.0", + "version": "0.42.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@vscode/proxy-agent", - "version": "0.41.0", + "version": "0.42.0", "license": "MIT", "dependencies": { "@tootallnate/once": "^3.0.0", diff --git a/package.json b/package.json index 3df900d..0c2f159 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/index.ts b/src/index.ts index bec5320..c130c3b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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), @@ -610,7 +614,23 @@ function patchCreateSecureContext(original: typeof tls.createSecureContext): typ }; } -export function createFetchPatch(params: ProxyAgentParams, originalFetch: typeof globalThis.fetch, resolveProxyURL: (url: string) => Promise) { +export function createFetchPatch(params: ProxyAgentParams, originalFetch: typeof globalThis.fetch, resolveProxyURL: (url: string) => Promise, 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() : 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); @@ -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); }; diff --git a/tests/test-client/src/direct.test.ts b/tests/test-client/src/direct.test.ts index dc0065a..38faa38 100644 --- a/tests/test-client/src/direct.test.ts +++ b/tests/test-client/src/direct.test.ts @@ -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!'); + }); + }); }); diff --git a/tests/test-client/src/proxy.test.ts b/tests/test-client/src/proxy.test.ts index ddb6154..3cbfea3 100644 --- a/tests/test-client/src/proxy.test.ts +++ b/tests/test-client/src/proxy.test.ts @@ -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: diff --git a/tests/test-https-server/nginx.conf b/tests/test-https-server/nginx.conf index 7f401ef..2723979 100644 --- a/tests/test-https-server/nginx.conf +++ b/tests/test-https-server/nginx.conf @@ -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 {