@tinify-dev/client 0.1.0 → 0.1.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/cli.cjs CHANGED
@@ -13,6 +13,7 @@ var DEFAULT_MAX_RETRIES = 3;
13
13
  var DEFAULT_TIMEOUT_MS = 6e4;
14
14
  var BACKOFF_BASE_MS = 500;
15
15
  var BACKOFF_CAP_MS = 8e3;
16
+ var MAX_RETRY_AFTER_MS = 3e4;
16
17
  var MAX_FILE_BYTES = 41943040;
17
18
  var DEFAULT_UPLOAD_CONCURRENCY = 4;
18
19
  var DEFAULT_POLL_INTERVAL_MS = 1e3;
@@ -67,105 +68,6 @@ var TinifyTimeoutError = class extends TinifyError {
67
68
  }
68
69
  };
69
70
 
70
- // src/inputs.ts
71
- var DEFAULT_FILENAME = "image";
72
- async function normalizeInput(input, filename) {
73
- if (typeof input === "string") {
74
- let fs;
75
- let path;
76
- try {
77
- fs = await import('fs/promises');
78
- path = await import('path');
79
- } catch {
80
- throw new TinifyError(
81
- "File path inputs are only supported in Node.js. Pass a Blob or File instead."
82
- );
83
- }
84
- const bytes = await fs.readFile(input);
85
- return {
86
- blob: new Blob([bytesToView(bytes)]),
87
- filename: filename ?? path.basename(input)
88
- };
89
- }
90
- if (typeof Blob !== "undefined" && input instanceof Blob) {
91
- const name = filename ?? (typeof File !== "undefined" && input instanceof File && input.name ? input.name : DEFAULT_FILENAME);
92
- return { blob: input, filename: name };
93
- }
94
- if (input instanceof ArrayBuffer) {
95
- return { blob: new Blob([input]), filename: filename ?? DEFAULT_FILENAME };
96
- }
97
- if (ArrayBuffer.isView(input)) {
98
- return {
99
- blob: new Blob([bytesToView(input)]),
100
- filename: filename ?? DEFAULT_FILENAME
101
- };
102
- }
103
- throw new TypeError(
104
- "Unsupported input: expected a file path, Uint8Array, ArrayBuffer, Blob, or File."
105
- );
106
- }
107
- function bytesToView(view) {
108
- const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
109
- if (bytes.buffer instanceof ArrayBuffer && bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) {
110
- return bytes;
111
- }
112
- const copy = new Uint8Array(new ArrayBuffer(bytes.byteLength));
113
- copy.set(bytes);
114
- return copy;
115
- }
116
-
117
- // src/batches.ts
118
- var TERMINAL = new Set(BATCH_TERMINAL_STATUSES);
119
- function isTerminalBatchStatus(status) {
120
- return TERMINAL.has(status);
121
- }
122
- async function uploadBatchFiles(fetchImpl, session, files, options = {}) {
123
- const missing = session.uploads.map((upload) => upload.client_id).filter((clientId) => !(clientId in files));
124
- if (missing.length > 0) {
125
- throw new TinifyError(
126
- `Missing input for batch client_id(s): ${missing.join(", ")}. Pass a { client_id: input } record covering every file in the session.`
127
- );
128
- }
129
- const queue = [...session.uploads];
130
- const concurrency = Math.max(
131
- 1,
132
- Math.min(options.concurrency ?? DEFAULT_UPLOAD_CONCURRENCY, queue.length)
133
- );
134
- const workers = Array.from({ length: concurrency }, async () => {
135
- for (; ; ) {
136
- const upload = queue.shift();
137
- if (upload === void 0) return;
138
- options.signal?.throwIfAborted();
139
- await putOne(fetchImpl, upload, files[upload.client_id], options.signal);
140
- }
141
- });
142
- await Promise.all(workers);
143
- }
144
- async function putOne(fetchImpl, upload, input, signal) {
145
- const { blob } = await normalizeInput(input);
146
- let response;
147
- try {
148
- response = await fetchImpl(upload.upload_url, {
149
- method: "PUT",
150
- headers: upload.headers,
151
- body: blob,
152
- ...signal !== void 0 ? { signal } : {}
153
- });
154
- } catch (error) {
155
- throw new TinifyNetworkError(
156
- `Upload for client_id "${upload.client_id}" failed: ${error instanceof Error ? error.message : String(error)}`,
157
- { cause: error }
158
- );
159
- }
160
- if (!response.ok) {
161
- const text = await response.text().catch(() => "");
162
- throw new TinifyError(
163
- `Upload for client_id "${upload.client_id}" was rejected with HTTP ${response.status}` + (text ? `: ${text.slice(0, 200)}` : ".")
164
- );
165
- }
166
- await response.arrayBuffer().catch(() => void 0);
167
- }
168
-
169
71
  // src/http.ts
