first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 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,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,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,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,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.