Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-event-emitter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@elevenlabs/client": minor
---

Add typed event emitter (`on`/`off`) to conversation instances. Listeners registered via `on()` receive the same payloads as the existing constructor callbacks. `on()` returns an unsubscribe function; `off()` removes a specific listener. Multiple listeners per event are supported.
5 changes: 5 additions & 0 deletions .changeset/move-types-to-client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@elevenlabs/types": minor
---

Remove manually maintained types (`Role`, `Mode`, `Status`, `Callbacks`, `CALLBACK_KEYS`, `DisconnectionDetails`, `MessagePayload`, `AudioAlignmentEvent`) from `@elevenlabs/types`. These types now live in `@elevenlabs/client` — import them from there instead. The types package now contains only generated code.
5 changes: 5 additions & 0 deletions .changeset/react-event-emitter-refactor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@elevenlabs/react": patch
---

Refactor sub-providers to subscribe via `conversation.on()` instead of composed callback objects. Removes `ListenerMap` and `ListenerSet` internal classes. No public API changes.
5 changes: 4 additions & 1 deletion packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
},
"scripts": {
"generate-version": "printf \"// This file is auto-generated during build\\nexport const PACKAGE_VERSION = \\\"%s\\\";\\n\" \"$npm_package_version\" > src/version.ts",
"generate-events": "node scripts/generate-events.ts && prettier --write src/generated/**/*.ts",
"generate-worklets": "node scripts/generateWorklets.js",
"build:esm": "tsc --build",
"build:iife": "rolldown src/index.ts -f iife --name ElevenLabsClient -o dist/lib.iife.js --sourcemap --external react --external react-dom",
Expand All @@ -38,10 +39,12 @@
"mock-socket": "^9.3.1",
"node-wav": "^0.0.2",
"playwright": "^1.54.1",
"prettier": "^3.8.3",
"rolldown": "^1.0.0-rc.9",
"typescript": "^5.5.4",
"typescript-eslint": "^8.59.2",
"vitest": "^4.1.5"
"vitest": "^4.1.5",
"yaml": "^2.8.4"
},
"repository": {
"type": "git",
Expand Down
1 change: 1 addition & 0 deletions packages/client/schemas
255 changes: 255 additions & 0 deletions packages/client/scripts/generate-events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
/**
* Event codegen script — reads agent.asyncapi.yaml and generates:
* src/generated/events.ts — GeneratedEventMap, wire type unions, lookup tables
* src/generated/dispatch.ts — dispatchGeneratedEvent() helper
*
* Run: node scripts/generate-events.ts
*/

import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import path from "node:path";
import { parse as parseYaml } from "yaml";

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/** Resolve a JSON-pointer $ref within the parsed schema object. */
function resolveRef(schema: Record<string, unknown>, ref: string): unknown {
const parts = ref.replace("#/", "").split("/");
let current: unknown = schema;
for (const part of parts) {
current = (current as Record<string, unknown>)[part];
}
return current;
}

/**
* Replicate Modelina's PascalCase normalisation for model names.
* Consecutive uppercase letters are lowered except the first character:
* MCPToolCall → McpToolCall, VADScore → VadScore, ASRInit → AsrInit
*/
function toModelinaPascalCase(name: string): string {
return name.replace(
/([A-Z]+)([A-Z][a-z])/g,
(_, prefix: string, suffix: string) =>
prefix[0] + prefix.slice(1).toLowerCase() + suffix,
);
}

// ---------------------------------------------------------------------------
// Parse schema
// ---------------------------------------------------------------------------

const schemasDir = path.resolve(process.cwd(), "schemas");
const outDir = path.resolve(process.cwd(), "src/generated");
const schemaPath = path.join(schemasDir, "agent.asyncapi.yaml");
const schema = parseYaml(readFileSync(schemaPath, "utf8")) as Record<
string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
any
>;

// ---------------------------------------------------------------------------
// Internally handled events — need custom dispatch logic in BaseConversation
// ---------------------------------------------------------------------------

const INTERNALLY_HANDLED_EVENTS: ReadonlySet<string> = new Set([
"agent_response", // combined with user_transcript → "message"
"user_transcript", // combined with agent_response → "message"
"audio", // unwraps base64 + alignment + mode tracking
"client_tool_call", // internal tool dispatch, not emitted
"internal_tentative_agent_response", // emits "debug"
"ping", // internal pong response
"error", // routed through handleErrorEvent
]);

// ---------------------------------------------------------------------------
// Extract server → client messages from the publish channel
// ---------------------------------------------------------------------------

const publishRefs = schema.channels.AgentMessages.publish.message.oneOf as {
$ref: string;
}[];

type EventInfo = {
messageKey: string;
typeName: string;
wireType: string;
eventName: string;
payloadField: string | null;
};

const allWireTypes: string[] = [];
const events: EventInfo[] = [];

for (const ref of publishRefs) {
const messageKey = ref.$ref.split("/").pop()!;
const message = schema.components.messages[messageKey];

// Resolve payload schema (always a $ref in our spec)
const payloadSchema = message.payload.$ref
? resolveRef(schema, message.payload.$ref)
: message.payload;

// Extract wire type from the `type` property's const value
const wireType = (payloadSchema as Record<string, unknown> & {
properties: Record<string, { const?: string }>;
}).properties.type?.const;
if (!wireType) {
throw new Error(`No type const found in payload for message ${messageKey}`);
}

allWireTypes.push(wireType);

if (INTERNALLY_HANDLED_EVENTS.has(wireType)) continue;

// Find the single non-`type` property (the payload field to unwrap)
const payloadFields = Object.keys(
(payloadSchema as { properties: Record<string, unknown> }).properties,
).filter((k) => k !== "type");
if (payloadFields.length > 1) {
throw new Error(
`Multiple non-type fields in ${messageKey}: ${payloadFields.join(", ")}`,
);
}
const payloadField = payloadFields.length === 1 ? payloadFields[0] : null;

const typeName = toModelinaPascalCase(messageKey);
const eventName = wireType.replace(/_/g, "-");

events.push({ messageKey, typeName, wireType, eventName, payloadField });
}

// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------

// Every internally-handled wire type must actually exist in the spec
for (const handled of INTERNALLY_HANDLED_EVENTS) {
if (!allWireTypes.includes(handled)) {
console.warn(
`⚠ Internally handled event "${handled}" not found in schema`,
);
}
}

// Every wire type must be either internally handled or generated
const unaccounted = allWireTypes.filter(
(wt) =>
!INTERNALLY_HANDLED_EVENTS.has(wt) &&
!events.some((e) => e.wireType === wt),
);
if (unaccounted.length > 0) {
throw new Error(`Unaccounted wire types: ${unaccounted.join(", ")}`);
}

// ---------------------------------------------------------------------------
// Generate events.ts
// ---------------------------------------------------------------------------

function generateEventsFile(): string {
const imports = events.map((e) => ` ${e.typeName},`).join("\n");

const wireTypeUnion = events
.map((e) => ` | "${e.wireType}"`)
.join("\n");

const eventMapEntries = events
.map((e) => {
if (e.payloadField) {
return ` "${e.eventName}": (props: ${e.typeName}["${e.payloadField}"]) => void;`;
}
return ` "${e.eventName}": () => void;`;
})
.join("\n");

const wireTypeToEventName = events
.map((e) => ` "${e.wireType}": "${e.eventName}",`)
.join("\n");

const wireTypeToPayloadField = events
.map(
(e) =>
` "${e.wireType}": ${e.payloadField ? `"${e.payloadField}"` : "null"},`,
)
.join("\n");

return `// Auto-generated from agent.asyncapi.yaml — DO NOT EDIT MANUALLY

import type {
${imports}
} from "@elevenlabs/types";

/** Union of all generated wire type strings */
export type GeneratedWireType =
${wireTypeUnion};

/** Generated event map — simple unwrap events only */
export type GeneratedEventMap = {
${eventMapEntries}
};

/** Runtime: wire \`type\` → event name (generated events only) */
export const WIRE_TYPE_TO_EVENT_NAME: Record<
GeneratedWireType,
keyof GeneratedEventMap
> = {
${wireTypeToEventName}
};

/** Runtime: wire \`type\` → payload field to unwrap (null = no payload) */
export const WIRE_TYPE_TO_PAYLOAD_FIELD: Record<
GeneratedWireType,
string | null
> = {
${wireTypeToPayloadField}
};
`;
}

// ---------------------------------------------------------------------------
// Generate dispatch.ts
// ---------------------------------------------------------------------------

function generateDispatchFile(): string {
return `// Auto-generated from agent.asyncapi.yaml — DO NOT EDIT MANUALLY

import {
WIRE_TYPE_TO_EVENT_NAME,
WIRE_TYPE_TO_PAYLOAD_FIELD,
} from "./events.js";
import type { GeneratedWireType } from "./events.js";

/**
* Dispatches a generated wire event by looking up the event name and
* unwrapping the payload field. Caller provides the emit function
* to avoid circular dependencies with BaseConversation.
*/
export function dispatchGeneratedEvent(
emit: (event: string, ...args: unknown[]) => void,
wireType: GeneratedWireType,
parsedEvent: Record<string, unknown>,
): void {
const eventName = WIRE_TYPE_TO_EVENT_NAME[wireType];
const payloadField = WIRE_TYPE_TO_PAYLOAD_FIELD[wireType];
if (payloadField) {
emit(eventName, parsedEvent[payloadField]);
} else {
emit(eventName);
}
}
`;
}

// ---------------------------------------------------------------------------
// Write output
// ---------------------------------------------------------------------------

mkdirSync(outDir, { recursive: true });
writeFileSync(path.join(outDir, "events.ts"), generateEventsFile());
writeFileSync(path.join(outDir, "dispatch.ts"), generateDispatchFile());

console.log(`✅ Generated event types (${events.length} events):`);
console.log(` ${path.join(outDir, "events.ts")}`);
console.log(` ${path.join(outDir, "dispatch.ts")}`);
2 changes: 1 addition & 1 deletion packages/client/src/BaseConversation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ class TestConversation extends BaseConversation {
public receiveMessage(
event: Parameters<Parameters<BaseConnection["onMessage"]>[0]>[0]
) {
return this["onMessage"](event);
return this["onMessageHandler"](event);
}
}

Expand Down
Loading
Loading