@buape/carbon 0.14.0 → 0.16.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.
Files changed (182) hide show
  1. package/LICENSE +1 -1
  2. package/dist/package.json +11 -9
  3. package/dist/src/abstracts/BaseChannel.d.ts +24 -2
  4. package/dist/src/abstracts/BaseChannel.d.ts.map +1 -1
  5. package/dist/src/abstracts/BaseChannel.js +45 -4
  6. package/dist/src/abstracts/BaseChannel.js.map +1 -1
  7. package/dist/src/abstracts/BaseCommand.d.ts +15 -3
  8. package/dist/src/abstracts/BaseCommand.d.ts.map +1 -1
  9. package/dist/src/abstracts/BaseCommand.js +32 -3
  10. package/dist/src/abstracts/BaseCommand.js.map +1 -1
  11. package/dist/src/abstracts/BaseInteraction.d.ts +1 -1
  12. package/dist/src/abstracts/BaseInteraction.d.ts.map +1 -1
  13. package/dist/src/abstracts/BaseInteraction.js +3 -3
  14. package/dist/src/abstracts/BaseInteraction.js.map +1 -1
  15. package/dist/src/abstracts/BaseListener.d.ts +7 -4
  16. package/dist/src/abstracts/BaseListener.d.ts.map +1 -1
  17. package/dist/src/abstracts/BaseListener.js.map +1 -1
  18. package/dist/src/abstracts/Plugin.d.ts +6 -0
  19. package/dist/src/abstracts/Plugin.d.ts.map +1 -1
  20. package/dist/src/abstracts/Plugin.js.map +1 -1
  21. package/dist/src/adapters/bun/index.d.ts +1 -1
  22. package/dist/src/adapters/bun/index.d.ts.map +1 -1
  23. package/dist/src/adapters/bun/index.js +6 -0
  24. package/dist/src/adapters/bun/index.js.map +1 -1
  25. package/dist/src/adapters/fetch/index.d.ts.map +1 -1
  26. package/dist/src/adapters/fetch/index.js +6 -0
  27. package/dist/src/adapters/fetch/index.js.map +1 -1
  28. package/dist/src/classes/Client.d.ts +97 -6
  29. package/dist/src/classes/Client.d.ts.map +1 -1
  30. package/dist/src/classes/Client.js +187 -12
  31. package/dist/src/classes/Client.js.map +1 -1
  32. package/dist/src/classes/EntryPointCommand.d.ts +11 -0
  33. package/dist/src/classes/EntryPointCommand.d.ts.map +1 -0
  34. package/dist/src/classes/EntryPointCommand.js +13 -0
  35. package/dist/src/classes/EntryPointCommand.js.map +1 -0
  36. package/dist/src/classes/Listener.d.ts +83 -81
  37. package/dist/src/classes/Listener.d.ts.map +1 -1
  38. package/dist/src/classes/Listener.js +15 -4
  39. package/dist/src/classes/Listener.js.map +1 -1
  40. package/dist/src/classes/RequestClient.d.ts +92 -17
  41. package/dist/src/classes/RequestClient.d.ts.map +1 -1
  42. package/dist/src/classes/RequestClient.js +207 -136
  43. package/dist/src/classes/RequestClient.js.map +1 -1
  44. package/dist/src/errors/RatelimitError.d.ts +4 -1
  45. package/dist/src/errors/RatelimitError.d.ts.map +1 -1
  46. package/dist/src/errors/RatelimitError.js +7 -1
  47. package/dist/src/errors/RatelimitError.js.map +1 -1
  48. package/dist/src/functions/channelFactory.d.ts +4 -12
  49. package/dist/src/functions/channelFactory.d.ts.map +1 -1
  50. package/dist/src/functions/channelFactory.js +39 -13
  51. package/dist/src/functions/channelFactory.js.map +1 -1
  52. package/dist/src/index.d.ts +1 -0
  53. package/dist/src/index.d.ts.map +1 -1
  54. package/dist/src/index.js +1 -0
  55. package/dist/src/index.js.map +1 -1
  56. package/dist/src/internals/BoundedExecutor.d.ts +12 -0
  57. package/dist/src/internals/BoundedExecutor.d.ts.map +1 -0
  58. package/dist/src/internals/BoundedExecutor.js +60 -0
  59. package/dist/src/internals/BoundedExecutor.js.map +1 -0
  60. package/dist/src/internals/CommandHandler.d.ts.map +1 -1
  61. package/dist/src/internals/CommandHandler.js +53 -6
  62. package/dist/src/internals/CommandHandler.js.map +1 -1
  63. package/dist/src/internals/ComponentHandler.d.ts +2 -1
  64. package/dist/src/internals/ComponentHandler.d.ts.map +1 -1
  65. package/dist/src/internals/ComponentHandler.js +11 -4
  66. package/dist/src/internals/ComponentHandler.js.map +1 -1
  67. package/dist/src/internals/EventHandler.d.ts +18 -3
  68. package/dist/src/internals/EventHandler.d.ts.map +1 -1
  69. package/dist/src/internals/EventQueue.d.ts +43 -12
  70. package/dist/src/internals/EventQueue.d.ts.map +1 -1
  71. package/dist/src/internals/EventQueue.js +294 -62
  72. package/dist/src/internals/EventQueue.js.map +1 -1
  73. package/dist/src/internals/FieldsHandler.d.ts.map +1 -1
  74. package/dist/src/internals/FieldsHandler.js +10 -1
  75. package/dist/src/internals/FieldsHandler.js.map +1 -1
  76. package/dist/src/internals/RequestBody.d.ts +6 -0
  77. package/dist/src/internals/RequestBody.d.ts.map +1 -0
  78. package/dist/src/internals/RequestBody.js +74 -0
  79. package/dist/src/internals/RequestBody.js.map +1 -0
  80. package/dist/src/internals/RequestScheduler.d.ts +131 -0
  81. package/dist/src/internals/RequestScheduler.d.ts.map +1 -0
  82. package/dist/src/internals/RequestScheduler.js +245 -0
  83. package/dist/src/internals/RequestScheduler.js.map +1 -0
  84. package/dist/src/internals/TemporaryListenerManager.d.ts +3 -3
  85. package/dist/src/internals/TemporaryListenerManager.d.ts.map +1 -1
  86. package/dist/src/internals/TemporaryListenerManager.js +1 -1
  87. package/dist/src/internals/TemporaryListenerManager.js.map +1 -1
  88. package/dist/src/plugins/client-manager/ClientManager.d.ts +30 -2
  89. package/dist/src/plugins/client-manager/ClientManager.d.ts.map +1 -1
  90. package/dist/src/plugins/client-manager/ClientManager.js +86 -16
  91. package/dist/src/plugins/client-manager/ClientManager.js.map +1 -1
  92. package/dist/src/plugins/cloudflare-gateway/CloudflareGatewayDurableObject.d.ts +43 -0
  93. package/dist/src/plugins/cloudflare-gateway/CloudflareGatewayDurableObject.d.ts.map +1 -0
  94. package/dist/src/plugins/cloudflare-gateway/CloudflareGatewayDurableObject.js +210 -0
  95. package/dist/src/plugins/cloudflare-gateway/CloudflareGatewayDurableObject.js.map +1 -0
  96. package/dist/src/plugins/cloudflare-gateway/CloudflareGatewayPlugin.d.ts +16 -0
  97. package/dist/src/plugins/cloudflare-gateway/CloudflareGatewayPlugin.d.ts.map +1 -0
  98. package/dist/src/plugins/cloudflare-gateway/CloudflareGatewayPlugin.js +129 -0
  99. package/dist/src/plugins/cloudflare-gateway/CloudflareGatewayPlugin.js.map +1 -0
  100. package/dist/src/plugins/cloudflare-gateway/index.d.ts +4 -0
  101. package/dist/src/plugins/cloudflare-gateway/index.d.ts.map +1 -0
  102. package/dist/src/plugins/cloudflare-gateway/index.js +4 -0
  103. package/dist/src/plugins/cloudflare-gateway/index.js.map +1 -0
  104. package/dist/src/plugins/cloudflare-gateway/types.d.ts +63 -0
  105. package/dist/src/plugins/cloudflare-gateway/types.d.ts.map +1 -0
  106. package/dist/src/plugins/cloudflare-gateway/types.js +2 -0
  107. package/dist/src/plugins/cloudflare-gateway/types.js.map +1 -0
  108. package/dist/src/plugins/gateway/GatewayPlugin.d.ts +108 -19
  109. package/dist/src/plugins/gateway/GatewayPlugin.d.ts.map +1 -1
  110. package/dist/src/plugins/gateway/GatewayPlugin.js +564 -253
  111. package/dist/src/plugins/gateway/GatewayPlugin.js.map +1 -1
  112. package/dist/src/plugins/gateway/types.d.ts +33 -1
  113. package/dist/src/plugins/gateway/types.d.ts.map +1 -1
  114. package/dist/src/plugins/gateway/types.js +21 -0
  115. package/dist/src/plugins/gateway/types.js.map +1 -1
  116. package/dist/src/plugins/gateway/utils/heartbeat.d.ts.map +1 -1
  117. package/dist/src/plugins/gateway/utils/heartbeat.js +10 -4
  118. package/dist/src/plugins/gateway/utils/heartbeat.js.map +1 -1
  119. package/dist/src/plugins/gateway/utils/payload.d.ts.map +1 -1
  120. package/dist/src/plugins/gateway/utils/payload.js +0 -4
  121. package/dist/src/plugins/gateway/utils/payload.js.map +1 -1
  122. package/dist/src/plugins/gateway-forwarder/GatewayForwarderPlugin.d.ts +56 -3
  123. package/dist/src/plugins/gateway-forwarder/GatewayForwarderPlugin.d.ts.map +1 -1
  124. package/dist/src/plugins/gateway-forwarder/GatewayForwarderPlugin.js +224 -21
  125. package/dist/src/plugins/gateway-forwarder/GatewayForwarderPlugin.js.map +1 -1
  126. package/dist/src/plugins/paginator/index.d.ts +1 -1
  127. package/dist/src/plugins/paginator/index.d.ts.map +1 -1
  128. package/dist/src/plugins/paginator/index.js +1 -1
  129. package/dist/src/plugins/paginator/index.js.map +1 -1
  130. package/dist/src/plugins/voice/VoicePlugin.d.ts.map +1 -1
  131. package/dist/src/plugins/voice/VoicePlugin.js +5 -1
  132. package/dist/src/plugins/voice/VoicePlugin.js.map +1 -1
  133. package/dist/src/plugins/voice/VoiceServerUpdateListener.d.ts +10 -0
  134. package/dist/src/plugins/voice/VoiceServerUpdateListener.d.ts.map +1 -0
  135. package/dist/src/plugins/voice/VoiceServerUpdateListener.js +16 -0
  136. package/dist/src/plugins/voice/VoiceServerUpdateListener.js.map +1 -0
  137. package/dist/src/plugins/voice/VoiceStateUpdateListener.d.ts +10 -0
  138. package/dist/src/plugins/voice/VoiceStateUpdateListener.d.ts.map +1 -0
  139. package/dist/src/plugins/voice/VoiceStateUpdateListener.js +21 -0
  140. package/dist/src/plugins/voice/VoiceStateUpdateListener.js.map +1 -0
  141. package/dist/src/structures/GroupDmChannel.d.ts +5 -1
  142. package/dist/src/structures/GroupDmChannel.d.ts.map +1 -1
  143. package/dist/src/structures/GroupDmChannel.js +10 -1
  144. package/dist/src/structures/GroupDmChannel.js.map +1 -1
  145. package/dist/src/structures/Guild.d.ts +13 -4
  146. package/dist/src/structures/Guild.d.ts.map +1 -1
  147. package/dist/src/structures/Guild.js +9 -1
  148. package/dist/src/structures/Guild.js.map +1 -1
  149. package/dist/src/structures/GuildMediaChannel.d.ts +1 -1
  150. package/dist/src/structures/GuildMediaChannel.d.ts.map +1 -1
  151. package/dist/src/structures/GuildMediaChannel.js.map +1 -1
  152. package/dist/src/structures/Message.d.ts +1 -1
  153. package/dist/src/structures/Poll.d.ts +1 -1
  154. package/dist/src/structures/Poll.d.ts.map +1 -1
  155. package/dist/src/structures/Webhook.d.ts +4 -2
  156. package/dist/src/structures/Webhook.d.ts.map +1 -1
  157. package/dist/src/structures/Webhook.js +42 -29
  158. package/dist/src/structures/Webhook.js.map +1 -1
  159. package/dist/src/types/channels.d.ts +33 -0
  160. package/dist/src/types/channels.d.ts.map +1 -0
  161. package/dist/src/types/channels.js +2 -0
  162. package/dist/src/types/channels.js.map +1 -0
  163. package/dist/src/types/commandMiddleware.d.ts +64 -0
  164. package/dist/src/types/commandMiddleware.d.ts.map +1 -0
  165. package/dist/src/types/commandMiddleware.js +2 -0
  166. package/dist/src/types/commandMiddleware.js.map +1 -0
  167. package/dist/src/types/index.d.ts +10 -5
  168. package/dist/src/types/index.d.ts.map +1 -1
  169. package/dist/src/types/index.js +2 -0
  170. package/dist/src/types/index.js.map +1 -1
  171. package/dist/src/types/listeners.d.ts +13 -4
  172. package/dist/src/types/listeners.d.ts.map +1 -1
  173. package/dist/src/types/listeners.js.map +1 -1
  174. package/dist/src/utils/payload.d.ts.map +1 -1
  175. package/dist/src/utils/payload.js +16 -0
  176. package/dist/src/utils/payload.js.map +1 -1
  177. package/dist/tsconfig.tsbuildinfo +1 -1
  178. package/package.json +11 -9
  179. package/dist/src/adapters/node/index.d.ts +0 -13
  180. package/dist/src/adapters/node/index.d.ts.map +0 -1
  181. package/dist/src/adapters/node/index.js +0 -17
  182. package/dist/src/adapters/node/index.js.map +0 -1
