@storyblok/api-client 0.1.0
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/LICENSE +21 -0
- package/README.md +74 -0
- package/dist/_virtual/_rolldown/runtime.cjs +29 -0
- package/dist/error.cjs +21 -0
- package/dist/error.cjs.map +1 -0
- package/dist/error.d.cts +19 -0
- package/dist/error.d.mts +19 -0
- package/dist/error.mjs +20 -0
- package/dist/error.mjs.map +1 -0
- package/dist/generated/datasource_entries/client.gen.cjs +10 -0
- package/dist/generated/datasource_entries/client.gen.cjs.map +1 -0
- package/dist/generated/datasource_entries/client.gen.mjs +10 -0
- package/dist/generated/datasource_entries/client.gen.mjs.map +1 -0
- package/dist/generated/datasource_entries/sdk.gen.cjs +21 -0
- package/dist/generated/datasource_entries/sdk.gen.cjs.map +1 -0
- package/dist/generated/datasource_entries/sdk.gen.mjs +21 -0
- package/dist/generated/datasource_entries/sdk.gen.mjs.map +1 -0
- package/dist/generated/datasource_entries/types.gen.d.cts +67 -0
- package/dist/generated/datasource_entries/types.gen.d.mts +67 -0
- package/dist/generated/datasources/client.gen.cjs +10 -0
- package/dist/generated/datasources/client.gen.cjs.map +1 -0
- package/dist/generated/datasources/client.gen.mjs +10 -0
- package/dist/generated/datasources/client.gen.mjs.map +1 -0
- package/dist/generated/datasources/sdk.gen.cjs +36 -0
- package/dist/generated/datasources/sdk.gen.cjs.map +1 -0
- package/dist/generated/datasources/sdk.gen.mjs +35 -0
- package/dist/generated/datasources/sdk.gen.mjs.map +1 -0
- package/dist/generated/datasources/types.gen.d.cts +109 -0
- package/dist/generated/datasources/types.gen.d.mts +109 -0
- package/dist/generated/links/client.gen.cjs +10 -0
- package/dist/generated/links/client.gen.cjs.map +1 -0
- package/dist/generated/links/client.gen.mjs +10 -0
- package/dist/generated/links/client.gen.mjs.map +1 -0
- package/dist/generated/links/sdk.gen.cjs +21 -0
- package/dist/generated/links/sdk.gen.cjs.map +1 -0
- package/dist/generated/links/sdk.gen.mjs +21 -0
- package/dist/generated/links/sdk.gen.mjs.map +1 -0
- package/dist/generated/links/types.gen.d.cts +142 -0
- package/dist/generated/links/types.gen.d.mts +142 -0
- package/dist/generated/shared/client/client.gen.cjs +215 -0
- package/dist/generated/shared/client/client.gen.cjs.map +1 -0
- package/dist/generated/shared/client/client.gen.mjs +213 -0
- package/dist/generated/shared/client/client.gen.mjs.map +1 -0
- package/dist/generated/shared/client/index.cjs +4 -0
- package/dist/generated/shared/client/index.mjs +6 -0
- package/dist/generated/shared/client/types.gen.d.cts +103 -0
- package/dist/generated/shared/client/types.gen.d.mts +103 -0
- package/dist/generated/shared/client/utils.gen.cjs +188 -0
- package/dist/generated/shared/client/utils.gen.cjs.map +1 -0
- package/dist/generated/shared/client/utils.gen.d.cts +21 -0
- package/dist/generated/shared/client/utils.gen.d.mts +21 -0
- package/dist/generated/shared/client/utils.gen.mjs +182 -0
- package/dist/generated/shared/client/utils.gen.mjs.map +1 -0
- package/dist/generated/shared/core/auth.gen.cjs +13 -0
- package/dist/generated/shared/core/auth.gen.cjs.map +1 -0
- package/dist/generated/shared/core/auth.gen.d.cts +21 -0
- package/dist/generated/shared/core/auth.gen.d.mts +21 -0
- package/dist/generated/shared/core/auth.gen.mjs +12 -0
- package/dist/generated/shared/core/auth.gen.mjs.map +1 -0
- package/dist/generated/shared/core/bodySerializer.gen.cjs +7 -0
- package/dist/generated/shared/core/bodySerializer.gen.cjs.map +1 -0
- package/dist/generated/shared/core/bodySerializer.gen.d.cts +20 -0
- package/dist/generated/shared/core/bodySerializer.gen.d.mts +20 -0
- package/dist/generated/shared/core/bodySerializer.gen.mjs +6 -0
- package/dist/generated/shared/core/bodySerializer.gen.mjs.map +1 -0
- package/dist/generated/shared/core/params.gen.cjs +11 -0
- package/dist/generated/shared/core/params.gen.cjs.map +1 -0
- package/dist/generated/shared/core/params.gen.mjs +11 -0
- package/dist/generated/shared/core/params.gen.mjs.map +1 -0
- package/dist/generated/shared/core/pathSerializer.gen.cjs +85 -0
- package/dist/generated/shared/core/pathSerializer.gen.cjs.map +1 -0
- package/dist/generated/shared/core/pathSerializer.gen.d.cts +13 -0
- package/dist/generated/shared/core/pathSerializer.gen.d.mts +13 -0
- package/dist/generated/shared/core/pathSerializer.gen.mjs +82 -0
- package/dist/generated/shared/core/pathSerializer.gen.mjs.map +1 -0
- package/dist/generated/shared/core/serverSentEvents.gen.cjs +96 -0
- package/dist/generated/shared/core/serverSentEvents.gen.cjs.map +1 -0
- package/dist/generated/shared/core/serverSentEvents.gen.d.cts +72 -0
- package/dist/generated/shared/core/serverSentEvents.gen.d.mts +72 -0
- package/dist/generated/shared/core/serverSentEvents.gen.mjs +95 -0
- package/dist/generated/shared/core/serverSentEvents.gen.mjs.map +1 -0
- package/dist/generated/shared/core/types.gen.d.cts +61 -0
- package/dist/generated/shared/core/types.gen.d.mts +61 -0
- package/dist/generated/shared/core/utils.gen.cjs +80 -0
- package/dist/generated/shared/core/utils.gen.cjs.map +1 -0
- package/dist/generated/shared/core/utils.gen.mjs +79 -0
- package/dist/generated/shared/core/utils.gen.mjs.map +1 -0
- package/dist/generated/spaces/client.gen.cjs +10 -0
- package/dist/generated/spaces/client.gen.cjs.map +1 -0
- package/dist/generated/spaces/client.gen.mjs +10 -0
- package/dist/generated/spaces/client.gen.mjs.map +1 -0
- package/dist/generated/spaces/sdk.gen.cjs +21 -0
- package/dist/generated/spaces/sdk.gen.cjs.map +1 -0
- package/dist/generated/spaces/sdk.gen.mjs +21 -0
- package/dist/generated/spaces/sdk.gen.mjs.map +1 -0
- package/dist/generated/spaces/types.gen.d.cts +34 -0
- package/dist/generated/spaces/types.gen.d.mts +34 -0
- package/dist/generated/stories/client.gen.cjs +10 -0
- package/dist/generated/stories/client.gen.cjs.map +1 -0
- package/dist/generated/stories/client.gen.mjs +10 -0
- package/dist/generated/stories/client.gen.mjs.map +1 -0
- package/dist/generated/stories/sdk.gen.cjs +36 -0
- package/dist/generated/stories/sdk.gen.cjs.map +1 -0
- package/dist/generated/stories/sdk.gen.mjs +35 -0
- package/dist/generated/stories/sdk.gen.mjs.map +1 -0
- package/dist/generated/stories/types.gen.d.cts +544 -0
- package/dist/generated/stories/types.gen.d.mts +544 -0
- package/dist/generated/tags/client.gen.cjs +10 -0
- package/dist/generated/tags/client.gen.cjs.map +1 -0
- package/dist/generated/tags/client.gen.mjs +10 -0
- package/dist/generated/tags/client.gen.mjs.map +1 -0
- package/dist/generated/tags/sdk.gen.cjs +21 -0
- package/dist/generated/tags/sdk.gen.cjs.map +1 -0
- package/dist/generated/tags/sdk.gen.mjs +21 -0
- package/dist/generated/tags/sdk.gen.mjs.map +1 -0
- package/dist/generated/tags/types.gen.d.cts +44 -0
- package/dist/generated/tags/types.gen.d.mts +44 -0
- package/dist/index.cjs +155 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +161 -0
- package/dist/index.d.mts +161 -0
- package/dist/index.mjs +150 -0
- package/dist/index.mjs.map +1 -0
- package/dist/resources/datasource-entries.cjs +22 -0
- package/dist/resources/datasource-entries.cjs.map +1 -0
- package/dist/resources/datasource-entries.mjs +22 -0
- package/dist/resources/datasource-entries.mjs.map +1 -0
- package/dist/resources/datasources.cjs +37 -0
- package/dist/resources/datasources.cjs.map +1 -0
- package/dist/resources/datasources.mjs +37 -0
- package/dist/resources/datasources.mjs.map +1 -0
- package/dist/resources/links.cjs +22 -0
- package/dist/resources/links.cjs.map +1 -0
- package/dist/resources/links.mjs +22 -0
- package/dist/resources/links.mjs.map +1 -0
- package/dist/resources/spaces.cjs +22 -0
- package/dist/resources/spaces.cjs.map +1 -0
- package/dist/resources/spaces.mjs +22 -0
- package/dist/resources/spaces.mjs.map +1 -0
- package/dist/resources/stories.cjs +69 -0
- package/dist/resources/stories.cjs.map +1 -0
- package/dist/resources/stories.d.cts +16 -0
- package/dist/resources/stories.d.mts +16 -0
- package/dist/resources/stories.mjs +69 -0
- package/dist/resources/stories.mjs.map +1 -0
- package/dist/resources/tags.cjs +22 -0
- package/dist/resources/tags.cjs.map +1 -0
- package/dist/resources/tags.mjs +22 -0
- package/dist/resources/tags.mjs.map +1 -0
- package/dist/types.d.cts +19 -0
- package/dist/types.d.mts +19 -0
- package/dist/utils/array.cjs +12 -0
- package/dist/utils/array.cjs.map +1 -0
- package/dist/utils/array.mjs +11 -0
- package/dist/utils/array.mjs.map +1 -0
- package/dist/utils/cache.cjs +73 -0
- package/dist/utils/cache.cjs.map +1 -0
- package/dist/utils/cache.d.cts +26 -0
- package/dist/utils/cache.d.mts +26 -0
- package/dist/utils/cache.mjs +71 -0
- package/dist/utils/cache.mjs.map +1 -0
- package/dist/utils/cv.cjs +19 -0
- package/dist/utils/cv.cjs.map +1 -0
- package/dist/utils/cv.mjs +18 -0
- package/dist/utils/cv.mjs.map +1 -0
- package/dist/utils/fetch-rel-uuids.cjs +45 -0
- package/dist/utils/fetch-rel-uuids.cjs.map +1 -0
- package/dist/utils/fetch-rel-uuids.mjs +45 -0
- package/dist/utils/fetch-rel-uuids.mjs.map +1 -0
- package/dist/utils/inline-relations.cjs +86 -0
- package/dist/utils/inline-relations.cjs.map +1 -0
- package/dist/utils/inline-relations.mjs +84 -0
- package/dist/utils/inline-relations.mjs.map +1 -0
- package/dist/utils/rate-limit.cjs +140 -0
- package/dist/utils/rate-limit.cjs.map +1 -0
- package/dist/utils/rate-limit.d.cts +36 -0
- package/dist/utils/rate-limit.d.mts +36 -0
- package/dist/utils/rate-limit.mjs +137 -0
- package/dist/utils/rate-limit.mjs.map +1 -0
- package/dist/utils/request.cjs +38 -0
- package/dist/utils/request.cjs.map +1 -0
- package/dist/utils/request.mjs +35 -0
- package/dist/utils/request.mjs.map +1 -0
- package/package.json +79 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
const require_fetch_rel_uuids = require('./fetch-rel-uuids.cjs');
|
|
2
|
+
|
|
3
|
+
//#region src/utils/inline-relations.ts
|
|
4
|
+
const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
5
|
+
const isComponentNode = (value) => typeof value.component === "string" && typeof value._uid === "string";
|
|
6
|
+
const inlineStoryContentInternal = (story, relationPaths, relationMap, resolved) => {
|
|
7
|
+
const existingStory = resolved.get(story.uuid);
|
|
8
|
+
if (existingStory) return existingStory;
|
|
9
|
+
const clonedStory = structuredClone(story);
|
|
10
|
+
resolved.set(story.uuid, clonedStory);
|
|
11
|
+
clonedStory.content = resolveNode(clonedStory.content, relationMap, relationPaths, resolved);
|
|
12
|
+
return clonedStory;
|
|
13
|
+
};
|
|
14
|
+
function resolveNode(value, relationMap, relationPaths, resolved) {
|
|
15
|
+
if (Array.isArray(value)) return value.map((item) => resolveNode(item, relationMap, relationPaths, resolved));
|
|
16
|
+
if (!isRecord(value)) return value;
|
|
17
|
+
if (isComponentNode(value)) {
|
|
18
|
+
for (const [fieldName, fieldValue] of Object.entries(value)) {
|
|
19
|
+
if (fieldName === "component" || fieldName === "_uid") continue;
|
|
20
|
+
const relationPath = `${value.component}.${fieldName}`;
|
|
21
|
+
value[fieldName] = relationPaths.has(relationPath) ? resolveFieldValue(fieldValue, relationMap, relationPaths, resolved) : resolveNode(fieldValue, relationMap, relationPaths, resolved);
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
for (const [fieldName, fieldValue] of Object.entries(value)) value[fieldName] = resolveNode(fieldValue, relationMap, relationPaths, resolved);
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
const parseResolveRelations = (query) => {
|
|
29
|
+
if (typeof query.resolve_relations !== "string") return [];
|
|
30
|
+
return query.resolve_relations.split(",").map((path) => path.trim()).filter((path) => {
|
|
31
|
+
const [component = "", field = "", ...rest] = path.split(".");
|
|
32
|
+
return component.length > 0 && field.length > 0 && rest.length === 0;
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
const buildRelationMap = (rels) => {
|
|
36
|
+
const relationMap = /* @__PURE__ */ new Map();
|
|
37
|
+
for (const story of rels ?? []) relationMap.set(story.uuid, story);
|
|
38
|
+
return relationMap;
|
|
39
|
+
};
|
|
40
|
+
function resolveFieldValue(value, relationMap, relationPaths, resolved) {
|
|
41
|
+
if (typeof value === "string") {
|
|
42
|
+
const relatedStory = relationMap.get(value);
|
|
43
|
+
if (!relatedStory) return value;
|
|
44
|
+
return inlineStoryContentInternal(relatedStory, relationPaths, relationMap, resolved);
|
|
45
|
+
}
|
|
46
|
+
if (Array.isArray(value)) return value.map((item) => resolveFieldValue(item, relationMap, relationPaths, resolved));
|
|
47
|
+
return resolveNode(value, relationMap, relationPaths, resolved);
|
|
48
|
+
}
|
|
49
|
+
const inlineStoryContent = (story, relationPaths, relationMap) => {
|
|
50
|
+
return inlineStoryContentInternal(story, new Set(relationPaths), relationMap, /* @__PURE__ */ new Map());
|
|
51
|
+
};
|
|
52
|
+
const inlineStoriesContent = (stories, relationPaths, relationMap) => {
|
|
53
|
+
const normalizedPaths = new Set(relationPaths);
|
|
54
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
55
|
+
return stories.map((story) => inlineStoryContentInternal(story, normalizedPaths, relationMap, resolved));
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Parses relation paths from the request query, builds a relation map from the
|
|
59
|
+
* response's `rels`, and fetches any additional relations referenced by `rel_uuids`.
|
|
60
|
+
*
|
|
61
|
+
* Returns `null` when there is nothing to inline (no `resolve_relations` in the query).
|
|
62
|
+
*/
|
|
63
|
+
const resolveRelationMap = async (responseData, requestQuery, { client, throttleManager }) => {
|
|
64
|
+
const relationPaths = parseResolveRelations(requestQuery);
|
|
65
|
+
if (relationPaths.length === 0) return null;
|
|
66
|
+
const relationMap = buildRelationMap(responseData.rels);
|
|
67
|
+
if (responseData.rel_uuids?.length) {
|
|
68
|
+
const fetchedRelations = await require_fetch_rel_uuids.fetchMissingRelations({
|
|
69
|
+
client,
|
|
70
|
+
uuids: responseData.rel_uuids,
|
|
71
|
+
baseQuery: requestQuery,
|
|
72
|
+
throttleManager
|
|
73
|
+
});
|
|
74
|
+
for (const relationStory of fetchedRelations) relationMap.set(relationStory.uuid, relationStory);
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
relationPaths,
|
|
78
|
+
relationMap
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
//#endregion
|
|
83
|
+
exports.inlineStoriesContent = inlineStoriesContent;
|
|
84
|
+
exports.inlineStoryContent = inlineStoryContent;
|
|
85
|
+
exports.resolveRelationMap = resolveRelationMap;
|
|
86
|
+
//# sourceMappingURL=inline-relations.cjs.map
|
|
@@ -0,0 +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 return query.resolve_relations\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 fetchedRelations = await fetchMissingRelations({\n client,\n uuids: responseData.rel_uuids,\n baseQuery: requestQuery,\n throttleManager,\n });\n for (const relationStory of fetchedRelations) {\n relationMap.set(relationStory.uuid, relationStory);\n }\n }\n\n return { relationPaths, relationMap };\n};\n"],"mappings":"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"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { fetchMissingRelations } from "./fetch-rel-uuids.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/utils/inline-relations.ts
|
|
4
|
+
const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
5
|
+
const isComponentNode = (value) => typeof value.component === "string" && typeof value._uid === "string";
|
|
6
|
+
const inlineStoryContentInternal = (story, relationPaths, relationMap, resolved) => {
|
|
7
|
+
const existingStory = resolved.get(story.uuid);
|
|
8
|
+
if (existingStory) return existingStory;
|
|
9
|
+
const clonedStory = structuredClone(story);
|
|
10
|
+
resolved.set(story.uuid, clonedStory);
|
|
11
|
+
clonedStory.content = resolveNode(clonedStory.content, relationMap, relationPaths, resolved);
|
|
12
|
+
return clonedStory;
|
|
13
|
+
};
|
|
14
|
+
function resolveNode(value, relationMap, relationPaths, resolved) {
|
|
15
|
+
if (Array.isArray(value)) return value.map((item) => resolveNode(item, relationMap, relationPaths, resolved));
|
|
16
|
+
if (!isRecord(value)) return value;
|
|
17
|
+
if (isComponentNode(value)) {
|
|
18
|
+
for (const [fieldName, fieldValue] of Object.entries(value)) {
|
|
19
|
+
if (fieldName === "component" || fieldName === "_uid") continue;
|
|
20
|
+
const relationPath = `${value.component}.${fieldName}`;
|
|
21
|
+
value[fieldName] = relationPaths.has(relationPath) ? resolveFieldValue(fieldValue, relationMap, relationPaths, resolved) : resolveNode(fieldValue, relationMap, relationPaths, resolved);
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
for (const [fieldName, fieldValue] of Object.entries(value)) value[fieldName] = resolveNode(fieldValue, relationMap, relationPaths, resolved);
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
const parseResolveRelations = (query) => {
|
|
29
|
+
if (typeof query.resolve_relations !== "string") return [];
|
|
30
|
+
return query.resolve_relations.split(",").map((path) => path.trim()).filter((path) => {
|
|
31
|
+
const [component = "", field = "", ...rest] = path.split(".");
|
|
32
|
+
return component.length > 0 && field.length > 0 && rest.length === 0;
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
const buildRelationMap = (rels) => {
|
|
36
|
+
const relationMap = /* @__PURE__ */ new Map();
|
|
37
|
+
for (const story of rels ?? []) relationMap.set(story.uuid, story);
|
|
38
|
+
return relationMap;
|
|
39
|
+
};
|
|
40
|
+
function resolveFieldValue(value, relationMap, relationPaths, resolved) {
|
|
41
|
+
if (typeof value === "string") {
|
|
42
|
+
const relatedStory = relationMap.get(value);
|
|
43
|
+
if (!relatedStory) return value;
|
|
44
|
+
return inlineStoryContentInternal(relatedStory, relationPaths, relationMap, resolved);
|
|
45
|
+
}
|
|
46
|
+
if (Array.isArray(value)) return value.map((item) => resolveFieldValue(item, relationMap, relationPaths, resolved));
|
|
47
|
+
return resolveNode(value, relationMap, relationPaths, resolved);
|
|
48
|
+
}
|
|
49
|
+
const inlineStoryContent = (story, relationPaths, relationMap) => {
|
|
50
|
+
return inlineStoryContentInternal(story, new Set(relationPaths), relationMap, /* @__PURE__ */ new Map());
|
|
51
|
+
};
|
|
52
|
+
const inlineStoriesContent = (stories, relationPaths, relationMap) => {
|
|
53
|
+
const normalizedPaths = new Set(relationPaths);
|
|
54
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
55
|
+
return stories.map((story) => inlineStoryContentInternal(story, normalizedPaths, relationMap, resolved));
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Parses relation paths from the request query, builds a relation map from the
|
|
59
|
+
* response's `rels`, and fetches any additional relations referenced by `rel_uuids`.
|
|
60
|
+
*
|
|
61
|
+
* Returns `null` when there is nothing to inline (no `resolve_relations` in the query).
|
|
62
|
+
*/
|
|
63
|
+
const resolveRelationMap = async (responseData, requestQuery, { client, throttleManager }) => {
|
|
64
|
+
const relationPaths = parseResolveRelations(requestQuery);
|
|
65
|
+
if (relationPaths.length === 0) return null;
|
|
66
|
+
const relationMap = buildRelationMap(responseData.rels);
|
|
67
|
+
if (responseData.rel_uuids?.length) {
|
|
68
|
+
const fetchedRelations = await fetchMissingRelations({
|
|
69
|
+
client,
|
|
70
|
+
uuids: responseData.rel_uuids,
|
|
71
|
+
baseQuery: requestQuery,
|
|
72
|
+
throttleManager
|
|
73
|
+
});
|
|
74
|
+
for (const relationStory of fetchedRelations) relationMap.set(relationStory.uuid, relationStory);
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
relationPaths,
|
|
78
|
+
relationMap
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
//#endregion
|
|
83
|
+
export { inlineStoriesContent, inlineStoryContent, resolveRelationMap };
|
|
84
|
+
//# sourceMappingURL=inline-relations.mjs.map
|
|
@@ -0,0 +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 return query.resolve_relations\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 fetchedRelations = await fetchMissingRelations({\n client,\n uuids: responseData.rel_uuids,\n baseQuery: requestQuery,\n throttleManager,\n });\n for (const relationStory of fetchedRelations) {\n relationMap.set(relationStory.uuid, relationStory);\n }\n }\n\n return { relationPaths, relationMap };\n};\n"],"mappings":"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"}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
|
|
2
|
+
//#region src/utils/rate-limit.ts
|
|
3
|
+
/**
|
|
4
|
+
* Rate limiting for the Content API client.
|
|
5
|
+
*
|
|
6
|
+
* Provides both a simple token-bucket throttle and a tier-aware manager
|
|
7
|
+
* that automatically selects the right concurrency limit based on request
|
|
8
|
+
* type (single story vs. listing) and the per_page query parameter — mirroring
|
|
9
|
+
* the tiers enforced server-side by the Storyblok CDN.
|
|
10
|
+
*/
|
|
11
|
+
const TIER_LIMITS = {
|
|
12
|
+
SINGLE_OR_SMALL: 50,
|
|
13
|
+
MEDIUM: 15,
|
|
14
|
+
LARGE: 10,
|
|
15
|
+
VERY_LARGE: 6
|
|
16
|
+
};
|
|
17
|
+
const PER_PAGE_THRESHOLDS = {
|
|
18
|
+
SMALL: 25,
|
|
19
|
+
MEDIUM: 50,
|
|
20
|
+
LARGE: 75
|
|
21
|
+
};
|
|
22
|
+
const DEFAULT_PER_PAGE = 25;
|
|
23
|
+
const MAX_RATE_LIMIT = 1e3;
|
|
24
|
+
/**
|
|
25
|
+
* Concurrency limiter: allows up to `initialLimit` requests to be in-flight
|
|
26
|
+
* at the same time. A slot is freed as soon as the request's promise settles
|
|
27
|
+
* (resolves or rejects), so throughput scales with how quickly requests
|
|
28
|
+
* complete rather than being artificially capped at N per second.
|
|
29
|
+
*/
|
|
30
|
+
function createThrottle(initialLimit) {
|
|
31
|
+
let limit = initialLimit;
|
|
32
|
+
let activeCount = 0;
|
|
33
|
+
const queue = [];
|
|
34
|
+
const tryNext = () => {
|
|
35
|
+
while (queue.length > 0 && activeCount < limit) {
|
|
36
|
+
activeCount++;
|
|
37
|
+
queue.shift()();
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const execute = (fn) => {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
queue.push(() => {
|
|
43
|
+
fn().then((value) => {
|
|
44
|
+
activeCount--;
|
|
45
|
+
tryNext();
|
|
46
|
+
resolve(value);
|
|
47
|
+
}, (error) => {
|
|
48
|
+
activeCount--;
|
|
49
|
+
tryNext();
|
|
50
|
+
reject(error);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
tryNext();
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
const setLimit = (n) => {
|
|
57
|
+
limit = n;
|
|
58
|
+
tryNext();
|
|
59
|
+
};
|
|
60
|
+
return {
|
|
61
|
+
execute,
|
|
62
|
+
setLimit
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const SINGLE_STORY_PATH_RE = /\/v2\/cdn\/stories\/.+$/;
|
|
66
|
+
/**
|
|
67
|
+
* Maps a request path + query to one of the four rate-limit tiers.
|
|
68
|
+
* Used by the auto-detection mode of `createThrottleManager`.
|
|
69
|
+
*/
|
|
70
|
+
function determineTier(path, query) {
|
|
71
|
+
if (SINGLE_STORY_PATH_RE.test(path)) return "SINGLE_OR_SMALL";
|
|
72
|
+
const rawPerPage = query.per_page;
|
|
73
|
+
const perPage = typeof rawPerPage === "number" ? rawPerPage : typeof rawPerPage === "string" ? Number.parseInt(rawPerPage, 10) || DEFAULT_PER_PAGE : DEFAULT_PER_PAGE;
|
|
74
|
+
if (perPage <= PER_PAGE_THRESHOLDS.SMALL) return "SINGLE_OR_SMALL";
|
|
75
|
+
if (perPage <= PER_PAGE_THRESHOLDS.MEDIUM) return "MEDIUM";
|
|
76
|
+
if (perPage <= PER_PAGE_THRESHOLDS.LARGE) return "LARGE";
|
|
77
|
+
return "VERY_LARGE";
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Extracts the quota (`q=`) value from the `X-RateLimit-Policy` response header.
|
|
81
|
+
* Returns `undefined` if the header is absent or unparseable.
|
|
82
|
+
*
|
|
83
|
+
* Example header: `"concurrent-requests";q=30`
|
|
84
|
+
*/
|
|
85
|
+
function parseRateLimitPolicyHeader(response) {
|
|
86
|
+
const policy = response.headers.get("x-ratelimit-policy");
|
|
87
|
+
if (!policy) return;
|
|
88
|
+
const match = policy.match(/q=(\d+)/);
|
|
89
|
+
if (!match) return;
|
|
90
|
+
return Math.min(Number.parseInt(match[1], 10), MAX_RATE_LIMIT);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Creates a `ThrottleManager` from the user-supplied `rateLimit` config.
|
|
94
|
+
*
|
|
95
|
+
* - `false` → no throttling (passthrough)
|
|
96
|
+
* - `number` → fixed single queue at that limit
|
|
97
|
+
* - `{ maxConcurrent: n }` → fixed single queue at n req/s
|
|
98
|
+
* - `{}` / `undefined` (default)→ auto-detect tier from path + per_page
|
|
99
|
+
*/
|
|
100
|
+
function createThrottleManager(config) {
|
|
101
|
+
if (config === false) return {
|
|
102
|
+
execute: (_path, _query, fn) => fn(),
|
|
103
|
+
adaptToResponse: () => {}
|
|
104
|
+
};
|
|
105
|
+
const { maxConcurrent, adaptToServerHeaders = true } = typeof config === "number" ? { maxConcurrent: config } : config;
|
|
106
|
+
if (maxConcurrent !== void 0) {
|
|
107
|
+
const cappedLimit = Math.min(maxConcurrent, MAX_RATE_LIMIT);
|
|
108
|
+
const throttle = createThrottle(cappedLimit);
|
|
109
|
+
return {
|
|
110
|
+
execute: (_path, _query, fn) => throttle.execute(fn),
|
|
111
|
+
adaptToResponse: (response) => {
|
|
112
|
+
if (!adaptToServerHeaders || response === void 0) return;
|
|
113
|
+
const serverLimit = parseRateLimitPolicyHeader(response);
|
|
114
|
+
if (serverLimit !== void 0) throttle.setLimit(Math.min(cappedLimit, serverLimit));
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
const throttles = {
|
|
119
|
+
SINGLE_OR_SMALL: createThrottle(TIER_LIMITS.SINGLE_OR_SMALL),
|
|
120
|
+
MEDIUM: createThrottle(TIER_LIMITS.MEDIUM),
|
|
121
|
+
LARGE: createThrottle(TIER_LIMITS.LARGE),
|
|
122
|
+
VERY_LARGE: createThrottle(TIER_LIMITS.VERY_LARGE)
|
|
123
|
+
};
|
|
124
|
+
return {
|
|
125
|
+
execute: (path, query, fn) => {
|
|
126
|
+
return throttles[determineTier(path, query)].execute(fn);
|
|
127
|
+
},
|
|
128
|
+
adaptToResponse: (response) => {
|
|
129
|
+
if (!adaptToServerHeaders || response === void 0) return;
|
|
130
|
+
const serverLimit = parseRateLimitPolicyHeader(response);
|
|
131
|
+
if (serverLimit !== void 0) throttles.SINGLE_OR_SMALL.setLimit(Math.min(TIER_LIMITS.SINGLE_OR_SMALL, serverLimit));
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
//#endregion
|
|
137
|
+
exports.createThrottle = createThrottle;
|
|
138
|
+
exports.createThrottleManager = createThrottleManager;
|
|
139
|
+
exports.parseRateLimitPolicyHeader = parseRateLimitPolicyHeader;
|
|
140
|
+
//# sourceMappingURL=rate-limit.cjs.map
|
|
@@ -0,0 +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 maxConcurrent?: 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 * Returns `undefined` if the header is absent or unparseable.\n *\n * Example header: `\"concurrent-requests\";q=30`\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 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 * - `{ maxConcurrent: 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' ? { maxConcurrent: config } : config;\n const { maxConcurrent, adaptToServerHeaders = true } = resolvedConfig;\n\n // Fixed-limit mode — single queue, optional server-header adaptation.\n if (maxConcurrent !== undefined) {\n const cappedLimit = Math.min(maxConcurrent, 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;;;;;;;;AAST,SAAgB,2BAA2B,UAAwC;CACjF,MAAM,SAAS,SAAS,QAAQ,IAAI,qBAAqB;AACzD,KAAI,CAAC,OACH;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,eAAe,uBAAuB,SADN,OAAO,WAAW,WAAW,EAAE,eAAe,QAAQ,GAAG;AAIjG,KAAI,kBAAkB,QAAW;EAC/B,MAAM,cAAc,KAAK,IAAI,eAAe,eAAe;EAC3D,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"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
//#region src/utils/rate-limit.d.ts
|
|
2
|
+
interface RateLimitConfig {
|
|
3
|
+
/**
|
|
4
|
+
* Fixed maximum number of concurrent requests per second.
|
|
5
|
+
* When set, disables automatic per_page tier detection and all requests
|
|
6
|
+
* share a single queue at this limit. Capped at 1000.
|
|
7
|
+
*/
|
|
8
|
+
maxConcurrent?: number;
|
|
9
|
+
/**
|
|
10
|
+
* Dynamically adjust the rate limit based on the `X-RateLimit-Policy`
|
|
11
|
+
* response header returned by the Storyblok API.
|
|
12
|
+
* @default true
|
|
13
|
+
*/
|
|
14
|
+
adaptToServerHeaders?: boolean;
|
|
15
|
+
}
|
|
16
|
+
interface Throttle {
|
|
17
|
+
execute: <T>(fn: () => Promise<T>) => Promise<T>;
|
|
18
|
+
setLimit: (n: number) => void;
|
|
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
|
+
//#endregion
|
|
35
|
+
export { RateLimitConfig, createThrottle, parseRateLimitPolicyHeader };
|
|
36
|
+
//# sourceMappingURL=rate-limit.d.cts.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
//#region src/utils/rate-limit.d.ts
|
|
2
|
+
interface RateLimitConfig {
|
|
3
|
+
/**
|
|
4
|
+
* Fixed maximum number of concurrent requests per second.
|
|
5
|
+
* When set, disables automatic per_page tier detection and all requests
|
|
6
|
+
* share a single queue at this limit. Capped at 1000.
|
|
7
|
+
*/
|
|
8
|
+
maxConcurrent?: number;
|
|
9
|
+
/**
|
|
10
|
+
* Dynamically adjust the rate limit based on the `X-RateLimit-Policy`
|
|
11
|
+
* response header returned by the Storyblok API.
|
|
12
|
+
* @default true
|
|
13
|
+
*/
|
|
14
|
+
adaptToServerHeaders?: boolean;
|
|
15
|
+
}
|
|
16
|
+
interface Throttle {
|
|
17
|
+
execute: <T>(fn: () => Promise<T>) => Promise<T>;
|
|
18
|
+
setLimit: (n: number) => void;
|
|
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
|
+
//#endregion
|
|
35
|
+
export { RateLimitConfig, createThrottle, parseRateLimitPolicyHeader };
|
|
36
|
+
//# sourceMappingURL=rate-limit.d.mts.map
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
//#region src/utils/rate-limit.ts
|
|
2
|
+
/**
|
|
3
|
+
* Rate limiting for the Content API client.
|
|
4
|
+
*
|
|
5
|
+
* Provides both a simple token-bucket throttle and a tier-aware manager
|
|
6
|
+
* that automatically selects the right concurrency limit based on request
|
|
7
|
+
* type (single story vs. listing) and the per_page query parameter — mirroring
|
|
8
|
+
* the tiers enforced server-side by the Storyblok CDN.
|
|
9
|
+
*/
|
|
10
|
+
const TIER_LIMITS = {
|
|
11
|
+
SINGLE_OR_SMALL: 50,
|
|
12
|
+
MEDIUM: 15,
|
|
13
|
+
LARGE: 10,
|
|
14
|
+
VERY_LARGE: 6
|
|
15
|
+
};
|
|
16
|
+
const PER_PAGE_THRESHOLDS = {
|
|
17
|
+
SMALL: 25,
|
|
18
|
+
MEDIUM: 50,
|
|
19
|
+
LARGE: 75
|
|
20
|
+
};
|
|
21
|
+
const DEFAULT_PER_PAGE = 25;
|
|
22
|
+
const MAX_RATE_LIMIT = 1e3;
|
|
23
|
+
/**
|
|
24
|
+
* Concurrency limiter: allows up to `initialLimit` requests to be in-flight
|
|
25
|
+
* at the same time. A slot is freed as soon as the request's promise settles
|
|
26
|
+
* (resolves or rejects), so throughput scales with how quickly requests
|
|
27
|
+
* complete rather than being artificially capped at N per second.
|
|
28
|
+
*/
|
|
29
|
+
function createThrottle(initialLimit) {
|
|
30
|
+
let limit = initialLimit;
|
|
31
|
+
let activeCount = 0;
|
|
32
|
+
const queue = [];
|
|
33
|
+
const tryNext = () => {
|
|
34
|
+
while (queue.length > 0 && activeCount < limit) {
|
|
35
|
+
activeCount++;
|
|
36
|
+
queue.shift()();
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const execute = (fn) => {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
queue.push(() => {
|
|
42
|
+
fn().then((value) => {
|
|
43
|
+
activeCount--;
|
|
44
|
+
tryNext();
|
|
45
|
+
resolve(value);
|
|
46
|
+
}, (error) => {
|
|
47
|
+
activeCount--;
|
|
48
|
+
tryNext();
|
|
49
|
+
reject(error);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
tryNext();
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
const setLimit = (n) => {
|
|
56
|
+
limit = n;
|
|
57
|
+
tryNext();
|
|
58
|
+
};
|
|
59
|
+
return {
|
|
60
|
+
execute,
|
|
61
|
+
setLimit
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const SINGLE_STORY_PATH_RE = /\/v2\/cdn\/stories\/.+$/;
|
|
65
|
+
/**
|
|
66
|
+
* Maps a request path + query to one of the four rate-limit tiers.
|
|
67
|
+
* Used by the auto-detection mode of `createThrottleManager`.
|
|
68
|
+
*/
|
|
69
|
+
function determineTier(path, query) {
|
|
70
|
+
if (SINGLE_STORY_PATH_RE.test(path)) return "SINGLE_OR_SMALL";
|
|
71
|
+
const rawPerPage = query.per_page;
|
|
72
|
+
const perPage = typeof rawPerPage === "number" ? rawPerPage : typeof rawPerPage === "string" ? Number.parseInt(rawPerPage, 10) || DEFAULT_PER_PAGE : DEFAULT_PER_PAGE;
|
|
73
|
+
if (perPage <= PER_PAGE_THRESHOLDS.SMALL) return "SINGLE_OR_SMALL";
|
|
74
|
+
if (perPage <= PER_PAGE_THRESHOLDS.MEDIUM) return "MEDIUM";
|
|
75
|
+
if (perPage <= PER_PAGE_THRESHOLDS.LARGE) return "LARGE";
|
|
76
|
+
return "VERY_LARGE";
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Extracts the quota (`q=`) value from the `X-RateLimit-Policy` response header.
|
|
80
|
+
* Returns `undefined` if the header is absent or unparseable.
|
|
81
|
+
*
|
|
82
|
+
* Example header: `"concurrent-requests";q=30`
|
|
83
|
+
*/
|
|
84
|
+
function parseRateLimitPolicyHeader(response) {
|
|
85
|
+
const policy = response.headers.get("x-ratelimit-policy");
|
|
86
|
+
if (!policy) return;
|
|
87
|
+
const match = policy.match(/q=(\d+)/);
|
|
88
|
+
if (!match) return;
|
|
89
|
+
return Math.min(Number.parseInt(match[1], 10), MAX_RATE_LIMIT);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Creates a `ThrottleManager` from the user-supplied `rateLimit` config.
|
|
93
|
+
*
|
|
94
|
+
* - `false` → no throttling (passthrough)
|
|
95
|
+
* - `number` → fixed single queue at that limit
|
|
96
|
+
* - `{ maxConcurrent: n }` → fixed single queue at n req/s
|
|
97
|
+
* - `{}` / `undefined` (default)→ auto-detect tier from path + per_page
|
|
98
|
+
*/
|
|
99
|
+
function createThrottleManager(config) {
|
|
100
|
+
if (config === false) return {
|
|
101
|
+
execute: (_path, _query, fn) => fn(),
|
|
102
|
+
adaptToResponse: () => {}
|
|
103
|
+
};
|
|
104
|
+
const { maxConcurrent, adaptToServerHeaders = true } = typeof config === "number" ? { maxConcurrent: config } : config;
|
|
105
|
+
if (maxConcurrent !== void 0) {
|
|
106
|
+
const cappedLimit = Math.min(maxConcurrent, MAX_RATE_LIMIT);
|
|
107
|
+
const throttle = createThrottle(cappedLimit);
|
|
108
|
+
return {
|
|
109
|
+
execute: (_path, _query, fn) => throttle.execute(fn),
|
|
110
|
+
adaptToResponse: (response) => {
|
|
111
|
+
if (!adaptToServerHeaders || response === void 0) return;
|
|
112
|
+
const serverLimit = parseRateLimitPolicyHeader(response);
|
|
113
|
+
if (serverLimit !== void 0) throttle.setLimit(Math.min(cappedLimit, serverLimit));
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const throttles = {
|
|
118
|
+
SINGLE_OR_SMALL: createThrottle(TIER_LIMITS.SINGLE_OR_SMALL),
|
|
119
|
+
MEDIUM: createThrottle(TIER_LIMITS.MEDIUM),
|
|
120
|
+
LARGE: createThrottle(TIER_LIMITS.LARGE),
|
|
121
|
+
VERY_LARGE: createThrottle(TIER_LIMITS.VERY_LARGE)
|
|
122
|
+
};
|
|
123
|
+
return {
|
|
124
|
+
execute: (path, query, fn) => {
|
|
125
|
+
return throttles[determineTier(path, query)].execute(fn);
|
|
126
|
+
},
|
|
127
|
+
adaptToResponse: (response) => {
|
|
128
|
+
if (!adaptToServerHeaders || response === void 0) return;
|
|
129
|
+
const serverLimit = parseRateLimitPolicyHeader(response);
|
|
130
|
+
if (serverLimit !== void 0) throttles.SINGLE_OR_SMALL.setLimit(Math.min(TIER_LIMITS.SINGLE_OR_SMALL, serverLimit));
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
//#endregion
|
|
136
|
+
export { createThrottle, createThrottleManager, parseRateLimitPolicyHeader };
|
|
137
|
+
//# sourceMappingURL=rate-limit.mjs.map
|
|
@@ -0,0 +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 maxConcurrent?: 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 * Returns `undefined` if the header is absent or unparseable.\n *\n * Example header: `\"concurrent-requests\";q=30`\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 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 * - `{ maxConcurrent: 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' ? { maxConcurrent: config } : config;\n const { maxConcurrent, adaptToServerHeaders = true } = resolvedConfig;\n\n // Fixed-limit mode — single queue, optional server-header adaptation.\n if (maxConcurrent !== undefined) {\n const cappedLimit = Math.min(maxConcurrent, 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;;;;;;;;AAST,SAAgB,2BAA2B,UAAwC;CACjF,MAAM,SAAS,SAAS,QAAQ,IAAI,qBAAqB;AACzD,KAAI,CAAC,OACH;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,eAAe,uBAAuB,SADN,OAAO,WAAW,WAAW,EAAE,eAAe,QAAQ,GAAG;AAIjG,KAAI,kBAAkB,QAAW;EAC/B,MAAM,cAAc,KAAK,IAAI,eAAe,eAAe;EAC3D,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"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
|
|
2
|
+
//#region src/utils/request.ts
|
|
3
|
+
const CACHEABLE_METHODS = new Set(["GET"]);
|
|
4
|
+
const NON_CACHEABLE_PATHS = new Set(["/v2/cdn/spaces/me"]);
|
|
5
|
+
/** Returns `true` when the query targets draft content (`version: 'draft'`). Draft requests bypass the cache. */
|
|
6
|
+
const isDraftRequest = (query) => query.version === "draft";
|
|
7
|
+
/** Ensures a path always starts with a leading slash for consistent comparisons and cache keys. */
|
|
8
|
+
const normalizePath = (path) => path.startsWith("/") ? path : `/${path}`;
|
|
9
|
+
/**
|
|
10
|
+
* Recursively normalizes query values by sorting object keys.
|
|
11
|
+
* This makes JSON stringification deterministic for cache key generation.
|
|
12
|
+
*/
|
|
13
|
+
const normalizeQuery = (value) => {
|
|
14
|
+
if (Array.isArray(value)) return value.map((item) => normalizeQuery(item));
|
|
15
|
+
if (value && typeof value === "object") {
|
|
16
|
+
const sorted = {};
|
|
17
|
+
for (const [key, val] of Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) sorted[key] = normalizeQuery(val);
|
|
18
|
+
return sorted;
|
|
19
|
+
}
|
|
20
|
+
return value;
|
|
21
|
+
};
|
|
22
|
+
const createCacheKey = (method, path, query) => {
|
|
23
|
+
return JSON.stringify({
|
|
24
|
+
method,
|
|
25
|
+
path: normalizePath(path),
|
|
26
|
+
query: normalizeQuery(query)
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
/** Returns `false` for non-GET methods, the spaces endpoint, and draft requests — all of which bypass the cache. */
|
|
30
|
+
const shouldUseCache = (method, path, query) => {
|
|
31
|
+
return CACHEABLE_METHODS.has(method) && !NON_CACHEABLE_PATHS.has(normalizePath(path)) && !isDraftRequest(query);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
//#endregion
|
|
35
|
+
exports.createCacheKey = createCacheKey;
|
|
36
|
+
exports.isDraftRequest = isDraftRequest;
|
|
37
|
+
exports.shouldUseCache = shouldUseCache;
|
|
38
|
+
//# sourceMappingURL=request.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"request.cjs","names":[],"sources":["../../src/utils/request.ts"],"sourcesContent":["export const CACHEABLE_METHODS = new Set(['GET']);\nexport const NON_CACHEABLE_PATHS = new Set(['/v2/cdn/spaces/me']);\n\n/** Returns `true` when the query targets draft content (`version: 'draft'`). Draft requests bypass the cache. */\nexport const isDraftRequest = (query: Record<string, unknown>) => query.version === 'draft';\n\n/** Ensures a path always starts with a leading slash for consistent comparisons and cache keys. */\nconst normalizePath = (path: string) => path.startsWith('/') ? path : `/${path}`;\n\n/**\n * Recursively normalizes query values by sorting object keys.\n * This makes JSON stringification deterministic for cache key generation.\n */\nconst normalizeQuery = (value: unknown): unknown => {\n if (Array.isArray(value)) {\n return value.map(item => normalizeQuery(item));\n }\n\n if (value && typeof value === 'object') {\n const sorted: Record<string, unknown> = {};\n for (const [key, val] of Object.entries(value).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {\n sorted[key] = normalizeQuery(val);\n }\n return sorted;\n }\n\n return value;\n};\n\nexport const createCacheKey = (method: string, path: string, query: Record<string, unknown>) => {\n return JSON.stringify({\n method,\n path: normalizePath(path),\n query: normalizeQuery(query),\n });\n};\n\n/** Returns `false` for non-GET methods, the spaces endpoint, and draft requests — all of which bypass the cache. */\nexport const shouldUseCache = (method: string, path: string, query: Record<string, unknown>) => {\n return CACHEABLE_METHODS.has(method)\n && !NON_CACHEABLE_PATHS.has(normalizePath(path))\n && !isDraftRequest(query);\n};\n"],"mappings":";;AAAA,MAAa,oBAAoB,IAAI,IAAI,CAAC,MAAM,CAAC;AACjD,MAAa,sBAAsB,IAAI,IAAI,CAAC,oBAAoB,CAAC;;AAGjE,MAAa,kBAAkB,UAAmC,MAAM,YAAY;;AAGpF,MAAM,iBAAiB,SAAiB,KAAK,WAAW,IAAI,GAAG,OAAO,IAAI;;;;;AAM1E,MAAM,kBAAkB,UAA4B;AAClD,KAAI,MAAM,QAAQ,MAAM,CACtB,QAAO,MAAM,KAAI,SAAQ,eAAe,KAAK,CAAC;AAGhD,KAAI,SAAS,OAAO,UAAU,UAAU;EACtC,MAAM,SAAkC,EAAE;AAC1C,OAAK,MAAM,CAAC,KAAK,QAAQ,OAAO,QAAQ,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,OAAQ,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,EAAG,CAC7F,QAAO,OAAO,eAAe,IAAI;AAEnC,SAAO;;AAGT,QAAO;;AAGT,MAAa,kBAAkB,QAAgB,MAAc,UAAmC;AAC9F,QAAO,KAAK,UAAU;EACpB;EACA,MAAM,cAAc,KAAK;EACzB,OAAO,eAAe,MAAM;EAC7B,CAAC;;;AAIJ,MAAa,kBAAkB,QAAgB,MAAc,UAAmC;AAC9F,QAAO,kBAAkB,IAAI,OAAO,IAC/B,CAAC,oBAAoB,IAAI,cAAc,KAAK,CAAC,IAC7C,CAAC,eAAe,MAAM"}
|