You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
543 lines
19 KiB
543 lines
19 KiB
import { randomUUID } from 'node:crypto';
|
|
import WebSocket from 'ws';
|
|
import type { LogEntry, Logger } from '../../core/logger.js';
|
|
import {
|
|
isBrowserClientEvent,
|
|
type BrowserClientEvent,
|
|
type ClientEvent,
|
|
type ServerEvent,
|
|
type SessionStartConfig
|
|
} from '../../core/protocol.js';
|
|
import { formatToolError } from '../../core/tools.js';
|
|
import type { AnalyticsRecorder } from './analytics.js';
|
|
import { createLogger, safeSerialize, verifyDebugAccessKey } from './logger.js';
|
|
import type { ProviderSession, RealtimeProvider, ResolvedSessionConfig } from './provider.js';
|
|
import type { ToolRegistry } from './tools.js';
|
|
|
|
export type RealtimeSessionState = 'idle' | 'connecting' | 'active' | 'closing' | 'closed';
|
|
|
|
/** Server-owned policy for a realtime session. Never client-controlled. */
|
|
export interface SessionDefaults {
|
|
model: string;
|
|
instructions: string;
|
|
/** Used only when the browser's `session.start` doesn't specify a voice. */
|
|
defaultVoice?: string;
|
|
toolRegistry: ToolRegistry;
|
|
}
|
|
|
|
/**
|
|
* Merges the browser's (deliberately tiny) client-visible config into the
|
|
* server-owned defaults. `instructions`/`model`/`tools` always come from
|
|
* `defaults` — this is the trust boundary that keeps a shared relay safe to
|
|
* expose to untrusted browsers.
|
|
*/
|
|
export function resolveSessionConfig(
|
|
defaults: SessionDefaults,
|
|
clientConfig?: SessionStartConfig
|
|
): ResolvedSessionConfig {
|
|
return {
|
|
model: defaults.model,
|
|
instructions: defaults.instructions,
|
|
voice: clientConfig?.voice ?? defaults.defaultVoice,
|
|
tools: defaults.toolRegistry.definitions
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Owns the lifecycle of one browser WS connection: turns its first
|
|
* `session.start` into a `ProviderSession` via the injected `RealtimeProvider`,
|
|
* pipes events both directions afterward, and guarantees that either side
|
|
* closing/erroring tears down the other.
|
|
*/
|
|
export class RealtimeSession {
|
|
private state: RealtimeSessionState = 'idle';
|
|
private providerSession: ProviderSession | null = null;
|
|
/** Tool calls currently awaiting resolution in the current turn (server executor pending or client round-trip pending). */
|
|
private outstandingToolCalls = 0;
|
|
/** Client-executed tool calls forwarded to the browser, awaiting `tool.result`. */
|
|
private readonly pendingClientToolCalls = new Map<string, NodeJS.Timeout>();
|
|
/** Set once `start()` reaches 'active'; null before then and stays that way if the session never gets that far. */
|
|
private sessionId: string | null = null;
|
|
/** Populated at the top of handleToolCall (before any branching) so even the unknown-tool path is covered; consumed in resolveToolCall. */
|
|
private readonly toolCallMeta = new Map<
|
|
string,
|
|
{ name: string; arguments: string; startedAt: number }
|
|
>();
|
|
/** Assistant voice replies stream as many transcript.delta chunks then one text-less transcript.done — see docs/ANALYTICS.md. */
|
|
private assistantTranscriptBuffer = '';
|
|
/** True once this connection is allowed to receive `debug.log` events — see docs/LOGGING.md. Starts true in dev (frictionless), false in prod until a valid `debug.subscribe`. */
|
|
private debugSubscribed = false;
|
|
/** Forwards to this session's own browser only when `debugSubscribed` — see forwardDebugLog. Also mirrors to console, except the `messages` category when `DEBUG_LOG_MESSAGES=false` — see createLogger. */
|
|
private readonly logger: Logger;
|
|
|
|
constructor(
|
|
private readonly browserSocket: WebSocket,
|
|
private readonly provider: RealtimeProvider,
|
|
private readonly defaults: SessionDefaults,
|
|
private readonly recorder: AnalyticsRecorder,
|
|
/** How long a client-executed tool call may go unanswered before we give up on it. */
|
|
private readonly clientToolTimeoutMs: number,
|
|
/** Dev builds forward debug logs automatically; prod requires `debug.subscribe` with a valid key. See docs/LOGGING.md. */
|
|
private readonly isDev: boolean = false,
|
|
/** Gates the `messages` category's console mirror only — browser forwarding is unaffected. See `DEBUG_LOG_MESSAGES` in config.ts and docs/LOGGING.md. */
|
|
logMessages: boolean = true
|
|
) {
|
|
this.debugSubscribed = isDev;
|
|
this.logger = createLogger((entry) => this.forwardDebugLog(entry), { logMessages });
|
|
this.browserSocket.on('message', (data, isBinary) => {
|
|
void this.handleBrowserMessage(data, isBinary).catch((error) => {
|
|
this.handleBrowserMessageFailure(error);
|
|
});
|
|
});
|
|
this.browserSocket.on('close', () => this.close('client_disconnected'));
|
|
this.browserSocket.on('error', () => this.close('client_disconnected'));
|
|
}
|
|
|
|
getState(): RealtimeSessionState {
|
|
return this.state;
|
|
}
|
|
|
|
private async handleBrowserMessage(data: WebSocket.RawData, isBinary: boolean): Promise<void> {
|
|
if (isBinary) {
|
|
this.logger.warn('ws', 'Rejected binary frame from browser.');
|
|
this.sendToBrowser({
|
|
type: 'error',
|
|
code: 'invalid_client_event',
|
|
message: 'Binary frames are not supported yet.'
|
|
});
|
|
return;
|
|
}
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(data.toString());
|
|
} catch {
|
|
this.logger.warn('ws', 'Rejected malformed JSON from browser.');
|
|
this.sendToBrowser({
|
|
type: 'error',
|
|
code: 'invalid_client_event',
|
|
message: 'Malformed JSON.'
|
|
});
|
|
return;
|
|
}
|
|
if (!isBrowserClientEvent(parsed)) {
|
|
this.logger.warn('ws', 'Rejected invalid client event.');
|
|
this.sendToBrowser({
|
|
type: 'error',
|
|
code: 'invalid_client_event',
|
|
message: 'Invalid client event.'
|
|
});
|
|
return;
|
|
}
|
|
const event: BrowserClientEvent = parsed;
|
|
|
|
if (event.type === 'session.start') {
|
|
await this.start(event.config);
|
|
return;
|
|
}
|
|
if (event.type === 'session.end') {
|
|
this.close('client_requested');
|
|
return;
|
|
}
|
|
if (event.type === 'debug.subscribe') {
|
|
this.handleDebugSubscribe(event.key);
|
|
return;
|
|
}
|
|
if (this.state !== 'active' || !this.providerSession) {
|
|
this.logger.warn('ws', `Rejected '${event.type}' before session.start.`);
|
|
this.sendToBrowser({
|
|
type: 'error',
|
|
code: 'invalid_client_event',
|
|
message: `Cannot handle '${event.type}' before session.start.`
|
|
});
|
|
return;
|
|
}
|
|
if (event.type === 'tool.result') {
|
|
const timeout = this.pendingClientToolCalls.get(event.callId);
|
|
if (!timeout) return; // stale/unknown callId (e.g. already timed out) — ignore
|
|
clearTimeout(timeout);
|
|
this.pendingClientToolCalls.delete(event.callId);
|
|
this.resolveToolCall(event.callId, event.output);
|
|
return;
|
|
}
|
|
if (event.type === 'input_text.send') {
|
|
// Typed text is always already a complete string — unlike the voice
|
|
// path, no buffering is needed.
|
|
this.logger.info('messages', 'User message', { role: 'user', text: event.text });
|
|
this.recordAnalytics('message', () => {
|
|
this.recorder.messageLogged({
|
|
sessionId: this.sessionId!,
|
|
role: 'user',
|
|
text: event.text,
|
|
loggedAt: Date.now()
|
|
});
|
|
});
|
|
}
|
|
this.providerSession.send(event);
|
|
}
|
|
|
|
/**
|
|
* Last-resort containment for unexpected failures in the async websocket
|
|
* message path. A single connection must never create an unhandled
|
|
* rejection in the process shared by every realtime session.
|
|
*/
|
|
private handleBrowserMessageFailure(error: unknown): void {
|
|
let message = 'Unknown error';
|
|
try {
|
|
message = error instanceof Error ? error.message : String(error);
|
|
} catch {
|
|
// Even a hostile thrown value with a broken toString stays contained.
|
|
}
|
|
try {
|
|
this.logger.error('ws', 'Unexpected failure handling browser message.', { message });
|
|
} catch {
|
|
// Error reporting itself must not escape this rejection boundary.
|
|
}
|
|
try {
|
|
this.sendToBrowser({
|
|
type: 'error',
|
|
code: 'internal_error',
|
|
message: 'Failed to handle client event.'
|
|
});
|
|
} catch {
|
|
// A failing analytics/logging sink may be the original cause.
|
|
}
|
|
try {
|
|
this.close('internal_error');
|
|
} catch {
|
|
// Keep the failure scoped to this connection even if teardown throws.
|
|
}
|
|
}
|
|
|
|
private async start(clientConfig?: SessionStartConfig): Promise<void> {
|
|
if (this.state !== 'idle') {
|
|
this.sendToBrowser({
|
|
type: 'error',
|
|
code: 'invalid_client_event',
|
|
message: 'Session already started.'
|
|
});
|
|
return;
|
|
}
|
|
this.state = 'connecting';
|
|
const config = resolveSessionConfig(this.defaults, clientConfig);
|
|
|
|
let providerSession: ProviderSession;
|
|
try {
|
|
providerSession = await this.provider.createSession(config);
|
|
} catch (err) {
|
|
this.state = 'closed';
|
|
const message = err instanceof Error ? err.message : 'Failed to start session.';
|
|
this.logger.error('session', 'Failed to start session.', { message });
|
|
this.sendToBrowser({
|
|
type: 'error',
|
|
code: 'upstream_unavailable',
|
|
message
|
|
});
|
|
this.browserSocket.close();
|
|
return;
|
|
}
|
|
|
|
// The browser may have disconnected (session.end / close) while we were
|
|
// awaiting the provider handshake — don't leak the upstream connection.
|
|
const stateAfterConnect: RealtimeSessionState = this.state;
|
|
if (stateAfterConnect !== 'connecting') {
|
|
providerSession.close('client_requested');
|
|
return;
|
|
}
|
|
|
|
this.providerSession = providerSession;
|
|
const sessionId = randomUUID();
|
|
this.sessionId = sessionId;
|
|
this.state = 'active';
|
|
this.logger.info('session', 'Session started.', {
|
|
sessionId,
|
|
model: config.model,
|
|
voice: config.voice
|
|
});
|
|
// Logged once here, alongside the user/assistant message logging below,
|
|
// so the debug panel can show the full conversation context — the
|
|
// system prompt is otherwise invisible while debugging a live session.
|
|
this.logger.info('messages', 'System instructions', {
|
|
role: 'system',
|
|
text: config.instructions
|
|
});
|
|
this.recordAnalytics('session start', () => {
|
|
this.recorder.sessionStarted({
|
|
sessionId,
|
|
model: config.model,
|
|
voice: config.voice,
|
|
startedAt: Date.now()
|
|
});
|
|
});
|
|
// Recorded above before wiring onEvent, so there's no window where a
|
|
// provider event could reach handleProviderEvent before sessionId is set.
|
|
this.providerSession.onEvent((event) => this.handleProviderEvent(event));
|
|
this.sendToBrowser({ type: 'session.ready', sessionId });
|
|
}
|
|
|
|
private handleProviderEvent(event: ServerEvent): void {
|
|
if (event.type === 'tool.call') {
|
|
void this.handleToolCall(event);
|
|
return;
|
|
}
|
|
if (event.type === 'transcript.delta') {
|
|
if (event.role === 'assistant') {
|
|
this.assistantTranscriptBuffer += event.text;
|
|
} else {
|
|
// User voice transcripts arrive as a single already-complete chunk
|
|
// (conversation.item.input_audio_transcription.completed in
|
|
// providers/openai.ts) with no corresponding
|
|
// transcript.done{role:'user'} ever emitted today — log
|
|
// immediately rather than buffering. Rests on unverified live
|
|
// behavior; see docs/ANALYTICS.md.
|
|
this.logger.info('messages', 'User message', { role: 'user', text: event.text });
|
|
this.recordAnalytics('message', () => {
|
|
this.recorder.messageLogged({
|
|
sessionId: this.sessionId!,
|
|
role: 'user',
|
|
text: event.text,
|
|
loggedAt: Date.now()
|
|
});
|
|
});
|
|
}
|
|
this.sendToBrowser(event);
|
|
return;
|
|
}
|
|
if (event.type === 'transcript.done' && event.role === 'assistant') {
|
|
const text = this.assistantTranscriptBuffer;
|
|
this.assistantTranscriptBuffer = '';
|
|
this.logger.info('messages', 'Assistant message', { role: 'assistant', text });
|
|
this.recordAnalytics('message', () => {
|
|
this.recorder.messageLogged({
|
|
sessionId: this.sessionId!,
|
|
role: 'assistant',
|
|
text,
|
|
loggedAt: Date.now()
|
|
});
|
|
});
|
|
this.sendToBrowser(event);
|
|
return;
|
|
}
|
|
if (event.type === 'session.closed') {
|
|
// Forward first, then close: close() moves the browser socket out of
|
|
// OPEN, and sendToBrowser no-ops once that happens, so closing first
|
|
// would silently swallow this notification.
|
|
this.sendToBrowser(event);
|
|
this.close('upstream_disconnected');
|
|
return;
|
|
}
|
|
this.sendToBrowser(event);
|
|
}
|
|
|
|
/**
|
|
* Routes a tool call by its registered `location`: a server tool is
|
|
* executed here directly (the browser never sees it); a client tool is
|
|
* forwarded to the browser and tracked with a timeout so an unresponsive
|
|
* tab can't hang the model's turn forever.
|
|
*/
|
|
private async handleToolCall(event: Extract<ServerEvent, { type: 'tool.call' }>): Promise<void> {
|
|
this.outstandingToolCalls++;
|
|
this.toolCallMeta.set(event.callId, {
|
|
name: event.name,
|
|
arguments: event.arguments,
|
|
startedAt: Date.now()
|
|
});
|
|
this.logger.debug('tools', `Tool call started: ${event.name}`, { callId: event.callId });
|
|
const definition = this.defaults.toolRegistry.definitions.find((d) => d.name === event.name);
|
|
if (!definition) {
|
|
this.logger.warn('tools', `Unknown tool "${event.name}".`, { callId: event.callId });
|
|
this.resolveToolCall(event.callId, formatToolError(`Unknown tool "${event.name}".`));
|
|
return;
|
|
}
|
|
|
|
if (definition.location === 'client') {
|
|
this.sendToBrowser(event);
|
|
const timeout = setTimeout(() => {
|
|
this.pendingClientToolCalls.delete(event.callId);
|
|
this.logger.warn('tools', `Tool call timed out: ${event.name}`, { callId: event.callId });
|
|
this.resolveToolCall(event.callId, formatToolError('Client tool call timed out.'));
|
|
}, this.clientToolTimeoutMs);
|
|
this.pendingClientToolCalls.set(event.callId, timeout);
|
|
return;
|
|
}
|
|
|
|
let args: Record<string, unknown>;
|
|
try {
|
|
args = JSON.parse(event.arguments);
|
|
} catch {
|
|
this.resolveToolCall(event.callId, formatToolError('Malformed tool arguments.'));
|
|
return;
|
|
}
|
|
try {
|
|
const executor = this.defaults.toolRegistry.executors.get(event.name);
|
|
const result = await executor!(args);
|
|
this.resolveToolCall(event.callId, JSON.stringify(result));
|
|
} catch (err) {
|
|
this.resolveToolCall(
|
|
event.callId,
|
|
formatToolError(err instanceof Error ? err.message : 'Tool execution failed.')
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sends a tool's result to the provider and, once every tool call
|
|
* outstanding in the current turn has resolved, explicitly triggers the
|
|
* model to continue — see the `response.trigger` note in protocol.ts for
|
|
* why this can't just be bundled with each individual result.
|
|
*/
|
|
private resolveToolCall(callId: string, output: string): void {
|
|
this.sendToProvider({ type: 'tool.result', callId, output });
|
|
const meta = this.toolCallMeta.get(callId);
|
|
if (meta) {
|
|
// Should always be present given handleToolCall sets it for every
|
|
// path (including the unknown-tool branch) before this can run —
|
|
// guarded defensively rather than asserted, so a future refactor
|
|
// that breaks the invariant fails soft here instead of crashing.
|
|
this.toolCallMeta.delete(callId);
|
|
this.logger.debug('tools', `Tool call completed: ${meta.name}`, {
|
|
callId,
|
|
durationMs: Date.now() - meta.startedAt
|
|
});
|
|
this.recordAnalytics('tool call', () => {
|
|
this.recorder.toolCallLogged({
|
|
sessionId: this.sessionId!,
|
|
callId,
|
|
name: meta.name,
|
|
arguments: meta.arguments,
|
|
output,
|
|
durationMs: Date.now() - meta.startedAt,
|
|
loggedAt: Date.now()
|
|
});
|
|
});
|
|
}
|
|
this.outstandingToolCalls--;
|
|
if (this.outstandingToolCalls === 0) {
|
|
this.sendToProvider({ type: 'response.trigger' });
|
|
}
|
|
}
|
|
|
|
private close(reason: string): void {
|
|
if (this.state === 'closing' || this.state === 'closed') return;
|
|
this.state = 'closing';
|
|
this.logger.info('session', `Session closed: ${reason}`);
|
|
for (const timeout of this.pendingClientToolCalls.values()) clearTimeout(timeout);
|
|
this.pendingClientToolCalls.clear();
|
|
this.providerSession?.close(reason);
|
|
this.providerSession = null;
|
|
if (this.browserSocket.readyState === WebSocket.OPEN) {
|
|
this.browserSocket.close();
|
|
}
|
|
this.state = 'closed';
|
|
if (this.sessionId) {
|
|
// Guarded: sessionId is still null if the browser disconnected
|
|
// mid-handshake, before start() ever reached 'active' — no sessions
|
|
// row exists yet to update in that case.
|
|
this.recordAnalytics('session end', () => {
|
|
this.recorder.sessionEnded({
|
|
sessionId: this.sessionId!,
|
|
endedAt: Date.now(),
|
|
reason
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
private sendToBrowser(event: ServerEvent): void {
|
|
if (event.type === 'error' || event.type === 'session.closed') {
|
|
// Logged before the delivery guard below: the event "happened" from
|
|
// the session's point of view even if the browser socket is already
|
|
// dead by the time we try to deliver it.
|
|
this.recordAnalytics('event', () => {
|
|
this.recorder.eventLogged({
|
|
sessionId: this.sessionId,
|
|
type: event.type,
|
|
detail: JSON.stringify(event),
|
|
loggedAt: Date.now()
|
|
});
|
|
});
|
|
}
|
|
if (this.browserSocket.readyState !== WebSocket.OPEN) return;
|
|
this.browserSocket.send(JSON.stringify(event));
|
|
}
|
|
|
|
/**
|
|
* Analytics is best-effort telemetry, never part of session correctness.
|
|
* A full disk, locked database, or broken recorder must not interrupt
|
|
* provider traffic, browser delivery, or teardown.
|
|
*/
|
|
private recordAnalytics(operation: string, write: () => void): void {
|
|
try {
|
|
write();
|
|
} catch (error) {
|
|
let message = 'Unknown error';
|
|
try {
|
|
message = error instanceof Error ? error.message : String(error);
|
|
} catch {
|
|
// Even a hostile thrown value with a broken toString stays contained.
|
|
}
|
|
try {
|
|
this.logger.error('analytics', `Failed to record ${operation}.`, { message });
|
|
} catch {
|
|
// Telemetry failure reporting is best-effort too.
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verifies a `debug.subscribe` request and replies with the result. In
|
|
* dev, any request succeeds (frictionless — see docs/LOGGING.md); in
|
|
* prod, `key` must match `DEBUG_ACCESS_KEY`.
|
|
*/
|
|
private handleDebugSubscribe(key: string | undefined): void {
|
|
this.debugSubscribed = this.isDev || verifyDebugAccessKey(key);
|
|
this.sendToBrowser({
|
|
type: 'debug.subscribe.result',
|
|
ok: this.debugSubscribed,
|
|
reason: this.debugSubscribed ? undefined : 'Invalid or missing debug access key.'
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Forwards a log entry to this session's own browser only — never another
|
|
* session's, since each `RealtimeSession` instance owns exactly one
|
|
* `browserSocket`. No-ops until `debugSubscribed`. Never lets a bad log
|
|
* entry (e.g. unserializable `data`) affect the session itself.
|
|
*/
|
|
private forwardDebugLog(entry: LogEntry): void {
|
|
if (!this.debugSubscribed) return;
|
|
try {
|
|
this.sendToBrowser({ type: 'debug.log', ...entry, data: safeSerialize(entry.data) });
|
|
} catch {
|
|
// Forwarding a debug entry must never break real session traffic.
|
|
}
|
|
}
|
|
|
|
// Guarded the same way sendToBrowser is: tool resolution can now reach
|
|
// the provider from an async callback (executor await, timeout) rather
|
|
// than only from a browser message already known to be in the 'active'
|
|
// state, so providerSession may have gone null by the time this runs.
|
|
private sendToProvider(event: ClientEvent): void {
|
|
this.providerSession?.send(event);
|
|
}
|
|
}
|
|
|
|
export function createRealtimeSession(
|
|
browserSocket: WebSocket,
|
|
provider: RealtimeProvider,
|
|
defaults: SessionDefaults,
|
|
recorder: AnalyticsRecorder,
|
|
clientToolTimeoutMs: number,
|
|
isDev = false,
|
|
logMessages = true
|
|
): RealtimeSession {
|
|
return new RealtimeSession(
|
|
browserSocket,
|
|
provider,
|
|
defaults,
|
|
recorder,
|
|
clientToolTimeoutMs,
|
|
isDev,
|
|
logMessages
|
|
);
|
|
}
|
|
|