@calmlens/sdk 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +54 -37
  2. package/cjs/CalmLensConnection-Bth5fJyp.js +234 -0
  3. package/cjs/CalmLensUtils-DNdmYeX6.js +699 -0
  4. package/cjs/{CalmLensError-CKwMKV0S.js → CalmLensVersion-Bs0DreBV.js} +114 -1
  5. package/cjs/{CalmLensWebhooks-BTPxKvcD.js → CalmLensWebhooks-6thkvr_F.js} +9 -9
  6. package/cjs/WebhookPayload-vy8HNMaK.d.ts +15834 -0
  7. package/cjs/app.d.ts +3 -3
  8. package/cjs/app.js +10 -10
  9. package/cjs/{index-BgmWj3KL.d.ts → index-Dz340UQ9.d.ts} +29 -1
  10. package/cjs/index.d.ts +3 -3
  11. package/cjs/index.js +4 -5
  12. package/cjs/server.d.ts +16 -7
  13. package/cjs/server.js +35 -21
  14. package/cjs/testing.d.ts +21 -42
  15. package/cjs/testing.js +181 -94
  16. package/esm/CalmLensConnection-DrCOL9h9.js +193 -0
  17. package/esm/{CalmLensUtils-Cx32j2zt.js → CalmLensUtils-BQo79TA1.js} +316 -2
  18. package/esm/{CalmLensError-CihGG26K.js → CalmLensVersion-DPnL_Ebv.js} +91 -2
  19. package/esm/{CalmLensWebhooks-DShmAal4.js → CalmLensWebhooks-CWlD-f_P.js} +2 -2
  20. package/esm/WebhookPayload-vy8HNMaK.d.ts +15834 -0
  21. package/esm/app.d.ts +3 -3
  22. package/esm/app.js +4 -4
  23. package/esm/{index-BgmWj3KL.d.ts → index-Dz340UQ9.d.ts} +29 -1
  24. package/esm/index.d.ts +3 -3
  25. package/esm/index.js +1 -3
  26. package/esm/server.d.ts +16 -7
  27. package/esm/server.js +24 -10
  28. package/esm/testing.d.ts +21 -42
  29. package/esm/testing.js +152 -65
  30. package/package.json +1 -1
  31. package/cjs/CalmLensConnection-CYtBP-vu.js +0 -516
  32. package/cjs/CalmLensUtils-Dg3s4loH.js +0 -367
  33. package/cjs/WebhookPayload-Cgb32aSH.d.ts +0 -10187
  34. package/cjs/src-Dsc8Pn6v.js +0 -45
  35. package/esm/CalmLensConnection-wLNy0OIP.js +0 -475
  36. package/esm/WebhookPayload-Cgb32aSH.d.ts +0 -10187
  37. package/esm/src-CJhrILvM.js +0 -22
