2
0
mirror of https://github.com/tenrok/axios.git synced 2026-05-15 11:59:42 +03:00

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;
This commit is contained in:
Dmitriy Mozgovoy
2025-08-30 22:02:24 +03:00
committed by GitHub
parent a9f47afbf3
commit c959ff2901
7 changed files with 340 additions and 192 deletions
+3
View File
@@ -423,6 +423,9 @@ declare namespace axios {
insecureHTTPParser?: boolean;
env?: {
FormData?: new (...args: any[]) => object;
fetch?: (input: URL | Request | string, init?: RequestInit) => Promise<Response>;
Request?: new (input: (RequestInfo | URL), init?: RequestInit) => Request;
Response?: new (body?: (BodyInit | null), init?: ResponseInit) => Response;
};
formSerializer?: FormSerializerOptions;
family?: AddressFamily;
Vendored
+3
View File
@@ -355,6 +355,9 @@ export interface AxiosRequestConfig<D = any> {
insecureHTTPParser?: boolean;
env?: {
FormData?: new (...args: any[]) => object;
fetch?: (input: URL | Request | string, init?: RequestInit) => Promise<Response>;
Request?: new (input: (RequestInfo | URL), init?: RequestInit) => Request;
Response?: new (body?: (BodyInit | null), init?: ResponseInit) => Response;
};
formSerializer?: FormSerializerOptions;
family?: AddressFamily;
+6 -4
View File
@@ -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;
}
+237 -180
View File
@@ -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;
+1 -1
View File
@@ -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);
+5 -3
View File
@@ -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;
}
}
}
+85 -4
View File
@@ -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);
});
});
});
});
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');
});
});
});