Skip to content

Commit da131ea

Browse files
Pablo Vitassoclaude
andcommitted
feat(api): add undici fetch wrapper utility
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3979da6 commit da131ea

1 file changed

Lines changed: 171 additions & 0 deletions

File tree

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/**
2+
* Wrapper to fix OpenAI SDK connection issues with Node.js 20+ and localhost
3+
*
4+
* Problem: Node.js 20+ uses undici for fetch(), which has bugs with localhost connections.
5+
* Both native fetch() and OpenAI SDK fail silently on localhost in streaming mode.
6+
*
7+
* Solution: Use undici.Client directly instead of fetch(), which works correctly.
8+
* This wrapper replaces global fetch with an undici-based implementation.
9+
*/
10+
11+
import * as undici from "undici"
12+
13+
const clientCache = new Map<string, undici.Client>()
14+
15+
function getOrCreateClient(baseURL: string): undici.Client {
16+
if (!clientCache.has(baseURL)) {
17+
clientCache.set(baseURL, new undici.Client(baseURL))
18+
}
19+
return clientCache.get(baseURL)!
20+
}
21+
22+
interface FetchHeaders {
23+
[key: string]: string | string[]
24+
}
25+
26+
/**
27+
* Wrapper for headers that implements the Headers interface
28+
*/
29+
class HeadersWrapper {
30+
private headersMap: Map<string, string>
31+
32+
constructor(headers: FetchHeaders) {
33+
this.headersMap = new Map()
34+
for (const [key, value] of Object.entries(headers)) {
35+
const headerValue = Array.isArray(value) ? value.join(",") : String(value)
36+
this.headersMap.set(key.toLowerCase(), headerValue)
37+
}
38+
}
39+
40+
get(name: string): string | null {
41+
return this.headersMap.get(name.toLowerCase()) || null
42+
}
43+
44+
has(name: string): boolean {
45+
return this.headersMap.has(name.toLowerCase())
46+
}
47+
48+
entries(): IterableIterator<[string, string]> {
49+
return this.headersMap.entries()
50+
}
51+
52+
keys(): IterableIterator<string> {
53+
return this.headersMap.keys()
54+
}
55+
56+
values(): IterableIterator<string> {
57+
return this.headersMap.values()
58+
}
59+
60+
[Symbol.iterator](): IterableIterator<[string, string]> {
61+
return this.headersMap.entries()
62+
}
63+
64+
forEach(callback: (value: string, key: string, parent: HeadersWrapper) => void, thisArg?: any): void {
65+
this.headersMap.forEach((value, key) => {
66+
callback.call(thisArg, value, key, this)
67+
})
68+
}
69+
}
70+
71+
class FetchResponse {
72+
ok: boolean
73+
status: number
74+
statusText: string
75+
headers: HeadersWrapper
76+
body: AsyncIterable<Buffer>
77+
bodyUsed: boolean = false
78+
79+
constructor(statusCode: number, headers: FetchHeaders, body: AsyncIterable<Buffer>) {
80+
this.status = statusCode
81+
this.ok = statusCode >= 200 && statusCode < 300
82+
this.statusText = ""
83+
this.headers = new HeadersWrapper(headers)
84+
this.body = body
85+
}
86+
87+
async json() {
88+
let data = ""
89+
for await (const chunk of this.body) {
90+
data += chunk.toString()
91+
}
92+
return JSON.parse(data)
93+
}
94+
95+
async text() {
96+
let data = ""
97+
for await (const chunk of this.body) {
98+
data += chunk.toString()
99+
}
100+
return data
101+
}
102+
103+
async blob() {
104+
let data = Buffer.alloc(0)
105+
for await (const chunk of this.body) {
106+
data = Buffer.concat([data, chunk])
107+
}
108+
return data
109+
}
110+
111+
async arrayBuffer() {
112+
const blob = await this.blob()
113+
return blob.buffer.slice(blob.byteOffset, blob.byteOffset + blob.byteLength)
114+
}
115+
116+
clone() {
117+
throw new Error("Response.clone() not implemented in undici wrapper")
118+
}
119+
}
120+
121+
/**
122+
* Undici-based fetch wrapper that works with OpenAI SDK
123+
*/
124+
export function createUndicsiFetch() {
125+
return async function fetch(url: string | URL, options?: RequestInit & { timeout?: number }): Promise<Response> {
126+
const urlObj = new URL(url)
127+
const baseURL = `${urlObj.protocol}//${urlObj.host}`
128+
const path = urlObj.pathname + (urlObj.search || "")
129+
130+
const client = getOrCreateClient(baseURL)
131+
132+
try {
133+
const response = await client.request({
134+
path,
135+
method: options?.method || "GET",
136+
headers: options?.headers as Record<string, string>,
137+
body: options?.body,
138+
})
139+
140+
return new FetchResponse(response.statusCode, response.headers as FetchHeaders, response.body) as any
141+
} catch (error) {
142+
throw new Error(`Fetch failed: ${error instanceof Error ? error.message : String(error)}`)
143+
}
144+
}
145+
}
146+
147+
/**
148+
* Install the undici-based fetch wrapper as global fetch
149+
* Call this at the top of your application initialization
150+
*/
151+
export function installUndisciFetchWrapper() {
152+
if (typeof globalThis !== "undefined") {
153+
// Store original fetch for debugging/fallback
154+
const originalFetch = (globalThis as any).fetch
155+
156+
// Override global fetch
157+
;(globalThis as any).fetch = createUndicsiFetch()
158+
159+
console.log("[undici-fetch-wrapper] Global fetch replaced with undici-based implementation")
160+
161+
// Return cleanup function
162+
return () => {
163+
;(globalThis as any).fetch = originalFetch
164+
// Close all cached clients
165+
for (const client of clientCache.values()) {
166+
client.close().catch(() => {})
167+
}
168+
clientCache.clear()
169+
}
170+
}
171+
}

0 commit comments

Comments
 (0)