/**
* Storyblok Management API helpers.
* Paths are relative to /spaces/:space_id (space ID is appended by api()).
*/
const REGION_BASES: Record<string, string> = {
eu: "https://mapi.storyblok.com/v1",
us: "https://api-us.storyblok.com/v1",
ca: "https://api-ca.storyblok.com/v1",
ap: "https://api-ap.storyblok.com/v1",
cn: "https://app.storyblokchina.cn/v1",
};
export function getApiBase(): string {
const custom = process.env.STORYBLOK_API_BASE?.trim();
if (custom) return custom.replace(/\/$/, "");
const region = (process.env.STORYBLOK_REGION || "eu").toLowerCase();
return REGION_BASES[region] ?? REGION_BASES.eu;
}
export function requireConfig(): void {
const id = process.env.STORYBLOK_SPACE_ID;
const token = process.env.STORYBLOK_MANAGEMENT_TOKEN;
if (!id || !token) {
console.error(
"Missing env: set STORYBLOK_SPACE_ID and STORYBLOK_MANAGEMENT_TOKEN. Copy env.example to .env and fill values."
);
process.exit(1);
}
}
export function buildQuery(params: Record<string, string | number | boolean | undefined>): string {
const p = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== "") p.set(k, String(v));
}
const s = p.toString();
return s ? `?${s}` : "";
}
export type ApiFn = <T = unknown>(
path: string,
opts?: RequestInit & { token?: string; spaceId?: string; apiBase?: string }
) => Promise<T>;
async function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function createApi(): ApiFn {
const defaultSpaceId = process.env.STORYBLOK_SPACE_ID;
const defaultToken = process.env.STORYBLOK_MANAGEMENT_TOKEN;
return async function api<T>(
path: string,
opts?: RequestInit & { token?: string; spaceId?: string; apiBase?: string }
): Promise<T> {
const base = (opts?.apiBase || getApiBase()).replace(/\/$/, "");
const spaceId = opts?.spaceId || defaultSpaceId;
const token = opts?.token || defaultToken;
if (!spaceId || !token) {
throw new Error("Missing Storyblok Space ID or Management Token.");
}
const url = `${base}/spaces/${spaceId}${path}`;
let lastError: Error | null = null;
for (let attempt = 0; attempt < 3; attempt++) {
try {
const res = await fetch(url, {
...opts,
headers: {
Authorization: token,
"Content-Type": "application/json",
...(opts?.headers as Record<string, string>),
},
});
if (res.status === 429) {
const delay = Math.pow(2, attempt) * 1000;
await sleep(delay);
continue;
}
if (!res.ok) {
let errorDetail = "";
try {
const body = (await res.json()) as any;
if (body.error) errorDetail = body.error;
else if (body.message) errorDetail = body.message;
else if (typeof body === "object") errorDetail = JSON.stringify(body);
} catch {
errorDetail = await res.text();
}
if (errorDetail.includes("slug_already_exists")) {
errorDetail = "The slug is already taken by another story in this folder.";
}
throw new Error(`Storyblok API ${res.status}: ${errorDetail}`);
}
return res.json() as Promise<T>;
} catch (e: any) {
lastError = e;
if (e.message?.includes("Storyblok API") && !e.message?.includes("429")) {
throw e; // Non-retryable API error
}
}
}
throw lastError || new Error(`Failed to call Storyblok API after retries: ${path}`);
};
}
export type ToolContext = {
api: ApiFn;
requireConfig: typeof requireConfig;
buildQuery: typeof buildQuery;
};