@formatjet/sdk 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js ADDED
@@ -0,0 +1,406 @@
1
+ import createClient, {} from 'openapi-fetch';
2
+ import { errorFromResponse, FormatJetError, retryAfterSeconds } from './errors.js';
3
+ import { parseWebhook, verifyWebhook } from './webhooks.js';
4
+ export const DEFAULT_BASE_URL = 'https://api.formatjet.com';
5
+ const TERMINAL = new Set(['completed', 'failed', 'canceled']);
6
+ const RETRY_STATUSES = new Set([429, 500, 502, 503, 504]);
7
+ /** A longer `Retry-After` is answered as an error rather than slept through. */
8
+ const MAX_RETRY_AFTER_SECONDS = 60;
9
+ const DEFAULT_WAIT_MS = 15 * 60_000;
10
+ const DOWNLOADS_AT_ONCE = 4;
11
+ function environment(name) {
12
+ const runtime = globalThis;
13
+ return runtime.process?.env?.[name];
14
+ }
15
+ function sleep(ms, signal) {
16
+ return new Promise((resolve, reject) => {
17
+ if (signal?.aborted)
18
+ return reject(signal.reason);
19
+ const onAbort = () => {
20
+ clearTimeout(timer);
21
+ reject(signal.reason);
22
+ };
23
+ const timer = setTimeout(() => {
24
+ signal?.removeEventListener('abort', onAbort);
25
+ resolve();
26
+ }, ms);
27
+ signal?.addEventListener('abort', onAbort, { once: true });
28
+ });
29
+ }
30
+ /** A signal that aborts after `ms` or with `signal`; `clear()` stops the timer. */
31
+ function deadline(ms, signal) {
32
+ const controller = new AbortController();
33
+ const timer = setTimeout(() => controller.abort(new DOMException('The request timed out.', 'TimeoutError')), ms);
34
+ if (signal?.aborted)
35
+ controller.abort(signal.reason);
36
+ else
37
+ signal?.addEventListener('abort', () => controller.abort(signal.reason), { once: true });
38
+ return { signal: controller.signal, clear: () => clearTimeout(timer) };
39
+ }
40
+ /** Half a second, doubling, with up to a quarter of a second's jitter. */
41
+ function backoff(attempt) {
42
+ return 500 * 2 ** attempt + Math.random() * 250;
43
+ }
44
+ function isFileData(value) {
45
+ return value instanceof Blob || value instanceof ArrayBuffer || ArrayBuffer.isView(value);
46
+ }
47
+ function prepare(input) {
48
+ const data = isFileData(input) ? input : input.data;
49
+ const named = !isFileData(input) ? input.filename : undefined;
50
+ const file = typeof File !== 'undefined' && data instanceof File ? data : undefined;
51
+ const blob = data instanceof Blob ? data : new Blob([data]);
52
+ return { blob, filename: named ?? file?.name ?? undefined };
53
+ }
54
+ function stepOf(options) {
55
+ if (!options.conversion && !options.to) {
56
+ throw new TypeError('Name the conversion, or the format to make (`to`).');
57
+ }
58
+ return {
59
+ op: 'convert',
60
+ ...(options.conversion && { conversion: options.conversion }),
61
+ ...(options.from && { from: options.from }),
62
+ ...(options.to && { to: options.to }),
63
+ ...(options.options && { options: options.options }),
64
+ };
65
+ }
66
+ /** The name in a `Content-Disposition` header (`filename*` first, as RFC 6266 says). */
67
+ export function filenameFrom(header) {
68
+ if (!header)
69
+ return null;
70
+ const extended = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(header);
71
+ if (extended) {
72
+ try {
73
+ return decodeURIComponent(extended[1].trim());
74
+ }
75
+ catch {
76
+ // Fall through to the plain name.
77
+ }
78
+ }
79
+ const plain = /filename\s*=\s*"((?:[^"\\]|\\.)*)"|filename\s*=\s*([^;]+)/i.exec(header);
80
+ const name = plain?.[1]?.replace(/\\(.)/g, '$1') ?? plain?.[2]?.trim();
81
+ return name || null;
82
+ }
83
+ /** Runs `work` on each item with at most `limit` at once, keeping the order. */
84
+ async function inOrder(items, limit, work) {
85
+ const results = new Array(items.length);
86
+ let next = 0;
87
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
88
+ while (next < items.length) {
89
+ const i = next++;
90
+ results[i] = await work(items[i]);
91
+ }
92
+ });
93
+ await Promise.all(workers);
94
+ return results;
95
+ }
96
+ /** Throws the API's error when an openapi-fetch call didn't succeed. */
97
+ async function unwrap(result) {
98
+ if (result.response.ok)
99
+ return result.data;
100
+ throw await errorFromResponse(result.response, result.error ?? undefined);
101
+ }
102
+ /** Jobs: make, read, list, wait for, cancel and delete them. */
103
+ export class Jobs {
104
+ #api;
105
+ constructor(api) {
106
+ this.#api = api;
107
+ }
108
+ /** Makes a job (`POST /v1/jobs`). It starts at once; wait for it with `wait()`. */
109
+ async create(request, options = {}) {
110
+ return unwrap(await this.#api.POST('/v1/jobs', {
111
+ body: request,
112
+ signal: options.signal,
113
+ ...(options.idempotencyKey && { headers: { 'Idempotency-Key': options.idempotencyKey } }),
114
+ }));
115
+ }
116
+ async get(id, options = {}) {
117
+ return unwrap(await this.#api.GET('/v1/jobs/{id}', { params: { path: { id } }, signal: options.signal }));
118
+ }
119
+ /** One page of jobs, newest first: pass `next_cursor` back as `cursor` for the next. */
120
+ async list(params = {}, options = {}) {
121
+ return unwrap(await this.#api.GET('/v1/jobs', { params: { query: params }, signal: options.signal }));
122
+ }
123
+ /** Every job of the last 30 days, newest first, a page at a time. */
124
+ async *iterate(params = {}, options = {}) {
125
+ let cursor;
126
+ do {
127
+ const page = await this.list({ ...params, ...(cursor && { cursor }) }, options);
128
+ yield* page.data;
129
+ cursor = page.next_cursor ?? undefined;
130
+ } while (cursor);
131
+ }
132
+ /** Cancels a job that hasn't finished (`POST /v1/jobs/{id}/cancel`). */
133
+ async cancel(id, options = {}) {
134
+ return unwrap(await this.#api.POST('/v1/jobs/{id}/cancel', {
135
+ params: { path: { id } },
136
+ signal: options.signal,
137
+ ...(options.idempotencyKey && { headers: { 'Idempotency-Key': options.idempotencyKey } }),
138
+ }));
139
+ }
140
+ /** Deletes a job's files now and returns the signed deletion receipt. */
141
+ async delete(id, options = {}) {
142
+ const answer = await unwrap(await this.#api.DELETE('/v1/jobs/{id}', { params: { path: { id } }, signal: options.signal }));
143
+ return answer.receipt;
144
+ }
145
+ /**
146
+ * Reads the job until it has ended (completed, failed or canceled) and returns it, whatever
147
+ * the outcome. Throws a `FormatJetError` with code `timeout` if it runs past `timeoutMs`.
148
+ */
149
+ async wait(id, options = {}) {
150
+ const until = Date.now() + (options.timeoutMs ?? DEFAULT_WAIT_MS);
151
+ let pause = 500;
152
+ for (;;) {
153
+ const job = await this.get(id, { signal: options.signal });
154
+ if (TERMINAL.has(job.status))
155
+ return job;
156
+ options.onProgress?.(job);
157
+ if (Date.now() + pause > until) {
158
+ throw new FormatJetError({
159
+ code: 'timeout',
160
+ detail: `Job ${id} was still ${job.status} when the wait ran out; it carries on.`,
161
+ jobId: id,
162
+ });
163
+ }
164
+ await sleep(pause, options.signal);
165
+ pause = Math.min(pause * 1.5, 5_000);
166
+ }
167
+ }
168
+ }
169
+ /** The webhook signature helpers. */
170
+ export const webhooks = { verify: verifyWebhook, parse: parseWebhook };
171
+ /**
172
+ * The FormatJet API. `convert()` sends a file and gets the result in one request (up to
173
+ * 25 MB); `uploadAndConvert()` uploads it first, for files up to the plan's limit. `jobs`
174
+ * manages jobs, `webhooks` checks deliveries and `api` calls any endpoint, typed.
175
+ */
176
+ export class FormatJet {
177
+ baseUrl;
178
+ /** The typed client for every endpoint (openapi-fetch). */
179
+ api;
180
+ jobs;
181
+ webhooks = webhooks;
182
+ #apiKey;
183
+ #fetch;
184
+ #maxRetries;
185
+ #timeoutMs;
186
+ constructor(options = {}) {
187
+ const apiKey = options.apiKey ?? environment('FORMATJET_API_KEY');
188
+ if (!apiKey)
189
+ throw new TypeError('No API key: pass `apiKey` or set FORMATJET_API_KEY.');
190
+ this.#apiKey = apiKey;
191
+ this.baseUrl = (options.baseUrl ??
192
+ environment('FORMATJET_BASE_URL') ??
193
+ DEFAULT_BASE_URL).replace(/\/+$/, '');
194
+ this.#fetch = options.fetch ?? ((request) => fetch(request));
195
+ this.#maxRetries = options.maxRetries ?? 2;
196
+ this.#timeoutMs = options.timeoutMs ?? 5 * 60_000;
197
+ this.api = createClient({
198
+ baseUrl: this.baseUrl,
199
+ fetch: (request) => this.#call(request),
200
+ });
201
+ this.jobs = new Jobs(this.api);
202
+ }
203
+ /**
204
+ * Converts one file (or the files a joining conversion takes) in one request, up to 25 MB in
205
+ * all, and returns the converted files. A job that runs past 30 seconds is waited for.
206
+ */
207
+ async convert(input, options) {
208
+ const files = (Array.isArray(input) ? input : [input]).map(prepare);
209
+ const step = stepOf(options);
210
+ const query = new URLSearchParams();
211
+ if (step.conversion)
212
+ query.set('conversion', step.conversion);
213
+ if (step.from)
214
+ query.set('from', step.from);
215
+ if (step.to)
216
+ query.set('to', step.to);
217
+ if (step.options)
218
+ query.set('options', JSON.stringify(step.options));
219
+ if (options.outputFilename)
220
+ query.set('output_filename', options.outputFilename);
221
+ if (options.ttl !== undefined)
222
+ query.set('ttl', String(options.ttl));
223
+ const headers = {
224
+ Authorization: `Bearer ${this.#apiKey}`,
225
+ 'Idempotency-Key': options.idempotencyKey ?? crypto.randomUUID(),
226
+ };
227
+ const response = await this.#retrying((signal) => {
228
+ const form = new FormData();
229
+ for (const file of files)
230
+ form.append('file', file.blob, file.filename ?? 'file');
231
+ return new Request(`${this.baseUrl}/v1/convert?${query}`, {
232
+ method: 'POST',
233
+ headers,
234
+ body: form,
235
+ signal,
236
+ });
237
+ }, options.signal);
238
+ if (!response.ok)
239
+ throw await errorFromResponse(response);
240
+ const type = response.headers.get('Content-Type') ?? '';
241
+ if (type.includes('application/json')) {
242
+ // Several files, or still converting after 30 s: the job, which lists them or carries on.
243
+ let job = (await response.json());
244
+ if (!TERMINAL.has(job.status)) {
245
+ job = await this.jobs.wait(job.id, {
246
+ signal: options.signal,
247
+ timeoutMs: options.timeoutMs,
248
+ });
249
+ }
250
+ return this.#result(job, options.signal);
251
+ }
252
+ const file = {
253
+ filename: filenameFrom(response.headers.get('Content-Disposition')),
254
+ mime: type || 'application/octet-stream',
255
+ data: new Uint8Array(await response.arrayBuffer()),
256
+ };
257
+ return {
258
+ jobId: response.headers.get('FormatJet-Job-Id') ?? '',
259
+ file,
260
+ files: [file],
261
+ credits: Number(response.headers.get('FormatJet-Credits') ?? 0),
262
+ engine: response.headers.get('FormatJet-Engine'),
263
+ job: null,
264
+ };
265
+ }
266
+ /**
267
+ * Uploads the file (or files, for a conversion that joins them), makes a job, waits for it
268
+ * and downloads what it made. For files larger than `convert()` takes.
269
+ */
270
+ async uploadAndConvert(input, options) {
271
+ const files = (Array.isArray(input) ? input : [input]).map(prepare);
272
+ const step = stepOf(options);
273
+ const uploads = await Promise.all(files.map((file) => this.#upload(file, options.signal)));
274
+ const refs = uploads.map((upload) => ({ upload_id: upload.id }));
275
+ const output = {
276
+ ...(options.ttl !== undefined && { ttl: options.ttl }),
277
+ ...(options.outputFilename && { filename: options.outputFilename }),
278
+ };
279
+ const created = await this.jobs.create({
280
+ ...(refs.length === 1 ? { input: refs[0] } : { inputs: refs }),
281
+ steps: [step],
282
+ ...(Object.keys(output).length > 0 && { output }),
283
+ ...(options.metadata && { metadata: options.metadata }),
284
+ ...(options.webhook && { webhook: options.webhook }),
285
+ }, { signal: options.signal, idempotencyKey: options.idempotencyKey });
286
+ const job = await this.jobs.wait(created.id, {
287
+ signal: options.signal,
288
+ timeoutMs: options.timeoutMs,
289
+ onProgress: options.onProgress,
290
+ });
291
+ return this.#result(job, options.signal);
292
+ }
293
+ /**
294
+ * Makes an upload and PUTs the file to it. Name the upload in a job's `input`
295
+ * (`{ upload_id: upload.id }`) within the hour.
296
+ */
297
+ async upload(input, options = {}) {
298
+ return this.#upload(prepare(input), options.signal);
299
+ }
300
+ /** Downloads one of a job's outputs. Its URL needs no key and works until `expires_at`. */
301
+ async download(output, options = {}) {
302
+ if (!output.url) {
303
+ throw new FormatJetError({
304
+ code: 'not_found',
305
+ detail: `Output ${output.id} has been deleted.`,
306
+ });
307
+ }
308
+ const url = output.url;
309
+ // Never send the key: the URL is signed, and may be our storage's rather than the API's.
310
+ const response = await this.#retrying((signal) => new Request(url, { signal }), options.signal);
311
+ if (!response.ok)
312
+ throw await errorFromResponse(response);
313
+ return {
314
+ filename: output.filename,
315
+ mime: output.mime,
316
+ data: new Uint8Array(await response.arrayBuffer()),
317
+ };
318
+ }
319
+ async #upload(file, signal) {
320
+ const upload = await unwrap(await this.api.POST('/v1/uploads', {
321
+ body: { size: file.blob.size, ...(file.filename && { filename: file.filename }) },
322
+ signal,
323
+ }));
324
+ const response = await this.#retrying((attempt) => new Request(upload.url, {
325
+ method: upload.method,
326
+ headers: upload.headers,
327
+ body: file.blob,
328
+ signal: attempt,
329
+ }), signal);
330
+ if (!response.ok)
331
+ throw await errorFromResponse(response);
332
+ await response.body?.cancel().catch(() => undefined);
333
+ return upload;
334
+ }
335
+ /** The finished job's files, or its failure as an error. */
336
+ async #result(job, signal) {
337
+ if (job.status === 'failed') {
338
+ throw new FormatJetError({
339
+ code: (job.error?.code ?? 'engine_failed'),
340
+ detail: job.error?.detail ?? 'The conversion failed.',
341
+ jobId: job.id,
342
+ });
343
+ }
344
+ if (job.status === 'canceled') {
345
+ throw new FormatJetError({
346
+ code: 'canceled',
347
+ detail: `Job ${job.id} was canceled.`,
348
+ jobId: job.id,
349
+ });
350
+ }
351
+ const files = await inOrder(job.outputs, DOWNLOADS_AT_ONCE, (output) => this.download(output, { signal }));
352
+ if (files.length === 0) {
353
+ throw new FormatJetError({
354
+ code: 'not_found',
355
+ detail: `Job ${job.id} has no outputs.`,
356
+ jobId: job.id,
357
+ });
358
+ }
359
+ return {
360
+ jobId: job.id,
361
+ file: files[0],
362
+ files,
363
+ credits: job.credits,
364
+ engine: job.engine ? `${job.engine} ${job.engine_version ?? ''}`.trim() : null,
365
+ job,
366
+ };
367
+ }
368
+ /** openapi-fetch's requests: the key, an Idempotency-Key on POSTs, and retries. */
369
+ async #call(request) {
370
+ const headers = new Headers(request.headers);
371
+ headers.set('Authorization', `Bearer ${this.#apiKey}`);
372
+ if (request.method === 'POST' && !headers.has('Idempotency-Key')) {
373
+ headers.set('Idempotency-Key', crypto.randomUUID());
374
+ }
375
+ // JSON bodies are small: read once, sent again as they are on a retry.
376
+ const body = request.body ? await request.arrayBuffer() : null;
377
+ const { url, method } = request;
378
+ return this.#retrying((signal) => new Request(url, { method, headers, body, signal }), request.signal);
379
+ }
380
+ /** Sends a request made fresh for each attempt, retrying 429s, 5xx answers and network errors. */
381
+ async #retrying(make, signal) {
382
+ for (let attempt = 0;; attempt++) {
383
+ const timer = deadline(this.#timeoutMs, signal);
384
+ let response;
385
+ try {
386
+ response = await this.#fetch(make(timer.signal));
387
+ }
388
+ catch (error) {
389
+ if (signal?.aborted || attempt >= this.#maxRetries)
390
+ throw error;
391
+ await sleep(backoff(attempt), signal);
392
+ continue;
393
+ }
394
+ finally {
395
+ timer.clear();
396
+ }
397
+ if (!RETRY_STATUSES.has(response.status) || attempt >= this.#maxRetries)
398
+ return response;
399
+ const wait = retryAfterSeconds(response);
400
+ if (wait !== undefined && wait > MAX_RETRY_AFTER_SECONDS)
401
+ return response;
402
+ await response.body?.cancel().catch(() => undefined);
403
+ await sleep(wait !== undefined ? wait * 1000 : backoff(attempt), signal);
404
+ }
405
+ }
406
+ }
@@ -0,0 +1,47 @@
1
+ import type { components } from './generated/openapi.js';
2
+ /** An RFC 9457 problem document, as the API answers every error. */
3
+ export type Problem = components['schemas']['Problem'];
4
+ /** The API's error codes, stable across versions: https://formatjet.com/docs/errors */
5
+ export type ErrorCode = Problem['code'];
6
+ /**
7
+ * The codes a `FormatJetError` carries: the API's, `timeout` when the SDK stopped waiting for a
8
+ * job (the job carries on) and `canceled` for a job canceled while the SDK waited for it.
9
+ */
10
+ export type FormatJetErrorCode = ErrorCode | 'timeout' | 'canceled';
11
+ export interface FormatJetErrorInit {
12
+ code: FormatJetErrorCode;
13
+ detail: string;
14
+ status?: number;
15
+ requestId?: string;
16
+ jobId?: string;
17
+ retryAfter?: number;
18
+ problem?: Problem;
19
+ }
20
+ /**
21
+ * An error from the API (its problem document), a job that failed, or a wait that ran out.
22
+ * Branch on `code`: it's stable, while `message` is written for people.
23
+ */
24
+ export declare class FormatJetError extends Error {
25
+ readonly name = "FormatJetError";
26
+ /** The problem document's `code`, or the failed job's `error.code`. */
27
+ readonly code: FormatJetErrorCode;
28
+ readonly detail: string;
29
+ /** The HTTP status, when the API answered with an error. */
30
+ readonly status: number | undefined;
31
+ /** `FormatJet-Request-Id`: quote it when you contact us. */
32
+ readonly requestId: string | undefined;
33
+ /** The job the error belongs to, when there is one. */
34
+ readonly jobId: string | undefined;
35
+ /** Seconds to wait before trying again (`Retry-After`), on 429 and some 503 answers. */
36
+ readonly retryAfter: number | undefined;
37
+ /** The whole problem document, with `errors` for each field that failed validation. */
38
+ readonly problem: Problem | undefined;
39
+ constructor(init: FormatJetErrorInit);
40
+ }
41
+ /** Seconds from a `Retry-After` header, when it holds a number of them. */
42
+ export declare function retryAfterSeconds(response: Response): number | undefined;
43
+ /**
44
+ * The error for an answer that wasn't a success. `body` is the answer's body when it has
45
+ * already been read (openapi-fetch reads it); otherwise it's read here.
46
+ */
47
+ export declare function errorFromResponse(response: Response, body?: unknown): Promise<FormatJetError>;
package/dist/errors.js ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * An error from the API (its problem document), a job that failed, or a wait that ran out.
3
+ * Branch on `code`: it's stable, while `message` is written for people.
4
+ */
5
+ export class FormatJetError extends Error {
6
+ name = 'FormatJetError';
7
+ /** The problem document's `code`, or the failed job's `error.code`. */
8
+ code;
9
+ detail;
10
+ /** The HTTP status, when the API answered with an error. */
11
+ status;
12
+ /** `FormatJet-Request-Id`: quote it when you contact us. */
13
+ requestId;
14
+ /** The job the error belongs to, when there is one. */
15
+ jobId;
16
+ /** Seconds to wait before trying again (`Retry-After`), on 429 and some 503 answers. */
17
+ retryAfter;
18
+ /** The whole problem document, with `errors` for each field that failed validation. */
19
+ problem;
20
+ constructor(init) {
21
+ super(`${init.code}: ${init.detail}`);
22
+ this.code = init.code;
23
+ this.detail = init.detail;
24
+ this.status = init.status;
25
+ this.requestId = init.requestId;
26
+ this.jobId = init.jobId;
27
+ this.retryAfter = init.retryAfter;
28
+ this.problem = init.problem;
29
+ }
30
+ }
31
+ /** Seconds from a `Retry-After` header, when it holds a number of them. */
32
+ export function retryAfterSeconds(response) {
33
+ const value = response.headers.get('Retry-After');
34
+ if (!value || !/^\d+$/.test(value.trim()))
35
+ return undefined;
36
+ return Number(value.trim());
37
+ }
38
+ function isProblem(value) {
39
+ return (typeof value === 'object' &&
40
+ value !== null &&
41
+ typeof value.code === 'string' &&
42
+ typeof value.status === 'number');
43
+ }
44
+ /**
45
+ * The error for an answer that wasn't a success. `body` is the answer's body when it has
46
+ * already been read (openapi-fetch reads it); otherwise it's read here.
47
+ */
48
+ export async function errorFromResponse(response, body) {
49
+ let parsed = body;
50
+ if (parsed === undefined) {
51
+ const text = await response.text().catch(() => '');
52
+ try {
53
+ parsed = JSON.parse(text);
54
+ }
55
+ catch {
56
+ parsed = text;
57
+ }
58
+ }
59
+ const requestId = response.headers.get('FormatJet-Request-Id') ?? undefined;
60
+ const retryAfter = retryAfterSeconds(response);
61
+ if (isProblem(parsed)) {
62
+ return new FormatJetError({
63
+ code: parsed.code,
64
+ detail: parsed.detail ?? parsed.title,
65
+ status: response.status,
66
+ requestId: parsed.request_id ?? requestId,
67
+ jobId: parsed.job_id,
68
+ retryAfter,
69
+ problem: parsed,
70
+ });
71
+ }
72
+ // Not one of ours: a proxy's page, or an answer cut short.
73
+ const code = response.status === 401
74
+ ? 'unauthorized'
75
+ : response.status === 404
76
+ ? 'not_found'
77
+ : response.status === 429
78
+ ? 'rate_limited'
79
+ : 'internal_error';
80
+ return new FormatJetError({
81
+ code,
82
+ detail: `The API answered ${response.status} ${response.statusText}`.trim(),
83
+ status: response.status,
84
+ requestId,
85
+ retryAfter,
86
+ });
87
+ }