@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.d.cts CHANGED
@@ -51,11 +51,13 @@ declare function listContentAll(client: BetterCMSDeliveryClient, opts?: ListCont
51
51
  declare class BetterCMSDeliveryClient {
52
52
  readonly workspace: string;
53
53
  readonly baseUrl: string;
54
+ protected readonly timeout: number;
54
55
  private readonly apiKey?;
55
56
  constructor(opts: {
56
57
  workspace: string;
57
58
  apiKey?: string;
58
59
  baseUrl: string;
60
+ timeout?: number;
59
61
  });
60
62
  getContent(slug: string): Promise<_bettercms_ai_types.Content>;
61
63
  listContent(opts?: Parameters<typeof listContent>[1]): Promise<_bettercms_ai_types.PaginatedResult<_bettercms_ai_types.Content>>;
@@ -71,8 +73,13 @@ declare class BetterCMSDeliveryClient {
71
73
  /** Build request headers, including auth if provided. */
72
74
  headers(): Record<string, string>;
73
75
  /**
74
- * Generic fetch helper — wraps a request with network-error handling
75
- * and throws a typed BetterCMSError on non-OK responses.
76
+ * Generic fetch helper — retries 429/503 honouring Retry-After, applies a
77
+ * per-attempt timeout, and throws a typed BetterCMSError on non-OK responses.
78
+ *
79
+ * This path (BetterCMS.site()) previously had NO retry and NO timeout: the first
80
+ * 429 from the delivery limiter threw straight through and killed the caller. In a
81
+ * static build that is a failed deploy. See ./retry.ts for why a fixed ladder was
82
+ * never going to be enough against a 60-second fixed window.
76
83
  */
77
84
  fetchJSON<T>(url: string): Promise<T>;
78
85
  }
@@ -139,7 +146,7 @@ interface WebhookListResponse {
139
146
  *
140
147
  * Extends BetterCMSDeliveryClient but overrides:
141
148
  * - `headers()` — uses Bearer token auth instead of API key
142
- * - `fetchJSON()` — adds retry on 429/503 and 10s timeout per request
149
+ * - `fetchJSON()` — adds Retry-After-aware retry on 429/503 (see ./retry.ts)
143
150
  *
144
151
  * @see openspec/changes/flo-6-backend-sdk-admin/specs/sdk-admin-client/spec.md
145
152
  */
@@ -158,13 +165,12 @@ interface BetterCMSAdminOptions {
158
165
  * fetchJSON() for Bearer auth + retry/timeout.
159
166
  */
160
167
  declare class BetterCMSAdminClient extends BetterCMSDeliveryClient {
161
- readonly timeout: number;
162
168
  private readonly _token;
163
169
  constructor(options: BetterCMSAdminOptions);
164
170
  headers(): Record<string, string>;
165
171
  /**
166
- * Admin fetchJSON with retry (429, 503) and per-request timeout (AbortController).
167
- * Each retry gets a fresh timeout.
172
+ * Admin fetchJSON with Retry-After-aware retry (429, 503) and a per-request
173
+ * timeout (AbortController). Each retry gets a fresh timeout.
168
174
  */
169
175
  fetchJSON<T>(url: string, init?: RequestInit): Promise<T>;
170
176
  /** Build the admin API URL. */
@@ -741,7 +747,11 @@ declare class BetterCMSManagementClient extends BetterCMSDeliveryClient {
741
747
  headers(): Record<string, string>;
742
748
  /** Management API URL: baseUrl + path (path is workspace-scoped via the key). */
743
749
  url(path: string): string;
744
- /** fetchJSON with retry (429, 503) + per-request timeout (mirrors the admin client). */
750
+ /**
751
+ * fetchJSON with Retry-After-aware retry (429, 503) + per-request timeout
752
+ * (mirrors the admin client). See ./retry.ts for why the delay must be able
753
+ * to outlast the backend's 60-second rate-limit window.
754
+ */
745
755
  fetchJSON<T>(url: string, init?: RequestInit): Promise<T>;
746
756
  listModels(): Promise<ManagedContentModel[]>;
747
757
  getModel(id: string): Promise<ManagedContentModel>;
@@ -864,6 +874,12 @@ interface BetterCMSSiteOptions {
864
874
  apiKey?: string;
865
875
  /** Base URL of the Delivery API. Defaults to https://api.bettercms.ai/v1. */
866
876
  baseUrl?: string;
877
+ /**
878
+ * Per-attempt request timeout in ms. Defaults to 10000. Each of the 3 retries gets
879
+ * a fresh one. Raise it for a slow delivery origin rather than losing the timeout —
880
+ * without one, a hung socket stalls a build indefinitely.
881
+ */
882
+ timeout?: number;
867
883
  }
868
884
  /**
869
885
  * BetterCMS factory — returns a typed client for the Delivery API.
@@ -931,6 +947,21 @@ interface CreateClientOptions {
931
947
  stega?: boolean;
932
948
  /** Per-attempt timeout in ms. Defaults to 10000. */
933
949
  timeout?: number;
950
+ /**
951
+ * Collapse identical GETs to one network round-trip for the life of this client.
952
+ * Default false.
953
+ *
954
+ * Only correct where the content is a POINT-IN-TIME SNAPSHOT — i.e. a static build,
955
+ * where every route renders from the same published dataset. `@bettercms-ai/astro`
956
+ * turns this on automatically when Astro's `command === "build"` and never for `dev`,
957
+ * SSR or the drafts perspective, where a repeat read must see the current row.
958
+ *
959
+ * A static build without it re-fetches the same nav/settings/collection once PER ROUTE.
960
+ * That is what made a large site exhaust the delivery rate limit: hundreds of identical
961
+ * requests, and `getPage()` re-scanning every page of the paginated list each time it is
962
+ * called.
963
+ */
964
+ cache?: boolean;
934
965
  }
935
966
  interface ListEntriesOptions {
936
967
  /** Filter by content-model slug. */
package/dist/index.d.ts CHANGED
@@ -51,11 +51,13 @@ declare function listContentAll(client: BetterCMSDeliveryClient, opts?: ListCont
51
51
  declare class BetterCMSDeliveryClient {
52
52
  readonly workspace: string;
53
53
  readonly baseUrl: string;
54
+ protected readonly timeout: number;
54
55
  private readonly apiKey?;
55
56
  constructor(opts: {
56
57
  workspace: string;
57
58
  apiKey?: string;
58
59
  baseUrl: string;
60
+ timeout?: number;
59
61
  });
60
62
  getContent(slug: string): Promise<_bettercms_ai_types.Content>;
61
63
  listContent(opts?: Parameters<typeof listContent>[1]): Promise<_bettercms_ai_types.PaginatedResult<_bettercms_ai_types.Content>>;
@@ -71,8 +73,13 @@ declare class BetterCMSDeliveryClient {
71
73
  /** Build request headers, including auth if provided. */
72
74
  headers(): Record<string, string>;
73
75
  /**
74
- * Generic fetch helper — wraps a request with network-error handling
75
- * and throws a typed BetterCMSError on non-OK responses.
76
+ * Generic fetch helper — retries 429/503 honouring Retry-After, applies a
77
+ * per-attempt timeout, and throws a typed BetterCMSError on non-OK responses.
78
+ *
79
+ * This path (BetterCMS.site()) previously had NO retry and NO timeout: the first
80
+ * 429 from the delivery limiter threw straight through and killed the caller. In a
81
+ * static build that is a failed deploy. See ./retry.ts for why a fixed ladder was
82
+ * never going to be enough against a 60-second fixed window.
76
83
  */
77
84
  fetchJSON<T>(url: string): Promise<T>;
78
85
  }
@@ -139,7 +146,7 @@ interface WebhookListResponse {
139
146
  *
140
147
  * Extends BetterCMSDeliveryClient but overrides:
141
148
  * - `headers()` — uses Bearer token auth instead of API key
142
- * - `fetchJSON()` — adds retry on 429/503 and 10s timeout per request
149
+ * - `fetchJSON()` — adds Retry-After-aware retry on 429/503 (see ./retry.ts)
143
150
  *
144
151
  * @see openspec/changes/flo-6-backend-sdk-admin/specs/sdk-admin-client/spec.md
145
152
  */
@@ -158,13 +165,12 @@ interface BetterCMSAdminOptions {
158
165
  * fetchJSON() for Bearer auth + retry/timeout.
159
166
  */
160
167
  declare class BetterCMSAdminClient extends BetterCMSDeliveryClient {
161
- readonly timeout: number;
162
168
  private readonly _token;
163
169
  constructor(options: BetterCMSAdminOptions);
164
170
  headers(): Record<string, string>;
165
171
  /**
166
- * Admin fetchJSON with retry (429, 503) and per-request timeout (AbortController).
167
- * Each retry gets a fresh timeout.
172
+ * Admin fetchJSON with Retry-After-aware retry (429, 503) and a per-request
173
+ * timeout (AbortController). Each retry gets a fresh timeout.
168
174
  */
169
175
  fetchJSON<T>(url: string, init?: RequestInit): Promise<T>;
170
176
  /** Build the admin API URL. */
@@ -741,7 +747,11 @@ declare class BetterCMSManagementClient extends BetterCMSDeliveryClient {
741
747
  headers(): Record<string, string>;
742
748
  /** Management API URL: baseUrl + path (path is workspace-scoped via the key). */
743
749
  url(path: string): string;
744
- /** fetchJSON with retry (429, 503) + per-request timeout (mirrors the admin client). */
750
+ /**
751
+ * fetchJSON with Retry-After-aware retry (429, 503) + per-request timeout
752
+ * (mirrors the admin client). See ./retry.ts for why the delay must be able
753
+ * to outlast the backend's 60-second rate-limit window.
754
+ */
745
755
  fetchJSON<T>(url: string, init?: RequestInit): Promise<T>;
746
756
  listModels(): Promise<ManagedContentModel[]>;
747
757
  getModel(id: string): Promise<ManagedContentModel>;
@@ -864,6 +874,12 @@ interface BetterCMSSiteOptions {
864
874
  apiKey?: string;
865
875
  /** Base URL of the Delivery API. Defaults to https://api.bettercms.ai/v1. */
866
876
  baseUrl?: string;
877
+ /**
878
+ * Per-attempt request timeout in ms. Defaults to 10000. Each of the 3 retries gets
879
+ * a fresh one. Raise it for a slow delivery origin rather than losing the timeout —
880
+ * without one, a hung socket stalls a build indefinitely.
881
+ */
882
+ timeout?: number;
867
883
  }
868
884
  /**
869
885
  * BetterCMS factory — returns a typed client for the Delivery API.
@@ -931,6 +947,21 @@ interface CreateClientOptions {
931
947
  stega?: boolean;
932
948
  /** Per-attempt timeout in ms. Defaults to 10000. */
933
949
  timeout?: number;
950
+ /**
951
+ * Collapse identical GETs to one network round-trip for the life of this client.
952
+ * Default false.
953
+ *
954
+ * Only correct where the content is a POINT-IN-TIME SNAPSHOT — i.e. a static build,
955
+ * where every route renders from the same published dataset. `@bettercms-ai/astro`
956
+ * turns this on automatically when Astro's `command === "build"` and never for `dev`,
957
+ * SSR or the drafts perspective, where a repeat read must see the current row.
958
+ *
959
+ * A static build without it re-fetches the same nav/settings/collection once PER ROUTE.
960
+ * That is what made a large site exhaust the delivery rate limit: hundreds of identical
961
+ * requests, and `getPage()` re-scanning every page of the paginated list each time it is
962
+ * called.
963
+ */
964
+ cache?: boolean;
934
965
  }
935
966
  interface ListEntriesOptions {
936
967
  /** Filter by content-model slug. */
package/dist/index.js CHANGED
@@ -89,6 +89,35 @@ var BetterCMSError = class _BetterCMSError extends Error {
89
89
  }
90
90
  };
91
91
 
92
+ // src/retry.ts
93
+ var RETRY_STATUSES = [429, 503];
94
+ var MAX_RETRIES = 3;
95
+ var MAX_DELAY_MS = 65e3;
96
+ var BASE_DELAY_MS = 1e3;
97
+ var JITTER_MS = 250;
98
+ function isRetryableStatus(status) {
99
+ return RETRY_STATUSES.includes(status);
100
+ }
101
+ function retryAfterMs(res) {
102
+ const raw = res.headers?.get?.("retry-after");
103
+ if (!raw) return null;
104
+ const seconds = Number(raw);
105
+ if (Number.isFinite(seconds)) {
106
+ return Math.min(Math.max(seconds, 0) * 1e3, MAX_DELAY_MS);
107
+ }
108
+ const at = Date.parse(raw);
109
+ if (!Number.isNaN(at)) {
110
+ return Math.min(Math.max(0, at - Date.now()), MAX_DELAY_MS);
111
+ }
112
+ return null;
113
+ }
114
+ function backoffMs(attempt, res) {
115
+ const fromServer = res ? retryAfterMs(res) : null;
116
+ const base = Math.min(fromServer ?? BASE_DELAY_MS * 2 ** attempt, MAX_DELAY_MS - JITTER_MS);
117
+ return base + Math.random() * JITTER_MS;
118
+ }
119
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
120
+
92
121
  // src/methods/getContent.ts
93
122
  async function getContent(client, slug) {
94
123
  const data = await client.fetchJSON(client.url(slug));
@@ -153,14 +182,17 @@ function listContentAll(client, opts) {
153
182
  }
154
183
 
155
184
  // src/delivery-client.ts
185
+ var DEFAULT_TIMEOUT = 1e4;
156
186
  var BetterCMSDeliveryClient = class {
157
187
  workspace;
158
188
  baseUrl;
189
+ timeout;
159
190
  apiKey;
160
191
  constructor(opts) {
161
192
  this.workspace = opts.workspace;
162
193
  this.baseUrl = opts.baseUrl;
163
194
  this.apiKey = opts.apiKey;
195
+ this.timeout = opts.timeout ?? DEFAULT_TIMEOUT;
164
196
  }
165
197
  getContent(slug) {
166
198
  return getContent(this, slug);
@@ -193,24 +225,46 @@ var BetterCMSDeliveryClient = class {
193
225
  return headers;
194
226
  }
195
227
  /**
196
- * Generic fetch helper — wraps a request with network-error handling
197
- * and throws a typed BetterCMSError on non-OK responses.
228
+ * Generic fetch helper — retries 429/503 honouring Retry-After, applies a
229
+ * per-attempt timeout, and throws a typed BetterCMSError on non-OK responses.
230
+ *
231
+ * This path (BetterCMS.site()) previously had NO retry and NO timeout: the first
232
+ * 429 from the delivery limiter threw straight through and killed the caller. In a
233
+ * static build that is a failed deploy. See ./retry.ts for why a fixed ladder was
234
+ * never going to be enough against a 60-second fixed window.
198
235
  */
199
236
  async fetchJSON(url) {
200
- let res;
201
- try {
202
- res = await globalThis.fetch(url, { headers: this.headers() });
203
- } catch (err) {
204
- throw new BetterCMSError(
205
- `Network error: ${err instanceof Error ? err.message : String(err)}`,
206
- 0,
207
- "NETWORK_ERROR"
208
- );
209
- }
210
- if (!res.ok) {
237
+ for (let attempt = 0; ; attempt++) {
238
+ const controller = new AbortController();
239
+ const timer = setTimeout(() => controller.abort(), this.timeout);
240
+ let res;
241
+ try {
242
+ res = await globalThis.fetch(url, {
243
+ headers: this.headers(),
244
+ signal: controller.signal
245
+ });
246
+ } catch (err) {
247
+ const timedOut = err instanceof Error && err.name === "AbortError";
248
+ if (attempt < MAX_RETRIES) {
249
+ await sleep(backoffMs(attempt));
250
+ continue;
251
+ }
252
+ if (timedOut) throw new BetterCMSError("Request timeout", 408, "NETWORK_ERROR");
253
+ throw new BetterCMSError(
254
+ `Network error: ${err instanceof Error ? err.message : String(err)}`,
255
+ 0,
256
+ "NETWORK_ERROR"
257
+ );
258
+ } finally {
259
+ clearTimeout(timer);
260
+ }
261
+ if (res.ok) return await res.json();
262
+ if (attempt < MAX_RETRIES && isRetryableStatus(res.status)) {
263
+ await sleep(backoffMs(attempt, res));
264
+ continue;
265
+ }
211
266
  throw await BetterCMSError.from(res);
212
267
  }
213
- return await res.json();
214
268
  }
215
269
  };
216
270
 
@@ -291,19 +345,15 @@ function addWebhookMethods(client) {
291
345
 
292
346
  // src/admin-client.ts
293
347
  var DEFAULT_BASE_URL = "https://api.bettercms.ai/v1";
294
- var DEFAULT_TIMEOUT = 1e4;
295
- var RETRY_STATUSES = [429, 503];
296
- var RETRY_DELAYS = [1e3, 2e3, 4e3];
297
348
  var BetterCMSAdminClient = class extends BetterCMSDeliveryClient {
298
- timeout;
299
349
  _token;
300
350
  constructor(options) {
301
351
  super({
302
352
  workspace: "",
303
- baseUrl: options.baseUrl ?? DEFAULT_BASE_URL
353
+ baseUrl: options.baseUrl ?? DEFAULT_BASE_URL,
354
+ timeout: options.timeout
304
355
  });
305
356
  this._token = options.token;
306
- this.timeout = options.timeout ?? DEFAULT_TIMEOUT;
307
357
  }
308
358
  headers() {
309
359
  return {
@@ -313,12 +363,12 @@ var BetterCMSAdminClient = class extends BetterCMSDeliveryClient {
313
363
  };
314
364
  }
315
365
  /**
316
- * Admin fetchJSON with retry (429, 503) and per-request timeout (AbortController).
317
- * Each retry gets a fresh timeout.
366
+ * Admin fetchJSON with Retry-After-aware retry (429, 503) and a per-request
367
+ * timeout (AbortController). Each retry gets a fresh timeout.
318
368
  */
319
369
  async fetchJSON(url, init) {
320
370
  let lastError;
321
- for (let attempt = 0; attempt <= 3; attempt++) {
371
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
322
372
  const controller = new AbortController();
323
373
  const timeoutId = setTimeout(() => controller.abort(), this.timeout);
324
374
  let nonRetryable = false;
@@ -332,12 +382,12 @@ var BetterCMSAdminClient = class extends BetterCMSDeliveryClient {
332
382
  if (res.ok) {
333
383
  return res.json();
334
384
  }
335
- const shouldRetry = attempt < 3 && RETRY_STATUSES.includes(res.status);
385
+ const shouldRetry = attempt < MAX_RETRIES && isRetryableStatus(res.status);
336
386
  if (!shouldRetry) {
337
387
  nonRetryable = true;
338
388
  throw await BetterCMSError.from(res);
339
389
  }
340
- await sleep(RETRY_DELAYS[attempt] ?? 0);
390
+ await sleep(backoffMs(attempt, res));
341
391
  } catch (err) {
342
392
  clearTimeout(timeoutId);
343
393
  if (err instanceof Error && err.name === "AbortError") {
@@ -347,8 +397,8 @@ var BetterCMSAdminClient = class extends BetterCMSDeliveryClient {
347
397
  throw err;
348
398
  }
349
399
  lastError = err;
350
- if (attempt < 3) {
351
- await sleep(RETRY_DELAYS[attempt] ?? 0);
400
+ if (attempt < MAX_RETRIES) {
401
+ await sleep(backoffMs(attempt));
352
402
  continue;
353
403
  }
354
404
  throw err;
@@ -365,9 +415,6 @@ var BetterCMSAdminClient = class extends BetterCMSDeliveryClient {
365
415
  return addWebhookMethods(this);
366
416
  }
367
417
  };
368
- function sleep(ms) {
369
- return new Promise((resolve) => setTimeout(resolve, ms));
370
- }
371
418
 
372
419
  // src/methods/management/content.ts
373
420
  async function listModels(client) {
@@ -744,8 +791,6 @@ async function commandManagedLayout(client, input) {
744
791
  // src/management-client.ts
745
792
  var DEFAULT_BASE_URL2 = "https://api.bettercms.ai/v1";
746
793
  var DEFAULT_TIMEOUT2 = 1e4;
747
- var RETRY_STATUSES2 = [429, 503];
748
- var RETRY_DELAYS2 = [1e3, 2e3, 4e3];
749
794
  var BetterCMSManagementClient = class extends BetterCMSDeliveryClient {
750
795
  timeout;
751
796
  _apiKey;
@@ -767,10 +812,14 @@ var BetterCMSManagementClient = class extends BetterCMSDeliveryClient {
767
812
  url(path) {
768
813
  return `${this.baseUrl}${path}`;
769
814
  }
770
- /** fetchJSON with retry (429, 503) + per-request timeout (mirrors the admin client). */
815
+ /**
816
+ * fetchJSON with Retry-After-aware retry (429, 503) + per-request timeout
817
+ * (mirrors the admin client). See ./retry.ts for why the delay must be able
818
+ * to outlast the backend's 60-second rate-limit window.
819
+ */
771
820
  async fetchJSON(url, init) {
772
821
  let lastError;
773
- for (let attempt = 0; attempt <= 3; attempt++) {
822
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
774
823
  const controller = new AbortController();
775
824
  const timeoutId = setTimeout(() => controller.abort(), this.timeout);
776
825
  let nonRetryable = false;
@@ -784,12 +833,12 @@ var BetterCMSManagementClient = class extends BetterCMSDeliveryClient {
784
833
  if (res.ok) {
785
834
  return res.json();
786
835
  }
787
- const shouldRetry = attempt < 3 && RETRY_STATUSES2.includes(res.status);
836
+ const shouldRetry = attempt < MAX_RETRIES && isRetryableStatus(res.status);
788
837
  if (!shouldRetry) {
789
838
  nonRetryable = true;
790
839
  throw await BetterCMSError.from(res);
791
840
  }
792
- await sleep2(RETRY_DELAYS2[attempt] ?? 0);
841
+ await sleep(backoffMs(attempt, res));
793
842
  } catch (err) {
794
843
  clearTimeout(timeoutId);
795
844
  if (err instanceof Error && err.name === "AbortError") {
@@ -797,8 +846,8 @@ var BetterCMSManagementClient = class extends BetterCMSDeliveryClient {
797
846
  }
798
847
  if (nonRetryable) throw err;
799
848
  lastError = err;
800
- if (attempt < 3) {
801
- await sleep2(RETRY_DELAYS2[attempt] ?? 0);
849
+ if (attempt < MAX_RETRIES) {
850
+ await sleep(backoffMs(attempt));
802
851
  continue;
803
852
  }
804
853
  throw err;
@@ -930,9 +979,6 @@ var BetterCMSManagementClient = class extends BetterCMSDeliveryClient {
930
979
  return generateSeoMeta(this, input);
931
980
  }
932
981
  };
933
- function sleep2(ms) {
934
- return new Promise((resolve) => setTimeout(resolve, ms));
935
- }
936
982
 
937
983
  // src/auth.ts
938
984
  var AUTH_BASE_URL = "https://api.bettercms.ai/api/v1/auth";
@@ -1023,7 +1069,8 @@ var BetterCMS = {
1023
1069
  return new BetterCMSDeliveryClient({
1024
1070
  workspace: options.workspace,
1025
1071
  apiKey: options.apiKey,
1026
- baseUrl: options.baseUrl ?? DEFAULT_BASE_URL3
1072
+ baseUrl: options.baseUrl ?? DEFAULT_BASE_URL3,
1073
+ timeout: options.timeout
1027
1074
  });
1028
1075
  },
1029
1076
  /**
@@ -1055,10 +1102,7 @@ var BetterCMS = {
1055
1102
  };
1056
1103
 
1057
1104
  // src/read-client.ts
1058
- var RETRY_STATUSES3 = [429, 503];
1059
- var RETRY_DELAYS3 = [250, 750, 1500];
1060
1105
  var DEFAULT_TIMEOUT3 = 1e4;
1061
- var sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
1062
1106
  function qs(params2) {
1063
1107
  const sp = new URLSearchParams();
1064
1108
  for (const [k, v] of Object.entries(params2)) {
@@ -1075,6 +1119,27 @@ function unwrap(json) {
1075
1119
  }
1076
1120
  return json;
1077
1121
  }
1122
+ function createRequestCache() {
1123
+ const inflight = /* @__PURE__ */ new Map();
1124
+ const settled = /* @__PURE__ */ new Map();
1125
+ const copy = (value) => typeof structuredClone === "function" ? structuredClone(value) : JSON.parse(JSON.stringify(value));
1126
+ return async function collapse(url, fetcher) {
1127
+ if (settled.has(url)) return copy(settled.get(url));
1128
+ const pending = inflight.get(url);
1129
+ if (pending) return copy(await pending);
1130
+ const run = (async () => {
1131
+ try {
1132
+ const value = await fetcher();
1133
+ settled.set(url, value);
1134
+ return value;
1135
+ } finally {
1136
+ inflight.delete(url);
1137
+ }
1138
+ })();
1139
+ inflight.set(url, run);
1140
+ return copy(await run);
1141
+ };
1142
+ }
1078
1143
  function createClient(options) {
1079
1144
  const apiUrl = options.apiUrl.replace(/\/+$/, "");
1080
1145
  const { workspace, apiKey, perspective = "published", previewToken } = options;
@@ -1086,9 +1151,11 @@ function createClient(options) {
1086
1151
  if (apiKey) h["X-API-Key"] = apiKey;
1087
1152
  return h;
1088
1153
  };
1089
- async function fetchJSON(url) {
1154
+ const collapse = options.cache && perspective === "published" ? createRequestCache() : void 0;
1155
+ const fetchJSON = (url) => collapse ? collapse(url, () => request(url)) : request(url);
1156
+ async function request(url) {
1090
1157
  let lastError;
1091
- for (let attempt = 0; attempt <= RETRY_DELAYS3.length; attempt++) {
1158
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
1092
1159
  const controller = new AbortController();
1093
1160
  const timer = setTimeout(() => controller.abort(), timeout);
1094
1161
  try {
@@ -1098,9 +1165,9 @@ function createClient(options) {
1098
1165
  });
1099
1166
  clearTimeout(timer);
1100
1167
  if (res.ok) return await res.json();
1101
- const retryable = attempt < RETRY_DELAYS3.length && RETRY_STATUSES3.includes(res.status);
1168
+ const retryable = attempt < MAX_RETRIES && isRetryableStatus(res.status);
1102
1169
  if (!retryable) throw await BetterCMSError.from(res);
1103
- await sleep3(RETRY_DELAYS3[attempt] ?? 0);
1170
+ await sleep(backoffMs(attempt, res));
1104
1171
  } catch (err) {
1105
1172
  clearTimeout(timer);
1106
1173
  if (err instanceof Error && err.name === "AbortError") {
@@ -1108,8 +1175,8 @@ function createClient(options) {
1108
1175
  }
1109
1176
  if (err instanceof BetterCMSError) throw err;
1110
1177
  lastError = err;
1111
- if (attempt < RETRY_DELAYS3.length) {
1112
- await sleep3(RETRY_DELAYS3[attempt] ?? 0);
1178
+ if (attempt < MAX_RETRIES) {
1179
+ await sleep(backoffMs(attempt));
1113
1180
  continue;
1114
1181
  }
1115
1182
  throw new BetterCMSError(