UNPKG

storyblok-js-client

Version:
1 lines 69.7 kB
{"version":3,"file":"index.cjs","names":["options: ISbStoriesParams","ms: number","func: ArrayFn","i: number","arr: RangeFn[]","func: AsyncFn","arr: ISbResult[]","func: FlatMapFn","params: ISbParams","prefix?: string","isArray?: boolean","pairs: string[]","regionCode?: string","$c: ISbFetch","url: string","params: ISbStoriesParams","params?: ISbStoriesParams","res: Response","headers: string[]","method: Method","body: string | null","timeout: ReturnType<typeof setTimeout> | null","err: any","error: ISbError","error","fetchOptions: ISbCustomFetch","data: any","res: ISbResponse","params: ISbStoriesParams","url: string","perPage: number","url?: string","params?: ISbStoriesParams","config: RateLimitConfig","defaultRateLimit?: number","headers: any","result: RateLimitHeaders","userRateLimit?: number","msg: string","fn: T","limit: number","interval: number","queue: Queue<Parameters<T>>[]","timeouts: ReturnType<typeof setTimeout>[]","throttled: ISbThrottle<T>","throttledRequestFn: ISbThrottledRequest","rateLimit: number","throttledQueue","memory: Partial<IMemoryType>","config: ISbConfig","pEndpoint?: string","headers: Headers","SbFetch","params: ISbStoriesParams","url: string","per_page: number","page: number","fetchOptions?: ISbCustomFetch","slug: string","params: ISbStoriesParams | ISbLinksParams","entity?: string","restRes: any","i: number","res: ISbFlatMapped","params: ISbStoriesParams | ISbContentMangmntAPI","params: ISbStoryParams","params: ISbStoriesParams | ISbStoryParams","value: LinksType","jtree: ISbStoriesParams","treeItem: keyof ISbStoriesParams","resolveId: string","uuid: string","fields: string | string[]","story: ISbStoryData","fields: string | Array<string>","jtree: ISbStoriesParams | any","responseData: ISbResponseData","links: (ISbStoryData | ISbLinkURLObject | string)[]","rel: ISbStoryData | ISbLinkURLObject | string","story: ISbStoryData | any","relations: ISbStoryData<ISbComponentType<string> & { [index: string]: any }>[]","rel: ISbStoryData","relationParams: string[]","retries?: number","error: Error | any","type: Method","cv: number","key: string","content: ISbResult","response: ISbResult","node: ISbField","asset: any","story: any"],"sources":["../src/utils.ts","../src/sbFetch.ts","../src/constants.ts","../src/rateLimit.ts","../src/throttlePromise.ts","../src/throttleQueueManager.ts","../src/index.ts"],"sourcesContent":["import type {\n AsyncFn,\n HtmlEscapes,\n ISbResult,\n ISbStoriesParams,\n} from './interfaces';\n\n// TODO: Revise this type, is it needed?\ninterface ISbParams extends ISbStoriesParams {\n [key: string]: any;\n}\n\ntype ArrayFn = (...args: any) => void;\ntype FlatMapFn = (...args: any) => [] | any;\ntype RangeFn = (...args: any) => [];\n\n/**\n * Checks if a URL is a CDN URL\n * @param url - The URL to check\n * @returns boolean indicating if the URL is a CDN URL\n */\nexport const isCDNUrl = (url = ''): boolean => url.includes('/cdn/');\n\n/**\n * Gets pagination options for the API request\n * @param options - The base options\n * @param perPage - Number of items per page\n * @param page - Current page number\n * @returns Object with pagination options\n */\nexport const getOptionsPage = (\n options: ISbStoriesParams,\n perPage = 25,\n page = 1,\n) => ({\n ...options,\n per_page: perPage,\n page,\n});\n\n/**\n * Creates a promise that resolves after the specified milliseconds\n * @param ms - Milliseconds to delay\n * @returns Promise that resolves after the delay\n */\nexport const delay = (ms: number): Promise<void> =>\n new Promise(res => setTimeout(res, ms));\n\n/**\n * Creates an array of specified length using a mapping function\n * @param length - Length of the array\n * @param func - Mapping function\n * @returns Array of specified length\n */\nexport const arrayFrom = (length = 0, func: ArrayFn) =>\n Array.from({ length }, func);\n\n/**\n * Creates an array of numbers in the specified range\n * @param start - Start of the range\n * @param end - End of the range\n * @returns Array of numbers in the range\n */\nexport const range = (start = 0, end = start): Array<any> => {\n const length = Math.abs(end - start) || 0;\n const step = start < end ? 1 : -1;\n return arrayFrom(length, (_, i: number) => i * step + start);\n};\n\n/**\n * Maps an array asynchronously\n * @param arr - Array to map\n * @param func - Async mapping function\n * @returns Promise resolving to mapped array\n */\nexport const asyncMap = async (arr: RangeFn[], func: AsyncFn) =>\n Promise.all(arr.map(func));\n\n/**\n * Flattens an array using a mapping function\n * @param arr - Array to flatten\n * @param func - Mapping function\n * @returns Flattened array\n */\nexport const flatMap = (arr: ISbResult[] = [], func: FlatMapFn) =>\n arr.map(func).reduce((xs, ys) => [...xs, ...ys], []);\n\n/**\n * Stringifies an object into a URL query string\n * @param params - Parameters to stringify\n * @param prefix - Prefix for nested keys\n * @param isArray - Whether the current level is an array\n * @returns Stringified query parameters\n */\nexport const stringify = (\n params: ISbParams,\n prefix?: string,\n isArray?: boolean,\n): string => {\n const pairs: string[] = [];\n for (const key in params) {\n if (!Object.prototype.hasOwnProperty.call(params, key)) {\n continue;\n }\n const value = params[key];\n if (value === null || value === undefined) {\n continue;\n }\n const enkey = isArray ? '' : encodeURIComponent(key);\n let pair;\n if (typeof value === 'object') {\n pair = stringify(\n value,\n prefix ? prefix + encodeURIComponent(`[${enkey}]`) : enkey,\n Array.isArray(value),\n );\n }\n else {\n pair = `${\n prefix ? prefix + encodeURIComponent(`[${enkey}]`) : enkey\n }=${encodeURIComponent(value)}`;\n }\n pairs.push(pair);\n }\n return pairs.join('&');\n};\n\n/**\n * Gets the base URL for a specific region\n * @param regionCode - Region code (eu, us, cn, ap, ca)\n * @returns Base URL for the region\n */\nexport const getRegionURL = (regionCode?: string): string => {\n const REGION_URLS = {\n eu: 'api.storyblok.com',\n us: 'api-us.storyblok.com',\n cn: 'app.storyblokchina.cn',\n ap: 'api-ap.storyblok.com',\n ca: 'api-ca.storyblok.com',\n } as const;\n\n return REGION_URLS[regionCode as keyof typeof REGION_URLS] ?? REGION_URLS.eu;\n};\n\n/**\n * Escapes HTML special characters in a string\n * @param string - String to escape\n * @returns Escaped string\n */\nexport const escapeHTML = (string: string): string => {\n const htmlEscapes = {\n '&': '&amp;',\n '<': '&lt;',\n '>': '&gt;',\n '\"': '&quot;',\n '\\'': '&#39;',\n } as HtmlEscapes;\n\n const reUnescapedHtml = /[&<>\"']/g;\n const reHasUnescapedHtml = new RegExp(reUnescapedHtml.source);\n\n return string && reHasUnescapedHtml.test(string)\n ? string.replace(reUnescapedHtml, chr => htmlEscapes[chr])\n : string;\n};\n","import { stringify } from './utils';\n\nimport type {\n ISbCustomFetch,\n ISbError,\n ISbResponse,\n ISbStoriesParams,\n} from './interfaces';\nimport type Method from './constants';\n\nexport interface ResponseFn {\n (arg?: ISbResponse | any): any;\n}\n\nexport interface ISbFetch {\n baseURL: string;\n timeout?: number;\n headers: Headers;\n responseInterceptor?: ResponseFn;\n fetch?: typeof fetch;\n}\n\nclass SbFetch {\n private baseURL: string;\n private timeout?: number;\n private headers: Headers;\n private responseInterceptor?: ResponseFn;\n private fetch: typeof fetch;\n private ejectInterceptor?: boolean;\n private url: string;\n private parameters: ISbStoriesParams;\n private fetchOptions: ISbCustomFetch;\n\n public constructor($c: ISbFetch) {\n this.baseURL = $c.baseURL;\n this.headers = $c.headers || new Headers();\n this.timeout = $c?.timeout ? $c.timeout * 1000 : 0;\n this.responseInterceptor = $c.responseInterceptor;\n this.fetch = (...args: [any]) =>\n $c.fetch ? $c.fetch(...args) : fetch(...args);\n this.ejectInterceptor = false;\n this.url = '';\n this.parameters = {} as ISbStoriesParams;\n this.fetchOptions = {};\n }\n\n /**\n *\n * @param url string\n * @param params ISbStoriesParams\n * @returns Promise<ISbResponse | Error>\n */\n public get(url: string, params: ISbStoriesParams) {\n this.url = url;\n this.parameters = params;\n return this._methodHandler('get');\n }\n\n public post(url: string, params: ISbStoriesParams) {\n this.url = url;\n this.parameters = params;\n return this._methodHandler('post');\n }\n\n public put(url: string, params: ISbStoriesParams) {\n this.url = url;\n this.parameters = params;\n return this._methodHandler('put');\n }\n\n public delete(url: string, params?: ISbStoriesParams) {\n this.url = url;\n this.parameters = params ?? {} as ISbStoriesParams;\n return this._methodHandler('delete');\n }\n\n private async _responseHandler(res: Response) {\n const headers: string[] = [];\n const response = {\n data: {},\n headers: {},\n status: 0,\n statusText: '',\n };\n\n if (res.status !== 204) {\n await res.json().then(($r) => {\n response.data = $r;\n });\n }\n\n for (const pair of res.headers.entries()) {\n headers[pair[0] as any] = pair[1];\n }\n\n response.headers = { ...headers };\n response.status = res.status;\n response.statusText = res.statusText;\n\n return response;\n }\n\n private async _methodHandler(\n method: Method,\n ): Promise<ISbResponse | ISbError> {\n let urlString = `${this.baseURL}${this.url}`;\n\n let body: string | null = null;\n\n if (method === 'get') {\n urlString = `${this.baseURL}${this.url}?${stringify(this.parameters)}`;\n }\n else {\n body = JSON.stringify(this.parameters);\n }\n\n const url = new URL(urlString);\n\n const controller = new AbortController();\n const { signal } = controller;\n\n let timeout: ReturnType<typeof setTimeout> | null = null;\n\n if (this.timeout) {\n timeout = setTimeout(() => controller.abort(), this.timeout);\n }\n\n try {\n const fetchResponse = await this.fetch(`${url}`, {\n method,\n headers: this.headers,\n body,\n signal,\n ...this.fetchOptions,\n });\n\n if (this.timeout && timeout) {\n clearTimeout(timeout);\n }\n\n const response = (await this._responseHandler(\n fetchResponse,\n )) as ISbResponse;\n\n if (this.responseInterceptor && !this.ejectInterceptor) {\n return this._statusHandler(this.responseInterceptor(response));\n }\n else {\n return this._statusHandler(response);\n }\n }\n catch (err: any) {\n // Check if it's a timeout/abort error\n if (err.name === 'AbortError') {\n const error: ISbError = {\n message: 'Request timeout: The request was aborted due to timeout',\n };\n return error;\n }\n\n // For other errors, try to extract a meaningful message\n const error: ISbError = {\n message: err.message || err.toString() || 'An unknown error occurred',\n };\n return error;\n }\n }\n\n public setFetchOptions(fetchOptions: ISbCustomFetch = {}) {\n if (Object.keys(fetchOptions).length > 0 && 'method' in fetchOptions) {\n delete fetchOptions.method;\n }\n this.fetchOptions = { ...fetchOptions };\n }\n\n public eject() {\n this.ejectInterceptor = true;\n }\n\n /**\n * Normalizes error messages from different response structures\n * @param data The response data that might contain error information\n * @returns A normalized error message string\n */\n private _normalizeErrorMessage(data: any): string {\n // Handle array of error messages\n if (Array.isArray(data)) {\n return data[0] || 'Unknown error';\n }\n\n // Handle object with error property\n if (data && typeof data === 'object') {\n // Check for common error message patterns\n if (data.error) {\n return data.error;\n }\n\n // Handle nested error objects (like { name: ['has already been taken'] })\n for (const key in data) {\n if (Array.isArray(data[key])) {\n return `${key}: ${data[key][0]}`;\n }\n if (typeof data[key] === 'string') {\n return `${key}: ${data[key]}`;\n }\n }\n\n // If we have a slug, it might be an error message\n if (data.slug) {\n return data.slug;\n }\n }\n\n // Fallback for unknown error structures\n return 'Unknown error';\n }\n\n private _statusHandler(res: ISbResponse): Promise<ISbResponse | ISbError> {\n const statusOk = /20[0-6]/g;\n\n return new Promise((resolve, reject) => {\n if (statusOk.test(`${res.status}`)) {\n return resolve(res);\n }\n\n const error: ISbError = {\n message: this._normalizeErrorMessage(res.data),\n status: res.status,\n response: res,\n };\n\n reject(error);\n });\n }\n}\n\nexport default SbFetch;\n","const _METHOD = {\n GET: 'get',\n DELETE: 'delete',\n POST: 'post',\n PUT: 'put',\n} as const;\n\ntype ObjectValues<T> = T[keyof T];\ntype Method = ObjectValues<typeof _METHOD>;\n\nexport default Method;\n\nexport const STORYBLOK_AGENT = 'SB-Agent';\n\nexport const STORYBLOK_JS_CLIENT_AGENT = {\n defaultAgentName: 'SB-JS-CLIENT',\n defaultAgentVersion: 'SB-Agent-Version',\n packageVersion: '7.0.0',\n};\n\nexport const StoryblokContentVersion = {\n DRAFT: 'draft',\n PUBLISHED: 'published',\n} as const;\n\nexport type StoryblokContentVersionKeys =\n typeof StoryblokContentVersion[keyof typeof StoryblokContentVersion];\n\nexport const StoryblokContentVersionValues = Object.values(\n StoryblokContentVersion,\n) as StoryblokContentVersionKeys[];\n\n/**\n * Default per_page value for Storyblok API requests\n */\nexport const DEFAULT_PER_PAGE = 25;\n\n/**\n * Per-page tier thresholds for rate limiting\n */\nexport const PER_PAGE_THRESHOLDS = {\n SMALL: 25,\n MEDIUM: 50,\n LARGE: 75,\n} as const;\n","import type { ISbStoriesParams } from './interfaces';\nimport { DEFAULT_PER_PAGE, PER_PAGE_THRESHOLDS, StoryblokContentVersion } from './constants';\n\nexport interface RateLimitConfig {\n // User-provided rate limit (applies only to uncached requests)\n userRateLimit?: number;\n // Rate limit determined from server response headers\n serverHeadersRateLimit?: number;\n // Whether this is a Management API client (uses oauthToken)\n isManagementApi?: boolean;\n}\n\nexport interface RateLimitHeaders {\n remaining?: number;\n max?: number;\n}\n\n/**\n * Rate limit tiers for uncached requests based on per_page parameter\n */\nconst UNCACHED_RATE_LIMITS = {\n SINGLE_OR_SMALL: 50, // Single entries or listings ≤25 entries\n MEDIUM: 15, // 26-50 entries\n LARGE: 10, // 51-75 entries\n VERY_LARGE: 6, // 76-100 entries\n} as const;\n\n/**\n * Maximum rate limit that should never be exceeded\n * Also used for cached requests (version=published or missing)\n */\nconst MAX_RATE_LIMIT = 1000;\n\n/**\n * Default rate limit for Management API (when using oauthToken)\n */\nexport const MANAGEMENT_API_DEFAULT_RATE_LIMIT = 3;\n\n/**\n * Determines if a request is uncached (draft version)\n */\nfunction isUncachedRequest(params: ISbStoriesParams): boolean {\n return params.version === StoryblokContentVersion.DRAFT;\n}\n\n/**\n * Determines if a request is for a single story based on URL and params\n */\nfunction isSingleStoryRequest(url: string, params: ISbStoriesParams): boolean {\n // Single story requests typically have a specific story slug/id in the URL\n // or use find_by parameter\n const isCdnStories = url.includes('/cdn/stories/');\n const hasSpecificPath = url.split('/').length > 3 && !url.endsWith('/cdn/stories');\n const hasFindBy = 'find_by' in params;\n\n return (isCdnStories && hasSpecificPath) || hasFindBy;\n}\n\n/**\n * Calculates the appropriate rate limit tier for uncached requests\n * based on the number of entries requested (per_page parameter)\n */\nfunction getUncachedRateLimitTier(perPage: number): number {\n if (perPage <= PER_PAGE_THRESHOLDS.SMALL) {\n return UNCACHED_RATE_LIMITS.SINGLE_OR_SMALL;\n }\n else if (perPage <= PER_PAGE_THRESHOLDS.MEDIUM) {\n return UNCACHED_RATE_LIMITS.MEDIUM;\n }\n else if (perPage <= PER_PAGE_THRESHOLDS.LARGE) {\n return UNCACHED_RATE_LIMITS.LARGE;\n }\n else {\n return UNCACHED_RATE_LIMITS.VERY_LARGE;\n }\n}\n\n/**\n * Determines the appropriate rate limit for a request based on:\n * - Request version (cached vs uncached)\n * - Request type (single vs listing)\n * - Number of entries (per_page)\n * - User configuration\n * - Server headers\n *\n * When url and params are not provided (Management API), uses the defaultRateLimit\n * as the fallback instead of automatic tier calculation.\n */\nexport function determineRateLimit(\n url?: string,\n params?: ISbStoriesParams,\n config: RateLimitConfig = {},\n defaultRateLimit?: number,\n): number {\n // Priority order for all requests:\n // 1. User-provided rate limit (highest priority, applies to all requests)\n // 2. Server-provided rate limit (from response headers)\n // 3. Default rate limit (Management API)\n // 4. For cached requests (published), use the maximum rate limit\n // 5. Automatic tier calculation (CDN)\n\n if (config.userRateLimit !== undefined) {\n return Math.min(config.userRateLimit, MAX_RATE_LIMIT);\n }\n\n if (config.serverHeadersRateLimit !== undefined) {\n return Math.min(config.serverHeadersRateLimit, MAX_RATE_LIMIT);\n }\n\n // If a default rate limit is provided (Management API), use it\n if (defaultRateLimit !== undefined) {\n return defaultRateLimit;\n }\n\n // For cached requests, use the maximum rate limit\n if (params && !isUncachedRequest(params)) {\n return MAX_RATE_LIMIT;\n }\n\n // For CDN API, calculate based on request type\n // At this point, url and params should be defined for CDN API calls\n // Single story requests or listings without per_page\n if (isSingleStoryRequest(url!, params!)) {\n return UNCACHED_RATE_LIMITS.SINGLE_OR_SMALL;\n }\n\n // For listings, determine tier based on per_page\n const perPage = params!.per_page || DEFAULT_PER_PAGE;\n return getUncachedRateLimitTier(perPage);\n}\n\n/**\n * Parses X-RateLimit and X-RateLimit-Policy headers from the response\n *\n * Example headers:\n * X-RateLimit: \"concurrent-requests\";r=29\n * X-RateLimit-Policy: \"concurrent-requests\";q=30\n *\n * Where:\n * - r = remaining requests\n * - q = maximum requests allowed (quota)\n */\nexport function parseRateLimitHeaders(headers: any): RateLimitHeaders | null {\n if (!headers) {\n return null;\n }\n\n const rateLimitHeader = headers['x-ratelimit'] || headers['X-RateLimit'];\n const rateLimitPolicyHeader = headers['x-ratelimit-policy'] || headers['X-RateLimit-Policy'];\n\n if (!rateLimitHeader && !rateLimitPolicyHeader) {\n return null;\n }\n\n const result: RateLimitHeaders = {};\n\n // Parse remaining from X-RateLimit header\n if (rateLimitHeader) {\n const remainingMatch = rateLimitHeader.match(/r=(\\d+)/);\n if (remainingMatch) {\n result.remaining = Number.parseInt(remainingMatch[1], 10);\n }\n }\n\n // Parse max from X-RateLimit-Policy header\n if (rateLimitPolicyHeader) {\n const maxMatch = rateLimitPolicyHeader.match(/q=(\\d+)/);\n if (maxMatch) {\n result.max = Number.parseInt(maxMatch[1], 10);\n }\n }\n\n return Object.keys(result).length > 0 ? result : null;\n}\n\n/**\n * Creates a rate limit configuration object\n */\nexport function createRateLimitConfig(userRateLimit?: number, isManagementApi = false): RateLimitConfig {\n return {\n userRateLimit,\n serverHeadersRateLimit: undefined,\n isManagementApi,\n };\n}\n","import type { ISbThrottle, Queue } from './interfaces';\n\nclass AbortError extends Error {\n constructor(msg: string) {\n super(msg);\n this.name = 'AbortError';\n }\n}\n\nfunction throttledQueue<T extends (...args: Parameters<T>) => ReturnType<T>>(\n fn: T,\n limit: number,\n interval: number,\n): ISbThrottle<T> {\n if (!Number.isFinite(limit)) {\n throw new TypeError('Expected `limit` to be a finite number');\n }\n\n if (!Number.isFinite(interval)) {\n throw new TypeError('Expected `interval` to be a finite number');\n }\n\n const queue: Queue<Parameters<T>>[] = [];\n let timeouts: ReturnType<typeof setTimeout>[] = [];\n let activeCount = 0;\n let isAborted = false;\n\n const next = async () => {\n activeCount++;\n\n const x = queue.shift();\n if (x) {\n try {\n const res = await fn(...x.args);\n x.resolve(res);\n }\n catch (error) {\n x.reject(error);\n }\n }\n\n const id = setTimeout(() => {\n activeCount--;\n\n if (queue.length > 0) {\n next();\n }\n\n timeouts = timeouts.filter(currentId => currentId !== id);\n }, interval);\n\n if (!timeouts.includes(id)) {\n timeouts.push(id);\n }\n };\n\n const throttled: ISbThrottle<T> = (...args) => {\n if (isAborted) {\n return Promise.reject(\n new Error(\n 'Throttled function is already aborted and not accepting new promises',\n ),\n );\n }\n\n return new Promise((resolve, reject) => {\n queue.push({\n resolve,\n reject,\n args,\n });\n\n if (activeCount < limit) {\n next();\n }\n });\n };\n\n throttled.abort = () => {\n isAborted = true;\n timeouts.forEach(clearTimeout);\n timeouts = [];\n\n queue.forEach(x =>\n x.reject(() => new AbortError('Throttle function aborted')),\n );\n queue.length = 0;\n };\n\n return throttled;\n}\n\nexport default throttledQueue;\n","import throttledQueue from './throttlePromise';\nimport type { ISbThrottle, ISbThrottledRequest } from './interfaces';\n\n/**\n * Manages multiple throttle queues, each with different rate limits.\n * This ensures that requests with different rate limits don't interfere with each other.\n *\n * For example, cached requests (1000 req/s) and uncached requests (50 req/s)\n * will use separate queues, preventing the slower queue from affecting the faster one.\n */\nexport class ThrottleQueueManager {\n private queues: Map<number, ISbThrottle<ISbThrottledRequest>>;\n private interval: number;\n private throttledRequestFn: ISbThrottledRequest;\n\n constructor(throttledRequestFn: ISbThrottledRequest, interval = 1000) {\n this.queues = new Map();\n this.interval = interval;\n this.throttledRequestFn = throttledRequestFn;\n }\n\n /**\n * Gets or creates a throttle queue for the specified rate limit\n */\n private getQueue(rateLimit: number): ISbThrottle<ISbThrottledRequest> {\n let queue = this.queues.get(rateLimit);\n\n if (!queue) {\n queue = throttledQueue(\n this.throttledRequestFn,\n rateLimit,\n this.interval,\n );\n this.queues.set(rateLimit, queue);\n }\n\n return queue;\n }\n\n /**\n * Executes a request through the appropriate throttle queue based on rate limit\n */\n public execute(\n rateLimit: number,\n ...args: Parameters<ISbThrottledRequest>\n ): Promise<unknown> {\n const queue = this.getQueue(rateLimit);\n return queue(...args);\n }\n\n /**\n * Aborts all throttle queues\n */\n public abortAll(): void {\n this.queues.forEach((queue) => {\n queue.abort?.();\n });\n this.queues.clear();\n }\n\n /**\n * Gets the number of active queues\n */\n public getQueueCount(): number {\n return this.queues.size;\n }\n}\n","import {\n asyncMap,\n delay,\n flatMap,\n getOptionsPage,\n getRegionURL,\n isCDNUrl,\n range,\n stringify,\n} from './utils';\nimport SbFetch from './sbFetch';\nimport type Method from './constants';\nimport type { StoryblokContentVersionKeys } from './constants';\nimport { STORYBLOK_AGENT, STORYBLOK_JS_CLIENT_AGENT, StoryblokContentVersion } from './constants';\nimport { createRateLimitConfig, determineRateLimit, MANAGEMENT_API_DEFAULT_RATE_LIMIT, parseRateLimitHeaders, type RateLimitConfig } from './rateLimit';\nimport { ThrottleQueueManager } from './throttleQueueManager';\n\nimport type {\n ICacheProvider,\n IMemoryType,\n ISbCache,\n ISbComponentType,\n ISbConfig,\n ISbContentMangmntAPI,\n ISbCustomFetch,\n ISbField,\n ISbLinksParams,\n ISbLinksResult,\n ISbLinkURLObject,\n ISbResponse,\n ISbResponseData,\n ISbResult,\n ISbStories,\n ISbStoriesParams,\n ISbStory,\n ISbStoryData,\n ISbStoryParams,\n} from './interfaces';\n\nexport * from './interfaces';\n\nlet memory: Partial<IMemoryType> = {};\n\nconst cacheVersions = {} as CachedVersions;\n\ninterface CachedVersions {\n [key: string]: number;\n}\n\ninterface LinksType {\n [key: string]: any;\n}\n\ninterface RelationsType {\n [key: string]: any;\n}\n\ninterface ISbFlatMapped {\n data: any;\n}\n\nconst _VERSION = {\n V1: 'v1',\n V2: 'v2',\n} as const;\n\ntype ObjectValues<T> = T[keyof T];\ntype Version = ObjectValues<typeof _VERSION>;\n\nexport class Storyblok {\n private client: SbFetch;\n private maxRetries: number;\n private retriesDelay: number;\n private throttleManager: ThrottleQueueManager;\n private accessToken: string;\n private cache: ISbCache;\n private resolveCounter: number;\n public relations: RelationsType;\n public links: LinksType;\n public version: StoryblokContentVersionKeys | undefined;\n private rateLimitConfig: RateLimitConfig;\n /**\n * @deprecated This property is deprecated. Use the standalone `richTextResolver` from `@storyblok/richtext` instead.\n * @see https://github.com/storyblok/richtext\n */\n public richTextResolver: unknown;\n public resolveNestedRelations: boolean;\n private stringifiedStoriesCache: Record<string, string>;\n private inlineAssets: boolean;\n\n /**\n *\n * @param config ISbConfig interface\n * @param pEndpoint string, optional\n */\n public constructor(config: ISbConfig, pEndpoint?: string) {\n let endpoint = config.endpoint || pEndpoint;\n\n if (!endpoint) {\n const protocol = config.https === false ? 'http' : 'https';\n\n if (!config.oauthToken) {\n endpoint = `${protocol}://${getRegionURL(config.region)}/${'v2' as Version}`;\n }\n else {\n endpoint = `${protocol}://${getRegionURL(config.region)}/${'v1' as Version}`;\n }\n }\n\n const headers: Headers = new Headers();\n\n headers.set('Content-Type', 'application/json');\n headers.set('Accept', 'application/json');\n\n if (config.headers) {\n const entries\n = config.headers.constructor.name === 'Headers'\n ? config.headers.entries().toArray()\n : Object.entries(config.headers);\n\n entries.forEach(([key, value]: [string, string]) => {\n headers.set(key, value);\n });\n }\n\n if (!headers.has(STORYBLOK_AGENT)) {\n headers.set(STORYBLOK_AGENT, STORYBLOK_JS_CLIENT_AGENT.defaultAgentName);\n headers.set(\n STORYBLOK_JS_CLIENT_AGENT.defaultAgentVersion,\n STORYBLOK_JS_CLIENT_AGENT.packageVersion,\n );\n }\n\n if (config.oauthToken) {\n headers.set('Authorization', config.oauthToken);\n }\n\n // Create rate limit config - user's rateLimit applies only to uncached requests\n // Pass isManagementApi flag to handle Management API default rate limit\n this.rateLimitConfig = createRateLimitConfig(config.rateLimit, !!config.oauthToken);\n\n this.maxRetries = config.maxRetries || 10;\n this.retriesDelay = 300;\n\n // Initialize throttle queue manager\n this.throttleManager = new ThrottleQueueManager(\n this.throttledRequest.bind(this),\n 1000,\n );\n\n this.accessToken = config.accessToken || '';\n this.relations = {} as RelationsType;\n this.links = {} as LinksType;\n this.cache = config.cache || { clear: 'manual' };\n this.resolveCounter = 0;\n this.resolveNestedRelations = config.resolveNestedRelations || true;\n this.stringifiedStoriesCache = {} as Record<string, string>;\n this.version = config.version || StoryblokContentVersion.PUBLISHED; // the default version is published as per API documentation\n this.inlineAssets = config.inlineAssets || false;\n\n this.client = new SbFetch({\n baseURL: endpoint,\n timeout: config.timeout || 0,\n headers,\n responseInterceptor: config.responseInterceptor,\n fetch: config.fetch,\n });\n }\n\n private parseParams(params: ISbStoriesParams): ISbStoriesParams {\n if (!params.token) {\n params.token = this.getToken();\n }\n\n if (!params.cv) {\n params.cv = cacheVersions[params.token];\n }\n\n if (Array.isArray(params.resolve_relations)) {\n params.resolve_relations = params.resolve_relations.join(',');\n }\n\n if (typeof params.resolve_relations !== 'undefined') {\n params.resolve_level = 2;\n }\n\n return params;\n }\n\n private factoryParamOptions(\n url: string,\n params: ISbStoriesParams,\n ): ISbStoriesParams {\n if (isCDNUrl(url)) {\n return this.parseParams(params);\n }\n\n return params;\n }\n\n private makeRequest(\n url: string,\n params: ISbStoriesParams,\n per_page: number,\n page: number,\n fetchOptions?: ISbCustomFetch,\n ): Promise<ISbResult> {\n const query = this.factoryParamOptions(\n url,\n getOptionsPage(params, per_page, page),\n );\n\n return this.cacheResponse(url, query, undefined, fetchOptions);\n }\n\n public get(\n slug: 'cdn/links',\n params?: ISbLinksParams,\n fetchOptions?: ISbCustomFetch\n ): Promise<ISbLinksResult>;\n\n public get(\n slug: string,\n params?: ISbStoriesParams,\n fetchOptions?: ISbCustomFetch\n ): Promise<ISbResult>;\n\n public get(\n slug: string,\n params: ISbStoriesParams | ISbLinksParams = {},\n fetchOptions?: ISbCustomFetch,\n ): Promise<ISbResult | ISbLinksResult> {\n if (!params) {\n params = {} as ISbStoriesParams;\n }\n const url = `/${slug}`;\n\n // Only add version parameter for CDN URLs\n if (isCDNUrl(url)) {\n params.version = params.version || this.version;\n }\n\n const query = this.factoryParamOptions(url, params);\n\n return this.cacheResponse(url, query, undefined, fetchOptions);\n }\n\n public async getAll(\n slug: string,\n params: ISbStoriesParams = {},\n entity?: string,\n fetchOptions?: ISbCustomFetch,\n ): Promise<any[]> {\n const perPage = params?.per_page || 25;\n const url = `/${slug}`.replace(/\\/$/, '');\n const e = entity ?? url.substring(url.lastIndexOf('/') + 1);\n params.version = params.version || this.version;\n\n const firstPage = 1;\n const firstRes = await this.makeRequest(\n url,\n params,\n perPage,\n firstPage,\n fetchOptions,\n );\n const lastPage = firstRes.total ? Math.ceil(firstRes.total / (firstRes.perPage || perPage)) : 1;\n\n const restRes: any = await asyncMap(\n range(firstPage, lastPage),\n (i: number) => {\n return this.makeRequest(url, params, perPage, i + 1, fetchOptions);\n },\n );\n\n return flatMap([firstRes, ...restRes], (res: ISbFlatMapped) =>\n Object.values(res.data[e]));\n }\n\n public post(\n slug: string,\n params: ISbStoriesParams | ISbContentMangmntAPI = {},\n fetchOptions?: ISbCustomFetch,\n ): Promise<ISbResponseData> {\n const url = `/${slug}`;\n\n const rateLimit = determineRateLimit(undefined, undefined, this.rateLimitConfig, MANAGEMENT_API_DEFAULT_RATE_LIMIT);\n return this.throttleManager.execute(rateLimit, 'post', url, params, fetchOptions) as Promise<ISbResponseData>;\n }\n\n public put(\n slug: string,\n params: ISbStoriesParams | ISbContentMangmntAPI = {},\n fetchOptions?: ISbCustomFetch,\n ): Promise<ISbResponseData> {\n const url = `/${slug}`;\n\n const rateLimit = determineRateLimit(undefined, undefined, this.rateLimitConfig, MANAGEMENT_API_DEFAULT_RATE_LIMIT);\n return this.throttleManager.execute(rateLimit, 'put', url, params, fetchOptions) as Promise<ISbResponseData>;\n }\n\n public delete(\n slug: string,\n params: ISbStoriesParams | ISbContentMangmntAPI = {},\n fetchOptions?: ISbCustomFetch,\n ): Promise<ISbResponseData> {\n if (!params) {\n params = {} as ISbStoriesParams;\n }\n const url = `/${slug}`;\n\n const rateLimit = determineRateLimit(undefined, undefined, this.rateLimitConfig, MANAGEMENT_API_DEFAULT_RATE_LIMIT);\n return this.throttleManager.execute(rateLimit, 'delete', url, params, fetchOptions) as Promise<ISbResponseData>;\n }\n\n public getStories(\n params: ISbStoriesParams = {},\n fetchOptions?: ISbCustomFetch,\n ): Promise<ISbStories> {\n this._addResolveLevel(params);\n\n return this.get('cdn/stories', params, fetchOptions);\n }\n\n public getStory(\n slug: string,\n params: ISbStoryParams = {},\n fetchOptions?: ISbCustomFetch,\n ): Promise<ISbStory> {\n this._addResolveLevel(params);\n\n return this.get(`cdn/stories/${slug}`, params, fetchOptions);\n }\n\n private getToken(): string {\n return this.accessToken;\n }\n\n public ejectInterceptor(): void {\n this.client.eject();\n }\n\n private _addResolveLevel(params: ISbStoriesParams | ISbStoryParams): void {\n if (typeof params.resolve_relations !== 'undefined') {\n params.resolve_level = 2;\n }\n }\n\n private _cleanCopy(value: LinksType): JSON {\n return JSON.parse(JSON.stringify(value));\n }\n\n private _insertLinks(\n jtree: ISbStoriesParams,\n treeItem: keyof ISbStoriesParams,\n resolveId: string,\n ): void {\n const node = jtree[treeItem];\n\n if (\n node\n && node.fieldtype === 'multilink'\n && node.linktype === 'story'\n && typeof node.id === 'string'\n && this.links[resolveId][node.id]\n ) {\n node.story = this._cleanCopy(this.links[resolveId][node.id]);\n }\n else if (\n node\n && node.linktype === 'story'\n && typeof node.uuid === 'string'\n && this.links[resolveId][node.uuid]\n ) {\n node.story = this._cleanCopy(this.links[resolveId][node.uuid]);\n }\n }\n\n /**\n *\n * @param resolveId A counter number as a string\n * @param uuid The uuid of the story\n * @returns string | object\n */\n private getStoryReference(resolveId: string, uuid: string): string | JSON {\n const result = this.relations[resolveId][uuid]\n ? JSON.parse(this.stringifiedStoriesCache[uuid] || JSON.stringify(this.relations[resolveId][uuid]))\n : uuid;\n return result;\n }\n\n /**\n * Resolves a field's value by replacing UUIDs with their corresponding story references\n * @param jtree - The JSON tree object containing the field to resolve\n * @param treeItem - The key of the field to resolve\n * @param resolveId - The unique identifier for the current resolution context\n *\n * This method handles both single string UUIDs and arrays of UUIDs:\n * - For single strings: directly replaces the UUID with the story reference\n * - For arrays: maps through each UUID and replaces with corresponding story references\n */\n private _resolveField(\n jtree: ISbStoriesParams,\n treeItem: keyof ISbStoriesParams,\n resolveId: string,\n ): void {\n const item = jtree[treeItem];\n if (typeof item === 'string') {\n jtree[treeItem] = this.getStoryReference(resolveId, item);\n }\n else if (Array.isArray(item)) {\n jtree[treeItem] = item.map(uuid =>\n this.getStoryReference(resolveId, uuid),\n ).filter(Boolean);\n }\n }\n\n /**\n * Inserts relations into the JSON tree by resolving references\n * @param jtree - The JSON tree object to process\n * @param treeItem - The current field being processed\n * @param fields - The relation patterns to resolve (string or array of strings)\n * @param resolveId - The unique identifier for the current resolution context\n *\n * This method handles two types of relation patterns:\n * 1. Nested relations: matches fields that end with the current field name\n * Example: If treeItem is \"event_type\", it matches patterns like \"*.event_type\"\n *\n * 2. Direct component relations: matches exact component.field patterns\n * Example: \"event.event_type\" for component \"event\" and field \"event_type\"\n *\n * The method supports both string and array formats for the fields parameter,\n * allowing flexible specification of relation patterns.\n */\n private _insertRelations(\n jtree: ISbStoriesParams,\n treeItem: keyof ISbStoriesParams,\n fields: string | string[],\n resolveId: string,\n ): void {\n // Check for nested relations (e.g., \"*.event_type\" or \"spots.event_type\")\n const fieldPattern = Array.isArray(fields)\n ? fields.find(f => f.endsWith(`.${treeItem}`))\n : fields.endsWith(`.${treeItem}`);\n\n if (fieldPattern) {\n // If we found a matching pattern, resolve this field\n this._resolveField(jtree, treeItem, resolveId);\n return;\n }\n\n // If no nested pattern matched, check for direct component.field pattern\n // e.g., \"event.event_type\" for a field within its immediate parent component\n const fieldPath = jtree.component ? `${jtree.component}.${treeItem}` : treeItem;\n // Check if this exact pattern exists in the fields to resolve\n if (Array.isArray(fields) ? fields.includes(fieldPath) : fields === fieldPath) {\n this._resolveField(jtree, treeItem, resolveId);\n }\n }\n\n /**\n * Recursively traverses and resolves relations in the story content tree\n * @param story - The story object containing the content to process\n * @param fields - The relation patterns to resolve\n * @param resolveId - The unique identifier for the current resolution context\n */\n private iterateTree(\n story: ISbStoryData,\n fields: string | Array<string>,\n resolveId: string,\n ): void {\n // Internal recursive function to process each node in the tree\n const enrich = (jtree: ISbStoriesParams | any, path = '') => {\n // Skip processing if node is null/undefined or marked to stop resolving\n if (!jtree || jtree._stopResolving) {\n return;\n }\n\n // Handle arrays by recursively processing each element\n // Maintains path context by adding array indices\n if (Array.isArray(jtree)) {\n jtree.forEach((item, index) => enrich(item, `${path}[${index}]`));\n }\n // Handle object nodes\n else if (typeof jtree === 'object') {\n // Process each property in the object\n for (const key in jtree) {\n // Build the current path for the context\n const newPath = path ? `${path}.${key}` : key;\n\n // If this is a component (has component and _uid) or a link,\n // attempt to resolve its relations and links\n if ((jtree.component && jtree._uid) || jtree.type === 'link') {\n this._insertRelations(jtree, key as keyof ISbStoriesParams, fields, resolveId);\n this._insertLinks(jtree, key as keyof ISbStoriesParams, resolveId);\n }\n\n // Continue traversing deeper into the tree\n // This ensures we process nested components and their relations\n enrich(jtree[key], newPath);\n }\n }\n };\n\n // Start the traversal from the story's content\n enrich(story.content);\n }\n\n private async resolveLinks(\n responseData: ISbResponseData,\n params: ISbStoriesParams,\n resolveId: string,\n ): Promise<void> {\n let links: (ISbStoryData | ISbLinkURLObject | string)[] = [];\n\n if (responseData.link_uuids) {\n const relSize = responseData.link_uuids.length;\n const chunks = [];\n const chunkSize = 50;\n\n for (let i = 0; i < relSize; i += chunkSize) {\n const end = Math.min(relSize, i + chunkSize);\n chunks.push(responseData.link_uuids.slice(i, end));\n }\n\n for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {\n const linksRes = await this.getStories({\n per_page: chunkSize,\n language: params.language,\n version: params.version,\n starts_with: params.starts_with,\n by_uuids: chunks[chunkIndex].join(','),\n });\n\n linksRes.data.stories.forEach(\n (rel: ISbStoryData | ISbLinkURLObject | string) => {\n links.push(rel);\n },\n );\n }\n }\n else {\n links = responseData.links;\n }\n\n links.forEach((story: ISbStoryData | any) => {\n this.links[resolveId][story.uuid] = {\n ...story,\n ...{ _stopResolving: true },\n };\n });\n }\n\n private async resolveRelations(\n responseData: ISbResponseData,\n params: ISbStoriesParams,\n resolveId: string,\n ): Promise<void> {\n let relations: ISbStoryData<ISbComponentType<string> & { [index: string]: any }>[] = [];\n\n if (responseData.rel_uuids) {\n const relSize = responseData.rel_uuids.length;\n const chunks = [];\n const chunkSize = 50;\n\n for (let i = 0; i < relSize; i += chunkSize) {\n const end = Math.min(relSize, i + chunkSize);\n chunks.push(responseData.rel_uuids.slice(i, end));\n }\n\n for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {\n const relationsRes = await this.getStories({\n per_page: chunkSize,\n language: params.language,\n version: params.version,\n starts_with: params.starts_with,\n by_uuids: chunks[chunkIndex].join(','),\n excluding_fields: params.excluding_fields,\n });\n\n relationsRes.data.stories.forEach((rel: ISbStoryData) => {\n relations.push(rel);\n });\n }\n\n // Replace rel_uuids with the fully resolved stories and clear it\n if (relations.length > 0) {\n responseData.rels = relations;\n delete responseData.rel_uuids;\n }\n }\n else {\n relations = responseData.rels;\n }\n\n if (relations && relations.length > 0) {\n relations.forEach((story: ISbStoryData) => {\n this.relations[resolveId][story.uuid] = {\n ...story,\n ...{ _stopResolving: true },\n };\n });\n }\n }\n\n /**\n *\n * @param responseData\n * @param params\n * @param resolveId\n * @description Resolves the relations and links of the stories\n * @returns Promise<void>\n *\n */\n private async resolveStories(\n responseData: ISbResponseData,\n params: ISbStoriesParams,\n resolveId: string,\n ): Promise<void> {\n let relationParams: string[] = [];\n\n this.links[resolveId] = {};\n this.relations[resolveId] = {};\n\n if (\n typeof params.resolve_relations !== 'undefined'\n && params.resolve_relations.length > 0\n ) {\n if (typeof params.resolve_relations === 'string') {\n relationParams = params.resolve_relations.split(',');\n }\n await this.resolveRelations(responseData, params, resolveId);\n }\n\n if (\n params.resolve_links\n && ['1', 'story', 'url', 'link'].includes(params.resolve_links)\n && (responseData.links?.length || responseData.link_uuids?.length)\n ) {\n await this.resolveLinks(responseData, params, resolveId);\n }\n\n if (this.resolveNestedRelations) {\n for (const relUuid in this.relations[resolveId]) {\n this.iterateTree(\n this.relations[resolveId][relUuid],\n relationParams,\n resolveId,\n );\n }\n }\n\n if (responseData.story) {\n this.iterateTree(responseData.story, relationParams, resolveId);\n }\n else {\n responseData.stories.forEach((story: ISbStoryData) => {\n this.iterateTree(story, relationParams, resolveId);\n });\n }\n\n this.stringifiedStoriesCache = {};\n\n delete this.links[resolveId];\n delete this.relations[resolveId];\n }\n\n private async cacheResponse(\n url: string,\n params: ISbStoriesParams,\n retries?: number,\n fetchOptions?: ISbCustomFetch,\n ): Promise<ISbResult> {\n const cacheKey = stringify({ url, params });\n const provider = this.cacheProvider();\n\n // Check in-memory cache first for published content\n // If cached, skip API call and rate limiting entirely\n if (params.version === 'published' && url !== '/cdn/spaces/me') {\n const cache = await provider.get(cacheKey);\n if (cache) {\n return Promise.resolve(cache);\n }\n }\n\n // Calculate appropriate rate limit for this request\n // For Management API requests (non-CDN URLs), use the MAPI rate limit\n const isMapi = !isCDNUrl(url) && this.rateLimitConfig.isManagementApi;\n const defaultLimit = isMapi ? MANAGEMENT_API_DEFAULT_RATE_LIMIT : undefined;\n const rateLimit = determineRateLimit(url, params, this.rateLimitConfig, defaultLimit);\n\n return new Promise(async (resolve, reject) => {\n try {\n // Execute through the appropriate throttle queue based on rate limit\n const res = (await this.throttleManager.execute(\n rateLimit,\n 'get',\n url,\n params,\n fetchOptions,\n )) as ISbResponse;\n if (res.status !== 200) {\n return reject(res);\n }\n\n let response = { data: res.data, headers: res.headers } as ISbResult;\n\n // Parse rate limit headers and update config if present\n const rateLimitHeaders = parseRateLimitHeaders(res.headers);\n if (rateLimitHeaders?.max !== undefined) {\n // Update server rate limit for subsequent requests\n this.rateLimitConfig.serverHeadersRateLimit = rateLimitHeaders.max;\n }\n\n if (res.headers?.['per-page']) {\n response = Object.assign({}, response, {\n perPage: res.headers['per-page']\n ? Number.parseInt(res.headers['per-page'])\n : 0,\n total: res.headers['per-page']\n ? Number.parseInt(res.headers.total)\n : 0,\n });\n }\n\n if (response.data.story || response.data.stories) {\n const resolveId = (this.resolveCounter\n = ++this.resolveCounter % 1000);\n await this.resolveStories(response.data, params, `${resolveId}`);\n response = await this.processInlineAssets(response);\n }\n\n if (params.version === 'published' && url !== '/cdn/spaces/me') {\n await provider.set(cacheKey, response);\n }\n\n const isCacheClearable = (this.cache.clear === 'onpreview' && params.version === 'draft')\n || this.cache.clear === 'auto';\n\n if (params.token && response.data.cv) {\n if (isCacheClearable\n && cacheVersions[params.token] // there is a cache\n && cacheVersions[params.token] !== response.data.cv // a new cv is incoming\n ) {\n await this.flushCache();\n }\n cacheVersions[params.token] = response.data.cv;\n }\n\n return resolve(response);\n }\n catch (error: Error | any) {\n if (error.response && error.status === 429) {\n retries = typeof retries === 'undefined' ? 0 : retries + 1;\n\n if (retries < this.maxRetries) {\n // eslint-disable-next-line no-console\n console.log(\n `Hit rate limit. Retrying in ${this.retriesDelay / 1000} seconds.`,\n );\n await delay(this.retriesDelay);\n return this.cacheResponse(url, params, retries)\n .then(resolve)\n .catch(reject);\n }\n }\n reject(error);\n }\n });\n }\n\n private throttledRequest(\n type: Method,\n url: string,\n params: ISbStoriesParams,\n fetchOptions?: ISbCustomFetch,\n ): Promise<unknown> {\n this.client.setFetchOptions(fetchOptions);\n return this.client[type](url, params);\n }\n\n public cacheVersions(): CachedVersions {\n return cacheVersions;\n }\n\n public cacheVersion(): number {\n return cacheVersions[this.accessToken];\n }\n\n public setCacheVersion(cv: number): void {\n if (this.accessToken) {\n cacheVersions[this.accessToken] = cv;\n }\n }\n\n public clearCacheVersion(): void {\n if (this.accessToken) {\n cacheVersions[this.accessToken] = 0;\n }\n }\n\n public cacheProvider(): ICacheProvider {\n switch (this.cache.type) {\n case 'memory':\n return {\n get(key: string) {\n return Promise.resolve(memory[key]);\n },\n getAll() {\n return Promise.resolve(memory as IMemoryType);\n },\n set(key: string, content: ISbResult) {\n memory[key] = content;\n return Promise.resolve(undefined);\n },\n flush() {\n memory = {};\n return Promise.resolve(undefined);\n },\n };\n case 'custom':\n if (this.cache.custom) {\n return this.cache.custom;\n }\n // eslint-disable-next-line no-fallthrough\n default:\n return {\n get() {\n return Promise.resolve();\n },\n getAll() {\n return Promise.resolve(undefined);\n },\n set() {\n return Promise.resolve(undefined);\n },\n flush() {\n return Promise.resolve(undefined);\n },\n };\n }\n }\n\n public async flushCache(): Promise<this> {\n await t