@videxo/sdk 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BEO TECHNOLOGY sp. z o.o.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,80 @@
1
- # Temporary Holding Version
1
+ # @videxo/sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The TypeScript client of the [Videxo](https://videxo.io) API: hosted ffmpeg jobs over HTTP,
4
+ described as JSON operations or as an ffmpeg command. Types are generated from the API's OpenAPI
5
+ 3.1 document; requests go through `fetch` and webhooks are verified with WebCrypto, so the package
6
+ has one small dependency and no Node built-ins.
7
+
8
+ ```bash
9
+ npm i @videxo/sdk
10
+ # or: pnpm add @videxo/sdk, yarn add @videxo/sdk, bun add @videxo/sdk
11
+ ```
12
+
13
+ It is an ES module with its own type declarations. It runs on Node 20 or newer, Bun, Deno and
14
+ Cloudflare Workers. In a browser, use only `sendUpload()` with a `File`, `Blob` or bytes.
15
+
16
+ ## Keep the API key on a server
17
+
18
+ The API key gives full access to your account. Use it only in server code (a route handler, a
19
+ worker, a job queue), never in code that runs in a browser or an app. An app or a web page uses
20
+ `@videxo/sdk/client` with a [client token](https://videxo.io/docs/client-tokens) your server mints,
21
+ or sends files to an upload URL your server created, with `sendUpload()`, which needs no key.
22
+
23
+ ## Use it
24
+
25
+ ```ts
26
+ import { Videxo, VidexoError } from '@videxo/sdk';
27
+
28
+ const videxo = new Videxo({ apiKey: process.env.VIDEXO_API_KEY });
29
+
30
+ const job = await videxo.transcode({ input: 'https://example.com/talk.mp4', preset: 'web-720p' });
31
+ const done = await videxo.jobs.wait(job.id);
32
+ if (done.state === 'succeeded') {
33
+ const response = await videxo.outputs.download(done.id, 'video');
34
+ // response.body is the file
35
+ }
36
+ ```
37
+
38
+ | Call | API |
39
+ |---|---|
40
+ | `new Videxo({ apiKey, baseUrl?, fetch?, maxRetries?, timeoutMs? })` | `baseUrl` is `https://api.videxo.io` by default; throws without a key |
41
+ | `jobs.create(body, { idempotencyKey? })` | `POST /v1/jobs`, a command body or an operations body |
42
+ | `transcode`, `thumbnails`, `storyboard`, `gif`, `audio`, `concat`, `slideshow`, `scenes` | `POST /v1/<operation>` |
43
+ | `jobs.get(id)`, `jobs.cancel(id)`, `jobs.deleteFiles(id)` | `GET /v1/jobs/{id}`, `POST .../cancel`, `DELETE .../files` |
44
+ | `jobs.list(params)`, `jobs.page(params)` | `GET /v1/jobs`: every job as an async iterator, or one page |
45
+ | `jobs.wait(id, { timeoutMs?, intervalMs?, signal? })` | polls until `succeeded`, `failed` or `cancelled` |
46
+ | `jobs.compile(body)` | `POST /v1/jobs/compile` |
47
+ | `presets.list()` | `GET /v1/presets` |
48
+ | `fonts.list()` | `GET /v1/fonts` |
49
+ | `uploads.create(params)`, `uploads.upload(data, { filename, contentType?, size? })` | `POST /v1/uploads`, then the PUT to its URL |
50
+ | `outputs.url(jobId, output)`, `outputs.download(jobId, output)` | `GET /v1/jobs/{id}/outputs/{alias}`: a fresh signed link, or the file |
51
+ | `webhooks.verify(body, headers, secret)`, `verifyWebhook(...)` | Standard Webhooks signature check; answers the event |
52
+ | `sendUpload(uploadUrl, data, { contentType? })` | the PUT alone, without a key |
53
+ | `clientTokens.create(grant)`, `clientTokens.forUpload(...)`, `clientTokens.forJob(jobId, ...)` | `POST /v1/client-tokens`: answers `id` and `token` for the app |
54
+ | `clientTokens.revoke(id)`, `clientTokens.revokeSubject(subject)` | ends a chain, or every chain of an end user |
55
+
56
+ `@videxo/sdk/client` is a separate entry point for apps and web pages, without the keyed client:
57
+ `new VidexoClient({ token, tokenId?, baseUrl?, getToken? })` with `uploads.create`,
58
+ `uploads.upload(file, { filename, onProgress })`, `jobs.get`, `jobs.list`, `jobs.wait` and `token()`.
59
+ `getToken({ tokenId })` asks your server for a continuation when the token is about to expire or is
60
+ refused as expired or superseded. The constructor refuses a value shaped like an API key.
61
+
62
+ The docs page [TypeScript SDK](https://videxo.io/docs/sdk) covers each call with examples.
63
+
64
+ ## Errors
65
+
66
+ Every failed call throws a `VidexoError` with `status`, `code`, `title`, `detail`, `type`,
67
+ `errors`, `problems`, `retryAfter` and the raw `body` of the API's problem details answer; programs
68
+ branch on `code` ([error codes](https://videxo.io/docs/errors)). Without such an answer `code` is
69
+ one of the SDK's own, listed in `SDK_ERROR_CODES`: `network_error`, `timeout`, `aborted`,
70
+ `wait_timeout`, `http_error`, `invalid_response`, `output_not_found` and `output_ambiguous`. An
71
+ error's message never holds the API key. A webhook that fails verification throws a
72
+ `VidexoWebhookError`.
73
+
74
+ ## Retries and idempotency
75
+
76
+ Every creation sends an `Idempotency-Key` (a random UUID unless you pass `idempotencyKey`) and
77
+ reuses it on retries. A request is retried after `429`, `502`, `503`, `504`, a timeout or a network
78
+ error, at most `maxRetries` times (2 by default), with backoff and jitter or after its
79
+ `Retry-After`. Other answers are not retried. `POST /v1/uploads` takes no Idempotency-Key, so it is
80
+ retried only after a `429`; bytes sent to an upload URL are never sent twice.
@@ -0,0 +1,112 @@
1
+ import { type RetryPolicy, type Transport } from './transport.ts';
2
+ import type { ClientJob, ClientTokenInfo, CompletedUpload, CreateUploadRequest, Upload } from './types.ts';
3
+ import { type UploadData, type UploadProgress } from './uploads.ts';
4
+ export { DEFAULT_BASE_URL } from './defaults.ts';
5
+ export { VidexoError, type VidexoErrorCode } from './errors.ts';
6
+ export { sendUpload, type SendUploadOptions, type UploadData, type UploadProgress } from './uploads.ts';
7
+ export type { ClientJob, ClientJobList, ClientJobOutput, ClientTokenInfo, CompletedUpload, CreateUploadRequest, Upload } from './types.ts';
8
+ /** The token a `getToken` callback answers: the string, or with its id as your backend got it. */
9
+ export type FreshToken = string | {
10
+ token: string;
11
+ id?: string;
12
+ };
13
+ export type VidexoClientOptions = {
14
+ /** A client token your backend minted (`vdx_ct_...`); never an API key. */
15
+ token: string;
16
+ /** The token's id. Without it the id is read from `GET /v1/client/token` on first use. */
17
+ tokenId?: string;
18
+ /** `https://api.videxo.io` by default. */
19
+ baseUrl?: string;
20
+ /**
21
+ * Asks your backend for a continuation of the token (`continues: tokenId`), when it is about to
22
+ * expire or is refused as expired or superseded, so long flows keep their uploads and jobs. A
23
+ * failed refresh is asked again with the same id on the next call.
24
+ */
25
+ getToken?(request: {
26
+ tokenId: string | null;
27
+ }): Promise<FreshToken>;
28
+ fetch?(input: Request, init?: RequestInit): Promise<Response>;
29
+ maxRetries?: number;
30
+ timeoutMs?: number;
31
+ };
32
+ export type ClientRequestOptions = {
33
+ signal?: AbortSignal;
34
+ timeoutMs?: number;
35
+ };
36
+ export type ClientCreateOptions = ClientRequestOptions & {
37
+ /** Makes the creation safe to retry for 24 hours; the SDK makes one and reuses it on its own retries. */
38
+ idempotencyKey?: string;
39
+ };
40
+ export type ClientUploadOptions = ClientCreateOptions & {
41
+ /** A plain file name: letters, digits, dots, underscores and hyphens. */
42
+ filename: string;
43
+ /** The declared type; the blob's own type or `application/octet-stream` by default. */
44
+ contentType?: string;
45
+ /** The exact byte length; needed for a stream, read from the data otherwise. */
46
+ size?: number;
47
+ /** Called as the bytes are sent. In a browser this uses `XMLHttpRequest`, as `fetch` reports no upload progress. */
48
+ onProgress?(progress: UploadProgress): void;
49
+ /** For sending the bytes; no timeout by default, as a large file may take a while. */
50
+ uploadTimeoutMs?: number;
51
+ };
52
+ export type ClientWaitOptions = {
53
+ timeoutMs?: number;
54
+ intervalMs?: number;
55
+ signal?: AbortSignal;
56
+ };
57
+ /**
58
+ * The client of `/v1/client/*` for an app or a web page.
59
+ *
60
+ * ```ts
61
+ * const videxo = new VidexoClient({ token, tokenId, getToken: ({ tokenId }) => myBackend.refresh(tokenId) });
62
+ * const upload = await videxo.uploads.upload(file, { filename: 'clip.mp4', onProgress: (p) => show(p.loaded / p.total) });
63
+ * ```
64
+ */
65
+ export declare class VidexoClient {
66
+ #private;
67
+ readonly baseUrl: string;
68
+ readonly uploads: ClientUploads;
69
+ readonly jobs: ClientJobs;
70
+ /** @internal */
71
+ readonly transport: Transport;
72
+ constructor(options: VidexoClientOptions);
73
+ /** `GET /v1/client/token`: the token's id, chain and expiry, the uploads used and the jobs it may read. */
74
+ token(options?: ClientRequestOptions): Promise<ClientTokenInfo>;
75
+ /** The id of the current token, once known. */
76
+ get tokenId(): string | null;
77
+ /** @internal One call: a token that is about to end is refreshed first, one refused as ended is refreshed and the call sent again once. */
78
+ request<T>(method: string, path: string, options: {
79
+ retry: RetryPolicy;
80
+ body?: unknown;
81
+ headers?: Record<string, string>;
82
+ remember?: boolean;
83
+ } & ClientRequestOptions): Promise<T>;
84
+ }
85
+ export declare class ClientUploads {
86
+ #private;
87
+ constructor(client: VidexoClient);
88
+ /**
89
+ * `POST /v1/client/uploads`: a one-time upload URL within the token's grant. It sends an
90
+ * Idempotency-Key of its own, kept for its retries and a refresh, so a lost answer spends nothing.
91
+ */
92
+ create(params: CreateUploadRequest, options?: ClientCreateOptions): Promise<Upload>;
93
+ /**
94
+ * Creates an upload and sends the bytes; answers the completed upload. Hand its `input_url` to your
95
+ * backend, which creates the job with its API key.
96
+ */
97
+ upload(data: UploadData, options: ClientUploadOptions): Promise<CompletedUpload>;
98
+ }
99
+ export declare class ClientJobs {
100
+ #private;
101
+ constructor(client: VidexoClient);
102
+ /** `GET /v1/client/jobs/{id}`: a job the token may read. Any other job is `not_found`. */
103
+ get(id: string, options?: ClientRequestOptions): Promise<ClientJob>;
104
+ /** `GET /v1/client/jobs`: the jobs the token may read, newest first. */
105
+ list(options?: ClientRequestOptions): Promise<ClientJob[]>;
106
+ /**
107
+ * Reads the job until it is `succeeded`, `failed` or `cancelled`, and answers it (a failed job is
108
+ * answered, not thrown). Waits out a 429 by its `Retry-After`. Throws `wait_timeout` when the job
109
+ * has not ended within `timeoutMs`, `aborted` when `signal` fires.
110
+ */
111
+ wait(id: string, options?: ClientWaitOptions): Promise<ClientJob>;
112
+ }
@@ -0,0 +1,311 @@
1
+ import { DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT_MS, DEFAULT_WAIT } from "./defaults.js";
2
+ import { VidexoError, errorFromResponse, sdkError } from "./errors.js";
3
+ import { abortableSleep, redacted, runtimeFetch, send } from "./transport.js";
4
+ import { sendUpload, sendUploadWithProgress, sizeOf } from "./uploads.js";
5
+ // `@videxo/sdk/client`: the client for apps and web pages, which hold a short-lived client token
6
+ // (ADR 016), never an API key. It imports nothing of the keyed client, so a bundle stays small and
7
+ // cannot reach a keyed route by accident.
8
+ export { DEFAULT_BASE_URL } from "./defaults.js";
9
+ export { VidexoError } from "./errors.js";
10
+ export { sendUpload } from "./uploads.js";
11
+ const CLIENT_TOKEN_PREFIX = 'vdx_ct_';
12
+ const API_KEY_SHAPE = /^vdx_[A-Za-z]{64}$/;
13
+ /** Refresh a token this close to its expiry, before the request would be refused. */
14
+ const REFRESH_BEFORE_EXPIRY_MS = 30_000;
15
+ const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
16
+ /** The refusals a new token of the chain answers. */
17
+ const REFRESHABLE = new Set(['client_token_expired', 'client_token_superseded']);
18
+ /** A value that must be a client token: an API key in an app is a published key, so it stops here. */
19
+ function checkedToken(value) {
20
+ const token = typeof value === 'string' ? value.trim() : '';
21
+ // The messages do not repeat the value: it may be a real key.
22
+ if (API_KEY_SHAPE.test(token))
23
+ throw new TypeError('VidexoClient takes a client token, never an API key. Keep the API key on your server and mint client tokens there.');
24
+ if (!token.startsWith(CLIENT_TOKEN_PREFIX) || !/^[\x21-\x7e]+$/.test(token))
25
+ throw new TypeError('VidexoClient needs a client token (vdx_ct_...) your server minted with POST /v1/client-tokens.');
26
+ return token;
27
+ }
28
+ /**
29
+ * The client of `/v1/client/*` for an app or a web page.
30
+ *
31
+ * ```ts
32
+ * const videxo = new VidexoClient({ token, tokenId, getToken: ({ tokenId }) => myBackend.refresh(tokenId) });
33
+ * const upload = await videxo.uploads.upload(file, { filename: 'clip.mp4', onProgress: (p) => show(p.loaded / p.total) });
34
+ * ```
35
+ */
36
+ export class VidexoClient {
37
+ baseUrl;
38
+ uploads;
39
+ jobs;
40
+ /** @internal */
41
+ transport;
42
+ #token;
43
+ #tokens = new Set();
44
+ #tokenId;
45
+ /** Milliseconds; null until the token has been read. */
46
+ #expiresAt = null;
47
+ #read = false;
48
+ #refreshed = false;
49
+ #clockAhead = false;
50
+ #refreshing = null;
51
+ #getToken;
52
+ constructor(options) {
53
+ this.#token = checkedToken(options?.token);
54
+ this.#tokens.add(this.#token);
55
+ this.#tokenId = options.tokenId ?? null;
56
+ this.#getToken = options.getToken;
57
+ const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
58
+ if (!/^https?:\/\//.test(baseUrl))
59
+ throw new TypeError('baseUrl must be an http or https URL.');
60
+ this.baseUrl = baseUrl;
61
+ const userFetch = options.fetch;
62
+ this.transport = {
63
+ fetch: userFetch ? (input, init) => userFetch(input, init) : runtimeFetch,
64
+ maxRetries: Math.max(0, Math.floor(options.maxRetries ?? DEFAULT_MAX_RETRIES)),
65
+ timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
66
+ // Every token of this client: a request under way when its token was replaced can still fail with it.
67
+ redact: (text) => [...this.#tokens].reduce((clean, token) => clean.replaceAll(token, '[client token]'), text),
68
+ };
69
+ this.uploads = new ClientUploads(this);
70
+ this.jobs = new ClientJobs(this);
71
+ }
72
+ /** `GET /v1/client/token`: the token's id, chain and expiry, the uploads used and the jobs it may read. */
73
+ async token(options = {}) {
74
+ return this.request('GET', '/v1/client/token', { retry: 'safe', remember: true, ...options });
75
+ }
76
+ /** The id of the current token, once known. */
77
+ get tokenId() {
78
+ return this.#tokenId;
79
+ }
80
+ /** What a read of the token `used` answered, unless the token was replaced while it was on its way. */
81
+ #remember(info, used) {
82
+ if (used !== this.#token)
83
+ return;
84
+ this.#tokenId = info.id;
85
+ this.#expiresAt = Date.parse(info.expires_at);
86
+ this.#read = true;
87
+ // A token just refreshed cannot be about to end, so this device's clock runs ahead. Asking for a new
88
+ // one before every call would not help: from here on only the API's own refusal starts a refresh.
89
+ if (this.#refreshed && this.#expiresAt - Date.now() < REFRESH_BEFORE_EXPIRY_MS)
90
+ this.#clockAhead = true;
91
+ this.#refreshed = false;
92
+ }
93
+ /** The current token is replaced by what `getToken` answers; concurrent calls share one refresh. */
94
+ #refresh() {
95
+ if (!this.#refreshing) {
96
+ this.#refreshing = (async () => {
97
+ const fresh = await this.#getToken({ tokenId: this.#tokenId });
98
+ const token = checkedToken(typeof fresh === 'string' ? fresh : fresh?.token);
99
+ // The old id stays until the new one is known, so a failed refresh asks with the same id.
100
+ this.#token = token;
101
+ this.#tokens.add(token);
102
+ if (typeof fresh !== 'string' && fresh.id)
103
+ this.#tokenId = fresh.id;
104
+ this.#expiresAt = null;
105
+ this.#read = false;
106
+ this.#refreshed = true;
107
+ })().finally(() => {
108
+ this.#refreshing = null;
109
+ });
110
+ }
111
+ return this.#refreshing;
112
+ }
113
+ /**
114
+ * After `used` was refused as ended: a refresh, unless another call already replaced that token. A
115
+ * refusal that arrives late must not mint a third token, whose first use would end the second while
116
+ * a call with it is under way.
117
+ */
118
+ async #replace(used, signal) {
119
+ if (this.#refreshing)
120
+ await settled(this.#refreshing, signal);
121
+ if (this.#token === used)
122
+ await settled(this.#refresh(), signal);
123
+ }
124
+ /** Before a request: learns the token's id and expiry while it still works, and refreshes it when it is about to end. */
125
+ async #prepare(path, options) {
126
+ if (!this.#getToken)
127
+ return;
128
+ if (this.#refreshing)
129
+ await settled(this.#refreshing, options.signal);
130
+ if (!this.#read && path !== '/v1/client/token') {
131
+ // Only the call's signal and timeout: its body and headers are not this request's.
132
+ const { signal, timeoutMs } = options;
133
+ const used = this.#token;
134
+ const response = await this.#send('GET', '/v1/client/token', {
135
+ retry: 'safe',
136
+ ...(signal ? { signal } : {}),
137
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
138
+ });
139
+ const body = await readBody(response);
140
+ if (response.ok)
141
+ this.#remember(body, used);
142
+ else if (refreshable(response, body))
143
+ return this.#replace(used, signal);
144
+ // Any other refusal is left to the call itself, which answers it.
145
+ }
146
+ if (!this.#clockAhead && this.#expiresAt !== null && this.#expiresAt - Date.now() < REFRESH_BEFORE_EXPIRY_MS)
147
+ await settled(this.#refresh(), options.signal);
148
+ }
149
+ #send(method, path, options) {
150
+ const request = new Request(`${this.baseUrl}${path}`, {
151
+ method,
152
+ headers: {
153
+ Authorization: `Bearer ${this.#token}`,
154
+ ...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }),
155
+ ...options.headers,
156
+ },
157
+ ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
158
+ });
159
+ return send(this.transport, request, { retry: options.retry, signal: options.signal, timeoutMs: options.timeoutMs, buffer: true });
160
+ }
161
+ /** @internal One call: a token that is about to end is refreshed first, one refused as ended is refreshed and the call sent again once. */
162
+ async request(method, path, options) {
163
+ try {
164
+ await this.#prepare(path, options);
165
+ let used = this.#token;
166
+ let response = await this.#send(method, path, options);
167
+ let body = await readBody(response);
168
+ if (this.#getToken && refreshable(response, body)) {
169
+ await this.#replace(used, options.signal);
170
+ used = this.#token;
171
+ response = await this.#send(method, path, options);
172
+ body = await readBody(response);
173
+ }
174
+ if (!response.ok)
175
+ throw errorFromResponse(response, body);
176
+ if (typeof body !== 'object' || body === null)
177
+ throw sdkError('invalid_response', 'The API answered without a JSON body.', { status: response.status, body });
178
+ if (options.remember)
179
+ this.#remember(body, used);
180
+ return body;
181
+ }
182
+ catch (error) {
183
+ throw redacted(this.transport, error);
184
+ }
185
+ }
186
+ }
187
+ /**
188
+ * Waits for the shared refresh as long as this call still wants it: its signal ends this call's wait
189
+ * and nothing else, so the refresh goes on for the calls still waiting.
190
+ */
191
+ function settled(refresh, signal) {
192
+ if (!signal)
193
+ return refresh;
194
+ return new Promise((resolve, reject) => {
195
+ const onAbort = () => reject(sdkError('aborted', 'The request was aborted while its token was refreshed.', { cause: signal.reason }));
196
+ if (signal.aborted)
197
+ return onAbort();
198
+ signal.addEventListener('abort', onAbort, { once: true });
199
+ refresh.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort));
200
+ });
201
+ }
202
+ const refreshable = (response, body) => response.status === 401 && REFRESHABLE.has(body?.reason ?? '');
203
+ async function readBody(response) {
204
+ const text = await response.text();
205
+ try {
206
+ return JSON.parse(text);
207
+ }
208
+ catch {
209
+ return text;
210
+ }
211
+ }
212
+ export class ClientUploads {
213
+ #client;
214
+ constructor(client) {
215
+ this.#client = client;
216
+ }
217
+ /**
218
+ * `POST /v1/client/uploads`: a one-time upload URL within the token's grant. It sends an
219
+ * Idempotency-Key of its own, kept for its retries and a refresh, so a lost answer spends nothing.
220
+ */
221
+ create(params, options = {}) {
222
+ return this.#client.request('POST', '/v1/client/uploads', {
223
+ retry: 'safe',
224
+ body: params,
225
+ headers: { 'Idempotency-Key': options.idempotencyKey ?? crypto.randomUUID() },
226
+ ...(options.signal ? { signal: options.signal } : {}),
227
+ ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
228
+ });
229
+ }
230
+ /**
231
+ * Creates an upload and sends the bytes; answers the completed upload. Hand its `input_url` to your
232
+ * backend, which creates the job with its API key.
233
+ */
234
+ async upload(data, options) {
235
+ const size = options.size ?? sizeOf(data);
236
+ if (size === null)
237
+ throw new TypeError('uploads.upload() needs options.size for a stream.');
238
+ const blobType = typeof Blob !== 'undefined' && data instanceof Blob ? data.type : '';
239
+ const contentType = options.contentType ?? (blobType || 'application/octet-stream');
240
+ const upload = await this.create({ filename: options.filename, size_bytes: size, content_type: contentType }, options);
241
+ const sending = {
242
+ contentType,
243
+ fetch: this.#client.transport.fetch,
244
+ ...(options.signal ? { signal: options.signal } : {}),
245
+ ...(options.uploadTimeoutMs === undefined ? {} : { timeoutMs: options.uploadTimeoutMs }),
246
+ };
247
+ return options.onProgress
248
+ ? sendUploadWithProgress(upload.upload_url, data, size, { ...sending, onProgress: options.onProgress })
249
+ : sendUpload(upload.upload_url, data, sending);
250
+ }
251
+ }
252
+ export class ClientJobs {
253
+ #client;
254
+ constructor(client) {
255
+ this.#client = client;
256
+ }
257
+ /** `GET /v1/client/jobs/{id}`: a job the token may read. Any other job is `not_found`. */
258
+ get(id, options = {}) {
259
+ return this.#client.request('GET', `/v1/client/jobs/${encodeURIComponent(id)}`, { retry: 'safe', ...options });
260
+ }
261
+ /** `GET /v1/client/jobs`: the jobs the token may read, newest first. */
262
+ async list(options = {}) {
263
+ const list = await this.#client.request('GET', '/v1/client/jobs', { retry: 'safe', ...options });
264
+ return list.data;
265
+ }
266
+ /**
267
+ * Reads the job until it is `succeeded`, `failed` or `cancelled`, and answers it (a failed job is
268
+ * answered, not thrown). Waits out a 429 by its `Retry-After`. Throws `wait_timeout` when the job
269
+ * has not ended within `timeoutMs`, `aborted` when `signal` fires.
270
+ */
271
+ async wait(id, options = {}) {
272
+ const timeoutMs = options.timeoutMs ?? DEFAULT_WAIT.timeoutMs;
273
+ const intervalMs = options.intervalMs ?? DEFAULT_WAIT.intervalMs;
274
+ const sleep = this.#client.transport.sleep ?? abortableSleep;
275
+ const deadline = new AbortController();
276
+ const onAbort = () => deadline.abort(options.signal?.reason);
277
+ if (options.signal?.aborted)
278
+ onAbort();
279
+ options.signal?.addEventListener('abort', onAbort, { once: true });
280
+ const timer = setTimeout(() => deadline.abort(), timeoutMs);
281
+ let job;
282
+ const timedOut = () => sdkError('wait_timeout', `Job ${id} did not end within ${timeoutMs} ms.`, { body: job });
283
+ try {
284
+ for (;;) {
285
+ let pause = intervalMs;
286
+ try {
287
+ job = await this.get(id, { signal: deadline.signal });
288
+ if (TERMINAL.has(job.state))
289
+ return job;
290
+ }
291
+ catch (error) {
292
+ if (!(error instanceof VidexoError && error.status === 429))
293
+ throw error;
294
+ pause = Math.max(intervalMs, (error.retryAfter ?? 0) * 1000);
295
+ }
296
+ if (deadline.signal.aborted)
297
+ throw timedOut();
298
+ await sleep(pause, deadline.signal);
299
+ }
300
+ }
301
+ catch (error) {
302
+ if (error instanceof VidexoError && error.code === 'aborted' && !options.signal?.aborted)
303
+ throw timedOut();
304
+ throw error;
305
+ }
306
+ finally {
307
+ clearTimeout(timer);
308
+ options.signal?.removeEventListener('abort', onAbort);
309
+ }
310
+ }
311
+ }