Skip to content

Commit b6602ed

Browse files
authored
Merge pull request #19 from Johnaverse/copilot/add-proxy-support-rpc
Add optional HTTP/HTTPS proxy support for RPC requests
2 parents 5812aa7 + e2867e7 commit b6602ed

11 files changed

Lines changed: 272 additions & 7 deletions

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,7 @@ MAX_SEARCH_QUERY_LENGTH=200
3030

3131
# CORS
3232
CORS_ORIGIN=* # Allowed origins (* for all, or comma-separated list)
33+
34+
# Proxy (optional)
35+
# PROXY_URL=http://proxy.example.com:8080 # HTTP/HTTPS proxy URL (leave empty to disable)
36+
# PROXY_URL=http://user:pass@proxy.example.com:8080 # Proxy with authentication

README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,24 @@ The server will start on `http://localhost:3000` by default.
170170
npm run dev
171171
```
172172

173+
#### Using a Proxy (Optional)
174+
175+
To route all outbound requests through a proxy server, set the `PROXY_URL` environment variable:
176+
177+
```bash
178+
# Using a proxy without authentication
179+
PROXY_URL=http://proxy.example.com:8080 npm start
180+
181+
# Using a proxy with authentication
182+
PROXY_URL=http://user:pass@proxy.example.com:8080 npm start
183+
```
184+
185+
When configured, the proxy will be used for:
186+
- All RPC endpoint health checks and monitoring
187+
- Fetching data from external sources (The Graph, Chainlist, etc.)
188+
189+
The proxy configuration is optional and disabled by default. See the [Environment Variables](#environment-variables) section for more details.
190+
173191
### MCP Server (for AI Assistants)
174192

175193
The Chains API can also be used as an MCP (Model Context Protocol) server, allowing AI assistants like Claude to query blockchain chain data directly. Two transport modes are supported:
@@ -274,11 +292,38 @@ Each tool returns JSON data that can be used by AI assistants to answer question
274292

275293
## Environment Variables
276294

295+
### Server Configuration
277296
- `PORT`: REST API server port (default: 3000)
278297
- `HOST`: REST API server host (default: 0.0.0.0)
279298
- `MCP_PORT`: MCP HTTP server port (default: 3001)
280299
- `MCP_HOST`: MCP HTTP server host (default: 0.0.0.0)
281300

301+
### Proxy Configuration (Optional)
302+
- `PROXY_URL`: HTTP/HTTPS proxy URL for all outbound requests (default: empty/disabled)
303+
- Example: `http://proxy.example.com:8080`
304+
- With authentication: `http://user:pass@proxy.example.com:8080`
305+
- When set, all RPC requests and data source fetching will route through this proxy
306+
- Leave empty or unset to disable proxy support
307+
308+
### Rate Limiting
309+
- `RATE_LIMIT_MAX`: Maximum requests per window for global endpoints (default: 100)
310+
- `RATE_LIMIT_WINDOW_MS`: Rate limit window in milliseconds (default: 60000 = 1 minute)
311+
- `RELOAD_RATE_LIMIT_MAX`: Maximum `/reload` requests per window (default: 5)
312+
- `SEARCH_RATE_LIMIT_MAX`: Maximum `/search` requests per window (default: 30)
313+
314+
### RPC Health Check
315+
- `RPC_CHECK_TIMEOUT_MS`: Timeout per RPC health check call in milliseconds (default: 8000)
316+
- `RPC_CHECK_CONCURRENCY`: Number of parallel RPC health checks (default: 8)
317+
- `MAX_ENDPOINTS_PER_CHAIN`: Maximum RPC endpoints tested per chain (default: 5)
318+
319+
### Other
320+
- `BODY_LIMIT`: Maximum request body size in bytes (default: 1048576 = 1 MB)
321+
- `MAX_PARAM_LENGTH`: Maximum URL parameter length (default: 200)
322+
- `MAX_SEARCH_QUERY_LENGTH`: Maximum search query length (default: 200)
323+
- `CORS_ORIGIN`: Allowed CORS origins (default: `*` for all origins)
324+
325+
See `.env.example` for a complete list of environment variables with example values.
326+
282327
## API Endpoints
283328

284329
### `GET /`

config.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,7 @@ export const DATA_SOURCE_SLIP44 = parseStringEnv(
6060

6161
// CORS
6262
export const CORS_ORIGIN = parseStringEnv('CORS_ORIGIN', '*');
63+
64+
// Proxy (optional)
65+
export const PROXY_URL = parseStringEnv('PROXY_URL', '');
66+
export const PROXY_ENABLED = PROXY_URL !== '';

dataService.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
DATA_SOURCE_CHAINS, DATA_SOURCE_SLIP44,
44
RPC_CHECK_TIMEOUT_MS, RPC_CHECK_CONCURRENCY
55
} from './config.js';
6+
import { proxyFetch } from './fetchUtil.js';
67

