@flixly/sdk 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 +126 -0
- package/dist/index.cjs +254 -0
- package/dist/index.d.cts +238 -0
- package/dist/index.d.ts +238 -0
- package/dist/index.js +227 -0
- package/package.json +51 -0
- package/src/index.ts +467 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @flixly/sdk — Official SDK for the Flixly AI API.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```ts
|
|
6
|
+
* import { Flixly } from "@flixly/sdk";
|
|
7
|
+
*
|
|
8
|
+
* const flixly = new Flixly({ apiKey: process.env.FLIXLY_API_KEY! });
|
|
9
|
+
*
|
|
10
|
+
* // Quick: kick off a generation and wait for it.
|
|
11
|
+
* const result = await flixly.generateAndWait({
|
|
12
|
+
* model: "flux-dev",
|
|
13
|
+
* prompt: "A cat wearing a top hat, oil painting style",
|
|
14
|
+
* type: "TEXT_TO_IMAGE",
|
|
15
|
+
* input: { aspect_ratio: "1:1", resolution: "1K" },
|
|
16
|
+
* });
|
|
17
|
+
* console.log(result.output_url);
|
|
18
|
+
*
|
|
19
|
+
* // Or: subscribe via webhook (preferred for video / slow models).
|
|
20
|
+
* await flixly.generate({
|
|
21
|
+
* model: "veo-3-fast",
|
|
22
|
+
* prompt: "Cinematic shot of mountains at dawn",
|
|
23
|
+
* webhook_url: "https://example.com/flixly-webhook",
|
|
24
|
+
* });
|
|
25
|
+
*
|
|
26
|
+
* // Verify incoming webhooks:
|
|
27
|
+
* const valid = await Flixly.verifyWebhookSignature({
|
|
28
|
+
* secret: process.env.FLIXLY_WEBHOOK_SECRET!,
|
|
29
|
+
* timestamp: req.headers["x-flixly-timestamp"]!,
|
|
30
|
+
* signature: req.headers["x-flixly-signature"]!,
|
|
31
|
+
* body: req.rawBody,
|
|
32
|
+
* });
|
|
33
|
+
* if (!valid) throw new Error("invalid signature");
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
export const BASE_URL = "https://www.flixly.ai";
|
|
38
|
+
|
|
39
|
+
// ============================================
|
|
40
|
+
// TYPES
|
|
41
|
+
// ============================================
|
|
42
|
+
|
|
43
|
+
export type TaskType =
|
|
44
|
+
| "TEXT_TO_IMAGE"
|
|
45
|
+
| "IMAGE_TO_IMAGE"
|
|
46
|
+
| "TEXT_TO_VIDEO"
|
|
47
|
+
| "IMAGE_TO_VIDEO"
|
|
48
|
+
| "VIDEO_TO_VIDEO"
|
|
49
|
+
| "TEXT_TO_SPEECH"
|
|
50
|
+
| "VOICE_CLONE"
|
|
51
|
+
| "MUSIC_GENERATION"
|
|
52
|
+
| "UPSCALE"
|
|
53
|
+
| "REMOVE_BACKGROUND";
|
|
54
|
+
|
|
55
|
+
export type GenerationStatus = "pending" | "processing" | "completed" | "failed";
|
|
56
|
+
|
|
57
|
+
export interface GenerateInput {
|
|
58
|
+
aspect_ratio?: string;
|
|
59
|
+
resolution?: string;
|
|
60
|
+
duration?: string | number;
|
|
61
|
+
image_url?: string;
|
|
62
|
+
video_url?: string;
|
|
63
|
+
audio_url?: string;
|
|
64
|
+
[key: string]: unknown;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface GenerateRequest {
|
|
68
|
+
/** Model id from `listModels()`. */
|
|
69
|
+
model: string;
|
|
70
|
+
/** Text prompt. */
|
|
71
|
+
prompt: string;
|
|
72
|
+
/** Task type. Inferred from the model when omitted. */
|
|
73
|
+
type?: TaskType;
|
|
74
|
+
/** Model-specific parameters (aspect ratio, resolution, image_url, etc.). */
|
|
75
|
+
input?: GenerateInput;
|
|
76
|
+
/**
|
|
77
|
+
* If set, we POST a signed event to this URL when the generation
|
|
78
|
+
* finishes. Overrides the key's default. Verify with
|
|
79
|
+
* `Flixly.verifyWebhookSignature()`.
|
|
80
|
+
*/
|
|
81
|
+
webhook_url?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface Generation {
|
|
85
|
+
id: string;
|
|
86
|
+
status: GenerationStatus;
|
|
87
|
+
type: string;
|
|
88
|
+
model: string | null;
|
|
89
|
+
output_url: string | null;
|
|
90
|
+
status_url: string;
|
|
91
|
+
webhook_url?: string | null;
|
|
92
|
+
credits_charged: number;
|
|
93
|
+
error?: string | null;
|
|
94
|
+
created_at: string;
|
|
95
|
+
completed_at: string | null;
|
|
96
|
+
message?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface ChatMessage {
|
|
100
|
+
role: "system" | "user" | "assistant";
|
|
101
|
+
content: string | Array<{ type: string; text?: string; image_url?: { url: string } }>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface ChatRequest {
|
|
105
|
+
model: string;
|
|
106
|
+
messages: ChatMessage[];
|
|
107
|
+
stream?: boolean;
|
|
108
|
+
max_tokens?: number;
|
|
109
|
+
temperature?: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface ChatResponse {
|
|
113
|
+
id: string;
|
|
114
|
+
object: "chat.completion";
|
|
115
|
+
created: number;
|
|
116
|
+
model: string;
|
|
117
|
+
choices: Array<{
|
|
118
|
+
index: number;
|
|
119
|
+
message: { role: string; content: string };
|
|
120
|
+
finish_reason: string;
|
|
121
|
+
}>;
|
|
122
|
+
usage: {
|
|
123
|
+
prompt_tokens: number;
|
|
124
|
+
completion_tokens: number;
|
|
125
|
+
total_tokens: number;
|
|
126
|
+
};
|
|
127
|
+
credits_charged: number;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface FlixlyModel {
|
|
131
|
+
id: string;
|
|
132
|
+
name: string;
|
|
133
|
+
type: string;
|
|
134
|
+
capabilities: string[];
|
|
135
|
+
estimated_credits?: number | null;
|
|
136
|
+
estimated_credits_per_1k_tokens?: number | null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface Account {
|
|
140
|
+
credits: { subscription: number; bonus: number; total: number };
|
|
141
|
+
plan: { name: string; parallel_limit: number; rate_limit: number; renews_at: string | null };
|
|
142
|
+
usage: { total_requests: number; total_credits_used: number; completed: number; failed: number; pending: number };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface RateLimit {
|
|
146
|
+
/** Requests allowed per minute on this key's plan. */
|
|
147
|
+
limit: number;
|
|
148
|
+
/** Requests left in the current 60s window at the time of the response. */
|
|
149
|
+
remaining: number;
|
|
150
|
+
/** Unix epoch seconds when the window resets. */
|
|
151
|
+
resetAtSec: number;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** A successful API response, with rate-limit headers parsed out. */
|
|
155
|
+
export interface FlixlyResponse<T> {
|
|
156
|
+
data: T;
|
|
157
|
+
rateLimit: RateLimit | null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ============================================
|
|
161
|
+
// ERRORS
|
|
162
|
+
// ============================================
|
|
163
|
+
|
|
164
|
+
export class FlixlyError extends Error {
|
|
165
|
+
readonly status: number;
|
|
166
|
+
readonly code: string;
|
|
167
|
+
readonly details?: Record<string, unknown>;
|
|
168
|
+
readonly rateLimit: RateLimit | null;
|
|
169
|
+
|
|
170
|
+
constructor(args: {
|
|
171
|
+
status: number;
|
|
172
|
+
code: string;
|
|
173
|
+
message: string;
|
|
174
|
+
details?: Record<string, unknown>;
|
|
175
|
+
rateLimit: RateLimit | null;
|
|
176
|
+
}) {
|
|
177
|
+
super(args.message);
|
|
178
|
+
this.name = "FlixlyError";
|
|
179
|
+
this.status = args.status;
|
|
180
|
+
this.code = args.code;
|
|
181
|
+
this.details = args.details;
|
|
182
|
+
this.rateLimit = args.rateLimit;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ============================================
|
|
187
|
+
// CLIENT
|
|
188
|
+
// ============================================
|
|
189
|
+
|
|
190
|
+
export interface FlixlyOptions {
|
|
191
|
+
apiKey: string;
|
|
192
|
+
/** Override the API base URL (defaults to https://www.flixly.ai). */
|
|
193
|
+
baseUrl?: string;
|
|
194
|
+
/** Custom fetch implementation. Defaults to global `fetch`. */
|
|
195
|
+
fetch?: typeof fetch;
|
|
196
|
+
/** Per-request timeout in ms. Defaults to 120_000 (2 minutes). */
|
|
197
|
+
timeoutMs?: number;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export class Flixly {
|
|
201
|
+
private readonly apiKey: string;
|
|
202
|
+
private readonly baseUrl: string;
|
|
203
|
+
private readonly fetchImpl: typeof fetch;
|
|
204
|
+
private readonly timeoutMs: number;
|
|
205
|
+
|
|
206
|
+
constructor(opts: FlixlyOptions) {
|
|
207
|
+
if (!opts?.apiKey) throw new Error("Flixly: `apiKey` is required");
|
|
208
|
+
this.apiKey = opts.apiKey;
|
|
209
|
+
this.baseUrl = (opts.baseUrl || BASE_URL).replace(/\/$/, "");
|
|
210
|
+
this.fetchImpl = opts.fetch || globalThis.fetch;
|
|
211
|
+
this.timeoutMs = opts.timeoutMs ?? 120_000;
|
|
212
|
+
if (!this.fetchImpl) {
|
|
213
|
+
throw new Error("Flixly: no fetch implementation. Pass `fetch` in options on Node < 18.");
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ─── Generations ───────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
/** Submit a generation. Returns immediately with `processing` for async models, or `completed` for fast sync models. */
|
|
220
|
+
async generate(req: GenerateRequest): Promise<FlixlyResponse<Generation>> {
|
|
221
|
+
return this.request<Generation>("POST", "/api/v1/generate", req);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Fetch the current state of a generation. */
|
|
225
|
+
async getGeneration(id: string): Promise<FlixlyResponse<Generation>> {
|
|
226
|
+
return this.request<Generation>("GET", `/api/v1/generations/${encodeURIComponent(id)}`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Submit a generation and poll until it completes or fails.
|
|
231
|
+
* Useful for short pipelines / one-shot scripts. Prefer
|
|
232
|
+
* `generate({ webhook_url })` in production.
|
|
233
|
+
*/
|
|
234
|
+
async generateAndWait(
|
|
235
|
+
req: GenerateRequest,
|
|
236
|
+
opts: { pollIntervalMs?: number; maxWaitMs?: number } = {},
|
|
237
|
+
): Promise<FlixlyResponse<Generation>> {
|
|
238
|
+
const start = Date.now();
|
|
239
|
+
const interval = opts.pollIntervalMs ?? 2_000;
|
|
240
|
+
const maxWait = opts.maxWaitMs ?? 10 * 60_000;
|
|
241
|
+
|
|
242
|
+
const initial = await this.generate(req);
|
|
243
|
+
if (initial.data.status === "completed" || initial.data.status === "failed") {
|
|
244
|
+
return initial;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
let last = initial;
|
|
248
|
+
while (Date.now() - start < maxWait) {
|
|
249
|
+
await sleep(interval);
|
|
250
|
+
last = await this.getGeneration(initial.data.id);
|
|
251
|
+
if (last.data.status === "completed" || last.data.status === "failed") {
|
|
252
|
+
return last;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
throw new FlixlyError({
|
|
256
|
+
status: 408,
|
|
257
|
+
code: "timeout",
|
|
258
|
+
message: `Generation ${initial.data.id} did not finish within ${Math.round(maxWait / 1000)}s. Last status: ${last.data.status}.`,
|
|
259
|
+
rateLimit: last.rateLimit,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ─── Models ────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
/** List all available models. Includes per-model estimated_credits when authenticated. */
|
|
266
|
+
async listModels(): Promise<FlixlyResponse<{ models: FlixlyModel[]; total: number }>> {
|
|
267
|
+
return this.request<{ models: FlixlyModel[]; total: number }>("GET", "/api/v1/models");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ─── Chat ──────────────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
/** OpenAI-compatible chat completion (non-streaming). */
|
|
273
|
+
async chat(req: Omit<ChatRequest, "stream"> & { stream?: false }): Promise<FlixlyResponse<ChatResponse>> {
|
|
274
|
+
return this.request<ChatResponse>("POST", "/api/v1/chat/completions", { ...req, stream: false });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* OpenAI-compatible streaming chat completion. Returns an async
|
|
279
|
+
* iterable of `chat.completion.chunk` events as parsed JSON. The
|
|
280
|
+
* stream terminates on `[DONE]`.
|
|
281
|
+
*/
|
|
282
|
+
async *chatStream(req: Omit<ChatRequest, "stream">): AsyncIterable<any> {
|
|
283
|
+
const url = `${this.baseUrl}/api/v1/chat/completions`;
|
|
284
|
+
const ac = new AbortController();
|
|
285
|
+
const timer = setTimeout(() => ac.abort(), this.timeoutMs);
|
|
286
|
+
let res: Response;
|
|
287
|
+
try {
|
|
288
|
+
res = await this.fetchImpl(url, {
|
|
289
|
+
method: "POST",
|
|
290
|
+
headers: this.headers({ "Content-Type": "application/json" }),
|
|
291
|
+
body: JSON.stringify({ ...req, stream: true }),
|
|
292
|
+
signal: ac.signal,
|
|
293
|
+
});
|
|
294
|
+
} finally {
|
|
295
|
+
clearTimeout(timer);
|
|
296
|
+
}
|
|
297
|
+
if (!res.ok) {
|
|
298
|
+
throw await responseToError(res);
|
|
299
|
+
}
|
|
300
|
+
if (!res.body) throw new Error("Flixly: streaming response has no body");
|
|
301
|
+
|
|
302
|
+
const reader = res.body.getReader();
|
|
303
|
+
const decoder = new TextDecoder();
|
|
304
|
+
let buf = "";
|
|
305
|
+
while (true) {
|
|
306
|
+
const { done, value } = await reader.read();
|
|
307
|
+
if (done) return;
|
|
308
|
+
buf += decoder.decode(value, { stream: true });
|
|
309
|
+
const lines = buf.split("\n");
|
|
310
|
+
buf = lines.pop() || "";
|
|
311
|
+
for (const line of lines) {
|
|
312
|
+
const trimmed = line.trim();
|
|
313
|
+
if (!trimmed || !trimmed.startsWith("data:")) continue;
|
|
314
|
+
const payload = trimmed.slice(5).trim();
|
|
315
|
+
if (payload === "[DONE]") return;
|
|
316
|
+
try {
|
|
317
|
+
yield JSON.parse(payload);
|
|
318
|
+
} catch {
|
|
319
|
+
// Malformed SSE chunk — skip rather than abort the stream.
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ─── Account ───────────────────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
/** Credit balance, plan, parallel + rate limits, and aggregate usage. */
|
|
328
|
+
async getAccount(): Promise<FlixlyResponse<Account>> {
|
|
329
|
+
return this.request<Account>("GET", "/api/v1/account");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ─── Webhook signature verification (static) ───────────────────
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Verify a Flixly webhook delivery. Returns true if and only if the
|
|
336
|
+
* signature is valid AND the timestamp is within `tolerance` seconds.
|
|
337
|
+
*
|
|
338
|
+
* Call this on EVERY webhook POST you receive — without it, anyone
|
|
339
|
+
* who guesses the URL can forge a delivery.
|
|
340
|
+
*/
|
|
341
|
+
static async verifyWebhookSignature(args: VerifyWebhookArgs): Promise<boolean> {
|
|
342
|
+
if (!args.signature?.startsWith("sha256=")) return false;
|
|
343
|
+
const provided = args.signature.slice(7).toLowerCase();
|
|
344
|
+
const ts = Number(args.timestamp);
|
|
345
|
+
if (!Number.isFinite(ts)) return false;
|
|
346
|
+
const now = Math.floor(Date.now() / 1000);
|
|
347
|
+
const tolerance = args.tolerance ?? 300;
|
|
348
|
+
if (Math.abs(now - ts) > tolerance) return false;
|
|
349
|
+
|
|
350
|
+
const bodyStr = typeof args.body === "string" ? args.body : new TextDecoder().decode(args.body);
|
|
351
|
+
const enc = new TextEncoder();
|
|
352
|
+
const key = await crypto.subtle.importKey(
|
|
353
|
+
"raw",
|
|
354
|
+
enc.encode(args.secret),
|
|
355
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
356
|
+
false,
|
|
357
|
+
["sign"],
|
|
358
|
+
);
|
|
359
|
+
const sigBuf = await crypto.subtle.sign("HMAC", key, enc.encode(`${args.timestamp}.${bodyStr}`));
|
|
360
|
+
const expectedHex = bufferToHex(sigBuf);
|
|
361
|
+
|
|
362
|
+
return timingSafeEqual(expectedHex, provided);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ─── Internals ─────────────────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
private headers(extra: Record<string, string> = {}): Record<string, string> {
|
|
368
|
+
return {
|
|
369
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
370
|
+
"User-Agent": "flixly-js/0.1.0",
|
|
371
|
+
...extra,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private async request<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<FlixlyResponse<T>> {
|
|
376
|
+
const url = `${this.baseUrl}${path}`;
|
|
377
|
+
const ac = new AbortController();
|
|
378
|
+
const timer = setTimeout(() => ac.abort(), this.timeoutMs);
|
|
379
|
+
let res: Response;
|
|
380
|
+
try {
|
|
381
|
+
res = await this.fetchImpl(url, {
|
|
382
|
+
method,
|
|
383
|
+
headers: this.headers(body ? { "Content-Type": "application/json" } : {}),
|
|
384
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
385
|
+
signal: ac.signal,
|
|
386
|
+
});
|
|
387
|
+
} finally {
|
|
388
|
+
clearTimeout(timer);
|
|
389
|
+
}
|
|
390
|
+
const rateLimit = parseRateLimit(res.headers);
|
|
391
|
+
if (!res.ok) {
|
|
392
|
+
throw await responseToError(res, rateLimit);
|
|
393
|
+
}
|
|
394
|
+
const data = (await res.json()) as T;
|
|
395
|
+
return { data, rateLimit };
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ============================================
|
|
400
|
+
// WEBHOOK VERIFICATION
|
|
401
|
+
// ============================================
|
|
402
|
+
|
|
403
|
+
export interface VerifyWebhookArgs {
|
|
404
|
+
/** The HMAC secret you stored from your API key's webhook settings. */
|
|
405
|
+
secret: string;
|
|
406
|
+
/** The `X-Flixly-Timestamp` request header value (unix seconds, string). */
|
|
407
|
+
timestamp: string;
|
|
408
|
+
/** The `X-Flixly-Signature` request header value (`sha256=<hex>`). */
|
|
409
|
+
signature: string;
|
|
410
|
+
/** Raw request body bytes or string. MUST be unmodified — JSON.parse + re-stringify will break verification. */
|
|
411
|
+
body: string | Uint8Array;
|
|
412
|
+
/** Reject signatures older than this many seconds. Defaults to 300 (5 minutes) to prevent replays. */
|
|
413
|
+
tolerance?: number;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ============================================
|
|
417
|
+
// HELPERS
|
|
418
|
+
// ============================================
|
|
419
|
+
|
|
420
|
+
function parseRateLimit(h: Headers): RateLimit | null {
|
|
421
|
+
const limit = Number(h.get("X-RateLimit-Limit"));
|
|
422
|
+
const remaining = Number(h.get("X-RateLimit-Remaining"));
|
|
423
|
+
const resetAtSec = Number(h.get("X-RateLimit-Reset"));
|
|
424
|
+
if (!Number.isFinite(limit) || !Number.isFinite(remaining) || !Number.isFinite(resetAtSec)) {
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
return { limit, remaining, resetAtSec };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function responseToError(res: Response, rateLimit?: RateLimit | null): Promise<FlixlyError> {
|
|
431
|
+
const rl = rateLimit ?? parseRateLimit(res.headers);
|
|
432
|
+
let code = "internal_error";
|
|
433
|
+
let message = `HTTP ${res.status}`;
|
|
434
|
+
let details: Record<string, unknown> | undefined;
|
|
435
|
+
try {
|
|
436
|
+
const body = await res.json();
|
|
437
|
+
if (body?.error) {
|
|
438
|
+
code = body.error.code || code;
|
|
439
|
+
message = body.error.message || message;
|
|
440
|
+
details = body.error.details;
|
|
441
|
+
}
|
|
442
|
+
} catch {
|
|
443
|
+
// Body wasn't JSON — keep the generic message.
|
|
444
|
+
}
|
|
445
|
+
return new FlixlyError({ status: res.status, code, message, details, rateLimit: rl });
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function sleep(ms: number): Promise<void> {
|
|
449
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function bufferToHex(buf: ArrayBuffer): string {
|
|
453
|
+
const bytes = new Uint8Array(buf);
|
|
454
|
+
let hex = "";
|
|
455
|
+
for (const b of bytes) hex += b.toString(16).padStart(2, "0");
|
|
456
|
+
return hex;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Length-bounded constant-time string compare to avoid timing attacks on the signature. */
|
|
460
|
+
function timingSafeEqual(a: string, b: string): boolean {
|
|
461
|
+
if (a.length !== b.length) return false;
|
|
462
|
+
let diff = 0;
|
|
463
|
+
for (let i = 0; i < a.length; i++) {
|
|
464
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
465
|
+
}
|
|
466
|
+
return diff === 0;
|
|
467
|
+
}
|