diff --git a/packages/core/README.md b/packages/core/README.md index 74e82d768..588405ced 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -37,9 +37,16 @@ pnpm add @discordjs/core These examples use [ES modules](https://nodejs.org/api/esm.html#enabling). ```ts +import { + Client, + GatewayDispatchEvents, + GatewayIntentBits, + InteractionType, + MessageFlags, + type RESTGetAPIGatewayBotResult, +} from '@discordjs/core'; import { REST } from '@discordjs/rest'; import { WebSocketManager } from '@discordjs/ws'; -import { GatewayDispatchEvents, GatewayIntentBits, InteractionType, MessageFlags, Client } from '@discordjs/core'; // Create REST and WebSocket managers directly const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_TOKEN); @@ -47,7 +54,7 @@ const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_TOKEN); const gateway = new WebSocketManager({ token: process.env.DISCORD_TOKEN, intents: GatewayIntentBits.GuildMessages | GatewayIntentBits.MessageContent, - rest, + fetchGatewayInformation: () => rest.get('/gateway/bot') as Promise, }); // Create a client to emit relevant events. diff --git a/packages/discord.js/src/client/Client.js b/packages/discord.js/src/client/Client.js index dcedc0f76..bd53a2b91 100644 --- a/packages/discord.js/src/client/Client.js +++ b/packages/discord.js/src/client/Client.js @@ -163,7 +163,7 @@ class Client extends BaseClient { const wsOptions = { ...this.options.ws, intents: this.options.intents.bitfield, - rest: this.rest, + fetchGatewayInformation: () => this.rest.get(Routes.gatewayBot()), // Explicitly nulled to always be set using `setToken` in `login` token: null, }; diff --git a/packages/ws/__tests__/gateway.mock.ts b/packages/ws/__tests__/gateway.mock.ts new file mode 100644 index 000000000..bc6147742 --- /dev/null +++ b/packages/ws/__tests__/gateway.mock.ts @@ -0,0 +1,12 @@ +import type { RESTGetAPIGatewayBotResult } from 'discord-api-types/v10'; + +export const mockGatewayInformation: RESTGetAPIGatewayBotResult = { + shards: 1, + session_start_limit: { + max_concurrency: 3, + reset_after: 60, + remaining: 3, + total: 3, + }, + url: 'wss://gateway.discord.gg', +}; diff --git a/packages/ws/__tests__/strategy/WorkerContextFetchingStrategy.test.ts b/packages/ws/__tests__/strategy/WorkerContextFetchingStrategy.test.ts index bed387a94..ffd062474 100644 --- a/packages/ws/__tests__/strategy/WorkerContextFetchingStrategy.test.ts +++ b/packages/ws/__tests__/strategy/WorkerContextFetchingStrategy.test.ts @@ -1,7 +1,5 @@ /* eslint-disable @typescript-eslint/consistent-type-imports */ // @ts-nocheck -import { REST } from '@discordjs/rest'; -import { MockAgent, type Interceptable } from 'undici'; import { beforeEach, test, vi, expect } from 'vitest'; import { managerToFetchingStrategyOptions, @@ -12,15 +10,7 @@ import { type WorkerReceivePayload, type WorkerSendPayload, } from '../../src/index.js'; - -let mockAgent: MockAgent; -let mockPool: Interceptable; - -beforeEach(() => { - mockAgent = new MockAgent(); - mockAgent.disableNetConnect(); - mockPool = mockAgent.get('https://discord.com'); -}); +import { mockGatewayInformation } from '../gateway.mock.js'; const session = { shardId: 0, @@ -52,32 +42,13 @@ vi.mock('node:worker_threads', async () => { }); test('session info', async () => { - const rest = new REST().setAgent(mockAgent).setToken('A-Very-Fake-Token'); - const manager = new WebSocketManager({ token: 'A-Very-Fake-Token', intents: 0, rest }); - - mockPool - .intercept({ - path: '/api/v10/gateway/bot', - method: 'GET', - }) - .reply(() => ({ - data: { - shards: 1, - session_start_limit: { - max_concurrency: 3, - reset_after: 60, - remaining: 3, - total: 3, - }, - url: 'wss://gateway.discord.gg', - }, - statusCode: 200, - responseOptions: { - headers: { - 'content-type': 'application/json', - }, - }, - })); + const manager = new WebSocketManager({ + token: 'A-Very-Fake-Token', + intents: 0, + async fetchGatewayInformation() { + return mockGatewayInformation; + }, + }); const strategy = new WorkerContextFetchingStrategy(await managerToFetchingStrategyOptions(manager)); diff --git a/packages/ws/__tests__/strategy/WorkerShardingStrategy.test.ts b/packages/ws/__tests__/strategy/WorkerShardingStrategy.test.ts index 6d547aabd..c4d3fade0 100644 --- a/packages/ws/__tests__/strategy/WorkerShardingStrategy.test.ts +++ b/packages/ws/__tests__/strategy/WorkerShardingStrategy.test.ts @@ -1,10 +1,8 @@ /* eslint-disable id-length */ import { setImmediate } from 'node:timers'; -import { REST } from '@discordjs/rest'; -import type { RESTGetAPIGatewayBotResult, GatewayDispatchPayload, GatewaySendPayload } from 'discord-api-types/v10'; -import { GatewayDispatchEvents, GatewayOpcodes, Routes } from 'discord-api-types/v10'; -import { MockAgent, type Interceptable } from 'undici'; -import { beforeEach, test, vi, expect, afterEach } from 'vitest'; +import type { GatewayDispatchPayload, GatewaySendPayload } from 'discord-api-types/v10'; +import { GatewayDispatchEvents, GatewayOpcodes } from 'discord-api-types/v10'; +import { test, vi, expect, afterEach } from 'vitest'; import { WebSocketManager, WorkerSendPayloadOp, @@ -15,9 +13,7 @@ import { type WorkerSendPayload, type SessionInfo, } from '../../src/index.js'; - -let mockAgent: MockAgent; -let mockPool: Interceptable; +import { mockGatewayInformation } from '../gateway.mock.js'; const mockConstructor = vi.fn(); const mockSend = vi.fn(); @@ -135,12 +131,6 @@ vi.mock('node:worker_threads', async () => { }; }); -beforeEach(() => { - mockAgent = new MockAgent(); - mockAgent.disableNetConnect(); - mockPool = mockAgent.get('https://discord.com'); -}); - afterEach(() => { mockConstructor.mockClear(); mockSend.mockClear(); @@ -148,15 +138,13 @@ afterEach(() => { }); test('spawn, connect, send a message, session info, and destroy', async () => { - const rest = new REST().setAgent(mockAgent).setToken('A-Very-Fake-Token'); - const mockRetrieveSessionInfo = vi.fn(); const mockUpdateSessionInfo = vi.fn(); const manager = new WebSocketManager({ token: 'A-Very-Fake-Token', intents: 0, async fetchGatewayInformation() { - return rest.get(Routes.gatewayBot()) as Promise; + return mockGatewayInformation; }, shardIds: [0, 1], retrieveSessionInfo: mockRetrieveSessionInfo, @@ -166,30 +154,6 @@ test('spawn, connect, send a message, session info, and destroy', async () => { const managerEmitSpy = vi.spyOn(manager, 'emit'); - mockPool - .intercept({ - path: '/api/v10/gateway/bot', - method: 'GET', - }) - .reply(() => ({ - data: { - shards: 1, - session_start_limit: { - max_concurrency: 3, - reset_after: 60, - remaining: 3, - total: 3, - }, - url: 'wss://gateway.discord.gg', - }, - statusCode: 200, - responseOptions: { - headers: { - 'content-type': 'application/json', - }, - }, - })); - await manager.connect(); expect(mockConstructor).toHaveBeenCalledWith( expect.stringContaining('defaultWorker.js'), diff --git a/packages/ws/__tests__/ws/WebSocketManager.test.ts b/packages/ws/__tests__/ws/WebSocketManager.test.ts index 16419ebb3..2c2dad7c4 100644 --- a/packages/ws/__tests__/ws/WebSocketManager.test.ts +++ b/packages/ws/__tests__/ws/WebSocketManager.test.ts @@ -1,107 +1,55 @@ -import { REST } from '@discordjs/rest'; -import type { RESTGetAPIGatewayBotResult, APIGatewayBotInfo, GatewaySendPayload } from 'discord-api-types/v10'; -import { GatewayOpcodes, Routes } from 'discord-api-types/v10'; -import { MockAgent, type Interceptable } from 'undici'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import type { GatewaySendPayload } from 'discord-api-types/v10'; +import { GatewayOpcodes } from 'discord-api-types/v10'; +import { describe, expect, test, vi } from 'vitest'; import { WebSocketManager, type IShardingStrategy } from '../../src/index.js'; +import { mockGatewayInformation } from '../gateway.mock.js'; vi.useFakeTimers(); -let mockAgent: MockAgent; -let mockPool: Interceptable; - -beforeEach(() => { - mockAgent = new MockAgent(); - mockAgent.disableNetConnect(); - mockPool = mockAgent.get('https://discord.com'); -}); - const NOW = vi.fn().mockReturnValue(Date.now()); global.Date.now = NOW; test('fetch gateway information', async () => { - const rest = new REST().setAgent(mockAgent).setToken('A-Very-Fake-Token'); + const fetchGatewayInformation = vi.fn(async () => mockGatewayInformation); + const manager = new WebSocketManager({ token: 'A-Very-Fake-Token', intents: 0, - async fetchGatewayInformation() { - return rest.get(Routes.gatewayBot()) as Promise; - }, + fetchGatewayInformation, }); - const data: APIGatewayBotInfo = { - shards: 1, - session_start_limit: { - max_concurrency: 3, - reset_after: 60, - remaining: 3, - total: 3, - }, - url: 'wss://gateway.discord.gg', - }; - - const fetch = vi.fn(() => ({ - data, - statusCode: 200, - responseOptions: { - headers: { - 'content-type': 'application/json', - }, - }, - })); - - mockPool - .intercept({ - path: '/api/v10/gateway/bot', - method: 'GET', - }) - .reply(fetch); - const initial = await manager.fetchGatewayInformation(); - expect(initial).toEqual(data); - expect(fetch).toHaveBeenCalledOnce(); + expect(initial).toEqual(mockGatewayInformation); + expect(fetchGatewayInformation).toHaveBeenCalledOnce(); - fetch.mockClear(); + fetchGatewayInformation.mockClear(); const cached = await manager.fetchGatewayInformation(); - expect(cached).toEqual(data); - expect(fetch).not.toHaveBeenCalled(); + expect(cached).toEqual(mockGatewayInformation); + expect(fetchGatewayInformation).not.toHaveBeenCalled(); - fetch.mockClear(); - mockPool - .intercept({ - path: '/api/v10/gateway/bot', - method: 'GET', - }) - .reply(fetch); + fetchGatewayInformation.mockClear(); const forced = await manager.fetchGatewayInformation(true); - expect(forced).toEqual(data); - expect(fetch).toHaveBeenCalledOnce(); + expect(forced).toEqual(mockGatewayInformation); + expect(fetchGatewayInformation).toHaveBeenCalledOnce(); - fetch.mockClear(); - mockPool - .intercept({ - path: '/api/v10/gateway/bot', - method: 'GET', - }) - .reply(fetch); + fetchGatewayInformation.mockClear(); NOW.mockReturnValue(Number.POSITIVE_INFINITY); const cacheExpired = await manager.fetchGatewayInformation(); - expect(cacheExpired).toEqual(data); - expect(fetch).toHaveBeenCalledOnce(); + expect(cacheExpired).toEqual(mockGatewayInformation); + expect(fetchGatewayInformation).toHaveBeenCalledOnce(); }); describe('get shard count', () => { test('with shard count', async () => { - const rest = new REST().setAgent(mockAgent).setToken('A-Very-Fake-Token'); const manager = new WebSocketManager({ token: 'A-Very-Fake-Token', intents: 0, shardCount: 2, async fetchGatewayInformation() { - return rest.get(Routes.gatewayBot()) as Promise; + return mockGatewayInformation; }, }); @@ -109,14 +57,13 @@ describe('get shard count', () => { }); test('with shard ids array', async () => { - const rest = new REST().setAgent(mockAgent).setToken('A-Very-Fake-Token'); const shardIds = [5, 9]; const manager = new WebSocketManager({ token: 'A-Very-Fake-Token', intents: 0, shardIds, async fetchGatewayInformation() { - return rest.get(Routes.gatewayBot()) as Promise; + return mockGatewayInformation; }, }); @@ -124,14 +71,13 @@ describe('get shard count', () => { }); test('with shard id range', async () => { - const rest = new REST().setAgent(mockAgent).setToken('A-Very-Fake-Token'); const shardIds = { start: 5, end: 9 }; const manager = new WebSocketManager({ token: 'A-Very-Fake-Token', intents: 0, shardIds, async fetchGatewayInformation() { - return rest.get(Routes.gatewayBot()) as Promise; + return mockGatewayInformation; }, }); @@ -140,62 +86,26 @@ describe('get shard count', () => { }); test('update shard count', async () => { - const rest = new REST().setAgent(mockAgent).setToken('A-Very-Fake-Token'); + const fetchGatewayInformation = vi.fn(async () => mockGatewayInformation); + const manager = new WebSocketManager({ token: 'A-Very-Fake-Token', intents: 0, shardCount: 2, - async fetchGatewayInformation() { - return rest.get(Routes.gatewayBot()) as Promise; - }, + fetchGatewayInformation, }); - const data: APIGatewayBotInfo = { - shards: 1, - session_start_limit: { - max_concurrency: 3, - reset_after: 60, - remaining: 3, - total: 3, - }, - url: 'wss://gateway.discord.gg', - }; - - const fetch = vi.fn(() => ({ - data, - statusCode: 200, - responseOptions: { - headers: { - 'content-type': 'application/json', - }, - }, - })); - - mockPool - .intercept({ - path: '/api/v10/gateway/bot', - method: 'GET', - }) - .reply(fetch); - expect(await manager.getShardCount()).toBe(2); - expect(fetch).not.toHaveBeenCalled(); + expect(fetchGatewayInformation).not.toHaveBeenCalled(); - fetch.mockClear(); - mockPool - .intercept({ - path: '/api/v10/gateway/bot', - method: 'GET', - }) - .reply(fetch); + fetchGatewayInformation.mockClear(); await manager.updateShardCount(3); expect(await manager.getShardCount()).toBe(3); - expect(fetch).toHaveBeenCalled(); + expect(fetchGatewayInformation).toHaveBeenCalled(); }); test('it handles passing in both shardIds and shardCount', async () => { - const rest = new REST().setAgent(mockAgent).setToken('A-Very-Fake-Token'); const shardIds = { start: 2, end: 3 }; const manager = new WebSocketManager({ token: 'A-Very-Fake-Token', @@ -203,7 +113,7 @@ test('it handles passing in both shardIds and shardCount', async () => { shardIds, shardCount: 4, async fetchGatewayInformation() { - return rest.get(Routes.gatewayBot()) as Promise; + return mockGatewayInformation; }, }); @@ -226,44 +136,18 @@ test('strategies', async () => { const strategy = new MockStrategy(); - const rest = new REST().setAgent(mockAgent).setToken('A-Very-Fake-Token'); const shardIds = [0, 1, 2]; + const manager = new WebSocketManager({ token: 'A-Very-Fake-Token', intents: 0, - rest, shardIds, + async fetchGatewayInformation() { + return mockGatewayInformation; + }, buildStrategy: () => strategy, }); - const data: APIGatewayBotInfo = { - shards: 1, - session_start_limit: { - max_concurrency: 3, - reset_after: 60, - remaining: 3, - total: 3, - }, - url: 'wss://gateway.discord.gg', - }; - - const fetch = vi.fn(() => ({ - data, - statusCode: 200, - responseOptions: { - headers: { - 'content-type': 'application/json', - }, - }, - })); - - mockPool - .intercept({ - path: '/api/v10/gateway/bot', - method: 'GET', - }) - .reply(fetch); - await manager.connect(); expect(strategy.spawn).toHaveBeenCalledWith(shardIds); expect(strategy.connect).toHaveBeenCalled(); diff --git a/packages/ws/package.json b/packages/ws/package.json index ea886078c..37fcffa92 100644 --- a/packages/ws/package.json +++ b/packages/ws/package.json @@ -74,7 +74,6 @@ "funding": "https://github.com/discordjs/discord.js?sponsor", "dependencies": { "@discordjs/collection": "workspace:^", - "@discordjs/rest": "workspace:^", "@discordjs/util": "workspace:^", "@sapphire/async-queue": "^1.5.5", "@types/ws": "^8.18.1", @@ -100,7 +99,6 @@ "tsup": "^8.5.0", "turbo": "^2.5.4", "typescript": "~5.8.3", - "undici": "7.11.0", "vitest": "^3.2.4", "zlib-sync": "^0.1.10" }, diff --git a/packages/ws/src/ws/WebSocketManager.ts b/packages/ws/src/ws/WebSocketManager.ts index 084db06e5..4cd6dde9b 100644 --- a/packages/ws/src/ws/WebSocketManager.ts +++ b/packages/ws/src/ws/WebSocketManager.ts @@ -1,17 +1,15 @@ import type { Collection } from '@discordjs/collection'; -import type { REST } from '@discordjs/rest'; import { range, type Awaitable } from '@discordjs/util'; import { AsyncEventEmitter } from '@vladfrangu/async_event_emitter'; -import { - Routes, - type APIGatewayBotInfo, - type GatewayIdentifyProperties, - type GatewayPresenceUpdateData, - type RESTGetAPIGatewayBotResult, - type GatewayIntentBits, - type GatewaySendPayload, - type GatewayDispatchPayload, - type GatewayReadyDispatchData, +import type { + APIGatewayBotInfo, + GatewayIdentifyProperties, + GatewayPresenceUpdateData, + RESTGetAPIGatewayBotResult, + GatewayIntentBits, + GatewaySendPayload, + GatewayDispatchPayload, + GatewayReadyDispatchData, } from 'discord-api-types/v10'; import type { IShardingStrategy } from '../strategies/sharding/IShardingStrategy.js'; import type { IIdentifyThrottler } from '../throttling/IIdentifyThrottler.js'; @@ -56,6 +54,22 @@ export interface SessionInfo { * Required options for the WebSocketManager */ export interface RequiredWebSocketManagerOptions { + /** + * Function for retrieving the information returned by the `/gateway/bot` endpoint. + * We recommend using a REST client that respects Discord's rate limits, such as `@discordjs/rest`. + * + * @example + * ```ts + * const rest = new REST().setToken(process.env.DISCORD_TOKEN); + * const manager = new WebSocketManager({ + * token: process.env.DISCORD_TOKEN, + * fetchGatewayInformation() { + * return rest.get(Routes.gatewayBot()) as Promise; + * }, + * }); + * ``` + */ + fetchGatewayInformation(): Awaitable; /** * The intents to request */ @@ -75,10 +89,13 @@ export interface OptionalWebSocketManagerOptions { * * @example * ```ts + * const rest = new REST().setToken(process.env.DISCORD_TOKEN); * const manager = new WebSocketManager({ * token: process.env.DISCORD_TOKEN, * intents: 0, // for no intents - * rest, + * fetchGatewayInformation() { + * return rest.get(Routes.gatewayBot()) as Promise; + * }, * buildStrategy: (manager) => new WorkerShardingStrategy(manager, { shardsPerWorker: 2 }), * }); * ``` @@ -96,21 +113,6 @@ export interface OptionalWebSocketManagerOptions { * @defaultValue `'json'` */ encoding: Encoding; - /** - * Fetches the initial gateway URL used to connect to Discord. When missing, this will default to the gateway URL - * that Discord returns from the `/gateway/bot` route. - * - * @example - * ```ts - * const manager = new WebSocketManager({ - * token: process.env.DISCORD_TOKEN, - * fetchGatewayInformation() { - * return rest.get(Routes.gatewayBot()); - * }, - * }) - * ``` - */ - fetchGatewayInformation(): Awaitable; /** * How long to wait for a shard to connect before giving up */ @@ -135,12 +137,6 @@ export interface OptionalWebSocketManagerOptions { * How long to wait for a shard's READY packet before giving up */ readyTimeout: number | null; - /** - * The REST instance to use for fetching gateway information - * - * @deprecated Providing a REST instance is deprecated. Provide the `fetchGatewayInformation` function instead. - */ - rest?: REST; /** * Function used to retrieve session information (and attempt to resume) for a given shard * @@ -271,22 +267,13 @@ export class WebSocketManager extends AsyncEventEmitter i } public constructor(options: CreateWebSocketManagerOptions) { - if (!options.rest && !options.fetchGatewayInformation) { - throw new RangeError('Either a REST instance or a fetchGatewayInformation function must be provided'); + if (typeof options.fetchGatewayInformation !== 'function') { + throw new TypeError('fetchGatewayInformation is required'); } super(); this.options = { ...DefaultWebSocketManagerOptions, - fetchGatewayInformation: - options.fetchGatewayInformation ?? - (async () => { - if (!options.rest) { - throw new RangeError('A REST instance must be provided if no fetchGatewayInformation function is provided'); - } - - return options.rest.get(Routes.gatewayBot()) as Promise; - }), ...options, }; this.strategy = this.options.buildStrategy(this); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3045a0b52..6237763e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1861,9 +1861,6 @@ importers: '@discordjs/collection': specifier: workspace:^ version: link:../collection - '@discordjs/rest': - specifier: workspace:^ - version: link:../rest '@discordjs/util': specifier: workspace:^ version: link:../util @@ -1934,9 +1931,6 @@ importers: typescript: specifier: ~5.8.3 version: 5.8.3 - undici: - specifier: 7.11.0 - version: 7.11.0 vitest: specifier: ^3.2.4 version: 3.2.4(@edge-runtime/vm@3.2.0)(@types/debug@4.1.12)(@types/node@22.16.3)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.0)