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