image2ppt 0.5.3

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/client.js ADDED
@@ -0,0 +1,1134 @@
1
+ /** The image2ppt API client. */
2
+ import { randomUUID } from "node:crypto";
3
+ import { createReadStream } from "node:fs";
4
+ import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
5
+ import { basename, dirname, extname, join } from "node:path";
6
+ import { Readable } from "node:stream";
7
+ // Imported from `node:timers` rather than taken off the global, and that is
8
+ // load-bearing: the test suite spies on `globalThis.setTimeout` to assert on the
9
+ // delays this client *waits* for (retry backoff, poll intervals). The idle
10
+ // watchdog below is not one of those waits — it is an internal deadline that
11
+ // never sleeps the caller — so it must stay out of that reckoning.
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";
14
+ import { compressImageForUpload } from "./compress.js";
15
+ import { checkFileSize, checkSubmission, planBatches } from "./limits.js";
16
+ import { Job, parseCancellationResult } from "./types.js";
17
+ import { VERSION } from "./version.js";
18
+ export const DEFAULT_BASE_URL = "https://image2ppt.com";
19
+ /**
20
+ * Sent on every request so the service knows which client version made it.
21
+ *
22
+ * The whole header has to be exactly this string: appending another product token
23
+ * means the request is no longer recognised as coming from an official SDK. It is
24
+ * not part of authentication and never changes the outcome of a request. Built from
25
+ * `VERSION`, which a test keeps in step with `package.json`.
26
+ */
27
+ const USER_AGENT = `image2ppt-node/${VERSION}`;
28
+ /** Wait between rate-limited retries when the server sends no `Retry-After`. */
29
+ const RATE_LIMIT_FALLBACK_WAIT_MS = 5_000;
30
+ /**
31
+ * Floor for a server-sent `Retry-After`, in seconds. `Retry-After: 0` is a legal
32
+ * value meaning "retry now", and a proxy can even send a negative one; taken
33
+ * literally either turns every retry loop in this client into a tight loop that
34
+ * re-sends the same multipart body as fast as the link allows — for up to
35
+ * `rateLimitMaxWaitMs`, with tens of megabytes of files on each pass. A floor makes
36
+ * a retry a retry instead of a flood, and costs nothing when the server means it.
37
+ */
38
+ const MIN_RETRY_AFTER_SECONDS = 1;
39
+ /** How many images are read and re-encoded at the same time. See `#prepareFiles`. */
40
+ const PREPARE_CONCURRENCY = 4;
41
+ /**
42
+ * How much of an upload body is handed to the runtime at a time.
43
+ *
44
+ * This is what makes `timeoutMs` mean what it says on the upload side. Progress is
45
+ * reported once per piece, so a piece is also the longest an upload can go without
46
+ * reporting any — and while a piece is being written to the socket, a slow link
47
+ * says nothing at all. A whole image handed over in one go therefore looked idle
48
+ * for the entire time it was being sent, and a healthy-but-slow upload was cut off
49
+ * mid-transfer. 64KiB is the size a file is read from disk in, so every part of a
50
+ * body now moves in the same units, whether it was buffered in memory or streamed.
51
+ */
52
+ const UPLOAD_SLICE_BYTES = 64 * 1024;
53
+ const MIME_BY_EXT = {
54
+ ".png": "image/png",
55
+ ".jpg": "image/jpeg",
56
+ ".jpeg": "image/jpeg",
57
+ ".webp": "image/webp",
58
+ ".gif": "image/gif",
59
+ ".pdf": "application/pdf",
60
+ };
61
+ /** Formats that count as exactly one page each. Anything else (PDF) is unknown. */
62
+ const IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
63
+ function multipartFilename(name) {
64
+ // A filename is multipart header data, not an arbitrary byte channel. Percent-encode
65
+ // the three characters that could break out of the quoted string, which is exactly
66
+ // what Node's built-in FormData did before this client assembled the body itself.
67
+ // Backslash-escaping instead would be ambiguous: a filename may legally contain a
68
+ // backslash, and a parser that un-escapes would then swallow the closing quote.
69
+ return name.replace(/\r/g, "%0D").replace(/\n/g, "%0A").replace(/"/g, "%22");
70
+ }
71
+ /** `actual` below zero means the file could no longer be read at all. */
72
+ function fileChanged(path, measured, actual) {
73
+ return new Image2PPTError(`"${path}" changed while it was being uploaded: ${measured} bytes when it was ` +
74
+ `measured, ${actual < 0 ? "unreadable" : `${actual} bytes`} now`, { code: "FILE_CHANGED" });
75
+ }
76
+ /**
77
+ * Aborts a request that has gone `timeoutMs` with **no data moving** either way.
78
+ *
79
+ * `timeoutMs` used to bound the whole request, which meant a 40MB upload or a
80
+ * large PPTX download was killed at 60 seconds however healthy it was — a
81
+ * progressing transfer punished for being big. It is an idle timeout instead:
82
+ * `kick()` is called as each chunk of the request body is produced and as each
83
+ * chunk of the response arrives, and only a real stall reaches the deadline. That
84
+ * is what the Python client's read timeout has always meant, so the two now agree.
85
+ *
86
+ * A request that never gets a response at all is still covered: the clock starts
87
+ * when the watchdog is built and nothing kicks it.
88
+ *
89
+ * The abort reason is an `APITimeoutError` rather than a bare signal, and that is
90
+ * deliberate. `fetch` rejects with whatever the signal was aborted with, and the
91
+ * multipart body's own errors only reach the caller if they are `Image2PPTError`s
92
+ * — anything else degrades into an opaque `TypeError: fetch failed` on the way
93
+ * out, whichever end of the request it came from.
94
+ */
95
+ class IdleWatchdog {
96
+ #controller = new AbortController();
97
+ #timeoutMs;
98
+ #describe;
99
+ #timer;
100
+ #stopped = false;
101
+ constructor(timeoutMs, describe) {
102
+ this.#timeoutMs = timeoutMs;
103
+ this.#describe = describe;
104
+ this.kick();
105
+ }
106
+ get signal() {
107
+ return this.#controller.signal;
108
+ }
109
+ /** Data moved: start the clock over. */
110
+ kick() {
111
+ if (this.#stopped)
112
+ return;
113
+ if (this.#timer !== undefined)
114
+ clearTimer(this.#timer);
115
+ this.#timer = setTimer(() => {
116
+ this.#stopped = true;
117
+ this.#controller.abort(new APITimeoutError(this.#describe));
118
+ }, this.#timeoutMs);
119
+ // The watchdog must never be the reason a process stays alive: a caller who
120
+ // has stopped awaiting this request should not be held open by its timer.
121
+ this.#timer.unref?.();
122
+ }
123
+ /** The exchange is over — release the timer. Safe to call more than once. */
124
+ stop() {
125
+ this.#stopped = true;
126
+ if (this.#timer !== undefined)
127
+ clearTimer(this.#timer);
128
+ this.#timer = undefined;
129
+ }
130
+ }
131
+ function buildMultipart(files, options, onChunk) {
132
+ const boundary = `----image2ppt-${randomUUID()}`;
133
+ const chunk = (value) => Buffer.from(value, "utf8");
134
+ const fileHeader = (file) => `--${boundary}\r\n` +
135
+ `Content-Disposition: form-data; name="files"; filename="${multipartFilename(file.name)}"\r\n` +
136
+ `Content-Type: ${file.mime}\r\n\r\n`;
137
+ const field = (name, value) => `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`;
138
+ 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));
144
+ // The body streams, but its length is known before the first byte goes out: image
145
+ // payloads are already in memory, a PDF's size was measured while preparing it, and
146
+ // every delimiter is a fixed string. Sending `Content-Length` rather than letting
147
+ // the request go out chunked is what lets an oversized submission be refused up
148
+ // front instead of after tens of megabytes have already crossed the wire.
149
+ const contentLength = files.reduce((total, file) => total + Buffer.byteLength(fileHeader(file), "utf8") + file.size + 2, 0) +
150
+ fields.reduce((total, value) => total + Buffer.byteLength(value, "utf8"), 0) +
151
+ Buffer.byteLength(trailer, "utf8");
152
+ async function* chunks() {
153
+ // Every yield goes through here so the idle watchdog sees the upload moving.
154
+ // A body that stops being produced — or that the connection stops accepting —
155
+ // stops kicking, which is exactly when the request should be given up on.
156
+ const moving = (part) => {
157
+ onChunk();
158
+ return part;
159
+ };
160
+ for (const file of files) {
161
+ yield moving(chunk(fileHeader(file)));
162
+ if (file.buffer !== undefined) {
163
+ // Sliced rather than handed over whole, so an image reports progress as it
164
+ // goes out instead of once before it starts. `subarray` is a view, not a
165
+ // copy: the same bytes go on the wire and `Content-Length` is untouched.
166
+ for (let at = 0; at < file.buffer.byteLength; at += UPLOAD_SLICE_BYTES) {
167
+ yield moving(file.buffer.subarray(at, at + UPLOAD_SLICE_BYTES));
168
+ }
169
+ }
170
+ else if (file.size > 0) {
171
+ // PDFs are intentionally never buffered: retries create a fresh stream from
172
+ // disk while images retain their already-compressed payload. The read is
173
+ // capped at the size measured while preparing the file and then checked
174
+ // against it, because that size is what `Content-Length` and the pre-flight
175
+ // limits were both computed from. A document rewritten underneath us has to
176
+ // fail here rather than send a body that contradicts its own header.
177
+ let sent = 0;
178
+ for await (const part of createReadStream(file.path, {
179
+ end: file.size - 1,
180
+ highWaterMark: UPLOAD_SLICE_BYTES,
181
+ })) {
182
+ sent += part.byteLength;
183
+ yield moving(Buffer.from(part));
184
+ }
185
+ // A file that shrank comes up short right here. One that grew would have been
186
+ // cut off at the cap instead — the byte count still matches, so the size on
187
+ // disk has to be checked once more to catch it. Either way the caller must
188
+ // hear about it: a truncated document is a deck they paid credits for and
189
+ // cannot use.
190
+ if (sent !== file.size)
191
+ throw fileChanged(file.path, file.size, sent);
192
+ // Deleted mid-upload counts as changed too — a raw ENOENT here would escape
193
+ // the unwrap in `#request` and reach the caller as a bare "fetch failed".
194
+ const sizeAfter = await stat(file.path).then((stats) => stats.size, () => -1);
195
+ if (sizeAfter !== file.size)
196
+ throw fileChanged(file.path, file.size, sizeAfter);
197
+ }
198
+ yield moving(chunk("\r\n"));
199
+ }
200
+ for (const value of fields)
201
+ yield moving(chunk(value));
202
+ yield moving(chunk(trailer));
203
+ }
204
+ const source = Readable.from(chunks());
205
+ // The body can refuse to finish — a file rewritten underneath the upload. That
206
+ // error reaches the caller through `fetch`, which rejects with it as the `cause`.
207
+ // The stream reports it a second time on its own, though, and an error event with
208
+ // no listener is an unhandled rejection: on a default Node setup that takes the
209
+ // caller's whole process down over a failure the SDK is already reporting properly.
210
+ source.on("error", () => { });
211
+ return {
212
+ body: Readable.toWeb(source),
213
+ contentType: `multipart/form-data; boundary=${boundary}`,
214
+ contentLength,
215
+ };
216
+ }
217
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
218
+ /**
219
+ * The real reason behind an opaque transport error.
220
+ *
221
+ * undici reports every connection failure — refused, reset, DNS, TLS — as the same
222
+ * `TypeError: fetch failed`, and puts what actually happened in `cause`. On its own
223
+ * the top-level message tells a caller nothing, so the chain is walked and the
224
+ * innermost description is what gets reported.
225
+ */
226
+ function describeCause(err) {
227
+ const seen = new Set();
228
+ let current = err;
229
+ let described = String(err);
230
+ while (current instanceof Error && !seen.has(current)) {
231
+ seen.add(current);
232
+ described = current.message || described;
233
+ if (current.cause === undefined)
234
+ break;
235
+ current = current.cause;
236
+ }
237
+ return described;
238
+ }
239
+ /** A download that stopped arriving. Never a verdict on the file already written. */
240
+ function downloadCutOff(err, jobId) {
241
+ // The watchdog's own abort is already the error worth reporting.
242
+ if (err instanceof Image2PPTError)
243
+ return err;
244
+ return new APIConnectionError(`download of job ${jobId} was cut off: ${describeCause(err)}`, { cause: err });
245
+ }
246
+ /**
247
+ * The response body, reporting progress and owning its failures.
248
+ *
249
+ * A download can fail at either end, and the two call for opposite answers: a body
250
+ * that stopped arriving is worth another try, a disk that is full or unwritable is
251
+ * not. So the two must never be told apart by guessing — which is what the
252
+ * `pipeline` this replaced forced on the client. `pipeline` ties its ends together
253
+ * by design, destroying the source with whatever the destination raised, so the
254
+ * listener watching for a dropped connection was handed the disk's error and
255
+ * wrapped it as a transport failure. Ordering could not break the tie either:
256
+ * which end reports first differs across the Node versions this package supports,
257
+ * and getting off that dependency was the point of the fix before this one.
258
+ *
259
+ * Wrapping the read side *here*, and letting `writeFile` own the write side, keeps
260
+ * them apart by construction. Whatever the body throws becomes an
261
+ * `APIConnectionError` — a body consumed outside the `fetch` call has nothing else
262
+ * to wrap it. The disk's own error is never seen by this function at all, so it
263
+ * reaches the caller as it happened: `ENOSPC`, `EACCES`, `ENOENT`, naming the
264
+ * problem they have to go and fix. None of this SDK's errors would say anything
265
+ * truer than the operating system already did. The Python client has always ended
266
+ * up in the same place, though not by this route: its write sits *inside* the
267
+ * transport wrapper, and the disk still gets through because that wrapper only
268
+ * catches `requests` exceptions and an `OSError` is not one. Worth knowing before
269
+ * widening what it catches to keep the two "in sync" — that would undo there
270
+ * exactly what this function is here to do.
271
+ *
272
+ * `onChunk` is the idle watchdog's per-chunk signal, which is what lets a
273
+ * slow-but-moving download run as long as it needs to. A *stalled* disk is still
274
+ * not told apart from a stalled connection, the same as before this rewrite: the
275
+ * watchdog is only kicked when a body chunk is read, and the write is awaited
276
+ * before the next one is pulled, so a destination that has stopped accepting bytes
277
+ * without failing eventually trips the idle timeout and the message talks about the
278
+ * request rather than the disk. Rare, and the transfer really has stopped either
279
+ * way, but the reason it names may be the wrong one.
280
+ */
281
+ async function* readingBody(body, jobId, onChunk) {
282
+ try {
283
+ for await (const chunk of body) {
284
+ onChunk();
285
+ yield chunk;
286
+ }
287
+ }
288
+ catch (err) {
289
+ throw downloadCutOff(err, jobId);
290
+ }
291
+ }
292
+ /**
293
+ * MIME type for a supported extension; refuse anything else locally.
294
+ *
295
+ * `MIME_BY_EXT` is what the API accepts. Falling back to a generic type meant a
296
+ * `.txt` or `.docx` was treated as PDF-like, given a batch of its own and uploaded,
297
+ * only to come back `INVALID_FILE` — and in `submitAll` the batches ahead of it were
298
+ * already jobs with credits reserved. The supported set is known locally, so this is
299
+ * a failure that can cost nothing.
300
+ *
301
+ * The trade-off is deliberate: a format the service starts accepting is refused here
302
+ * until this list is updated and released.
303
+ */
304
+ function guessMime(name) {
305
+ const mime = MIME_BY_EXT[extname(name).toLowerCase()];
306
+ if (mime === undefined) {
307
+ throw new InvalidFileError(`"${name}" is not a supported file type; this client accepts ` +
308
+ `${Object.keys(MIME_BY_EXT).sort().join(", ")}. Nothing was uploaded`, { code: "INVALID_FILE" });
309
+ }
310
+ return mime;
311
+ }
312
+ function isImageMime(mime) {
313
+ return IMAGE_MIMES.has(mime);
314
+ }
315
+ /**
316
+ * Create `destDir` if needed and prove a file can actually be written in it.
317
+ *
318
+ * Creating the directory is not enough on its own: when it already exists, a
319
+ * recursive `mkdir` succeeds no matter what the permissions are, so a read-only
320
+ * destination sails through and only fails later — after the jobs exist and the
321
+ * credits are spent.
322
+ *
323
+ * The proof is an actual file rather than a permission-bit check. Permission bits
324
+ * get it wrong in exactly the environments that need the answer: they ignore
325
+ * read-only mounts and ACLs, and running as root they report writable for
326
+ * directories nothing can be written to. Creating a real file is the same
327
+ * operation `download` will do a few seconds later, so it is the same answer.
328
+ *
329
+ * The probe has a random name and is opened with the `wx` flag, which fails if
330
+ * anything is already there. A predictable name opened for writing would follow —
331
+ * and truncate — whatever already sits at that path, including a symlink someone
332
+ * left in a shared output directory. Only the entry this call actually created is
333
+ * removed afterwards.
334
+ */
335
+ async function ensureWritableDir(destDir) {
336
+ await mkdir(destDir, { recursive: true });
337
+ const probe = join(destDir, `.image2ppt-write-test-${randomUUID()}`);
338
+ try {
339
+ await writeFile(probe, "", { flag: "wx" });
340
+ }
341
+ catch (err) {
342
+ throw new Error(`cannot write to destDir "${destDir}" (${err.code ?? err}); ` +
343
+ "nothing was submitted", { cause: err });
344
+ }
345
+ // Only reached when this call created it.
346
+ await rm(probe, { force: true }).catch(() => undefined);
347
+ }
348
+ /**
349
+ * Milliseconds left for waiting out rate limits — spent only by actual waiting.
350
+ *
351
+ * A wall-clock deadline fixed at the start of the call would be eaten by the uploads
352
+ * themselves: a large pile on a slow uplink can burn the whole allowance before the
353
+ * first 429 even arrives, and then `rateLimitMaxWaitMs` quietly means "do not wait at
354
+ * all" — with the cutoff depending on link speed rather than on anything the caller
355
+ * chose. The option promises time spent waiting, so only waiting takes from it.
356
+ */
357
+ class WaitBudget {
358
+ #remainingMs;
359
+ constructor(remainingMs) {
360
+ this.#remainingMs = remainingMs;
361
+ }
362
+ /** Wait `ms` if the budget covers it; resolve false if it does not. */
363
+ async spend(ms) {
364
+ if (ms > this.#remainingMs)
365
+ return false;
366
+ await sleep(ms);
367
+ this.#remainingMs -= ms;
368
+ return true;
369
+ }
370
+ }
371
+ /**
372
+ * Record the jobs created so far on an error escaping a batch call.
373
+ *
374
+ * Every `Image2PPTError` declares `submittedJobs`; this also reaches the rarer
375
+ * non-SDK escape (an exhausted network error), so the caller never has to know
376
+ * which kind they caught to find out what they already paid for.
377
+ */
378
+ function attachSubmittedJobs(err, jobs) {
379
+ if (typeof err === "object" && err !== null) {
380
+ err.submittedJobs = [...jobs];
381
+ }
382
+ }
383
+ /**
384
+ * Client for the image2ppt API. Server-side only — keep your key off the browser.
385
+ *
386
+ * ```ts
387
+ * const client = new Image2PPTClient({ apiKey: "i2p_live_..." });
388
+ * const job = await client.convert(["slide.png", "report.pdf"], "out.pptx");
389
+ * ```
390
+ */
391
+ export class Image2PPTClient {
392
+ baseUrl;
393
+ timeoutMs;
394
+ rateLimitMaxWaitMs;
395
+ warnOnDeprecated;
396
+ /**
397
+ * `Accept-Language` sent on every request, or undefined to send none.
398
+ *
399
+ * Sets the language of the service's error **messages** only — not the language
400
+ * of the deck, which is `SubmitOptions.locale`.
401
+ */
402
+ acceptLanguage;
403
+ #apiKey;
404
+ #fetch;
405
+ #deprecationWarned = false;
406
+ constructor(options) {
407
+ if (!options?.apiKey) {
408
+ throw new Error("apiKey is required");
409
+ }
410
+ this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
411
+ this.timeoutMs = options.timeoutMs ?? 60_000;
412
+ this.rateLimitMaxWaitMs = Math.max(0, options.rateLimitMaxWaitMs ?? 1_800_000);
413
+ this.warnOnDeprecated = options.warnOnDeprecated !== false;
414
+ this.acceptLanguage = options.acceptLanguage;
415
+ this.#apiKey = options.apiKey;
416
+ const impl = options.fetch ?? globalThis.fetch;
417
+ if (!impl) {
418
+ throw new Error("no global fetch found; pass options.fetch (Node 18+ has one)");
419
+ }
420
+ this.#fetch = impl;
421
+ }
422
+ /**
423
+ * Submit a batch of files and create a conversion job.
424
+ *
425
+ * Checked locally before anything is uploaded: the files must add up to at most
426
+ * 45MB and at most 50 pages. Over either limit this throws without opening a
427
+ * connection — going over the size cap on the wire does not come back as a clean
428
+ * error, it comes back as a dead connection.
429
+ *
430
+ * **A failed submission is never retried automatically.** A network error does
431
+ * not tell you whether the request body made it: the job may not exist, or it
432
+ * may exist with credits already reserved and only the response lost. Retrying
433
+ * the second case charges twice, and without an idempotency key there is no way
434
+ * to tell them apart — so the error is thrown as-is. Check `account()` or your
435
+ * job list before resending.
436
+ *
437
+ * @param paths Local file paths (one or more). png/jpeg/webp/gif/pdf, each ≤ 35MB,
438
+ * and ≤ 45MB of file content per request. An image is 1 page, a PDF is its page
439
+ * count; the total must be ≤ 50 pages. For more files than one request can hold,
440
+ * use `submitAll` / `convertAll`.
441
+ * @returns A `Job` with status `pending`, plus `slideCount` and `creditsReserved`.
442
+ * @throws InvalidFileError Including the local `PAYLOAD_TOO_LARGE` pre-flight failure.
443
+ */
444
+ async submit(paths, options = {}) {
445
+ if (!paths.length) {
446
+ throw new Error("at least one file is required");
447
+ }
448
+ return this.#submitPrepared(await this.#prepareFiles(paths), options);
449
+ }
450
+ /**
451
+ * Split files into submittable batches and create **one job per batch**.
452
+ *
453
+ * For a pile of files too big or too numerous for a single request. Batching
454
+ * rules live in `planBatches`: at most 40MB of file content and at most 50
455
+ * images per batch, and every PDF in a batch of its own (the SDK does not parse
456
+ * PDFs, so only the server knows their page count). Input order is preserved.
457
+ *
458
+ * **Each returned job produces its own PPTX.** There is no server-side merge —
459
+ * N batches means N decks. If you need exactly one deck, keep the submission
460
+ * inside one request's limits and use `convert`.
461
+ *
462
+ * **Rate limits are waited out, not thrown.** A pile big enough to need
463
+ * batching is a pile big enough to hit the account's per-minute page quota (and
464
+ * its cap on concurrently active jobs). Both arrive as a 429 with a
465
+ * `Retry-After`, and both are handled the same way: sleep that long, then try
466
+ * the same batch again. Waiting is the normal path here. Total waiting is capped
467
+ * by the client's `rateLimitMaxWaitMs`.
468
+ *
469
+ * **Network errors are not retried** — see `submit`. Only a 429 is, because only
470
+ * a 429 proves the server did not take the submission.
471
+ *
472
+ * **If it does give up, the jobs already created are handed back on the error**,
473
+ * in `err.submittedJobs`. Those jobs are running on the server with credits
474
+ * already reserved — they are not lost and not refunded. Wait on them or fetch
475
+ * them later; do not resubmit those files.
476
+ *
477
+ * @returns One pending `Job` per batch, in batch order.
478
+ * @throws InvalidFileError A single file is over the 35MB per-file limit, so no
479
+ * batching can carry it.
480
+ * @throws RateLimitedError Still rate limited after `rateLimitMaxWaitMs`.
481
+ */
482
+ async submitAll(paths, options = {}) {
483
+ if (!paths.length) {
484
+ throw new Error("at least one file is required");
485
+ }
486
+ const files = await this.#prepareFiles(paths);
487
+ const batches = planBatches(files.map((file) => ({
488
+ path: file.path,
489
+ size: file.size,
490
+ isPdf: !file.isImage,
491
+ file,
492
+ })));
493
+ const budget = new WaitBudget(this.rateLimitMaxWaitMs);
494
+ const jobs = [];
495
+ for (const batch of batches) {
496
+ try {
497
+ jobs.push(await this.#submitBatch(batch.map((item) => item.file), options, budget));
498
+ }
499
+ catch (err) {
500
+ // Whatever went wrong, the earlier batches are already jobs on the server
501
+ // with credits reserved. Losing the ids would mean the caller paid for
502
+ // work they can never collect.
503
+ attachSubmittedJobs(err, jobs);
504
+ throw err;
505
+ }
506
+ }
507
+ return jobs;
508
+ }
509
+ /** Fetch the current job state as a `Job` snapshot. Throws JobNotFoundError. */
510
+ async getJob(jobId) {
511
+ return Job.fromJson(await this.#request("GET", `/api/v1/jobs/${encodeURIComponent(jobId)}`, {
512
+ consume: (res) => this.#parseJson(res),
513
+ }));
514
+ }
515
+ /**
516
+ * Request graceful cancellation of a conversion job.
517
+ *
518
+ * Pages already running finish and remain in the deliverable; pages that have
519
+ * not started are skipped and refunded. **A page being dispatched at the very
520
+ * moment the request arrives may still run to completion and be billed.**
521
+ * Repeating the call is safe. When `finalizing` is true, keep polling with
522
+ * `getJob` until the job is terminal.
523
+ *
524
+ * @throws JobAlreadyFinishedError The job already finished, **or is past the
525
+ * point where cancelling could change the outcome** (409 `JOB_ALREADY_FINISHED`).
526
+ */
527
+ async cancel(jobId) {
528
+ return parseCancellationResult(await this.#request("POST", `/api/v1/jobs/${encodeURIComponent(jobId)}/cancel`, {
529
+ consume: (res) => this.#parseJson(res),
530
+ }));
531
+ }
532
+ /**
533
+ * Poll until the job reaches a terminal state; return the completed `Job`.
534
+ *
535
+ * Backs off from `pollIntervalMs` to 15s. On a 429 it waits the `Retry-After`
536
+ * seconds. A failed job throws JobFailedError; exceeding `timeoutMs` throws
537
+ * Image2PPTTimeoutError (the job itself may still be running).
538
+ */
539
+ async wait(jobId, options = {}) {
540
+ const pollInterval = options.pollIntervalMs ?? 5_000;
541
+ const timeout = options.timeoutMs ?? 1_800_000;
542
+ // Monotonic clock so a system-time jump can't skew the deadline (mirrors the
543
+ // Python client's time.monotonic()).
544
+ const deadline = performance.now() + timeout;
545
+ let interval = pollInterval;
546
+ for (;;) {
547
+ let job;
548
+ try {
549
+ job = await this.getJob(jobId);
550
+ }
551
+ catch (err) {
552
+ if (err instanceof RateLimitedError) {
553
+ const waitMs = err.retryAfter != null ? err.retryAfter * 1000 : interval;
554
+ await this.#sleepUntil(deadline, waitMs, jobId);
555
+ continue;
556
+ }
557
+ // A single poll failed. The job itself is fine — the question is only
558
+ // whether asking again could go better, and the error answers that itself
559
+ // through `isTransient`: a 5xx, a dropped connection, a request that
560
+ // stalled. Back off and ask again until the deadline.
561
+ //
562
+ // Anything else ends the wait, and that now includes an error this client
563
+ // did not expect at all. It used to be the opposite: "not one of ours" was
564
+ // read as "transient", so a bug in this SDK was swallowed and retried for
565
+ // the full half hour before surfacing as a timeout. The mirror-image bug
566
+ // was worse in practice — a single slow status poll is reported as this
567
+ // SDK's own error, so "one of ours" made it fatal and one stalled poll
568
+ // killed the whole wait.
569
+ if (!(err instanceof Image2PPTError) || !err.isTransient)
570
+ throw err;
571
+ await this.#sleepUntil(deadline, interval, jobId);
572
+ interval = Math.min(interval * 1.5, 15_000);
573
+ continue;
574
+ }
575
+ if (job.isCompleted)
576
+ return job;
577
+ if (job.isFailed) {
578
+ const err = job.error ?? undefined;
579
+ const ErrorClass = err?.code === "JOB_CANCELLED" ? JobCancelledError : JobFailedError;
580
+ throw new ErrorClass(err?.message ?? "conversion failed", {
581
+ code: err?.code,
582
+ job,
583
+ });
584
+ }
585
+ await this.#sleepUntil(deadline, interval, jobId);
586
+ interval = Math.min(interval * 1.5, 15_000);
587
+ }
588
+ }
589
+ /**
590
+ * Download a completed job's PPTX to `destPath`; return that path. Throws
591
+ * NotReadyError (409), JobNotFoundError (404), or OutputExpiredError (410).
592
+ *
593
+ * **`destPath` either holds a complete deck or is not written at all.** The bytes
594
+ * go to a temporary file beside it and are renamed into place once the last one
595
+ * arrives, so a connection dropped mid-download cannot leave a truncated `.pptx`
596
+ * that opens in a file listing and nowhere else. That matters most for
597
+ * `convertAll`, whose contract is "the decks already downloaded stay on disk" — a
598
+ * half-written `part-02.pptx` would be indexed as one of them.
599
+ */
600
+ async download(jobId, destPath) {
601
+ // Same directory as the destination, so the rename is atomic rather than a
602
+ // cross-filesystem copy.
603
+ const partial = join(dirname(destPath), `.${basename(destPath)}.${randomUUID()}.part`);
604
+ return this.#request("GET", `/api/v1/jobs/${encodeURIComponent(jobId)}/download`, {
605
+ consume: async (res, watchdog) => {
606
+ if (!res.ok) {
607
+ await this.#raiseForError(res);
608
+ }
609
+ try {
610
+ if (res.body) {
611
+ // Stream to disk in chunks so a large PPTX never sits fully in memory
612
+ // (mirrors the Python client's iter_content streaming). A deck of any
613
+ // size downloads fine as long as bytes keep arriving — see `timeoutMs`.
614
+ //
615
+ // `writeFile` over an async iterable, not a `pipeline`: it awaits each
616
+ // write, so back-pressure is inherent and the deck never piles up in
617
+ // memory, and — the reason this download stopped using `pipeline` — it
618
+ // keeps the two ends' failures apart. See `readingBody`.
619
+ //
620
+ // `Readable.fromWeb` rather than iterating the web stream directly:
621
+ // async iteration over a `ReadableStream` has not been available for the
622
+ // whole range of Node versions this package supports, and the adapter
623
+ // has. It is a byte-mode wrapper, so it does not buffer a megabyte of
624
+ // deck the way an object-mode one would.
625
+ const source = Readable.fromWeb(res.body);
626
+ // Load-bearing, despite doing nothing: a stream's `error` with no listener
627
+ // takes the whole process down, and `pipeline` is no longer here to hold
628
+ // one open. `fromWeb` wires the body's failure to this stream from the
629
+ // moment it is built, but nothing is iterating it yet while `writeFile`
630
+ // is opening the file — so a connection reset inside that window has
631
+ // nowhere to go. It decides nothing: a failure that reaches the iterator
632
+ // is still what the caller hears, wrapped by `readingBody`.
633
+ source.on("error", () => undefined);
634
+ try {
635
+ await writeFile(partial, readingBody(source, jobId, () => watchdog.kick()));
636
+ }
637
+ finally {
638
+ // A destination that could not even be opened leaves `writeFile` never
639
+ // starting the iterator at all, so nothing closes the body and the
640
+ // connection stays open for the life of the process. (A write that
641
+ // fails midway does close it, by throwing out of the `for await`.)
642
+ // Destroying the source covers both, and is a no-op once it has ended.
643
+ source.destroy();
644
+ }
645
+ }
646
+ else {
647
+ // No body stream (shouldn't happen for a 200 download): buffer as a
648
+ // fallback, wrapped the same way so a failure here is an SDK error too.
649
+ await writeFile(partial, Buffer.from(await res.arrayBuffer().catch((err) => {
650
+ throw downloadCutOff(err, jobId);
651
+ })));
652
+ }
653
+ await rename(partial, destPath);
654
+ }
655
+ catch (err) {
656
+ await rm(partial, { force: true }).catch(() => undefined);
657
+ throw err;
658
+ }
659
+ return destPath;
660
+ },
661
+ });
662
+ }
663
+ /**
664
+ * One-shot: submit → wait for completion → download to `destPath`.
665
+ *
666
+ * One job, one PPTX — the files must fit in a single submission (45MB of file
667
+ * content, 50 pages). For more than that, `convertAll` splits the pile and writes
668
+ * one PPTX per batch.
669
+ */
670
+ async convert(paths, destPath, options = {}) {
671
+ const job = await this.submit(paths, options);
672
+ const completed = await this.wait(job.jobId, options);
673
+ await this.download(completed.jobId, destPath);
674
+ return completed;
675
+ }
676
+ /**
677
+ * Batch version of `convert`: submit everything, wait, download each deck.
678
+ *
679
+ * Files are split with `submitAll`, every batch is submitted first (so the
680
+ * server works on them in parallel), then each job is waited on and downloaded
681
+ * in order.
682
+ *
683
+ * **This writes one PPTX per batch, not one merged deck.** Output files are
684
+ * named `part-01.pptx`, `part-02.pptx`, ... inside `destDir` — stable for the
685
+ * same input, and never overwriting each other. Existing files with those names
686
+ * are overwritten. `convert` is unchanged: one job, one PPTX.
687
+ *
688
+ * `timeoutMs` is the wait cap **per job**, not for the whole pile. If a job
689
+ * fails or runs past it, earlier batches that already downloaded stay on disk
690
+ * and later ones are not waited on. Every job created so far is on the thrown
691
+ * error as `err.submittedJobs` — those are still running with credits reserved,
692
+ * so wait on them rather than resubmitting.
693
+ *
694
+ * Rate limits during submission are waited out — see `submitAll`.
695
+ *
696
+ * @param destDir Directory for the PPTX files. Created **and proven writable
697
+ * before anything is submitted**, so an unusable destination costs nothing.
698
+ * @returns The written file paths, in batch order.
699
+ */
700
+ async convertAll(paths, destDir, options = {}) {
701
+ // Before anything is submitted: if the destination is unusable, fail now
702
+ // rather than after N jobs exist with credits reserved and nowhere to put
703
+ // their output. This is the one step that can fail for free.
704
+ await ensureWritableDir(destDir);
705
+ const jobs = await this.submitAll(paths, options);
706
+ const written = [];
707
+ try {
708
+ for (const [index, job] of jobs.entries()) {
709
+ const completed = await this.wait(job.jobId, options);
710
+ const destPath = join(destDir, `part-${String(index + 1).padStart(2, "0")}.pptx`);
711
+ await this.download(completed.jobId, destPath);
712
+ written.push(destPath);
713
+ }
714
+ }
715
+ catch (err) {
716
+ // Same contract as submitAll: the jobs are already paid for, so the caller
717
+ // gets their ids back instead of having to guess.
718
+ attachSubmittedJobs(err, jobs);
719
+ throw err;
720
+ }
721
+ return written;
722
+ }
723
+ /** Return account info: `{ email, credits }` (available credits). */
724
+ async account() {
725
+ return (await this.#request("GET", "/api/v1/account", {
726
+ consume: (res) => this.#parseJson(res),
727
+ }));
728
+ }
729
+ // ----- internals --------------------------------------------------- //
730
+ /**
731
+ * Submit one batch, waiting out rate limits until `deadline`.
732
+ *
733
+ * Retrying a 429 is not the same gamble as retrying a broken upload: a 429 is
734
+ * the server saying it did *not* take the submission. Nothing was created and
735
+ * nothing was charged, so trying the same batch again is free.
736
+ *
737
+ * Both flavors of 429 (per-minute page quota, concurrent-job cap) carry a
738
+ * `Retry-After` and are handled identically. When the header is missing we fall
739
+ * back to a fixed wait.
740
+ */
741
+ async #submitBatch(files, options, budget) {
742
+ // Two things stop this: the shared waiting `budget`, and MAX_BATCH_ATTEMPTS. The
743
+ // budget bounds time spent waiting; the attempt count bounds the uploads, which
744
+ // the budget cannot see — a server answering `Retry-After: 1` forever costs almost
745
+ // no budget per round while re-sending the whole batch every time.
746
+ let attemptsLeft = MAX_BATCH_ATTEMPTS;
747
+ for (;;) {
748
+ try {
749
+ return await this.#submitPrepared(files, options);
750
+ }
751
+ catch (err) {
752
+ if (!(err instanceof RateLimitedError))
753
+ throw err;
754
+ attemptsLeft -= 1;
755
+ const delay = err.retryAfter != null ? err.retryAfter * 1000 : RATE_LIMIT_FALLBACK_WAIT_MS;
756
+ // On the last attempt, do not wait first: nothing follows the wait, so it
757
+ // would only delay the error the caller is already getting.
758
+ if (attemptsLeft <= 0 || !(await budget.spend(delay)))
759
+ throw err;
760
+ }
761
+ }
762
+ }
763
+ /**
764
+ * Prepare paths once, so pre-flight, batching, and multipart share identical bytes.
765
+ *
766
+ * Bounded on purpose. `submitAll` takes an arbitrarily long list, and preparing one
767
+ * image holds the whole source file in memory while it is decoded and re-encoded.
768
+ * Preparing them all at once meant a few hundred photos put a few hundred whole
769
+ * files in memory simultaneously — enough to take the process down before a single
770
+ * byte was uploaded. A small pool keeps the decoder busy and the peak flat.
771
+ */
772
+ async #prepareFiles(paths) {
773
+ const prepared = new Array(paths.length);
774
+ let next = 0;
775
+ const worker = async () => {
776
+ for (let index = next++; index < paths.length; index = next++) {
777
+ prepared[index] = await this.#prepareFile(paths[index]);
778
+ }
779
+ };
780
+ await Promise.all(Array.from({ length: Math.min(PREPARE_CONCURRENCY, paths.length) }, worker));
781
+ return prepared;
782
+ }
783
+ /** Resolve one path to its exact multipart metadata and wire size. */
784
+ async #prepareFile(path) {
785
+ const name = basename(path);
786
+ const mime = guessMime(name);
787
+ if (!isImageMime(mime)) {
788
+ // PDF files are only stat-ed here; their bytes are streamed while building the
789
+ // multipart body so a large document never occupies client memory.
790
+ return {
791
+ path,
792
+ name,
793
+ mime,
794
+ size: (await stat(path)).size,
795
+ isImage: false,
796
+ };
797
+ }
798
+ // Read outside the try: a missing or unreadable path is a filesystem problem, not
799
+ // an invalid image, and the PDF branch above lets its own ENOENT/EACCES through.
800
+ const raw = await readFile(path);
801
+ let compressed;
802
+ try {
803
+ compressed = await compressImageForUpload(raw, mime);
804
+ }
805
+ catch (err) {
806
+ // An SDK error here is preparation refusing to run at all (no native decoder
807
+ // for this platform), not a verdict on this file. Passing it through keeps it
808
+ // from being reported as a bad image.
809
+ if (err instanceof Image2PPTError)
810
+ throw err;
811
+ // Corrupt, truncated, or undecodable image. Surfaced as an SDK error, matching
812
+ // the Python client, so callers catching Image2PPTError never see a raw decoder
813
+ // type leak out of this SDK.
814
+ throw new InvalidFileError(`could not read image "${name}": ${err instanceof Error ? err.message : String(err)}`, { code: "INVALID_FILE" });
815
+ }
816
+ const buffer = compressed.buffer;
817
+ const outputName = compressed.mime === "image/jpeg" && !/\.jpe?g$/i.test(name)
818
+ ? `${name.slice(0, Math.max(0, name.length - extname(name).length))}.jpg`
819
+ : name;
820
+ return {
821
+ path,
822
+ name: outputName,
823
+ mime: compressed.mime,
824
+ buffer,
825
+ size: buffer.byteLength,
826
+ isImage: true,
827
+ };
828
+ }
829
+ /** Validate and submit a prepared payload exactly once. */
830
+ async #submitPrepared(files, options) {
831
+ // Pre-flight happens after image preparation because these are the exact bytes
832
+ // that will be transmitted, not the potentially much larger source files.
833
+ for (const file of files)
834
+ checkFileSize(file.path, file.size);
835
+ checkSubmission(files.reduce((total, file) => total + file.size, 0), files.filter((file) => file.isImage).length, files.filter((file) => !file.isImage).length);
836
+ return Job.fromJson(await this.#request("POST", "/api/v1/jobs", {
837
+ // Rebuilt per attempt: a retry needs a body that has not been consumed.
838
+ body: (watchdog) => buildMultipart(files, options, () => watchdog.kick()),
839
+ consume: (res) => this.#parseJson(res),
840
+ }));
841
+ }
842
+ /**
843
+ * Make one HTTP request and consume its response, under an idle watchdog.
844
+ *
845
+ * The response body is read inside `consume` rather than after this returns,
846
+ * because the watchdog has to cover it: a body that starts arriving and then
847
+ * stops is exactly the failure `timeoutMs` is there to catch, and it happens
848
+ * after the headers are in. `consume` is handed the watchdog so a path that
849
+ * streams — only `download` — can report each chunk as progress; the short JSON
850
+ * bodies do not need to, since a JSON reply that takes longer than the whole
851
+ * idle budget to arrive genuinely is stuck.
852
+ *
853
+ * Every failure *of the request* leaves here as an `Image2PPTError`. The READMEs
854
+ * promise that, and four different platform errors used to escape it: a transport
855
+ * failure as undici's opaque `TypeError: fetch failed`, an unparseable 2xx body as
856
+ * a raw `SyntaxError`, a job body missing its own id as no error at all, and a
857
+ * download cut off mid-stream as whatever the stream happened to throw.
858
+ *
859
+ * The caller's own disk is the one deliberate exception, and it is deliberate:
860
+ * `download` writes inside its `consume`, and an `ENOSPC` / `EACCES` / `ENOENT`
861
+ * from that write passes through here untouched, because the operating system
862
+ * already named the thing the caller has to go and fix. Do not be tempted to wrap
863
+ * it — a disk that is full is not a transport failure and must not carry
864
+ * `isTransient`. See `readingBody`.
865
+ */
866
+ async #request(method, path, options) {
867
+ const watchdog = new IdleWatchdog(this.timeoutMs, `request to ${path} went ${this.timeoutMs}ms with no data moving ` +
868
+ "(timeoutMs is an idle timeout, not a limit on how long a transfer may take)");
869
+ try {
870
+ const payload = options.body?.(watchdog);
871
+ let res;
872
+ try {
873
+ res = await this.#fetch(`${this.baseUrl}${path}`, {
874
+ method,
875
+ headers: {
876
+ Authorization: `Bearer ${this.#apiKey}`,
877
+ "User-Agent": USER_AGENT,
878
+ ...(this.acceptLanguage ? { "Accept-Language": this.acceptLanguage } : {}),
879
+ ...(payload
880
+ ? {
881
+ "Content-Type": payload.contentType,
882
+ "Content-Length": String(payload.contentLength),
883
+ }
884
+ : {}),
885
+ },
886
+ body: payload?.body,
887
+ signal: watchdog.signal,
888
+ // Node's fetch requires this for a streaming request body. It is ignored by
889
+ // browsers, but this SDK is intentionally server-side only.
890
+ duplex: "half",
891
+ });
892
+ }
893
+ catch (err) {
894
+ throw this.#asRequestError(err, path);
895
+ }
896
+ // The answer arriving is data moving, so the body starts on a full budget
897
+ // rather than on whatever is left of the one the request was sent under.
898
+ // Without this the wait for the answer is charged twice — once to the
899
+ // request, then again to the body — and a deck the service took a while to
900
+ // start sending is cut off while it is arriving perfectly normally.
901
+ watchdog.kick();
902
+ this.#warnIfDeprecated(res);
903
+ return await options.consume(res, watchdog);
904
+ }
905
+ finally {
906
+ watchdog.stop();
907
+ }
908
+ }
909
+ /**
910
+ * Turn whatever `fetch` rejected with into this SDK's own error.
911
+ *
912
+ * Order matters here and is not interchangeable. An error the request *body*
913
+ * raised — this client's own code objecting that a file changed underneath the
914
+ * upload, or the watchdog giving up on a stalled one — has to be recognised
915
+ * before the generic transport wrapping, or the caller is told "the connection
916
+ * failed" about a problem that was never on the connection.
917
+ */
918
+ #asRequestError(err, path) {
919
+ // The watchdog aborts with its own APITimeoutError, and `fetch` rejects with
920
+ // whatever the signal carried, so this arrives already in the right shape.
921
+ if (err instanceof Image2PPTError)
922
+ return err;
923
+ // A throw from the request body reaches the caller as undici's bare
924
+ // `TypeError: fetch failed`, with the real reason demoted to `cause`. The body
925
+ // is this client's own code, so when it is the one that objected — a file that
926
+ // changed underneath the upload — its message is the one worth surfacing.
927
+ if (err instanceof Error && err.cause instanceof Image2PPTError)
928
+ return err.cause;
929
+ // An abort that is not the watchdog's: a caller-supplied `fetch` implementation
930
+ // with its own deadline, or a runtime that aborts with a bare DOMException.
931
+ if (err instanceof DOMException &&
932
+ (err.name === "TimeoutError" || err.name === "AbortError")) {
933
+ return new APITimeoutError(`request to ${path} was aborted: ${err.message}`, {
934
+ cause: err,
935
+ });
936
+ }
937
+ // Everything else is the connection itself: refused, reset, DNS, TLS. undici
938
+ // reports all of them as the same unhelpful `TypeError: fetch failed` and hides
939
+ // the actual reason in `cause`, so lift that reason into the message and keep
940
+ // the original reachable.
941
+ return new APIConnectionError(`request to ${path} failed: ${describeCause(err)}`, {
942
+ cause: err,
943
+ });
944
+ }
945
+ /**
946
+ * Log at most one warning if this SDK version has been marked deprecated.
947
+ *
948
+ * A response from a version below the support floor carries a `Deprecation`
949
+ * header — successful ones included, which is why this is checked before the
950
+ * status code rather than after. Presence is the whole signal, the value is not
951
+ * parsed. `Sunset` and `Link` join the message when present. `wait()` polls every
952
+ * few seconds, so this is latched per client.
953
+ *
954
+ * Everything below is inside the guard on purpose: this notice is advisory, and
955
+ * nothing it does — reading the headers included — may turn a served response into
956
+ * a thrown error.
957
+ */
958
+ #warnIfDeprecated(res) {
959
+ if (!this.warnOnDeprecated || this.#deprecationWarned)
960
+ return;
961
+ try {
962
+ if (!res.headers.has("Deprecation"))
963
+ return;
964
+ this.#deprecationWarned = true;
965
+ const parts = [
966
+ `This image2ppt Node SDK (${VERSION}) has been marked deprecated.`,
967
+ ];
968
+ const url = linkUrl(res.headers.get("Link"));
969
+ if (url)
970
+ parts.push(`See ${url} for what changed.`);
971
+ const sunset = res.headers.get("Sunset");
972
+ if (sunset)
973
+ parts.push(`Support is planned to end ${sunset}.`);
974
+ parts.push("Pass warnOnDeprecated: false to Image2PPTClient() to silence this warning.");
975
+ console.warn(parts.join(" "));
976
+ }
977
+ catch {
978
+ // Advisory only: neither a throwing console.warn nor an unexpected response
979
+ // object may fail the request.
980
+ }
981
+ }
982
+ /**
983
+ * The JSON body of a successful response.
984
+ *
985
+ * A 2xx that is not JSON is a real thing to meet — a captive portal, a CDN error
986
+ * page, a proxy that replaced the response — and it used to escape as a raw
987
+ * `SyntaxError` from `res.json()`, which is not an `Image2PPTError` and so was
988
+ * not catchable the way the README says every failure is.
989
+ */
990
+ async #parseJson(res) {
991
+ if (!res.ok) {
992
+ await this.#raiseForError(res);
993
+ }
994
+ try {
995
+ return (await res.json());
996
+ }
997
+ catch (err) {
998
+ // The watchdog's abort comes through here too when the body stalls partway.
999
+ if (err instanceof Image2PPTError)
1000
+ throw err;
1001
+ if (err instanceof SyntaxError) {
1002
+ throw new MalformedResponseError(`${res.status} response was not JSON: ${err.message}`, { statusCode: res.status, cause: err });
1003
+ }
1004
+ // The body started and then stopped arriving: a transport failure, not a
1005
+ // parsing one, and worth retrying where a mangled body would not be.
1006
+ throw new APIConnectionError(`${res.status} response body did not finish arriving: ${describeCause(err)}`, { statusCode: res.status, cause: err });
1007
+ }
1008
+ }
1009
+ /**
1010
+ * Raise the mapped error for a non-2xx, reading the `{ error: ... }` envelope.
1011
+ *
1012
+ * Reading that envelope is itself a read off the socket, and `download` reaches
1013
+ * here with a response whose body has not arrived yet — so the connection can
1014
+ * still die at this point. A body that failed to **parse** is nothing worth
1015
+ * reporting: the status code already says what happened, and an HTML gateway page
1016
+ * adds nothing. A body that never finished **arriving** is a transport failure and
1017
+ * has to be raised as one, exactly as the Python client does.
1018
+ */
1019
+ async #raiseForError(res) {
1020
+ let code;
1021
+ let message;
1022
+ try {
1023
+ const body = (await res.json());
1024
+ if (body && typeof body === "object" && body.error) {
1025
+ code = body.error.code;
1026
+ message = body.error.message;
1027
+ }
1028
+ }
1029
+ catch (err) {
1030
+ // The watchdog's abort comes through here when the error body stalls partway.
1031
+ if (err instanceof Image2PPTError)
1032
+ throw err;
1033
+ if (!(err instanceof SyntaxError)) {
1034
+ throw new APIConnectionError(`${res.status} error body did not finish arriving: ${describeCause(err)}`, { statusCode: res.status, cause: err });
1035
+ }
1036
+ // non-JSON error body (e.g. a gateway HTML page): fall back to status text
1037
+ }
1038
+ throw exceptionFor({
1039
+ statusCode: res.status,
1040
+ code,
1041
+ message: message ?? `request failed (HTTP ${res.status})`,
1042
+ retryAfter: parseRetryAfter(res.headers.get("Retry-After")),
1043
+ });
1044
+ }
1045
+ /**
1046
+ * Sleep `ms`, but never past `deadline` and never past `MAX_SLEEP_MS`.
1047
+ *
1048
+ * The `MAX_SLEEP_MS` clamp is not redundant with the deadline: `deadline` comes from
1049
+ * the caller's `timeoutMs`, so both bounds here can be caller-supplied and neither
1050
+ * constrains the other. See `MAX_SLEEP_MS` for what an out-of-range wait does.
1051
+ */
1052
+ async #sleepUntil(deadline, ms, jobId) {
1053
+ const remaining = deadline - performance.now();
1054
+ if (remaining <= 0) {
1055
+ throw new Image2PPTTimeoutError(`timed out waiting for job ${jobId}`, jobId);
1056
+ }
1057
+ await sleep(Math.min(ms, remaining, MAX_SLEEP_MS));
1058
+ }
1059
+ }
1060
+ /** Pull the URL out of a `Link: <url>; rel=...` header, or null. */
1061
+ function linkUrl(value) {
1062
+ if (!value)
1063
+ return null;
1064
+ const start = value.indexOf("<");
1065
+ const end = value.indexOf(">", start + 1);
1066
+ if (start === -1 || end === -1)
1067
+ return null;
1068
+ const url = value.slice(start + 1, end).trim();
1069
+ return url || null;
1070
+ }
1071
+ /**
1072
+ * `Retry-After` as plain decimal seconds — the only spelling this client accepts.
1073
+ *
1074
+ * Written with `[0-9]` and an explicit leading/trailing space-or-tab rather than `\d`
1075
+ * and `trim()`. Python's `\d` matches every Unicode decimal digit and JavaScript's
1076
+ * matches only ASCII, so `Retry-After: 5` would be five seconds to one client and
1077
+ * unparseable to the other — the exact two-client disagreement this pattern exists to
1078
+ * remove. Space and tab are the only whitespace HTTP allows around a field value;
1079
+ * `trim()` would also eat Unicode spaces that never belong there.
1080
+ */
1081
+ const RETRY_AFTER_SECONDS = /^[ \t]*([0-9]+(?:\.[0-9]+)?)[ \t]*$/;
1082
+ /**
1083
+ * Longest delay this client will ever wait in one go, in milliseconds (~24.8 days).
1084
+ *
1085
+ * The line is this platform's timer range: a delay past 2**31-1 milliseconds is not
1086
+ * representable, and `setTimeout` silently clamps it to *1 millisecond* — so an
1087
+ * out-of-range wait turns into "retry immediately, at full speed". Python fails
1088
+ * differently on the same input (`time.sleep` raises `OverflowError`), which is the
1089
+ * other half of the problem: the two clients would stop agreeing. Drawing the line at
1090
+ * the same number in both keeps them in step. Nothing legitimate lives out here.
1091
+ *
1092
+ * It bounds **every** wait, not just a server-sent `Retry-After`: the polling backoff
1093
+ * is seeded from the caller's own `pollIntervalMs`, and on repeated 429s without a
1094
+ * `Retry-After` that seed is reused unchanged — so an absurd `pollIntervalMs` with a
1095
+ * large enough `timeoutMs` would reach the timer the same way.
1096
+ */
1097
+ const MAX_SLEEP_MS = 2 ** 31 - 1;
1098
+ /**
1099
+ * How many times one batch may be re-sent after a 429 before giving up.
1100
+ *
1101
+ * The waiting budget alone does not bound the work: a server answering
1102
+ * `Retry-After: 1` indefinitely costs only a second per round, so a 30-minute budget
1103
+ * would buy ~1800 rounds — and every round re-uploads the whole batch, tens of
1104
+ * megabytes at a time. A server still refusing after this many tries is not going to
1105
+ * be talked round by more of them.
1106
+ */
1107
+ const MAX_BATCH_ATTEMPTS = 10;
1108
+ /**
1109
+ * Parse the `Retry-After` header as seconds (contract: integer seconds).
1110
+ *
1111
+ * Anything unusable comes back as `undefined` so the caller falls back to its own
1112
+ * wait: a missing header, an HTTP-date, a negative value, a value past `MAX_SLEEP_MS`
1113
+ * once converted, or any spelling that is not plain decimal seconds.
1114
+ *
1115
+ * The syntax is matched explicitly rather than handed to `Number`. Both languages'
1116
+ * parsers are lenient in their own way — `Number` takes `"0x10"` as 16, Python's
1117
+ * `float` takes `"1e3"` and `"nan"` — so "whatever the parser accepts" would mean the
1118
+ * two clients disagreeing about the same header. One pattern, one answer.
1119
+ *
1120
+ * A usable value is floored at `MIN_RETRY_AFTER_SECONDS` — see that constant for why
1121
+ * zero cannot be taken literally.
1122
+ */
1123
+ function parseRetryAfter(value) {
1124
+ if (!value)
1125
+ return undefined;
1126
+ const match = RETRY_AFTER_SECONDS.exec(value);
1127
+ if (match === null)
1128
+ return undefined;
1129
+ const seconds = Number(match[1]);
1130
+ if (seconds * 1000 > MAX_SLEEP_MS)
1131
+ return undefined;
1132
+ return Math.max(seconds, MIN_RETRY_AFTER_SECONDS);
1133
+ }
1134
+ //# sourceMappingURL=client.js.map