diff --git a/src/AnamClient.ts b/src/AnamClient.ts index bf87271..77d6e76 100644 --- a/src/AnamClient.ts +++ b/src/AnamClient.ts @@ -9,6 +9,7 @@ import { setMetricsContext, } from './lib/ClientMetrics'; import { generateCorrelationId } from './lib/correlationId'; +import { validateApiGatewayConfig } from './lib/validateApiGatewayConfig'; import { CoreApiRestClient, InternalEventEmitter, @@ -117,6 +118,12 @@ export default class AnamClient { return 'Only one of sessionToken or apiKey should be used'; } + // Validate gateway configuration + const apiGatewayError = validateApiGatewayConfig(options?.api?.apiGateway); + if (apiGatewayError) { + return apiGatewayError; + } + // Validate persona configuration based on session token if (sessionToken) { const decodedToken = this.decodeJwt(sessionToken); @@ -226,6 +233,7 @@ export default class AnamClient { audioDeviceId: this.clientOptions?.audioDeviceId, disableInputAudio: this.clientOptions?.disableInputAudio, }, + apiGateway: this.clientOptions?.api?.apiGateway, metrics: { showPeerConnectionStatsReport: this.clientOptions?.metrics?.showPeerConnectionStatsReport ?? diff --git a/src/lib/validateApiGatewayConfig.ts b/src/lib/validateApiGatewayConfig.ts new file mode 100644 index 0000000..878d948 --- /dev/null +++ b/src/lib/validateApiGatewayConfig.ts @@ -0,0 +1,37 @@ +import { ApiGatewayConfig } from '../types/ApiGatewayConfig'; + +/** + * Validates API Gateway configuration + * @param apiGatewayConfig - The API Gateway configuration to validate + * @returns Error message if invalid, undefined if valid + */ +export function validateApiGatewayConfig( + apiGatewayConfig: ApiGatewayConfig | undefined, +): string | undefined { + if (!apiGatewayConfig || !apiGatewayConfig.enabled) { + return undefined; + } + + if (!apiGatewayConfig.baseUrl) { + return 'API Gateway baseUrl is required when enabled'; + } + + // Validate baseUrl format + try { + const url = new URL(apiGatewayConfig.baseUrl); + if (!['http:', 'https:', 'ws:', 'wss:'].includes(url.protocol)) { + return `Invalid API Gateway baseUrl protocol: ${url.protocol}. Must be http:, https:, ws:, or wss:`; + } + } catch (error) { + return `Invalid API Gateway baseUrl: ${apiGatewayConfig.baseUrl}`; + } + + // Validate wsPath if provided + if (apiGatewayConfig.wsPath) { + if (!apiGatewayConfig.wsPath.startsWith('/')) { + return 'API Gateway wsPath must start with /'; + } + } + + return undefined; +} diff --git a/src/modules/CoreApiRestClient.ts b/src/modules/CoreApiRestClient.ts index bcc9a4c..fa8e303 100644 --- a/src/modules/CoreApiRestClient.ts +++ b/src/modules/CoreApiRestClient.ts @@ -5,9 +5,10 @@ import { DEFAULT_API_VERSION, } from '../lib/constants'; import { - CoreApiRestClientOptions, + ApiOptions, PersonaConfig, StartSessionResponse, + ApiGatewayConfig, } from '../types'; import { StartSessionOptions } from '../types/coreApi/StartSessionOptions'; import { isCustomPersonaConfig } from '../types/PersonaConfig'; @@ -17,12 +18,9 @@ export class CoreApiRestClient { private apiVersion: string; private apiKey: string | null; private sessionToken: string | null; + private apiGatewayConfig: ApiGatewayConfig | undefined; - constructor( - sessionToken?: string, - apiKey?: string, - options?: CoreApiRestClientOptions, - ) { + constructor(sessionToken?: string, apiKey?: string, options?: ApiOptions) { if (!sessionToken && !apiKey) { throw new Error('Either sessionToken or apiKey must be provided'); } @@ -30,6 +28,33 @@ export class CoreApiRestClient { this.apiKey = apiKey || null; this.baseUrl = options?.baseUrl || DEFAULT_API_BASE_URL; this.apiVersion = options?.apiVersion || DEFAULT_API_VERSION; + this.apiGatewayConfig = options?.apiGateway || undefined; + } + + /** + * Builds URL and headers for a request, applying API Gateway configuration if enabled + */ + private buildGatewayUrlAndHeaders( + targetPath: string, + baseHeaders: Record, + ): { url: string; headers: Record } { + if (this.apiGatewayConfig?.enabled && this.apiGatewayConfig?.baseUrl) { + // Use gateway base URL with same endpoint path + const url = `${this.apiGatewayConfig.baseUrl}${targetPath}`; + // Add complete target URL header for gateway routing + const targetUrl = new URL(`${this.baseUrl}${targetPath}`); + const headers = { + ...baseHeaders, + 'X-Anam-Target-Url': targetUrl.href, + }; + return { url, headers }; + } else { + // Direct call to Anam API + return { + url: `${this.baseUrl}${targetPath}`, + headers: baseHeaders, + }; + } } public async startSession( @@ -55,12 +80,15 @@ export class CoreApiRestClient { } try { - const response = await fetch(`${this.getApiUrl()}/engine/session`, { + const targetPath = `${this.apiVersion}/engine/session`; + const { url, headers } = this.buildGatewayUrlAndHeaders(targetPath, { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.sessionToken}`, + }); + + const response = await fetch(url, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${this.sessionToken}`, - }, + headers, body: JSON.stringify({ personaConfig, sessionOptions, @@ -179,12 +207,15 @@ export class CoreApiRestClient { body = { ...body, personaConfig }; } try { - const response = await fetch(`${this.getApiUrl()}/auth/session-token`, { + const targetPath = `${this.apiVersion}/auth/session-token`; + const { url, headers } = this.buildGatewayUrlAndHeaders(targetPath, { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.apiKey}`, + }); + + const response = await fetch(url, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${this.apiKey}`, - }, + headers, body: JSON.stringify(body), }); const data = await response.json(); diff --git a/src/modules/EngineApiRestClient.ts b/src/modules/EngineApiRestClient.ts index 7a755b9..4f124bd 100644 --- a/src/modules/EngineApiRestClient.ts +++ b/src/modules/EngineApiRestClient.ts @@ -1,26 +1,49 @@ +import { ApiGatewayConfig } from '../types/ApiGatewayConfig'; + export class EngineApiRestClient { private baseUrl: string; private sessionId: string; + private apiGatewayConfig: ApiGatewayConfig | undefined; - constructor(baseUrl: string, sessionId: string) { + constructor( + baseUrl: string, + sessionId: string, + apiGatewayConfig?: ApiGatewayConfig, + ) { this.baseUrl = baseUrl; this.sessionId = sessionId; + this.apiGatewayConfig = apiGatewayConfig; } public async sendTalkCommand(content: string): Promise { try { - const response = await fetch( - `${this.baseUrl}/talk?session_id=${this.sessionId}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - content, - }), - }, - ); + // Determine the URL and headers based on API Gateway configuration + let url: string; + let headers: Record = { + 'Content-Type': 'application/json', + }; + + const targetPath = `/talk`; + const queryString = `?session_id=${this.sessionId}`; + + if (this.apiGatewayConfig?.enabled && this.apiGatewayConfig?.baseUrl) { + // Use gateway base URL with same endpoint path + url = `${this.apiGatewayConfig.baseUrl}${targetPath}${queryString}`; + // Add complete target URL header for gateway routing + const targetUrl = new URL(`${this.baseUrl}${targetPath}${queryString}`); + headers['X-Anam-Target-Url'] = targetUrl.href; + } else { + // Direct call to Anam engine + url = `${this.baseUrl}${targetPath}${queryString}`; + } + + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ + content, + }), + }); if (!response.ok) { throw new Error( `Failed to send talk command: ${response.status} ${response.statusText}`, diff --git a/src/modules/SignallingClient.ts b/src/modules/SignallingClient.ts index a663308..b0b5b76 100644 --- a/src/modules/SignallingClient.ts +++ b/src/modules/SignallingClient.ts @@ -6,6 +6,7 @@ import { SignalMessageAction, SignallingClientOptions, ConnectionClosedCode, + ApiGatewayConfig, } from '../types'; import { TalkMessageStreamPayload } from '../types/signalling/TalkMessageStreamPayload'; @@ -24,15 +25,18 @@ export class SignallingClient { private wsConnectionAttempts = 0; private socket: WebSocket | null = null; private heartBeatIntervalRef: ReturnType | null = null; + private apiGatewayConfig: ApiGatewayConfig | undefined; constructor( sessionId: string, options: SignallingClientOptions, publicEventEmitter: PublicEventEmitter, internalEventEmitter: InternalEventEmitter, + apiGatewayConfig?: ApiGatewayConfig, ) { this.publicEventEmitter = publicEventEmitter; this.internalEventEmitter = internalEventEmitter; + this.apiGatewayConfig = apiGatewayConfig; if (!sessionId) { throw new Error('Signalling Client: sessionId is required'); @@ -51,15 +55,47 @@ export class SignallingClient { 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; + + // 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; + } + 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) + 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() { diff --git a/src/modules/StreamingClient.ts b/src/modules/StreamingClient.ts index f153119..2dbf064 100644 --- a/src/modules/StreamingClient.ts +++ b/src/modules/StreamingClient.ts @@ -14,6 +14,7 @@ import { StreamingClientOptions, WebRtcTextMessageEvent, ConnectionClosedCode, + ApiGatewayConfig, } from '../types'; import { TalkMessageStream } from '../types/TalkMessageStream'; import { TalkStreamInterruptedSignalMessage } from '../types/signalling/TalkStreamInterruptedSignalMessage'; @@ -33,6 +34,7 @@ export class StreamingClient { private signallingClient: SignallingClient; private engineApiRestClient: EngineApiRestClient; private iceServers: RTCIceServer[]; + private apiGatewayConfig: ApiGatewayConfig | undefined; private peerConnection: RTCPeerConnection | null = null; private connectionReceivedAnswer = false; private remoteIceCandidateBuffer: RTCIceCandidate[] = []; @@ -61,6 +63,7 @@ export class StreamingClient { ) { this.publicEventEmitter = publicEventEmitter; this.internalEventEmitter = internalEventEmitter; + this.apiGatewayConfig = options.apiGateway; // initialize input audio state const { inputAudio } = options; this.inputAudioState = inputAudio.inputAudioState; @@ -85,11 +88,13 @@ export class StreamingClient { options.signalling, this.publicEventEmitter, this.internalEventEmitter, + this.apiGatewayConfig, ); // initialize engine API client this.engineApiRestClient = new EngineApiRestClient( options.engine.baseUrl, sessionId, + this.apiGatewayConfig, ); this.audioDeviceId = options.inputAudio.audioDeviceId; this.showPeerConnectionStatsReport = diff --git a/src/types/AnamPublicClientOptions.ts b/src/types/AnamPublicClientOptions.ts index 1368ef2..05a21d3 100644 --- a/src/types/AnamPublicClientOptions.ts +++ b/src/types/AnamPublicClientOptions.ts @@ -1,7 +1,8 @@ -import { CoreApiRestClientOptions } from '../types'; +import { ApiOptions } from '../types'; import { VoiceDetectionOptions } from './VoiceDetectionOptions'; + export interface AnamPublicClientOptions { - api?: CoreApiRestClientOptions; + api?: ApiOptions; voiceDetection?: VoiceDetectionOptions; audioDeviceId?: string; disableInputAudio?: boolean; diff --git a/src/types/ApiGatewayConfig.ts b/src/types/ApiGatewayConfig.ts new file mode 100644 index 0000000..c578c73 --- /dev/null +++ b/src/types/ApiGatewayConfig.ts @@ -0,0 +1,46 @@ +/** + * Configuration for routing SDK requests through an API Gateway. + * + * When enabled, the SDK will route HTTP REST calls and WebSocket signalling + * through the specified API Gateway while maintaining direct WebRTC peer connections. + * + * @remarks + * - API Gateway is opt-in and disabled by default + * - The SDK passes complete target URLs to the gateway via headers/query params + * - The gateway handles routing and forwarding to Anam's infrastructure + * - WebRTC peer connections remain direct (not routed through gateway) + * + * @example + * ```typescript + * const client = createClient(sessionToken, { + * apiGateway: { + * enabled: true, + * baseUrl: 'https://my-gateway.com', // Base URL for all gateway requests + * wsPath: '/ws' // WebSocket endpoint path (default: '/ws') + * } + * }); + * ``` + */ +export interface ApiGatewayConfig { + /** + * Enable or disable API Gateway routing + */ + enabled: boolean; + + /** + * Base URL of the API Gateway server + * Used for both HTTP and WebSocket connections + * + * @example 'https://my-gateway.com' or 'http://localhost:3001' + */ + baseUrl: string; + + /** + * WebSocket endpoint path on the gateway + * Defaults to '/ws' if not specified + * + * @example '/ws' or '/api/websocket' + * @default '/ws' + */ + wsPath?: string; +} diff --git a/src/types/coreApi/ApiOptions.ts b/src/types/coreApi/ApiOptions.ts new file mode 100644 index 0000000..4177944 --- /dev/null +++ b/src/types/coreApi/ApiOptions.ts @@ -0,0 +1,7 @@ +import { ApiGatewayConfig } from '../ApiGatewayConfig'; + +export interface ApiOptions { + baseUrl?: string; + apiVersion?: string; + apiGateway?: ApiGatewayConfig; +} diff --git a/src/types/coreApi/CoreApiRestClientOptions.ts b/src/types/coreApi/CoreApiRestClientOptions.ts deleted file mode 100644 index 1436955..0000000 --- a/src/types/coreApi/CoreApiRestClientOptions.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface CoreApiRestClientOptions { - baseUrl?: string; - apiVersion?: string; -} diff --git a/src/types/coreApi/index.ts b/src/types/coreApi/index.ts index 5e374db..62c07ab 100644 --- a/src/types/coreApi/index.ts +++ b/src/types/coreApi/index.ts @@ -1,4 +1,6 @@ -export type { CoreApiRestClientOptions } from './CoreApiRestClientOptions'; +export type { ApiOptions } from './ApiOptions'; +// for backwards compatibility with deprecated name +export type { ApiOptions as CoreApiOptions } from './ApiOptions'; export type { StartSessionResponse, ClientConfigResponse, diff --git a/src/types/index.ts b/src/types/index.ts index f511bc7..e263d5d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,6 +4,7 @@ export { SignalMessageAction } from './signalling'; // need to export this expli export type * from './streaming'; export type * from './coreApi'; export type { PersonaConfig } from './PersonaConfig'; +export type { ApiGatewayConfig } from './ApiGatewayConfig'; export type { InputAudioState } from './InputAudioState'; export { AudioPermissionState } from './InputAudioState'; export type * from './messageHistory'; diff --git a/src/types/streaming/StreamingClientOptions.ts b/src/types/streaming/StreamingClientOptions.ts index e2a564d..0539413 100644 --- a/src/types/streaming/StreamingClientOptions.ts +++ b/src/types/streaming/StreamingClientOptions.ts @@ -1,12 +1,14 @@ import { SignallingClientOptions } from '../../types'; import { EngineApiRestClientOptions } from '../engineApi/EngineApiRestClientOptions'; import { InputAudioOptions } from './InputAudioOptions'; +import { ApiGatewayConfig } from '../ApiGatewayConfig'; export interface StreamingClientOptions { engine: EngineApiRestClientOptions; signalling: SignallingClientOptions; iceServers: RTCIceServer[]; inputAudio: InputAudioOptions; + apiGateway?: ApiGatewayConfig; metrics?: { showPeerConnectionStatsReport?: boolean; peerConnectionStatsReportOutputFormat?: 'console' | 'json';