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.
- package/dist/client.d.ts +12 -0
- package/dist/client.js +51 -0
- package/dist/events.d.ts +8 -0
- package/dist/events.js +70 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +21 -0
- package/dist/room.d.ts +18 -0
- package/dist/room.js +154 -0
- package/dist/signaling.d.ts +10 -0
- package/dist/signaling.js +52 -0
- package/dist/types.d.ts +28 -0
- package/dist/types.js +2 -0
- package/package.json +23 -4
- package/src/client.ts +59 -0
- package/src/events.ts +64 -0
- package/src/index.ts +5 -0
- package/src/room.ts +163 -0
- package/src/signaling.ts +53 -0
- package/src/types.ts +44 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
package/dist/client.d.ts
ADDED
|
@@ -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;
|
package/dist/events.d.ts
ADDED
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;
|
package/dist/index.d.ts
ADDED
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;
|
package/dist/types.d.ts
ADDED
|
@@ -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
package/package.json
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ulai-js-sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
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
|
+
}
|
package/src/signaling.ts
ADDED
|
@@ -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