Skip to content

Commit e441bdb

Browse files
karpikplCopilotCopilot
authored
refactor: extract shared proxy agent initialization utility (#350)
* refactor: extract shared proxy agent initialization into server/utils/proxy-agent.ts Both server/plugins/http-agent.ts (Nitro web app) and server/sync-entry.ts (standalone sync container) previously duplicated proxy initialization logic. Extract into a single initializeProxyAgent() utility that: - Creates a ProxyAgent from HTTP_PROXY env var - Validates CUSTOM_CA_PATH exists before reading (improvement over original) - Sets the global undici dispatcher for all ofetch/$fetch calls - Returns the ProxyAgent so http-agent.ts can wire it into ofetch hooks - Accepts exitOnError flag (true for sync-entry CLI, false for Nitro plugin) http-agent.ts now only handles the Nitro-specific ofetch hook wiring. sync-entry.ts reduced from 24 lines to 2 lines of proxy setup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: add unit tests for server/utils/proxy-agent.ts Agent-Logs-Url: https://github.com/github-copilot-resources/copilot-metrics-viewer/sessions/a1c7a94a-e1af-4a94-afa5-33e46eec40d9 Co-authored-by: karpikpl <3539908+karpikpl@users.noreply.github.com> * test: improve process.exit mock to throw sentinel error for accurate simulation Agent-Logs-Url: https://github.com/github-copilot-resources/copilot-metrics-viewer/sessions/a1c7a94a-e1af-4a94-afa5-33e46eec40d9 Co-authored-by: karpikpl <3539908+karpikpl@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: karpikpl <3539908+karpikpl@users.noreply.github.com>
1 parent 8d7c827 commit e441bdb

4 files changed

Lines changed: 181 additions & 42 deletions

File tree

server/plugins/http-agent.ts

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,10 @@
1-
import { ProxyAgent, setGlobalDispatcher } from 'undici';
21
import { ofetch } from 'ofetch';
3-
import { readFileSync } from 'fs';
2+
import { initializeProxyAgent } from '../utils/proxy-agent';
43

54
export default defineNitroPlugin((nitro) => {
6-
if (process.env.HTTP_PROXY) {
7-
const tlsOptions = process.env.CUSTOM_CA_PATH ? {
8-
tls: {
9-
ca: [
10-
readFileSync(process.env.CUSTOM_CA_PATH)
11-
]
12-
}
13-
} : {};
5+
const proxyAgent = initializeProxyAgent();
146

15-
const proxyAgent = new ProxyAgent({
16-
uri: process.env.HTTP_PROXY,
17-
...tlsOptions
18-
});
19-
20-
setGlobalDispatcher(proxyAgent);
21-
7+
if (proxyAgent) {
228
const fetchWithProxy = ofetch.create({
239
dispatcher: proxyAgent,
2410
httpsProxy: process.env.HTTP_PROXY,

server/sync-entry.ts

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -16,31 +16,9 @@
1616
* - CUSTOM_CA_PATH: Optional path to a custom CA certificate file
1717
*/
1818

19-
// Initialize proxy agent before any fetch calls (mirrors server/plugins/http-agent.ts)
20-
import { ProxyAgent, setGlobalDispatcher } from 'undici';
21-
import { readFileSync, existsSync } from 'fs';
22-
23-
if (process.env.HTTP_PROXY) {
24-
try {
25-
const tlsOptions = process.env.CUSTOM_CA_PATH ? (() => {
26-
if (!existsSync(process.env.CUSTOM_CA_PATH!)) {
27-
throw new Error(`CUSTOM_CA_PATH file not found: ${process.env.CUSTOM_CA_PATH}`);
28-
}
29-
return { tls: { ca: [readFileSync(process.env.CUSTOM_CA_PATH!)] } };
30-
})() : {};
31-
32-
const proxyAgent = new ProxyAgent({
33-
uri: process.env.HTTP_PROXY,
34-
...tlsOptions
35-
});
36-
37-
setGlobalDispatcher(proxyAgent);
38-
console.info(`Proxy agent initialized: ${process.env.HTTP_PROXY}`);
39-
} catch (error) {
40-
console.error('Failed to initialize proxy agent:', error);
41-
process.exit(1);
42-
}
43-
}
19+
// Initialize proxy agent before any fetch calls
20+
import { initializeProxyAgent } from './utils/proxy-agent';
21+
initializeProxyAgent(true /* exitOnError */);
4422

4523
import { syncBulk } from './services/sync-service';
4624
import { initSchema } from './storage/db';

server/utils/proxy-agent.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* Shared proxy agent initialization utility.
3+
*
4+
* Used by:
5+
* - server/plugins/http-agent.ts (Nitro web app)
6+
* - server/sync-entry.ts (standalone sync container)
7+
*
8+
* Environment variables:
9+
* - HTTP_PROXY: Optional HTTP/HTTPS proxy URL (e.g. http://proxy:8080)
10+
* - CUSTOM_CA_PATH: Optional path to a custom CA certificate file
11+
*/
12+
13+
import { ProxyAgent, setGlobalDispatcher } from 'undici';
14+
import { readFileSync, existsSync } from 'fs';
15+
16+
/**
17+
* Initializes a ProxyAgent from environment variables and sets it as the
18+
* global undici dispatcher so all `$fetch`/`ofetch` calls use the proxy.
19+
*
20+
* Returns the created ProxyAgent so callers can wire it into ofetch hooks,
21+
* or null if HTTP_PROXY is not set.
22+
*
23+
* Throws (and exits with code 1 when `exitOnError` is true) if proxy
24+
* initialization fails — e.g. missing CA file or invalid proxy URL.
25+
*/
26+
export function initializeProxyAgent(exitOnError = false): ProxyAgent | null {
27+
if (!process.env.HTTP_PROXY) return null;
28+
29+
try {
30+
let tlsOptions: { tls?: { ca: Buffer[] } } = {};
31+
32+
if (process.env.CUSTOM_CA_PATH) {
33+
if (!existsSync(process.env.CUSTOM_CA_PATH)) {
34+
throw new Error(`CUSTOM_CA_PATH file not found: ${process.env.CUSTOM_CA_PATH}`);
35+
}
36+
tlsOptions = { tls: { ca: [readFileSync(process.env.CUSTOM_CA_PATH)] } };
37+
}
38+
39+
const proxyAgent = new ProxyAgent({
40+
uri: process.env.HTTP_PROXY,
41+
...tlsOptions
42+
});
43+
44+
setGlobalDispatcher(proxyAgent);
45+
console.info(`[proxy-agent] Proxy initialized: ${process.env.HTTP_PROXY}`);
46+
47+
return proxyAgent;
48+
} catch (error) {
49+
console.error('[proxy-agent] Failed to initialize proxy agent:', error);
50+
if (exitOnError) process.exit(1);
51+
throw error;
52+
}
53+
}

tests/proxy-agent.spec.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/**
2+
* Unit tests for server/utils/proxy-agent.ts
3+
*
4+
* Covers:
5+
* - Returns null when HTTP_PROXY is not set
6+
* - Creates and returns a ProxyAgent when HTTP_PROXY is set
7+
* - Calls setGlobalDispatcher with the created agent
8+
* - Reads CA certificate when CUSTOM_CA_PATH is set and file exists
9+
* - Throws (and optionally calls process.exit) when CUSTOM_CA_PATH file is missing
10+
* - exitOnError=false rethrows instead of calling process.exit
11+
*/
12+
13+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
14+
15+
// --- Mocks ---
16+
17+
const mockSetGlobalDispatcher = vi.fn();
18+
const mockProxyAgentInstance = { uri: '' };
19+
const MockProxyAgent = vi.fn((opts: { uri: string }) => {
20+
mockProxyAgentInstance.uri = opts.uri;
21+
return mockProxyAgentInstance;
22+
});
23+
24+
vi.mock('undici', () => ({
25+
ProxyAgent: MockProxyAgent,
26+
setGlobalDispatcher: mockSetGlobalDispatcher,
27+
}));
28+
29+
const mockExistsSync = vi.fn();
30+
const mockReadFileSync = vi.fn();
31+
32+
vi.mock('fs', () => ({
33+
default: { existsSync: mockExistsSync, readFileSync: mockReadFileSync },
34+
existsSync: mockExistsSync,
35+
readFileSync: mockReadFileSync,
36+
}));
37+
38+
// Import AFTER mocks are registered
39+
const { initializeProxyAgent } = await import('../server/utils/proxy-agent');
40+
41+
describe('initializeProxyAgent', () => {
42+
const originalEnv = process.env;
43+
44+
beforeEach(() => {
45+
process.env = { ...originalEnv };
46+
vi.clearAllMocks();
47+
});
48+
49+
afterEach(() => {
50+
process.env = originalEnv;
51+
});
52+
53+
it('returns null when HTTP_PROXY is not set', () => {
54+
delete process.env.HTTP_PROXY;
55+
const result = initializeProxyAgent();
56+
expect(result).toBeNull();
57+
expect(MockProxyAgent).not.toHaveBeenCalled();
58+
expect(mockSetGlobalDispatcher).not.toHaveBeenCalled();
59+
});
60+
61+
it('creates a ProxyAgent and sets global dispatcher when HTTP_PROXY is set', () => {
62+
process.env.HTTP_PROXY = 'http://proxy.example.com:8080';
63+
delete process.env.CUSTOM_CA_PATH;
64+
65+
const result = initializeProxyAgent();
66+
67+
expect(MockProxyAgent).toHaveBeenCalledOnce();
68+
expect(MockProxyAgent).toHaveBeenCalledWith(
69+
expect.objectContaining({ uri: 'http://proxy.example.com:8080' })
70+
);
71+
expect(mockSetGlobalDispatcher).toHaveBeenCalledOnce();
72+
expect(mockSetGlobalDispatcher).toHaveBeenCalledWith(mockProxyAgentInstance);
73+
expect(result).toBe(mockProxyAgentInstance);
74+
});
75+
76+
it('reads the CA file and passes tls options when CUSTOM_CA_PATH is set and file exists', () => {
77+
process.env.HTTP_PROXY = 'http://proxy.example.com:8080';
78+
process.env.CUSTOM_CA_PATH = '/certs/custom-ca.crt';
79+
const fakeBuffer = Buffer.from('cert-data');
80+
mockExistsSync.mockReturnValue(true);
81+
mockReadFileSync.mockReturnValue(fakeBuffer);
82+
83+
const result = initializeProxyAgent();
84+
85+
expect(mockExistsSync).toHaveBeenCalledWith('/certs/custom-ca.crt');
86+
expect(mockReadFileSync).toHaveBeenCalledWith('/certs/custom-ca.crt');
87+
expect(MockProxyAgent).toHaveBeenCalledWith(
88+
expect.objectContaining({
89+
uri: 'http://proxy.example.com:8080',
90+
tls: { ca: [fakeBuffer] },
91+
})
92+
);
93+
expect(result).toBe(mockProxyAgentInstance);
94+
});
95+
96+
it('throws an error when CUSTOM_CA_PATH is set but the file does not exist (exitOnError=false)', () => {
97+
process.env.HTTP_PROXY = 'http://proxy.example.com:8080';
98+
process.env.CUSTOM_CA_PATH = '/certs/missing-ca.crt';
99+
mockExistsSync.mockReturnValue(false);
100+
101+
expect(() => initializeProxyAgent(false)).toThrowError(
102+
'CUSTOM_CA_PATH file not found: /certs/missing-ca.crt'
103+
);
104+
expect(mockSetGlobalDispatcher).not.toHaveBeenCalled();
105+
});
106+
107+
it('calls process.exit(1) when initialization fails and exitOnError=true', () => {
108+
process.env.HTTP_PROXY = 'http://proxy.example.com:8080';
109+
process.env.CUSTOM_CA_PATH = '/certs/missing-ca.crt';
110+
mockExistsSync.mockReturnValue(false);
111+
112+
// Throw a sentinel so execution stops after process.exit — matching real process termination.
113+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(
114+
(() => { throw new Error('process.exit called'); }) as () => never
115+
);
116+
117+
expect(() => initializeProxyAgent(true)).toThrowError('process.exit called');
118+
expect(exitSpy).toHaveBeenCalledWith(1);
119+
120+
exitSpy.mockRestore();
121+
});
122+
});

0 commit comments

Comments
 (0)