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}