@storyblok/api-client 0.2.3 → 1.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client.cjs +176 -0
- package/dist/client.cjs.map +1 -0
- package/dist/client.d.cts +333 -0
- package/dist/client.d.mts +333 -0
- package/dist/client.mjs +175 -0
- package/dist/client.mjs.map +1 -0
- package/dist/error.cjs.map +1 -1
- package/dist/error.d.cts +13 -2
- package/dist/error.d.mts +13 -2
- package/dist/error.mjs.map +1 -1
- package/dist/generated/datasource_entries/types.gen.d.cts +1 -16
- package/dist/generated/datasource_entries/types.gen.d.mts +1 -16
- package/dist/generated/datasources/types.gen.d.cts +1 -28
- package/dist/generated/datasources/types.gen.d.mts +1 -28
- package/dist/generated/links/types.gen.d.cts +1 -18
- package/dist/generated/links/types.gen.d.mts +1 -18
- package/dist/generated/shared/client/index.d.mts +1 -1
- package/dist/generated/shared/client/types.gen.d.cts +39 -3
- package/dist/generated/shared/client/types.gen.d.mts +39 -3
- package/dist/generated/shared/core/serverSentEvents.gen.d.cts +4 -1
- package/dist/generated/shared/core/serverSentEvents.gen.d.mts +4 -1
- package/dist/generated/shared/core/types.gen.d.cts +14 -1
- package/dist/generated/shared/core/types.gen.d.mts +14 -1
- package/dist/generated/spaces/types.gen.d.cts +1 -9
- package/dist/generated/spaces/types.gen.d.mts +1 -9
- package/dist/generated/stories/index.d.mts +1 -1
- package/dist/generated/stories/types.gen.d.cts +72 -9
- package/dist/generated/stories/types.gen.d.mts +72 -9
- package/dist/generated/tags/types.gen.d.cts +1 -16
- package/dist/generated/tags/types.gen.d.mts +1 -16
- package/dist/index.cjs +2 -153
- package/dist/index.d.cts +4 -172
- package/dist/index.d.mts +4 -172
- package/dist/index.mjs +2 -150
- package/dist/resources/datasource-entries.cjs.map +1 -1
- package/dist/resources/datasource-entries.mjs.map +1 -1
- package/dist/resources/datasources.cjs.map +1 -1
- package/dist/resources/datasources.mjs.map +1 -1
- package/dist/resources/links.cjs.map +1 -1
- package/dist/resources/links.mjs.map +1 -1
- package/dist/resources/spaces.cjs.map +1 -1
- package/dist/resources/spaces.mjs.map +1 -1
- package/dist/resources/stories.cjs +6 -4
- package/dist/resources/stories.cjs.map +1 -1
- package/dist/resources/stories.d.cts +78 -3
- package/dist/resources/stories.d.mts +79 -3
- package/dist/resources/stories.mjs +6 -4
- package/dist/resources/stories.mjs.map +1 -1
- package/dist/resources/tags.cjs.map +1 -1
- package/dist/resources/tags.mjs.map +1 -1
- package/dist/utils/inline-relations.cjs +13 -1
- package/dist/utils/inline-relations.cjs.map +1 -1
- package/dist/utils/inline-relations.mjs +13 -1
- package/dist/utils/inline-relations.mjs.map +1 -1
- package/dist/utils/rate-limit.cjs +9 -5
- package/dist/utils/rate-limit.cjs.map +1 -1
- package/dist/utils/rate-limit.d.cts +4 -18
- package/dist/utils/rate-limit.d.mts +4 -18
- package/dist/utils/rate-limit.mjs +10 -4
- package/dist/utils/rate-limit.mjs.map +1 -1
- package/package.json +18 -8
- package/dist/index.cjs.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/dist/types.d.cts +0 -37
- package/dist/types.d.mts +0 -37
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"stories.mjs","names":[],"sources":["../../src/resources/stories.ts"],"sourcesContent":["import { get, list } from '../generated/stories/sdk.gen';\nimport type { GetData, GetResponses, ListData, ListResponses } from '../generated/stories/types.gen';\nimport type {\n AssetField,\n MultilinkField,\n PluginField,\n RichtextField,\n StoryCapi,\n StoryContent,\n TableField,\n} from '../generated/stories';\nimport { inlineStoriesContent, inlineStoryContent, resolveRelationMap } from '../utils/inline-relations';\nimport type { ApiResponse, FetchOptions, ResourceDeps } from '../types';\n\ntype InlinedStoryContentField =\n | string\n | number\n | boolean\n | Array<string | AssetField | StoryContent | StoryWithInlinedRelations>\n | AssetField\n | MultilinkField\n | TableField\n | RichtextField\n | PluginField\n | StoryWithInlinedRelations\n | undefined;\n\ninterface InlinedStoryContent {\n _uid: string;\n component: string;\n _editable?: string;\n [key: string]: InlinedStoryContentField;\n}\n\nexport type StoryWithInlinedRelations = Omit<StoryCapi, 'content'> & {\n content: InlinedStoryContent;\n};\n\ntype StoryResult<InlineRelations extends boolean> = InlineRelations extends true\n ? StoryWithInlinedRelations\n : StoryCapi;\n\ntype GetResponse<InlineRelations extends boolean> = Omit<GetResponses[200], 'story'> & {\n story: StoryResult<InlineRelations>;\n};\ntype ListResponse<InlineRelations extends boolean> = Omit<ListResponses[200], 'stories'> & {\n stories: Array<StoryResult<InlineRelations>>;\n};\n\n/** Pre-resolved to avoid TypeScript emitting deep indexed-access chains that trip up DTS bundlers. */\ntype StoryIdentifier = GetData['path']['identifier'];\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\nexport interface StoriesResourceDeps extends ResourceDeps {\n inlineRelations: boolean;\n}\n\nexport function createStoriesResource<InlineRelations extends boolean>(\n deps: StoriesResourceDeps,\n) {\n const { client, requestWithCache, asApiResponse, inlineRelations, throttleManager } = deps;\n\n return {\n get: async <ThrowOnError extends boolean = false>(\n identifier: StoryIdentifier,\n options: { query?: GetData['query']; signal?: AbortSignal; throwOnError?: ThrowOnError; fetchOptions?: FetchOptions } = {},\n ): Promise<ApiResponse<GetResponse<InlineRelations>, ThrowOnError>> => {\n const { query = {}, signal, throwOnError, fetchOptions } = options;\n const resolvedQuery = typeof identifier === 'string' && UUID_RE.test(identifier) && !query.find_by\n ? { ...query, find_by: 'uuid' as const }\n : query;\n const requestPath = `/v2/cdn/stories/${identifier}`;\n type Res = ApiResponse<GetResponse<InlineRelations>, ThrowOnError>;\n return requestWithCache<GetResponse<InlineRelations>, ThrowOnError>('GET', requestPath, resolvedQuery, async (requestQuery: Record<string, unknown>): Promise<Res> => {\n const response = await throttleManager.execute(requestPath, requestQuery, () =>\n asApiResponse<GetResponse<InlineRelations>, ThrowOnError>(get({\n client,\n path: { identifier },\n query: requestQuery,\n signal,\n ...(throwOnError === undefined ? {} : { throwOnError }),\n ...(fetchOptions ? { kyOptions: { ...client.getConfig().kyOptions, ...fetchOptions } } : {}),\n })));\n\n if (!inlineRelations || response.data === undefined) {\n return response;\n }\n\n const resolved = await resolveRelationMap(response.data, requestQuery, { client, throttleManager });\n if (!resolved) {\n return response;\n }\n\n return {\n ...response,\n data: {\n ...response.data,\n story: inlineStoryContent(response.data.story, resolved.relationPaths, resolved.relationMap),\n },\n };\n }, inlineRelations ? { cacheKeyPrefix: 'inline' } : undefined);\n },\n\n list: async <ThrowOnError extends boolean = false>(\n options: { query?: ListData['query']; signal?: AbortSignal; throwOnError?: ThrowOnError; fetchOptions?: FetchOptions } = {},\n ): Promise<ApiResponse<ListResponse<InlineRelations>, ThrowOnError>> => {\n const { query = {}, signal, throwOnError, fetchOptions } = options;\n const requestPath = '/v2/cdn/stories';\n type ResAll = ApiResponse<ListResponse<InlineRelations>, ThrowOnError>;\n return requestWithCache<ListResponse<InlineRelations>, ThrowOnError>('GET', requestPath, query, async (requestQuery: Record<string, unknown>): Promise<ResAll> => {\n const response = await throttleManager.execute(requestPath, requestQuery, () =>\n asApiResponse<ListResponse<InlineRelations>, ThrowOnError>(list({\n client,\n query: requestQuery,\n signal,\n ...(throwOnError === undefined ? {} : { throwOnError }),\n ...(fetchOptions ? { kyOptions: { ...client.getConfig().kyOptions, ...fetchOptions } } : {}),\n })));\n\n if (!inlineRelations || response.data === undefined) {\n return response;\n }\n\n const resolved = await resolveRelationMap(response.data, requestQuery, { client, throttleManager });\n if (!resolved) {\n return response;\n }\n\n return {\n ...response,\n data: {\n ...response.data,\n stories: inlineStoriesContent(response.data.stories, resolved.relationPaths, resolved.relationMap),\n },\n };\n }, inlineRelations ? { cacheKeyPrefix: 'inline' } : undefined);\n },\n };\n}\n"],"mappings":";;;;AAoDA,MAAM,UAAU;AAMhB,SAAgB,sBACd,MACA;CACA,MAAM,EAAE,QAAQ,kBAAkB,eAAe,iBAAiB,oBAAoB;AAEtF,QAAO;EACL,KAAK,OACH,YACA,UAAwH,EAAE,KACrD;GACrE,MAAM,EAAE,QAAQ,EAAE,EAAE,QAAQ,cAAc,iBAAiB;GAC3D,MAAM,gBAAgB,OAAO,eAAe,YAAY,QAAQ,KAAK,WAAW,IAAI,CAAC,MAAM,UACvF;IAAE,GAAG;IAAO,SAAS;IAAiB,GACtC;GACJ,MAAM,cAAc,mBAAmB;AAEvC,UAAO,iBAA6D,OAAO,aAAa,eAAe,OAAO,iBAAwD;IACpK,MAAM,WAAW,MAAM,gBAAgB,QAAQ,aAAa,oBAC1D,cAA0D,IAAI;KAC5D;KACA,MAAM,EAAE,YAAY;KACpB,OAAO;KACP;KACA,GAAI,iBAAiB,SAAY,EAAE,GAAG,EAAE,cAAc;KACtD,GAAI,eAAe,EAAE,WAAW;MAAE,GAAG,OAAO,WAAW,CAAC;MAAW,GAAG;MAAc,EAAE,GAAG,EAAE;KAC5F,CAAC,CAAC,CAAC;AAEN,QAAI,CAAC,mBAAmB,SAAS,SAAS,OACxC,QAAO;IAGT,MAAM,WAAW,MAAM,mBAAmB,SAAS,MAAM,cAAc;KAAE;KAAQ;KAAiB,CAAC;AACnG,QAAI,CAAC,SACH,QAAO;AAGT,WAAO;KACL,GAAG;KACH,MAAM;MACJ,GAAG,SAAS;MACZ,OAAO,mBAAmB,SAAS,KAAK,OAAO,SAAS,eAAe,SAAS,YAAY;MAC7F;KACF;MACA,kBAAkB,EAAE,gBAAgB,UAAU,GAAG,OAAU;;EAGhE,MAAM,OACJ,UAAyH,EAAE,KACrD;GACtE,MAAM,EAAE,QAAQ,EAAE,EAAE,QAAQ,cAAc,iBAAiB;GAC3D,MAAM,cAAc;AAEpB,UAAO,iBAA8D,OAAO,aAAa,OAAO,OAAO,iBAA2D;IAChK,MAAM,WAAW,MAAM,gBAAgB,QAAQ,aAAa,oBAC1D,cAA2D,KAAK;KAC9D;KACA,OAAO;KACP;KACA,GAAI,iBAAiB,SAAY,EAAE,GAAG,EAAE,cAAc;KACtD,GAAI,eAAe,EAAE,WAAW;MAAE,GAAG,OAAO,WAAW,CAAC;MAAW,GAAG;MAAc,EAAE,GAAG,EAAE;KAC5F,CAAC,CAAC,CAAC;AAEN,QAAI,CAAC,mBAAmB,SAAS,SAAS,OACxC,QAAO;IAGT,MAAM,WAAW,MAAM,mBAAmB,SAAS,MAAM,cAAc;KAAE;KAAQ;KAAiB,CAAC;AACnG,QAAI,CAAC,SACH,QAAO;AAGT,WAAO;KACL,GAAG;KACH,MAAM;MACJ,GAAG,SAAS;MACZ,SAAS,qBAAqB,SAAS,KAAK,SAAS,SAAS,eAAe,SAAS,YAAY;MACnG;KACF;MACA,kBAAkB,EAAE,gBAAgB,UAAU,GAAG,OAAU;;EAEjE"}
|
|
1
|
+
{"version":3,"file":"stories.mjs","names":[],"sources":["../../src/resources/stories.ts"],"sourcesContent":["import { get, list } from '../generated/stories/sdk.gen';\nimport type { GetData, GetResponses, ListData, ListResponses } from '../generated/stories/types.gen';\nimport type {\n AssetFieldValue,\n BlokContent,\n MultilinkFieldValue,\n PluginFieldValue,\n RichtextFieldValue,\n StoryCapi,\n TableFieldValue,\n} from '../generated/stories';\nimport { inlineStoriesContent, inlineStoryContent, resolveRelationMap } from '../utils/inline-relations';\nimport type { ApiResponse, FetchOptions, ResourceDeps } from '../client';\nimport type { Story as CapiStory, Block as Component, RootBlocks as RootComponents } from '@storyblok/schema';\n\ntype InlinedStoryContentField =\n | string\n | number\n | boolean\n | Array<string | AssetFieldValue | BlokContent | StoryWithInlinedRelations>\n | AssetFieldValue\n | MultilinkFieldValue\n | TableFieldValue\n | RichtextFieldValue\n | PluginFieldValue\n | StoryWithInlinedRelations\n | undefined;\n\ninterface InlinedStoryContent {\n _uid: string;\n component: string;\n _editable?: string;\n [key: string]: InlinedStoryContentField;\n}\n\nexport type StoryWithInlinedRelations = Omit<StoryCapi, 'content'> & {\n content: InlinedStoryContent;\n};\n\n/** Splits `\"comp.field,comp2.field2\"` into a union of `{ component, field }`. */\ntype ParseRelations<T extends string> =\n T extends `${infer Comp}.${infer Field},${infer Rest}`\n ? { component: Comp; field: Field } | ParseRelations<Rest>\n : T extends `${infer Comp}.${infer Field}`\n ? { component: Comp; field: Field }\n : never;\n\n/** Extracts resolved field names for a given component name. */\ntype ResolvedFieldsFor<R extends string, ComponentName extends string> =\n Extract<ParseRelations<R>, { component: ComponentName }>['field'];\n\n/** A resolved relation: a full story typed to the component union. */\ntype ResolvedRelation<TComponents extends Component> =\n { [K in TComponents as K['name']]: CapiStory<K, TComponents> }[TComponents['name']];\n\n/**\n * Given a story type and a set of resolved field names, replaces\n * those fields with `ResolvedRelation<TComponents>` (a full story object).\n */\ntype WithResolvedRelations<\n TStory,\n TComponents extends Component,\n Fields extends string,\n> = TStory extends { content: infer C } ? Omit<TStory, 'content'> & {\n content: {\n [K in keyof C]: K extends Fields ? ResolvedRelation<TComponents> : C[K]\n };\n}\n : TStory;\n\n/**\n * Resolves to a narrowed component-derived story type when `TComponents` is a specific\n * Component union, or falls back to the generated StoryCapi / StoryWithInlinedRelations\n * when `TComponents` is the default Component base type (no type argument provided).\n *\n * When `ResolveRelations` is a string literal (e.g. `\"article.author\"`),\n * matched fields are widened from their schema type to `ResolvedRelation<TComponents>`\n * — a full story object typed to the component union.\n *\n * Uses a mapped-type approach instead of a distributive conditional with a\n * separate full-components parameter. This ensures the full `TComponents` union is\n * preserved even when DTS bundlers (like tsdown) inline the type alias —\n * a distributive conditional + default-parameter pattern would collapse\n * both parameters to the distributed single member after inlining.\n *\n * The mapped type `{ [K in TComponents as K[\"name\"]]: CapiStory<K, TComponents> }`\n * iterates each union member as `K` while keeping `TComponents` as the full union\n * for nested blok field resolution. The final indexed access\n * `[TComponents[\"name\"]]` produces the discriminated union of all story types.\n */\ntype StoryResult<\n TComponents extends Component,\n InlineRelations extends boolean,\n ResolveRelationsRaw extends string | undefined = undefined,\n> =\n Component extends TComponents\n ? InlineRelations extends true ? StoryWithInlinedRelations : StoryCapi // fallback\n : ResolveRelationsRaw extends string\n ? {\n [K in RootComponents<TComponents> as K['name']]: WithResolvedRelations<\n CapiStory<K, TComponents>,\n TComponents,\n ResolvedFieldsFor<ResolveRelationsRaw, K['name']>\n >\n }[RootComponents<TComponents>['name']]\n : CapiStory<TComponents>;\n\ntype GetResponse<\n TComponents extends Component,\n InlineRelations extends boolean,\n ResolveRelationsRaw extends string | undefined = undefined,\n> = Omit<GetResponses[200], 'story'> & {\n story: StoryResult<TComponents, InlineRelations, ResolveRelationsRaw>;\n};\ntype ListResponse<\n TComponents extends Component,\n InlineRelations extends boolean,\n ResolveRelationsRaw extends string | undefined = undefined,\n> = Omit<ListResponses[200], 'stories'> & {\n stories: Array<StoryResult<TComponents, InlineRelations, ResolveRelationsRaw>>;\n};\n\n/** Pre-resolved to avoid TypeScript emitting deep indexed-access chains that trip up DTS bundlers. */\ntype StoryIdentifier = GetData['path']['identifier'];\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\nexport interface StoriesResourceDeps<DefaultThrowOnError extends boolean = false> extends ResourceDeps<DefaultThrowOnError> {\n inlineRelations: boolean;\n}\n\nexport function createStoriesResource<\n TComponents extends Component = Component,\n InlineRelations extends boolean = false,\n DefaultThrowOnError extends boolean = false,\n>(\n deps: StoriesResourceDeps<DefaultThrowOnError>,\n) {\n const { client, requestWithCache, asApiResponse, inlineRelations, throttleManager } = deps;\n\n return {\n get: async <\n ThrowOnError extends boolean = DefaultThrowOnError,\n const ResolveRelationsStr extends string | undefined = undefined,\n >(\n identifier: StoryIdentifier,\n options: { query?: Omit<NonNullable<GetData['query']>, 'resolve_relations'> & { resolve_relations?: ResolveRelationsStr }; signal?: AbortSignal; throwOnError?: ThrowOnError; fetchOptions?: FetchOptions } = {},\n ): Promise<ApiResponse<GetResponse<TComponents, InlineRelations, ResolveRelationsStr>, ThrowOnError>> => {\n const { query = {}, signal, throwOnError, fetchOptions } = options;\n const typedQuery = (query ?? {}) as NonNullable<GetData['query']>;\n const resolvedQuery = typeof identifier === 'string' && UUID_RE.test(identifier) && !typedQuery.find_by\n ? { ...typedQuery, find_by: 'uuid' }\n : typedQuery;\n const requestPath = `/v2/cdn/stories/${identifier}`;\n return requestWithCache('GET', requestPath, resolvedQuery, async (requestQuery: Record<string, unknown>) => {\n const response = await throttleManager.execute(requestPath, requestQuery, () =>\n asApiResponse(get({\n client,\n path: { identifier },\n query: requestQuery,\n signal,\n ...(throwOnError === undefined ? {} : { throwOnError }),\n ...(fetchOptions ? { kyOptions: { ...client.getConfig().kyOptions, ...fetchOptions } } : {}),\n }))) satisfies ApiResponse<GetResponse<TComponents, InlineRelations, ResolveRelationsStr>, ThrowOnError>;\n\n if (!inlineRelations || response.data === undefined) {\n return response;\n }\n\n const resolved = await resolveRelationMap(response.data, requestQuery, { client, throttleManager });\n if (!resolved) {\n return response;\n }\n\n return {\n ...response,\n data: {\n ...response.data,\n // `inlineStoryContent` operates on raw `StoryCapi` shapes and mutates relation fields\n // from UUID strings to full story objects. We cast to satisfy its parameter type.\n story: inlineStoryContent(response.data.story as StoryCapi, resolved.relationPaths, resolved.relationMap),\n },\n };\n }, inlineRelations ? { cacheKeyPrefix: 'inline' } : undefined);\n },\n\n list: async <\n ThrowOnError extends boolean = DefaultThrowOnError,\n const ResolveRelationsStr extends string | undefined = undefined,\n >(\n options: { query?: Omit<NonNullable<ListData['query']>, 'resolve_relations'> & { resolve_relations?: ResolveRelationsStr }; signal?: AbortSignal; throwOnError?: ThrowOnError; fetchOptions?: FetchOptions } = {},\n ): Promise<ApiResponse<ListResponse<TComponents, InlineRelations, ResolveRelationsStr>, ThrowOnError>> => {\n const { query = {}, signal, throwOnError, fetchOptions } = options;\n const typedQuery = (query ?? {}) as NonNullable<ListData['query']>;\n const requestPath = '/v2/cdn/stories';\n return requestWithCache('GET', requestPath, typedQuery, async (requestQuery: Record<string, unknown>) => {\n const response = await throttleManager.execute(requestPath, requestQuery, () =>\n asApiResponse(list({\n client,\n query: requestQuery,\n signal,\n ...(throwOnError === undefined ? {} : { throwOnError }),\n ...(fetchOptions ? { kyOptions: { ...client.getConfig().kyOptions, ...fetchOptions } } : {}),\n }))) satisfies ApiResponse<ListResponse<TComponents, InlineRelations, ResolveRelationsStr>, ThrowOnError>;\n\n if (!inlineRelations || response.data === undefined) {\n return response;\n }\n\n const resolved = await resolveRelationMap(response.data, requestQuery, { client, throttleManager });\n if (!resolved) {\n return response;\n }\n\n return {\n ...response,\n data: {\n ...response.data,\n // `inlineStoriesContent` operates on raw `StoryCapi` shapes and mutates relation fields\n // from UUID strings to full story objects. We cast to satisfy its parameter type.\n stories: inlineStoriesContent(response.data.stories as StoryCapi[], resolved.relationPaths, resolved.relationMap),\n },\n };\n }, inlineRelations ? { cacheKeyPrefix: 'inline' } : undefined);\n },\n };\n}\n"],"mappings":";;;;AA6HA,MAAM,UAAU;AAMhB,SAAgB,sBAKd,MACA;CACA,MAAM,EAAE,QAAQ,kBAAkB,eAAe,iBAAiB,oBAAoB;AAEtF,QAAO;EACL,KAAK,OAIH,YACA,UAA8M,EAAE,KACzG;GACvG,MAAM,EAAE,QAAQ,EAAE,EAAE,QAAQ,cAAc,iBAAiB;GAC3D,MAAM,aAAc,SAAS,EAAE;GAC/B,MAAM,gBAAgB,OAAO,eAAe,YAAY,QAAQ,KAAK,WAAW,IAAI,CAAC,WAAW,UAC5F;IAAE,GAAG;IAAY,SAAS;IAAQ,GAClC;GACJ,MAAM,cAAc,mBAAmB;AACvC,UAAO,iBAAiB,OAAO,aAAa,eAAe,OAAO,iBAA0C;IAC1G,MAAM,WAAW,MAAM,gBAAgB,QAAQ,aAAa,oBAC1D,cAAc,IAAI;KAChB;KACA,MAAM,EAAE,YAAY;KACpB,OAAO;KACP;KACA,GAAI,iBAAiB,SAAY,EAAE,GAAG,EAAE,cAAc;KACtD,GAAI,eAAe,EAAE,WAAW;MAAE,GAAG,OAAO,WAAW,CAAC;MAAW,GAAG;MAAc,EAAE,GAAG,EAAE;KAC5F,CAAC,CAAC,CAAC;AAEN,QAAI,CAAC,mBAAmB,SAAS,SAAS,OACxC,QAAO;IAGT,MAAM,WAAW,MAAM,mBAAmB,SAAS,MAAM,cAAc;KAAE;KAAQ;KAAiB,CAAC;AACnG,QAAI,CAAC,SACH,QAAO;AAGT,WAAO;KACL,GAAG;KACH,MAAM;MACJ,GAAG,SAAS;MAGZ,OAAO,mBAAmB,SAAS,KAAK,OAAoB,SAAS,eAAe,SAAS,YAAY;MAC1G;KACF;MACA,kBAAkB,EAAE,gBAAgB,UAAU,GAAG,OAAU;;EAGhE,MAAM,OAIJ,UAA+M,EAAE,KACzG;GACxG,MAAM,EAAE,QAAQ,EAAE,EAAE,QAAQ,cAAc,iBAAiB;GAC3D,MAAM,aAAc,SAAS,EAAE;GAC/B,MAAM,cAAc;AACpB,UAAO,iBAAiB,OAAO,aAAa,YAAY,OAAO,iBAA0C;IACvG,MAAM,WAAW,MAAM,gBAAgB,QAAQ,aAAa,oBAC1D,cAAc,KAAK;KACjB;KACA,OAAO;KACP;KACA,GAAI,iBAAiB,SAAY,EAAE,GAAG,EAAE,cAAc;KACtD,GAAI,eAAe,EAAE,WAAW;MAAE,GAAG,OAAO,WAAW,CAAC;MAAW,GAAG;MAAc,EAAE,GAAG,EAAE;KAC5F,CAAC,CAAC,CAAC;AAEN,QAAI,CAAC,mBAAmB,SAAS,SAAS,OACxC,QAAO;IAGT,MAAM,WAAW,MAAM,mBAAmB,SAAS,MAAM,cAAc;KAAE;KAAQ;KAAiB,CAAC;AACnG,QAAI,CAAC,SACH,QAAO;AAGT,WAAO;KACL,GAAG;KACH,MAAM;MACJ,GAAG,SAAS;MAGZ,SAAS,qBAAqB,SAAS,KAAK,SAAwB,SAAS,eAAe,SAAS,YAAY;MAClH;KACF;MACA,kBAAkB,EAAE,gBAAgB,UAAU,GAAG,OAAU;;EAEjE"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tags.cjs","names":["listTagsApi"],"sources":["../../src/resources/tags.ts"],"sourcesContent":["import { list as listTagsApi } from '../generated/tags/sdk.gen';\nimport type { ListData as TagsListData, ListResponses as TagsListResponses } from '../generated/tags/types.gen';\nimport type { ApiResponse, FetchOptions, ResourceDeps } from '../
|
|
1
|
+
{"version":3,"file":"tags.cjs","names":["listTagsApi"],"sources":["../../src/resources/tags.ts"],"sourcesContent":["import { list as listTagsApi } from '../generated/tags/sdk.gen';\nimport type { ListData as TagsListData, ListResponses as TagsListResponses } from '../generated/tags/types.gen';\nimport type { ApiResponse, FetchOptions, ResourceDeps } from '../client';\n\nexport function createTagsResource<DefaultThrowOnError extends boolean = false>(deps: ResourceDeps<DefaultThrowOnError>) {\n const { client, requestWithCache, asApiResponse, throttleManager } = deps;\n\n return {\n list: async <ThrowOnError extends boolean = DefaultThrowOnError>(\n options: { query?: TagsListData['query']; signal?: AbortSignal; throwOnError?: ThrowOnError; fetchOptions?: FetchOptions } = {},\n ): Promise<ApiResponse<TagsListResponses[200], ThrowOnError>> => {\n const { query = {}, signal, throwOnError, fetchOptions } = options;\n const requestPath = '/v2/cdn/tags';\n return requestWithCache<TagsListResponses[200], ThrowOnError>('GET', requestPath, query, (requestQuery: Record<string, unknown>) => {\n return throttleManager.execute(requestPath, requestQuery, () =>\n asApiResponse<TagsListResponses[200], ThrowOnError>(listTagsApi({\n client,\n query: requestQuery,\n signal,\n ...(throwOnError === undefined ? {} : { throwOnError }),\n ...(fetchOptions ? { kyOptions: { ...client.getConfig().kyOptions, ...fetchOptions } } : {}),\n })));\n });\n },\n };\n}\n"],"mappings":";;;AAIA,SAAgB,mBAAgE,MAAyC;CACvH,MAAM,EAAE,QAAQ,kBAAkB,eAAe,oBAAoB;AAErE,QAAO,EACL,MAAM,OACJ,UAA6H,EAAE,KAChE;EAC/D,MAAM,EAAE,QAAQ,EAAE,EAAE,QAAQ,cAAc,iBAAiB;EAC3D,MAAM,cAAc;AACpB,SAAO,iBAAuD,OAAO,aAAa,QAAQ,iBAA0C;AAClI,UAAO,gBAAgB,QAAQ,aAAa,oBAC1C,cAAoDA,qBAAY;IAC9D;IACA,OAAO;IACP;IACA,GAAI,iBAAiB,SAAY,EAAE,GAAG,EAAE,cAAc;IACtD,GAAI,eAAe,EAAE,WAAW;KAAE,GAAG,OAAO,WAAW,CAAC;KAAW,GAAG;KAAc,EAAE,GAAG,EAAE;IAC5F,CAAC,CAAC,CAAC;IACN;IAEL"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tags.mjs","names":["listTagsApi"],"sources":["../../src/resources/tags.ts"],"sourcesContent":["import { list as listTagsApi } from '../generated/tags/sdk.gen';\nimport type { ListData as TagsListData, ListResponses as TagsListResponses } from '../generated/tags/types.gen';\nimport type { ApiResponse, FetchOptions, ResourceDeps } from '../
|
|
1
|
+
{"version":3,"file":"tags.mjs","names":["listTagsApi"],"sources":["../../src/resources/tags.ts"],"sourcesContent":["import { list as listTagsApi } from '../generated/tags/sdk.gen';\nimport type { ListData as TagsListData, ListResponses as TagsListResponses } from '../generated/tags/types.gen';\nimport type { ApiResponse, FetchOptions, ResourceDeps } from '../client';\n\nexport function createTagsResource<DefaultThrowOnError extends boolean = false>(deps: ResourceDeps<DefaultThrowOnError>) {\n const { client, requestWithCache, asApiResponse, throttleManager } = deps;\n\n return {\n list: async <ThrowOnError extends boolean = DefaultThrowOnError>(\n options: { query?: TagsListData['query']; signal?: AbortSignal; throwOnError?: ThrowOnError; fetchOptions?: FetchOptions } = {},\n ): Promise<ApiResponse<TagsListResponses[200], ThrowOnError>> => {\n const { query = {}, signal, throwOnError, fetchOptions } = options;\n const requestPath = '/v2/cdn/tags';\n return requestWithCache<TagsListResponses[200], ThrowOnError>('GET', requestPath, query, (requestQuery: Record<string, unknown>) => {\n return throttleManager.execute(requestPath, requestQuery, () =>\n asApiResponse<TagsListResponses[200], ThrowOnError>(listTagsApi({\n client,\n query: requestQuery,\n signal,\n ...(throwOnError === undefined ? {} : { throwOnError }),\n ...(fetchOptions ? { kyOptions: { ...client.getConfig().kyOptions, ...fetchOptions } } : {}),\n })));\n });\n },\n };\n}\n"],"mappings":";;;AAIA,SAAgB,mBAAgE,MAAyC;CACvH,MAAM,EAAE,QAAQ,kBAAkB,eAAe,oBAAoB;AAErE,QAAO,EACL,MAAM,OACJ,UAA6H,EAAE,KAChE;EAC/D,MAAM,EAAE,QAAQ,EAAE,EAAE,QAAQ,cAAc,iBAAiB;EAC3D,MAAM,cAAc;AACpB,SAAO,iBAAuD,OAAO,aAAa,QAAQ,iBAA0C;AAClI,UAAO,gBAAgB,QAAQ,aAAa,oBAC1C,cAAoDA,KAAY;IAC9D;IACA,OAAO;IACP;IACA,GAAI,iBAAiB,SAAY,EAAE,GAAG,EAAE,cAAc;IACtD,GAAI,eAAe,EAAE,WAAW;KAAE,GAAG,OAAO,WAAW,CAAC;KAAW,GAAG;KAAc,EAAE,GAAG,EAAE;IAC5F,CAAC,CAAC,CAAC;IACN;IAEL"}
|
|
@@ -3,6 +3,18 @@ const require_fetch_rel_uuids = require('./fetch-rel-uuids.cjs');
|
|
|
3
3
|
//#region src/utils/inline-relations.ts
|
|
4
4
|
const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
5
5
|
const isComponentNode = (value) => typeof value.component === "string" && typeof value._uid === "string";
|
|
6
|
+
/**
|
|
7
|
+
* Decodes a string if it appears to be URL-encoded.
|
|
8
|
+
* Detects common encoded characters (%2C for comma, %20 for space, etc.)
|
|
9
|
+
*/
|
|
10
|
+
const decodeIfEncoded = (value) => {
|
|
11
|
+
if (/%[0-9A-F]{2}/i.test(value)) try {
|
|
12
|
+
return decodeURIComponent(value);
|
|
13
|
+
} catch {
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
};
|
|
6
18
|
const inlineStoryContentInternal = (story, relationPaths, relationMap, resolved) => {
|
|
7
19
|
const existingStory = resolved.get(story.uuid);
|
|
8
20
|
if (existingStory) return existingStory;
|
|
@@ -27,7 +39,7 @@ function resolveNode(value, relationMap, relationPaths, resolved) {
|
|
|
27
39
|
}
|
|
28
40
|
const parseResolveRelations = (query) => {
|
|
29
41
|
if (typeof query.resolve_relations !== "string") return [];
|
|
30
|
-
return query.resolve_relations.split(",").map((path) => path.trim()).filter((path) => {
|
|
42
|
+
return decodeIfEncoded(query.resolve_relations).split(",").map((path) => path.trim()).filter((path) => {
|
|
31
43
|
const [component = "", field = "", ...rest] = path.split(".");
|
|
32
44
|
return component.length > 0 && field.length > 0 && rest.length === 0;
|
|
33
45
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"inline-relations.cjs","names":["fetchMissingRelations"],"sources":["../../src/utils/inline-relations.ts"],"sourcesContent":["import type { Client } from '../generated/shared/client';\nimport type { StoryCapi } from '../generated/stories';\nimport type { StoryWithInlinedRelations } from '../resources/stories';\nimport { fetchMissingRelations } from './fetch-rel-uuids';\nimport type { ThrottleManager } from './rate-limit';\n\ntype RelationPath = `${string}.${string}`;\n\ninterface ComponentNode {\n _uid: string;\n component: string;\n [key: string]: unknown;\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n value !== null && typeof value === 'object' && !Array.isArray(value);\n\nconst isComponentNode = (value: Record<string, unknown>): value is ComponentNode =>\n typeof value.component === 'string' && typeof value._uid === 'string';\n\nconst inlineStoryContentInternal = <TStory extends StoryCapi | StoryWithInlinedRelations>(\n story: TStory,\n relationPaths: ReadonlySet<RelationPath>,\n relationMap: ReadonlyMap<string, TStory>,\n resolved: Map<string, TStory>,\n): TStory => {\n const existingStory = resolved.get(story.uuid);\n if (existingStory) {\n return existingStory;\n }\n\n const clonedStory = structuredClone(story);\n resolved.set(story.uuid, clonedStory);\n // resolveNode returns `unknown` to handle arbitrary JSON trees; shape is preserved at runtime.\n clonedStory.content = resolveNode(clonedStory.content, relationMap, relationPaths, resolved) as StoryCapi['content'];\n return clonedStory;\n};\n\nfunction resolveNode<TStory extends StoryCapi | StoryWithInlinedRelations>(\n value: unknown,\n relationMap: ReadonlyMap<string, TStory>,\n relationPaths: ReadonlySet<RelationPath>,\n resolved: Map<string, TStory>,\n): unknown {\n if (Array.isArray(value)) {\n return value.map(item => resolveNode(item, relationMap, relationPaths, resolved));\n }\n\n if (!isRecord(value)) {\n return value;\n }\n\n if (isComponentNode(value)) {\n for (const [fieldName, fieldValue] of Object.entries(value)) {\n if (fieldName === 'component' || fieldName === '_uid') {\n continue;\n }\n\n const relationPath: RelationPath = `${value.component}.${fieldName}`;\n value[fieldName] = relationPaths.has(relationPath)\n ? resolveFieldValue(fieldValue, relationMap, relationPaths, resolved)\n : resolveNode(fieldValue, relationMap, relationPaths, resolved);\n }\n\n return value;\n }\n\n for (const [fieldName, fieldValue] of Object.entries(value)) {\n value[fieldName] = resolveNode(fieldValue, relationMap, relationPaths, resolved);\n }\n\n return value;\n}\n\nexport const parseResolveRelations = (query: Record<string, unknown>): RelationPath[] => {\n if (typeof query.resolve_relations !== 'string') {\n return [];\n }\n\n
|
|
1
|
+
{"version":3,"file":"inline-relations.cjs","names":["fetchMissingRelations"],"sources":["../../src/utils/inline-relations.ts"],"sourcesContent":["import type { Client } from '../generated/shared/client';\nimport type { StoryCapi } from '../generated/stories';\nimport type { StoryWithInlinedRelations } from '../resources/stories';\nimport { fetchMissingRelations } from './fetch-rel-uuids';\nimport type { ThrottleManager } from './rate-limit';\n\ntype RelationPath = `${string}.${string}`;\n\ninterface ComponentNode {\n _uid: string;\n component: string;\n [key: string]: unknown;\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n value !== null && typeof value === 'object' && !Array.isArray(value);\n\nconst isComponentNode = (value: Record<string, unknown>): value is ComponentNode =>\n typeof value.component === 'string' && typeof value._uid === 'string';\n\n/**\n * Decodes a string if it appears to be URL-encoded.\n * Detects common encoded characters (%2C for comma, %20 for space, etc.)\n */\nconst decodeIfEncoded = (value: string): string => {\n // Check if the string contains URL-encoded characters (% followed by hex digits)\n if (/%[0-9A-F]{2}/i.test(value)) {\n try {\n return decodeURIComponent(value);\n }\n catch {\n // If decoding fails (malformed encoding), return original\n return value;\n }\n }\n return value;\n};\n\nconst inlineStoryContentInternal = <TStory extends StoryCapi | StoryWithInlinedRelations>(\n story: TStory,\n relationPaths: ReadonlySet<RelationPath>,\n relationMap: ReadonlyMap<string, TStory>,\n resolved: Map<string, TStory>,\n): TStory => {\n const existingStory = resolved.get(story.uuid);\n if (existingStory) {\n return existingStory;\n }\n\n const clonedStory = structuredClone(story);\n resolved.set(story.uuid, clonedStory);\n // resolveNode returns `unknown` to handle arbitrary JSON trees; shape is preserved at runtime.\n clonedStory.content = resolveNode(clonedStory.content, relationMap, relationPaths, resolved) as StoryCapi['content'];\n return clonedStory;\n};\n\nfunction resolveNode<TStory extends StoryCapi | StoryWithInlinedRelations>(\n value: unknown,\n relationMap: ReadonlyMap<string, TStory>,\n relationPaths: ReadonlySet<RelationPath>,\n resolved: Map<string, TStory>,\n): unknown {\n if (Array.isArray(value)) {\n return value.map(item => resolveNode(item, relationMap, relationPaths, resolved));\n }\n\n if (!isRecord(value)) {\n return value;\n }\n\n if (isComponentNode(value)) {\n for (const [fieldName, fieldValue] of Object.entries(value)) {\n if (fieldName === 'component' || fieldName === '_uid') {\n continue;\n }\n\n const relationPath: RelationPath = `${value.component}.${fieldName}`;\n value[fieldName] = relationPaths.has(relationPath)\n ? resolveFieldValue(fieldValue, relationMap, relationPaths, resolved)\n : resolveNode(fieldValue, relationMap, relationPaths, resolved);\n }\n\n return value;\n }\n\n for (const [fieldName, fieldValue] of Object.entries(value)) {\n value[fieldName] = resolveNode(fieldValue, relationMap, relationPaths, resolved);\n }\n\n return value;\n}\n\nexport const parseResolveRelations = (query: Record<string, unknown>): RelationPath[] => {\n if (typeof query.resolve_relations !== 'string') {\n return [];\n }\n\n // Decode URL-encoded strings to handle pre-encoded input\n const resolveRelations = decodeIfEncoded(query.resolve_relations);\n\n return resolveRelations\n .split(',')\n .map(path => path.trim())\n .filter((path): path is RelationPath => {\n const [component = '', field = '', ...rest] = path.split('.');\n return component.length > 0 && field.length > 0 && rest.length === 0;\n });\n};\n\nexport const buildRelationMap = (rels: Array<StoryCapi> | undefined): Map<string, StoryCapi> => {\n const relationMap = new Map<string, StoryCapi>();\n\n for (const story of rels ?? []) {\n relationMap.set(story.uuid, story);\n }\n\n return relationMap;\n};\n\nfunction resolveFieldValue<TStory extends StoryCapi | StoryWithInlinedRelations>(\n value: unknown,\n relationMap: ReadonlyMap<string, TStory>,\n relationPaths: ReadonlySet<RelationPath>,\n resolved: Map<string, TStory>,\n): unknown {\n if (typeof value === 'string') {\n const relatedStory = relationMap.get(value);\n if (!relatedStory) {\n return value;\n }\n\n return inlineStoryContentInternal(relatedStory, relationPaths, relationMap, resolved);\n }\n\n if (Array.isArray(value)) {\n return value.map(item => resolveFieldValue(item, relationMap, relationPaths, resolved));\n }\n\n return resolveNode(value, relationMap, relationPaths, resolved);\n}\n\nexport const inlineStoryContent = <TStory extends StoryCapi | StoryWithInlinedRelations>(\n story: TStory,\n relationPaths: RelationPath[],\n relationMap: ReadonlyMap<string, TStory>,\n): TStory => {\n const normalizedPaths = new Set(relationPaths);\n const resolved = new Map<string, TStory>();\n return inlineStoryContentInternal(story, normalizedPaths, relationMap, resolved);\n};\n\nexport const inlineStoriesContent = <TStory extends StoryCapi | StoryWithInlinedRelations>(\n stories: Array<TStory>,\n relationPaths: RelationPath[],\n relationMap: ReadonlyMap<string, TStory>,\n): Array<TStory> => {\n const normalizedPaths = new Set(relationPaths);\n const resolved = new Map<string, TStory>();\n return stories.map(story => inlineStoryContentInternal(story, normalizedPaths, relationMap, resolved));\n};\n\ninterface ResolveRelationMapOptions {\n client: Client;\n throttleManager: ThrottleManager;\n}\n\nexport interface ResolvedRelations {\n relationPaths: RelationPath[];\n relationMap: Map<string, StoryCapi>;\n}\n\n/**\n * Parses relation paths from the request query, builds a relation map from the\n * response's `rels`, and fetches any additional relations referenced by `rel_uuids`.\n *\n * Returns `null` when there is nothing to inline (no `resolve_relations` in the query).\n */\nexport const resolveRelationMap = async (\n responseData: { rels?: StoryCapi[]; rel_uuids?: string[] },\n requestQuery: Record<string, unknown>,\n { client, throttleManager }: ResolveRelationMapOptions,\n): Promise<ResolvedRelations | null> => {\n const relationPaths = parseResolveRelations(requestQuery);\n if (relationPaths.length === 0) {\n return null;\n }\n\n const relationMap = buildRelationMap(responseData.rels);\n if (responseData.rel_uuids?.length) {\n const missingUuids = responseData.rel_uuids.filter(uuid => !relationMap.has(uuid));\n if (missingUuids.length > 0) {\n const fetchedRelations = await fetchMissingRelations({\n client,\n uuids: missingUuids,\n baseQuery: requestQuery,\n throttleManager,\n });\n for (const relationStory of fetchedRelations) {\n relationMap.set(relationStory.uuid, relationStory);\n }\n }\n }\n\n return { relationPaths, relationMap };\n};\n"],"mappings":"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"}
|
|
@@ -3,6 +3,18 @@ import { fetchMissingRelations } from "./fetch-rel-uuids.mjs";
|
|
|
3
3
|
//#region src/utils/inline-relations.ts
|
|
4
4
|
const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
5
5
|
const isComponentNode = (value) => typeof value.component === "string" && typeof value._uid === "string";
|
|
6
|
+
/**
|
|
7
|
+
* Decodes a string if it appears to be URL-encoded.
|
|
8
|
+
* Detects common encoded characters (%2C for comma, %20 for space, etc.)
|
|
9
|
+
*/
|
|
10
|
+
const decodeIfEncoded = (value) => {
|
|
11
|
+
if (/%[0-9A-F]{2}/i.test(value)) try {
|
|
12
|
+
return decodeURIComponent(value);
|
|
13
|
+
} catch {
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
};
|
|
6
18
|
const inlineStoryContentInternal = (story, relationPaths, relationMap, resolved) => {
|
|
7
19
|
const existingStory = resolved.get(story.uuid);
|
|
8
20
|
if (existingStory) return existingStory;
|
|
@@ -27,7 +39,7 @@ function resolveNode(value, relationMap, relationPaths, resolved) {
|
|
|
27
39
|
}
|
|
28
40
|
const parseResolveRelations = (query) => {
|
|
29
41
|
if (typeof query.resolve_relations !== "string") return [];
|
|
30
|
-
return query.resolve_relations.split(",").map((path) => path.trim()).filter((path) => {
|
|
42
|
+
return decodeIfEncoded(query.resolve_relations).split(",").map((path) => path.trim()).filter((path) => {
|
|
31
43
|
const [component = "", field = "", ...rest] = path.split(".");
|
|
32
44
|
return component.length > 0 && field.length > 0 && rest.length === 0;
|
|
33
45
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"inline-relations.mjs","names":[],"sources":["../../src/utils/inline-relations.ts"],"sourcesContent":["import type { Client } from '../generated/shared/client';\nimport type { StoryCapi } from '../generated/stories';\nimport type { StoryWithInlinedRelations } from '../resources/stories';\nimport { fetchMissingRelations } from './fetch-rel-uuids';\nimport type { ThrottleManager } from './rate-limit';\n\ntype RelationPath = `${string}.${string}`;\n\ninterface ComponentNode {\n _uid: string;\n component: string;\n [key: string]: unknown;\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n value !== null && typeof value === 'object' && !Array.isArray(value);\n\nconst isComponentNode = (value: Record<string, unknown>): value is ComponentNode =>\n typeof value.component === 'string' && typeof value._uid === 'string';\n\nconst inlineStoryContentInternal = <TStory extends StoryCapi | StoryWithInlinedRelations>(\n story: TStory,\n relationPaths: ReadonlySet<RelationPath>,\n relationMap: ReadonlyMap<string, TStory>,\n resolved: Map<string, TStory>,\n): TStory => {\n const existingStory = resolved.get(story.uuid);\n if (existingStory) {\n return existingStory;\n }\n\n const clonedStory = structuredClone(story);\n resolved.set(story.uuid, clonedStory);\n // resolveNode returns `unknown` to handle arbitrary JSON trees; shape is preserved at runtime.\n clonedStory.content = resolveNode(clonedStory.content, relationMap, relationPaths, resolved) as StoryCapi['content'];\n return clonedStory;\n};\n\nfunction resolveNode<TStory extends StoryCapi | StoryWithInlinedRelations>(\n value: unknown,\n relationMap: ReadonlyMap<string, TStory>,\n relationPaths: ReadonlySet<RelationPath>,\n resolved: Map<string, TStory>,\n): unknown {\n if (Array.isArray(value)) {\n return value.map(item => resolveNode(item, relationMap, relationPaths, resolved));\n }\n\n if (!isRecord(value)) {\n return value;\n }\n\n if (isComponentNode(value)) {\n for (const [fieldName, fieldValue] of Object.entries(value)) {\n if (fieldName === 'component' || fieldName === '_uid') {\n continue;\n }\n\n const relationPath: RelationPath = `${value.component}.${fieldName}`;\n value[fieldName] = relationPaths.has(relationPath)\n ? resolveFieldValue(fieldValue, relationMap, relationPaths, resolved)\n : resolveNode(fieldValue, relationMap, relationPaths, resolved);\n }\n\n return value;\n }\n\n for (const [fieldName, fieldValue] of Object.entries(value)) {\n value[fieldName] = resolveNode(fieldValue, relationMap, relationPaths, resolved);\n }\n\n return value;\n}\n\nexport const parseResolveRelations = (query: Record<string, unknown>): RelationPath[] => {\n if (typeof query.resolve_relations !== 'string') {\n return [];\n }\n\n
|
|
1
|
+
{"version":3,"file":"inline-relations.mjs","names":[],"sources":["../../src/utils/inline-relations.ts"],"sourcesContent":["import type { Client } from '../generated/shared/client';\nimport type { StoryCapi } from '../generated/stories';\nimport type { StoryWithInlinedRelations } from '../resources/stories';\nimport { fetchMissingRelations } from './fetch-rel-uuids';\nimport type { ThrottleManager } from './rate-limit';\n\ntype RelationPath = `${string}.${string}`;\n\ninterface ComponentNode {\n _uid: string;\n component: string;\n [key: string]: unknown;\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n value !== null && typeof value === 'object' && !Array.isArray(value);\n\nconst isComponentNode = (value: Record<string, unknown>): value is ComponentNode =>\n typeof value.component === 'string' && typeof value._uid === 'string';\n\n/**\n * Decodes a string if it appears to be URL-encoded.\n * Detects common encoded characters (%2C for comma, %20 for space, etc.)\n */\nconst decodeIfEncoded = (value: string): string => {\n // Check if the string contains URL-encoded characters (% followed by hex digits)\n if (/%[0-9A-F]{2}/i.test(value)) {\n try {\n return decodeURIComponent(value);\n }\n catch {\n // If decoding fails (malformed encoding), return original\n return value;\n }\n }\n return value;\n};\n\nconst inlineStoryContentInternal = <TStory extends StoryCapi | StoryWithInlinedRelations>(\n story: TStory,\n relationPaths: ReadonlySet<RelationPath>,\n relationMap: ReadonlyMap<string, TStory>,\n resolved: Map<string, TStory>,\n): TStory => {\n const existingStory = resolved.get(story.uuid);\n if (existingStory) {\n return existingStory;\n }\n\n const clonedStory = structuredClone(story);\n resolved.set(story.uuid, clonedStory);\n // resolveNode returns `unknown` to handle arbitrary JSON trees; shape is preserved at runtime.\n clonedStory.content = resolveNode(clonedStory.content, relationMap, relationPaths, resolved) as StoryCapi['content'];\n return clonedStory;\n};\n\nfunction resolveNode<TStory extends StoryCapi | StoryWithInlinedRelations>(\n value: unknown,\n relationMap: ReadonlyMap<string, TStory>,\n relationPaths: ReadonlySet<RelationPath>,\n resolved: Map<string, TStory>,\n): unknown {\n if (Array.isArray(value)) {\n return value.map(item => resolveNode(item, relationMap, relationPaths, resolved));\n }\n\n if (!isRecord(value)) {\n return value;\n }\n\n if (isComponentNode(value)) {\n for (const [fieldName, fieldValue] of Object.entries(value)) {\n if (fieldName === 'component' || fieldName === '_uid') {\n continue;\n }\n\n const relationPath: RelationPath = `${value.component}.${fieldName}`;\n value[fieldName] = relationPaths.has(relationPath)\n ? resolveFieldValue(fieldValue, relationMap, relationPaths, resolved)\n : resolveNode(fieldValue, relationMap, relationPaths, resolved);\n }\n\n return value;\n }\n\n for (const [fieldName, fieldValue] of Object.entries(value)) {\n value[fieldName] = resolveNode(fieldValue, relationMap, relationPaths, resolved);\n }\n\n return value;\n}\n\nexport const parseResolveRelations = (query: Record<string, unknown>): RelationPath[] => {\n if (typeof query.resolve_relations !== 'string') {\n return [];\n }\n\n // Decode URL-encoded strings to handle pre-encoded input\n const resolveRelations = decodeIfEncoded(query.resolve_relations);\n\n return resolveRelations\n .split(',')\n .map(path => path.trim())\n .filter((path): path is RelationPath => {\n const [component = '', field = '', ...rest] = path.split('.');\n return component.length > 0 && field.length > 0 && rest.length === 0;\n });\n};\n\nexport const buildRelationMap = (rels: Array<StoryCapi> | undefined): Map<string, StoryCapi> => {\n const relationMap = new Map<string, StoryCapi>();\n\n for (const story of rels ?? []) {\n relationMap.set(story.uuid, story);\n }\n\n return relationMap;\n};\n\nfunction resolveFieldValue<TStory extends StoryCapi | StoryWithInlinedRelations>(\n value: unknown,\n relationMap: ReadonlyMap<string, TStory>,\n relationPaths: ReadonlySet<RelationPath>,\n resolved: Map<string, TStory>,\n): unknown {\n if (typeof value === 'string') {\n const relatedStory = relationMap.get(value);\n if (!relatedStory) {\n return value;\n }\n\n return inlineStoryContentInternal(relatedStory, relationPaths, relationMap, resolved);\n }\n\n if (Array.isArray(value)) {\n return value.map(item => resolveFieldValue(item, relationMap, relationPaths, resolved));\n }\n\n return resolveNode(value, relationMap, relationPaths, resolved);\n}\n\nexport const inlineStoryContent = <TStory extends StoryCapi | StoryWithInlinedRelations>(\n story: TStory,\n relationPaths: RelationPath[],\n relationMap: ReadonlyMap<string, TStory>,\n): TStory => {\n const normalizedPaths = new Set(relationPaths);\n const resolved = new Map<string, TStory>();\n return inlineStoryContentInternal(story, normalizedPaths, relationMap, resolved);\n};\n\nexport const inlineStoriesContent = <TStory extends StoryCapi | StoryWithInlinedRelations>(\n stories: Array<TStory>,\n relationPaths: RelationPath[],\n relationMap: ReadonlyMap<string, TStory>,\n): Array<TStory> => {\n const normalizedPaths = new Set(relationPaths);\n const resolved = new Map<string, TStory>();\n return stories.map(story => inlineStoryContentInternal(story, normalizedPaths, relationMap, resolved));\n};\n\ninterface ResolveRelationMapOptions {\n client: Client;\n throttleManager: ThrottleManager;\n}\n\nexport interface ResolvedRelations {\n relationPaths: RelationPath[];\n relationMap: Map<string, StoryCapi>;\n}\n\n/**\n * Parses relation paths from the request query, builds a relation map from the\n * response's `rels`, and fetches any additional relations referenced by `rel_uuids`.\n *\n * Returns `null` when there is nothing to inline (no `resolve_relations` in the query).\n */\nexport const resolveRelationMap = async (\n responseData: { rels?: StoryCapi[]; rel_uuids?: string[] },\n requestQuery: Record<string, unknown>,\n { client, throttleManager }: ResolveRelationMapOptions,\n): Promise<ResolvedRelations | null> => {\n const relationPaths = parseResolveRelations(requestQuery);\n if (relationPaths.length === 0) {\n return null;\n }\n\n const relationMap = buildRelationMap(responseData.rels);\n if (responseData.rel_uuids?.length) {\n const missingUuids = responseData.rel_uuids.filter(uuid => !relationMap.has(uuid));\n if (missingUuids.length > 0) {\n const fetchedRelations = await fetchMissingRelations({\n client,\n uuids: missingUuids,\n baseQuery: requestQuery,\n throttleManager,\n });\n for (const relationStory of fetchedRelations) {\n relationMap.set(relationStory.uuid, relationStory);\n }\n }\n }\n\n return { relationPaths, relationMap };\n};\n"],"mappings":"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"}
|
|
@@ -77,14 +77,20 @@ function determineTier(path, query) {
|
|
|
77
77
|
return "VERY_LARGE";
|
|
78
78
|
}
|
|
79
79
|
/**
|
|
80
|
-
* Extracts the quota (`q=`) value from the `X-RateLimit-Policy` response header
|
|
81
|
-
*
|
|
80
|
+
* Extracts the quota (`q=`) value from the `X-RateLimit-Policy` response header,
|
|
81
|
+
* but only when the policy describes a rate limit — not a concurrency limit.
|
|
82
82
|
*
|
|
83
|
-
*
|
|
83
|
+
* The Storyblok CDN currently returns `"concurrent-requests";q=30` which is a
|
|
84
|
+
* concurrency limit (always ~30), not a rate limit. Applying that value to the
|
|
85
|
+
* tier-based rate limiter would incorrectly cap throughput below what the API
|
|
86
|
+
* allows. This function therefore ignores concurrency policies and only returns
|
|
87
|
+
* a value for rate-limit policies (e.g. `"rate-limit";q=50`), which the API
|
|
88
|
+
* does not send yet but may in the future.
|
|
84
89
|
*/
|
|
85
90
|
function parseRateLimitPolicyHeader(response) {
|
|
86
91
|
const policy = response.headers.get("x-ratelimit-policy");
|
|
87
92
|
if (!policy) return;
|
|
93
|
+
if (policy.includes("\"concurrent-requests\"")) return;
|
|
88
94
|
const match = policy.match(/q=(\d+)/);
|
|
89
95
|
if (!match) return;
|
|
90
96
|
return Math.min(Number.parseInt(match[1], 10), MAX_RATE_LIMIT);
|
|
@@ -134,7 +140,5 @@ function createThrottleManager(config) {
|
|
|
134
140
|
}
|
|
135
141
|
|
|
136
142
|
//#endregion
|
|
137
|
-
exports.createThrottle = createThrottle;
|
|
138
143
|
exports.createThrottleManager = createThrottleManager;
|
|
139
|
-
exports.parseRateLimitPolicyHeader = parseRateLimitPolicyHeader;
|
|
140
144
|
//# sourceMappingURL=rate-limit.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rate-limit.cjs","names":[],"sources":["../../src/utils/rate-limit.ts"],"sourcesContent":["/**\n * Rate limiting for the Content API client.\n *\n * Provides both a simple token-bucket throttle and a tier-aware manager\n * that automatically selects the right concurrency limit based on request\n * type (single story vs. listing) and the per_page query parameter — mirroring\n * the tiers enforced server-side by the Storyblok CDN.\n */\n\nconst TIER_LIMITS = {\n SINGLE_OR_SMALL: 50, // single story fetch or per_page ≤ 25\n MEDIUM: 15, // per_page 26–50\n LARGE: 10, // per_page 51–75\n VERY_LARGE: 6, // per_page 76–100\n} as const;\n\ntype TierName = keyof typeof TIER_LIMITS;\n\nconst PER_PAGE_THRESHOLDS = {\n SMALL: 25,\n MEDIUM: 50,\n LARGE: 75,\n} as const;\n\nconst DEFAULT_PER_PAGE = 25;\nconst MAX_RATE_LIMIT = 1_000;\n\nexport interface RateLimitConfig {\n /**\n * Fixed maximum number of concurrent requests per second.\n * When set, disables automatic per_page tier detection and all requests\n * share a single queue at this limit. Capped at 1000.\n */\n maxConcurrency?: number;\n /**\n * Dynamically adjust the rate limit based on the `X-RateLimit-Policy`\n * response header returned by the Storyblok API.\n * @default true\n */\n adaptToServerHeaders?: boolean;\n}\n\nexport interface ThrottleManager {\n execute: <T>(path: string, query: Record<string, unknown>, fn: () => Promise<T>) => Promise<T>;\n adaptToResponse: (response: Response | undefined) => void;\n}\n\ninterface Throttle {\n execute: <T>(fn: () => Promise<T>) => Promise<T>;\n setLimit: (n: number) => void;\n}\n\n/**\n * Concurrency limiter: allows up to `initialLimit` requests to be in-flight\n * at the same time. A slot is freed as soon as the request's promise settles\n * (resolves or rejects), so throughput scales with how quickly requests\n * complete rather than being artificially capped at N per second.\n */\nexport function createThrottle(initialLimit: number): Throttle {\n let limit = initialLimit;\n let activeCount = 0;\n const queue: Array<() => void> = [];\n\n const tryNext = () => {\n while (queue.length > 0 && activeCount < limit) {\n activeCount++;\n const run = queue.shift()!;\n run();\n }\n };\n\n const execute = <T>(fn: () => Promise<T>): Promise<T> => {\n return new Promise<T>((resolve, reject) => {\n queue.push(() => {\n fn().then(\n (value) => {\n activeCount--;\n tryNext();\n resolve(value);\n },\n (error) => {\n activeCount--;\n tryNext();\n reject(error);\n },\n );\n });\n tryNext();\n });\n };\n\n const setLimit = (n: number) => {\n limit = n;\n // If the limit increased, unblock any waiting requests.\n tryNext();\n };\n\n return { execute, setLimit };\n}\n\n// Matches /v2/cdn/stories/<identifier> — a single story fetch (including nested slugs).\nconst SINGLE_STORY_PATH_RE = /\\/v2\\/cdn\\/stories\\/.+$/;\n\n/**\n * Maps a request path + query to one of the four rate-limit tiers.\n * Used by the auto-detection mode of `createThrottleManager`.\n */\nexport function determineTier(path: string, query: Record<string, unknown>): TierName {\n if (SINGLE_STORY_PATH_RE.test(path)) {\n return 'SINGLE_OR_SMALL';\n }\n\n const rawPerPage = query.per_page;\n const perPage\n = typeof rawPerPage === 'number'\n ? rawPerPage\n : typeof rawPerPage === 'string'\n ? Number.parseInt(rawPerPage, 10) || DEFAULT_PER_PAGE\n : DEFAULT_PER_PAGE;\n\n if (perPage <= PER_PAGE_THRESHOLDS.SMALL) {\n return 'SINGLE_OR_SMALL';\n }\n if (perPage <= PER_PAGE_THRESHOLDS.MEDIUM) {\n return 'MEDIUM';\n }\n if (perPage <= PER_PAGE_THRESHOLDS.LARGE) {\n return 'LARGE';\n }\n return 'VERY_LARGE';\n}\n\n/**\n * Extracts the quota (`q=`) value from the `X-RateLimit-Policy` response header
|
|
1
|
+
{"version":3,"file":"rate-limit.cjs","names":[],"sources":["../../src/utils/rate-limit.ts"],"sourcesContent":["/**\n * Rate limiting for the Content API client.\n *\n * Provides both a simple token-bucket throttle and a tier-aware manager\n * that automatically selects the right concurrency limit based on request\n * type (single story vs. listing) and the per_page query parameter — mirroring\n * the tiers enforced server-side by the Storyblok CDN.\n */\n\nconst TIER_LIMITS = {\n SINGLE_OR_SMALL: 50, // single story fetch or per_page ≤ 25\n MEDIUM: 15, // per_page 26–50\n LARGE: 10, // per_page 51–75\n VERY_LARGE: 6, // per_page 76–100\n} as const;\n\ntype TierName = keyof typeof TIER_LIMITS;\n\nconst PER_PAGE_THRESHOLDS = {\n SMALL: 25,\n MEDIUM: 50,\n LARGE: 75,\n} as const;\n\nconst DEFAULT_PER_PAGE = 25;\nconst MAX_RATE_LIMIT = 1_000;\n\nexport interface RateLimitConfig {\n /**\n * Fixed maximum number of concurrent requests per second.\n * When set, disables automatic per_page tier detection and all requests\n * share a single queue at this limit. Capped at 1000.\n */\n maxConcurrency?: number;\n /**\n * Dynamically adjust the rate limit based on the `X-RateLimit-Policy`\n * response header returned by the Storyblok API.\n * @default true\n */\n adaptToServerHeaders?: boolean;\n}\n\nexport interface ThrottleManager {\n execute: <T>(path: string, query: Record<string, unknown>, fn: () => Promise<T>) => Promise<T>;\n adaptToResponse: (response: Response | undefined) => void;\n}\n\ninterface Throttle {\n execute: <T>(fn: () => Promise<T>) => Promise<T>;\n setLimit: (n: number) => void;\n}\n\n/**\n * Concurrency limiter: allows up to `initialLimit` requests to be in-flight\n * at the same time. A slot is freed as soon as the request's promise settles\n * (resolves or rejects), so throughput scales with how quickly requests\n * complete rather than being artificially capped at N per second.\n */\nexport function createThrottle(initialLimit: number): Throttle {\n let limit = initialLimit;\n let activeCount = 0;\n const queue: Array<() => void> = [];\n\n const tryNext = () => {\n while (queue.length > 0 && activeCount < limit) {\n activeCount++;\n const run = queue.shift()!;\n run();\n }\n };\n\n const execute = <T>(fn: () => Promise<T>): Promise<T> => {\n return new Promise<T>((resolve, reject) => {\n queue.push(() => {\n fn().then(\n (value) => {\n activeCount--;\n tryNext();\n resolve(value);\n },\n (error) => {\n activeCount--;\n tryNext();\n reject(error);\n },\n );\n });\n tryNext();\n });\n };\n\n const setLimit = (n: number) => {\n limit = n;\n // If the limit increased, unblock any waiting requests.\n tryNext();\n };\n\n return { execute, setLimit };\n}\n\n// Matches /v2/cdn/stories/<identifier> — a single story fetch (including nested slugs).\nconst SINGLE_STORY_PATH_RE = /\\/v2\\/cdn\\/stories\\/.+$/;\n\n/**\n * Maps a request path + query to one of the four rate-limit tiers.\n * Used by the auto-detection mode of `createThrottleManager`.\n */\nexport function determineTier(path: string, query: Record<string, unknown>): TierName {\n if (SINGLE_STORY_PATH_RE.test(path)) {\n return 'SINGLE_OR_SMALL';\n }\n\n const rawPerPage = query.per_page;\n const perPage\n = typeof rawPerPage === 'number'\n ? rawPerPage\n : typeof rawPerPage === 'string'\n ? Number.parseInt(rawPerPage, 10) || DEFAULT_PER_PAGE\n : DEFAULT_PER_PAGE;\n\n if (perPage <= PER_PAGE_THRESHOLDS.SMALL) {\n return 'SINGLE_OR_SMALL';\n }\n if (perPage <= PER_PAGE_THRESHOLDS.MEDIUM) {\n return 'MEDIUM';\n }\n if (perPage <= PER_PAGE_THRESHOLDS.LARGE) {\n return 'LARGE';\n }\n return 'VERY_LARGE';\n}\n\n/**\n * Extracts the quota (`q=`) value from the `X-RateLimit-Policy` response header,\n * but only when the policy describes a rate limit — not a concurrency limit.\n *\n * The Storyblok CDN currently returns `\"concurrent-requests\";q=30` which is a\n * concurrency limit (always ~30), not a rate limit. Applying that value to the\n * tier-based rate limiter would incorrectly cap throughput below what the API\n * allows. This function therefore ignores concurrency policies and only returns\n * a value for rate-limit policies (e.g. `\"rate-limit\";q=50`), which the API\n * does not send yet but may in the future.\n */\nexport function parseRateLimitPolicyHeader(response: Response): number | undefined {\n const policy = response.headers.get('x-ratelimit-policy');\n if (!policy) {\n return undefined;\n }\n // Only act on rate-limit policies — skip concurrency limits like\n // \"concurrent-requests\";q=30 which represent a different constraint.\n if (policy.includes('\"concurrent-requests\"')) {\n return undefined;\n }\n const match = policy.match(/q=(\\d+)/);\n if (!match) {\n return undefined;\n }\n return Math.min(Number.parseInt(match[1], 10), MAX_RATE_LIMIT);\n}\n\n/**\n * Creates a `ThrottleManager` from the user-supplied `rateLimit` config.\n *\n * - `false` → no throttling (passthrough)\n * - `number` → fixed single queue at that limit\n * - `{ maxConcurrency: n }` → fixed single queue at n req/s\n * - `{}` / `undefined` (default)→ auto-detect tier from path + per_page\n */\nexport function createThrottleManager(config: RateLimitConfig | number | false): ThrottleManager {\n // Disabled — every request goes straight through.\n if (config === false) {\n return {\n execute: (_path, _query, fn) => fn(),\n adaptToResponse: () => {},\n };\n }\n\n const resolvedConfig: RateLimitConfig = typeof config === 'number' ? { maxConcurrency: config } : config;\n const { maxConcurrency, adaptToServerHeaders = true } = resolvedConfig;\n\n // Fixed-limit mode — single queue, optional server-header adaptation.\n if (maxConcurrency !== undefined) {\n const cappedLimit = Math.min(maxConcurrency, MAX_RATE_LIMIT);\n const throttle = createThrottle(cappedLimit);\n\n return {\n execute: (_path, _query, fn) => throttle.execute(fn),\n adaptToResponse: (response) => {\n if (!adaptToServerHeaders || response === undefined) {\n return;\n }\n const serverLimit = parseRateLimitPolicyHeader(response);\n if (serverLimit !== undefined) {\n // Never exceed the user-configured ceiling.\n throttle.setLimit(Math.min(cappedLimit, serverLimit));\n }\n },\n };\n }\n\n // Auto-detect mode — one throttle per tier, tier chosen per request.\n const throttles: Record<TierName, Throttle> = {\n SINGLE_OR_SMALL: createThrottle(TIER_LIMITS.SINGLE_OR_SMALL),\n MEDIUM: createThrottle(TIER_LIMITS.MEDIUM),\n LARGE: createThrottle(TIER_LIMITS.LARGE),\n VERY_LARGE: createThrottle(TIER_LIMITS.VERY_LARGE),\n };\n\n return {\n execute: (path, query, fn) => {\n const tier = determineTier(path, query);\n return throttles[tier].execute(fn);\n },\n adaptToResponse: (response) => {\n if (!adaptToServerHeaders || response === undefined) {\n return;\n }\n const serverLimit = parseRateLimitPolicyHeader(response);\n if (serverLimit !== undefined) {\n // The SINGLE_OR_SMALL tier is the most common; adapting it covers the\n // majority of requests. Other tiers are already conservatively limited.\n throttles.SINGLE_OR_SMALL.setLimit(Math.min(TIER_LIMITS.SINGLE_OR_SMALL, serverLimit));\n }\n },\n };\n}\n"],"mappings":";;;;;;;;;;AASA,MAAM,cAAc;CAClB,iBAAiB;CACjB,QAAQ;CACR,OAAO;CACP,YAAY;CACb;AAID,MAAM,sBAAsB;CAC1B,OAAO;CACP,QAAQ;CACR,OAAO;CACR;AAED,MAAM,mBAAmB;AACzB,MAAM,iBAAiB;;;;;;;AAiCvB,SAAgB,eAAe,cAAgC;CAC7D,IAAI,QAAQ;CACZ,IAAI,cAAc;CAClB,MAAM,QAA2B,EAAE;CAEnC,MAAM,gBAAgB;AACpB,SAAO,MAAM,SAAS,KAAK,cAAc,OAAO;AAC9C;AAEA,GADY,MAAM,OAAO,EACpB;;;CAIT,MAAM,WAAc,OAAqC;AACvD,SAAO,IAAI,SAAY,SAAS,WAAW;AACzC,SAAM,WAAW;AACf,QAAI,CAAC,MACF,UAAU;AACT;AACA,cAAS;AACT,aAAQ,MAAM;QAEf,UAAU;AACT;AACA,cAAS;AACT,YAAO,MAAM;MAEhB;KACD;AACF,YAAS;IACT;;CAGJ,MAAM,YAAY,MAAc;AAC9B,UAAQ;AAER,WAAS;;AAGX,QAAO;EAAE;EAAS;EAAU;;AAI9B,MAAM,uBAAuB;;;;;AAM7B,SAAgB,cAAc,MAAc,OAA0C;AACpF,KAAI,qBAAqB,KAAK,KAAK,CACjC,QAAO;CAGT,MAAM,aAAa,MAAM;CACzB,MAAM,UACF,OAAO,eAAe,WACpB,aACA,OAAO,eAAe,WACpB,OAAO,SAAS,YAAY,GAAG,IAAI,mBACnC;AAER,KAAI,WAAW,oBAAoB,MACjC,QAAO;AAET,KAAI,WAAW,oBAAoB,OACjC,QAAO;AAET,KAAI,WAAW,oBAAoB,MACjC,QAAO;AAET,QAAO;;;;;;;;;;;;;AAcT,SAAgB,2BAA2B,UAAwC;CACjF,MAAM,SAAS,SAAS,QAAQ,IAAI,qBAAqB;AACzD,KAAI,CAAC,OACH;AAIF,KAAI,OAAO,SAAS,0BAAwB,CAC1C;CAEF,MAAM,QAAQ,OAAO,MAAM,UAAU;AACrC,KAAI,CAAC,MACH;AAEF,QAAO,KAAK,IAAI,OAAO,SAAS,MAAM,IAAI,GAAG,EAAE,eAAe;;;;;;;;;;AAWhE,SAAgB,sBAAsB,QAA2D;AAE/F,KAAI,WAAW,MACb,QAAO;EACL,UAAU,OAAO,QAAQ,OAAO,IAAI;EACpC,uBAAuB;EACxB;CAIH,MAAM,EAAE,gBAAgB,uBAAuB,SADP,OAAO,WAAW,WAAW,EAAE,gBAAgB,QAAQ,GAAG;AAIlG,KAAI,mBAAmB,QAAW;EAChC,MAAM,cAAc,KAAK,IAAI,gBAAgB,eAAe;EAC5D,MAAM,WAAW,eAAe,YAAY;AAE5C,SAAO;GACL,UAAU,OAAO,QAAQ,OAAO,SAAS,QAAQ,GAAG;GACpD,kBAAkB,aAAa;AAC7B,QAAI,CAAC,wBAAwB,aAAa,OACxC;IAEF,MAAM,cAAc,2BAA2B,SAAS;AACxD,QAAI,gBAAgB,OAElB,UAAS,SAAS,KAAK,IAAI,aAAa,YAAY,CAAC;;GAG1D;;CAIH,MAAM,YAAwC;EAC5C,iBAAiB,eAAe,YAAY,gBAAgB;EAC5D,QAAQ,eAAe,YAAY,OAAO;EAC1C,OAAO,eAAe,YAAY,MAAM;EACxC,YAAY,eAAe,YAAY,WAAW;EACnD;AAED,QAAO;EACL,UAAU,MAAM,OAAO,OAAO;AAE5B,UAAO,UADM,cAAc,MAAM,MAAM,EAChB,QAAQ,GAAG;;EAEpC,kBAAkB,aAAa;AAC7B,OAAI,CAAC,wBAAwB,aAAa,OACxC;GAEF,MAAM,cAAc,2BAA2B,SAAS;AACxD,OAAI,gBAAgB,OAGlB,WAAU,gBAAgB,SAAS,KAAK,IAAI,YAAY,iBAAiB,YAAY,CAAC;;EAG3F"}
|
|
@@ -13,24 +13,10 @@ interface RateLimitConfig {
|
|
|
13
13
|
*/
|
|
14
14
|
adaptToServerHeaders?: boolean;
|
|
15
15
|
}
|
|
16
|
-
interface
|
|
17
|
-
execute: <T>(fn: () => Promise<T>) => Promise<T>;
|
|
18
|
-
|
|
16
|
+
interface ThrottleManager {
|
|
17
|
+
execute: <T>(path: string, query: Record<string, unknown>, fn: () => Promise<T>) => Promise<T>;
|
|
18
|
+
adaptToResponse: (response: Response | undefined) => void;
|
|
19
19
|
}
|
|
20
|
-
/**
|
|
21
|
-
* Concurrency limiter: allows up to `initialLimit` requests to be in-flight
|
|
22
|
-
* at the same time. A slot is freed as soon as the request's promise settles
|
|
23
|
-
* (resolves or rejects), so throughput scales with how quickly requests
|
|
24
|
-
* complete rather than being artificially capped at N per second.
|
|
25
|
-
*/
|
|
26
|
-
declare function createThrottle(initialLimit: number): Throttle;
|
|
27
|
-
/**
|
|
28
|
-
* Extracts the quota (`q=`) value from the `X-RateLimit-Policy` response header.
|
|
29
|
-
* Returns `undefined` if the header is absent or unparseable.
|
|
30
|
-
*
|
|
31
|
-
* Example header: `"concurrent-requests";q=30`
|
|
32
|
-
*/
|
|
33
|
-
declare function parseRateLimitPolicyHeader(response: Response): number | undefined;
|
|
34
20
|
//#endregion
|
|
35
|
-
export { RateLimitConfig,
|
|
21
|
+
export { RateLimitConfig, ThrottleManager };
|
|
36
22
|
//# sourceMappingURL=rate-limit.d.cts.map
|
|
@@ -13,24 +13,10 @@ interface RateLimitConfig {
|
|
|
13
13
|
*/
|
|
14
14
|
adaptToServerHeaders?: boolean;
|
|
15
15
|
}
|
|
16
|
-
interface
|
|
17
|
-
execute: <T>(fn: () => Promise<T>) => Promise<T>;
|
|
18
|
-
|
|
16
|
+
interface ThrottleManager {
|
|
17
|
+
execute: <T>(path: string, query: Record<string, unknown>, fn: () => Promise<T>) => Promise<T>;
|
|
18
|
+
adaptToResponse: (response: Response | undefined) => void;
|
|
19
19
|
}
|
|
20
|
-
/**
|
|
21
|
-
* Concurrency limiter: allows up to `initialLimit` requests to be in-flight
|
|
22
|
-
* at the same time. A slot is freed as soon as the request's promise settles
|
|
23
|
-
* (resolves or rejects), so throughput scales with how quickly requests
|
|
24
|
-
* complete rather than being artificially capped at N per second.
|
|
25
|
-
*/
|
|
26
|
-
declare function createThrottle(initialLimit: number): Throttle;
|
|
27
|
-
/**
|
|
28
|
-
* Extracts the quota (`q=`) value from the `X-RateLimit-Policy` response header.
|
|
29
|
-
* Returns `undefined` if the header is absent or unparseable.
|
|
30
|
-
*
|
|
31
|
-
* Example header: `"concurrent-requests";q=30`
|
|
32
|
-
*/
|
|
33
|
-
declare function parseRateLimitPolicyHeader(response: Response): number | undefined;
|
|
34
20
|
//#endregion
|
|
35
|
-
export { RateLimitConfig,
|
|
21
|
+
export { RateLimitConfig, ThrottleManager };
|
|
36
22
|
//# sourceMappingURL=rate-limit.d.mts.map
|
|
@@ -76,14 +76,20 @@ function determineTier(path, query) {
|
|
|
76
76
|
return "VERY_LARGE";
|
|
77
77
|
}
|
|
78
78
|
/**
|
|
79
|
-
* Extracts the quota (`q=`) value from the `X-RateLimit-Policy` response header
|
|
80
|
-
*
|
|
79
|
+
* Extracts the quota (`q=`) value from the `X-RateLimit-Policy` response header,
|
|
80
|
+
* but only when the policy describes a rate limit — not a concurrency limit.
|
|
81
81
|
*
|
|
82
|
-
*
|
|
82
|
+
* The Storyblok CDN currently returns `"concurrent-requests";q=30` which is a
|
|
83
|
+
* concurrency limit (always ~30), not a rate limit. Applying that value to the
|
|
84
|
+
* tier-based rate limiter would incorrectly cap throughput below what the API
|
|
85
|
+
* allows. This function therefore ignores concurrency policies and only returns
|
|
86
|
+
* a value for rate-limit policies (e.g. `"rate-limit";q=50`), which the API
|
|
87
|
+
* does not send yet but may in the future.
|
|
83
88
|
*/
|
|
84
89
|
function parseRateLimitPolicyHeader(response) {
|
|
85
90
|
const policy = response.headers.get("x-ratelimit-policy");
|
|
86
91
|
if (!policy) return;
|
|
92
|
+
if (policy.includes("\"concurrent-requests\"")) return;
|
|
87
93
|
const match = policy.match(/q=(\d+)/);
|
|
88
94
|
if (!match) return;
|
|
89
95
|
return Math.min(Number.parseInt(match[1], 10), MAX_RATE_LIMIT);
|
|
@@ -133,5 +139,5 @@ function createThrottleManager(config) {
|
|
|
133
139
|
}
|
|
134
140
|
|
|
135
141
|
//#endregion
|
|
136
|
-
export {
|
|
142
|
+
export { createThrottleManager };
|
|
137
143
|
//# sourceMappingURL=rate-limit.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rate-limit.mjs","names":[],"sources":["../../src/utils/rate-limit.ts"],"sourcesContent":["/**\n * Rate limiting for the Content API client.\n *\n * Provides both a simple token-bucket throttle and a tier-aware manager\n * that automatically selects the right concurrency limit based on request\n * type (single story vs. listing) and the per_page query parameter — mirroring\n * the tiers enforced server-side by the Storyblok CDN.\n */\n\nconst TIER_LIMITS = {\n SINGLE_OR_SMALL: 50, // single story fetch or per_page ≤ 25\n MEDIUM: 15, // per_page 26–50\n LARGE: 10, // per_page 51–75\n VERY_LARGE: 6, // per_page 76–100\n} as const;\n\ntype TierName = keyof typeof TIER_LIMITS;\n\nconst PER_PAGE_THRESHOLDS = {\n SMALL: 25,\n MEDIUM: 50,\n LARGE: 75,\n} as const;\n\nconst DEFAULT_PER_PAGE = 25;\nconst MAX_RATE_LIMIT = 1_000;\n\nexport interface RateLimitConfig {\n /**\n * Fixed maximum number of concurrent requests per second.\n * When set, disables automatic per_page tier detection and all requests\n * share a single queue at this limit. Capped at 1000.\n */\n maxConcurrency?: number;\n /**\n * Dynamically adjust the rate limit based on the `X-RateLimit-Policy`\n * response header returned by the Storyblok API.\n * @default true\n */\n adaptToServerHeaders?: boolean;\n}\n\nexport interface ThrottleManager {\n execute: <T>(path: string, query: Record<string, unknown>, fn: () => Promise<T>) => Promise<T>;\n adaptToResponse: (response: Response | undefined) => void;\n}\n\ninterface Throttle {\n execute: <T>(fn: () => Promise<T>) => Promise<T>;\n setLimit: (n: number) => void;\n}\n\n/**\n * Concurrency limiter: allows up to `initialLimit` requests to be in-flight\n * at the same time. A slot is freed as soon as the request's promise settles\n * (resolves or rejects), so throughput scales with how quickly requests\n * complete rather than being artificially capped at N per second.\n */\nexport function createThrottle(initialLimit: number): Throttle {\n let limit = initialLimit;\n let activeCount = 0;\n const queue: Array<() => void> = [];\n\n const tryNext = () => {\n while (queue.length > 0 && activeCount < limit) {\n activeCount++;\n const run = queue.shift()!;\n run();\n }\n };\n\n const execute = <T>(fn: () => Promise<T>): Promise<T> => {\n return new Promise<T>((resolve, reject) => {\n queue.push(() => {\n fn().then(\n (value) => {\n activeCount--;\n tryNext();\n resolve(value);\n },\n (error) => {\n activeCount--;\n tryNext();\n reject(error);\n },\n );\n });\n tryNext();\n });\n };\n\n const setLimit = (n: number) => {\n limit = n;\n // If the limit increased, unblock any waiting requests.\n tryNext();\n };\n\n return { execute, setLimit };\n}\n\n// Matches /v2/cdn/stories/<identifier> — a single story fetch (including nested slugs).\nconst SINGLE_STORY_PATH_RE = /\\/v2\\/cdn\\/stories\\/.+$/;\n\n/**\n * Maps a request path + query to one of the four rate-limit tiers.\n * Used by the auto-detection mode of `createThrottleManager`.\n */\nexport function determineTier(path: string, query: Record<string, unknown>): TierName {\n if (SINGLE_STORY_PATH_RE.test(path)) {\n return 'SINGLE_OR_SMALL';\n }\n\n const rawPerPage = query.per_page;\n const perPage\n = typeof rawPerPage === 'number'\n ? rawPerPage\n : typeof rawPerPage === 'string'\n ? Number.parseInt(rawPerPage, 10) || DEFAULT_PER_PAGE\n : DEFAULT_PER_PAGE;\n\n if (perPage <= PER_PAGE_THRESHOLDS.SMALL) {\n return 'SINGLE_OR_SMALL';\n }\n if (perPage <= PER_PAGE_THRESHOLDS.MEDIUM) {\n return 'MEDIUM';\n }\n if (perPage <= PER_PAGE_THRESHOLDS.LARGE) {\n return 'LARGE';\n }\n return 'VERY_LARGE';\n}\n\n/**\n * Extracts the quota (`q=`) value from the `X-RateLimit-Policy` response header
|
|
1
|
+
{"version":3,"file":"rate-limit.mjs","names":[],"sources":["../../src/utils/rate-limit.ts"],"sourcesContent":["/**\n * Rate limiting for the Content API client.\n *\n * Provides both a simple token-bucket throttle and a tier-aware manager\n * that automatically selects the right concurrency limit based on request\n * type (single story vs. listing) and the per_page query parameter — mirroring\n * the tiers enforced server-side by the Storyblok CDN.\n */\n\nconst TIER_LIMITS = {\n SINGLE_OR_SMALL: 50, // single story fetch or per_page ≤ 25\n MEDIUM: 15, // per_page 26–50\n LARGE: 10, // per_page 51–75\n VERY_LARGE: 6, // per_page 76–100\n} as const;\n\ntype TierName = keyof typeof TIER_LIMITS;\n\nconst PER_PAGE_THRESHOLDS = {\n SMALL: 25,\n MEDIUM: 50,\n LARGE: 75,\n} as const;\n\nconst DEFAULT_PER_PAGE = 25;\nconst MAX_RATE_LIMIT = 1_000;\n\nexport interface RateLimitConfig {\n /**\n * Fixed maximum number of concurrent requests per second.\n * When set, disables automatic per_page tier detection and all requests\n * share a single queue at this limit. Capped at 1000.\n */\n maxConcurrency?: number;\n /**\n * Dynamically adjust the rate limit based on the `X-RateLimit-Policy`\n * response header returned by the Storyblok API.\n * @default true\n */\n adaptToServerHeaders?: boolean;\n}\n\nexport interface ThrottleManager {\n execute: <T>(path: string, query: Record<string, unknown>, fn: () => Promise<T>) => Promise<T>;\n adaptToResponse: (response: Response | undefined) => void;\n}\n\ninterface Throttle {\n execute: <T>(fn: () => Promise<T>) => Promise<T>;\n setLimit: (n: number) => void;\n}\n\n/**\n * Concurrency limiter: allows up to `initialLimit` requests to be in-flight\n * at the same time. A slot is freed as soon as the request's promise settles\n * (resolves or rejects), so throughput scales with how quickly requests\n * complete rather than being artificially capped at N per second.\n */\nexport function createThrottle(initialLimit: number): Throttle {\n let limit = initialLimit;\n let activeCount = 0;\n const queue: Array<() => void> = [];\n\n const tryNext = () => {\n while (queue.length > 0 && activeCount < limit) {\n activeCount++;\n const run = queue.shift()!;\n run();\n }\n };\n\n const execute = <T>(fn: () => Promise<T>): Promise<T> => {\n return new Promise<T>((resolve, reject) => {\n queue.push(() => {\n fn().then(\n (value) => {\n activeCount--;\n tryNext();\n resolve(value);\n },\n (error) => {\n activeCount--;\n tryNext();\n reject(error);\n },\n );\n });\n tryNext();\n });\n };\n\n const setLimit = (n: number) => {\n limit = n;\n // If the limit increased, unblock any waiting requests.\n tryNext();\n };\n\n return { execute, setLimit };\n}\n\n// Matches /v2/cdn/stories/<identifier> — a single story fetch (including nested slugs).\nconst SINGLE_STORY_PATH_RE = /\\/v2\\/cdn\\/stories\\/.+$/;\n\n/**\n * Maps a request path + query to one of the four rate-limit tiers.\n * Used by the auto-detection mode of `createThrottleManager`.\n */\nexport function determineTier(path: string, query: Record<string, unknown>): TierName {\n if (SINGLE_STORY_PATH_RE.test(path)) {\n return 'SINGLE_OR_SMALL';\n }\n\n const rawPerPage = query.per_page;\n const perPage\n = typeof rawPerPage === 'number'\n ? rawPerPage\n : typeof rawPerPage === 'string'\n ? Number.parseInt(rawPerPage, 10) || DEFAULT_PER_PAGE\n : DEFAULT_PER_PAGE;\n\n if (perPage <= PER_PAGE_THRESHOLDS.SMALL) {\n return 'SINGLE_OR_SMALL';\n }\n if (perPage <= PER_PAGE_THRESHOLDS.MEDIUM) {\n return 'MEDIUM';\n }\n if (perPage <= PER_PAGE_THRESHOLDS.LARGE) {\n return 'LARGE';\n }\n return 'VERY_LARGE';\n}\n\n/**\n * Extracts the quota (`q=`) value from the `X-RateLimit-Policy` response header,\n * but only when the policy describes a rate limit — not a concurrency limit.\n *\n * The Storyblok CDN currently returns `\"concurrent-requests\";q=30` which is a\n * concurrency limit (always ~30), not a rate limit. Applying that value to the\n * tier-based rate limiter would incorrectly cap throughput below what the API\n * allows. This function therefore ignores concurrency policies and only returns\n * a value for rate-limit policies (e.g. `\"rate-limit\";q=50`), which the API\n * does not send yet but may in the future.\n */\nexport function parseRateLimitPolicyHeader(response: Response): number | undefined {\n const policy = response.headers.get('x-ratelimit-policy');\n if (!policy) {\n return undefined;\n }\n // Only act on rate-limit policies — skip concurrency limits like\n // \"concurrent-requests\";q=30 which represent a different constraint.\n if (policy.includes('\"concurrent-requests\"')) {\n return undefined;\n }\n const match = policy.match(/q=(\\d+)/);\n if (!match) {\n return undefined;\n }\n return Math.min(Number.parseInt(match[1], 10), MAX_RATE_LIMIT);\n}\n\n/**\n * Creates a `ThrottleManager` from the user-supplied `rateLimit` config.\n *\n * - `false` → no throttling (passthrough)\n * - `number` → fixed single queue at that limit\n * - `{ maxConcurrency: n }` → fixed single queue at n req/s\n * - `{}` / `undefined` (default)→ auto-detect tier from path + per_page\n */\nexport function createThrottleManager(config: RateLimitConfig | number | false): ThrottleManager {\n // Disabled — every request goes straight through.\n if (config === false) {\n return {\n execute: (_path, _query, fn) => fn(),\n adaptToResponse: () => {},\n };\n }\n\n const resolvedConfig: RateLimitConfig = typeof config === 'number' ? { maxConcurrency: config } : config;\n const { maxConcurrency, adaptToServerHeaders = true } = resolvedConfig;\n\n // Fixed-limit mode — single queue, optional server-header adaptation.\n if (maxConcurrency !== undefined) {\n const cappedLimit = Math.min(maxConcurrency, MAX_RATE_LIMIT);\n const throttle = createThrottle(cappedLimit);\n\n return {\n execute: (_path, _query, fn) => throttle.execute(fn),\n adaptToResponse: (response) => {\n if (!adaptToServerHeaders || response === undefined) {\n return;\n }\n const serverLimit = parseRateLimitPolicyHeader(response);\n if (serverLimit !== undefined) {\n // Never exceed the user-configured ceiling.\n throttle.setLimit(Math.min(cappedLimit, serverLimit));\n }\n },\n };\n }\n\n // Auto-detect mode — one throttle per tier, tier chosen per request.\n const throttles: Record<TierName, Throttle> = {\n SINGLE_OR_SMALL: createThrottle(TIER_LIMITS.SINGLE_OR_SMALL),\n MEDIUM: createThrottle(TIER_LIMITS.MEDIUM),\n LARGE: createThrottle(TIER_LIMITS.LARGE),\n VERY_LARGE: createThrottle(TIER_LIMITS.VERY_LARGE),\n };\n\n return {\n execute: (path, query, fn) => {\n const tier = determineTier(path, query);\n return throttles[tier].execute(fn);\n },\n adaptToResponse: (response) => {\n if (!adaptToServerHeaders || response === undefined) {\n return;\n }\n const serverLimit = parseRateLimitPolicyHeader(response);\n if (serverLimit !== undefined) {\n // The SINGLE_OR_SMALL tier is the most common; adapting it covers the\n // majority of requests. Other tiers are already conservatively limited.\n throttles.SINGLE_OR_SMALL.setLimit(Math.min(TIER_LIMITS.SINGLE_OR_SMALL, serverLimit));\n }\n },\n };\n}\n"],"mappings":";;;;;;;;;AASA,MAAM,cAAc;CAClB,iBAAiB;CACjB,QAAQ;CACR,OAAO;CACP,YAAY;CACb;AAID,MAAM,sBAAsB;CAC1B,OAAO;CACP,QAAQ;CACR,OAAO;CACR;AAED,MAAM,mBAAmB;AACzB,MAAM,iBAAiB;;;;;;;AAiCvB,SAAgB,eAAe,cAAgC;CAC7D,IAAI,QAAQ;CACZ,IAAI,cAAc;CAClB,MAAM,QAA2B,EAAE;CAEnC,MAAM,gBAAgB;AACpB,SAAO,MAAM,SAAS,KAAK,cAAc,OAAO;AAC9C;AAEA,GADY,MAAM,OAAO,EACpB;;;CAIT,MAAM,WAAc,OAAqC;AACvD,SAAO,IAAI,SAAY,SAAS,WAAW;AACzC,SAAM,WAAW;AACf,QAAI,CAAC,MACF,UAAU;AACT;AACA,cAAS;AACT,aAAQ,MAAM;QAEf,UAAU;AACT;AACA,cAAS;AACT,YAAO,MAAM;MAEhB;KACD;AACF,YAAS;IACT;;CAGJ,MAAM,YAAY,MAAc;AAC9B,UAAQ;AAER,WAAS;;AAGX,QAAO;EAAE;EAAS;EAAU;;AAI9B,MAAM,uBAAuB;;;;;AAM7B,SAAgB,cAAc,MAAc,OAA0C;AACpF,KAAI,qBAAqB,KAAK,KAAK,CACjC,QAAO;CAGT,MAAM,aAAa,MAAM;CACzB,MAAM,UACF,OAAO,eAAe,WACpB,aACA,OAAO,eAAe,WACpB,OAAO,SAAS,YAAY,GAAG,IAAI,mBACnC;AAER,KAAI,WAAW,oBAAoB,MACjC,QAAO;AAET,KAAI,WAAW,oBAAoB,OACjC,QAAO;AAET,KAAI,WAAW,oBAAoB,MACjC,QAAO;AAET,QAAO;;;;;;;;;;;;;AAcT,SAAgB,2BAA2B,UAAwC;CACjF,MAAM,SAAS,SAAS,QAAQ,IAAI,qBAAqB;AACzD,KAAI,CAAC,OACH;AAIF,KAAI,OAAO,SAAS,0BAAwB,CAC1C;CAEF,MAAM,QAAQ,OAAO,MAAM,UAAU;AACrC,KAAI,CAAC,MACH;AAEF,QAAO,KAAK,IAAI,OAAO,SAAS,MAAM,IAAI,GAAG,EAAE,eAAe;;;;;;;;;;AAWhE,SAAgB,sBAAsB,QAA2D;AAE/F,KAAI,WAAW,MACb,QAAO;EACL,UAAU,OAAO,QAAQ,OAAO,IAAI;EACpC,uBAAuB;EACxB;CAIH,MAAM,EAAE,gBAAgB,uBAAuB,SADP,OAAO,WAAW,WAAW,EAAE,gBAAgB,QAAQ,GAAG;AAIlG,KAAI,mBAAmB,QAAW;EAChC,MAAM,cAAc,KAAK,IAAI,gBAAgB,eAAe;EAC5D,MAAM,WAAW,eAAe,YAAY;AAE5C,SAAO;GACL,UAAU,OAAO,QAAQ,OAAO,SAAS,QAAQ,GAAG;GACpD,kBAAkB,aAAa;AAC7B,QAAI,CAAC,wBAAwB,aAAa,OACxC;IAEF,MAAM,cAAc,2BAA2B,SAAS;AACxD,QAAI,gBAAgB,OAElB,UAAS,SAAS,KAAK,IAAI,aAAa,YAAY,CAAC;;GAG1D;;CAIH,MAAM,YAAwC;EAC5C,iBAAiB,eAAe,YAAY,gBAAgB;EAC5D,QAAQ,eAAe,YAAY,OAAO;EAC1C,OAAO,eAAe,YAAY,MAAM;EACxC,YAAY,eAAe,YAAY,WAAW;EACnD;AAED,QAAO;EACL,UAAU,MAAM,OAAO,OAAO;AAE5B,UAAO,UADM,cAAc,MAAM,MAAM,EAChB,QAAQ,GAAG;;EAEpC,kBAAkB,aAAa;AAC7B,OAAI,CAAC,wBAAwB,aAAa,OACxC;GAEF,MAAM,cAAc,2BAA2B,SAAS;AACxD,OAAI,gBAAgB,OAGlB,WAAU,gBAAgB,SAAS,KAAK,IAAI,YAAY,iBAAiB,YAAY,CAAC;;EAG3F"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@storyblok/api-client",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "1.0.0-alpha.1",
|
|
5
5
|
"private": false,
|
|
6
6
|
"description": "Storyblok Content Delivery API Client",
|
|
7
7
|
"author": "",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"homepage": "https://github.com/storyblok/monoblok/tree/main/packages/capi-client#readme",
|
|
10
10
|
"repository": {
|
|
11
11
|
"type": "git",
|
|
12
|
-
"url": "https://github.com/storyblok/monoblok.git",
|
|
12
|
+
"url": "git+https://github.com/storyblok/monoblok.git",
|
|
13
13
|
"directory": "packages/capi-client"
|
|
14
14
|
},
|
|
15
15
|
"bugs": {
|
|
@@ -31,27 +31,35 @@
|
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"ky": "^1.14.3",
|
|
34
|
-
"@storyblok/region-helper": "1.4.0"
|
|
34
|
+
"@storyblok/region-helper": "1.4.0",
|
|
35
|
+
"@storyblok/schema": "1.0.0-alpha.0"
|
|
35
36
|
},
|
|
36
37
|
"devDependencies": {
|
|
37
38
|
"@hey-api/openapi-ts": "^0.92.3",
|
|
38
39
|
"@msw/source": "^0.6.1",
|
|
39
40
|
"@types/node": "^24.11.0",
|
|
41
|
+
"dotenv": "^17.3.1",
|
|
40
42
|
"eslint": "^9.39.2",
|
|
41
43
|
"glob": "^13.0.6",
|
|
42
44
|
"msw": "^2.12.9",
|
|
43
45
|
"pathe": "^2.0.3",
|
|
44
46
|
"tsdown": "^0.20.3",
|
|
45
47
|
"tsx": "^4.21.0",
|
|
46
|
-
"vitest": "^4.
|
|
48
|
+
"vitest": "^4.1.3",
|
|
47
49
|
"@storyblok/eslint-config": "0.5.0",
|
|
48
|
-
"@storyblok/
|
|
50
|
+
"@storyblok/management-api-client": "1.0.0-alpha.1",
|
|
51
|
+
"@storyblok/openapi": "2.1.0-alpha.0"
|
|
49
52
|
},
|
|
50
53
|
"nx": {
|
|
51
54
|
"targets": {
|
|
52
55
|
"generate": {
|
|
53
56
|
"dependsOn": [
|
|
54
|
-
|
|
57
|
+
{
|
|
58
|
+
"projects": [
|
|
59
|
+
"@storyblok/openapi"
|
|
60
|
+
],
|
|
61
|
+
"target": "build"
|
|
62
|
+
}
|
|
55
63
|
],
|
|
56
64
|
"outputs": [
|
|
57
65
|
"{projectRoot}/src/generated/**"
|
|
@@ -72,8 +80,10 @@
|
|
|
72
80
|
"generate": "tsx scripts/generate.ts",
|
|
73
81
|
"build": "tsdown",
|
|
74
82
|
"test": "vitest run",
|
|
83
|
+
"test:types": "tsc --noEmit --skipLibCheck",
|
|
84
|
+
"test:e2e": "vitest run -c vitest.config.e2e.ts",
|
|
85
|
+
"coverage": "vitest run --coverage",
|
|
75
86
|
"lint": "eslint .",
|
|
76
|
-
"lint:fix": "eslint . --fix"
|
|
77
|
-
"typecheck": "tsc --noEmit"
|
|
87
|
+
"lint:fix": "eslint . --fix"
|
|
78
88
|
}
|
|
79
89
|
}
|
package/dist/index.cjs.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.cjs","names":["createThrottleManager","createMemoryCacheProvider","createStrategy","createClient","createConfig","ClientError","extractCv","applyCvToQuery","shouldUseCache","createCacheKey","createStoriesResource","createDatasourceEntriesResource","createDatasourcesResource","createLinksResource","createSpacesResource","createTagsResource"],"sources":["../src/index.ts"],"sourcesContent":["import { createClient, createConfig } from './generated/shared/client';\nimport type { StoryCapi } from './generated/stories';\nimport type { CacheProvider, CacheStrategy, CacheStrategyHandler } from './utils/cache';\nimport { createMemoryCacheProvider, createStrategy } from './utils/cache';\nimport { ClientError } from './error';\nimport type { RateLimitConfig } from './utils/rate-limit';\nimport { createThrottleManager } from './utils/rate-limit';\nimport { applyCvToQuery, extractCv } from './utils/cv';\nimport { createCacheKey, shouldUseCache } from './utils/request';\nimport { getRegionBaseUrl, type Region } from '@storyblok/region-helper';\nimport type { RetryOptions } from 'ky';\nimport type { Client } from './generated/shared/client';\nimport type { ApiResponse, FetchOptions, HttpRequestMethod, HttpRequestOptions, RequestWithCacheOptions, ResourceDeps } from './types';\nimport { createStoriesResource } from './resources/stories';\nimport { createLinksResource } from './resources/links';\nimport { createTagsResource } from './resources/tags';\nimport { createDatasourcesResource } from './resources/datasources';\nimport { createDatasourceEntriesResource } from './resources/datasource-entries';\nimport { createSpacesResource } from './resources/spaces';\n\ntype Prettify<T> = {\n [K in keyof T]: T[K];\n} & {};\n\nexport type Story = Prettify<StoryCapi>;\nexport { ClientError } from './error';\nexport type { DatasourceEntryCapi as DatasourceEntry } from './generated/datasource_entries/types.gen';\nexport type { DatasourceCapi as Datasource } from './generated/datasources/types.gen';\nexport type { LinkCapi as Link } from './generated/links/types.gen';\nexport type { Middleware } from './generated/shared/client/utils.gen';\nexport type { SpaceCapi as Space } from './generated/spaces/types.gen';\nexport type { ApiResponse, FetchOptions, HttpRequestMethod, HttpRequestOptions };\nexport type { CacheProvider, CacheStrategy, CacheStrategyHandler };\nexport type { RateLimitConfig };\nexport type { TagCapi as Tag } from './generated/tags/types.gen';\nexport type { StoryWithInlinedRelations } from './resources/stories';\n\nexport { createThrottle, parseRateLimitPolicyHeader } from './utils/rate-limit';\n\n/**\n * Cache configuration.\n *\n * **Note:** Requests with `version: 'draft'` always bypass the cache regardless\n * of the configured strategy. Only published content is cached.\n */\ninterface CacheConfig {\n /** Custom cache provider. Defaults to an in-memory LRU cache (1 000 entries). */\n provider?: CacheProvider;\n /** Cache strategy for published requests. @default 'cache-first' */\n strategy?: CacheStrategy | CacheStrategyHandler;\n /** Time-to-live in milliseconds for cached entries. @default 60_000 */\n ttlMs?: number;\n /**\n * Controls when the cache is flushed on cv change.\n *\n * - `'auto'` (default): automatically flush the cache whenever the API returns a new cv value.\n * - `'manual'`: never auto-flush; call `client.flushCache()` explicitly (e.g. on webhook trigger).\n */\n flush?: 'auto' | 'manual';\n /**\n * Called when SWR background revalidation fails.\n * Only relevant when `strategy` is `'swr'`.\n * @default console.warn\n */\n onRevalidationError?: (error: unknown) => void;\n}\n\nexport interface ContentApiClientConfig<\n ThrowOnError extends boolean = false,\n InlineRelations extends boolean = false,\n> {\n accessToken: string;\n region?: Region;\n baseUrl?: string;\n headers?: Record<string, string>;\n throwOnError?: ThrowOnError;\n cache?: CacheConfig;\n inlineRelations?: InlineRelations;\n retry?: RetryOptions;\n /**\n * Request timeout in milliseconds.\n * @default 30_000\n */\n timeout?: number;\n /**\n * Preventive rate limiting to avoid hitting the Storyblok CDN rate limits.\n *\n * - `undefined` (default): auto-detect tier from path + `per_page` query param.\n * - `number`: fixed max concurrent requests per second (single queue).\n * - `{ maxConcurrency?: number; adaptToServerHeaders?: boolean }`: full config.\n * - `false`: disable rate limiting entirely.\n */\n rateLimit?: RateLimitConfig | number | false;\n /**\n * Custom `fetch` function to use for all requests.\n * Must be fully compatible with the Fetch API standard.\n *\n * Use cases:\n * - SSR framework fetch wrappers (e.g., Next.js `fetch` with caching)\n * - Custom instrumentation or logging around requests\n *\n * @default globalThis.fetch\n */\n fetch?: typeof globalThis.fetch;\n}\n\nexport const createApiClient = <\n ThrowOnError extends boolean = false,\n InlineRelations extends boolean = false,\n>(\n config: ContentApiClientConfig<ThrowOnError, InlineRelations>,\n) => {\n const {\n accessToken,\n region = 'eu',\n baseUrl,\n headers = {},\n throwOnError = false,\n cache = {},\n inlineRelations = false,\n retry,\n timeout = 30_000,\n rateLimit,\n fetch: customFetch,\n } = config;\n const retryOptions: RetryOptions = { limit: 3, backoffLimit: 20_000, jitter: true, ...retry };\n // `rateLimit` defaults to `{}` (auto-detect mode) when not supplied.\n const throttleManager = createThrottleManager(rateLimit ?? {});\n const cacheProvider = cache.provider ?? createMemoryCacheProvider();\n const swrOptions = cache.onRevalidationError ? { onRevalidationError: cache.onRevalidationError } : undefined;\n const strategy = cache.strategy\n ? typeof cache.strategy === 'string'\n ? createStrategy(cache.strategy, swrOptions)\n : cache.strategy\n : createStrategy('cache-first');\n const cacheTtlMs = cache.ttlMs ?? 60_000;\n const cacheFlush = cache.flush ?? 'auto';\n let currentCv: number | undefined;\n\n const client: Client = createClient(\n createConfig({\n auth: accessToken,\n baseUrl: baseUrl || getRegionBaseUrl(region),\n headers,\n throwOnError,\n kyOptions: {\n // Enable `throwHttpErrors` to make retry work, even if `throwOnError`\n // is `false`. The client's error handling will still work because it\n // catches `HTTPError`.\n throwHttpErrors: true,\n timeout,\n retry: retryOptions,\n ...(customFetch && { fetch: customFetch }),\n },\n }),\n );\n\n client.interceptors.error.use(\n (error: unknown, response: Response) =>\n new ClientError(response?.statusText || 'API request failed', {\n status: response?.status ?? 0,\n statusText: response?.statusText ?? '',\n data: error,\n }),\n );\n\n const security = [\n {\n in: 'query' as const,\n name: 'token',\n type: 'apiKey' as const,\n },\n ];\n\n const updateCv = async (result: ApiResponse): Promise<boolean> => {\n const nextCv = extractCv(result.data);\n if (nextCv === undefined) {\n return true;\n }\n\n // Guard against cv regression: SWR background revalidation may carry a\n // stale cv from a prior request; never move cv backward.\n if (currentCv !== undefined && nextCv < currentCv) {\n return false;\n }\n\n if (cacheFlush === 'auto' && currentCv !== undefined && currentCv !== nextCv) {\n await cacheProvider.flush();\n }\n\n currentCv = nextCv;\n return true;\n };\n\n const cacheSuccessResult = async <TResponse extends ApiResponse>(key: string, result: TResponse) => {\n const shouldCacheResult = await updateCv(result);\n if (result.error === undefined && shouldCacheResult) {\n await cacheProvider.set(key, {\n value: result,\n ttlMs: cacheTtlMs,\n });\n }\n return result;\n };\n\n const requestNetwork = async (\n method: 'GET',\n path: string,\n query: Record<string, unknown>,\n options: HttpRequestOptions,\n ): Promise<ApiResponse> => {\n return client.request<unknown, ClientError, boolean>({\n ...options,\n method,\n query,\n security,\n url: path,\n });\n };\n\n /**\n * Wraps a raw SDK call to cast the `error: unknown` type returned by\n * generated code to `ClientError` — the error interceptor ensures the\n * runtime value IS a ClientError.\n */\n const asApiResponse = <TData, ThrowOnError extends boolean = false>(\n p: Promise<unknown>,\n ): Promise<ApiResponse<TData, ThrowOnError>> => p as unknown as Promise<ApiResponse<TData, ThrowOnError>>;\n\n const requestWithCache = async <TData = unknown, ThrowOnError extends boolean = false>(\n method: 'GET',\n path: string,\n rawQuery: Record<string, unknown>,\n fetchFn: (query: Record<string, unknown>) => Promise<ApiResponse<TData, ThrowOnError>>,\n cacheOptions?: RequestWithCacheOptions,\n ): Promise<ApiResponse<TData, ThrowOnError>> => {\n const query = currentCv !== undefined ? applyCvToQuery(rawQuery, currentCv) : rawQuery;\n const cacheEnabled = shouldUseCache(method, path, rawQuery);\n\n if (!cacheEnabled) {\n const networkResult = await fetchFn(query);\n throttleManager.adaptToResponse(networkResult.response);\n await updateCv(networkResult);\n return networkResult;\n }\n\n const baseKey = createCacheKey(method, path, rawQuery);\n const key = cacheOptions?.cacheKeyPrefix ? `${cacheOptions.cacheKeyPrefix}:${baseKey}` : baseKey;\n const cachedEntry = await cacheProvider.get<ApiResponse<TData, ThrowOnError>>(key);\n const cachedResult = cachedEntry?.value;\n\n const loadNetwork = async () => {\n const result = await fetchFn(query);\n throttleManager.adaptToResponse(result.response);\n return cacheSuccessResult(key, result);\n };\n\n return strategy({\n key,\n cachedResult,\n loadNetwork,\n });\n };\n\n const request = async (\n method: 'GET',\n path: string,\n options: HttpRequestOptions = {},\n ): Promise<ApiResponse> => {\n const rawQuery = options.query || {};\n\n return requestWithCache(method, path, rawQuery, (query) => {\n return throttleManager.execute(path, rawQuery, () => requestNetwork(method, path, query, options));\n });\n };\n\n const getRequest = (\n path: string,\n options: HttpRequestOptions = {},\n ) => {\n return request('GET', path, options);\n };\n\n const resourceDeps: ResourceDeps = {\n client,\n requestWithCache,\n asApiResponse,\n throttleManager,\n };\n\n const stories = createStoriesResource<InlineRelations>({\n ...resourceDeps,\n inlineRelations,\n });\n\n /**\n * Flush the in-memory cache and reset the tracked cv.\n *\n * Call this explicitly when `cache.flush` is set to `'manual'`, e.g. after\n * receiving a Storyblok webhook event that signals content has changed.\n */\n const flushCache = async (): Promise<void> => {\n await cacheProvider.flush();\n currentCv = undefined;\n };\n\n return {\n datasourceEntries: createDatasourceEntriesResource(resourceDeps),\n datasources: createDatasourcesResource(resourceDeps),\n flushCache,\n get: getRequest,\n interceptors: client.interceptors,\n links: createLinksResource(resourceDeps),\n spaces: createSpacesResource(resourceDeps),\n stories,\n tags: createTagsResource(resourceDeps),\n };\n};\n"],"mappings":"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"}
|
package/dist/index.mjs.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../src/index.ts"],"sourcesContent":["import { createClient, createConfig } from './generated/shared/client';\nimport type { StoryCapi } from './generated/stories';\nimport type { CacheProvider, CacheStrategy, CacheStrategyHandler } from './utils/cache';\nimport { createMemoryCacheProvider, createStrategy } from './utils/cache';\nimport { ClientError } from './error';\nimport type { RateLimitConfig } from './utils/rate-limit';\nimport { createThrottleManager } from './utils/rate-limit';\nimport { applyCvToQuery, extractCv } from './utils/cv';\nimport { createCacheKey, shouldUseCache } from './utils/request';\nimport { getRegionBaseUrl, type Region } from '@storyblok/region-helper';\nimport type { RetryOptions } from 'ky';\nimport type { Client } from './generated/shared/client';\nimport type { ApiResponse, FetchOptions, HttpRequestMethod, HttpRequestOptions, RequestWithCacheOptions, ResourceDeps } from './types';\nimport { createStoriesResource } from './resources/stories';\nimport { createLinksResource } from './resources/links';\nimport { createTagsResource } from './resources/tags';\nimport { createDatasourcesResource } from './resources/datasources';\nimport { createDatasourceEntriesResource } from './resources/datasource-entries';\nimport { createSpacesResource } from './resources/spaces';\n\ntype Prettify<T> = {\n [K in keyof T]: T[K];\n} & {};\n\nexport type Story = Prettify<StoryCapi>;\nexport { ClientError } from './error';\nexport type { DatasourceEntryCapi as DatasourceEntry } from './generated/datasource_entries/types.gen';\nexport type { DatasourceCapi as Datasource } from './generated/datasources/types.gen';\nexport type { LinkCapi as Link } from './generated/links/types.gen';\nexport type { Middleware } from './generated/shared/client/utils.gen';\nexport type { SpaceCapi as Space } from './generated/spaces/types.gen';\nexport type { ApiResponse, FetchOptions, HttpRequestMethod, HttpRequestOptions };\nexport type { CacheProvider, CacheStrategy, CacheStrategyHandler };\nexport type { RateLimitConfig };\nexport type { TagCapi as Tag } from './generated/tags/types.gen';\nexport type { StoryWithInlinedRelations } from './resources/stories';\n\nexport { createThrottle, parseRateLimitPolicyHeader } from './utils/rate-limit';\n\n/**\n * Cache configuration.\n *\n * **Note:** Requests with `version: 'draft'` always bypass the cache regardless\n * of the configured strategy. Only published content is cached.\n */\ninterface CacheConfig {\n /** Custom cache provider. Defaults to an in-memory LRU cache (1 000 entries). */\n provider?: CacheProvider;\n /** Cache strategy for published requests. @default 'cache-first' */\n strategy?: CacheStrategy | CacheStrategyHandler;\n /** Time-to-live in milliseconds for cached entries. @default 60_000 */\n ttlMs?: number;\n /**\n * Controls when the cache is flushed on cv change.\n *\n * - `'auto'` (default): automatically flush the cache whenever the API returns a new cv value.\n * - `'manual'`: never auto-flush; call `client.flushCache()` explicitly (e.g. on webhook trigger).\n */\n flush?: 'auto' | 'manual';\n /**\n * Called when SWR background revalidation fails.\n * Only relevant when `strategy` is `'swr'`.\n * @default console.warn\n */\n onRevalidationError?: (error: unknown) => void;\n}\n\nexport interface ContentApiClientConfig<\n ThrowOnError extends boolean = false,\n InlineRelations extends boolean = false,\n> {\n accessToken: string;\n region?: Region;\n baseUrl?: string;\n headers?: Record<string, string>;\n throwOnError?: ThrowOnError;\n cache?: CacheConfig;\n inlineRelations?: InlineRelations;\n retry?: RetryOptions;\n /**\n * Request timeout in milliseconds.\n * @default 30_000\n */\n timeout?: number;\n /**\n * Preventive rate limiting to avoid hitting the Storyblok CDN rate limits.\n *\n * - `undefined` (default): auto-detect tier from path + `per_page` query param.\n * - `number`: fixed max concurrent requests per second (single queue).\n * - `{ maxConcurrency?: number; adaptToServerHeaders?: boolean }`: full config.\n * - `false`: disable rate limiting entirely.\n */\n rateLimit?: RateLimitConfig | number | false;\n /**\n * Custom `fetch` function to use for all requests.\n * Must be fully compatible with the Fetch API standard.\n *\n * Use cases:\n * - SSR framework fetch wrappers (e.g., Next.js `fetch` with caching)\n * - Custom instrumentation or logging around requests\n *\n * @default globalThis.fetch\n */\n fetch?: typeof globalThis.fetch;\n}\n\nexport const createApiClient = <\n ThrowOnError extends boolean = false,\n InlineRelations extends boolean = false,\n>(\n config: ContentApiClientConfig<ThrowOnError, InlineRelations>,\n) => {\n const {\n accessToken,\n region = 'eu',\n baseUrl,\n headers = {},\n throwOnError = false,\n cache = {},\n inlineRelations = false,\n retry,\n timeout = 30_000,\n rateLimit,\n fetch: customFetch,\n } = config;\n const retryOptions: RetryOptions = { limit: 3, backoffLimit: 20_000, jitter: true, ...retry };\n // `rateLimit` defaults to `{}` (auto-detect mode) when not supplied.\n const throttleManager = createThrottleManager(rateLimit ?? {});\n const cacheProvider = cache.provider ?? createMemoryCacheProvider();\n const swrOptions = cache.onRevalidationError ? { onRevalidationError: cache.onRevalidationError } : undefined;\n const strategy = cache.strategy\n ? typeof cache.strategy === 'string'\n ? createStrategy(cache.strategy, swrOptions)\n : cache.strategy\n : createStrategy('cache-first');\n const cacheTtlMs = cache.ttlMs ?? 60_000;\n const cacheFlush = cache.flush ?? 'auto';\n let currentCv: number | undefined;\n\n const client: Client = createClient(\n createConfig({\n auth: accessToken,\n baseUrl: baseUrl || getRegionBaseUrl(region),\n headers,\n throwOnError,\n kyOptions: {\n // Enable `throwHttpErrors` to make retry work, even if `throwOnError`\n // is `false`. The client's error handling will still work because it\n // catches `HTTPError`.\n throwHttpErrors: true,\n timeout,\n retry: retryOptions,\n ...(customFetch && { fetch: customFetch }),\n },\n }),\n );\n\n client.interceptors.error.use(\n (error: unknown, response: Response) =>\n new ClientError(response?.statusText || 'API request failed', {\n status: response?.status ?? 0,\n statusText: response?.statusText ?? '',\n data: error,\n }),\n );\n\n const security = [\n {\n in: 'query' as const,\n name: 'token',\n type: 'apiKey' as const,\n },\n ];\n\n const updateCv = async (result: ApiResponse): Promise<boolean> => {\n const nextCv = extractCv(result.data);\n if (nextCv === undefined) {\n return true;\n }\n\n // Guard against cv regression: SWR background revalidation may carry a\n // stale cv from a prior request; never move cv backward.\n if (currentCv !== undefined && nextCv < currentCv) {\n return false;\n }\n\n if (cacheFlush === 'auto' && currentCv !== undefined && currentCv !== nextCv) {\n await cacheProvider.flush();\n }\n\n currentCv = nextCv;\n return true;\n };\n\n const cacheSuccessResult = async <TResponse extends ApiResponse>(key: string, result: TResponse) => {\n const shouldCacheResult = await updateCv(result);\n if (result.error === undefined && shouldCacheResult) {\n await cacheProvider.set(key, {\n value: result,\n ttlMs: cacheTtlMs,\n });\n }\n return result;\n };\n\n const requestNetwork = async (\n method: 'GET',\n path: string,\n query: Record<string, unknown>,\n options: HttpRequestOptions,\n ): Promise<ApiResponse> => {\n return client.request<unknown, ClientError, boolean>({\n ...options,\n method,\n query,\n security,\n url: path,\n });\n };\n\n /**\n * Wraps a raw SDK call to cast the `error: unknown` type returned by\n * generated code to `ClientError` — the error interceptor ensures the\n * runtime value IS a ClientError.\n */\n const asApiResponse = <TData, ThrowOnError extends boolean = false>(\n p: Promise<unknown>,\n ): Promise<ApiResponse<TData, ThrowOnError>> => p as unknown as Promise<ApiResponse<TData, ThrowOnError>>;\n\n const requestWithCache = async <TData = unknown, ThrowOnError extends boolean = false>(\n method: 'GET',\n path: string,\n rawQuery: Record<string, unknown>,\n fetchFn: (query: Record<string, unknown>) => Promise<ApiResponse<TData, ThrowOnError>>,\n cacheOptions?: RequestWithCacheOptions,\n ): Promise<ApiResponse<TData, ThrowOnError>> => {\n const query = currentCv !== undefined ? applyCvToQuery(rawQuery, currentCv) : rawQuery;\n const cacheEnabled = shouldUseCache(method, path, rawQuery);\n\n if (!cacheEnabled) {\n const networkResult = await fetchFn(query);\n throttleManager.adaptToResponse(networkResult.response);\n await updateCv(networkResult);\n return networkResult;\n }\n\n const baseKey = createCacheKey(method, path, rawQuery);\n const key = cacheOptions?.cacheKeyPrefix ? `${cacheOptions.cacheKeyPrefix}:${baseKey}` : baseKey;\n const cachedEntry = await cacheProvider.get<ApiResponse<TData, ThrowOnError>>(key);\n const cachedResult = cachedEntry?.value;\n\n const loadNetwork = async () => {\n const result = await fetchFn(query);\n throttleManager.adaptToResponse(result.response);\n return cacheSuccessResult(key, result);\n };\n\n return strategy({\n key,\n cachedResult,\n loadNetwork,\n });\n };\n\n const request = async (\n method: 'GET',\n path: string,\n options: HttpRequestOptions = {},\n ): Promise<ApiResponse> => {\n const rawQuery = options.query || {};\n\n return requestWithCache(method, path, rawQuery, (query) => {\n return throttleManager.execute(path, rawQuery, () => requestNetwork(method, path, query, options));\n });\n };\n\n const getRequest = (\n path: string,\n options: HttpRequestOptions = {},\n ) => {\n return request('GET', path, options);\n };\n\n const resourceDeps: ResourceDeps = {\n client,\n requestWithCache,\n asApiResponse,\n throttleManager,\n };\n\n const stories = createStoriesResource<InlineRelations>({\n ...resourceDeps,\n inlineRelations,\n });\n\n /**\n * Flush the in-memory cache and reset the tracked cv.\n *\n * Call this explicitly when `cache.flush` is set to `'manual'`, e.g. after\n * receiving a Storyblok webhook event that signals content has changed.\n */\n const flushCache = async (): Promise<void> => {\n await cacheProvider.flush();\n currentCv = undefined;\n };\n\n return {\n datasourceEntries: createDatasourceEntriesResource(resourceDeps),\n datasources: createDatasourcesResource(resourceDeps),\n flushCache,\n get: getRequest,\n interceptors: client.interceptors,\n links: createLinksResource(resourceDeps),\n spaces: createSpacesResource(resourceDeps),\n stories,\n tags: createTagsResource(resourceDeps),\n };\n};\n"],"mappings":"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"}
|