@ovencord/ws 2.0.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.
@@ -0,0 +1,876 @@
1
+ import { Collection } from '@ovencord/collection';
2
+ import { AsyncEventEmitter, AsyncQueue, shouldUseGlobalFetchAndWebSocket } from '@ovencord/util';
3
+ import {
4
+ GatewayCloseCodes,
5
+ GatewayDispatchEvents,
6
+ GatewayOpcodes,
7
+ type GatewayDispatchPayload,
8
+ type GatewayIdentifyData,
9
+ type GatewayReadyDispatchData,
10
+ type GatewayReceivePayload,
11
+ type GatewaySendPayload,
12
+ } from 'discord-api-types/v10';
13
+ import type { IContextFetchingStrategy } from '../strategies/context/IContextFetchingStrategy.js';
14
+ import {
15
+ CompressionMethod,
16
+ CompressionParameterMap,
17
+ ImportantGatewayOpcodes,
18
+ getInitialSendRateLimitState,
19
+ } from '../utils/constants.js';
20
+ import { BunInflateHandler } from '../utils/BunCompression.js';
21
+ import type { SessionInfo } from './WebSocketManager.js';
22
+
23
+ export enum WebSocketShardEvents {
24
+ Closed = 'closed',
25
+ Debug = 'debug',
26
+ Dispatch = 'dispatch',
27
+ Error = 'error',
28
+ HeartbeatComplete = 'heartbeat',
29
+ Hello = 'hello',
30
+ Ready = 'ready',
31
+ Resumed = 'resumed',
32
+ SocketError = 'socketError',
33
+ }
34
+
35
+ export enum WebSocketShardStatus {
36
+ Idle,
37
+ Connecting,
38
+ Resuming,
39
+ Ready,
40
+ }
41
+
42
+ export enum WebSocketShardDestroyRecovery {
43
+ Reconnect,
44
+ Resume,
45
+ }
46
+
47
+ export interface WebSocketShardEventsMap {
48
+ [WebSocketShardEvents.Closed]: [code: number];
49
+ [WebSocketShardEvents.Debug]: [message: string];
50
+ [WebSocketShardEvents.Dispatch]: [payload: GatewayDispatchPayload];
51
+ [WebSocketShardEvents.Error]: [error: Error];
52
+ [WebSocketShardEvents.Hello]: [];
53
+ [WebSocketShardEvents.Ready]: [payload: GatewayReadyDispatchData];
54
+ [WebSocketShardEvents.Resumed]: [];
55
+ [WebSocketShardEvents.HeartbeatComplete]: [stats: { ackAt: number; heartbeatAt: number; latency: number }];
56
+ [WebSocketShardEvents.SocketError]: [error: Error];
57
+ }
58
+
59
+ export interface WebSocketShardDestroyOptions {
60
+ code?: number;
61
+ reason?: string;
62
+ recover?: WebSocketShardDestroyRecovery;
63
+ }
64
+
65
+ export enum CloseCodes {
66
+ Normal = 1_000,
67
+ Resuming = 4_200,
68
+ }
69
+
70
+ export interface SendRateLimitState {
71
+ resetAt: number;
72
+ sent: number;
73
+ }
74
+
75
+ const WebSocketConstructor: typeof WebSocket = shouldUseGlobalFetchAndWebSocket()
76
+ ? (globalThis as any).WebSocket
77
+ : WebSocket;
78
+
79
+ export class WebSocketShard extends AsyncEventEmitter<WebSocketShardEventsMap> {
80
+ private connection: WebSocket | null = null;
81
+
82
+ /**
83
+ * Bun-native compression handler for Discord gateway
84
+ */
85
+ private bunInflate: BunInflateHandler = new BunInflateHandler();
86
+
87
+
88
+
89
+ private replayedEvents = 0;
90
+
91
+ private isAck = true;
92
+
93
+ private sendRateLimitState: SendRateLimitState = getInitialSendRateLimitState();
94
+
95
+ private initialHeartbeatTimeoutController: AbortController | null = null;
96
+
97
+ private heartbeatInterval: any | null = null;
98
+
99
+ private lastHeartbeatAt = -1;
100
+
101
+ // Indicates whether the shard has already resolved its original connect() call
102
+ private initialConnectResolved = false;
103
+
104
+ // Indicates if we failed to connect to the ws url
105
+ private failedToConnectDueToNetworkError = false;
106
+
107
+ private readonly sendQueue = new AsyncQueue();
108
+
109
+ private readonly timeoutAbortControllers = new Collection<WebSocketShardEvents, AbortController>();
110
+
111
+ private readonly strategy: IContextFetchingStrategy;
112
+
113
+ public readonly id: number;
114
+
115
+ #status: WebSocketShardStatus = WebSocketShardStatus.Idle;
116
+
117
+ private identifyCompressionEnabled = false;
118
+
119
+ /**
120
+ * @privateRemarks
121
+ *
122
+ * This is needed because `this.strategy.options.compression` is not an actual reflection of the compression method
123
+ * used, but rather the compression method that the user wants to use.
124
+ *
125
+ * With Bun native compression, we always have compression available if enabled.
126
+ */
127
+ private get transportCompressionEnabled() {
128
+ return this.strategy.options.compression !== null;
129
+ }
130
+
131
+ public get status(): WebSocketShardStatus {
132
+ return this.#status;
133
+ }
134
+
135
+ public constructor(strategy: IContextFetchingStrategy, id: number) {
136
+ super();
137
+ this.strategy = strategy;
138
+ this.id = id;
139
+ }
140
+
141
+ public async connect() {
142
+ const controller = new AbortController();
143
+ let promise;
144
+
145
+ if (!this.initialConnectResolved) {
146
+ // Sleep for the remaining time, but if the connection closes in the meantime, we shouldn't wait the remainder to avoid blocking the new conn
147
+ promise = Promise.race([
148
+ this.awaitEvent(WebSocketShardEvents.Ready, { signal: controller.signal }),
149
+ this.awaitEvent(WebSocketShardEvents.Resumed, { signal: controller.signal }),
150
+ ]);
151
+ }
152
+
153
+ void this.internalConnect();
154
+
155
+ try {
156
+ await promise;
157
+ } finally {
158
+ // cleanup hanging listeners
159
+ controller.abort();
160
+ }
161
+
162
+ this.initialConnectResolved = true;
163
+ }
164
+
165
+ private async internalConnect() {
166
+ if (this.#status !== WebSocketShardStatus.Idle) {
167
+ throw new Error("Tried to connect a shard that wasn't idle");
168
+ }
169
+
170
+ const { version, encoding, compression, useIdentifyCompression } = this.strategy.options;
171
+ this.identifyCompressionEnabled = useIdentifyCompression;
172
+
173
+ const params = new URLSearchParams({ v: version, encoding });
174
+ if (compression !== null) {
175
+ if (useIdentifyCompression) {
176
+ console.warn('WebSocketShard: transport compression is enabled, disabling identify compression');
177
+ this.identifyCompressionEnabled = false;
178
+ }
179
+
180
+ // Bun native compression - no initialization needed, BunInflateHandler handles everything
181
+ params.append('compress', CompressionParameterMap[compression]);
182
+ }
183
+
184
+ // Identify compression is always available with Bun.gzipSync - no need to check availability
185
+
186
+ const session = await this.strategy.retrieveSessionInfo(this.id);
187
+
188
+ const url = `${session?.resumeURL ?? this.strategy.options.gatewayInformation.url}?${params.toString()}`;
189
+
190
+ this.debug([`Connecting to ${url}`]);
191
+
192
+ const connection = new WebSocketConstructor(url);
193
+
194
+ connection.binaryType = 'arraybuffer';
195
+
196
+ connection.onmessage = (event) => {
197
+ void this.onMessage(event.data, event.data instanceof ArrayBuffer);
198
+ };
199
+
200
+ connection.onerror = (event: Event) => {
201
+ this.onError((event as any).error ?? new Error('Unknown WebSocket Error'));
202
+ };
203
+
204
+ connection.onclose = (event) => {
205
+ void this.onClose(event.code);
206
+ };
207
+
208
+ connection.onopen = () => {
209
+ this.sendRateLimitState = getInitialSendRateLimitState();
210
+ };
211
+
212
+ this.connection = connection;
213
+
214
+ this.#status = WebSocketShardStatus.Connecting;
215
+
216
+ const { ok } = await this.waitForEvent(WebSocketShardEvents.Hello, this.strategy.options.helloTimeout);
217
+ if (!ok) {
218
+ return;
219
+ }
220
+
221
+ if (session?.shardCount === this.strategy.options.shardCount) {
222
+ await this.resume(session);
223
+ } else {
224
+ await this.identify();
225
+ }
226
+ }
227
+
228
+ public async destroy(options: WebSocketShardDestroyOptions = {}) {
229
+ if (this.#status === WebSocketShardStatus.Idle) {
230
+ this.debug(['Tried to destroy a shard that was idle']);
231
+ return;
232
+ }
233
+
234
+
235
+ if (!options.code) {
236
+ options.code = options.recover === WebSocketShardDestroyRecovery.Resume ? CloseCodes.Resuming : CloseCodes.Normal;
237
+ }
238
+
239
+ this.debug([
240
+ 'Destroying shard',
241
+ `Reason: ${options.reason ?? 'none'}`,
242
+ `Code: ${options.code}`,
243
+ `Recover: ${options.recover === undefined ? 'none' : WebSocketShardDestroyRecovery[options.recover]!}`,
244
+ ]);
245
+
246
+ // Reset state
247
+ this.isAck = true;
248
+ if (this.heartbeatInterval) {
249
+ clearInterval(this.heartbeatInterval);
250
+ }
251
+
252
+ if (this.initialHeartbeatTimeoutController) {
253
+ this.initialHeartbeatTimeoutController.abort();
254
+ this.initialHeartbeatTimeoutController = null;
255
+ }
256
+
257
+ this.lastHeartbeatAt = -1;
258
+
259
+ for (const controller of this.timeoutAbortControllers.values()) {
260
+ controller.abort();
261
+ }
262
+
263
+ this.timeoutAbortControllers.clear();
264
+
265
+ this.failedToConnectDueToNetworkError = false;
266
+
267
+ // Clear session state if applicable
268
+ if (options.recover !== WebSocketShardDestroyRecovery.Resume) {
269
+ await this.strategy.updateSessionInfo(this.id, null);
270
+ }
271
+
272
+ if (this.connection) {
273
+ // No longer need to listen to messages
274
+ this.connection.onmessage = null;
275
+ // Prevent a reconnection loop by unbinding the main close event
276
+ this.connection.onclose = null;
277
+
278
+ const shouldClose = this.connection.readyState === WebSocket.OPEN;
279
+
280
+ this.debug([
281
+ 'Connection status during destroy',
282
+ `Needs closing: ${shouldClose}`,
283
+ `Ready state: ${this.connection.readyState}`,
284
+ ]);
285
+
286
+ if (shouldClose) {
287
+ let outerResolve: () => void;
288
+ const promise = new Promise<void>((resolve) => {
289
+ outerResolve = resolve;
290
+ });
291
+
292
+ this.connection.onclose = outerResolve!;
293
+
294
+ this.connection.close(options.code, options.reason);
295
+
296
+ await promise;
297
+ this.emit(WebSocketShardEvents.Closed, options.code);
298
+ }
299
+
300
+ // Lastly, remove the error event.
301
+ // Doing this earlier would cause a hard crash in case an error event fired on our `close` call
302
+ this.connection.onerror = null;
303
+ } else {
304
+ this.debug(['Destroying a shard that has no connection; please open an issue on GitHub']);
305
+ }
306
+
307
+ this.#status = WebSocketShardStatus.Idle;
308
+
309
+ if (options.recover !== undefined) {
310
+ // There's cases (like no internet connection) where we immediately fail to connect,
311
+ // causing a very fast and draining reconnection loop.
312
+ await this.sleep(500);
313
+ return this.internalConnect();
314
+ }
315
+ }
316
+
317
+ private async waitForEvent(event: WebSocketShardEvents, timeoutDuration?: number | null): Promise<{ ok: boolean }> {
318
+ this.debug([`Waiting for event ${event} ${timeoutDuration ? `for ${timeoutDuration}ms` : 'indefinitely'}`]);
319
+ const timeoutController = new AbortController();
320
+ const timeout = timeoutDuration ? setTimeout(() => timeoutController.abort(), timeoutDuration).unref() : null;
321
+
322
+ this.timeoutAbortControllers.set(event, timeoutController);
323
+
324
+ const closeController = new AbortController();
325
+
326
+ try {
327
+ // If the first promise resolves, all is well. If the 2nd promise resolves,
328
+ // the shard has meanwhile closed. In that case, a destroy is already ongoing, so we just need to
329
+ // return false. Meanwhile, if something rejects (error event) or the first controller is aborted,
330
+ // we enter the catch block and trigger a destroy there.
331
+ const closed = await Promise.race<boolean>([
332
+ this.awaitEvent(event, { signal: timeoutController.signal }).then(() => false),
333
+ this.awaitEvent(WebSocketShardEvents.Closed, { signal: closeController.signal }).then(() => true),
334
+ ]);
335
+
336
+ return { ok: !closed };
337
+ } catch {
338
+ // If we're here because of other reasons, we need to destroy the shard
339
+ void this.destroy({
340
+ code: CloseCodes.Normal,
341
+ reason: 'Something timed out or went wrong while waiting for an event',
342
+ recover: WebSocketShardDestroyRecovery.Reconnect,
343
+ });
344
+
345
+ return { ok: false };
346
+ } finally {
347
+ if (timeout) {
348
+ clearTimeout(timeout);
349
+ }
350
+
351
+ this.timeoutAbortControllers.delete(event);
352
+
353
+ // Clean up the close listener to not leak memory
354
+ if (!closeController.signal.aborted) {
355
+ closeController.abort();
356
+ }
357
+ }
358
+ }
359
+
360
+ public async send(payload: GatewaySendPayload): Promise<void> {
361
+ if (!this.connection) {
362
+ throw new Error("WebSocketShard wasn't connected");
363
+ }
364
+
365
+ // Generally, the way we treat payloads is 115/60 seconds. The actual limit is 120/60, so we have a bit of leeway.
366
+ // We use that leeway for those special payloads that we just fire with no checking, since there's no shot we ever
367
+ // send more than 5 of those in a 60 second interval. This way we can avoid more complex queueing logic.
368
+
369
+ if (ImportantGatewayOpcodes.has(payload.op)) {
370
+ this.connection.send(JSON.stringify(payload));
371
+ return;
372
+ }
373
+
374
+ if (this.#status !== WebSocketShardStatus.Ready && !ImportantGatewayOpcodes.has(payload.op)) {
375
+ this.debug(['Tried to send a non-crucial payload before the shard was ready, waiting']);
376
+ // This will throw if the shard throws an error event in the meantime, just requeue the payload
377
+ try {
378
+ await this.awaitEvent(WebSocketShardEvents.Ready);
379
+ } catch {
380
+ return this.send(payload);
381
+ }
382
+ }
383
+
384
+ await this.sendQueue.wait();
385
+
386
+ const now = Date.now();
387
+ if (now >= this.sendRateLimitState.resetAt) {
388
+ this.sendRateLimitState = getInitialSendRateLimitState();
389
+ }
390
+
391
+ if (this.sendRateLimitState.sent + 1 >= 115) {
392
+ // Sprinkle in a little randomness just in case.
393
+ const sleepFor = this.sendRateLimitState.resetAt - now + Math.random() * 1_500;
394
+
395
+ this.debug([`Was about to hit the send rate limit, sleeping for ${sleepFor}ms`]);
396
+ const controller = new AbortController();
397
+
398
+ // Sleep for the remaining time, but if the connection closes in the meantime, we shouldn't wait the remainder to avoid blocking the new conn
399
+ const interrupted = await Promise.race([
400
+ this.sleep(sleepFor).then(() => false),
401
+ this.awaitEvent(WebSocketShardEvents.Closed, { signal: controller.signal }).then(() => true),
402
+ ]);
403
+
404
+ if (interrupted) {
405
+ this.debug(['Connection closed while waiting for the send rate limit to reset, re-queueing payload']);
406
+ this.sendQueue.shift();
407
+ return this.send(payload);
408
+ }
409
+
410
+ // This is so the listener from the `once` call is removed
411
+ controller.abort();
412
+ }
413
+
414
+ this.sendRateLimitState.sent++;
415
+
416
+ this.sendQueue.shift();
417
+ this.connection.send(JSON.stringify(payload));
418
+ }
419
+
420
+ private async identify() {
421
+ this.debug(['Waiting for identify throttle']);
422
+
423
+ const controller = new AbortController();
424
+ const closeHandler = () => {
425
+ controller.abort();
426
+ };
427
+
428
+ this.on(WebSocketShardEvents.Closed, closeHandler);
429
+
430
+ try {
431
+ await this.strategy.waitForIdentify(this.id, controller.signal);
432
+ } catch {
433
+ if (controller.signal.aborted) {
434
+ this.debug(['Was waiting for an identify, but the shard closed in the meantime']);
435
+ return;
436
+ }
437
+
438
+ this.debug([
439
+ 'IContextFetchingStrategy#waitForIdentify threw an unknown error.',
440
+ "If you're using a custom strategy, this is probably nothing to worry about.",
441
+ "If you're not, please open an issue on GitHub.",
442
+ ]);
443
+
444
+ await this.destroy({
445
+ reason: 'Identify throttling logic failed',
446
+ recover: WebSocketShardDestroyRecovery.Resume,
447
+ });
448
+ } finally {
449
+ this.off(WebSocketShardEvents.Closed, closeHandler);
450
+ }
451
+
452
+ this.debug([
453
+ 'Identifying',
454
+ `shard id: ${this.id.toString()}`,
455
+ `shard count: ${this.strategy.options.shardCount}`,
456
+ `intents: ${this.strategy.options.intents}`,
457
+ `compression: ${this.transportCompressionEnabled ? CompressionParameterMap[this.strategy.options.compression!] : this.identifyCompressionEnabled ? 'identify' : 'none'}`,
458
+ ]);
459
+
460
+ const data: GatewayIdentifyData = {
461
+ token: this.strategy.options.token,
462
+ properties: this.strategy.options.identifyProperties,
463
+ intents: this.strategy.options.intents,
464
+ compress: this.identifyCompressionEnabled,
465
+ shard: [this.id, this.strategy.options.shardCount],
466
+ };
467
+
468
+ if (this.strategy.options.largeThreshold) {
469
+ data.large_threshold = this.strategy.options.largeThreshold;
470
+ }
471
+
472
+ if (this.strategy.options.initialPresence) {
473
+ data.presence = this.strategy.options.initialPresence;
474
+ }
475
+
476
+ await this.send({
477
+ op: GatewayOpcodes.Identify,
478
+
479
+ d: data,
480
+ });
481
+
482
+ await this.waitForEvent(WebSocketShardEvents.Ready, this.strategy.options.readyTimeout);
483
+ }
484
+
485
+ private async resume(session: SessionInfo) {
486
+ this.debug([
487
+ 'Resuming session',
488
+ `resume url: ${session.resumeURL}`,
489
+ `sequence: ${session.sequence}`,
490
+ `shard id: ${this.id.toString()}`,
491
+ ]);
492
+
493
+ this.#status = WebSocketShardStatus.Resuming;
494
+ this.replayedEvents = 0;
495
+ return this.send({
496
+ op: GatewayOpcodes.Resume,
497
+
498
+ d: {
499
+ token: this.strategy.options.token,
500
+ seq: session.sequence,
501
+ session_id: session.sessionId,
502
+ },
503
+ });
504
+ }
505
+
506
+ private async heartbeat(requested = false) {
507
+ if (!this.isAck && !requested) {
508
+ return this.destroy({ reason: 'Zombie connection', recover: WebSocketShardDestroyRecovery.Resume });
509
+ }
510
+
511
+ const session = await this.strategy.retrieveSessionInfo(this.id);
512
+
513
+ await this.send({
514
+ op: GatewayOpcodes.Heartbeat,
515
+
516
+ d: session?.sequence ?? null,
517
+ });
518
+
519
+ this.lastHeartbeatAt = Date.now();
520
+ this.isAck = false;
521
+ }
522
+
523
+
524
+
525
+ private async unpackMessage(data: ArrayBuffer | string, isBinary: boolean): Promise<GatewayReceivePayload | null> {
526
+ // Deal with no compression
527
+ if (!isBinary) {
528
+ try {
529
+ return JSON.parse(data as string) as GatewayReceivePayload;
530
+ } catch {
531
+ // This is a non-JSON payload / (at the time of writing this comment) emitted by bun wrongly interpreting custom close codes https://github.com/oven-sh/bun/issues/3392
532
+ return null;
533
+ }
534
+ }
535
+
536
+ const decompressable = new Uint8Array(data as ArrayBuffer);
537
+
538
+ // Deal with identify compress (one-shot gzip compression)
539
+ if (this.identifyCompressionEnabled) {
540
+ try {
541
+ const decompressed = this.bunInflate.process(decompressable, true);
542
+ if (decompressed) {
543
+ return JSON.parse(decompressed) as GatewayReceivePayload;
544
+ }
545
+ return null;
546
+ } catch (error) {
547
+ this.emit(WebSocketShardEvents.Error, error as Error);
548
+ return null;
549
+ }
550
+ }
551
+
552
+ // Deal with transport compression (streaming zlib)
553
+ if (this.transportCompressionEnabled) {
554
+ try {
555
+ const decompressed = this.bunInflate.process(decompressable, false);
556
+ if (decompressed) {
557
+ return JSON.parse(decompressed) as GatewayReceivePayload;
558
+ }
559
+ // Message not complete yet (waiting for suffix)
560
+ return null;
561
+ } catch (error) {
562
+ this.emit(WebSocketShardEvents.Error, error as Error);
563
+ return null;
564
+ }
565
+ }
566
+
567
+ this.debug([
568
+ 'Received a message we were unable to decompress',
569
+ `isBinary: ${isBinary.toString()}`,
570
+ `identifyCompressionEnabled: ${this.identifyCompressionEnabled.toString()}`,
571
+ `inflate: ${this.transportCompressionEnabled ? CompressionMethod[this.strategy.options.compression!] : 'none'}`,
572
+ ]);
573
+
574
+ return null;
575
+ }
576
+
577
+ private async onMessage(data: ArrayBuffer | string, isBinary: boolean) {
578
+ const payload = await this.unpackMessage(data, isBinary);
579
+ if (!payload) {
580
+ return;
581
+ }
582
+
583
+ switch (payload.op) {
584
+ case GatewayOpcodes.Dispatch: {
585
+ if (this.#status === WebSocketShardStatus.Resuming) {
586
+ this.replayedEvents++;
587
+ }
588
+
589
+ // eslint-disable-next-line sonarjs/no-nested-switch
590
+ switch (payload.t) {
591
+ case GatewayDispatchEvents.Ready: {
592
+ this.#status = WebSocketShardStatus.Ready;
593
+
594
+ const session = {
595
+ sequence: payload.s,
596
+ sessionId: payload.d.session_id,
597
+ shardId: this.id,
598
+ shardCount: this.strategy.options.shardCount,
599
+ resumeURL: payload.d.resume_gateway_url,
600
+ };
601
+
602
+ await this.strategy.updateSessionInfo(this.id, session);
603
+
604
+ this.emit(WebSocketShardEvents.Ready, payload.d);
605
+ break;
606
+ }
607
+
608
+ case GatewayDispatchEvents.Resumed: {
609
+ this.#status = WebSocketShardStatus.Ready;
610
+ this.debug([`Resumed and replayed ${this.replayedEvents} events`]);
611
+ this.emit(WebSocketShardEvents.Resumed);
612
+ break;
613
+ }
614
+
615
+ default: {
616
+ break;
617
+ }
618
+ }
619
+
620
+ const session = await this.strategy.retrieveSessionInfo(this.id);
621
+ if (session) {
622
+ if (payload.s > session.sequence) {
623
+ await this.strategy.updateSessionInfo(this.id, { ...session, sequence: payload.s });
624
+ }
625
+ } else {
626
+ this.debug([
627
+ `Received a ${payload.t} event but no session is available. Session information cannot be re-constructed in this state without a full reconnect`,
628
+ ]);
629
+ }
630
+
631
+ this.emit(WebSocketShardEvents.Dispatch, payload);
632
+
633
+ break;
634
+ }
635
+
636
+ case GatewayOpcodes.Heartbeat: {
637
+ await this.heartbeat(true);
638
+ break;
639
+ }
640
+
641
+ case GatewayOpcodes.Reconnect: {
642
+ await this.destroy({
643
+ reason: 'Told to reconnect by Discord',
644
+ recover: WebSocketShardDestroyRecovery.Resume,
645
+ });
646
+ break;
647
+ }
648
+
649
+ case GatewayOpcodes.InvalidSession: {
650
+ this.debug([`Invalid session; will attempt to resume: ${payload.d.toString()}`]);
651
+ const session = await this.strategy.retrieveSessionInfo(this.id);
652
+ if (payload.d && session) {
653
+ await this.resume(session);
654
+ } else {
655
+ await this.destroy({
656
+ reason: 'Invalid session',
657
+ recover: WebSocketShardDestroyRecovery.Reconnect,
658
+ });
659
+ }
660
+
661
+ break;
662
+ }
663
+
664
+ case GatewayOpcodes.Hello: {
665
+ this.emit(WebSocketShardEvents.Hello);
666
+ const jitter = Math.random();
667
+ const firstWait = Math.floor(payload.d.heartbeat_interval * jitter);
668
+ this.debug([`Preparing first heartbeat of the connection with a jitter of ${jitter}; waiting ${firstWait}ms`]);
669
+
670
+ try {
671
+ const controller = new AbortController();
672
+ this.initialHeartbeatTimeoutController = controller;
673
+ await this.sleep(firstWait);
674
+ } catch {
675
+ this.debug(['Cancelled initial heartbeat due to #destroy being called']);
676
+ return;
677
+ } finally {
678
+ this.initialHeartbeatTimeoutController = null;
679
+ }
680
+
681
+ await this.heartbeat();
682
+
683
+ this.debug([`First heartbeat sent, starting to beat every ${payload.d.heartbeat_interval}ms`]);
684
+ this.heartbeatInterval = setInterval(() => void this.heartbeat(), payload.d.heartbeat_interval);
685
+ break;
686
+ }
687
+
688
+ case GatewayOpcodes.HeartbeatAck: {
689
+ this.isAck = true;
690
+
691
+ const ackAt = Date.now();
692
+ this.emit(WebSocketShardEvents.HeartbeatComplete, {
693
+ ackAt,
694
+ heartbeatAt: this.lastHeartbeatAt,
695
+ latency: ackAt - this.lastHeartbeatAt,
696
+ });
697
+
698
+ break;
699
+ }
700
+ }
701
+ }
702
+
703
+ private onError(error: Error) {
704
+ this.emit(WebSocketShardEvents.SocketError, error);
705
+ this.failedToConnectDueToNetworkError = true;
706
+ }
707
+
708
+ private async onClose(code: number) {
709
+ this.emit(WebSocketShardEvents.Closed, code);
710
+
711
+ switch (code) {
712
+ case CloseCodes.Normal: {
713
+ return this.destroy({
714
+ code,
715
+ reason: 'Got disconnected by Discord',
716
+ recover: WebSocketShardDestroyRecovery.Reconnect,
717
+ });
718
+ }
719
+
720
+ case CloseCodes.Resuming: {
721
+ break;
722
+ }
723
+
724
+ case GatewayCloseCodes.UnknownError: {
725
+ this.debug([`An unknown error occurred: ${code}`]);
726
+ return this.destroy({ code, recover: WebSocketShardDestroyRecovery.Resume });
727
+ }
728
+
729
+ case GatewayCloseCodes.UnknownOpcode: {
730
+ this.debug(['An invalid opcode was sent to Discord.']);
731
+ return this.destroy({ code, recover: WebSocketShardDestroyRecovery.Resume });
732
+ }
733
+
734
+ case GatewayCloseCodes.DecodeError: {
735
+ this.debug(['An invalid payload was sent to Discord.']);
736
+ return this.destroy({ code, recover: WebSocketShardDestroyRecovery.Resume });
737
+ }
738
+
739
+ case GatewayCloseCodes.NotAuthenticated: {
740
+ this.debug(['A request was somehow sent before the identify/resume payload.']);
741
+ return this.destroy({ code, recover: WebSocketShardDestroyRecovery.Reconnect });
742
+ }
743
+
744
+ case GatewayCloseCodes.AuthenticationFailed: {
745
+ this.emit(
746
+ WebSocketShardEvents.Error,
747
+
748
+ new Error('Authentication failed'),
749
+ );
750
+ return this.destroy({ code });
751
+ }
752
+
753
+ case GatewayCloseCodes.AlreadyAuthenticated: {
754
+ this.debug(['More than one auth payload was sent.']);
755
+ return this.destroy({ code, recover: WebSocketShardDestroyRecovery.Reconnect });
756
+ }
757
+
758
+ case GatewayCloseCodes.InvalidSeq: {
759
+ this.debug(['An invalid sequence was sent.']);
760
+ return this.destroy({ code, recover: WebSocketShardDestroyRecovery.Reconnect });
761
+ }
762
+
763
+ case GatewayCloseCodes.RateLimited: {
764
+ this.debug(['The WebSocket rate limit has been hit, this should never happen']);
765
+ return this.destroy({ code, recover: WebSocketShardDestroyRecovery.Reconnect });
766
+ }
767
+
768
+ case GatewayCloseCodes.SessionTimedOut: {
769
+ this.debug(['Session timed out.']);
770
+ return this.destroy({ code, recover: WebSocketShardDestroyRecovery.Resume });
771
+ }
772
+
773
+ case GatewayCloseCodes.InvalidShard: {
774
+ this.emit(WebSocketShardEvents.Error, new Error('Invalid shard'));
775
+ return this.destroy({ code });
776
+ }
777
+
778
+ case GatewayCloseCodes.ShardingRequired: {
779
+ this.emit(
780
+ WebSocketShardEvents.Error,
781
+
782
+ new Error('Sharding is required'),
783
+ );
784
+ return this.destroy({ code });
785
+ }
786
+
787
+ case GatewayCloseCodes.InvalidAPIVersion: {
788
+ this.emit(
789
+ WebSocketShardEvents.Error,
790
+
791
+ new Error('Used an invalid API version'),
792
+ );
793
+ return this.destroy({ code });
794
+ }
795
+
796
+ case GatewayCloseCodes.InvalidIntents: {
797
+ this.emit(
798
+ WebSocketShardEvents.Error,
799
+
800
+ new Error('Used invalid intents'),
801
+ );
802
+ return this.destroy({ code });
803
+ }
804
+
805
+ case GatewayCloseCodes.DisallowedIntents: {
806
+ this.emit(
807
+ WebSocketShardEvents.Error,
808
+
809
+ new Error('Used disallowed intents'),
810
+ );
811
+ return this.destroy({ code });
812
+ }
813
+
814
+ default: {
815
+ this.debug([
816
+ `The gateway closed with an unexpected code ${code}, attempting to ${
817
+ this.failedToConnectDueToNetworkError ? 'reconnect' : 'resume'
818
+ }.`,
819
+ ]);
820
+ return this.destroy({
821
+ code,
822
+ recover: this.failedToConnectDueToNetworkError
823
+ ? WebSocketShardDestroyRecovery.Reconnect
824
+ : WebSocketShardDestroyRecovery.Resume,
825
+ });
826
+ }
827
+ }
828
+ }
829
+
830
+ private debug(messages: [string, ...string[]]) {
831
+ this.emitEvent(WebSocketShardEvents.Debug, messages.join('\n\t'));
832
+ }
833
+
834
+ // Helper for sleep using Bun native
835
+ private async sleep(ms: number) {
836
+ await Bun.sleep(ms);
837
+ }
838
+
839
+ // Helper to handle emit type casting
840
+ private emitEvent(event: WebSocketShardEvents, ...args: any[]) {
841
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
842
+ (this as any).emit(event, ...args);
843
+ }
844
+
845
+ // Helper to replace Node.js once
846
+ private awaitEvent(event: WebSocketShardEvents, options?: { signal?: AbortSignal }): Promise<any[]> {
847
+ return new Promise((resolve, reject) => {
848
+ const listener = (...args: any[]) => {
849
+ resolve(args);
850
+ };
851
+
852
+ if (options?.signal) {
853
+ const { signal } = options;
854
+ if (signal.aborted) {
855
+ reject(signal.reason);
856
+ return;
857
+ }
858
+ signal.addEventListener('abort', () => {
859
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
860
+ (this as any).off(event, listener);
861
+ reject(signal.reason);
862
+ });
863
+ }
864
+
865
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
866
+ (this as any).once(event, listener);
867
+ });
868
+ }
869
+
870
+ // Override emit to fix TypeScript visibility/type issues
871
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
872
+ public override emit(event: any, ...args: any[]): boolean {
873
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
874
+ return (super.emit as any)(event, ...args);
875
+ }
876
+ }