Skip to content

Commit eebe6f7

Browse files
committed
fix(ci): run unit tests on Node 24 and lazy-load undici
CI pinned Node 20 but undici 8 requires >=22.19, crashing every suite at import time (webidl.util.markAsUncloneable). Bump CI to Node 24 and load undici lazily so direct-only deployments never pay for or depend on the proxy runtime: load failure fails open to direct.
1 parent 9d1b423 commit eebe6f7

3 files changed

Lines changed: 77 additions & 8 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ jobs:
2323
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
2424
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
2525
with:
26-
node-version: 20
26+
# Must satisfy package.json engines (undici 8 needs >=22.19).
27+
node-version: 24
2728
cache: npm
2829
- run: npm ci
2930
- run: npm run typecheck

‎src/upstream-proxy/pool.ts‎

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
//
77
// Loopback targets (localhost/127.0.0.1/::1) always bypass the proxy so the
88
// default managed backend (`http://127.0.0.1:10001`) is never proxied.
9-
import { ProxyAgent, Socks5ProxyAgent, fetch as undiciFetch } from 'undici';
9+
//
10+
// undici is loaded lazily (dynamic import) so hosts on older Node keep
11+
// running direct-only: only actual proxy engagement needs it, and a load
12+
// failure fails open to direct with a warning.
1013
import type { Dispatcher } from 'undici';
1114

1215
export type ProxyStrategy = 'failover-rr' | 'round-robin' | 'random';
@@ -152,14 +155,39 @@ export function createProxyPool(options: ProxyPoolOptions = {}): UpstreamProxyPo
152155
let engagedUntil = 0;
153156
let current: string | null = null;
154157

155-
const getDispatcher = (proxyUrl: string): Dispatcher => {
158+
// Cached dynamic import: resolved at most once; a rejection (e.g. Node too
159+
// old for undici) is memorized so every proxied call fails open to direct.
160+
let undiciModule: {
161+
ProxyAgent: new (url: string) => Dispatcher;
162+
Socks5ProxyAgent: new (url: string) => Dispatcher;
163+
fetch: (input: unknown, init?: unknown) => Promise<unknown>;
164+
} | null = null;
165+
let undiciFailed = false;
166+
const loadUndici = async (): Promise<typeof undiciModule> => {
167+
if (undiciModule || undiciFailed) return undiciModule;
168+
try {
169+
const mod = (await import('undici')) as unknown as NonNullable<typeof undiciModule>;
170+
undiciModule = mod;
171+
return mod;
172+
} catch (err) {
173+
undiciFailed = true;
174+
logDebug('Upstream proxy runtime unavailable, staying direct', {
175+
error: err instanceof Error ? err.message : String(err),
176+
});
177+
return null;
178+
}
179+
};
180+
181+
const getDispatcher = async (proxyUrl: string): Promise<Dispatcher | null> => {
156182
const hit = dispatchers.get(proxyUrl);
157183
if (hit) return hit;
184+
const mod = await loadUndici();
185+
if (!mod) return null;
158186
const protocol = new URL(proxyUrl).protocol;
159187
const agent: Dispatcher =
160188
protocol === 'socks:' || protocol === 'socks5:' || protocol === 'socks5h:'
161-
? ((new Socks5ProxyAgent(proxyUrl) as unknown) as Dispatcher)
162-
: ((new ProxyAgent(proxyUrl) as unknown) as Dispatcher);
189+
? new mod.Socks5ProxyAgent(proxyUrl)
190+
: new mod.ProxyAgent(proxyUrl);
163191
dispatchers.set(proxyUrl, agent);
164192
return agent;
165193
};
@@ -224,10 +252,13 @@ export function createProxyPool(options: ProxyPoolOptions = {}): UpstreamProxyPo
224252
if (!proxyUrl) return direct();
225253
current = proxyUrl;
226254
try {
227-
return await (undiciFetch as (i: unknown, o?: unknown) => Promise<unknown>)(input as never, {
255+
const mod = await loadUndici();
256+
const dispatcher = proxyUrl ? await getDispatcher(proxyUrl) : null;
257+
if (!mod || !dispatcher) return direct();
258+
return await mod.fetch(input as never, {
228259
...((init as Record<string, unknown>) ?? {}),
229-
dispatcher: getDispatcher(proxyUrl),
230-
});
260+
dispatcher,
261+
} as never);
231262
} catch (err) {
232263
// Transport failure through this proxy: cool it, fail open to direct
233264
// for this call (quota errors are handled by callers, not here).
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { jest } from '@jest/globals';
2+
3+
// Simulates hosts where undici cannot load (e.g. Node < 22.19): engagement
4+
// must fail open to direct instead of crashing the request.
5+
jest.unstable_mockModule('undici', () => {
6+
throw new Error('undici unavailable on this runtime');
7+
});
8+
9+
const { createProxyPool } = await import('../src/upstream-proxy/pool.js');
10+
11+
describe('proxy pool without undici runtime', () => {
12+
let savedFetch;
13+
let fetchCalls;
14+
beforeEach(() => {
15+
fetchCalls = [];
16+
savedFetch = globalThis.fetch;
17+
globalThis.fetch = (async (...args) => {
18+
fetchCalls.push(args);
19+
return new Response('direct-ok');
20+
}) as typeof fetch;
21+
});
22+
afterEach(() => {
23+
globalThis.fetch = savedFetch;
24+
});
25+
26+
test('engaged non-loopback target fails open to direct', async () => {
27+
const pool = createProxyPool({ proxies: 'socks5://10.0.0.9:1080', cooldownMs: 60000 });
28+
expect(pool.engage('free')).toContain('socks5://');
29+
expect(pool.isEngaged()).toBe(true);
30+
const res = await pool.proxiedFetch('https://example.com/api', {});
31+
expect(fetchCalls.length).toBe(1);
32+
expect(await res.text()).toBe('direct-ok');
33+
// Second call remembers the failure (no repeated import storms).
34+
await pool.proxiedFetch('https://example.com/api', {});
35+
expect(fetchCalls.length).toBe(2);
36+
});
37+
});

0 commit comments

Comments
 (0)