@videxo/sdk 0.0.0-stage → 0.1.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 +21 -0
- package/README.md +70 -2
- package/dist/client.d.ts +173 -0
- package/dist/client.js +331 -0
- package/dist/errors.d.ts +80 -0
- package/dist/errors.js +132 -0
- package/dist/generated/openapi.d.ts +6812 -0
- package/dist/generated/openapi.js +2 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/transport.d.ts +45 -0
- package/dist/transport.js +148 -0
- package/dist/types.d.ts +56 -0
- package/dist/types.js +1 -0
- package/dist/uploads.d.ts +19 -0
- package/dist/uploads.js +42 -0
- package/dist/webhooks.d.ts +18 -0
- package/dist/webhooks.js +85 -0
- package/package.json +51 -4
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT_MS, DEFAULT_WAIT, Fonts, Jobs, Outputs, Presets, Uploads, Videxo, Webhooks, type CreateOptions, type RequestOptions, type UploadOptions, type VidexoOptions, type WaitOptions, } from './client.ts';
|
|
2
|
+
export { SDK_ERROR_CODES, VidexoError, VidexoWebhookError, type ApiErrorCode, type SdkErrorCode, type VidexoErrorCode, type VidexoWebhookErrorCode, } from './errors.ts';
|
|
3
|
+
export { MAX_RETRY_AFTER_SECONDS, type RetryPolicy } from './transport.ts';
|
|
4
|
+
export { sendUpload, type SendUploadOptions, type UploadData } from './uploads.ts';
|
|
5
|
+
export { WEBHOOK_TOLERANCE_SECONDS, verifyWebhook, type VerifyWebhookOptions, type WebhookHeaders } from './webhooks.ts';
|
|
6
|
+
export type * from './types.ts';
|
|
7
|
+
export type { components, paths } from './generated/openapi.ts';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// @videxo/sdk: the TypeScript client of the Videxo API (README.md). Runs on Node 20+, Bun, Deno and
|
|
2
|
+
// Workers, and `sendUpload()` in browsers: it uses only fetch, Web Streams and WebCrypto.
|
|
3
|
+
export { DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT_MS, DEFAULT_WAIT, Fonts, Jobs, Outputs, Presets, Uploads, Videxo, Webhooks, } from "./client.js";
|
|
4
|
+
export { SDK_ERROR_CODES, VidexoError, VidexoWebhookError, } from "./errors.js";
|
|
5
|
+
export { MAX_RETRY_AFTER_SECONDS } from "./transport.js";
|
|
6
|
+
export { sendUpload } from "./uploads.js";
|
|
7
|
+
export { WEBHOOK_TOLERANCE_SECONDS, verifyWebhook } from "./webhooks.js";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* - `safe`: may run twice (reads, cancel, compile, deleting files, and creations with an
|
|
3
|
+
* Idempotency-Key): every retryable failure is retried.
|
|
4
|
+
* - `rate_limit`: not safe to repeat (`POST /v1/uploads` has no Idempotency-Key): retried only after
|
|
5
|
+
* a 429, which the API answers before it does anything.
|
|
6
|
+
* - `never`: sent once (the one-time upload URL).
|
|
7
|
+
*/
|
|
8
|
+
export type RetryPolicy = 'safe' | 'rate_limit' | 'never';
|
|
9
|
+
/** The `fetch` the SDK sends with: the runtime's own, or one you pass. */
|
|
10
|
+
export type Fetch = (input: Request, init?: RequestInit) => Promise<Response>;
|
|
11
|
+
/**
|
|
12
|
+
* The runtime's `fetch`, called unbound (some runtimes refuse it called as a method of another
|
|
13
|
+
* object). The cast is for projects with both Bun's and Node's types, which disagree about `Request`.
|
|
14
|
+
*/
|
|
15
|
+
export declare const runtimeFetch: Fetch;
|
|
16
|
+
export type Transport = {
|
|
17
|
+
fetch: Fetch;
|
|
18
|
+
maxRetries: number;
|
|
19
|
+
/** Per attempt, until the answer's headers (and its body when `buffer` is set) arrive; null for none. */
|
|
20
|
+
timeoutMs: number | null;
|
|
21
|
+
/** Removes the API key from a text that might hold it (an error message of a custom fetch). */
|
|
22
|
+
redact?: (text: string) => string;
|
|
23
|
+
/** Injected in tests. */
|
|
24
|
+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
25
|
+
random?: () => number;
|
|
26
|
+
};
|
|
27
|
+
export type SendOptions = {
|
|
28
|
+
retry: RetryPolicy;
|
|
29
|
+
signal?: AbortSignal | undefined;
|
|
30
|
+
timeoutMs?: number | null | undefined;
|
|
31
|
+
/** Read the whole body inside the attempt (JSON answers); false hands the stream on (downloads). */
|
|
32
|
+
buffer: boolean;
|
|
33
|
+
};
|
|
34
|
+
/** A Retry-After longer than this is not waited for: the answer is returned as it is. */
|
|
35
|
+
export declare const MAX_RETRY_AFTER_SECONDS = 60;
|
|
36
|
+
/** The wait before retry `attempt` (0-based) without a Retry-After: 0.5 s doubling to 8 s, with jitter. */
|
|
37
|
+
export declare function backoffMs(attempt: number, random?: () => number): number;
|
|
38
|
+
export declare function abortableSleep(ms: number, signal?: AbortSignal): Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* `error` with the API key removed from its message: itself when the message does not hold the key,
|
|
41
|
+
* otherwise a new error of the same name with the redacted message and no cause (a thrown string is
|
|
42
|
+
* redacted as it is).
|
|
43
|
+
*/
|
|
44
|
+
export declare function redacted(transport: Pick<Transport, 'redact'>, error: unknown): unknown;
|
|
45
|
+
export declare function send(transport: Transport, request: Request, options: SendOptions): Promise<Response>;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { VidexoError, retryAfterSeconds, sdkError } from "./errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* The runtime's `fetch`, called unbound (some runtimes refuse it called as a method of another
|
|
4
|
+
* object). The cast is for projects with both Bun's and Node's types, which disagree about `Request`.
|
|
5
|
+
*/
|
|
6
|
+
export const runtimeFetch = (input, init) => fetch(input, init);
|
|
7
|
+
const RETRY_STATUSES = new Set([429, 502, 503, 504]);
|
|
8
|
+
/** A Retry-After longer than this is not waited for: the answer is returned as it is. */
|
|
9
|
+
export const MAX_RETRY_AFTER_SECONDS = 60;
|
|
10
|
+
const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]);
|
|
11
|
+
/** The wait before retry `attempt` (0-based) without a Retry-After: 0.5 s doubling to 8 s, with jitter. */
|
|
12
|
+
export function backoffMs(attempt, random = Math.random) {
|
|
13
|
+
return Math.min(8_000, 500 * 2 ** attempt) * (0.5 + random() / 2);
|
|
14
|
+
}
|
|
15
|
+
export function abortableSleep(ms, signal) {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
if (signal?.aborted)
|
|
18
|
+
return reject(sdkError('aborted', 'The request was aborted.', { cause: signal.reason }));
|
|
19
|
+
const onAbort = () => {
|
|
20
|
+
clearTimeout(timer);
|
|
21
|
+
reject(sdkError('aborted', 'The request was aborted.', { cause: 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
|
+
/**
|
|
31
|
+
* `error` with the API key removed from its message: itself when the message does not hold the key,
|
|
32
|
+
* otherwise a new error of the same name with the redacted message and no cause (a thrown string is
|
|
33
|
+
* redacted as it is).
|
|
34
|
+
*/
|
|
35
|
+
export function redacted(transport, error) {
|
|
36
|
+
if (!transport.redact || error instanceof VidexoError)
|
|
37
|
+
return error;
|
|
38
|
+
if (typeof error === 'string')
|
|
39
|
+
return transport.redact(error);
|
|
40
|
+
if (!(error instanceof Error))
|
|
41
|
+
return error;
|
|
42
|
+
const message = transport.redact(error.message);
|
|
43
|
+
const stack = transport.redact(String(error.stack));
|
|
44
|
+
if (message === error.message && stack === String(error.stack))
|
|
45
|
+
return error;
|
|
46
|
+
const copy = new Error(message);
|
|
47
|
+
copy.name = error.name;
|
|
48
|
+
copy.stack = stack;
|
|
49
|
+
return copy;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* `response` with a body that calls `release` once it is read to the end, fails or is cancelled, so
|
|
53
|
+
* the caller's abort signal keeps reaching the fetch while a download streams.
|
|
54
|
+
*/
|
|
55
|
+
function linkedBody(response, release) {
|
|
56
|
+
if (!response.body) {
|
|
57
|
+
release();
|
|
58
|
+
return response;
|
|
59
|
+
}
|
|
60
|
+
const reader = response.body.getReader();
|
|
61
|
+
const body = new ReadableStream({
|
|
62
|
+
async pull(controller) {
|
|
63
|
+
try {
|
|
64
|
+
const { done, value } = await reader.read();
|
|
65
|
+
if (done) {
|
|
66
|
+
release();
|
|
67
|
+
controller.close();
|
|
68
|
+
}
|
|
69
|
+
else
|
|
70
|
+
controller.enqueue(value);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
release();
|
|
74
|
+
controller.error(error);
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
async cancel(reason) {
|
|
78
|
+
release();
|
|
79
|
+
await reader.cancel(reason);
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
83
|
+
}
|
|
84
|
+
const describe = (request) => `${request.method} ${new URL(request.url).pathname}`;
|
|
85
|
+
export async function send(transport, request, options) {
|
|
86
|
+
const sleep = transport.sleep ?? abortableSleep;
|
|
87
|
+
const random = transport.random ?? Math.random;
|
|
88
|
+
const timeoutMs = options.timeoutMs === undefined ? transport.timeoutMs : options.timeoutMs;
|
|
89
|
+
const maxRetries = options.retry === 'never' ? 0 : transport.maxRetries;
|
|
90
|
+
for (let attempt = 0;; attempt++) {
|
|
91
|
+
const last = attempt >= maxRetries;
|
|
92
|
+
if (options.signal?.aborted)
|
|
93
|
+
throw sdkError('aborted', `${describe(request)} was aborted.`, { cause: options.signal.reason });
|
|
94
|
+
const controller = new AbortController();
|
|
95
|
+
let timedOut = false;
|
|
96
|
+
const timer = timeoutMs === null
|
|
97
|
+
? undefined
|
|
98
|
+
: setTimeout(() => {
|
|
99
|
+
timedOut = true;
|
|
100
|
+
controller.abort();
|
|
101
|
+
}, timeoutMs);
|
|
102
|
+
const onAbort = () => controller.abort(options.signal?.reason);
|
|
103
|
+
options.signal?.addEventListener('abort', onAbort, { once: true });
|
|
104
|
+
const done = () => {
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
options.signal?.removeEventListener('abort', onAbort);
|
|
107
|
+
};
|
|
108
|
+
let failure;
|
|
109
|
+
try {
|
|
110
|
+
// `as Request`: with both Bun's and Node's types, `clone()` is typed as the other `Request`.
|
|
111
|
+
const response = await transport.fetch(last ? request : request.clone(), { signal: controller.signal });
|
|
112
|
+
const retryable = RETRY_STATUSES.has(response.status) && (options.retry === 'safe' || response.status === 429);
|
|
113
|
+
const retryAfter = retryAfterSeconds(response.headers.get('retry-after'));
|
|
114
|
+
if (!last && retryable && (retryAfter === null || retryAfter <= MAX_RETRY_AFTER_SECONDS)) {
|
|
115
|
+
done();
|
|
116
|
+
await response.body?.cancel().catch(() => undefined);
|
|
117
|
+
await sleep(retryAfter === null ? backoffMs(attempt, random) : retryAfter * 1000, options.signal);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (!options.buffer) {
|
|
121
|
+
// The timeout covers the headers only; the caller's signal stays linked until the body is read.
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
return linkedBody(response, () => options.signal?.removeEventListener('abort', onAbort));
|
|
124
|
+
}
|
|
125
|
+
const text = await response.text();
|
|
126
|
+
done();
|
|
127
|
+
return new Response(NULL_BODY_STATUSES.has(response.status) ? null : text, {
|
|
128
|
+
status: response.status,
|
|
129
|
+
statusText: response.statusText,
|
|
130
|
+
headers: response.headers,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
done();
|
|
135
|
+
if (error instanceof VidexoError)
|
|
136
|
+
throw error;
|
|
137
|
+
if (options.signal?.aborted)
|
|
138
|
+
throw sdkError('aborted', `${describe(request)} was aborted.`, { cause: options.signal.reason });
|
|
139
|
+
const cause = redacted(transport, error);
|
|
140
|
+
failure = timedOut
|
|
141
|
+
? sdkError('timeout', `${describe(request)} got no answer within ${timeoutMs} ms.`, { cause })
|
|
142
|
+
: sdkError('network_error', `${describe(request)} failed: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
143
|
+
}
|
|
144
|
+
if (last || options.retry !== 'safe')
|
|
145
|
+
throw failure;
|
|
146
|
+
await sleep(backoffMs(attempt, random), options.signal);
|
|
147
|
+
}
|
|
148
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { components, paths } from './generated/openapi.ts';
|
|
2
|
+
type Schemas = components['schemas'];
|
|
3
|
+
type JsonBody<T> = T extends {
|
|
4
|
+
requestBody: {
|
|
5
|
+
content: {
|
|
6
|
+
'application/json': infer B;
|
|
7
|
+
};
|
|
8
|
+
};
|
|
9
|
+
} ? B : never;
|
|
10
|
+
export type Job = Schemas['Job'];
|
|
11
|
+
export type JobState = Schemas['JobState'];
|
|
12
|
+
export type JobOutput = Schemas['JobOutput'];
|
|
13
|
+
export type JobList = Schemas['JobList'];
|
|
14
|
+
/** `POST /v1/jobs`: a command body or an operations body. */
|
|
15
|
+
export type JobRequest = JsonBody<paths['/v1/jobs']['post']>;
|
|
16
|
+
/** The command body: `command`, `input_files`, `output_files`. */
|
|
17
|
+
export type CommandJobRequest = Schemas['CreateJobRequest'];
|
|
18
|
+
/** The operations body: `inputs`, `outputs`. */
|
|
19
|
+
export type OperationsJobRequest = Schemas['OperationsJobRequest'];
|
|
20
|
+
export type JobSpec = Schemas['JobSpec'];
|
|
21
|
+
export type Output = Schemas['Output'];
|
|
22
|
+
export type ListJobsParams = NonNullable<paths['/v1/jobs']['get']['parameters']['query']>;
|
|
23
|
+
export type CompileRequest = JsonBody<paths['/v1/jobs/compile']['post']>;
|
|
24
|
+
export type CompileResult = Schemas['CompileResult'];
|
|
25
|
+
export type TranscodeRequest = JsonBody<paths['/v1/transcode']['post']>;
|
|
26
|
+
export type ThumbnailsRequest = JsonBody<paths['/v1/thumbnails']['post']>;
|
|
27
|
+
export type StoryboardRequest = JsonBody<paths['/v1/storyboard']['post']>;
|
|
28
|
+
export type GifRequest = JsonBody<paths['/v1/gif']['post']>;
|
|
29
|
+
export type AudioRequest = JsonBody<paths['/v1/audio']['post']>;
|
|
30
|
+
export type ConcatRequest = JsonBody<paths['/v1/concat']['post']>;
|
|
31
|
+
export type Font = Schemas['Font'];
|
|
32
|
+
export type FontList = Schemas['FontList'];
|
|
33
|
+
export type Preset = Schemas['Preset'];
|
|
34
|
+
export type PresetList = Schemas['PresetList'];
|
|
35
|
+
export type Upload = Schemas['Upload'];
|
|
36
|
+
export type CreateUploadRequest = JsonBody<paths['/v1/uploads']['post']>;
|
|
37
|
+
/** The answer of the upload URL once every byte arrived; use `input_url` as a job input. */
|
|
38
|
+
export type CompletedUpload = {
|
|
39
|
+
id: string;
|
|
40
|
+
input_url: string;
|
|
41
|
+
size_bytes: number;
|
|
42
|
+
sha256: string;
|
|
43
|
+
content_type: string;
|
|
44
|
+
expires_at: string;
|
|
45
|
+
};
|
|
46
|
+
export type Problem = Schemas['Problem'];
|
|
47
|
+
export type FieldError = Schemas['FieldError'];
|
|
48
|
+
export type CommandProblem = Schemas['CommandProblem'];
|
|
49
|
+
export type WebhookEventType = Schemas['WebhookEventType'];
|
|
50
|
+
/** The body of every webhook delivery: the event, when the job ended, and the job. */
|
|
51
|
+
export type WebhookEvent = {
|
|
52
|
+
type: WebhookEventType;
|
|
53
|
+
timestamp: string;
|
|
54
|
+
data: Job;
|
|
55
|
+
};
|
|
56
|
+
export {};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CompletedUpload } from './types.ts';
|
|
2
|
+
/** What `uploads.upload()` and `sendUpload()` take: a file, a blob, bytes or a stream of bytes. */
|
|
3
|
+
export type UploadData = Blob | ArrayBuffer | Uint8Array | ReadableStream<Uint8Array>;
|
|
4
|
+
export type SendUploadOptions = {
|
|
5
|
+
/** The content type the upload was created with; `application/octet-stream` by default. */
|
|
6
|
+
contentType?: string;
|
|
7
|
+
fetch?(input: Request, init?: RequestInit): Promise<Response>;
|
|
8
|
+
signal?: AbortSignal;
|
|
9
|
+
/** No timeout by default: a large file may take a while. */
|
|
10
|
+
timeoutMs?: number | null;
|
|
11
|
+
};
|
|
12
|
+
/** The byte length of `data`, when it can be known without reading it. */
|
|
13
|
+
export declare function sizeOf(data: UploadData): number | null;
|
|
14
|
+
/**
|
|
15
|
+
* Sends the bytes to an upload's one-time `upload_url` and answers the completed upload. It needs no
|
|
16
|
+
* API key, so it also runs in a browser with an upload URL your server created. The URL is used up
|
|
17
|
+
* once the bytes start to flow, so this request is never retried.
|
|
18
|
+
*/
|
|
19
|
+
export declare function sendUpload(uploadUrl: string, data: UploadData, options?: SendUploadOptions): Promise<CompletedUpload>;
|
package/dist/uploads.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { errorFromResponse, sdkError } from "./errors.js";
|
|
2
|
+
import { runtimeFetch, send } from "./transport.js";
|
|
3
|
+
/** The byte length of `data`, when it can be known without reading it. */
|
|
4
|
+
export function sizeOf(data) {
|
|
5
|
+
if (typeof Blob !== 'undefined' && data instanceof Blob)
|
|
6
|
+
return data.size;
|
|
7
|
+
if (data instanceof ArrayBuffer || ArrayBuffer.isView(data))
|
|
8
|
+
return data.byteLength;
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Sends the bytes to an upload's one-time `upload_url` and answers the completed upload. It needs no
|
|
13
|
+
* API key, so it also runs in a browser with an upload URL your server created. The URL is used up
|
|
14
|
+
* once the bytes start to flow, so this request is never retried.
|
|
15
|
+
*/
|
|
16
|
+
export async function sendUpload(uploadUrl, data, options = {}) {
|
|
17
|
+
const stream = typeof ReadableStream !== 'undefined' && data instanceof ReadableStream;
|
|
18
|
+
// `duplex` is required for a stream body (Node, Deno, Chrome); the DOM types do not have it yet.
|
|
19
|
+
const init = {
|
|
20
|
+
method: 'PUT',
|
|
21
|
+
headers: { 'Content-Type': options.contentType ?? 'application/octet-stream' },
|
|
22
|
+
body: data,
|
|
23
|
+
...(stream ? { duplex: 'half' } : {}),
|
|
24
|
+
};
|
|
25
|
+
const request = new Request(uploadUrl, init);
|
|
26
|
+
const userFetch = options.fetch;
|
|
27
|
+
const response = await send({ fetch: userFetch ? (input, init) => userFetch(input, init) : runtimeFetch, maxRetries: 0, timeoutMs: options.timeoutMs ?? null }, request, { retry: 'never', signal: options.signal, buffer: true });
|
|
28
|
+
const text = await response.text();
|
|
29
|
+
let body = text;
|
|
30
|
+
try {
|
|
31
|
+
body = JSON.parse(text);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// Not JSON: kept as text for the error.
|
|
35
|
+
}
|
|
36
|
+
if (!response.ok)
|
|
37
|
+
throw errorFromResponse(response, body);
|
|
38
|
+
const value = body;
|
|
39
|
+
if (typeof value !== 'object' || value === null || typeof value.input_url !== 'string')
|
|
40
|
+
throw sdkError('invalid_response', 'The upload URL answered without the completed upload.', { status: response.status, body });
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { WebhookEvent } from './types.ts';
|
|
2
|
+
/** How far a delivery's `webhook-timestamp` may be from now, either way. */
|
|
3
|
+
export declare const WEBHOOK_TOLERANCE_SECONDS: number;
|
|
4
|
+
export type WebhookHeaders = Headers | Record<string, string | string[] | undefined | null>;
|
|
5
|
+
export type VerifyWebhookOptions = {
|
|
6
|
+
/** Seconds the timestamp may differ from now; 300 by default. */
|
|
7
|
+
toleranceSeconds?: number;
|
|
8
|
+
/** The current time in milliseconds, for tests. */
|
|
9
|
+
now?: number;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Checks a webhook delivery and answers its event. `body` is the raw request body, exactly as it
|
|
13
|
+
* arrived (read it as text before any JSON parser touches it); `headers` the request's headers;
|
|
14
|
+
* `secret` the account's `whsec_` signing secret. Throws a `VidexoWebhookError` when the headers are
|
|
15
|
+
* missing, the timestamp is more than five minutes away, no `v1` signature matches or the body is not
|
|
16
|
+
* an event.
|
|
17
|
+
*/
|
|
18
|
+
export declare function verifyWebhook(body: string | Uint8Array | ArrayBuffer, headers: WebhookHeaders, secret: string, options?: VerifyWebhookOptions): Promise<WebhookEvent>;
|
package/dist/webhooks.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { VidexoWebhookError } from "./errors.js";
|
|
2
|
+
// Standard Webhooks verification with WebCrypto, so it runs in every runtime the SDK does. A
|
|
3
|
+
// delivery is signed `v1,` and base64 HMAC-SHA-256 of `webhook-id.webhook-timestamp.body` with the
|
|
4
|
+
// key after `whsec_`; after a secret rotation it carries a second signature, space separated.
|
|
5
|
+
/** How far a delivery's `webhook-timestamp` may be from now, either way. */
|
|
6
|
+
export const WEBHOOK_TOLERANCE_SECONDS = 5 * 60;
|
|
7
|
+
const encoder = new TextEncoder();
|
|
8
|
+
function header(headers, name) {
|
|
9
|
+
if (typeof Headers !== 'undefined' && headers instanceof Headers)
|
|
10
|
+
return headers.get(name);
|
|
11
|
+
const record = headers;
|
|
12
|
+
const key = Object.keys(record).find((candidate) => candidate.toLowerCase() === name);
|
|
13
|
+
const value = key === undefined ? undefined : record[key];
|
|
14
|
+
if (Array.isArray(value))
|
|
15
|
+
return value.join(' ');
|
|
16
|
+
return value ?? null;
|
|
17
|
+
}
|
|
18
|
+
function fromBase64(text) {
|
|
19
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(text) || text.length % 4 !== 0)
|
|
20
|
+
return null;
|
|
21
|
+
try {
|
|
22
|
+
return Uint8Array.from(atob(text), (char) => char.charCodeAt(0));
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Checks a webhook delivery and answers its event. `body` is the raw request body, exactly as it
|
|
30
|
+
* arrived (read it as text before any JSON parser touches it); `headers` the request's headers;
|
|
31
|
+
* `secret` the account's `whsec_` signing secret. Throws a `VidexoWebhookError` when the headers are
|
|
32
|
+
* missing, the timestamp is more than five minutes away, no `v1` signature matches or the body is not
|
|
33
|
+
* an event.
|
|
34
|
+
*/
|
|
35
|
+
export async function verifyWebhook(body, headers, secret, options = {}) {
|
|
36
|
+
const id = header(headers, 'webhook-id');
|
|
37
|
+
const timestamp = header(headers, 'webhook-timestamp');
|
|
38
|
+
const signatures = header(headers, 'webhook-signature');
|
|
39
|
+
if (!id || !timestamp || !signatures)
|
|
40
|
+
throw new VidexoWebhookError('missing_headers', 'The webhook-id, webhook-timestamp and webhook-signature headers are required.');
|
|
41
|
+
const key = fromBase64(secret.startsWith('whsec_') ? secret.slice('whsec_'.length) : secret);
|
|
42
|
+
if (!key || key.length === 0)
|
|
43
|
+
throw new VidexoWebhookError('invalid_secret', 'The secret is not a whsec_ signing secret.');
|
|
44
|
+
if (!/^\d{1,15}$/.test(timestamp))
|
|
45
|
+
throw new VidexoWebhookError('invalid_timestamp', 'The webhook-timestamp header is not a number.');
|
|
46
|
+
const tolerance = options.toleranceSeconds ?? WEBHOOK_TOLERANCE_SECONDS;
|
|
47
|
+
const now = Math.floor((options.now ?? Date.now()) / 1000);
|
|
48
|
+
if (Math.abs(now - Number(timestamp)) > tolerance)
|
|
49
|
+
throw new VidexoWebhookError('invalid_timestamp', 'The webhook-timestamp is too far from the current time.');
|
|
50
|
+
// The signature covers the body's bytes exactly as they arrived; they are decoded only once it matched.
|
|
51
|
+
const bytes = typeof body === 'string' ? encoder.encode(body) : body instanceof ArrayBuffer ? new Uint8Array(body) : body;
|
|
52
|
+
const prefix = encoder.encode(`${id}.${timestamp}.`);
|
|
53
|
+
const signed = new Uint8Array(prefix.length + bytes.length);
|
|
54
|
+
signed.set(prefix);
|
|
55
|
+
signed.set(bytes, prefix.length);
|
|
56
|
+
const hmac = await crypto.subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
|
|
57
|
+
let matched = false;
|
|
58
|
+
for (const entry of signatures.split(' ')) {
|
|
59
|
+
const [version, signature] = entry.split(',', 2);
|
|
60
|
+
if (version !== 'v1' || !signature)
|
|
61
|
+
continue;
|
|
62
|
+
const mac = fromBase64(signature);
|
|
63
|
+
// crypto.subtle.verify compares in constant time.
|
|
64
|
+
if (mac && (await crypto.subtle.verify('HMAC', hmac, mac, signed)))
|
|
65
|
+
matched = true;
|
|
66
|
+
}
|
|
67
|
+
if (!matched)
|
|
68
|
+
throw new VidexoWebhookError('invalid_signature', 'No v1 signature matches the body.');
|
|
69
|
+
let event;
|
|
70
|
+
try {
|
|
71
|
+
const text = typeof body === 'string' ? body : new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
72
|
+
event = JSON.parse(text);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
throw new VidexoWebhookError('invalid_payload', 'The body is not JSON in UTF-8.');
|
|
76
|
+
}
|
|
77
|
+
const value = event;
|
|
78
|
+
if (typeof value !== 'object' ||
|
|
79
|
+
value === null ||
|
|
80
|
+
typeof value.type !== 'string' ||
|
|
81
|
+
typeof value.data !== 'object' ||
|
|
82
|
+
value.data === null)
|
|
83
|
+
throw new VidexoWebhookError('invalid_payload', 'The body is not a webhook event.');
|
|
84
|
+
return event;
|
|
85
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@videxo/sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The TypeScript client of the Videxo API: hosted ffmpeg jobs over HTTP.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Videxo",
|
|
7
|
+
"homepage": "https://videxo.io/docs/sdk",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"videxo",
|
|
10
|
+
"ffmpeg",
|
|
11
|
+
"video",
|
|
12
|
+
"transcode",
|
|
13
|
+
"api",
|
|
14
|
+
"sdk"
|
|
15
|
+
],
|
|
16
|
+
"type": "module",
|
|
17
|
+
"sideEffects": false,
|
|
18
|
+
"main": "./dist/index.js",
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"import": "./dist/index.js",
|
|
24
|
+
"default": "./dist/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"README.md",
|
|
31
|
+
"LICENSE"
|
|
32
|
+
],
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=20"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"check": "tsc -p tsconfig.json && tsc -p tsconfig.test.json && bun run build && bun scripts/pack-check.ts",
|
|
41
|
+
"build": "bun scripts/build.ts",
|
|
42
|
+
"generate": "bun scripts/generate.ts",
|
|
43
|
+
"test": "vitest run"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"openapi-fetch": "^0.17.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"openapi-typescript": "^7.13.0",
|
|
50
|
+
"typescript": "~6.0.3",
|
|
51
|
+
"vitest": "^5.1.0"
|
|
52
|
+
}
|
|
53
|
+
}
|