shoukaku-bun 4.2.0-b

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,442 @@
1
+
2
+ import { OpCodes, ShoukakuClientInfo, State, Versions } from '../Constants';
3
+ import type {
4
+ PlayerUpdate,
5
+ TrackEndEvent,
6
+ TrackExceptionEvent,
7
+ TrackStartEvent,
8
+ TrackStuckEvent,
9
+ WebSocketClosedEvent
10
+ } from '../guild/Player';
11
+ import type { NodeOption, Shoukaku, ShoukakuEvents } from '../Shoukaku';
12
+ import { TypedEventEmitter, wait } from '../Utils';
13
+ import { Rest } from './Rest';
14
+
15
+ export interface Ready {
16
+ op: OpCodes.READY;
17
+ resumed: boolean;
18
+ sessionId: string;
19
+ }
20
+
21
+ export interface NodeMemory {
22
+ reservable: number;
23
+ used: number;
24
+ free: number;
25
+ allocated: number;
26
+ }
27
+
28
+ export interface NodeFrameStats {
29
+ sent: number;
30
+ deficit: number;
31
+ nulled: number;
32
+ }
33
+
34
+ export interface NodeCpu {
35
+ cores: number;
36
+ systemLoad: number;
37
+ lavalinkLoad: number;
38
+ }
39
+
40
+ export interface Stats {
41
+ op: OpCodes.STATS;
42
+ players: number;
43
+ playingPlayers: number;
44
+ memory: NodeMemory;
45
+ frameStats: NodeFrameStats | null;
46
+ cpu: NodeCpu;
47
+ uptime: number;
48
+ }
49
+
50
+ export interface NodeInfoVersion {
51
+ semver: string;
52
+ major: number;
53
+ minor: number;
54
+ patch: number;
55
+ preRelease?: string;
56
+ build?: string;
57
+ }
58
+
59
+ export interface NodeInfoGit {
60
+ branch: string;
61
+ commit: string;
62
+ commitTime: number;
63
+ }
64
+
65
+ export interface NodeInfoPlugin {
66
+ name: string;
67
+ version: string;
68
+ }
69
+
70
+ export interface NodeInfo {
71
+ version: NodeInfoVersion;
72
+ buildTime: number;
73
+ git: NodeInfoGit;
74
+ jvm: string;
75
+ lavaplayer: string;
76
+ sourceManagers: string[];
77
+ filters: string[];
78
+ plugins: NodeInfoPlugin[];
79
+ }
80
+
81
+ export interface ResumableHeaders {
82
+ [key: string]: string;
83
+ 'Client-Name': string;
84
+ 'User-Agent': string;
85
+ 'Authorization': string;
86
+ 'User-Id': string;
87
+ 'Session-Id': string;
88
+ }
89
+
90
+ export type NonResumableHeaders = Omit<ResumableHeaders, 'Session-Id'>;
91
+
92
+ export type NodeEvents = {
93
+ [K in keyof ShoukakuEvents]: ShoukakuEvents[K] extends [unknown, ...infer R] ? R : never;
94
+ };
95
+
96
+ /**
97
+ * Represents a Lavalink node
98
+ */
99
+ export class Node extends TypedEventEmitter<NodeEvents> {
100
+ /**
101
+ * Shoukaku class
102
+ */
103
+ public readonly manager: Shoukaku;
104
+ /**
105
+ * Lavalink rest API
106
+ */
107
+ public readonly rest: Rest;
108
+ /**
109
+ * Name of this node
110
+ */
111
+ public readonly name: string;
112
+ /**
113
+ * Group in which this node is contained
114
+ */
115
+ public readonly group?: string;
116
+ /**
117
+ * URL of Lavalink
118
+ */
119
+ private readonly url: string;
120
+ /**
121
+ * Credentials to access Lavalink
122
+ */
123
+ private readonly auth: string;
124
+ /**
125
+ * The state of this connection
126
+ */
127
+ public state: State;
128
+ /**
129
+ * The number of reconnects to Lavalink
130
+ */
131
+ public reconnects: number;
132
+ /**
133
+ * Statistics from Lavalink
134
+ */
135
+ public stats: Stats | null;
136
+ /**
137
+ * Information about lavalink node
138
+ */
139
+ public info: NodeInfo | null;
140
+ /**
141
+ * Websocket instance
142
+ */
143
+ public ws: WebSocket | null;
144
+ /**
145
+ * SessionId of this Lavalink connection (not to be confused with Discord SessionId)
146
+ */
147
+ public sessionId: string | null;
148
+
149
+ /**
150
+ * @param manager Shoukaku instance
151
+ * @param options Options on creating this node
152
+ * @param options.name Name of this node
153
+ * @param options.url URL of Lavalink
154
+ * @param options.auth Credentials to access Lavalink
155
+ * @param options.secure Whether to use secure protocols or not
156
+ * @param options.group Group of this node
157
+ */
158
+ constructor(manager: Shoukaku, options: NodeOption) {
159
+ super();
160
+ this.manager = manager;
161
+ this.rest = new (this.manager.options.structures.rest ?? Rest)(this, options);
162
+ this.name = options.name;
163
+ this.group = options.group;
164
+ this.auth = options.auth;
165
+ this.url = `${options.secure ? 'wss' : 'ws'}://${options.url}/v${Versions.WEBSOCKET_VERSION}/websocket`;
166
+ this.state = State.DISCONNECTED;
167
+ this.reconnects = 0;
168
+ this.stats = null;
169
+ this.info = null;
170
+ this.ws = null;
171
+ this.sessionId = null;
172
+ }
173
+
174
+ /**
175
+ * Penalties for load balancing
176
+ * @returns Penalty score
177
+ * @internal @readonly
178
+ */
179
+ get penalties(): number {
180
+ let penalties = 0;
181
+ if (!this.stats) return penalties;
182
+
183
+ penalties += this.stats.players;
184
+ penalties += Math.round(Math.pow(1.05, 100 * this.stats.cpu.systemLoad) * 10 - 10);
185
+
186
+ if (this.stats.frameStats) {
187
+ penalties += this.stats.frameStats.deficit;
188
+ penalties += this.stats.frameStats.nulled * 2;
189
+ }
190
+
191
+ return penalties;
192
+ }
193
+
194
+ /**
195
+ * Connect to Lavalink
196
+ */
197
+ public async connect(): Promise<void> {
198
+ if (!this.manager.id)
199
+ throw new Error('UserId missing, probably your connector is misconfigured?');
200
+
201
+ if (this.state === State.CONNECTED || this.state === State.CONNECTING)
202
+ return;
203
+
204
+ this.cleanupWebsocket();
205
+
206
+ this.state = State.CONNECTING;
207
+
208
+ const headers: NonResumableHeaders | ResumableHeaders = {
209
+ 'Client-Name': ShoukakuClientInfo,
210
+ 'User-Agent': this.manager.options.userAgent,
211
+ 'Authorization': this.auth,
212
+ 'User-Id': this.manager.id
213
+ };
214
+
215
+ if (this.sessionId && this.manager.options.resume) {
216
+ headers['Session-Id'] = this.sessionId;
217
+ this.emit('debug', `[Socket] -> [${this.name}] : Session-Id is present, attempting to resume`);
218
+ }
219
+
220
+ this.emit('debug', `[Socket] -> [${this.name}] : Connecting to ${this.url} ...`);
221
+
222
+ const createConnection = () => {
223
+ const url = new URL(this.url);
224
+
225
+ const server = new WebSocket(url.toString(), { headers });
226
+
227
+ const cleanup = () => {
228
+ server.onopen = null;
229
+ server.onclose = null;
230
+ server.onerror = null;
231
+ };
232
+
233
+ return new Promise<WebSocket>((resolve, reject) => {
234
+ server.onopen = () => {
235
+ cleanup();
236
+ resolve(server);
237
+ };
238
+ server.onclose = () => {
239
+ cleanup();
240
+ reject(new Error('Websocket closed before a connection was established'));
241
+ };
242
+ server.onerror = (error) => {
243
+ cleanup();
244
+ reject(new Error(`Websocket failed to connect due to: ${(error as any).message || 'Unknown error'}`));
245
+ };
246
+ });
247
+ };
248
+
249
+ let connectError: Error | undefined;
250
+
251
+ for (this.reconnects = 0; this.reconnects < this.manager.options.reconnectTries; this.reconnects++) {
252
+ try {
253
+ this.ws = await createConnection();
254
+ break;
255
+ } catch (error) {
256
+ this.emit('reconnecting', this.manager.options.reconnectTries - this.reconnects, this.manager.options.reconnectInterval);
257
+ this.emit('debug', `[Socket] -> [${this.name}] : Reconnecting in ${this.manager.options.reconnectInterval} seconds. ${this.manager.options.reconnectTries - this.reconnects} tries left`);
258
+ connectError = error as Error;
259
+ await wait(this.manager.options.reconnectInterval * 1000);
260
+ }
261
+ }
262
+
263
+ if (connectError) {
264
+ this.state = State.DISCONNECTED;
265
+ this.cleanupWebsocket();
266
+ let count = 0;
267
+ if (this.manager.options.moveOnDisconnect) {
268
+ count = await this.movePlayers();
269
+ }
270
+ this.emit('disconnect', count);
271
+ // Should I throw or not? :confusion:
272
+ throw connectError;
273
+ }
274
+
275
+ this.ws!.onclose = event => void this.close(event.code, event.reason);
276
+ this.ws!.onerror = event => this.error(new Error(`WebSocket connection error: ${(event as any).message || 'Unknown error'}`));
277
+ this.ws!.onmessage = event => void this.message(event.data).catch(error => this.error(error as Error));
278
+ this.open();
279
+ }
280
+
281
+ /**
282
+ * Disconnect from Lavalink
283
+ * @param code Status code
284
+ * @param reason Reason for disconnect
285
+ */
286
+ public disconnect(code: number, reason?: string): void {
287
+ if (this.state !== State.CONNECTED && this.state !== State.CONNECTING) return;
288
+
289
+ this.state = State.DISCONNECTING;
290
+
291
+ if (this.ws)
292
+ this.ws.close(code, reason);
293
+ else
294
+ void this.close(1000, reason ?? 'Unknown Reason');
295
+ }
296
+
297
+ /**
298
+ * Handle connection open event from Lavalink
299
+ * @param response Response from Lavalink
300
+ * @internal
301
+ */
302
+ /**
303
+ * Handle connection open event from Lavalink
304
+ * @internal
305
+ */
306
+ private open(): void {
307
+ this.reconnects = 0;
308
+ // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
309
+ this.emit('debug', `[Socket] <-> [${this.name}] : Connection Handshake Done => ${this.url}`);
310
+ }
311
+
312
+ /**
313
+ * Handle message from Lavalink
314
+ * @param message JSON message
315
+ * @internal
316
+ */
317
+ private async message(message: unknown): Promise<void> {
318
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
319
+ const json: Ready | Stats | PlayerUpdate | TrackStartEvent | TrackEndEvent | TrackStuckEvent | TrackExceptionEvent | WebSocketClosedEvent = JSON.parse(message as string);
320
+ if (!json) return;
321
+ this.emit('raw', json);
322
+ switch (json.op) {
323
+ case OpCodes.STATS:
324
+ this.emit('debug', `[Socket] <- [${this.name}] : Node Status Update | Server Load: ${this.penalties}`);
325
+ this.stats = json;
326
+ break;
327
+ case OpCodes.READY: {
328
+ this.state = State.CONNECTED;
329
+
330
+ if (!json.sessionId) {
331
+ this.emit('debug', `[Socket] -> [${this.name}] : No session id found from ready op? disconnecting and reconnecting to avoid issues`);
332
+ return this.disconnect(1000);
333
+ }
334
+
335
+ this.sessionId = json.sessionId;
336
+
337
+ const players = [ ...this.manager.players.values() ].filter(player => player.node.name === this.name);
338
+
339
+ let resumedByLibrary = false;
340
+ if (!json.resumed && Boolean(players.length && this.manager.options.resumeByLibrary)) {
341
+ try {
342
+ await this.resumePlayers();
343
+ resumedByLibrary = true;
344
+ } catch (error) {
345
+ this.error(error as Error);
346
+ }
347
+ }
348
+
349
+ this.emit('debug', `[Socket] -> [${this.name}] : Lavalink is ready to communicate !`);
350
+ this.emit('ready', json.resumed, resumedByLibrary);
351
+
352
+ if (this.manager.options.resume) {
353
+ await this.rest.updateSession(this.manager.options.resume, this.manager.options.resumeTimeout);
354
+ this.emit('debug', `[Socket] -> [${this.name}] : Resuming configured for this Session Id: ${this.sessionId}`);
355
+ }
356
+
357
+ break;
358
+ }
359
+ case OpCodes.EVENT:
360
+ case OpCodes.PLAYER_UPDATE: {
361
+ const player = this.manager.players.get(json.guildId);
362
+ if (!player) return;
363
+ if (json.op === OpCodes.EVENT)
364
+ player.onPlayerEvent(json);
365
+ else
366
+ player.onPlayerUpdate(json);
367
+ break;
368
+ }
369
+ default:
370
+ this.emit('debug', `[Player] -> [Node] : Unknown Message Op, Data => ${JSON.stringify(json)}`);
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Handle closed event from lavalink
376
+ * @param code Status close
377
+ * @param reason Reason for connection close
378
+ */
379
+ private async close(code: number, reason: string): Promise<void> {
380
+ this.emit('close', code, String(reason));
381
+ this.emit('debug', `[Socket] <-/-> [${this.name}] : Connection Closed, Code: ${code || 'Unknown Code'}`);
382
+
383
+ this.state = State.DISCONNECTING;
384
+
385
+ try {
386
+ await this.connect();
387
+ } catch (error) {
388
+ this.emit('error', error as Error);
389
+ }
390
+ }
391
+
392
+ /**
393
+ * To emit error events easily
394
+ * @param error error message
395
+ */
396
+ public error(error: Error): void {
397
+ this.emit('error', error);
398
+ }
399
+
400
+ /**
401
+ * Tries to resume the players internally
402
+ * @internal
403
+ */
404
+ private async resumePlayers(): Promise<void> {
405
+ const playersWithData = [];
406
+ const playersWithoutData = [];
407
+
408
+ for (const player of this.manager.players.values()) {
409
+ const serverUpdate = this.manager.connections.get(player.guildId)?.serverUpdate;
410
+ if (serverUpdate)
411
+ playersWithData.push(player);
412
+ else
413
+ playersWithoutData.push(player);
414
+ }
415
+
416
+ await Promise.allSettled([
417
+ ...playersWithData.map(player => player.resume()),
418
+ ...playersWithoutData.map(player => this.manager.leaveVoiceChannel(player.guildId))
419
+ ]);
420
+ }
421
+
422
+ /**
423
+ * Tries to move the players to another node
424
+ * @internal
425
+ */
426
+ private async movePlayers(): Promise<number> {
427
+ const players = [ ...this.manager.players.values() ];
428
+ const data = await Promise.allSettled(players.map(player => player.move()));
429
+ return data.filter(results => results.status === 'fulfilled').length;
430
+ }
431
+
432
+ private cleanupWebsocket(): void {
433
+ if (this.ws) {
434
+ this.ws.onopen = null;
435
+ this.ws.onclose = null;
436
+ this.ws.onerror = null;
437
+ this.ws.onmessage = null;
438
+ this.ws.close();
439
+ }
440
+ this.ws = null;
441
+ }
442
+ }