@@ -0,0 +1,193 @@
1
+ import { c as invalidArgument, o as CalmLensError, s as SDK_ERROR_CODES, u as API_ERROR_CODES } from "./CalmLensVersion-DPnL_Ebv.js";
2
+ import { a as stringifyQuery, b as getRejectedUploadMimeTypeError, d as CalmLensTransport, g as validateAssetPayload, h as getAssetPayloadByteLength, i as randomUuid, m as assetToData, v as MAX_IMAGE_UPLOAD_BYTES, x as isRejectedUploadMimeType, y as MIN_MULTIPART_IMAGE_UPLOAD_BYTES } from "./CalmLensUtils-BQo79TA1.js";
3
+ //#region src/client/CalmLensConnection.ts
4
+ const DEFAULT_TIMEOUT_MS = 12e4;
5
+ const DEFAULT_MAX_RETRIES = 2;
6
+ const DEFAULT_MAX_RESPONSE_BYTES = 33554432;
7
+ /**
8
+ * Bytes as a fetch body. They're sent as they are rather than in a Blob: React Native can't
9
+ * build a Blob from bytes, and every fetch accepts a Uint8Array.
10
+ */
11
+ const toBodyBytes = (value) => value.buffer instanceof ArrayBuffer ? value : Uint8Array.from(value);
12
+ const toRawUploadBody = (payload) => {
13
+ const data = assetToData(payload);
14
+ if (data instanceof Response) {
15
+ if (!data.body) throw new CalmLensError(API_ERROR_CODES.INVALID_UPLOAD_PAYLOAD, { message: "Response upload data must have a body" });
16
+ return data.body;
17
+ }
18
+ if (data instanceof ArrayBuffer || data instanceof Blob) return data;
19
+ if ("getReader" in data) return data;
20
+ return toBodyBytes(data);
21
+ };
22
+ const setOptionalQueryValue = (params, key, value) => {
23
+ if (value !== void 0 && value !== null) params.set(key, typeof value === "string" ? value : JSON.stringify(value));
24
+ };
25
+ const assertValidUploadMimeType = (mimeType) => {
26
+ if (isRejectedUploadMimeType(mimeType)) throw new CalmLensError(API_ERROR_CODES.INVALID_UPLOAD_MIME_TYPE, {
27
+ message: getRejectedUploadMimeTypeError(mimeType),
28
+ details: { mimeType }
29
+ });
30
+ };
31
+ const isImageMimeType = (mimeType) => mimeType.toLowerCase().startsWith("image/");
32
+ const invalidPayload = (message) => new CalmLensError(API_ERROR_CODES.INVALID_UPLOAD_PAYLOAD, { message });
33
+ /** Checks upload options before anything is sent. */
34
+ const prepareSubmitAssetOptions = (options) => {
35
+ if (!options || typeof options !== "object") throw invalidArgument("Asset options are required");
36
+ if (!("content" in options) && !("url" in options) && !("part" in options)) throw invalidPayload("Either file content, URL, or multipart part is required");
37
+ if (!options.name) throw invalidPayload("Asset name is required");
38
+ if (!("part" in options) && "isFinal" in options) throw invalidPayload("isFinal is only valid for multipart uploads");
39
+ if ("part" in options && options.part.partNumber && options.part.partNumber > 1) throw new CalmLensError(API_ERROR_CODES.USE_UPLOAD_PART, { message: "Use uploadPart for multipart upload parts after the first part" });
40
+ if ("content" in options) {
41
+ assertValidUploadMimeType(options.content.type);
42
+ if (!("plainText" in options.content)) {
43
+ const validation = validateAssetPayload(options.content);
44
+ if (!validation.ok) throw invalidPayload(validation.error);
45
+ }
46
+ }
47
+ if ("part" in options) {
48
+ assertValidUploadMimeType(options.part.type);
49
+ const partSizeInBytes = options.part.sizeInBytes ?? getAssetPayloadByteLength(options.part);
50
+ if (isImageMimeType(options.part.type) && partSizeInBytes !== void 0) {
51
+ if (partSizeInBytes > MAX_IMAGE_UPLOAD_BYTES) throw new CalmLensError(API_ERROR_CODES.MULTIPART_IMAGE_TOO_LARGE, { message: "Multipart image uploads cannot exceed 20MB." });
52
+ if (options.part.isFinal && partSizeInBytes < MIN_MULTIPART_IMAGE_UPLOAD_BYTES) throw new CalmLensError(API_ERROR_CODES.MULTIPART_IMAGE_TOO_SMALL, { message: "Multipart image uploads must total at least 10MB." });
53
+ }
54
+ const validation = validateAssetPayload(options.part, {
55
+ multipart: true,
56
+ isFinal: options.part.isFinal
57
+ });
58
+ if (!validation.ok) throw invalidPayload(validation.error);
59
+ }
60
+ return options;
61
+ };
62
+ const newIdempotencyKey = () => randomUuid();
63
+ /** Text content is small, so it keeps the API deadline; binary files may be arbitrarily large. */
64
+ const isTextPayload = (payload) => "plainText" in payload;
65
+ const hasStringStatus = (value) => !!value && typeof value === "object" && typeof value.status === "string";
66
+ const isAsset = (data) => !!data && typeof data === "object" && typeof data.id === "string" && !!data.id && hasStringStatus(data.processing) && hasStringStatus(data.moderation);
67
+ const isUploadedAsset = (data) => !!data && typeof data === "object" && typeof data.id === "string" && !!data.id;
68
+ const expectUploadedAsset = (data) => {
69
+ if (!isUploadedAsset(data)) throw new CalmLensError(SDK_ERROR_CODES.INVALID_RESPONSE, {
70
+ message: "Expected an uploaded asset response",
71
+ status: 200
72
+ });
73
+ return data;
74
+ };
75
+ const expectAsset = (data) => {
76
+ if (!isAsset(data)) throw new CalmLensError(SDK_ERROR_CODES.INVALID_RESPONSE, {
77
+ message: "Expected an asset response",
78
+ status: 200
79
+ });
80
+ return data;
81
+ };
82
+ /** Fills in the default API base URL and drops trailing slashes. */
83
+ const resolveConnectionOptions = (options) => ({
84
+ ...options,
85
+ baseUrl: (options.baseUrl ?? `https://api.calmlens.com/v1`).replace(/\/+$/, "")
86
+ });
87
+ /** One client's transport, base URL and deadlines, and the requests both clients make. */
88
+ var CalmLensConnection = class {
89
+ constructor(options, apiKey) {
90
+ this.options = options;
91
+ const resolveFetch = () => {
92
+ const fetchImpl = this.options.fetch ?? globalThis.fetch;
93
+ if (typeof fetchImpl !== "function") throw new CalmLensError(SDK_ERROR_CODES.NO_FETCH, { message: "No fetch implementation is available; pass one as the fetch option" });
94
+ return fetchImpl;
95
+ };
96
+ this.transport = new CalmLensTransport({
97
+ apiKey,
98
+ fetch: ((input, init) => resolveFetch()(input, init)),
99
+ signal: options.signal,
100
+ maxRetries: options.maxRetries ?? DEFAULT_MAX_RETRIES,
101
+ maxResponseBytes: options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES
102
+ });
103
+ }
104
+ get timeoutMs() {
105
+ return this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
106
+ }
107
+ url(path, query) {
108
+ const search = query ? stringifyQuery(query) : "";
109
+ return `${this.options.baseUrl}${path}${search ? `?${search}` : ""}`;
110
+ }
111
+ /** A JSON request whose deadline covers reading the response. */
112
+ json(method, path, { query, body, headers, idempotencyKey } = {}) {
113
+ return this.transport.json({
114
+ method,
115
+ url: this.url(path, query),
116
+ headers: body === void 0 ? headers : {
117
+ ...headers,
118
+ "Content-Type": "application/json"
119
+ },
120
+ body: body === void 0 ? void 0 : JSON.stringify(body),
121
+ idempotencyKey,
122
+ timeoutMs: this.timeoutMs,
123
+ deadline: "body"
124
+ });
125
+ }
126
+ upload(url, payload, headers, idempotencyKey) {
127
+ return this.transport.json({
128
+ method: "POST",
129
+ url,
130
+ headers,
131
+ body: toRawUploadBody(payload),
132
+ idempotencyKey,
133
+ timeoutMs: isTextPayload(payload) ? this.timeoutMs : this.options.uploadTimeoutMs,
134
+ deadline: "body",
135
+ fetch: this.options.uploadFetch
136
+ });
137
+ }
138
+ /**
139
+ * Uploads file content or a first multipart part to `uploadsPath` (`.../assets`), as raw
140
+ * bytes with the options in the query.
141
+ */
142
+ submitRawUpload(uploadsPath, options, queue, authHeaders = {}) {
143
+ const query = new URLSearchParams();
144
+ query.set("name", options.name);
145
+ query.set("queue", String(queue));
146
+ setOptionalQueryValue(query, "description", options.description);
147
+ setOptionalQueryValue(query, "processingOptions", options.processingOptions);
148
+ setOptionalQueryValue(query, "visibility", options.visibility);
149
+ setOptionalQueryValue(query, "metadata", options.metadata);
150
+ setOptionalQueryValue(query, "externalId", options.externalId);
151
+ setOptionalQueryValue(query, "externalUrl", options.externalUrl);
152
+ setOptionalQueryValue(query, "folderId", options.folderId);
153
+ setOptionalQueryValue(query, "uploadPresetId", options.uploadPresetId);
154
+ const isMultipart = "part" in options;
155
+ const payload = isMultipart ? options.part : options.content;
156
+ query.set("contentType", payload.type);
157
+ setOptionalQueryValue(query, "sizeInBytes", payload.sizeInBytes ?? getAssetPayloadByteLength(payload));
158
+ if (isMultipart) {
159
+ const part = payload;
160
+ const multipartOptions = options;
161
+ query.set("partNumber", String(part.partNumber ?? 1));
162
+ query.set("isFinal", String(part.isFinal ?? false));
163
+ setOptionalQueryValue(query, "sizeInBytes", part.sizeInBytes ?? multipartOptions.sizeInBytes);
164
+ setOptionalQueryValue(query, "totalSizeInBytes", multipartOptions.totalSizeInBytes);
165
+ }
166
+ return this.upload(this.url(`${uploadsPath}?${query}`), payload, {
167
+ ...authHeaders,
168
+ "Content-Type": payload.type,
169
+ "X-CalmLens-Upload": isMultipart ? "part" : "content"
170
+ }, options.idempotencyKey ?? newIdempotencyKey());
171
+ }
172
+ /** Uploads the next part of a multipart upload to `partPath` (`.../assets/{id}/part`). */
173
+ uploadPart(partPath, { part, queue, idempotencyKey }, authHeaders = {}) {
174
+ if (!part) throw invalidArgument("Part options are required");
175
+ const validation = validateAssetPayload(part, {
176
+ multipart: true,
177
+ isFinal: part.isFinal
178
+ });
179
+ if (!validation.ok) throw invalidPayload(validation.error);
180
+ const query = new URLSearchParams({
181
+ partNumber: String(part.partNumber),
182
+ isFinal: String(part.isFinal ?? false)
183
+ });
184
+ if (queue !== void 0) query.set("queue", String(queue));
185
+ setOptionalQueryValue(query, "sizeInBytes", part.sizeInBytes ?? getAssetPayloadByteLength(part));
186
+ return this.upload(this.url(`${partPath}?${query}`), part, {
187
+ ...authHeaders,
188
+ "X-CalmLens-Upload": "part"
189
+ }, idempotencyKey ?? newIdempotencyKey());
190
+ }
191
+ };
192
+ //#endregion
193
+ export { newIdempotencyKey as a, invalidPayload as i, expectAsset as n, prepareSubmitAssetOptions as o, expectUploadedAsset as r, resolveConnectionOptions as s, CalmLensConnection as t };
@@ -1,4 +1,4 @@
1
- import { a as invalidConfig, i as invalidArgument, n as CalmLensError, o as API_ERROR_CODES } from "./CalmLensError-CihGG26K.js";
1
+ import { c as invalidArgument, l as invalidConfig, n as getUserAgent, o as CalmLensError, r as isBrowserRuntime, s as SDK_ERROR_CODES, u as API_ERROR_CODES } from "./CalmLensVersion-DPnL_Ebv.js";
2
2
  const MAX_IMAGE_UPLOAD_BYTES = /* @__PURE__ */ (() => 20971520)();
