@bettercms-ai/sdk 1.13.2 → 1.14.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/dist/index.cjs CHANGED
@@ -207,6 +207,35 @@ var BetterCMSError = class _BetterCMSError extends Error {
207
207
  }
208
208
  };
209
209
 
210
+ // src/retry.ts
211
+ var RETRY_STATUSES = [429, 503];
212
+ var MAX_RETRIES = 3;
213
+ var MAX_DELAY_MS = 65e3;
214
+ var BASE_DELAY_MS = 1e3;
215
+ var JITTER_MS = 250;
216
+ function isRetryableStatus(status) {
217
+ return RETRY_STATUSES.includes(status);
218
+ }
219
+ function retryAfterMs(res) {
220
+ const raw = res.headers?.get?.("retry-after");
221
+ if (!raw) return null;
222
+ const seconds = Number(raw);
223
+ if (Number.isFinite(seconds)) {
224
+ return Math.min(Math.max(seconds, 0) * 1e3, MAX_DELAY_MS);
225
+ }
226
+ const at = Date.parse(raw);
227
+ if (!Number.isNaN(at)) {
228
+ return Math.min(Math.max(0, at - Date.now()), MAX_DELAY_MS);
229
+ }
230
+ return null;
231
+ }
232
+ function backoffMs(attempt, res) {
233
+ const fromServer = res ? retryAfterMs(res) : null;
234
+ const base = Math.min(fromServer ?? BASE_DELAY_MS * 2 ** attempt, MAX_DELAY_MS - JITTER_MS);
235
+ return base + Math.random() * JITTER_MS;
236
+ }
237
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
238
+
210
239
  // src/methods/getContent.ts
