@rendobar/sdk 2.2.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -303,13 +303,161 @@ function createBillingResource(request) {
303
303
  }
304
304
  //#endregion
305
305
  //#region src/resources/uploads.ts
306
+ function toBlob(file) {
307
+ return file instanceof Blob ? file : new Blob([file]);
308
+ }
309
+ /**
310
+ * Per-attempt upload time budget. fetch() exposes no upload progress, so a
311
+ * stalled socket and a slow link are indistinguishable from the outside; a
312
+ * hard per-attempt budget is the only portable stall cap. The ladder escalates
313
+ * (healthy 500 KiB/s assumption first, then 100, then 50) so a genuinely slow
314
+ * connection that times out on an early attempt still completes on a later,
315
+ * more patient one — while a hung socket gets cut and retried on a fresh
316
+ * connection instead of waiting on the OS TCP timeout.
317
+ *
318
+ * @internal exported for tests
319
+ */
320
+ function attemptTimeoutMs(sizeBytes, attempt) {
321
+ const GRACE_MS = [
322
+ 1e4,
323
+ 3e4,
324
+ 6e4
325
+ ];
326
+ const RATE_BPS = [
327
+ 500 * 1024,
328
+ 100 * 1024,
329
+ 50 * 1024
330
+ ];
331
+ const i = Math.min(attempt, GRACE_MS.length - 1);
332
+ return GRACE_MS[i] + Math.ceil(sizeBytes / RATE_BPS[i] * 1e3);
333
+ }
334
+ /**
335
+ * PUT a body to a presigned R2 url with bounded retry.
336
+ *
337
+ * Returns the ETag string when the bucket exposes it, or `null` when the
338
+ * response header is absent (e.g. bucket CORS not exposing ETag). Network/HTTP
339
+ * failures and per-attempt timeouts trigger retries; a caller abort and a
340
+ * missing ETag do not (the latter is a deterministic misconfiguration, not a
341
+ * transient error).
342
+ *
343
+ * @internal exported for tests
344
+ */
345
+ async function putWithRetry(url, body, signal, sizeBytes, attempts = 3, attemptTimeouts) {
346
+ let lastErr;
347
+ for (let i = 0; i < attempts; i++) {
348
+ if (signal?.aborted) throw new Error("Upload aborted");
349
+ const timeoutMs = attemptTimeouts?.[i] ?? attemptTimeoutMs(sizeBytes, i);
350
+ const timeout = AbortSignal.timeout(timeoutMs);
351
+ const attemptSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
352
+ try {
353
+ const res = await fetch(url, {
354
+ method: "PUT",
355
+ body,
356
+ signal: attemptSignal
357
+ });
358
+ if (!res.ok) throw new Error(`Upload PUT failed: ${res.status}`);
359
+ return res.headers.get("etag");
360
+ } catch (e) {
361
+ lastErr = e;
362
+ if (signal?.aborted) throw e;
363
+ if (i < attempts - 1) await new Promise((r) => setTimeout(r, 200 * 2 ** i));
364
+ }
365
+ }
366
+ throw lastErr;
367
+ }
368
+ /**
369
+ * Run `fn` over `items` with at most `limit` in flight; preserves order.
370
+ * Workers stop pulling new items once `signal` is aborted, enabling fast
371
+ * cancellation when one part fails.
372
+ */
373
+ async function mapLimit(items, limit, fn, signal) {
374
+ const out = new Array(items.length);
375
+ let next = 0;
376
+ const worker = async () => {
377
+ while (next < items.length) {
378
+ if (signal?.aborted) break;
379
+ const i = next++;
380
+ out[i] = await fn(items[i]);
381
+ }
382
+ };
383
+ const workers = Math.min(Math.max(1, limit), items.length);
384
+ await Promise.all(Array.from({ length: workers }, worker));
385
+ return out;
386
+ }
306
387
  function createUploadsResource(request) {
307
- return { async upload(file, options) {
308
- return request("/uploads", {
388
+ return { async create(file, options) {
389
+ const blob = toBlob(file);
390
+ const size = options.size ?? blob.size;
391
+ if (options.signal?.aborted) throw new Error("Upload aborted");
392
+ const init = await (await request("/assets", {
393
+ method: "POST",
394
+ body: {
395
+ filename: options.filename,
396
+ size,
397
+ contentType: options.contentType,
398
+ checksum: options.checksum,
399
+ lifecycle: options.persist ? "persisted" : "ephemeral"
400
+ },
401
+ raw: true,
402
+ signal: options.signal
403
+ })).json();
404
+ if (init.status === "deduplicated") {
405
+ options.onProgress?.({
406
+ loaded: size,
407
+ total: size
408
+ });
409
+ return init.data;
410
+ }
411
+ if (init.status === "presigned") {
412
+ await putWithRetry(init.upload.url, blob, options.signal, size);
413
+ options.onProgress?.({
414
+ loaded: size,
415
+ total: size
416
+ });
417
+ return request(`/assets/${init.data.id}/complete`, {
418
+ method: "POST",
419
+ body: {},
420
+ signal: options.signal
421
+ });
422
+ }
423
+ const internal = new AbortController();
424
+ const combinedSignal = options.signal ? AbortSignal.any([options.signal, internal.signal]) : internal.signal;
425
+ const { partSize, parts } = init.upload;
426
+ let loaded = 0;
427
+ let partError = null;
428
+ const completed = await mapLimit(parts, options.concurrency ?? 5, async (p) => {
429
+ const start = (p.partNumber - 1) * partSize;
430
+ const chunk = blob.slice(start, Math.min(start + partSize, size));
431
+ let etag;
432
+ try {
433
+ etag = await putWithRetry(p.url, chunk, combinedSignal, chunk.size);
434
+ } catch (e) {
435
+ internal.abort();
436
+ partError = e instanceof Error ? e : new Error(String(e));
437
+ throw partError;
438
+ }
439
+ if (etag === null) {
440
+ internal.abort();
441
+ const corsErr = /* @__PURE__ */ new Error("Multipart upload requires the ETag response header. Configure the R2 bucket CORS to expose 'ETag'.");
442
+ partError = corsErr;
443
+ throw corsErr;
444
+ }
445
+ loaded += chunk.size;
446
+ options.onProgress?.({
447
+ loaded,
448
+ total: size
449
+ });
450
+ return {
451
+ partNumber: p.partNumber,
452
+ etag
453
+ };
454
+ }, combinedSignal);
455
+ if (partError) throw partError;
456
+ completed.sort((a, b) => a.partNumber - b.partNumber);
457
+ return request(`/assets/${init.data.id}/complete`, {
309
458
  method: "POST",
310
- bodyRaw: file,
311
- query: options?.filename ? { filename: options.filename } : void 0,
312
- signal: options?.signal
459
+ body: { parts: completed },
460
+ signal: options.signal
313
461
  });
314
462
  } };
315
463
  }
@@ -449,12 +597,32 @@ function createBatchesResource(request) {
449
597
  //#endregion
450
598
  //#region src/resources/assets.ts
451
599
  function createAssetsResource(request) {
452
- return { async templates(params, options) {
453
- return request("/assets/templates", {
454
- query: params ? { ...params } : void 0,
455
- signal: options?.signal
456
- });
457
- } };
600
+ return {
601
+ async list(params, options) {
602
+ return request("/assets", {
603
+ query: params,
604
+ signal: options?.signal
605
+ });
606
+ },
607
+ async get(id, options) {
608
+ return request(`/assets/${id}`, { signal: options?.signal });
609
+ },
610
+ async delete(id, options) {
611
+ return request(`/assets/${id}`, {
612
+ method: "DELETE",
613
+ signal: options?.signal
614
+ });
615
+ },
616
+ async download(id, options) {
617
+ return request(`/assets/${id}/download`, { signal: options?.signal });
618
+ },
619
+ async templates(params, options) {
620
+ return request("/assets/templates", {
621
+ query: params,
622
+ signal: options?.signal
623
+ });
624
+ }
625
+ };
458
626
  }
459
627
  //#endregion
460
628
  //#region src/resources/team.ts