Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View 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.

View File

@@ -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");
});
});

View 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 });
}
}
}

View File

@@ -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.

View File

@@ -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");
});
});

View File

@@ -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;
}

View File

@@ -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";

View File

@@ -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.

View File

@@ -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();
});
});

View File

@@ -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;
}

View 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.

View File

@@ -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;
}
});
});

View 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);
});
}
}

View File

@@ -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.

View File

@@ -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");
});
});

View File

@@ -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;
}

View File

@@ -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]);
});
});

View 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`.

View File

@@ -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();
});
});

View 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)
);
}
}

View File

@@ -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";

View 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";

View File

@@ -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";

View 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.

View 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;
}