Initial Commit
This commit is contained in:
109
packages/media/audio/third-party-elevenlabs/src/client/client.md
Normal file
109
packages/media/audio/third-party-elevenlabs/src/client/client.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# `client`
|
||||
|
||||
REST + WebSocket transport for the ElevenLabs platform. `ElevenLabsClient`
|
||||
exposes typed `fetchJson` / `fetchBinary` / `fetchStream` /
|
||||
`fetchMultipartJson` / `fetchMultipartBinary` / `openSocket` methods, plus
|
||||
internal retry-with-exponential-backoff that honours the `Retry-After`
|
||||
header. Every adapter in the package (`tts`, `stt`, `voices`, …) composes
|
||||
this single client so authentication, timeouts, retries, and event
|
||||
emissions live in exactly one place.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
ElevenLabs offers a sprawling REST + WebSocket API surface. Without a
|
||||
shared transport, every adapter would duplicate the same error mapping,
|
||||
header injection, retry policy, and timeout handling. `ElevenLabsClient`
|
||||
solves that: it's the only piece of code in the package that calls
|
||||
`fetch`, and the only piece that knows how to map HTTP status codes onto
|
||||
typed errors. Adapters become thin domain remappers.
|
||||
|
||||
## Features
|
||||
|
||||
- Five typed request shapes — JSON in/out, JSON in / binary out, JSON in /
|
||||
streaming out, multipart in / JSON out, multipart in / binary out.
|
||||
- WebSocket support via `openSocket()` for ElevenLabs's streaming-input
|
||||
TTS endpoints.
|
||||
- Built-in retry: exponential backoff (`initialDelay * 2^attempt`) with
|
||||
`Retry-After` honouring on 429 / 503 / 504 / network errors.
|
||||
- Per-request `AbortSignal` plus a per-call `timeoutMs` race.
|
||||
- Optional `LoggerLike` and `EventBusLike` for observability — emits
|
||||
`request-start`, `request-end`, `retry-attempt`, `rate-limit-hit`.
|
||||
- Caller-supplied `fetchImpl` and `webSocketImpl` for tests / edge runtimes.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ElevenLabsClient } from "@suaveplan/third-party-elevenlabs";
|
||||
|
||||
const client = new ElevenLabsClient({ apiKey: process.env.ELEVENLABS_KEY! });
|
||||
|
||||
const res = await client.fetchJson<{ voice_id: string }>({
|
||||
method: "GET",
|
||||
path: "/v1/voices/abc",
|
||||
feature: "voices",
|
||||
operation: "get",
|
||||
});
|
||||
console.log(res.body.voice_id, res.metadata.requestId);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { ElevenLabsClient } from "@suaveplan/third-party-elevenlabs";
|
||||
|
||||
const client = new ElevenLabsClient({
|
||||
apiKey: process.env.ELEVENLABS_KEY!,
|
||||
baseUrl: "https://eu.api.elevenlabs.io",
|
||||
timeoutMs: 60_000,
|
||||
maxRetries: 5,
|
||||
initialRetryDelayMs: 250,
|
||||
fetchImpl: customFetch,
|
||||
events: myBus,
|
||||
logger: myLogger,
|
||||
});
|
||||
|
||||
const ctrl = new AbortController();
|
||||
const stream = await client.fetchStream({
|
||||
method: "POST",
|
||||
path: "/v1/text-to-speech/voice-id/stream",
|
||||
feature: "tts",
|
||||
operation: "synthesize_stream",
|
||||
body: { text: "hello" },
|
||||
accept: "audio/mpeg",
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
for await (const chunk of stream.body) {
|
||||
/* … */
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new ElevenLabsClient(options: ElevenLabsClientOptions)` — constructor.
|
||||
- `fetchJson<TBody>(req: JsonRequest): Promise<JsonResponse<TBody>>`
|
||||
- `fetchBinary(req: BinaryRequest): Promise<BinaryResponse>`
|
||||
- `fetchStream(req: StreamRequest): Promise<StreamResponse>`
|
||||
- `fetchMultipartJson<TBody>(req: MultipartRequest): Promise<JsonResponse<TBody>>`
|
||||
- `fetchMultipartBinary(req: MultipartRequest): Promise<BinaryResponse>`
|
||||
- `openSocket(req: SocketRequest): ElevenLabsSocket` — wss connection with
|
||||
`xi-api-key` injected as a query parameter.
|
||||
|
||||
Throws typed `ElevenLabsApiError` subclasses on non-2xx responses.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `xi-api-key` is sent as a request header for HTTP, but as a query
|
||||
parameter for WebSocket (browsers can't set headers on the WS upgrade).
|
||||
- Multipart values may be `Uint8Array | Blob | ReadableStream | URL`;
|
||||
streams are buffered and URLs are fetched before upload — ElevenLabs
|
||||
multipart endpoints require complete bodies.
|
||||
- Retries fire only for `RATE_LIMIT.EXCEEDED`, `SERVER.TRANSIENT`,
|
||||
`NETWORK.TIMEOUT`, and `NETWORK.UNREACHABLE` codes. Auth and validation
|
||||
failures are not retried.
|
||||
- The class delegates error mapping, multipart materialisation, retry
|
||||
decision, and backoff math to `internals.ts` so the class itself stays
|
||||
focused on transport orchestration.
|
||||
@@ -0,0 +1,872 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import {
|
||||
ELEVENLABS_CODES,
|
||||
ForbiddenError,
|
||||
NetworkAbortedError,
|
||||
NetworkTimeoutError,
|
||||
NetworkUnreachableError,
|
||||
RateLimitExceededError,
|
||||
RateLimitQuotaExhaustedError,
|
||||
RequestInvalidError,
|
||||
RequestNotFoundError,
|
||||
RequestValidationFailedError,
|
||||
ResponseMalformedError,
|
||||
ServerTransientError,
|
||||
ServerUnavailableError,
|
||||
UnauthorizedError,
|
||||
} from "../errors/errors.js";
|
||||
import type { ElevenLabsClientEvents } from "../types/types.js";
|
||||
import { ElevenLabsClient } from "./client.js";
|
||||
|
||||
interface FetchCall {
|
||||
url: string;
|
||||
init: RequestInit;
|
||||
}
|
||||
|
||||
function makeFetch(
|
||||
responses: Array<Response | (() => Response | Promise<Response>) | Error>
|
||||
): {
|
||||
fetchImpl: typeof fetch;
|
||||
calls: FetchCall[];
|
||||
} {
|
||||
const calls: FetchCall[] = [];
|
||||
let i = 0;
|
||||
const fetchImpl: typeof fetch = (input: unknown, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : String(input);
|
||||
calls.push({ url, init: init ?? {} });
|
||||
const slot = responses[i++];
|
||||
if (slot === undefined) {
|
||||
return Promise.reject(new Error("no more responses queued"));
|
||||
}
|
||||
if (slot instanceof Error) return Promise.reject(slot);
|
||||
if (typeof slot === "function") return Promise.resolve(slot());
|
||||
return Promise.resolve(slot);
|
||||
};
|
||||
return { fetchImpl, calls };
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
function errResponse(
|
||||
status: number,
|
||||
body: unknown = "",
|
||||
init: ResponseInit = {}
|
||||
): Response {
|
||||
return new Response(
|
||||
typeof body === "string" ? body : JSON.stringify(body),
|
||||
{
|
||||
status,
|
||||
statusText: typeof body === "string" ? body : "",
|
||||
...init,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function makeBus(): {
|
||||
bus: { emit: (e: string, p: unknown) => void };
|
||||
received: Array<{ event: string; payload: unknown }>;
|
||||
} {
|
||||
const received: Array<{ event: string; payload: unknown }> = [];
|
||||
return {
|
||||
bus: {
|
||||
emit: (event, payload): void => {
|
||||
received.push({ event, payload });
|
||||
},
|
||||
},
|
||||
received,
|
||||
};
|
||||
}
|
||||
|
||||
let originalFetch: typeof fetch;
|
||||
beforeEach(() => {
|
||||
originalFetch = globalThis.fetch;
|
||||
});
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("ElevenLabsClient.fetchJson", () => {
|
||||
it("returns JSON body and metadata on 200", async () => {
|
||||
const { fetchImpl, calls } = makeFetch([
|
||||
jsonResponse(
|
||||
{ hello: "world" },
|
||||
{
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-request-id": "req-1",
|
||||
"x-ratelimit-remaining": "42",
|
||||
"character-cost": "100",
|
||||
},
|
||||
}
|
||||
),
|
||||
]);
|
||||
const client = new ElevenLabsClient({ apiKey: "k", fetchImpl });
|
||||
const res = await client.fetchJson<{ hello: string }>({
|
||||
method: "POST",
|
||||
path: "/v1/x",
|
||||
feature: "tts",
|
||||
operation: "synthesize",
|
||||
body: { input: 1 },
|
||||
});
|
||||
expect(res.body).toEqual({ hello: "world" });
|
||||
expect(res.metadata.requestId).toBe("req-1");
|
||||
expect(res.metadata.rateLimitRemaining).toBe(42);
|
||||
expect(res.metadata.characterCost).toBe(100);
|
||||
expect(calls[0]?.url).toBe("https://api.elevenlabs.io/v1/x");
|
||||
const headers = calls[0]?.init.headers as Record<string, string>;
|
||||
expect(headers["xi-api-key"]).toBe("k");
|
||||
expect(headers["content-type"]).toBe("application/json");
|
||||
expect(calls[0]?.init.body).toBe(JSON.stringify({ input: 1 }));
|
||||
});
|
||||
|
||||
it("returns undefined body when response is empty", async () => {
|
||||
const { fetchImpl } = makeFetch([new Response(null, { status: 204 })]);
|
||||
const client = new ElevenLabsClient({ apiKey: "k", fetchImpl });
|
||||
const res = await client.fetchJson({
|
||||
method: "DELETE",
|
||||
path: "/v1/x",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
});
|
||||
expect(res.body).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns body as string when JSON parse fails", async () => {
|
||||
const { fetchImpl } = makeFetch([
|
||||
new Response("not-json", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/plain" },
|
||||
}),
|
||||
]);
|
||||
const client = new ElevenLabsClient({ apiKey: "k", fetchImpl });
|
||||
const res = await client.fetchJson({
|
||||
method: "GET",
|
||||
path: "/v1/x",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
});
|
||||
expect(res.body).toBe("not-json");
|
||||
});
|
||||
|
||||
it("includes default and per-request headers", async () => {
|
||||
const { fetchImpl, calls } = makeFetch([jsonResponse({})]);
|
||||
const client = new ElevenLabsClient({
|
||||
apiKey: "k",
|
||||
fetchImpl,
|
||||
defaultHeaders: { "x-tenant": "abc" },
|
||||
userAgent: "test/1",
|
||||
});
|
||||
await client.fetchJson({
|
||||
method: "GET",
|
||||
path: "/v1/x",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
headers: { "x-trace": "t1" },
|
||||
});
|
||||
const h = calls[0]?.init.headers as Record<string, string>;
|
||||
expect(h["x-tenant"]).toBe("abc");
|
||||
expect(h["x-trace"]).toBe("t1");
|
||||
expect(h["user-agent"]).toBe("test/1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ElevenLabsClient HTTP error mapping", () => {
|
||||
const cases: Array<{
|
||||
status: number;
|
||||
statusText?: string;
|
||||
ctor: new (...args: never[]) => Error;
|
||||
code: string;
|
||||
}> = [
|
||||
{
|
||||
status: 401,
|
||||
ctor: UnauthorizedError,
|
||||
code: ELEVENLABS_CODES["AUTH.UNAUTHORIZED"],
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
ctor: ForbiddenError,
|
||||
code: ELEVENLABS_CODES["AUTH.FORBIDDEN"],
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
ctor: RequestNotFoundError,
|
||||
code: ELEVENLABS_CODES["REQUEST.NOT_FOUND"],
|
||||
},
|
||||
{
|
||||
status: 422,
|
||||
ctor: RequestValidationFailedError,
|
||||
code: ELEVENLABS_CODES["REQUEST.VALIDATION_FAILED"],
|
||||
},
|
||||
{
|
||||
status: 400,
|
||||
ctor: RequestInvalidError,
|
||||
code: ELEVENLABS_CODES["REQUEST.INVALID"],
|
||||
},
|
||||
{
|
||||
status: 502,
|
||||
ctor: ServerTransientError,
|
||||
code: ELEVENLABS_CODES["SERVER.TRANSIENT"],
|
||||
},
|
||||
{
|
||||
status: 503,
|
||||
ctor: ServerTransientError,
|
||||
code: ELEVENLABS_CODES["SERVER.TRANSIENT"],
|
||||
},
|
||||
{
|
||||
status: 504,
|
||||
ctor: ServerTransientError,
|
||||
code: ELEVENLABS_CODES["SERVER.TRANSIENT"],
|
||||
},
|
||||
{
|
||||
status: 500,
|
||||
ctor: ServerUnavailableError,
|
||||
code: ELEVENLABS_CODES["SERVER.UNAVAILABLE"],
|
||||
},
|
||||
];
|
||||
for (const c of cases) {
|
||||
it(`maps HTTP ${c.status} to ${c.ctor.name}`, async () => {
|
||||
const { fetchImpl } = makeFetch([
|
||||
errResponse(c.status, "", { statusText: c.statusText ?? "" }),
|
||||
]);
|
||||
const client = new ElevenLabsClient({
|
||||
apiKey: "k",
|
||||
fetchImpl,
|
||||
maxRetries: 0,
|
||||
});
|
||||
await expect(
|
||||
client.fetchJson({
|
||||
method: "GET",
|
||||
path: "/v1/x",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
})
|
||||
).rejects.toBeInstanceOf(c.ctor);
|
||||
});
|
||||
}
|
||||
|
||||
it("maps 429 with quota text to RateLimitQuotaExhaustedError", async () => {
|
||||
const { fetchImpl } = makeFetch([
|
||||
errResponse(429, "", { statusText: "quota exhausted" }),
|
||||
]);
|
||||
const client = new ElevenLabsClient({
|
||||
apiKey: "k",
|
||||
fetchImpl,
|
||||
maxRetries: 0,
|
||||
});
|
||||
await expect(
|
||||
client.fetchJson({
|
||||
method: "GET",
|
||||
path: "/v1/x",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
})
|
||||
).rejects.toBeInstanceOf(RateLimitQuotaExhaustedError);
|
||||
});
|
||||
|
||||
it("maps 429 without quota text to RateLimitExceededError with retryAfterMs", async () => {
|
||||
const { fetchImpl } = makeFetch([
|
||||
new Response("", {
|
||||
status: 429,
|
||||
headers: { "retry-after": "2" },
|
||||
}),
|
||||
]);
|
||||
const client = new ElevenLabsClient({
|
||||
apiKey: "k",
|
||||
fetchImpl,
|
||||
maxRetries: 0,
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
await client.fetchJson({
|
||||
method: "GET",
|
||||
path: "/v1/x",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
});
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(RateLimitExceededError);
|
||||
const err = caught as RateLimitExceededError;
|
||||
expect(err.context?.["retryAfterMs"]).toBe(2000);
|
||||
});
|
||||
|
||||
it("preserves response body on 422", async () => {
|
||||
const { fetchImpl } = makeFetch([
|
||||
new Response(JSON.stringify({ detail: "bad" }), {
|
||||
status: 422,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
]);
|
||||
const client = new ElevenLabsClient({
|
||||
apiKey: "k",
|
||||
fetchImpl,
|
||||
maxRetries: 0,
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
await client.fetchJson({
|
||||
method: "POST",
|
||||
path: "/v1/x",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
});
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(RequestValidationFailedError);
|
||||
const err = caught as RequestValidationFailedError;
|
||||
expect(err.context?.["body"]).toEqual({ detail: "bad" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("ElevenLabsClient retry behaviour", () => {
|
||||
it("retries 503 then succeeds", async () => {
|
||||
const { fetchImpl, calls } = makeFetch([
|
||||
errResponse(503),
|
||||
jsonResponse({ ok: true }),
|
||||
]);
|
||||
const client = new ElevenLabsClient({
|
||||
apiKey: "k",
|
||||
fetchImpl,
|
||||
initialRetryDelayMs: 1,
|
||||
maxRetries: 3,
|
||||
});
|
||||
const res = await client.fetchJson<{ ok: boolean }>({
|
||||
method: "GET",
|
||||
path: "/v1/x",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
});
|
||||
expect(res.body.ok).toBe(true);
|
||||
expect(calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("retries network errors then succeeds", async () => {
|
||||
const { fetchImpl } = makeFetch([
|
||||
new TypeError("fetch failed"),
|
||||
jsonResponse({ ok: true }),
|
||||
]);
|
||||
const client = new ElevenLabsClient({
|
||||
apiKey: "k",
|
||||
fetchImpl,
|
||||
initialRetryDelayMs: 1,
|
||||
maxRetries: 2,
|
||||
});
|
||||
const res = await client.fetchJson({
|
||||
method: "GET",
|
||||
path: "/v1/x",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
});
|
||||
expect(res.body).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("does not retry 401", async () => {
|
||||
const { fetchImpl, calls } = makeFetch([errResponse(401)]);
|
||||
const client = new ElevenLabsClient({
|
||||
apiKey: "k",
|
||||
fetchImpl,
|
||||
maxRetries: 3,
|
||||
initialRetryDelayMs: 1,
|
||||
});
|
||||
await expect(
|
||||
client.fetchJson({
|
||||
method: "GET",
|
||||
path: "/v1/x",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
})
|
||||
).rejects.toBeInstanceOf(UnauthorizedError);
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("exhausts retries then throws final error", async () => {
|
||||
const { fetchImpl } = makeFetch([
|
||||
errResponse(503),
|
||||
errResponse(503),
|
||||
errResponse(503),
|
||||
]);
|
||||
const client = new ElevenLabsClient({
|
||||
apiKey: "k",
|
||||
fetchImpl,
|
||||
maxRetries: 2,
|
||||
initialRetryDelayMs: 1,
|
||||
});
|
||||
await expect(
|
||||
client.fetchJson({
|
||||
method: "GET",
|
||||
path: "/v1/x",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
})
|
||||
).rejects.toBeInstanceOf(ServerTransientError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ElevenLabsClient.fetchBinary", () => {
|
||||
it("returns binary body with content-type", async () => {
|
||||
const { fetchImpl } = makeFetch([
|
||||
new Response(new Uint8Array([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: { "content-type": "audio/mpeg" },
|
||||
}),
|
||||
]);
|
||||
const client = new ElevenLabsClient({ apiKey: "k", fetchImpl });
|
||||
const res = await client.fetchBinary({
|
||||
method: "POST",
|
||||
path: "/v1/x",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
body: { x: 1 },
|
||||
accept: "audio/mpeg",
|
||||
});
|
||||
expect(res.body).toEqual(new Uint8Array([1, 2, 3]));
|
||||
expect(res.contentType).toBe("audio/mpeg");
|
||||
});
|
||||
|
||||
it("defaults content-type to application/octet-stream when missing", async () => {
|
||||
const { fetchImpl } = makeFetch([
|
||||
new Response(new Uint8Array([0]), { status: 200 }),
|
||||
]);
|
||||
const client = new ElevenLabsClient({ apiKey: "k", fetchImpl });
|
||||
const res = await client.fetchBinary({
|
||||
method: "GET",
|
||||
path: "/v1/x",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
});
|
||||
expect(res.contentType).toBe("application/octet-stream");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ElevenLabsClient.fetchStream", () => {
|
||||
it("returns the response body stream", async () => {
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller): void {
|
||||
controller.enqueue(new Uint8Array([1, 2]));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const { fetchImpl } = makeFetch([
|
||||
new Response(stream, {
|
||||
status: 200,
|
||||
headers: { "content-type": "audio/mpeg" },
|
||||
}),
|
||||
]);
|
||||
const client = new ElevenLabsClient({ apiKey: "k", fetchImpl });
|
||||
const res = await client.fetchStream({
|
||||
method: "POST",
|
||||
path: "/v1/x",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
body: { x: 1 },
|
||||
});
|
||||
expect(res.contentType).toBe("audio/mpeg");
|
||||
const reader = res.body.getReader();
|
||||
const { value } = await reader.read();
|
||||
expect(value).toEqual(new Uint8Array([1, 2]));
|
||||
});
|
||||
|
||||
it("throws ResponseMalformedError when body is null", async () => {
|
||||
const { fetchImpl } = makeFetch([new Response(null, { status: 200 })]);
|
||||
const client = new ElevenLabsClient({ apiKey: "k", fetchImpl });
|
||||
await expect(
|
||||
client.fetchStream({
|
||||
method: "GET",
|
||||
path: "/v1/x",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
})
|
||||
).rejects.toBeInstanceOf(ResponseMalformedError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ElevenLabsClient.fetchMultipart{Json,Binary}", () => {
|
||||
it("posts a Uint8Array file as multipart/form-data and returns JSON", async () => {
|
||||
const { fetchImpl, calls } = makeFetch([jsonResponse({ ok: 1 })]);
|
||||
const client = new ElevenLabsClient({ apiKey: "k", fetchImpl });
|
||||
const audio = new Uint8Array([10, 20, 30]);
|
||||
const res = await client.fetchMultipartJson({
|
||||
method: "POST",
|
||||
path: "/v1/stt",
|
||||
feature: "stt",
|
||||
operation: "transcribe",
|
||||
fields: [
|
||||
{ kind: "scalar", name: "model_id", value: "m1" },
|
||||
{
|
||||
kind: "file",
|
||||
name: "audio",
|
||||
filename: "audio.wav",
|
||||
contentType: "audio/wav",
|
||||
value: audio,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(res.body).toEqual({ ok: 1 });
|
||||
expect(calls[0]?.init.body).toBeInstanceOf(FormData);
|
||||
});
|
||||
|
||||
it("buffers a ReadableStream file before posting", async () => {
|
||||
const { fetchImpl } = makeFetch([jsonResponse({ ok: 1 })]);
|
||||
const client = new ElevenLabsClient({ apiKey: "k", fetchImpl });
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(c): void {
|
||||
c.enqueue(new Uint8Array([1]));
|
||||
c.enqueue(new Uint8Array([2, 3]));
|
||||
c.close();
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
client.fetchMultipartJson({
|
||||
method: "POST",
|
||||
path: "/v1/stt",
|
||||
feature: "stt",
|
||||
operation: "transcribe",
|
||||
fields: [
|
||||
{
|
||||
kind: "file",
|
||||
name: "audio",
|
||||
filename: "a.wav",
|
||||
value: stream,
|
||||
},
|
||||
],
|
||||
})
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("fetches a URL value before uploading", async () => {
|
||||
globalThis.fetch = ((): Promise<Response> =>
|
||||
Promise.resolve(
|
||||
new Response(new Uint8Array([7, 8]), { status: 200 })
|
||||
)) as unknown as typeof fetch;
|
||||
const { fetchImpl } = makeFetch([jsonResponse({ ok: 1 })]);
|
||||
const client = new ElevenLabsClient({ apiKey: "k", fetchImpl });
|
||||
const res = await client.fetchMultipartJson({
|
||||
method: "POST",
|
||||
path: "/v1/stt",
|
||||
feature: "stt",
|
||||
operation: "transcribe",
|
||||
fields: [
|
||||
{
|
||||
kind: "file",
|
||||
name: "audio",
|
||||
filename: "a.wav",
|
||||
value: "https://example.com/a.wav",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(res.body).toEqual({ ok: 1 });
|
||||
});
|
||||
|
||||
it("rejects when URL fetch fails", async () => {
|
||||
globalThis.fetch = ((): Promise<Response> =>
|
||||
Promise.resolve(
|
||||
new Response("", { status: 500 })
|
||||
)) as unknown as typeof fetch;
|
||||
const { fetchImpl } = makeFetch([]);
|
||||
const client = new ElevenLabsClient({ apiKey: "k", fetchImpl });
|
||||
await expect(
|
||||
client.fetchMultipartJson({
|
||||
method: "POST",
|
||||
path: "/v1/stt",
|
||||
feature: "stt",
|
||||
operation: "transcribe",
|
||||
fields: [
|
||||
{
|
||||
kind: "file",
|
||||
name: "audio",
|
||||
filename: "a.wav",
|
||||
value: "https://example.com/a.wav",
|
||||
},
|
||||
],
|
||||
})
|
||||
).rejects.toBeInstanceOf(NetworkUnreachableError);
|
||||
});
|
||||
|
||||
it("supports Blob multipart values", async () => {
|
||||
const { fetchImpl } = makeFetch([jsonResponse({ ok: 1 })]);
|
||||
const client = new ElevenLabsClient({ apiKey: "k", fetchImpl });
|
||||
const blob = new Blob([new Uint8Array([1, 2])], {
|
||||
type: "audio/wav",
|
||||
});
|
||||
await expect(
|
||||
client.fetchMultipartJson({
|
||||
method: "POST",
|
||||
path: "/v1/stt",
|
||||
feature: "stt",
|
||||
operation: "transcribe",
|
||||
fields: [
|
||||
{
|
||||
kind: "file",
|
||||
name: "audio",
|
||||
filename: "a.wav",
|
||||
value: blob,
|
||||
},
|
||||
],
|
||||
})
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("returns binary multipart response", async () => {
|
||||
const { fetchImpl } = makeFetch([
|
||||
new Response(new Uint8Array([99]), {
|
||||
status: 200,
|
||||
headers: { "content-type": "audio/mpeg" },
|
||||
}),
|
||||
]);
|
||||
const client = new ElevenLabsClient({ apiKey: "k", fetchImpl });
|
||||
const res = await client.fetchMultipartBinary({
|
||||
method: "POST",
|
||||
path: "/v1/iso",
|
||||
feature: "iso",
|
||||
operation: "isolate",
|
||||
fields: [
|
||||
{
|
||||
kind: "file",
|
||||
name: "audio",
|
||||
filename: "a.wav",
|
||||
value: new Uint8Array([1]),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(res.body).toEqual(new Uint8Array([99]));
|
||||
expect(res.contentType).toBe("audio/mpeg");
|
||||
});
|
||||
|
||||
it("rejects unsupported multipart values", async () => {
|
||||
const { fetchImpl } = makeFetch([jsonResponse({})]);
|
||||
const client = new ElevenLabsClient({ apiKey: "k", fetchImpl });
|
||||
await expect(
|
||||
client.fetchMultipartJson({
|
||||
method: "POST",
|
||||
path: "/v1/x",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
fields: [
|
||||
{
|
||||
kind: "file",
|
||||
name: "audio",
|
||||
filename: "a.wav",
|
||||
value: 42 as unknown as Uint8Array,
|
||||
},
|
||||
],
|
||||
})
|
||||
).rejects.toBeInstanceOf(RequestInvalidError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ElevenLabsClient signal / timeout / abort", () => {
|
||||
it("rejects with NetworkAbortedError when caller-signal is already aborted", async () => {
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
const { fetchImpl } = makeFetch([jsonResponse({})]);
|
||||
const client = new ElevenLabsClient({
|
||||
apiKey: "k",
|
||||
fetchImpl,
|
||||
maxRetries: 0,
|
||||
});
|
||||
await expect(
|
||||
client.fetchJson({
|
||||
method: "GET",
|
||||
path: "/v1/x",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
signal: ctrl.signal,
|
||||
})
|
||||
).rejects.toBeInstanceOf(NetworkAbortedError);
|
||||
});
|
||||
|
||||
it("times out when fetch exceeds timeoutMs", async () => {
|
||||
const fetchImpl: typeof fetch = (_url, init) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => {
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
});
|
||||
});
|
||||
const client = new ElevenLabsClient({
|
||||
apiKey: "k",
|
||||
fetchImpl,
|
||||
timeoutMs: 5,
|
||||
maxRetries: 0,
|
||||
});
|
||||
await expect(
|
||||
client.fetchJson({
|
||||
method: "GET",
|
||||
path: "/v1/x",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
})
|
||||
).rejects.toBeInstanceOf(NetworkTimeoutError);
|
||||
});
|
||||
|
||||
it("propagates caller abort to NetworkAbortedError", async () => {
|
||||
const ctrl = new AbortController();
|
||||
const fetchImpl: typeof fetch = (_url, init) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => {
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
});
|
||||
});
|
||||
const client = new ElevenLabsClient({
|
||||
apiKey: "k",
|
||||
fetchImpl,
|
||||
timeoutMs: 60_000,
|
||||
maxRetries: 0,
|
||||
});
|
||||
const p = client.fetchJson({
|
||||
method: "GET",
|
||||
path: "/v1/x",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
ctrl.abort();
|
||||
await expect(p).rejects.toBeInstanceOf(NetworkAbortedError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ElevenLabsClient events", () => {
|
||||
it("emits request-start, retry-attempt, rate-limit-hit, request-end on success after retry", async () => {
|
||||
const { fetchImpl } = makeFetch([
|
||||
errResponse(429, "", { statusText: "" }),
|
||||
jsonResponse({ ok: 1 }),
|
||||
]);
|
||||
const { bus, received } = makeBus();
|
||||
const client = new ElevenLabsClient({
|
||||
apiKey: "k",
|
||||
fetchImpl,
|
||||
initialRetryDelayMs: 1,
|
||||
maxRetries: 3,
|
||||
events: bus as unknown as ElevenLabsClientEvents extends infer _T
|
||||
? Parameters<typeof ElevenLabsClient>[0]["events"]
|
||||
: never,
|
||||
});
|
||||
await client.fetchJson({
|
||||
method: "GET",
|
||||
path: "/v1/x",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
});
|
||||
const types = received.map((r) => r.event);
|
||||
expect(types).toContain("request-start");
|
||||
expect(types).toContain("rate-limit-hit");
|
||||
expect(types).toContain("retry-attempt");
|
||||
const end = received.find((r) => r.event === "request-end");
|
||||
expect((end?.payload as { status: string }).status).toBe("ok");
|
||||
});
|
||||
|
||||
it("emits request-end with status=client_error on terminal 401", async () => {
|
||||
const { fetchImpl } = makeFetch([errResponse(401)]);
|
||||
const { bus, received } = makeBus();
|
||||
const client = new ElevenLabsClient({
|
||||
apiKey: "k",
|
||||
fetchImpl,
|
||||
maxRetries: 0,
|
||||
events: bus as unknown as Parameters<
|
||||
typeof ElevenLabsClient
|
||||
>[0]["events"],
|
||||
});
|
||||
await expect(
|
||||
client.fetchJson({
|
||||
method: "GET",
|
||||
path: "/v1/x",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
})
|
||||
).rejects.toBeInstanceOf(UnauthorizedError);
|
||||
const end = received.find((r) => r.event === "request-end");
|
||||
expect((end?.payload as { status: string }).status).toBe(
|
||||
"client_error"
|
||||
);
|
||||
});
|
||||
|
||||
it("emits request-end status=cancelled on abort", async () => {
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
const { fetchImpl } = makeFetch([jsonResponse({})]);
|
||||
const { bus, received } = makeBus();
|
||||
const client = new ElevenLabsClient({
|
||||
apiKey: "k",
|
||||
fetchImpl,
|
||||
maxRetries: 0,
|
||||
events: bus as unknown as Parameters<
|
||||
typeof ElevenLabsClient
|
||||
>[0]["events"],
|
||||
});
|
||||
await expect(
|
||||
client.fetchJson({
|
||||
method: "GET",
|
||||
path: "/v1/x",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
signal: ctrl.signal,
|
||||
})
|
||||
).rejects.toBeInstanceOf(NetworkAbortedError);
|
||||
const end = received.find((r) => r.event === "request-end");
|
||||
expect((end?.payload as { status: string }).status).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("logs warning if event bus throws", async () => {
|
||||
const { fetchImpl } = makeFetch([jsonResponse({})]);
|
||||
const warns: Array<{ msg: string; ctx: unknown }> = [];
|
||||
const throwingBus = {
|
||||
emit: (): void => {
|
||||
throw new Error("bus broken");
|
||||
},
|
||||
};
|
||||
const client = new ElevenLabsClient({
|
||||
apiKey: "k",
|
||||
fetchImpl,
|
||||
events: throwingBus as unknown as Parameters<
|
||||
typeof ElevenLabsClient
|
||||
>[0]["events"],
|
||||
logger: {
|
||||
debug: (): void => {},
|
||||
info: (): void => {},
|
||||
warn: (msg, ctx): void => {
|
||||
warns.push({ msg, ctx });
|
||||
},
|
||||
error: (): void => {},
|
||||
},
|
||||
});
|
||||
await client.fetchJson({
|
||||
method: "GET",
|
||||
path: "/v1/x",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
});
|
||||
expect(warns.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ElevenLabsClient query + base URL", () => {
|
||||
it("appends query parameters and respects custom baseUrl", async () => {
|
||||
const { fetchImpl, calls } = makeFetch([jsonResponse({})]);
|
||||
const client = new ElevenLabsClient({
|
||||
apiKey: "k",
|
||||
fetchImpl,
|
||||
baseUrl: "https://eu.example.com/",
|
||||
});
|
||||
await client.fetchJson({
|
||||
method: "GET",
|
||||
path: "v1/x",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
query: { a: 1, b: "two", c: undefined },
|
||||
});
|
||||
expect(calls[0]?.url).toBe("https://eu.example.com/v1/x?a=1&b=two");
|
||||
});
|
||||
});
|
||||
442
packages/media/audio/third-party-elevenlabs/src/client/client.ts
Normal file
442
packages/media/audio/third-party-elevenlabs/src/client/client.ts
Normal file
@@ -0,0 +1,442 @@
|
||||
import { sleep } from "@suaveplan/retry";
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import {
|
||||
ELEVENLABS_CODES,
|
||||
ElevenLabsApiError,
|
||||
NetworkAbortedError,
|
||||
NetworkTimeoutError,
|
||||
ResponseMalformedError,
|
||||
ServerUnavailableError,
|
||||
WebSocketConnectionFailedError,
|
||||
} from "../errors/errors.js";
|
||||
import type {
|
||||
BinaryRequest,
|
||||
BinaryResponse,
|
||||
ElevenLabsClientOptions,
|
||||
JsonRequest,
|
||||
JsonResponse,
|
||||
MultipartRequest,
|
||||
RequestEnvelope,
|
||||
RequestMetadata,
|
||||
SocketRequest,
|
||||
StreamRequest,
|
||||
StreamResponse,
|
||||
WebSocketConstructor,
|
||||
} from "../types/types.js";
|
||||
import { buildHeaders, extractMetadata, parseRetryAfter } from "./headers.js";
|
||||
import {
|
||||
backoffDelay,
|
||||
buildMultipartBody,
|
||||
endpointFor,
|
||||
mapHttpStatus,
|
||||
mapNetworkError,
|
||||
materializeMultipart,
|
||||
readJsonBody,
|
||||
shouldRetry,
|
||||
} from "./internals.js";
|
||||
import { ElevenLabsSocket } from "./socket.js";
|
||||
import { buildUrl } from "./url.js";
|
||||
|
||||
const DEFAULT_BASE_URL = "https://api.elevenlabs.io";
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_MAX_RETRIES = 3;
|
||||
const DEFAULT_INITIAL_RETRY_DELAY_MS = 500;
|
||||
const DEFAULT_USER_AGENT = "suaveplan-elevenlabs/0.0.0";
|
||||
|
||||
interface ResolvedConfig {
|
||||
readonly apiKey: string;
|
||||
readonly baseUrl: string;
|
||||
readonly timeoutMs: number;
|
||||
readonly maxRetries: number;
|
||||
readonly initialRetryDelayMs: number;
|
||||
readonly fetchImpl: typeof fetch;
|
||||
readonly webSocketImpl: WebSocketConstructor | undefined;
|
||||
readonly clock: Clock;
|
||||
readonly userAgent: string;
|
||||
readonly defaultHeaders?: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
function resolveConfig(opts: ElevenLabsClientOptions): ResolvedConfig {
|
||||
const out: {
|
||||
-readonly [K in keyof ResolvedConfig]: ResolvedConfig[K];
|
||||
} = {
|
||||
apiKey: opts.apiKey,
|
||||
baseUrl: opts.baseUrl ?? DEFAULT_BASE_URL,
|
||||
timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
maxRetries: opts.maxRetries ?? DEFAULT_MAX_RETRIES,
|
||||
initialRetryDelayMs:
|
||||
opts.initialRetryDelayMs ?? DEFAULT_INITIAL_RETRY_DELAY_MS,
|
||||
fetchImpl: opts.fetchImpl ?? fetch,
|
||||
webSocketImpl: opts.webSocketImpl,
|
||||
clock: opts.clock ?? ((): number => Date.now()),
|
||||
userAgent: opts.userAgent ?? DEFAULT_USER_AGENT,
|
||||
};
|
||||
if (opts.defaultHeaders !== undefined) {
|
||||
out.defaultHeaders = opts.defaultHeaders;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function toWebSocketUrl(baseUrl: string): string {
|
||||
if (baseUrl.startsWith("https://"))
|
||||
return `wss://${baseUrl.slice("https://".length)}`;
|
||||
if (baseUrl.startsWith("http://"))
|
||||
return `ws://${baseUrl.slice("http://".length)}`;
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* REST + WebSocket transport for ElevenLabs. Internally retries 429 / 5xx /
|
||||
* network errors with exponential backoff, honoring `Retry-After`. Emits
|
||||
* lifecycle events on the optional event bus.
|
||||
*/
|
||||
export class ElevenLabsClient {
|
||||
readonly #cfg: ResolvedConfig;
|
||||
readonly #events?: ElevenLabsClientOptions["events"];
|
||||
readonly #logger?: ElevenLabsClientOptions["logger"];
|
||||
|
||||
constructor(opts: ElevenLabsClientOptions) {
|
||||
this.#cfg = resolveConfig(opts);
|
||||
if (opts.events !== undefined) this.#events = opts.events;
|
||||
if (opts.logger !== undefined) this.#logger = opts.logger;
|
||||
}
|
||||
|
||||
/** JSON body in, JSON body out. */
|
||||
async fetchJson<TBody>(req: JsonRequest): Promise<JsonResponse<TBody>> {
|
||||
const init = this.#initFor(req, "application/json", "application/json");
|
||||
if (req.body !== undefined) init.body = JSON.stringify(req.body);
|
||||
const { response, metadata } = await this.#run(req, init);
|
||||
const body = (await readJsonBody(response)) as TBody;
|
||||
return { body, metadata };
|
||||
}
|
||||
|
||||
/** Body in (caller-encoded), binary bytes out. */
|
||||
async fetchBinary(req: BinaryRequest): Promise<BinaryResponse> {
|
||||
const init = this.#initFor(
|
||||
req,
|
||||
req.body !== undefined ? "application/json" : undefined,
|
||||
req.accept ?? "application/octet-stream"
|
||||
);
|
||||
if (req.body !== undefined) init.body = JSON.stringify(req.body);
|
||||
const { response, metadata } = await this.#run(req, init);
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
const contentType =
|
||||
response.headers.get("content-type") ?? "application/octet-stream";
|
||||
return { body: bytes, contentType, metadata };
|
||||
}
|
||||
|
||||
/** Body in (caller-encoded), streaming bytes out. */
|
||||
async fetchStream(req: StreamRequest): Promise<StreamResponse> {
|
||||
const init = this.#initFor(
|
||||
req,
|
||||
req.body !== undefined ? "application/json" : undefined,
|
||||
req.accept ?? "application/octet-stream"
|
||||
);
|
||||
if (req.body !== undefined) init.body = JSON.stringify(req.body);
|
||||
const { response, metadata } = await this.#run(req, init);
|
||||
const body = response.body;
|
||||
if (body === null) {
|
||||
throw new ResponseMalformedError({
|
||||
endpoint: endpointFor(req),
|
||||
message: "Streaming response had no body",
|
||||
});
|
||||
}
|
||||
const contentType =
|
||||
response.headers.get("content-type") ?? "application/octet-stream";
|
||||
return { body, contentType, metadata };
|
||||
}
|
||||
|
||||
/** Multipart-form-data body, JSON body out. */
|
||||
async fetchMultipartJson<TBody>(
|
||||
req: MultipartRequest
|
||||
): Promise<JsonResponse<TBody>> {
|
||||
const fields = await materializeMultipart(req.fields);
|
||||
const init = this.#initFor(
|
||||
req,
|
||||
undefined,
|
||||
req.accept ?? "application/json"
|
||||
);
|
||||
init.body = buildMultipartBody(fields);
|
||||
const { response, metadata } = await this.#run(req, init);
|
||||
const body = (await readJsonBody(response)) as TBody;
|
||||
return { body, metadata };
|
||||
}
|
||||
|
||||
/** Multipart-form-data body, binary bytes out. */
|
||||
async fetchMultipartBinary(req: MultipartRequest): Promise<BinaryResponse> {
|
||||
const fields = await materializeMultipart(req.fields);
|
||||
const init = this.#initFor(
|
||||
req,
|
||||
undefined,
|
||||
req.accept ?? "application/octet-stream"
|
||||
);
|
||||
init.body = buildMultipartBody(fields);
|
||||
const { response, metadata } = await this.#run(req, init);
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
const contentType =
|
||||
response.headers.get("content-type") ?? "application/octet-stream";
|
||||
return { body: bytes, contentType, metadata };
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a WebSocket connection. The API key is appended to the query
|
||||
* string as `xi-api-key=...` (browsers can't send headers on the WS
|
||||
* upgrade). The returned `ElevenLabsSocket` exposes `waitOpen()` for the
|
||||
* handshake and `send` / `onMessage` / `onClose` / `onError` for the
|
||||
* frame loop.
|
||||
*/
|
||||
openSocket(req: SocketRequest): ElevenLabsSocket {
|
||||
const Ctor =
|
||||
this.#cfg.webSocketImpl ??
|
||||
(globalThis as { WebSocket?: WebSocketConstructor }).WebSocket;
|
||||
if (Ctor === undefined) {
|
||||
throw new WebSocketConnectionFailedError({
|
||||
context: { endpoint: req.path },
|
||||
message:
|
||||
"No WebSocket implementation available; pass `webSocketImpl` to ElevenLabsClient",
|
||||
});
|
||||
}
|
||||
const wsBase = toWebSocketUrl(this.#cfg.baseUrl);
|
||||
const query: Record<string, string | number | boolean | undefined> = {
|
||||
...(req.query ?? {}),
|
||||
"xi-api-key": this.#cfg.apiKey,
|
||||
};
|
||||
const url = buildUrl(wsBase, req.path, query);
|
||||
const ws = new Ctor(url);
|
||||
return new ElevenLabsSocket(ws, req.path);
|
||||
}
|
||||
|
||||
#initFor(
|
||||
req: RequestEnvelope,
|
||||
contentType: string | undefined,
|
||||
accept: string
|
||||
): RequestInit & { headers: Record<string, string>; body?: BodyInit } {
|
||||
const headers = buildHeaders(
|
||||
this.#cfg.apiKey,
|
||||
this.#cfg.userAgent,
|
||||
this.#cfg.defaultHeaders,
|
||||
req.headers,
|
||||
contentType,
|
||||
accept
|
||||
);
|
||||
const init: RequestInit & {
|
||||
headers: Record<string, string>;
|
||||
body?: BodyInit;
|
||||
} = { method: req.method, headers };
|
||||
if (req.signal !== undefined) init.signal = req.signal;
|
||||
return init;
|
||||
}
|
||||
|
||||
async #run(
|
||||
req: RequestEnvelope,
|
||||
init: RequestInit
|
||||
): Promise<{ response: Response; metadata: RequestMetadata }> {
|
||||
const url = buildUrl(this.#cfg.baseUrl, req.path, req.query);
|
||||
const endpoint = endpointFor(req);
|
||||
const startedAt = this.#cfg.clock();
|
||||
this.#emit("request-start", {
|
||||
feature: req.feature,
|
||||
operation: req.operation,
|
||||
endpoint,
|
||||
method: req.method,
|
||||
});
|
||||
let lastError: ElevenLabsApiError | undefined;
|
||||
for (let attempt = 0; attempt <= this.#cfg.maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await this.#fetchOnce(url, init, endpoint);
|
||||
if (response.ok) {
|
||||
const metadata = extractMetadata(response.headers);
|
||||
this.#emit("request-end", {
|
||||
feature: req.feature,
|
||||
operation: req.operation,
|
||||
endpoint,
|
||||
method: req.method,
|
||||
status: "ok",
|
||||
statusCode: response.status,
|
||||
durationMs: this.#cfg.clock() - startedAt,
|
||||
});
|
||||
return { response, metadata };
|
||||
}
|
||||
lastError = await this.#errorFor(response, endpoint);
|
||||
this.#maybeEmitRateLimit(req, endpoint, response, lastError);
|
||||
if (!shouldRetry(lastError, attempt, this.#cfg.maxRetries))
|
||||
throw lastError;
|
||||
const delay = backoffDelay(
|
||||
attempt,
|
||||
this.#cfg.initialRetryDelayMs,
|
||||
this.#extractRetryAfter(response)
|
||||
);
|
||||
this.#emit("retry-attempt", {
|
||||
feature: req.feature,
|
||||
operation: req.operation,
|
||||
endpoint,
|
||||
attempt: attempt + 1,
|
||||
statusCode: response.status,
|
||||
delayMs: delay,
|
||||
});
|
||||
await sleep(delay, req.signal);
|
||||
} catch (err) {
|
||||
lastError =
|
||||
err instanceof ElevenLabsApiError
|
||||
? err
|
||||
: mapNetworkError(err, endpoint);
|
||||
if (!shouldRetry(lastError, attempt, this.#cfg.maxRetries)) {
|
||||
this.#emitFinal(req, endpoint, startedAt, lastError);
|
||||
throw lastError;
|
||||
}
|
||||
const delay = backoffDelay(
|
||||
attempt,
|
||||
this.#cfg.initialRetryDelayMs,
|
||||
undefined
|
||||
);
|
||||
this.#emit("retry-attempt", {
|
||||
feature: req.feature,
|
||||
operation: req.operation,
|
||||
endpoint,
|
||||
attempt: attempt + 1,
|
||||
delayMs: delay,
|
||||
});
|
||||
await sleep(delay, req.signal);
|
||||
}
|
||||
}
|
||||
const finalErr =
|
||||
lastError ??
|
||||
new ServerUnavailableError({
|
||||
endpoint,
|
||||
message: "Retry budget exhausted",
|
||||
});
|
||||
this.#emitFinal(req, endpoint, startedAt, finalErr);
|
||||
throw finalErr;
|
||||
}
|
||||
|
||||
async #fetchOnce(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
endpoint: string
|
||||
): Promise<Response> {
|
||||
const callerSignal = init.signal ?? undefined;
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), this.#cfg.timeoutMs);
|
||||
const onAbort = (): void => ctrl.abort();
|
||||
if (callerSignal) {
|
||||
if (callerSignal.aborted) {
|
||||
clearTimeout(timer);
|
||||
throw new NetworkAbortedError({ endpoint });
|
||||
}
|
||||
callerSignal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
try {
|
||||
return await this.#cfg.fetchImpl(url, {
|
||||
...init,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof DOMException &&
|
||||
err.name === "AbortError" &&
|
||||
callerSignal?.aborted
|
||||
) {
|
||||
throw new NetworkAbortedError({ endpoint, cause: err });
|
||||
}
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
throw new NetworkTimeoutError({
|
||||
endpoint,
|
||||
timeoutMs: this.#cfg.timeoutMs,
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
throw mapNetworkError(err, endpoint);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
if (callerSignal)
|
||||
callerSignal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
async #errorFor(
|
||||
response: Response,
|
||||
endpoint: string
|
||||
): Promise<ElevenLabsApiError> {
|
||||
const requestId =
|
||||
response.headers.get("x-request-id") ??
|
||||
response.headers.get("request-id") ??
|
||||
undefined;
|
||||
const retryAfterMs = this.#extractRetryAfter(response);
|
||||
const body = await readJsonBody(response);
|
||||
return mapHttpStatus(
|
||||
response.status,
|
||||
response.statusText,
|
||||
endpoint,
|
||||
requestId ?? undefined,
|
||||
retryAfterMs,
|
||||
body
|
||||
);
|
||||
}
|
||||
|
||||
#extractRetryAfter(response: Response): number | undefined {
|
||||
return parseRetryAfter(
|
||||
response.headers.get("retry-after"),
|
||||
this.#cfg.clock
|
||||
);
|
||||
}
|
||||
|
||||
#maybeEmitRateLimit(
|
||||
req: RequestEnvelope,
|
||||
endpoint: string,
|
||||
response: Response,
|
||||
err: ElevenLabsApiError
|
||||
): void {
|
||||
if (
|
||||
err.code !== ELEVENLABS_CODES["RATE_LIMIT.EXCEEDED"] &&
|
||||
err.code !== ELEVENLABS_CODES["RATE_LIMIT.QUOTA_EXHAUSTED"]
|
||||
)
|
||||
return;
|
||||
const payload: {
|
||||
feature: string;
|
||||
operation: string;
|
||||
endpoint: string;
|
||||
retryAfterMs?: number;
|
||||
} = {
|
||||
feature: req.feature,
|
||||
operation: req.operation,
|
||||
endpoint,
|
||||
};
|
||||
const after = this.#extractRetryAfter(response);
|
||||
if (after !== undefined) payload.retryAfterMs = after;
|
||||
this.#emit("rate-limit-hit", payload);
|
||||
}
|
||||
|
||||
#emitFinal(
|
||||
req: RequestEnvelope,
|
||||
endpoint: string,
|
||||
startedAt: number,
|
||||
err: ElevenLabsApiError
|
||||
): void {
|
||||
const status =
|
||||
err.code === ELEVENLABS_CODES["NETWORK.ABORTED"]
|
||||
? "cancelled"
|
||||
: err.code === ELEVENLABS_CODES["NETWORK.TIMEOUT"] ||
|
||||
err.code === ELEVENLABS_CODES["NETWORK.UNREACHABLE"]
|
||||
? "network_error"
|
||||
: err.code.startsWith("THIRD_PARTY_ELEVENLABS.SERVER.")
|
||||
? "server_error"
|
||||
: "client_error";
|
||||
this.#emit("request-end", {
|
||||
feature: req.feature,
|
||||
operation: req.operation,
|
||||
endpoint,
|
||||
method: req.method,
|
||||
status,
|
||||
durationMs: this.#cfg.clock() - startedAt,
|
||||
});
|
||||
}
|
||||
|
||||
#emit(event: string, payload: unknown): void {
|
||||
if (this.#events === undefined) return;
|
||||
try {
|
||||
this.#events.emit(event, payload);
|
||||
} catch (err) {
|
||||
this.#logger?.warn("elevenlabs event emit failed", { err });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
# `headers`
|
||||
|
||||
HTTP header helpers for the ElevenLabs transport. Pure functions for
|
||||
parsing the `Retry-After` header, extracting per-response metadata, and
|
||||
building the canonical outbound header set.
|
||||
|
||||
## Purpose
|
||||
|
||||
ElevenLabs's REST API uses a small but specific set of conventions:
|
||||
authentication via `xi-api-key`, request correlation via `x-request-id`,
|
||||
rate-limit observability via `x-ratelimit-remaining`, and billing
|
||||
observability via `character-cost`. The functions here normalise those
|
||||
conventions into typed values that the rest of the client can safely
|
||||
work with — so no other module has to remember header names or worry
|
||||
about case sensitivity, missing headers, or malformed `Retry-After`
|
||||
values.
|
||||
|
||||
## Features
|
||||
|
||||
- `parseRetryAfter()` — accepts the two RFC 7231 forms (delta-seconds or
|
||||
HTTP-date) and returns milliseconds, with `undefined` on malformed
|
||||
input rather than throwing.
|
||||
- `extractMetadata()` — pulls every observability header into a typed
|
||||
`RequestMetadata` shape, returning a partial object with only the
|
||||
fields that were actually present.
|
||||
- `buildHeaders()` — composes API key + user-agent + caller-supplied
|
||||
defaults + per-request overrides + content-type into the final outbound
|
||||
`Record<string, string>`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { buildHeaders, extractMetadata, parseRetryAfter } from "./headers.js";
|
||||
|
||||
const headers = buildHeaders(
|
||||
"secret-key",
|
||||
"my-app/1.0",
|
||||
undefined,
|
||||
undefined,
|
||||
"application/json",
|
||||
"application/json"
|
||||
);
|
||||
|
||||
const ms = parseRetryAfter("5", () => Date.now()); // 5000
|
||||
const meta = extractMetadata(response.headers);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { buildHeaders, parseRetryAfter } from "./headers.js";
|
||||
|
||||
// Tenant-tagged outbound headers with a per-request override.
|
||||
const headers = buildHeaders(
|
||||
apiKey,
|
||||
"tenant-A/2.3",
|
||||
{ "x-tenant": "acme", "x-feature-flag": "stream" },
|
||||
{ "x-trace-id": traceId },
|
||||
undefined,
|
||||
"audio/mpeg"
|
||||
);
|
||||
|
||||
// Honour an HTTP-date Retry-After against an injectable clock.
|
||||
const fakeClock = () => 1_700_000_000_000;
|
||||
const wait = parseRetryAfter("Wed, 14 Nov 2023 22:13:25 GMT", fakeClock);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `parseRetryAfter(header: string | null | undefined, clock: Clock): number | undefined`
|
||||
— milliseconds until retry, or `undefined` if the header is missing /
|
||||
unparseable.
|
||||
- `extractMetadata(headers: Headers): RequestMetadata` — populates
|
||||
`requestId`, `rateLimitRemaining`, and `characterCost` only when the
|
||||
underlying header is present.
|
||||
- `buildHeaders(apiKey, userAgent, defaults?, perRequest?, contentType?, accept): Record<string, string>`
|
||||
— final header bag. Per-request entries override defaults.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `parseRetryAfter` strictly rejects fractional or negative numerics —
|
||||
V8's `Date.parse` is permissive enough that `"1.5"` becomes a real
|
||||
timestamp; that footgun lives here, not in the caller.
|
||||
- `extractMetadata` checks both `x-request-id` and `request-id` because
|
||||
ElevenLabs has used both spellings historically.
|
||||
- `buildHeaders` is order-sensitive: caller-supplied defaults are layered
|
||||
on top of the canonical headers, then per-request overrides win on top
|
||||
of defaults — but `xi-api-key` and `accept` always reflect the
|
||||
caller-provided values because they're set last in the canonical set.
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { buildHeaders, extractMetadata, parseRetryAfter } from "./headers.js";
|
||||
|
||||
describe("parseRetryAfter", () => {
|
||||
const clock = () => 1_000_000;
|
||||
|
||||
it("returns undefined when header is null or undefined", () => {
|
||||
expect(parseRetryAfter(null, clock)).toBeUndefined();
|
||||
expect(parseRetryAfter(undefined, clock)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for empty / whitespace header", () => {
|
||||
expect(parseRetryAfter("", clock)).toBeUndefined();
|
||||
expect(parseRetryAfter(" ", clock)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses delta-seconds as milliseconds", () => {
|
||||
expect(parseRetryAfter("5", clock)).toBe(5000);
|
||||
expect(parseRetryAfter(" 12 ", clock)).toBe(12_000);
|
||||
});
|
||||
|
||||
it("rejects non-integer or negative seconds", () => {
|
||||
expect(parseRetryAfter("1.5", clock)).toBeUndefined();
|
||||
expect(parseRetryAfter("-1", clock)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses HTTP-date relative to clock", () => {
|
||||
const futureClock = () => Date.parse("Wed, 21 Oct 2026 07:00:00 GMT");
|
||||
const tenSecondsLater = "Wed, 21 Oct 2026 07:00:10 GMT";
|
||||
const ms = parseRetryAfter(tenSecondsLater, futureClock);
|
||||
expect(ms).toBeGreaterThanOrEqual(9_500);
|
||||
expect(ms).toBeLessThanOrEqual(10_500);
|
||||
});
|
||||
|
||||
it("returns 0 when HTTP-date is in the past", () => {
|
||||
const futureClock = () => Date.parse("Wed, 21 Oct 2026 07:00:30 GMT");
|
||||
expect(
|
||||
parseRetryAfter("Wed, 21 Oct 2026 07:00:00 GMT", futureClock)
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("returns undefined for unparseable header", () => {
|
||||
expect(parseRetryAfter("not-a-thing", clock)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractMetadata", () => {
|
||||
it("returns empty object when no relevant headers are present", () => {
|
||||
expect(extractMetadata(new Headers())).toEqual({});
|
||||
});
|
||||
|
||||
it("captures requestId from x-request-id", () => {
|
||||
const h = new Headers({ "x-request-id": "req-1" });
|
||||
expect(extractMetadata(h)).toEqual({ requestId: "req-1" });
|
||||
});
|
||||
|
||||
it("falls back to request-id header", () => {
|
||||
const h = new Headers({ "request-id": "req-2" });
|
||||
expect(extractMetadata(h)).toEqual({ requestId: "req-2" });
|
||||
});
|
||||
|
||||
it("captures rateLimitRemaining as a number", () => {
|
||||
const h = new Headers({ "x-ratelimit-remaining": "42" });
|
||||
expect(extractMetadata(h)).toEqual({ rateLimitRemaining: 42 });
|
||||
});
|
||||
|
||||
it("ignores non-numeric rateLimitRemaining", () => {
|
||||
const h = new Headers({ "x-ratelimit-remaining": "nope" });
|
||||
expect(extractMetadata(h)).toEqual({});
|
||||
});
|
||||
|
||||
it("captures characterCost as a number", () => {
|
||||
const h = new Headers({ "character-cost": "100" });
|
||||
expect(extractMetadata(h)).toEqual({ characterCost: 100 });
|
||||
});
|
||||
|
||||
it("ignores non-numeric characterCost", () => {
|
||||
const h = new Headers({ "character-cost": "xx" });
|
||||
expect(extractMetadata(h)).toEqual({});
|
||||
});
|
||||
|
||||
it("captures all three fields together", () => {
|
||||
const h = new Headers({
|
||||
"x-request-id": "req-1",
|
||||
"x-ratelimit-remaining": "42",
|
||||
"character-cost": "100",
|
||||
});
|
||||
expect(extractMetadata(h)).toEqual({
|
||||
requestId: "req-1",
|
||||
rateLimitRemaining: 42,
|
||||
characterCost: 100,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildHeaders", () => {
|
||||
it("includes xi-api-key, user-agent and accept by default", () => {
|
||||
const h = buildHeaders(
|
||||
"sk_test",
|
||||
"test-agent/1.0",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
"application/json"
|
||||
);
|
||||
expect(h).toEqual({
|
||||
"xi-api-key": "sk_test",
|
||||
"user-agent": "test-agent/1.0",
|
||||
accept: "application/json",
|
||||
});
|
||||
});
|
||||
|
||||
it("adds content-type when supplied", () => {
|
||||
const h = buildHeaders(
|
||||
"sk_test",
|
||||
"agent",
|
||||
undefined,
|
||||
undefined,
|
||||
"application/json",
|
||||
"application/json"
|
||||
);
|
||||
expect(h["content-type"]).toBe("application/json");
|
||||
});
|
||||
|
||||
it("merges defaults and per-request headers, with per-request winning", () => {
|
||||
const h = buildHeaders(
|
||||
"sk_test",
|
||||
"agent",
|
||||
{ "x-tenant": "default", "x-trace": "abc" },
|
||||
{ "x-tenant": "override" },
|
||||
undefined,
|
||||
"application/json"
|
||||
);
|
||||
expect(h["x-tenant"]).toBe("override");
|
||||
expect(h["x-trace"]).toBe("abc");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import type { RequestMetadata } from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Parse a `Retry-After` header value to milliseconds. Accepts either a
|
||||
* non-negative integer (delta-seconds) or an HTTP-date (RFC 7231 §7.1.1.1).
|
||||
*
|
||||
* Returns `undefined` when the header is missing or unparseable.
|
||||
*/
|
||||
export function parseRetryAfter(
|
||||
header: string | null | undefined,
|
||||
clock: Clock
|
||||
): number | undefined {
|
||||
if (header === null || header === undefined) return undefined;
|
||||
const trimmed = header.trim();
|
||||
if (trimmed.length === 0) return undefined;
|
||||
const asInt = Number(trimmed);
|
||||
if (Number.isFinite(asInt)) {
|
||||
if (asInt >= 0 && Number.isInteger(asInt)) {
|
||||
return asInt * 1000;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const ts = Date.parse(trimmed);
|
||||
if (!Number.isNaN(ts)) {
|
||||
const delta = ts - clock();
|
||||
return delta > 0 ? delta : 0;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Extract `RequestMetadata` from a successful response's headers. */
|
||||
export function extractMetadata(headers: Headers): RequestMetadata {
|
||||
const out: { -readonly [K in keyof RequestMetadata]: RequestMetadata[K] } =
|
||||
{};
|
||||
const requestId =
|
||||
headers.get("x-request-id") ?? headers.get("request-id") ?? undefined;
|
||||
if (requestId !== null && requestId !== undefined)
|
||||
out.requestId = requestId;
|
||||
const rateLimitRemaining = headers.get("x-ratelimit-remaining");
|
||||
if (rateLimitRemaining !== null) {
|
||||
const n = Number(rateLimitRemaining);
|
||||
if (Number.isFinite(n)) out.rateLimitRemaining = n;
|
||||
}
|
||||
const characterCost = headers.get("character-cost");
|
||||
if (characterCost !== null) {
|
||||
const n = Number(characterCost);
|
||||
if (Number.isFinite(n)) out.characterCost = n;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Build the canonical request headers for an outbound call. */
|
||||
export function buildHeaders(
|
||||
apiKey: string,
|
||||
userAgent: string,
|
||||
defaults: Readonly<Record<string, string>> | undefined,
|
||||
perRequest: Readonly<Record<string, string>> | undefined,
|
||||
contentType: string | undefined,
|
||||
accept: string
|
||||
): Record<string, string> {
|
||||
const out: Record<string, string> = {
|
||||
"xi-api-key": apiKey,
|
||||
"user-agent": userAgent,
|
||||
accept,
|
||||
};
|
||||
if (contentType !== undefined) out["content-type"] = contentType;
|
||||
if (defaults !== undefined) Object.assign(out, defaults);
|
||||
if (perRequest !== undefined) Object.assign(out, perRequest);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { ElevenLabsClient } from "./client.js";
|
||||
export { buildHeaders, extractMetadata, parseRetryAfter } from "./headers.js";
|
||||
export { ElevenLabsSocket } from "./socket.js";
|
||||
export { buildUrl } from "./url.js";
|
||||
@@ -0,0 +1,96 @@
|
||||
# `internals`
|
||||
|
||||
Pure helper functions used by `ElevenLabsClient`. Everything here is
|
||||
side-effect-free, so the helpers are unit-testable without spinning up
|
||||
the full client machinery, and the `ElevenLabsClient` class can stay
|
||||
focused on transport orchestration.
|
||||
|
||||
## Purpose
|
||||
|
||||
The transport layer has many concerns that are independent of any
|
||||
particular request: mapping HTTP status codes to typed errors, deciding
|
||||
whether an error is retryable, computing the next backoff delay,
|
||||
materialising multipart bodies, and parsing response bodies safely. Each
|
||||
of these is a pure function of its inputs. Exposing them here keeps
|
||||
`client.ts` short, lets each helper be tested in isolation, and makes the
|
||||
HTTP-status-to-error mapping easy to audit at a glance.
|
||||
|
||||
## Features
|
||||
|
||||
- `mapHttpStatus()` — total mapping over `[400, 600)` to typed
|
||||
`ElevenLabsApiError` subclasses, preserving body on 422 and
|
||||
`retryAfterMs` on 429.
|
||||
- `mapNetworkError()` — translates DOMException AbortError / generic
|
||||
thrown errors into typed network errors.
|
||||
- `shouldRetry()` — checks code-and-budget for retryability.
|
||||
- `backoffDelay()` — exponential backoff that honours `Retry-After` when
|
||||
present.
|
||||
- `readJsonBody()` — safe response-body parser that returns `undefined`
|
||||
for empty bodies and a `string` when JSON parsing fails.
|
||||
- `buildMultipartBody()` — turns typed `MultipartField[]` into `FormData`.
|
||||
- `materializeMultipart()` — drains `ReadableStream` and fetches URL
|
||||
values into in-memory `Uint8Array` before upload.
|
||||
- `endpointFor()` / `buildCtx()` — context-builder helpers used in error
|
||||
payloads.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { mapHttpStatus, shouldRetry } from "./internals.js";
|
||||
|
||||
const err = mapHttpStatus(429, "", "GET /v1/x", "rid", 5000, undefined);
|
||||
if (shouldRetry(err, 0, 3)) {
|
||||
/* retry */
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
backoffDelay,
|
||||
buildMultipartBody,
|
||||
materializeMultipart,
|
||||
readJsonBody,
|
||||
} from "./internals.js";
|
||||
|
||||
// 503 retry with no Retry-After → exponential.
|
||||
const wait = backoffDelay(2, 250, undefined); // 1000
|
||||
|
||||
// Multipart upload from a streamed file.
|
||||
const fields = await materializeMultipart([
|
||||
{ kind: "file", name: "audio", filename: "a.wav", value: incomingStream },
|
||||
{ kind: "scalar", name: "model_id", value: "scribe_v1" },
|
||||
]);
|
||||
const form = buildMultipartBody(fields);
|
||||
|
||||
// Tolerant JSON parse.
|
||||
const body = await readJsonBody(response);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `mapHttpStatus(status, statusText, endpoint, requestId?, retryAfterMs?, body?): ElevenLabsApiError`
|
||||
- `mapNetworkError(err: unknown, endpoint: string): ElevenLabsApiError`
|
||||
- `shouldRetry(err, attempt, max): boolean`
|
||||
- `backoffDelay(attempt, initialMs, retryAfterMs?): number`
|
||||
- `readJsonBody(response: Response): Promise<unknown>`
|
||||
- `buildMultipartBody(fields: MultipartField[]): FormData`
|
||||
- `materializeMultipart(fields): Promise<MultipartField[]>`
|
||||
- `endpointFor(env: RequestEnvelope): string`
|
||||
- `buildCtx(endpoint, status, requestId?): { endpoint, statusCode, requestId? }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The HTTP-status mapping is **total** over `[400, 600)`: every code maps
|
||||
to exactly one `ElevenLabsApiError` subclass. 5xx codes 502 / 503 /
|
||||
504 map to `ServerTransientError` (retryable); other 5xx map to
|
||||
`ServerUnavailableError` (terminal).
|
||||
- 429 uses a heuristic on `statusText` to distinguish quota exhaustion
|
||||
from transient throttling — quota-exhausted is non-retryable.
|
||||
- `materializeMultipart` is the only place in the package that calls the
|
||||
global `fetch` directly (to resolve URL-typed multipart values); every
|
||||
other call goes through `client.#fetchOnce` which adds auth, timeouts,
|
||||
and retries.
|
||||
- `readJsonBody` never throws: empty body → `undefined`, JSON-parse
|
||||
failure → raw string. Callers don't need a try/catch.
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { mapHttpStatus } from "./internals.js";
|
||||
|
||||
describe("internals.mapHttpStatus", () => {
|
||||
it("covers the statusText nullish-coalesce branch when called directly with undefined", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// `mapHttpStatus(status, statusText ?? "")` cannot exercise the
|
||||
// `?? ""` branch from a `Response` because Response always provides
|
||||
// a string statusText. Calling the function directly with
|
||||
// `statusText: undefined` exercises the branch.
|
||||
const err = mapHttpStatus(
|
||||
429,
|
||||
undefined as unknown as string,
|
||||
"GET /x",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
expect(err).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import {
|
||||
ELEVENLABS_CODES,
|
||||
ElevenLabsApiError,
|
||||
ForbiddenError,
|
||||
NetworkAbortedError,
|
||||
NetworkUnreachableError,
|
||||
RateLimitExceededError,
|
||||
RateLimitQuotaExhaustedError,
|
||||
RequestInvalidError,
|
||||
RequestNotFoundError,
|
||||
RequestValidationFailedError,
|
||||
ServerTransientError,
|
||||
ServerUnavailableError,
|
||||
UnauthorizedError,
|
||||
} from "../errors/errors.js";
|
||||
import type { MultipartField, RequestEnvelope } from "../types/types.js";
|
||||
|
||||
/** `${method} ${path}` — used in error context. */
|
||||
export function endpointFor(env: RequestEnvelope): string {
|
||||
return `${env.method} ${env.path}`;
|
||||
}
|
||||
|
||||
/** Build the standard error-context bag used when constructing typed errors. */
|
||||
export function buildCtx(
|
||||
endpoint: string,
|
||||
status: number,
|
||||
requestId: string | undefined
|
||||
): { endpoint: string; statusCode: number; requestId?: string } {
|
||||
const ctx: { endpoint: string; statusCode: number; requestId?: string } = {
|
||||
endpoint,
|
||||
statusCode: status,
|
||||
};
|
||||
if (requestId !== undefined) ctx.requestId = requestId;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an HTTP status code (and the parsed body / `Retry-After` header) to a
|
||||
* typed `ElevenLabsApiError` subclass. The mapping is total over the
|
||||
* `[400, 600)` range; 422 preserves `body`, 429 preserves `retryAfterMs`.
|
||||
*/
|
||||
export function mapHttpStatus(
|
||||
status: number,
|
||||
statusText: string,
|
||||
endpoint: string,
|
||||
requestId: string | undefined,
|
||||
retryAfterMs: number | undefined,
|
||||
body: unknown
|
||||
): ElevenLabsApiError {
|
||||
const ctx = buildCtx(endpoint, status, requestId);
|
||||
if (status === 401) return new UnauthorizedError(ctx);
|
||||
if (status === 403) return new ForbiddenError(ctx);
|
||||
if (status === 404) return new RequestNotFoundError(ctx);
|
||||
if (status === 422) {
|
||||
const v: typeof ctx & { body?: unknown } = { ...ctx };
|
||||
if (body !== undefined) v.body = body;
|
||||
return new RequestValidationFailedError(v);
|
||||
}
|
||||
if (status === 429) {
|
||||
const looksLikeQuota = /quota|exhaust/i.test(statusText ?? "");
|
||||
if (looksLikeQuota) return new RateLimitQuotaExhaustedError(ctx);
|
||||
const r: typeof ctx & { retryAfterMs?: number } = { ...ctx };
|
||||
if (retryAfterMs !== undefined) r.retryAfterMs = retryAfterMs;
|
||||
return new RateLimitExceededError(r);
|
||||
}
|
||||
if (status >= 400 && status < 500) return new RequestInvalidError(ctx);
|
||||
if (status === 502 || status === 503 || status === 504)
|
||||
return new ServerTransientError(ctx);
|
||||
return new ServerUnavailableError(ctx);
|
||||
}
|
||||
|
||||
/** Map a thrown transport error to a typed `ElevenLabsApiError`. */
|
||||
export function mapNetworkError(
|
||||
err: unknown,
|
||||
endpoint: string
|
||||
): ElevenLabsApiError {
|
||||
if (err instanceof ElevenLabsApiError) return err;
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
return new NetworkAbortedError({ endpoint, cause: err });
|
||||
}
|
||||
return new NetworkUnreachableError({ endpoint, cause: err });
|
||||
}
|
||||
|
||||
/** Whether `err` should be retried at `attempt` given a budget of `max`. */
|
||||
export function shouldRetry(
|
||||
err: ElevenLabsApiError,
|
||||
attempt: number,
|
||||
max: number
|
||||
): boolean {
|
||||
if (attempt >= max) return false;
|
||||
return (
|
||||
err.code === ELEVENLABS_CODES["RATE_LIMIT.EXCEEDED"] ||
|
||||
err.code === ELEVENLABS_CODES["SERVER.TRANSIENT"] ||
|
||||
err.code === ELEVENLABS_CODES["NETWORK.UNREACHABLE"] ||
|
||||
err.code === ELEVENLABS_CODES["NETWORK.TIMEOUT"]
|
||||
);
|
||||
}
|
||||
|
||||
/** Exponential backoff in milliseconds, honoring `Retry-After` if present. */
|
||||
export function backoffDelay(
|
||||
attempt: number,
|
||||
initialMs: number,
|
||||
retryAfterMs: number | undefined
|
||||
): number {
|
||||
if (retryAfterMs !== undefined) return retryAfterMs;
|
||||
return initialMs * 2 ** attempt;
|
||||
}
|
||||
|
||||
/** Parse a JSON response body, returning `undefined` on empty / `string` on parse failure. */
|
||||
export async function readJsonBody(response: Response): Promise<unknown> {
|
||||
const text = await response.text();
|
||||
if (text.length === 0) return undefined;
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a `FormData` body from typed `MultipartField[]`. */
|
||||
export function buildMultipartBody(
|
||||
fields: readonly MultipartField[]
|
||||
): FormData {
|
||||
const form = new FormData();
|
||||
for (const field of fields) {
|
||||
if (field.kind === "scalar") {
|
||||
form.append(field.name, field.value);
|
||||
continue;
|
||||
}
|
||||
const value = field.value;
|
||||
const filename = field.filename;
|
||||
if (value instanceof Uint8Array) {
|
||||
const blob = new Blob([new Uint8Array(value)], {
|
||||
type: field.contentType ?? "application/octet-stream",
|
||||
});
|
||||
form.append(field.name, blob, filename);
|
||||
} else if (value instanceof Blob) {
|
||||
form.append(field.name, value, filename);
|
||||
} else {
|
||||
throw new RequestInvalidError({
|
||||
message: "Unsupported multipart value type",
|
||||
});
|
||||
}
|
||||
}
|
||||
return form;
|
||||
}
|
||||
|
||||
/** Resolve URL- and stream-typed multipart values into in-memory `Uint8Array`. */
|
||||
export async function materializeMultipart(
|
||||
fields: readonly MultipartField[]
|
||||
): Promise<readonly MultipartField[]> {
|
||||
const out: MultipartField[] = [];
|
||||
for (const field of fields) {
|
||||
if (field.kind === "scalar") {
|
||||
out.push(field);
|
||||
continue;
|
||||
}
|
||||
const v = field.value;
|
||||
if (typeof v === "string") {
|
||||
const res = await fetch(v);
|
||||
if (!res.ok)
|
||||
throw new NetworkUnreachableError({
|
||||
message: `Failed to fetch multipart URL: ${res.status}`,
|
||||
});
|
||||
const buf = new Uint8Array(await res.arrayBuffer());
|
||||
out.push({ ...field, value: buf });
|
||||
continue;
|
||||
}
|
||||
if (v instanceof ReadableStream) {
|
||||
const chunks: Uint8Array[] = [];
|
||||
const reader = v.getReader();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value !== undefined) chunks.push(value);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
const total = chunks.reduce((n, c) => n + c.byteLength, 0);
|
||||
const merged = new Uint8Array(total);
|
||||
let off = 0;
|
||||
for (const c of chunks) {
|
||||
merged.set(c, off);
|
||||
off += c.byteLength;
|
||||
}
|
||||
out.push({ ...field, value: merged });
|
||||
continue;
|
||||
}
|
||||
out.push(field);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
108
packages/media/audio/third-party-elevenlabs/src/client/socket.md
Normal file
108
packages/media/audio/third-party-elevenlabs/src/client/socket.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# `socket`
|
||||
|
||||
Wrapper around any `WebSocketLike` (the browser global, `ws` from Node,
|
||||
or a fake from tests). Exposes a typed frame-level API and a
|
||||
`Promise<void>` for the open handshake.
|
||||
|
||||
## Purpose
|
||||
|
||||
Raw WebSocket APIs are awkward — open / message / close / error are
|
||||
delivered as untyped events, binary frames arrive as `Blob` /
|
||||
`ArrayBuffer` / typed-array views depending on platform, and there's no
|
||||
built-in way to await the open handshake. `ElevenLabsSocket` papers over
|
||||
all of that so adapters that need WebSocket transport (e.g. the
|
||||
streaming-input TTS endpoint) can `await sock.waitOpen()` and then send
|
||||
typed `SocketFrame` values without touching the raw event surface.
|
||||
|
||||
## Features
|
||||
|
||||
- `waitOpen()` — promise that resolves on `open` and rejects on `error`
|
||||
or caller-signal abort.
|
||||
- `send(frame: SocketFrame)` — typed text or binary send; binary frames
|
||||
are copied into a fresh `ArrayBuffer` so the underlying WS impl never
|
||||
sees a SharedArrayBuffer-backed view by accident.
|
||||
- `onMessage` / `onClose` / `onError` — listeners returning unsubscribe
|
||||
functions.
|
||||
- Accepts `Uint8Array`, `ArrayBuffer`, or any `ArrayBufferView` on the
|
||||
wire and normalises them to `Uint8Array` before dispatching.
|
||||
- Sends on a closed socket throw `WebSocketProtocolError` instead of
|
||||
silently dropping the frame.
|
||||
- Pluggable `WebSocketConstructor` so tests inject a fake.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ElevenLabsClient } from "@suaveplan/third-party-elevenlabs";
|
||||
|
||||
const client = new ElevenLabsClient({ apiKey: process.env.KEY! });
|
||||
const sock = client.openSocket({
|
||||
path: "/v1/text-to-speech/voice-id/stream-input",
|
||||
feature: "tts",
|
||||
operation: "stream_input",
|
||||
query: { model_id: "eleven_flash_v2" },
|
||||
});
|
||||
await sock.waitOpen();
|
||||
sock.onMessage((f) => process.stdout.write(f.data));
|
||||
sock.send({ kind: "text", data: JSON.stringify({ text: "hello" }) });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { ElevenLabsClient } from "@suaveplan/third-party-elevenlabs";
|
||||
|
||||
const ctrl = new AbortController();
|
||||
const client = new ElevenLabsClient({
|
||||
apiKey: KEY,
|
||||
webSocketImpl: WS, // 'ws' on Node
|
||||
});
|
||||
const sock = client.openSocket({
|
||||
path: "/v1/text-to-speech/abc/stream-input",
|
||||
feature: "tts",
|
||||
operation: "stream_input",
|
||||
});
|
||||
const unsubMsg = sock.onMessage((f) => sink.write(f.data));
|
||||
const unsubClose = sock.onClose((e) =>
|
||||
log.info("closed", { code: e.code, clean: e.wasClean })
|
||||
);
|
||||
sock.onError((err) => log.warn("ws error", { err }));
|
||||
try {
|
||||
await sock.waitOpen();
|
||||
for (const chunk of corpus) {
|
||||
sock.send({ kind: "text", data: JSON.stringify({ text: chunk }) });
|
||||
}
|
||||
} finally {
|
||||
unsubMsg();
|
||||
unsubClose();
|
||||
sock.close(1000, "done");
|
||||
}
|
||||
ctrl.abort();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new ElevenLabsSocket(ws: WebSocketLike, endpoint: string, signal?: AbortSignal)`
|
||||
- `waitOpen(): Promise<void>`
|
||||
- `readyState: number` — 0 connecting / 1 open / 2 closing / 3 closed.
|
||||
- `send(frame: SocketFrame): void` — `{ kind: "text" | "binary", data }`
|
||||
- `close(code?: number, reason?: string): void`
|
||||
- `onMessage(listener): () => void`
|
||||
- `onClose(listener): () => void`
|
||||
- `onError(listener): () => void`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `xi-api-key` is sent as a query parameter (browsers can't set
|
||||
headers on the WS upgrade) — that's done in `ElevenLabsClient.openSocket`,
|
||||
not here.
|
||||
- Binary `send` always copies into a fresh `ArrayBuffer` to defend
|
||||
against SharedArrayBuffer leakage; the cost is one heap allocation per
|
||||
outbound binary frame.
|
||||
- The internal `#wireOpenPromise` removes its `error` listener once the
|
||||
promise settles, so post-open transport errors flow only into the
|
||||
user-installed `onError` listeners — they don't reject `waitOpen()`
|
||||
twice.
|
||||
- `bytesFrom` rejects unsupported binary payload types with
|
||||
`WebSocketProtocolError`. The error is dispatched to `onError` rather
|
||||
than thrown synchronously, since it originates inside an event handler
|
||||
callback.
|
||||
@@ -0,0 +1,365 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
NetworkAbortedError,
|
||||
WebSocketConnectionFailedError,
|
||||
WebSocketProtocolError,
|
||||
} from "../errors/errors.js";
|
||||
import type {
|
||||
SocketCloseEvent,
|
||||
SocketFrame,
|
||||
WebSocketConstructor,
|
||||
WebSocketLike,
|
||||
} from "../types/types.js";
|
||||
import { ElevenLabsClient } from "./client.js";
|
||||
import { ElevenLabsSocket } from "./socket.js";
|
||||
|
||||
type EventName = "open" | "message" | "close" | "error";
|
||||
type EventListener = (event: unknown) => void;
|
||||
|
||||
interface FakeOptions {
|
||||
readonly readyState?: number;
|
||||
}
|
||||
|
||||
class FakeWebSocket implements WebSocketLike {
|
||||
readyState: number;
|
||||
readonly url: string;
|
||||
sent: Array<string | ArrayBufferLike | Blob | ArrayBufferView> = [];
|
||||
closed: { code?: number; reason?: string } | null = null;
|
||||
readonly #listeners: Record<EventName, Set<EventListener>> = {
|
||||
open: new Set(),
|
||||
message: new Set(),
|
||||
close: new Set(),
|
||||
error: new Set(),
|
||||
};
|
||||
|
||||
constructor(url: string | URL, opts: FakeOptions = {}) {
|
||||
this.url = String(url);
|
||||
this.readyState = opts.readyState ?? 0;
|
||||
}
|
||||
|
||||
addEventListener(type: EventName, listener: EventListener): void {
|
||||
this.#listeners[type].add(listener);
|
||||
}
|
||||
|
||||
removeEventListener(type: EventName, listener: EventListener): void {
|
||||
this.#listeners[type].delete(listener);
|
||||
}
|
||||
|
||||
send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
|
||||
this.sent.push(data);
|
||||
}
|
||||
|
||||
close(code?: number, reason?: string): void {
|
||||
this.readyState = 3;
|
||||
this.closed = {};
|
||||
if (code !== undefined) this.closed.code = code;
|
||||
if (reason !== undefined) this.closed.reason = reason;
|
||||
}
|
||||
|
||||
fireOpen(): void {
|
||||
this.readyState = 1;
|
||||
for (const fn of this.#listeners.open) fn({});
|
||||
}
|
||||
|
||||
fireMessage(data: unknown): void {
|
||||
for (const fn of this.#listeners.message) fn({ data });
|
||||
}
|
||||
|
||||
fireClose(payload: Partial<SocketCloseEvent> = {}): void {
|
||||
for (const fn of this.#listeners.close) fn(payload);
|
||||
}
|
||||
|
||||
fireError(payload: unknown = {}): void {
|
||||
for (const fn of this.#listeners.error) fn(payload);
|
||||
}
|
||||
}
|
||||
|
||||
describe("ElevenLabsSocket", () => {
|
||||
it("waitOpen resolves on `open` event", async () => {
|
||||
const ws = new FakeWebSocket("wss://x");
|
||||
const sock = new ElevenLabsSocket(ws, "/v1/x");
|
||||
const p = sock.waitOpen();
|
||||
ws.fireOpen();
|
||||
await expect(p).resolves.toBeUndefined();
|
||||
expect(sock.readyState).toBe(1);
|
||||
});
|
||||
|
||||
it("waitOpen rejects on `error` event", async () => {
|
||||
const ws = new FakeWebSocket("wss://x");
|
||||
const sock = new ElevenLabsSocket(ws, "/v1/x");
|
||||
const p = sock.waitOpen();
|
||||
ws.fireError(new Error("boom"));
|
||||
await expect(p).rejects.toBeInstanceOf(WebSocketConnectionFailedError);
|
||||
});
|
||||
|
||||
it("waitOpen rejects immediately when signal already aborted", async () => {
|
||||
const ws = new FakeWebSocket("wss://x");
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
const sock = new ElevenLabsSocket(ws, "/v1/x", ctrl.signal);
|
||||
await expect(sock.waitOpen()).rejects.toBeInstanceOf(
|
||||
NetworkAbortedError
|
||||
);
|
||||
});
|
||||
|
||||
it("waitOpen aborts on later signal abort and closes the socket", async () => {
|
||||
const ws = new FakeWebSocket("wss://x");
|
||||
const ctrl = new AbortController();
|
||||
const sock = new ElevenLabsSocket(ws, "/v1/x", ctrl.signal);
|
||||
const p = sock.waitOpen();
|
||||
ctrl.abort();
|
||||
await expect(p).rejects.toBeInstanceOf(NetworkAbortedError);
|
||||
expect(ws.closed).not.toBeNull();
|
||||
});
|
||||
|
||||
it("send delivers text frames as strings", () => {
|
||||
const ws = new FakeWebSocket("wss://x");
|
||||
const sock = new ElevenLabsSocket(ws, "/v1/x");
|
||||
sock.send({ kind: "text", data: "hello" });
|
||||
expect(ws.sent[0]).toBe("hello");
|
||||
});
|
||||
|
||||
it("send delivers binary frames as ArrayBuffer copies", () => {
|
||||
const ws = new FakeWebSocket("wss://x");
|
||||
const sock = new ElevenLabsSocket(ws, "/v1/x");
|
||||
const original = new Uint8Array([1, 2, 3]);
|
||||
sock.send({ kind: "binary", data: original });
|
||||
const sent = ws.sent[0] as ArrayBuffer;
|
||||
expect(sent).toBeInstanceOf(ArrayBuffer);
|
||||
expect(new Uint8Array(sent)).toEqual(original);
|
||||
});
|
||||
|
||||
it("send throws WebSocketProtocolError after close()", () => {
|
||||
const ws = new FakeWebSocket("wss://x");
|
||||
const sock = new ElevenLabsSocket(ws, "/v1/x");
|
||||
sock.close();
|
||||
expect(() => sock.send({ kind: "text", data: "x" })).toThrow(
|
||||
WebSocketProtocolError
|
||||
);
|
||||
});
|
||||
|
||||
it("close() forwards code and reason to underlying ws", () => {
|
||||
const ws = new FakeWebSocket("wss://x");
|
||||
const sock = new ElevenLabsSocket(ws, "/v1/x");
|
||||
sock.close(4000, "bye");
|
||||
expect(ws.closed).toEqual({ code: 4000, reason: "bye" });
|
||||
});
|
||||
|
||||
it("onMessage receives text frames", () => {
|
||||
const ws = new FakeWebSocket("wss://x");
|
||||
const sock = new ElevenLabsSocket(ws, "/v1/x");
|
||||
const received: SocketFrame[] = [];
|
||||
sock.onMessage((f) => received.push(f));
|
||||
ws.fireMessage("hello");
|
||||
expect(received).toEqual([{ kind: "text", data: "hello" }]);
|
||||
});
|
||||
|
||||
it("onMessage receives binary frames from Uint8Array", () => {
|
||||
const ws = new FakeWebSocket("wss://x");
|
||||
const sock = new ElevenLabsSocket(ws, "/v1/x");
|
||||
const received: SocketFrame[] = [];
|
||||
sock.onMessage((f) => received.push(f));
|
||||
ws.fireMessage(new Uint8Array([1, 2]));
|
||||
expect(received[0]).toEqual({
|
||||
kind: "binary",
|
||||
data: new Uint8Array([1, 2]),
|
||||
});
|
||||
});
|
||||
|
||||
it("onMessage receives binary frames from ArrayBuffer", () => {
|
||||
const ws = new FakeWebSocket("wss://x");
|
||||
const sock = new ElevenLabsSocket(ws, "/v1/x");
|
||||
const received: SocketFrame[] = [];
|
||||
sock.onMessage((f) => received.push(f));
|
||||
ws.fireMessage(new Uint8Array([5, 6]).buffer);
|
||||
expect(received[0]).toEqual({
|
||||
kind: "binary",
|
||||
data: new Uint8Array([5, 6]),
|
||||
});
|
||||
});
|
||||
|
||||
it("onMessage receives binary frames from typed-array view", () => {
|
||||
const ws = new FakeWebSocket("wss://x");
|
||||
const sock = new ElevenLabsSocket(ws, "/v1/x");
|
||||
const received: SocketFrame[] = [];
|
||||
sock.onMessage((f) => received.push(f));
|
||||
const buf = new Uint8Array([0, 9, 8, 7, 0]);
|
||||
const view = new Uint8Array(buf.buffer, 1, 3);
|
||||
ws.fireMessage(view);
|
||||
expect(received[0]).toEqual({
|
||||
kind: "binary",
|
||||
data: new Uint8Array([9, 8, 7]),
|
||||
});
|
||||
});
|
||||
|
||||
it("onMessage routes unsupported binary payloads to onError", () => {
|
||||
const ws = new FakeWebSocket("wss://x");
|
||||
const sock = new ElevenLabsSocket(ws, "/v1/x");
|
||||
const errors: unknown[] = [];
|
||||
sock.onError((e) => errors.push(e));
|
||||
ws.fireMessage(42 as unknown);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toBeInstanceOf(WebSocketProtocolError);
|
||||
});
|
||||
|
||||
it("onMessage drops events with undefined data", () => {
|
||||
const ws = new FakeWebSocket("wss://x");
|
||||
const sock = new ElevenLabsSocket(ws, "/v1/x");
|
||||
const received: SocketFrame[] = [];
|
||||
sock.onMessage((f) => received.push(f));
|
||||
ws.fireMessage(undefined);
|
||||
expect(received).toEqual([]);
|
||||
});
|
||||
|
||||
it("onClose receives normalised SocketCloseEvent", () => {
|
||||
const ws = new FakeWebSocket("wss://x");
|
||||
const sock = new ElevenLabsSocket(ws, "/v1/x");
|
||||
const got: SocketCloseEvent[] = [];
|
||||
sock.onClose((e) => got.push(e));
|
||||
ws.fireClose({ code: 1006, reason: "abnormal", wasClean: false });
|
||||
expect(got).toEqual([
|
||||
{ code: 1006, reason: "abnormal", wasClean: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("onClose defaults missing fields", () => {
|
||||
const ws = new FakeWebSocket("wss://x");
|
||||
const sock = new ElevenLabsSocket(ws, "/v1/x");
|
||||
const got: SocketCloseEvent[] = [];
|
||||
sock.onClose((e) => got.push(e));
|
||||
ws.fireClose({});
|
||||
expect(got[0]).toEqual({ code: 1000, reason: "", wasClean: false });
|
||||
});
|
||||
|
||||
it("listeners can be unsubscribed", () => {
|
||||
const ws = new FakeWebSocket("wss://x");
|
||||
const sock = new ElevenLabsSocket(ws, "/v1/x");
|
||||
const got: SocketFrame[] = [];
|
||||
const unsub = sock.onMessage((f) => got.push(f));
|
||||
ws.fireMessage("first");
|
||||
unsub();
|
||||
ws.fireMessage("second");
|
||||
expect(got).toHaveLength(1);
|
||||
const unsubClose = sock.onClose(() => undefined);
|
||||
const unsubError = sock.onError(() => undefined);
|
||||
expect(typeof unsubClose).toBe("function");
|
||||
expect(typeof unsubError).toBe("function");
|
||||
unsubClose();
|
||||
unsubError();
|
||||
});
|
||||
|
||||
it("send after fireClose() is treated as send-on-closed", () => {
|
||||
const ws = new FakeWebSocket("wss://x");
|
||||
const sock = new ElevenLabsSocket(ws, "/v1/x");
|
||||
ws.fireClose();
|
||||
expect(() => sock.send({ kind: "text", data: "x" })).toThrow(
|
||||
WebSocketProtocolError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ElevenLabsClient.openSocket", () => {
|
||||
function makeCtor(captured: { url?: string }): {
|
||||
Ctor: WebSocketConstructor;
|
||||
instances: FakeWebSocket[];
|
||||
} {
|
||||
const instances: FakeWebSocket[] = [];
|
||||
const Ctor = function (this: FakeWebSocket, url: string | URL) {
|
||||
const ws = new FakeWebSocket(url);
|
||||
captured.url = String(url);
|
||||
instances.push(ws);
|
||||
return ws;
|
||||
} as unknown as WebSocketConstructor;
|
||||
return { Ctor, instances };
|
||||
}
|
||||
|
||||
it("constructs a wss URL with xi-api-key query parameter", () => {
|
||||
const captured: { url?: string } = {};
|
||||
const { Ctor } = makeCtor(captured);
|
||||
const client = new ElevenLabsClient({
|
||||
apiKey: "secret",
|
||||
fetchImpl: globalThis.fetch,
|
||||
webSocketImpl: Ctor,
|
||||
});
|
||||
client.openSocket({
|
||||
path: "/v1/text-to-speech/v/stream-input",
|
||||
feature: "tts",
|
||||
operation: "stream_input",
|
||||
query: { model_id: "m" },
|
||||
});
|
||||
expect(captured.url).toContain("wss://api.elevenlabs.io/");
|
||||
expect(captured.url).toContain("model_id=m");
|
||||
expect(captured.url).toContain("xi-api-key=secret");
|
||||
});
|
||||
|
||||
it("converts http base URL to ws://", () => {
|
||||
const captured: { url?: string } = {};
|
||||
const { Ctor } = makeCtor(captured);
|
||||
const client = new ElevenLabsClient({
|
||||
apiKey: "k",
|
||||
fetchImpl: globalThis.fetch,
|
||||
webSocketImpl: Ctor,
|
||||
baseUrl: "http://localhost:1234",
|
||||
});
|
||||
client.openSocket({
|
||||
path: "/x",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
});
|
||||
expect(captured.url).toContain("ws://localhost:1234/x");
|
||||
});
|
||||
|
||||
it("leaves a non-http baseUrl untouched (already-ws scheme)", () => {
|
||||
const captured: { url?: string } = {};
|
||||
const { Ctor } = makeCtor(captured);
|
||||
const client = new ElevenLabsClient({
|
||||
apiKey: "k",
|
||||
fetchImpl: globalThis.fetch,
|
||||
webSocketImpl: Ctor,
|
||||
baseUrl: "wss://eu.example.com",
|
||||
});
|
||||
client.openSocket({ path: "/x", feature: "f", operation: "o" });
|
||||
expect(captured.url).toContain("wss://eu.example.com/x");
|
||||
});
|
||||
|
||||
it("throws WebSocketConnectionFailedError when no WebSocket impl is available", () => {
|
||||
const previous = (globalThis as { WebSocket?: unknown }).WebSocket;
|
||||
(globalThis as { WebSocket?: unknown }).WebSocket = undefined;
|
||||
try {
|
||||
const client = new ElevenLabsClient({
|
||||
apiKey: "k",
|
||||
fetchImpl: globalThis.fetch,
|
||||
});
|
||||
expect(() =>
|
||||
client.openSocket({
|
||||
path: "/x",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
})
|
||||
).toThrow(WebSocketConnectionFailedError);
|
||||
} finally {
|
||||
(globalThis as { WebSocket?: unknown }).WebSocket = previous;
|
||||
}
|
||||
});
|
||||
|
||||
it("uses globalThis.WebSocket when no explicit impl is given", () => {
|
||||
const captured: { url?: string } = {};
|
||||
const { Ctor } = makeCtor(captured);
|
||||
const previous = (globalThis as { WebSocket?: unknown }).WebSocket;
|
||||
(globalThis as { WebSocket?: unknown }).WebSocket = Ctor;
|
||||
try {
|
||||
const client = new ElevenLabsClient({
|
||||
apiKey: "k",
|
||||
fetchImpl: globalThis.fetch,
|
||||
});
|
||||
client.openSocket({
|
||||
path: "/x",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
});
|
||||
expect(captured.url).toBeDefined();
|
||||
} finally {
|
||||
(globalThis as { WebSocket?: unknown }).WebSocket = previous;
|
||||
}
|
||||
});
|
||||
});
|
||||
190
packages/media/audio/third-party-elevenlabs/src/client/socket.ts
Normal file
190
packages/media/audio/third-party-elevenlabs/src/client/socket.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import {
|
||||
NetworkAbortedError,
|
||||
WebSocketConnectionFailedError,
|
||||
WebSocketProtocolError,
|
||||
} from "../errors/errors.js";
|
||||
import type {
|
||||
SocketCloseEvent,
|
||||
SocketFrame,
|
||||
WebSocketLike,
|
||||
} from "../types/types.js";
|
||||
|
||||
/** Listener registered via `onMessage` / `onClose` / `onError`. */
|
||||
type Listener<T> = (payload: T) => void;
|
||||
type Unsubscribe = () => void;
|
||||
|
||||
interface CloseEventLike {
|
||||
readonly code?: number;
|
||||
readonly reason?: string;
|
||||
readonly wasClean?: boolean;
|
||||
}
|
||||
|
||||
interface MessageEventLike {
|
||||
readonly data: unknown;
|
||||
}
|
||||
|
||||
function bytesFrom(data: unknown): Uint8Array {
|
||||
if (data instanceof Uint8Array) return data;
|
||||
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
||||
if (ArrayBuffer.isView(data)) {
|
||||
return new Uint8Array(
|
||||
data.buffer.slice(
|
||||
data.byteOffset,
|
||||
data.byteOffset + data.byteLength
|
||||
)
|
||||
);
|
||||
}
|
||||
throw new WebSocketProtocolError({
|
||||
message: "Unsupported binary frame payload",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around any `WebSocketLike` (e.g. the browser global, `ws` from
|
||||
* Node). Exposes a `Promise<void>` for the open handshake, typed
|
||||
* `send(SocketFrame)` for outbound frames, and dispatches `SocketFrame` /
|
||||
* `SocketCloseEvent` to subscribers. Map close codes / errors to typed
|
||||
* `WebSocketProtocolError` / `WebSocketConnectionFailedError`.
|
||||
*/
|
||||
export class ElevenLabsSocket {
|
||||
readonly #ws: WebSocketLike;
|
||||
readonly #endpoint: string;
|
||||
readonly #messageListeners = new Set<Listener<SocketFrame>>();
|
||||
readonly #closeListeners = new Set<Listener<SocketCloseEvent>>();
|
||||
readonly #errorListeners = new Set<Listener<unknown>>();
|
||||
readonly #openPromise: Promise<void>;
|
||||
#closed = false;
|
||||
|
||||
constructor(ws: WebSocketLike, endpoint: string, signal?: AbortSignal) {
|
||||
this.#ws = ws;
|
||||
this.#endpoint = endpoint;
|
||||
this.#openPromise = this.#wireOpenPromise(signal);
|
||||
this.#wireMessage();
|
||||
this.#wireClose();
|
||||
this.#wireError();
|
||||
}
|
||||
|
||||
/** Resolves once the WebSocket transitions to OPEN. */
|
||||
waitOpen(): Promise<void> {
|
||||
return this.#openPromise;
|
||||
}
|
||||
|
||||
/** Returns the underlying readyState (0=connecting, 1=open, 2=closing, 3=closed). */
|
||||
get readyState(): number {
|
||||
return this.#ws.readyState;
|
||||
}
|
||||
|
||||
/** Send a typed text or binary frame. */
|
||||
send(frame: SocketFrame): void {
|
||||
if (this.#closed)
|
||||
throw new WebSocketProtocolError({
|
||||
context: { endpoint: this.#endpoint },
|
||||
message: "Cannot send on a closed socket",
|
||||
});
|
||||
if (frame.kind === "text") {
|
||||
this.#ws.send(frame.data);
|
||||
return;
|
||||
}
|
||||
// Force a fresh ArrayBufferLike copy so the underlying WS impl
|
||||
// never sees a SharedArrayBuffer-backed view by accident.
|
||||
this.#ws.send(new Uint8Array(frame.data).buffer);
|
||||
}
|
||||
|
||||
/** Close the socket. */
|
||||
close(code?: number, reason?: string): void {
|
||||
this.#closed = true;
|
||||
this.#ws.close(code, reason);
|
||||
}
|
||||
|
||||
/** Subscribe to inbound frames. Returns an unsubscribe function. */
|
||||
onMessage(listener: Listener<SocketFrame>): Unsubscribe {
|
||||
this.#messageListeners.add(listener);
|
||||
return () => this.#messageListeners.delete(listener);
|
||||
}
|
||||
|
||||
/** Subscribe to close events. Returns an unsubscribe function. */
|
||||
onClose(listener: Listener<SocketCloseEvent>): Unsubscribe {
|
||||
this.#closeListeners.add(listener);
|
||||
return () => this.#closeListeners.delete(listener);
|
||||
}
|
||||
|
||||
/** Subscribe to raw transport errors. Returns an unsubscribe function. */
|
||||
onError(listener: Listener<unknown>): Unsubscribe {
|
||||
this.#errorListeners.add(listener);
|
||||
return () => this.#errorListeners.delete(listener);
|
||||
}
|
||||
|
||||
#wireOpenPromise(signal: AbortSignal | undefined): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new NetworkAbortedError({ endpoint: this.#endpoint }));
|
||||
return;
|
||||
}
|
||||
const onOpen = (): void => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const onError = (event: unknown): void => {
|
||||
cleanup();
|
||||
reject(
|
||||
new WebSocketConnectionFailedError({
|
||||
context: { endpoint: this.#endpoint },
|
||||
cause: event,
|
||||
})
|
||||
);
|
||||
};
|
||||
const onAbort = (): void => {
|
||||
cleanup();
|
||||
this.#ws.close(1000, "client aborted");
|
||||
reject(new NetworkAbortedError({ endpoint: this.#endpoint }));
|
||||
};
|
||||
const cleanup = (): void => {
|
||||
this.#ws.removeEventListener("open", onOpen);
|
||||
this.#ws.removeEventListener("error", onError);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
};
|
||||
this.#ws.addEventListener("open", onOpen);
|
||||
this.#ws.addEventListener("error", onError);
|
||||
if (signal !== undefined)
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
#wireMessage(): void {
|
||||
this.#ws.addEventListener("message", (event: unknown) => {
|
||||
const data = (event as MessageEventLike | undefined)?.data;
|
||||
if (data === undefined) return;
|
||||
let frame: SocketFrame;
|
||||
if (typeof data === "string") {
|
||||
frame = { kind: "text", data };
|
||||
} else {
|
||||
try {
|
||||
frame = { kind: "binary", data: bytesFrom(data) };
|
||||
} catch (err) {
|
||||
for (const fn of this.#errorListeners) fn(err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (const fn of this.#messageListeners) fn(frame);
|
||||
});
|
||||
}
|
||||
|
||||
#wireClose(): void {
|
||||
this.#ws.addEventListener("close", (event: unknown) => {
|
||||
this.#closed = true;
|
||||
const e = (event ?? {}) as CloseEventLike;
|
||||
const payload: SocketCloseEvent = {
|
||||
code: typeof e.code === "number" ? e.code : 1000,
|
||||
reason: typeof e.reason === "string" ? e.reason : "",
|
||||
wasClean: e.wasClean === true,
|
||||
};
|
||||
for (const fn of this.#closeListeners) fn(payload);
|
||||
});
|
||||
}
|
||||
|
||||
#wireError(): void {
|
||||
this.#ws.addEventListener("error", (event: unknown) => {
|
||||
for (const fn of this.#errorListeners) fn(event);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
# `url`
|
||||
|
||||
Tiny URL composition helper for the ElevenLabs transport. One function:
|
||||
`buildUrl(baseUrl, path, query?)`.
|
||||
|
||||
## Purpose
|
||||
|
||||
The platform exposes around fifty REST endpoints, all rooted at the same
|
||||
base URL, all reached by joining a slash-prefixed path and (sometimes) a
|
||||
query string. Doing that with template literals at every call site
|
||||
inevitably accumulates subtle bugs — double slashes, missing leading
|
||||
slashes, `undefined` values serialised as the literal string
|
||||
`"undefined"`. `buildUrl` normalises the construction so adapters never
|
||||
have to think about it.
|
||||
|
||||
## Features
|
||||
|
||||
- Strips a trailing slash from the base URL, preserves a leading slash on
|
||||
the path, and inserts one if missing.
|
||||
- Accepts `string | number | boolean | undefined` query values and
|
||||
silently drops `undefined` entries before serialisation.
|
||||
- Builds the query string with `URLSearchParams` so values are
|
||||
percent-encoded according to the WHATWG URL spec.
|
||||
- Pure and synchronous — no I/O, no allocations beyond the returned
|
||||
string.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { buildUrl } from "./url.js";
|
||||
|
||||
buildUrl("https://api.elevenlabs.io", "/v1/voices");
|
||||
// → "https://api.elevenlabs.io/v1/voices"
|
||||
|
||||
buildUrl("https://api.elevenlabs.io/", "v1/voices/abc");
|
||||
// → "https://api.elevenlabs.io/v1/voices/abc"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { buildUrl } from "./url.js";
|
||||
|
||||
const url = buildUrl(
|
||||
"https://api.elevenlabs.io",
|
||||
"/v1/text-to-speech/voice-123/stream",
|
||||
{
|
||||
output_format: "mp3_44100_128",
|
||||
optimize_streaming_latency: 2,
|
||||
enable_logging: false,
|
||||
next_text: undefined, // dropped
|
||||
}
|
||||
);
|
||||
// → "https://api.elevenlabs.io/v1/text-to-speech/voice-123/stream?output_format=mp3_44100_128&optimize_streaming_latency=2&enable_logging=false"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `buildUrl(baseUrl: string, path: string, query?: Readonly<Record<string, string | number | boolean | undefined>>): string`
|
||||
- `baseUrl` — typically `https://api.elevenlabs.io`; trailing slash
|
||||
is normalised away.
|
||||
- `path` — leading slash is preserved or added.
|
||||
- `query` — optional record. `undefined` values are dropped; non-string
|
||||
values are stringified via `String(value)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The function returns the input as-is if `query` is omitted — no
|
||||
`URLSearchParams` allocation when there are no parameters.
|
||||
- All parameters become string-typed entries on the wire because that's
|
||||
what `URLSearchParams.append` does, regardless of the input type.
|
||||
- `buildUrl` is also reused by `ElevenLabsClient.openSocket` to build
|
||||
WSS URLs after rewriting the scheme — the path / query semantics are
|
||||
the same.
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { buildUrl } from "./url.js";
|
||||
|
||||
describe("buildUrl", () => {
|
||||
it("joins base and path", () => {
|
||||
expect(buildUrl("https://api.example.com", "/v1/x")).toBe(
|
||||
"https://api.example.com/v1/x"
|
||||
);
|
||||
});
|
||||
|
||||
it("normalises trailing slash on base", () => {
|
||||
expect(buildUrl("https://api.example.com/", "/v1/x")).toBe(
|
||||
"https://api.example.com/v1/x"
|
||||
);
|
||||
});
|
||||
|
||||
it("adds leading slash to path when missing", () => {
|
||||
expect(buildUrl("https://api.example.com", "v1/x")).toBe(
|
||||
"https://api.example.com/v1/x"
|
||||
);
|
||||
});
|
||||
|
||||
it("appends query parameters", () => {
|
||||
expect(
|
||||
buildUrl("https://api.example.com", "/v1/x", { a: 1, b: "two" })
|
||||
).toBe("https://api.example.com/v1/x?a=1&b=two");
|
||||
});
|
||||
|
||||
it("drops undefined query values", () => {
|
||||
expect(
|
||||
buildUrl("https://api.example.com", "/v1/x", {
|
||||
a: 1,
|
||||
b: undefined,
|
||||
c: "z",
|
||||
})
|
||||
).toBe("https://api.example.com/v1/x?a=1&c=z");
|
||||
});
|
||||
|
||||
it("omits the question mark when every query value is undefined", () => {
|
||||
expect(
|
||||
buildUrl("https://api.example.com", "/v1/x", { a: undefined })
|
||||
).toBe("https://api.example.com/v1/x");
|
||||
});
|
||||
|
||||
it("works without a query argument", () => {
|
||||
expect(buildUrl("https://api.example.com", "/v1/x")).toBe(
|
||||
"https://api.example.com/v1/x"
|
||||
);
|
||||
});
|
||||
|
||||
it("handles boolean query values", () => {
|
||||
expect(
|
||||
buildUrl("https://api.example.com", "/v1/x", { flag: true })
|
||||
).toBe("https://api.example.com/v1/x?flag=true");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Build a fully-qualified URL from a base URL, path, and optional query.
|
||||
*
|
||||
* - Trailing slash on the base URL is normalised away.
|
||||
* - Leading slash on the path is preserved.
|
||||
* - `undefined` query values are dropped before serialisation.
|
||||
*/
|
||||
export function buildUrl(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
query?: Readonly<Record<string, string | number | boolean | undefined>>
|
||||
): string {
|
||||
const base = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
||||
const normalisedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
let url = `${base}${normalisedPath}`;
|
||||
if (query !== undefined) {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value === undefined) continue;
|
||||
params.append(key, String(value));
|
||||
}
|
||||
const qs = params.toString();
|
||||
if (qs.length > 0) url = `${url}?${qs}`;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { mapNetworkError } from "./client/internals.js";
|
||||
import {
|
||||
ElevenLabsClient,
|
||||
RateLimitExceededError,
|
||||
RequestValidationFailedError,
|
||||
ServerUnavailableError,
|
||||
} from "./index.js";
|
||||
|
||||
/**
|
||||
* Focused coverage tests for branches that, prior to the
|
||||
* `voice-elevenlabs` split, were exercised by the cross-package
|
||||
* coverage.test.ts. Each `it` block names the file + line range it
|
||||
* covers so future deletions can be reasoned about.
|
||||
*/
|
||||
|
||||
describe("coverage-gaps — client.ts retry-budget fallthrough", () => {
|
||||
it("retry loop never enters when maxRetries is -1 → ServerUnavailableError fallthrough (client.ts:303-309)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetchImpl: typeof fetch = () =>
|
||||
Promise.resolve(new Response("ok", { status: 200 }));
|
||||
const client = new ElevenLabsClient({
|
||||
apiKey: "k",
|
||||
fetchImpl,
|
||||
maxRetries: -1,
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
await client.fetchJson({
|
||||
method: "GET",
|
||||
path: "/v1/x",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
});
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(ServerUnavailableError);
|
||||
expect((caught as ServerUnavailableError).context?.["endpoint"]).toBe(
|
||||
"GET /v1/x"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("coverage-gaps — internals.mapNetworkError AbortError branch", () => {
|
||||
it("DOMException AbortError maps to NetworkAbortedError (internals.ts:78-80)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new DOMException("aborted", "AbortError");
|
||||
const mapped = mapNetworkError(err, "GET /x");
|
||||
expect(mapped.code).toBe("THIRD_PARTY_ELEVENLABS.NETWORK.ABORTED");
|
||||
expect(mapped.context?.["endpoint"]).toBe("GET /x");
|
||||
expect(mapped.cause).toBe(err);
|
||||
});
|
||||
});
|
||||
|
||||
describe("coverage-gaps — error context branches", () => {
|
||||
it("RequestValidationFailedError populates requestId when provided (errors.ts:129)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new RequestValidationFailedError({
|
||||
endpoint: "POST /v1/x",
|
||||
statusCode: 422,
|
||||
requestId: "req-422",
|
||||
body: { field: "bad" },
|
||||
});
|
||||
expect(err.context?.["requestId"]).toBe("req-422");
|
||||
expect(err.context?.["body"]).toEqual({ field: "bad" });
|
||||
expect(err.context?.["statusCode"]).toBe(422);
|
||||
});
|
||||
|
||||
it("RateLimitExceededError populates requestId + retryAfterMs together (errors.ts:160)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new RateLimitExceededError({
|
||||
endpoint: "GET /v1/x",
|
||||
statusCode: 429,
|
||||
requestId: "rid-429",
|
||||
retryAfterMs: 1000,
|
||||
});
|
||||
expect(err.context?.["requestId"]).toBe("rid-429");
|
||||
expect(err.context?.["retryAfterMs"]).toBe(1000);
|
||||
expect(err.context?.["statusCode"]).toBe(429);
|
||||
});
|
||||
});
|
||||
|
||||
describe("coverage-gaps — client option defaults", () => {
|
||||
it("falls back to globalThis.fetch when fetchImpl omitted (client.ts:69)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const original = globalThis.fetch;
|
||||
let called = false;
|
||||
globalThis.fetch = ((..._args: unknown[]) => {
|
||||
called = true;
|
||||
return Promise.resolve(
|
||||
new Response("{}", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
);
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const client = new ElevenLabsClient({ apiKey: "k" });
|
||||
return client
|
||||
.fetchJson({
|
||||
method: "GET",
|
||||
path: "/v1/x",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
})
|
||||
.then(() => {
|
||||
expect(called).toBe(true);
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("coverage-gaps — content-type fallbacks", () => {
|
||||
it("fetchStream missing content-type falls back to application/octet-stream (client.ts:145)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetchImpl: typeof fetch = () => {
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array([1, 2, 3]));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return Promise.resolve(
|
||||
new Response(stream, { status: 200, headers: {} })
|
||||
);
|
||||
};
|
||||
const client = new ElevenLabsClient({ apiKey: "k", fetchImpl });
|
||||
const r = await client.fetchStream({
|
||||
method: "GET",
|
||||
path: "/v1/x",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
});
|
||||
expect(r.contentType).toBe("application/octet-stream");
|
||||
r.body.cancel();
|
||||
});
|
||||
|
||||
it("fetchMultipartBinary missing content-type falls back to application/octet-stream (client.ts:177)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetchImpl: typeof fetch = () =>
|
||||
Promise.resolve(
|
||||
new Response(new Uint8Array([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: {},
|
||||
})
|
||||
);
|
||||
const client = new ElevenLabsClient({ apiKey: "k", fetchImpl });
|
||||
const r = await client.fetchMultipartBinary({
|
||||
method: "POST",
|
||||
path: "/v1/x",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
fields: [{ kind: "scalar", name: "a", value: "1" }],
|
||||
});
|
||||
expect(r.body).toBeInstanceOf(Uint8Array);
|
||||
});
|
||||
|
||||
it("fetchBinary missing content-type falls back to application/octet-stream (client.ts inline)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetchImpl: typeof fetch = () =>
|
||||
Promise.resolve(
|
||||
new Response(new Uint8Array([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: {},
|
||||
})
|
||||
);
|
||||
const client = new ElevenLabsClient({ apiKey: "k", fetchImpl });
|
||||
const r = await client.fetchBinary({
|
||||
method: "GET",
|
||||
path: "/v1/x",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
});
|
||||
expect(r.contentType).toBe("application/octet-stream");
|
||||
});
|
||||
});
|
||||
|
||||
describe("coverage-gaps — backoffDelay / readJsonBody branches", () => {
|
||||
it("backoffDelay uses exponential when retryAfterMs is undefined (internals.ts:105)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { backoffDelay } = await import("./client/internals.js");
|
||||
expect(backoffDelay(0, 100, undefined)).toBe(100);
|
||||
expect(backoffDelay(1, 100, undefined)).toBe(200);
|
||||
expect(backoffDelay(2, 100, undefined)).toBe(400);
|
||||
});
|
||||
|
||||
it("backoffDelay returns retryAfterMs unchanged when defined (internals.ts:105 true branch)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { backoffDelay } = await import("./client/internals.js");
|
||||
expect(backoffDelay(0, 100, 1234)).toBe(1234);
|
||||
expect(backoffDelay(99, 1, 5)).toBe(5);
|
||||
});
|
||||
|
||||
it("buildCtx omits requestId when undefined (internals.ts:33)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { buildCtx } = await import("./client/internals.js");
|
||||
const ctx = buildCtx("GET /x", 500, undefined);
|
||||
expect(ctx).toEqual({ endpoint: "GET /x", statusCode: 500 });
|
||||
expect(Object.hasOwn(ctx, "requestId")).toBe(false);
|
||||
});
|
||||
|
||||
it("buildCtx populates requestId when present (internals.ts:33 true branch)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { buildCtx } = await import("./client/internals.js");
|
||||
const ctx = buildCtx("GET /x", 500, "rid-1");
|
||||
expect(ctx).toEqual({
|
||||
endpoint: "GET /x",
|
||||
statusCode: 500,
|
||||
requestId: "rid-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("mapNetworkError passes ElevenLabsApiError through unchanged (internals.ts:77)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const original = new ServerUnavailableError({
|
||||
endpoint: "GET /x",
|
||||
message: "down",
|
||||
});
|
||||
const passed = mapNetworkError(original, "GET /y");
|
||||
expect(passed).toBe(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe("coverage-gaps — fetchStream materialisation paths", () => {
|
||||
it("materialiseMultipart skips reader chunks whose value is undefined (internals.ts:176)", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// Build a multipart file whose `data` is a ReadableStream that
|
||||
// yields one undefined chunk before its real bytes. The
|
||||
// materialiser must skip the undefined chunk and still produce
|
||||
// the request body.
|
||||
let observed: unknown;
|
||||
const fetchImpl: typeof fetch = (_url, init) => {
|
||||
observed = init?.body;
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
);
|
||||
};
|
||||
const client = new ElevenLabsClient({ apiKey: "k", fetchImpl });
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
// Force an undefined chunk via direct controller enqueue.
|
||||
controller.enqueue(undefined as unknown as Uint8Array);
|
||||
controller.enqueue(new Uint8Array([0x68, 0x69])); // "hi"
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
await client.fetchMultipartJson({
|
||||
method: "POST",
|
||||
path: "/v1/x",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
fields: [
|
||||
{
|
||||
kind: "file",
|
||||
name: "audio",
|
||||
filename: "x.bin",
|
||||
contentType: "application/octet-stream",
|
||||
value: stream,
|
||||
},
|
||||
],
|
||||
});
|
||||
// Body materialised: undefined skipped without throwing, request
|
||||
// reached fetchImpl. `init.body` is a FormData instance in this path.
|
||||
expect(observed).toBeDefined();
|
||||
});
|
||||
|
||||
it("abort during retry sleep maps to NetworkAbortedError (client.ts:280 false branch)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// The fetchOnce wrapper turns most network errors into
|
||||
// ElevenLabsApiError before they reach the outer catch. The one path
|
||||
// that reaches the outer catch with a *non*-ElevenLabsApiError is
|
||||
// `await sleep(delay, req.signal)` throwing a DOMException AbortError
|
||||
// when the caller aborts mid-sleep.
|
||||
const ctrl = new AbortController();
|
||||
let calls = 0;
|
||||
const fetchImpl: typeof fetch = () => {
|
||||
calls++;
|
||||
if (calls === 1) {
|
||||
// First attempt → 503 forces retry → enters sleep.
|
||||
queueMicrotask(() => queueMicrotask(() => ctrl.abort()));
|
||||
return Promise.resolve(new Response("", { status: 503 }));
|
||||
}
|
||||
return Promise.resolve(new Response("ok", { status: 200 }));
|
||||
};
|
||||
const client = new ElevenLabsClient({
|
||||
apiKey: "k",
|
||||
fetchImpl,
|
||||
maxRetries: 3,
|
||||
initialRetryDelayMs: 50,
|
||||
});
|
||||
await expect(
|
||||
client.fetchJson({
|
||||
method: "GET",
|
||||
path: "/v1/x",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
signal: ctrl.signal,
|
||||
} as unknown as Parameters<typeof client.fetchJson>[0])
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("retry catch path passes ElevenLabsApiError through unchanged (client.ts:280 true branch)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// 401 → UnauthorizedError; shouldRetry=false → throw lastError inside
|
||||
// try block → outer catch sees the ElevenLabsApiError and takes the
|
||||
// TRUE branch of the ternary.
|
||||
const fetchImpl: typeof fetch = () =>
|
||||
Promise.resolve(new Response("", { status: 401 }));
|
||||
const client = new ElevenLabsClient({
|
||||
apiKey: "k",
|
||||
fetchImpl,
|
||||
maxRetries: 0,
|
||||
});
|
||||
await expect(
|
||||
client.fetchJson({
|
||||
method: "GET",
|
||||
path: "/v1/x",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("retry catch path wraps non-API error via mapNetworkError (client.ts:280 false branch)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// First call throws a non-API TypeError → outer catch's ternary takes
|
||||
// the FALSE branch and invokes mapNetworkError(err, endpoint).
|
||||
const fetchImpl: typeof fetch = () =>
|
||||
Promise.reject(new TypeError("network blew up"));
|
||||
const client = new ElevenLabsClient({
|
||||
apiKey: "k",
|
||||
fetchImpl,
|
||||
maxRetries: 0,
|
||||
initialRetryDelayMs: 1,
|
||||
});
|
||||
await expect(
|
||||
client.fetchJson({
|
||||
method: "GET",
|
||||
path: "/v1/x",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("coverage-gaps — socket close-event payload defaults (socket.ts:175)", () => {
|
||||
it("error event fans out to onError listeners (socket.ts:187)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
type Listener = (e: unknown) => void;
|
||||
const listeners: Record<string, Listener[]> = {};
|
||||
class FakeWebSocketCtor {
|
||||
readyState = 0;
|
||||
url = "";
|
||||
send = () => {};
|
||||
close = () => {};
|
||||
addEventListener = (t: string, l: Listener) => {
|
||||
(listeners[t] ??= []).push(l);
|
||||
};
|
||||
removeEventListener = (t: string, l: Listener) => {
|
||||
listeners[t] = (listeners[t] ?? []).filter((x) => x !== l);
|
||||
};
|
||||
}
|
||||
const client = new ElevenLabsClient({
|
||||
apiKey: "k",
|
||||
fetchImpl: () =>
|
||||
Promise.resolve(new Response("ok", { status: 200 })),
|
||||
webSocketImpl: FakeWebSocketCtor as unknown as new (
|
||||
url: string | URL
|
||||
) => WebSocket,
|
||||
});
|
||||
const socket = client.openSocket({
|
||||
path: "/v1/ws",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
});
|
||||
const errs: unknown[] = [];
|
||||
socket.onError((e) => errs.push(e));
|
||||
(listeners["open"] ?? []).forEach((l) => l(new Event("open")));
|
||||
const ev = new Event("error");
|
||||
(listeners["error"] ?? []).forEach((l) => l(ev));
|
||||
expect(errs).toHaveLength(1);
|
||||
expect(errs[0]).toBe(ev);
|
||||
});
|
||||
|
||||
it("close-event with undefined payload defaults all fields (socket.ts:175 null-coalesce)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
type Listener = (e: unknown) => void;
|
||||
const listeners: Record<string, Listener[]> = {};
|
||||
class FakeWebSocketCtor {
|
||||
readyState = 0;
|
||||
url = "";
|
||||
send = () => {};
|
||||
close = () => {};
|
||||
addEventListener = (t: string, l: Listener) => {
|
||||
(listeners[t] ??= []).push(l);
|
||||
};
|
||||
removeEventListener = (t: string, l: Listener) => {
|
||||
listeners[t] = (listeners[t] ?? []).filter((x) => x !== l);
|
||||
};
|
||||
}
|
||||
const client = new ElevenLabsClient({
|
||||
apiKey: "k",
|
||||
fetchImpl: () =>
|
||||
Promise.resolve(new Response("ok", { status: 200 })),
|
||||
webSocketImpl: FakeWebSocketCtor as unknown as new (
|
||||
url: string | URL
|
||||
) => WebSocket,
|
||||
});
|
||||
const socket = client.openSocket({
|
||||
path: "/v1/ws",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
});
|
||||
const closes: Array<{
|
||||
code: number;
|
||||
reason: string;
|
||||
wasClean: boolean;
|
||||
}> = [];
|
||||
socket.onClose((e) => closes.push(e));
|
||||
(listeners["open"] ?? []).forEach((l) => l(new Event("open")));
|
||||
(listeners["close"] ?? []).forEach((l) => l(undefined));
|
||||
expect(closes).toHaveLength(1);
|
||||
expect(closes[0]).toEqual({
|
||||
code: 1000,
|
||||
reason: "",
|
||||
wasClean: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("close-event with wasClean=true exercises the true branch (socket.ts:175)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
type Listener = (e: unknown) => void;
|
||||
const listeners: Record<string, Listener[]> = {};
|
||||
class FakeWebSocketCtor {
|
||||
readyState = 0;
|
||||
url = "";
|
||||
send = () => {};
|
||||
close = () => {};
|
||||
addEventListener = (t: string, l: Listener) => {
|
||||
(listeners[t] ??= []).push(l);
|
||||
};
|
||||
removeEventListener = (t: string, l: Listener) => {
|
||||
listeners[t] = (listeners[t] ?? []).filter((x) => x !== l);
|
||||
};
|
||||
}
|
||||
const client = new ElevenLabsClient({
|
||||
apiKey: "k",
|
||||
fetchImpl: () =>
|
||||
Promise.resolve(new Response("ok", { status: 200 })),
|
||||
webSocketImpl: FakeWebSocketCtor as unknown as new (
|
||||
url: string | URL
|
||||
) => WebSocket,
|
||||
});
|
||||
const socket = client.openSocket({
|
||||
path: "/v1/ws",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
});
|
||||
const closes: Array<{
|
||||
code: number;
|
||||
reason: string;
|
||||
wasClean: boolean;
|
||||
}> = [];
|
||||
socket.onClose((e) => closes.push(e));
|
||||
(listeners["open"] ?? []).forEach((l) => l(new Event("open")));
|
||||
(listeners["close"] ?? []).forEach((l) =>
|
||||
l({ code: 1000, reason: "bye", wasClean: true })
|
||||
);
|
||||
expect(closes).toHaveLength(1);
|
||||
expect(closes[0]).toEqual({
|
||||
code: 1000,
|
||||
reason: "bye",
|
||||
wasClean: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("close-event with non-numeric code defaults to 1000", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
type Listener = (e: unknown) => void;
|
||||
const listeners: Record<string, Listener[]> = {};
|
||||
class FakeWebSocketCtor {
|
||||
readyState = 0;
|
||||
url = "";
|
||||
send = () => {};
|
||||
close = () => {};
|
||||
addEventListener = (t: string, l: Listener) => {
|
||||
(listeners[t] ??= []).push(l);
|
||||
};
|
||||
removeEventListener = (t: string, l: Listener) => {
|
||||
listeners[t] = (listeners[t] ?? []).filter((x) => x !== l);
|
||||
};
|
||||
}
|
||||
const client = new ElevenLabsClient({
|
||||
apiKey: "k",
|
||||
fetchImpl: () =>
|
||||
Promise.resolve(new Response("ok", { status: 200 })),
|
||||
webSocketImpl: FakeWebSocketCtor as unknown as new (
|
||||
url: string | URL
|
||||
) => WebSocket,
|
||||
});
|
||||
const socket = client.openSocket({
|
||||
path: "/v1/ws",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
});
|
||||
const closes: Array<{
|
||||
code: number;
|
||||
reason: string;
|
||||
wasClean: boolean;
|
||||
}> = [];
|
||||
socket.onClose((e) => closes.push(e));
|
||||
(listeners["open"] ?? []).forEach((l) => l(new Event("open")));
|
||||
// Dispatch close event with no code/reason → both defaults exercised.
|
||||
(listeners["close"] ?? []).forEach((l) => l({}));
|
||||
expect(closes).toHaveLength(1);
|
||||
expect(closes[0]).toEqual({
|
||||
code: 1000,
|
||||
reason: "",
|
||||
wasClean: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("coverage-gaps — socket.bytesFrom ArrayBufferView branch", () => {
|
||||
it("ArrayBufferView (not Uint8Array) is copied via slice path (socket.ts:29-34)", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// Build a fake WebSocket whose message handler receives a
|
||||
// DataView (an ArrayBufferView that is not Uint8Array).
|
||||
type Listener = (e: unknown) => void;
|
||||
const listeners: Record<string, Listener[]> = {};
|
||||
const fakeWs = {
|
||||
readyState: 0, // CONNECTING
|
||||
url: "",
|
||||
send: () => {},
|
||||
close: () => {},
|
||||
addEventListener: (t: string, l: Listener) => {
|
||||
(listeners[t] ??= []).push(l);
|
||||
},
|
||||
removeEventListener: (t: string, l: Listener) => {
|
||||
listeners[t] = (listeners[t] ?? []).filter((x) => x !== l);
|
||||
},
|
||||
};
|
||||
class FakeWebSocketCtor {
|
||||
readyState = fakeWs.readyState;
|
||||
url = fakeWs.url;
|
||||
send = fakeWs.send;
|
||||
close = fakeWs.close;
|
||||
addEventListener = fakeWs.addEventListener;
|
||||
removeEventListener = fakeWs.removeEventListener;
|
||||
}
|
||||
const client = new ElevenLabsClient({
|
||||
apiKey: "k",
|
||||
fetchImpl: () =>
|
||||
Promise.resolve(new Response("ok", { status: 200 })),
|
||||
webSocketImpl: FakeWebSocketCtor as unknown as new (
|
||||
url: string | URL
|
||||
) => WebSocket,
|
||||
});
|
||||
const socket = client.openSocket({
|
||||
path: "/v1/ws",
|
||||
feature: "f",
|
||||
operation: "o",
|
||||
});
|
||||
const received: Uint8Array[] = [];
|
||||
socket.onMessage((frame) => {
|
||||
if (frame.kind === "binary") received.push(frame.data);
|
||||
});
|
||||
// Trigger open then dispatch a DataView message.
|
||||
(listeners["open"] ?? []).forEach((l) => l(new Event("open")));
|
||||
const underlying = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
const view = new DataView(underlying.buffer, 1, 3); // [2,3,4]
|
||||
(listeners["message"] ?? []).forEach((l) =>
|
||||
l({ data: view } as unknown as MessageEvent)
|
||||
);
|
||||
// Close to release any listeners.
|
||||
socket.close();
|
||||
(listeners["close"] ?? []).forEach((l) =>
|
||||
l({
|
||||
code: 1000,
|
||||
reason: "ok",
|
||||
wasClean: true,
|
||||
} as unknown as CloseEvent)
|
||||
);
|
||||
expect(received).toHaveLength(1);
|
||||
expect(Array.from(received[0]!)).toEqual([2, 3, 4]);
|
||||
});
|
||||
});
|
||||
148
packages/media/audio/third-party-elevenlabs/src/errors/errors.md
Normal file
148
packages/media/audio/third-party-elevenlabs/src/errors/errors.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# `errors`
|
||||
|
||||
Stable error class hierarchy and code registry for
|
||||
`@suaveplan/third-party-elevenlabs`. Every non-2xx HTTP response and every
|
||||
transport-level failure maps to one of 17 typed `ElevenLabsApiError`
|
||||
subclasses under namespace `THIRD_PARTY_ELEVENLABS.*`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The ElevenLabs API surface is large and surfaces failures across HTTP status
|
||||
codes, malformed JSON bodies, transport timeouts, and WebSocket protocol
|
||||
errors. Without a stable error hierarchy, every consumer of this client
|
||||
would have to learn the upstream's quirks. This module defines the
|
||||
canonical 17-code surface so callers can branch on `error.code` or
|
||||
`error instanceof RateLimitExceededError` without touching the wire.
|
||||
|
||||
The mapping is total and disjoint over the `[400, 600)` HTTP range, every
|
||||
documented network failure mode, every JSON / Zod parse failure, and the
|
||||
two WebSocket failure modes (handshake / protocol). Every error preserves
|
||||
the original cause via the cause-chain on `SuaveplanError`.
|
||||
|
||||
## Features
|
||||
|
||||
- 17 error codes registered via `defineCodes('THIRD_PARTY_ELEVENLABS', ...)`.
|
||||
- 17 typed subclasses, one per code, with sensible default messages.
|
||||
- Convenience options surface common transport context:
|
||||
- `RateLimitExceededError({ retryAfterMs })` — retries planning.
|
||||
- `RequestValidationFailedError({ body })` — preserved 422 body.
|
||||
- `WebSocketConnectionFailedError({ code })` — close code.
|
||||
- `NetworkTimeoutError({ timeoutMs })` — observed limit.
|
||||
- Every HTTP error accepts `{ endpoint, statusCode, requestId }`.
|
||||
- Caller-supplied `context` is shallow-merged on top of derived context so
|
||||
adapter packages can layer their own attribution (e.g. `tenantId`).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
ElevenLabsApiError,
|
||||
RateLimitExceededError,
|
||||
} from "@suaveplan/third-party-elevenlabs/errors/errors";
|
||||
|
||||
try {
|
||||
await client.fetchJson(req, schema);
|
||||
} catch (err) {
|
||||
if (err instanceof RateLimitExceededError) {
|
||||
console.log(`retry after ${err.context?.retryAfterMs ?? "unknown"} ms`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Branch on every documented failure mode and re-throw with adapter context:
|
||||
|
||||
```ts
|
||||
import {
|
||||
ElevenLabsApiError,
|
||||
ELEVENLABS_CODES,
|
||||
} from "@suaveplan/third-party-elevenlabs/errors/errors";
|
||||
|
||||
function handleFailure(err: unknown): never {
|
||||
if (!(err instanceof ElevenLabsApiError)) throw err;
|
||||
switch (err.code) {
|
||||
case ELEVENLABS_CODES["AUTH.UNAUTHORIZED"]:
|
||||
case ELEVENLABS_CODES["AUTH.FORBIDDEN"]:
|
||||
throw new ProviderNotConfiguredError({ cause: err });
|
||||
case ELEVENLABS_CODES["RATE_LIMIT.EXCEEDED"]:
|
||||
case ELEVENLABS_CODES["RATE_LIMIT.QUOTA_EXHAUSTED"]:
|
||||
throw new ProviderRateLimitedError({
|
||||
retryAfterMs: err.context?.retryAfterMs as number | undefined,
|
||||
cause: err,
|
||||
});
|
||||
default:
|
||||
throw new TtsSynthesisFailedError({ cause: err });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ELEVENLABS_CODES` — frozen record `{ "<reason>":
|
||||
"THIRD_PARTY_ELEVENLABS.<reason>" }`.
|
||||
- `ElevenLabsErrorCode` — TypeScript union of every code value.
|
||||
- `ElevenLabsApiError` — abstract base extending `SuaveplanError`.
|
||||
|
||||
Auth + request errors:
|
||||
|
||||
- `UnauthorizedError({ endpoint?, statusCode?, requestId?, ...base })` —
|
||||
`AUTH.UNAUTHORIZED` (HTTP 401).
|
||||
- `ForbiddenError(...)` — `AUTH.FORBIDDEN` (HTTP 403).
|
||||
- `RequestInvalidError(...)` — `REQUEST.INVALID` (other 4xx).
|
||||
- `RequestValidationFailedError({ body?, ...request })` —
|
||||
`REQUEST.VALIDATION_FAILED` (HTTP 422); body preserved verbatim.
|
||||
- `RequestNotFoundError(...)` — `REQUEST.NOT_FOUND` (HTTP 404).
|
||||
|
||||
Rate limit:
|
||||
|
||||
- `RateLimitExceededError({ retryAfterMs?, ...request })` —
|
||||
`RATE_LIMIT.EXCEEDED` (HTTP 429).
|
||||
- `RateLimitQuotaExhaustedError(...)` — `RATE_LIMIT.QUOTA_EXHAUSTED`.
|
||||
|
||||
Server:
|
||||
|
||||
- `ServerTransientError(...)` — `SERVER.TRANSIENT` (retryable 5xx).
|
||||
- `ServerUnavailableError(...)` — `SERVER.UNAVAILABLE` (non-retryable / circuit-open).
|
||||
|
||||
Network:
|
||||
|
||||
- `NetworkTimeoutError({ endpoint?, timeoutMs?, ...base })` —
|
||||
`NETWORK.TIMEOUT`.
|
||||
- `NetworkAbortedError({ endpoint?, ...base })` — `NETWORK.ABORTED`.
|
||||
- `NetworkUnreachableError({ endpoint?, ...base })` — `NETWORK.UNREACHABLE`.
|
||||
|
||||
Response / stream:
|
||||
|
||||
- `ResponseMalformedError({ endpoint?, bodyPreview?, ...base })` —
|
||||
`RESPONSE.MALFORMED`.
|
||||
- `ResponseUnsupportedFormatError({ contentType?, ...base })` —
|
||||
`RESPONSE.UNSUPPORTED_FORMAT`.
|
||||
- `StreamInterruptedError(...)` — `STREAM.INTERRUPTED`.
|
||||
|
||||
WebSocket:
|
||||
|
||||
- `WebSocketConnectionFailedError({ code?, ...base })` —
|
||||
`WEBSOCKET.CONNECTION_FAILED`.
|
||||
- `WebSocketProtocolError({ code?, ...base })` — `WEBSOCKET.PROTOCOL`.
|
||||
|
||||
Every constructor accepts `{ message?, cause?, context?, traceId? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The 17 codes partition `[400, 600)` HTTP statuses plus three networking
|
||||
modes plus three response / stream modes plus two WebSocket modes. The
|
||||
client never throws a generic `ElevenLabsApiError` — every thrown error
|
||||
is one of the typed subclasses.
|
||||
- `RequestValidationFailedError.body` should be the **parsed** JSON body
|
||||
(truncated to a reasonable bound by the client — currently 4 KB; see
|
||||
`client.ts`). Never put raw network buffers here.
|
||||
- `bodyPreview` on `ResponseMalformedError` is a short string (≤ 256
|
||||
bytes) suitable for logs. Adapters should not re-serialise the full body
|
||||
into context.
|
||||
- All subclasses default to a stable English message. Localisation flows
|
||||
through `MessageResolver` on `@suaveplan/error`.
|
||||
@@ -0,0 +1,229 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
ELEVENLABS_CODES,
|
||||
ElevenLabsApiError,
|
||||
ForbiddenError,
|
||||
NetworkAbortedError,
|
||||
NetworkTimeoutError,
|
||||
NetworkUnreachableError,
|
||||
RateLimitExceededError,
|
||||
RateLimitQuotaExhaustedError,
|
||||
RequestInvalidError,
|
||||
RequestNotFoundError,
|
||||
RequestValidationFailedError,
|
||||
ResponseMalformedError,
|
||||
ResponseUnsupportedFormatError,
|
||||
ServerTransientError,
|
||||
ServerUnavailableError,
|
||||
StreamInterruptedError,
|
||||
UnauthorizedError,
|
||||
WebSocketConnectionFailedError,
|
||||
WebSocketProtocolError,
|
||||
} from "./errors.js";
|
||||
|
||||
describe("ELEVENLABS_CODES", () => {
|
||||
it("namespaces every reason under THIRD_PARTY_ELEVENLABS.*", () => {
|
||||
expect(ELEVENLABS_CODES["AUTH.UNAUTHORIZED"]).toBe(
|
||||
"THIRD_PARTY_ELEVENLABS.AUTH.UNAUTHORIZED"
|
||||
);
|
||||
expect(ELEVENLABS_CODES["WEBSOCKET.PROTOCOL"]).toBe(
|
||||
"THIRD_PARTY_ELEVENLABS.WEBSOCKET.PROTOCOL"
|
||||
);
|
||||
expect(Object.isFrozen(ELEVENLABS_CODES)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UnauthorizedError", () => {
|
||||
it("uses defaults", () => {
|
||||
const e = new UnauthorizedError();
|
||||
expect(e).toBeInstanceOf(ElevenLabsApiError);
|
||||
expect(e.code).toBe("THIRD_PARTY_ELEVENLABS.AUTH.UNAUTHORIZED");
|
||||
expect(e.context).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves endpoint, statusCode, requestId, cause, traceId", () => {
|
||||
const cause = new Error("upstream");
|
||||
const e = new UnauthorizedError({
|
||||
endpoint: "/v1/voices",
|
||||
statusCode: 401,
|
||||
requestId: "req-1",
|
||||
cause,
|
||||
traceId: "trace-x",
|
||||
});
|
||||
expect(e.context).toEqual({
|
||||
endpoint: "/v1/voices",
|
||||
statusCode: 401,
|
||||
requestId: "req-1",
|
||||
});
|
||||
expect(e.cause).toBe(cause);
|
||||
expect(e.traceId).toBe("trace-x");
|
||||
});
|
||||
|
||||
it("merges caller context on top of derived context", () => {
|
||||
const e = new UnauthorizedError({
|
||||
endpoint: "/v1/x",
|
||||
context: { tenantId: "t1" },
|
||||
});
|
||||
expect(e.context).toEqual({ endpoint: "/v1/x", tenantId: "t1" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("ForbiddenError, RequestInvalidError, RequestNotFoundError", () => {
|
||||
it("each carries its own code", () => {
|
||||
expect(new ForbiddenError().code).toBe(
|
||||
"THIRD_PARTY_ELEVENLABS.AUTH.FORBIDDEN"
|
||||
);
|
||||
expect(new RequestInvalidError().code).toBe(
|
||||
"THIRD_PARTY_ELEVENLABS.REQUEST.INVALID"
|
||||
);
|
||||
expect(new RequestNotFoundError().code).toBe(
|
||||
"THIRD_PARTY_ELEVENLABS.REQUEST.NOT_FOUND"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RequestValidationFailedError", () => {
|
||||
it("preserves body in context", () => {
|
||||
const body = { detail: "bad input" };
|
||||
const e = new RequestValidationFailedError({
|
||||
endpoint: "/v1/x",
|
||||
statusCode: 422,
|
||||
body,
|
||||
});
|
||||
expect(e.context).toEqual({ endpoint: "/v1/x", statusCode: 422, body });
|
||||
});
|
||||
|
||||
it("works without body", () => {
|
||||
const e = new RequestValidationFailedError();
|
||||
expect(e.context).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RateLimitExceededError", () => {
|
||||
it("preserves retryAfterMs in context", () => {
|
||||
const e = new RateLimitExceededError({ retryAfterMs: 5000 });
|
||||
expect(e.context).toEqual({ retryAfterMs: 5000 });
|
||||
});
|
||||
|
||||
it("merges retryAfterMs with endpoint", () => {
|
||||
const e = new RateLimitExceededError({
|
||||
endpoint: "/v1/tts",
|
||||
retryAfterMs: 12_000,
|
||||
});
|
||||
expect(e.context).toEqual({
|
||||
endpoint: "/v1/tts",
|
||||
retryAfterMs: 12_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("works without retryAfterMs", () => {
|
||||
const e = new RateLimitExceededError();
|
||||
expect(e.context).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RateLimitQuotaExhaustedError, ServerTransientError, ServerUnavailableError", () => {
|
||||
it("each carries its code", () => {
|
||||
expect(new RateLimitQuotaExhaustedError().code).toBe(
|
||||
"THIRD_PARTY_ELEVENLABS.RATE_LIMIT.QUOTA_EXHAUSTED"
|
||||
);
|
||||
expect(new ServerTransientError().code).toBe(
|
||||
"THIRD_PARTY_ELEVENLABS.SERVER.TRANSIENT"
|
||||
);
|
||||
expect(new ServerUnavailableError().code).toBe(
|
||||
"THIRD_PARTY_ELEVENLABS.SERVER.UNAVAILABLE"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NetworkTimeoutError", () => {
|
||||
it("preserves timeoutMs in context", () => {
|
||||
const e = new NetworkTimeoutError({ timeoutMs: 30_000 });
|
||||
expect(e.context).toEqual({ timeoutMs: 30_000 });
|
||||
});
|
||||
|
||||
it("merges timeoutMs with endpoint", () => {
|
||||
const e = new NetworkTimeoutError({
|
||||
endpoint: "/v1/tts",
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
expect(e.context).toEqual({ endpoint: "/v1/tts", timeoutMs: 30_000 });
|
||||
});
|
||||
|
||||
it("works without optional fields", () => {
|
||||
const e = new NetworkTimeoutError();
|
||||
expect(e.context).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("NetworkAbortedError, NetworkUnreachableError", () => {
|
||||
it("each carries its code", () => {
|
||||
expect(new NetworkAbortedError().code).toBe(
|
||||
"THIRD_PARTY_ELEVENLABS.NETWORK.ABORTED"
|
||||
);
|
||||
expect(new NetworkUnreachableError().code).toBe(
|
||||
"THIRD_PARTY_ELEVENLABS.NETWORK.UNREACHABLE"
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves endpoint when supplied", () => {
|
||||
const e = new NetworkAbortedError({ endpoint: "/v1/x" });
|
||||
expect(e.context).toEqual({ endpoint: "/v1/x" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResponseMalformedError", () => {
|
||||
it("preserves bodyPreview in context", () => {
|
||||
const e = new ResponseMalformedError({
|
||||
endpoint: "/v1/x",
|
||||
bodyPreview: "not json",
|
||||
});
|
||||
expect(e.context).toEqual({
|
||||
endpoint: "/v1/x",
|
||||
bodyPreview: "not json",
|
||||
});
|
||||
});
|
||||
|
||||
it("works without optional fields", () => {
|
||||
expect(new ResponseMalformedError().context).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResponseUnsupportedFormatError", () => {
|
||||
it("preserves contentType in context", () => {
|
||||
const e = new ResponseUnsupportedFormatError({
|
||||
contentType: "text/csv",
|
||||
});
|
||||
expect(e.context).toEqual({ contentType: "text/csv" });
|
||||
});
|
||||
|
||||
it("works without contentType", () => {
|
||||
expect(new ResponseUnsupportedFormatError().context).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("StreamInterruptedError", () => {
|
||||
it("carries its code", () => {
|
||||
expect(new StreamInterruptedError().code).toBe(
|
||||
"THIRD_PARTY_ELEVENLABS.STREAM.INTERRUPTED"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebSocketConnectionFailedError, WebSocketProtocolError", () => {
|
||||
it("preserve code in context", () => {
|
||||
expect(
|
||||
new WebSocketConnectionFailedError({ code: 4001 }).context
|
||||
).toEqual({
|
||||
code: 4001,
|
||||
});
|
||||
expect(new WebSocketProtocolError({ code: 1006 }).context).toEqual({
|
||||
code: 1006,
|
||||
});
|
||||
});
|
||||
|
||||
it("work without code", () => {
|
||||
expect(new WebSocketConnectionFailedError().context).toBeUndefined();
|
||||
expect(new WebSocketProtocolError().context).toBeUndefined();
|
||||
});
|
||||
});
|
||||
324
packages/media/audio/third-party-elevenlabs/src/errors/errors.ts
Normal file
324
packages/media/audio/third-party-elevenlabs/src/errors/errors.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error code registry for `@suaveplan/third-party-elevenlabs`. Every
|
||||
* non-2xx HTTP response and every transport-level failure maps to one of
|
||||
* these codes. The `[400, 600)` range is partitioned exhaustively so the
|
||||
* mapping is total and disjoint.
|
||||
*/
|
||||
export const ELEVENLABS_CODES = defineCodes("THIRD_PARTY_ELEVENLABS", [
|
||||
"AUTH.UNAUTHORIZED",
|
||||
"AUTH.FORBIDDEN",
|
||||
"REQUEST.INVALID",
|
||||
"REQUEST.VALIDATION_FAILED",
|
||||
"REQUEST.NOT_FOUND",
|
||||
"RATE_LIMIT.EXCEEDED",
|
||||
"RATE_LIMIT.QUOTA_EXHAUSTED",
|
||||
"SERVER.TRANSIENT",
|
||||
"SERVER.UNAVAILABLE",
|
||||
"NETWORK.TIMEOUT",
|
||||
"NETWORK.ABORTED",
|
||||
"NETWORK.UNREACHABLE",
|
||||
"RESPONSE.MALFORMED",
|
||||
"RESPONSE.UNSUPPORTED_FORMAT",
|
||||
"STREAM.INTERRUPTED",
|
||||
"WEBSOCKET.CONNECTION_FAILED",
|
||||
"WEBSOCKET.PROTOCOL",
|
||||
] as const);
|
||||
|
||||
/** Union of every code under namespace `THIRD_PARTY_ELEVENLABS.*`. */
|
||||
export type ElevenLabsErrorCode =
|
||||
(typeof ELEVENLABS_CODES)[keyof typeof ELEVENLABS_CODES];
|
||||
|
||||
interface BaseOpts {
|
||||
readonly cause?: unknown;
|
||||
readonly message?: string;
|
||||
readonly context?: Readonly<Record<string, unknown>>;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Base class for every typed ElevenLabs API error. */
|
||||
export abstract class ElevenLabsApiError extends SuaveplanError<ElevenLabsErrorCode> {}
|
||||
|
||||
function buildOpts(
|
||||
opts: BaseOpts,
|
||||
derived?: Readonly<Record<string, unknown>>
|
||||
): {
|
||||
cause?: unknown;
|
||||
context?: Readonly<Record<string, unknown>>;
|
||||
traceId?: string;
|
||||
} {
|
||||
const out: {
|
||||
cause?: unknown;
|
||||
context?: Readonly<Record<string, unknown>>;
|
||||
traceId?: string;
|
||||
} = {};
|
||||
if (opts.cause !== undefined) out.cause = opts.cause;
|
||||
if (opts.traceId !== undefined) out.traceId = opts.traceId;
|
||||
if (derived !== undefined && opts.context !== undefined) {
|
||||
out.context = { ...derived, ...opts.context };
|
||||
} else if (derived !== undefined) {
|
||||
out.context = derived;
|
||||
} else if (opts.context !== undefined) {
|
||||
out.context = opts.context;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface RequestContextOpts extends BaseOpts {
|
||||
readonly endpoint?: string;
|
||||
readonly statusCode?: number;
|
||||
readonly requestId?: string;
|
||||
}
|
||||
|
||||
function withRequestContext(opts: RequestContextOpts): {
|
||||
cause?: unknown;
|
||||
context?: Readonly<Record<string, unknown>>;
|
||||
traceId?: string;
|
||||
} {
|
||||
const ctx: Record<string, unknown> = {};
|
||||
if (opts.endpoint !== undefined) ctx["endpoint"] = opts.endpoint;
|
||||
if (opts.statusCode !== undefined) ctx["statusCode"] = opts.statusCode;
|
||||
if (opts.requestId !== undefined) ctx["requestId"] = opts.requestId;
|
||||
return buildOpts(opts, Object.keys(ctx).length > 0 ? ctx : undefined);
|
||||
}
|
||||
|
||||
/** HTTP 401 — invalid or missing API key. */
|
||||
export class UnauthorizedError extends ElevenLabsApiError {
|
||||
constructor(opts: RequestContextOpts = {}) {
|
||||
super(
|
||||
ELEVENLABS_CODES["AUTH.UNAUTHORIZED"],
|
||||
opts.message ?? "ElevenLabs authentication failed",
|
||||
withRequestContext(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** HTTP 403 — key valid but lacks the required scope or quota. */
|
||||
export class ForbiddenError extends ElevenLabsApiError {
|
||||
constructor(opts: RequestContextOpts = {}) {
|
||||
super(
|
||||
ELEVENLABS_CODES["AUTH.FORBIDDEN"],
|
||||
opts.message ?? "ElevenLabs API key forbidden from this operation",
|
||||
withRequestContext(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Generic 4xx — caller's request was malformed in a non-validation way. */
|
||||
export class RequestInvalidError extends ElevenLabsApiError {
|
||||
constructor(opts: RequestContextOpts = {}) {
|
||||
super(
|
||||
ELEVENLABS_CODES["REQUEST.INVALID"],
|
||||
opts.message ?? "Invalid ElevenLabs request",
|
||||
withRequestContext(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface ValidationFailedOpts extends RequestContextOpts {
|
||||
readonly body?: unknown;
|
||||
}
|
||||
|
||||
/** HTTP 422 — server-side schema validation failed. Body is preserved. */
|
||||
export class RequestValidationFailedError extends ElevenLabsApiError {
|
||||
constructor(opts: ValidationFailedOpts = {}) {
|
||||
const ctx: Record<string, unknown> = {};
|
||||
if (opts.endpoint !== undefined) ctx["endpoint"] = opts.endpoint;
|
||||
if (opts.statusCode !== undefined) ctx["statusCode"] = opts.statusCode;
|
||||
if (opts.requestId !== undefined) ctx["requestId"] = opts.requestId;
|
||||
if (opts.body !== undefined) ctx["body"] = opts.body;
|
||||
super(
|
||||
ELEVENLABS_CODES["REQUEST.VALIDATION_FAILED"],
|
||||
opts.message ?? "ElevenLabs validation failed",
|
||||
buildOpts(opts, Object.keys(ctx).length > 0 ? ctx : undefined)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** HTTP 404 — requested resource does not exist. */
|
||||
export class RequestNotFoundError extends ElevenLabsApiError {
|
||||
constructor(opts: RequestContextOpts = {}) {
|
||||
super(
|
||||
ELEVENLABS_CODES["REQUEST.NOT_FOUND"],
|
||||
opts.message ?? "ElevenLabs resource not found",
|
||||
withRequestContext(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface RateLimitOpts extends RequestContextOpts {
|
||||
readonly retryAfterMs?: number;
|
||||
}
|
||||
|
||||
/** HTTP 429 — throttled. `retryAfterMs` populated from `Retry-After`. */
|
||||
export class RateLimitExceededError extends ElevenLabsApiError {
|
||||
constructor(opts: RateLimitOpts = {}) {
|
||||
const ctx: Record<string, unknown> = {};
|
||||
if (opts.endpoint !== undefined) ctx["endpoint"] = opts.endpoint;
|
||||
if (opts.statusCode !== undefined) ctx["statusCode"] = opts.statusCode;
|
||||
if (opts.requestId !== undefined) ctx["requestId"] = opts.requestId;
|
||||
if (opts.retryAfterMs !== undefined)
|
||||
ctx["retryAfterMs"] = opts.retryAfterMs;
|
||||
super(
|
||||
ELEVENLABS_CODES["RATE_LIMIT.EXCEEDED"],
|
||||
opts.message ?? "ElevenLabs rate limit exceeded",
|
||||
buildOpts(opts, Object.keys(ctx).length > 0 ? ctx : undefined)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Quota header indicates the account has exhausted its allotment. */
|
||||
export class RateLimitQuotaExhaustedError extends ElevenLabsApiError {
|
||||
constructor(opts: RequestContextOpts = {}) {
|
||||
super(
|
||||
ELEVENLABS_CODES["RATE_LIMIT.QUOTA_EXHAUSTED"],
|
||||
opts.message ?? "ElevenLabs character / minute quota exhausted",
|
||||
withRequestContext(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Retryable 5xx (502, 503, 504) — the client retries by default. */
|
||||
export class ServerTransientError extends ElevenLabsApiError {
|
||||
constructor(opts: RequestContextOpts = {}) {
|
||||
super(
|
||||
ELEVENLABS_CODES["SERVER.TRANSIENT"],
|
||||
opts.message ?? "ElevenLabs server error (transient)",
|
||||
withRequestContext(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Non-retryable 5xx or circuit-open. */
|
||||
export class ServerUnavailableError extends ElevenLabsApiError {
|
||||
constructor(opts: RequestContextOpts = {}) {
|
||||
super(
|
||||
ELEVENLABS_CODES["SERVER.UNAVAILABLE"],
|
||||
opts.message ?? "ElevenLabs server unavailable",
|
||||
withRequestContext(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface NetworkOpts extends BaseOpts {
|
||||
readonly endpoint?: string;
|
||||
}
|
||||
|
||||
function withEndpoint(opts: NetworkOpts): {
|
||||
cause?: unknown;
|
||||
context?: Readonly<Record<string, unknown>>;
|
||||
traceId?: string;
|
||||
} {
|
||||
return buildOpts(
|
||||
opts,
|
||||
opts.endpoint !== undefined ? { endpoint: opts.endpoint } : undefined
|
||||
);
|
||||
}
|
||||
|
||||
/** Request exceeded the configured timeout. */
|
||||
export class NetworkTimeoutError extends ElevenLabsApiError {
|
||||
constructor(opts: NetworkOpts & { readonly timeoutMs?: number } = {}) {
|
||||
const ctx: Record<string, unknown> = {};
|
||||
if (opts.endpoint !== undefined) ctx["endpoint"] = opts.endpoint;
|
||||
if (opts.timeoutMs !== undefined) ctx["timeoutMs"] = opts.timeoutMs;
|
||||
super(
|
||||
ELEVENLABS_CODES["NETWORK.TIMEOUT"],
|
||||
opts.message ?? "ElevenLabs request timed out",
|
||||
buildOpts(opts, Object.keys(ctx).length > 0 ? ctx : undefined)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Caller-supplied AbortSignal aborted in-flight request. */
|
||||
export class NetworkAbortedError extends ElevenLabsApiError {
|
||||
constructor(opts: NetworkOpts = {}) {
|
||||
super(
|
||||
ELEVENLABS_CODES["NETWORK.ABORTED"],
|
||||
opts.message ?? "ElevenLabs request aborted",
|
||||
withEndpoint(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** DNS / connect failure before bytes were exchanged. */
|
||||
export class NetworkUnreachableError extends ElevenLabsApiError {
|
||||
constructor(opts: NetworkOpts = {}) {
|
||||
super(
|
||||
ELEVENLABS_CODES["NETWORK.UNREACHABLE"],
|
||||
opts.message ?? "ElevenLabs server unreachable",
|
||||
withEndpoint(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface MalformedOpts extends BaseOpts {
|
||||
readonly endpoint?: string;
|
||||
readonly bodyPreview?: string;
|
||||
}
|
||||
|
||||
/** Response body could not be parsed or did not match the declared schema. */
|
||||
export class ResponseMalformedError extends ElevenLabsApiError {
|
||||
constructor(opts: MalformedOpts = {}) {
|
||||
const ctx: Record<string, unknown> = {};
|
||||
if (opts.endpoint !== undefined) ctx["endpoint"] = opts.endpoint;
|
||||
if (opts.bodyPreview !== undefined)
|
||||
ctx["bodyPreview"] = opts.bodyPreview;
|
||||
super(
|
||||
ELEVENLABS_CODES["RESPONSE.MALFORMED"],
|
||||
opts.message ?? "ElevenLabs response was malformed",
|
||||
buildOpts(opts, Object.keys(ctx).length > 0 ? ctx : undefined)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Server returned a content-type the client cannot handle. */
|
||||
export class ResponseUnsupportedFormatError extends ElevenLabsApiError {
|
||||
constructor(opts: BaseOpts & { readonly contentType?: string } = {}) {
|
||||
const ctx =
|
||||
opts.contentType !== undefined
|
||||
? { contentType: opts.contentType }
|
||||
: undefined;
|
||||
super(
|
||||
ELEVENLABS_CODES["RESPONSE.UNSUPPORTED_FORMAT"],
|
||||
opts.message ??
|
||||
"ElevenLabs returned an unsupported response format",
|
||||
buildOpts(opts, ctx)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Streaming response was closed by the server before completion. */
|
||||
export class StreamInterruptedError extends ElevenLabsApiError {
|
||||
constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
ELEVENLABS_CODES["STREAM.INTERRUPTED"],
|
||||
opts.message ?? "ElevenLabs stream was interrupted",
|
||||
buildOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** WebSocket handshake failed. */
|
||||
export class WebSocketConnectionFailedError extends ElevenLabsApiError {
|
||||
constructor(opts: BaseOpts & { readonly code?: number } = {}) {
|
||||
const ctx = opts.code !== undefined ? { code: opts.code } : undefined;
|
||||
super(
|
||||
ELEVENLABS_CODES["WEBSOCKET.CONNECTION_FAILED"],
|
||||
opts.message ?? "ElevenLabs WebSocket connection failed",
|
||||
buildOpts(opts, ctx)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Server sent an unexpected frame or close code. */
|
||||
export class WebSocketProtocolError extends ElevenLabsApiError {
|
||||
constructor(opts: BaseOpts & { readonly code?: number } = {}) {
|
||||
const ctx = opts.code !== undefined ? { code: opts.code } : undefined;
|
||||
super(
|
||||
ELEVENLABS_CODES["WEBSOCKET.PROTOCOL"],
|
||||
opts.message ?? "ElevenLabs WebSocket protocol error",
|
||||
buildOpts(opts, ctx)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export type { ElevenLabsErrorCode } from "./errors.js";
|
||||
export {
|
||||
ELEVENLABS_CODES,
|
||||
ElevenLabsApiError,
|
||||
ForbiddenError,
|
||||
NetworkAbortedError,
|
||||
NetworkTimeoutError,
|
||||
NetworkUnreachableError,
|
||||
RateLimitExceededError,
|
||||
RateLimitQuotaExhaustedError,
|
||||
RequestInvalidError,
|
||||
RequestNotFoundError,
|
||||
RequestValidationFailedError,
|
||||
ResponseMalformedError,
|
||||
ResponseUnsupportedFormatError,
|
||||
ServerTransientError,
|
||||
ServerUnavailableError,
|
||||
StreamInterruptedError,
|
||||
UnauthorizedError,
|
||||
WebSocketConnectionFailedError,
|
||||
WebSocketProtocolError,
|
||||
} from "./errors.js";
|
||||
51
packages/media/audio/third-party-elevenlabs/src/index.ts
Normal file
51
packages/media/audio/third-party-elevenlabs/src/index.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export {
|
||||
buildHeaders,
|
||||
buildUrl,
|
||||
ElevenLabsClient,
|
||||
extractMetadata,
|
||||
parseRetryAfter,
|
||||
} from "./client/index.js";
|
||||
export {
|
||||
ELEVENLABS_CODES,
|
||||
ElevenLabsApiError,
|
||||
type ElevenLabsErrorCode,
|
||||
ForbiddenError,
|
||||
NetworkAbortedError,
|
||||
NetworkTimeoutError,
|
||||
NetworkUnreachableError,
|
||||
RateLimitExceededError,
|
||||
RateLimitQuotaExhaustedError,
|
||||
RequestInvalidError,
|
||||
RequestNotFoundError,
|
||||
RequestValidationFailedError,
|
||||
ResponseMalformedError,
|
||||
ResponseUnsupportedFormatError,
|
||||
ServerTransientError,
|
||||
ServerUnavailableError,
|
||||
StreamInterruptedError,
|
||||
UnauthorizedError,
|
||||
WebSocketConnectionFailedError,
|
||||
WebSocketProtocolError,
|
||||
} from "./errors/index.js";
|
||||
export type {
|
||||
BinaryRequest,
|
||||
BinaryResponse,
|
||||
ElevenLabsClientEvents,
|
||||
ElevenLabsClientOptions,
|
||||
JsonRequest,
|
||||
JsonResponse,
|
||||
MultipartField,
|
||||
MultipartFieldFile,
|
||||
MultipartFieldScalar,
|
||||
MultipartFile,
|
||||
MultipartRequest,
|
||||
RequestEnvelope,
|
||||
RequestMetadata,
|
||||
SocketCloseEvent,
|
||||
SocketFrame,
|
||||
SocketRequest,
|
||||
StreamRequest,
|
||||
StreamResponse,
|
||||
WebSocketConstructor,
|
||||
WebSocketLike,
|
||||
} from "./types/index.js";
|
||||
@@ -0,0 +1,22 @@
|
||||
export type {
|
||||
BinaryRequest,
|
||||
BinaryResponse,
|
||||
ElevenLabsClientEvents,
|
||||
ElevenLabsClientOptions,
|
||||
JsonRequest,
|
||||
JsonResponse,
|
||||
MultipartField,
|
||||
MultipartFieldFile,
|
||||
MultipartFieldScalar,
|
||||
MultipartFile,
|
||||
MultipartRequest,
|
||||
RequestEnvelope,
|
||||
RequestMetadata,
|
||||
SocketCloseEvent,
|
||||
SocketFrame,
|
||||
SocketRequest,
|
||||
StreamRequest,
|
||||
StreamResponse,
|
||||
WebSocketConstructor,
|
||||
WebSocketLike,
|
||||
} from "./types.js";
|
||||
153
packages/media/audio/third-party-elevenlabs/src/types/types.md
Normal file
153
packages/media/audio/third-party-elevenlabs/src/types/types.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# `types`
|
||||
|
||||
Shared types for `@suaveplan/third-party-elevenlabs`. Defines the request /
|
||||
response envelope shapes consumed by the transport layer (`ElevenLabsClient`)
|
||||
and the feature modules built on top of it.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The transport layer needs a small, stable surface that every feature module
|
||||
agrees on: how to describe a JSON request, how to describe a multipart
|
||||
upload with mixed scalar / file fields, what the response metadata envelope
|
||||
looks like, what events the client emits over an optional event bus. This
|
||||
module locks those types in one place so any new feature module slots into
|
||||
the existing transport without a new envelope shape.
|
||||
|
||||
## Features
|
||||
|
||||
- `RequestEnvelope` shared by every transport call (`method`, `path`,
|
||||
`query`, `headers`, `signal`, `feature`, `operation`).
|
||||
- `JsonRequest`, `BinaryRequest`, `StreamRequest`, `MultipartRequest`,
|
||||
`SocketRequest` — one per transport mode, all derived from the shared
|
||||
envelope.
|
||||
- `MultipartFile` union of `Uint8Array | Blob | ReadableStream | string`
|
||||
(URL).
|
||||
- `MultipartField` discriminated union over `'scalar'` and `'file'` parts.
|
||||
- `JsonResponse<TBody>`, `BinaryResponse`, `StreamResponse` — each carrying
|
||||
the body and a `RequestMetadata` envelope (`requestId`,
|
||||
`rateLimitRemaining`, `characterCost`).
|
||||
- `ElevenLabsClientEvents` — typed event-bus map covering
|
||||
`request-start`, `request-end`, `retry-attempt`, `rate-limit-hit`.
|
||||
- `ElevenLabsClientOptions` — full constructor options including auth, base
|
||||
URL, timeout, retry config, `fetchImpl`, `webSocketImpl`, `clock`,
|
||||
`logger`, `events`, default headers.
|
||||
- `WebSocketConstructor` / `WebSocketLike` — minimal duck types so
|
||||
consumers can inject a fake WebSocket in tests.
|
||||
- `SocketFrame` — `'text' | 'binary'` discriminated union.
|
||||
- `SocketCloseEvent` — close-event surface.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { JsonRequest } from "@suaveplan/third-party-elevenlabs/types/types";
|
||||
|
||||
const req: JsonRequest = {
|
||||
method: "GET",
|
||||
path: "/v1/voices",
|
||||
feature: "voices",
|
||||
operation: "list",
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Build a multipart upload from a URL, raw bytes, and scalar fields:
|
||||
|
||||
```ts
|
||||
import type {
|
||||
MultipartField,
|
||||
MultipartRequest,
|
||||
} from "@suaveplan/third-party-elevenlabs/types/types";
|
||||
|
||||
const fields: MultipartField[] = [
|
||||
{ kind: "scalar", name: "model_id", value: "scribe_v2" },
|
||||
{
|
||||
kind: "file",
|
||||
name: "file",
|
||||
filename: "audio.wav",
|
||||
contentType: "audio/wav",
|
||||
value: new Uint8Array(audioBytes),
|
||||
},
|
||||
{
|
||||
kind: "file",
|
||||
name: "additional",
|
||||
filename: "extra.wav",
|
||||
contentType: "audio/wav",
|
||||
value: "https://cdn.example.com/extra.wav",
|
||||
},
|
||||
];
|
||||
|
||||
const req: MultipartRequest = {
|
||||
method: "POST",
|
||||
path: "/v1/speech-to-text",
|
||||
fields,
|
||||
feature: "stt",
|
||||
operation: "transcribe",
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
Request envelopes:
|
||||
|
||||
- `RequestEnvelope` — `{ method, path, query?, headers?, signal?, feature,
|
||||
operation }`.
|
||||
- `JsonRequest extends RequestEnvelope` — `body?: unknown`.
|
||||
- `BinaryRequest extends RequestEnvelope` — `body?: unknown; accept?: string`.
|
||||
- `StreamRequest extends RequestEnvelope` — `body?: unknown; accept?: string`.
|
||||
- `MultipartRequest extends RequestEnvelope` —
|
||||
`fields: readonly MultipartField[]; accept?: string`.
|
||||
- `SocketRequest` — `{ path, query?, headers?, feature, operation }`.
|
||||
|
||||
Multipart:
|
||||
|
||||
- `MultipartFile = Uint8Array | Blob | ReadableStream<Uint8Array> | string`.
|
||||
- `MultipartFieldFile { kind: 'file'; name, filename, contentType?, value }`.
|
||||
- `MultipartFieldScalar { kind: 'scalar'; name, value }`.
|
||||
- `MultipartField = MultipartFieldFile | MultipartFieldScalar`.
|
||||
|
||||
Response envelopes:
|
||||
|
||||
- `RequestMetadata` — `{ requestId?, rateLimitRemaining?, characterCost? }`.
|
||||
- `JsonResponse<TBody>` — `{ body: TBody, metadata: RequestMetadata }`.
|
||||
- `BinaryResponse` — `{ body: Uint8Array, contentType, metadata }`.
|
||||
- `StreamResponse` —
|
||||
`{ body: ReadableStream<Uint8Array>, contentType, metadata }`.
|
||||
|
||||
Events:
|
||||
|
||||
- `ElevenLabsClientEvents` — typed event-bus map. See source for the four
|
||||
event payload shapes.
|
||||
|
||||
Options:
|
||||
|
||||
- `ElevenLabsClientOptions` — see source for the full set of constructor
|
||||
options.
|
||||
|
||||
WebSocket:
|
||||
|
||||
- `WebSocketConstructor` — `new (url, protocols?) => WebSocketLike`.
|
||||
- `WebSocketLike` — minimal duck type covering `readyState`, `url`, `send`,
|
||||
`close`, `addEventListener`, `removeEventListener`.
|
||||
- `SocketFrame` — `'text' | 'binary'` discriminated union.
|
||||
- `SocketCloseEvent` — `{ code, reason, wasClean }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `feature` and `operation` are intentionally part of every request
|
||||
envelope so the transport layer can label OTel spans and counters
|
||||
consistently without per-call wiring.
|
||||
- `MultipartFile` accepts URLs as strings; the client fetches them via
|
||||
the injected `fetchImpl` before uploading. This is the only place in
|
||||
the transport that issues a *secondary* network call from a single
|
||||
caller invocation.
|
||||
- `RequestMetadata` keeps every field optional because some endpoints
|
||||
do not surface `X-RateLimit-Remaining` or `X-Character-Cost`.
|
||||
- `WebSocketConstructor` mirrors the WHATWG `WebSocket` constructor
|
||||
signature so the global `WebSocket` is a valid value at runtime.
|
||||
- The two-mode `SocketFrame` discriminated union keeps the wire shape
|
||||
explicit; feature modules layer their own typed schemas on top.
|
||||
229
packages/media/audio/third-party-elevenlabs/src/types/types.ts
Normal file
229
packages/media/audio/third-party-elevenlabs/src/types/types.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import type { Clock, EventBusLike, LoggerLike } from "@suaveplan/types";
|
||||
|
||||
/**
|
||||
* A multipart file payload accepted by `client.fetchMultipart`. The four
|
||||
* shapes are interpreted as follows:
|
||||
*
|
||||
* - `Uint8Array` — raw bytes already in memory; attached without copy.
|
||||
* - `Blob` — passed through to `FormData` directly.
|
||||
* - `ReadableStream<Uint8Array>` — fully drained via
|
||||
* `@suaveplan/streams.toBytes` before upload (ElevenLabs multipart
|
||||
* endpoints require a complete body).
|
||||
* - `string` — interpreted as a URL; the client `fetch`es it first then
|
||||
* uploads the resulting bytes.
|
||||
*/
|
||||
export type MultipartFile =
|
||||
| Uint8Array
|
||||
| Blob
|
||||
| ReadableStream<Uint8Array>
|
||||
| string;
|
||||
|
||||
/** A multipart field — either a scalar form value or an uploaded file. */
|
||||
export interface MultipartFieldFile {
|
||||
readonly kind: "file";
|
||||
readonly name: string;
|
||||
readonly filename: string;
|
||||
readonly contentType?: string;
|
||||
readonly value: MultipartFile;
|
||||
}
|
||||
|
||||
export interface MultipartFieldScalar {
|
||||
readonly kind: "scalar";
|
||||
readonly name: string;
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
export type MultipartField = MultipartFieldFile | MultipartFieldScalar;
|
||||
|
||||
/** Metadata returned alongside every successful response. */
|
||||
export interface RequestMetadata {
|
||||
readonly requestId?: string;
|
||||
readonly rateLimitRemaining?: number;
|
||||
readonly characterCost?: number;
|
||||
}
|
||||
|
||||
/** A response carrying a parsed JSON body and its metadata envelope. */
|
||||
export interface JsonResponse<TBody> {
|
||||
readonly body: TBody;
|
||||
readonly metadata: RequestMetadata;
|
||||
}
|
||||
|
||||
/** A response carrying raw binary bytes and its metadata envelope. */
|
||||
export interface BinaryResponse {
|
||||
readonly body: Uint8Array;
|
||||
readonly contentType: string;
|
||||
readonly metadata: RequestMetadata;
|
||||
}
|
||||
|
||||
/** A response carrying a streaming body and its metadata envelope. */
|
||||
export interface StreamResponse {
|
||||
readonly body: ReadableStream<Uint8Array>;
|
||||
readonly contentType: string;
|
||||
readonly metadata: RequestMetadata;
|
||||
}
|
||||
|
||||
/** Common request-envelope fields shared by every transport call. */
|
||||
export interface RequestEnvelope {
|
||||
readonly method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
readonly path: string;
|
||||
readonly query?: Readonly<
|
||||
Record<string, string | number | boolean | undefined>
|
||||
>;
|
||||
readonly headers?: Readonly<Record<string, string>>;
|
||||
readonly signal?: AbortSignal;
|
||||
/** Logical operation name, used in OTel span name and counter labels. */
|
||||
readonly operation: string;
|
||||
/** Logical feature name, used in OTel span name and counter labels. */
|
||||
readonly feature: string;
|
||||
}
|
||||
|
||||
/** Envelope for a JSON-body request. `body` is JSON-serialised. */
|
||||
export interface JsonRequest extends RequestEnvelope {
|
||||
readonly body?: unknown;
|
||||
}
|
||||
|
||||
/** Envelope for a binary-bytes-out request. */
|
||||
export interface BinaryRequest extends RequestEnvelope {
|
||||
readonly body?: unknown;
|
||||
readonly accept?: string;
|
||||
}
|
||||
|
||||
/** Envelope for a streaming-bytes-out request. */
|
||||
export interface StreamRequest extends RequestEnvelope {
|
||||
readonly body?: unknown;
|
||||
readonly accept?: string;
|
||||
}
|
||||
|
||||
/** Envelope for a multipart-form-data request. */
|
||||
export interface MultipartRequest extends RequestEnvelope {
|
||||
readonly fields: readonly MultipartField[];
|
||||
readonly accept?: string;
|
||||
}
|
||||
|
||||
/** Envelope for opening a WebSocket. */
|
||||
export interface SocketRequest {
|
||||
readonly path: string;
|
||||
readonly query?: Readonly<
|
||||
Record<string, string | number | boolean | undefined>
|
||||
>;
|
||||
readonly headers?: Readonly<Record<string, string>>;
|
||||
readonly feature: string;
|
||||
readonly operation: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Events emitted by `ElevenLabsClient` over the optional event bus.
|
||||
*
|
||||
* - `request-start` fires before the first attempt of a logical call.
|
||||
* - `request-end` fires once on success / final failure.
|
||||
* - `retry-attempt` fires once per retried attempt.
|
||||
* - `rate-limit-hit` fires whenever a 429 is observed (even if retried).
|
||||
*/
|
||||
export interface ElevenLabsClientEvents {
|
||||
"request-start": {
|
||||
readonly feature: string;
|
||||
readonly operation: string;
|
||||
readonly endpoint: string;
|
||||
readonly method: string;
|
||||
};
|
||||
"request-end": {
|
||||
readonly feature: string;
|
||||
readonly operation: string;
|
||||
readonly endpoint: string;
|
||||
readonly method: string;
|
||||
readonly status:
|
||||
| "ok"
|
||||
| "client_error"
|
||||
| "server_error"
|
||||
| "network_error"
|
||||
| "cancelled";
|
||||
readonly statusCode?: number;
|
||||
readonly durationMs: number;
|
||||
};
|
||||
"retry-attempt": {
|
||||
readonly feature: string;
|
||||
readonly operation: string;
|
||||
readonly endpoint: string;
|
||||
readonly attempt: number;
|
||||
readonly statusCode?: number;
|
||||
readonly delayMs: number;
|
||||
};
|
||||
"rate-limit-hit": {
|
||||
readonly feature: string;
|
||||
readonly operation: string;
|
||||
readonly endpoint: string;
|
||||
readonly retryAfterMs?: number;
|
||||
};
|
||||
/**
|
||||
* Index signature required to satisfy `EventBusLike<TMap extends
|
||||
* Record<string, unknown>>`. The four named keys above remain the
|
||||
* authoritative contract; this catch-all permits structural compatibility
|
||||
* without weakening individual payload types at the call-site.
|
||||
*/
|
||||
readonly [event: string]: unknown;
|
||||
}
|
||||
|
||||
/** Options accepted by `ElevenLabsClient`. */
|
||||
export interface ElevenLabsClientOptions {
|
||||
/** ElevenLabs API key. Required; never read from `process.env`. */
|
||||
readonly apiKey: string;
|
||||
/** Base URL. Default `https://api.elevenlabs.io`. */
|
||||
readonly baseUrl?: string;
|
||||
/** Request timeout in milliseconds. Default `30_000`. */
|
||||
readonly timeoutMs?: number;
|
||||
/** Maximum number of retry attempts. Default `3`. */
|
||||
readonly maxRetries?: number;
|
||||
/** Initial retry backoff delay in milliseconds. Default `500`. */
|
||||
readonly initialRetryDelayMs?: number;
|
||||
/** Optional `fetch` implementation (for tests / edge runtimes). */
|
||||
readonly fetchImpl?: typeof fetch;
|
||||
/** Optional `WebSocket` constructor (for tests). */
|
||||
readonly webSocketImpl?: WebSocketConstructor;
|
||||
/** Time source. Default `() => Date.now()`. */
|
||||
readonly clock?: Clock;
|
||||
/** Optional structured logger. */
|
||||
readonly logger?: LoggerLike;
|
||||
/** Optional event bus to receive lifecycle events. */
|
||||
readonly events?: EventBusLike<ElevenLabsClientEvents>;
|
||||
/** Optional User-Agent. Default `'suaveplan-elevenlabs/<version>'`. */
|
||||
readonly userAgent?: string;
|
||||
/** Optional default headers. */
|
||||
readonly defaultHeaders?: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
/** Minimal `WebSocket` constructor surface — matches the browser global. */
|
||||
export type WebSocketConstructor = new (
|
||||
url: string | URL,
|
||||
protocols?: string | string[]
|
||||
) => WebSocketLike;
|
||||
|
||||
/**
|
||||
* Minimal duck type of the WHATWG `WebSocket`. Anything that exposes the
|
||||
* needed event handlers and `send` / `close` methods is acceptable.
|
||||
*/
|
||||
export interface WebSocketLike {
|
||||
readonly readyState: number;
|
||||
readonly url: string;
|
||||
send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void;
|
||||
close(code?: number, reason?: string): void;
|
||||
addEventListener(
|
||||
type: "open" | "message" | "close" | "error",
|
||||
listener: (event: unknown) => void
|
||||
): void;
|
||||
removeEventListener(
|
||||
type: "open" | "message" | "close" | "error",
|
||||
listener: (event: unknown) => void
|
||||
): void;
|
||||
}
|
||||
|
||||
/** A frame sent or received over the WebSocket transport. */
|
||||
export type SocketFrame =
|
||||
| { readonly kind: "text"; readonly data: string }
|
||||
| { readonly kind: "binary"; readonly data: Uint8Array };
|
||||
|
||||
/** Close event surfaced via `ElevenLabsSocket.onClose`. */
|
||||
export interface SocketCloseEvent {
|
||||
readonly code: number;
|
||||
readonly reason: string;
|
||||
readonly wasClean: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user