UNPKG

storyblok-js-client

Version:
738 lines (733 loc) 25 kB
//#region src/throttlePromise.ts var AbortError = class extends Error { constructor(msg) { super(msg); this.name = "AbortError"; } }; function throttledQueue(fn, limit, interval) { 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 = []; let timeouts = []; let activeCount = 0; let isAborted = false; const next = async () => { activeCount++; const x = queue.shift(); if (x) try { const res = await fn(...x.args); x.resolve(res); } catch (error) { x.reject(error); } const id = setTimeout(() => { activeCount--; if (queue.length > 0) next(); timeouts = timeouts.filter((currentId) => currentId !== id); }, interval); if (!timeouts.includes(id)) timeouts.push(id); }; const throttled = (...args) => { if (isAborted) return Promise.reject(/* @__PURE__ */ new Error("Throttled function is already aborted and not accepting new promises")); return new Promise((resolve, reject) => { queue.push({ resolve, reject, args }); if (activeCount < limit) next(); }); }; throttled.abort = () => { isAborted = true; timeouts.forEach(clearTimeout); timeouts = []; queue.forEach((x) => x.reject(() => new AbortError("Throttle function aborted"))); queue.length = 0; }; return throttled; } var throttlePromise_default = throttledQueue; //#endregion //#region src/utils.ts /** * Checks if a URL is a CDN URL * @param url - The URL to check * @returns boolean indicating if the URL is a CDN URL */ const isCDNUrl = (url = "") => url.includes("/cdn/"); /** * Gets pagination options for the API request * @param options - The base options * @param perPage - Number of items per page * @param page - Current page number * @returns Object with pagination options */ const getOptionsPage = (options, perPage = 25, page = 1) => ({ ...options, per_page: perPage, page }); /** * Creates a promise that resolves after the specified milliseconds * @param ms - Milliseconds to delay * @returns Promise that resolves after the delay */ const delay = (ms) => new Promise((res) => setTimeout(res, ms)); /** * Creates an array of specified length using a mapping function * @param length - Length of the array * @param func - Mapping function * @returns Array of specified length */ const arrayFrom = (length = 0, func) => Array.from({ length }, func); /** * Creates an array of numbers in the specified range * @param start - Start of the range * @param end - End of the range * @returns Array of numbers in the range */ const range = (start = 0, end = start) => { const length = Math.abs(end - start) || 0; const step = start < end ? 1 : -1; return arrayFrom(length, (_, i) => i * step + start); }; /** * Maps an array asynchronously * @param arr - Array to map * @param func - Async mapping function * @returns Promise resolving to mapped array */ const asyncMap = async (arr, func) => Promise.all(arr.map(func)); /** * Flattens an array using a mapping function * @param arr - Array to flatten * @param func - Mapping function * @returns Flattened array */ const flatMap = (arr = [], func) => arr.map(func).reduce((xs, ys) => [...xs, ...ys], []); /** * Stringifies an object into a URL query string * @param params - Parameters to stringify * @param prefix - Prefix for nested keys * @param isArray - Whether the current level is an array * @returns Stringified query parameters */ const stringify = (params, prefix, isArray) => { const pairs = []; for (const key in params) { if (!Object.prototype.hasOwnProperty.call(params, key)) continue; const value = params[key]; if (value === null || value === void 0) continue; const enkey = isArray ? "" : encodeURIComponent(key); let pair; if (typeof value === "object") pair = stringify(value, prefix ? prefix + encodeURIComponent(`[${enkey}]`) : enkey, Array.isArray(value)); else pair = `${prefix ? prefix + encodeURIComponent(`[${enkey}]`) : enkey}=${encodeURIComponent(value)}`; pairs.push(pair); } return pairs.join("&"); }; /** * Gets the base URL for a specific region * @param regionCode - Region code (eu, us, cn, ap, ca) * @returns Base URL for the region */ const getRegionURL = (regionCode) => { const REGION_URLS = { eu: "api.storyblok.com", us: "api-us.storyblok.com", cn: "app.storyblokchina.cn", ap: "api-ap.storyblok.com", ca: "api-ca.storyblok.com" }; return REGION_URLS[regionCode] ?? REGION_URLS.eu; }; //#endregion //#region src/sbFetch.ts var SbFetch = class { baseURL; timeout; headers; responseInterceptor; fetch; ejectInterceptor; url; parameters; fetchOptions; constructor($c) { this.baseURL = $c.baseURL; this.headers = $c.headers || new Headers(); this.timeout = $c?.timeout ? $c.timeout * 1e3 : 0; this.responseInterceptor = $c.responseInterceptor; this.fetch = (...args) => $c.fetch ? $c.fetch(...args) : fetch(...args); this.ejectInterceptor = false; this.url = ""; this.parameters = {}; this.fetchOptions = {}; } /** * * @param url string * @param params ISbStoriesParams * @returns Promise<ISbResponse | Error> */ get(url, params) { this.url = url; this.parameters = params; return this._methodHandler("get"); } post(url, params) { this.url = url; this.parameters = params; return this._methodHandler("post"); } put(url, params) { this.url = url; this.parameters = params; return this._methodHandler("put"); } delete(url, params) { this.url = url; this.parameters = params ?? {}; return this._methodHandler("delete"); } async _responseHandler(res) { const headers = []; const response = { data: {}, headers: {}, status: 0, statusText: "" }; if (res.status !== 204) await res.json().then(($r) => { response.data = $r; }); for (const pair of res.headers.entries()) headers[pair[0]] = pair[1]; response.headers = { ...headers }; response.status = res.status; response.statusText = res.statusText; return response; } async _methodHandler(method) { let urlString = `${this.baseURL}${this.url}`; let body = null; if (method === "get") urlString = `${this.baseURL}${this.url}?${stringify(this.parameters)}`; else body = JSON.stringify(this.parameters); const url = new URL(urlString); const controller = new AbortController(); const { signal } = controller; let timeout; if (this.timeout) timeout = setTimeout(() => controller.abort(), this.timeout); try { const fetchResponse = await this.fetch(`${url}`, { method, headers: this.headers, body, signal, ...this.fetchOptions }); if (this.timeout) clearTimeout(timeout); const response = await this._responseHandler(fetchResponse); if (this.responseInterceptor && !this.ejectInterceptor) return this._statusHandler(this.responseInterceptor(response)); else return this._statusHandler(response); } catch (err) { const error = { message: err }; return error; } } setFetchOptions(fetchOptions = {}) { if (Object.keys(fetchOptions).length > 0 && "method" in fetchOptions) delete fetchOptions.method; this.fetchOptions = { ...fetchOptions }; } eject() { this.ejectInterceptor = true; } /** * Normalizes error messages from different response structures * @param data The response data that might contain error information * @returns A normalized error message string */ _normalizeErrorMessage(data) { if (Array.isArray(data)) return data[0] || "Unknown error"; if (data && typeof data === "object") { if (data.error) return data.error; for (const key in data) { if (Array.isArray(data[key])) return `${key}: ${data[key][0]}`; if (typeof data[key] === "string") return `${key}: ${data[key]}`; } if (data.slug) return data.slug; } return "Unknown error"; } _statusHandler(res) { const statusOk = /20[0-6]/g; return new Promise((resolve, reject) => { if (statusOk.test(`${res.status}`)) return resolve(res); const error = { message: this._normalizeErrorMessage(res.data), status: res.status, response: res }; reject(error); }); } }; var sbFetch_default = SbFetch; //#endregion //#region src/constants.ts const STORYBLOK_AGENT = "SB-Agent"; const STORYBLOK_JS_CLIENT_AGENT = { defaultAgentName: "SB-JS-CLIENT", defaultAgentVersion: "SB-Agent-Version", packageVersion: "7.0.0" }; const StoryblokContentVersion = { DRAFT: "draft", PUBLISHED: "published" }; const StoryblokContentVersionValues = Object.values(StoryblokContentVersion); //#endregion //#region src/index.ts let memory = {}; const cacheVersions = {}; var Storyblok = class { client; maxRetries; retriesDelay; throttle; accessToken; cache; resolveCounter; relations; links; version; /** * @deprecated This property is deprecated. Use the standalone `richTextResolver` from `@storyblok/richtext` instead. * @see https://github.com/storyblok/richtext */ richTextResolver; resolveNestedRelations; stringifiedStoriesCache; inlineAssets; /** * * @param config ISbConfig interface * @param pEndpoint string, optional */ constructor(config, pEndpoint) { let endpoint = config.endpoint || pEndpoint; if (!endpoint) { const protocol = config.https === false ? "http" : "https"; if (!config.oauthToken) endpoint = `${protocol}://${getRegionURL(config.region)}/v2`; else endpoint = `${protocol}://${getRegionURL(config.region)}/v1`; } const headers = new Headers(); headers.set("Content-Type", "application/json"); headers.set("Accept", "application/json"); if (config.headers) { const entries = config.headers.constructor.name === "Headers" ? config.headers.entries().toArray() : Object.entries(config.headers); entries.forEach(([key, value]) => { headers.set(key, value); }); } if (!headers.has(STORYBLOK_AGENT)) { headers.set(STORYBLOK_AGENT, STORYBLOK_JS_CLIENT_AGENT.defaultAgentName); headers.set(STORYBLOK_JS_CLIENT_AGENT.defaultAgentVersion, STORYBLOK_JS_CLIENT_AGENT.packageVersion); } let rateLimit = 5; if (config.oauthToken) { headers.set("Authorization", config.oauthToken); rateLimit = 3; } if (config.rateLimit) rateLimit = config.rateLimit; this.maxRetries = config.maxRetries || 10; this.retriesDelay = 300; this.throttle = throttlePromise_default(this.throttledRequest.bind(this), rateLimit, 1e3); this.accessToken = config.accessToken || ""; this.relations = {}; this.links = {}; this.cache = config.cache || { clear: "manual" }; this.resolveCounter = 0; this.resolveNestedRelations = config.resolveNestedRelations || true; this.stringifiedStoriesCache = {}; this.version = config.version || StoryblokContentVersion.PUBLISHED; this.inlineAssets = config.inlineAssets || false; this.client = new sbFetch_default({ baseURL: endpoint, timeout: config.timeout || 0, headers, responseInterceptor: config.responseInterceptor, fetch: config.fetch }); } parseParams(params) { if (!params.token) params.token = this.getToken(); if (!params.cv) params.cv = cacheVersions[params.token]; if (Array.isArray(params.resolve_relations)) params.resolve_relations = params.resolve_relations.join(","); if (typeof params.resolve_relations !== "undefined") params.resolve_level = 2; return params; } factoryParamOptions(url, params) { if (isCDNUrl(url)) return this.parseParams(params); return params; } makeRequest(url, params, per_page, page, fetchOptions) { const query = this.factoryParamOptions(url, getOptionsPage(params, per_page, page)); return this.cacheResponse(url, query, void 0, fetchOptions); } get(slug, params = {}, fetchOptions) { if (!params) params = {}; const url = `/${slug}`; if (isCDNUrl(url)) params.version = params.version || this.version; const query = this.factoryParamOptions(url, params); return this.cacheResponse(url, query, void 0, fetchOptions); } async getAll(slug, params = {}, entity, fetchOptions) { const perPage = params?.per_page || 25; const url = `/${slug}`.replace(/\/$/, ""); const e = entity ?? url.substring(url.lastIndexOf("/") + 1); params.version = params.version || this.version; const firstPage = 1; const firstRes = await this.makeRequest(url, params, perPage, firstPage, fetchOptions); const lastPage = firstRes.total ? Math.ceil(firstRes.total / (firstRes.perPage || perPage)) : 1; const restRes = await asyncMap(range(firstPage, lastPage), (i) => { return this.makeRequest(url, params, perPage, i + 1, fetchOptions); }); return flatMap([firstRes, ...restRes], (res) => Object.values(res.data[e])); } post(slug, params = {}, fetchOptions) { const url = `/${slug}`; return this.throttle("post", url, params, fetchOptions); } put(slug, params = {}, fetchOptions) { const url = `/${slug}`; return this.throttle("put", url, params, fetchOptions); } delete(slug, params = {}, fetchOptions) { if (!params) params = {}; const url = `/${slug}`; return this.throttle("delete", url, params, fetchOptions); } getStories(params = {}, fetchOptions) { this._addResolveLevel(params); return this.get("cdn/stories", params, fetchOptions); } getStory(slug, params = {}, fetchOptions) { this._addResolveLevel(params); return this.get(`cdn/stories/${slug}`, params, fetchOptions); } getToken() { return this.accessToken; } ejectInterceptor() { this.client.eject(); } _addResolveLevel(params) { if (typeof params.resolve_relations !== "undefined") params.resolve_level = 2; } _cleanCopy(value) { return JSON.parse(JSON.stringify(value)); } _insertLinks(jtree, treeItem, resolveId) { const node = jtree[treeItem]; if (node && node.fieldtype === "multilink" && node.linktype === "story" && typeof node.id === "string" && this.links[resolveId][node.id]) node.story = this._cleanCopy(this.links[resolveId][node.id]); else if (node && node.linktype === "story" && typeof node.uuid === "string" && this.links[resolveId][node.uuid]) node.story = this._cleanCopy(this.links[resolveId][node.uuid]); } /** * * @param resolveId A counter number as a string * @param uuid The uuid of the story * @returns string | object */ getStoryReference(resolveId, uuid) { const result = this.relations[resolveId][uuid] ? JSON.parse(this.stringifiedStoriesCache[uuid] || JSON.stringify(this.relations[resolveId][uuid])) : uuid; return result; } /** * Resolves a field's value by replacing UUIDs with their corresponding story references * @param jtree - The JSON tree object containing the field to resolve * @param treeItem - The key of the field to resolve * @param resolveId - The unique identifier for the current resolution context * * This method handles both single string UUIDs and arrays of UUIDs: * - For single strings: directly replaces the UUID with the story reference * - For arrays: maps through each UUID and replaces with corresponding story references */ _resolveField(jtree, treeItem, resolveId) { const item = jtree[treeItem]; if (typeof item === "string") jtree[treeItem] = this.getStoryReference(resolveId, item); else if (Array.isArray(item)) jtree[treeItem] = item.map((uuid) => this.getStoryReference(resolveId, uuid)).filter(Boolean); } /** * Inserts relations into the JSON tree by resolving references * @param jtree - The JSON tree object to process * @param treeItem - The current field being processed * @param fields - The relation patterns to resolve (string or array of strings) * @param resolveId - The unique identifier for the current resolution context * * This method handles two types of relation patterns: * 1. Nested relations: matches fields that end with the current field name * Example: If treeItem is "event_type", it matches patterns like "*.event_type" * * 2. Direct component relations: matches exact component.field patterns * Example: "event.event_type" for component "event" and field "event_type" * * The method supports both string and array formats for the fields parameter, * allowing flexible specification of relation patterns. */ _insertRelations(jtree, treeItem, fields, resolveId) { const fieldPattern = Array.isArray(fields) ? fields.find((f) => f.endsWith(`.${treeItem}`)) : fields.endsWith(`.${treeItem}`); if (fieldPattern) { this._resolveField(jtree, treeItem, resolveId); return; } const fieldPath = jtree.component ? `${jtree.component}.${treeItem}` : treeItem; if (Array.isArray(fields) ? fields.includes(fieldPath) : fields === fieldPath) this._resolveField(jtree, treeItem, resolveId); } /** * Recursively traverses and resolves relations in the story content tree * @param story - The story object containing the content to process * @param fields - The relation patterns to resolve * @param resolveId - The unique identifier for the current resolution context */ iterateTree(story, fields, resolveId) { const enrich = (jtree, path = "") => { if (!jtree || jtree._stopResolving) return; if (Array.isArray(jtree)) jtree.forEach((item, index) => enrich(item, `${path}[${index}]`)); else if (typeof jtree === "object") for (const key in jtree) { const newPath = path ? `${path}.${key}` : key; if (jtree.component && jtree._uid || jtree.type === "link") { this._insertRelations(jtree, key, fields, resolveId); this._insertLinks(jtree, key, resolveId); } enrich(jtree[key], newPath); } }; enrich(story.content); } async resolveLinks(responseData, params, resolveId) { let links = []; if (responseData.link_uuids) { const relSize = responseData.link_uuids.length; const chunks = []; const chunkSize = 50; for (let i = 0; i < relSize; i += chunkSize) { const end = Math.min(relSize, i + chunkSize); chunks.push(responseData.link_uuids.slice(i, end)); } for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { const linksRes = await this.getStories({ per_page: chunkSize, language: params.language, version: params.version, starts_with: params.starts_with, by_uuids: chunks[chunkIndex].join(",") }); linksRes.data.stories.forEach((rel) => { links.push(rel); }); } } else links = responseData.links; links.forEach((story) => { this.links[resolveId][story.uuid] = { ...story, _stopResolving: true }; }); } async resolveRelations(responseData, params, resolveId) { let relations = []; if (responseData.rel_uuids) { const relSize = responseData.rel_uuids.length; const chunks = []; const chunkSize = 50; for (let i = 0; i < relSize; i += chunkSize) { const end = Math.min(relSize, i + chunkSize); chunks.push(responseData.rel_uuids.slice(i, end)); } for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { const relationsRes = await this.getStories({ per_page: chunkSize, language: params.language, version: params.version, starts_with: params.starts_with, by_uuids: chunks[chunkIndex].join(","), excluding_fields: params.excluding_fields }); relationsRes.data.stories.forEach((rel) => { relations.push(rel); }); } if (relations.length > 0) { responseData.rels = relations; delete responseData.rel_uuids; } } else relations = responseData.rels; if (relations && relations.length > 0) relations.forEach((story) => { this.relations[resolveId][story.uuid] = { ...story, _stopResolving: true }; }); } /** * * @param responseData * @param params * @param resolveId * @description Resolves the relations and links of the stories * @returns Promise<void> * */ async resolveStories(responseData, params, resolveId) { let relationParams = []; this.links[resolveId] = {}; this.relations[resolveId] = {}; if (typeof params.resolve_relations !== "undefined" && params.resolve_relations.length > 0) { if (typeof params.resolve_relations === "string") relationParams = params.resolve_relations.split(","); await this.resolveRelations(responseData, params, resolveId); } if (params.resolve_links && [ "1", "story", "url", "link" ].includes(params.resolve_links) && (responseData.links?.length || responseData.link_uuids?.length)) await this.resolveLinks(responseData, params, resolveId); if (this.resolveNestedRelations) for (const relUuid in this.relations[resolveId]) this.iterateTree(this.relations[resolveId][relUuid], relationParams, resolveId); if (responseData.story) this.iterateTree(responseData.story, relationParams, resolveId); else responseData.stories.forEach((story) => { this.iterateTree(story, relationParams, resolveId); }); this.stringifiedStoriesCache = {}; delete this.links[resolveId]; delete this.relations[resolveId]; } async cacheResponse(url, params, retries, fetchOptions) { const cacheKey = stringify({ url, params }); const provider = this.cacheProvider(); if (params.version === "published" && url !== "/cdn/spaces/me") { const cache = await provider.get(cacheKey); if (cache) return Promise.resolve(cache); } return new Promise(async (resolve, reject) => { try { const res = await this.throttle("get", url, params, fetchOptions); if (res.status !== 200) return reject(res); let response = { data: res.data, headers: res.headers }; if (res.headers?.["per-page"]) response = Object.assign({}, response, { perPage: res.headers["per-page"] ? Number.parseInt(res.headers["per-page"]) : 0, total: res.headers["per-page"] ? Number.parseInt(res.headers.total) : 0 }); if (response.data.story || response.data.stories) { const resolveId = this.resolveCounter = ++this.resolveCounter % 1e3; await this.resolveStories(response.data, params, `${resolveId}`); response = await this.processInlineAssets(response); } if (params.version === "published" && url !== "/cdn/spaces/me") await provider.set(cacheKey, response); const isCacheClearable = this.cache.clear === "onpreview" && params.version === "draft" || this.cache.clear === "auto"; if (params.token && response.data.cv) { if (isCacheClearable && cacheVersions[params.token] && cacheVersions[params.token] !== response.data.cv) await this.flushCache(); cacheVersions[params.token] = response.data.cv; } return resolve(response); } catch (error) { if (error.response && error.status === 429) { retries = typeof retries === "undefined" ? 0 : retries + 1; if (retries < this.maxRetries) { console.log(`Hit rate limit. Retrying in ${this.retriesDelay / 1e3} seconds.`); await delay(this.retriesDelay); return this.cacheResponse(url, params, retries).then(resolve).catch(reject); } } reject(error); } }); } throttledRequest(type, url, params, fetchOptions) { this.client.setFetchOptions(fetchOptions); return this.client[type](url, params); } cacheVersions() { return cacheVersions; } cacheVersion() { return cacheVersions[this.accessToken]; } setCacheVersion(cv) { if (this.accessToken) cacheVersions[this.accessToken] = cv; } clearCacheVersion() { if (this.accessToken) cacheVersions[this.accessToken] = 0; } cacheProvider() { switch (this.cache.type) { case "memory": return { get(key) { return Promise.resolve(memory[key]); }, getAll() { return Promise.resolve(memory); }, set(key, content) { memory[key] = content; return Promise.resolve(void 0); }, flush() { memory = {}; return Promise.resolve(void 0); } }; case "custom": if (this.cache.custom) return this.cache.custom; default: return { get() { return Promise.resolve(); }, getAll() { return Promise.resolve(void 0); }, set() { return Promise.resolve(void 0); }, flush() { return Promise.resolve(void 0); } }; } } async flushCache() { await this.cacheProvider().flush(); this.clearCacheVersion(); return this; } async processInlineAssets(response) { if (!this.inlineAssets) return response; const processNode = (node) => { if (!node || typeof node !== "object") return node; if (Array.isArray(node)) return node.map((item) => processNode(item)); let processedNode = { ...node }; if (processedNode.fieldtype === "asset" && Array.isArray(response.data.assets)) processedNode = { ...processedNode, ...response.data.assets.find((asset) => asset.id === processedNode.id) }; for (const key in processedNode) if (typeof processedNode[key] === "object") processedNode[key] = processNode(processedNode[key]); return processedNode; }; if (response.data.story) response.data.story.content = processNode(response.data.story.content); if (response.data.stories) response.data.stories = response.data.stories.map((story) => { story.content = processNode(story.content); return story; }); return response; } }; var src_default = Storyblok; //#endregion export { Storyblok, src_default as default }; //# sourceMappingURL=index.js.map