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/README.md +557 -0
- package/dist/events.d.ts +13 -0
- package/dist/events.js +149 -42
- package/dist/room.d.ts +29 -1
- package/dist/room.js +239 -52
- package/dist/signaling.d.ts +11 -0
- package/dist/signaling.js +139 -24
- package/dist/types.d.ts +6 -4
- package/package.json +14 -4
- package/src/client.ts +0 -59
- package/src/events.ts +0 -64
- package/src/index.ts +0 -5
- package/src/room.ts +0 -163
- package/src/signaling.ts +0 -53
- package/src/types.ts +0 -44
- package/tsconfig.json +0 -15
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
|
-
|
|
14
|
-
|
|
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.
|
|
18
|
-
this.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
24
|
-
this.emit('message', msg);
|
|
97
|
+
this.ws.send(JSON.stringify(message));
|
|
25
98
|
}
|
|
26
99
|
catch (err) {
|
|
27
|
-
console.error('Failed to
|
|
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
|
-
|
|
105
|
+
// Buffer in outbox until WebSocket connects
|
|
106
|
+
this.outbox.push(message);
|
|
43
107
|
}
|
|
44
108
|
}
|
|
45
|
-
|
|
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.
|
|
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?:
|
|
18
|
-
candidate?:
|
|
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.
|
|
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
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
|
-
}
|