image2ppt 0.5.3 → 0.6.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/README.md +175 -13
- package/dist/client.d.ts +80 -17
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +424 -54
- package/dist/client.js.map +1 -1
- package/dist/compress.d.ts +3 -3
- package/dist/compress.d.ts.map +1 -1
- package/dist/compress.js +7 -5
- package/dist/compress.js.map +1 -1
- package/dist/errors.d.ts +118 -8
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +150 -18
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/limits.d.ts +23 -5
- package/dist/limits.d.ts.map +1 -1
- package/dist/limits.js +51 -10
- package/dist/limits.js.map +1 -1
- package/dist/pages.d.ts +47 -0
- package/dist/pages.d.ts.map +1 -0
- package/dist/pages.js +90 -0
- package/dist/pages.js.map +1 -0
- package/dist/types.d.ts +122 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +87 -2
- package/dist/types.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/webhook.d.ts +75 -0
- package/dist/webhook.d.ts.map +1 -0
- package/dist/webhook.js +182 -0
- package/dist/webhook.js.map +1 -0
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -10,10 +10,11 @@ import { Readable } from "node:stream";
|
|
|
10
10
|
// watchdog below is not one of those waits — it is an internal deadline that
|
|
11
11
|
// never sleeps the caller — so it must stay out of that reckoning.
|
|
12
12
|
import { clearTimeout as clearTimer, setTimeout as setTimer } from "node:timers";
|
|
13
|
-
import { APIConnectionError, APITimeoutError, Image2PPTError, Image2PPTTimeoutError, InvalidFileError, JobCancelledError, JobFailedError, MalformedResponseError, RateLimitedError, exceptionFor, } from "./errors.js";
|
|
13
|
+
import { APIConnectionError, APITimeoutError, IdempotencyKeyInProgressError, IdempotencyKeyMismatchError, Image2PPTError, Image2PPTTimeoutError, InvalidFileError, InvalidIdempotencyKeyError, InvalidParameterError, JobCancelledError, JobFailedError, MalformedResponseError, RateLimitedError, ServerError, exceptionFor, } from "./errors.js";
|
|
14
14
|
import { compressImageForUpload } from "./compress.js";
|
|
15
|
-
import { checkFileSize, checkSubmission, planBatches } from "./limits.js";
|
|
16
|
-
import {
|
|
15
|
+
import { checkFileSize, checkPageSelection, checkSubmission, planBatches } from "./limits.js";
|
|
16
|
+
import { isBlank, normalizePages, parsePages } from "./pages.js";
|
|
17
|
+
import { Job, JobList, parseCancellationResult, wholeNumber } from "./types.js";
|
|
17
18
|
import { VERSION } from "./version.js";
|
|
18
19
|
export const DEFAULT_BASE_URL = "https://image2ppt.com";
|
|
19
20
|
/**
|
|
@@ -36,6 +37,93 @@ const RATE_LIMIT_FALLBACK_WAIT_MS = 5_000;
|
|
|
36
37
|
* a retry a retry instead of a flood, and costs nothing when the server means it.
|
|
37
38
|
*/
|
|
38
39
|
const MIN_RETRY_AFTER_SECONDS = 1;
|
|
40
|
+
/**
|
|
41
|
+
* Waits before resending a submission whose outcome is unknown — a dropped
|
|
42
|
+
* connection, a timeout, a 5xx. One entry per extra attempt, so two retries.
|
|
43
|
+
*
|
|
44
|
+
* Resending is safe only because every attempt carries the same `Idempotency-Key`:
|
|
45
|
+
* if an earlier attempt did create the job, the service answers with that job
|
|
46
|
+
* instead of creating a second one. The count stays small because each attempt can
|
|
47
|
+
* re-upload up to 90MB.
|
|
48
|
+
*/
|
|
49
|
+
const SUBMIT_RETRY_DELAYS_MS = [1_000, 2_000];
|
|
50
|
+
/**
|
|
51
|
+
* Bounds on waiting out `IDEMPOTENCY_KEY_IN_PROGRESS` — an earlier attempt with the
|
|
52
|
+
* same key that the service is still working on (typically one this client gave up
|
|
53
|
+
* on after its idle timeout). Bounded by attempts as well as time for the reason
|
|
54
|
+
* `MAX_BATCH_ATTEMPTS` gives: every attempt re-sends the whole body. The wait starts
|
|
55
|
+
* at `Retry-After` and grows by half each time, up to the cap.
|
|
56
|
+
*/
|
|
57
|
+
const IN_PROGRESS_MAX_ATTEMPTS = 10;
|
|
58
|
+
const IN_PROGRESS_MAX_WAIT_MS = 180_000;
|
|
59
|
+
const IN_PROGRESS_WAIT_CAP_MS = 30_000;
|
|
60
|
+
/**
|
|
61
|
+
* Floor for the idle timeout of a submission by URL. The service may spend up to 120
|
|
62
|
+
* seconds downloading before it answers at all, and nothing moves on the connection
|
|
63
|
+
* meanwhile, so the client's usual 60 would give up on requests that are going fine.
|
|
64
|
+
*/
|
|
65
|
+
const URL_SUBMIT_MIN_TIMEOUT_MS = 180_000;
|
|
66
|
+
/** Most links one submission by URL may carry. */
|
|
67
|
+
const MAX_URLS = 50;
|
|
68
|
+
/** What `Idempotency-Key` may contain: 1–255 printable ASCII characters. */
|
|
69
|
+
const IDEMPOTENCY_KEY = /^[\x21-\x7e]{1,255}$/;
|
|
70
|
+
/** The caller's key, checked, or a fresh random one when they gave none. */
|
|
71
|
+
function resolveIdempotencyKey(key) {
|
|
72
|
+
if (key === undefined || key === null)
|
|
73
|
+
return randomUUID();
|
|
74
|
+
if (typeof key !== "string" || !IDEMPOTENCY_KEY.test(key)) {
|
|
75
|
+
throw new InvalidIdempotencyKeyError("idempotencyKey must be 1-255 printable ASCII characters (no spaces)", { code: "INVALID_IDEMPOTENCY_KEY" });
|
|
76
|
+
}
|
|
77
|
+
return key;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* `value` as an array of strings, refusing a bare string.
|
|
81
|
+
*
|
|
82
|
+
* A string is iterable, so `submit("deck.pdf")` from plain JavaScript would
|
|
83
|
+
* otherwise be read as one file per character.
|
|
84
|
+
*/
|
|
85
|
+
/** How long to wait before retrying a 429: `Retry-After`, else a fixed wait. */
|
|
86
|
+
function rateLimitDelayMs(err) {
|
|
87
|
+
return err.retryAfter != null ? err.retryAfter * 1000 : RATE_LIMIT_FALLBACK_WAIT_MS;
|
|
88
|
+
}
|
|
89
|
+
function stringList(value, name) {
|
|
90
|
+
if (!Array.isArray(value)) {
|
|
91
|
+
throw new TypeError(`${name} must be an array of strings, not ${typeof value}`);
|
|
92
|
+
}
|
|
93
|
+
for (const item of value) {
|
|
94
|
+
if (typeof item !== "string") {
|
|
95
|
+
throw new TypeError(`${name} must hold strings, not ${typeof item}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The optional fields both kinds of submission carry, leaving out unset ones.
|
|
102
|
+
*
|
|
103
|
+
* An empty or all-whitespace `pages` or `callbackUrl` means "not given" to the
|
|
104
|
+
* service, so it is not sent at all.
|
|
105
|
+
*/
|
|
106
|
+
function submissionFields(options) {
|
|
107
|
+
const { callbackUrl } = options;
|
|
108
|
+
if (callbackUrl != null && typeof callbackUrl !== "string") {
|
|
109
|
+
throw new TypeError(`callbackUrl must be a string, not ${typeof callbackUrl}`);
|
|
110
|
+
}
|
|
111
|
+
const fields = {};
|
|
112
|
+
if (options.locale != null)
|
|
113
|
+
fields.locale = options.locale;
|
|
114
|
+
if (options.aspectRatio != null)
|
|
115
|
+
fields.aspectRatio = options.aspectRatio;
|
|
116
|
+
const selection = normalizePages(options.pages);
|
|
117
|
+
if (selection !== undefined)
|
|
118
|
+
fields.pages = selection;
|
|
119
|
+
if (callbackUrl != null && !isBlank(callbackUrl))
|
|
120
|
+
fields.callbackUrl = callbackUrl;
|
|
121
|
+
return fields;
|
|
122
|
+
}
|
|
123
|
+
/** Whether a header value says `true`, ignoring case and surrounding space. */
|
|
124
|
+
function headerIsTrue(value) {
|
|
125
|
+
return (value ?? "").trim().toLowerCase() === "true";
|
|
126
|
+
}
|
|
39
127
|
/** How many images are read and re-encoded at the same time. See `#prepareFiles`. */
|
|
40
128
|
const PREPARE_CONCURRENCY = 4;
|
|
41
129
|
/**
|
|
@@ -128,7 +216,7 @@ class IdleWatchdog {
|
|
|
128
216
|
this.#timer = undefined;
|
|
129
217
|
}
|
|
130
218
|
}
|
|
131
|
-
function buildMultipart(files,
|
|
219
|
+
function buildMultipart(files, formFields, onChunk) {
|
|
132
220
|
const boundary = `----image2ppt-${randomUUID()}`;
|
|
133
221
|
const chunk = (value) => Buffer.from(value, "utf8");
|
|
134
222
|
const fileHeader = (file) => `--${boundary}\r\n` +
|
|
@@ -136,11 +224,7 @@ function buildMultipart(files, options, onChunk) {
|
|
|
136
224
|
`Content-Type: ${file.mime}\r\n\r\n`;
|
|
137
225
|
const field = (name, value) => `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`;
|
|
138
226
|
const trailer = `--${boundary}--\r\n`;
|
|
139
|
-
const fields = [];
|
|
140
|
-
if (options.locale)
|
|
141
|
-
fields.push(field("locale", options.locale));
|
|
142
|
-
if (options.aspectRatio)
|
|
143
|
-
fields.push(field("aspectRatio", options.aspectRatio));
|
|
227
|
+
const fields = Object.entries(formFields).map(([name, value]) => field(name, value));
|
|
144
228
|
// The body streams, but its length is known before the first byte goes out: image
|
|
145
229
|
// payloads are already in memory, a PDF's size was measured while preparing it, and
|
|
146
230
|
// every delimiter is a fixed string. Sending `Content-Length` rather than letting
|
|
@@ -423,35 +507,104 @@ export class Image2PPTClient {
|
|
|
423
507
|
* Submit a batch of files and create a conversion job.
|
|
424
508
|
*
|
|
425
509
|
* Checked locally before anything is uploaded: the files must add up to at most
|
|
426
|
-
*
|
|
510
|
+
* 90MB and at most 50 pages (the pages *selected*, when `pages` is given), and
|
|
511
|
+
* `pages` must be spelled correctly. Over a limit this throws without opening a
|
|
427
512
|
* connection — going over the size cap on the wire does not come back as a clean
|
|
428
513
|
* error, it comes back as a dead connection.
|
|
429
514
|
*
|
|
430
|
-
* **
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
515
|
+
* **Every submission carries an `Idempotency-Key`**, and that is what makes a
|
|
516
|
+
* failed one safe to resend: if an earlier attempt did create the job, the service
|
|
517
|
+
* hands that job back (`job.replayed` is true) instead of creating and charging for
|
|
518
|
+
* a second one. So this call resends by itself, always with the same key, when the
|
|
519
|
+
* outcome is unknown — a dropped connection, an idle timeout, or a 5xx (up to 2
|
|
520
|
+
* more attempts, 1s and 2s apart) — and waits out `IDEMPOTENCY_KEY_IN_PROGRESS`, an
|
|
521
|
+
* earlier attempt the service is still working on (up to 10 attempts / 3 minutes).
|
|
522
|
+
* A 429 is not retried here; `submitAll` waits those out.
|
|
523
|
+
*
|
|
524
|
+
* If it still fails, the key is on the error as `err.idempotencyKey`. Calling
|
|
525
|
+
* `submit` again with that key and the same files and options is safe for 24
|
|
526
|
+
* hours; calling it without the key may create a second job.
|
|
436
527
|
*
|
|
437
528
|
* @param paths Local file paths (one or more). png/jpeg/webp/gif/pdf, each ≤ 35MB,
|
|
438
|
-
* and ≤
|
|
529
|
+
* and ≤ 90MB of file content per request. An image is 1 page, a PDF is its page
|
|
439
530
|
* count; the total must be ≤ 50 pages. For more files than one request can hold,
|
|
440
531
|
* use `submitAll` / `convertAll`.
|
|
441
|
-
* @returns A `Job` with status `pending
|
|
532
|
+
* @returns A `Job` with status `pending` (for a replay: the job's current status),
|
|
533
|
+
* plus `slideCount` and `creditsReserved`.
|
|
442
534
|
* @throws InvalidFileError Including the local `PAYLOAD_TOO_LARGE` pre-flight failure.
|
|
535
|
+
* @throws InvalidPagesError / PagesOutOfRangeError / TooManySlidesError A selection
|
|
536
|
+
* that is certain to be refused, before anything is sent.
|
|
537
|
+
* @throws IdempotencyKeyMismatchError The key was used for a different request.
|
|
538
|
+
* @throws APIConnectionError / ServerError / IdempotencyKeyInProgressError Once the
|
|
539
|
+
* retries above run out.
|
|
443
540
|
*/
|
|
444
541
|
async submit(paths, options = {}) {
|
|
445
|
-
|
|
542
|
+
const list = stringList(paths, "paths");
|
|
543
|
+
if (!list.length) {
|
|
446
544
|
throw new Error("at least one file is required");
|
|
447
545
|
}
|
|
448
|
-
|
|
546
|
+
const key = resolveIdempotencyKey(options.idempotencyKey);
|
|
547
|
+
const fields = submissionFields(options);
|
|
548
|
+
// The spelling needs no files: refuse a typo before compressing anything. Range
|
|
549
|
+
// and count wait for the page total, so the error is the one the service would
|
|
550
|
+
// give.
|
|
551
|
+
if (fields.pages !== undefined)
|
|
552
|
+
parsePages(fields.pages);
|
|
553
|
+
const files = await this.#prepareFiles(list);
|
|
554
|
+
checkPrepared(files, fields.pages);
|
|
555
|
+
return this.#submitWithKey(() => this.#postFiles(files, fields, key), key);
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Create a conversion job from files the service downloads itself.
|
|
559
|
+
*
|
|
560
|
+
* Instead of uploading, hand over `https` links; the service fetches them in order
|
|
561
|
+
* and converts them as one deck. The downloaded files are held to the same limits
|
|
562
|
+
* as uploads (35MB each, 90MB together, 50 pages). File types are judged by
|
|
563
|
+
* content, not by the link or its `Content-Type`.
|
|
564
|
+
*
|
|
565
|
+
* Downloading takes time — up to 60 seconds a link and 120 for the request — so
|
|
566
|
+
* this call waits at least 180 seconds for an answer, whatever the client's
|
|
567
|
+
* `timeoutMs`. Retries and `idempotencyKey` work exactly as in `submit`. Only one
|
|
568
|
+
* submission by URL per account can be in flight at a time; another gets a
|
|
569
|
+
* `RateLimitedError`.
|
|
570
|
+
*
|
|
571
|
+
* @param urls 1–50 `https` links. Each must resolve to a public address.
|
|
572
|
+
* @param options As `submit`. With links the page total is not known here, so
|
|
573
|
+
* `pages` is only checked for spelling and for selecting more than 50 pages.
|
|
574
|
+
* @throws InvalidUrlError A link was refused before downloading.
|
|
575
|
+
* @throws UrlFetchFailedError A download failed; may be temporary. For these two —
|
|
576
|
+
* and `InvalidFileError` about a downloaded file — `err.index` says which link,
|
|
577
|
+
* counting from 0.
|
|
578
|
+
* @throws InvalidParameterError More than 50 links, thrown locally.
|
|
579
|
+
*/
|
|
580
|
+
async submitUrls(urls, options = {}) {
|
|
581
|
+
const list = stringList(urls, "urls");
|
|
582
|
+
if (!list.length) {
|
|
583
|
+
throw new Error("at least one URL is required");
|
|
584
|
+
}
|
|
585
|
+
if (list.length > MAX_URLS) {
|
|
586
|
+
throw new InvalidParameterError(`${list.length} URLs in one submission, over the ${MAX_URLS} allowed`, { code: "INVALID_PARAMETER" });
|
|
587
|
+
}
|
|
588
|
+
const key = resolveIdempotencyKey(options.idempotencyKey);
|
|
589
|
+
const fields = submissionFields(options);
|
|
590
|
+
checkPageSelection(fields.pages);
|
|
591
|
+
const body = Buffer.from(JSON.stringify({ urls: list, ...fields }), "utf8");
|
|
592
|
+
return this.#submitWithKey(() => this.#request("POST", "/api/v1/jobs", {
|
|
593
|
+
body: () => ({
|
|
594
|
+
body,
|
|
595
|
+
contentType: "application/json",
|
|
596
|
+
contentLength: body.byteLength,
|
|
597
|
+
}),
|
|
598
|
+
headers: { "Idempotency-Key": key },
|
|
599
|
+
timeoutMs: Math.max(this.timeoutMs, URL_SUBMIT_MIN_TIMEOUT_MS),
|
|
600
|
+
consume: (res) => this.#parseSubmission(res),
|
|
601
|
+
}), key);
|
|
449
602
|
}
|
|
450
603
|
/**
|
|
451
604
|
* Split files into submittable batches and create **one job per batch**.
|
|
452
605
|
*
|
|
453
606
|
* For a pile of files too big or too numerous for a single request. Batching
|
|
454
|
-
* rules live in `planBatches`: at most
|
|
607
|
+
* rules live in `planBatches`: at most 80MB of file content and at most 50
|
|
455
608
|
* images per batch, and every PDF in a batch of its own (the SDK does not parse
|
|
456
609
|
* PDFs, so only the server knows their page count). Input order is preserved.
|
|
457
610
|
*
|
|
@@ -466,8 +619,11 @@ export class Image2PPTClient {
|
|
|
466
619
|
* the same batch again. Waiting is the normal path here. Total waiting is capped
|
|
467
620
|
* by the client's `rateLimitMaxWaitMs`.
|
|
468
621
|
*
|
|
469
|
-
* **
|
|
470
|
-
*
|
|
622
|
+
* **Each batch is its own submission with its own `Idempotency-Key`**, and is
|
|
623
|
+
* retried exactly as `submit` retries — plus the 429s above, with the same key.
|
|
624
|
+
* There is no `pages` or `idempotencyKey` option here: page numbers run across one
|
|
625
|
+
* submission and a key names one job, so neither can span several batches. Submit
|
|
626
|
+
* batches yourself with `submit` if you need them.
|
|
471
627
|
*
|
|
472
628
|
* **If it does give up, the jobs already created are handed back on the error**,
|
|
473
629
|
* in `err.submittedJobs`. Those jobs are running on the server with credits
|
|
@@ -480,10 +636,12 @@ export class Image2PPTClient {
|
|
|
480
636
|
* @throws RateLimitedError Still rate limited after `rateLimitMaxWaitMs`.
|
|
481
637
|
*/
|
|
482
638
|
async submitAll(paths, options = {}) {
|
|
483
|
-
|
|
639
|
+
const list = stringList(paths, "paths");
|
|
640
|
+
if (!list.length) {
|
|
484
641
|
throw new Error("at least one file is required");
|
|
485
642
|
}
|
|
486
|
-
const
|
|
643
|
+
const fields = batchFields(options);
|
|
644
|
+
const files = await this.#prepareFiles(list);
|
|
487
645
|
const batches = planBatches(files.map((file) => ({
|
|
488
646
|
path: file.path,
|
|
489
647
|
size: file.size,
|
|
@@ -494,7 +652,7 @@ export class Image2PPTClient {
|
|
|
494
652
|
const jobs = [];
|
|
495
653
|
for (const batch of batches) {
|
|
496
654
|
try {
|
|
497
|
-
jobs.push(await this.#submitBatch(batch.map((item) => item.file),
|
|
655
|
+
jobs.push(await this.#submitBatch(batch.map((item) => item.file), fields, budget));
|
|
498
656
|
}
|
|
499
657
|
catch (err) {
|
|
500
658
|
// Whatever went wrong, the earlier batches are already jobs on the server
|
|
@@ -663,15 +821,32 @@ export class Image2PPTClient {
|
|
|
663
821
|
/**
|
|
664
822
|
* One-shot: submit → wait for completion → download to `destPath`.
|
|
665
823
|
*
|
|
666
|
-
* One job, one PPTX — the files must fit in a single submission (
|
|
824
|
+
* One job, one PPTX — the files must fit in a single submission (90MB of file
|
|
667
825
|
* content, 50 pages). For more than that, `convertAll` splits the pile and writes
|
|
668
826
|
* one PPTX per batch.
|
|
669
827
|
*/
|
|
670
828
|
async convert(paths, destPath, options = {}) {
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
829
|
+
// Resolved here, not in submit: a wait or download that fails after the job
|
|
830
|
+
// exists must hand back the key too, or retrying convert() would create and pay
|
|
831
|
+
// for a second job.
|
|
832
|
+
const idempotencyKey = resolveIdempotencyKey(options.idempotencyKey);
|
|
833
|
+
const job = await this.submit(paths, { ...options, idempotencyKey });
|
|
834
|
+
try {
|
|
835
|
+
const completed = await this.wait(job.jobId, options);
|
|
836
|
+
await this.download(completed.jobId, destPath);
|
|
837
|
+
return completed;
|
|
838
|
+
}
|
|
839
|
+
catch (err) {
|
|
840
|
+
if (typeof err === "object" && err !== null) {
|
|
841
|
+
try {
|
|
842
|
+
err.idempotencyKey = idempotencyKey;
|
|
843
|
+
}
|
|
844
|
+
catch {
|
|
845
|
+
// a frozen error object: keep it as it is rather than replace it
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
throw err;
|
|
849
|
+
}
|
|
675
850
|
}
|
|
676
851
|
/**
|
|
677
852
|
* Batch version of `convert`: submit everything, wait, download each deck.
|
|
@@ -698,6 +873,9 @@ export class Image2PPTClient {
|
|
|
698
873
|
* @returns The written file paths, in batch order.
|
|
699
874
|
*/
|
|
700
875
|
async convertAll(paths, destDir, options = {}) {
|
|
876
|
+
// Refused before the destination is even created: a plain-JS caller passing
|
|
877
|
+
// `pages` or `idempotencyKey` gets the answer before anything touches the disk.
|
|
878
|
+
batchFields(options);
|
|
701
879
|
// Before anything is submitted: if the destination is unusable, fail now
|
|
702
880
|
// rather than after N jobs exist with credits reserved and nowhere to put
|
|
703
881
|
// their output. This is the one step that can fail for free.
|
|
@@ -720,6 +898,55 @@ export class Image2PPTClient {
|
|
|
720
898
|
}
|
|
721
899
|
return written;
|
|
722
900
|
}
|
|
901
|
+
/**
|
|
902
|
+
* One page of the jobs this account submitted through the API, newest first.
|
|
903
|
+
*
|
|
904
|
+
* Jobs deleted on the website are not listed. Each job has `getJob`'s shape minus
|
|
905
|
+
* `pageResults`; for reconciliation, add up `creditsUsed` and `creditsRefunded`.
|
|
906
|
+
* To walk every page, use `iterJobs`.
|
|
907
|
+
*
|
|
908
|
+
* @throws InvalidParameterError A parameter is invalid; `message` says which.
|
|
909
|
+
*/
|
|
910
|
+
async listJobs(options = {}) {
|
|
911
|
+
const params = new URLSearchParams();
|
|
912
|
+
for (const [name, value] of [
|
|
913
|
+
["createdFrom", options.createdFrom],
|
|
914
|
+
["createdTo", options.createdTo],
|
|
915
|
+
["limit", options.limit],
|
|
916
|
+
["cursor", options.cursor],
|
|
917
|
+
]) {
|
|
918
|
+
// null/undefined and "" all mean "not given"; everything else goes as given,
|
|
919
|
+
// so the service is the one to judge it — `limit: 0` included.
|
|
920
|
+
if (value != null && value !== "")
|
|
921
|
+
params.set(name, String(value));
|
|
922
|
+
}
|
|
923
|
+
const query = params.toString();
|
|
924
|
+
return new JobList(await this.#request("GET", `/api/v1/jobs${query ? `?${query}` : ""}`, {
|
|
925
|
+
consume: (res) => this.#parseJson(res),
|
|
926
|
+
}));
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Every job `listJobs` would list, following `nextCursor` to the end.
|
|
930
|
+
*
|
|
931
|
+
* `limit` is the page size, not a cap on how many jobs come back. A 429 is waited
|
|
932
|
+
* out (`Retry-After`, else 5s), and a page that fails in a way worth repeating
|
|
933
|
+
* (`isTransient`) is fetched again after a short backoff — at most 10 attempts per
|
|
934
|
+
* page before the error is thrown.
|
|
935
|
+
*
|
|
936
|
+
* ```ts
|
|
937
|
+
* for await (const job of client.iterJobs({ createdFrom: "2026-10-01" })) { ... }
|
|
938
|
+
* ```
|
|
939
|
+
*/
|
|
940
|
+
async *iterJobs(options = {}) {
|
|
941
|
+
let cursor;
|
|
942
|
+
for (;;) {
|
|
943
|
+
const page = await this.#listPageWithRetries({ ...options, cursor });
|
|
944
|
+
yield* page.data;
|
|
945
|
+
if (page.nextCursor === null)
|
|
946
|
+
return;
|
|
947
|
+
cursor = page.nextCursor;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
723
950
|
/** Return account info: `{ email, credits }` (available credits). */
|
|
724
951
|
async account() {
|
|
725
952
|
return (await this.#request("GET", "/api/v1/account", {
|
|
@@ -728,17 +955,21 @@ export class Image2PPTClient {
|
|
|
728
955
|
}
|
|
729
956
|
// ----- internals --------------------------------------------------- //
|
|
730
957
|
/**
|
|
731
|
-
* Submit one batch, waiting out rate limits
|
|
958
|
+
* Submit one batch, waiting out rate limits while `budget` allows.
|
|
732
959
|
*
|
|
733
|
-
*
|
|
734
|
-
*
|
|
735
|
-
*
|
|
960
|
+
* The batch was prepared (images compressed) once, in `submitAll`, and gets its
|
|
961
|
+
* `Idempotency-Key` once, here, before the loop — so every attempt sends the very
|
|
962
|
+
* same request under the same key, which is what lets the service recognise a
|
|
963
|
+
* resend.
|
|
736
964
|
*
|
|
737
|
-
*
|
|
738
|
-
*
|
|
739
|
-
*
|
|
965
|
+
* A 429 is the server saying it did *not* take the submission — nothing was
|
|
966
|
+
* created and nothing charged — so trying the same batch again is free. Both
|
|
967
|
+
* flavors (per-minute page quota, concurrent-job cap) carry a `Retry-After` and are
|
|
968
|
+
* handled identically; when the header is missing we fall back to a fixed wait.
|
|
969
|
+
* Other failures are retried inside `#submitWithKey`.
|
|
740
970
|
*/
|
|
741
|
-
async #submitBatch(files,
|
|
971
|
+
async #submitBatch(files, fields, budget) {
|
|
972
|
+
const key = resolveIdempotencyKey(undefined);
|
|
742
973
|
// Two things stop this: the shared waiting `budget`, and MAX_BATCH_ATTEMPTS. The
|
|
743
974
|
// budget bounds time spent waiting; the attempt count bounds the uploads, which
|
|
744
975
|
// the budget cannot see — a server answering `Retry-After: 1` forever costs almost
|
|
@@ -746,17 +977,108 @@ export class Image2PPTClient {
|
|
|
746
977
|
let attemptsLeft = MAX_BATCH_ATTEMPTS;
|
|
747
978
|
for (;;) {
|
|
748
979
|
try {
|
|
749
|
-
return await this.#
|
|
980
|
+
return await this.#submitWithKey(() => this.#postFiles(files, fields, key), key);
|
|
750
981
|
}
|
|
751
982
|
catch (err) {
|
|
752
983
|
if (!(err instanceof RateLimitedError))
|
|
753
984
|
throw err;
|
|
754
985
|
attemptsLeft -= 1;
|
|
755
|
-
const delay = err.retryAfter != null ? err.retryAfter * 1000 : RATE_LIMIT_FALLBACK_WAIT_MS;
|
|
756
986
|
// On the last attempt, do not wait first: nothing follows the wait, so it
|
|
757
987
|
// would only delay the error the caller is already getting.
|
|
758
|
-
if (attemptsLeft <= 0 || !(await budget.spend(
|
|
988
|
+
if (attemptsLeft <= 0 || !(await budget.spend(rateLimitDelayMs(err))))
|
|
989
|
+
throw err;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* Send one submission until its outcome is known; see `submit` for the rules.
|
|
995
|
+
*
|
|
996
|
+
* `send` makes one attempt and must send the identical request every time, under
|
|
997
|
+
* `key`. Whatever finally escapes carries `key` as `idempotencyKey`, so the caller
|
|
998
|
+
* can resend safely.
|
|
999
|
+
*
|
|
1000
|
+
* A 429 is left to the caller on purpose. By the contract, resending the same
|
|
1001
|
+
* request under a key whose job exists is answered with that job (and one still
|
|
1002
|
+
* being processed with "in progress"), so a 429 here still means nothing was taken.
|
|
1003
|
+
*/
|
|
1004
|
+
async #submitWithKey(send, key) {
|
|
1005
|
+
let retriesUsed = 0;
|
|
1006
|
+
let inProgressAttempts = 0;
|
|
1007
|
+
const inProgressBudget = new WaitBudget(IN_PROGRESS_MAX_WAIT_MS);
|
|
1008
|
+
try {
|
|
1009
|
+
for (let attempt = 1;; attempt += 1) {
|
|
1010
|
+
try {
|
|
1011
|
+
return await send();
|
|
1012
|
+
}
|
|
1013
|
+
catch (err) {
|
|
1014
|
+
if (err instanceof IdempotencyKeyInProgressError) {
|
|
1015
|
+
inProgressAttempts += 1;
|
|
1016
|
+
const firstMs = (err.retryAfter ?? 2) * 1000;
|
|
1017
|
+
const delay = Math.min(firstMs * 1.5 ** (inProgressAttempts - 1), IN_PROGRESS_WAIT_CAP_MS);
|
|
1018
|
+
if (inProgressAttempts >= IN_PROGRESS_MAX_ATTEMPTS ||
|
|
1019
|
+
!(await inProgressBudget.spend(delay))) {
|
|
1020
|
+
throw err;
|
|
1021
|
+
}
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
if (err instanceof APIConnectionError || err instanceof ServerError) {
|
|
1025
|
+
const delay = SUBMIT_RETRY_DELAYS_MS[retriesUsed];
|
|
1026
|
+
if (delay === undefined)
|
|
1027
|
+
throw err;
|
|
1028
|
+
retriesUsed += 1;
|
|
1029
|
+
await sleep(delay);
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
if (err instanceof IdempotencyKeyMismatchError && attempt > 1) {
|
|
1033
|
+
// Only a request that created a job holds its key, so an earlier attempt
|
|
1034
|
+
// of this very call did — and the files changed on disk in between, or
|
|
1035
|
+
// this would have been a replay.
|
|
1036
|
+
err.message +=
|
|
1037
|
+
"; an earlier attempt of this call did create a job with this key before " +
|
|
1038
|
+
"the request changed (did a file change on disk?) — find it with listJobs()";
|
|
1039
|
+
}
|
|
759
1040
|
throw err;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
catch (err) {
|
|
1045
|
+
// Not only SDK errors: a PDF that vanished between attempts fails with a
|
|
1046
|
+
// filesystem error, and the first attempt may already have created the job.
|
|
1047
|
+
if (typeof err === "object" && err !== null) {
|
|
1048
|
+
try {
|
|
1049
|
+
err.idempotencyKey = key;
|
|
1050
|
+
}
|
|
1051
|
+
catch {
|
|
1052
|
+
// a frozen error object: keep it as it is rather than replace it
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
throw err;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* One `listJobs` page for `iterJobs`, retried while that is worth it.
|
|
1060
|
+
*
|
|
1061
|
+
* Listing is a read, so repeating it is free: a 429 waits `Retry-After` and anything
|
|
1062
|
+
* `isTransient` backs off, both within `MAX_BATCH_ATTEMPTS`.
|
|
1063
|
+
*/
|
|
1064
|
+
async #listPageWithRetries(options) {
|
|
1065
|
+
let backoff = 1_000;
|
|
1066
|
+
for (let attempt = 1;; attempt += 1) {
|
|
1067
|
+
try {
|
|
1068
|
+
return await this.listJobs(options);
|
|
1069
|
+
}
|
|
1070
|
+
catch (err) {
|
|
1071
|
+
if (attempt >= MAX_BATCH_ATTEMPTS)
|
|
1072
|
+
throw err;
|
|
1073
|
+
if (!(err instanceof Image2PPTError) || !err.isTransient)
|
|
1074
|
+
throw err;
|
|
1075
|
+
if (err instanceof RateLimitedError) {
|
|
1076
|
+
await sleep(rateLimitDelayMs(err));
|
|
1077
|
+
}
|
|
1078
|
+
else {
|
|
1079
|
+
await sleep(backoff);
|
|
1080
|
+
backoff = Math.min(backoff * 2, 15_000);
|
|
1081
|
+
}
|
|
760
1082
|
}
|
|
761
1083
|
}
|
|
762
1084
|
}
|
|
@@ -826,18 +1148,26 @@ export class Image2PPTClient {
|
|
|
826
1148
|
isImage: true,
|
|
827
1149
|
};
|
|
828
1150
|
}
|
|
829
|
-
/**
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
1151
|
+
/**
|
|
1152
|
+
* POST the multipart submission once, under `key`.
|
|
1153
|
+
*
|
|
1154
|
+
* PDFs are reopened and streamed from disk on every attempt; images go from the
|
|
1155
|
+
* bytes compressed once in `#prepareFile`, so a resend carries the same image bytes
|
|
1156
|
+
* as the first attempt.
|
|
1157
|
+
*/
|
|
1158
|
+
async #postFiles(files, fields, key) {
|
|
1159
|
+
return this.#request("POST", "/api/v1/jobs", {
|
|
837
1160
|
// Rebuilt per attempt: a retry needs a body that has not been consumed.
|
|
838
|
-
body: (watchdog) => buildMultipart(files,
|
|
839
|
-
|
|
840
|
-
|
|
1161
|
+
body: (watchdog) => buildMultipart(files, fields, () => watchdog.kick()),
|
|
1162
|
+
headers: { "Idempotency-Key": key },
|
|
1163
|
+
consume: (res) => this.#parseSubmission(res),
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
/** A submission's answer: the job, marked when the service replayed it. */
|
|
1167
|
+
async #parseSubmission(res) {
|
|
1168
|
+
const job = Job.fromJson(await this.#parseJson(res));
|
|
1169
|
+
job.replayed = headerIsTrue(res.headers.get("Idempotent-Replayed"));
|
|
1170
|
+
return job;
|
|
841
1171
|
}
|
|
842
1172
|
/**
|
|
843
1173
|
* Make one HTTP request and consume its response, under an idle watchdog.
|
|
@@ -864,7 +1194,8 @@ export class Image2PPTClient {
|
|
|
864
1194
|
* `isTransient`. See `readingBody`.
|
|
865
1195
|
*/
|
|
866
1196
|
async #request(method, path, options) {
|
|
867
|
-
const
|
|
1197
|
+
const timeoutMs = options.timeoutMs ?? this.timeoutMs;
|
|
1198
|
+
const watchdog = new IdleWatchdog(timeoutMs, `request to ${path} went ${timeoutMs}ms with no data moving ` +
|
|
868
1199
|
"(timeoutMs is an idle timeout, not a limit on how long a transfer may take)");
|
|
869
1200
|
try {
|
|
870
1201
|
const payload = options.body?.(watchdog);
|
|
@@ -876,6 +1207,7 @@ export class Image2PPTClient {
|
|
|
876
1207
|
Authorization: `Bearer ${this.#apiKey}`,
|
|
877
1208
|
"User-Agent": USER_AGENT,
|
|
878
1209
|
...(this.acceptLanguage ? { "Accept-Language": this.acceptLanguage } : {}),
|
|
1210
|
+
...options.headers,
|
|
879
1211
|
...(payload
|
|
880
1212
|
? {
|
|
881
1213
|
"Content-Type": payload.contentType,
|
|
@@ -1019,11 +1351,18 @@ export class Image2PPTClient {
|
|
|
1019
1351
|
async #raiseForError(res) {
|
|
1020
1352
|
let code;
|
|
1021
1353
|
let message;
|
|
1354
|
+
let index;
|
|
1022
1355
|
try {
|
|
1023
1356
|
const body = (await res.json());
|
|
1024
1357
|
if (body && typeof body === "object" && body.error) {
|
|
1025
1358
|
code = body.error.code;
|
|
1026
1359
|
message = body.error.message;
|
|
1360
|
+
// A whole, non-negative number only: `3.0` is 3, `"1"`, `true`, `-1` and
|
|
1361
|
+
// `1.5` are no index at all — the same reading as the Python client.
|
|
1362
|
+
const raw = body.error.index;
|
|
1363
|
+
const whole = wholeNumber(raw);
|
|
1364
|
+
if (whole !== null && whole >= 0)
|
|
1365
|
+
index = whole;
|
|
1027
1366
|
}
|
|
1028
1367
|
}
|
|
1029
1368
|
catch (err) {
|
|
@@ -1040,6 +1379,7 @@ export class Image2PPTClient {
|
|
|
1040
1379
|
code,
|
|
1041
1380
|
message: message ?? `request failed (HTTP ${res.status})`,
|
|
1042
1381
|
retryAfter: parseRetryAfter(res.headers.get("Retry-After")),
|
|
1382
|
+
index,
|
|
1043
1383
|
});
|
|
1044
1384
|
}
|
|
1045
1385
|
/**
|
|
@@ -1057,6 +1397,36 @@ export class Image2PPTClient {
|
|
|
1057
1397
|
await sleep(Math.min(ms, remaining, MAX_SLEEP_MS));
|
|
1058
1398
|
}
|
|
1059
1399
|
}
|
|
1400
|
+
/**
|
|
1401
|
+
* Pre-flight, before a single byte goes out: an oversized request is not answered
|
|
1402
|
+
* with an error, it is cut off — so it must never be sent.
|
|
1403
|
+
*/
|
|
1404
|
+
function checkPrepared(files, pages) {
|
|
1405
|
+
// After image preparation, because these are the exact bytes that will be
|
|
1406
|
+
// transmitted, not the potentially much larger source files.
|
|
1407
|
+
for (const file of files)
|
|
1408
|
+
checkFileSize(file.path, file.size);
|
|
1409
|
+
checkSubmission(files.reduce((total, file) => total + file.size, 0), files.filter((file) => file.isImage).length,
|
|
1410
|
+
// A PDF's real page count is only known server-side; counting it as at least 1
|
|
1411
|
+
// is what stops "50 images + a PDF" from being sent as a submission that is
|
|
1412
|
+
// certain to come back over the page limit.
|
|
1413
|
+
files.filter((file) => !file.isImage).length, pages);
|
|
1414
|
+
}
|
|
1415
|
+
/**
|
|
1416
|
+
* The fields for `submitAll` / `convertAll`, refusing the two options that cannot
|
|
1417
|
+
* span several batches. The types already leave them out; this is for plain
|
|
1418
|
+
* JavaScript, where a silently ignored `pages` would convert pages nobody asked for.
|
|
1419
|
+
*/
|
|
1420
|
+
function batchFields(options) {
|
|
1421
|
+
const extra = options;
|
|
1422
|
+
for (const name of ["pages", "idempotencyKey"]) {
|
|
1423
|
+
if (extra[name] !== undefined) {
|
|
1424
|
+
throw new TypeError(`${name} is not supported by submitAll / convertAll: it cannot span several ` +
|
|
1425
|
+
"batches. Use submit() for one submission");
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
return submissionFields(options);
|
|
1429
|
+
}
|
|
1060
1430
|
/** Pull the URL out of a `Link: <url>; rel=...` header, or null. */
|
|
1061
1431
|
function linkUrl(value) {
|
|
1062
1432
|
if (!value)
|