@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,65 @@
1
+ import {
2
+ WorkerSendPayloadOp,
3
+ } from '../strategies/sharding/WorkerShardingStrategy.js';
4
+ import { WebSocketManager } from '../ws/WebSocketManager.js';
5
+ import { SimpleShardingStrategy } from '../strategies/sharding/SimpleShardingStrategy.js';
6
+
7
+ // Define the global self explicitly for TypeScript as a Worker
8
+ declare const self: Worker;
9
+
10
+ export class WorkerBootstrapper {
11
+ private manager!: WebSocketManager;
12
+
13
+ public async bootstrap() {
14
+ // Listen for messages from the main thread
15
+ self.addEventListener('message', async (event: MessageEvent) => {
16
+ const message = event.data;
17
+
18
+ // Initial setup if we receive the custom 'initial_data' op
19
+ if (message && message.op === 'initial_data') {
20
+ const { token, intents, shardCount } = message.data;
21
+
22
+ this.manager = new WebSocketManager(
23
+ {
24
+ token,
25
+ intents,
26
+ buildStrategy: (manager: WebSocketManager) => new SimpleShardingStrategy(manager),
27
+ shardCount,
28
+ async fetchGatewayInformation() {
29
+ // In a worker, we might need to fetch this from the main thread too,
30
+ // but manager usually handles it.
31
+ // For now, satisfy the interface.
32
+ return message.data.gatewayInformation;
33
+ }
34
+ } as any,
35
+ );
36
+
37
+ await this.manager.connect();
38
+ return;
39
+ }
40
+
41
+ // Handle standard protocol messages
42
+ if (!message || typeof message.op === 'undefined') return;
43
+
44
+ switch (message.op) {
45
+ case WorkerSendPayloadOp.Connect: {
46
+ const { shardId: _shardId } = message.d;
47
+ await this.manager.connect();
48
+ break;
49
+ }
50
+
51
+ case WorkerSendPayloadOp.Destroy: {
52
+ const { options } = message.d;
53
+ await this.manager.destroy(options);
54
+ break;
55
+ }
56
+
57
+ case WorkerSendPayloadOp.Send: {
58
+ const { shardId, payload } = message.d;
59
+ await this.manager.send(shardId, payload);
60
+ break;
61
+ }
62
+ }
63
+ });
64
+ }
65
+ }
@@ -0,0 +1,85 @@
1
+ import { Collection } from '@ovencord/collection';
2
+ import { lazy } from '@ovencord/util';
3
+ import { APIVersion, GatewayOpcodes } from 'discord-api-types/v10';
4
+ import { SimpleShardingStrategy } from '../strategies/sharding/SimpleShardingStrategy.js';
5
+ import { SimpleIdentifyThrottler } from '../throttling/SimpleIdentifyThrottler.js';
6
+ import type { SessionInfo, OptionalWebSocketManagerOptions, WebSocketManager } from '../ws/WebSocketManager.js';
7
+ import type { SendRateLimitState } from '../ws/WebSocketShard.js';
8
+
9
+ /**
10
+ * Valid encoding types
11
+ */
12
+ export enum Encoding {
13
+ JSON = 'json',
14
+ }
15
+
16
+ /**
17
+ * Valid compression methods
18
+ */
19
+ export enum CompressionMethod {
20
+ ZlibNative,
21
+ ZlibSync,
22
+ ZstdNative,
23
+ }
24
+
25
+ export const DefaultDeviceProperty = `@ovencord/ws [VI]{{inject}}[/VI]` as `@ovencord/ws ${string}`;
26
+
27
+ const getDefaultSessionStore = lazy(() => new Collection<number, SessionInfo | null>());
28
+
29
+ export const CompressionParameterMap = {
30
+ [CompressionMethod.ZlibNative]: 'zlib-stream',
31
+ [CompressionMethod.ZlibSync]: 'zlib-stream',
32
+ [CompressionMethod.ZstdNative]: 'zstd-stream',
33
+ } as const satisfies Record<CompressionMethod, string>;
34
+
35
+ /**
36
+ * Default options used by the manager
37
+ */
38
+ export const DefaultWebSocketManagerOptions = {
39
+ async buildIdentifyThrottler(manager: WebSocketManager) {
40
+ const info = await manager.fetchGatewayInformation();
41
+ return new SimpleIdentifyThrottler(info.session_start_limit.max_concurrency);
42
+ },
43
+ buildStrategy: (manager) => new SimpleShardingStrategy(manager),
44
+ shardCount: null,
45
+ shardIds: null,
46
+ largeThreshold: null,
47
+ initialPresence: null,
48
+ identifyProperties: {
49
+ browser: DefaultDeviceProperty,
50
+ device: DefaultDeviceProperty,
51
+ os: process.platform,
52
+ },
53
+ version: APIVersion,
54
+ encoding: Encoding.JSON,
55
+ compression: null,
56
+ useIdentifyCompression: false,
57
+ retrieveSessionInfo(shardId) {
58
+ const store = getDefaultSessionStore();
59
+ return store.get(shardId) ?? null;
60
+ },
61
+ updateSessionInfo(shardId: number, info: SessionInfo | null) {
62
+ const store = getDefaultSessionStore();
63
+ if (info) {
64
+ store.set(shardId, info);
65
+ } else {
66
+ store.delete(shardId);
67
+ }
68
+ },
69
+ handshakeTimeout: 30_000,
70
+ helloTimeout: 60_000,
71
+ readyTimeout: 15_000,
72
+ } as const satisfies Omit<OptionalWebSocketManagerOptions, 'fetchGatewayInformation' | 'token'>;
73
+
74
+ export const ImportantGatewayOpcodes = new Set([
75
+ GatewayOpcodes.Heartbeat,
76
+ GatewayOpcodes.Identify,
77
+ GatewayOpcodes.Resume,
78
+ ]);
79
+
80
+ export function getInitialSendRateLimitState(): SendRateLimitState {
81
+ return {
82
+ sent: 0,
83
+ resetAt: Date.now() + 60_000,
84
+ };
85
+ }
@@ -0,0 +1,396 @@
1
+ import type { Collection } from '@ovencord/collection';
2
+ import { AsyncEventEmitter, range, type Awaitable } from '@ovencord/util';
3
+ import type {
4
+ APIGatewayBotInfo,
5
+ GatewayIdentifyProperties,
6
+ GatewayPresenceUpdateData,
7
+ RESTGetAPIGatewayBotResult,
8
+ GatewayIntentBits,
9
+ GatewaySendPayload,
10
+ GatewayDispatchPayload,
11
+ GatewayReadyDispatchData,
12
+ } from 'discord-api-types/v10';
13
+ import type { IShardingStrategy } from '../strategies/sharding/IShardingStrategy.js';
14
+ import type { IIdentifyThrottler } from '../throttling/IIdentifyThrottler.js';
15
+ import { DefaultWebSocketManagerOptions, type CompressionMethod, type Encoding } from '../utils/constants.js';
16
+ import type { WebSocketShardDestroyOptions, WebSocketShardEvents, WebSocketShardStatus } from './WebSocketShard.js';
17
+
18
+ /**
19
+ * Represents a range of shard ids
20
+ */
21
+ export interface ShardRange {
22
+ end: number;
23
+ start: number;
24
+ }
25
+
26
+ /**
27
+ * Session information for a given shard, used to resume a session
28
+ */
29
+ export interface SessionInfo {
30
+ /**
31
+ * URL to use when resuming
32
+ */
33
+ resumeURL: string;
34
+ /**
35
+ * The sequence number of the last message sent by the shard
36
+ */
37
+ sequence: number;
38
+ /**
39
+ * Session id for this shard
40
+ */
41
+ sessionId: string;
42
+ /**
43
+ * The total number of shards at the time of this shard identifying
44
+ */
45
+ shardCount: number;
46
+ /**
47
+ * The id of the shard
48
+ */
49
+ shardId: number;
50
+ }
51
+
52
+ /**
53
+ * Required options for the WebSocketManager
54
+ */
55
+ export interface RequiredWebSocketManagerOptions {
56
+ /**
57
+ * Function for retrieving the information returned by the `/gateway/bot` endpoint.
58
+ * We recommend using a REST client that respects Discord's rate limits, such as `@ovencord/rest`.
59
+ *
60
+ * @example
61
+ * ```ts
62
+ * const rest = new REST().setToken(process.env.DISCORD_TOKEN);
63
+ * const manager = new WebSocketManager({
64
+ * token: process.env.DISCORD_TOKEN,
65
+ * fetchGatewayInformation() {
66
+ * return rest.get(Routes.gatewayBot()) as Promise<RESTGetAPIGatewayBotResult>;
67
+ * },
68
+ * });
69
+ * ```
70
+ */
71
+ fetchGatewayInformation(): Awaitable<RESTGetAPIGatewayBotResult>;
72
+ /**
73
+ * The intents to request
74
+ */
75
+ intents: GatewayIntentBits | 0;
76
+ }
77
+
78
+ /**
79
+ * Optional additional configuration for the WebSocketManager
80
+ */
81
+ export interface OptionalWebSocketManagerOptions {
82
+ /**
83
+ * Builds an identify throttler to use for this manager's shards
84
+ */
85
+ buildIdentifyThrottler(manager: WebSocketManager): Awaitable<IIdentifyThrottler>;
86
+ /**
87
+ * Builds the strategy to use for sharding
88
+ *
89
+ * @example
90
+ * ```ts
91
+ * const rest = new REST().setToken(process.env.DISCORD_TOKEN);
92
+ * const manager = new WebSocketManager({
93
+ * token: process.env.DISCORD_TOKEN,
94
+ * intents: 0, // for no intents
95
+ * fetchGatewayInformation() {
96
+ * return rest.get(Routes.gatewayBot()) as Promise<RESTGetAPIGatewayBotResult>;
97
+ * },
98
+ * buildStrategy: (manager) => new WorkerShardingStrategy(manager, { shardsPerWorker: 2 }),
99
+ * });
100
+ * ```
101
+ */
102
+ buildStrategy(manager: WebSocketManager): IShardingStrategy;
103
+ /**
104
+ * The transport compression method to use - mutually exclusive with `useIdentifyCompression`
105
+ *
106
+ * @defaultValue `null` (no transport compression)
107
+ */
108
+ compression: CompressionMethod | null;
109
+ /**
110
+ * The encoding to use
111
+ *
112
+ * @defaultValue `'json'`
113
+ */
114
+ encoding: Encoding;
115
+ /**
116
+ * How long to wait for a shard to connect before giving up
117
+ */
118
+ handshakeTimeout: number | null;
119
+ /**
120
+ * How long to wait for a shard's HELLO packet before giving up
121
+ */
122
+ helloTimeout: number | null;
123
+ /**
124
+ * Properties to send to the gateway when identifying
125
+ */
126
+ identifyProperties: GatewayIdentifyProperties;
127
+ /**
128
+ * Initial presence data to send to the gateway when identifying
129
+ */
130
+ initialPresence: GatewayPresenceUpdateData | null;
131
+ /**
132
+ * Value between 50 and 250, total number of members where the gateway will stop sending offline members in the guild member list
133
+ */
134
+ largeThreshold: number | null;
135
+ /**
136
+ * How long to wait for a shard's READY packet before giving up
137
+ */
138
+ readyTimeout: number | null;
139
+ /**
140
+ * Function used to retrieve session information (and attempt to resume) for a given shard
141
+ *
142
+ * @example
143
+ * ```ts
144
+ * const manager = new WebSocketManager({
145
+ * async retrieveSessionInfo(shardId): Awaitable<SessionInfo | null> {
146
+ * // Fetch this info from redis or similar
147
+ * return { sessionId: string, sequence: number };
148
+ * // Return null if no information is found
149
+ * },
150
+ * });
151
+ * ```
152
+ */
153
+ retrieveSessionInfo(shardId: number): Awaitable<SessionInfo | null>;
154
+ /**
155
+ * The total number of shards across all WebsocketManagers you intend to instantiate.
156
+ * Use `null` to use Discord's recommended shard count
157
+ */
158
+ shardCount: number | null;
159
+ /**
160
+ * The ids of the shards this WebSocketManager should manage.
161
+ * Use `null` to simply spawn 0 through `shardCount - 1`
162
+ *
163
+ * @example
164
+ * ```ts
165
+ * const manager = new WebSocketManager({
166
+ * shardIds: [1, 3, 7], // spawns shard 1, 3, and 7, nothing else
167
+ * });
168
+ * ```
169
+ * @example
170
+ * ```ts
171
+ * const manager = new WebSocketManager({
172
+ * shardIds: {
173
+ * start: 3,
174
+ * end: 6,
175
+ * }, // spawns shards 3, 4, 5, and 6
176
+ * });
177
+ * ```
178
+ */
179
+ shardIds: number[] | ShardRange | null;
180
+ /**
181
+ * The token to use for identifying with the gateway
182
+ *
183
+ * If not provided, the token must be set using {@link WebSocketManager.setToken}
184
+ */
185
+ token: string;
186
+ /**
187
+ * Function used to store session information for a given shard
188
+ */
189
+ updateSessionInfo(shardId: number, sessionInfo: SessionInfo | null): Awaitable<void>;
190
+ /**
191
+ * Whether to use the `compress` option when identifying
192
+ *
193
+ * @defaultValue `false`
194
+ */
195
+ useIdentifyCompression: boolean;
196
+ /**
197
+ * The gateway version to use
198
+ *
199
+ * @defaultValue `'10'`
200
+ */
201
+ version: string;
202
+ }
203
+
204
+ export interface WebSocketManagerOptions extends OptionalWebSocketManagerOptions, RequiredWebSocketManagerOptions {}
205
+
206
+ export interface CreateWebSocketManagerOptions
207
+ extends Partial<OptionalWebSocketManagerOptions>, RequiredWebSocketManagerOptions {}
208
+
209
+ export interface ManagerShardEventsMap {
210
+ [WebSocketShardEvents.Closed]: [code: number, shardId: number];
211
+ [WebSocketShardEvents.Debug]: [message: string, shardId: number];
212
+ [WebSocketShardEvents.Dispatch]: [payload: GatewayDispatchPayload, shardId: number];
213
+ [WebSocketShardEvents.Error]: [error: Error, shardId: number];
214
+ [WebSocketShardEvents.Hello]: [shardId: number];
215
+ [WebSocketShardEvents.Ready]: [data: GatewayReadyDispatchData, shardId: number];
216
+ [WebSocketShardEvents.Resumed]: [shardId: number];
217
+ [WebSocketShardEvents.HeartbeatComplete]: [
218
+ stats: { ackAt: number; heartbeatAt: number; latency: number },
219
+ shardId: number,
220
+ ];
221
+ [WebSocketShardEvents.SocketError]: [error: Error, shardId: number];
222
+ }
223
+
224
+ export class WebSocketManager extends AsyncEventEmitter<ManagerShardEventsMap> implements AsyncDisposable {
225
+ #token: string | null = null;
226
+
227
+ /**
228
+ * The options being used by this manager
229
+ */
230
+ public readonly options: Omit<WebSocketManagerOptions, 'token'>;
231
+
232
+ /**
233
+ * Internal cache for a GET /gateway/bot result
234
+ */
235
+ private gatewayInformation: {
236
+ data: APIGatewayBotInfo;
237
+ expiresAt: number;
238
+ } | null = null;
239
+
240
+ /**
241
+ * Internal cache for the shard ids
242
+ */
243
+ private shardIds: number[] | null = null;
244
+
245
+ /**
246
+ * Strategy used to manage shards
247
+ *
248
+ * @defaultValue `SimpleShardingStrategy`
249
+ */
250
+ private readonly strategy: IShardingStrategy;
251
+
252
+ /**
253
+ * Gets the token set for this manager. If no token is set, an error is thrown.
254
+ * To set the token, use {@link WebSocketManager.setToken} or pass it in the options.
255
+ *
256
+ * @remarks
257
+ * This getter is mostly used to pass the token to the sharding strategy internally, there's not much reason to use it.
258
+ */
259
+ public get token(): string {
260
+ if (!this.#token) {
261
+ throw new Error('Token has not been set');
262
+ }
263
+
264
+ return this.#token;
265
+ }
266
+
267
+ public constructor(options: CreateWebSocketManagerOptions) {
268
+ if (typeof options.fetchGatewayInformation !== 'function') {
269
+ throw new TypeError('fetchGatewayInformation is required');
270
+ }
271
+
272
+ super();
273
+ this.options = {
274
+ ...DefaultWebSocketManagerOptions,
275
+ ...options,
276
+ };
277
+ this.strategy = this.options.buildStrategy(this);
278
+ this.#token = options.token ?? null;
279
+ }
280
+
281
+ /**
282
+ * Fetches the gateway information from Discord - or returns it from cache if available
283
+ *
284
+ * @param force - Whether to ignore the cache and force a fresh fetch
285
+ */
286
+ public async fetchGatewayInformation(force = false) {
287
+ if (this.gatewayInformation) {
288
+ if (this.gatewayInformation.expiresAt <= Date.now()) {
289
+ this.gatewayInformation = null;
290
+ } else if (!force) {
291
+ return this.gatewayInformation.data;
292
+ }
293
+ }
294
+
295
+ const data = await this.options.fetchGatewayInformation();
296
+
297
+ // For single sharded bots session_start_limit.reset_after will be 0, use 5 seconds as a minimum expiration time
298
+ this.gatewayInformation = { data, expiresAt: Date.now() + (data.session_start_limit.reset_after || 5_000) };
299
+ return this.gatewayInformation.data;
300
+ }
301
+
302
+ /**
303
+ * Updates your total shard count on-the-fly, spawning shards as needed
304
+ *
305
+ * @param shardCount - The new shard count to use
306
+ */
307
+ public async updateShardCount(shardCount: number | null) {
308
+ await this.strategy.destroy({ reason: 'User is adjusting their shards' });
309
+ this.options.shardCount = shardCount;
310
+
311
+ const shardIds = await this.getShardIds(true);
312
+ await this.strategy.spawn(shardIds);
313
+
314
+ return this;
315
+ }
316
+
317
+ /**
318
+ * Yields the total number of shards across for your bot, accounting for Discord recommendations
319
+ */
320
+ public async getShardCount(): Promise<number> {
321
+ if (this.options.shardCount) {
322
+ return this.options.shardCount;
323
+ }
324
+
325
+ const shardIds = await this.getShardIds();
326
+ return Math.max(...shardIds) + 1;
327
+ }
328
+
329
+ /**
330
+ * Yields the ids of the shards this manager should manage
331
+ */
332
+ public async getShardIds(force = false): Promise<number[]> {
333
+ if (this.shardIds && !force) {
334
+ return this.shardIds;
335
+ }
336
+
337
+ let shardIds: number[];
338
+ if (this.options.shardIds) {
339
+ if (Array.isArray(this.options.shardIds)) {
340
+ shardIds = this.options.shardIds;
341
+ } else {
342
+ const { start, end } = this.options.shardIds;
343
+ shardIds = [...range({ start, end: end + 1 })];
344
+ }
345
+ } else {
346
+ const data = await this.fetchGatewayInformation();
347
+ shardIds = [...range(this.options.shardCount ?? data.shards)];
348
+ }
349
+
350
+ this.shardIds = shardIds;
351
+ return shardIds;
352
+ }
353
+
354
+ public async connect() {
355
+ const shardCount = await this.getShardCount();
356
+ // Spawn shards and adjust internal state
357
+ await this.updateShardCount(shardCount);
358
+
359
+ const shardIds = await this.getShardIds();
360
+ const data = await this.fetchGatewayInformation();
361
+
362
+ if (data.session_start_limit.remaining < shardIds.length) {
363
+ throw new Error(
364
+ `Not enough sessions remaining to spawn ${shardIds.length} shards; only ${
365
+ data.session_start_limit.remaining
366
+ } remaining; resets at ${new Date(Date.now() + data.session_start_limit.reset_after).toISOString()}`,
367
+ );
368
+ }
369
+
370
+ await this.strategy.connect();
371
+ }
372
+
373
+ public setToken(token: string): void {
374
+ if (this.#token) {
375
+ throw new Error('Token has already been set');
376
+ }
377
+
378
+ this.#token = token;
379
+ }
380
+
381
+ public destroy(options?: Omit<WebSocketShardDestroyOptions, 'recover'>) {
382
+ return this.strategy.destroy(options);
383
+ }
384
+
385
+ public send(shardId: number, payload: GatewaySendPayload) {
386
+ return this.strategy.send(shardId, payload);
387
+ }
388
+
389
+ public fetchStatus(): Awaitable<Collection<number, WebSocketShardStatus>> {
390
+ return this.strategy.fetchStatus();
391
+ }
392
+
393
+ public async [Symbol.asyncDispose]() {
394
+ await this.destroy();
395
+ }
396
+ }