@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.
- package/LICENSE +21 -0
- package/README.md +562 -2
- package/dist/animation-B1h0Ryvj.d.ts +97 -0
- package/dist/bake/index.d.ts +369 -0
- package/dist/bake/index.js +9 -0
- package/dist/bake/index.js.map +1 -0
- package/dist/bake-DZ-CJR6f.d.ts +1364 -0
- package/dist/catalog/index.d.ts +100 -0
- package/dist/catalog/index.js +154 -0
- package/dist/catalog/index.js.map +1 -0
- package/dist/catalog.gen-BM-aNf7n.d.ts +733 -0
- package/dist/chunk-3G5QL4F4.js +145 -0
- package/dist/chunk-3G5QL4F4.js.map +1 -0
- package/dist/chunk-4E5VV4QY.js +12 -0
- package/dist/chunk-4E5VV4QY.js.map +1 -0
- package/dist/chunk-62M6XXNX.js +142 -0
- package/dist/chunk-62M6XXNX.js.map +1 -0
- package/dist/chunk-6FYI6AJM.js +1157 -0
- package/dist/chunk-6FYI6AJM.js.map +1 -0
- package/dist/chunk-6NYR73Y7.js +832 -0
- package/dist/chunk-6NYR73Y7.js.map +1 -0
- package/dist/chunk-APWUCEEB.js +118 -0
- package/dist/chunk-APWUCEEB.js.map +1 -0
- package/dist/chunk-HNMSWQU7.js +1709 -0
- package/dist/chunk-HNMSWQU7.js.map +1 -0
- package/dist/chunk-JFYUDERE.js +1412 -0
- package/dist/chunk-JFYUDERE.js.map +1 -0
- package/dist/chunk-KNUAOILG.js +551 -0
- package/dist/chunk-KNUAOILG.js.map +1 -0
- package/dist/chunk-KZLVTSBI.js +191 -0
- package/dist/chunk-KZLVTSBI.js.map +1 -0
- package/dist/chunk-LAKXC4WR.js +2028 -0
- package/dist/chunk-LAKXC4WR.js.map +1 -0
- package/dist/chunk-NXBAZGNB.js +1107 -0
- package/dist/chunk-NXBAZGNB.js.map +1 -0
- package/dist/chunk-QRCT5UGZ.js +3286 -0
- package/dist/chunk-QRCT5UGZ.js.map +1 -0
- package/dist/chunk-S4AJTLLN.js +23 -0
- package/dist/chunk-S4AJTLLN.js.map +1 -0
- package/dist/chunk-UWBP7B54.js +92 -0
- package/dist/chunk-UWBP7B54.js.map +1 -0
- package/dist/chunk-XK35ANPJ.js +346 -0
- package/dist/chunk-XK35ANPJ.js.map +1 -0
- package/dist/chunk-Z7IYVH22.js +4781 -0
- package/dist/chunk-Z7IYVH22.js.map +1 -0
- package/dist/chunk-ZEBVAIJJ.js +156 -0
- package/dist/chunk-ZEBVAIJJ.js.map +1 -0
- package/dist/devtools/index.d.ts +536 -0
- package/dist/devtools/index.js +15 -0
- package/dist/devtools/index.js.map +1 -0
- package/dist/environments/index.d.ts +93 -0
- package/dist/environments/index.js +382 -0
- package/dist/environments/index.js.map +1 -0
- package/dist/hotspots/index.d.ts +111 -0
- package/dist/hotspots/index.js +285 -0
- package/dist/hotspots/index.js.map +1 -0
- package/dist/index.d.ts +975 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/node/index.cjs +5240 -0
- package/dist/node/index.cjs.map +1 -0
- package/dist/node/index.d.cts +4927 -0
- package/dist/node/index.d.ts +597 -0
- package/dist/node/index.js +1328 -0
- package/dist/node/index.js.map +1 -0
- package/dist/prepare-BtjY4G3q.d.ts +112 -0
- package/dist/presets/index.d.ts +562 -0
- package/dist/presets/index.js +14 -0
- package/dist/presets/index.js.map +1 -0
- package/dist/r3f/index.d.ts +159 -0
- package/dist/r3f/index.js +592 -0
- package/dist/r3f/index.js.map +1 -0
- package/dist/room-FS26KAPQ.js +9 -0
- package/dist/room-FS26KAPQ.js.map +1 -0
- package/dist/rooms.gen-DItzBR9k.d.ts +1462 -0
- package/dist/session-VCIQEO26.js +9 -0
- package/dist/session-VCIQEO26.js.map +1 -0
- package/dist/shapes/index.d.ts +222 -0
- package/dist/shapes/index.js +836 -0
- package/dist/shapes/index.js.map +1 -0
- package/dist/testRun-20OARnQr.d.ts +1139 -0
- package/dist/timeline-ChwgD7bT.d.ts +470 -0
- package/dist/tsl/index.d.ts +165 -0
- package/dist/tsl/index.js +310 -0
- package/dist/tsl/index.js.map +1 -0
- 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
|