Something went wrong. Try again.
Mirror: A Node.js fetch shim using built-in Request, Response, and Headers (but without native fetch)
Something went wrong. Try again.
12 kB · 387 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388import { Stream, Readable, pipeline } from 'node:stream';import { Socket } from 'node:net';import * as https from 'node:https';import * as http from 'node:http';import * as url from 'node:url';
import { extractBody } from './body';import { createContentDecoder } from './encoding';import { URL, Request, RequestInit, Response, HeadersInit, Headers,} from './webstd';import { getHttpsAgent, getHttpAgent } from './agent';
/** Maximum allowed redirects (matching Chromium's limit) */const MAX_REDIRECTS = 20;
const DEFAULT_TIMEOUT = 30_000;
const parseURL = (input: string, base?: string | URL): URL | null => { try { return new URL(input, base); } catch { return null; }};
const isHeaders = (x: unknown): x is Headers => x != null && typeof x === 'object' && (('append' in x && typeof x.append === 'function') || x instanceof Headers);
/** Convert Node.js raw headers array to Headers */const headersOfRawHeaders = (rawHeaders: readonly string[]): Headers => { const headers = new Headers(); for (let i = 0; i < rawHeaders.length; i += 2) headers.append(rawHeaders[i], rawHeaders[i + 1]); return headers;};
type HeadersDict = Record<string, string | readonly string[]>;
/** Assign Headers to a Node.js OutgoingMessage (request) */const assignOutgoingMessageHeaders = ( outgoing: http.OutgoingMessage, headers: HeadersInit): HeadersDict => { // Preassemble array headers, mostly only for Set-Cookie // We're avoiding `getSetCookie` since support is unclear in Node 18 let collection: HeadersDict; if (!Array.isArray(headers) && !isHeaders(headers)) { collection = headers; } else { collection = Object.create(null); const canonicalNames = new Map(); for (const [name, value] of headers) { const lowerKey = name.toLowerCase(); let key = canonicalNames.get(lowerKey) ?? name; if (!canonicalNames.has(lowerKey)) canonicalNames.set(lowerKey, name); if (Array.isArray(collection[key])) { collection[key].push(value); } else if (collection[key] != undefined) { collection[key] = [collection[key] as string, value]; } else { collection[key] = value; } } } // We don't use `setHeaders` due to a Bun bug (Fix: https://github.com/oven-sh/bun/pull/27050) for (const key in collection) { outgoing.setHeader(key, collection[key]); } return collection;};
const stripRedirectHeaders = (headers: HeadersDict | undefined) => { if (headers) { for (const key in headers) { switch (key.toLowerCase()) { case 'content-length': case 'content-type': delete headers[key]; } } }};
/** Normalize methods and disallow special methods */const toRedirectOption = ( redirect: string | undefined): 'follow' | 'manual' | 'error' => { switch (redirect) { case 'follow': case 'manual': case 'error': return redirect; case undefined: return 'follow'; default: throw new TypeError( `Request constructor: ${redirect} is not an accepted type. Expected one of follow, manual, error.` ); }};
/** Normalize methods and disallow special methods */const methodToHttpOption = (method: string | undefined): string => { const normalized = method?.toUpperCase(); switch (normalized) { case 'CONNECT': case 'TRACE': case 'TRACK': throw new TypeError( `Failed to construct 'Request': '${method}' HTTP method is unsupported.` ); case 'DELETE': case 'GET': case 'HEAD': case 'OPTIONS': case 'POST': case 'PUT': return normalized; default: return method ?? 'GET'; }};
/** Convert URL to Node.js HTTP request options and disallow unsupported protocols */const urlToHttpOptions = (input: URL) => { const _url = new URL(input); switch (_url.protocol) { // TODO: 'file:' and 'data:' support case 'http:': case 'https:': return url.urlToHttpOptions(_url); default: throw new TypeError(`URL scheme "${_url.protocol}" is not supported.`); }};
/** Returns if `input` is a Request object */const isRequest = (input: any): input is Request => input != null && typeof input === 'object' && 'body' in input;
/** Returns if status `code` is a redirect code */const isRedirectCode = ( code: number | undefined): code is 301 | 302 | 303 | 307 | 308 => code === 301 || code === 302 || code === 303 || code === 307 || code === 308;
function createResponse( body: ConstructorParameters<typeof Response>[0] | null, init: ResponseInit, params: { url: string; redirected: boolean; type: 'basic' | 'cors' | 'default' | 'error' | 'opaque' | 'opaqueredirect'; }) { const response = new Response(body, init); Object.defineProperty(response, 'url', { value: params.url }); if (params.type !== 'default') Object.defineProperty(response, 'type', { value: params.type }); if (params.redirected) Object.defineProperty(response, 'redirected', { value: params.redirected }); return response;}
function attachRefLifetime(body: Readable, socket: Socket): void { const { _read } = body; body.on('close', () => { socket.unref(); }); body._read = function _readRef(...args: Parameters<Readable['_read']>) { body._read = _read; socket.ref(); return _read.apply(this, args); };}
async function _fetch( input: string | URL | Request, init?: RequestInit): Promise<Response> { const initFromRequest = isRequest(input); const initUrl = initFromRequest ? input.url : input; const initBody = init?.body ?? (initFromRequest ? input.body : null); const signal = init?.signal ?? (initFromRequest ? input.signal : undefined); const redirect = toRedirectOption( init?.redirect ?? (initFromRequest ? input.redirect : undefined) );
let requestUrl = new URL(initUrl); let requestBody = extractBody(initBody); let redirects = 0;
let requestHeaders = init?.headers ?? (initFromRequest ? input.headers : undefined);
const requestOptions = { ...urlToHttpOptions(requestUrl), timeout: init?.connectTimeout ?? DEFAULT_TIMEOUT, method: methodToHttpOption(initFromRequest ? input.method : init?.method), signal, } satisfies http.RequestOptions;
function _call( resolve: (response: Response | Promise<Response>) => void, reject: (reason?: any) => void ) { requestOptions.agent = requestOptions.protocol === 'https:' ? getHttpsAgent(requestOptions) : getHttpAgent(requestOptions); const method = requestOptions.method; const protocol = requestOptions.protocol === 'https:' ? https : http; const outgoing = protocol.request(requestOptions);
let incoming: http.IncomingMessage | undefined;
const destroy = (reason?: any) => { if (reason) { outgoing?.destroy(signal?.aborted ? signal.reason : reason); incoming?.destroy(signal?.aborted ? signal.reason : reason); reject(signal?.aborted ? signal.reason : reason); } signal?.removeEventListener('abort', destroy); };
signal?.addEventListener('abort', destroy);
outgoing.on('timeout', () => { if (!incoming) { const error = new Error('Request timed out') as NodeJS.ErrnoException; error.code = 'ETIMEDOUT'; destroy(error); } });
outgoing.on('response', _incoming => { if (signal?.aborted) { return; }
incoming = _incoming; incoming.setTimeout(0); // Forcefully disable timeout incoming.socket.unref(); incoming.on('error', destroy);
const responseInit = { status: incoming.statusCode, statusText: incoming.statusMessage, headers: headersOfRawHeaders(incoming.rawHeaders), } satisfies ResponseInit;
if (isRedirectCode(responseInit.status)) { const location = responseInit.headers.get('Location'); const locationURL = location != null ? parseURL(location, requestUrl) : null; if (redirect === 'error') { reject( new Error( 'URI requested responds with a redirect, redirect mode is set to error' ) ); return; } else if (redirect === 'manual' && location) { responseInit.headers.set('Location', locationURL?.href ?? location); } else if (redirect === 'follow') { if (locationURL === null) { reject( new Error('URI requested responds with an invalid redirect URL') ); return; } else if (++redirects > MAX_REDIRECTS) { reject(new Error(`maximum redirect reached at: ${requestUrl}`)); return; } else if ( locationURL.protocol !== 'http:' && locationURL.protocol !== 'https:' ) { // TODO: do we need a special Error instance here? reject(new Error('URL scheme must be a HTTP(S) scheme')); return; }
if ( responseInit.status === 303 || ((responseInit.status === 301 || responseInit.status === 302) && method === 'POST') ) { requestBody = extractBody(null); requestOptions.method = 'GET'; stripRedirectHeaders(requestHeaders as HeadersDict); } else if ( requestBody.body != null && requestBody.contentLength == null ) { reject(new Error('Cannot follow redirect with a streamed body')); return; } else { requestBody = extractBody(initBody); }
Object.assign( requestOptions, urlToHttpOptions((requestUrl = locationURL)) ); return _call(resolve, reject); } }
let body: Readable | null = incoming; const encoding = responseInit.headers .get('Content-Encoding') ?.toLowerCase(); if ( method === 'HEAD' || responseInit.status === 204 || responseInit.status === 304 ) { body = null; } else if (encoding != null) { responseInit.headers.set('Content-Encoding', encoding); body = pipeline(body, createContentDecoder(encoding), destroy); outgoing.on('error', destroy); }
// Re-ref the socket when the body starts being consumed to prevent // early process exit, then unref when done to allow normal exit. if (body != null) { attachRefLifetime(body, incoming.socket); }
resolve( createResponse(body, responseInit, { type: 'default', url: requestUrl.toString(), redirected: redirects > 0, }) ); });
outgoing.on('error', destroy);
if (requestHeaders) { requestHeaders = assignOutgoingMessageHeaders(outgoing, requestHeaders); }
if (!outgoing.hasHeader('Accept')) { outgoing.setHeader('Accept', '*/*'); } if (!outgoing.hasHeader('Content-Type') && requestBody.contentType) { outgoing.setHeader('Content-Type', requestBody.contentType); }
if ( requestBody.body == null && (method === 'POST' || method === 'PUT' || method === 'PATCH') ) { outgoing.setHeader('Content-Length', '0'); } else if (requestBody.body != null && requestBody.contentLength != null) { outgoing.setHeader('Content-Length', `${requestBody.contentLength}`); }
if (requestBody.body == null) { outgoing.end(); } else if (requestBody.body instanceof Uint8Array) { outgoing.write(requestBody.body); outgoing.end(); } else { const body = requestBody.body instanceof Stream ? requestBody.body : Readable.fromWeb(requestBody.body); pipeline(body, outgoing, destroy); } }
return await new Promise(_call);}
export { _fetch as fetch };