Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/AnamClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
setMetricsContext,
} from './lib/ClientMetrics';
import { generateCorrelationId } from './lib/correlationId';
import { validateApiGatewayConfig } from './lib/validateApiGatewayConfig';
import {
CoreApiRestClient,
InternalEventEmitter,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 ??
Expand Down
37 changes: 37 additions & 0 deletions src/lib/validateApiGatewayConfig.ts
Original file line number Diff line number Diff line change
@@ -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;
}
63 changes: 47 additions & 16 deletions src/modules/CoreApiRestClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -17,19 +18,43 @@ 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');
}
this.sessionToken = sessionToken || null;
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<string, string>,
): { url: string; headers: Record<string, string> } {
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(
Expand All @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
49 changes: 36 additions & 13 deletions src/modules/EngineApiRestClient.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<string, string> = {
'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}`,
Expand Down
52 changes: 44 additions & 8 deletions src/modules/SignallingClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
SignalMessageAction,
SignallingClientOptions,
ConnectionClosedCode,
ApiGatewayConfig,
} from '../types';
import { TalkMessageStreamPayload } from '../types/signalling/TalkMessageStreamPayload';

Expand All @@ -24,15 +25,18 @@ export class SignallingClient {
private wsConnectionAttempts = 0;
private socket: WebSocket | null = null;
private heartBeatIntervalRef: ReturnType<typeof setInterval> | 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');
Expand All @@ -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() {
Expand Down
5 changes: 5 additions & 0 deletions src/modules/StreamingClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
StreamingClientOptions,
WebRtcTextMessageEvent,
ConnectionClosedCode,
ApiGatewayConfig,
} from '../types';
import { TalkMessageStream } from '../types/TalkMessageStream';
import { TalkStreamInterruptedSignalMessage } from '../types/signalling/TalkStreamInterruptedSignalMessage';
Expand All @@ -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[] = [];
Expand Down Expand Up @@ -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;
Expand All @@ -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 =
Expand Down
5 changes: 3 additions & 2 deletions src/types/AnamPublicClientOptions.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Loading