@videxo/sdk 0.1.0 → 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/README.md +12 -3
- package/dist/app-client.d.ts +112 -0
- package/dist/app-client.js +311 -0
- package/dist/client.d.ts +55 -11
- package/dist/client.js +76 -4
- package/dist/defaults.d.ts +7 -0
- package/dist/defaults.js +6 -0
- package/dist/generated/openapi.d.ts +3511 -907
- package/dist/index.d.ts +2 -2
- package/dist/index.js +3 -2
- package/dist/types.d.ts +14 -0
- package/dist/uploads.d.ts +13 -0
- package/dist/uploads.js +73 -1
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -16,8 +16,9 @@ Cloudflare Workers. In a browser, use only `sendUpload()` with a `File`, `Blob`
|
|
|
16
16
|
## Keep the API key on a server
|
|
17
17
|
|
|
18
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.
|
|
20
|
-
|
|
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.
|
|
21
22
|
|
|
22
23
|
## Use it
|
|
23
24
|
|
|
@@ -38,7 +39,7 @@ if (done.state === 'succeeded') {
|
|
|
38
39
|
|---|---|
|
|
39
40
|
| `new Videxo({ apiKey, baseUrl?, fetch?, maxRetries?, timeoutMs? })` | `baseUrl` is `https://api.videxo.io` by default; throws without a key |
|
|
40
41
|
| `jobs.create(body, { idempotencyKey? })` | `POST /v1/jobs`, a command body or an operations body |
|
|
41
|
-
| `transcode`, `thumbnails`, `storyboard`, `gif`, `audio`, `concat` | `POST /v1/<operation>` |
|
|
42
|
+
| `transcode`, `thumbnails`, `storyboard`, `gif`, `audio`, `concat`, `slideshow`, `scenes` | `POST /v1/<operation>` |
|
|
42
43
|
| `jobs.get(id)`, `jobs.cancel(id)`, `jobs.deleteFiles(id)` | `GET /v1/jobs/{id}`, `POST .../cancel`, `DELETE .../files` |
|
|
43
44
|
| `jobs.list(params)`, `jobs.page(params)` | `GET /v1/jobs`: every job as an async iterator, or one page |
|
|
44
45
|
| `jobs.wait(id, { timeoutMs?, intervalMs?, signal? })` | polls until `succeeded`, `failed` or `cancelled` |
|
|
@@ -49,6 +50,14 @@ if (done.state === 'succeeded') {
|
|
|
49
50
|
| `outputs.url(jobId, output)`, `outputs.download(jobId, output)` | `GET /v1/jobs/{id}/outputs/{alias}`: a fresh signed link, or the file |
|
|
50
51
|
| `webhooks.verify(body, headers, secret)`, `verifyWebhook(...)` | Standard Webhooks signature check; answers the event |
|
|
51
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.
|
|
52
61
|
|
|
53
62
|
The docs page [TypeScript SDK](https://videxo.io/docs/sdk) covers each call with examples.
|
|
54
63
|
|
|
@@ -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
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -1,16 +1,11 @@
|
|
|
1
1
|
import { type Client } from 'openapi-fetch';
|
|
2
|
+
import { DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT_MS, DEFAULT_WAIT } from './defaults.ts';
|
|
2
3
|
import type { paths } from './generated/openapi.ts';
|
|
3
4
|
import { type RetryPolicy, type Transport } from './transport.ts';
|
|
4
|
-
import type { AudioRequest, CompileRequest, CompileResult, CompletedUpload, ConcatRequest, CreateUploadRequest, GifRequest, Job, JobList, JobRequest, ListJobsParams, FontList, PresetList, StoryboardRequest, ThumbnailsRequest, TranscodeRequest, Upload, WebhookEvent } from './types.ts';
|
|
5
|
+
import type { AudioRequest, ClientToken, CompileRequest, CompileResult, CompletedUpload, ConcatRequest, CreateClientTokenRequest, CreateUploadRequest, GifRequest, Job, JobList, JobRequest, ListJobsParams, FontList, PresetList, ProbeRequest, ProbeResponse, ScenesRequest, SlideshowRequest, StoryboardRequest, ThumbnailsRequest, TranscodeRequest, Upload, WebhookEvent } from './types.ts';
|
|
5
6
|
import { type UploadData } from './uploads.ts';
|
|
6
7
|
import { type VerifyWebhookOptions, type WebhookHeaders } from './webhooks.ts';
|
|
7
|
-
export
|
|
8
|
-
export declare const DEFAULT_MAX_RETRIES = 2;
|
|
9
|
-
export declare const DEFAULT_TIMEOUT_MS = 60000;
|
|
10
|
-
export declare const DEFAULT_WAIT: Readonly<{
|
|
11
|
-
timeoutMs: number;
|
|
12
|
-
intervalMs: 2000;
|
|
13
|
-
}>;
|
|
8
|
+
export { DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT_MS, DEFAULT_WAIT };
|
|
14
9
|
export type VidexoOptions = {
|
|
15
10
|
/**
|
|
16
11
|
* An API key from the dashboard (`vdx_...`). Keep it on a server, never in browser code. Typed to
|
|
@@ -55,6 +50,24 @@ export type UploadOptions = RequestOptions & {
|
|
|
55
50
|
/** For sending the bytes; no timeout by default, as a large file may take a while. */
|
|
56
51
|
uploadTimeoutMs?: number;
|
|
57
52
|
};
|
|
53
|
+
export type ClientTokenForUploadOptions = RequestOptions & {
|
|
54
|
+
/** Your own opaque id of the end user. */
|
|
55
|
+
subject: string;
|
|
56
|
+
/** The largest file the app may upload, in bytes. */
|
|
57
|
+
maxSizeBytes: number;
|
|
58
|
+
/** Media types the upload may declare, exact or `type/*`; video, audio and images by default. */
|
|
59
|
+
contentTypes?: string[];
|
|
60
|
+
/** Lifetime in seconds, 60 to 21600; 900 by default. */
|
|
61
|
+
ttlSeconds?: number;
|
|
62
|
+
};
|
|
63
|
+
export type ClientTokenForJobOptions = RequestOptions & {
|
|
64
|
+
/** Your own opaque id of the end user; a continuation keeps its chain's. */
|
|
65
|
+
subject?: string;
|
|
66
|
+
/** The app's token id: the new token continues its chain and adds the job. */
|
|
67
|
+
continues?: string;
|
|
68
|
+
/** Lifetime in seconds, 60 to 21600; 900 by default. */
|
|
69
|
+
ttlSeconds?: number;
|
|
70
|
+
};
|
|
58
71
|
type Result<T> = {
|
|
59
72
|
data?: T;
|
|
60
73
|
error?: unknown;
|
|
@@ -77,6 +90,7 @@ export declare class Videxo {
|
|
|
77
90
|
readonly uploads: Uploads;
|
|
78
91
|
readonly outputs: Outputs;
|
|
79
92
|
readonly webhooks: Webhooks;
|
|
93
|
+
readonly clientTokens: ClientTokens;
|
|
80
94
|
/** @internal */
|
|
81
95
|
readonly transport: Transport;
|
|
82
96
|
/** @internal */
|
|
@@ -87,7 +101,7 @@ export declare class Videxo {
|
|
|
87
101
|
/** @internal The data of a 2xx answer, or the error of any other. */
|
|
88
102
|
unwrap<T>(pending: Promise<Result<T>>): Promise<T>;
|
|
89
103
|
/** @internal A job creation at `path`, with an Idempotency-Key that its retries reuse. */
|
|
90
|
-
createAt(path: CreatePath, body: unknown, options?: CreateOptions): Promise<
|
|
104
|
+
createAt<T = Job>(path: CreatePath, body: unknown, options?: CreateOptions): Promise<T>;
|
|
91
105
|
/** `POST /v1/transcode`: one video output from `input`, from a preset or your own options. */
|
|
92
106
|
transcode(body: TranscodeRequest, options?: CreateOptions): Promise<Job>;
|
|
93
107
|
/** `POST /v1/thumbnails`: one image at `at`, or one per time in `times`. */
|
|
@@ -100,8 +114,20 @@ export declare class Videxo {
|
|
|
100
114
|
audio(body: AudioRequest, options?: CreateOptions): Promise<Job>;
|
|
101
115
|
/** `POST /v1/concat`: the `inputs` one after another, as one file. */
|
|
102
116
|
concat(body: ConcatRequest, options?: CreateOptions): Promise<Job>;
|
|
117
|
+
/** `POST /v1/slideshow`: the still `images` as one video, each held for a time, with an optional motion. */
|
|
118
|
+
slideshow(body: SlideshowRequest, options?: CreateOptions): Promise<Job>;
|
|
119
|
+
/**
|
|
120
|
+
* `POST /v1/probe`: the container, duration and streams of `input`, read with ffprobe. The api waits
|
|
121
|
+
* up to 25 seconds; a job still running then is waited for here (`wait`), and its document read from
|
|
122
|
+
* its output file. A job that failed is answered, not thrown: `probe` is null and `error` says why.
|
|
123
|
+
*/
|
|
124
|
+
probe(body: ProbeRequest, options?: CreateOptions & {
|
|
125
|
+
wait?: Omit<WaitOptions, 'signal'>;
|
|
126
|
+
}): Promise<ProbeResponse>;
|
|
127
|
+
/** `POST /v1/scenes`: the scenes of `input`, with their times and scores, as a JSON file. */
|
|
128
|
+
scenes(body: ScenesRequest, options?: CreateOptions): Promise<Job>;
|
|
103
129
|
}
|
|
104
|
-
type CreatePath = '/v1/jobs' | '/v1/transcode' | '/v1/thumbnails' | '/v1/storyboard' | '/v1/gif' | '/v1/audio' | '/v1/concat';
|
|
130
|
+
type CreatePath = '/v1/jobs' | '/v1/transcode' | '/v1/thumbnails' | '/v1/storyboard' | '/v1/gif' | '/v1/audio' | '/v1/concat' | '/v1/slideshow' | '/v1/probe' | '/v1/scenes';
|
|
105
131
|
export declare class Jobs {
|
|
106
132
|
#private;
|
|
107
133
|
constructor(videxo: Videxo);
|
|
@@ -166,8 +192,26 @@ export declare class Outputs {
|
|
|
166
192
|
*/
|
|
167
193
|
download(jobId: string, output: string, options?: RequestOptions): Promise<Response>;
|
|
168
194
|
}
|
|
195
|
+
/**
|
|
196
|
+
* Client tokens for apps and web pages (ADR 016): short-lived, narrowly scoped, minted here on your
|
|
197
|
+
* server. Hand the answer's `token` and `id` to the app, which uses `@videxo/sdk/client`. Minting
|
|
198
|
+
* supersedes nothing, so a lost answer is retried by minting again.
|
|
199
|
+
*/
|
|
200
|
+
export declare class ClientTokens {
|
|
201
|
+
#private;
|
|
202
|
+
constructor(videxo: Videxo);
|
|
203
|
+
/** `POST /v1/client-tokens`: a token with the grant you describe; `continues` refreshes a chain. */
|
|
204
|
+
create(grant: CreateClientTokenRequest, options?: RequestOptions): Promise<ClientToken>;
|
|
205
|
+
/** A token for one upload by `subject`: what an app needs before it knows of any job. */
|
|
206
|
+
forUpload({ subject, maxSizeBytes, contentTypes, ttlSeconds, ...options }: ClientTokenForUploadOptions): Promise<ClientToken>;
|
|
207
|
+
/** A token that reads one job: a new one, or with `continues` the next of the app's chain, adding the job. */
|
|
208
|
+
forJob(jobId: string, { subject, continues, ttlSeconds, ...options }?: ClientTokenForJobOptions): Promise<ClientToken>;
|
|
209
|
+
/** `DELETE /v1/client-tokens/{id}`: ends the token and its whole chain. Revoking again is fine. */
|
|
210
|
+
revoke(id: string, options?: RequestOptions): Promise<void>;
|
|
211
|
+
/** `POST /v1/client-tokens/revoke`: ends every chain of the end user; answers how many tokens. */
|
|
212
|
+
revokeSubject(subject: string, options?: RequestOptions): Promise<number>;
|
|
213
|
+
}
|
|
169
214
|
export declare class Webhooks {
|
|
170
215
|
/** Checks a delivery's Standard Webhooks signature and answers its event (`verifyWebhook`). */
|
|
171
216
|
verify(body: string | Uint8Array | ArrayBuffer, headers: WebhookHeaders, secret: string, options?: VerifyWebhookOptions): Promise<WebhookEvent>;
|
|
172
217
|
}
|
|
173
|
-
export {};
|