Skip to content
Open
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
85 changes: 69 additions & 16 deletions src/AnamClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
PublicEventEmitter,
StreamingClient,
} from './modules';
import { ExternalSessionClient } from './modules/ExternalSessionClient';
import {
AnamClientOptions,
AnamEvent,
Expand Down Expand Up @@ -51,6 +52,7 @@ export default class AnamClient {

private streamingClient: StreamingClient | null = null;
private apiClient: CoreApiRestClient;
private externalSessionClient: ExternalSessionClient | null = null;

private _isStreaming = false;

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
{
Expand All @@ -240,11 +297,7 @@ export default class AnamClient {
signalling: {
heartbeatIntervalSeconds,
maxWsReconnectionAttempts,
url: {
baseUrl: engineHost,
protocol: engineProtocol,
signallingPath: signallingEndpoint,
},
url: signallingUrlConfig,
},
iceServers,
inputAudio: {
Expand Down
50 changes: 50 additions & 0 deletions src/modules/ExternalSessionClient.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
}

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<ExternalStartSessionResponse> {
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;
}
}
79 changes: 47 additions & 32 deletions src/modules/SignallingClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
}
}
}

Expand Down Expand Up @@ -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();
Expand Down
10 changes: 10 additions & 0 deletions src/types/AnamPublicClientOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
};
};
metrics?: {
showPeerConnectionStatsReport?: boolean;
peerConnectionStatsReportOutputFormat?: 'console' | 'json';
Expand Down
4 changes: 4 additions & 0 deletions src/types/shims-buffer.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
declare module 'buffer' {
// Minimal shim to satisfy TS in browser environments
export const Buffer: any;
}
3 changes: 2 additions & 1 deletion src/types/signalling/SignalMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
28 changes: 26 additions & 2 deletions src/types/signalling/SignallingClientOptions.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
5 changes: 4 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down