78
// Data source URLs (from config, overridable via env)
89
const DATA_SOURCES = {
@@ -32,7 +33,7 @@ let rpcCheckPending = false;
3233
*/
3334
async function fetchData(url, format = 'json') {
3435
try {
35-
const response = await fetch(url);
36+
const response = await proxyFetch(url);
3637
if (!response.ok) {
3738
throw new Error(`HTTP error! status: ${response.status}`);
3839
}
@@ -944,7 +945,7 @@ async function performJsonRpc(url, method) {
944945
const timeoutId = setTimeout(() => controller.abort(), RPC_CHECK_TIMEOUT_MS);
945946

946947
try {
947-
const response = await fetch(url, {
948+
const response = await proxyFetch(url, {
948949
method: 'POST',
949950
headers: { 'Content-Type': 'application/json' },
950951
body: JSON.stringify({

fetchUtil.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { HttpsProxyAgent } from 'https-proxy-agent';
2+
import { PROXY_URL, PROXY_ENABLED } from './config.js';
3+
4+
/**
5+
* Proxy-aware fetch wrapper
6+
* Supports HTTP/HTTPS proxies via the PROXY_URL environment variable
7+
* Falls back to standard fetch if no proxy is configured
8+
*/
9+
10+
let proxyAgent = null;
11+
12+
// Initialize proxy agent if configured
13+
if (PROXY_ENABLED) {
14+
try {
15+
proxyAgent = new HttpsProxyAgent(PROXY_URL);
16+
console.log(`Proxy enabled: ${PROXY_URL.replace(/:[^:@]*@/, ':****@')}`); // Hide password in logs
17+
} catch (error) {
18+
console.error(`Failed to initialize proxy agent: ${error.message}`);
19+
console.error('Proxy will be disabled. Continuing without proxy support.');
20+
}
21+
}
22+
23+
/**
24+
* Proxy-aware fetch function
25+
* @param {string} url - URL to fetch
26+
* @param {object} options - Fetch options
27+
* @returns {Promise<Response>} Fetch response
28+
*/
29+
export async function proxyFetch(url, options = {}) {
30+
// If proxy is enabled and agent is initialized, add it to options
31+
if (proxyAgent && (url.startsWith('http://') || url.startsWith('https://'))) {
32+
// Create a new options object to avoid mutating the original
33+
const proxyOptions = {
34+
...options,
35+
// Use dispatcher for undici (Node's fetch implementation)
36+
dispatcher: proxyAgent
37+
};
38+
return fetch(url, proxyOptions);
39+
}
40+
41+
// Use standard fetch if no proxy configured
42+
return fetch(url, options);
43+
}
44+
45+
/**
46+
* Get proxy status information
47+
* @returns {object} Proxy status
48+
*/
49+
export function getProxyStatus() {
50+
return {
51+
enabled: PROXY_ENABLED,
52+
url: PROXY_ENABLED ? PROXY_URL.replace(/:[^:@]*@/, ':****@') : null
53+
};
54+
}

package-lock.json

Lines changed: 24 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@
3434
"@fastify/rate-limit": "^10.3.0",
3535
"@modelcontextprotocol/sdk": "^1.26.0",
3636
"express": "^5.2.1",
37-
"fastify": "^5.7.4"
37+
"fastify": "^5.7.4",
38+
"https-proxy-agent": "^7.0.6"
3839
},
3940
"devDependencies": {
4041
"@fast-check/vitest": "^0.2.4",

rpcMonitor.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { getAllEndpoints } from './dataService.js';
22
import { MAX_ENDPOINTS_PER_CHAIN } from './config.js';
3+
import { proxyFetch } from './fetchUtil.js';
34

45
// Store monitoring results in memory
56
let monitoringResults = {
@@ -60,7 +61,7 @@ async function makeRpcCall(url, method, params = []) {
6061
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
6162

6263
try {
63-
const response = await fetch(url, {
64+
const response = await proxyFetch(url, {
6465
method: 'POST',
6566
headers: {
6667
'Content-Type': 'application/json',

tests/unit/dataService.test.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,15 @@ vi.mock('../../config.js', () => ({
77
DATA_SOURCE_CHAINS: 'https://example.com/chains.json',
88
DATA_SOURCE_SLIP44: 'https://example.com/slip44.md',
99
RPC_CHECK_TIMEOUT_MS: 8000,
10-
RPC_CHECK_CONCURRENCY: 8
10+
RPC_CHECK_CONCURRENCY: 8,
11+
PROXY_URL: '',
12+
PROXY_ENABLED: false
13+
}));
14+
15+
// Mock fetchUtil to use standard fetch
16+
vi.mock('../../fetchUtil.js', () => ({
17+
proxyFetch: vi.fn((...args) => fetch(...args)),
18+
getProxyStatus: vi.fn(() => ({ enabled: false, url: null }))
1119
}));
1220

1321
import {

tests/unit/proxy.test.js

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
3+
describe('Proxy Support', () => {
4+
beforeEach(() => {
5+
vi.resetModules();
6+
vi.clearAllMocks();
7+
});
8+
9+
describe('fetchUtil', () => {
10+
it('should export proxyFetch and getProxyStatus functions', async () => {
11+
// Mock config with proxy disabled
12+
vi.doMock('../../config.js', () => ({
13+
PROXY_URL: '',
14+
PROXY_ENABLED: false
15+
}));
16+
17+
const { proxyFetch, getProxyStatus } = await import('../../fetchUtil.js');
18+
19+
expect(proxyFetch).toBeDefined();
20+
expect(typeof proxyFetch).toBe('function');
21+
expect(getProxyStatus).toBeDefined();
22+
expect(typeof getProxyStatus).toBe('function');
23+
});
24+
25+
it('should return disabled status when proxy is not configured', async () => {
26+
vi.doMock('../../config.js', () => ({
27+
PROXY_URL: '',
28+
PROXY_ENABLED: false
29+
}));
30+
31+
const { getProxyStatus } = await import('../../fetchUtil.js');
32+
const status = getProxyStatus();
33+
34+
expect(status.enabled).toBe(false);
35+
expect(status.url).toBeNull();
36+
});
37+
38+
it('should return enabled status when proxy is configured', async () => {
39+
vi.doMock('../../config.js', () => ({
40+
PROXY_URL: 'http://proxy.example.com:8080',
41+
PROXY_ENABLED: true
42+
}));
43+
44+
const { getProxyStatus } = await import('../../fetchUtil.js');
45+
const status = getProxyStatus();
46+
47+
expect(status.enabled).toBe(true);
48+
expect(status.url).toBe('http://proxy.example.com:8080');
49+
});
50+
51+
it('should hide password in proxy URL when returning status', async () => {
52+
vi.doMock('../../config.js', () => ({
53+
PROXY_URL: 'http://user:password@proxy.example.com:8080',
54+
PROXY_ENABLED: true
55+
}));
56+
57+
const { getProxyStatus } = await import('../../fetchUtil.js');
58+
const status = getProxyStatus();
59+
60+
expect(status.enabled).toBe(true);
61+
expect(status.url).toBe('http://user:****@proxy.example.com:8080');
62+
expect(status.url).not.toContain('password');
63+
});
64+
65+
it('should use standard fetch when proxy is disabled', async () => {
66+
vi.doMock('../../config.js', () => ({
67+
PROXY_URL: '',
68+
PROXY_ENABLED: false
69+
}));
70+
71+
global.fetch = vi.fn().mockResolvedValue({
72+
ok: true,
73+
json: async () => ({ test: 'data' })
74+
});
75+
76+
const { proxyFetch } = await import('../../fetchUtil.js');
77+
78+
await proxyFetch('https://example.com/api', { method: 'GET' });
79+
80+
expect(global.fetch).toHaveBeenCalledWith('https://example.com/api', { method: 'GET' });
81+
});
82+
83+
it('should handle proxy configuration errors gracefully', async () => {
84+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
85+
86+
vi.doMock('../../config.js', () => ({
87+
PROXY_URL: 'invalid-url',
88+
PROXY_ENABLED: true
89+
}));
90+
91+
global.fetch = vi.fn().mockResolvedValue({
92+
ok: true,
93+
json: async () => ({ test: 'data' })
94+
});
95+
96+
const { proxyFetch } = await import('../../fetchUtil.js');
97+
98+
// Should fall back to standard fetch if proxy initialization fails
99+
const result = await proxyFetch('https://example.com/api');
100+
101+
expect(result).toBeDefined();
102+
expect(global.fetch).toHaveBeenCalled();
103+
104+
consoleErrorSpy.mockRestore();
105+
});
106+
});
107+
108+
describe('Config', () => {
109+
it('should export PROXY_URL and PROXY_ENABLED', async () => {
110+
const config = await import('../../config.js');
111+
112+
expect(config).toHaveProperty('PROXY_URL');
113+
expect(config).toHaveProperty('PROXY_ENABLED');
114+
});
115+
});
116+
});

0 commit comments

Comments
 (0)