@oeave/bakery3 0.0.0-stage → 0.2.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.
Files changed (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +562 -2
  3. package/dist/animation-B1h0Ryvj.d.ts +97 -0
  4. package/dist/bake/index.d.ts +369 -0
  5. package/dist/bake/index.js +9 -0
  6. package/dist/bake/index.js.map +1 -0
  7. package/dist/bake-DZ-CJR6f.d.ts +1364 -0
  8. package/dist/catalog/index.d.ts +100 -0
  9. package/dist/catalog/index.js +154 -0
  10. package/dist/catalog/index.js.map +1 -0
  11. package/dist/catalog.gen-BM-aNf7n.d.ts +733 -0
  12. package/dist/chunk-3G5QL4F4.js +145 -0
  13. package/dist/chunk-3G5QL4F4.js.map +1 -0
  14. package/dist/chunk-4E5VV4QY.js +12 -0
  15. package/dist/chunk-4E5VV4QY.js.map +1 -0
  16. package/dist/chunk-62M6XXNX.js +142 -0
  17. package/dist/chunk-62M6XXNX.js.map +1 -0
  18. package/dist/chunk-6FYI6AJM.js +1157 -0
  19. package/dist/chunk-6FYI6AJM.js.map +1 -0
  20. package/dist/chunk-6NYR73Y7.js +832 -0
  21. package/dist/chunk-6NYR73Y7.js.map +1 -0
  22. package/dist/chunk-APWUCEEB.js +118 -0
  23. package/dist/chunk-APWUCEEB.js.map +1 -0
  24. package/dist/chunk-HNMSWQU7.js +1709 -0
  25. package/dist/chunk-HNMSWQU7.js.map +1 -0
  26. package/dist/chunk-JFYUDERE.js +1412 -0
  27. package/dist/chunk-JFYUDERE.js.map +1 -0
  28. package/dist/chunk-KNUAOILG.js +551 -0
  29. package/dist/chunk-KNUAOILG.js.map +1 -0
  30. package/dist/chunk-KZLVTSBI.js +191 -0
  31. package/dist/chunk-KZLVTSBI.js.map +1 -0
  32. package/dist/chunk-LAKXC4WR.js +2028 -0
  33. package/dist/chunk-LAKXC4WR.js.map +1 -0
  34. package/dist/chunk-NXBAZGNB.js +1107 -0
  35. package/dist/chunk-NXBAZGNB.js.map +1 -0
  36. package/dist/chunk-QRCT5UGZ.js +3286 -0
  37. package/dist/chunk-QRCT5UGZ.js.map +1 -0
  38. package/dist/chunk-S4AJTLLN.js +23 -0
  39. package/dist/chunk-S4AJTLLN.js.map +1 -0
  40. package/dist/chunk-UWBP7B54.js +92 -0
  41. package/dist/chunk-UWBP7B54.js.map +1 -0
  42. package/dist/chunk-XK35ANPJ.js +346 -0
  43. package/dist/chunk-XK35ANPJ.js.map +1 -0
  44. package/dist/chunk-Z7IYVH22.js +4781 -0
  45. package/dist/chunk-Z7IYVH22.js.map +1 -0
  46. package/dist/chunk-ZEBVAIJJ.js +156 -0
  47. package/dist/chunk-ZEBVAIJJ.js.map +1 -0
  48. package/dist/devtools/index.d.ts +536 -0
  49. package/dist/devtools/index.js +15 -0
  50. package/dist/devtools/index.js.map +1 -0
  51. package/dist/environments/index.d.ts +93 -0
  52. package/dist/environments/index.js +382 -0
  53. package/dist/environments/index.js.map +1 -0
  54. package/dist/hotspots/index.d.ts +111 -0
  55. package/dist/hotspots/index.js +285 -0
  56. package/dist/hotspots/index.js.map +1 -0
  57. package/dist/index.d.ts +975 -0
  58. package/dist/index.js +15 -0
  59. package/dist/index.js.map +1 -0
  60. package/dist/node/index.cjs +5240 -0
  61. package/dist/node/index.cjs.map +1 -0
  62. package/dist/node/index.d.cts +4927 -0
  63. package/dist/node/index.d.ts +597 -0
  64. package/dist/node/index.js +1328 -0
  65. package/dist/node/index.js.map +1 -0
  66. package/dist/prepare-BtjY4G3q.d.ts +112 -0
  67. package/dist/presets/index.d.ts +562 -0
  68. package/dist/presets/index.js +14 -0
  69. package/dist/presets/index.js.map +1 -0
  70. package/dist/r3f/index.d.ts +159 -0
  71. package/dist/r3f/index.js +592 -0
  72. package/dist/r3f/index.js.map +1 -0
  73. package/dist/room-FS26KAPQ.js +9 -0
  74. package/dist/room-FS26KAPQ.js.map +1 -0
  75. package/dist/rooms.gen-DItzBR9k.d.ts +1462 -0
  76. package/dist/session-VCIQEO26.js +9 -0
  77. package/dist/session-VCIQEO26.js.map +1 -0
  78. package/dist/shapes/index.d.ts +222 -0
  79. package/dist/shapes/index.js +836 -0
  80. package/dist/shapes/index.js.map +1 -0
  81. package/dist/testRun-20OARnQr.d.ts +1139 -0
  82. package/dist/timeline-ChwgD7bT.d.ts +470 -0
  83. package/dist/tsl/index.d.ts +165 -0
  84. package/dist/tsl/index.js +310 -0
  85. package/dist/tsl/index.js.map +1 -0
  86. package/package.json +170 -4
@@ -0,0 +1,1107 @@
1
+ import { SDK_VERSION } from './chunk-UWBP7B54.js';
2
+ import { Bakery3Error, RenderBudgetExceeded, BatchPageRefused } from './chunk-3G5QL4F4.js';
3
+
4
+ // src/warn.ts
5
+ var warned = /* @__PURE__ */ new Set();
6
+ var BROWSER_TOKEN_PREFIX = "bk_pt_";
7
+ function isBrowserLike(scope = globalThis) {
8
+ const global = scope;
9
+ if (typeof global.window === "object" && global.window !== null) {
10
+ if (global.window.document !== void 0) return true;
11
+ }
12
+ if (typeof global.importScripts === "function") return true;
13
+ if (global.navigator?.product === "ReactNative") return true;
14
+ return false;
15
+ }
16
+ function isProductionBuild(scope = globalThis) {
17
+ const env = scope.process?.env;
18
+ return env?.NODE_ENV === "production";
19
+ }
20
+ function redact(key) {
21
+ const prefix = /^bk_[a-z]{2}_/.exec(key)?.[0] ?? "";
22
+ const secret = key.slice(prefix.length);
23
+ return secret.length < 16 ? `${prefix}\u2026` : `${key.slice(0, 12)}\u2026`;
24
+ }
25
+ function warnIfKeyIsExposed(key, { source, scope = globalThis, log }) {
26
+ if (!key || key.startsWith(BROWSER_TOKEN_PREFIX)) return false;
27
+ if (!isBrowserLike(scope)) return false;
28
+ if (warned.has(key)) return false;
29
+ warned.add(key);
30
+ const warn = log ?? (typeof console !== "undefined" && typeof console.warn === "function" ? (message2) => console.warn(message2) : () => {
31
+ });
32
+ warn(message(key, source, isProductionBuild(scope)));
33
+ return true;
34
+ }
35
+ function message(key, source, production) {
36
+ const lines = [
37
+ production ? `[bakery3] A secret API key is in a PRODUCTION browser bundle: ${redact(key)}` : `[bakery3] Your secret API key is in browser JavaScript: ${redact(key)}`,
38
+ "",
39
+ "Anyone who opens the network tab on this page can read it, and a bk_sk_ key",
40
+ "can start renders, read every render on the account, and reach billing. It",
41
+ "cannot be origin-restricted, spend-capped or revoked without replacing it.",
42
+ "",
43
+ production ? "This build has NODE_ENV=production. Do not ship it. Mint a browser token" : "Fine while you are testing. Before you deploy, mint a browser token",
44
+ "on your own server instead:",
45
+ "",
46
+ " // app/api/bakery3-token/route.ts (server only)",
47
+ " import { Bakery3Server } from '@oeave/bakery3/node';",
48
+ " const bakery3 = new Bakery3Server(process.env.BAKERY3_SECRET_KEY!);",
49
+ " export const POST = async () =>",
50
+ " Response.json(await bakery3.tokens.create({",
51
+ " expiresInSeconds: 300, maxCost: 1,",
52
+ " allowedOrigins: ['https://shop.example.com'],",
53
+ " }));",
54
+ ""
55
+ ];
56
+ if (source === "apiKey") {
57
+ lines.push(
58
+ " // browser: token instead of apiKey",
59
+ " createBakery3({",
60
+ " token: () => fetch('/api/bakery3-token', { method: 'POST' }).then((r) => r.json()),",
61
+ " });",
62
+ ""
63
+ );
64
+ } else {
65
+ lines.push(
66
+ "@oeave/bakery3/node is a server module. Importing it into a component that",
67
+ "runs in the browser puts the key in the bundle. Keep it behind a route",
68
+ "handler, a server action, or a file with 'use server'.",
69
+ ""
70
+ );
71
+ }
72
+ lines.push("https://bakery3.com/docs/auth");
73
+ return lines.join("\n");
74
+ }
75
+
76
+ // src/client.ts
77
+ var DEFAULT_BASE_URL = "https://api.bakery3.com";
78
+ var SDK_VERSION_HEADER = "bakery3-sdk";
79
+ var VERSION_HEADER_IN_BROWSERS = true;
80
+ var DEFAULT_RETRY = {
81
+ maxTries: 5,
82
+ baseDelayMs: 500,
83
+ maxDelayMs: 3e4
84
+ };
85
+ var CHARGING_ROUTES = /* @__PURE__ */ new Set(["/v1/renders", "/v1/bakes"]);
86
+ var MAX_OWNERS = 1e3;
87
+ var AUTH_DOCS = "https://bakery3.com/docs/auth";
88
+ var TOKEN_ROUTE_DOCS = "https://bakery3.com/docs/auth#the-token-endpoint";
89
+ var KEYS_URL = "https://bakery3.com/keys";
90
+ var Bakery3Client = class {
91
+ baseUrl;
92
+ auth;
93
+ /**
94
+ * The fetch this client sends with: the one passed in `options.fetch`, or
95
+ * the global. Uploads and bundle reads use it too, so a test, a proxy or a
96
+ * runtime with its own fetch sees every request the SDK makes.
97
+ */
98
+ fetch;
99
+ /** Cached until it expires. Re-minting per render would add a round trip to
100
+ * the developer's own server on the critical path of every click. */
101
+ cached = null;
102
+ /** One token fetch at a time. React's StrictMode mounts twice, and two
103
+ * renders at once both find the cache empty: they share this one. */
104
+ minting = null;
105
+ /**
106
+ * The browser token each render, bake and batch was created with, by id.
107
+ *
108
+ * A token can only read the rows it created. Once it has spent its
109
+ * allowance or expired, the API still lets it read and cancel those rows
110
+ * (for 24 hours past expiry) but not create more, and a freshly minted
111
+ * token cannot read them at all. So a job polls with the token that
112
+ * started it, whatever the local cache thinks of that token's expiry.
113
+ */
114
+ owners = /* @__PURE__ */ new Map();
115
+ retry;
116
+ sendVersion;
117
+ constructor(options = {}) {
118
+ const given = options ?? {};
119
+ const { token, apiKey } = given;
120
+ if (token && apiKey) {
121
+ throw new Bakery3Error({
122
+ code: "InvalidRequest",
123
+ message: "Pass either token or apiKey, not both.",
124
+ why: "They are two different integrations, and the SDK will not guess which one you meant.",
125
+ fix: "Keep `token` for anything you deploy; `apiKey` is for local testing only.",
126
+ docs: AUTH_DOCS
127
+ });
128
+ }
129
+ this.auth = authOf(token, apiKey, "apiKey" in given);
130
+ this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
131
+ this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
132
+ this.retry = { ...DEFAULT_RETRY, ...options.retry };
133
+ this.sendVersion = VERSION_HEADER_IN_BROWSERS;
134
+ if (this.auth.kind === "key")
135
+ warnIfKeyIsExposed(this.auth.key, { source: "apiKey" });
136
+ }
137
+ /**
138
+ * Why this client cannot make a request, or null when it can. The same
139
+ * error its first request would throw, for a panel or a status line that
140
+ * wants to say so before anyone presses Render.
141
+ */
142
+ credentialError() {
143
+ return this.auth.kind === "missing" ? this.auth.refusal() : null;
144
+ }
145
+ async token(force = false) {
146
+ const auth = this.auth;
147
+ if (auth.kind === "key") return auth.key;
148
+ if (auth.kind === "missing") throw auth.refusal();
149
+ if (!force && this.cached && this.cached.expiresAt - 1e4 > Date.now()) {
150
+ return this.cached.token;
151
+ }
152
+ this.minting ??= this.mint(auth.provider).finally(() => {
153
+ this.minting = null;
154
+ });
155
+ return this.minting;
156
+ }
157
+ async mint(provider) {
158
+ let result;
159
+ try {
160
+ result = await provider();
161
+ } catch (cause) {
162
+ throw providerThrew(cause);
163
+ }
164
+ const { token, expiresAt } = readMinted(result);
165
+ this.cached = {
166
+ token,
167
+ expiresAt: expiresAt ?? // Unknown expiry: hold it for a minute rather than for the session,
168
+ // so a stale one self-heals without a failed render to prompt it.
169
+ Date.now() + 6e4
170
+ };
171
+ return token;
172
+ }
173
+ async checkAssets(body) {
174
+ return this.post("/v1/assets/check", body);
175
+ }
176
+ /**
177
+ * `POST /v1/assets/import`: have the service fetch a public URL into the
178
+ * project's assets. Secret key only. Safe to repeat: the same bytes are the
179
+ * same asset, and an unchanged URL is answered without a download.
180
+ */
181
+ async importAsset(body) {
182
+ return this.post("/v1/assets/import", body);
183
+ }
184
+ async estimate(body) {
185
+ return this.post("/v1/estimate", body);
186
+ }
187
+ async createRender(body) {
188
+ const { value, credential } = await this.call(
189
+ "POST",
190
+ "/v1/renders",
191
+ body,
192
+ { retry: Boolean(body.idempotencyKey) }
193
+ );
194
+ this.remember("render", value.id, credential);
195
+ return value;
196
+ }
197
+ /**
198
+ * A render's record, with fresh signed URLs for its result and preview.
199
+ * Read with the token that created it, when this client created it.
200
+ */
201
+ async getRender(id, options = {}) {
202
+ return this.read("GET", `/v1/renders/${segment(id)}`, {
203
+ owner: `render:${id}`,
204
+ signal: options.signal
205
+ });
206
+ }
207
+ async cancelRender(id) {
208
+ return this.read(
209
+ "POST",
210
+ `/v1/renders/${segment(id)}/cancel`,
211
+ { owner: `render:${id}` },
212
+ {}
213
+ );
214
+ }
215
+ /**
216
+ * `POST /v1/renders/:id/retry`: one more attempt for a dead-lettered render.
217
+ * Answers 201 with a new render linked to the old one, or 409 when the
218
+ * render is not dead-lettered. Not repeated on a 429/503: it creates a
219
+ * render, and a duplicate would be a duplicate charge.
220
+ */
221
+ async retryRender(id) {
222
+ const { value, credential } = await this.call(
223
+ "POST",
224
+ `/v1/renders/${segment(id)}/retry`,
225
+ {},
226
+ { retry: false, owner: `render:${id}` }
227
+ );
228
+ this.remember("render", value.id, credential);
229
+ return value;
230
+ }
231
+ async createBake(body) {
232
+ const { value, credential } = await this.call(
233
+ "POST",
234
+ "/v1/bakes",
235
+ body,
236
+ { retry: Boolean(body.idempotencyKey) }
237
+ );
238
+ this.remember("bake", value.id, credential);
239
+ return value;
240
+ }
241
+ async getBake(id, options = {}) {
242
+ return this.read("GET", `/v1/bakes/${segment(id)}`, {
243
+ owner: `bake:${id}`,
244
+ signal: options.signal
245
+ });
246
+ }
247
+ async cancelBake(id) {
248
+ return this.read(
249
+ "POST",
250
+ `/v1/bakes/${segment(id)}/cancel`,
251
+ { owner: `bake:${id}` },
252
+ {}
253
+ );
254
+ }
255
+ // -------------------------------------------------------------------------
256
+ // Batches
257
+ // -------------------------------------------------------------------------
258
+ /** `POST /v1/batches`. The key is the idempotency key: the same key
259
+ * re-opens the same batch (200) while it is not terminal. */
260
+ async openBatch(body) {
261
+ const { value, credential } = await this.call(
262
+ "POST",
263
+ "/v1/batches",
264
+ body,
265
+ {}
266
+ );
267
+ this.remember("batch", value.id, credential);
268
+ return value;
269
+ }
270
+ /**
271
+ * `GET /v1/batches/:id`. With `wait`, long-polls for up to that many
272
+ * seconds (at most 25) until the counts change: the one poll a batch needs.
273
+ */
274
+ async getBatch(id, options = {}) {
275
+ const wait = options.wait === void 0 ? void 0 : Math.min(25, Math.max(0, options.wait));
276
+ const suffix = wait ? `?wait=${wait}` : "";
277
+ return this.read(
278
+ "GET",
279
+ `/v1/batches/${segment(id)}${suffix}`,
280
+ {
281
+ owner: `batch:${id}`,
282
+ signal: options.signal
283
+ }
284
+ );
285
+ }
286
+ /**
287
+ * `POST /v1/batches/:id/items`: one page, at most `BATCH_PAGE_SIZE`.
288
+ *
289
+ * Every item carries its own key, so the page is safe to send twice: the
290
+ * second copy adopts what the first admitted. A refusal of the whole page
291
+ * (402 InsufficientBalance / BatchBudgetExceeded, 409 BatchClosed) throws;
292
+ * a problem with one item comes back inside `items[i].error` with a 200.
293
+ */
294
+ async addBatchItems(id, body) {
295
+ return this.read(
296
+ "POST",
297
+ `/v1/batches/${segment(id)}/items`,
298
+ { owner: `batch:${id}` },
299
+ body
300
+ );
301
+ }
302
+ /** `GET /v1/batches/:id/items?state=&cursor=&limit=`. */
303
+ async listBatchItems(id, options = {}) {
304
+ const query = new URLSearchParams();
305
+ if (options.state) query.set("state", options.state);
306
+ if (options.cursor) query.set("cursor", options.cursor);
307
+ if (options.limit) query.set("limit", String(options.limit));
308
+ const suffix = query.toString() ? `?${query}` : "";
309
+ return this.read(
310
+ "GET",
311
+ `/v1/batches/${segment(id)}/items${suffix}`,
312
+ { owner: `batch:${id}` }
313
+ );
314
+ }
315
+ /**
316
+ * `GET /v1/batches/:id/export`: the ledger as NDJSON, one line per item,
317
+ * yielded as it streams so a ten-thousand-item batch is never one string.
318
+ */
319
+ async *exportBatch(id) {
320
+ const { response } = await this.send(
321
+ "GET",
322
+ `/v1/batches/${segment(id)}/export`,
323
+ void 0,
324
+ { retry: true, owner: `batch:${id}` }
325
+ );
326
+ yield* ndjson(response);
327
+ }
328
+ /** `POST /v1/batches/:id/{close,pause,resume,cancel,retry}`. Every one is a
329
+ * state transition the control plane treats idempotently, so all repeat. */
330
+ async batchAction(id, action) {
331
+ return this.read(
332
+ "POST",
333
+ `/v1/batches/${segment(id)}/${action}`,
334
+ { owner: `batch:${id}` },
335
+ {}
336
+ );
337
+ }
338
+ post(path, body, options) {
339
+ return this.request("POST", path, body, options);
340
+ }
341
+ /** A request about a row this client may have created, with its token. */
342
+ async read(method, path, options, body) {
343
+ return (await this.call(method, path, body, options)).value;
344
+ }
345
+ /** Remember which token created a row, so the row is read with it. */
346
+ remember(kind, id, credential) {
347
+ if (this.auth.kind !== "token" || !id) return;
348
+ const owner = `${kind}:${id}`;
349
+ this.owners.delete(owner);
350
+ this.owners.set(owner, credential);
351
+ if (this.owners.size > MAX_OWNERS) {
352
+ const oldest = this.owners.keys().next().value;
353
+ if (oldest !== void 0) this.owners.delete(oldest);
354
+ }
355
+ }
356
+ /**
357
+ * One authenticated JSON request, with the 401 re-mint and the 429/503
358
+ * backoff. Public for the server SDK, which shares the transport; the
359
+ * typed methods above are the API.
360
+ */
361
+ async request(method, path, body, options = {}) {
362
+ return (await this.call(method, path, body, options)).value;
363
+ }
364
+ /** A JSON request, and the credential that got the answer. */
365
+ async call(method, path, body, options) {
366
+ const { response, credential, url } = await this.send(
367
+ method,
368
+ path,
369
+ body,
370
+ options
371
+ );
372
+ if (response.status === 204) return { value: void 0, credential };
373
+ const text = await response.text();
374
+ if (!text.trim()) return { value: void 0, credential };
375
+ try {
376
+ return { value: JSON.parse(text), credential };
377
+ } catch {
378
+ throw notOurs(response.status, url, void 0, text, this.baseUrl);
379
+ }
380
+ }
381
+ async send(method, path, body, options) {
382
+ const retry = options.retry ?? isSafeToRepeat(method, path);
383
+ const maxTries = retry ? this.retry.maxTries : 1;
384
+ const url = `${this.baseUrl}${path}`;
385
+ const pinned = options.owner ? this.owners.get(options.owner) : void 0;
386
+ let credential = pinned ?? await this.token();
387
+ let reminted = false;
388
+ let leftPin = false;
389
+ for (let attempt = 1; ; attempt++) {
390
+ const response = await this.fetchOnce(
391
+ method,
392
+ url,
393
+ body,
394
+ credential,
395
+ options.signal
396
+ );
397
+ if (response.ok) {
398
+ if (leftPin && options.owner) {
399
+ const [kind, ...rest] = options.owner.split(":");
400
+ this.remember(kind, rest.join(":"), credential);
401
+ }
402
+ return { response, credential, url };
403
+ }
404
+ if (response.status === 401 && !reminted && this.auth.kind === "token") {
405
+ await response.body?.cancel().catch(() => {
406
+ });
407
+ reminted = true;
408
+ leftPin = pinned !== void 0;
409
+ credential = await this.token(
410
+ !pinned || this.cached?.token === credential
411
+ );
412
+ continue;
413
+ }
414
+ if (leftPin && response.status === 404 && options.owner) {
415
+ await response.body?.cancel().catch(() => {
416
+ });
417
+ throw lostOwner(options.owner);
418
+ }
419
+ if (response.status === 401 && this.auth.kind === "key" && this.auth.key.startsWith("bk_pt_")) {
420
+ await response.body?.cancel().catch(() => {
421
+ });
422
+ throw browserTokenAsKey();
423
+ }
424
+ if ((response.status === 429 || response.status === 503) && attempt < maxTries) {
425
+ await response.body?.cancel().catch(() => {
426
+ });
427
+ await sleep(
428
+ this.delayFor(attempt, response.headers.get("retry-after")),
429
+ options.signal
430
+ );
431
+ continue;
432
+ }
433
+ throw await toError(response, url, path, this.baseUrl);
434
+ }
435
+ }
436
+ async fetchOnce(method, url, body, credential, signal) {
437
+ try {
438
+ return await this.fetch(url, {
439
+ method,
440
+ headers: {
441
+ authorization: `Bearer ${credential}`,
442
+ ...this.sendVersion ? { [SDK_VERSION_HEADER]: SDK_VERSION } : {},
443
+ ...body === void 0 ? {} : { "content-type": "application/json" }
444
+ },
445
+ ...body === void 0 ? {} : { body: JSON.stringify(body) },
446
+ ...signal ? { signal } : {}
447
+ });
448
+ } catch (error) {
449
+ if (signal?.aborted) throw error;
450
+ throw new Bakery3Error({
451
+ code: "NetworkError",
452
+ message: `Could not reach the Bakery3 API: ${error instanceof Error ? error.message : String(error)}`,
453
+ fix: "Check the browser network tab. If you set a custom baseUrl, confirm it allows your origin.",
454
+ retryable: true
455
+ });
456
+ }
457
+ }
458
+ /**
459
+ * Exponential with full jitter, or the server's own `Retry-After` when it
460
+ * sent one: a server that says "in 3 s" knows more than a formula does.
461
+ * Both are capped, so a header naming next Tuesday does not hang a page.
462
+ */
463
+ delayFor(attempt, retryAfter) {
464
+ const { baseDelayMs, maxDelayMs } = this.retry;
465
+ const advised = parseRetryAfter(retryAfter);
466
+ if (advised !== null) return Math.min(maxDelayMs, advised);
467
+ const ceiling = Math.min(maxDelayMs, baseDelayMs * 2 ** (attempt - 1));
468
+ return Math.random() * ceiling;
469
+ }
470
+ };
471
+ function segment(id) {
472
+ return encodeURIComponent(id);
473
+ }
474
+ function authOf(token, apiKey, keyNamed) {
475
+ if (token !== void 0 && token !== null) {
476
+ if (typeof token === "function")
477
+ return { kind: "token", provider: token };
478
+ return { kind: "missing", refusal: tokenNotAFunction };
479
+ }
480
+ if (typeof apiKey === "string" && apiKey.trim()) {
481
+ const key = apiKey.trim();
482
+ if (isPlaceholder(key))
483
+ return { kind: "missing", refusal: () => placeholderKey(key) };
484
+ return { kind: "key", key };
485
+ }
486
+ if (keyNamed || apiKey !== void 0)
487
+ return { kind: "missing", refusal: emptyKey };
488
+ return { kind: "missing", refusal: noCredentials };
489
+ }
490
+ function isPlaceholder(key) {
491
+ return key.includes("...") || key.includes("\u2026");
492
+ }
493
+ function noCredentials() {
494
+ return new Bakery3Error({
495
+ code: "Unauthorized",
496
+ message: `No API key or token. Create a key at ${KEYS_URL} (sign up there first if you have no account).`,
497
+ why: "`apiKey` is a secret key (bk_sk_) you pass directly, for testing and for servers. `token` is a function that fetches a short-lived browser token from your own server, which is what you deploy, because it keeps the key out of the page.",
498
+ fix: "While testing: createBakery3({ apiKey: 'bk_sk_...' }). Before you deploy: createBakery3({ token: getToken }).",
499
+ docs: AUTH_DOCS
500
+ });
501
+ }
502
+ function emptyKey() {
503
+ return new Bakery3Error({
504
+ code: "Unauthorized",
505
+ message: "apiKey is empty. Check that the environment variable holding your key is set.",
506
+ why: "createBakery3({ apiKey }) was given undefined or an empty string, which is what an environment variable that is not set reads as. In a browser bundle, only variables with your framework's public prefix (NEXT_PUBLIC_, VITE_) are set.",
507
+ fix: `Set the variable and restart the dev server. You can copy a key from ${KEYS_URL}.`,
508
+ docs: AUTH_DOCS
509
+ });
510
+ }
511
+ function placeholderKey(key) {
512
+ return new Bakery3Error({
513
+ code: "Unauthorized",
514
+ message: `Replace the placeholder with your key from ${KEYS_URL}.`,
515
+ why: `apiKey is ${redact(key)}, which looks like the example from the docs rather than a real key.`,
516
+ fix: `Copy a secret key (bk_sk_) from ${KEYS_URL} into apiKey while testing, and switch to token before you deploy.`,
517
+ docs: AUTH_DOCS
518
+ });
519
+ }
520
+ function tokenNotAFunction() {
521
+ return new Bakery3Error({
522
+ code: "Unauthorized",
523
+ message: "token must be a function that fetches a browser token, not the token itself.",
524
+ why: "Browser tokens expire after minutes, so the SDK calls your function again for a new one when it needs to.",
525
+ fix: "createBakery3({ token: () => fetch('/api/bakery3-token', { method: 'POST' }).then((r) => r.json()) })",
526
+ docs: TOKEN_ROUTE_DOCS
527
+ });
528
+ }
529
+ function browserTokenAsKey() {
530
+ return new Bakery3Error({
531
+ code: "TokenExpired",
532
+ message: "apiKey holds a browser token (bk_pt_), and the API refused it. It has most likely expired.",
533
+ why: "Browser tokens last minutes. Passed as apiKey, the SDK has no way to fetch a new one when it runs out.",
534
+ fix: "Pass it as token instead, as a function that fetches a fresh one: createBakery3({ token: () => fetch('/api/bakery3-token', { method: 'POST' }).then((r) => r.json()) }).",
535
+ docs: TOKEN_ROUTE_DOCS
536
+ });
537
+ }
538
+ function lostOwner(owner) {
539
+ const kind = owner.slice(0, owner.indexOf(":"));
540
+ const started = kind === "batch" ? "opened" : "started";
541
+ return new Bakery3Error({
542
+ code: "TokenExpired",
543
+ message: `This ${kind} was ${started} with a browser token that can no longer read it. Mint tokens with a longer expiresInSeconds, or read the ${kind} from your server with the secret key.`,
544
+ why: `A browser token can only read the ${kind}s it ${started}. That token was refused, and a new token cannot read a ${kind} it did not start.`,
545
+ fix: "Pass a longer expiresInSeconds to tokens.create() on your server, or read it there with new Bakery3Server(process.env.BAKERY3_SECRET_KEY!).",
546
+ docs: AUTH_DOCS
547
+ });
548
+ }
549
+ function providerThrew(cause) {
550
+ const said = cause instanceof Error ? cause.message : String(cause);
551
+ if (cause instanceof SyntaxError || cause?.name === "SyntaxError") {
552
+ return new Bakery3Error({
553
+ code: "Unauthorized",
554
+ message: "Your token endpoint did not return JSON. It probably answered 404 or 500 with an HTML page.",
555
+ why: `Reading its answer as JSON failed: ${said}`,
556
+ fix: "Open your token route in the network tab. It must answer 200 with the JSON from tokens.create(). On a 500, check your server log: BAKERY3_SECRET_KEY may be unset.",
557
+ docs: TOKEN_ROUTE_DOCS
558
+ });
559
+ }
560
+ return new Bakery3Error({
561
+ code: "Unauthorized",
562
+ message: `The token provider threw: ${said}`,
563
+ why: "createBakery3({ token }) calls your function for a browser token before each request that needs one, and it rejected.",
564
+ fix: "Check your /api/bakery3-token route in the network tab: it should answer 200 with the JSON from tokens.create().",
565
+ docs: TOKEN_ROUTE_DOCS,
566
+ retryable: true
567
+ });
568
+ }
569
+ function readMinted(result) {
570
+ if (isFetchResponse(result)) {
571
+ const status = result.ok ? "" : ` (it answered ${result.status})`;
572
+ throw new Bakery3Error({
573
+ code: "Unauthorized",
574
+ message: `Your token function returned a fetch Response, not the token${status}. Add .then((r) => r.json()).`,
575
+ why: "createBakery3({ token }) needs the token itself: a string, or the JSON from tokens.create().",
576
+ fix: "token: () => fetch('/api/bakery3-token', { method: 'POST' }).then((r) => r.json())",
577
+ docs: TOKEN_ROUTE_DOCS
578
+ });
579
+ }
580
+ if (typeof result === "string" && result) return { token: result };
581
+ if (isRecord(result) && typeof result.token === "string" && result.token) {
582
+ return {
583
+ token: result.token,
584
+ ...typeof result.expiresAt === "number" && result.expiresAt > 0 ? { expiresAt: result.expiresAt } : {}
585
+ };
586
+ }
587
+ const reported = isRecord(result) ? errorText(result) : void 0;
588
+ if (reported) {
589
+ throw new Bakery3Error({
590
+ code: "Unauthorized",
591
+ message: `Your token endpoint answered with an error instead of a token: ${reported}`,
592
+ why: "createBakery3({ token }) needs { token } from your route, and the JSON it got has an error in its place.",
593
+ fix: "Check your server log for the request to your token route. BAKERY3_SECRET_KEY may be unset or wrong.",
594
+ docs: TOKEN_ROUTE_DOCS
595
+ });
596
+ }
597
+ throw new Bakery3Error({
598
+ code: "Unauthorized",
599
+ message: "The token provider returned nothing.",
600
+ why: "createBakery3({ token }) must resolve to a string, or to { token }.",
601
+ fix: "Check that your /api/bakery3-token route returns the JSON from tokens.create().",
602
+ docs: TOKEN_ROUTE_DOCS
603
+ });
604
+ }
605
+ function isFetchResponse(value) {
606
+ if (typeof Response !== "undefined" && value instanceof Response) return true;
607
+ return isRecord(value) && typeof value.json === "function" && typeof value.status === "number" && "headers" in value;
608
+ }
609
+ function errorText(body) {
610
+ const error = body.error;
611
+ if (typeof error === "string" && error) return error;
612
+ if (isRecord(error)) {
613
+ const message2 = typeof error.message === "string" ? error.message : "";
614
+ const code = typeof error.code === "string" ? error.code : "";
615
+ if (message2 || code)
616
+ return code && message2 ? `${code}: ${message2}` : message2 || code;
617
+ return snippet(JSON.stringify(error));
618
+ }
619
+ if (typeof body.message === "string" && body.message) return body.message;
620
+ return void 0;
621
+ }
622
+ function isRecord(value) {
623
+ return typeof value === "object" && value !== null;
624
+ }
625
+ function isSafeToRepeat(method, path) {
626
+ if (method === "GET" || method === "HEAD") return true;
627
+ return !CHARGING_ROUTES.has(path.split("?")[0]);
628
+ }
629
+ function parseRetryAfter(header, now = Date.now()) {
630
+ if (!header) return null;
631
+ const trimmed = header.trim();
632
+ if (/^\d+$/.test(trimmed)) return Number(trimmed) * 1e3;
633
+ const date = Date.parse(trimmed);
634
+ if (Number.isNaN(date)) return null;
635
+ return Math.max(0, date - now);
636
+ }
637
+ function sleep(ms, signal) {
638
+ return new Promise((resolve, reject) => {
639
+ if (signal?.aborted) return reject(signal.reason);
640
+ const timer = setTimeout(() => {
641
+ signal?.removeEventListener("abort", onAbort);
642
+ resolve();
643
+ }, ms);
644
+ function onAbort() {
645
+ clearTimeout(timer);
646
+ reject(signal.reason);
647
+ }
648
+ signal?.addEventListener("abort", onAbort, { once: true });
649
+ });
650
+ }
651
+ async function* ndjson(response) {
652
+ const decoder = new TextDecoder();
653
+ let buffer = "";
654
+ const emit = function* (chunk, flush = false) {
655
+ buffer += chunk;
656
+ const lines = buffer.split("\n");
657
+ buffer = flush ? "" : lines.pop() ?? "";
658
+ for (const line of lines) {
659
+ const trimmed = line.trim();
660
+ if (trimmed) yield JSON.parse(trimmed);
661
+ }
662
+ };
663
+ if (!response.body) {
664
+ yield* emit(await response.text(), true);
665
+ return;
666
+ }
667
+ const reader = response.body.getReader();
668
+ for (; ; ) {
669
+ const { value, done } = await reader.read();
670
+ if (done) break;
671
+ yield* emit(decoder.decode(value, { stream: true }));
672
+ }
673
+ yield* emit(decoder.decode(), true);
674
+ }
675
+ async function toError(response, url, path, baseUrl) {
676
+ const text = await response.text().catch(() => "");
677
+ let payload;
678
+ try {
679
+ payload = text ? JSON.parse(text) : void 0;
680
+ } catch {
681
+ payload = void 0;
682
+ }
683
+ const error = isRecord(payload) ? isRecord(payload.error) ? payload.error : payload : void 0;
684
+ if (!error || typeof error.code !== "string" || !error.code) {
685
+ return notOurs(response.status, url, error, text, baseUrl);
686
+ }
687
+ const str = (value) => typeof value === "string" && value ? value : void 0;
688
+ const num = (value) => typeof value === "number" && Number.isFinite(value) ? value : void 0;
689
+ const message2 = str(error.message) ?? `${response.status} ${response.statusText}`.trim();
690
+ if (error.code === "RenderBudgetExceeded") {
691
+ return new RenderBudgetExceeded({
692
+ message: message2,
693
+ why: str(error.why),
694
+ fix: str(error.fix),
695
+ docs: str(error.docs),
696
+ maxCost: num(error.maxCost),
697
+ estimatedCost: num(error.estimatedCost),
698
+ maximumCost: num(error.maximumCost),
699
+ kind: budgetedKind(error.kind, path)
700
+ });
701
+ }
702
+ return new Bakery3Error({
703
+ code: error.code,
704
+ message: message2,
705
+ why: str(error.why),
706
+ fix: str(error.fix),
707
+ docs: str(error.docs),
708
+ path: str(error.path),
709
+ retryable: response.status >= 500 || response.status === 429
710
+ });
711
+ }
712
+ function budgetedKind(kind, path) {
713
+ if (kind === "render" || kind === "video" || kind === "bake") return kind;
714
+ return path.startsWith("/v1/bakes") ? "bake" : "render";
715
+ }
716
+ function notOurs(status, url, body, text, baseUrl) {
717
+ const where = `${status} from ${url.split("?")[0]}`;
718
+ const said = body && errorText(body) || snippet(text);
719
+ const answered = said ? ` It answered: ${said}` : "";
720
+ if (status >= 500) {
721
+ return new Bakery3Error({
722
+ code: "NetworkError",
723
+ message: `${where}: the server failed before the Bakery3 API could answer.`,
724
+ why: `The answer has no Bakery3 error in it, so a gateway or proxy in between answered.${answered}`,
725
+ fix: `Try again in a moment. If it keeps happening, check that baseUrl (${baseUrl}) points at the Bakery3 API.`,
726
+ retryable: true
727
+ });
728
+ }
729
+ return new Bakery3Error({
730
+ code: status === 401 || status === 403 ? "Unauthorized" : status === 429 ? "RateLimited" : "InvalidRequest",
731
+ message: `${where}: not the Bakery3 API. Check baseUrl.`,
732
+ why: `The answer has no Bakery3 error in it, so another server answered: a wrong baseUrl, a proxy, or a page of your own app.${answered}`,
733
+ fix: `Leave baseUrl out to use ${DEFAULT_BASE_URL}, or point it at a proxy that forwards /v1/ requests unchanged.`,
734
+ retryable: status === 429
735
+ });
736
+ }
737
+ function snippet(text) {
738
+ const flat = text.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
739
+ return flat.length > 160 ? `${flat.slice(0, 160)}\u2026` : flat;
740
+ }
741
+
742
+ // src/protocol/batch.ts
743
+ var BATCH_PAGE_SIZE = 100;
744
+ var BATCH_PAGE_BYTES = 4 * 1024 * 1024;
745
+ var BATCH_ITEM_BYTES = 900 * 1024;
746
+
747
+ // src/pollFailures.ts
748
+ var POLL_GIVE_UP_MS = 5 * 6e4;
749
+ var PollFailures = class {
750
+ constructor(now = Date.now) {
751
+ this.now = now;
752
+ }
753
+ now;
754
+ since = null;
755
+ /** A poll got through: whatever was failing has cleared. */
756
+ succeeded() {
757
+ this.since = null;
758
+ }
759
+ /** A poll failed. True once they have all been failing for the whole window. */
760
+ exhausted() {
761
+ this.since ??= this.now();
762
+ return this.now() - this.since >= POLL_GIVE_UP_MS;
763
+ }
764
+ };
765
+
766
+ // src/batch.ts
767
+ var LONG_POLL_SECONDS = 25;
768
+ var WAIT_RETRY_MS = { first: 1e3, ceiling: 3e4 };
769
+ var LIST_PAGE_SIZE = 100;
770
+ var Batch = class {
771
+ id;
772
+ key;
773
+ latest;
774
+ /** The flush in flight, or the last one, settled either way. */
775
+ flushing = Promise.resolve();
776
+ buffer = [];
777
+ /** `buffer[i]` as JSON is about `sizes[i]` bytes. Kept beside it so a
778
+ * page can be closed on bytes without serializing anything twice. */
779
+ sizes = [];
780
+ bytes = 0;
781
+ client;
782
+ /** The clock `wait()` measures its patience with. A test turns it. */
783
+ clock = {
784
+ now: () => Date.now(),
785
+ sleep: pause
786
+ };
787
+ /** @internal `bakery3.batches.open()` and `.get()` make these. */
788
+ constructor(client, record) {
789
+ this.client = client;
790
+ this.latest = record;
791
+ this.id = record.id;
792
+ this.key = record.key;
793
+ }
794
+ /** Buffer an item. Nothing is sent until `flush()`. */
795
+ add(item) {
796
+ if (!item.key) {
797
+ throw new Bakery3Error({
798
+ code: "InvalidRequest",
799
+ message: "A batch item needs a key.",
800
+ why: "The key is what lets a re-run adopt the item instead of rendering it twice, and what a failed item is reported under.",
801
+ fix: "Use something stable that names the picture: `${sku}/${finish}/${camera}`.",
802
+ docs: "https://bakery3.com/docs/api"
803
+ });
804
+ }
805
+ const size = jsonBytes(item);
806
+ if (size > BATCH_ITEM_BYTES) throw itemTooLarge(item.key, size);
807
+ this.buffer.push(item);
808
+ this.sizes.push(size);
809
+ this.bytes += size;
810
+ return this;
811
+ }
812
+ /**
813
+ * `add`, and send a page when one is full. Awaiting it is the
814
+ * backpressure: a loop over a file, a cursor or a generator of any length
815
+ * holds one page at most.
816
+ *
817
+ * for await (const item of source) await batch.write(item);
818
+ * await batch.close();
819
+ */
820
+ async write(item) {
821
+ this.add(item);
822
+ if (this.buffer.length >= BATCH_PAGE_SIZE || this.bytes >= BATCH_PAGE_BYTES)
823
+ await this.flush();
824
+ }
825
+ /** Items added and not yet sent. */
826
+ get buffered() {
827
+ return this.buffer.length;
828
+ }
829
+ /** About how many bytes of JSON those items are. */
830
+ get bufferedBytes() {
831
+ return this.bytes;
832
+ }
833
+ /**
834
+ * Price items without buffering or admitting them: the estimate, the
835
+ * maximum and any refusal, per item, in the order given. Paged like a
836
+ * flush. An item already completed under its key is quoted as adopted, at
837
+ * nothing, which makes this the price of a re-run too.
838
+ */
839
+ async quote(items) {
840
+ const outcomes = [];
841
+ const sizes = items.map(jsonBytes);
842
+ let offset = 0;
843
+ while (offset < items.length) {
844
+ const count = pageLength(sizes, offset);
845
+ const response = await this.client.addBatchItems(this.id, {
846
+ items: items.slice(offset, offset + count),
847
+ dryRun: true
848
+ });
849
+ outcomes.push(...response.items);
850
+ offset += count;
851
+ }
852
+ return outcomes;
853
+ }
854
+ /**
855
+ * Send the buffer in pages: `BATCH_PAGE_SIZE` items or `BATCH_PAGE_BYTES`
856
+ * of JSON, whichever a page reaches first.
857
+ *
858
+ * Returns one outcome per item sent: admitted, adopted, or refused alone
859
+ * (`outcome.error`). A page refused whole throws `BatchPageRefused` and
860
+ * leaves that page and everything behind it in the buffer, so the caller
861
+ * can top up and call `flush()` again; the error carries the outcomes of
862
+ * the pages that did get through.
863
+ */
864
+ flush(options = {}) {
865
+ const run = this.flushing.then(() => this.flushNow(options));
866
+ this.flushing = run.catch(() => {
867
+ });
868
+ return run;
869
+ }
870
+ async flushNow(options) {
871
+ const outcomes = [];
872
+ let offset = 0;
873
+ while (offset < this.buffer.length) {
874
+ const page = this.buffer.slice(
875
+ offset,
876
+ offset + pageLength(this.sizes, offset)
877
+ );
878
+ let response;
879
+ try {
880
+ response = await this.client.addBatchItems(this.id, {
881
+ items: page,
882
+ ...options.dryRun ? { dryRun: true } : {}
883
+ });
884
+ } catch (error) {
885
+ if (error instanceof Bakery3Error)
886
+ throw new BatchPageRefused(error, page, outcomes);
887
+ throw error;
888
+ }
889
+ this.latest = response.batch;
890
+ outcomes.push(...response.items);
891
+ if (options.dryRun) {
892
+ offset += page.length;
893
+ } else {
894
+ this.buffer.splice(offset, page.length);
895
+ for (const size of this.sizes.splice(offset, page.length))
896
+ this.bytes -= size;
897
+ }
898
+ }
899
+ return outcomes;
900
+ }
901
+ /** Seal: no more items. `completed` fires once the last one is terminal.
902
+ * Anything still buffered is flushed first, because closing over unsent
903
+ * items would quietly drop the tail of a catalog. */
904
+ async close() {
905
+ if (this.buffer.length) await this.flush();
906
+ return this.apply(await this.client.batchAction(this.id, "close"));
907
+ }
908
+ /** Stop feeding the queue; running items finish. */
909
+ async pause() {
910
+ return this.apply(await this.client.batchAction(this.id, "pause"));
911
+ }
912
+ async resume() {
913
+ return this.apply(await this.client.batchAction(this.id, "resume"));
914
+ }
915
+ /** Cancel every non-terminal item. What completed stays completed: it rendered. */
916
+ async cancel() {
917
+ return this.apply(await this.client.batchAction(this.id, "cancel"));
918
+ }
919
+ /** Re-queue every dead-lettered item, one new attempt each. */
920
+ async retry() {
921
+ return this.apply(await this.client.batchAction(this.id, "retry"));
922
+ }
923
+ /** The latest record this batch has seen. No request. */
924
+ record() {
925
+ return this.latest;
926
+ }
927
+ /** Fetch the record now. */
928
+ async refresh() {
929
+ return this.apply(await this.client.getBatch(this.id));
930
+ }
931
+ /** Counts and cost from the latest record. No request. */
932
+ progress() {
933
+ return progressOf(this.latest);
934
+ }
935
+ /**
936
+ * Wait for the batch to finish: one long-poll loop on the batch record
937
+ * until it is `completed` or `cancelled` (or sealed with every item
938
+ * terminal). Never a poll per render.
939
+ *
940
+ * A paused batch is waited on, not given up on (someone may resume it from
941
+ * the console), and `onProgress` sees `pausedReason` meanwhile. Pass
942
+ * `signal` to stop waiting; the batch itself is untouched.
943
+ *
944
+ * A fault that clears is ridden out. One that does not (five minutes with
945
+ * not one poll through) rejects with an error that says so and carries the
946
+ * last fault's code. The batch renders on regardless, and `wait()` can be
947
+ * called again.
948
+ */
949
+ async wait(options = {}) {
950
+ const { onProgress, signal } = options;
951
+ const failures = new PollFailures(this.clock.now);
952
+ let failed = 0;
953
+ for (; ; ) {
954
+ if (signal?.aborted) throw signal.reason ?? abortError();
955
+ if (isFinished(this.latest)) return this.latest;
956
+ try {
957
+ this.apply(
958
+ await this.client.getBatch(this.id, {
959
+ wait: LONG_POLL_SECONDS,
960
+ signal
961
+ })
962
+ );
963
+ failed = 0;
964
+ failures.succeeded();
965
+ } catch (error) {
966
+ if (signal?.aborted) throw signal.reason ?? abortError();
967
+ if (error instanceof Bakery3Error && !error.retryable) throw error;
968
+ if (failures.exhausted()) throw lostContact(this.id, error);
969
+ await this.clock.sleep(
970
+ Math.min(WAIT_RETRY_MS.ceiling, WAIT_RETRY_MS.first * 2 ** failed++),
971
+ signal
972
+ );
973
+ continue;
974
+ }
975
+ onProgress?.(this.progress());
976
+ }
977
+ }
978
+ /** Every item, or every item in one state, walked with the cursor. */
979
+ async *items(options = {}) {
980
+ let cursor;
981
+ do {
982
+ const page = await this.client.listBatchItems(this.id, {
983
+ ...options.state ? { state: options.state } : {},
984
+ ...cursor ? { cursor } : {},
985
+ limit: LIST_PAGE_SIZE
986
+ });
987
+ yield* page.items;
988
+ cursor = page.cursor ?? void 0;
989
+ } while (cursor);
990
+ }
991
+ /** The ledger, line by line, as the server streams it. */
992
+ export() {
993
+ return this.client.exportBatch(this.id);
994
+ }
995
+ apply(record) {
996
+ this.latest = record;
997
+ return record;
998
+ }
999
+ };
1000
+ function progressOf(record) {
1001
+ return {
1002
+ ...record.counts,
1003
+ id: record.id,
1004
+ state: record.state,
1005
+ total: record.total,
1006
+ done: record.done,
1007
+ cost: record.cost,
1008
+ ...record.pausedReason ? { pausedReason: record.pausedReason } : {}
1009
+ };
1010
+ }
1011
+ function isFinished(record) {
1012
+ if (record.state === "completed" || record.state === "cancelled") return true;
1013
+ return record.state === "sealed" && record.done >= record.total;
1014
+ }
1015
+ function pageLength(sizes, offset) {
1016
+ let count = 0;
1017
+ let bytes = 0;
1018
+ while (offset + count < sizes.length && count < BATCH_PAGE_SIZE) {
1019
+ bytes += sizes[offset + count];
1020
+ if (count > 0 && bytes > BATCH_PAGE_BYTES) break;
1021
+ count++;
1022
+ }
1023
+ return count;
1024
+ }
1025
+ function jsonBytes(item) {
1026
+ return JSON.stringify(item).length;
1027
+ }
1028
+ function itemTooLarge(key, size) {
1029
+ return new Bakery3Error({
1030
+ code: "InvalidRequest",
1031
+ message: `Item "${key}" is ${(size / 1024).toFixed(0)} KB of JSON; an item holds at most ${BATCH_ITEM_BYTES / 1024} KB.`,
1032
+ why: "An item is a manifest: cameras, lights and ids, a few KB. The heavy bytes are assets, uploaded once by hash and named from the manifest.",
1033
+ fix: "Look for something large inline in the spec or its metadata (a data: URL, a geometry, a long array) and ship it as an asset, or leave it out of `metadata`.",
1034
+ path: key,
1035
+ docs: "https://bakery3.com/docs/batches#errors"
1036
+ });
1037
+ }
1038
+ function lostContact(id, cause) {
1039
+ const last = cause instanceof Error ? cause.message : String(cause);
1040
+ return new Bakery3Error({
1041
+ code: cause instanceof Bakery3Error ? cause.code : "NetworkError",
1042
+ message: `Stopped waiting for batch ${id}: no poll has got through for ${POLL_GIVE_UP_MS / 6e4} minutes. The last error was: ${last}`,
1043
+ why: "The batch keeps rendering on the server whether or not this page can reach it, so wait() rides out a dropped connection. A fault that lasts this long (a token route that is gone, a base URL that stopped resolving) will not clear by itself.",
1044
+ fix: "Fix what the last error names, then call wait() again, or read the set later with getRenderSet(id). Nothing was lost.",
1045
+ docs: "https://bakery3.com/docs/batches#errors",
1046
+ retryable: true
1047
+ });
1048
+ }
1049
+ function pause(ms, signal) {
1050
+ return new Promise((resolve) => {
1051
+ const timer = setTimeout(done, ms);
1052
+ function done() {
1053
+ clearTimeout(timer);
1054
+ signal?.removeEventListener("abort", done);
1055
+ resolve();
1056
+ }
1057
+ signal?.addEventListener("abort", done, { once: true });
1058
+ });
1059
+ }
1060
+ function abortError() {
1061
+ const error = new Error("The wait was aborted.");
1062
+ error.name = "AbortError";
1063
+ return error;
1064
+ }
1065
+
1066
+ // src/testRun.ts
1067
+ var TEST_DEFAULTS = { products: 3, size: 512, maxCost: 5 };
1068
+ function testSettings(run, options = {}, now = Date.now()) {
1069
+ const size = Math.max(64, Math.round(options.size ?? TEST_DEFAULTS.size));
1070
+ const scale = size / Math.max(run.width, run.height);
1071
+ const stamp = now.toString(36);
1072
+ return {
1073
+ key: `${run.key}:test:${stamp}`,
1074
+ version: `test-${stamp}`,
1075
+ quality: "preview",
1076
+ // Even, because an encoder somewhere always wants that.
1077
+ width: Math.max(2, Math.round(run.width * scale / 2) * 2),
1078
+ height: Math.max(2, Math.round(run.height * scale / 2) * 2),
1079
+ maxCost: options.maxCost ?? TEST_DEFAULTS.maxCost,
1080
+ lane: "normal"
1081
+ };
1082
+ }
1083
+ function quoteKey(runKey) {
1084
+ return `${runKey}:quote`;
1085
+ }
1086
+ async function* sampleOf(variants, count = TEST_DEFAULTS.products) {
1087
+ const wanted = Math.max(1, Math.floor(count));
1088
+ if (Array.isArray(variants)) {
1089
+ const length = variants.length;
1090
+ if (length <= wanted) return yield* variants;
1091
+ const picked = /* @__PURE__ */ new Set();
1092
+ for (let i = 0; i < wanted; i++)
1093
+ picked.add(Math.round(i * (length - 1) / (wanted - 1 || 1)));
1094
+ for (const index of [...picked].sort((a, b) => a - b))
1095
+ yield variants[index];
1096
+ return;
1097
+ }
1098
+ let taken = 0;
1099
+ for await (const variant of variants) {
1100
+ yield variant;
1101
+ if (++taken >= wanted) return;
1102
+ }
1103
+ }
1104
+
1105
+ export { Bakery3Client, Batch, PollFailures, quoteKey, sampleOf, testSettings, warnIfKeyIsExposed };
1106
+ //# sourceMappingURL=chunk-NXBAZGNB.js.map
1107
+ //# sourceMappingURL=chunk-NXBAZGNB.js.map