Mirror: A Node.js fetch shim using built-in Request, Response, and Headers (but without native fetch)
1import { Stream, Readable, pipeline } from 'node:stream';
2import { Socket } from 'node:net';
3import * as https from 'node:https';
4import * as http from 'node:http';
5import * as url from 'node:url';
6
7import { extractBody } from './body';
8import { createContentDecoder } from './encoding';
9import { URL, Request, RequestInit, Response } from './webstd';
10import { getHttpsAgent, getHttpAgent } from './agent';
11
12/** Maximum allowed redirects (matching Chromium's limit) */
13const MAX_REDIRECTS = 20;
14
15const parseURL = (input: string, base?: string | URL): URL | null => {
16 try {
17 return new URL(input, base);
18 } catch {
19 return null;
20 }
21};
22
23/** Convert Node.js raw headers array to Headers */
24const headersOfRawHeaders = (rawHeaders: readonly string[]): Headers => {
25 const headers = new Headers();
26 for (let i = 0; i < rawHeaders.length; i += 2)
27 headers.append(rawHeaders[i], rawHeaders[i + 1]);
28 return headers;
29};
30
31/** Assign Headers to a Node.js OutgoingMessage (request) */
32const assignOutgoingMessageHeaders = (
33 outgoing: http.OutgoingMessage,
34 headers: Headers
35) => {
36 if (typeof outgoing.setHeaders === 'function') {
37 outgoing.setHeaders(headers);
38 } else {
39 for (const [key, value] of headers) outgoing.setHeader(key, value);
40 }
41};
42
43/** Normalize methods and disallow special methods */
44const toRedirectOption = (
45 redirect: string | undefined
46): 'follow' | 'manual' | 'error' => {
47 switch (redirect) {
48 case 'follow':
49 case 'manual':
50 case 'error':
51 return redirect;
52 case undefined:
53 return 'follow';
54 default:
55 throw new TypeError(
56 `Request constructor: ${redirect} is not an accepted type. Expected one of follow, manual, error.`
57 );
58 }
59};
60
61/** Normalize methods and disallow special methods */
62const methodToHttpOption = (method: string | undefined): string => {
63 switch (method) {
64 case 'CONNECT':
65 case 'TRACE':
66 case 'TRACK':
67 throw new TypeError(
68 `Failed to construct 'Request': '${method}' HTTP method is unsupported.`
69 );
70 default:
71 return method ? method.toUpperCase() : 'GET';
72 }
73};
74
75/** Convert URL to Node.js HTTP request options and disallow unsupported protocols */
76const urlToHttpOptions = (input: URL) => {
77 const _url = new URL(input);
78 switch (_url.protocol) {
79 // TODO: 'file:' and 'data:' support
80 case 'http:':
81 case 'https:':
82 return url.urlToHttpOptions(_url);
83 default:
84 throw new TypeError(`URL scheme "${_url.protocol}" is not supported.`);
85 }
86};
87
88/** Returns if `input` is a Request object */
89const isRequest = (input: any): input is Request =>
90 input != null && typeof input === 'object' && 'body' in input;
91
92/** Returns if status `code` is a redirect code */
93const isRedirectCode = (
94 code: number | undefined
95): code is 301 | 302 | 303 | 307 | 308 =>
96 code === 301 || code === 302 || code === 303 || code === 307 || code === 308;
97
98function createResponse(
99 body: ConstructorParameters<typeof Response>[0] | null,
100 init: ResponseInit,
101 params: {
102 url: string;
103 redirected: boolean;
104 type: 'basic' | 'cors' | 'default' | 'error' | 'opaque' | 'opaqueredirect';
105 }
106) {
107 const response = new Response(body, init);
108 Object.defineProperty(response, 'url', { value: params.url });
109 if (params.type !== 'default')
110 Object.defineProperty(response, 'type', { value: params.type });
111 if (params.redirected)
112 Object.defineProperty(response, 'redirected', { value: params.redirected });
113 return response;
114}
115
116function attachRefLifetime(body: Readable, socket: Socket): void {
117 const { _read } = body;
118 body.on('close', () => {
119 socket.unref();
120 });
121 body._read = function _readRef(...args: Parameters<Readable['_read']>) {
122 body._read = _read;
123 socket.ref();
124 return _read.apply(this, args);
125 };
126}
127
128async function _fetch(
129 input: string | URL | Request,
130 requestInit?: RequestInit
131): Promise<Response> {
132 const initFromRequest = isRequest(input);
133 const initUrl = initFromRequest ? input.url : input;
134 const initBody = initFromRequest ? input.body : requestInit?.body || null;
135 const signal = initFromRequest
136 ? input.signal
137 : requestInit?.signal || undefined;
138 const redirect = toRedirectOption(
139 initFromRequest ? input.redirect : requestInit?.redirect
140 );
141
142 let requestUrl = new URL(initUrl);
143 let requestBody = extractBody(initBody);
144 let redirects = 0;
145
146 const requestHeaders = new Headers(
147 requestInit?.headers || (initFromRequest ? input.headers : undefined)
148 );
149 const requestOptions = {
150 ...urlToHttpOptions(requestUrl),
151 timeout: 5_000,
152 method: methodToHttpOption(
153 initFromRequest ? input.method : requestInit?.method
154 ),
155 signal,
156 } satisfies http.RequestOptions;
157
158 function _call(
159 resolve: (response: Response | Promise<Response>) => void,
160 reject: (reason?: any) => void
161 ) {
162 requestOptions.agent =
163 requestOptions.protocol === 'https:'
164 ? getHttpsAgent(requestOptions)
165 : getHttpAgent(requestOptions);
166 const method = requestOptions.method;
167 const protocol = requestOptions.protocol === 'https:' ? https : http;
168 const outgoing = protocol.request(requestOptions);
169
170 let incoming: http.IncomingMessage | undefined;
171
172 const destroy = (reason?: any) => {
173 if (reason) {
174 outgoing?.destroy(signal?.aborted ? signal.reason : reason);
175 incoming?.destroy(signal?.aborted ? signal.reason : reason);
176 reject(signal?.aborted ? signal.reason : reason);
177 }
178 signal?.removeEventListener('abort', destroy);
179 };
180
181 signal?.addEventListener('abort', destroy);
182
183 outgoing.on('timeout', () => {
184 if (!incoming) {
185 const error = new Error('Request timed out') as NodeJS.ErrnoException;
186 error.code = 'ETIMEDOUT';
187 destroy(error);
188 }
189 });
190
191 outgoing.on('response', _incoming => {
192 if (signal?.aborted) {
193 return;
194 }
195
196 incoming = _incoming;
197 incoming.setTimeout(0); // Forcefully disable timeout
198 incoming.socket.unref();
199 incoming.on('error', destroy);
200
201 const init = {
202 status: incoming.statusCode,
203 statusText: incoming.statusMessage,
204 headers: headersOfRawHeaders(incoming.rawHeaders),
205 } satisfies ResponseInit;
206
207 if (isRedirectCode(init.status)) {
208 const location = init.headers.get('Location');
209 const locationURL =
210 location != null ? parseURL(location, requestUrl) : null;
211 if (redirect === 'error') {
212 reject(
213 new Error(
214 'URI requested responds with a redirect, redirect mode is set to error'
215 )
216 );
217 return;
218 } else if (redirect === 'manual' && location) {
219 init.headers.set('Location', locationURL?.href ?? location);
220 } else if (redirect === 'follow') {
221 if (locationURL === null) {
222 reject(
223 new Error('URI requested responds with an invalid redirect URL')
224 );
225 return;
226 } else if (++redirects > MAX_REDIRECTS) {
227 reject(new Error(`maximum redirect reached at: ${requestUrl}`));
228 return;
229 } else if (
230 locationURL.protocol !== 'http:' &&
231 locationURL.protocol !== 'https:'
232 ) {
233 // TODO: do we need a special Error instance here?
234 reject(new Error('URL scheme must be a HTTP(S) scheme'));
235 return;
236 }
237
238 if (
239 init.status === 303 ||
240 ((init.status === 301 || init.status === 302) && method === 'POST')
241 ) {
242 requestBody = extractBody(null);
243 requestOptions.method = 'GET';
244 requestHeaders.delete('Content-Length');
245 } else if (
246 requestBody.body != null &&
247 requestBody.contentLength == null
248 ) {
249 reject(new Error('Cannot follow redirect with a streamed body'));
250 return;
251 } else {
252 requestBody = extractBody(initBody);
253 }
254
255 Object.assign(
256 requestOptions,
257 urlToHttpOptions((requestUrl = locationURL))
258 );
259 return _call(resolve, reject);
260 }
261 }
262
263 let body: Readable | null = incoming;
264 const encoding = init.headers.get('Content-Encoding')?.toLowerCase();
265 if (method === 'HEAD' || init.status === 204 || init.status === 304) {
266 body = null;
267 } else if (encoding != null) {
268 init.headers.set('Content-Encoding', encoding);
269 body = pipeline(body, createContentDecoder(encoding), destroy);
270 outgoing.on('error', destroy);
271 }
272
273 // Re-ref the socket when the body starts being consumed to prevent
274 // early process exit, then unref when done to allow normal exit.
275 if (body != null) {
276 attachRefLifetime(body, incoming.socket);
277 }
278
279 resolve(
280 createResponse(body, init, {
281 type: 'default',
282 url: requestUrl.toString(),
283 redirected: redirects > 0,
284 })
285 );
286 });
287
288 outgoing.on('error', destroy);
289
290 if (!requestHeaders.has('Accept')) {
291 requestHeaders.set('Accept', '*/*');
292 }
293 if (!requestHeaders.has('Content-Type') && requestBody.contentType) {
294 requestHeaders.set('Content-Type', requestBody.contentType);
295 }
296
297 if (
298 requestBody.body == null &&
299 (method === 'POST' || method === 'PUT' || method === 'PATCH')
300 ) {
301 requestHeaders.set('Content-Length', '0');
302 } else if (requestBody.body != null && requestBody.contentLength != null) {
303 requestHeaders.set('Content-Length', `${requestBody.contentLength}`);
304 }
305
306 assignOutgoingMessageHeaders(outgoing, requestHeaders);
307
308 if (requestBody.body == null) {
309 outgoing.end();
310 } else if (requestBody.body instanceof Uint8Array) {
311 outgoing.write(requestBody.body);
312 outgoing.end();
313 } else {
314 const body =
315 requestBody.body instanceof Stream
316 ? requestBody.body
317 : Readable.fromWeb(requestBody.body);
318 pipeline(body, outgoing, destroy);
319 }
320 }
321
322 return await new Promise(_call);
323}
324
325export { _fetch as fetch };