ai
Version:
AI SDK by Vercel - build apps like ChatGPT, Claude, Gemini, and more with a single interface for any model using the Vercel AI Gateway or go direct to OpenAI, Anthropic, Google, or any other model provider.
175 lines (164 loc) • 5.37 kB
text/typescript
import { safeParseJSON } from '@ai-sdk/provider-utils';
import type {
RealtimeClientEvent,
RealtimeModel,
RealtimeServerEvent,
} from '../types/realtime-model';
/** One codec queue per connection; retired connections cannot deliver late work. */
export class RealtimeEventChannel {
private active = true;
private finishing = false;
private readonly parse: (
raw: unknown,
) => RealtimeServerEvent | RealtimeServerEvent[];
private incoming = Promise.resolve();
private outgoing = Promise.resolve();
private incomingCount = 0;
private incomingBytes = 0;
private outgoingCount = 0;
constructor(
private readonly options: {
model: RealtimeModel;
send: (data: unknown) => void;
onEvent: (event: RealtimeServerEvent) => void | Promise<void>;
onError: (error: Error) => void;
onFatalError?: (error: Error) => void;
maxPending?: number;
maxPendingBytes?: number;
},
) {
this.parse =
options.model.createServerEventParser?.() ??
options.model.parseServerEvent.bind(options.model);
}
get hasPendingIncoming(): boolean {
return this.incomingCount > 0;
}
dispose(): void {
this.active = false;
}
/** Stop accepting messages, but deliver already received terminal events. */
async finish(): Promise<void> {
this.stopWriting();
await this.incoming;
this.dispose();
}
stopWriting(): void {
this.finishing = true;
}
send(event: RealtimeClientEvent, shouldSend?: () => boolean): Promise<void> {
if (!this.active || this.finishing)
throw new Error('Realtime connection is closed');
if (this.outgoingCount >= (this.options.maxPending ?? 512)) {
throw new Error('Realtime outgoing queue is full');
}
this.outgoingCount++;
const operation = this.outgoing.then(async () => {
if (!this.active || this.finishing)
throw new Error('Realtime connection is closed');
if (shouldSend?.() === false) return;
if (!this.active || this.finishing)
throw new Error('Realtime connection is closed');
const data = await this.options.model.serializeClientEvent(event);
if (!this.active || this.finishing)
throw new Error('Realtime connection is closed');
if (shouldSend?.() === false) return;
if (!this.active || this.finishing)
throw new Error('Realtime connection is closed');
if (data != null) this.options.send(data);
});
this.outgoing = operation
.catch(error => {
if (!this.finishing) {
// Capture owners escalate automatic audio failures; manual sends stay recoverable.
if (
event.type !== 'input-audio-append' &&
event.type !== 'session-close'
)
this.report(error);
}
})
.finally(() => {
this.outgoingCount--;
});
return operation;
}
receive(data: unknown): void {
if (!this.active || this.finishing) return;
const bytes =
typeof data === 'string'
? data.length * 2
: data instanceof Blob
? data.size
: ((data as ArrayBuffer).byteLength ?? 0);
if (
this.incomingCount >= (this.options.maxPending ?? 512) ||
this.incomingBytes + bytes >
(this.options.maxPendingBytes ?? 8 * 1024 * 1024)
) {
this.finishing = true;
this.fatal(
new Error(
'Realtime incoming queue is full; input protocol continuity was lost',
),
);
return;
}
this.incomingCount++;
this.incomingBytes += bytes;
this.incoming = this.incoming
.then(async () => {
if (!this.active) return;
const text =
typeof data === 'string'
? data
: data instanceof Blob
? await data.text()
: new TextDecoder().decode(data as ArrayBuffer);
const parsed = await safeParseJSON({ text });
if (!this.active) return;
if (!parsed.success)
throw new Error('Invalid JSON in realtime server message', {
cause: parsed.error,
});
const health = this.options.model.getHealthCheckResponse?.(
parsed.value,
);
if (!this.active) return;
if (health != null && !this.finishing) this.options.send(health);
if (!this.active) return;
const result = this.parse(parsed.value);
for (const event of Array.isArray(result) ? result : [result]) {
if (!this.active) return;
try {
await this.options.onEvent(event);
} catch (error) {
this.report(error);
}
}
})
.catch(error => {
this.finishing = true;
this.fatal(error instanceof Error ? error : new Error(String(error)));
})
.finally(() => {
this.incomingCount--;
this.incomingBytes -= bytes;
});
}
private fatal(error: Error): void {
if (!this.active) return;
if (this.options.onFatalError != null) this.options.onFatalError(error);
else this.report(error);
}
private report(error: unknown): void {
if (!this.active) return;
try {
this.options.onError(
error instanceof Error ? error : new Error(String(error)),
);
} catch {
/* Application callbacks cannot break the codec queue. */
}
}
}