ulai-js-sdk 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,12 @@
1
+ import { ConnectOptions, JoinRoomOptions } from './types';
2
+ import { Room } from './room';
3
+ export declare class UlaiClient {
4
+ private url;
5
+ private apiKey?;
6
+ constructor(options: ConnectOptions);
7
+ /**
8
+ * Joins a room by making an API request to the control plane,
9
+ * then connects WebRTC and Event subscriptions.
10
+ */
11
+ joinRoom(options: JoinRoomOptions): Promise<Room>;
12
+ }
package/dist/client.js ADDED
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.UlaiClient = void 0;
4
+ const room_1 = require("./room");
5
+ class UlaiClient {
6
+ url;
7
+ apiKey;
8
+ constructor(options) {
9
+ this.url = options.url.replace(/\/$/, '');
10
+ this.apiKey = options.apiKey;
11
+ }
12
+ /**
13
+ * Joins a room by making an API request to the control plane,
14
+ * then connects WebRTC and Event subscriptions.
15
+ */
16
+ async joinRoom(options) {
17
+ const headers = {
18
+ 'Content-Type': 'application/json',
19
+ };
20
+ if (this.apiKey) {
21
+ headers['Authorization'] = `Bearer ${this.apiKey}`;
22
+ }
23
+ const body = {
24
+ kind: 'STANDARD',
25
+ can_publish: true,
26
+ can_subscribe: true,
27
+ participant_id: options.participantId,
28
+ };
29
+ const res = await fetch(`${this.url}/api/v1/sessions/${options.roomId}/join`, {
30
+ method: 'POST',
31
+ headers,
32
+ body: JSON.stringify(body),
33
+ });
34
+ if (!res.ok) {
35
+ const errText = await res.text();
36
+ throw new Error(`Failed to join room: ${res.status} ${errText}`);
37
+ }
38
+ const joinData = await res.json();
39
+ // joinData typically contains: session_id, events_token, connection_id, ticket, node: { address }
40
+ const room = new room_1.Room(this.url);
41
+ await room.connect({
42
+ roomId: joinData.session_id || options.roomId,
43
+ participantId: joinData.connection_id || options.participantId,
44
+ eventsToken: joinData.events_token,
45
+ ticket: joinData.ticket,
46
+ nodeAddress: joinData.node?.address
47
+ });
48
+ return room;
49
+ }
50
+ }
51
+ exports.UlaiClient = UlaiClient;
@@ -0,0 +1,8 @@
1
+ import EventEmitter from 'eventemitter3';
2
+ export declare class EventsClient extends EventEmitter {
3
+ private ws;
4
+ private url;
5
+ constructor(cpBase: string);
6
+ connect(sessionId: string, eventsToken: string): void;
7
+ close(): void;
8
+ }
package/dist/events.js ADDED
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.EventsClient = void 0;
7
+ const eventemitter3_1 = __importDefault(require("eventemitter3"));
8
+ class EventsClient extends eventemitter3_1.default {
9
+ ws = null;
10
+ url;
11
+ constructor(cpBase) {
12
+ super();
13
+ const scheme = cpBase.startsWith('https') ? 'wss' : 'ws';
14
+ const host = cpBase.replace(/^https?:\/\//, '').replace(/\/$/, '');
15
+ this.url = `${scheme}://${host}/api/v1/events`;
16
+ }
17
+ connect(sessionId, eventsToken) {
18
+ this.ws = new WebSocket(this.url);
19
+ this.ws.onopen = () => {
20
+ this.ws?.send(JSON.stringify({
21
+ action: 'subscribe',
22
+ scope: 'session',
23
+ session_id: sessionId,
24
+ token: eventsToken
25
+ }));
26
+ };
27
+ this.ws.onmessage = (event) => {
28
+ try {
29
+ const msg = JSON.parse(event.data);
30
+ if (msg.type === 'subscribed') {
31
+ this.emit('connected', msg);
32
+ }
33
+ else if (msg.type === 'transcript_delta') {
34
+ this.emit('transcript_delta', msg);
35
+ }
36
+ else if (msg.type === 'roster') {
37
+ this.emit('roster', msg);
38
+ }
39
+ else if (msg.type === 'session_terminated') {
40
+ this.emit('session_terminated', msg);
41
+ }
42
+ else if (msg.type === 'participant_joined') {
43
+ this.emit('participant_joined', msg);
44
+ }
45
+ else if (msg.type === 'participant_left') {
46
+ this.emit('participant_left', msg);
47
+ }
48
+ else if (msg.type === 'error') {
49
+ this.emit('error', msg);
50
+ }
51
+ }
52
+ catch (err) {
53
+ console.error('Failed to parse events message', err);
54
+ }
55
+ };
56
+ this.ws.onclose = () => {
57
+ this.emit('disconnected');
58
+ };
59
+ this.ws.onerror = (error) => {
60
+ this.emit('error', error);
61
+ };
62
+ }
63
+ close() {
64
+ if (this.ws) {
65
+ this.ws.close();
66
+ this.ws = null;
67
+ }
68
+ }
69
+ }
70
+ exports.EventsClient = EventsClient;
@@ -0,0 +1,5 @@
1
+ export * from './client';
2
+ export * from './room';
3
+ export * from './signaling';
4
+ export * from './events';
5
+ export * from './types';
package/dist/index.js ADDED
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./client"), exports);
18
+ __exportStar(require("./room"), exports);
19
+ __exportStar(require("./signaling"), exports);
20
+ __exportStar(require("./events"), exports);
21
+ __exportStar(require("./types"), exports);
package/dist/room.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ import EventEmitter from 'eventemitter3';
2
+ import { RoomEvent, JoinRoomOptions } from './types';
3
+ export declare class Room extends EventEmitter<RoomEvent> {
4
+ private cpBase;
5
+ private signaling;
6
+ private eventsClient;
7
+ private peerConnection;
8
+ private localStream;
9
+ private remoteStreams;
10
+ roomId: string | null;
11
+ participantId: string | null;
12
+ constructor(cpBase: string);
13
+ connect(options: JoinRoomOptions): Promise<void>;
14
+ private setupEventsClient;
15
+ private setupSignaling;
16
+ publishAudio(): Promise<void>;
17
+ disconnect(): void;
18
+ }
package/dist/room.js ADDED
@@ -0,0 +1,154 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.Room = void 0;
7
+ const eventemitter3_1 = __importDefault(require("eventemitter3"));
8
+ const signaling_1 = require("./signaling");
9
+ const events_1 = require("./events");
10
+ class Room extends eventemitter3_1.default {
11
+ cpBase;
12
+ signaling = null;
13
+ eventsClient = null;
14
+ peerConnection = null;
15
+ localStream = null;
16
+ remoteStreams = new Map();
17
+ roomId = null;
18
+ participantId = null;
19
+ constructor(cpBase) {
20
+ super();
21
+ this.cpBase = cpBase;
22
+ }
23
+ async connect(options) {
24
+ this.roomId = options.roomId;
25
+ this.participantId = options.participantId || '';
26
+ // 1. Setup Control Plane Events
27
+ if (options.eventsToken) {
28
+ this.eventsClient = new events_1.EventsClient(this.cpBase);
29
+ this.setupEventsClient();
30
+ this.eventsClient.connect(this.roomId, options.eventsToken);
31
+ }
32
+ // 2. Setup WebRTC Signaling if nodeAddress and ticket are provided
33
+ if (options.nodeAddress && options.ticket) {
34
+ this.signaling = new signaling_1.SignalingClient(options.nodeAddress, options.ticket);
35
+ this.setupSignaling();
36
+ this.peerConnection = new RTCPeerConnection({
37
+ iceServers: [
38
+ { urls: 'stun:stun.l.google.com:19302' },
39
+ { urls: ['turn:turn.ulai.in:3478', 'turns:turn.ulai.in:5349'], username: 'ulai', credential: 'xulaizrt' }
40
+ ]
41
+ });
42
+ this.peerConnection.onicecandidate = (event) => {
43
+ if (event.candidate && this.signaling) {
44
+ this.signaling.send({ type: 'candidate', candidate: JSON.stringify(event.candidate.toJSON()) });
45
+ }
46
+ };
47
+ this.peerConnection.ontrack = (event) => {
48
+ const stream = event.streams[0];
49
+ if (stream) {
50
+ this.remoteStreams.set(stream.id, stream);
51
+ this.emit('trackSubscribed', event.track, stream);
52
+ }
53
+ };
54
+ this.peerConnection.onconnectionstatechange = () => {
55
+ switch (this.peerConnection?.connectionState) {
56
+ case 'connected':
57
+ this.emit('connected');
58
+ break;
59
+ case 'disconnected':
60
+ case 'failed':
61
+ this.emit('disconnected');
62
+ break;
63
+ }
64
+ };
65
+ this.signaling.connect();
66
+ }
67
+ }
68
+ setupEventsClient() {
69
+ if (!this.eventsClient)
70
+ return;
71
+ this.eventsClient.on('roster', (msg) => this.emit('roster', msg));
72
+ this.eventsClient.on('participant_joined', (msg) => this.emit('participantJoined', msg));
73
+ this.eventsClient.on('participant_left', (msg) => this.emit('participantLeft', msg));
74
+ this.eventsClient.on('transcript_delta', (msg) => this.emit('transcriptDelta', msg));
75
+ this.eventsClient.on('session_terminated', (msg) => {
76
+ this.emit('sessionTerminated', msg);
77
+ this.disconnect();
78
+ });
79
+ this.eventsClient.on('error', (err) => this.emit('error', err));
80
+ }
81
+ setupSignaling() {
82
+ if (!this.signaling)
83
+ return;
84
+ this.signaling.on('connected', async () => {
85
+ // Create initial offer when signaling connects
86
+ if (this.peerConnection) {
87
+ const offer = await this.peerConnection.createOffer();
88
+ await this.peerConnection.setLocalDescription(offer);
89
+ this.signaling.send({ type: 'offer', sdp: this.peerConnection.localDescription?.sdp });
90
+ }
91
+ });
92
+ this.signaling.on('message', async (msg) => {
93
+ if (!this.peerConnection)
94
+ return;
95
+ try {
96
+ if (msg.type === 'offer' || msg.type === 'restart-offer') {
97
+ await this.peerConnection.setRemoteDescription(new RTCSessionDescription({ type: 'offer', sdp: msg.sdp }));
98
+ const answer = await this.peerConnection.createAnswer();
99
+ await this.peerConnection.setLocalDescription(answer);
100
+ this.signaling.send({ type: 'answer', sdp: this.peerConnection.localDescription?.sdp });
101
+ }
102
+ else if (msg.type === 'answer') {
103
+ await this.peerConnection.setRemoteDescription(new RTCSessionDescription({ type: 'answer', sdp: msg.sdp }));
104
+ }
105
+ else if (msg.type === 'candidate' && msg.candidate) {
106
+ await this.peerConnection.addIceCandidate(JSON.parse(msg.candidate));
107
+ }
108
+ }
109
+ catch (err) {
110
+ console.error('Error handling signaling message', err);
111
+ }
112
+ });
113
+ this.signaling.on('disconnected', () => {
114
+ this.emit('disconnected');
115
+ });
116
+ }
117
+ async publishAudio() {
118
+ try {
119
+ this.localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
120
+ if (this.peerConnection && this.localStream) {
121
+ this.localStream.getTracks().forEach((track) => {
122
+ this.peerConnection.addTrack(track, this.localStream);
123
+ });
124
+ // Renegotiate
125
+ const offer = await this.peerConnection.createOffer();
126
+ await this.peerConnection.setLocalDescription(offer);
127
+ this.signaling?.send({ type: 'offer', sdp: this.peerConnection.localDescription?.sdp });
128
+ }
129
+ }
130
+ catch (err) {
131
+ console.error('Failed to publish audio:', err);
132
+ this.emit('error', err);
133
+ }
134
+ }
135
+ disconnect() {
136
+ if (this.localStream) {
137
+ this.localStream.getTracks().forEach(track => track.stop());
138
+ }
139
+ if (this.peerConnection) {
140
+ this.peerConnection.close();
141
+ this.peerConnection = null;
142
+ }
143
+ if (this.signaling) {
144
+ this.signaling.close();
145
+ this.signaling = null;
146
+ }
147
+ if (this.eventsClient) {
148
+ this.eventsClient.close();
149
+ this.eventsClient = null;
150
+ }
151
+ this.emit('disconnected');
152
+ }
153
+ }
154
+ exports.Room = Room;
@@ -0,0 +1,10 @@
1
+ import EventEmitter from 'eventemitter3';
2
+ import { SignalMessage } from './types';
3
+ export declare class SignalingClient extends EventEmitter {
4
+ private ws;
5
+ private url;
6
+ constructor(nodeAddress: string, ticket: string);
7
+ connect(): void;
8
+ send(message: SignalMessage): void;
9
+ close(): void;
10
+ }
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.SignalingClient = void 0;
7
+ const eventemitter3_1 = __importDefault(require("eventemitter3"));
8
+ class SignalingClient extends eventemitter3_1.default {
9
+ ws = null;
10
+ url;
11
+ constructor(nodeAddress, ticket) {
12
+ super();
13
+ const scheme = nodeAddress.startsWith('localhost') || nodeAddress.startsWith('127.0.0.1') ? 'ws' : 'wss';
14
+ this.url = `${scheme}://${nodeAddress}/webrtc/room?ticket=${ticket}`;
15
+ }
16
+ connect() {
17
+ this.ws = new WebSocket(this.url);
18
+ this.ws.onopen = () => {
19
+ this.emit('connected');
20
+ };
21
+ this.ws.onmessage = (event) => {
22
+ try {
23
+ const msg = JSON.parse(event.data);
24
+ this.emit('message', msg);
25
+ }
26
+ catch (err) {
27
+ console.error('Failed to parse signaling message', err);
28
+ }
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
+ }
41
+ else {
42
+ console.warn('Cannot send message, WebSocket is not open', message);
43
+ }
44
+ }
45
+ close() {
46
+ if (this.ws) {
47
+ this.ws.close();
48
+ this.ws = null;
49
+ }
50
+ }
51
+ }
52
+ exports.SignalingClient = SignalingClient;
@@ -0,0 +1,28 @@
1
+ export interface ConnectOptions {
2
+ url: string;
3
+ apiKey?: string;
4
+ token?: string;
5
+ }
6
+ export interface JoinRoomOptions {
7
+ roomId: string;
8
+ participantId?: string;
9
+ eventsToken?: string;
10
+ ticket?: string;
11
+ nodeAddress?: string;
12
+ }
13
+ export type RoomEvent = 'connected' | 'disconnected' | 'reconnecting' | 'trackSubscribed' | 'trackUnsubscribed' | 'participantJoined' | 'participantLeft' | 'roster' | 'transcriptDelta' | 'sessionTerminated' | 'error';
14
+ export interface SignalMessage {
15
+ type: 'offer' | 'answer' | 'ice-candidate' | 'candidate' | 'join' | 'leave';
16
+ payload?: any;
17
+ sdp?: any;
18
+ candidate?: any;
19
+ }
20
+ export interface TranscriptDelta {
21
+ segment_id: string;
22
+ speaker: 'user' | 'agent' | string;
23
+ text: string;
24
+ is_final: boolean;
25
+ }
26
+ export interface RosterMessage {
27
+ participants: any[];
28
+ }
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
package/package.json CHANGED
@@ -1,6 +1,25 @@
1
1
  {
2
2
  "name": "ulai-js-sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Ulai Cloud JS/TS Client SDK for WebRTC Sessions",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "scripts": {
8
+ "build": "tsc",
9
+ "dev": "tsc -w"
10
+ },
11
+ "keywords": [
12
+ "ulai",
13
+ "webrtc",
14
+ "agent",
15
+ "sdk"
16
+ ],
17
+ "author": "",
18
+ "license": "MIT",
19
+ "devDependencies": {
20
+ "typescript": "^5.0.0"
21
+ },
22
+ "dependencies": {
23
+ "eventemitter3": "^5.0.1"
24
+ }
25
+ }
package/src/client.ts ADDED
@@ -0,0 +1,59 @@
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 ADDED
@@ -0,0 +1,64 @@
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 ADDED
@@ -0,0 +1,5 @@
1
+ export * from './client';
2
+ export * from './room';
3
+ export * from './signaling';
4
+ export * from './events';
5
+ export * from './types';
package/src/room.ts ADDED
@@ -0,0 +1,163 @@
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
+ }
@@ -0,0 +1,53 @@
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 ADDED
@@ -0,0 +1,44 @@
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 ADDED
@@ -0,0 +1,15 @@
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
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.