UNPKG

@whatwg-node/node-fetch

Version:

Fetch API implementation for Node

307 lines (306 loc) • 13.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.fetchNodeHttp = fetchNodeHttp; const tslib_1 = require("tslib"); const node_http_1 = require("node:http"); const node_https_1 = require("node:https"); const node_stream_1 = require("node:stream"); const node_zlib_1 = tslib_1.__importDefault(require("node:zlib")); const promise_helpers_1 = require("@whatwg-node/promise-helpers"); const checkServerIdentity_js_1 = require("./checkServerIdentity.js"); const Headers_js_1 = require("./Headers.js"); const Request_js_1 = require("./Request.js"); const Response_js_1 = require("./Response.js"); const URL_js_1 = require("./URL.js"); const utils_js_1 = require("./utils.js"); // https://fetch.spec.whatwg.org/#http-redirect-fetch step 7 const MAX_REDIRECTS = 20; const redirectCounts = new WeakMap(); // https://fetch.spec.whatwg.org/#cors-non-wildcard-request-header-name // Cookie, Cookie2, and Host are forbidden request headers. Undici removes them // on a cross-origin redirect because this client has no cookie jar and would // otherwise forward caller-supplied credentials. const CROSS_ORIGIN_REMOVED_HEADERS = [ 'authorization', 'proxy-authorization', 'cookie', 'cookie2', 'host', ]; // https://fetch.spec.whatwg.org/#request-body-header-name // Content-Length is included for the same reason as undici: this Headers // implementation does not treat it as forbidden. const REQUEST_BODY_HEADERS = [ 'content-encoding', 'content-language', 'content-location', 'content-type', 'content-length', ]; function shouldRewriteRedirectMethod(statusCode, method) { return (((statusCode === 301 || statusCode === 302) && method === 'POST') || (statusCode === 303 && method !== 'GET' && method !== 'HEAD')); } function headersForRedirect(source, omit) { // Rebuild instead of deleting on a copy. Header names from a plain object // stay in their original case until the map is materialized, so // `delete('authorization')` would miss `Authorization`. const headers = new Headers_js_1.PonyfillHeaders(); source.forEach((value, key) => { if (!omit.has(key.toLowerCase())) { headers.append(key, value); } }); return headers; } const REPLAYABLE_BODY_TYPES = new Set(['String', 'Blob', 'FormData']); function replayableRedirectBody(fetchRequest) { const bodyInit = fetchRequest['bodyInit']; const bodyType = fetchRequest['bodyType']; const buffer = fetchRequest['_buffer']; if (bodyInit == null && buffer == null) { return { body: null, dropContentType: false }; } // A buffer, string, blob, or FormData can be sent again. A stream's source // is null after the first hop, which the Fetch standard treats as a network error. if (buffer != null) { return { body: buffer, dropContentType: false }; } if (bodyType != null && REPLAYABLE_BODY_TYPES.has(bodyType)) { return { body: bodyInit ?? null, dropContentType: bodyType === 'FormData' }; } throw new TypeError('Request body cannot be replayed across redirects'); } function createRedirectRequest(fetchRequest, redirectedUrl, statusCode) { const crossOrigin = redirectedUrl.origin !== fetchRequest.parsedUrl.origin; // Node turns URL username/password into an Authorization header. A cors // request must not carry those credentials to another origin. if (crossOrigin && fetchRequest.mode === 'cors' && (redirectedUrl.username !== '' || redirectedUrl.password !== '')) { throw new TypeError('URL cannot contain credentials for request mode "cors"'); } const rewriteMethod = shouldRewriteRedirectMethod(statusCode, fetchRequest.method); const replayed = rewriteMethod ? { body: null, dropContentType: false } : replayableRedirectBody(fetchRequest); const omit = new Set(); if (crossOrigin) { for (const headerName of CROSS_ORIGIN_REMOVED_HEADERS) { omit.add(headerName); } } if (rewriteMethod) { for (const headerName of REQUEST_BODY_HEADERS) { omit.add(headerName); } } if (replayed.dropContentType) { omit.add('content-type'); } const headers = headersForRedirect(fetchRequest.headers, omit); const redirectInit = { method: rewriteMethod ? 'GET' : fetchRequest.method, headers, body: replayed.body, redirect: fetchRequest.redirect, credentials: fetchRequest.credentials, mode: fetchRequest.mode, cache: fetchRequest.cache, integrity: fetchRequest.integrity, keepalive: fetchRequest.keepalive, referrer: fetchRequest.referrer, referrerPolicy: fetchRequest.referrerPolicy, duplex: fetchRequest.duplex, ...(fetchRequest.headersSerializer ? { headersSerializer: fetchRequest.headersSerializer } : {}), ...(fetchRequest.agent != null ? { agent: fetchRequest.agent } : {}), ...(fetchRequest._signal ? { signal: fetchRequest._signal } : {}), }; return new Request_js_1.PonyfillRequest(redirectedUrl, redirectInit); } function getRequestFnForProtocol(url) { if (url.startsWith('http:')) { return node_http_1.request; } else if (url.startsWith('https:')) { return node_https_1.request; } throw new TypeError(`Unsupported protocol: ${url.split(':')[0] || url}`); } function isHttpsRequest(url) { if (!url) { return false; } if (typeof url === 'string') { return url.startsWith('https:'); } return url.protocol === 'https:'; } function fetchNodeHttp(fetchRequest) { return new Promise((resolve, reject) => { try { const requestFn = getRequestFnForProtocol(fetchRequest.parsedUrl?.protocol || fetchRequest.url); const headersSerializer = fetchRequest.headersSerializer || utils_js_1.getHeadersObj; const nodeHeaders = headersSerializer(fetchRequest.headers); nodeHeaders['accept-encoding'] ||= utils_js_1.DEFAULT_ACCEPT_ENCODING; if (nodeHeaders['user-agent'] == null && nodeHeaders['User-Agent'] == null) { nodeHeaders['user-agent'] = 'node'; } let signal; if (fetchRequest._signal == null) { signal = undefined; } else if (fetchRequest._signal) { signal = fetchRequest._signal; } let nodeRequest; const requestTarget = fetchRequest.parsedUrl || fetchRequest.url; const requestOptions = { method: fetchRequest.method, headers: nodeHeaders, signal, agent: fetchRequest.agent, }; // Probe once on first https use; override only if this Node build is affected // (https://github.com/nodejs/node/issues/64032). const httpsCheckServerIdentity = isHttpsRequest(requestTarget) ? (0, checkServerIdentity_js_1.getHttpsCheckServerIdentity)() : undefined; if (httpsCheckServerIdentity) { requestOptions.checkServerIdentity = httpsCheckServerIdentity; } // If it is our ponyfilled Request, it should have `parsedUrl` which is a `URL` object if (fetchRequest.parsedUrl) { nodeRequest = requestFn(fetchRequest.parsedUrl, requestOptions); } else { nodeRequest = requestFn(fetchRequest.url, requestOptions); } nodeRequest.once('error', reject); nodeRequest.once('response', nodeResponse => { let outputStream; const contentEncoding = nodeResponse.headers['content-encoding']; switch (contentEncoding) { case 'x-gzip': case 'gzip': outputStream = node_zlib_1.default.createGunzip(); break; case 'x-deflate': case 'deflate': outputStream = node_zlib_1.default.createInflate(); break; case 'x-deflate-raw': case 'deflate-raw': outputStream = node_zlib_1.default.createInflateRaw(); break; case 'br': outputStream = node_zlib_1.default.createBrotliDecompress(); break; case 'zstd': outputStream = node_zlib_1.default.createZstdDecompress(); break; } const location = Array.isArray(nodeResponse.headers.location) ? nodeResponse.headers.location[0] : nodeResponse.headers.location; if (location && (0, utils_js_1.shouldRedirect)(nodeResponse.statusCode)) { if (fetchRequest.redirect === 'error') { const redirectError = new TypeError('Redirects are not allowed'); reject(redirectError); nodeResponse.resume(); return; } if (fetchRequest.redirect === 'follow') { const redirectCount = redirectCounts.get(fetchRequest) ?? 0; if (redirectCount >= MAX_REDIRECTS) { // Deno rejects with this message. Bun uses TooManyRedirects. const redirectError = new TypeError(`Fetch failed: Maximum number of redirects (${MAX_REDIRECTS}) reached`); redirectError.code = 'TooManyRedirects'; reject(redirectError); nodeResponse.resume(); return; } let redirectedUrl; try { redirectedUrl = new URL_js_1.PonyfillURL(location, fetchRequest.parsedUrl || fetchRequest.url); } catch (error) { reject(error); nodeResponse.resume(); return; } let redirectRequest; try { redirectRequest = createRedirectRequest(fetchRequest, redirectedUrl, nodeResponse.statusCode); } catch (error) { reject(error); nodeResponse.resume(); return; } redirectCounts.set(redirectRequest, redirectCount + 1); const redirectResponse$ = fetchNodeHttp(redirectRequest); resolve(redirectResponse$.then(redirectResponse => { redirectResponse.redirected = true; return redirectResponse; })); nodeResponse.resume(); return; } } outputStream ||= new node_stream_1.PassThrough(); (0, utils_js_1.pipeThrough)({ src: nodeResponse, dest: outputStream, signal, onError: e => { if (!nodeResponse.destroyed) { nodeResponse.destroy(e); } if (!outputStream.destroyed) { outputStream.destroy(e); } reject(e); }, }); const statusCode = nodeResponse.statusCode || 200; let statusText = nodeResponse.statusMessage || node_http_1.STATUS_CODES[statusCode]; if (statusText == null) { statusText = ''; } const ponyfillResponse = new Response_js_1.PonyfillResponse(outputStream || nodeResponse, { status: statusCode, statusText, headers: nodeResponse.headers, url: fetchRequest.url, signal, }); resolve(ponyfillResponse); }); if (fetchRequest['_buffer'] != null) { (0, promise_helpers_1.handleMaybePromise)(() => (0, utils_js_1.safeWrite)(fetchRequest['_buffer'], nodeRequest), () => (0, utils_js_1.endStream)(nodeRequest), reject); } else if (fetchRequest['bodyType'] === 'String') { (0, promise_helpers_1.handleMaybePromise)(() => (0, utils_js_1.safeWrite)(fetchRequest['bodyInit'], nodeRequest), () => (0, utils_js_1.endStream)(nodeRequest), reject); } else { const nodeReadable = (fetchRequest.body != null ? (0, utils_js_1.isNodeReadable)(fetchRequest.body) ? fetchRequest.body : node_stream_1.Readable.from(fetchRequest.body) : null); if (nodeReadable) { nodeReadable.pipe(nodeRequest); } else { (0, utils_js_1.endStream)(nodeRequest); } } } catch (e) { reject(e); } }); }