Mirror: A Node.js fetch shim using built-in Request, Response, and Headers (but without native fetch)
at v0.4.5 325 lines 10 kB view raw
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 };