contentful-sdk-core
Version:
Core modules for the Contentful JS SDKs
657 lines (637 loc) • 22.4 kB
JavaScript
;
var copy = require('fast-copy');
var process = require('process');
var isString = require('lodash/isString.js');
var qs = require('qs');
var isPlainObject = require('lodash/isPlainObject.js');
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
var copy__default = /*#__PURE__*/_interopDefault(copy);
var process__default = /*#__PURE__*/_interopDefault(process);
var isString__default = /*#__PURE__*/_interopDefault(isString);
var qs__default = /*#__PURE__*/_interopDefault(qs);
var isPlainObject__default = /*#__PURE__*/_interopDefault(isPlainObject);
function asyncToken(instance, getToken) {
instance.interceptors.request.use(function (config) {
return getToken().then((accessToken) => {
config.headers.set('Authorization', `Bearer ${accessToken}`);
return config;
});
});
}
function isNode() {
/**
* Polyfills of 'process' might set process.browser === true
*
* See:
* https://github.com/webpack/node-libs-browser/blob/master/mock/process.js#L8
* https://github.com/defunctzombie/node-process/blob/master/browser.js#L156
**/
return typeof process__default.default !== 'undefined' && !process__default.default.browser;
}
function isReactNative() {
return (typeof window !== 'undefined' &&
'navigator' in window &&
'product' in window.navigator &&
window.navigator.product === 'ReactNative');
}
function getNodeVersion() {
return process__default.default.versions && process__default.default.versions.node ? `v${process__default.default.versions.node}` : process__default.default.version;
}
function getWindow() {
return window;
}
function noop() {
return undefined;
}
const delay = (ms) => new Promise((resolve) => {
setTimeout(resolve, ms);
});
const defaultWait = (attempts) => {
return Math.pow(Math.SQRT2, attempts);
};
function rateLimit(instance, maxRetry = 5) {
const { responseLogger = noop, requestLogger = noop } = instance.defaults;
instance.interceptors.request.use(function (config) {
requestLogger(config);
return config;
}, function (error) {
requestLogger(error);
return Promise.reject(error);
});
instance.interceptors.response.use(function (response) {
// we don't need to do anything here
responseLogger(response);
return response;
}, async function (error) {
const { response } = error;
const { config } = error;
responseLogger(error);
// Do not retry if it is disabled or no request config exists (not an axios error)
if (!config || !instance.defaults.retryOnError) {
return Promise.reject(error);
}
// Retried already for max attempts
const doneAttempts = config.attempts || 1;
if (doneAttempts > maxRetry) {
error.attempts = config.attempts;
return Promise.reject(error);
}
let retryErrorType = null;
let wait = defaultWait(doneAttempts);
// Errors without response did not receive anything from the server
if (!response) {
retryErrorType = 'Connection';
}
else if (response.status >= 500 && response.status < 600) {
// 5** errors are server related
retryErrorType = `Server ${response.status}`;
}
else if (response.status === 429) {
// 429 errors are exceeded rate limit exceptions
retryErrorType = 'Rate limit';
// all headers are lowercased by axios https://github.com/mzabriskie/axios/issues/413
if (response.headers && error.response.headers['x-contentful-ratelimit-reset']) {
wait = response.headers['x-contentful-ratelimit-reset'];
}
}
if (retryErrorType) {
// convert to ms and add jitter
wait = Math.floor(wait * 1000 + Math.random() * 200 + 500);
instance.defaults.logHandler('warning', `${retryErrorType} error occurred. Waiting for ${wait} ms before retrying...`);
// increase attempts counter
config.attempts = doneAttempts + 1;
/* Somehow between the interceptor and retrying the request the httpAgent/httpsAgent gets transformed from an Agent-like object
to a regular object, causing failures on retries after rate limits. Removing these properties here fixes the error, but retry
requests still use the original http/httpsAgent property */
delete config.httpAgent;
delete config.httpsAgent;
return delay(wait).then(() => instance(config));
}
return Promise.reject(error);
});
}
class AbortError extends Error {
name = 'AbortError';
constructor() {
super('Throttled function aborted');
}
}
/**
* Throttle promise-returning/async/normal functions.
*
* It rate-limits function calls without discarding them, making it ideal for external API interactions where avoiding call loss is crucial.
*
* @returns A throttle function.
*
* Both the `limit` and `interval` options must be specified.
*
* @example
* ```
* import pThrottle from './PThrottle';
*
* const now = Date.now();
*
* const throttle = pThrottle({
* limit: 2,
* interval: 1000
* });
*
* const throttled = throttle(async index => {
* const secDiff = ((Date.now() - now) / 1000).toFixed();
* return `${index}: ${secDiff}s`;
* });
*
* for (let index = 1; index <= 6; index++) {
* (async () => {
* console.log(await throttled(index));
* })();
* }
* //=> 1: 0s
* //=> 2: 0s
* //=> 3: 1s
* //=> 4: 1s
* //=> 5: 2s
* //=> 6: 2s
* ```
*/
function pThrottle({ limit, interval, strict, onDelay }) {
if (!Number.isFinite(limit)) {
throw new TypeError('Expected `limit` to be a finite number');
}
if (!Number.isFinite(interval)) {
throw new TypeError('Expected `interval` to be a finite number');
}
const queue = new Map();
let currentTick = 0;
let activeCount = 0;
function windowedDelay() {
const now = Date.now();
if (now - currentTick > interval) {
activeCount = 1;
currentTick = now;
return 0;
}
if (activeCount < limit) {
activeCount++;
}
else {
currentTick += interval;
activeCount = 1;
}
return currentTick - now;
}
const getDelay = windowedDelay;
return function (function_) {
const throttled = function (...arguments_) {
if (!throttled.isEnabled) {
return (async () => function_.apply(this, arguments_))();
}
let timeoutId;
return new Promise((resolve, reject) => {
const execute = () => {
resolve(function_.apply(this, arguments_));
queue.delete(timeoutId);
};
const delay = getDelay();
if (delay > 0) {
timeoutId = setTimeout(execute, delay);
queue.set(timeoutId, reject);
onDelay?.();
}
else {
execute();
}
});
};
throttled.abort = () => {
for (const timeout of queue.keys()) {
clearTimeout(timeout);
queue.get(timeout)(new AbortError());
}
queue.clear();
};
throttled.isEnabled = true;
Object.defineProperty(throttled, 'queueSize', {
get() {
return queue.size;
},
});
return throttled;
};
}
const PERCENTAGE_REGEX = /(?<value>\d+)(%)/;
function calculateLimit(type, max = 7) {
let limit = max;
if (PERCENTAGE_REGEX.test(type)) {
const groups = type.match(PERCENTAGE_REGEX)?.groups;
if (groups && groups.value) {
const percentage = parseInt(groups.value) / 100;
limit = Math.round(max * percentage);
}
}
return Math.min(30, Math.max(1, limit));
}
function createThrottle(limit, logger) {
logger('info', `Throttle request to ${limit}/s`);
return pThrottle({
limit,
interval: 1000,
strict: false,
});
}
var rateLimitThrottle = (axiosInstance, type = 'auto') => {
const { logHandler = noop } = axiosInstance.defaults;
let limit = isString__default.default(type) ? calculateLimit(type) : calculateLimit('auto', type);
let throttle = createThrottle(limit, logHandler);
let isCalculated = false;
let requestInterceptorId = axiosInstance.interceptors.request.use((config) => {
return throttle(() => config)();
}, function (error) {
return Promise.reject(error);
});
const responseInterceptorId = axiosInstance.interceptors.response.use((response) => {
if (!isCalculated &&
isString__default.default(type) &&
(type === 'auto' || PERCENTAGE_REGEX.test(type)) &&
response.headers &&
response.headers['x-contentful-ratelimit-second-limit']) {
const rawLimit = parseInt(response.headers['x-contentful-ratelimit-second-limit']);
const nextLimit = calculateLimit(type, rawLimit);
if (nextLimit !== limit) {
if (requestInterceptorId) {
axiosInstance.interceptors.request.eject(requestInterceptorId);
}
limit = nextLimit;
throttle = createThrottle(nextLimit, logHandler);
requestInterceptorId = axiosInstance.interceptors.request.use((config) => {
return throttle(() => config)();
}, function (error) {
return Promise.reject(error);
});
}
isCalculated = true;
}
return response;
}, function (error) {
return Promise.reject(error);
});
return () => {
axiosInstance.interceptors.request.eject(requestInterceptorId);
axiosInstance.interceptors.response.eject(responseInterceptorId);
};
};
// Matches 'sub.host:port' or 'host:port' and extracts hostname and port
// Also enforces toplevel domain specified, no spaces and no protocol
const HOST_REGEX = /^(?!\w+:\/\/)([^\s:]+\.?[^\s:]+)(?::(\d+))?(?!:)$/;
/**
* Create default options
* @private
* @param {CreateHttpClientParams} options - Initialization parameters for the HTTP client
* @return {DefaultOptions} options to pass to axios
*/
function createDefaultOptions(options) {
const defaultConfig = {
insecure: false,
retryOnError: true,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
logHandler: (level, data) => {
if (level === 'error' && data) {
const title = [data.name, data.message].filter((a) => a).join(' - ');
console.error(`[error] ${title}`);
console.error(data);
return;
}
console.log(`[${level}] ${data}`);
},
// Passed to axios
headers: {},
httpAgent: false,
httpsAgent: false,
timeout: 30000,
throttle: 0,
basePath: '',
adapter: undefined,
maxContentLength: 1073741824, // 1GB
maxBodyLength: 1073741824, // 1GB
};
const config = {
...defaultConfig,
...options,
};
if (!config.accessToken) {
const missingAccessTokenError = new TypeError('Expected parameter accessToken');
config.logHandler('error', missingAccessTokenError);
throw missingAccessTokenError;
}
// Construct axios baseURL option
const protocol = config.insecure ? 'http' : 'https';
const space = config.space ? `${config.space}/` : '';
let hostname = config.defaultHostname;
let port = config.insecure ? 80 : 443;
if (config.host && HOST_REGEX.test(config.host)) {
const parsed = config.host.split(':');
if (parsed.length === 2) {
[hostname, port] = parsed;
}
else {
hostname = parsed[0];
}
}
// Ensure that basePath does start but not end with a slash
if (config.basePath) {
config.basePath = `/${config.basePath.split('/').filter(Boolean).join('/')}`;
}
const baseURL = options.baseURL || `${protocol}://${hostname}:${port}${config.basePath}/spaces/${space}`;
if (!config.headers.Authorization && typeof config.accessToken !== 'function') {
config.headers.Authorization = 'Bearer ' + config.accessToken;
}
const axiosOptions = {
// Axios
baseURL,
headers: config.headers,
httpAgent: config.httpAgent,
httpsAgent: config.httpsAgent,
proxy: config.proxy,
timeout: config.timeout,
adapter: config.adapter,
fetchOptions: config.fetchOptions,
maxContentLength: config.maxContentLength,
maxBodyLength: config.maxBodyLength,
paramsSerializer: {
serialize: (params) => {
return qs__default.default.stringify(params);
},
},
// Contentful
logHandler: config.logHandler,
responseLogger: config.responseLogger,
requestLogger: config.requestLogger,
retryOnError: config.retryOnError,
};
return axiosOptions;
}
function copyHttpClientParams(options) {
const copiedOptions = copy__default.default(options);
// httpAgent and httpsAgent cannot be copied because they can contain private fields
copiedOptions.httpAgent = options.httpAgent;
copiedOptions.httpsAgent = options.httpsAgent;
return copiedOptions;
}
/**
* Create pre-configured axios instance
* @private
* @param {AxiosStatic} axios - Axios library
* @param {CreateHttpClientParams} options - Initialization parameters for the HTTP client
* @return {AxiosInstance} Initialized axios instance
*/
function createHttpClient(axios, options) {
const axiosOptions = createDefaultOptions(options);
const instance = axios.create(axiosOptions);
instance.httpClientParams = options;
/**
* Creates a new axios instance with the same default base parameters as the
* current one, and with any overrides passed to the newParams object
* This is useful as the SDKs use dependency injection to get the axios library
* and the version of the library comes from different places depending
* on whether it's a browser build or a node.js build.
* @private
* @param {CreateHttpClientParams} newParams - Initialization parameters for the HTTP client
* @return {AxiosInstance} Initialized axios instance
*/
instance.cloneWithNewParams = function (newParams) {
return createHttpClient(axios, {
...copyHttpClientParams(options),
...newParams,
});
};
/**
* Apply interceptors.
* Please note that the order of interceptors is important
*/
if (options.onBeforeRequest) {
instance.interceptors.request.use(options.onBeforeRequest);
}
if (typeof options.accessToken === 'function') {
asyncToken(instance, options.accessToken);
}
if (options.throttle) {
rateLimitThrottle(instance, options.throttle);
}
rateLimit(instance, options.retryLimit);
if (options.onError) {
instance.interceptors.response.use((response) => response, options.onError);
}
return instance;
}
/**
* Creates request parameters configuration by parsing an existing query object
* @private
* @param {Object} query
* @return {Object} Config object with `params` property, ready to be used in axios
*/
function createRequestConfig({ query }) {
const config = {};
delete query.resolveLinks;
config.params = copy__default.default(query);
return config;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function enforceObjPath(obj, path) {
if (!(path in obj)) {
const err = new Error();
err.name = 'PropertyMissing';
err.message = `Required property ${path} missing from:
${JSON.stringify(obj)}
`;
throw err;
}
return true;
}
// copied from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze
function deepFreeze(object) {
const propNames = Object.getOwnPropertyNames(object);
for (const name of propNames) {
const value = object[name];
if (value && typeof value === 'object') {
deepFreeze(value);
}
}
return Object.freeze(object);
}
function freezeSys(obj) {
deepFreeze(obj.sys || {});
return obj;
}
function getBrowserOS() {
const win = getWindow();
if (!win) {
return null;
}
const userAgent = win.navigator.userAgent;
// TODO: platform is deprecated.
const platform = win.navigator.platform;
const macosPlatforms = ['Macintosh', 'MacIntel', 'MacPPC', 'Mac68K'];
const windowsPlatforms = ['Win32', 'Win64', 'Windows', 'WinCE'];
const iosPlatforms = ['iPhone', 'iPad', 'iPod'];
if (macosPlatforms.indexOf(platform) !== -1) {
return 'macOS';
}
else if (iosPlatforms.indexOf(platform) !== -1) {
return 'iOS';
}
else if (windowsPlatforms.indexOf(platform) !== -1) {
return 'Windows';
}
else if (/Android/.test(userAgent)) {
return 'Android';
}
else if (/Linux/.test(platform)) {
return 'Linux';
}
return null;
}
function getNodeOS() {
const platform = process__default.default.platform || 'linux';
const version = process__default.default.version || '0.0.0';
const platformMap = {
android: 'Android',
aix: 'Linux',
darwin: 'macOS',
freebsd: 'Linux',
linux: 'Linux',
openbsd: 'Linux',
sunos: 'Linux',
win32: 'Windows',
};
if (platform in platformMap) {
return `${platformMap[platform] || 'Linux'}/${version}`;
}
return null;
}
function getUserAgentHeader(sdk, application, integration, feature) {
const headerParts = [];
if (application) {
headerParts.push(`app ${application}`);
}
if (integration) {
headerParts.push(`integration ${integration}`);
}
if (feature) {
headerParts.push('feature ' + feature);
}
headerParts.push(`sdk ${sdk}`);
let platform = null;
try {
if (isReactNative()) {
platform = getBrowserOS();
headerParts.push('platform ReactNative');
}
else if (isNode()) {
platform = getNodeOS();
headerParts.push(`platform node.js/${getNodeVersion()}`);
}
else {
platform = getBrowserOS();
headerParts.push('platform browser');
}
}
catch (e) {
platform = null;
}
if (platform) {
headerParts.push(`os ${platform}`);
}
return `${headerParts.filter((item) => item !== '').join('; ')};`;
}
/**
* Mixes in a method to return just a plain object with no additional methods
* @private
* @param data - Any plain JSON response returned from the API
* @return Enhanced object with toPlainObject method
*/
function toPlainObject(data) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
return Object.defineProperty(data, 'toPlainObject', {
enumerable: false,
configurable: false,
writable: false,
value: function () {
return copy__default.default(this);
},
});
}
function obscureHeaders(config) {
// Management, Delivery and Preview API tokens
if (config?.headers?.['Authorization']) {
const token = `...${config.headers['Authorization'].toString().substr(-5)}`;
config.headers['Authorization'] = `Bearer ${token}`;
}
// Encoded Delivery or Preview token map for Cross-Space References
if (config?.headers?.['X-Contentful-Resource-Resolution']) {
const token = `...${config.headers['X-Contentful-Resource-Resolution'].toString().substr(-5)}`;
config.headers['X-Contentful-Resource-Resolution'] = token;
}
}
/**
* Handles errors received from the server. Parses the error into a more useful
* format, places it in an exception and throws it.
* See https://www.contentful.com/developers/docs/references/errors/
* for more details on the data received on the errorResponse.data property
* and the expected error codes.
* @private
*/
function errorHandler(errorResponse) {
const { config, response } = errorResponse;
let errorName;
obscureHeaders(config);
if (!isPlainObject__default.default(response) || !isPlainObject__default.default(config)) {
throw errorResponse;
}
const data = response?.data;
const errorData = {
status: response?.status,
statusText: response?.statusText,
message: '',
details: {},
};
if (config && isPlainObject__default.default(config)) {
errorData.request = {
url: config.url,
headers: config.headers,
method: config.method,
payloadData: config.data,
};
}
if (data && typeof data === 'object') {
if ('requestId' in data) {
errorData.requestId = data.requestId || 'UNKNOWN';
}
if ('message' in data) {
errorData.message = data.message || '';
}
if ('details' in data) {
errorData.details = data.details || {};
}
errorName = data.sys?.id;
}
const error = new Error();
error.name =
errorName && errorName !== 'Unknown' ? errorName : `${response?.status} ${response?.statusText}`;
try {
error.message = JSON.stringify(errorData, null, ' ');
}
catch {
error.message = errorData?.message ?? '';
}
throw error;
}
exports.createDefaultOptions = createDefaultOptions;
exports.createHttpClient = createHttpClient;
exports.createRequestConfig = createRequestConfig;
exports.enforceObjPath = enforceObjPath;
exports.errorHandler = errorHandler;
exports.freezeSys = freezeSys;
exports.getUserAgentHeader = getUserAgentHeader;
exports.toPlainObject = toPlainObject;
//# sourceMappingURL=index.cjs.map