UNPKG

contentful-sdk-core

Version:
657 lines (637 loc) 22.4 kB
'use strict'; 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