@@ -1,14 +1,18 @@
1
1
  import { EventEmitter } from "node:events";
2
- import WebSocket from "ws";
2
+ import { createRequire } from "node:module";
3
3
  import { Plugin } from "../../abstracts/Plugin.js";
4
- import { ListenerEvent } from "../../types/index.js";
5
4
  import { BabyCache } from "./BabyCache.js";
6
5
  import { InteractionEventListener } from "./InteractionEventListener.js";
7
- import { GatewayCloseCodes, GatewayIntents, GatewayOpcodes } from "./types.js";
6
+ import { fatalGatewayCloseCodes, GatewayIntents, GatewayOpcodes, listenerEvents, nonResumableGatewayCloseCodes, reconnectDefaults } from "./types.js";
8
7
  import { startHeartbeat, stopHeartbeat } from "./utils/heartbeat.js";
9
8
  import { ConnectionMonitor } from "./utils/monitor.js";
10
9
  import { createIdentifyPayload, createRequestGuildMembersPayload, createResumePayload, createUpdatePresencePayload, createUpdateVoiceStatePayload, validatePayload } from "./utils/payload.js";
11
10
  import { GatewayRateLimit } from "./utils/rateLimit.js";
11
+ const textDecoder = new TextDecoder();
12
+ const socketOpenState = 1;
13
+ const nodeRequire = typeof process !== "undefined" && process.versions?.node
14
+ ? createRequire(import.meta.url)
15
+ : null;
12
16
  export class GatewayPlugin extends Plugin {
13
17
  id = "gateway";
14
18
  client;
@@ -18,6 +22,7 @@ export class GatewayPlugin extends Plugin {
18
22
  monitor;
19
23
  rateLimit;
20
24
  heartbeatInterval;
25
+ firstHeartbeatTimeout;
21
26
  sequence = null;
22
27
  lastHeartbeatAck = true;
23
28
  emitter;
@@ -30,15 +35,20 @@ export class GatewayPlugin extends Plugin {
30
35
  babyCache;
31
36
  reconnectTimeout;
32
37
  isConnecting = false;
38
+ socketGeneration = 0;
39
+ shouldReconnect = false;
40
+ nextConnectionShouldResume = false;
41
+ silentSocketClosures = new WeakSet();
42
+ consecutiveResumeFailures = 0;
33
43
  constructor(options, gatewayInfo) {
34
44
  super();
35
45
  this.options = {
36
46
  reconnect: {
37
- maxAttempts: 5,
38
- baseDelay: 1000,
39
- maxDelay: 30000
47
+ ...reconnectDefaults,
48
+ ...options.reconnect
40
49
  },
41
- ...options
50
+ ...options,
51
+ intents: options.intents ?? 0
42
52
  };
43
53
  this.state = {
44
54
  sequence: null,
@@ -58,305 +68,265 @@ export class GatewayPlugin extends Plugin {
58
68
  ? this.pings.reduce((a, b) => a + b, 0) / this.pings.length
59
69
  : null;
60
70
  }
71
+ /**
72
+ * Bootstraps gateway metadata and opens the initial websocket connection.
73
+ */
61
74
  async registerClient(client) {
62
75
  this.client = client;
63
76
  if (!this.gatewayInfo) {
77
+ let response;
64
78
  try {
65
- const response = await fetch("https://discord.com/api/v10/gateway/bot", {
79
+ response = await fetch("https://discord.com/api/v10/gateway/bot", {
66
80
  headers: {
67
81
  Authorization: `Bot ${client.options.token}`
68
82
  }
69
83
  });
70
- this.gatewayInfo = (await response.json());
71
84
  }
72
85
  catch (error) {
73
- throw new Error(`Failed to get gateway information from Discord: ${error instanceof Error ? error.message : String(error)}`);
86
+ throw new Error(`Failed to get gateway information from Discord: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
74
87
  }
88
+ if (!response.ok) {
89
+ throw new Error(`Failed to get gateway information from Discord: ${response.status} ${response.statusText}`);
90
+ }
91
+ this.gatewayInfo = (await response.json());
75
92
  }
76
- // Set shard information on the client
77
93
  if (this.options.shard) {
78
94
  client.shardId = this.options.shard[0];
79
95
  client.totalShards = this.options.shard[1];
80
96
  }
81
97
  if (this.options.autoInteractions) {
82
- this.client?.listeners.push(new InteractionEventListener());
98
+ this.client.registerListener(new InteractionEventListener());
83
99
  }
84
- this.connect();
100
+ this.shouldReconnect = true;
101
+ this.connect(false);
85
102
  }
103
+ /**
104
+ * Opens a new websocket connection and prepares either IDENTIFY or RESUME on HELLO.
105
+ */
86
106
  connect(resume = false) {
87
- if (this.isConnecting)
107
+ if (this.isConnecting) {
88
108
  return;
89
- if (this.reconnectTimeout) {
90
- clearTimeout(this.reconnectTimeout);
91
- this.reconnectTimeout = undefined;
92
109
  }
93
- this.ws?.close();
110
+ this.shouldReconnect = true;
111
+ this.clearReconnectTimeout();
112
+ stopHeartbeat(this);
113
+ this.lastHeartbeatAck = true;
114
+ const oldSocket = this.ws;
115
+ if (oldSocket) {
116
+ this.silentSocketClosures.add(oldSocket);
117
+ this.closeSocketImmediately(oldSocket);
118
+ }
94
119
  const baseUrl = resume && this.state.resumeGatewayUrl
95
120
  ? this.state.resumeGatewayUrl
96
121
  : (this.gatewayInfo?.url ??
97
122
  this.options.url ??
98
123
  "wss://gateway.discord.gg/");
99
124
  const url = this.ensureGatewayParams(baseUrl);
125
+ this.nextConnectionShouldResume = resume;
126
+ this.socketGeneration++;
100
127
  this.ws = this.createWebSocket(url);
101
128
  this.isConnecting = true;
129
+ this.isConnected = false;
102
130
  this.setupWebSocket();
103
131
  }
132
+ /**
133
+ * Stops heartbeats, clears reconnect state, and closes the active socket intentionally.
134
+ */
104
135
  disconnect() {
136
+ this.shouldReconnect = false;
137
+ this.clearReconnectTimeout();
105
138
  stopHeartbeat(this);
139
+ this.lastHeartbeatAck = true;
106
140
  this.monitor.resetUptime();
107
- this.ws?.close();
108
- this.ws = null;
109
- if (this.reconnectTimeout) {
110
- clearTimeout(this.reconnectTimeout);
111
- this.reconnectTimeout = undefined;
141
+ this.rateLimit.reset();
142
+ if (this.ws) {
143
+ this.silentSocketClosures.add(this.ws);
144
+ this.ws.close(1000, "Client disconnect");
112
145
  }
146
+ this.ws = null;
113
147
  this.isConnecting = false;
114
148
  this.isConnected = false;
149
+ this.reconnectAttempts = 0;
150
+ this.consecutiveResumeFailures = 0;
115
151
  this.pings = [];
116
152
  }
153
+ /**
154
+ * Creates the websocket instance for a gateway URL. Override in tests if needed.
155
+ */
117
156
  createWebSocket(url) {
118
157
  if (!url) {
119
158
  throw new Error("Gateway URL is required");
120
159
  }
121
- return new WebSocket(url);
160
+ const socket = this.options.webSocketFactory
161
+ ? this.options.webSocketFactory(url)
162
+ : (() => {
163
+ if (nodeRequire) {
164
+ try {
165
+ const wsModule = nodeRequire("ws");
166
+ const nodeWebSocket = typeof wsModule.WebSocket === "function"
167
+ ? wsModule.WebSocket
168
+ : typeof wsModule.default === "function"
169
+ ? wsModule.default
170
+ : null;
171
+ if (nodeWebSocket) {
172
+ return new nodeWebSocket(url);
173
+ }
174
+ }
175
+ catch {
176
+ // fall through to global WebSocket
177
+ }
178
+ }
179
+ if (typeof globalThis.WebSocket === "function") {
180
+ return new globalThis.WebSocket(url);
181
+ }
182
+ return null;
183
+ })();
184
+ if (!socket) {
185
+ throw new Error("No WebSocket implementation available. Provide GatewayPluginOptions.webSocketFactory or install 'ws'.");
186
+ }
187
+ if ("binaryType" in socket) {
188
+ try {
189
+ ;
190
+ socket.binaryType = "arraybuffer";
191
+ }
192
+ catch {
193
+ // Ignore runtimes that expose a readonly binaryType.
194
+ }
195
+ }
196
+ return socket;
122
197
  }
198
+ /**
199
+ * Attaches websocket lifecycle handlers for open, message, close, and error.
200
+ */
123
201
  setupWebSocket() {
124
- if (!this.ws)
202
+ if (!this.ws) {
125
203
  return;
126
- let closed = false;
127
- this.ws.on("open", () => {
204
+ }
205
+ const socket = this.ws;
206
+ const generation = this.socketGeneration;
207
+ this.onSocketEvent(socket, "open", () => {
208
+ if (!this.isCurrentSocket(socket, generation)) {
209
+ return;
210
+ }
128
211
  this.isConnecting = false;
129
- this.reconnectAttempts = 0;
130
- this.emitter.emit("debug", "WebSocket connection opened");
212
+ this.emitter.emit("debug", "Gateway websocket opened");
131
213
  });
132
- this.ws.on("message", (data) => {
214
+ this.onSocketEvent(socket, "message", (incoming) => {
215
+ if (!this.isCurrentSocket(socket, generation)) {
216
+ return;
217
+ }
133
218
  this.monitor.recordMessageReceived();
134
- const payload = validatePayload(data.toString());
219
+ const payloadText = this.getMessageText(incoming);
220
+ const payload = payloadText ? validatePayload(payloadText) : null;
135
221
  if (!payload) {
136
222
  this.monitor.recordError();
137
- this.emitter.emit("error", new Error("Invalid gateway payload received"));
223
+ this.emitter.emit("error", new Error("Invalid gateway payload"));
138
224
  return;
139
225
  }
140
- const { op, d, s, t } = payload;
141
- if (s !== null && s !== undefined) {
142
- this.sequence = s;
143
- this.state.sequence = s;
226
+ if (payload.s !== null && payload.s !== undefined) {
227
+ this.sequence = payload.s;
228
+ this.state.sequence = payload.s;
144
229
  }
145
- switch (op) {
146
- case GatewayOpcodes.Hello: {
147
- const helloData = d;
148
- const interval = helloData.heartbeat_interval;
149
- startHeartbeat(this, {
150
- interval,
151
- reconnectCallback: () => {
152
- if (closed) {
153
- throw new Error("Attempted to reconnect zombie connection after disconnecting first (this shouldn't be possible)");
154
- }
155
- closed = true;
156
- this.handleZombieConnection();
157
- }
158
- });
159
- if (this.canResume()) {
160
- this.resume();
161
- }
162
- else {
163
- this.identify();
164
- }
230
+ switch (payload.op) {
231
+ case GatewayOpcodes.Hello:
232
+ this.handleHello(payload.d, generation);
165
233
  break;
166
- }
167
- case GatewayOpcodes.HeartbeatAck: {
168
- this.lastHeartbeatAck = true;
169
- this.monitor.recordHeartbeatAck();
170
- // Record the latency for ping averaging
171
- const latency = this.monitor.getMetrics().latency;
172
- if (latency > 0) {
173
- this.pings.push(latency);
174
- // Keep only the last 10 pings to prevent unbounded growth
175
- if (this.pings.length > 10) {
176
- this.pings.shift();
177
- }
178
- }
234
+ case GatewayOpcodes.HeartbeatAck:
235
+ this.handleHeartbeatAck();
179
236
  break;
180
- }
181
- case GatewayOpcodes.Heartbeat: {
182
- this.lastHeartbeatAck = false;
183
- this.send({
184
- op: GatewayOpcodes.Heartbeat,
185
- d: this.sequence
186
- });
237
+ case GatewayOpcodes.Heartbeat:
238
+ this.sendHeartbeatNow();
187
239
  break;
188
- }
189
- case GatewayOpcodes.Dispatch: {
190
- const payload1 = payload;
191
- const t1 = payload1.t;
192
- try {
193
- if (!Object.values(ListenerEvent).includes(t1)) {
194
- break;
195
- }
196
- if (t1 === "READY") {
197
- const readyData = d;
198
- this.state.sessionId = readyData.session_id;
199
- this.state.resumeGatewayUrl = readyData.resume_gateway_url;
200
- }
201
- if (t && this.client) {
202
- if (!this.options.eventFilter || this.options.eventFilter?.(t1)) {
203
- if (t1 === "READY" || t1 === "RESUMED") {
204
- this.isConnected = true;
205
- }
206
- if (t1 === "READY") {
207
- const readyData = d;
208
- readyData.guilds.forEach((guild) => {
209
- this.babyCache.setGuild(guild.id, {
210
- available: false,
211
- lastEvent: Date.now()
212
- });
213
- });
214
- }
215
- if (t1 === "GUILD_CREATE") {
216
- const guildCreateData = d;
217
- const existingGuild = this.babyCache.getGuild(guildCreateData.id);
218
- if (existingGuild && !existingGuild.available) {
219
- this.babyCache.setGuild(guildCreateData.id, {
220
- available: true,
221
- lastEvent: Date.now()
222
- });
223
- this.client.eventHandler.handleEvent({
224
- ...guildCreateData,
225
- clientId: this.client.options.clientId
226
- }, "GUILD_AVAILABLE");
227
- break;
228
- }
229
- }
230
- if (t1 === "GUILD_DELETE") {
231
- const guildDeleteData = d;
232
- const existingGuild = this.babyCache.getGuild(guildDeleteData.id);
233
- if (existingGuild?.available && guildDeleteData.unavailable) {
234
- this.babyCache.setGuild(guildDeleteData.id, {
235
- available: false,
236
- lastEvent: Date.now()
237
- });
238
- this.client.eventHandler.handleEvent({
239
- ...guildDeleteData,
240
- clientId: this.client.options.clientId
241
- }, "GUILD_UNAVAILABLE");
242
- break;
243
- }
244
- }
245
- this.client.eventHandler.handleEvent({ ...payload1.d, clientId: this.client.options.clientId }, t1);
246
- }
247
- }
248
- }
249
- catch (err) {
250
- console.error(err);
251
- }
240
+ case GatewayOpcodes.Dispatch:
241
+ this.handleDispatch(payload);
252
242
  break;
253
- }
254
- case GatewayOpcodes.InvalidSession: {
255
- const canResume = Boolean(d);
256
- setTimeout(() => {
257
- closed = true;
258
- if (canResume && this.canResume()) {
259
- this.connect(true);
260
- }
261
- else {
262
- this.state.sessionId = null;
263
- this.state.resumeGatewayUrl = null;
264
- this.state.sequence = null;
265
- this.sequence = null;
266
- this.pings = [];
267
- this.connect(false);
268
- }
269
- }, 5000);
243
+ case GatewayOpcodes.InvalidSession:
244
+ this.handleInvalidSession(payload.d);
270
245
  break;
271
- }
272
246
  case GatewayOpcodes.Reconnect:
273
- if (closed) {
274
- throw new Error("Attempted to reconnect gateway after disconnecting first (this shouldn't be possible)");
275
- }
276
- closed = true;
277
- this.state.sequence = this.sequence;
278
- this.ws?.close();
279
- this.handleReconnect();
247
+ this.handleReconnectOpcode();
280
248
  break;
281
249
  }
282
250
  });
283
- this.ws.on("close", (code, _reason) => {
251
+ this.onSocketEvent(socket, "close", (incoming) => {
252
+ if (!this.isCurrentSocket(socket, generation)) {
253
+ return;
254
+ }
284
255
  this.isConnecting = false;
285
- this.emitter.emit("debug", `WebSocket connection closed with code ${code}`);
286
- this.monitor.recordReconnect();
287
- if (closed)
256
+ this.isConnected = false;
257
+ stopHeartbeat(this);
258
+ this.lastHeartbeatAck = true;
259
+ const wasSilentClose = this.silentSocketClosures.has(socket);
260
+ if (wasSilentClose) {
261
+ this.silentSocketClosures.delete(socket);
288
262
  return;
289
- closed = true;
263
+ }
264
+ const code = this.getCloseCode(incoming);
265
+ this.monitor.recordReconnect();
266
+ this.emitter.emit("debug", `Gateway websocket closed: ${code}`);
290
267
  this.handleClose(code);
291
268
  });
292
- this.ws.on("error", (error) => {
269
+ this.onSocketEvent(socket, "error", (incoming) => {
270
+ if (!this.isCurrentSocket(socket, generation)) {
271
+ return;
272
+ }
293
273
  this.isConnecting = false;
294
274
  this.monitor.recordError();
295
- this.emitter.emit("error", error);
275
+ this.emitter.emit("error", this.getSocketError(incoming));
296
276
  });
297
277
  }
298
- handleReconnectionAttempt(options) {
299
- const { maxAttempts = 5, baseDelay = 1000, maxDelay = 30000 } = this.options.reconnect ?? {};
300
- if (this.reconnectAttempts >= maxAttempts) {
301
- this.emitter.emit("error", new Error(`Max reconnect attempts (${maxAttempts}) reached${options.code ? ` after code ${options.code}` : ""}`));
302
- this.monitor.destroy();
278
+ /**
279
+ * Handles close codes and decides whether reconnection should resume or re-identify.
280
+ */
281
+ handleClose(code) {
282
+ if (!this.shouldReconnect) {
303
283
  return;
304
284
  }
305
- if (options.code) {
306
- switch (options.code) {
307
- case GatewayCloseCodes.AuthenticationFailed:
308
- case GatewayCloseCodes.InvalidAPIVersion:
309
- case GatewayCloseCodes.InvalidIntents:
310
- case GatewayCloseCodes.DisallowedIntents:
311
- case GatewayCloseCodes.ShardingRequired: {
312
- this.emitter.emit("error", new Error(`Fatal Gateway error: ${options.code}`));
313
- this.reconnectAttempts = maxAttempts;
314
- this.monitor.destroy();
315
- return;
316
- }
317
- case GatewayCloseCodes.InvalidSeq:
318
- case GatewayCloseCodes.SessionTimedOut: {
319
- this.state.sessionId = null;
320
- this.state.resumeGatewayUrl = null;
321
- this.state.sequence = null;
322
- this.sequence = null;
323
- this.pings = [];
324
- options.forceNoResume = true;
325
- break;
326
- }
327
- }
328
- }
329
- if (this.reconnectTimeout || this.isConnecting) {
285
+ if (fatalGatewayCloseCodes.has(code)) {
286
+ this.shouldReconnect = false;
287
+ this.emitter.emit("error", new Error(`Fatal gateway close code: ${code}`));
288
+ this.disconnect();
330
289
  return;
331
290
  }
332
- this.disconnect();
333
- const backoffTime = Math.min(baseDelay * 2 ** this.reconnectAttempts, maxDelay);
334
- this.reconnectAttempts++;
335
- if (options.isZombieConnection) {
336
- this.monitor.recordZombieConnection();
291
+ if (nonResumableGatewayCloseCodes.has(code)) {
292
+ this.resetSessionState();
337
293
  }
338
- const shouldResume = !options.forceNoResume && this.canResume();
339
- this.emitter.emit("debug", `${shouldResume ? "Attempting resume" : "Reconnecting"} with backoff: ${backoffTime}ms${options.code ? ` after code ${options.code}` : ""}`);
340
- this.reconnectTimeout = setTimeout(() => {
341
- this.reconnectTimeout = undefined;
342
- this.connect(shouldResume);
343
- }, backoffTime);
344
- }
345
- handleClose(code) {
346
- this.handleReconnectionAttempt({ code });
294
+ this.scheduleReconnect({
295
+ code,
296
+ reason: "close",
297
+ preferResume: !nonResumableGatewayCloseCodes.has(code)
298
+ });
347
299
  }
300
+ /**
301
+ * Handles missing heartbeat acknowledgements by forcing a reconnect flow.
302
+ */
348
303
  handleZombieConnection() {
349
- this.handleReconnectionAttempt({ isZombieConnection: true });
304
+ this.monitor.recordZombieConnection();
305
+ this.scheduleReconnect({
306
+ reason: "zombie",
307
+ preferResume: true
308
+ });
309
+ this.reconnectWithSocketRestart();
350
310
  }
311
+ /**
312
+ * Compatibility wrapper that maps to reconnect opcode handling.
313
+ */
351
314
  handleReconnect() {
352
- this.handleReconnectionAttempt({});
315
+ this.handleReconnectOpcode();
353
316
  }
317
+ /**
318
+ * Returns whether session_id and sequence are both available for RESUME.
319
+ */
354
320
  canResume() {
355
321
  return Boolean(this.state.sessionId && this.sequence !== null);
356
322
  }
323
+ /**
324
+ * Sends a RESUME payload using cached session_id and latest sequence.
325
+ */
357
326
  resume() {
358
- if (!this.client || !this.state.sessionId || this.sequence === null)
327
+ if (!this.client || !this.state.sessionId || this.sequence === null) {
359
328
  return;
329
+ }
360
330
  const payload = createResumePayload({
361
331
  token: this.client.options.token,
362
332
  sessionId: this.state.sessionId,
@@ -364,33 +334,49 @@ export class GatewayPlugin extends Plugin {
364
334
  });
365
335
  this.send(payload, true);
366
336
  }
337
+ /**
338
+ * Sends a gateway payload with size and rate-limit safeguards.
339
+ */
367
340
  send(payload, skipRateLimit = false) {
368
- if (this.ws && this.ws.readyState === 1) {
369
- // Skip rate limiting for essential connection events
370
- const isEssentialEvent = payload.op === GatewayOpcodes.Heartbeat ||
371
- payload.op === GatewayOpcodes.Identify ||
372
- payload.op === GatewayOpcodes.Resume;
373
- if (!skipRateLimit && !isEssentialEvent && !this.rateLimit.canSend()) {
374
- throw new Error(`Gateway rate limit exceeded. ${this.rateLimit.getRemainingEvents()} events remaining. Reset in ${this.rateLimit.getResetTime()}ms`);
375
- }
376
- this.ws.send(JSON.stringify(payload));
377
- this.monitor.recordMessageSent();
378
- if (!isEssentialEvent) {
379
- this.rateLimit.recordEvent();
380
- }
381
- if (payload.op === GatewayOpcodes.Heartbeat) {
382
- this.monitor.recordHeartbeat();
383
- }
341
+ if (!this.ws || this.ws.readyState !== socketOpenState) {
342
+ throw new Error("Gateway websocket is not open");
343
+ }
344
+ const isEssentialEvent = payload.op === GatewayOpcodes.Heartbeat ||
345
+ payload.op === GatewayOpcodes.Identify ||
346
+ payload.op === GatewayOpcodes.Resume;
347
+ if (!skipRateLimit && !isEssentialEvent && !this.rateLimit.canSend()) {
348
+ throw new Error(`Gateway rate limit exceeded. ${this.rateLimit.getRemainingEvents()} events remaining. Reset in ${this.rateLimit.getResetTime()}ms`);
349
+ }
350
+ const encodedPayload = JSON.stringify(payload);
351
+ const payloadSize = typeof Buffer !== "undefined"
352
+ ? Buffer.byteLength(encodedPayload, "utf8")
353
+ : new TextEncoder().encode(encodedPayload).byteLength;
354
+ if (payloadSize > 4096) {
355
+ throw new Error("Gateway payload exceeds 4096-byte Discord limit");
356
+ }
357
+ this.ws.send(encodedPayload);
358
+ this.monitor.recordMessageSent();
359
+ if (!isEssentialEvent) {
360
+ this.rateLimit.recordEvent();
361
+ }
362
+ if (payload.op === GatewayOpcodes.Heartbeat) {
363
+ this.monitor.recordHeartbeat();
384
364
  }
385
365
  }
366
+ /**
367
+ * Sends an IDENTIFY payload for a fresh gateway session.
368
+ */
386
369
  identify() {
387
- if (!this.client)
370
+ if (!this.client) {
388
371
  return;
372
+ }
389
373
  const payload = createIdentifyPayload({
390
374
  token: this.client.options.token,
391
375
  intents: this.options.intents,
392
376
  properties: {
393
- os: process.platform,
377
+ os: typeof process !== "undefined" && process?.platform
378
+ ? process.platform
379
+ : "unknown",
394
380
  browser: "@buape/carbon - https://carbon.buape.com",
395
381
  device: "@buape/carbon - https://carbon.buape.com"
396
382
  },
@@ -399,30 +385,25 @@ export class GatewayPlugin extends Plugin {
399
385
  this.send(payload, true);
400
386
  }
401
387
  /**
402
- * Update the bot's presence (status, activity, etc.)
403
- * @param data Presence data to update
388
+ * Updates bot presence over the gateway connection.
404
389
  */
405
390
  updatePresence(data) {
406
391
  if (!this.isConnected) {
407
392
  throw new Error("Gateway is not connected");
408
393
  }
409
- const payload = createUpdatePresencePayload(data);
410
- this.send(payload);
394
+ this.send(createUpdatePresencePayload(data));
411
395
  }
412
396
  /**
413
- * Update the bot's voice state
414
- * @param data Voice state data to update
397
+ * Updates bot voice state for a guild over the gateway connection.
415
398
  */
416
399
  updateVoiceState(data) {
417
400
  if (!this.isConnected) {
418
401
  throw new Error("Gateway is not connected");
419
402
  }
420
- const payload = createUpdateVoiceStatePayload(data);
421
- this.send(payload);
403
+ this.send(createUpdateVoiceStatePayload(data));
422
404
  }
423
405
  /**
424
- * Request guild members from Discord. The data will come in through the GUILD_MEMBERS_CHUNK event, not as a return on this function.
425
- * @param data Guild members request data
406
+ * Requests guild members and validates required intents/options.
426
407
  */
427
408
  requestGuildMembers(data) {
428
409
  if (!this.isConnected) {
@@ -441,11 +422,10 @@ export class GatewayPlugin extends Plugin {
441
422
  if (!data.query && data.query !== "" && !data.user_ids) {
442
423
  throw new Error("Either 'query' or 'user_ids' field is required for requestGuildMembers");
443
424
  }
444
- const payload = createRequestGuildMembersPayload(data);
445
- this.send(payload);
425
+ this.send(createRequestGuildMembersPayload(data));
446
426
  }
447
427
  /**
448
- * Get the current rate limit status
428
+ * Returns the current outbound gateway rate-limit snapshot.
449
429
  */
450
430
  getRateLimitStatus() {
451
431
  return {
@@ -455,7 +435,7 @@ export class GatewayPlugin extends Plugin {
455
435
  };
456
436
  }
457
437
  /**
458
- * Get information about optionsured intents
438
+ * Returns helpers describing which intents are currently enabled.
459
439
  */
460
440
  getIntentsInfo() {
461
441
  return {
@@ -468,12 +448,343 @@ export class GatewayPlugin extends Plugin {
468
448
  };
469
449
  }
470
450
  /**
471
- * Check if a specific intent is enabled
472
- * @param intent The intent to check
451
+ * Checks if a specific intent bit is enabled.
473
452
  */
474
453
  hasIntent(intent) {
475
454
  return (this.options.intents & intent) !== 0;
476
455
  }
456
+ /**
457
+ * Guards handlers from acting on stale websocket instances.
458
+ */
459
+ isCurrentSocket(socket, generation) {
460
+ return this.ws === socket && this.socketGeneration === generation;
461
+ }
462
+ onSocketEvent(socket, event, handler) {
463
+ if (typeof socket.on === "function") {
464
+ socket.on(event, (...args) => {
465
+ if (args.length === 0) {
466
+ handler(undefined);
467
+ return;
468
+ }
469
+ handler(args.length === 1 ? args[0] : args);
470
+ });
471
+ return;
472
+ }
473
+ if (typeof socket.addEventListener === "function") {
474
+ socket.addEventListener(event, (incoming) => {
475
+ handler(incoming);
476
+ });
477
+ return;
478
+ }
479
+ throw new Error("WebSocket implementation does not support event listeners");
480
+ }
481
+ getMessageText(incoming) {
482
+ const payload = this.extractSocketPayload(incoming);
483
+ if (typeof payload === "string") {
484
+ return payload;
485
+ }
486
+ if (payload instanceof ArrayBuffer) {
487
+ return textDecoder.decode(new Uint8Array(payload));
488
+ }
489
+ if (ArrayBuffer.isView(payload)) {
490
+ return textDecoder.decode(new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength));
491
+ }
492
+ if (payload && typeof payload === "object" && "toString" in payload) {
493
+ const text = String(payload);
494
+ return text === "[object Object]" ? null : text;
495
+ }
496
+ return null;
497
+ }
498
+ extractSocketPayload(incoming) {
499
+ if (Array.isArray(incoming)) {
500
+ return incoming[0];
501
+ }
502
+ if (incoming && typeof incoming === "object" && "data" in incoming) {
503
+ return incoming.data;
504
+ }
505
+ return incoming;
506
+ }
507
+ getCloseCode(incoming) {
508
+ if (Array.isArray(incoming)) {
509
+ const [code] = incoming;
510
+ if (typeof code === "number") {
511
+ return code;
512
+ }
513
+ }
514
+ if (incoming && typeof incoming === "object" && "code" in incoming) {
515
+ const code = incoming.code;
516
+ if (typeof code === "number") {
517
+ return code;
518
+ }
519
+ }
520
+ return 1000;
521
+ }
522
+ getSocketError(incoming) {
523
+ const payload = this.extractSocketPayload(incoming);
524
+ if (payload instanceof Error) {
525
+ return payload;
526
+ }
527
+ if (payload && typeof payload === "object" && "error" in payload) {
528
+ const nested = payload.error;
529
+ if (nested instanceof Error) {
530
+ return nested;
531
+ }
532
+ }
533
+ return new Error(typeof payload === "string"
534
+ ? payload
535
+ : "Gateway socket emitted an unknown error");
536
+ }
537
+ closeSocketImmediately(socket) {
538
+ if (typeof socket.terminate === "function") {
539
+ socket.terminate();
540
+ return;
541
+ }
542
+ socket.close(1000, "Gateway reconnect");
543
+ }
544
+ /**
545
+ * Processes HELLO, starts heartbeat scheduling, then sends RESUME or IDENTIFY.
546
+ */
547
+ handleHello(data, generation) {
548
+ const heartbeatInterval = data?.heartbeat_interval;
549
+ if (typeof heartbeatInterval !== "number" || heartbeatInterval <= 0) {
550
+ this.monitor.recordError();
551
+ this.emitter.emit("error", new Error("Gateway HELLO missing heartbeat"));
552
+ this.handleZombieConnection();
553
+ return;
554
+ }
555
+ startHeartbeat(this, {
556
+ interval: heartbeatInterval,
557
+ reconnectCallback: () => {
558
+ if (this.socketGeneration !== generation) {
559
+ return;
560
+ }
561
+ this.handleZombieConnection();
562
+ }
563
+ });
564
+ const shouldResume = this.nextConnectionShouldResume && this.canResume();
565
+ this.nextConnectionShouldResume = false;
566
+ try {
567
+ if (shouldResume) {
568
+ this.resume();
569
+ return;
570
+ }
571
+ this.identify();
572
+ }
573
+ catch {
574
+ this.handleZombieConnection();
575
+ }
576
+ }
577
+ /**
578
+ * Marks heartbeat acknowledged and updates rolling ping metrics.
579
+ */
580
+ handleHeartbeatAck() {
581
+ this.lastHeartbeatAck = true;
582
+ this.monitor.recordHeartbeatAck();
583
+ const latency = this.monitor.getMetrics().latency;
584
+ if (latency > 0) {
585
+ this.pings.push(latency);
586
+ if (this.pings.length > 10) {
587
+ this.pings.shift();
588
+ }
589
+ }
590
+ }
591
+ /**
592
+ * Immediately sends a heartbeat in response to gateway heartbeat requests.
593
+ */
594
+ sendHeartbeatNow() {
595
+ this.lastHeartbeatAck = false;
596
+ try {
597
+ this.send({
598
+ op: GatewayOpcodes.Heartbeat,
599
+ d: this.sequence
600
+ });
601
+ }
602
+ catch {
603
+ this.handleZombieConnection();
604
+ }
605
+ }
606
+ /**
607
+ * Processes dispatch events, session caching, and Carbon event forwarding.
608
+ */
609
+ handleDispatch(payload) {
610
+ const type = payload.t;
611
+ if (!listenerEvents.has(type)) {
612
+ return;
613
+ }
614
+ if (type === "READY") {
615
+ const readyData = payload.d;
616
+ this.state.sessionId = readyData.session_id;
617
+ this.state.resumeGatewayUrl = readyData.resume_gateway_url;
618
+ }
619
+ if (type === "READY" || type === "RESUMED") {
620
+ this.isConnected = true;
621
+ this.reconnectAttempts = 0;
622
+ this.consecutiveResumeFailures = 0;
623
+ }
624
+ if (!this.client) {
625
+ return;
626
+ }
627
+ if (this.options.eventFilter && !this.options.eventFilter(type)) {
628
+ return;
629
+ }
630
+ if (type === "READY") {
631
+ const readyData = payload.d;
632
+ readyData.guilds.forEach((guild) => {
633
+ this.babyCache.setGuild(guild.id, {
634
+ available: false,
635
+ lastEvent: Date.now()
636
+ });
637
+ });
638
+ }
639
+ if (type === "GUILD_CREATE") {
640
+ const guildCreateData = payload.d;
641
+ const existingGuild = this.babyCache.getGuild(guildCreateData.id);
642
+ if (existingGuild && !existingGuild.available) {
643
+ this.babyCache.setGuild(guildCreateData.id, {
644
+ available: true,
645
+ lastEvent: Date.now()
646
+ });
647
+ this.client.eventHandler.handleEvent({
648
+ ...guildCreateData,
649
+ clientId: this.client.options.clientId
650
+ }, "GUILD_AVAILABLE");
651
+ return;
652
+ }
653
+ }
654
+ if (type === "GUILD_DELETE") {
655
+ const guildDeleteData = payload.d;
656
+ const existingGuild = this.babyCache.getGuild(guildDeleteData.id);
657
+ if (existingGuild?.available && guildDeleteData.unavailable) {
658
+ this.babyCache.setGuild(guildDeleteData.id, {
659
+ available: false,
660
+ lastEvent: Date.now()
661
+ });
662
+ this.client.eventHandler.handleEvent({
663
+ ...guildDeleteData,
664
+ clientId: this.client.options.clientId
665
+ }, "GUILD_UNAVAILABLE");
666
+ return;
667
+ }
668
+ if (!guildDeleteData.unavailable) {
669
+ this.babyCache.removeGuild(guildDeleteData.id);
670
+ }
671
+ }
672
+ this.client.eventHandler.handleEvent({ ...payload.d, clientId: this.client.options.clientId }, type);
673
+ }
674
+ /**
675
+ * Handles INVALID_SESSION and schedules reconnect with Discord-compliant delay.
676
+ */
677
+ handleInvalidSession(data) {
678
+ const isResumable = Boolean(data) && this.canResume();
679
+ if (!isResumable) {
680
+ this.resetSessionState();
681
+ }
682
+ const discordRequiredDelay = 1000 + Math.floor(Math.random() * 4000);
683
+ this.scheduleReconnect({
684
+ reason: "invalid-session",
685
+ preferResume: isResumable,
686
+ minDelayMs: discordRequiredDelay
687
+ });
688
+ this.reconnectWithSocketRestart();
689
+ }
690
+ /**
691
+ * Handles RECONNECT opcode by scheduling reconnect with resume preference.
692
+ */
693
+ handleReconnectOpcode() {
694
+ this.scheduleReconnect({
695
+ reason: "reconnect-opcode",
696
+ preferResume: true,
697
+ allowImmediateFirstAttempt: true
698
+ });
699
+ this.reconnectWithSocketRestart();
700
+ }
701
+ /**
702
+ * Terminates the current socket after reconnect has been scheduled.
703
+ */
704
+ reconnectWithSocketRestart() {
705
+ stopHeartbeat(this);
706
+ this.lastHeartbeatAck = true;
707
+ if (!this.ws) {
708
+ return;
709
+ }
710
+ this.silentSocketClosures.add(this.ws);
711
+ this.closeSocketImmediately(this.ws);
712
+ }
713
+ /**
714
+ * Schedules a single reconnect attempt with backoff and attempt limits.
715
+ */
716
+ scheduleReconnect(options) {
717
+ if (!this.shouldReconnect || this.reconnectTimeout || this.isConnecting) {
718
+ return;
719
+ }
720
+ const maxAttempts = this.options.reconnect?.maxAttempts ?? reconnectDefaults.maxAttempts;
721
+ if (Number.isFinite(maxAttempts) && this.reconnectAttempts >= maxAttempts) {
722
+ this.shouldReconnect = false;
723
+ this.emitter.emit("error", new Error(`Max reconnect attempts (${maxAttempts}) reached${options.code ? ` after close code ${options.code}` : ""}`));
724
+ return;
725
+ }
726
+ let shouldResume = options.preferResume && this.canResume();
727
+ const resumeFailureThreshold = 3;
728
+ if (shouldResume &&
729
+ this.consecutiveResumeFailures >= resumeFailureThreshold) {
730
+ this.resetSessionState();
731
+ shouldResume = false;
732
+ this.emitter.emit("debug", `Gateway forcing fresh IDENTIFY after ${resumeFailureThreshold} failed resume attempts`);
733
+ }
734
+ const delay = this.computeReconnectDelay(options);
735
+ if (shouldResume) {
736
+ this.consecutiveResumeFailures++;
737
+ }
738
+ else {
739
+ this.consecutiveResumeFailures = 0;
740
+ }
741
+ this.reconnectAttempts++;
742
+ this.emitter.emit("debug", `Gateway reconnect scheduled in ${delay}ms (${options.reason}, resume=${String(shouldResume)})`);
743
+ this.reconnectTimeout = setTimeout(() => {
744
+ this.reconnectTimeout = undefined;
745
+ this.connect(shouldResume);
746
+ }, delay);
747
+ }
748
+ /**
749
+ * Computes exponential reconnect delay with jitter and optional minimum delay.
750
+ */
751
+ computeReconnectDelay(options) {
752
+ const baseDelay = this.options.reconnect?.baseDelay ?? reconnectDefaults.baseDelay;
753
+ const maxDelay = this.options.reconnect?.maxDelay ?? reconnectDefaults.maxDelay;
754
+ if (options.allowImmediateFirstAttempt &&
755
+ this.reconnectAttempts === 0 &&
756
+ (options.minDelayMs ?? 0) === 0) {
757
+ return 0;
758
+ }
759
+ const exponentialDelay = Math.min(baseDelay * 2 ** this.reconnectAttempts, maxDelay);
760
+ const jitterFactor = 0.85 + Math.random() * 0.3;
761
+ const jitteredDelay = Math.floor(exponentialDelay * jitterFactor);
762
+ return Math.max(options.minDelayMs ?? 0, jitteredDelay);
763
+ }
764
+ /**
765
+ * Clears any pending reconnect timer.
766
+ */
767
+ clearReconnectTimeout() {
768
+ if (!this.reconnectTimeout) {
769
+ return;
770
+ }
771
+ clearTimeout(this.reconnectTimeout);
772
+ this.reconnectTimeout = undefined;
773
+ }
774
+ /**
775
+ * Clears resume-related session state after non-resumable failures.
776
+ */
777
+ resetSessionState() {
778
+ this.state.sessionId = null;
779
+ this.state.resumeGatewayUrl = null;
780
+ this.state.sequence = null;
781
+ this.sequence = null;
782
+ this.consecutiveResumeFailures = 0;
783
+ this.pings = [];
784
+ }
785
+ /**
786
+ * Ensures gateway URLs include v=10 and encoding=json query parameters.
787
+ */
477
788
  ensureGatewayParams(url) {
478
789
  try {
479
790
  const parsed = new URL(url);