211
240
  async function getContent(client, slug) {
212
241
  const data = await client.fetchJSON(client.url(slug));
@@ -271,14 +300,17 @@ function listContentAll(client, opts) {
271
300
  }
272
301
 
273
302
  // src/delivery-client.ts
303
+ var DEFAULT_TIMEOUT = 1e4;
274
304
  var BetterCMSDeliveryClient = class {
275
305
  workspace;
276
306
  baseUrl;
307
+ timeout;
277
308
  apiKey;
278
309
  constructor(opts) {
279
310
  this.workspace = opts.workspace;
280
311
  this.baseUrl = opts.baseUrl;
281
312
  this.apiKey = opts.apiKey;
313
+ this.timeout = opts.timeout ?? DEFAULT_TIMEOUT;
282
314
  }
283
315
  getContent(slug) {
284
316
  return getContent(this, slug);
@@ -311,24 +343,46 @@ var BetterCMSDeliveryClient = class {
311
343
  return headers;
312
344
  }
313
345
  /**
314
- * Generic fetch helper — wraps a request with network-error handling
315
- * and throws a typed BetterCMSError on non-OK responses.
346
+ * Generic fetch helper — retries 429/503 honouring Retry-After, applies a
347
+ * per-attempt timeout, and throws a typed BetterCMSError on non-OK responses.
348
+ *
349
+ * This path (BetterCMS.site()) previously had NO retry and NO timeout: the first
350
+ * 429 from the delivery limiter threw straight through and killed the caller. In a
351
+ * static build that is a failed deploy. See ./retry.ts for why a fixed ladder was
352
+ * never going to be enough against a 60-second fixed window.
316
353
  */
317
354
  async fetchJSON(url) {
318
- let res;
319
- try {
320
- res = await globalThis.fetch(url, { headers: this.headers() });
321
- } catch (err) {
322
- throw new BetterCMSError(
323
- `Network error: ${err instanceof Error ? err.message : String(err)}`,
324
- 0,
325
- "NETWORK_ERROR"
326
- );
327
- }
328
- if (!res.ok) {
355
+ for (let attempt = 0; ; attempt++) {
356
+ const controller = new AbortController();
357
+ const timer = setTimeout(() => controller.abort(), this.timeout);
358
+ let res;
359
+ try {
360
+ res = await globalThis.fetch(url, {
361
+ headers: this.headers(),
362
+ signal: controller.signal
363
+ });
364
+ } catch (err) {
365
+ const timedOut = err instanceof Error && err.name === "AbortError";
366
+ if (attempt < MAX_RETRIES) {
367
+ await sleep(backoffMs(attempt));
368
+ continue;
369
+ }
370
+ if (timedOut) throw new BetterCMSError("Request timeout", 408, "NETWORK_ERROR");
371
+ throw new BetterCMSError(
372
+ `Network error: ${err instanceof Error ? err.message : String(err)}`,
373
+ 0,
374
+ "NETWORK_ERROR"
375
+ );
376
+ } finally {
377
+ clearTimeout(timer);
378
+ }
379
+ if (res.ok) return await res.json();
380
+ if (attempt < MAX_RETRIES && isRetryableStatus(res.status)) {
381
+ await sleep(backoffMs(attempt, res));
382
+ continue;
383
+ }
329
384
  throw await BetterCMSError.from(res);
330
385
  }
331
- return await res.json();
332
386
  }
333
387
  };
334
388
 
@@ -409,19 +463,15 @@ function addWebhookMethods(client) {
409
463
 
410
464
  // src/admin-client.ts
411
465
  var DEFAULT_BASE_URL = "https://api.bettercms.ai/v1";
412
- var DEFAULT_TIMEOUT = 1e4;
413
- var RETRY_STATUSES = [429, 503];
414
- var RETRY_DELAYS = [1e3, 2e3, 4e3];
415
466
  var BetterCMSAdminClient = class extends BetterCMSDeliveryClient {
416
- timeout;
417
467
  _token;
418
468
  constructor(options) {
419
469
  super({
420
470
  workspace: "",
421
- baseUrl: options.baseUrl ?? DEFAULT_BASE_URL
471
+ baseUrl: options.baseUrl ?? DEFAULT_BASE_URL,
472
+ timeout: options.timeout
422
473
  });
423
474
  this._token = options.token;
424
- this.timeout = options.timeout ?? DEFAULT_TIMEOUT;
425
475
  }
426
476
  headers() {
427
477
  return {
@@ -431,12 +481,12 @@ var BetterCMSAdminClient = class extends BetterCMSDeliveryClient {
431
481
  };
432
482
  }
433
483
  /**
434
- * Admin fetchJSON with retry (429, 503) and per-request timeout (AbortController).
435
- * Each retry gets a fresh timeout.
484
+ * Admin fetchJSON with Retry-After-aware retry (429, 503) and a per-request
485
+ * timeout (AbortController). Each retry gets a fresh timeout.
436
486
  */
437
487
  async fetchJSON(url, init) {
438
488
  let lastError;
439
- for (let attempt = 0; attempt <= 3; attempt++) {
489
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
440
490
  const controller = new AbortController();
441
491
  const timeoutId = setTimeout(() => controller.abort(), this.timeout);
442
492
  let nonRetryable = false;
@@ -450,12 +500,12 @@ var BetterCMSAdminClient = class extends BetterCMSDeliveryClient {
450
500
  if (res.ok) {
451
501
  return res.json();
452
502
  }
453
- const shouldRetry = attempt < 3 && RETRY_STATUSES.includes(res.status);
503
+ const shouldRetry = attempt < MAX_RETRIES && isRetryableStatus(res.status);
454
504
  if (!shouldRetry) {
455
505
  nonRetryable = true;
456
506
  throw await BetterCMSError.from(res);
457
507
  }
458
- await sleep(RETRY_DELAYS[attempt] ?? 0);
508
+ await sleep(backoffMs(attempt, res));
459
509
  } catch (err) {
460
510
  clearTimeout(timeoutId);
461
511
  if (err instanceof Error && err.name === "AbortError") {
@@ -465,8 +515,8 @@ var BetterCMSAdminClient = class extends BetterCMSDeliveryClient {
465
515
  throw err;
466
516
  }
467
517
  lastError = err;
468
- if (attempt < 3) {
469
- await sleep(RETRY_DELAYS[attempt] ?? 0);
518
+ if (attempt < MAX_RETRIES) {
519
+ await sleep(backoffMs(attempt));
470
520
  continue;
471
521
  }
472
522
  throw err;
@@ -483,9 +533,6 @@ var BetterCMSAdminClient = class extends BetterCMSDeliveryClient {
483
533
  return addWebhookMethods(this);
484
534
  }
485
535
  };
486
- function sleep(ms) {
487
- return new Promise((resolve) => setTimeout(resolve, ms));
488
- }
489
536
 
490
537
  // src/methods/management/content.ts
491
538
  async function listModels(client) {
@@ -862,8 +909,6 @@ async function commandManagedLayout(client, input) {
862
909
  // src/management-client.ts
863
910
  var DEFAULT_BASE_URL2 = "https://api.bettercms.ai/v1";
864
911
  var DEFAULT_TIMEOUT2 = 1e4;
865
- var RETRY_STATUSES2 = [429, 503];
866
- var RETRY_DELAYS2 = [1e3, 2e3, 4e3];
867
912
  var BetterCMSManagementClient = class extends BetterCMSDeliveryClient {
868
913
  timeout;
869
914
  _apiKey;
@@ -885,10 +930,14 @@ var BetterCMSManagementClient = class extends BetterCMSDeliveryClient {
885
930
  url(path) {
886
931
  return `${this.baseUrl}${path}`;
887
932
  }
888
- /** fetchJSON with retry (429, 503) + per-request timeout (mirrors the admin client). */
933
+ /**
934
+ * fetchJSON with Retry-After-aware retry (429, 503) + per-request timeout
935
+ * (mirrors the admin client). See ./retry.ts for why the delay must be able
936
+ * to outlast the backend's 60-second rate-limit window.
937
+ */
889
938
  async fetchJSON(url, init) {
890
939
  let lastError;
891
- for (let attempt = 0; attempt <= 3; attempt++) {
940
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
892
941
  const controller = new AbortController();
893
942
  const timeoutId = setTimeout(() => controller.abort(), this.timeout);
894
943
  let nonRetryable = false;
@@ -902,12 +951,12 @@ var BetterCMSManagementClient = class extends BetterCMSDeliveryClient {
902
951
  if (res.ok) {
903
952
  return res.json();
904
953
  }
905
- const shouldRetry = attempt < 3 && RETRY_STATUSES2.includes(res.status);
954
+ const shouldRetry = attempt < MAX_RETRIES && isRetryableStatus(res.status);
906
955
  if (!shouldRetry) {
907
956
  nonRetryable = true;
908
957
  throw await BetterCMSError.from(res);
909
958
  }
910
- await sleep2(RETRY_DELAYS2[attempt] ?? 0);
959
+ await sleep(backoffMs(attempt, res));
911
960
  } catch (err) {
912
961
  clearTimeout(timeoutId);
913
962
  if (err instanceof Error && err.name === "AbortError") {
@@ -915,8 +964,8 @@ var BetterCMSManagementClient = class extends BetterCMSDeliveryClient {
915
964
  }
916
965
  if (nonRetryable) throw err;
917
966
  lastError = err;
918
- if (attempt < 3) {
919
- await sleep2(RETRY_DELAYS2[attempt] ?? 0);
967
+ if (attempt < MAX_RETRIES) {
968
+ await sleep(backoffMs(attempt));
920
969
  continue;
921
970
  }
922
971
  throw err;
@@ -1048,9 +1097,6 @@ var BetterCMSManagementClient = class extends BetterCMSDeliveryClient {
1048
1097
  return generateSeoMeta(this, input);
1049
1098
  }
1050
1099
  };
1051
- function sleep2(ms) {
1052
- return new Promise((resolve) => setTimeout(resolve, ms));
1053
- }
1054
1100
 
1055
1101
  // src/auth.ts
1056
1102
  var AUTH_BASE_URL = "https://api.bettercms.ai/api/v1/auth";
@@ -1141,7 +1187,8 @@ var BetterCMS = {
1141
1187
  return new BetterCMSDeliveryClient({
1142
1188
  workspace: options.workspace,
1143
1189
  apiKey: options.apiKey,
1144
- baseUrl: options.baseUrl ?? DEFAULT_BASE_URL3
1190
+ baseUrl: options.baseUrl ?? DEFAULT_BASE_URL3,
1191
+ timeout: options.timeout
1145
1192
  });
1146
1193
  },
1147
1194
  /**
@@ -1173,10 +1220,7 @@ var BetterCMS = {
1173
1220
  };
1174
1221
 
1175
1222
  // src/read-client.ts
1176
- var RETRY_STATUSES3 = [429, 503];
1177
- var RETRY_DELAYS3 = [250, 750, 1500];
1178
1223
  var DEFAULT_TIMEOUT3 = 1e4;
1179
- var sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
1180
1224
  function qs(params2) {
1181
1225
  const sp = new URLSearchParams();
1182
1226
  for (const [k, v] of Object.entries(params2)) {
@@ -1193,6 +1237,27 @@ function unwrap(json) {
1193
1237
  }
1194
1238
  return json;
1195
1239
  }
1240
+ function createRequestCache() {
1241
+ const inflight = /* @__PURE__ */ new Map();
1242
+ const settled = /* @__PURE__ */ new Map();
1243
+ const copy = (value) => typeof structuredClone === "function" ? structuredClone(value) : JSON.parse(JSON.stringify(value));
1244
+ return async function collapse(url, fetcher) {
1245
+ if (settled.has(url)) return copy(settled.get(url));
1246
+ const pending = inflight.get(url);
1247
+ if (pending) return copy(await pending);
1248
+ const run = (async () => {
1249
+ try {
1250
+ const value = await fetcher();
1251
+ settled.set(url, value);
1252
+ return value;
1253
+ } finally {
1254
+ inflight.delete(url);
1255
+ }
1256
+ })();
1257
+ inflight.set(url, run);
1258
+ return copy(await run);
1259
+ };
1260
+ }
1196
1261
  function createClient(options) {
1197
1262
  const apiUrl = options.apiUrl.replace(/\/+$/, "");
1198
1263
  const { workspace, apiKey, perspective = "published", previewToken } = options;
@@ -1204,9 +1269,11 @@ function createClient(options) {
1204
1269
  if (apiKey) h["X-API-Key"] = apiKey;
1205
1270
  return h;
1206
1271
  };
1207
- async function fetchJSON(url) {
1272
+ const collapse = options.cache && perspective === "published" ? createRequestCache() : void 0;
1273
+ const fetchJSON = (url) => collapse ? collapse(url, () => request(url)) : request(url);
1274
+ async function request(url) {
1208
1275
  let lastError;
1209
- for (let attempt = 0; attempt <= RETRY_DELAYS3.length; attempt++) {
1276
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
1210
1277
  const controller = new AbortController();
1211
1278
  const timer = setTimeout(() => controller.abort(), timeout);
1212
1279
  try {
@@ -1216,9 +1283,9 @@ function createClient(options) {
1216
1283
  });
1217
1284
  clearTimeout(timer);
1218
1285
  if (res.ok) return await res.json();
1219
- const retryable = attempt < RETRY_DELAYS3.length && RETRY_STATUSES3.includes(res.status);
1286
+ const retryable = attempt < MAX_RETRIES && isRetryableStatus(res.status);
1220
1287
  if (!retryable) throw await BetterCMSError.from(res);
1221
- await sleep3(RETRY_DELAYS3[attempt] ?? 0);
1288
+ await sleep(backoffMs(attempt, res));
1222
1289
  } catch (err) {
1223
1290
  clearTimeout(timer);
1224
1291
  if (err instanceof Error && err.name === "AbortError") {
@@ -1226,8 +1293,8 @@ function createClient(options) {
1226
1293
  }
1227
1294
  if (err instanceof BetterCMSError) throw err;
1228
1295
  lastError = err;
1229
- if (attempt < RETRY_DELAYS3.length) {
1230
- await sleep3(RETRY_DELAYS3[attempt] ?? 0);
1296
+ if (attempt < MAX_RETRIES) {
1297
+ await sleep(backoffMs(attempt));
1231
1298
  continue;
1232
1299
  }
1233
1300
  throw new BetterCMSError(