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