3
3
  const MIN_MULTIPART_IMAGE_UPLOAD_BYTES = /* @__PURE__ */ (() => 10485760)();
4
4
  const REJECTED_UPLOAD_MIME_TYPE_SET = /* @__PURE__ */ new Set([
@@ -97,6 +97,287 @@ const validateAssetPayload = (payload, options) => {
97
97
  return { ok: true };
98
98
  };
99
99
  //#endregion
100
+ //#region src/client/CalmLensTransport.ts
101
+ /** Retry delays grow from this base, doubling per attempt, with jitter. */
102
+ const RETRY_BASE_DELAY_MS = 500;
103
+ const RETRY_MAX_DELAY_MS = 8e3;
104
+ /** A `Retry-After` longer than this is returned to the caller instead of waited out. */
105
+ const RETRY_AFTER_MAX_MS = 6e4;
106
+ const RETRYABLE_STATUSES = /* @__PURE__ */ new Set([
107
+ 429,
108
+ 502,
109
+ 503,
110
+ 504
111
+ ]);
112
+ const timeoutReason = () => {
113
+ const reason = /* @__PURE__ */ new Error("The operation timed out.");
114
+ reason.name = "TimeoutError";
115
+ return reason;
116
+ };
117
+ /**
118
+ * Links the callers' signals and a per-attempt deadline. `AbortSignal.any` holds its sources
119
+ * weakly, so a long-lived caller signal doesn't collect a listener per streamed response;
120
+ * runtimes without it get explicit listeners that `release` removes.
121
+ */
122
+ const createAttemptSignal = (signals, timeoutMs) => {
123
+ const callers = signals.filter((signal) => signal !== void 0);
124
+ const deadline = new AbortController();
125
+ let timedOut = false;
126
+ let signal;
127
+ let onAbort;
128
+ if (typeof AbortSignal.any === "function") signal = AbortSignal.any([...callers, deadline.signal]);
129
+ else {
130
+ const controller = new AbortController();
131
+ signal = controller.signal;
132
+ onAbort = (event) => controller.abort(event.target.reason);
133
+ for (const source of [...callers, deadline.signal]) if (source.aborted) controller.abort(source.reason);
134
+ else source.addEventListener("abort", onAbort, { once: true });
135
+ }
136
+ let timer = timeoutMs === void 0 ? void 0 : setTimeout(() => {
137
+ timedOut = true;
138
+ deadline.abort(timeoutReason());
139
+ }, timeoutMs);
140
+ return {
141
+ signal,
142
+ isTimedOut: () => timedOut,
143
+ isCancelled: () => callers.some((caller) => caller.aborted),
144
+ clearDeadline: () => {
145
+ if (timer !== void 0) clearTimeout(timer);
146
+ timer = void 0;
147
+ },
148
+ release: () => {
149
+ if (timer !== void 0) clearTimeout(timer);
150
+ timer = void 0;
151
+ if (onAbort) for (const caller of callers) caller.removeEventListener("abort", onAbort);
152
+ }
153
+ };
154
+ };
155
+ /**
156
+ * A redirect the SDK must not follow: it would send the API key to another origin. Browsers report
157
+ * a manual redirect as an opaque response; other runtimes return the 3xx with its Location.
158
+ */
159
+ const isRedirect = (response) => response.type === "opaqueredirect" || response.status >= 300 && response.status < 400 && response.headers.has("location");
160
+ /** Turns anything thrown while sending or reading a request into a `CalmLensError`. */
161
+ const toFailure = (cause, attempt) => {
162
+ if (CalmLensError.isCalmLensError(cause)) return cause;
163
+ if (attempt.isCancelled()) return new CalmLensError(SDK_ERROR_CODES.CANCELLED, {
164
+ message: "Request cancelled",
165
+ cause
166
+ });
167
+ if (attempt.isTimedOut()) return new CalmLensError(SDK_ERROR_CODES.TIMEOUT, {
168
+ message: "Request timed out",
169
+ cause
170
+ });
171
+ return new CalmLensError(SDK_ERROR_CODES.TRANSPORT_ERROR, {
172
+ message: "Request failed",
173
+ cause
174
+ });
175
+ };
176
+ const sleep = (ms, signal) => new Promise((resolve, reject) => {
177
+ if (signal?.aborted) {
178
+ reject(new CalmLensError(SDK_ERROR_CODES.CANCELLED, { message: "Request cancelled" }));
179
+ return;
180
+ }
181
+ const onAbort = () => {
182
+ clearTimeout(timer);
183
+ reject(new CalmLensError(SDK_ERROR_CODES.CANCELLED, { message: "Request cancelled" }));
184
+ };
185
+ const timer = setTimeout(() => {
186
+ signal?.removeEventListener("abort", onAbort);
187
+ resolve();
188
+ }, ms);
189
+ signal?.addEventListener("abort", onAbort, { once: true });
190
+ });
191
+ /** Exponential backoff with jitter: half the delay is fixed, the other half random. */
192
+ const getRetryDelayMs = (attempt, random = Math.random) => {
193
+ const delay = Math.min(RETRY_MAX_DELAY_MS, RETRY_BASE_DELAY_MS * 2 ** attempt);
194
+ return Math.round(delay / 2 + random() * (delay / 2));
195
+ };
196
+ /** Parses `Retry-After` as seconds or an HTTP date, in seconds from `now`. */
197
+ const parseRetryAfter = (value, now = Date.now()) => {
198
+ if (!value) return void 0;
199
+ const trimmed = value.trim();
200
+ if (/^\d+$/.test(trimmed)) return Number(trimmed);
201
+ const date = Date.parse(trimmed);
202
+ if (Number.isNaN(date)) return void 0;
203
+ return Math.max(0, Math.ceil((date - now) / 1e3));
204
+ };
205
+ /** Bodies that can be sent again unchanged. Streams are consumed by the first attempt. */
206
+ const isReplayableBody = (body) => body === void 0 || body === null || typeof body === "string" || body instanceof Blob || body instanceof ArrayBuffer || ArrayBuffer.isView(body) || body instanceof URLSearchParams;
207
+ const isApiErrorPayload = (value) => !!value && typeof value === "object" && typeof value.code === "string";
208
+ /** Reads at most `limit` bytes of a body, failing with `sdk/response-too-large` beyond that. */
209
+ const readBody = async (response, limit) => {
210
+ const declared = Number(response.headers.get("content-length"));
211
+ const tooLarge = () => new CalmLensError(SDK_ERROR_CODES.RESPONSE_TOO_LARGE, {
212
+ message: `Response body exceeds ${limit} bytes`,
213
+ status: response.status,
214
+ requestId: response.headers.get("x-request-id") ?? void 0,
215
+ headers: response.headers
216
+ });
217
+ if (Number.isFinite(declared) && declared > limit) {
218
+ await response.body?.cancel().catch(() => {});
219
+ throw tooLarge();
220
+ }
221
+ if (!response.body) return /* @__PURE__ */ new Uint8Array(0);
222
+ const reader = response.body.getReader();
223
+ const chunks = [];
224
+ let total = 0;
225
+ for (;;) {
226
+ const { done, value } = await reader.read();
227
+ if (done) break;
228
+ total += value.byteLength;
229
+ if (total > limit) {
230
+ await reader.cancel().catch(() => {});
231
+ throw tooLarge();
232
+ }
233
+ chunks.push(value);
234
+ }
235
+ const bytes = new Uint8Array(total);
236
+ let offset = 0;
237
+ for (const chunk of chunks) {
238
+ bytes.set(chunk, offset);
239
+ offset += chunk.byteLength;
240
+ }
241
+ return bytes;
242
+ };
243
+ const responseInfo = (response) => ({
244
+ status: response.status,
245
+ requestId: response.headers.get("x-request-id") ?? void 0,
246
+ headers: response.headers,
247
+ retryAfter: parseRetryAfter(response.headers.get("retry-after"))
248
+ });
249
+ /** The `CalmLensError` for a non-2xx response whose body has been read. */
250
+ const toHttpError = (response, bytes) => {
251
+ let data;
252
+ try {
253
+ data = JSON.parse(new TextDecoder().decode(bytes));
254
+ } catch {}
255
+ if (isApiErrorPayload(data)) {
256
+ const { code, message, details, traceId } = data;
257
+ return new CalmLensError(code, {
258
+ message: message ?? code,
259
+ ...responseInfo(response),
260
+ details: details || traceId ? {
261
+ ...details,
262
+ ...traceId ? { traceId } : {}
263
+ } : void 0
264
+ });
265
+ }
266
+ return new CalmLensError(SDK_ERROR_CODES.HTTP_ERROR, {
267
+ message: `HTTP ${response.status}`,
268
+ ...responseInfo(response)
269
+ });
270
+ };
271
+ /** Sends requests with the client's credentials, deadlines, retries and response limits. */
272
+ var CalmLensTransport = class {
273
+ constructor(settings) {
274
+ this.settings = settings;
275
+ }
276
+ headersFor(request) {
277
+ const headers = new Headers(request.init?.headers);
278
+ for (const [name, value] of Object.entries(request.headers ?? {})) headers.set(name, value);
279
+ if (!headers.has("accept") && request.deadline === "body") headers.set("accept", "application/json");
280
+ if (this.settings.apiKey) headers.set("x-api-key", this.settings.apiKey);
281
+ if (!isBrowserRuntime()) headers.set("user-agent", getUserAgent());
282
+ if (request.idempotencyKey) headers.set("idempotency-key", request.idempotencyKey);
283
+ return Object.fromEntries(headers.entries());
284
+ }
285
+ canRetry(request) {
286
+ return this.settings.maxRetries > 0 && (request.method === "GET" || request.method === "HEAD" || !!request.idempotencyKey) && isReplayableBody(request.body);
287
+ }
288
+ /**
289
+ * Sends a request and returns the response, retrying transport failures, 429, 502–504 and
290
+ * `idempotency/request-in-progress` when the request is safe to repeat. Non-2xx responses
291
+ * throw unless `allowHttpErrors` is set. Call `release` once the body has been read.
292
+ */
293
+ async send(request, { allowHttpErrors = false } = {}) {
294
+ const fetchImpl = request.fetch ?? this.settings.fetch;
295
+ const retryable = this.canRetry(request);
296
+ const headers = this.headersFor(request);
297
+ for (let attemptNumber = 0;; attemptNumber++) {
298
+ const canRetryAgain = retryable && attemptNumber < this.settings.maxRetries;
299
+ const attempt = createAttemptSignal([this.settings.signal, request.init?.signal ?? void 0], request.timeoutMs);
300
+ let response;
301
+ try {
302
+ if (attempt.signal.aborted) throw attempt.signal.reason;
303
+ response = await fetchImpl(request.url, {
304
+ ...request.init,
305
+ method: request.method,
306
+ headers,
307
+ body: request.body,
308
+ redirect: "manual",
309
+ signal: attempt.signal,
310
+ duplex: "half"
311
+ });
312
+ if (isRedirect(response)) {
313
+ await response.body?.cancel().catch(() => {});
314
+ throw new CalmLensError(SDK_ERROR_CODES.TRANSPORT_ERROR, { message: `Unexpected redirect (HTTP ${response.status})` });
315
+ }
316
+ } catch (cause) {
317
+ attempt.release();
318
+ const failure = toFailure(cause, attempt);
319
+ if (canRetryAgain && failure.code === SDK_ERROR_CODES.TRANSPORT_ERROR) {
320
+ await sleep(getRetryDelayMs(attemptNumber), this.settings.signal);
321
+ continue;
322
+ }
323
+ throw failure;
324
+ }
325
+ if (request.deadline === "headers") attempt.clearDeadline();
326
+ if (response.ok) return {
327
+ response,
328
+ attempt
329
+ };
330
+ if (allowHttpErrors) {
331
+ const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
332
+ const delayMs = retryAfter !== void 0 ? retryAfter * 1e3 : getRetryDelayMs(attemptNumber);
333
+ if (!canRetryAgain || !RETRYABLE_STATUSES.has(response.status) || delayMs > RETRY_AFTER_MAX_MS) return {
334
+ response,
335
+ attempt
336
+ };
337
+ attempt.release();
338
+ await response.body?.cancel().catch(() => {});
339
+ await sleep(delayMs, this.settings.signal);
340
+ continue;
341
+ }
342
+ let error;
343
+ try {
344
+ error = toHttpError(response, await readBody(response, this.settings.maxResponseBytes));
345
+ } catch (cause) {
346
+ error = toFailure(cause, attempt);
347
+ } finally {
348
+ attempt.release();
349
+ }
350
+ const retryDelayMs = error.retryAfter !== void 0 ? error.retryAfter * 1e3 : getRetryDelayMs(attemptNumber);
351
+ if (canRetryAgain && retryDelayMs <= RETRY_AFTER_MAX_MS && (error.status !== void 0 && RETRYABLE_STATUSES.has(error.status) || error.code === API_ERROR_CODES.IDEMPOTENT_REQUEST_IN_PROGRESS)) {
352
+ await sleep(retryDelayMs, this.settings.signal);
353
+ continue;
354
+ }
355
+ throw error;
356
+ }
357
+ }
358
+ /** Sends a request and parses its JSON response body. */
359
+ async json(request) {
360
+ const { response, attempt } = await this.send(request);
361
+ try {
362
+ const bytes = await readBody(response, this.settings.maxResponseBytes);
363
+ if (bytes.byteLength === 0 && (response.status === 204 || request.method === "DELETE")) return;
364
+ try {
365
+ return JSON.parse(new TextDecoder().decode(bytes));
366
+ } catch (cause) {
367
+ throw new CalmLensError(SDK_ERROR_CODES.INVALID_RESPONSE, {
368
+ message: `Invalid JSON response (HTTP ${response.status})`,
369
+ ...responseInfo(response),
370
+ cause
371
+ });
372
+ }
373
+ } catch (cause) {
374
+ throw toFailure(cause, attempt);
375
+ } finally {
376
+ attempt.release();
377
+ }
378
+ }
379
+ };
380
+ //#endregion
100
381
  //#region src/client/CalmLensUtils.ts
101
382
  const assertHttpUrl = (name, value) => {
102
383
  if (value === void 0) return;
@@ -256,5 +537,38 @@ const stringifyQuery = (query) => {
256
537
  for (const [key, value] of Object.entries(query)) appendQueryValue(parts, key, value);
257
538
  return parts.join("&");
258
539
  };
540
+ /**
541
+ * Whether an asset has a moderation decision (`moderation.status` `approved` or `rejected`) or
542
+ * failed processing; `assets.waitForDecision` returns at the first such state.
543
+ */
544
+ const isAssetDecided = (asset) => asset.moderation.status === "approved" || asset.moderation.status === "rejected" || asset.processing.status === "failed";
545
+ const DECISION_POLL_FIRST_DELAY_MS = 500;
546
+ const DECISION_POLL_MAX_DELAY_MS = 5e3;
547
+ const DEFAULT_DECISION_TIMEOUT_MS = 6e4;
548
+ /**
549
+ * Retrieves an asset until it has a decision, waiting 0.5s between checks and 1.5 times longer
550
+ * each time, up to 5s. Rejects with `sdk/timeout` once `timeoutMs` passes without one.
551
+ */
552
+ const pollForDecision = async (retrieve, { timeoutMs = DEFAULT_DECISION_TIMEOUT_MS, signal }, sleep, now = Date.now) => {
553
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0) throw invalidArgument("timeoutMs must be a non-negative number");
554
+ const deadline = now() + timeoutMs;
555
+ let delay = DECISION_POLL_FIRST_DELAY_MS;
556
+ for (;;) {
557
+ if (signal?.aborted) throw new CalmLensError(SDK_ERROR_CODES.CANCELLED, { message: "Request cancelled" });
558
+ const asset = await retrieve();
559
+ if (isAssetDecided(asset)) return asset;
560
+ const remaining = deadline - now();
561
+ if (remaining <= 0) throw new CalmLensError(SDK_ERROR_CODES.TIMEOUT, {
562
+ message: `Asset ${asset.id} had no moderation decision after ${timeoutMs}ms (processing ${asset.processing.status}, moderation ${asset.moderation.status})`,
563
+ details: {
564
+ assetId: asset.id,
565
+ processingStatus: asset.processing.status,
566
+ moderationStatus: asset.moderation.status
567
+ }
568
+ });
569
+ await sleep(Math.min(delay, remaining), signal);
570
+ delay = Math.min(DECISION_POLL_MAX_DELAY_MS, delay * 1.5);
571
+ }
572
+ };
259
573
  //#endregion
260
- export { getRejectedUploadMimeTypeError as _, toTextAssetOptions as a, validateRequestOptions as c, assetToData as d, getAssetPayloadByteLength as f, MIN_MULTIPART_IMAGE_UPLOAD_BYTES as g, MAX_IMAGE_UPLOAD_BYTES as h, stringifyQuery as i, validateServerClientOptions as l, DOMAIN as m, encodePathSegment as n, transformSelector as o, validateAssetPayload as p, randomUuid as r, validateAppClientOptions as s, appendCdnSelector as t, assetDataToUint8Array as u, isRejectedUploadMimeType as v };
574
+ export { DOMAIN as _, stringifyQuery as a, getRejectedUploadMimeTypeError as b, validateAppClientOptions as c, CalmLensTransport as d, sleep as f, validateAssetPayload as g, getAssetPayloadByteLength as h, randomUuid as i, validateRequestOptions as l, assetToData as m, encodePathSegment as n, toTextAssetOptions as o, assetDataToUint8Array as p, pollForDecision as r, transformSelector as s, appendCdnSelector as t, validateServerClientOptions as u, MAX_IMAGE_UPLOAD_BYTES as v, isRejectedUploadMimeType as x, MIN_MULTIPART_IMAGE_UPLOAD_BYTES as y };
@@ -11,6 +11,7 @@ const API_ERROR_CODES = {
11
11
  MULTIPART_IMAGE_TOO_LARGE: "upload/multipart-image-too-large",
12
12
  IMAGE_EXCEEDED_MAX_DIMENSIONS: "upload/image-exceeded-max-dimensions",
13
13
  UPLOAD_LIMIT_EXCEEDED: "upload/limit-exceeded",
14
+ SPEND_LIMIT_REACHED: "upload/spend-limit-reached",
14
15
  INVALID_UPLOAD_MIME_TYPE: "upload/invalid-mime-type",
15
16
  INVALID_EVENT_CURSOR: "events/invalid-cursor",
16
17
  INVALID_UPLOAD_PAYLOAD: "upload/invalid-payload",
@@ -38,6 +39,8 @@ const API_ERROR_CODES = {
38
39
  UPLOAD_PRESET_NOT_FOUND: "upload-preset/not-found",
39
40
  UPLOAD_PRESET_DISABLED: "upload-preset/disabled",
40
41
  UPLOAD_PRESET_TOKEN_REQUIRED: "upload-preset/token-required",
42
+ REMOTE_URL_NOT_ALLOWED: "upload/remote-url-not-allowed",
43
+ WEBSITE_RATE_LIMIT_EXCEEDED: "upload/website-rate-limit-exceeded",
41
44
  UPLOAD_PRESET_CONTENT_TYPE_NOT_ALLOWED: "upload-preset/content-type-not-allowed",
42
45
  UPLOAD_PRESET_TOO_LARGE: "upload-preset/too-large",
43
46
  UPLOAD_PRESET_DAILY_LIMIT_REACHED: "upload-preset/daily-limit-reached",
@@ -45,7 +48,18 @@ const API_ERROR_CODES = {
45
48
  UPLOAD_PRESET_FIELD_NOT_ALLOWED: "upload-preset/field-not-allowed",
46
49
  UPLOAD_TOKEN_INVALID: "upload-token/invalid",
47
50
  UPLOAD_TOKEN_EXPIRED: "upload-token/expired",
48
- UPLOAD_TOKEN_USED: "upload-token/used"
51
+ UPLOAD_TOKEN_USED: "upload-token/used",
52
+ INVALID_REMOTE_URL: "asset/invalid-remote-url",
53
+ REMOTE_URL_UNAVAILABLE: "asset/remote-url-unavailable",
54
+ APPEAL_NOT_FOUND: "appeal/not-found",
55
+ APPEAL_JUDGMENT_NOT_FOUND: "appeal/judgment-not-found",
56
+ APPEAL_ASSET_NOT_FOUND: "appeal/asset-not-found",
57
+ APPEAL_PERMISSION_DENIED: "appeal/permission-denied",
58
+ APPEAL_ALREADY_REVIEWED: "appeal/already-reviewed",
59
+ APPEAL_DECISION_CHANGED: "appeal/decision-changed",
60
+ APPEAL_INVALID_ACTION: "appeal/invalid-action",
61
+ APPEAL_SAFETY_HELD: "appeal/safety-held",
62
+ APPEAL_CONTENT_UNAVAILABLE: "appeal/content-unavailable"
49
63
  };
50
64
  /** The HTTP status and default message the API returns with each documented error code. */
51
65
  const API_ERROR_DEFINITIONS = {
@@ -89,6 +103,10 @@ const API_ERROR_DEFINITIONS = {
89
103
  status: 403,
90
104
  message: "Upload limit exceeded. You have used all your free uploads. Please upgrade to Plus or Pro for unlimited uploads."
91
105
  },
106
+ "upload/spend-limit-reached": {
107
+ status: 402,
108
+ message: "The project owner's spend limit for this billing period has been reached. Raise the limit in Billing to resume uploads."
109
+ },
92
110
  "upload/invalid-mime-type": {
93
111
  status: 400,
94
112
  message: "Upload file type is not supported. Uploads must use a concrete file MIME type that CalmLens can store and process, such as image/png, image/jpeg, application/pdf, or text/plain."
@@ -197,6 +215,14 @@ const API_ERROR_DEFINITIONS = {
197
215
  status: 401,
198
216
  message: "This upload preset only accepts uploads with an upload token. Sign one on your server with uploadTokens.sign and pass it to the client."
199
217
  },
218
+ "upload/remote-url-not-allowed": {
219
+ status: 403,
220
+ message: "This project doesn't accept URLs from this domain. Change the allowed or blocked domains under Project settings."
221
+ },
222
+ "upload/website-rate-limit-exceeded": {
223
+ status: 429,
224
+ message: "This project has submitted too many websites in the last minute. Wait for the Retry-After period, or lower the pace of submissions."
225
+ },
200
226
  "upload-preset/content-type-not-allowed": {
201
227
  status: 415,
202
228
  message: "This upload preset doesn't accept this file type."
@@ -228,6 +254,50 @@ const API_ERROR_DEFINITIONS = {
228
254
  "upload-token/used": {
229
255
  status: 409,
230
256
  message: "The upload token has already been used. Each token uploads one asset."
257
+ },
258
+ "asset/invalid-remote-url": {
259
+ status: 400,
260
+ message: "Remote URLs must use HTTP or HTTPS and cannot target private networks, localhost names or URL credentials."
261
+ },
262
+ "asset/remote-url-unavailable": {
263
+ status: 422,
264
+ message: "The URL responded with an error status. Check that it is publicly reachable."
265
+ },
266
+ "appeal/not-found": {
267
+ status: 404,
268
+ message: "The appeal does not exist in this project."
269
+ },
270
+ "appeal/judgment-not-found": {
271
+ status: 404,
272
+ message: "The judgment event is unavailable, belongs to another project or cannot supply an asset decision. Supply explicit context instead."
273
+ },
274
+ "appeal/asset-not-found": {
275
+ status: 404,
276
+ message: "The linked asset does not exist in this project."
277
+ },
278
+ "appeal/permission-denied": {
279
+ status: 403,
280
+ message: "You cannot review this project's appeals."
281
+ },
282
+ "appeal/already-reviewed": {
283
+ status: 409,
284
+ message: "A reviewer already resolved this appeal."
285
+ },
286
+ "appeal/decision-changed": {
287
+ status: 409,
288
+ message: "The linked asset's decision changed after the appealed decision. Submit a new appeal for the current decision."
289
+ },
290
+ "appeal/invalid-action": {
291
+ status: 400,
292
+ message: "The decision's action is not valid for this appeal."
293
+ },
294
+ "appeal/safety-held": {
295
+ status: 409,
296
+ message: "A safety hold prevents this appeal from being submitted or applied."
297
+ },
298
+ "appeal/content-unavailable": {
299
+ status: 409,
300
+ message: "The appeal's content is unavailable, or approval would require restoring removed content."
231
301
  }
232
302
  };
233
303
  //#endregion
@@ -318,4 +388,23 @@ const invalidArgument = (message, details) => new CalmLensError(SDK_ERROR_CODES.
318
388
  });
319
389
  const invalidConfig = (message) => new CalmLensError(SDK_ERROR_CODES.INVALID_CONFIG, { message });
320
390
  //#endregion
321
- export { invalidConfig as a, invalidArgument as i, CalmLensError as n, API_ERROR_CODES as o, SDK_ERROR_CODES as r, API_ERROR_DEFINITIONS as s, CALMLENS_ERROR_CODES as t };
391
+ //#region src/client/CalmLensVersion.ts
392
+ /** This SDK's version, from its package.json. */
393
+ const SDK_VERSION = "0.14.0";
394
+ const runtimeName = () => {
395
+ const runtime = globalThis;
396
+ if (isReactNativeRuntime()) return "react-native";
397
+ if (runtime.Bun?.version) return `bun/${runtime.Bun.version}`;
398
+ if (runtime.Deno?.version?.deno) return `deno/${runtime.Deno.version.deno}`;
399
+ if (runtime.navigator?.userAgent === "Cloudflare-Workers") return "workerd";
400
+ if (runtime.process?.versions?.node) return `node/${runtime.process.versions.node}`;
401
+ return "unknown";
402
+ };
403
+ /** The `User-Agent` the SDK sends from server runtimes, e.g. `calmlens-js/0.10.0 (node/22.12.0)`. */
404
+ const getUserAgent = () => `calmlens-js/${SDK_VERSION} (${runtimeName()})`;
405
+ /** True in a web page, where API keys would be visible to every visitor. */
406
+ const isBrowserRuntime = () => typeof globalThis.window !== "undefined" && typeof globalThis.document !== "undefined";
407
+ /** True in a React Native app, where an API key ships inside the app for anyone to extract. */
408
+ const isReactNativeRuntime = () => globalThis.navigator?.product === "ReactNative";
409
+ //#endregion
410
+ export { CALMLENS_ERROR_CODES as a, invalidArgument as c, API_ERROR_DEFINITIONS as d, isReactNativeRuntime as i, invalidConfig as l, getUserAgent as n, CalmLensError as o, isBrowserRuntime as r, SDK_ERROR_CODES as s, SDK_VERSION as t, API_ERROR_CODES as u };
@@ -1,5 +1,5 @@
1
- import { i as invalidArgument, n as CalmLensError, r as SDK_ERROR_CODES } from "./CalmLensError-CihGG26K.js";
2
- import { n as encodePathSegment } from "./CalmLensUtils-Cx32j2zt.js";
1
+ import { c as invalidArgument, o as CalmLensError, s as SDK_ERROR_CODES } from "./CalmLensVersion-DPnL_Ebv.js";
2
+ import { n as encodePathSegment } from "./CalmLensUtils-BQo79TA1.js";
3
3
  //#region ../shared/src/JsonUtils.ts
4
4
  const stringifyJsonCanonical = (value) => JSON.stringify(value, (_key, nestedValue) => {
5
5
  if (!nestedValue || typeof nestedValue !== "object" || Array.isArray(nestedValue)) return nestedValue;