ulai-js-sdk 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/signaling.js CHANGED
@@ -8,45 +8,160 @@ const eventemitter3_1 = __importDefault(require("eventemitter3"));
8
8
  class SignalingClient extends eventemitter3_1.default {
9
9
  ws = null;
10
10
  url;
11
+ outbox = [];
12
+ isClosedManually = false;
13
+ retryAttempts = 0;
14
+ maxRetries = 5;
15
+ reconnectTimer = null;
16
+ heartbeatTimer = null;
11
17
  constructor(nodeAddress, ticket) {
12
18
  super();
13
- const scheme = nodeAddress.startsWith('localhost') || nodeAddress.startsWith('127.0.0.1') ? 'ws' : 'wss';
14
- this.url = `${scheme}://${nodeAddress}/webrtc/room?ticket=${ticket}`;
19
+ this.url = this.buildUrl(nodeAddress, ticket);
20
+ }
21
+ buildUrl(nodeAddress, ticket) {
22
+ const cleanAddress = nodeAddress
23
+ .replace(/^https?:\/\//, '')
24
+ .replace(/^wss?:\/\//, '')
25
+ .replace(/\/$/, '');
26
+ const isLocal = cleanAddress.startsWith('localhost') ||
27
+ cleanAddress.startsWith('127.0.0.1');
28
+ // Default to wss unless connecting to localhost/127.0.0.1 over plain http
29
+ let scheme = 'wss';
30
+ if (isLocal) {
31
+ scheme = 'ws';
32
+ }
33
+ else if (typeof window !== 'undefined' && window.location.protocol === 'http:') {
34
+ scheme = 'ws';
35
+ }
36
+ return `${scheme}://${cleanAddress}/webrtc/room?ticket=${encodeURIComponent(ticket)}`;
15
37
  }
16
38
  connect() {
17
- this.ws = new WebSocket(this.url);
18
- this.ws.onopen = () => {
19
- this.emit('connected');
20
- };
21
- this.ws.onmessage = (event) => {
39
+ this.isClosedManually = false;
40
+ this.cleanupSocket();
41
+ try {
42
+ this.ws = new WebSocket(this.url);
43
+ this.ws.onopen = () => {
44
+ const wasReconnecting = this.retryAttempts > 0;
45
+ this.retryAttempts = 0;
46
+ this.flushOutbox();
47
+ this.startHeartbeat();
48
+ if (wasReconnecting) {
49
+ this.emit('reconnected');
50
+ }
51
+ else {
52
+ this.emit('connected');
53
+ }
54
+ };
55
+ this.ws.onmessage = (event) => {
56
+ try {
57
+ const msg = JSON.parse(event.data);
58
+ this.emit('message', msg);
59
+ }
60
+ catch (err) {
61
+ console.error('[Signaling] Failed to parse message', err);
62
+ }
63
+ };
64
+ this.ws.onclose = (event) => {
65
+ this.stopHeartbeat();
66
+ if (this.isClosedManually) {
67
+ this.emit('disconnected');
68
+ return;
69
+ }
70
+ if (this.retryAttempts < this.maxRetries) {
71
+ this.retryAttempts++;
72
+ const delay = Math.min(1000 * Math.pow(1.5, this.retryAttempts), 10000);
73
+ console.warn(`[Signaling] Connection dropped. Retrying in ${Math.round(delay)}ms (attempt ${this.retryAttempts}/${this.maxRetries})...`);
74
+ this.emit('reconnecting', { attempt: this.retryAttempts });
75
+ this.reconnectTimer = setTimeout(() => {
76
+ if (!this.isClosedManually) {
77
+ this.connect();
78
+ }
79
+ }, delay);
80
+ }
81
+ else {
82
+ console.error('[Signaling] Max reconnect attempts exceeded');
83
+ this.emit('disconnected', event);
84
+ }
85
+ };
86
+ this.ws.onerror = (error) => {
87
+ this.emit('error', error);
88
+ };
89
+ }
90
+ catch (err) {
91
+ this.emit('error', err);
92
+ }
93
+ }
94
+ send(message) {
95
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
22
96
  try {
23
- const msg = JSON.parse(event.data);
24
- this.emit('message', msg);
97
+ this.ws.send(JSON.stringify(message));
25
98
  }
26
99
  catch (err) {
27
- console.error('Failed to parse signaling message', err);
100
+ console.error('[Signaling] Failed to send message, buffering in outbox', err);
101
+ this.outbox.push(message);
28
102
  }
29
- };
30
- this.ws.onclose = () => {
31
- this.emit('disconnected');
32
- };
33
- this.ws.onerror = (error) => {
34
- this.emit('error', error);
35
- };
36
- }
37
- send(message) {
38
- if (this.ws && this.ws.readyState === WebSocket.OPEN) {
39
- this.ws.send(JSON.stringify(message));
40
103
  }
41
104
  else {
42
- console.warn('Cannot send message, WebSocket is not open', message);
105
+ // Buffer in outbox until WebSocket connects
106
+ this.outbox.push(message);
43
107
  }
44
108
  }
45
- close() {
109
+ flushOutbox() {
110
+ while (this.outbox.length > 0 && this.ws && this.ws.readyState === WebSocket.OPEN) {
111
+ const msg = this.outbox.shift();
112
+ if (msg) {
113
+ try {
114
+ this.ws.send(JSON.stringify(msg));
115
+ }
116
+ catch (err) {
117
+ console.error('[Signaling] Error flushing outbox message', err);
118
+ this.outbox.unshift(msg);
119
+ break;
120
+ }
121
+ }
122
+ }
123
+ }
124
+ startHeartbeat() {
125
+ this.stopHeartbeat();
126
+ this.heartbeatTimer = setInterval(() => {
127
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
128
+ // Send lightweight keepalive if socket is open
129
+ try {
130
+ this.ws.send(JSON.stringify({ type: 'ping' }));
131
+ }
132
+ catch (_) { }
133
+ }
134
+ }, 20000);
135
+ }
136
+ stopHeartbeat() {
137
+ if (this.heartbeatTimer) {
138
+ clearInterval(this.heartbeatTimer);
139
+ this.heartbeatTimer = null;
140
+ }
141
+ }
142
+ cleanupSocket() {
143
+ this.stopHeartbeat();
144
+ if (this.reconnectTimer) {
145
+ clearTimeout(this.reconnectTimer);
146
+ this.reconnectTimer = null;
147
+ }
46
148
  if (this.ws) {
47
- this.ws.close();
149
+ this.ws.onopen = null;
150
+ this.ws.onmessage = null;
151
+ this.ws.onclose = null;
152
+ this.ws.onerror = null;
153
+ try {
154
+ this.ws.close();
155
+ }
156
+ catch (_) { }
48
157
  this.ws = null;
49
158
  }
50
159
  }
160
+ close() {
161
+ this.isClosedManually = true;
162
+ this.outbox = [];
163
+ this.cleanupSocket();
164
+ this.emit('disconnected');
165
+ }
51
166
  }
52
167
  exports.SignalingClient = SignalingClient;
package/dist/types.d.ts CHANGED
@@ -9,13 +9,15 @@ export interface JoinRoomOptions {
9
9
  eventsToken?: string;
10
10
  ticket?: string;
11
11
  nodeAddress?: string;
12
+ iceServers?: RTCIceServer[];
13
+ autoSubscribeAudio?: boolean;
12
14
  }
13
- export type RoomEvent = 'connected' | 'disconnected' | 'reconnecting' | 'trackSubscribed' | 'trackUnsubscribed' | 'participantJoined' | 'participantLeft' | 'roster' | 'transcriptDelta' | 'sessionTerminated' | 'error';
15
+ export type RoomEvent = 'connected' | 'disconnected' | 'reconnecting' | 'reconnected' | 'trackSubscribed' | 'trackUnsubscribed' | 'participantJoined' | 'participantLeft' | 'roster' | 'transcriptDelta' | 'sessionTerminated' | 'muteChanged' | 'error';
14
16
  export interface SignalMessage {
15
- type: 'offer' | 'answer' | 'ice-candidate' | 'candidate' | 'join' | 'leave';
17
+ type: 'offer' | 'answer' | 'ice-candidate' | 'candidate' | 'restart-offer' | 'join' | 'leave' | 'ping';
16
18
  payload?: any;
17
- sdp?: any;
18
- candidate?: any;
19
+ sdp?: string;
20
+ candidate?: string;
19
21
  }
20
22
  export interface TranscriptDelta {
21
23
  segment_id: string;
package/package.json CHANGED
@@ -1,20 +1,30 @@
1
1
  {
2
2
  "name": "ulai-js-sdk",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Ulai Cloud JS/TS Client SDK for WebRTC Sessions",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "scripts": {
8
8
  "build": "tsc",
9
- "dev": "tsc -w"
9
+ "dev": "tsc -w",
10
+ "prepublishOnly": "npm run build"
10
11
  },
12
+ "files": [
13
+ "dist",
14
+ "README.md"
15
+ ],
11
16
  "keywords": [
12
17
  "ulai",
13
18
  "webrtc",
14
- "agent",
19
+ "ai-agent",
20
+ "voice-agent",
21
+ "speech",
22
+ "sfu",
23
+ "realtime",
24
+ "audio",
15
25
  "sdk"
16
26
  ],
17
- "author": "",
27
+ "author": "Ulai Team",
18
28
  "license": "MIT",
19
29
  "devDependencies": {
20
30
  "typescript": "^5.0.0"
package/src/client.ts DELETED
@@ -1,59 +0,0 @@
1
- import { ConnectOptions, JoinRoomOptions } from './types';
2
- import { Room } from './room';
3
-
4
- export class UlaiClient {
5
- private url: string;
6
- private apiKey?: string;
7
-
8
- constructor(options: ConnectOptions) {
9
- this.url = options.url.replace(/\/$/, '');
10
- this.apiKey = options.apiKey;
11
- }
12
-
13
- /**
14
- * Joins a room by making an API request to the control plane,
15
- * then connects WebRTC and Event subscriptions.
16
- */
17
- public async joinRoom(options: JoinRoomOptions): Promise<Room> {
18
- const headers: Record<string, string> = {
19
- 'Content-Type': 'application/json',
20
- };
21
-
22
- if (this.apiKey) {
23
- headers['Authorization'] = `Bearer ${this.apiKey}`;
24
- }
25
-
26
- const body = {
27
- kind: 'STANDARD',
28
- can_publish: true,
29
- can_subscribe: true,
30
- participant_id: options.participantId,
31
- };
32
-
33
- const res = await fetch(`${this.url}/api/v1/sessions/${options.roomId}/join`, {
34
- method: 'POST',
35
- headers,
36
- body: JSON.stringify(body),
37
- });
38
-
39
- if (!res.ok) {
40
- const errText = await res.text();
41
- throw new Error(`Failed to join room: ${res.status} ${errText}`);
42
- }
43
-
44
- const joinData = await res.json();
45
-
46
- // joinData typically contains: session_id, events_token, connection_id, ticket, node: { address }
47
- const room = new Room(this.url);
48
-
49
- await room.connect({
50
- roomId: joinData.session_id || options.roomId,
51
- participantId: joinData.connection_id || options.participantId,
52
- eventsToken: joinData.events_token,
53
- ticket: joinData.ticket,
54
- nodeAddress: joinData.node?.address
55
- });
56
-
57
- return room;
58
- }
59
- }
package/src/events.ts DELETED
@@ -1,64 +0,0 @@
1
- import EventEmitter from 'eventemitter3';
2
-
3
- export class EventsClient extends EventEmitter {
4
- private ws: WebSocket | null = null;
5
- private url: string;
6
-
7
- constructor(cpBase: string) {
8
- super();
9
- const scheme = cpBase.startsWith('https') ? 'wss' : 'ws';
10
- const host = cpBase.replace(/^https?:\/\//, '').replace(/\/$/, '');
11
- this.url = `${scheme}://${host}/api/v1/events`;
12
- }
13
-
14
- public connect(sessionId: string, eventsToken: string) {
15
- this.ws = new WebSocket(this.url);
16
-
17
- this.ws.onopen = () => {
18
- this.ws?.send(JSON.stringify({
19
- action: 'subscribe',
20
- scope: 'session',
21
- session_id: sessionId,
22
- token: eventsToken
23
- }));
24
- };
25
-
26
- this.ws.onmessage = (event) => {
27
- try {
28
- const msg = JSON.parse(event.data);
29
- if (msg.type === 'subscribed') {
30
- this.emit('connected', msg);
31
- } else if (msg.type === 'transcript_delta') {
32
- this.emit('transcript_delta', msg);
33
- } else if (msg.type === 'roster') {
34
- this.emit('roster', msg);
35
- } else if (msg.type === 'session_terminated') {
36
- this.emit('session_terminated', msg);
37
- } else if (msg.type === 'participant_joined') {
38
- this.emit('participant_joined', msg);
39
- } else if (msg.type === 'participant_left') {
40
- this.emit('participant_left', msg);
41
- } else if (msg.type === 'error') {
42
- this.emit('error', msg);
43
- }
44
- } catch (err) {
45
- console.error('Failed to parse events message', err);
46
- }
47
- };
48
-
49
- this.ws.onclose = () => {
50
- this.emit('disconnected');
51
- };
52
-
53
- this.ws.onerror = (error) => {
54
- this.emit('error', error);
55
- };
56
- }
57
-
58
- public close() {
59
- if (this.ws) {
60
- this.ws.close();
61
- this.ws = null;
62
- }
63
- }
64
- }
package/src/index.ts DELETED
@@ -1,5 +0,0 @@
1
- export * from './client';
2
- export * from './room';
3
- export * from './signaling';
4
- export * from './events';
5
- export * from './types';
package/src/room.ts DELETED
@@ -1,163 +0,0 @@
1
- import EventEmitter from 'eventemitter3';
2
- import { SignalingClient } from './signaling';
3
- import { EventsClient } from './events';
4
- import { RoomEvent, JoinRoomOptions } from './types';
5
-
6
- export class Room extends EventEmitter<RoomEvent> {
7
- private cpBase: string;
8
- private signaling: SignalingClient | null = null;
9
- private eventsClient: EventsClient | null = null;
10
-
11
- private peerConnection: RTCPeerConnection | null = null;
12
- private localStream: MediaStream | null = null;
13
- private remoteStreams: Map<string, MediaStream> = new Map();
14
-
15
- public roomId: string | null = null;
16
- public participantId: string | null = null;
17
-
18
- constructor(cpBase: string) {
19
- super();
20
- this.cpBase = cpBase;
21
- }
22
-
23
- public async connect(options: JoinRoomOptions) {
24
- this.roomId = options.roomId;
25
- this.participantId = options.participantId || '';
26
-
27
- // 1. Setup Control Plane Events
28
- if (options.eventsToken) {
29
- this.eventsClient = new EventsClient(this.cpBase);
30
- this.setupEventsClient();
31
- this.eventsClient.connect(this.roomId, options.eventsToken);
32
- }
33
-
34
- // 2. Setup WebRTC Signaling if nodeAddress and ticket are provided
35
- if (options.nodeAddress && options.ticket) {
36
- this.signaling = new SignalingClient(options.nodeAddress, options.ticket);
37
- this.setupSignaling();
38
-
39
- this.peerConnection = new RTCPeerConnection({
40
- iceServers: [
41
- { urls: 'stun:stun.l.google.com:19302' },
42
- { urls: ['turn:turn.ulai.in:3478', 'turns:turn.ulai.in:5349'], username: 'ulai', credential: 'xulaizrt' }
43
- ]
44
- });
45
-
46
- this.peerConnection.onicecandidate = (event) => {
47
- if (event.candidate && this.signaling) {
48
- this.signaling.send({ type: 'candidate', candidate: JSON.stringify(event.candidate.toJSON()) });
49
- }
50
- };
51
-
52
- this.peerConnection.ontrack = (event) => {
53
- const stream = event.streams[0];
54
- if (stream) {
55
- this.remoteStreams.set(stream.id, stream);
56
- this.emit('trackSubscribed', event.track, stream);
57
- }
58
- };
59
-
60
- this.peerConnection.onconnectionstatechange = () => {
61
- switch (this.peerConnection?.connectionState) {
62
- case 'connected':
63
- this.emit('connected');
64
- break;
65
- case 'disconnected':
66
- case 'failed':
67
- this.emit('disconnected');
68
- break;
69
- }
70
- };
71
-
72
- this.signaling.connect();
73
- }
74
- }
75
-
76
- private setupEventsClient() {
77
- if (!this.eventsClient) return;
78
-
79
- this.eventsClient.on('roster', (msg) => this.emit('roster', msg));
80
- this.eventsClient.on('participant_joined', (msg) => this.emit('participantJoined', msg));
81
- this.eventsClient.on('participant_left', (msg) => this.emit('participantLeft', msg));
82
- this.eventsClient.on('transcript_delta', (msg) => this.emit('transcriptDelta', msg));
83
- this.eventsClient.on('session_terminated', (msg) => {
84
- this.emit('sessionTerminated', msg);
85
- this.disconnect();
86
- });
87
- this.eventsClient.on('error', (err) => this.emit('error', err));
88
- }
89
-
90
- private setupSignaling() {
91
- if (!this.signaling) return;
92
-
93
- this.signaling.on('connected', async () => {
94
- // Create initial offer when signaling connects
95
- if (this.peerConnection) {
96
- const offer = await this.peerConnection.createOffer();
97
- await this.peerConnection.setLocalDescription(offer);
98
- this.signaling!.send({ type: 'offer', sdp: this.peerConnection.localDescription?.sdp });
99
- }
100
- });
101
-
102
- this.signaling.on('message', async (msg) => {
103
- if (!this.peerConnection) return;
104
-
105
- try {
106
- if (msg.type === 'offer' || msg.type === 'restart-offer' as any) {
107
- await this.peerConnection.setRemoteDescription(new RTCSessionDescription({ type: 'offer', sdp: msg.sdp }));
108
- const answer = await this.peerConnection.createAnswer();
109
- await this.peerConnection.setLocalDescription(answer);
110
- this.signaling!.send({ type: 'answer', sdp: this.peerConnection.localDescription?.sdp });
111
- } else if (msg.type === 'answer') {
112
- await this.peerConnection.setRemoteDescription(new RTCSessionDescription({ type: 'answer', sdp: msg.sdp }));
113
- } else if (msg.type === 'candidate' && msg.candidate) {
114
- await this.peerConnection.addIceCandidate(JSON.parse(msg.candidate));
115
- }
116
- } catch (err) {
117
- console.error('Error handling signaling message', err);
118
- }
119
- });
120
-
121
- this.signaling.on('disconnected', () => {
122
- this.emit('disconnected');
123
- });
124
- }
125
-
126
- public async publishAudio() {
127
- try {
128
- this.localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
129
- if (this.peerConnection && this.localStream) {
130
- this.localStream.getTracks().forEach((track) => {
131
- this.peerConnection!.addTrack(track, this.localStream!);
132
- });
133
-
134
- // Renegotiate
135
- const offer = await this.peerConnection.createOffer();
136
- await this.peerConnection.setLocalDescription(offer);
137
- this.signaling?.send({ type: 'offer', sdp: this.peerConnection.localDescription?.sdp });
138
- }
139
- } catch (err) {
140
- console.error('Failed to publish audio:', err);
141
- this.emit('error', err);
142
- }
143
- }
144
-
145
- public disconnect() {
146
- if (this.localStream) {
147
- this.localStream.getTracks().forEach(track => track.stop());
148
- }
149
- if (this.peerConnection) {
150
- this.peerConnection.close();
151
- this.peerConnection = null;
152
- }
153
- if (this.signaling) {
154
- this.signaling.close();
155
- this.signaling = null;
156
- }
157
- if (this.eventsClient) {
158
- this.eventsClient.close();
159
- this.eventsClient = null;
160
- }
161
- this.emit('disconnected');
162
- }
163
- }
package/src/signaling.ts DELETED
@@ -1,53 +0,0 @@
1
- import EventEmitter from 'eventemitter3';
2
- import { SignalMessage } from './types';
3
-
4
- export class SignalingClient extends EventEmitter {
5
- private ws: WebSocket | null = null;
6
- private url: string;
7
-
8
- constructor(nodeAddress: string, ticket: string) {
9
- super();
10
- const scheme = nodeAddress.startsWith('localhost') || nodeAddress.startsWith('127.0.0.1') ? 'ws' : 'wss';
11
- this.url = `${scheme}://${nodeAddress}/webrtc/room?ticket=${ticket}`;
12
- }
13
-
14
- public connect() {
15
- this.ws = new WebSocket(this.url);
16
-
17
- this.ws.onopen = () => {
18
- this.emit('connected');
19
- };
20
-
21
- this.ws.onmessage = (event) => {
22
- try {
23
- const msg: SignalMessage = JSON.parse(event.data);
24
- this.emit('message', msg);
25
- } catch (err) {
26
- console.error('Failed to parse signaling message', err);
27
- }
28
- };
29
-
30
- this.ws.onclose = () => {
31
- this.emit('disconnected');
32
- };
33
-
34
- this.ws.onerror = (error) => {
35
- this.emit('error', error);
36
- };
37
- }
38
-
39
- public send(message: SignalMessage) {
40
- if (this.ws && this.ws.readyState === WebSocket.OPEN) {
41
- this.ws.send(JSON.stringify(message));
42
- } else {
43
- console.warn('Cannot send message, WebSocket is not open', message);
44
- }
45
- }
46
-
47
- public close() {
48
- if (this.ws) {
49
- this.ws.close();
50
- this.ws = null;
51
- }
52
- }
53
- }
package/src/types.ts DELETED
@@ -1,44 +0,0 @@
1
- export interface ConnectOptions {
2
- url: string;
3
- apiKey?: string;
4
- token?: string;
5
- }
6
-
7
- export interface JoinRoomOptions {
8
- roomId: string;
9
- participantId?: string;
10
- eventsToken?: string;
11
- ticket?: string;
12
- nodeAddress?: string;
13
- }
14
-
15
- export type RoomEvent =
16
- | 'connected'
17
- | 'disconnected'
18
- | 'reconnecting'
19
- | 'trackSubscribed'
20
- | 'trackUnsubscribed'
21
- | 'participantJoined'
22
- | 'participantLeft'
23
- | 'roster'
24
- | 'transcriptDelta'
25
- | 'sessionTerminated'
26
- | 'error';
27
-
28
- export interface SignalMessage {
29
- type: 'offer' | 'answer' | 'ice-candidate' | 'candidate' | 'join' | 'leave';
30
- payload?: any;
31
- sdp?: any;
32
- candidate?: any;
33
- }
34
-
35
- export interface TranscriptDelta {
36
- segment_id: string;
37
- speaker: 'user' | 'agent' | string;
38
- text: string;
39
- is_final: boolean;
40
- }
41
-
42
- export interface RosterMessage {
43
- participants: any[];
44
- }
package/tsconfig.json DELETED
@@ -1,15 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "CommonJS",
5
- "moduleResolution": "node",
6
- "declaration": true,
7
- "outDir": "./dist",
8
- "strict": true,
9
- "esModuleInterop": true,
10
- "skipLibCheck": true,
11
- "forceConsistentCasingInFileNames": true,
12
- "lib": ["DOM", "DOM.Iterable", "ES2022"]
13
- },
14
- "include": ["src/**/*"]
15
- }