diff --git a/src/AnamClient.ts b/src/AnamClient.ts index 8276006..c1a320e 100644 --- a/src/AnamClient.ts +++ b/src/AnamClient.ts @@ -19,6 +19,7 @@ import { PublicEventEmitter, StreamingClient, } from './modules'; +import { ExternalSessionClient } from './modules/ExternalSessionClient'; import { AnamClientOptions, AnamEvent, @@ -51,6 +52,7 @@ export default class AnamClient { private streamingClient: StreamingClient | null = null; private apiClient: CoreApiRestClient; + private externalSessionClient: ExternalSessionClient | null = null; private _isStreaming = false; @@ -100,6 +102,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, @@ -203,17 +213,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, @@ -231,6 +258,36 @@ export default class AnamClient { : defaultIceServers; 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, { @@ -240,11 +297,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 c150787..e8bcf90 100644 --- a/src/modules/SignallingClient.ts +++ b/src/modules/SignallingClient.ts @@ -53,40 +53,16 @@ export class SignallingClient { this.maxWsReconnectionAttempts = maxWsReconnectionAttempts || DEFAULT_WS_RECONNECTION_ATTEMPTS; - if (!url.baseUrl) { - throw new Error('Signalling Client: baseUrl is required'); - } - - // Construct WebSocket URL (with or without API Gateway) - if (this.apiGatewayConfig?.enabled && this.apiGatewayConfig?.baseUrl) { - // Use API Gateway WebSocket URL - const gatewayUrl = new URL(this.apiGatewayConfig.baseUrl); - const wsPath = this.apiGatewayConfig.wsPath ?? '/ws'; - - // Construct gateway WebSocket URL - gatewayUrl.protocol = gatewayUrl.protocol.replace('http', 'ws'); - gatewayUrl.pathname = wsPath; - this.url = gatewayUrl; - - // Construct the complete target WebSocket URL and pass it as a query parameter - const httpProtocol = url.protocol || 'https'; - const targetProtocol = httpProtocol === 'http' ? 'ws' : 'wss'; - const httpUrl = `${httpProtocol}://${url.baseUrl}`; - const targetWsPath = url.signallingPath ?? '/ws'; - - // Build complete target URL - const targetUrl = new URL(httpUrl); - targetUrl.protocol = targetProtocol === 'ws' ? 'ws:' : 'wss:'; - if (url.port) { - targetUrl.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); } - targetUrl.pathname = targetWsPath; - targetUrl.searchParams.append('session_id', sessionId); - - // Pass complete target URL as query parameter - this.url.searchParams.append('target_url', targetUrl.href); } else { - // Direct connection to Anam (original behavior) + 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); @@ -96,6 +72,17 @@ export class SignallingClient { } this.url.pathname = url.signallingPath ?? '/ws'; this.url.searchParams.append('session_id', sessionId); + + // If API Gateway is enabled, wrap the URL for gateway routing + if (this.apiGatewayConfig?.enabled && this.apiGatewayConfig?.baseUrl) { + const targetUrl = this.url.href; + const gatewayUrl = new URL(this.apiGatewayConfig.baseUrl); + const wsPath = this.apiGatewayConfig.wsPath ?? '/ws'; + gatewayUrl.protocol = gatewayUrl.protocol.replace('http', 'ws'); + gatewayUrl.pathname = wsPath; + gatewayUrl.searchParams.append('target_url', targetUrl); + this.url = gatewayUrl; + } } } @@ -176,6 +163,34 @@ export class SignallingClient { this.sendSignalMessage(message); } + /** + * 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 3e04d56..6b2b3f5 100644 --- a/src/types/AnamPublicClientOptions.ts +++ b/src/types/AnamPublicClientOptions.ts @@ -6,6 +6,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 1201de0..be7b3dd 100644 --- a/src/types/signalling/SignalMessage.ts +++ b/src/types/signalling/SignalMessage.ts @@ -13,7 +13,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"]