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/LICENSE +21 -0
- package/README.md +269 -0
- package/dist/client.d.ts +154 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1134 -0
- package/dist/client.js.map +1 -0
- package/dist/compress.d.ts +24 -0
- package/dist/compress.d.ts.map +1 -0
- package/dist/compress.js +83 -0
- package/dist/compress.js.map +1 -0
- package/dist/errors.d.ts +235 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +293 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/limits.d.ts +125 -0
- package/dist/limits.d.ts.map +1 -0
- package/dist/limits.js +177 -0
- package/dist/limits.js.map +1 -0
- package/dist/types.d.ts +229 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +256 -0
- package/dist/types.js.map +1 -0
- package/dist/version.d.ts +11 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +11 -0
- package/dist/version.js.map +1 -0
- package/package.json +59 -0
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
|