From c959ff29013a3bc90cde3ac7ea2d9a3f9c08974b Mon Sep 17 00:00:00 2001 From: Dmitriy Mozgovoy Date: Sat, 30 Aug 2025 22:02:24 +0300 Subject: [PATCH] feat(fetch): add fetch, Request, Response env config variables for the adapter; (#7003) * feat(fetch): add fetch, Request, Response env config variables for the adapter; * feat(fetch): fixed design issue for environments without fetch API globals; --- index.d.cts | 3 + index.d.ts | 3 + lib/adapters/adapters.js | 10 +- lib/adapters/fetch.js | 417 ++++++++++++++++++++---------------- lib/core/dispatchRequest.js | 2 +- lib/utils.js | 8 +- test/unit/adapters/fetch.js | 89 +++++++- 7 files changed, 340 insertions(+), 192 deletions(-) diff --git a/index.d.cts b/index.d.cts index 3126703..8a9e90e 100644 --- a/index.d.cts +++ b/index.d.cts @@ -423,6 +423,9 @@ declare namespace axios { insecureHTTPParser?: boolean; env?: { FormData?: new (...args: any[]) => object; + fetch?: (input: URL | Request | string, init?: RequestInit) => Promise; + Request?: new (input: (RequestInfo | URL), init?: RequestInit) => Request; + Response?: new (body?: (BodyInit | null), init?: ResponseInit) => Response; }; formSerializer?: FormSerializerOptions; family?: AddressFamily; diff --git a/index.d.ts b/index.d.ts index 27f3b7d..82b486b 100644 --- a/index.d.ts +++ b/index.d.ts @@ -355,6 +355,9 @@ export interface AxiosRequestConfig { insecureHTTPParser?: boolean; env?: { FormData?: new (...args: any[]) => object; + fetch?: (input: URL | Request | string, init?: RequestInit) => Promise; + Request?: new (input: (RequestInfo | URL), init?: RequestInit) => Request; + Response?: new (body?: (BodyInit | null), init?: ResponseInit) => Response; }; formSerializer?: FormSerializerOptions; family?: AddressFamily; diff --git a/lib/adapters/adapters.js b/lib/adapters/adapters.js index b466dd5..7be725c 100644 --- a/lib/adapters/adapters.js +++ b/lib/adapters/adapters.js @@ -1,13 +1,15 @@ import utils from '../utils.js'; import httpAdapter from './http.js'; import xhrAdapter from './xhr.js'; -import fetchAdapter from './fetch.js'; +import * as fetchAdapter from './fetch.js'; import AxiosError from "../core/AxiosError.js"; const knownAdapters = { http: httpAdapter, xhr: xhrAdapter, - fetch: fetchAdapter + fetch: { + get: fetchAdapter.getFetch, + } } utils.forEach(knownAdapters, (fn, value) => { @@ -26,7 +28,7 @@ const renderReason = (reason) => `- ${reason}`; const isResolvedHandle = (adapter) => utils.isFunction(adapter) || adapter === null || adapter === false; export default { - getAdapter: (adapters) => { + getAdapter: (adapters, config) => { adapters = utils.isArray(adapters) ? adapters : [adapters]; const {length} = adapters; @@ -49,7 +51,7 @@ export default { } } - if (adapter) { + if (adapter && (utils.isFunction(adapter) || (adapter = adapter.get(config)))) { break; } diff --git a/lib/adapters/fetch.js b/lib/adapters/fetch.js index 0e76cdd..574117b 100644 --- a/lib/adapters/fetch.js +++ b/lib/adapters/fetch.js @@ -8,14 +8,18 @@ import {progressEventReducer, progressEventDecorator, asyncDecorator} from "../h import resolveConfig from "../helpers/resolveConfig.js"; import settle from "../core/settle.js"; -const isFetchSupported = typeof fetch === 'function' && typeof Request === 'function' && typeof Response === 'function'; -const isReadableStreamSupported = isFetchSupported && typeof ReadableStream === 'function'; +const DEFAULT_CHUNK_SIZE = 64 * 1024; + +const {isFunction} = utils; + +const globalFetchAPI = (({fetch, Request, Response}) => ({ + fetch, Request, Response + }))(utils.global); + +const { + ReadableStream, TextEncoder +} = utils.global; -// used only inside the fetch adapter -const encodeText = isFetchSupported && (typeof TextEncoder === 'function' ? - ((encoder) => (str) => encoder.encode(str))(new TextEncoder()) : - async (str) => new Uint8Array(await new Response(str).arrayBuffer()) -); const test = (fn, ...args) => { try { @@ -25,205 +29,258 @@ const test = (fn, ...args) => { } } -const supportsRequestStream = isReadableStreamSupported && test(() => { - let duplexAccessed = false; +const factory = (env) => { + const {fetch, Request, Response} = Object.assign({}, globalFetchAPI, env); + const isFetchSupported = isFunction(fetch); + const isRequestSupported = isFunction(Request); + const isResponseSupported = isFunction(Response); - const hasContentType = new Request(platform.origin, { - body: new ReadableStream(), - method: 'POST', - get duplex() { - duplexAccessed = true; - return 'half'; - }, - }).headers.has('Content-Type'); + if (!isFetchSupported) { + return false; + } - return duplexAccessed && !hasContentType; -}); + const isReadableStreamSupported = isFetchSupported && isFunction(ReadableStream); -const DEFAULT_CHUNK_SIZE = 64 * 1024; + const encodeText = isFetchSupported && (typeof TextEncoder === 'function' ? + ((encoder) => (str) => encoder.encode(str))(new TextEncoder()) : + async (str) => new Uint8Array(await new Request(str).arrayBuffer()) + ); -const supportsResponseStream = isReadableStreamSupported && - test(() => utils.isReadableStream(new Response('').body)); + const supportsRequestStream = isRequestSupported && isReadableStreamSupported && test(() => { + let duplexAccessed = false; + const hasContentType = new Request(platform.origin, { + body: new ReadableStream(), + method: 'POST', + get duplex() { + duplexAccessed = true; + return 'half'; + }, + }).headers.has('Content-Type'); -const resolvers = { - stream: supportsResponseStream && ((res) => res.body) -}; + return duplexAccessed && !hasContentType; + }); + + const supportsResponseStream = isResponseSupported && isReadableStreamSupported && + test(() => utils.isReadableStream(new Response('').body)); + + const resolvers = { + stream: supportsResponseStream && ((res) => res.body) + }; + + isFetchSupported && ((() => { + ['text', 'arrayBuffer', 'blob', 'formData', 'stream'].forEach(type => { + !resolvers[type] && (resolvers[type] = (res, config) => { + let method = res && res[type]; + + if (method) { + return method.call(res); + } -isFetchSupported && (((res) => { - ['text', 'arrayBuffer', 'blob', 'formData', 'stream'].forEach(type => { - !resolvers[type] && (resolvers[type] = utils.isFunction(res[type]) ? (res) => res[type]() : - (_, config) => { throw new AxiosError(`Response type '${type}' is not supported`, AxiosError.ERR_NOT_SUPPORT, config); }) - }); -})(new Response)); - -const getBodyLength = async (body) => { - if (body == null) { - return 0; - } - - if(utils.isBlob(body)) { - return body.size; - } - - if(utils.isSpecCompliantForm(body)) { - const _request = new Request(platform.origin, { - method: 'POST', - body, }); - return (await _request.arrayBuffer()).byteLength; - } + })()); - if(utils.isArrayBufferView(body) || utils.isArrayBuffer(body)) { - return body.byteLength; - } + const getBodyLength = async (body) => { + if (body == null) { + return 0; + } - if(utils.isURLSearchParams(body)) { - body = body + ''; - } + if (utils.isBlob(body)) { + return body.size; + } - if(utils.isString(body)) { - return (await encodeText(body)).byteLength; - } -} - -const resolveBodyLength = async (headers, body) => { - const length = utils.toFiniteNumber(headers.getContentLength()); - - return length == null ? getBodyLength(body) : length; -} - -export default isFetchSupported && (async (config) => { - let { - url, - method, - data, - signal, - cancelToken, - timeout, - onDownloadProgress, - onUploadProgress, - responseType, - headers, - withCredentials = 'same-origin', - fetchOptions - } = resolveConfig(config); - - responseType = responseType ? (responseType + '').toLowerCase() : 'text'; - - let composedSignal = composeSignals([signal, cancelToken && cancelToken.toAbortSignal()], timeout); - - let request; - - const unsubscribe = composedSignal && composedSignal.unsubscribe && (() => { - composedSignal.unsubscribe(); - }); - - let requestContentLength; - - try { - if ( - onUploadProgress && supportsRequestStream && method !== 'get' && method !== 'head' && - (requestContentLength = await resolveBodyLength(headers, data)) !== 0 - ) { - let _request = new Request(url, { + if (utils.isSpecCompliantForm(body)) { + const _request = new Request(platform.origin, { method: 'POST', - body: data, - duplex: "half" + body, }); - - let contentTypeHeader; - - if (utils.isFormData(data) && (contentTypeHeader = _request.headers.get('content-type'))) { - headers.setContentType(contentTypeHeader) - } - - if (_request.body) { - const [onProgress, flush] = progressEventDecorator( - requestContentLength, - progressEventReducer(asyncDecorator(onUploadProgress)) - ); - - data = trackStream(_request.body, DEFAULT_CHUNK_SIZE, onProgress, flush); - } + return (await _request.arrayBuffer()).byteLength; } - if (!utils.isString(withCredentials)) { - withCredentials = withCredentials ? 'include' : 'omit'; + if (utils.isArrayBufferView(body) || utils.isArrayBuffer(body)) { + return body.byteLength; } - // Cloudflare Workers throws when credentials are defined - // see https://github.com/cloudflare/workerd/issues/902 - const isCredentialsSupported = "credentials" in Request.prototype; - request = new Request(url, { - ...fetchOptions, - signal: composedSignal, - method: method.toUpperCase(), - headers: headers.normalize().toJSON(), - body: data, - duplex: "half", - credentials: isCredentialsSupported ? withCredentials : undefined + if (utils.isURLSearchParams(body)) { + body = body + ''; + } + + if (utils.isString(body)) { + return (await encodeText(body)).byteLength; + } + } + + const resolveBodyLength = async (headers, body) => { + const length = utils.toFiniteNumber(headers.getContentLength()); + + return length == null ? getBodyLength(body) : length; + } + + return async (config) => { + let { + url, + method, + data, + signal, + cancelToken, + timeout, + onDownloadProgress, + onUploadProgress, + responseType, + headers, + withCredentials = 'same-origin', + fetchOptions + } = resolveConfig(config); + + responseType = responseType ? (responseType + '').toLowerCase() : 'text'; + + let composedSignal = composeSignals([signal, cancelToken && cancelToken.toAbortSignal()], timeout); + + let request = null; + + const unsubscribe = composedSignal && composedSignal.unsubscribe && (() => { + composedSignal.unsubscribe(); }); - let response = await fetch(request, fetchOptions); + let requestContentLength; - const isStreamResponse = supportsResponseStream && (responseType === 'stream' || responseType === 'response'); + try { + if ( + onUploadProgress && supportsRequestStream && method !== 'get' && method !== 'head' && + (requestContentLength = await resolveBodyLength(headers, data)) !== 0 + ) { + let _request = new Request(url, { + method: 'POST', + body: data, + duplex: "half" + }); - if (supportsResponseStream && (onDownloadProgress || (isStreamResponse && unsubscribe))) { - const options = {}; + let contentTypeHeader; - ['status', 'statusText', 'headers'].forEach(prop => { - options[prop] = response[prop]; - }); - - const responseContentLength = utils.toFiniteNumber(response.headers.get('content-length')); - - const [onProgress, flush] = onDownloadProgress && progressEventDecorator( - responseContentLength, - progressEventReducer(asyncDecorator(onDownloadProgress), true) - ) || []; - - response = new Response( - trackStream(response.body, DEFAULT_CHUNK_SIZE, onProgress, () => { - flush && flush(); - unsubscribe && unsubscribe(); - }), - options - ); - } - - responseType = responseType || 'text'; - - let responseData = await resolvers[utils.findKey(resolvers, responseType) || 'text'](response, config); - - !isStreamResponse && unsubscribe && unsubscribe(); - - return await new Promise((resolve, reject) => { - settle(resolve, reject, { - data: responseData, - headers: AxiosHeaders.from(response.headers), - status: response.status, - statusText: response.statusText, - config, - request - }) - }) - } catch (err) { - unsubscribe && unsubscribe(); - - if (err && err.name === 'TypeError' && /Load failed|fetch/i.test(err.message)) { - throw Object.assign( - new AxiosError('Network Error', AxiosError.ERR_NETWORK, config, request), - { - cause: err.cause || err + if (utils.isFormData(data) && (contentTypeHeader = _request.headers.get('content-type'))) { + headers.setContentType(contentTypeHeader) } - ) + + if (_request.body) { + const [onProgress, flush] = progressEventDecorator( + requestContentLength, + progressEventReducer(asyncDecorator(onUploadProgress)) + ); + + data = trackStream(_request.body, DEFAULT_CHUNK_SIZE, onProgress, flush); + } + } + + if (!utils.isString(withCredentials)) { + withCredentials = withCredentials ? 'include' : 'omit'; + } + + // Cloudflare Workers throws when credentials are defined + // see https://github.com/cloudflare/workerd/issues/902 + const isCredentialsSupported = isRequestSupported && "credentials" in Request.prototype; + + const resolvedOptions = { + ...fetchOptions, + signal: composedSignal, + method: method.toUpperCase(), + headers: headers.normalize().toJSON(), + body: data, + duplex: "half", + credentials: isCredentialsSupported ? withCredentials : undefined + }; + + request = isRequestSupported && new Request(url, resolvedOptions); + + let response = await (isRequestSupported ? fetch(request, fetchOptions) : fetch(url, resolvedOptions)); + + const isStreamResponse = supportsResponseStream && (responseType === 'stream' || responseType === 'response'); + + if (supportsResponseStream && (onDownloadProgress || (isStreamResponse && unsubscribe))) { + const options = {}; + + ['status', 'statusText', 'headers'].forEach(prop => { + options[prop] = response[prop]; + }); + + const responseContentLength = utils.toFiniteNumber(response.headers.get('content-length')); + + const [onProgress, flush] = onDownloadProgress && progressEventDecorator( + responseContentLength, + progressEventReducer(asyncDecorator(onDownloadProgress), true) + ) || []; + + response = new Response( + trackStream(response.body, DEFAULT_CHUNK_SIZE, onProgress, () => { + flush && flush(); + unsubscribe && unsubscribe(); + }), + options + ); + } + + responseType = responseType || 'text'; + + let responseData = await resolvers[utils.findKey(resolvers, responseType) || 'text'](response, config); + + !isStreamResponse && unsubscribe && unsubscribe(); + + return await new Promise((resolve, reject) => { + settle(resolve, reject, { + data: responseData, + headers: AxiosHeaders.from(response.headers), + status: response.status, + statusText: response.statusText, + config, + request + }) + }) + } catch (err) { + unsubscribe && unsubscribe(); + + if (err && err.name === 'TypeError' && /Load failed|fetch/i.test(err.message)) { + throw Object.assign( + new AxiosError('Network Error', AxiosError.ERR_NETWORK, config, request), + { + cause: err.cause || err + } + ) + } + + throw AxiosError.from(err, err && err.code, config, request); } - - throw AxiosError.from(err, err && err.code, config, request); } -}); +} +const seedCache = new Map(); +export const getFetch = (config) => { + let env = utils.merge.call({ + skipUndefined: true + }, globalFetchAPI, config ? config.env : null); + + const {fetch, Request, Response} = env; + + const seeds = [ + Request, Response, fetch + ]; + + let len = seeds.length, i = len, + seed, target, map = seedCache; + + while (i--) { + seed = seeds[i]; + target = map.get(seed); + + target === undefined && map.set(seed, target = (i ? new Map() : factory(env))) + + map = target; + } + + return target; +}; + +const adapter = getFetch(); + +export default adapter; diff --git a/lib/core/dispatchRequest.js b/lib/core/dispatchRequest.js index 9e306aa..bdd07f8 100644 --- a/lib/core/dispatchRequest.js +++ b/lib/core/dispatchRequest.js @@ -46,7 +46,7 @@ export default function dispatchRequest(config) { config.headers.setContentType('application/x-www-form-urlencoded', false); } - const adapter = adapters.getAdapter(config.adapter || defaults.adapter); + const adapter = adapters.getAdapter(config.adapter || defaults.adapter, config); return adapter(config).then(function onAdapterResolution(response) { throwIfCancellationRequested(config); diff --git a/lib/utils.js b/lib/utils.js index e9a45ff..ef9ebbc 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -148,7 +148,7 @@ const isEmptyObject = (val) => { if (!isObject(val) || isBuffer(val)) { return false; } - + try { return Object.keys(val).length === 0 && Object.getPrototypeOf(val) === Object.prototype; } catch (e) { @@ -341,7 +341,7 @@ const isContextDefined = (context) => !isUndefined(context) && context !== _glob * @returns {Object} Result of all merge properties */ function merge(/* obj1, obj2, obj3, ... */) { - const {caseless} = isContextDefined(this) && this || {}; + const {caseless, skipUndefined} = isContextDefined(this) && this || {}; const result = {}; const assignValue = (val, key) => { const targetKey = caseless && findKey(result, key) || key; @@ -352,7 +352,9 @@ function merge(/* obj1, obj2, obj3, ... */) { } else if (isArray(val)) { result[targetKey] = val.slice(); } else { - result[targetKey] = val; + if (!skipUndefined || !isUndefined(val)) { + result[targetKey] = val; + } } } diff --git a/test/unit/adapters/fetch.js b/test/unit/adapters/fetch.js index 0559731..c221d0b 100644 --- a/test/unit/adapters/fetch.js +++ b/test/unit/adapters/fetch.js @@ -399,14 +399,95 @@ describe('supports fetch with nodejs', function () { const FormData = (await import('form-data')).default; // Node FormData const form = new FormData(); form.append('foo', 'bar'); - + server = await startHTTPServer((req, res) => { const contentType = req.headers['content-type']; assert.match(contentType, /^multipart\/form-data; boundary=/i); res.end('OK'); }); - + await fetchAxios.post('/form', form); }); - }); -}); \ No newline at end of file + }); + + describe('env config', () => { + it('should respect env fetch API configuration', async() => { + const { data, headers } = await fetchAxios.get('/', { + env: { + fetch() { + return { + headers: { + foo: '1' + }, + text: async () => 'test' + } + } + } + }); + + assert.strictEqual(headers.get('foo'), '1'); + assert.strictEqual(data, 'test'); + }); + + it('should be able to request with lack of Request object', async() => { + const form = new FormData(); + + form.append('x', '1'); + + const { data, headers } = await fetchAxios.post('/', form, { + onUploadProgress() { + // dummy listener to activate streaming + }, + env: { + Request: null, + fetch() { + return { + headers: { + foo: '1' + }, + text: async () => 'test' + } + } + } + }); + + assert.strictEqual(headers.get('foo'), '1'); + assert.strictEqual(data, 'test'); + }); + + it('should be able to handle response with lack of Response object', async() => { + const { data, headers } = await fetchAxios.get('/', { + onDownloadProgress() { + // dummy listener to activate streaming + }, + env: { + Request: null, + Response: null, + fetch() { + return { + headers: { + foo: '1' + }, + text: async () => 'test' + } + } + } + }); + + assert.strictEqual(headers.get('foo'), '1'); + assert.strictEqual(data, 'test'); + }); + + it('should fallback to the global on undefined env value', async() => { + server = await startHTTPServer((req, res) => res.end('OK')); + + const { data } = await fetchAxios.get('/', { + env: { + fetch: undefined + } + }); + + assert.strictEqual(data, 'OK'); + }); + }); +});