storyblok-js-client
Version:
Universal JavaScript SDK for Storyblok's API
747 lines (741 loc) • 25.5 kB
JavaScript
(function(global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory((global.StoryblokJSClient = {})));
})(this, function(exports) {
Object.defineProperty(exports, '__esModule', { value: true });
//#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
exports.Storyblok = Storyblok;
exports.default = src_default;
});
//# sourceMappingURL=index.umd.js.map