Skip to content

Commit 3684af4

Browse files
committed
fix: use explicit binding for Headers re-export for Node 24+ ESM compat
1 parent cb73a16 commit 3684af4

1 file changed

Lines changed: 175 additions & 0 deletions

File tree

src/fetch-adapter.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/**
2+
* Adapter to wrap undici's fetch to provide node-fetch v2 compatibility.
3+
* This allows gradual migration by providing a drop-in replacement.
4+
*
5+
* Node.js v18+ includes undici's fetch as global, no need to import.
6+
* This adapter bridges the gap between undici (Web Streams) and node-fetch v2 (Node streams).
7+
*/
8+
9+
import { Readable } from 'node:stream';
10+
11+
/**
12+
* Custom error classes to match node-fetch v2 behavior
13+
*/
14+
export class FetchError extends Error {
15+
public readonly type: string;
16+
public readonly code?: string;
17+
public readonly errno?: string;
18+
19+
constructor(message: string, type: string, systemError?: any) {
20+
super(message);
21+
this.name = 'FetchError';
22+
this.type = type;
23+
24+
if (systemError) {
25+
this.code = systemError.code;
26+
this.errno = systemError.errno;
27+
}
28+
29+
Error.captureStackTrace(this, this.constructor);
30+
}
31+
}
32+
33+
export class AbortError extends Error {
34+
public readonly type: string;
35+
36+
constructor(message: string = 'The operation was aborted') {
37+
super(message);
38+
this.name = 'AbortError';
39+
this.type = 'aborted';
40+
41+
Error.captureStackTrace(this, this.constructor);
42+
}
43+
}
44+
45+
/**
46+
* Compatible response interface that matches node-fetch v2
47+
*/
48+
export interface NodeFetchResponse {
49+
readonly headers: Headers;
50+
readonly ok: boolean;
51+
readonly redirected: boolean;
52+
readonly status: number;
53+
readonly statusText: string;
54+
readonly type: string;
55+
readonly url: string;
56+
readonly body: Readable | null;
57+
readonly bodyUsed: boolean;
58+
59+
arrayBuffer(): Promise<ArrayBuffer>;
60+
blob(): Promise<Blob>;
61+
formData(): Promise<FormData>;
62+
json(): Promise<any>;
63+
text(): Promise<string>;
64+
buffer(): Promise<Buffer>;
65+
clone(): NodeFetchResponse;
66+
}
67+
68+
/**
69+
* Response wrapper that converts Web Streams to Node.js streams
70+
*/
71+
class NodeFetchCompatibleResponse implements NodeFetchResponse {
72+
private _undiciResponse: Response;
73+
private _nodeBody: Readable | null = null;
74+
75+
constructor(undiciResponse: Response) {
76+
this._undiciResponse = undiciResponse;
77+
}
78+
79+
// Delegate all standard Response properties
80+
get headers(): Headers {
81+
return this._undiciResponse.headers;
82+
}
83+
84+
get ok(): boolean {
85+
return this._undiciResponse.ok;
86+
}
87+
88+
get redirected(): boolean {
89+
return this._undiciResponse.redirected;
90+
}
91+
92+
get status(): number {
93+
return this._undiciResponse.status;
94+
}
95+
96+
get statusText(): string {
97+
return this._undiciResponse.statusText;
98+
}
99+
100+
get type(): string {
101+
return this._undiciResponse.type;
102+
}
103+
104+
get url(): string {
105+
return this._undiciResponse.url;
106+
}
107+
108+
get body(): Readable | null {
109+
// Convert Web ReadableStream to Node.js Readable stream
110+
// This is the key compatibility shim for watch.ts and log.ts
111+
if (!this._nodeBody && this._undiciResponse.body) {
112+
this._nodeBody = Readable.fromWeb(this._undiciResponse.body as any);
113+
}
114+
return this._nodeBody;
115+
}
116+
117+
get bodyUsed(): boolean {
118+
return this._undiciResponse.bodyUsed;
119+
}
120+
121+
// Delegate all standard Response methods
122+
async arrayBuffer(): Promise<ArrayBuffer> {
123+
return this._undiciResponse.arrayBuffer();
124+
}
125+
126+
async blob(): Promise<Blob> {
127+
return this._undiciResponse.blob();
128+
}
129+
130+
async formData(): Promise<FormData> {
131+
return this._undiciResponse.formData();
132+
}
133+
134+
async json(): Promise<any> {
135+
return this._undiciResponse.json();
136+
}
137+
138+
async text(): Promise<string> {
139+
return this._undiciResponse.text();
140+
}
141+
142+
clone(): NodeFetchResponse {
143+
return new NodeFetchCompatibleResponse(this._undiciResponse.clone());
144+
}
145+
146+
// node-fetch v2 specific method (deprecated in v3, but used in this codebase)
147+
async buffer(): Promise<Buffer> {
148+
const arrayBuffer = await this._undiciResponse.arrayBuffer();
149+
return Buffer.from(arrayBuffer);
150+
}
151+
}
152+
153+
/**
154+
* Fetch wrapper that provides node-fetch v2 compatibility
155+
*/
156+
export default async function fetch(url: string | URL, init?: RequestInit): Promise<NodeFetchResponse> {
157+
try {
158+
const response = await globalThis.fetch(url, init);
159+
return new NodeFetchCompatibleResponse(response);
160+
} catch (err: any) {
161+
// Convert undici errors to node-fetch compatible errors
162+
if (err.name === 'AbortError' || err.code === 'UND_ERR_ABORTED') {
163+
throw new AbortError(err.message);
164+
}
165+
166+
// Convert other errors to FetchError
167+
throw new FetchError(err.message || 'request failed', err.type || 'system', err);
168+
}
169+
}
170+
171+
// Re-export global Headers (matches what globalThis.fetch returns)
172+
const _Headers = Headers;
173+
export { _Headers as Headers };
174+
175+
export type { RequestInit };

0 commit comments

Comments
 (0)