@lemonadecms/client 0.0.0-stage → 0.1.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/CHANGELOG.md +84 -0
- package/LICENSE +21 -0
- package/README.md +304 -2
- package/bin/cli/config.mjs +309 -0
- package/bin/cli/create-migration.mjs +157 -0
- package/bin/cli/errors.mjs +9 -0
- package/bin/cli/io.mjs +70 -0
- package/bin/cli/migrate.mjs +109 -0
- package/bin/cli/network.mjs +134 -0
- package/bin/cli/picker.mjs +118 -0
- package/bin/cli/run.mjs +240 -0
- package/bin/cli/schema.mjs +397 -0
- package/bin/codegen/csharp.mjs +180 -0
- package/bin/codegen/definitions.mjs +52 -0
- package/bin/codegen/languages.mjs +16 -0
- package/bin/codegen/python.mjs +125 -0
- package/bin/generate-types.mjs +202 -0
- package/bin/lemonade.mjs +11 -0
- package/dist/errors.d.ts +24 -0
- package/dist/errors.js +39 -0
- package/dist/images.d.ts +11 -0
- package/dist/images.js +10 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +17 -0
- package/dist/preview.d.ts +11 -0
- package/dist/preview.js +92 -0
- package/dist/rich-text.d.ts +4 -0
- package/dist/rich-text.js +228 -0
- package/dist/server.browser.d.ts +2 -0
- package/dist/server.browser.js +9 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.js +17 -0
- package/dist/transport.d.ts +3 -0
- package/dist/transport.js +519 -0
- package/dist/types.d.ts +202 -0
- package/dist/types.js +6 -0
- package/package.json +52 -4
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
// Shared, framework-independent HTTP implementation behind the fluent SDK.
|
|
2
|
+
// Configuration and query options are validated before sending a request. The
|
|
3
|
+
// selected factory supplies the credential and route; callers cannot switch modes
|
|
4
|
+
// through list options. Responses are checked as unknown data before their envelope
|
|
5
|
+
// is converted from API snake_case to SDK camelCase. No automatic retries occur.
|
|
6
|
+
import { CMSConfigurationError, CMSRequestError, CMSResponseError, CMSTimeoutError, CMSNotFoundError } from "./errors.js";
|
|
7
|
+
const identifier = /^[a-z][a-z0-9_]{0,47}$/;
|
|
8
|
+
const cursor = (value) => uuid.test(value) || (value.length <= 8192 && /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(value));
|
|
9
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
10
|
+
const serviceURL = "https://lemonadecms.com";
|
|
11
|
+
const localeTag = /^[a-z]{2,3}(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|[0-9]{3}))?$/;
|
|
12
|
+
// Narrow unknown JSON to an object before accessing properties. Arrays and null
|
|
13
|
+
// are not valid objects for the API envelope even though typeof reports object.
|
|
14
|
+
function record(value) {
|
|
15
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
16
|
+
}
|
|
17
|
+
function readOptions(value, allowed, method) {
|
|
18
|
+
const controls = ["limit", "page", "pageSize", "after", "before", "maxPages", "select", "sort", "filters", "includeRemote", "ids", "search"];
|
|
19
|
+
if (!record(value) || Object.keys(value).some(key => !allowed.includes(key)) ||
|
|
20
|
+
controls.some(key => !allowed.includes(key) && key in value)) {
|
|
21
|
+
throw new TypeError(`${method} supports ${allowed.join(", ")}.`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// Only configured deadlines need a local controller/race. Keep the default
|
|
25
|
+
// fetch signal unchanged, while rejecting already-cancelled work before I/O.
|
|
26
|
+
// Native composition preserves fetch-style cancellation even when another caller
|
|
27
|
+
// listener suppresses the caller's abort event. Reject the new timeout+signal
|
|
28
|
+
// combination when that capability is absent rather than weaken cancellation.
|
|
29
|
+
// One cleanup path releases the timer and private listener regardless of outcome.
|
|
30
|
+
// The attached fulfillment/rejection handlers also consume late custom-fetch
|
|
31
|
+
// completions when a transport ignores cancellation; they cannot settle twice.
|
|
32
|
+
function withinDeadline(timeoutMs, callerSignal, execute) {
|
|
33
|
+
callerSignal?.throwIfAborted();
|
|
34
|
+
if (timeoutMs === undefined)
|
|
35
|
+
return execute(callerSignal);
|
|
36
|
+
if (callerSignal && typeof AbortSignal.any !== "function") {
|
|
37
|
+
throw new CMSConfigurationError("Combining timeoutMs and signal requires AbortSignal.any support.");
|
|
38
|
+
}
|
|
39
|
+
const controller = new AbortController();
|
|
40
|
+
const requestSignal = callerSignal ? AbortSignal.any([callerSignal, controller.signal]) : controller.signal;
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
let settled = false;
|
|
43
|
+
let timer;
|
|
44
|
+
const finish = (complete) => {
|
|
45
|
+
if (settled)
|
|
46
|
+
return;
|
|
47
|
+
settled = true;
|
|
48
|
+
if (timer !== undefined)
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
requestSignal.removeEventListener("abort", cancel);
|
|
51
|
+
complete();
|
|
52
|
+
};
|
|
53
|
+
const cancel = () => {
|
|
54
|
+
const reason = requestSignal.reason;
|
|
55
|
+
finish(() => reject(reason));
|
|
56
|
+
controller.abort(reason);
|
|
57
|
+
};
|
|
58
|
+
requestSignal.addEventListener("abort", cancel, { once: true });
|
|
59
|
+
timer = setTimeout(() => {
|
|
60
|
+
const error = requestSignal.aborted ? requestSignal.reason : callerSignal?.aborted ? callerSignal.reason : new CMSTimeoutError(timeoutMs);
|
|
61
|
+
finish(() => reject(error));
|
|
62
|
+
controller.abort(error);
|
|
63
|
+
}, timeoutMs);
|
|
64
|
+
try {
|
|
65
|
+
execute(requestSignal).then(value => finish(() => resolve(value)), error => finish(() => reject(error)));
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
finish(() => reject(error));
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
function validateFilters(value) {
|
|
73
|
+
let nodes = 0, leaves = 0, stringUnits = 0;
|
|
74
|
+
const scalar = (v) => {
|
|
75
|
+
if (typeof v === "string") {
|
|
76
|
+
stringUnits += v.length;
|
|
77
|
+
return stringUnits <= 4096 && !v.includes("\0");
|
|
78
|
+
}
|
|
79
|
+
return typeof v === "boolean" || typeof v === "number" && Number.isFinite(v);
|
|
80
|
+
};
|
|
81
|
+
const walk = (node, depth) => {
|
|
82
|
+
if (!record(node) || depth > 4 || ++nodes > 31)
|
|
83
|
+
return false;
|
|
84
|
+
const keys = Object.keys(node);
|
|
85
|
+
if (keys.length === 1 && ["and", "or", "not"].includes(keys[0])) {
|
|
86
|
+
const group = keys[0];
|
|
87
|
+
if (group === "not")
|
|
88
|
+
return walk(node.not, depth + 1);
|
|
89
|
+
const children = node[group];
|
|
90
|
+
return Array.isArray(children) && children.length >= 1 && children.length <= 10 && Array.from(children).every(child => walk(child, depth + 1));
|
|
91
|
+
}
|
|
92
|
+
if (++leaves > 10 || keys.sort().join(",") !== "field,op,value" || typeof node.field !== "string" ||
|
|
93
|
+
!identifier.test(node.field) || typeof node.op !== "string")
|
|
94
|
+
return false;
|
|
95
|
+
const v = node.value;
|
|
96
|
+
if (["eq", "ne"].includes(node.op))
|
|
97
|
+
return v === null || scalar(v);
|
|
98
|
+
if (["in", "not_in"].includes(node.op))
|
|
99
|
+
return Array.isArray(v) && v.length >= 1 && v.length <= 100 && Array.from(v).every(scalar);
|
|
100
|
+
if (["contains_any", "contains_all"].includes(node.op))
|
|
101
|
+
return Array.isArray(v) && v.length >= 1 && v.length <= 100 && new Set(v).size === v.length &&
|
|
102
|
+
Array.from(v).every(option => typeof option === "string" && option.trim().length > 0 && scalar(option));
|
|
103
|
+
if (["gt", "gte", "lt", "lte"].includes(node.op))
|
|
104
|
+
return (typeof v === "string" || typeof v === "number") && scalar(v);
|
|
105
|
+
if (["eqi", "contains", "containsi", "starts_with", "starts_withi", "ends_with", "ends_withi"].includes(node.op))
|
|
106
|
+
return typeof v === "string" && (node.op === "eqi" || v.length > 0) && scalar(v);
|
|
107
|
+
return false;
|
|
108
|
+
};
|
|
109
|
+
const valid = Array.isArray(value) ? value.length <= 10 && Array.from(value).every(node => walk(node, 0)) : walk(value, 0);
|
|
110
|
+
if (!valid)
|
|
111
|
+
throw new TypeError("filters require valid conditions or nonempty and/or/not groups, at most ten leaves, 31 nodes and depth four.");
|
|
112
|
+
}
|
|
113
|
+
function query(options) {
|
|
114
|
+
if (!record(options) || Object.keys(options).some(key => !["select", "limit", "page", "pageSize", "after", "before", "signal", "locale", "filters", "sort", "search", "includeRemote"].includes(key))) {
|
|
115
|
+
throw new TypeError("Supported list options are select, limit, page, pageSize, after, before, locale, filters, sort, search, includeRemote and signal.");
|
|
116
|
+
}
|
|
117
|
+
const params = new URLSearchParams();
|
|
118
|
+
if (options.includeRemote !== undefined) {
|
|
119
|
+
if (typeof options.includeRemote !== "boolean")
|
|
120
|
+
throw new TypeError("includeRemote must be a boolean.");
|
|
121
|
+
params.set("includeRemote", String(options.includeRemote));
|
|
122
|
+
}
|
|
123
|
+
if (options.select !== undefined) {
|
|
124
|
+
if (!Array.isArray(options.select) || options.select.length < 1 || options.select.length > 24 ||
|
|
125
|
+
new Set(options.select).size !== options.select.length ||
|
|
126
|
+
!options.select.every(key => typeof key === "string" && identifier.test(key))) {
|
|
127
|
+
throw new TypeError("select must contain 1–24 unique field identifiers.");
|
|
128
|
+
}
|
|
129
|
+
params.set("select", options.select.join(","));
|
|
130
|
+
}
|
|
131
|
+
if (options.page !== undefined || options.pageSize !== undefined) {
|
|
132
|
+
if (options.limit !== undefined || options.after !== undefined || options.before !== undefined)
|
|
133
|
+
throw new TypeError("Use page/pageSize or limit/after/before, never both modes.");
|
|
134
|
+
const page = options.page ?? 1, size = options.pageSize ?? 25;
|
|
135
|
+
if (!Number.isSafeInteger(page) || page < 1 || !Number.isInteger(size) || size < 1 || size > 100 || (page - 1) * size > 10000)
|
|
136
|
+
throw new TypeError("page must be positive, pageSize 1–100, and skipped rows at most 10000.");
|
|
137
|
+
params.set("page", String(page));
|
|
138
|
+
params.set("pageSize", String(size));
|
|
139
|
+
}
|
|
140
|
+
if (options.limit !== undefined) {
|
|
141
|
+
if (typeof options.limit !== "number" || !Number.isInteger(options.limit) || options.limit < 1 || options.limit > 100) {
|
|
142
|
+
throw new TypeError("limit must be an integer between 1 and 100.");
|
|
143
|
+
}
|
|
144
|
+
params.set("limit", String(options.limit));
|
|
145
|
+
}
|
|
146
|
+
if (options.locale !== undefined) {
|
|
147
|
+
if (typeof options.locale !== "string" || !localeTag.test(options.locale)) {
|
|
148
|
+
throw new TypeError("locale must be a language code such as en, es or pt-BR.");
|
|
149
|
+
}
|
|
150
|
+
params.set("locale", options.locale);
|
|
151
|
+
}
|
|
152
|
+
if (options.after !== undefined && options.before !== undefined) {
|
|
153
|
+
throw new TypeError("Use either after or before, never both.");
|
|
154
|
+
}
|
|
155
|
+
for (const direction of ["after", "before"]) {
|
|
156
|
+
const boundary = options[direction];
|
|
157
|
+
if (boundary !== undefined) {
|
|
158
|
+
if (typeof boundary !== "string" || !cursor(boundary)) {
|
|
159
|
+
throw new TypeError(`${direction} must be a cursor returned by the CMS.`);
|
|
160
|
+
}
|
|
161
|
+
params.set(direction, boundary);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (options.filters !== undefined) {
|
|
165
|
+
validateFilters(options.filters);
|
|
166
|
+
const encoded = JSON.stringify(options.filters);
|
|
167
|
+
if (new TextEncoder().encode(encoded).length > 4096)
|
|
168
|
+
throw new TypeError("filters exceed 4 KiB.");
|
|
169
|
+
params.set("filters", encoded);
|
|
170
|
+
}
|
|
171
|
+
if (options.search !== undefined) {
|
|
172
|
+
const search = options.search;
|
|
173
|
+
if (!record(search) || Object.keys(search).some(key => !["query", "fields"].includes(key)) ||
|
|
174
|
+
typeof search.query !== "string" || search.query.includes("\0") ||
|
|
175
|
+
new TextEncoder().encode(search.query).length > 512 || Array.from(search.query.trim()).length < 1 ||
|
|
176
|
+
Array.from(search.query.trim()).length > 200 || search.query.trim().split(/\s+/u).length > 16 ||
|
|
177
|
+
search.fields !== undefined && (!Array.isArray(search.fields) || search.fields.length < 1 || search.fields.length > 24 ||
|
|
178
|
+
new Set(search.fields).size !== search.fields.length || !Array.from(search.fields).every(key => typeof key === "string" && identifier.test(key)))) {
|
|
179
|
+
throw new TypeError("search requires 1–200 characters/512 UTF-8 bytes, at most 16 terms, and optionally 1–24 unique text field keys.");
|
|
180
|
+
}
|
|
181
|
+
params.set("search", JSON.stringify({ query: search.query.trim(), ...(search.fields !== undefined ? { fields: search.fields } : {}) }));
|
|
182
|
+
}
|
|
183
|
+
if (options.sort !== undefined) {
|
|
184
|
+
const orders = Array.isArray(options.sort) ? options.sort : [options.sort];
|
|
185
|
+
if (orders.length < 1 || orders.length > 3 || !Array.from(orders).every(order => record(order) && Object.keys(order).sort().join(",") === "direction,field" &&
|
|
186
|
+
typeof order.field === "string" && identifier.test(order.field) && ["asc", "desc"].includes(String(order.direction))) ||
|
|
187
|
+
new Set(orders.map(order => order.field)).size !== orders.length)
|
|
188
|
+
throw new TypeError("sort requires 1–3 unique scalar fields and asc/desc directions.");
|
|
189
|
+
params.set("sort", JSON.stringify(options.sort));
|
|
190
|
+
}
|
|
191
|
+
return params;
|
|
192
|
+
}
|
|
193
|
+
// Network responses start as unknown even in TypeScript. Check structural
|
|
194
|
+
// properties before returning them, and translate HTTP snake_case metadata into
|
|
195
|
+
// SDK camelCase. This does not validate the caller-declared model field types.
|
|
196
|
+
function decodePage(value, projectId, model, locale) {
|
|
197
|
+
if (!record(value) || !Array.isArray(value.data) || !record(value.meta) ||
|
|
198
|
+
typeof value.meta.limit !== "number" || !Number.isInteger(value.meta.limit) ||
|
|
199
|
+
value.meta.limit < 1 || value.meta.limit > 100 || value.data.length > value.meta.limit ||
|
|
200
|
+
!(value.meta.next_cursor === null || (typeof value.meta.next_cursor === "string" && cursor(value.meta.next_cursor)))) {
|
|
201
|
+
throw new CMSResponseError("CMS returned an invalid content page.");
|
|
202
|
+
}
|
|
203
|
+
const data = value.data.map(entry => decodeEntry(entry, projectId, model, locale));
|
|
204
|
+
const meta = { limit: value.meta.limit, nextCursor: value.meta.next_cursor };
|
|
205
|
+
if (value.meta.page !== undefined || value.meta.page_size !== undefined) {
|
|
206
|
+
if (typeof value.meta.page !== "number" || !Number.isInteger(value.meta.page) || value.meta.page < 1 || value.meta.page_size !== value.meta.limit || (value.meta.page - 1) * value.meta.limit > 10000)
|
|
207
|
+
throw new CMSResponseError("CMS returned invalid numbered pagination metadata.");
|
|
208
|
+
meta.page = value.meta.page;
|
|
209
|
+
meta.pageSize = value.meta.limit;
|
|
210
|
+
}
|
|
211
|
+
// Additive metadata is optional for older installations. When supplied, check
|
|
212
|
+
// it rather than silently treating a malformed boundary as the end of a list.
|
|
213
|
+
if (value.meta.previous_cursor !== undefined) {
|
|
214
|
+
if (!(value.meta.previous_cursor === null || typeof value.meta.previous_cursor === "string" && cursor(value.meta.previous_cursor)))
|
|
215
|
+
throw new CMSResponseError("CMS returned an invalid previous cursor.");
|
|
216
|
+
meta.previousCursor = value.meta.previous_cursor;
|
|
217
|
+
}
|
|
218
|
+
for (const [wire, sdk] of [["has_next_page", "hasNextPage"], ["has_previous_page", "hasPreviousPage"]]) {
|
|
219
|
+
if (value.meta[wire] !== undefined) {
|
|
220
|
+
if (typeof value.meta[wire] !== "boolean")
|
|
221
|
+
throw new CMSResponseError("CMS returned invalid pagination metadata.");
|
|
222
|
+
meta[sdk] = value.meta[wire];
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (meta.hasNextPage !== undefined && meta.hasNextPage !== (meta.nextCursor !== null) ||
|
|
226
|
+
meta.hasPreviousPage !== undefined && meta.previousCursor !== undefined && meta.hasPreviousPage !== (meta.previousCursor !== null)) {
|
|
227
|
+
throw new CMSResponseError("CMS returned inconsistent pagination metadata.");
|
|
228
|
+
}
|
|
229
|
+
return { data, meta };
|
|
230
|
+
}
|
|
231
|
+
function decodeEntry(entry, projectId, model, locale) {
|
|
232
|
+
if (!record(entry) || typeof entry.id !== "string" || !uuid.test(entry.id) || !record(entry.fields) ||
|
|
233
|
+
!(entry.published_at === null || typeof entry.published_at === "string"))
|
|
234
|
+
throw new CMSResponseError("CMS returned an invalid content entry.");
|
|
235
|
+
const result = {
|
|
236
|
+
id: entry.id, fields: entry.fields, publishedAt: entry.published_at,
|
|
237
|
+
projectId, contentType: { key: model },
|
|
238
|
+
};
|
|
239
|
+
// Old servers omit this metadata. Retain only known request identity until
|
|
240
|
+
// the server supplies the rest; never infer an environment from a credential.
|
|
241
|
+
if (entry.project_id !== undefined &&
|
|
242
|
+
(typeof entry.project_id !== "string" || entry.project_id.toLowerCase() !== projectId.toLowerCase())) {
|
|
243
|
+
throw new CMSResponseError("CMS returned conflicting workspace metadata.");
|
|
244
|
+
}
|
|
245
|
+
if (entry.content_type !== undefined) {
|
|
246
|
+
const type = entry.content_type;
|
|
247
|
+
if (!record(type) || typeof type.id !== "string" || !uuid.test(type.id) ||
|
|
248
|
+
type.key !== model || typeof type.name !== "string" || !type.name.trim()) {
|
|
249
|
+
throw new CMSResponseError("CMS returned invalid content-type metadata.");
|
|
250
|
+
}
|
|
251
|
+
result.contentType = { id: type.id, key: model, name: type.name };
|
|
252
|
+
}
|
|
253
|
+
if (entry.environment !== undefined) {
|
|
254
|
+
const environment = entry.environment;
|
|
255
|
+
if (!record(environment) || typeof environment.id !== "string" || !uuid.test(environment.id) ||
|
|
256
|
+
typeof environment.key !== "string" || !/^[a-z][a-z0-9_-]{0,31}$/.test(environment.key)) {
|
|
257
|
+
throw new CMSResponseError("CMS returned invalid environment metadata.");
|
|
258
|
+
}
|
|
259
|
+
result.environment = { id: environment.id, key: environment.key };
|
|
260
|
+
}
|
|
261
|
+
if (entry.locale !== undefined) {
|
|
262
|
+
if (typeof entry.locale !== "string" || !localeTag.test(entry.locale) || locale !== undefined && entry.locale !== locale) {
|
|
263
|
+
throw new CMSResponseError("CMS returned invalid locale metadata.");
|
|
264
|
+
}
|
|
265
|
+
result.locale = entry.locale;
|
|
266
|
+
}
|
|
267
|
+
else if (locale !== undefined)
|
|
268
|
+
result.locale = locale;
|
|
269
|
+
if (entry.remote !== undefined) {
|
|
270
|
+
const remote = entry.remote;
|
|
271
|
+
if (remote === null)
|
|
272
|
+
result.remote = null;
|
|
273
|
+
else {
|
|
274
|
+
if (!record(remote) || !["available", "error", "disabled", "not_linked"].includes(String(remote.status)) ||
|
|
275
|
+
remote.read_only !== true || typeof remote.stale !== "boolean" || typeof remote.source !== "string" ||
|
|
276
|
+
!(remote.error === null || typeof remote.error === "string") ||
|
|
277
|
+
!(remote.fetched_at === null || typeof remote.fetched_at === "string") ||
|
|
278
|
+
!(remote.expires_at === null || typeof remote.expires_at === "string") ||
|
|
279
|
+
!(remote.data === null || record(remote.data) && Object.values(remote.data).every(v => v === null ||
|
|
280
|
+
typeof v === "string" || typeof v === "boolean" || typeof v === "number" && Number.isFinite(v)))) {
|
|
281
|
+
throw new CMSResponseError("CMS returned invalid remote metadata.");
|
|
282
|
+
}
|
|
283
|
+
result.remote = { source: remote.source, status: remote.status,
|
|
284
|
+
readOnly: true, stale: remote.stale, data: remote.data,
|
|
285
|
+
error: remote.error, fetchedAt: remote.fetched_at, expiresAt: remote.expires_at };
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return result;
|
|
289
|
+
}
|
|
290
|
+
/** Internal shared transport; public exports choose the credential and route. */
|
|
291
|
+
export function createTransport(options, key, mode) {
|
|
292
|
+
if (!record(options)) {
|
|
293
|
+
throw new CMSConfigurationError("CMS client options must be an object.");
|
|
294
|
+
}
|
|
295
|
+
// Reject old host configuration rather than silently sending a developer's
|
|
296
|
+
// credentials to production. Check inherited options too: spreads and plain
|
|
297
|
+
// JavaScript callers must receive the same migration error as TypeScript.
|
|
298
|
+
if (["url", "baseURL", "baseUrl", "host", "endpoint"].some(option => option in options)) {
|
|
299
|
+
throw new CMSConfigurationError("CMS clients connect to https://lemonadecms.com. Remove custom URL configuration.");
|
|
300
|
+
}
|
|
301
|
+
const credential = mode === "content" ? "publicKey" : "secretKey";
|
|
302
|
+
if (Object.keys(options).some(option => !["projectId", credential, "fetch", "timeoutMs"].includes(option)) || "environment" in options) {
|
|
303
|
+
throw new CMSConfigurationError("Supported CMS options are projectId, the delivery key, fetch and timeoutMs. Select a content environment with its delivery key.");
|
|
304
|
+
}
|
|
305
|
+
const timeoutMs = options.timeoutMs;
|
|
306
|
+
if (timeoutMs !== undefined && (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2147483647)) {
|
|
307
|
+
throw new CMSConfigurationError("CMS timeoutMs must be an integer between 1 and 2147483647 milliseconds.");
|
|
308
|
+
}
|
|
309
|
+
if (typeof options.projectId !== "string" || !uuid.test(options.projectId)) {
|
|
310
|
+
throw new CMSConfigurationError("CMS projectId is required and must be a UUID.");
|
|
311
|
+
}
|
|
312
|
+
// Each factory chooses its credential kind. Prefix and length detect mistakes
|
|
313
|
+
// early; only the backend can verify whether the credential is currently valid.
|
|
314
|
+
const prefix = mode === "content" ? "pk" : "sk";
|
|
315
|
+
// Named tokens retain a visible public/preview distinction. The server owns
|
|
316
|
+
// scope and revocation checks; the browser factory still rejects preview keys.
|
|
317
|
+
const scoped = new RegExp(`^lmd_${prefix}_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\.[A-Za-z0-9_-]{43}$`);
|
|
318
|
+
if (typeof key !== "string" || (!new RegExp(`^${prefix}_[A-Za-z0-9_-]{43}$`).test(key) && !scoped.test(key))) {
|
|
319
|
+
throw new CMSConfigurationError(mode === "content"
|
|
320
|
+
? "A valid publicKey (pk_ or lmd_pk_) is required. Never pass a secret key to the browser client."
|
|
321
|
+
: "A valid secretKey (sk_ or lmd_sk_) is required for the server preview client.");
|
|
322
|
+
}
|
|
323
|
+
// Dependency injection supports framework server contexts and deterministic tests
|
|
324
|
+
// without coupling the client to Vue, React, or a particular HTTP library.
|
|
325
|
+
const fetcher = options.fetch ?? globalThis.fetch?.bind(globalThis);
|
|
326
|
+
if (typeof fetcher !== "function") {
|
|
327
|
+
throw new CMSConfigurationError("This environment needs fetch; supply a compatible fetch implementation.");
|
|
328
|
+
}
|
|
329
|
+
const projectId = options.projectId;
|
|
330
|
+
const endpoint = `${serviceURL}/api/v1/projects/${projectId}/${mode}`;
|
|
331
|
+
return {
|
|
332
|
+
content(model) {
|
|
333
|
+
if (typeof model !== "string" || !identifier.test(model)) {
|
|
334
|
+
throw new TypeError("Content type must be a lowercase model identifier.");
|
|
335
|
+
}
|
|
336
|
+
return {
|
|
337
|
+
async list(options = {}) {
|
|
338
|
+
return await request(options);
|
|
339
|
+
},
|
|
340
|
+
async search(text, options = {}) {
|
|
341
|
+
if (!record(options) || "search" in options)
|
|
342
|
+
throw new TypeError("search accepts a query string and list options with optional fields.");
|
|
343
|
+
const { fields, ...list } = options;
|
|
344
|
+
return await request({ ...list, search: { query: text, ...(fields !== undefined ? { fields } : {}) } });
|
|
345
|
+
},
|
|
346
|
+
async findOne(options = {}) {
|
|
347
|
+
return await firstMatch(options);
|
|
348
|
+
},
|
|
349
|
+
async findOneOrThrow(options = {}) {
|
|
350
|
+
const entry = await firstMatch(options);
|
|
351
|
+
if (entry === null)
|
|
352
|
+
throw new CMSNotFoundError(model);
|
|
353
|
+
return entry;
|
|
354
|
+
},
|
|
355
|
+
async count(options = {}) {
|
|
356
|
+
return await statistic("count", options);
|
|
357
|
+
},
|
|
358
|
+
async exists(options = {}) {
|
|
359
|
+
return await statistic("exists", options);
|
|
360
|
+
},
|
|
361
|
+
async getMany(ids, options = {}) {
|
|
362
|
+
readOptions(options, ["select", "locale", "signal", "includeRemote"], "getMany");
|
|
363
|
+
if (!Array.isArray(ids) || ids.length > 100 || !Array.from(ids).every(id => typeof id === "string" && uuid.test(id))) {
|
|
364
|
+
throw new TypeError("getMany ids must contain at most 100 entry UUIDs.");
|
|
365
|
+
}
|
|
366
|
+
query(options);
|
|
367
|
+
options.signal?.throwIfAborted();
|
|
368
|
+
const ordered = ids.map(id => id.toLowerCase());
|
|
369
|
+
if (ordered.length === 0)
|
|
370
|
+
return [];
|
|
371
|
+
const requested = new Set(ordered);
|
|
372
|
+
const requestedLocale = options.locale;
|
|
373
|
+
return await send(options, "/_many", body => {
|
|
374
|
+
if (!record(body) || !Array.isArray(body.data) || body.data.length > requested.size) {
|
|
375
|
+
throw new CMSResponseError("CMS returned an invalid bulk response.");
|
|
376
|
+
}
|
|
377
|
+
const entries = new Map();
|
|
378
|
+
for (const value of body.data) {
|
|
379
|
+
const entry = decodeEntry(value, projectId, model, requestedLocale);
|
|
380
|
+
const id = entry.id.toLowerCase();
|
|
381
|
+
if (!requested.has(id) || entries.has(id))
|
|
382
|
+
throw new CMSResponseError("CMS returned unexpected or duplicate bulk entries.");
|
|
383
|
+
entries.set(id, entry);
|
|
384
|
+
}
|
|
385
|
+
return ordered.map(id => entries.get(id) ?? null);
|
|
386
|
+
}, [...requested]);
|
|
387
|
+
},
|
|
388
|
+
pages(options = {}) {
|
|
389
|
+
return iteratePages(options);
|
|
390
|
+
},
|
|
391
|
+
async *entries(options = {}) {
|
|
392
|
+
const signal = options?.signal;
|
|
393
|
+
for await (const page of iteratePages(options)) {
|
|
394
|
+
for (const entry of page.data) {
|
|
395
|
+
signal?.throwIfAborted();
|
|
396
|
+
yield entry;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
},
|
|
400
|
+
async get(id, options = {}) {
|
|
401
|
+
if (typeof id !== "string" || !uuid.test(id))
|
|
402
|
+
throw new TypeError("id must be an entry UUID.");
|
|
403
|
+
if (!record(options) || Object.keys(options).some(k => !["select", "locale", "signal", "includeRemote"].includes(k)))
|
|
404
|
+
throw new TypeError("get supports select, locale, includeRemote and signal.");
|
|
405
|
+
return await request(options, id);
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
async function firstMatch(options) {
|
|
409
|
+
readOptions(options, ["select", "locale", "filters", "sort", "search", "signal", "includeRemote"], "findOne/findOneOrThrow");
|
|
410
|
+
const page = await request({ ...options, limit: 1 });
|
|
411
|
+
if (page.meta.limit !== 1)
|
|
412
|
+
throw new CMSResponseError("CMS returned an invalid single-match page.");
|
|
413
|
+
return (page.data[0] ?? null);
|
|
414
|
+
}
|
|
415
|
+
async function statistic(operation, options) {
|
|
416
|
+
readOptions(options, ["locale", "filters", "search", "signal"], operation);
|
|
417
|
+
return await send(options, `/_${operation}`, body => {
|
|
418
|
+
if (!record(body) || !record(body.data))
|
|
419
|
+
throw new CMSResponseError("CMS returned an invalid statistic.");
|
|
420
|
+
const value = body.data[operation];
|
|
421
|
+
if (operation === "count" ? typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 : typeof value !== "boolean") {
|
|
422
|
+
throw new CMSResponseError("CMS returned an invalid statistic.");
|
|
423
|
+
}
|
|
424
|
+
return value;
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
// No eager fetch or prefetch: the iterator asks for a page only after the
|
|
428
|
+
// previous yield is consumed. A break/error closes it without more requests.
|
|
429
|
+
async function* iteratePages(options = {}) {
|
|
430
|
+
if (!record(options) || "before" in options || "page" in options || "pageSize" in options)
|
|
431
|
+
throw new TypeError("pages/entries support forward traversal with after.");
|
|
432
|
+
const { maxPages = 1000, ...list } = options;
|
|
433
|
+
if (!Number.isInteger(maxPages) || maxPages < 1 || maxPages > 10000)
|
|
434
|
+
throw new TypeError("maxPages must be an integer between 1 and 10000.");
|
|
435
|
+
query(list);
|
|
436
|
+
// Freeze query semantics at first consumption; callers can reuse/mutate
|
|
437
|
+
// their form state without changing a traversal halfway through.
|
|
438
|
+
const { signal, ...serializable } = list;
|
|
439
|
+
const snapshot = { ...JSON.parse(JSON.stringify(serializable)), ...(signal ? { signal } : {}) };
|
|
440
|
+
let after = snapshot.after;
|
|
441
|
+
const seen = new Set(after ? [after] : []);
|
|
442
|
+
for (let count = 0; count < maxPages; count++) {
|
|
443
|
+
signal?.throwIfAborted();
|
|
444
|
+
const page = await request({ ...snapshot, ...(after ? { after } : {}) });
|
|
445
|
+
const next = page.meta.nextCursor;
|
|
446
|
+
if (next !== null && seen.has(next))
|
|
447
|
+
throw new CMSResponseError("CMS returned a repeated pagination cursor.");
|
|
448
|
+
yield page;
|
|
449
|
+
if (next === null)
|
|
450
|
+
return;
|
|
451
|
+
if (count + 1 === maxPages)
|
|
452
|
+
throw new RangeError("Pagination reached maxPages; resume with the last nextCursor or raise the limit.");
|
|
453
|
+
seen.add(next);
|
|
454
|
+
after = next;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
async function request(options, id) {
|
|
458
|
+
const requestedLocale = options.locale;
|
|
459
|
+
return await send(options, id ? `/${id}` : "", body => {
|
|
460
|
+
if (id) {
|
|
461
|
+
if (!record(body))
|
|
462
|
+
throw new CMSResponseError("CMS returned an invalid content item.");
|
|
463
|
+
const entry = decodeEntry(body.data, projectId, model, requestedLocale);
|
|
464
|
+
if (entry.id.toLowerCase() !== id.toLowerCase())
|
|
465
|
+
throw new CMSResponseError("CMS returned a different entry than requested.");
|
|
466
|
+
return entry;
|
|
467
|
+
}
|
|
468
|
+
return decodePage(body, projectId, model, requestedLocale);
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
async function send(options, path, decode, ids) {
|
|
472
|
+
const params = query(options);
|
|
473
|
+
if (ids)
|
|
474
|
+
params.set("ids", ids.join(","));
|
|
475
|
+
const suffix = params.size ? `?${params}` : "";
|
|
476
|
+
return withinDeadline(timeoutMs, options.signal, async (signal) => {
|
|
477
|
+
const checkCancellation = () => {
|
|
478
|
+
signal?.throwIfAborted();
|
|
479
|
+
options.signal?.throwIfAborted();
|
|
480
|
+
};
|
|
481
|
+
checkCancellation();
|
|
482
|
+
// Use only bearer authorization: omit browser cookies, refuse redirects that
|
|
483
|
+
// could change the destination, and request fresh data. Abort and network errors
|
|
484
|
+
// propagate to the caller; this client does not silently retry a request.
|
|
485
|
+
const response = await fetcher(`${endpoint}/${model}${path}${suffix}`, {
|
|
486
|
+
method: "GET",
|
|
487
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${key}` },
|
|
488
|
+
credentials: "omit",
|
|
489
|
+
redirect: "error",
|
|
490
|
+
cache: "no-store",
|
|
491
|
+
...(signal ? { signal } : {}),
|
|
492
|
+
});
|
|
493
|
+
checkCancellation();
|
|
494
|
+
let body;
|
|
495
|
+
try {
|
|
496
|
+
body = await response.json();
|
|
497
|
+
}
|
|
498
|
+
catch (error) {
|
|
499
|
+
checkCancellation();
|
|
500
|
+
if (error instanceof Error && error.name === "AbortError")
|
|
501
|
+
throw error;
|
|
502
|
+
if (response.ok)
|
|
503
|
+
throw new CMSResponseError("CMS returned invalid JSON.");
|
|
504
|
+
}
|
|
505
|
+
checkCancellation();
|
|
506
|
+
// An unsuccessful HTTP status becomes CMSRequestError, even if the response
|
|
507
|
+
// was not JSON. A successful response with malformed JSON instead signals a
|
|
508
|
+
// response-contract error. Consumers can distinguish these failure categories.
|
|
509
|
+
if (!response.ok) {
|
|
510
|
+
throw new CMSRequestError(record(body) && typeof body.error === "string" ? body.error : `CMS request failed (${response.status}).`, response.status, record(body) && typeof body.code === "string" ? body.code : "http_error");
|
|
511
|
+
}
|
|
512
|
+
// Validate the envelope at runtime. Model field types are the caller's
|
|
513
|
+
// declaration until schema-generated types/runtime validators exist.
|
|
514
|
+
return decode(body);
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
},
|
|
518
|
+
};
|
|
519
|
+
}
|