@applitools/req
Version:
Applitools fetch-based request library
364 lines (363 loc) • 18.1 kB
JavaScript
import { AbortController } from 'abort-controller';
import { stop } from './stop.js';
import { makeAgent } from './agent.js';
import { AbortCode, RequestTimeoutError, ConnectionTimeoutError, RetryTimeoutError } from './req-errors.js';
import globalFetch, { Request, Headers, Response } from './fetch.js';
import * as utils from '@applitools/utils';
import { Buffer } from 'buffer';
const disableHttpAgentReuse = process.env.APPLITOOLS_DISABLE_AGENT_CACHIFY;
/**
* Helper function that will create {@link req} function with predefined options
* @example const req = makeReq({baseUrl: 'http://localhost:2107'})
*/
export function makeReq(baseOptions) {
return (location, options) => req(location, mergeOptions(baseOptions, options !== null && options !== void 0 ? options : {}));
}
export async function req(input, ...requestOptions) {
const options = mergeOptions({}, ...requestOptions);
let abortCode;
let retryStartTime = null;
if (options.baseUrl && !options.baseUrl.endsWith('/'))
options.baseUrl += '/';
if (options.headers)
options.headers = Object.fromEntries(Object.entries(options.headers).filter(([_, value]) => value));
else if (!disableHttpAgentReuse)
options.headers = {};
if (!disableHttpAgentReuse)
if (!Object.keys(options.headers).some(header => header.toLowerCase() === 'connection'))
options.headers = {
Connection: 'keep-alive',
...options.headers,
};
if (options.retry)
options.retry = utils.types.isArray(options.retry) ? options.retry : [options.retry];
if (options.hooks)
options.hooks = utils.types.isArray(options.hooks) ? options.hooks : [options.hooks];
const connectionController = new AbortController();
const connectionTimer = options.connectionTimeout
? setTimeout(() => {
abortCode = AbortCode.connectionTimeout;
connectionController.abort();
}, options.connectionTimeout)
: null;
try {
return await singleReq(input, options);
}
finally {
if (connectionTimer)
clearTimeout(connectionTimer);
}
function setupRequestController(opts) {
const requestController = new AbortController();
const timeout = calculateTimeout(opts.requestTimeout, opts.body, opts);
const requestTimer = timeout
? setTimeout(() => {
abortCode !== null && abortCode !== void 0 ? abortCode : (abortCode = AbortCode.requestTimeout);
requestController.abort();
}, timeout)
: null;
const abortHandler = () => requestController.abort();
if (connectionController.signal.aborted)
requestController.abort();
connectionController.signal.addEventListener('abort', abortHandler);
if (opts.signal) {
if (opts.signal.aborted)
requestController.abort();
opts.signal.addEventListener('abort', abortHandler);
}
return { requestController, requestTimer, abortHandler };
}
async function buildRequest(input, opts, requestController) {
var _a, _b, _c, _d, _e;
const url = new URL(String((_a = input.url) !== null && _a !== void 0 ? _a : input), opts.baseUrl);
if (opts.query) {
Object.entries(opts.query).forEach(([key, value]) => {
if (!utils.types.isNull(value))
url.searchParams.set(key, String(value));
});
}
const extraHeaders = {};
if (utils.types.isPlainObject(opts.body) || utils.types.isArray(opts.body) || opts.body === null) {
opts.body = JSON.stringify(opts.body);
extraHeaders['content-type'] = 'application/json';
}
let request = new Request(url, {
method: (_b = opts.method) !== null && _b !== void 0 ? _b : input.method,
headers: {
...extraHeaders,
...Object.fromEntries((_d = (_c = input.headers) === null || _c === void 0 ? void 0 : _c.entries()) !== null && _d !== void 0 ? _d : []),
...Object.fromEntries(new Headers(opts.headers).entries()),
},
body: (_e = opts.body) !== null && _e !== void 0 ? _e : input.body,
highWaterMark: 1024 * 1024 * 100 + 1,
agent: makeAgent({
proxy: opts.proxy,
useDnsCache: opts.useDnsCache,
keepAliveOptions: opts.keepAliveOptions,
}),
signal: requestController.signal,
});
request = await beforeRequest({ request, options: opts });
return request;
}
async function tryFallback(request, response, opts, optionsFallbacks) {
var _a, _b;
if (response.ok || optionsFallbacks.length === 0)
return null;
const fallbackStrategy = optionsFallbacks[0];
const shouldFallback = await fallbackStrategy.shouldFallbackCondition({ request, response });
const fallbackOptions = shouldFallback &&
(await ((_a = fallbackStrategy === null || fallbackStrategy === void 0 ? void 0 : fallbackStrategy.updateOptions) === null || _a === void 0 ? void 0 : _a.call(fallbackStrategy, {
options: { ...opts, fallbacks: optionsFallbacks.slice(1) },
})));
if (fallbackOptions) {
const fallbackStrategyResponse = await singleReq(request, fallbackOptions);
(_b = fallbackStrategy.cache) !== null && _b !== void 0 ? _b : (fallbackStrategy.cache = new Map());
fallbackStrategy.cache.set(new URL(request.url).origin, fallbackStrategyResponse.ok);
return fallbackStrategyResponse;
}
return null;
}
async function findApplicableRetry(opts, context) {
const retries = opts.retry;
if (!retries)
return null;
if (context.response) {
return await retries.reduce(async (prev, retry) => {
var _a, _b;
const result = await prev;
return (result !== null && result !== void 0 ? result : ((((_a = retry.statuses) === null || _a === void 0 ? void 0 : _a.includes(context.response.status)) ||
(await ((_b = retry.validate) === null || _b === void 0 ? void 0 : _b.call(retry, { response: context.response })))) &&
(!retry.limit || !retry.attempt || retry.attempt < retry.limit)
? retry
: null));
}, Promise.resolve(null));
}
if (context.error) {
return await retries.reduce((prev, retry) => {
return prev.then(async (result) => {
var _a, _b;
return (result !== null && result !== void 0 ? result : ((((_a = retry.codes) === null || _a === void 0 ? void 0 : _a.includes(context.error.code)) ||
(await ((_b = retry.validate) === null || _b === void 0 ? void 0 : _b.call(retry, { error: context.error })))) &&
(!retry.limit || !retry.attempt || retry.attempt < retry.limit)))
? retry
: null;
});
}, Promise.resolve(null));
}
return null;
}
function calculateRetryDelay(retry, response) {
var _a, _b;
if (response === null || response === void 0 ? void 0 : response.headers.has('Retry-After')) {
return Number(response.headers.get('Retry-After')) * 1000;
}
if (utils.types.isArray(retry.timeout)) {
return retry.timeout[Math.min((_a = retry.attempt) !== null && _a !== void 0 ? _a : 0, retry.timeout.length - 1)];
}
return (_b = retry.timeout) !== null && _b !== void 0 ? _b : 0;
}
function checkRetryTimeout() {
if (options.retryTimeout && retryStartTime && Date.now() - retryStartTime >= options.retryTimeout) {
throw new RetryTimeoutError(options.retryTimeout);
}
}
async function handleRetry(request, retry, context, opts) {
var _a;
(_a = retry.attempt) !== null && _a !== void 0 ? _a : (retry.attempt = 0);
retryStartTime !== null && retryStartTime !== void 0 ? retryStartTime : (retryStartTime = Date.now());
checkRetryTimeout();
const delay = calculateRetryDelay(retry, context.response);
await utils.general.sleep(delay);
retry.attempt += 1;
return await beforeRetry({
request,
...context,
attempt: retry.attempt,
stop,
options: opts,
});
}
function normalizeAbortError(error) {
if (abortCode === AbortCode.requestTimeout)
return new RequestTimeoutError();
if (abortCode === AbortCode.connectionTimeout)
return new ConnectionTimeoutError();
return error;
}
function cleanupRequest(opts, requestTimer, abortHandler) {
if (requestTimer)
clearTimeout(requestTimer);
// Remove the abort listeners we attached
connectionController.signal.removeEventListener('abort', abortHandler);
if (opts.signal) {
opts.signal.removeEventListener('abort', abortHandler);
}
}
async function singleReq(input, options) {
var _a, _b;
const fetch = (_a = options.fetch) !== null && _a !== void 0 ? _a : globalFetch;
let optionsFallbacks = [];
if (options.fallbacks)
optionsFallbacks = utils.types.isArray(options.fallbacks) ? options.fallbacks : [options.fallbacks];
// Apply cached fallback options if available
const url = new URL(String((_b = input.url) !== null && _b !== void 0 ? _b : input), options.baseUrl);
const fb = optionsFallbacks.find(fallback => { var _a; return (_a = fallback.cache) === null || _a === void 0 ? void 0 : _a.get(url.origin); });
if (fb === null || fb === void 0 ? void 0 : fb.updateOptions)
options = await fb.updateOptions({ options });
while (true) {
const { requestController, requestTimer, abortHandler } = setupRequestController(options);
const request = await buildRequest(input, options, requestController);
try {
let response = await fetch(request);
// Try fallback if needed
const fallbackResponse = await tryFallback(request, response, options, optionsFallbacks);
if (fallbackResponse)
return fallbackResponse;
// Check if retry is needed for status code
const retry = await findApplicableRetry(options, { response });
if (retry) {
const retryRequest = await handleRetry(request, retry, { response }, options);
if (retryRequest !== stop) {
cleanupRequest(options, requestTimer, abortHandler);
input = retryRequest;
continue;
}
}
// Success - return response
response = await afterResponse({ request, response, options });
return response;
}
catch (error) {
error = normalizeAbortError(error);
// Check if retry is needed for network error
const retry = await findApplicableRetry(options, { error });
if (retry) {
const retryRequest = await handleRetry(request, retry, { error }, options);
if (retryRequest !== stop) {
cleanupRequest(options, requestTimer, abortHandler);
input = retryRequest;
continue;
}
}
// No retry - throw error
error = await afterError({ request, error, options });
throw error;
}
finally {
cleanupRequest(options, requestTimer, abortHandler);
}
}
}
}
function mergeOptions(baseOptions, ...options) {
var _a;
const mergedOptions = options.reduce((baseOptions, options) => ({
...baseOptions,
...options,
query: { ...baseOptions.query, ...options === null || options === void 0 ? void 0 : options.query },
headers: { ...baseOptions.headers, ...options === null || options === void 0 ? void 0 : options.headers },
retry: [
...(baseOptions.retry ? [].concat(baseOptions.retry) : []),
...((options === null || options === void 0 ? void 0 : options.retry) ? [].concat(options.retry) : []),
],
hooks: [
...(baseOptions.hooks ? [].concat(baseOptions.hooks) : []),
...((options === null || options === void 0 ? void 0 : options.hooks) ? [].concat(options.hooks) : []),
],
fallbacks: [
...(baseOptions.fallbacks ? [].concat(baseOptions.fallbacks) : []),
...((options === null || options === void 0 ? void 0 : options.fallbacks) ? [].concat(options.fallbacks) : []),
],
}), baseOptions);
return ((_a = mergedOptions.hooks) !== null && _a !== void 0 ? _a : []).reduce((options, hooks) => { var _a, _b; return (_b = (_a = hooks.afterOptionsMerged) === null || _a === void 0 ? void 0 : _a.call(hooks, { options })) !== null && _b !== void 0 ? _b : options; }, mergedOptions);
}
function beforeRequest({ request, options, ...rest }) {
var _a;
return ((_a = options === null || options === void 0 ? void 0 : options.hooks) !== null && _a !== void 0 ? _a : []).reduce(async (request, hooks) => {
var _a, _b;
request = await request;
const result = await ((_a = hooks.beforeRequest) === null || _a === void 0 ? void 0 : _a.call(hooks, { request, options, ...rest }));
if (!result)
return request;
else if (utils.types.instanceOf(result, Request))
return result;
else if (utils.types.has(result, 'url'))
return new Request(result.url, (_b = result.request) !== null && _b !== void 0 ? _b : result);
else
return new Request(result.request, result);
}, request);
}
function beforeRetry({ request, options, ...rest }) {
var _a;
return ((_a = options === null || options === void 0 ? void 0 : options.hooks) !== null && _a !== void 0 ? _a : []).reduce(async (request, hooks) => {
var _a, _b;
request = await request;
if (request === stop)
return request;
const result = await ((_a = hooks.beforeRetry) === null || _a === void 0 ? void 0 : _a.call(hooks, { request, options, ...rest }));
if (result === stop)
return result;
else if (!result)
return request;
else if (utils.types.instanceOf(result, Request))
return result;
else if (utils.types.has(result, 'url'))
return new Request(result.url, (_b = result.request) !== null && _b !== void 0 ? _b : result);
else
return new Request(result.request, result);
}, request);
}
function afterResponse({ response, options, ...rest }) {
var _a, _b;
return (_b = (((_a = options === null || options === void 0 ? void 0 : options.hooks) !== null && _a !== void 0 ? _a : []))) === null || _b === void 0 ? void 0 : _b.reduce(async (response, hooks) => {
var _a, _b, _c, _d;
response = await response;
const result = await ((_a = hooks.afterResponse) === null || _a === void 0 ? void 0 : _a.call(hooks, { response, options, ...rest }));
if (!result)
return response;
else if (utils.types.instanceOf(result, Response))
return result;
else
return new Response((_b = result.body) !== null && _b !== void 0 ? _b : (_c = result.response) === null || _c === void 0 ? void 0 : _c.body, (_d = result.response) !== null && _d !== void 0 ? _d : result);
}, response);
}
function afterError({ error, options, ...rest }) {
var _a, _b;
return (_b = (((_a = options === null || options === void 0 ? void 0 : options.hooks) !== null && _a !== void 0 ? _a : []))) === null || _b === void 0 ? void 0 : _b.reduce(async (error, hooks) => {
var _a;
error = await error;
return (await ((_a = hooks.afterError) === null || _a === void 0 ? void 0 : _a.call(hooks, { error, options, ...rest }))) || error;
}, error);
}
function calculateTimeout(requestTimeout, body, options) {
var _a;
if (!requestTimeout)
return requestTimeout;
if (utils.types.isNumber(requestTimeout))
return requestTimeout;
if (utils.types.isPlainObject(requestTimeout)) {
const { base, perByte } = requestTimeout;
if (utils.types.isString(body))
return base + perByte * body.length;
if (utils.types.isArray(body))
return base + perByte * body.reduce((acc, item) => acc + item.length, 0);
if (utils.types.instanceOf(body, ArrayBuffer))
return base + perByte * body.byteLength;
if (utils.types.instanceOf(body, Uint8Array) ||
utils.types.instanceOf(body, Uint16Array) ||
utils.types.instanceOf(body, Uint32Array))
return base + perByte * body.byteLength;
if (global.Blob && utils.types.instanceOf(body, Blob))
return base + perByte * body.size;
if (global.Buffer && utils.types.instanceOf(body, Buffer))
return base + perByte * body.byteLength;
const allHooks = ((_a = options === null || options === void 0 ? void 0 : options.hooks) !== null && _a !== void 0 ? _a : []);
allHooks.forEach(hooks => {
var _a;
(_a = hooks.unknownBodyType) === null || _a === void 0 ? void 0 : _a.call(hooks, { body, options });
});
return base + perByte * 10;
}
return requestTimeout;
}