storyblok-js-client
Version:
Universal JavaScript SDK for Storyblok's API
622 lines (621 loc) • 20.5 kB
JavaScript
/**
* name: storyblok-js-client
* (c) 2025
* description: Universal JavaScript SDK for Storyblok's API
* author: Alexander Feiglstorfer <delooks@gmail.com>
*/
var b = Object.defineProperty;
var A = (c, e, t) => e in c ? b(c, e, { enumerable: !0, configurable: !0, writable: !0, value: t }) : c[e] = t;
var u = (c, e, t) => A(c, typeof e != "symbol" ? e + "" : e, t);
class w extends Error {
constructor(e) {
super(e), this.name = "AbortError";
}
}
function T(c, e, t) {
if (!Number.isFinite(e))
throw new TypeError("Expected `limit` to be a finite number");
if (!Number.isFinite(t))
throw new TypeError("Expected `interval` to be a finite number");
const i = [];
let s = [], r = 0, n = !1;
const o = async () => {
r++;
const a = i.shift();
if (a)
try {
const d = await c(...a.args);
a.resolve(d);
} catch (d) {
a.reject(d);
}
const h = setTimeout(() => {
r--, i.length > 0 && o(), s = s.filter((d) => d !== h);
}, t);
s.includes(h) || s.push(h);
}, l = (...a) => n ? Promise.reject(
new Error(
"Throttled function is already aborted and not accepting new promises"
)
) : new Promise((h, d) => {
i.push({
resolve: h,
reject: d,
args: a
}), r < e && o();
});
return l.abort = () => {
n = !0, s.forEach(clearTimeout), s = [], i.forEach(
(a) => a.reject(() => new w("Throttle function aborted"))
), i.length = 0;
}, l;
}
const S = (c = "") => c.includes("/cdn/"), P = (c, e = 25, t = 1) => ({
...c,
per_page: e,
page: t
}), C = (c) => new Promise((e) => setTimeout(e, c)), $ = (c = 0, e) => Array.from({ length: c }, e), E = (c = 0, e = c) => {
const t = Math.abs(e - c) || 0, i = c < e ? 1 : -1;
return $(t, (s, r) => r * i + c);
}, L = async (c, e) => Promise.all(c.map(e)), O = (c = [], e) => c.map(e).reduce((t, i) => [...t, ...i], []), k = (c, e, t) => {
const i = [];
for (const s in c) {
if (!Object.prototype.hasOwnProperty.call(c, s))
continue;
const r = c[s];
if (r == null)
continue;
const n = t ? "" : encodeURIComponent(s);
let o;
typeof r == "object" ? o = k(
r,
e ? e + encodeURIComponent(`[${n}]`) : n,
Array.isArray(r)
) : o = `${e ? e + encodeURIComponent(`[${n}]`) : n}=${encodeURIComponent(r)}`, i.push(o);
}
return i.join("&");
}, v = (c) => {
const e = {
eu: "api.storyblok.com",
us: "api-us.storyblok.com",
cn: "app.storyblokchina.cn",
ap: "api-ap.storyblok.com",
ca: "api-ca.storyblok.com"
};
return e[c] ?? e.eu;
}, H = (c) => {
const e = {
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'"
}, t = /[&<>"']/g, i = new RegExp(t.source);
return c && i.test(c) ? c.replace(t, (s) => e[s]) : c;
};
class N {
constructor(e) {
u(this, "baseURL");
u(this, "timeout");
u(this, "headers");
u(this, "responseInterceptor");
u(this, "fetch");
u(this, "ejectInterceptor");
u(this, "url");
u(this, "parameters");
u(this, "fetchOptions");
this.baseURL = e.baseURL, this.headers = e.headers || new Headers(), this.timeout = e != null && e.timeout ? e.timeout * 1e3 : 0, this.responseInterceptor = e.responseInterceptor, this.fetch = (...t) => e.fetch ? e.fetch(...t) : fetch(...t), this.ejectInterceptor = !1, this.url = "", this.parameters = {}, this.fetchOptions = {};
}
/**
*
* @param url string
* @param params ISbStoriesParams
* @returns Promise<ISbResponse | Error>
*/
get(e, t) {
return this.url = e, this.parameters = t, this._methodHandler("get");
}
post(e, t) {
return this.url = e, this.parameters = t, this._methodHandler("post");
}
put(e, t) {
return this.url = e, this.parameters = t, this._methodHandler("put");
}
delete(e, t) {
return this.url = e, this.parameters = t ?? {}, this._methodHandler("delete");
}
async _responseHandler(e) {
const t = [], i = {
data: {},
headers: {},
status: 0,
statusText: ""
};
e.status !== 204 && await e.json().then((s) => {
i.data = s;
});
for (const s of e.headers.entries())
t[s[0]] = s[1];
return i.headers = { ...t }, i.status = e.status, i.statusText = e.statusText, i;
}
async _methodHandler(e) {
let t = `${this.baseURL}${this.url}`, i = null;
e === "get" ? t = `${this.baseURL}${this.url}?${k(this.parameters)}` : i = JSON.stringify(this.parameters);
const s = new URL(t), r = new AbortController(), { signal: n } = r;
let o;
this.timeout && (o = setTimeout(() => r.abort(), this.timeout));
try {
const l = await this.fetch(`${s}`, {
method: e,
headers: this.headers,
body: i,
signal: n,
...this.fetchOptions
});
this.timeout && clearTimeout(o);
const a = await this._responseHandler(
l
);
return this.responseInterceptor && !this.ejectInterceptor ? this._statusHandler(this.responseInterceptor(a)) : this._statusHandler(a);
} catch (l) {
return {
message: l
};
}
}
setFetchOptions(e = {}) {
Object.keys(e).length > 0 && "method" in e && delete e.method, this.fetchOptions = { ...e };
}
eject() {
this.ejectInterceptor = !0;
}
/**
* Normalizes error messages from different response structures
* @param data The response data that might contain error information
* @returns A normalized error message string
*/
_normalizeErrorMessage(e) {
if (Array.isArray(e))
return e[0] || "Unknown error";
if (e && typeof e == "object") {
if (e.error)
return e.error;
for (const t in e) {
if (Array.isArray(e[t]))
return `${t}: ${e[t][0]}`;
if (typeof e[t] == "string")
return `${t}: ${e[t]}`;
}
if (e.slug)
return e.slug;
}
return "Unknown error";
}
_statusHandler(e) {
const t = /20[0-6]/g;
return new Promise((i, s) => {
if (t.test(`${e.status}`))
return i(e);
const r = {
message: this._normalizeErrorMessage(e.data),
status: e.status,
response: e
};
s(r);
});
}
}
const _ = "SB-Agent", g = {
defaultAgentName: "SB-JS-CLIENT",
defaultAgentVersion: "SB-Agent-Version",
packageVersion: "6.0.0"
}, R = {
DRAFT: "draft",
PUBLISHED: "published"
}, x = Object.values(
R
);
let p = {};
const f = {};
class V {
/**
*
* @param config ISbConfig interface
* @param pEndpoint string, optional
*/
constructor(e, t) {
u(this, "client");
u(this, "maxRetries");
u(this, "retriesDelay");
u(this, "throttle");
u(this, "accessToken");
u(this, "cache");
u(this, "resolveCounter");
u(this, "relations");
u(this, "links");
u(this, "version");
/**
* @deprecated This property is deprecated. Use the standalone `richTextResolver` from `@storyblok/richtext` instead.
* @see https://github.com/storyblok/richtext
*/
u(this, "richTextResolver");
u(this, "resolveNestedRelations");
u(this, "stringifiedStoriesCache");
u(this, "inlineAssets");
let i = e.endpoint || t;
if (!i) {
const n = e.https === !1 ? "http" : "https";
e.oauthToken ? i = `${n}://${v(e.region)}/v1` : i = `${n}://${v(e.region)}/v2`;
}
const s = new Headers();
s.set("Content-Type", "application/json"), s.set("Accept", "application/json"), e.headers && (e.headers.constructor.name === "Headers" ? e.headers.entries().toArray() : Object.entries(e.headers)).forEach(([o, l]) => {
s.set(o, l);
}), s.has(_) || (s.set(_, g.defaultAgentName), s.set(
g.defaultAgentVersion,
g.packageVersion
));
let r = 5;
e.oauthToken && (s.set("Authorization", e.oauthToken), r = 3), e.rateLimit && (r = e.rateLimit), this.maxRetries = e.maxRetries || 10, this.retriesDelay = 300, this.throttle = T(
this.throttledRequest.bind(this),
r,
1e3
), this.accessToken = e.accessToken || "", this.relations = {}, this.links = {}, this.cache = e.cache || { clear: "manual" }, this.resolveCounter = 0, this.resolveNestedRelations = e.resolveNestedRelations || !0, this.stringifiedStoriesCache = {}, this.version = e.version || R.DRAFT, this.inlineAssets = e.inlineAssets || !1, this.client = new N({
baseURL: i,
timeout: e.timeout || 0,
headers: s,
responseInterceptor: e.responseInterceptor,
fetch: e.fetch
});
}
parseParams(e) {
return e.token || (e.token = this.getToken()), e.cv || (e.cv = f[e.token]), Array.isArray(e.resolve_relations) && (e.resolve_relations = e.resolve_relations.join(",")), typeof e.resolve_relations < "u" && (e.resolve_level = 2), e;
}
factoryParamOptions(e, t) {
return S(e) ? this.parseParams(t) : t;
}
makeRequest(e, t, i, s, r) {
const n = this.factoryParamOptions(
e,
P(t, i, s)
);
return this.cacheResponse(e, n, void 0, r);
}
get(e, t = {}, i) {
t || (t = {});
const s = `/${e}`;
t.version = t.version || this.version;
const r = this.factoryParamOptions(s, t);
return this.cacheResponse(s, r, void 0, i);
}
async getAll(e, t = {}, i, s) {
const r = (t == null ? void 0 : t.per_page) || 25, n = `/${e}`.replace(/\/$/, ""), o = i ?? n.substring(n.lastIndexOf("/") + 1);
t.version = t.version || this.version;
const l = 1, a = await this.makeRequest(
n,
t,
r,
l,
s
), h = a.total ? Math.ceil(a.total / r) : 1, d = await L(
E(l, h),
(y) => this.makeRequest(n, t, r, y + 1, s)
);
return O([a, ...d], (y) => Object.values(y.data[o]));
}
post(e, t = {}, i) {
const s = `/${e}`;
return this.throttle("post", s, t, i);
}
put(e, t = {}, i) {
const s = `/${e}`;
return this.throttle("put", s, t, i);
}
delete(e, t = {}, i) {
t || (t = {});
const s = `/${e}`;
return this.throttle("delete", s, t, i);
}
getStories(e = {}, t) {
return this._addResolveLevel(e), this.get("cdn/stories", e, t);
}
getStory(e, t = {}, i) {
return this._addResolveLevel(t), this.get(`cdn/stories/${e}`, t, i);
}
getToken() {
return this.accessToken;
}
ejectInterceptor() {
this.client.eject();
}
_addResolveLevel(e) {
typeof e.resolve_relations < "u" && (e.resolve_level = 2);
}
_cleanCopy(e) {
return JSON.parse(JSON.stringify(e));
}
_insertLinks(e, t, i) {
const s = e[t];
s && s.fieldtype === "multilink" && s.linktype === "story" && typeof s.id == "string" && this.links[i][s.id] ? s.story = this._cleanCopy(this.links[i][s.id]) : s && s.linktype === "story" && typeof s.uuid == "string" && this.links[i][s.uuid] && (s.story = this._cleanCopy(this.links[i][s.uuid]));
}
/**
*
* @param resolveId A counter number as a string
* @param uuid The uuid of the story
* @returns string | object
*/
getStoryReference(e, t) {
return this.relations[e][t] ? JSON.parse(this.stringifiedStoriesCache[t] || JSON.stringify(this.relations[e][t])) : t;
}
/**
* 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(e, t, i) {
const s = e[t];
typeof s == "string" ? e[t] = this.getStoryReference(i, s) : Array.isArray(s) && (e[t] = s.map(
(r) => this.getStoryReference(i, r)
).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(e, t, i, s) {
if (Array.isArray(i) ? i.find((o) => o.endsWith(`.${t}`)) : i.endsWith(`.${t}`)) {
this._resolveField(e, t, s);
return;
}
const n = e.component ? `${e.component}.${t}` : t;
(Array.isArray(i) ? i.includes(n) : i === n) && this._resolveField(e, t, s);
}
/**
* 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(e, t, i) {
const s = (r, n = "") => {
if (!(!r || r._stopResolving)) {
if (Array.isArray(r))
r.forEach((o, l) => s(o, `${n}[${l}]`));
else if (typeof r == "object")
for (const o in r) {
const l = n ? `${n}.${o}` : o;
(r.component && r._uid || r.type === "link") && (this._insertRelations(r, o, t, i), this._insertLinks(r, o, i)), s(r[o], l);
}
}
};
s(e.content);
}
async resolveLinks(e, t, i) {
let s = [];
if (e.link_uuids) {
const r = e.link_uuids.length, n = [], o = 50;
for (let l = 0; l < r; l += o) {
const a = Math.min(r, l + o);
n.push(e.link_uuids.slice(l, a));
}
for (let l = 0; l < n.length; l++)
(await this.getStories({
per_page: o,
language: t.language,
version: t.version,
starts_with: t.starts_with,
by_uuids: n[l].join(",")
})).data.stories.forEach(
(h) => {
s.push(h);
}
);
} else
s = e.links;
s.forEach((r) => {
this.links[i][r.uuid] = {
...r,
_stopResolving: !0
};
});
}
async resolveRelations(e, t, i) {
let s = [];
if (e.rel_uuids) {
const r = e.rel_uuids.length, n = [], o = 50;
for (let l = 0; l < r; l += o) {
const a = Math.min(r, l + o);
n.push(e.rel_uuids.slice(l, a));
}
for (let l = 0; l < n.length; l++)
(await this.getStories({
per_page: o,
language: t.language,
version: t.version,
starts_with: t.starts_with,
by_uuids: n[l].join(","),
excluding_fields: t.excluding_fields
})).data.stories.forEach((h) => {
s.push(h);
});
s.length > 0 && (e.rels = s, delete e.rel_uuids);
} else
s = e.rels;
s && s.length > 0 && s.forEach((r) => {
this.relations[i][r.uuid] = {
...r,
_stopResolving: !0
};
});
}
/**
*
* @param responseData
* @param params
* @param resolveId
* @description Resolves the relations and links of the stories
* @returns Promise<void>
*
*/
async resolveStories(e, t, i) {
var r, n;
let s = [];
if (this.links[i] = {}, this.relations[i] = {}, typeof t.resolve_relations < "u" && t.resolve_relations.length > 0 && (typeof t.resolve_relations == "string" && (s = t.resolve_relations.split(",")), await this.resolveRelations(e, t, i)), t.resolve_links && ["1", "story", "url", "link"].includes(t.resolve_links) && ((r = e.links) != null && r.length || (n = e.link_uuids) != null && n.length) && await this.resolveLinks(e, t, i), this.resolveNestedRelations)
for (const o in this.relations[i])
this.iterateTree(
this.relations[i][o],
s,
i
);
e.story ? this.iterateTree(e.story, s, i) : e.stories.forEach((o) => {
this.iterateTree(o, s, i);
}), this.stringifiedStoriesCache = {}, delete this.links[i], delete this.relations[i];
}
async cacheResponse(e, t, i, s) {
const r = k({ url: e, params: t }), n = this.cacheProvider();
if (t.version === "published" && e !== "/cdn/spaces/me") {
const o = await n.get(r);
if (o)
return Promise.resolve(o);
}
return new Promise(async (o, l) => {
var a;
try {
const h = await this.throttle(
"get",
e,
t,
s
);
if (h.status !== 200)
return l(h);
let d = { data: h.data, headers: h.headers };
if ((a = h.headers) != null && a["per-page"] && (d = Object.assign({}, d, {
perPage: h.headers["per-page"] ? Number.parseInt(h.headers["per-page"]) : 0,
total: h.headers["per-page"] ? Number.parseInt(h.headers.total) : 0
})), d.data.story || d.data.stories) {
const m = this.resolveCounter = ++this.resolveCounter % 1e3;
await this.resolveStories(d.data, t, `${m}`), d = await this.processInlineAssets(d);
}
t.version === "published" && e !== "/cdn/spaces/me" && await n.set(r, d);
const y = this.cache.clear === "onpreview" && t.version === "draft" || this.cache.clear === "auto";
return t.token && d.data.cv && (y && f[t.token] && f[t.token] !== d.data.cv && await this.flushCache(), f[t.token] = d.data.cv), o(d);
} catch (h) {
if (h.response && h.status === 429 && (i = typeof i > "u" ? 0 : i + 1, i < this.maxRetries))
return console.log(
`Hit rate limit. Retrying in ${this.retriesDelay / 1e3} seconds.`
), await C(this.retriesDelay), this.cacheResponse(e, t, i).then(o).catch(l);
l(h);
}
});
}
throttledRequest(e, t, i, s) {
return this.client.setFetchOptions(s), this.client[e](t, i);
}
cacheVersions() {
return f;
}
cacheVersion() {
return f[this.accessToken];
}
setCacheVersion(e) {
this.accessToken && (f[this.accessToken] = e);
}
clearCacheVersion() {
this.accessToken && (f[this.accessToken] = 0);
}
cacheProvider() {
switch (this.cache.type) {
case "memory":
return {
get(e) {
return Promise.resolve(p[e]);
},
getAll() {
return Promise.resolve(p);
},
set(e, t) {
return p[e] = t, Promise.resolve(void 0);
},
flush() {
return p = {}, 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() {
return await this.cacheProvider().flush(), this.clearCacheVersion(), this;
}
async processInlineAssets(e) {
if (!this.inlineAssets)
return e;
const t = (i) => {
if (!i || typeof i != "object")
return i;
if (Array.isArray(i))
return i.map((r) => t(r));
let s = { ...i };
s.fieldtype === "asset" && Array.isArray(e.data.assets) && (s = {
...s,
...e.data.assets.find((r) => r.id === s.id)
});
for (const r in s)
typeof s[r] == "object" && (s[r] = t(s[r]));
return s;
};
return e.data.story && (e.data.story.content = t(e.data.story.content)), e.data.stories && (e.data.stories = e.data.stories.map((i) => (i.content = t(i.content), i))), e;
}
}
export {
_ as STORYBLOK_AGENT,
g as STORYBLOK_JS_CLIENT_AGENT,
N as SbFetch,
R as StoryblokContentVersion,
x as StoryblokContentVersionValues,
$ as arrayFrom,
L as asyncMap,
V as default,
C as delay,
H as escapeHTML,
O as flatMap,
P as getOptionsPage,
v as getRegionURL,
S as isCDNUrl,
E as range,
k as stringify
};