Skip to content

Commit 4b557a0

Browse files
egavrindevagent
andcommitted
fix: keep undici out of Bun startup
- Move proxy-aware fetch behind lazy runtime loading - Externalize undici from the publish bundle and smoke-test Bun startup Co-Authored-By: devagent <devagent@egavrin>
1 parent 7b48e5d commit 4b557a0

10 files changed

Lines changed: 305 additions & 206 deletions

File tree

packages/providers/src/index.test.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ describe("createDefaultRegistry", () => {
8989
expect(body.messages?.map((message) => message.role)).not.toContain("developer");
9090
});
9191

92-
it("keeps devagent-api request rewriting when proxy env vars are configured", async () => {
92+
it("keeps devagent-api request rewriting when proxy env vars are configured for loopback", async () => {
9393
const fetchMock = vi.fn().mockResolvedValue(makeChatStreamingResponse());
9494
globalThis.fetch = fetchMock as typeof globalThis.fetch;
9595
setProxyEnv(originalProxyEnv, {
@@ -100,6 +100,7 @@ describe("createDefaultRegistry", () => {
100100
const provider = registry.get("devagent-api", {
101101
model: "cortex",
102102
apiKey: "test-key",
103+
baseUrl: "http://localhost:8080/v1",
103104
capabilities: {
104105
useResponsesApi: false,
105106
reasoning: true,
@@ -120,7 +121,7 @@ describe("createDefaultRegistry", () => {
120121
messages?: Array<{ role?: string }>;
121122
};
122123
expect(headers.get("x-request-id")).toBeTruthy();
123-
expect(init.dispatcher).toBeTruthy();
124+
expect(init.dispatcher).toBeUndefined();
124125
expect(body.messages?.map((message) => message.role)).toContain("system");
125126
expect(body.messages?.map((message) => message.role)).not.toContain("developer");
126127
});
@@ -185,7 +186,7 @@ describe("createDefaultRegistry", () => {
185186
expect(body.messages?.map((message) => message.role)).not.toContain("developer");
186187
});
187188

188-
it("keeps DeepSeek request rewriting when proxy env vars are configured", async () => {
189+
it("keeps DeepSeek request rewriting when proxy env vars are configured for loopback", async () => {
189190
const fetchMock = vi.fn().mockResolvedValue(makeChatStreamingResponse());
190191
globalThis.fetch = fetchMock as typeof globalThis.fetch;
191192
setProxyEnv(originalProxyEnv, {
@@ -196,6 +197,7 @@ describe("createDefaultRegistry", () => {
196197
const provider = registry.get("deepseek", {
197198
model: "deepseek-chat",
198199
apiKey: "test-key",
200+
baseUrl: "http://localhost:8080/v1",
199201
capabilities: {
200202
useResponsesApi: false,
201203
reasoning: true,
@@ -214,7 +216,7 @@ describe("createDefaultRegistry", () => {
214216
const body = JSON.parse(String(init.body ?? "{}")) as {
215217
messages?: Array<{ role?: string }>;
216218
};
217-
expect(init.dispatcher).toBeTruthy();
219+
expect(init.dispatcher).toBeUndefined();
218220
expect(body.messages?.map((message) => message.role)).toContain("system");
219221
expect(body.messages?.map((message) => message.role)).not.toContain("developer");
220222
});

packages/providers/src/network.test.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,20 +38,32 @@ describe("network proxy helpers", () => {
3838
it("does not attach a dispatcher when no proxy env vars are set", async () => {
3939
await withClearedProxyEnvAsync(async () => {
4040
const fetchMock = vi.fn().mockResolvedValue(new Response("ok"));
41-
const proxyFetch = createProxyAwareFetch(fetchMock as typeof globalThis.fetch);
41+
const loadUndici = vi.fn();
42+
const proxyFetch = createProxyAwareFetch(fetchMock as typeof globalThis.fetch, { loadUndici });
4243

4344
await proxyFetch("https://example.com/v1/models");
4445

4546
expect(fetchMock).toHaveBeenCalledTimes(1);
4647
expect(fetchMock.mock.calls[0]?.[1]).toBeUndefined();
48+
expect(loadUndici).not.toHaveBeenCalled();
4749
});
4850
});
4951

5052
it("attaches an undici dispatcher for remote requests when proxy env vars are set", async () => {
5153
await withClearedProxyEnvAsync(async () => {
5254
process.env["HTTPS_PROXY"] = "https://proxy.example.com:8443";
5355
const fetchMock = vi.fn().mockResolvedValue(new Response("ok"));
54-
const proxyFetch = createProxyAwareFetch(fetchMock as typeof globalThis.fetch);
56+
const dispatcher = { dispatch: vi.fn() };
57+
const proxyFetch = createProxyAwareFetch(fetchMock as typeof globalThis.fetch, {
58+
runtime: "node",
59+
loadUndici: async () => ({
60+
EnvHttpProxyAgent: class {
61+
constructor() {
62+
return dispatcher;
63+
}
64+
},
65+
}),
66+
});
5567

5668
await proxyFetch("https://example.com/v1/models", {
5769
headers: { "x-test": "1" },
@@ -60,19 +72,34 @@ describe("network proxy helpers", () => {
6072
expect(fetchMock).toHaveBeenCalledTimes(1);
6173
const init = fetchMock.mock.calls[0]?.[1] as RequestInit & { dispatcher?: unknown };
6274
expect(init.headers).toEqual({ "x-test": "1" });
63-
expect(init.dispatcher).toBeTruthy();
75+
expect(init.dispatcher).toBe(dispatcher);
76+
});
77+
});
78+
79+
it("fails clearly under Bun when proxy env vars are set", async () => {
80+
await withClearedProxyEnvAsync(async () => {
81+
process.env["HTTPS_PROXY"] = "https://proxy.example.com:8443";
82+
const fetchMock = vi.fn().mockResolvedValue(new Response("ok"));
83+
const proxyFetch = createProxyAwareFetch(fetchMock as typeof globalThis.fetch, { runtime: "bun" });
84+
85+
await expect(proxyFetch("https://example.com/v1/models")).rejects.toThrow(
86+
"proxy dispatchers require Node.js",
87+
);
88+
expect(fetchMock).not.toHaveBeenCalled();
6489
});
6590
});
6691

6792
it("bypasses proxy dispatch for loopback hosts even when proxy env vars are set", async () => {
6893
await withClearedProxyEnvAsync(async () => {
6994
process.env["HTTPS_PROXY"] = "https://proxy.example.com:8443";
7095
const fetchMock = vi.fn().mockResolvedValue(new Response("ok"));
71-
const proxyFetch = createProxyAwareFetch(fetchMock as typeof globalThis.fetch);
96+
const loadUndici = vi.fn();
97+
const proxyFetch = createProxyAwareFetch(fetchMock as typeof globalThis.fetch, { runtime: "node", loadUndici });
7298

7399
await proxyFetch("http://localhost:11434/v1/models");
74100
expect(fetchMock).toHaveBeenCalledTimes(1);
75101
expect((fetchMock.mock.calls[0]?.[1] as { dispatcher?: unknown } | undefined)?.dispatcher).toBeUndefined();
102+
expect(loadUndici).not.toHaveBeenCalled();
76103
expect(shouldBypassProxy("http://127.0.0.1:11434/v1/models")).toBe(true);
77104
expect(shouldBypassProxy("http://[::1]:11434/v1/models")).toBe(true);
78105
});

packages/providers/src/network.ts

Lines changed: 5 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,5 @@
1-
import { EnvHttpProxyAgent, type Dispatcher } from "undici";
2-
3-
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
4-
5-
interface ProxyableRequestInit extends RequestInit {
6-
dispatcher?: Dispatcher;
7-
}
8-
9-
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
10-
11-
let cachedProxySignature: string | null = null;
12-
let cachedProxyDispatcher: Dispatcher | null = null;
13-
14-
function getProxyEnvValue(name: string): string | undefined {
15-
return process.env[name] ?? process.env[name.toLowerCase()];
16-
}
17-
18-
function getProxySignature(): string | null {
19-
const httpProxy = getProxyEnvValue("HTTP_PROXY");
20-
const httpsProxy = getProxyEnvValue("HTTPS_PROXY");
21-
if (!httpProxy && !httpsProxy) return null;
22-
23-
return JSON.stringify({
24-
HTTP_PROXY: httpProxy ?? null,
25-
HTTPS_PROXY: httpsProxy ?? null,
26-
NO_PROXY: getProxyEnvValue("NO_PROXY") ?? null,
27-
});
28-
}
29-
30-
function getProxyDispatcher(): Dispatcher | null {
31-
const signature = getProxySignature();
32-
if (!signature) return null;
33-
if (signature === cachedProxySignature && cachedProxyDispatcher) {
34-
return cachedProxyDispatcher;
35-
}
36-
37-
cachedProxySignature = signature;
38-
cachedProxyDispatcher = new EnvHttpProxyAgent();
39-
return cachedProxyDispatcher;
40-
}
41-
42-
function resolveRequestUrl(input: RequestInfo | URL): URL | null {
43-
try {
44-
if (typeof input === "string") return new URL(input);
45-
if (input instanceof URL) return input;
46-
if (typeof Request !== "undefined" && input instanceof Request) {
47-
return new URL(input.url);
48-
}
49-
if (typeof input === "object" && input !== null && "url" in input) {
50-
const url = (input as { url?: unknown }).url;
51-
return typeof url === "string" ? new URL(url) : null;
52-
}
53-
} catch {
54-
return null;
55-
}
56-
return null;
57-
}
58-
59-
export function hasProxyEnv(): boolean {
60-
return getProxySignature() !== null;
61-
}
62-
63-
export function shouldBypassProxy(input: RequestInfo | URL): boolean {
64-
const url = resolveRequestUrl(input);
65-
if (!url) return false;
66-
return LOOPBACK_HOSTS.has(url.hostname.toLowerCase());
67-
}
68-
69-
export function createProxyAwareFetch(
70-
baseFetch: FetchFn = globalThis.fetch.bind(globalThis),
71-
): FetchFn {
72-
return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
73-
const dispatcher = getProxyDispatcher();
74-
if (!dispatcher || shouldBypassProxy(input)) {
75-
return baseFetch(input, init);
76-
}
77-
78-
return baseFetch(input, {
79-
...(init ?? {}),
80-
dispatcher,
81-
} as ProxyableRequestInit);
82-
};
83-
}
1+
export {
2+
createProxyAwareFetch,
3+
hasProxyEnv,
4+
shouldBypassProxy,
5+
} from "@devagent/runtime";

packages/providers/src/openai.test.ts

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -94,28 +94,6 @@ describe("createOpenAIProvider", () => {
9494
expect(headers.get("x-request-id")).toBe("preset-request-id");
9595
});
9696

97-
it("uses proxy-aware transport for remote requests when proxy env vars are set", async () => {
98-
const fetchMock = vi.fn().mockResolvedValue(makeStreamingResponse());
99-
globalThis.fetch = fetchMock as typeof globalThis.fetch;
100-
setProxyEnv(originalProxyEnv, {
101-
HTTPS_PROXY: "https://proxy.example.com:8443",
102-
});
103-
104-
const provider = createOpenAIProvider({
105-
model: "gpt-4o",
106-
apiKey: "test-key",
107-
requestIdHeaderName: "x-request-id",
108-
});
109-
110-
await collectText(provider);
111-
112-
expect(fetchMock).toHaveBeenCalledTimes(1);
113-
const init = fetchMock.mock.calls[0]?.[1] as RequestInit & { dispatcher?: unknown };
114-
const headers = new Headers(init.headers);
115-
expect(headers.get("x-request-id")).toBeTruthy();
116-
expect(init.dispatcher).toBeTruthy();
117-
});
118-
11997
it("bypasses proxy-aware transport for loopback baseUrls", async () => {
12098
const fetchMock = vi.fn().mockResolvedValue(makeStreamingResponse());
12199
globalThis.fetch = fetchMock as typeof globalThis.fetch;

packages/runtime/src/core/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,20 @@ export {
5454

5555
export type { ToolCategory } from "./types.js";
5656

57+
// Proxy-aware fetch
58+
export {
59+
BUN_PROXY_UNSUPPORTED_MESSAGE,
60+
createProxyAwareFetch,
61+
hasProxyEnv,
62+
shouldBypassProxy,
63+
} from "./proxy-fetch.js";
64+
export type {
65+
FetchFn,
66+
ProxyAwareFetchOptions,
67+
ProxyRuntime,
68+
UndiciProxyModule,
69+
} from "./proxy-fetch.js";
70+
5771
// Event bus
5872
export { EventBus } from "./events.js";
5973
export type {
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import type { Dispatcher } from "undici";
2+
3+
export type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
4+
export type ProxyRuntime = "bun" | "node";
5+
6+
export type UndiciProxyModule = {
7+
readonly EnvHttpProxyAgent: new () => Dispatcher;
8+
};
9+
10+
export interface ProxyAwareFetchOptions {
11+
readonly runtime?: ProxyRuntime;
12+
readonly loadUndici?: () => Promise<UndiciProxyModule>;
13+
readonly createUnsupportedProxyError?: (message: string) => Error;
14+
}
15+
16+
interface ProxyableRequestInit extends RequestInit {
17+
readonly dispatcher?: Dispatcher;
18+
}
19+
20+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
21+
22+
export const BUN_PROXY_UNSUPPORTED_MESSAGE =
23+
"Proxy environment variables are set, but proxy dispatchers require Node.js for now. Unset proxy env vars or run DevAgent with Node.js.";
24+
25+
let cachedProxySignature: string | null = null;
26+
let cachedProxyDispatcher: Dispatcher | null = null;
27+
28+
function getProxyEnvValue(name: string): string | undefined {
29+
return process.env[name] ?? process.env[name.toLowerCase()];
30+
}
31+
32+
function getProxySignature(): string | null {
33+
const httpProxy = getProxyEnvValue("HTTP_PROXY");
34+
const httpsProxy = getProxyEnvValue("HTTPS_PROXY");
35+
if (!httpProxy && !httpsProxy) return null;
36+
37+
return JSON.stringify({
38+
HTTP_PROXY: httpProxy ?? null,
39+
HTTPS_PROXY: httpsProxy ?? null,
40+
NO_PROXY: getProxyEnvValue("NO_PROXY") ?? null,
41+
});
42+
}
43+
44+
function getProxyRuntime(): ProxyRuntime {
45+
return typeof Bun === "undefined" ? "node" : "bun";
46+
}
47+
48+
async function loadUndiciProxyModule(): Promise<UndiciProxyModule> {
49+
return await import("undici") as UndiciProxyModule;
50+
}
51+
52+
async function getProxyDispatcher(options?: ProxyAwareFetchOptions): Promise<Dispatcher | null> {
53+
const signature = getProxySignature();
54+
if (!signature) return null;
55+
const runtime = options?.runtime ?? getProxyRuntime();
56+
if (runtime === "bun") {
57+
throw options?.createUnsupportedProxyError?.(BUN_PROXY_UNSUPPORTED_MESSAGE)
58+
?? new Error(BUN_PROXY_UNSUPPORTED_MESSAGE);
59+
}
60+
if (signature === cachedProxySignature && cachedProxyDispatcher) {
61+
return cachedProxyDispatcher;
62+
}
63+
64+
const { EnvHttpProxyAgent } = await (options?.loadUndici ?? loadUndiciProxyModule)();
65+
cachedProxySignature = signature;
66+
cachedProxyDispatcher = new EnvHttpProxyAgent();
67+
return cachedProxyDispatcher;
68+
}
69+
70+
function resolveRequestUrl(input: RequestInfo | URL): URL | null {
71+
try {
72+
if (typeof input === "string") return new URL(input);
73+
if (input instanceof URL) return input;
74+
if (typeof Request !== "undefined" && input instanceof Request) {
75+
return new URL(input.url);
76+
}
77+
if (typeof input === "object" && input !== null && "url" in input) {
78+
const url = (input as { url?: unknown }).url;
79+
return typeof url === "string" ? new URL(url) : null;
80+
}
81+
} catch {
82+
return null;
83+
}
84+
return null;
85+
}
86+
87+
export function hasProxyEnv(): boolean {
88+
return getProxySignature() !== null;
89+
}
90+
91+
export function shouldBypassProxy(input: RequestInfo | URL): boolean {
92+
const url = resolveRequestUrl(input);
93+
if (!url) return false;
94+
return LOOPBACK_HOSTS.has(url.hostname.toLowerCase());
95+
}
96+
97+
export function createProxyAwareFetch(
98+
baseFetch: FetchFn = globalThis.fetch.bind(globalThis),
99+
options?: ProxyAwareFetchOptions,
100+
): FetchFn {
101+
return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
102+
if (shouldBypassProxy(input)) {
103+
return baseFetch(input, init);
104+
}
105+
const dispatcher = await getProxyDispatcher(options);
106+
if (!dispatcher) {
107+
return baseFetch(input, init);
108+
}
109+
110+
return baseFetch(input, {
111+
...(init ?? {}),
112+
dispatcher,
113+
} as ProxyableRequestInit);
114+
};
115+
}

0 commit comments

Comments
 (0)