UNPKG

storyblok-js-client

Version:
881 lines (761 loc) 25.1 kB
import throttledQueue from './throttlePromise'; import { asyncMap, delay, flatMap, getOptionsPage, getRegionURL, isCDNUrl, range, stringify, } from './utils'; import SbFetch from './sbFetch'; import type Method from './constants'; import type { StoryblokContentVersionKeys } from './constants'; import { STORYBLOK_AGENT, STORYBLOK_JS_CLIENT_AGENT, StoryblokContentVersion } from './constants'; import type { ICacheProvider, IMemoryType, ISbCache, ISbComponentType, ISbConfig, ISbContentMangmntAPI, ISbCustomFetch, ISbField, ISbLinksParams, ISbLinksResult, ISbLinkURLObject, ISbResponse, ISbResponseData, ISbResult, ISbStories, ISbStoriesParams, ISbStory, ISbStoryData, ISbStoryParams, } from './interfaces'; export * from './interfaces'; let memory: Partial<IMemoryType> = {}; const cacheVersions = {} as CachedVersions; interface CachedVersions { [key: string]: number; } interface LinksType { [key: string]: any; } interface RelationsType { [key: string]: any; } interface ISbFlatMapped { data: any; } const _VERSION = { V1: 'v1', V2: 'v2', } as const; type ObjectValues<T> = T[keyof T]; type Version = ObjectValues<typeof _VERSION>; export class Storyblok { private client: SbFetch; private maxRetries: number; private retriesDelay: number; private throttle: ReturnType<typeof throttledQueue>; private accessToken: string; private cache: ISbCache; private resolveCounter: number; public relations: RelationsType; public links: LinksType; public version: StoryblokContentVersionKeys | undefined; /** * @deprecated This property is deprecated. Use the standalone `richTextResolver` from `@storyblok/richtext` instead. * @see https://github.com/storyblok/richtext */ public richTextResolver: unknown; public resolveNestedRelations: boolean; private stringifiedStoriesCache: Record<string, string>; private inlineAssets: boolean; /** * * @param config ISbConfig interface * @param pEndpoint string, optional */ public constructor(config: ISbConfig, pEndpoint?: string) { let endpoint = config.endpoint || pEndpoint; if (!endpoint) { const protocol = config.https === false ? 'http' : 'https'; if (!config.oauthToken) { endpoint = `${protocol}://${getRegionURL(config.region)}/${'v2' as Version}`; } else { endpoint = `${protocol}://${getRegionURL(config.region)}/${'v1' as Version}`; } } const headers: 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]: [string, string]) => { 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; // per second for cdn api if (config.oauthToken) { headers.set('Authorization', config.oauthToken); rateLimit = 3; // per second for management api } if (config.rateLimit) { rateLimit = config.rateLimit; } this.maxRetries = config.maxRetries || 10; this.retriesDelay = 300; this.throttle = throttledQueue( this.throttledRequest.bind(this), rateLimit, 1000, ); this.accessToken = config.accessToken || ''; this.relations = {} as RelationsType; this.links = {} as LinksType; this.cache = config.cache || { clear: 'manual' }; this.resolveCounter = 0; this.resolveNestedRelations = config.resolveNestedRelations || true; this.stringifiedStoriesCache = {} as Record<string, string>; this.version = config.version || StoryblokContentVersion.PUBLISHED; // the default version is published as per API documentation this.inlineAssets = config.inlineAssets || false; this.client = new SbFetch({ baseURL: endpoint, timeout: config.timeout || 0, headers, responseInterceptor: config.responseInterceptor, fetch: config.fetch, }); } private parseParams(params: ISbStoriesParams): ISbStoriesParams { 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; } private factoryParamOptions( url: string, params: ISbStoriesParams, ): ISbStoriesParams { if (isCDNUrl(url)) { return this.parseParams(params); } return params; } private makeRequest( url: string, params: ISbStoriesParams, per_page: number, page: number, fetchOptions?: ISbCustomFetch, ): Promise<ISbResult> { const query = this.factoryParamOptions( url, getOptionsPage(params, per_page, page), ); return this.cacheResponse(url, query, undefined, fetchOptions); } public get( slug: 'cdn/links', params?: ISbLinksParams, fetchOptions?: ISbCustomFetch ): Promise<ISbLinksResult>; public get( slug: string, params?: ISbStoriesParams, fetchOptions?: ISbCustomFetch ): Promise<ISbResult>; public get( slug: string, params: ISbStoriesParams | ISbLinksParams = {}, fetchOptions?: ISbCustomFetch, ): Promise<ISbResult | ISbLinksResult> { if (!params) { params = {} as ISbStoriesParams; } const url = `/${slug}`; // Only add version parameter for CDN URLs if (isCDNUrl(url)) { params.version = params.version || this.version; } const query = this.factoryParamOptions(url, params); return this.cacheResponse(url, query, undefined, fetchOptions); } public async getAll( slug: string, params: ISbStoriesParams = {}, entity?: string, fetchOptions?: ISbCustomFetch, ): Promise<any[]> { 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: any = await asyncMap( range(firstPage, lastPage), (i: number) => { return this.makeRequest(url, params, perPage, i + 1, fetchOptions); }, ); return flatMap([firstRes, ...restRes], (res: ISbFlatMapped) => Object.values(res.data[e])); } public post( slug: string, params: ISbStoriesParams | ISbContentMangmntAPI = {}, fetchOptions?: ISbCustomFetch, ): Promise<ISbResponseData> { const url = `/${slug}`; return this.throttle('post', url, params, fetchOptions) as Promise<ISbResponseData>; } public put( slug: string, params: ISbStoriesParams | ISbContentMangmntAPI = {}, fetchOptions?: ISbCustomFetch, ): Promise<ISbResponseData> { const url = `/${slug}`; return this.throttle('put', url, params, fetchOptions) as Promise<ISbResponseData>; } public delete( slug: string, params: ISbStoriesParams | ISbContentMangmntAPI = {}, fetchOptions?: ISbCustomFetch, ): Promise<ISbResponseData> { if (!params) { params = {} as ISbStoriesParams; } const url = `/${slug}`; return this.throttle('delete', url, params, fetchOptions) as Promise<ISbResponseData>; } public getStories( params: ISbStoriesParams = {}, fetchOptions?: ISbCustomFetch, ): Promise<ISbStories> { this._addResolveLevel(params); return this.get('cdn/stories', params, fetchOptions); } public getStory( slug: string, params: ISbStoryParams = {}, fetchOptions?: ISbCustomFetch, ): Promise<ISbStory> { this._addResolveLevel(params); return this.get(`cdn/stories/${slug}`, params, fetchOptions); } private getToken(): string { return this.accessToken; } public ejectInterceptor(): void { this.client.eject(); } private _addResolveLevel(params: ISbStoriesParams | ISbStoryParams): void { if (typeof params.resolve_relations !== 'undefined') { params.resolve_level = 2; } } private _cleanCopy(value: LinksType): JSON { return JSON.parse(JSON.stringify(value)); } private _insertLinks( jtree: ISbStoriesParams, treeItem: keyof ISbStoriesParams, resolveId: string, ): void { 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 */ private getStoryReference(resolveId: string, uuid: string): string | JSON { 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 */ private _resolveField( jtree: ISbStoriesParams, treeItem: keyof ISbStoriesParams, resolveId: string, ): void { 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. */ private _insertRelations( jtree: ISbStoriesParams, treeItem: keyof ISbStoriesParams, fields: string | string[], resolveId: string, ): void { // Check for nested relations (e.g., "*.event_type" or "spots.event_type") const fieldPattern = Array.isArray(fields) ? fields.find(f => f.endsWith(`.${treeItem}`)) : fields.endsWith(`.${treeItem}`); if (fieldPattern) { // If we found a matching pattern, resolve this field this._resolveField(jtree, treeItem, resolveId); return; } // If no nested pattern matched, check for direct component.field pattern // e.g., "event.event_type" for a field within its immediate parent component const fieldPath = jtree.component ? `${jtree.component}.${treeItem}` : treeItem; // Check if this exact pattern exists in the fields to resolve 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 */ private iterateTree( story: ISbStoryData, fields: string | Array<string>, resolveId: string, ): void { // Internal recursive function to process each node in the tree const enrich = (jtree: ISbStoriesParams | any, path = '') => { // Skip processing if node is null/undefined or marked to stop resolving if (!jtree || jtree._stopResolving) { return; } // Handle arrays by recursively processing each element // Maintains path context by adding array indices if (Array.isArray(jtree)) { jtree.forEach((item, index) => enrich(item, `${path}[${index}]`)); } // Handle object nodes else if (typeof jtree === 'object') { // Process each property in the object for (const key in jtree) { // Build the current path for the context const newPath = path ? `${path}.${key}` : key; // If this is a component (has component and _uid) or a link, // attempt to resolve its relations and links if ((jtree.component && jtree._uid) || jtree.type === 'link') { this._insertRelations(jtree, key as keyof ISbStoriesParams, fields, resolveId); this._insertLinks(jtree, key as keyof ISbStoriesParams, resolveId); } // Continue traversing deeper into the tree // This ensures we process nested components and their relations enrich(jtree[key], newPath); } } }; // Start the traversal from the story's content enrich(story.content); } private async resolveLinks( responseData: ISbResponseData, params: ISbStoriesParams, resolveId: string, ): Promise<void> { let links: (ISbStoryData | ISbLinkURLObject | string)[] = []; 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: ISbStoryData | ISbLinkURLObject | string) => { links.push(rel); }, ); } } else { links = responseData.links; } links.forEach((story: ISbStoryData | any) => { this.links[resolveId][story.uuid] = { ...story, ...{ _stopResolving: true }, }; }); } private async resolveRelations( responseData: ISbResponseData, params: ISbStoriesParams, resolveId: string, ): Promise<void> { let relations: ISbStoryData<ISbComponentType<string> & { [index: string]: any }>[] = []; 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: ISbStoryData) => { relations.push(rel); }); } // Replace rel_uuids with the fully resolved stories and clear it if (relations.length > 0) { responseData.rels = relations; delete responseData.rel_uuids; } } else { relations = responseData.rels; } if (relations && relations.length > 0) { relations.forEach((story: ISbStoryData) => { 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> * */ private async resolveStories( responseData: ISbResponseData, params: ISbStoriesParams, resolveId: string, ): Promise<void> { let relationParams: string[] = []; 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: ISbStoryData) => { this.iterateTree(story, relationParams, resolveId); }); } this.stringifiedStoriesCache = {}; delete this.links[resolveId]; delete this.relations[resolveId]; } private async cacheResponse( url: string, params: ISbStoriesParams, retries?: number, fetchOptions?: ISbCustomFetch, ): Promise<ISbResult> { 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, )) as ISbResponse; if (res.status !== 200) { return reject(res); } let response = { data: res.data, headers: res.headers } as ISbResult; 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 % 1000); 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] // there is a cache && cacheVersions[params.token] !== response.data.cv // a new cv is incoming ) { await this.flushCache(); } cacheVersions[params.token] = response.data.cv; } return resolve(response); } catch (error: Error | any) { if (error.response && error.status === 429) { retries = typeof retries === 'undefined' ? 0 : retries + 1; if (retries < this.maxRetries) { // eslint-disable-next-line no-console console.log( `Hit rate limit. Retrying in ${this.retriesDelay / 1000} seconds.`, ); await delay(this.retriesDelay); return this.cacheResponse(url, params, retries) .then(resolve) .catch(reject); } } reject(error); } }); } private throttledRequest( type: Method, url: string, params: ISbStoriesParams, fetchOptions?: ISbCustomFetch, ): Promise<unknown> { this.client.setFetchOptions(fetchOptions); return this.client[type](url, params); } public cacheVersions(): CachedVersions { return cacheVersions; } public cacheVersion(): number { return cacheVersions[this.accessToken]; } public setCacheVersion(cv: number): void { if (this.accessToken) { cacheVersions[this.accessToken] = cv; } } public clearCacheVersion(): void { if (this.accessToken) { cacheVersions[this.accessToken] = 0; } } public cacheProvider(): ICacheProvider { switch (this.cache.type) { case 'memory': return { get(key: string) { return Promise.resolve(memory[key]); }, getAll() { return Promise.resolve(memory as IMemoryType); }, set(key: string, content: ISbResult) { memory[key] = content; return Promise.resolve(undefined); }, flush() { memory = {}; return Promise.resolve(undefined); }, }; case 'custom': if (this.cache.custom) { return this.cache.custom; } // eslint-disable-next-line no-fallthrough default: return { get() { return Promise.resolve(); }, getAll() { return Promise.resolve(undefined); }, set() { return Promise.resolve(undefined); }, flush() { return Promise.resolve(undefined); }, }; } } public async flushCache(): Promise<this> { await this.cacheProvider().flush(); this.clearCacheVersion(); return this; } private async processInlineAssets(response: ISbResult): Promise<ISbResult> { if (!this.inlineAssets) { return response; } const processNode = (node: ISbField): unknown => { 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)) { // Enrich the asset with an actual asset object processedNode = { ...response.data.assets.find((asset: any) => asset.id === processedNode.id), ...processedNode, }; } // Recursively process all properties for (const key in processedNode) { if (typeof processedNode[key] === 'object') { processedNode[key] = processNode(processedNode[key] as ISbField); } } return processedNode; }; // Process the story content if (response.data.story) { response.data.story.content = processNode(response.data.story.content); } // Process all stories if present if (response.data.stories) { response.data.stories = response.data.stories.map((story: any) => { story.content = processNode(story.content); return story; }); } return response; } } export default Storyblok;