diff --git a/packages/core/README.md b/packages/core/README.md index 7c0ae85819e8..e950109138fc 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -23,7 +23,7 @@ ## Installation -**Node.js 16.9.0 or newer is required.** +**Node.js 18.12.0 or newer is required.** ```sh npm install @discordjs/core diff --git a/packages/core/package.json b/packages/core/package.json index b33400fa562e..ac5d5db634d1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -64,7 +64,7 @@ "devDependencies": { "@favware/cliff-jumper": "^2.0.0", "@microsoft/api-extractor": "^7.34.8", - "@types/node": "16.18.25", + "@types/node": "18.15.11", "@vitest/coverage-c8": "^0.31.0", "cross-env": "^7.0.3", "esbuild-plugin-version-injector": "^1.1.0", @@ -78,7 +78,7 @@ "vitest": "^0.31.0" }, "engines": { - "node": ">=16.9.0" + "node": ">=18.12.0" }, "publishConfig": { "access": "public" diff --git a/packages/discord.js/package.json b/packages/discord.js/package.json index b22df3e96589..a76f01e5df18 100644 --- a/packages/discord.js/package.json +++ b/packages/discord.js/package.json @@ -53,7 +53,7 @@ "@discordjs/builders": "workspace:^", "@discordjs/collection": "workspace:^", "@discordjs/formatters": "workspace:^", - "@discordjs/rest": "workspace:^", + "@discordjs/rest": "^1.7.1", "@discordjs/util": "workspace:^", "@discordjs/ws": "workspace:^", "@sapphire/snowflake": "^3.4.2", diff --git a/packages/proxy-container/package.json b/packages/proxy-container/package.json index 7504e054cf34..ae41fa157d1a 100644 --- a/packages/proxy-container/package.json +++ b/packages/proxy-container/package.json @@ -49,7 +49,7 @@ "tslib": "^2.5.0" }, "devDependencies": { - "@types/node": "16.18.25", + "@types/node": "18.15.11", "cross-env": "^7.0.3", "eslint": "^8.39.0", "eslint-config-neon": "^0.1.46", @@ -60,7 +60,7 @@ "typescript": "^5.0.4" }, "engines": { - "node": ">=16.9.0" + "node": ">=18.12.0" }, "publishConfig": { "access": "public" diff --git a/packages/proxy/README.md b/packages/proxy/README.md index 6058d7263fe0..5cb7a3bedffc 100644 --- a/packages/proxy/README.md +++ b/packages/proxy/README.md @@ -23,7 +23,7 @@ ## Installation -**Node.js 16.9.0 or newer is required.** +**Node.js 18.12.0 or newer is required.** ```sh npm install @discordjs/proxy diff --git a/packages/proxy/package.json b/packages/proxy/package.json index 1cb614c5b70f..249e97485217 100644 --- a/packages/proxy/package.json +++ b/packages/proxy/package.json @@ -64,7 +64,7 @@ "devDependencies": { "@favware/cliff-jumper": "^2.0.0", "@microsoft/api-extractor": "^7.34.8", - "@types/node": "16.18.25", + "@types/node": "18.15.11", "@types/supertest": "^2.0.12", "@vitest/coverage-c8": "^0.31.0", "cross-env": "^7.0.3", @@ -79,7 +79,7 @@ "vitest": "^0.31.0" }, "engines": { - "node": ">=16.9.0" + "node": ">=18.12.0" }, "publishConfig": { "access": "public" diff --git a/packages/proxy/src/util/responseHelpers.ts b/packages/proxy/src/util/responseHelpers.ts index 092b93c74df1..0c036aac85e1 100644 --- a/packages/proxy/src/util/responseHelpers.ts +++ b/packages/proxy/src/util/responseHelpers.ts @@ -1,7 +1,7 @@ import type { ServerResponse } from 'node:http'; +import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; -import { DiscordAPIError, HTTPError, RateLimitError } from '@discordjs/rest'; -import type { Dispatcher } from 'undici'; +import { DiscordAPIError, HTTPError, RateLimitError, type ResponseLike } from '@discordjs/rest'; /** * Populates a server response with the data from a Discord 2xx REST response @@ -9,19 +9,21 @@ import type { Dispatcher } from 'undici'; * @param res - The server response to populate * @param data - The data to populate the response with */ -export async function populateSuccessfulResponse(res: ServerResponse, data: Dispatcher.ResponseData): Promise { - res.statusCode = data.statusCode; +export async function populateSuccessfulResponse(res: ServerResponse, data: ResponseLike): Promise { + res.statusCode = data.status; - for (const header of Object.keys(data.headers)) { + for (const [header, value] of data.headers) { // Strip ratelimit headers - if (header.startsWith('x-ratelimit')) { + if (/^x-ratelimit/i.test(header)) { continue; } - res.setHeader(header, data.headers[header]!); + res.setHeader(header, value); } - await pipeline(data.body, res); + if (data.body) { + await pipeline(data.body instanceof Readable ? data.body : Readable.fromWeb(data.body), res); + } } /** diff --git a/packages/rest/README.md b/packages/rest/README.md index 5ae5a6ffa4f3..90fde3bc4a54 100644 --- a/packages/rest/README.md +++ b/packages/rest/README.md @@ -23,7 +23,7 @@ ## Installation -**Node.js 16.9.0 or newer is required.** +**Node.js 18.12.0 or newer is required.** ```sh npm install @discordjs/rest @@ -80,6 +80,25 @@ try { } ``` +Send a basic message in an edge environment: + +```js +import { REST } from '@discordjs/rest'; +import { Routes } from 'discord-api-types/v10'; + +const rest = new REST({ version: '10', makeRequest: fetch }).setToken(TOKEN); + +try { + await rest.post(Routes.channelMessages(CHANNEL_ID), { + body: { + content: 'A message via REST from the edge!', + }, + }); +} catch (error) { + console.error(error); +} +``` + ## Links - [Website][website] ([source][website-source]) diff --git a/packages/rest/__tests__/BurstHandler.test.ts b/packages/rest/__tests__/BurstHandler.test.ts index 25831d538aa4..f052bc038600 100644 --- a/packages/rest/__tests__/BurstHandler.test.ts +++ b/packages/rest/__tests__/BurstHandler.test.ts @@ -3,7 +3,7 @@ import { performance } from 'node:perf_hooks'; import { MockAgent, setGlobalDispatcher } from 'undici'; import type { Interceptable, MockInterceptor } from 'undici/types/mock-interceptor'; -import { beforeEach, afterEach, test, expect, vitest } from 'vitest'; +import { beforeEach, afterEach, test, expect } from 'vitest'; import { DiscordAPIError, REST, BurstHandlerMajorIdKey } from '../src/index.js'; import { BurstHandler } from '../src/lib/handlers/BurstHandler.js'; import { genPath } from './util.js'; @@ -46,6 +46,7 @@ test('Interaction callback creates burst handler', async () => { auth: false, body: { type: 4, data: { content: 'Reply' } }, }), + // TODO: This should be ArrayBuffer, there is a bug in undici request ).toBeInstanceOf(Uint8Array); expect(api.requestManager.handlers.get(callbackKey)).toBeInstanceOf(BurstHandler); }); diff --git a/packages/rest/__tests__/REST.test.ts b/packages/rest/__tests__/REST.test.ts index dbaba12067f3..521d3cd21557 100644 --- a/packages/rest/__tests__/REST.test.ts +++ b/packages/rest/__tests__/REST.test.ts @@ -3,10 +3,10 @@ import { URLSearchParams } from 'node:url'; import { DiscordSnowflake } from '@sapphire/snowflake'; import type { Snowflake } from 'discord-api-types/v10'; import { Routes } from 'discord-api-types/v10'; -import type { FormData } from 'undici'; +import { type FormData, fetch } from 'undici'; import { File as UndiciFile, MockAgent, setGlobalDispatcher } from 'undici'; import type { Interceptable, MockInterceptor } from 'undici/types/mock-interceptor.js'; -import { beforeEach, afterEach, test, expect } from 'vitest'; +import { beforeEach, afterEach, test, expect, vitest } from 'vitest'; import { REST } from '../src/index.js'; import { genPath } from './util.js'; @@ -16,6 +16,10 @@ const newSnowflake: Snowflake = DiscordSnowflake.generate().toString(); const api = new REST().setToken('A-Very-Fake-Token'); +const makeRequestMock = vitest.fn(fetch); + +const fetchApi = new REST({ makeRequest: makeRequestMock }).setToken('A-Very-Fake-Token'); + // @discordjs/rest uses the `content-type` header to detect whether to parse // the response as JSON or as an ArrayBuffer. const responseOptions: MockInterceptor.MockResponseOptions = { @@ -114,6 +118,22 @@ test('simple POST', async () => { expect(await api.post('/simplePost')).toStrictEqual({ test: true }); }); +test('simple POST with fetch', async () => { + mockPool + .intercept({ + path: genPath('/fetchSimplePost'), + method: 'POST', + }) + .reply(() => ({ + data: { test: true }, + statusCode: 200, + responseOptions, + })); + + expect(await fetchApi.post('/fetchSimplePost')).toStrictEqual({ test: true }); + expect(makeRequestMock).toHaveBeenCalledTimes(1); +}); + test('simple PUT 2', async () => { mockPool .intercept({ @@ -159,11 +179,11 @@ test('getAuth', async () => { path: genPath('/getAuth'), method: 'GET', }) - .reply((from) => ({ - data: { auth: (from.headers as unknown as Record).Authorization ?? null }, - statusCode: 200, + .reply( + 200, + (from) => ({ auth: (from.headers as unknown as Record).Authorization ?? null }), responseOptions, - })) + ) .times(3); // default @@ -190,11 +210,13 @@ test('getReason', async () => { path: genPath('/getReason'), method: 'GET', }) - .reply((from) => ({ - data: { reason: (from.headers as unknown as Record)['X-Audit-Log-Reason'] ?? null }, - statusCode: 200, + .reply( + 200, + (from) => ({ + reason: (from.headers as unknown as Record)['X-Audit-Log-Reason'] ?? null, + }), responseOptions, - })) + ) .times(3); // default diff --git a/packages/rest/__tests__/RequestHandler.test.ts b/packages/rest/__tests__/RequestHandler.test.ts index 1421fd69e13c..eb0cc06977e5 100644 --- a/packages/rest/__tests__/RequestHandler.test.ts +++ b/packages/rest/__tests__/RequestHandler.test.ts @@ -1,7 +1,7 @@ /* eslint-disable id-length */ /* eslint-disable promise/prefer-await-to-then */ import { performance } from 'node:perf_hooks'; -import { setInterval, clearInterval, setTimeout } from 'node:timers'; +import { setInterval, clearInterval } from 'node:timers'; import { MockAgent, setGlobalDispatcher } from 'undici'; import type { Interceptable, MockInterceptor } from 'undici/types/mock-interceptor.js'; import { beforeEach, afterEach, test, expect, vitest } from 'vitest'; @@ -492,7 +492,7 @@ test('server responding too slow', async () => { const promise = api2.get('/slow'); - await expect(promise).rejects.toThrowError('Request aborted'); + await expect(promise).rejects.toThrowError('aborted'); }, 1_000); test('Unauthorized', async () => { @@ -570,8 +570,8 @@ test('abort', async () => { controller.abort(); // Abort mid-execution: - await expect(bP2).rejects.toThrowError('Request aborted'); + await expect(bP2).rejects.toThrowError('aborted'); // Abort scheduled: - await expect(cP2).rejects.toThrowError('Request aborted'); + await expect(cP2).rejects.toThrowError('Request aborted manually'); }); diff --git a/packages/rest/__tests__/Util.test.ts b/packages/rest/__tests__/UndiciRequest.test.ts similarity index 52% rename from packages/rest/__tests__/Util.test.ts rename to packages/rest/__tests__/UndiciRequest.test.ts index ad4cbb5c74b2..783555ec88f8 100644 --- a/packages/rest/__tests__/Util.test.ts +++ b/packages/rest/__tests__/UndiciRequest.test.ts @@ -1,22 +1,37 @@ import { Blob, Buffer } from 'node:buffer'; import { URLSearchParams } from 'node:url'; -import { test, expect } from 'vitest'; -import { resolveBody, parseHeader } from '../src/lib/utils/utils.js'; +import { MockAgent, setGlobalDispatcher } from 'undici'; +import type { Interceptable, MockInterceptor } from 'undici/types/mock-interceptor.js'; +import { beforeEach, afterEach, test, expect, vitest } from 'vitest'; +import { REST } from '../src/index.js'; +import { makeRequest, resolveBody } from '../src/strategies/undiciRequest.js'; +import { genPath } from './util.js'; -test('GIVEN string parseHeader returns string', () => { - const header = 'application/json'; +const makeRequestMock = vitest.fn(makeRequest); - expect(parseHeader(header)).toEqual(header); -}); +const api = new REST({ makeRequest: makeRequestMock }).setToken('A-Very-Fake-Token'); + +// @discordjs/rest uses the `content-type` header to detect whether to parse +// the response as JSON or as an ArrayBuffer. +const responseOptions: MockInterceptor.MockResponseOptions = { + headers: { + 'content-type': 'application/json', + }, +}; + +let mockAgent: MockAgent; +let mockPool: Interceptable; -test('GIVEN string[] parseHeader returns string', () => { - const header = ['application/json', 'wait sorry I meant text/html']; +beforeEach(() => { + mockAgent = new MockAgent(); + mockAgent.disableNetConnect(); // prevent actual requests to Discord + setGlobalDispatcher(mockAgent); // enabled the mock client to intercept requests - expect(parseHeader(header)).toEqual(header.join(';')); + mockPool = mockAgent.get('https://discord.com'); }); -test('GIVEN undefined parseHeader return undefined', () => { - expect(parseHeader(undefined)).toBeUndefined(); +afterEach(async () => { + await mockAgent.close(); }); test('resolveBody', async () => { @@ -43,7 +58,7 @@ test('resolveBody', async () => { } }, }; - await expect(resolveBody(iterable)).resolves.toStrictEqual(new Uint8Array([1, 2, 3, 1, 2, 3, 1, 2, 3])); + await expect(resolveBody(iterable)).resolves.toStrictEqual(Buffer.from([1, 2, 3, 1, 2, 3, 1, 2, 3])); const asyncIterable: AsyncIterable = { [Symbol.asyncIterator]() { @@ -66,3 +81,19 @@ test('resolveBody', async () => { // @ts-expect-error: This test is ensuring that this throws await expect(resolveBody(true)).rejects.toThrow(TypeError); }); + +test('use passed undici request', async () => { + mockPool + .intercept({ + path: genPath('/simplePost'), + method: 'POST', + }) + .reply(() => ({ + data: { test: true }, + statusCode: 200, + responseOptions, + })); + + expect(await api.post('/simplePost')).toStrictEqual({ test: true }); + expect(makeRequestMock).toHaveBeenCalledTimes(1); +}); diff --git a/packages/rest/package.json b/packages/rest/package.json index 0a5aa0ecfb9b..f1d43fb7656c 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -18,9 +18,16 @@ "module": "./dist/index.mjs", "typings": "./dist/index.d.ts", "exports": { - "types": "./dist/index.d.ts", - "import": "./dist/index.mjs", - "require": "./dist/index.js" + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + }, + "./*": { + "types": "./dist/strategies/*.d.ts", + "import": "./dist/strategies/*.mjs", + "require": "./dist/strategies/*.js" + } }, "directories": { "lib": "src", @@ -66,7 +73,7 @@ "devDependencies": { "@favware/cliff-jumper": "^2.0.0", "@microsoft/api-extractor": "^7.34.8", - "@types/node": "16.18.25", + "@types/node": "18.15.11", "@vitest/coverage-c8": "^0.31.0", "cross-env": "^7.0.3", "esbuild-plugin-version-injector": "^1.1.0", @@ -80,7 +87,7 @@ "vitest": "^0.31.0" }, "engines": { - "node": ">=16.9.0" + "node": ">=18.12.0" }, "publishConfig": { "access": "public" diff --git a/packages/rest/src/lib/REST.ts b/packages/rest/src/lib/REST.ts index 7369187f4568..abfc06560a6e 100644 --- a/packages/rest/src/lib/REST.ts +++ b/packages/rest/src/lib/REST.ts @@ -1,6 +1,8 @@ import { EventEmitter } from 'node:events'; +import type { Readable } from 'node:stream'; +import type { ReadableStream } from 'node:stream/web'; import type { Collection } from '@discordjs/collection'; -import type { request, Dispatcher } from 'undici'; +import type { Dispatcher, RequestInit, Response } from 'undici'; import { CDN } from './CDN.js'; import { RequestManager, @@ -11,7 +13,7 @@ import { type RequestData, type RouteLike, } from './RequestManager.js'; -import type { IHandler } from './handlers/IHandler.js'; +import type { IHandler } from './interfaces/Handler.js'; import { DefaultRestOptions, RESTEvents } from './utils/constants.js'; import { parseResponse } from './utils/utils.js'; @@ -22,7 +24,7 @@ export interface RESTOptions { /** * The agent to set globally */ - agent: Dispatcher; + agent: Dispatcher | null; /** * The base api path, without version * @@ -79,6 +81,13 @@ export interface RESTOptions { * @defaultValue `0` */ invalidRequestWarningInterval: number; + /** + * The method called to perform the actual HTTP request given a url and web `fetch` options + * For example, to use global fetch, simply provide `makeRequest: fetch` + * + * @defaultValue `undici.request` + */ + makeRequest(url: string, init: RequestInit): Promise; /** * The extra offset to add to rate limits in milliseconds * @@ -179,7 +188,7 @@ export interface APIRequest { /** * Additional HTTP options for this request */ - options: RequestOptions; + options: RequestInit; /** * The full path used to make the request */ @@ -194,6 +203,11 @@ export interface APIRequest { route: string; } +export interface ResponseLike + extends Pick { + body: Readable | ReadableStream | null; +} + export interface InvalidRequestWarningData { /** * Number of invalid requests that have been made in the window @@ -212,7 +226,7 @@ export interface RestEvents { newListener: [name: string, listener: (...args: any) => void]; rateLimited: [rateLimitInfo: RateLimitData]; removeListener: [name: string, listener: (...args: any) => void]; - response: [request: APIRequest, response: Dispatcher.ResponseData]; + response: [request: APIRequest, response: ResponseLike]; restDebug: [info: string]; } @@ -233,8 +247,6 @@ export interface REST { ((event?: Exclude) => this); } -export type RequestOptions = Exclude[1], undefined>; - export class REST extends EventEmitter { public readonly cdn: CDN; diff --git a/packages/rest/src/lib/RequestManager.ts b/packages/rest/src/lib/RequestManager.ts index a318b39174e3..7b3abb8f6569 100644 --- a/packages/rest/src/lib/RequestManager.ts +++ b/packages/rest/src/lib/RequestManager.ts @@ -5,11 +5,11 @@ import type { URLSearchParams } from 'node:url'; import { Collection } from '@discordjs/collection'; import { lazy } from '@discordjs/util'; import { DiscordSnowflake } from '@sapphire/snowflake'; -import { FormData, type RequestInit, type BodyInit, type Dispatcher, type Agent } from 'undici'; -import type { RESTOptions, RestEvents, RequestOptions } from './REST.js'; +import type { RequestInit, BodyInit, Dispatcher, Agent } from 'undici'; +import type { RESTOptions, ResponseLike, RestEvents } from './REST.js'; import { BurstHandler } from './handlers/BurstHandler.js'; -import type { IHandler } from './handlers/IHandler.js'; import { SequentialHandler } from './handlers/SequentialHandler.js'; +import type { IHandler } from './interfaces/Handler.js'; import { BurstHandlerMajorIdKey, DefaultRestOptions, @@ -17,7 +17,6 @@ import { OverwrittenMimeTypes, RESTEvents, } from './utils/constants.js'; -import { resolveBody } from './utils/utils.js'; // Make this a lazy dynamic import as file-type is a pure ESM package const getFileType = lazy(async () => import('file-type')); @@ -323,7 +322,7 @@ export class RequestManager extends EventEmitter { * @param request - All the information needed to make a request * @returns The response from the api request */ - public async queueRequest(request: InternalRequest): Promise { + public async queueRequest(request: InternalRequest): Promise { // Generalize the endpoint to its route data const routeId = RequestManager.generateRouteData(request.fullRoute, request.method); // Get the bucket hash for the generic route, or point to a global route otherwise @@ -373,7 +372,7 @@ export class RequestManager extends EventEmitter { * * @param request - The request data */ - private async resolveRequest(request: InternalRequest): Promise<{ fetchOptions: RequestOptions; url: string }> { + private async resolveRequest(request: InternalRequest): Promise<{ fetchOptions: RequestInit; url: string }> { const { options } = this; let query = ''; @@ -470,20 +469,18 @@ export class RequestManager extends EventEmitter { } } - finalBody = await resolveBody(finalBody); + const method = request.method.toUpperCase(); - const fetchOptions: RequestOptions = { + // The non null assertions in the following block are due to exactOptionalPropertyTypes, they have been tested to work with undefined + const fetchOptions: RequestInit = { + // Set body to null on get / head requests. This does not follow fetch spec (likely because it causes subtle bugs) but is aligned with what request was doing + body: ['GET', 'HEAD'].includes(method) ? null : finalBody!, headers: { ...request.headers, ...additionalHeaders, ...headers } as Record, - method: request.method.toUpperCase() as Dispatcher.HttpMethod, + method, + // Prioritize setting an agent per request, use the agent for this instance otherwise. + dispatcher: request.dispatcher ?? this.agent ?? undefined!, }; - if (finalBody !== undefined) { - fetchOptions.body = finalBody as Exclude; - } - - // Prioritize setting an agent per request, use the agent for this instance otherwise. - fetchOptions.dispatcher = request.dispatcher ?? this.agent ?? undefined!; - return { url, fetchOptions }; } diff --git a/packages/rest/src/lib/global/fetch.d.ts b/packages/rest/src/lib/global/fetch.d.ts new file mode 100644 index 000000000000..8522b42bae77 --- /dev/null +++ b/packages/rest/src/lib/global/fetch.d.ts @@ -0,0 +1,5 @@ +import type * as undici from 'undici'; + +declare global { + export const { fetch, FormData, Headers, Request, Response }: typeof undici; +} diff --git a/packages/rest/src/lib/handlers/BurstHandler.ts b/packages/rest/src/lib/handlers/BurstHandler.ts index e030fcdb0d0f..971ac32d60a6 100644 --- a/packages/rest/src/lib/handlers/BurstHandler.ts +++ b/packages/rest/src/lib/handlers/BurstHandler.ts @@ -1,10 +1,10 @@ import { setTimeout as sleep } from 'node:timers/promises'; -import type { Dispatcher } from 'undici'; -import type { RequestOptions } from '../REST.js'; +import type { RequestInit } from 'undici'; +import type { ResponseLike } from '../REST.js'; import type { HandlerRequestData, RequestManager, RouteData } from '../RequestManager.js'; +import type { IHandler } from '../interfaces/Handler.js'; import { RESTEvents } from '../utils/constants.js'; -import { onRateLimit, parseHeader } from '../utils/utils.js'; -import type { IHandler } from './IHandler.js'; +import { onRateLimit } from '../utils/utils.js'; import { handleErrors, incrementInvalidCount, makeNetworkRequest } from './Shared.js'; /** @@ -54,9 +54,9 @@ export class BurstHandler implements IHandler { public async queueRequest( routeId: RouteData, url: string, - options: RequestOptions, + options: RequestInit, requestData: HandlerRequestData, - ): Promise { + ): Promise { return this.runRequest(routeId, url, options, requestData); } @@ -72,10 +72,10 @@ export class BurstHandler implements IHandler { private async runRequest( routeId: RouteData, url: string, - options: RequestOptions, + options: RequestInit, requestData: HandlerRequestData, retries = 0, - ): Promise { + ): Promise { const method = options.method ?? 'get'; const res = await makeNetworkRequest(this.manager, routeId, url, options, requestData, retries); @@ -86,9 +86,9 @@ export class BurstHandler implements IHandler { return this.runRequest(routeId, url, options, requestData, ++retries); } - const status = res.statusCode; + const status = res.status; let retryAfter = 0; - const retry = parseHeader(res.headers['retry-after']); + const retry = res.headers.get('Retry-After'); // Amount of time in milliseconds until we should retry if rate limited (globally or otherwise) if (retry) retryAfter = Number(retry) * 1_000 + this.manager.options.offset; @@ -102,7 +102,7 @@ export class BurstHandler implements IHandler { return res; } else if (status === 429) { // Unexpected ratelimit - const isGlobal = res.headers['x-ratelimit-global'] !== undefined; + const isGlobal = res.headers.has('X-RateLimit-Global'); await onRateLimit(this.manager, { timeToReset: retryAfter, limit: Number.POSITIVE_INFINITY, diff --git a/packages/rest/src/lib/handlers/SequentialHandler.ts b/packages/rest/src/lib/handlers/SequentialHandler.ts index a6b461afa4d6..f18919b18fe9 100644 --- a/packages/rest/src/lib/handlers/SequentialHandler.ts +++ b/packages/rest/src/lib/handlers/SequentialHandler.ts @@ -1,11 +1,11 @@ import { setTimeout as sleep } from 'node:timers/promises'; import { AsyncQueue } from '@sapphire/async-queue'; -import type { Dispatcher } from 'undici'; -import type { RateLimitData, RequestOptions } from '../REST.js'; +import type { RequestInit } from 'undici'; +import type { RateLimitData, ResponseLike } from '../REST.js'; import type { HandlerRequestData, RequestManager, RouteData } from '../RequestManager.js'; +import type { IHandler } from '../interfaces/Handler.js'; import { RESTEvents } from '../utils/constants.js'; -import { hasSublimit, onRateLimit, parseHeader } from '../utils/utils.js'; -import type { IHandler } from './IHandler.js'; +import { hasSublimit, onRateLimit } from '../utils/utils.js'; import { handleErrors, incrementInvalidCount, makeNetworkRequest } from './Shared.js'; const enum QueueType { @@ -134,9 +134,9 @@ export class SequentialHandler implements IHandler { public async queueRequest( routeId: RouteData, url: string, - options: RequestOptions, + options: RequestInit, requestData: HandlerRequestData, - ): Promise { + ): Promise { let queue = this.#asyncQueue; let queueType = QueueType.Standard; // Separate sublimited requests when already sublimited @@ -195,10 +195,10 @@ export class SequentialHandler implements IHandler { private async runRequest( routeId: RouteData, url: string, - options: RequestOptions, + options: RequestInit, requestData: HandlerRequestData, retries = 0, - ): Promise { + ): Promise { /* * After calculations have been done, pre-emptively stop further requests * Potentially loop until this task can run if e.g. the global rate limit is hit twice @@ -270,14 +270,14 @@ export class SequentialHandler implements IHandler { return this.runRequest(routeId, url, options, requestData, ++retries); } - const status = res.statusCode; + const status = res.status; let retryAfter = 0; - const limit = parseHeader(res.headers['x-ratelimit-limit']); - const remaining = parseHeader(res.headers['x-ratelimit-remaining']); - const reset = parseHeader(res.headers['x-ratelimit-reset-after']); - const hash = parseHeader(res.headers['x-ratelimit-bucket']); - const retry = parseHeader(res.headers['retry-after']); + const limit = res.headers.get('X-RateLimit-Limit'); + const remaining = res.headers.get('X-RateLimit-Remaining'); + const reset = res.headers.get('X-RateLimit-Reset-After'); + const hash = res.headers.get('X-RateLimit-Bucket'); + const retry = res.headers.get('Retry-After'); // Update the total number of requests that can be made before the rate limit resets this.limit = limit ? Number(limit) : Number.POSITIVE_INFINITY; @@ -309,7 +309,7 @@ export class SequentialHandler implements IHandler { // Handle retryAfter, which means we have actually hit a rate limit let sublimitTimeout: number | null = null; if (retryAfter > 0) { - if (res.headers['x-ratelimit-global'] !== undefined) { + if (res.headers.has('X-RateLimit-Global')) { this.manager.globalRemaining = 0; this.manager.globalReset = Date.now() + retryAfter; } else if (!this.localLimited) { @@ -327,7 +327,7 @@ export class SequentialHandler implements IHandler { incrementInvalidCount(this.manager); } - if (status >= 200 && status < 300) { + if (res.ok) { return res; } else if (status === 429) { // A rate limit was hit - this may happen if the route isn't associated with an official bucket hash yet, or when first globally rate limited diff --git a/packages/rest/src/lib/handlers/Shared.ts b/packages/rest/src/lib/handlers/Shared.ts index 3736f5a56e34..42c3278b4bb0 100644 --- a/packages/rest/src/lib/handlers/Shared.ts +++ b/packages/rest/src/lib/handlers/Shared.ts @@ -1,13 +1,13 @@ import { setTimeout, clearTimeout } from 'node:timers'; -import { request, type Dispatcher } from 'undici'; -import type { RequestOptions } from '../REST.js'; +import { Response } from 'undici'; +import type { RequestInit } from 'undici'; +import type { ResponseLike } from '../REST.js'; import type { HandlerRequestData, RequestManager, RouteData } from '../RequestManager.js'; import type { DiscordErrorData, OAuthErrorData } from '../errors/DiscordAPIError.js'; import { DiscordAPIError } from '../errors/DiscordAPIError.js'; import { HTTPError } from '../errors/HTTPError.js'; import { RESTEvents } from '../utils/constants.js'; import { parseResponse, shouldRetry } from '../utils/utils.js'; -import type { PolyFillAbortSignal } from './IHandler.js'; /** * Invalid request limiting is done on a per-IP basis, not a per-token basis. @@ -60,25 +60,23 @@ export async function makeNetworkRequest( manager: RequestManager, routeId: RouteData, url: string, - options: RequestOptions, + options: RequestInit, requestData: HandlerRequestData, retries: number, ) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), manager.options.timeout).unref(); if (requestData.signal) { - // The type polyfill is required because Node.js's types are incomplete. - const signal = requestData.signal as unknown as PolyFillAbortSignal; // If the user signal was aborted, abort the controller, else abort the local signal. // The reason why we don't re-use the user's signal, is because users may use the same signal for multiple // requests, and we do not want to cause unexpected side-effects. - if (signal.aborted) controller.abort(); - else signal.addEventListener('abort', () => controller.abort()); + if (requestData.signal.aborted) controller.abort(); + else requestData.signal.addEventListener('abort', () => controller.abort()); } - let res: Dispatcher.ResponseData; + let res: ResponseLike; try { - res = await request(url, { ...options, signal: controller.signal }); + res = await manager.options.makeRequest(url, { ...options, signal: controller.signal }); } catch (error: unknown) { if (!(error instanceof Error)) throw error; // Retry the specified number of times if needed @@ -103,7 +101,7 @@ export async function makeNetworkRequest( data: requestData, retries, }, - { ...res }, + res instanceof Response ? res.clone() : { ...res }, ); } @@ -123,13 +121,13 @@ export async function makeNetworkRequest( */ export async function handleErrors( manager: RequestManager, - res: Dispatcher.ResponseData, + res: ResponseLike, method: string, url: string, requestData: HandlerRequestData, retries: number, ) { - const status = res.statusCode; + const status = res.status; if (status >= 500 && status < 600) { // Retry the specified number of times for possible server side issues if (retries !== manager.options.retries) { diff --git a/packages/rest/src/lib/handlers/IHandler.ts b/packages/rest/src/lib/interfaces/Handler.ts similarity index 66% rename from packages/rest/src/lib/handlers/IHandler.ts rename to packages/rest/src/lib/interfaces/Handler.ts index a6837d16b8f1..c8cbc6c4d768 100644 --- a/packages/rest/src/lib/handlers/IHandler.ts +++ b/packages/rest/src/lib/interfaces/Handler.ts @@ -1,5 +1,5 @@ -import type { Dispatcher } from 'undici'; -import type { RequestOptions } from '../REST.js'; +import type { RequestInit } from 'undici'; +import type { ResponseLike } from '../REST.js'; import type { HandlerRequestData, RouteData } from '../RequestManager.js'; export interface IHandler { @@ -22,13 +22,7 @@ export interface IHandler { queueRequest( routeId: RouteData, url: string, - options: RequestOptions, + options: RequestInit, requestData: HandlerRequestData, - ): Promise; -} - -export interface PolyFillAbortSignal { - readonly aborted: boolean; - addEventListener(type: 'abort', listener: () => void): void; - removeEventListener(type: 'abort', listener: () => void): void; + ): Promise; } diff --git a/packages/rest/src/lib/utils/constants.ts b/packages/rest/src/lib/utils/constants.ts index 105af92cbdaa..98f84ac9d4fd 100644 --- a/packages/rest/src/lib/utils/constants.ts +++ b/packages/rest/src/lib/utils/constants.ts @@ -1,8 +1,12 @@ import process from 'node:process'; +import { lazy } from '@discordjs/util'; import { APIVersion } from 'discord-api-types/v10'; -import { Agent } from 'undici'; import type { RESTOptions } from '../REST.js'; +const getUndiciRequest = lazy(async () => { + return import('../../strategies/undiciRequest.js'); +}); + export const DefaultUserAgent = `DiscordBot (https://discord.js.org, [VI]{{inject}}[/VI])` as `DiscordBot (https://discord.js.org, ${string})`; @@ -12,13 +16,7 @@ export const DefaultUserAgent = export const DefaultUserAgentAppendix = process.release?.name === 'node' ? `Node.js/${process.version}` : ''; export const DefaultRestOptions = { - get agent() { - return new Agent({ - connect: { - timeout: 30_000, - }, - }); - }, + agent: null, api: 'https://discord.com/api', authPrefix: 'Bot', cdn: 'https://cdn.discordapp.com', @@ -34,6 +32,10 @@ export const DefaultRestOptions = { hashSweepInterval: 14_400_000, // 4 Hours hashLifetime: 86_400_000, // 24 Hours handlerSweepInterval: 3_600_000, // 1 Hour + async makeRequest(...args) { + const strategy = await getUndiciRequest(); + return strategy.makeRequest(...args); + }, } as const satisfies Required; /** diff --git a/packages/rest/src/lib/utils/utils.ts b/packages/rest/src/lib/utils/utils.ts index 5e17bd5c217b..0489b02d289d 100644 --- a/packages/rest/src/lib/utils/utils.ts +++ b/packages/rest/src/lib/utils/utils.ts @@ -1,20 +1,9 @@ -import { Blob, Buffer } from 'node:buffer'; import { URLSearchParams } from 'node:url'; -import { types } from 'node:util'; import type { RESTPatchAPIChannelJSONBody } from 'discord-api-types/v10'; -import { FormData, type Dispatcher, type RequestInit } from 'undici'; -import type { RateLimitData, RequestOptions } from '../REST.js'; +import type { RateLimitData, ResponseLike } from '../REST.js'; import { type RequestManager, RequestMethod } from '../RequestManager.js'; import { RateLimitError } from '../errors/RateLimitError.js'; -export function parseHeader(header: string[] | string | undefined): string | undefined { - if (header === undefined || typeof header === 'string') { - return header; - } - - return header.join(';'); -} - function serializeSearchParam(value: unknown): string | null { switch (typeof value) { case 'string': @@ -61,13 +50,12 @@ export function makeURLSearchParams(options?: Readonly) { * * @param res - The fetch response */ -export async function parseResponse(res: Dispatcher.ResponseData): Promise { - const header = parseHeader(res.headers['content-type']); - if (header?.startsWith('application/json')) { - return res.body.json(); +export async function parseResponse(res: ResponseLike): Promise { + if (res.headers.get('Content-Type')?.startsWith('application/json')) { + return res.json(); } - return res.body.arrayBuffer(); + return res.arrayBuffer(); } /** @@ -94,49 +82,6 @@ export function hasSublimit(bucketRoute: string, body?: unknown, method?: string return true; } -export async function resolveBody(body: RequestInit['body']): Promise { - // eslint-disable-next-line no-eq-null, eqeqeq - if (body == null) { - return null; - } else if (typeof body === 'string') { - return body; - } else if (types.isUint8Array(body)) { - return body; - } else if (types.isArrayBuffer(body)) { - return new Uint8Array(body); - } else if (body instanceof URLSearchParams) { - return body.toString(); - } else if (body instanceof DataView) { - return new Uint8Array(body.buffer); - } else if (body instanceof Blob) { - return new Uint8Array(await body.arrayBuffer()); - } else if (body instanceof FormData) { - return body; - } else if ((body as Iterable)[Symbol.iterator]) { - const chunks = [...(body as Iterable)]; - const length = chunks.reduce((a, b) => a + b.length, 0); - - const uint8 = new Uint8Array(length); - let lengthUsed = 0; - - return chunks.reduce((a, b) => { - a.set(b, lengthUsed); - lengthUsed += b.length; - return a; - }, uint8); - } else if ((body as AsyncIterable)[Symbol.asyncIterator]) { - const chunks: Uint8Array[] = []; - - for await (const chunk of body as AsyncIterable) { - chunks.push(chunk); - } - - return Buffer.concat(chunks); - } - - throw new TypeError(`Unable to resolve body.`); -} - /** * Check whether an error indicates that a retry can be attempted * diff --git a/packages/rest/src/strategies/undiciRequest.ts b/packages/rest/src/strategies/undiciRequest.ts new file mode 100644 index 000000000000..691ab365f268 --- /dev/null +++ b/packages/rest/src/strategies/undiciRequest.ts @@ -0,0 +1,70 @@ +import { Buffer } from 'node:buffer'; +import { URLSearchParams } from 'node:url'; +import { types } from 'node:util'; +import { type RequestInit, request } from 'undici'; +import type { ResponseLike } from '../index.js'; + +export type RequestOptions = Exclude[1], undefined>; + +export async function makeRequest(url: string, init: RequestInit): Promise { + // The cast is necessary because `headers` and `method` are narrower types in `undici.request` + // our request path guarantees they are of acceptable type for `undici.request` + const options = { + ...init, + body: await resolveBody(init.body), + } as RequestOptions; + const res = await request(url, options); + return { + body: res.body, + async arrayBuffer() { + return res.body.arrayBuffer(); + }, + async json() { + return res.body.json(); + }, + async text() { + return res.body.text(); + }, + get bodyUsed() { + return res.body.bodyUsed; + }, + headers: new Headers(res.headers as Record), + status: res.statusCode, + ok: res.statusCode >= 200 && res.statusCode < 300, + }; +} + +export async function resolveBody(body: RequestInit['body']): Promise> { + // eslint-disable-next-line no-eq-null, eqeqeq + if (body == null) { + return null; + } else if (typeof body === 'string') { + return body; + } else if (types.isUint8Array(body)) { + return body; + } else if (types.isArrayBuffer(body)) { + return new Uint8Array(body); + } else if (body instanceof URLSearchParams) { + return body.toString(); + } else if (body instanceof DataView) { + return new Uint8Array(body.buffer); + } else if (body instanceof Blob) { + return new Uint8Array(await body.arrayBuffer()); + } else if (body instanceof FormData) { + return body; + } else if ((body as Iterable)[Symbol.iterator]) { + const chunks = [...(body as Iterable)]; + + return Buffer.concat(chunks); + } else if ((body as AsyncIterable)[Symbol.asyncIterator]) { + const chunks: Uint8Array[] = []; + + for await (const chunk of body as AsyncIterable) { + chunks.push(chunk); + } + + return Buffer.concat(chunks); + } + + throw new TypeError(`Unable to resolve body.`); +} diff --git a/packages/rest/tsup.config.ts b/packages/rest/tsup.config.ts index afd45736d47b..10ddbde0c605 100644 --- a/packages/rest/tsup.config.ts +++ b/packages/rest/tsup.config.ts @@ -2,5 +2,6 @@ import { esbuildPluginVersionInjector } from 'esbuild-plugin-version-injector'; import { createTsupConfig } from '../../tsup.config.js'; export default createTsupConfig({ + entry: ['src/index.ts', 'src/strategies/*.ts'], esbuildPlugins: [esbuildPluginVersionInjector()], }); diff --git a/packages/ws/README.md b/packages/ws/README.md index b51e75c0964d..a95260c51f77 100644 --- a/packages/ws/README.md +++ b/packages/ws/README.md @@ -23,7 +23,7 @@ ## Installation -**Node.js 16.9.0 or newer is required.** +**Node.js 18.12.0 or newer is required.** ```sh npm install @discordjs/ws diff --git a/packages/ws/package.json b/packages/ws/package.json index 6c06072149e2..090be698827e 100644 --- a/packages/ws/package.json +++ b/packages/ws/package.json @@ -74,7 +74,7 @@ "devDependencies": { "@favware/cliff-jumper": "^2.0.0", "@microsoft/api-extractor": "^7.34.8", - "@types/node": "16.18.25", + "@types/node": "18.15.11", "@vitest/coverage-c8": "^0.31.0", "cross-env": "^7.0.3", "esbuild-plugin-version-injector": "^1.1.0", @@ -91,7 +91,7 @@ "zlib-sync": "^0.1.8" }, "engines": { - "node": ">=16.9.0" + "node": ">=18.12.0" }, "publishConfig": { "access": "public" diff --git a/vitest.config.ts b/vitest.config.ts index 81e9cde9038b..aea40ebda80c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ exclude: [ // All ts files that only contain types, due to ALL '**/*.{interface,type,d}.ts', + '**/{interfaces,types}/*.ts', // All index files that *should* only contain exports from other files '**/index.{js,ts}', // All exports files that make subpackages available as submodules diff --git a/yarn.lock b/yarn.lock index 2c1b6b974c75..f4f635933b40 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2102,7 +2102,7 @@ __metadata: "@favware/cliff-jumper": ^2.0.0 "@microsoft/api-extractor": ^7.34.8 "@sapphire/snowflake": ^3.4.2 - "@types/node": 16.18.25 + "@types/node": 18.15.11 "@vitest/coverage-c8": ^0.31.0 "@vladfrangu/async_event_emitter": ^2.2.1 cross-env: ^7.0.3 @@ -2274,7 +2274,7 @@ __metadata: dependencies: "@discordjs/proxy": "workspace:^" "@discordjs/rest": "workspace:^" - "@types/node": 16.18.25 + "@types/node": 18.15.11 cross-env: ^7.0.3 eslint: ^8.39.0 eslint-config-neon: ^0.1.46 @@ -2295,7 +2295,7 @@ __metadata: "@discordjs/util": "workspace:^" "@favware/cliff-jumper": ^2.0.0 "@microsoft/api-extractor": ^7.34.8 - "@types/node": 16.18.25 + "@types/node": 18.15.11 "@types/supertest": ^2.0.12 "@vitest/coverage-c8": ^0.31.0 cross-env: ^7.0.3 @@ -2313,7 +2313,7 @@ __metadata: languageName: unknown linkType: soft -"@discordjs/rest@workspace:^, @discordjs/rest@workspace:packages/rest": +"@discordjs/rest@^1.7.1, @discordjs/rest@workspace:^, @discordjs/rest@workspace:packages/rest": version: 0.0.0-use.local resolution: "@discordjs/rest@workspace:packages/rest" dependencies: @@ -2323,7 +2323,7 @@ __metadata: "@microsoft/api-extractor": ^7.34.8 "@sapphire/async-queue": ^1.5.0 "@sapphire/snowflake": ^3.4.2 - "@types/node": 16.18.25 + "@types/node": 18.15.11 "@vitest/coverage-c8": ^0.31.0 cross-env: ^7.0.3 discord-api-types: ^0.37.41 @@ -2533,7 +2533,7 @@ __metadata: "@favware/cliff-jumper": ^2.0.0 "@microsoft/api-extractor": ^7.34.8 "@sapphire/async-queue": ^1.5.0 - "@types/node": 16.18.25 + "@types/node": 18.15.11 "@types/ws": ^8.5.4 "@vitest/coverage-c8": ^0.31.0 "@vladfrangu/async_event_emitter": ^2.2.1 @@ -6657,6 +6657,13 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:18.15.11": + version: 18.15.11 + resolution: "@types/node@npm:18.15.11" + checksum: 977b4ad04708897ff0eb049ecf82246d210939c82461922d20f7d2dcfd81bbc661582ba3af28869210f7e8b1934529dcd46bff7d448551400f9d48b9d3bddec3 + languageName: node + linkType: hard + "@types/node@npm:18.16.4, @types/node@npm:^18.0.0": version: 18.16.4 resolution: "@types/node@npm:18.16.4" @@ -11335,7 +11342,7 @@ __metadata: "@discordjs/collection": "workspace:^" "@discordjs/docgen": "workspace:^" "@discordjs/formatters": "workspace:^" - "@discordjs/rest": "workspace:^" + "@discordjs/rest": ^1.7.1 "@discordjs/util": "workspace:^" "@discordjs/ws": "workspace:^" "@favware/cliff-jumper": ^2.0.0