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