@krystalnaroo/ai-chat-core 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 +46 -0
- package/package.json +16 -0
- package/src/VoiceAgentClient.js +242 -0
- package/src/audio/audioProcessor.js +151 -0
- package/src/events/EventEmitter.js +27 -0
- package/src/index.js +4 -0
- package/src/ws/createWebSocket.js +27 -0
package/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# @krytabo/ai-chat-core
|
|
2
|
+
|
|
3
|
+
Framework-agnostic core for AI Chat WebSocket communication.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
yarn add @krystalnaroo/ai-chat-core
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
import { VoiceAgentClient } from "@krystalnaroo/ai-chat-core";
|
|
15
|
+
|
|
16
|
+
const client = new VoiceAgentClient({
|
|
17
|
+
basePath: "/ws/audio",
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
client.on("state_update", (payload) => {
|
|
21
|
+
console.log(payload.state);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
client.on("text", (payload) => {
|
|
25
|
+
console.log(payload.source, payload.text);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
client.on("audio", (buffer) => {
|
|
29
|
+
console.log("tts audio", buffer.byteLength);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
client.connect();
|
|
33
|
+
|
|
34
|
+
client.sendText("hello");
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Supported Events
|
|
38
|
+
|
|
39
|
+
- state_update
|
|
40
|
+
- text
|
|
41
|
+
- trace
|
|
42
|
+
- llm_delta
|
|
43
|
+
- agent_response
|
|
44
|
+
- audio
|
|
45
|
+
- tts_stop
|
|
46
|
+
- error
|
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@krystalnaroo/ai-chat-core",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Framework-agnostic core for AI Chat WebSocket communication.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src"
|
|
12
|
+
],
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { EventEmitter } from "./events/EventEmitter.js";
|
|
2
|
+
import { createWebSocket } from "./ws/createWebSocket.js";
|
|
3
|
+
import { isNonEmptyBuffer, toArrayBuffer } from "./audio/audioProcessor.js";
|
|
4
|
+
|
|
5
|
+
export class VoiceAgentClient {
|
|
6
|
+
constructor(options = {}) {
|
|
7
|
+
this.options = options;
|
|
8
|
+
this.ws = undefined;
|
|
9
|
+
this.emitter = new EventEmitter();
|
|
10
|
+
this.state = "idle";
|
|
11
|
+
this.sessionId = null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
connect() {
|
|
15
|
+
if (this.state === "connecting" || this.state === "connected") {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
this.setState("connecting");
|
|
20
|
+
this.ws = createWebSocket({
|
|
21
|
+
url: this.options.url,
|
|
22
|
+
basePath: this.options.basePath,
|
|
23
|
+
host: this.options.host,
|
|
24
|
+
port: this.options.port,
|
|
25
|
+
});
|
|
26
|
+
this.ws.binaryType = "arraybuffer";
|
|
27
|
+
|
|
28
|
+
this.ws.addEventListener("open", () => {
|
|
29
|
+
this.setState("connected");
|
|
30
|
+
this.emitter.emit("connected", undefined);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
this.ws.addEventListener("close", (event) => {
|
|
34
|
+
this.setState("disconnected");
|
|
35
|
+
this.emitter.emit("disconnected", event);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
this.ws.addEventListener("message", (event) => {
|
|
39
|
+
if (event.data instanceof ArrayBuffer) {
|
|
40
|
+
this.emitter.emit("audio", event.data);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (typeof event.data === "string") {
|
|
45
|
+
this.handleJsonMessage(event.data);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (event.data instanceof Blob) {
|
|
50
|
+
event.data.arrayBuffer().then((buffer) => {
|
|
51
|
+
this.emitter.emit("audio", buffer);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
this.ws.addEventListener("error", (event) => {
|
|
57
|
+
this.emitter.emit("error", event);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
disconnect() {
|
|
62
|
+
if (!this.ws) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
this.ws.close();
|
|
66
|
+
this.ws = undefined;
|
|
67
|
+
this.setState("disconnected");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
sendJSON(payload, attachSessionId = true) {
|
|
71
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
if (attachSessionId && this.sessionId && typeof payload === 'object') {
|
|
75
|
+
payload.session_id = this.sessionId;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
this.ws.send(JSON.stringify(payload));
|
|
79
|
+
return true;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
console.error("Failed to send JSON:", error);
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
sendText(text) {
|
|
87
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (!text || typeof text !== "string") {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const payload = {
|
|
95
|
+
type: "text_input",
|
|
96
|
+
text,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
if (this.sessionId) {
|
|
100
|
+
payload.session_id = this.sessionId;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
this.ws.send(JSON.stringify(payload));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
sendAudio(chunk) {
|
|
107
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const buffer = toArrayBuffer(chunk);
|
|
112
|
+
if (!isNonEmptyBuffer(buffer)) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
this.ws.send(buffer);
|
|
117
|
+
return true;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
console.error("Failed to send audio:", error);
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
sendAudioFrameBase64(base64Payload, sampleRate = 16000) {
|
|
125
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
if (!base64Payload) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
const payload = {
|
|
132
|
+
type: "audio_frame",
|
|
133
|
+
payload: base64Payload,
|
|
134
|
+
sample_rate: sampleRate,
|
|
135
|
+
};
|
|
136
|
+
try {
|
|
137
|
+
this.ws.send(JSON.stringify(payload));
|
|
138
|
+
return true;
|
|
139
|
+
} catch (error) {
|
|
140
|
+
console.error("Failed to send audio frame:", error);
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
sendImage(dataUrl) {
|
|
146
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (typeof dataUrl !== "string" || dataUrl.length === 0) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const payload = JSON.stringify({ type: "image", dataUrl });
|
|
153
|
+
this.ws.send(payload);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
on(event, handler) {
|
|
157
|
+
this.emitter.on(event, handler);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
off(event, handler) {
|
|
161
|
+
this.emitter.off(event, handler);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
getState() {
|
|
165
|
+
return this.state;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
setState(state) {
|
|
169
|
+
this.state = state;
|
|
170
|
+
this.emitter.emit("state", state);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
handleJsonMessage(raw) {
|
|
174
|
+
const payload = this.parseJsonMessage(raw);
|
|
175
|
+
if (!payload || !payload.type) {
|
|
176
|
+
this.emitter.emit("text", raw);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (payload.session_id) {
|
|
181
|
+
this.sessionId = payload.session_id;
|
|
182
|
+
}
|
|
183
|
+
if (payload.type === "session_config" && payload.session_id) {
|
|
184
|
+
this.sessionId = payload.session_id;
|
|
185
|
+
this.emitter.emit("session_config", payload);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
switch (payload.type) {
|
|
190
|
+
case "state_update":
|
|
191
|
+
this.emitter.emit("state_update", payload.data ?? payload);
|
|
192
|
+
break;
|
|
193
|
+
case "state":
|
|
194
|
+
this.emitter.emit("state_update", payload);
|
|
195
|
+
break;
|
|
196
|
+
case "final_text":
|
|
197
|
+
this.emitter.emit("text", payload.data ?? payload);
|
|
198
|
+
break;
|
|
199
|
+
case "text":
|
|
200
|
+
this.emitter.emit("text", payload);
|
|
201
|
+
break;
|
|
202
|
+
case "trace":
|
|
203
|
+
this.emitter.emit("trace", payload);
|
|
204
|
+
break;
|
|
205
|
+
case "llm_delta":
|
|
206
|
+
this.emitter.emit("llm_delta", payload);
|
|
207
|
+
break;
|
|
208
|
+
case "text_response":
|
|
209
|
+
this.emitter.emit("agent_response", {
|
|
210
|
+
content: payload.data?.text ?? payload.text ?? "",
|
|
211
|
+
artifacts: payload.data?.artifacts ?? payload.artifacts ?? [],
|
|
212
|
+
});
|
|
213
|
+
break;
|
|
214
|
+
case "agent_response":
|
|
215
|
+
this.emitter.emit("agent_response", payload);
|
|
216
|
+
break;
|
|
217
|
+
case "tts_stop":
|
|
218
|
+
this.emitter.emit("tts_stop", payload);
|
|
219
|
+
break;
|
|
220
|
+
case "vad_turn_start":
|
|
221
|
+
this.emitter.emit("vad_turn_start", payload);
|
|
222
|
+
break;
|
|
223
|
+
case "agent_resumed":
|
|
224
|
+
this.emitter.emit("agent_resumed", payload);
|
|
225
|
+
break;
|
|
226
|
+
case "error":
|
|
227
|
+
this.emitter.emit("error", payload);
|
|
228
|
+
break;
|
|
229
|
+
default:
|
|
230
|
+
this.emitter.emit("text", payload);
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
parseJsonMessage(raw) {
|
|
236
|
+
try {
|
|
237
|
+
return JSON.parse(raw);
|
|
238
|
+
} catch (error) {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
const DEFAULT_SAMPLE_RATE = 16000;
|
|
2
|
+
|
|
3
|
+
export const toArrayBuffer = (input) => {
|
|
4
|
+
if (!input) {
|
|
5
|
+
return null;
|
|
6
|
+
}
|
|
7
|
+
if (input instanceof ArrayBuffer) {
|
|
8
|
+
return input;
|
|
9
|
+
}
|
|
10
|
+
if (ArrayBuffer.isView(input)) {
|
|
11
|
+
return input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength);
|
|
12
|
+
}
|
|
13
|
+
return null;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const isNonEmptyBuffer = (buffer) =>
|
|
17
|
+
buffer instanceof ArrayBuffer && buffer.byteLength > 0;
|
|
18
|
+
|
|
19
|
+
function isWavBuffer(buffer) {
|
|
20
|
+
if (buffer.byteLength < 12) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
const view = new DataView(buffer);
|
|
24
|
+
const riff =
|
|
25
|
+
String.fromCharCode(view.getUint8(0)) +
|
|
26
|
+
String.fromCharCode(view.getUint8(1)) +
|
|
27
|
+
String.fromCharCode(view.getUint8(2)) +
|
|
28
|
+
String.fromCharCode(view.getUint8(3));
|
|
29
|
+
const wave =
|
|
30
|
+
String.fromCharCode(view.getUint8(8)) +
|
|
31
|
+
String.fromCharCode(view.getUint8(9)) +
|
|
32
|
+
String.fromCharCode(view.getUint8(10)) +
|
|
33
|
+
String.fromCharCode(view.getUint8(11));
|
|
34
|
+
return riff === "RIFF" && wave === "WAVE";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function decodePcm16(buffer, audioContext, sampleRate) {
|
|
38
|
+
const pcm = new Int16Array(buffer);
|
|
39
|
+
const audioBuffer = audioContext.createBuffer(1, pcm.length, sampleRate);
|
|
40
|
+
const channel = audioBuffer.getChannelData(0);
|
|
41
|
+
for (let i = 0; i < pcm.length; i += 1) {
|
|
42
|
+
channel[i] = pcm[i] / 0x8000;
|
|
43
|
+
}
|
|
44
|
+
return audioBuffer;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class AudioProcessor {
|
|
48
|
+
constructor({ sampleRate = DEFAULT_SAMPLE_RATE } = {}) {
|
|
49
|
+
this.sampleRate = sampleRate;
|
|
50
|
+
this.audioContext = null;
|
|
51
|
+
this.playingSources = [];
|
|
52
|
+
this.stopRequested = false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
initialize() {
|
|
56
|
+
if (!this.audioContext) {
|
|
57
|
+
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
|
58
|
+
this.analyser = this.audioContext.createAnalyser();
|
|
59
|
+
this.analyser.fftSize = 256;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
isReady() {
|
|
64
|
+
return this.audioContext && this.audioContext.state === "running";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async resume() {
|
|
68
|
+
this.initialize();
|
|
69
|
+
if (this.audioContext.state === "suspended") {
|
|
70
|
+
await this.audioContext.resume();
|
|
71
|
+
}
|
|
72
|
+
return this.isReady();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
stop() {
|
|
76
|
+
this.stopRequested = true;
|
|
77
|
+
this.playingSources.forEach((source) => {
|
|
78
|
+
try {
|
|
79
|
+
source.stop();
|
|
80
|
+
} catch (error) {
|
|
81
|
+
// Ignore stop errors for ended sources.
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
this.playingSources = [];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
getVolume() {
|
|
88
|
+
if (!this.analyser) return 0;
|
|
89
|
+
const dataArray = new Uint8Array(this.analyser.frequencyBinCount);
|
|
90
|
+
this.analyser.getByteTimeDomainData(dataArray);
|
|
91
|
+
|
|
92
|
+
let sum = 0;
|
|
93
|
+
for(let i = 0; i < dataArray.length; i++) {
|
|
94
|
+
const v = (dataArray[i] - 128) / 128;
|
|
95
|
+
sum += v * v;
|
|
96
|
+
}
|
|
97
|
+
return Math.sqrt(sum / dataArray.length);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async decodeBuffer(buffer) {
|
|
101
|
+
this.initialize();
|
|
102
|
+
const arrayBuffer = buffer instanceof ArrayBuffer ? buffer : buffer.buffer.slice(0);
|
|
103
|
+
if (isWavBuffer(arrayBuffer)) {
|
|
104
|
+
return this.audioContext.decodeAudioData(arrayBuffer.slice(0));
|
|
105
|
+
}
|
|
106
|
+
return decodePcm16(arrayBuffer, this.audioContext, this.sampleRate);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async getAudioDuration(buffer) {
|
|
110
|
+
const audioBuffer = await this.decodeBuffer(buffer);
|
|
111
|
+
return audioBuffer.duration;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async play(buffers, onEnd) {
|
|
115
|
+
if (!buffers || buffers.length === 0) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
await this.resume();
|
|
119
|
+
this.stop();
|
|
120
|
+
this.stopRequested = false;
|
|
121
|
+
|
|
122
|
+
for (const buffer of buffers) {
|
|
123
|
+
if (this.stopRequested) {
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
const audioBuffer = await this.decodeBuffer(buffer);
|
|
127
|
+
await this.playAudioBuffer(audioBuffer);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (onEnd) {
|
|
131
|
+
onEnd();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
playAudioBuffer(audioBuffer) {
|
|
136
|
+
return new Promise((resolve) => {
|
|
137
|
+
const source = this.audioContext.createBufferSource();
|
|
138
|
+
source.buffer = audioBuffer;
|
|
139
|
+
// Connect: Source -> Analyser -> Destination
|
|
140
|
+
source.connect(this.analyser);
|
|
141
|
+
this.analyser.connect(this.audioContext.destination);
|
|
142
|
+
|
|
143
|
+
source.onended = () => {
|
|
144
|
+
this.playingSources = this.playingSources.filter((item) => item !== source);
|
|
145
|
+
resolve();
|
|
146
|
+
};
|
|
147
|
+
this.playingSources.push(source);
|
|
148
|
+
source.start();
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export class EventEmitter {
|
|
2
|
+
constructor() {
|
|
3
|
+
this.listeners = {};
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
on(event, handler) {
|
|
7
|
+
if (!this.listeners[event]) {
|
|
8
|
+
this.listeners[event] = new Set();
|
|
9
|
+
}
|
|
10
|
+
this.listeners[event].add(handler);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
off(event, handler) {
|
|
14
|
+
const set = this.listeners[event];
|
|
15
|
+
if (set) {
|
|
16
|
+
set.delete(handler);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
emit(event, payload) {
|
|
21
|
+
const set = this.listeners[event];
|
|
22
|
+
if (!set) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
set.forEach((handler) => handler(payload));
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { VoiceAgentClient } from "./VoiceAgentClient.js";
|
|
2
|
+
export { buildWebSocketUrl, createWebSocket } from "./ws/createWebSocket.js";
|
|
3
|
+
export { AudioProcessor, toArrayBuffer, isNonEmptyBuffer } from "./audio/audioProcessor.js";
|
|
4
|
+
export { EventEmitter } from "./events/EventEmitter.js";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const DEFAULT_BASE_PATH = "/ws/audio";
|
|
2
|
+
|
|
3
|
+
function normalizeBasePath(basePath) {
|
|
4
|
+
if (!basePath) {
|
|
5
|
+
return DEFAULT_BASE_PATH;
|
|
6
|
+
}
|
|
7
|
+
return basePath.startsWith("/") ? basePath : `/${basePath}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function buildWebSocketUrl({ basePath, host, port } = {}) {
|
|
11
|
+
if (typeof location === "undefined") {
|
|
12
|
+
throw new Error("location is not available in this environment");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
|
16
|
+
const finalHost = host || location.hostname;
|
|
17
|
+
const path = normalizeBasePath(basePath);
|
|
18
|
+
if (port === undefined || port === null || port === "") {
|
|
19
|
+
return `${protocol}//${finalHost}${path}`;
|
|
20
|
+
}
|
|
21
|
+
return `${protocol}//${finalHost}:${port}${path}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createWebSocket({ url, basePath, host, port } = {}) {
|
|
25
|
+
const finalUrl = url ?? buildWebSocketUrl({ basePath, host, port });
|
|
26
|
+
return new WebSocket(finalUrl);
|
|
27
|
+
}
|