image2ppt 0.5.3 → 0.7.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 +246 -13
- package/dist/client.d.ts +80 -17
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +433 -55
- 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 +121 -8
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +154 -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 +163 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +125 -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,99 @@ 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.name != null) {
|
|
113
|
+
if (typeof options.name !== "string") {
|
|
114
|
+
throw new TypeError(`name must be a string, not ${typeof options.name}`);
|
|
115
|
+
}
|
|
116
|
+
fields.name = options.name;
|
|
117
|
+
}
|
|
118
|
+
if (options.locale != null)
|
|
119
|
+
fields.locale = options.locale;
|
|
120
|
+
if (options.aspectRatio != null)
|
|
121
|
+
fields.aspectRatio = options.aspectRatio;
|
|
122
|
+
const selection = normalizePages(options.pages);
|
|
123
|
+
if (selection !== undefined)
|
|
124
|
+
fields.pages = selection;
|
|
125
|
+
if (callbackUrl != null && !isBlank(callbackUrl))
|
|
126
|
+
fields.callbackUrl = callbackUrl;
|
|
127
|
+
return fields;
|
|
128
|
+
}
|
|
129
|
+
/** Whether a header value says `true`, ignoring case and surrounding space. */
|
|
130
|
+
function headerIsTrue(value) {
|
|
131
|
+
return (value ?? "").trim().toLowerCase() === "true";
|
|
132
|
+
}
|
|
39
133
|
/** How many images are read and re-encoded at the same time. See `#prepareFiles`. */
|
|
40
134
|
const PREPARE_CONCURRENCY = 4;
|
|
41
135
|
/**
|
|
@@ -128,7 +222,7 @@ class IdleWatchdog {
|
|
|
128
222
|
this.#timer = undefined;
|
|
129
223
|
}
|
|
130
224
|
}
|
|
131
|
-
function buildMultipart(files,
|
|
225
|
+
function buildMultipart(files, formFields, onChunk) {
|
|
132
226
|
const boundary = `----image2ppt-${randomUUID()}`;
|
|
133
227
|
const chunk = (value) => Buffer.from(value, "utf8");
|
|
134
228
|
const fileHeader = (file) => `--${boundary}\r\n` +
|
|
@@ -136,11 +230,7 @@ function buildMultipart(files, options, onChunk) {
|
|
|
136
230
|
`Content-Type: ${file.mime}\r\n\r\n`;
|
|
137
231
|
const field = (name, value) => `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`;
|
|
138
232
|
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));
|
|
233
|
+
const fields = Object.entries(formFields).map(([name, value]) => field(name, value));
|
|
144
234
|
// The body streams, but its length is known before the first byte goes out: image
|
|
145
235
|
// payloads are already in memory, a PDF's size was measured while preparing it, and
|
|
146
236
|
// every delimiter is a fixed string. Sending `Content-Length` rather than letting
|
|
@@ -423,35 +513,104 @@ export class Image2PPTClient {
|
|
|
423
513
|
* Submit a batch of files and create a conversion job.
|
|
424
514
|
*
|
|
425
515
|
* Checked locally before anything is uploaded: the files must add up to at most
|
|
426
|
-
*
|
|
516
|
+
* 90MB and at most 50 pages (the pages *selected*, when `pages` is given), and
|
|
517
|
+
* `pages` must be spelled correctly. Over a limit this throws without opening a
|
|
427
518
|
* connection — going over the size cap on the wire does not come back as a clean
|
|
428
519
|
* error, it comes back as a dead connection.
|
|
429
520
|
*
|
|
430
|
-
* **
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
521
|
+
* **Every submission carries an `Idempotency-Key`**, and that is what makes a
|
|
522
|
+
* failed one safe to resend: if an earlier attempt did create the job, the service
|
|
523
|
+
* hands that job back (`job.replayed` is true) instead of creating and charging for
|
|
524
|
+
* a second one. So this call resends by itself, always with the same key, when the
|
|
525
|
+
* outcome is unknown — a dropped connection, an idle timeout, or a 5xx (up to 2
|
|
526
|
+
* more attempts, 1s and 2s apart) — and waits out `IDEMPOTENCY_KEY_IN_PROGRESS`, an
|
|
527
|
+
* earlier attempt the service is still working on (up to 10 attempts / 3 minutes).
|
|
528
|
+
* A 429 is not retried here; `submitAll` waits those out.
|
|
529
|
+
*
|
|
530
|
+
* If it still fails, the key is on the error as `err.idempotencyKey`. Calling
|
|
531
|
+
* `submit` again with that key and the same files and options is safe for 24
|
|
532
|
+
* hours; calling it without the key may create a second job.
|
|
436
533
|
*
|
|
437
534
|
* @param paths Local file paths (one or more). png/jpeg/webp/gif/pdf, each ≤ 35MB,
|
|
438
|
-
* and ≤
|
|
535
|
+
* and ≤ 90MB of file content per request. An image is 1 page, a PDF is its page
|
|
439
536
|
* count; the total must be ≤ 50 pages. For more files than one request can hold,
|
|
440
537
|
* use `submitAll` / `convertAll`.
|
|
441
|
-
* @returns A `Job` with status `pending
|
|
538
|
+
* @returns A `Job` with status `pending` (for a replay: the job's current status),
|
|
539
|
+
* plus `slideCount` and `creditsReserved`.
|
|
442
540
|
* @throws InvalidFileError Including the local `PAYLOAD_TOO_LARGE` pre-flight failure.
|
|
541
|
+
* @throws InvalidPagesError / PagesOutOfRangeError / TooManySlidesError A selection
|
|
542
|
+
* that is certain to be refused, before anything is sent.
|
|
543
|
+
* @throws IdempotencyKeyMismatchError The key was used for a different request.
|
|
544
|
+
* @throws APIConnectionError / ServerError / IdempotencyKeyInProgressError Once the
|
|
545
|
+
* retries above run out.
|
|
443
546
|
*/
|
|
444
547
|
async submit(paths, options = {}) {
|
|
445
|
-
|
|
548
|
+
const list = stringList(paths, "paths");
|
|
549
|
+
if (!list.length) {
|
|
446
550
|
throw new Error("at least one file is required");
|
|
447
551
|
}
|
|
448
|
-
|
|
552
|
+
const key = resolveIdempotencyKey(options.idempotencyKey);
|
|
553
|
+
const fields = submissionFields(options);
|
|
554
|
+
// The spelling needs no files: refuse a typo before compressing anything. Range
|
|
555
|
+
// and count wait for the page total, so the error is the one the service would
|
|
556
|
+
// give.
|
|
557
|
+
if (fields.pages !== undefined)
|
|
558
|
+
parsePages(fields.pages);
|
|
559
|
+
const files = await this.#prepareFiles(list);
|
|
560
|
+
checkPrepared(files, fields.pages);
|
|
561
|
+
return this.#submitWithKey(() => this.#postFiles(files, fields, key), key);
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Create a conversion job from files the service downloads itself.
|
|
565
|
+
*
|
|
566
|
+
* Instead of uploading, hand over `https` links; the service fetches them in order
|
|
567
|
+
* and converts them as one deck. The downloaded files are held to the same limits
|
|
568
|
+
* as uploads (35MB each, 90MB together, 50 pages). File types are judged by
|
|
569
|
+
* content, not by the link or its `Content-Type`.
|
|
570
|
+
*
|
|
571
|
+
* Downloading takes time — up to 60 seconds a link and 120 for the request — so
|
|
572
|
+
* this call waits at least 180 seconds for an answer, whatever the client's
|
|
573
|
+
* `timeoutMs`. Retries and `idempotencyKey` work exactly as in `submit`. Only one
|
|
574
|
+
* submission by URL per account can be in flight at a time; another gets a
|
|
575
|
+
* `RateLimitedError`.
|
|
576
|
+
*
|
|
577
|
+
* @param urls 1–50 `https` links. Each must resolve to a public address.
|
|
578
|
+
* @param options As `submit`. With links the page total is not known here, so
|
|
579
|
+
* `pages` is only checked for spelling and for selecting more than 50 pages.
|
|
580
|
+
* @throws InvalidUrlError A link was refused before downloading.
|
|
581
|
+
* @throws UrlFetchFailedError A download failed; may be temporary. For these two —
|
|
582
|
+
* and `InvalidFileError` about a downloaded file — `err.index` says which link,
|
|
583
|
+
* counting from 0.
|
|
584
|
+
* @throws InvalidParameterError More than 50 links, thrown locally.
|
|
585
|
+
*/
|
|
586
|
+
async submitUrls(urls, options = {}) {
|
|
587
|
+
const list = stringList(urls, "urls");
|
|
588
|
+
if (!list.length) {
|
|
589
|
+
throw new Error("at least one URL is required");
|
|
590
|
+
}
|
|
591
|
+
if (list.length > MAX_URLS) {
|
|
592
|
+
throw new InvalidParameterError(`${list.length} URLs in one submission, over the ${MAX_URLS} allowed`, { code: "INVALID_PARAMETER" });
|
|
593
|
+
}
|
|
594
|
+
const key = resolveIdempotencyKey(options.idempotencyKey);
|
|
595
|
+
const fields = submissionFields(options);
|
|
596
|
+
checkPageSelection(fields.pages);
|
|
597
|
+
const body = Buffer.from(JSON.stringify({ urls: list, ...fields }), "utf8");
|
|
598
|
+
return this.#submitWithKey(() => this.#request("POST", "/api/v1/jobs", {
|
|
599
|
+
body: () => ({
|
|
600
|
+
body,
|
|
601
|
+
contentType: "application/json",
|
|
602
|
+
contentLength: body.byteLength,
|
|
603
|
+
}),
|
|
604
|
+
headers: { "Idempotency-Key": key },
|
|
605
|
+
timeoutMs: Math.max(this.timeoutMs, URL_SUBMIT_MIN_TIMEOUT_MS),
|
|
606
|
+
consume: (res) => this.#parseSubmission(res),
|
|
607
|
+
}), key);
|
|
449
608
|
}
|
|
450
609
|
/**
|
|
451
610
|
* Split files into submittable batches and create **one job per batch**.
|
|
452
611
|
*
|
|
453
612
|
* For a pile of files too big or too numerous for a single request. Batching
|
|
454
|
-
* rules live in `planBatches`: at most
|
|
613
|
+
* rules live in `planBatches`: at most 80MB of file content and at most 50
|
|
455
614
|
* images per batch, and every PDF in a batch of its own (the SDK does not parse
|
|
456
615
|
* PDFs, so only the server knows their page count). Input order is preserved.
|
|
457
616
|
*
|
|
@@ -466,8 +625,11 @@ export class Image2PPTClient {
|
|
|
466
625
|
* the same batch again. Waiting is the normal path here. Total waiting is capped
|
|
467
626
|
* by the client's `rateLimitMaxWaitMs`.
|
|
468
627
|
*
|
|
469
|
-
* **
|
|
470
|
-
*
|
|
628
|
+
* **Each batch is its own submission with its own `Idempotency-Key`**, and is
|
|
629
|
+
* retried exactly as `submit` retries — plus the 429s above, with the same key.
|
|
630
|
+
* There is no `pages` or `idempotencyKey` option here: page numbers run across one
|
|
631
|
+
* submission and a key names one job, so neither can span several batches. Submit
|
|
632
|
+
* batches yourself with `submit` if you need them.
|
|
471
633
|
*
|
|
472
634
|
* **If it does give up, the jobs already created are handed back on the error**,
|
|
473
635
|
* in `err.submittedJobs`. Those jobs are running on the server with credits
|
|
@@ -480,10 +642,12 @@ export class Image2PPTClient {
|
|
|
480
642
|
* @throws RateLimitedError Still rate limited after `rateLimitMaxWaitMs`.
|
|
481
643
|
*/
|
|
482
644
|
async submitAll(paths, options = {}) {
|
|
483
|
-
|
|
645
|
+
const list = stringList(paths, "paths");
|
|
646
|
+
if (!list.length) {
|
|
484
647
|
throw new Error("at least one file is required");
|
|
485
648
|
}
|
|
486
|
-
const
|
|
649
|
+
const fields = batchFields(options);
|
|
650
|
+
const files = await this.#prepareFiles(list);
|
|
487
651
|
const batches = planBatches(files.map((file) => ({
|
|
488
652
|
path: file.path,
|
|
489
653
|
size: file.size,
|
|
@@ -494,7 +658,7 @@ export class Image2PPTClient {
|
|
|
494
658
|
const jobs = [];
|
|
495
659
|
for (const batch of batches) {
|
|
496
660
|
try {
|
|
497
|
-
jobs.push(await this.#submitBatch(batch.map((item) => item.file),
|
|
661
|
+
jobs.push(await this.#submitBatch(batch.map((item) => item.file), fields, budget));
|
|
498
662
|
}
|
|
499
663
|
catch (err) {
|
|
500
664
|
// Whatever went wrong, the earlier batches are already jobs on the server
|
|
@@ -541,7 +705,8 @@ export class Image2PPTClient {
|
|
|
541
705
|
const timeout = options.timeoutMs ?? 1_800_000;
|
|
542
706
|
// Monotonic clock so a system-time jump can't skew the deadline (mirrors the
|
|
543
707
|
// Python client's time.monotonic()).
|
|
544
|
-
const
|
|
708
|
+
const started = performance.now();
|
|
709
|
+
const deadline = started + timeout;
|
|
545
710
|
let interval = pollInterval;
|
|
546
711
|
for (;;) {
|
|
547
712
|
let job;
|
|
@@ -572,6 +737,7 @@ export class Image2PPTClient {
|
|
|
572
737
|
interval = Math.min(interval * 1.5, 15_000);
|
|
573
738
|
continue;
|
|
574
739
|
}
|
|
740
|
+
await options.onProgress?.(job, performance.now() - started);
|
|
575
741
|
if (job.isCompleted)
|
|
576
742
|
return job;
|
|
577
743
|
if (job.isFailed) {
|
|
@@ -663,15 +829,32 @@ export class Image2PPTClient {
|
|
|
663
829
|
/**
|
|
664
830
|
* One-shot: submit → wait for completion → download to `destPath`.
|
|
665
831
|
*
|
|
666
|
-
* One job, one PPTX — the files must fit in a single submission (
|
|
832
|
+
* One job, one PPTX — the files must fit in a single submission (90MB of file
|
|
667
833
|
* content, 50 pages). For more than that, `convertAll` splits the pile and writes
|
|
668
834
|
* one PPTX per batch.
|
|
669
835
|
*/
|
|
670
836
|
async convert(paths, destPath, options = {}) {
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
837
|
+
// Resolved here, not in submit: a wait or download that fails after the job
|
|
838
|
+
// exists must hand back the key too, or retrying convert() would create and pay
|
|
839
|
+
// for a second job.
|
|
840
|
+
const idempotencyKey = resolveIdempotencyKey(options.idempotencyKey);
|
|
841
|
+
const job = await this.submit(paths, { ...options, idempotencyKey });
|
|
842
|
+
try {
|
|
843
|
+
const completed = await this.wait(job.jobId, options);
|
|
844
|
+
await this.download(completed.jobId, destPath);
|
|
845
|
+
return completed;
|
|
846
|
+
}
|
|
847
|
+
catch (err) {
|
|
848
|
+
if (typeof err === "object" && err !== null) {
|
|
849
|
+
try {
|
|
850
|
+
err.idempotencyKey = idempotencyKey;
|
|
851
|
+
}
|
|
852
|
+
catch {
|
|
853
|
+
// a frozen error object: keep it as it is rather than replace it
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
throw err;
|
|
857
|
+
}
|
|
675
858
|
}
|
|
676
859
|
/**
|
|
677
860
|
* Batch version of `convert`: submit everything, wait, download each deck.
|
|
@@ -698,6 +881,9 @@ export class Image2PPTClient {
|
|
|
698
881
|
* @returns The written file paths, in batch order.
|
|
699
882
|
*/
|
|
700
883
|
async convertAll(paths, destDir, options = {}) {
|
|
884
|
+
// Refused before the destination is even created: a plain-JS caller passing
|
|
885
|
+
// `pages` or `idempotencyKey` gets the answer before anything touches the disk.
|
|
886
|
+
batchFields(options);
|
|
701
887
|
// Before anything is submitted: if the destination is unusable, fail now
|
|
702
888
|
// rather than after N jobs exist with credits reserved and nowhere to put
|
|
703
889
|
// their output. This is the one step that can fail for free.
|
|
@@ -720,6 +906,55 @@ export class Image2PPTClient {
|
|
|
720
906
|
}
|
|
721
907
|
return written;
|
|
722
908
|
}
|
|
909
|
+
/**
|
|
910
|
+
* One page of the jobs this account submitted through the API, newest first.
|
|
911
|
+
*
|
|
912
|
+
* Jobs deleted on the website are not listed. Each job has `getJob`'s shape minus
|
|
913
|
+
* `pageResults`; for reconciliation, add up `creditsUsed` and `creditsRefunded`.
|
|
914
|
+
* To walk every page, use `iterJobs`.
|
|
915
|
+
*
|
|
916
|
+
* @throws InvalidParameterError A parameter is invalid; `message` says which.
|
|
917
|
+
*/
|
|
918
|
+
async listJobs(options = {}) {
|
|
919
|
+
const params = new URLSearchParams();
|
|
920
|
+
for (const [name, value] of [
|
|
921
|
+
["createdFrom", options.createdFrom],
|
|
922
|
+
["createdTo", options.createdTo],
|
|
923
|
+
["limit", options.limit],
|
|
924
|
+
["cursor", options.cursor],
|
|
925
|
+
]) {
|
|
926
|
+
// null/undefined and "" all mean "not given"; everything else goes as given,
|
|
927
|
+
// so the service is the one to judge it — `limit: 0` included.
|
|
928
|
+
if (value != null && value !== "")
|
|
929
|
+
params.set(name, String(value));
|
|
930
|
+
}
|
|
931
|
+
const query = params.toString();
|
|
932
|
+
return new JobList(await this.#request("GET", `/api/v1/jobs${query ? `?${query}` : ""}`, {
|
|
933
|
+
consume: (res) => this.#parseJson(res),
|
|
934
|
+
}));
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* Every job `listJobs` would list, following `nextCursor` to the end.
|
|
938
|
+
*
|
|
939
|
+
* `limit` is the page size, not a cap on how many jobs come back. A 429 is waited
|
|
940
|
+
* out (`Retry-After`, else 5s), and a page that fails in a way worth repeating
|
|
941
|
+
* (`isTransient`) is fetched again after a short backoff — at most 10 attempts per
|
|
942
|
+
* page before the error is thrown.
|
|
943
|
+
*
|
|
944
|
+
* ```ts
|
|
945
|
+
* for await (const job of client.iterJobs({ createdFrom: "2026-10-01" })) { ... }
|
|
946
|
+
* ```
|
|
947
|
+
*/
|
|
948
|
+
async *iterJobs(options = {}) {
|
|
949
|
+
let cursor;
|
|
950
|
+
for (;;) {
|
|
951
|
+
const page = await this.#listPageWithRetries({ ...options, cursor });
|
|
952
|
+
yield* page.data;
|
|
953
|
+
if (page.nextCursor === null)
|
|
954
|
+
return;
|
|
955
|
+
cursor = page.nextCursor;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
723
958
|
/** Return account info: `{ email, credits }` (available credits). */
|
|
724
959
|
async account() {
|
|
725
960
|
return (await this.#request("GET", "/api/v1/account", {
|
|
@@ -728,17 +963,21 @@ export class Image2PPTClient {
|
|
|
728
963
|
}
|
|
729
964
|
// ----- internals --------------------------------------------------- //
|
|
730
965
|
/**
|
|
731
|
-
* Submit one batch, waiting out rate limits
|
|
966
|
+
* Submit one batch, waiting out rate limits while `budget` allows.
|
|
732
967
|
*
|
|
733
|
-
*
|
|
734
|
-
*
|
|
735
|
-
*
|
|
968
|
+
* The batch was prepared (images compressed) once, in `submitAll`, and gets its
|
|
969
|
+
* `Idempotency-Key` once, here, before the loop — so every attempt sends the very
|
|
970
|
+
* same request under the same key, which is what lets the service recognise a
|
|
971
|
+
* resend.
|
|
736
972
|
*
|
|
737
|
-
*
|
|
738
|
-
*
|
|
739
|
-
*
|
|
973
|
+
* A 429 is the server saying it did *not* take the submission — nothing was
|
|
974
|
+
* created and nothing charged — so trying the same batch again is free. Both
|
|
975
|
+
* flavors (per-minute page quota, concurrent-job cap) carry a `Retry-After` and are
|
|
976
|
+
* handled identically; when the header is missing we fall back to a fixed wait.
|
|
977
|
+
* Other failures are retried inside `#submitWithKey`.
|
|
740
978
|
*/
|
|
741
|
-
async #submitBatch(files,
|
|
979
|
+
async #submitBatch(files, fields, budget) {
|
|
980
|
+
const key = resolveIdempotencyKey(undefined);
|
|
742
981
|
// Two things stop this: the shared waiting `budget`, and MAX_BATCH_ATTEMPTS. The
|
|
743
982
|
// budget bounds time spent waiting; the attempt count bounds the uploads, which
|
|
744
983
|
// the budget cannot see — a server answering `Retry-After: 1` forever costs almost
|
|
@@ -746,17 +985,108 @@ export class Image2PPTClient {
|
|
|
746
985
|
let attemptsLeft = MAX_BATCH_ATTEMPTS;
|
|
747
986
|
for (;;) {
|
|
748
987
|
try {
|
|
749
|
-
return await this.#
|
|
988
|
+
return await this.#submitWithKey(() => this.#postFiles(files, fields, key), key);
|
|
750
989
|
}
|
|
751
990
|
catch (err) {
|
|
752
991
|
if (!(err instanceof RateLimitedError))
|
|
753
992
|
throw err;
|
|
754
993
|
attemptsLeft -= 1;
|
|
755
|
-
const delay = err.retryAfter != null ? err.retryAfter * 1000 : RATE_LIMIT_FALLBACK_WAIT_MS;
|
|
756
994
|
// On the last attempt, do not wait first: nothing follows the wait, so it
|
|
757
995
|
// would only delay the error the caller is already getting.
|
|
758
|
-
if (attemptsLeft <= 0 || !(await budget.spend(
|
|
996
|
+
if (attemptsLeft <= 0 || !(await budget.spend(rateLimitDelayMs(err))))
|
|
997
|
+
throw err;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* Send one submission until its outcome is known; see `submit` for the rules.
|
|
1003
|
+
*
|
|
1004
|
+
* `send` makes one attempt and must send the identical request every time, under
|
|
1005
|
+
* `key`. Whatever finally escapes carries `key` as `idempotencyKey`, so the caller
|
|
1006
|
+
* can resend safely.
|
|
1007
|
+
*
|
|
1008
|
+
* A 429 is left to the caller on purpose. By the contract, resending the same
|
|
1009
|
+
* request under a key whose job exists is answered with that job (and one still
|
|
1010
|
+
* being processed with "in progress"), so a 429 here still means nothing was taken.
|
|
1011
|
+
*/
|
|
1012
|
+
async #submitWithKey(send, key) {
|
|
1013
|
+
let retriesUsed = 0;
|
|
1014
|
+
let inProgressAttempts = 0;
|
|
1015
|
+
const inProgressBudget = new WaitBudget(IN_PROGRESS_MAX_WAIT_MS);
|
|
1016
|
+
try {
|
|
1017
|
+
for (let attempt = 1;; attempt += 1) {
|
|
1018
|
+
try {
|
|
1019
|
+
return await send();
|
|
1020
|
+
}
|
|
1021
|
+
catch (err) {
|
|
1022
|
+
if (err instanceof IdempotencyKeyInProgressError) {
|
|
1023
|
+
inProgressAttempts += 1;
|
|
1024
|
+
const firstMs = (err.retryAfter ?? 2) * 1000;
|
|
1025
|
+
const delay = Math.min(firstMs * 1.5 ** (inProgressAttempts - 1), IN_PROGRESS_WAIT_CAP_MS);
|
|
1026
|
+
if (inProgressAttempts >= IN_PROGRESS_MAX_ATTEMPTS ||
|
|
1027
|
+
!(await inProgressBudget.spend(delay))) {
|
|
1028
|
+
throw err;
|
|
1029
|
+
}
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
if (err instanceof APIConnectionError || err instanceof ServerError) {
|
|
1033
|
+
const delay = SUBMIT_RETRY_DELAYS_MS[retriesUsed];
|
|
1034
|
+
if (delay === undefined)
|
|
1035
|
+
throw err;
|
|
1036
|
+
retriesUsed += 1;
|
|
1037
|
+
await sleep(delay);
|
|
1038
|
+
continue;
|
|
1039
|
+
}
|
|
1040
|
+
if (err instanceof IdempotencyKeyMismatchError && attempt > 1) {
|
|
1041
|
+
// Only a request that created a job holds its key, so an earlier attempt
|
|
1042
|
+
// of this very call did — and the files changed on disk in between, or
|
|
1043
|
+
// this would have been a replay.
|
|
1044
|
+
err.message +=
|
|
1045
|
+
"; an earlier attempt of this call did create a job with this key before " +
|
|
1046
|
+
"the request changed (did a file change on disk?) — find it with listJobs()";
|
|
1047
|
+
}
|
|
1048
|
+
throw err;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
catch (err) {
|
|
1053
|
+
// Not only SDK errors: a PDF that vanished between attempts fails with a
|
|
1054
|
+
// filesystem error, and the first attempt may already have created the job.
|
|
1055
|
+
if (typeof err === "object" && err !== null) {
|
|
1056
|
+
try {
|
|
1057
|
+
err.idempotencyKey = key;
|
|
1058
|
+
}
|
|
1059
|
+
catch {
|
|
1060
|
+
// a frozen error object: keep it as it is rather than replace it
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
throw err;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
/**
|
|
1067
|
+
* One `listJobs` page for `iterJobs`, retried while that is worth it.
|
|
1068
|
+
*
|
|
1069
|
+
* Listing is a read, so repeating it is free: a 429 waits `Retry-After` and anything
|
|
1070
|
+
* `isTransient` backs off, both within `MAX_BATCH_ATTEMPTS`.
|
|
1071
|
+
*/
|
|
1072
|
+
async #listPageWithRetries(options) {
|
|
1073
|
+
let backoff = 1_000;
|
|
1074
|
+
for (let attempt = 1;; attempt += 1) {
|
|
1075
|
+
try {
|
|
1076
|
+
return await this.listJobs(options);
|
|
1077
|
+
}
|
|
1078
|
+
catch (err) {
|
|
1079
|
+
if (attempt >= MAX_BATCH_ATTEMPTS)
|
|
1080
|
+
throw err;
|
|
1081
|
+
if (!(err instanceof Image2PPTError) || !err.isTransient)
|
|
759
1082
|
throw err;
|
|
1083
|
+
if (err instanceof RateLimitedError) {
|
|
1084
|
+
await sleep(rateLimitDelayMs(err));
|
|
1085
|
+
}
|
|
1086
|
+
else {
|
|
1087
|
+
await sleep(backoff);
|
|
1088
|
+
backoff = Math.min(backoff * 2, 15_000);
|
|
1089
|
+
}
|
|
760
1090
|
}
|
|
761
1091
|
}
|
|
762
1092
|
}
|
|
@@ -826,18 +1156,26 @@ export class Image2PPTClient {
|
|
|
826
1156
|
isImage: true,
|
|
827
1157
|
};
|
|
828
1158
|
}
|
|
829
|
-
/**
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
1159
|
+
/**
|
|
1160
|
+
* POST the multipart submission once, under `key`.
|
|
1161
|
+
*
|
|
1162
|
+
* PDFs are reopened and streamed from disk on every attempt; images go from the
|
|
1163
|
+
* bytes compressed once in `#prepareFile`, so a resend carries the same image bytes
|
|
1164
|
+
* as the first attempt.
|
|
1165
|
+
*/
|
|
1166
|
+
async #postFiles(files, fields, key) {
|
|
1167
|
+
return this.#request("POST", "/api/v1/jobs", {
|
|
837
1168
|
// Rebuilt per attempt: a retry needs a body that has not been consumed.
|
|
838
|
-
body: (watchdog) => buildMultipart(files,
|
|
839
|
-
|
|
840
|
-
|
|
1169
|
+
body: (watchdog) => buildMultipart(files, fields, () => watchdog.kick()),
|
|
1170
|
+
headers: { "Idempotency-Key": key },
|
|
1171
|
+
consume: (res) => this.#parseSubmission(res),
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
/** A submission's answer: the job, marked when the service replayed it. */
|
|
1175
|
+
async #parseSubmission(res) {
|
|
1176
|
+
const job = Job.fromJson(await this.#parseJson(res));
|
|
1177
|
+
job.replayed = headerIsTrue(res.headers.get("Idempotent-Replayed"));
|
|
1178
|
+
return job;
|
|
841
1179
|
}
|
|
842
1180
|
/**
|
|
843
1181
|
* Make one HTTP request and consume its response, under an idle watchdog.
|
|
@@ -864,7 +1202,8 @@ export class Image2PPTClient {
|
|
|
864
1202
|
* `isTransient`. See `readingBody`.
|
|
865
1203
|
*/
|
|
866
1204
|
async #request(method, path, options) {
|
|
867
|
-
const
|
|
1205
|
+
const timeoutMs = options.timeoutMs ?? this.timeoutMs;
|
|
1206
|
+
const watchdog = new IdleWatchdog(timeoutMs, `request to ${path} went ${timeoutMs}ms with no data moving ` +
|
|
868
1207
|
"(timeoutMs is an idle timeout, not a limit on how long a transfer may take)");
|
|
869
1208
|
try {
|
|
870
1209
|
const payload = options.body?.(watchdog);
|
|
@@ -876,6 +1215,7 @@ export class Image2PPTClient {
|
|
|
876
1215
|
Authorization: `Bearer ${this.#apiKey}`,
|
|
877
1216
|
"User-Agent": USER_AGENT,
|
|
878
1217
|
...(this.acceptLanguage ? { "Accept-Language": this.acceptLanguage } : {}),
|
|
1218
|
+
...options.headers,
|
|
879
1219
|
...(payload
|
|
880
1220
|
? {
|
|
881
1221
|
"Content-Type": payload.contentType,
|
|
@@ -1019,11 +1359,18 @@ export class Image2PPTClient {
|
|
|
1019
1359
|
async #raiseForError(res) {
|
|
1020
1360
|
let code;
|
|
1021
1361
|
let message;
|
|
1362
|
+
let index;
|
|
1022
1363
|
try {
|
|
1023
1364
|
const body = (await res.json());
|
|
1024
1365
|
if (body && typeof body === "object" && body.error) {
|
|
1025
1366
|
code = body.error.code;
|
|
1026
1367
|
message = body.error.message;
|
|
1368
|
+
// A whole, non-negative number only: `3.0` is 3, `"1"`, `true`, `-1` and
|
|
1369
|
+
// `1.5` are no index at all — the same reading as the Python client.
|
|
1370
|
+
const raw = body.error.index;
|
|
1371
|
+
const whole = wholeNumber(raw);
|
|
1372
|
+
if (whole !== null && whole >= 0)
|
|
1373
|
+
index = whole;
|
|
1027
1374
|
}
|
|
1028
1375
|
}
|
|
1029
1376
|
catch (err) {
|
|
@@ -1040,6 +1387,7 @@ export class Image2PPTClient {
|
|
|
1040
1387
|
code,
|
|
1041
1388
|
message: message ?? `request failed (HTTP ${res.status})`,
|
|
1042
1389
|
retryAfter: parseRetryAfter(res.headers.get("Retry-After")),
|
|
1390
|
+
index,
|
|
1043
1391
|
});
|
|
1044
1392
|
}
|
|
1045
1393
|
/**
|
|
@@ -1057,6 +1405,36 @@ export class Image2PPTClient {
|
|
|
1057
1405
|
await sleep(Math.min(ms, remaining, MAX_SLEEP_MS));
|
|
1058
1406
|
}
|
|
1059
1407
|
}
|
|
1408
|
+
/**
|
|
1409
|
+
* Pre-flight, before a single byte goes out: an oversized request is not answered
|
|
1410
|
+
* with an error, it is cut off — so it must never be sent.
|
|
1411
|
+
*/
|
|
1412
|
+
function checkPrepared(files, pages) {
|
|
1413
|
+
// After image preparation, because these are the exact bytes that will be
|
|
1414
|
+
// transmitted, not the potentially much larger source files.
|
|
1415
|
+
for (const file of files)
|
|
1416
|
+
checkFileSize(file.path, file.size);
|
|
1417
|
+
checkSubmission(files.reduce((total, file) => total + file.size, 0), files.filter((file) => file.isImage).length,
|
|
1418
|
+
// A PDF's real page count is only known server-side; counting it as at least 1
|
|
1419
|
+
// is what stops "50 images + a PDF" from being sent as a submission that is
|
|
1420
|
+
// certain to come back over the page limit.
|
|
1421
|
+
files.filter((file) => !file.isImage).length, pages);
|
|
1422
|
+
}
|
|
1423
|
+
/**
|
|
1424
|
+
* The fields for `submitAll` / `convertAll`, refusing the two options that cannot
|
|
1425
|
+
* span several batches. The types already leave them out; this is for plain
|
|
1426
|
+
* JavaScript, where a silently ignored `pages` would convert pages nobody asked for.
|
|
1427
|
+
*/
|
|
1428
|
+
function batchFields(options) {
|
|
1429
|
+
const extra = options;
|
|
1430
|
+
for (const name of ["pages", "idempotencyKey"]) {
|
|
1431
|
+
if (extra[name] !== undefined) {
|
|
1432
|
+
throw new TypeError(`${name} is not supported by submitAll / convertAll: it cannot span several ` +
|
|
1433
|
+
"batches. Use submit() for one submission");
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
return submissionFields(options);
|
|
1437
|
+
}
|
|
1060
1438
|
/** Pull the URL out of a `Link: <url>; rel=...` header, or null. */
|
|
1061
1439
|
function linkUrl(value) {
|
|
1062
1440
|
if (!value)
|