From 1c3a190660663c2042e542dd9eee16754d2220ac Mon Sep 17 00:00:00 2001 From: Stu Kennedy Date: Fri, 12 Sep 2025 18:16:57 +0100 Subject: [PATCH] feat(js-sdk): add proxy transport and custom signaling refactor: enhance SignallingClientOptions with structured URL options --- src/AnamClient.ts | 85 +++++++++++++++---- src/modules/ExternalSessionClient.ts | 50 +++++++++++ src/modules/SignallingClient.ts | 58 ++++++++++--- src/types/AnamPublicClientOptions.ts | 10 +++ src/types/shims-buffer.d.ts | 4 + src/types/signalling/SignalMessage.ts | 3 +- .../signalling/SignallingClientOptions.ts | 28 +++++- tsconfig.json | 5 +- 8 files changed, 212 insertions(+), 31 deletions(-) create mode 100644 src/modules/ExternalSessionClient.ts create mode 100644 src/types/shims-buffer.d.ts diff --git a/src/AnamClient.ts b/src/AnamClient.ts index e5bb520..38a300e 100644 --- a/src/AnamClient.ts +++ b/src/AnamClient.ts @@ -16,6 +16,7 @@ import { PublicEventEmitter, StreamingClient, } from './modules'; +import { ExternalSessionClient } from './modules/ExternalSessionClient'; import { AnamClientOptions, AnamEvent, @@ -42,6 +43,7 @@ export default class AnamClient { private streamingClient: StreamingClient | null = null; private apiClient: CoreApiRestClient; + private externalSessionClient: ExternalSessionClient | null = null; private _isStreaming = false; @@ -81,6 +83,14 @@ export default class AnamClient { options?.apiKey, options?.api, ); + if (options?.transport?.mode === 'proxy' && options.transport.proxy) { + this.externalSessionClient = new ExternalSessionClient({ + baseUrl: options.transport.proxy.baseUrl, + startSessionPath: options.transport.proxy.startSessionPath, + getUserId: options.transport.proxy.getUserId, + headers: options.transport.proxy.headers, + }); + } this.messageHistoryClient = new MessageHistoryClient( this.publicEventEmitter, this.internalEventEmitter, @@ -178,17 +188,34 @@ export default class AnamClient { const sessionOptions: StartSessionOptions | undefined = this.buildStartSessionOptionsForClient(); // start a new session - const response: StartSessionResponse = await this.apiClient.startSession( - config, - sessionOptions, - ); - const { - sessionId, - clientConfig, - engineHost, - engineProtocol, - signallingEndpoint, - } = response; + let sessionId: string; + let engineHost: string; + let engineProtocol: 'http' | 'https'; + let signallingEndpoint: string; + let clientConfig: any; + + if ( + this.clientOptions?.transport?.mode === 'proxy' && + this.externalSessionClient + ) { + const response = + await this.externalSessionClient.startSession(sessionOptions); + sessionId = response.sessionId; + engineHost = response.engineHost; + engineProtocol = response.engineProtocol; + signallingEndpoint = response.signallingEndpoint; + clientConfig = response.clientConfig ?? {}; + } else { + const response: StartSessionResponse = await this.apiClient.startSession( + config, + sessionOptions, + ); + sessionId = response.sessionId; + clientConfig = response.clientConfig; + engineHost = response.engineHost; + engineProtocol = response.engineProtocol as 'http' | 'https'; + signallingEndpoint = response.signallingEndpoint; + } const { heartbeatIntervalSeconds, maxWsReconnectionAttempts, iceServers } = clientConfig; @@ -198,6 +225,36 @@ export default class AnamClient { }); try { + // Build signalling config; if in proxy mode, supply absolute WS URL to our proxy + let signallingUrlConfig: any = { + baseUrl: engineHost, + protocol: engineProtocol, + signallingPath: signallingEndpoint, + }; + + if ( + this.clientOptions?.transport?.mode === 'proxy' && + this.clientOptions.transport.proxy + ) { + const proxyCfg = this.clientOptions.transport.proxy; + const userId = proxyCfg.getUserId(); + const wsBase = new URL(proxyCfg.baseUrl); + // Convert to ws/wss and append params for the DO proxy + const agentPathTemplate = + proxyCfg.agentWsPathTemplate ?? '/v1/agents/{userId}/ws'; + const agentPath = agentPathTemplate.replace( + '{userId}', + encodeURIComponent(userId), + ); + const wsUrl = new URL(agentPath, wsBase.origin); + wsUrl.protocol = wsUrl.protocol === 'http:' ? 'ws:' : 'wss:'; + wsUrl.searchParams.set('engineHost', engineHost); + wsUrl.searchParams.set('engineProtocol', engineProtocol); + wsUrl.searchParams.set('signallingEndpoint', signallingEndpoint); + wsUrl.searchParams.set('session_id', sessionId); + signallingUrlConfig = { absoluteWsUrl: wsUrl.href }; + } + this.streamingClient = new StreamingClient( sessionId, { @@ -207,11 +264,7 @@ export default class AnamClient { signalling: { heartbeatIntervalSeconds, maxWsReconnectionAttempts, - url: { - baseUrl: engineHost, - protocol: engineProtocol, - signallingPath: signallingEndpoint, - }, + url: signallingUrlConfig, }, iceServers, inputAudio: { diff --git a/src/modules/ExternalSessionClient.ts b/src/modules/ExternalSessionClient.ts new file mode 100644 index 0000000..69bf8cf --- /dev/null +++ b/src/modules/ExternalSessionClient.ts @@ -0,0 +1,50 @@ +import { StartSessionOptions } from '../types/coreApi/StartSessionOptions'; + +export interface ExternalSessionClientConfig { + baseUrl: string; // e.g., window.location.origin + startSessionPath?: string; // default: '/v1/auth/session' + getUserId: () => string; + headers?: Record; +} + +export interface ExternalStartSessionResponse { + sessionId: string; + engineHost: string; + engineProtocol: 'http' | 'https'; + signallingEndpoint: string; + clientConfig?: { + heartbeatIntervalSeconds?: number; + maxWsReconnectionAttempts?: number; + iceServers: RTCIceServer[]; + }; + userId?: string; +} + +export class ExternalSessionClient { + private config: ExternalSessionClientConfig; + + constructor(config: ExternalSessionClientConfig) { + this.config = config; + } + + public async startSession( + _sessionOptions?: StartSessionOptions, + ): Promise { + const path = this.config.startSessionPath ?? '/v1/auth/session'; + const userId = this.config.getUserId(); + const res = await fetch(this.config.baseUrl + path, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...this.config.headers, + }, + body: JSON.stringify({ userId }), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`External session start failed: ${res.status} ${text}`); + } + const data = (await res.json()) as ExternalStartSessionResponse; + return data; + } +} diff --git a/src/modules/SignallingClient.ts b/src/modules/SignallingClient.ts index a663308..cf7786e 100644 --- a/src/modules/SignallingClient.ts +++ b/src/modules/SignallingClient.ts @@ -48,18 +48,26 @@ export class SignallingClient { this.maxWsReconnectionAttempts = maxWsReconnectionAttempts || DEFAULT_WS_RECONNECTION_ATTEMPTS; - if (!url.baseUrl) { - throw new Error('Signalling Client: baseUrl is required'); - } - const httpProtocol = url.protocol || 'https'; - const initUrl = `${httpProtocol}://${url.baseUrl}`; - this.url = new URL(initUrl); - this.url.protocol = url.protocol === 'http' ? 'ws:' : 'wss:'; - if (url.port) { - this.url.port = url.port; + if (url.absoluteWsUrl) { + this.url = new URL(url.absoluteWsUrl); + // ensure session_id param exists + if (!this.url.searchParams.get('session_id')) { + this.url.searchParams.append('session_id', sessionId); + } + } else { + if (!url.baseUrl) { + throw new Error('Signalling Client: baseUrl is required'); + } + const httpProtocol = url.protocol || 'https'; + const initUrl = `${httpProtocol}://${url.baseUrl}`; + this.url = new URL(initUrl); + this.url.protocol = url.protocol === 'http' ? 'ws:' : 'wss:'; + if (url.port) { + this.url.port = url.port; + } + this.url.pathname = url.signallingPath ?? '/ws'; + this.url.searchParams.append('session_id', sessionId); } - this.url.pathname = url.signallingPath ?? '/ws'; - this.url.searchParams.append('session_id', sessionId); } public stop() { @@ -121,6 +129,34 @@ export class SignallingClient { this.sendSignalMessage(chatMessage); } + /** + * Send a custom signaling message. Useful for proxy-side extensions. + */ + public sendCustom(actionType: string, payload: any) { + const message: SignalMessage = { + actionType, + sessionId: this.sessionId, + payload, + }; + this.sendSignalMessage(message); + } + + /** + * Send binary data directly over the WebSocket if open. + */ + public sendBinary(data: ArrayBuffer | Uint8Array) { + try { + if (this.socket?.readyState === WebSocket.OPEN) { + this.socket.send(data); + } + } catch (error) { + console.error( + 'SignallingClient - sendBinary: error sending binary', + error, + ); + } + } + private closeSocket() { if (this.socket) { this.socket.close(); diff --git a/src/types/AnamPublicClientOptions.ts b/src/types/AnamPublicClientOptions.ts index 1368ef2..54b728a 100644 --- a/src/types/AnamPublicClientOptions.ts +++ b/src/types/AnamPublicClientOptions.ts @@ -5,6 +5,16 @@ export interface AnamPublicClientOptions { voiceDetection?: VoiceDetectionOptions; audioDeviceId?: string; disableInputAudio?: boolean; + transport?: { + mode?: 'direct' | 'proxy'; + proxy?: { + baseUrl: string; // e.g., window.location.origin + startSessionPath?: string; // default '/v1/auth/session' + agentWsPathTemplate?: string; // e.g., '/v1/agents/{userId}/ws' + getUserId: () => string; + headers?: Record; + }; + }; metrics?: { showPeerConnectionStatsReport?: boolean; peerConnectionStatsReportOutputFormat?: 'console' | 'json'; diff --git a/src/types/shims-buffer.d.ts b/src/types/shims-buffer.d.ts new file mode 100644 index 0000000..e7a7942 --- /dev/null +++ b/src/types/shims-buffer.d.ts @@ -0,0 +1,4 @@ +declare module 'buffer' { + // Minimal shim to satisfy TS in browser environments + export const Buffer: any; +} diff --git a/src/types/signalling/SignalMessage.ts b/src/types/signalling/SignalMessage.ts index 0fa26b0..c8dc2cc 100644 --- a/src/types/signalling/SignalMessage.ts +++ b/src/types/signalling/SignalMessage.ts @@ -11,7 +11,8 @@ export enum SignalMessageAction { } export interface SignalMessage { - actionType: SignalMessageAction; + // Allow custom action types beyond the enum + actionType: string; sessionId: string; payload: object | string; } diff --git a/src/types/signalling/SignallingClientOptions.ts b/src/types/signalling/SignallingClientOptions.ts index a49608e..effb043 100644 --- a/src/types/signalling/SignallingClientOptions.ts +++ b/src/types/signalling/SignallingClientOptions.ts @@ -1,10 +1,34 @@ -export interface SignallingURLOptions { - baseUrl: string; +interface SignallingURLOptionsBase { protocol?: string; port?: string; signallingPath?: string; } +/** + * Use baseUrl to construct the WebSocket URL from components. + */ +interface SignallingURLOptionsWithBaseUrl extends SignallingURLOptionsBase { + baseUrl: string; + absoluteWsUrl?: never; +} + +/** + * Use absoluteWsUrl to provide a complete WebSocket URL directly. + * Example: wss://example.com/v1/agents/123/ws?engineHost=...&engineProtocol=...&signallingEndpoint=...&session_id=... + */ +interface SignallingURLOptionsWithAbsoluteUrl extends SignallingURLOptionsBase { + baseUrl?: never; + absoluteWsUrl: string; +} + +/** + * URL configuration for the signalling client. + * Either provide `baseUrl` to construct the URL, or `absoluteWsUrl` for a complete URL. + */ +export type SignallingURLOptions = + | SignallingURLOptionsWithBaseUrl + | SignallingURLOptionsWithAbsoluteUrl; + export interface SignallingClientOptions { heartbeatIntervalSeconds?: number; maxWsReconnectionAttempts?: number; diff --git a/tsconfig.json b/tsconfig.json index 5deefb9..8603e46 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,10 @@ "strict": true, "stripInternal": true, "allowSyntheticDefaultImports": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "skipLibCheck": true, + "lib": ["ES2020", "DOM"], + "types": [] }, "include": ["src"], "exclude": ["node_modules/**/*.ts"]