170
72
  var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([429, 503]);
171
73
  function generateIdempotencyKey() {
@@ -236,6 +138,38 @@ async function throwApiError(response) {
236
138
  ...retryAfterMs !== void 0 ? { retryAfter: retryAfterMs / 1e3 } : {}
237
139
  });
238
140
  }
141
+ function withTimeoutSignal(timeoutMs, signal) {
142
+ const timeout = AbortSignal.timeout(timeoutMs);
143
+ if (signal === void 0) return { signal: timeout, timeout };
144
+ const anyOf = AbortSignal.any;
145
+ if (typeof anyOf === "function") return { signal: anyOf([signal, timeout]), timeout };
146
+ const controller = new AbortController();
147
+ const abortFrom = (src) => controller.abort(src.reason);
148
+ if (signal.aborted) controller.abort(signal.reason);
149
+ else if (timeout.aborted) controller.abort(timeout.reason);
150
+ else {
151
+ signal.addEventListener("abort", () => abortFrom(signal), { once: true });
152
+ timeout.addEventListener("abort", () => abortFrom(timeout), { once: true });
153
+ }
154
+ return { signal: controller.signal, timeout };
155
+ }
156
+ function asTimeoutError(error, timeoutMs) {
157
+ if (typeof error === "object" && error !== null && error.name === "TimeoutError") {
158
+ return new TinifyTimeoutError(`Request timed out after ${timeoutMs} ms.`, timeoutMs);
159
+ }
160
+ return error;
161
+ }
162
+ async function fetchWithTimeout(fetchImpl, url, init, timeoutMs, userSignal) {
163
+ const { signal, timeout } = withTimeoutSignal(timeoutMs, userSignal);
164
+ try {
165
+ return await fetchImpl(url, { ...init, signal });
166
+ } catch (error) {
167
+ if (timeout.aborted) {
168
+ throw new TinifyTimeoutError(`Request timed out after ${timeoutMs} ms.`, timeoutMs);
169
+ }
170
+ throw error;
171
+ }
172
+ }
239
173
  var HttpClient = class {
240
174
  config;
241
175
  constructor(config) {
@@ -268,19 +202,21 @@ var HttpClient = class {
268
202
  const headers = {
269
203
  authorization: `Bearer ${this.config.apiKey}`,
270
204
  accept: "application/json",
271
- "user-agent": "@tinify-dev/client/0.1.0"
205
+ "user-agent": "@tinify-dev/client/0.1.1"
272
206
  };
273
207
  if (idempotencyKey !== void 0) headers["idempotency-key"] = idempotencyKey;
274
208
  if (options.contentType !== void 0) headers["content-type"] = options.contentType;
275
209
  let response;
276
210
  try {
277
- response = await this.fetchWithTimeout(
211
+ response = await fetchWithTimeout(
212
+ this.config.fetchImpl,
278
213
  url,
279
214
  {
280
215
  method: options.method,
281
216
  headers,
282
217
  ...options.body !== void 0 ? { body: options.body() } : {}
283
218
  },
219
+ this.config.timeoutMs,
284
220
  options.signal
285
221
  );
286
222
  } catch (error) {
@@ -299,7 +235,8 @@ var HttpClient = class {
299
235
  if (RETRYABLE_STATUSES.has(response.status) && attempt < this.config.maxRetries) {
300
236
  const retryAfter = parseRetryAfterMs(response.headers.get("retry-after"));
301
237
  await response.arrayBuffer().catch(() => void 0);
302
- await sleep(retryAfter ?? backoffMs(attempt), options.signal);
238
+ const wait = Math.min(retryAfter ?? backoffMs(attempt), MAX_RETRY_AFTER_MS);
239
+ await sleep(wait, options.signal);
303
240
  attempt += 1;
304
241
  continue;
305
242
  }
@@ -315,10 +252,14 @@ var HttpClient = class {
315
252
  async requestJson(options) {
316
253
  const result = await this.request(options);
317
254
  let body;
318
- if (result.response.status === 204) {
319
- body = void 0;
320
- } else {
321
- body = await result.response.json();
255
+ try {
256
+ if (result.response.status === 204) {
257
+ body = void 0;
258
+ } else {
259
+ body = await result.response.json();
260
+ }
261
+ } catch (error) {
262
+ throw asTimeoutError(error, this.config.timeoutMs);
322
263
  }
323
264
  const bodyRequestId = body?.request_id;
324
265
  return {
@@ -327,29 +268,112 @@ var HttpClient = class {
327
268
  rateLimit: result.rateLimit
328
269
  };
329
270
  }
330
- async fetchWithTimeout(url, init, signal) {
331
- const controller = new AbortController();
332
- const timeoutError = new TinifyTimeoutError(
333
- `Request timed out after ${this.config.timeoutMs} ms.`,
334
- this.config.timeoutMs
335
- );
336
- const timer = setTimeout(() => controller.abort(timeoutError), this.config.timeoutMs);
337
- const onAbort = () => controller.abort(abortReason(signal));
338
- signal?.addEventListener("abort", onAbort, { once: true });
271
+ /** The configured per-request timeout (ms); used by download/upload paths. */
272
+ get timeoutMs() {
273
+ return this.config.timeoutMs;
274
+ }
275
+ };
276
+
277
+ // src/inputs.ts
278
+ var DEFAULT_FILENAME = "image";
279
+ async function normalizeInput(input, filename) {
280
+ if (typeof input === "string") {
281
+ let fs;
282
+ let path;
339
283
  try {
340
- return await this.config.fetchImpl(url, { ...init, signal: controller.signal });
341
- } catch (error) {
342
- if (error === timeoutError) throw error;
343
- if (controller.signal.aborted && controller.signal.reason === timeoutError) {
344
- throw timeoutError;
345
- }
346
- throw error;
347
- } finally {
348
- clearTimeout(timer);
349
- signal?.removeEventListener("abort", onAbort);
284
+ fs = await import('fs/promises');
285
+ path = await import('path');
286
+ } catch {
287
+ throw new TinifyError(
288
+ "File path inputs are only supported in Node.js. Pass a Blob or File instead."
289
+ );
350
290
  }
291
+ const bytes = await fs.readFile(input);
292
+ return {
293
+ blob: new Blob([bytesToView(bytes)]),
294
+ filename: filename ?? path.basename(input)
295
+ };
351
296
  }
352
- };
297
+ if (typeof Blob !== "undefined" && input instanceof Blob) {
298
+ const name = filename ?? (typeof File !== "undefined" && input instanceof File && input.name ? input.name : DEFAULT_FILENAME);
299
+ return { blob: input, filename: name };
300
+ }
301
+ if (input instanceof ArrayBuffer) {
302
+ return { blob: new Blob([input]), filename: filename ?? DEFAULT_FILENAME };
303
+ }
304
+ if (ArrayBuffer.isView(input)) {
305
+ return {
306
+ blob: new Blob([bytesToView(input)]),
307
+ filename: filename ?? DEFAULT_FILENAME
308
+ };
309
+ }
310
+ throw new TypeError(
311
+ "Unsupported input: expected a file path, Uint8Array, ArrayBuffer, Blob, or File."
312
+ );
313
+ }
314
+ function bytesToView(view) {
315
+ const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
316
+ if (bytes.buffer instanceof ArrayBuffer && bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) {
317
+ return bytes;
318
+ }
319
+ const copy = new Uint8Array(new ArrayBuffer(bytes.byteLength));
320
+ copy.set(bytes);
321
+ return copy;
322
+ }
323
+
324
+ // src/batches.ts
325
+ var TERMINAL = new Set(BATCH_TERMINAL_STATUSES);
326
+ function isTerminalBatchStatus(status) {
327
+ return TERMINAL.has(status);
328
+ }
329
+ async function uploadBatchFiles(fetchImpl, session, files, options, timeoutMs) {
330
+ const missing = session.uploads.map((upload) => upload.client_id).filter((clientId) => !(clientId in files));
331
+ if (missing.length > 0) {
332
+ throw new TinifyError(
333
+ `Missing input for batch client_id(s): ${missing.join(", ")}. Pass a { client_id: input } record covering every file in the session.`
334
+ );
335
+ }
336
+ const queue = [...session.uploads];
337
+ const concurrency = Math.max(
338
+ 1,
339
+ Math.min(options.concurrency ?? DEFAULT_UPLOAD_CONCURRENCY, queue.length)
340
+ );
341
+ const workers = Array.from({ length: concurrency }, async () => {
342
+ for (; ; ) {
343
+ const upload = queue.shift();
344
+ if (upload === void 0) return;
345
+ options.signal?.throwIfAborted();
346
+ await putOne(fetchImpl, upload, files[upload.client_id], timeoutMs, options.signal);
347
+ }
348
+ });
349
+ await Promise.all(workers);
350
+ }
351
+ async function putOne(fetchImpl, upload, input, timeoutMs, signal) {
352
+ const { blob } = await normalizeInput(input);
353
+ let response;
354
+ try {
355
+ response = await fetchWithTimeout(
356
+ fetchImpl,
357
+ upload.upload_url,
358
+ { method: "PUT", headers: upload.headers, body: blob },
359
+ timeoutMs,
360
+ signal
361
+ );
362
+ } catch (error) {
363
+ if (error instanceof TinifyTimeoutError) throw error;
364
+ throw new TinifyNetworkError(
365
+ `Upload for client_id "${upload.client_id}" failed: ${error instanceof Error ? error.message : String(error)}`,
366
+ { cause: error }
367
+ );
368
+ }
369
+ if (!response.ok) {
370
+ const text = await response.text().catch(() => "");
371
+ throw new TinifyError(
372
+ `Upload for client_id "${upload.client_id}" was rejected with HTTP ${response.status}` + (text ? `: ${text.slice(0, 200)}` : ".")
373
+ );
374
+ }
375
+ await response.arrayBuffer().catch(() => void 0);
376
+ }
353
377
 
354
378
  // src/client.ts
355
379
  var TinifyClient = class {
@@ -453,12 +477,19 @@ var TinifyClient = class {
453
477
  async download(resultOrUrl, options = {}) {
454
478
  const raw = typeof resultOrUrl === "string" ? resultOrUrl : "download_url" in resultOrUrl ? resultOrUrl.download_url : resultOrUrl.data.download_url;
455
479
  const url = new URL(raw, `${this.http.baseUrl}/`).toString();
456
- const response = await this.http.fetchImpl(url, {
457
- method: "GET",
458
- ...options.signal !== void 0 ? { signal: options.signal } : {}
459
- });
480
+ const response = await fetchWithTimeout(
481
+ this.http.fetchImpl,
482
+ url,
483
+ { method: "GET" },
484
+ this.http.timeoutMs,
485
+ options.signal
486
+ );
460
487
  if (!response.ok) await throwApiError(response);
461
- return response.blob();
488
+ try {
489
+ return await response.blob();
490
+ } catch (error) {
491
+ throw asTimeoutError(error, this.http.timeoutMs);
492
+ }
462
493
  }
463
494
  // -------------------------------------------------------------------------
464
495
  // Batches
@@ -483,7 +514,7 @@ var TinifyClient = class {
483
514
  * `client_id`. Runs up to `concurrency` (default 4) PUTs in parallel.
484
515
  */
485
516
  uploadBatchFiles(session, files, options = {}) {
486
- return uploadBatchFiles(this.http.fetchImpl, session, files, options);
517
+ return uploadBatchFiles(this.http.fetchImpl, session, files, options, this.http.timeoutMs);
487
518
  }
488
519
  /** Commits an uploaded batch for processing. */
489
520
  async commitBatch(batchId, options = {}) {
@@ -548,7 +579,11 @@ var TinifyClient = class {
548
579
  path: `/api/v1/batches/${encodeURIComponent(batchId)}/archive`,
549
580
  ...options.signal !== void 0 ? { signal: options.signal } : {}
550
581
  });
551
- return result.response.blob();
582
+ try {
583
+ return await result.response.blob();
584
+ } catch (error) {
585
+ throw asTimeoutError(error, this.http.timeoutMs);
586
+ }
552
587
  }
553
588
  // -------------------------------------------------------------------------
554
589
  // Internals
@@ -584,7 +619,7 @@ var CliUsageError = class extends Error {
584
619
  this.name = "CliUsageError";
585
620
  }
586
621
  };
587
- var HELP_TEXT = `tinify-dev \u2014 Tinify.dev image API CLI
622
+ var HELP_TEXT = `tinify-dev - Tinify.dev image API CLI
588
623
 
589
624
  Usage:
590
625
  tinify-dev <command> [options] <files...>