From 8f4256db8a52ac08359d0b3436f41b641ac4e382 Mon Sep 17 00:00:00 2001 From: ckohen Date: Tue, 25 Jul 2023 01:40:21 -0700 Subject: [PATCH] refactor(REST): remove double classing (#9722) * refactor(REST): remove double classing BREAKING CHANGE: `REST` and `RequestManager` have been combined, most of the properties, methods, and events from both classes can now be found on `REST` BREAKING CHANGE: `REST#raw` has been removed in favor of `REST#queueRequest` BREAKING CHANGE: `REST#getAgent` has been removed in favor of `REST#agent` * chore: update for /rest changes --- packages/proxy/src/handlers/proxyRequests.ts | 2 +- packages/rest/__tests__/BurstHandler.test.ts | 4 +- .../rest/__tests__/RequestHandler.test.ts | 4 +- .../rest/__tests__/RequestManager.test.ts | 2 +- packages/rest/src/index.ts | 6 + packages/rest/src/lib/REST.ts | 624 ++++++++++-------- packages/rest/src/lib/RequestManager.ts | 532 --------------- .../rest/src/lib/errors/DiscordAPIError.ts | 2 +- packages/rest/src/lib/errors/HTTPError.ts | 2 +- .../rest/src/lib/errors/RateLimitError.ts | 2 +- .../rest/src/lib/handlers/BurstHandler.ts | 6 +- .../src/lib/handlers/SequentialHandler.ts | 6 +- packages/rest/src/lib/handlers/Shared.ts | 10 +- packages/rest/src/lib/interfaces/Handler.ts | 3 +- packages/rest/src/lib/utils/constants.ts | 2 +- packages/rest/src/lib/utils/types.ts | 359 ++++++++++ packages/rest/src/lib/utils/utils.ts | 6 +- packages/rest/src/shared.ts | 2 +- packages/rest/src/strategies/undiciRequest.ts | 2 +- 19 files changed, 759 insertions(+), 817 deletions(-) delete mode 100644 packages/rest/src/lib/RequestManager.ts create mode 100644 packages/rest/src/lib/utils/types.ts diff --git a/packages/proxy/src/handlers/proxyRequests.ts b/packages/proxy/src/handlers/proxyRequests.ts index c243982ad0ae..79bc18eea1c2 100644 --- a/packages/proxy/src/handlers/proxyRequests.ts +++ b/packages/proxy/src/handlers/proxyRequests.ts @@ -33,7 +33,7 @@ export function proxyRequests(rest: REST): RequestHandler { } try { - const discordResponse = await rest.raw({ + const discordResponse = await rest.queueRequest({ body: req, fullRoute, // This type cast is technically incorrect, but we want Discord to throw Method Not Allowed for us diff --git a/packages/rest/__tests__/BurstHandler.test.ts b/packages/rest/__tests__/BurstHandler.test.ts index f052bc038600..8f9303fb5345 100644 --- a/packages/rest/__tests__/BurstHandler.test.ts +++ b/packages/rest/__tests__/BurstHandler.test.ts @@ -40,7 +40,7 @@ const responseOptions: MockInterceptor.MockResponseOptions = { test('Interaction callback creates burst handler', async () => { mockPool.intercept({ path: callbackPath, method: 'POST' }).reply(200); - expect(api.requestManager.handlers.get(callbackKey)).toBe(undefined); + expect(api.handlers.get(callbackKey)).toBe(undefined); expect( await api.post('/interactions/1234567890123456789/totallyarealtoken/callback', { auth: false, @@ -48,7 +48,7 @@ test('Interaction callback creates burst handler', async () => { }), // TODO: This should be ArrayBuffer, there is a bug in undici request ).toBeInstanceOf(Uint8Array); - expect(api.requestManager.handlers.get(callbackKey)).toBeInstanceOf(BurstHandler); + expect(api.handlers.get(callbackKey)).toBeInstanceOf(BurstHandler); }); test('Requests are handled in bursts', async () => { diff --git a/packages/rest/__tests__/RequestHandler.test.ts b/packages/rest/__tests__/RequestHandler.test.ts index eb0cc06977e5..5d4061c4bc91 100644 --- a/packages/rest/__tests__/RequestHandler.test.ts +++ b/packages/rest/__tests__/RequestHandler.test.ts @@ -106,7 +106,7 @@ test('Significant Invalid Requests', async () => { await expect(e).rejects.toThrowError('Missing Permissions'); expect(invalidListener).toHaveBeenCalledTimes(0); // eslint-disable-next-line require-atomic-updates - api.requestManager.options.invalidRequestWarningInterval = 2; + api.options.invalidRequestWarningInterval = 2; const [f, g, h, i, j] = [ api.get('/badRequest'), @@ -504,7 +504,7 @@ test('Unauthorized', async () => { .reply(401, { message: '401: Unauthorized', code: 0 }, responseOptions) .times(2); - const setTokenSpy = vitest.spyOn(invalidAuthApi.requestManager, 'setToken'); + const setTokenSpy = vitest.spyOn(invalidAuthApi, 'setToken'); // Ensure authless requests don't reset the token const promiseWithoutTokenClear = invalidAuthApi.get('/unauthorized', { auth: false }); diff --git a/packages/rest/__tests__/RequestManager.test.ts b/packages/rest/__tests__/RequestManager.test.ts index 73dcf6285365..213ccffc4518 100644 --- a/packages/rest/__tests__/RequestManager.test.ts +++ b/packages/rest/__tests__/RequestManager.test.ts @@ -36,5 +36,5 @@ test('no token', async () => { test('negative offset', () => { const badREST = new REST({ offset: -5_000 }); - expect(badREST.requestManager.options.offset).toEqual(0); + expect(badREST.options.offset).toEqual(0); }); diff --git a/packages/rest/src/index.ts b/packages/rest/src/index.ts index 32934acf5466..eb6fc7a17c03 100644 --- a/packages/rest/src/index.ts +++ b/packages/rest/src/index.ts @@ -1,7 +1,13 @@ +import { Blob } from 'node:buffer'; import { shouldUseGlobalFetchAndWebSocket } from '@discordjs/util'; +import { FormData } from 'undici'; import { setDefaultStrategy } from './environment.js'; import { makeRequest } from './strategies/undiciRequest.js'; +// TODO(ckohen): remove once node engine req is bumped to > v18 +(globalThis as any).FormData ??= FormData; +globalThis.Blob ??= Blob; + setDefaultStrategy(shouldUseGlobalFetchAndWebSocket() ? fetch : makeRequest); export * from './shared.js'; diff --git a/packages/rest/src/lib/REST.ts b/packages/rest/src/lib/REST.ts index 03723d3017fe..c70efccef34f 100644 --- a/packages/rest/src/lib/REST.ts +++ b/packages/rest/src/lib/REST.ts @@ -1,288 +1,155 @@ -import type { Readable } from 'node:stream'; -import type { ReadableStream } from 'node:stream/web'; -import type { Collection } from '@discordjs/collection'; +import { Collection } from '@discordjs/collection'; +import { DiscordSnowflake } from '@sapphire/snowflake'; import { AsyncEventEmitter } from '@vladfrangu/async_event_emitter'; -import type { Dispatcher, RequestInit, Response } from 'undici'; +import { filetypeinfo } from 'magic-bytes.js'; +import type { RequestInit, BodyInit, Dispatcher } from 'undici'; import { CDN } from './CDN.js'; -import { - RequestManager, - RequestMethod, - type HashData, - type HandlerRequestData, - type InternalRequest, - type RequestData, - type RouteLike, -} from './RequestManager.js'; +import { BurstHandler } from './handlers/BurstHandler.js'; +import { SequentialHandler } from './handlers/SequentialHandler.js'; import type { IHandler } from './interfaces/Handler.js'; -import { DefaultRestOptions, RESTEvents } from './utils/constants.js'; -import { parseResponse } from './utils/utils.js'; - -/** - * Options to be passed when creating the REST instance - */ -export interface RESTOptions { - /** - * The agent to set globally - */ - agent: Dispatcher | null; - /** - * The base api path, without version - * - * @defaultValue `'https://discord.com/api'` - */ - api: string; - /** - * The authorization prefix to use for requests, useful if you want to use - * bearer tokens - * - * @defaultValue `'Bot'` - */ - authPrefix: 'Bearer' | 'Bot'; - /** - * The cdn path - * - * @defaultValue `'https://cdn.discordapp.com'` - */ - cdn: string; - /** - * How many requests to allow sending per second (Infinity for unlimited, 50 for the standard global limit used by Discord) - * - * @defaultValue `50` - */ - globalRequestsPerSecond: number; - /** - * The amount of time in milliseconds that passes between each hash sweep. (defaults to 1h) - * - * @defaultValue `3_600_000` - */ - handlerSweepInterval: number; - /** - * The maximum amount of time a hash can exist in milliseconds without being hit with a request (defaults to 24h) - * - * @defaultValue `86_400_000` - */ - hashLifetime: number; - /** - * The amount of time in milliseconds that passes between each hash sweep. (defaults to 4h) - * - * @defaultValue `14_400_000` - */ - hashSweepInterval: number; - /** - * Additional headers to send for all API requests - * - * @defaultValue `{}` - */ - headers: Record; - /** - * The number of invalid REST requests (those that return 401, 403, or 429) in a 10 minute window between emitted warnings (0 for no warnings). - * That is, if set to 500, warnings will be emitted at invalid request number 500, 1000, 1500, and so on. - * - * @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 - * - * @defaultValue `50` - */ - offset: number; - /** - * Determines how rate limiting and pre-emptive throttling should be handled. - * When an array of strings, each element is treated as a prefix for the request route - * (e.g. `/channels` to match any route starting with `/channels` such as `/channels/:id/messages`) - * for which to throw {@link RateLimitError}s. All other request routes will be queued normally - * - * @defaultValue `null` - */ - rejectOnRateLimit: RateLimitQueueFilter | string[] | null; - /** - * The number of retries for errors with the 500 code, or errors - * that timeout - * - * @defaultValue `3` - */ - retries: number; - /** - * The time to wait in milliseconds before a request is aborted - * - * @defaultValue `15_000` - */ - timeout: number; - /** - * Extra information to add to the user agent - * - * @defaultValue DefaultUserAgentAppendix - */ - userAgentAppendix: string; - /** - * The version of the API to use - * - * @defaultValue `'10'` - */ - version: string; -} +import { + BurstHandlerMajorIdKey, + DefaultRestOptions, + DefaultUserAgent, + OverwrittenMimeTypes, + RESTEvents, +} from './utils/constants.js'; +import { RequestMethod } from './utils/types.js'; +import type { + RESTOptions, + ResponseLike, + RestEventsMap, + HashData, + InternalRequest, + RouteLike, + RequestHeaders, + RouteData, + RequestData, +} from './utils/types.js'; +import { isBufferLike, parseResponse } from './utils/utils.js'; /** - * Data emitted on `RESTEvents.RateLimited` + * Represents the class that manages handlers for endpoints */ -export interface RateLimitData { - /** - * Whether the rate limit that was reached was the global limit - */ - global: boolean; - /** - * The bucket hash for this request - */ - hash: string; - /** - * The amount of requests we can perform before locking requests - */ - limit: number; - /** - * The major parameter of the route - * - * For example, in `/channels/x`, this will be `x`. - * If there is no major parameter (e.g: `/bot/gateway`) this will be `global`. - */ - majorParameter: string; - /** - * The HTTP method being performed - */ - method: string; - /** - * The route being hit in this request - */ - route: string; - /** - * The time, in milliseconds, until the request-lock is reset - */ - timeToReset: number; +export class REST extends AsyncEventEmitter { /** - * The full URL for this request + * The {@link https://undici.nodejs.org/#/docs/api/Agent | Agent} for all requests + * performed by this manager. */ - url: string; -} + public agent: Dispatcher | null = null; -/** - * A function that determines whether the rate limit hit should throw an Error - */ -export type RateLimitQueueFilter = (rateLimitData: RateLimitData) => Promise | boolean; + public readonly cdn: CDN; -export interface APIRequest { - /** - * The data that was used to form the body of this request - */ - data: HandlerRequestData; /** - * The HTTP method used in this request + * The number of requests remaining in the global bucket */ - method: string; - /** - * Additional HTTP options for this request - */ - options: RequestInit; - /** - * The full path used to make the request - */ - path: RouteLike; + public globalRemaining: number; + /** - * The number of times this request has been attempted + * The promise used to wait out the global rate limit */ - retries: number; + public globalDelay: Promise | null = null; + /** - * The API route identifying the ratelimit for this request + * The timestamp at which the global bucket resets */ - route: string; -} - -export interface ResponseLike - extends Pick { - body: Readable | ReadableStream | null; -} + public globalReset = -1; -export interface InvalidRequestWarningData { /** - * Number of invalid requests that have been made in the window + * API bucket hashes that are cached from provided routes */ - count: number; + public readonly hashes = new Collection(); + /** - * Time in milliseconds remaining before the count resets + * Request handlers created from the bucket hash and the major parameters */ - remainingTime: number; -} + public readonly handlers = new Collection(); -export interface RestEvents { - handlerSweep: [sweptHandlers: Collection]; - hashSweep: [sweptHashes: Collection]; - invalidRequestWarning: [invalidRequestInfo: InvalidRequestWarningData]; - rateLimited: [rateLimitInfo: RateLimitData]; - response: [request: APIRequest, response: ResponseLike]; - restDebug: [info: string]; -} + #token: string | null = null; -export type RestEventsMap = { - [K in keyof RestEvents]: RestEvents[K]; -}; + private hashTimer!: NodeJS.Timer | number; -export class REST extends AsyncEventEmitter { - public readonly cdn: CDN; + private handlerTimer!: NodeJS.Timer | number; - public readonly requestManager: RequestManager; + public readonly options: RESTOptions; public constructor(options: Partial = {}) { super(); this.cdn = new CDN(options.cdn ?? DefaultRestOptions.cdn); - this.requestManager = new RequestManager(options) - // @ts-expect-error For some reason ts can't infer these types - .on(RESTEvents.Debug, this.emit.bind(this, RESTEvents.Debug)) - // @ts-expect-error For some reason ts can't infer these types - .on(RESTEvents.RateLimited, this.emit.bind(this, RESTEvents.RateLimited)) - // @ts-expect-error For some reason ts can't infer these types - .on(RESTEvents.InvalidRequestWarning, this.emit.bind(this, RESTEvents.InvalidRequestWarning)) - // @ts-expect-error For some reason ts can't infer these types - .on(RESTEvents.HashSweep, this.emit.bind(this, RESTEvents.HashSweep)); - - this.on('newListener', (name, listener) => { - if (name === RESTEvents.Response) this.requestManager.on(name, listener); - }); - this.on('removeListener', (name, listener) => { - if (name === RESTEvents.Response) this.requestManager.off(name, listener); - }); - } + this.options = { ...DefaultRestOptions, ...options }; + this.options.offset = Math.max(0, this.options.offset); + this.globalRemaining = Math.max(1, this.options.globalRequestsPerSecond); + this.agent = options.agent ?? null; - /** - * Gets the agent set for this instance - */ - public getAgent() { - return this.requestManager.agent; + // Start sweepers + this.setupSweepers(); } - /** - * Sets the default agent to use for requests performed by this instance - * - * @param agent - Sets the agent to use - */ - public setAgent(agent: Dispatcher) { - this.requestManager.setAgent(agent); - return this; - } + private setupSweepers() { + // eslint-disable-next-line unicorn/consistent-function-scoping + const validateMaxInterval = (interval: number) => { + if (interval > 14_400_000) { + throw new Error('Cannot set an interval greater than 4 hours'); + } + }; - /** - * Sets the authorization token that should be used for requests - * - * @param token - The authorization token to use - */ - public setToken(token: string) { - this.requestManager.setToken(token); - return this; + if (this.options.hashSweepInterval !== 0 && this.options.hashSweepInterval !== Number.POSITIVE_INFINITY) { + validateMaxInterval(this.options.hashSweepInterval); + this.hashTimer = setInterval(() => { + const sweptHashes = new Collection(); + const currentDate = Date.now(); + + // Begin sweeping hash based on lifetimes + this.hashes.sweep((val, key) => { + // `-1` indicates a global hash + if (val.lastAccess === -1) return false; + + // Check if lifetime has been exceeded + const shouldSweep = Math.floor(currentDate - val.lastAccess) > this.options.hashLifetime; + + // Add hash to collection of swept hashes + if (shouldSweep) { + // Add to swept hashes + sweptHashes.set(key, val); + + // Emit debug information + this.emit(RESTEvents.Debug, `Hash ${val.value} for ${key} swept due to lifetime being exceeded`); + } + + return shouldSweep; + }); + + // Fire event + this.emit(RESTEvents.HashSweep, sweptHashes); + }, this.options.hashSweepInterval); + + this.hashTimer.unref?.(); + } + + if (this.options.handlerSweepInterval !== 0 && this.options.handlerSweepInterval !== Number.POSITIVE_INFINITY) { + validateMaxInterval(this.options.handlerSweepInterval); + this.handlerTimer = setInterval(() => { + const sweptHandlers = new Collection(); + + // Begin sweeping handlers based on activity + this.handlers.sweep((val, key) => { + const { inactive } = val; + + // Collect inactive handlers + if (inactive) { + sweptHandlers.set(key, val); + this.emit(RESTEvents.Debug, `Handler ${val.id} for ${key} swept due to being inactive`); + } + + return inactive; + }); + + // Fire event + this.emit(RESTEvents.HandlerSweep, sweptHandlers); + }, this.options.handlerSweepInterval); + + this.handlerTimer.unref?.(); + } } /** @@ -341,16 +208,259 @@ export class REST extends AsyncEventEmitter { * @param options - Request options */ public async request(options: InternalRequest) { - const response = await this.raw(options); + const response = await this.queueRequest(options); return parseResponse(response); } /** - * Runs a request from the API, yielding the raw Response object + * Sets the default agent to use for requests performed by this manager * - * @param options - Request options + * @param agent - The agent to use + */ + public setAgent(agent: Dispatcher) { + this.agent = agent; + return this; + } + + /** + * Sets the authorization token that should be used for requests + * + * @param token - The authorization token to use */ - public async raw(options: InternalRequest) { - return this.requestManager.queueRequest(options); + public setToken(token: string) { + this.#token = token; + return this; + } + + /** + * Queues a request to be sent + * + * @param request - All the information needed to make a request + * @returns The response from the api request + */ + public async queueRequest(request: InternalRequest): Promise { + // Generalize the endpoint to its route data + const routeId = REST.generateRouteData(request.fullRoute, request.method); + // Get the bucket hash for the generic route, or point to a global route otherwise + const hash = this.hashes.get(`${request.method}:${routeId.bucketRoute}`) ?? { + value: `Global(${request.method}:${routeId.bucketRoute})`, + lastAccess: -1, + }; + + // Get the request handler for the obtained hash, with its major parameter + const handler = + this.handlers.get(`${hash.value}:${routeId.majorParameter}`) ?? + this.createHandler(hash.value, routeId.majorParameter); + + // Resolve the request into usable fetch options + const { url, fetchOptions } = await this.resolveRequest(request); + + // Queue the request + return handler.queueRequest(routeId, url, fetchOptions, { + body: request.body, + files: request.files, + auth: request.auth !== false, + signal: request.signal, + }); + } + + /** + * Creates a new rate limit handler from a hash, based on the hash and the major parameter + * + * @param hash - The hash for the route + * @param majorParameter - The major parameter for this handler + * @internal + */ + private createHandler(hash: string, majorParameter: string) { + // Create the async request queue to handle requests + const queue = + majorParameter === BurstHandlerMajorIdKey + ? new BurstHandler(this, hash, majorParameter) + : new SequentialHandler(this, hash, majorParameter); + // Save the queue based on its id + this.handlers.set(queue.id, queue); + + return queue; + } + + /** + * Formats the request data to a usable format for fetch + * + * @param request - The request data + */ + private async resolveRequest(request: InternalRequest): Promise<{ fetchOptions: RequestInit; url: string }> { + const { options } = this; + + let query = ''; + + // If a query option is passed, use it + if (request.query) { + const resolvedQuery = request.query.toString(); + if (resolvedQuery !== '') { + query = `?${resolvedQuery}`; + } + } + + // Create the required headers + const headers: RequestHeaders = { + ...this.options.headers, + 'User-Agent': `${DefaultUserAgent} ${options.userAgentAppendix}`.trim(), + }; + + // If this request requires authorization (allowing non-"authorized" requests for webhooks) + if (request.auth !== false) { + // If we haven't received a token, throw an error + if (!this.#token) { + throw new Error('Expected token to be set for this request, but none was present'); + } + + headers.Authorization = `${request.authPrefix ?? this.options.authPrefix} ${this.#token}`; + } + + // If a reason was set, set it's appropriate header + if (request.reason?.length) { + headers['X-Audit-Log-Reason'] = encodeURIComponent(request.reason); + } + + // Format the full request URL (api base, optional version, endpoint, optional querystring) + const url = `${options.api}${request.versioned === false ? '' : `/v${options.version}`}${ + request.fullRoute + }${query}`; + + let finalBody: RequestInit['body']; + let additionalHeaders: Record = {}; + + if (request.files?.length) { + const formData = new FormData(); + + // Attach all files to the request + for (const [index, file] of request.files.entries()) { + const fileKey = file.key ?? `files[${index}]`; + + // https://developer.mozilla.org/en-US/docs/Web/API/FormData/append#parameters + // FormData.append only accepts a string or Blob. + // https://developer.mozilla.org/en-US/docs/Web/API/Blob/Blob#parameters + // The Blob constructor accepts TypedArray/ArrayBuffer, strings, and Blobs. + if (isBufferLike(file.data)) { + // Try to infer the content type from the buffer if one isn't passed + let contentType = file.contentType; + + if (!contentType) { + const [parsedType] = filetypeinfo(file.data); + + if (parsedType) { + contentType = + OverwrittenMimeTypes[parsedType.mime as keyof typeof OverwrittenMimeTypes] ?? + parsedType.mime ?? + 'application/octet-stream'; + } + } + + formData.append(fileKey, new Blob([file.data], { type: contentType }), file.name); + } else { + formData.append(fileKey, new Blob([`${file.data}`], { type: file.contentType }), file.name); + } + } + + // If a JSON body was added as well, attach it to the form data, using payload_json unless otherwise specified + // eslint-disable-next-line no-eq-null, eqeqeq + if (request.body != null) { + if (request.appendToFormData) { + for (const [key, value] of Object.entries(request.body as Record)) { + formData.append(key, value); + } + } else { + formData.append('payload_json', JSON.stringify(request.body)); + } + } + + // Set the final body to the form data + finalBody = formData; + + // eslint-disable-next-line no-eq-null, eqeqeq + } else if (request.body != null) { + if (request.passThroughBody) { + finalBody = request.body as BodyInit; + } else { + // Stringify the JSON data + finalBody = JSON.stringify(request.body); + // Set the additional headers to specify the content-type + additionalHeaders = { 'Content-Type': 'application/json' }; + } + } + + const method = request.method.toUpperCase(); + + // 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, + // Prioritize setting an agent per request, use the agent for this instance otherwise. + dispatcher: request.dispatcher ?? this.agent ?? undefined!, + }; + + return { url, fetchOptions }; + } + + /** + * Stops the hash sweeping interval + */ + public clearHashSweeper() { + clearInterval(this.hashTimer); + } + + /** + * Stops the request handler sweeping interval + */ + public clearHandlerSweeper() { + clearInterval(this.handlerTimer); + } + + /** + * Generates route data for an endpoint:method + * + * @param endpoint - The raw endpoint to generalize + * @param method - The HTTP method this endpoint is called without + * @internal + */ + private static generateRouteData(endpoint: RouteLike, method: RequestMethod): RouteData { + if (endpoint.startsWith('/interactions/') && endpoint.endsWith('/callback')) { + return { + majorParameter: BurstHandlerMajorIdKey, + bucketRoute: '/interactions/:id/:token/callback', + original: endpoint, + }; + } + + const majorIdMatch = /^\/(?:channels|guilds|webhooks)\/(\d{17,19})/.exec(endpoint); + + // Get the major id for this route - global otherwise + const majorId = majorIdMatch?.[1] ?? 'global'; + + const baseRoute = endpoint + // Strip out all ids + .replaceAll(/\d{17,19}/g, ':id') + // Strip out reaction as they fall under the same bucket + .replace(/\/reactions\/(.*)/, '/reactions/:reaction'); + + let exceptions = ''; + + // Hard-Code Old Message Deletion Exception (2 week+ old messages are a different bucket) + // https://github.com/discord/discord-api-docs/issues/1295 + if (method === RequestMethod.Delete && baseRoute === '/channels/:id/messages/:id') { + const id = /\d{17,19}$/.exec(endpoint)![0]!; + const timestamp = DiscordSnowflake.timestampFrom(id); + if (Date.now() - timestamp > 1_000 * 60 * 60 * 24 * 14) { + exceptions += '/Delete Old Message'; + } + } + + return { + majorParameter: majorId, + bucketRoute: baseRoute + exceptions, + original: endpoint, + }; } } diff --git a/packages/rest/src/lib/RequestManager.ts b/packages/rest/src/lib/RequestManager.ts deleted file mode 100644 index 66920b03b668..000000000000 --- a/packages/rest/src/lib/RequestManager.ts +++ /dev/null @@ -1,532 +0,0 @@ -import { Collection } from '@discordjs/collection'; -import { DiscordSnowflake } from '@sapphire/snowflake'; -import { AsyncEventEmitter } from '@vladfrangu/async_event_emitter'; -import { filetypeinfo } from 'magic-bytes.js'; -import type { RequestInit, BodyInit, Dispatcher, Agent } from 'undici'; -import type { RESTOptions, ResponseLike, RestEventsMap } from './REST.js'; -import { BurstHandler } from './handlers/BurstHandler.js'; -import { SequentialHandler } from './handlers/SequentialHandler.js'; -import type { IHandler } from './interfaces/Handler.js'; -import { - BurstHandlerMajorIdKey, - DefaultRestOptions, - DefaultUserAgent, - OverwrittenMimeTypes, - RESTEvents, -} from './utils/constants.js'; -import { isBufferLike } from './utils/utils.js'; - -/** - * Represents a file to be added to the request - */ -export interface RawFile { - /** - * Content-Type of the file - */ - contentType?: string; - /** - * The actual data for the file - */ - data: Buffer | Uint8Array | boolean | number | string; - /** - * An explicit key to use for key of the formdata field for this file. - * When not provided, the index of the file in the files array is used in the form `files[${index}]`. - * If you wish to alter the placeholder snowflake, you must provide this property in the same form (`files[${placeholder}]`) - */ - key?: string; - /** - * The name of the file - */ - name: string; -} - -/** - * Represents possible data to be given to an endpoint - */ -export interface RequestData { - /** - * Whether to append JSON data to form data instead of `payload_json` when sending files - */ - appendToFormData?: boolean; - /** - * If this request needs the `Authorization` header - * - * @defaultValue `true` - */ - auth?: boolean; - /** - * The authorization prefix to use for this request, useful if you use this with bearer tokens - * - * @defaultValue `'Bot'` - */ - authPrefix?: 'Bearer' | 'Bot'; - /** - * The body to send to this request. - * If providing as BodyInit, set `passThroughBody: true` - */ - body?: BodyInit | unknown; - /** - * The {@link https://undici.nodejs.org/#/docs/api/Agent | Agent} to use for the request. - */ - dispatcher?: Agent; - /** - * Files to be attached to this request - */ - files?: RawFile[] | undefined; - /** - * Additional headers to add to this request - */ - headers?: Record; - /** - * Whether to pass-through the body property directly to `fetch()`. - * This only applies when files is NOT present - */ - passThroughBody?: boolean; - /** - * Query string parameters to append to the called endpoint - */ - query?: URLSearchParams; - /** - * Reason to show in the audit logs - */ - reason?: string | undefined; - /** - * The signal to abort the queue entry or the REST call, where applicable - */ - signal?: AbortSignal | undefined; - /** - * If this request should be versioned - * - * @defaultValue `true` - */ - versioned?: boolean; -} - -/** - * Possible headers for an API call - */ -export interface RequestHeaders { - Authorization?: string; - 'User-Agent': string; - 'X-Audit-Log-Reason'?: string; -} - -/** - * Possible API methods to be used when doing requests - */ -export enum RequestMethod { - Delete = 'DELETE', - Get = 'GET', - Patch = 'PATCH', - Post = 'POST', - Put = 'PUT', -} - -export type RouteLike = `/${string}`; - -/** - * Internal request options - * - * @internal - */ -export interface InternalRequest extends RequestData { - fullRoute: RouteLike; - method: RequestMethod; -} - -export type HandlerRequestData = Pick; - -/** - * Parsed route data for an endpoint - * - * @internal - */ -export interface RouteData { - bucketRoute: string; - majorParameter: string; - original: RouteLike; -} - -/** - * Represents a hash and its associated fields - * - * @internal - */ -export interface HashData { - lastAccess: number; - value: string; -} - -/** - * Represents the class that manages handlers for endpoints - */ -export class RequestManager extends AsyncEventEmitter { - /** - * The {@link https://undici.nodejs.org/#/docs/api/Agent | Agent} for all requests - * performed by this manager. - */ - public agent: Dispatcher | null = null; - - /** - * The number of requests remaining in the global bucket - */ - public globalRemaining: number; - - /** - * The promise used to wait out the global rate limit - */ - public globalDelay: Promise | null = null; - - /** - * The timestamp at which the global bucket resets - */ - public globalReset = -1; - - /** - * API bucket hashes that are cached from provided routes - */ - public readonly hashes = new Collection(); - - /** - * Request handlers created from the bucket hash and the major parameters - */ - public readonly handlers = new Collection(); - - #token: string | null = null; - - private hashTimer!: NodeJS.Timer | number; - - private handlerTimer!: NodeJS.Timer | number; - - public readonly options: RESTOptions; - - public constructor(options: Partial) { - super(); - this.options = { ...DefaultRestOptions, ...options }; - this.options.offset = Math.max(0, this.options.offset); - this.globalRemaining = this.options.globalRequestsPerSecond; - this.agent = options.agent ?? null; - - // Start sweepers - this.setupSweepers(); - } - - private setupSweepers() { - // eslint-disable-next-line unicorn/consistent-function-scoping - const validateMaxInterval = (interval: number) => { - if (interval > 14_400_000) { - throw new Error('Cannot set an interval greater than 4 hours'); - } - }; - - if (this.options.hashSweepInterval !== 0 && this.options.hashSweepInterval !== Number.POSITIVE_INFINITY) { - validateMaxInterval(this.options.hashSweepInterval); - this.hashTimer = setInterval(() => { - const sweptHashes = new Collection(); - const currentDate = Date.now(); - - // Begin sweeping hash based on lifetimes - this.hashes.sweep((val, key) => { - // `-1` indicates a global hash - if (val.lastAccess === -1) return false; - - // Check if lifetime has been exceeded - const shouldSweep = Math.floor(currentDate - val.lastAccess) > this.options.hashLifetime; - - // Add hash to collection of swept hashes - if (shouldSweep) { - // Add to swept hashes - sweptHashes.set(key, val); - } - - // Emit debug information - this.emit(RESTEvents.Debug, `Hash ${val.value} for ${key} swept due to lifetime being exceeded`); - - return shouldSweep; - }); - - // Fire event - this.emit(RESTEvents.HashSweep, sweptHashes); - }, this.options.hashSweepInterval); - - this.hashTimer.unref?.(); - } - - if (this.options.handlerSweepInterval !== 0 && this.options.handlerSweepInterval !== Number.POSITIVE_INFINITY) { - validateMaxInterval(this.options.handlerSweepInterval); - this.handlerTimer = setInterval(() => { - const sweptHandlers = new Collection(); - - // Begin sweeping handlers based on activity - this.handlers.sweep((val, key) => { - const { inactive } = val; - - // Collect inactive handlers - if (inactive) { - sweptHandlers.set(key, val); - } - - this.emit(RESTEvents.Debug, `Handler ${val.id} for ${key} swept due to being inactive`); - return inactive; - }); - - // Fire event - this.emit(RESTEvents.HandlerSweep, sweptHandlers); - }, this.options.handlerSweepInterval); - - this.handlerTimer.unref?.(); - } - } - - /** - * Sets the default agent to use for requests performed by this manager - * - * @param agent - The agent to use - */ - public setAgent(agent: Dispatcher) { - this.agent = agent; - return this; - } - - /** - * Sets the authorization token that should be used for requests - * - * @param token - The authorization token to use - */ - public setToken(token: string) { - this.#token = token; - return this; - } - - /** - * Queues a request to be sent - * - * @param request - All the information needed to make a request - * @returns The response from the api request - */ - 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 - const hash = this.hashes.get(`${request.method}:${routeId.bucketRoute}`) ?? { - value: `Global(${request.method}:${routeId.bucketRoute})`, - lastAccess: -1, - }; - - // Get the request handler for the obtained hash, with its major parameter - const handler = - this.handlers.get(`${hash.value}:${routeId.majorParameter}`) ?? - this.createHandler(hash.value, routeId.majorParameter); - - // Resolve the request into usable fetch options - const { url, fetchOptions } = await this.resolveRequest(request); - - // Queue the request - return handler.queueRequest(routeId, url, fetchOptions, { - body: request.body, - files: request.files, - auth: request.auth !== false, - signal: request.signal, - }); - } - - /** - * Creates a new rate limit handler from a hash, based on the hash and the major parameter - * - * @param hash - The hash for the route - * @param majorParameter - The major parameter for this handler - * @internal - */ - private createHandler(hash: string, majorParameter: string) { - // Create the async request queue to handle requests - const queue = - majorParameter === BurstHandlerMajorIdKey - ? new BurstHandler(this, hash, majorParameter) - : new SequentialHandler(this, hash, majorParameter); - // Save the queue based on its id - this.handlers.set(queue.id, queue); - - return queue; - } - - /** - * Formats the request data to a usable format for fetch - * - * @param request - The request data - */ - private async resolveRequest(request: InternalRequest): Promise<{ fetchOptions: RequestInit; url: string }> { - const { options } = this; - - let query = ''; - - // If a query option is passed, use it - if (request.query) { - const resolvedQuery = request.query.toString(); - if (resolvedQuery !== '') { - query = `?${resolvedQuery}`; - } - } - - // Create the required headers - const headers: RequestHeaders = { - ...this.options.headers, - 'User-Agent': `${DefaultUserAgent} ${options.userAgentAppendix}`.trim(), - }; - - // If this request requires authorization (allowing non-"authorized" requests for webhooks) - if (request.auth !== false) { - // If we haven't received a token, throw an error - if (!this.#token) { - throw new Error('Expected token to be set for this request, but none was present'); - } - - headers.Authorization = `${request.authPrefix ?? this.options.authPrefix} ${this.#token}`; - } - - // If a reason was set, set it's appropriate header - if (request.reason?.length) { - headers['X-Audit-Log-Reason'] = encodeURIComponent(request.reason); - } - - // Format the full request URL (api base, optional version, endpoint, optional querystring) - const url = `${options.api}${request.versioned === false ? '' : `/v${options.version}`}${ - request.fullRoute - }${query}`; - - let finalBody: RequestInit['body']; - let additionalHeaders: Record = {}; - - if (request.files?.length) { - const formData = new FormData(); - - // Attach all files to the request - for (const [index, file] of request.files.entries()) { - const fileKey = file.key ?? `files[${index}]`; - - // https://developer.mozilla.org/en-US/docs/Web/API/FormData/append#parameters - // FormData.append only accepts a string or Blob. - // https://developer.mozilla.org/en-US/docs/Web/API/Blob/Blob#parameters - // The Blob constructor accepts TypedArray/ArrayBuffer, strings, and Blobs. - if (isBufferLike(file.data)) { - // Try to infer the content type from the buffer if one isn't passed - let contentType = file.contentType; - - if (!contentType) { - const [parsedType] = filetypeinfo(file.data); - - if (parsedType) { - contentType = - OverwrittenMimeTypes[parsedType.mime as keyof typeof OverwrittenMimeTypes] ?? - parsedType.mime ?? - 'application/octet-stream'; - } - } - - formData.append(fileKey, new Blob([file.data], { type: contentType }), file.name); - } else { - formData.append(fileKey, new Blob([`${file.data}`], { type: file.contentType }), file.name); - } - } - - // If a JSON body was added as well, attach it to the form data, using payload_json unless otherwise specified - // eslint-disable-next-line no-eq-null, eqeqeq - if (request.body != null) { - if (request.appendToFormData) { - for (const [key, value] of Object.entries(request.body as Record)) { - formData.append(key, value); - } - } else { - formData.append('payload_json', JSON.stringify(request.body)); - } - } - - // Set the final body to the form data - finalBody = formData; - - // eslint-disable-next-line no-eq-null, eqeqeq - } else if (request.body != null) { - if (request.passThroughBody) { - finalBody = request.body as BodyInit; - } else { - // Stringify the JSON data - finalBody = JSON.stringify(request.body); - // Set the additional headers to specify the content-type - additionalHeaders = { 'Content-Type': 'application/json' }; - } - } - - const method = request.method.toUpperCase(); - - // 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, - // Prioritize setting an agent per request, use the agent for this instance otherwise. - dispatcher: request.dispatcher ?? this.agent ?? undefined!, - }; - - return { url, fetchOptions }; - } - - /** - * Stops the hash sweeping interval - */ - public clearHashSweeper() { - clearInterval(this.hashTimer); - } - - /** - * Stops the request handler sweeping interval - */ - public clearHandlerSweeper() { - clearInterval(this.handlerTimer); - } - - /** - * Generates route data for an endpoint:method - * - * @param endpoint - The raw endpoint to generalize - * @param method - The HTTP method this endpoint is called without - * @internal - */ - private static generateRouteData(endpoint: RouteLike, method: RequestMethod): RouteData { - if (endpoint.startsWith('/interactions/') && endpoint.endsWith('/callback')) { - return { - majorParameter: BurstHandlerMajorIdKey, - bucketRoute: '/interactions/:id/:token/callback', - original: endpoint, - }; - } - - const majorIdMatch = /^\/(?:channels|guilds|webhooks)\/(\d{17,19})/.exec(endpoint); - - // Get the major id for this route - global otherwise - const majorId = majorIdMatch?.[1] ?? 'global'; - - const baseRoute = endpoint - // Strip out all ids - .replaceAll(/\d{17,19}/g, ':id') - // Strip out reaction as they fall under the same bucket - .replace(/\/reactions\/(.*)/, '/reactions/:reaction'); - - let exceptions = ''; - - // Hard-Code Old Message Deletion Exception (2 week+ old messages are a different bucket) - // https://github.com/discord/discord-api-docs/issues/1295 - if (method === RequestMethod.Delete && baseRoute === '/channels/:id/messages/:id') { - const id = /\d{17,19}$/.exec(endpoint)![0]!; - const timestamp = DiscordSnowflake.timestampFrom(id); - if (Date.now() - timestamp > 1_000 * 60 * 60 * 24 * 14) { - exceptions += '/Delete Old Message'; - } - } - - return { - majorParameter: majorId, - bucketRoute: baseRoute + exceptions, - original: endpoint, - }; - } -} diff --git a/packages/rest/src/lib/errors/DiscordAPIError.ts b/packages/rest/src/lib/errors/DiscordAPIError.ts index ab60dfab0b20..ef4c7ba2f37d 100644 --- a/packages/rest/src/lib/errors/DiscordAPIError.ts +++ b/packages/rest/src/lib/errors/DiscordAPIError.ts @@ -1,4 +1,4 @@ -import type { InternalRequest, RawFile } from '../RequestManager.js'; +import type { InternalRequest, RawFile } from '../utils/types.js'; interface DiscordErrorFieldInformation { code: string; diff --git a/packages/rest/src/lib/errors/HTTPError.ts b/packages/rest/src/lib/errors/HTTPError.ts index f04982422c89..6c9e51a1a5f1 100644 --- a/packages/rest/src/lib/errors/HTTPError.ts +++ b/packages/rest/src/lib/errors/HTTPError.ts @@ -1,4 +1,4 @@ -import type { InternalRequest } from '../RequestManager.js'; +import type { InternalRequest } from '../utils/types.js'; import type { RequestBody } from './DiscordAPIError.js'; /** diff --git a/packages/rest/src/lib/errors/RateLimitError.ts b/packages/rest/src/lib/errors/RateLimitError.ts index cb6b7c1df74b..ecc408c1c92a 100644 --- a/packages/rest/src/lib/errors/RateLimitError.ts +++ b/packages/rest/src/lib/errors/RateLimitError.ts @@ -1,4 +1,4 @@ -import type { RateLimitData } from '../REST.js'; +import type { RateLimitData } from '../utils/types.js'; export class RateLimitError extends Error implements RateLimitData { public timeToReset: number; diff --git a/packages/rest/src/lib/handlers/BurstHandler.ts b/packages/rest/src/lib/handlers/BurstHandler.ts index 812abdde1ce0..e534c1e286ac 100644 --- a/packages/rest/src/lib/handlers/BurstHandler.ts +++ b/packages/rest/src/lib/handlers/BurstHandler.ts @@ -1,8 +1,8 @@ import type { RequestInit } from 'undici'; -import type { ResponseLike } from '../REST.js'; -import type { HandlerRequestData, RequestManager, RouteData } from '../RequestManager.js'; +import type { REST } from '../REST.js'; import type { IHandler } from '../interfaces/Handler.js'; import { RESTEvents } from '../utils/constants.js'; +import type { ResponseLike, HandlerRequestData, RouteData } from '../utils/types.js'; import { onRateLimit, sleep } from '../utils/utils.js'; import { handleErrors, incrementInvalidCount, makeNetworkRequest } from './Shared.js'; @@ -31,7 +31,7 @@ export class BurstHandler implements IHandler { * @param majorParameter - The major parameter for this handler */ public constructor( - private readonly manager: RequestManager, + private readonly manager: REST, private readonly hash: string, private readonly majorParameter: string, ) { diff --git a/packages/rest/src/lib/handlers/SequentialHandler.ts b/packages/rest/src/lib/handlers/SequentialHandler.ts index acfcc7072bfe..216af00af496 100644 --- a/packages/rest/src/lib/handlers/SequentialHandler.ts +++ b/packages/rest/src/lib/handlers/SequentialHandler.ts @@ -1,9 +1,9 @@ import { AsyncQueue } from '@sapphire/async-queue'; import type { RequestInit } from 'undici'; -import type { RateLimitData, ResponseLike } from '../REST.js'; -import type { HandlerRequestData, RequestManager, RouteData } from '../RequestManager.js'; +import type { REST } from '../REST.js'; import type { IHandler } from '../interfaces/Handler.js'; import { RESTEvents } from '../utils/constants.js'; +import type { RateLimitData, ResponseLike, HandlerRequestData, RouteData } from '../utils/types.js'; import { hasSublimit, onRateLimit, sleep } from '../utils/utils.js'; import { handleErrors, incrementInvalidCount, makeNetworkRequest } from './Shared.js'; @@ -62,7 +62,7 @@ export class SequentialHandler implements IHandler { * @param majorParameter - The major parameter for this handler */ public constructor( - private readonly manager: RequestManager, + private readonly manager: REST, private readonly hash: string, private readonly majorParameter: string, ) { diff --git a/packages/rest/src/lib/handlers/Shared.ts b/packages/rest/src/lib/handlers/Shared.ts index b51c11202b17..a1eeeb061d6d 100644 --- a/packages/rest/src/lib/handlers/Shared.ts +++ b/packages/rest/src/lib/handlers/Shared.ts @@ -1,10 +1,10 @@ import type { RequestInit } from 'undici'; -import type { ResponseLike } from '../REST.js'; -import type { HandlerRequestData, RequestManager, RouteData } from '../RequestManager.js'; +import type { REST } from '../REST.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 type { ResponseLike, HandlerRequestData, RouteData } from '../utils/types.js'; import { parseResponse, shouldRetry } from '../utils/utils.js'; /** @@ -22,7 +22,7 @@ let invalidCountResetTime: number | null = null; * * @internal */ -export function incrementInvalidCount(manager: RequestManager) { +export function incrementInvalidCount(manager: REST) { if (!invalidCountResetTime || invalidCountResetTime < Date.now()) { invalidCountResetTime = Date.now() + 1_000 * 60 * 10; invalidCount = 0; @@ -55,7 +55,7 @@ export function incrementInvalidCount(manager: RequestManager) { * @internal */ export async function makeNetworkRequest( - manager: RequestManager, + manager: REST, routeId: RouteData, url: string, options: RequestInit, @@ -118,7 +118,7 @@ export async function makeNetworkRequest( * @returns - The response if the status code is not handled or null to request a retry */ export async function handleErrors( - manager: RequestManager, + manager: REST, res: ResponseLike, method: string, url: string, diff --git a/packages/rest/src/lib/interfaces/Handler.ts b/packages/rest/src/lib/interfaces/Handler.ts index c8cbc6c4d768..b7a5d0103b56 100644 --- a/packages/rest/src/lib/interfaces/Handler.ts +++ b/packages/rest/src/lib/interfaces/Handler.ts @@ -1,6 +1,5 @@ import type { RequestInit } from 'undici'; -import type { ResponseLike } from '../REST.js'; -import type { HandlerRequestData, RouteData } from '../RequestManager.js'; +import type { HandlerRequestData, RouteData, ResponseLike } from '../utils/types.js'; export interface IHandler { /** diff --git a/packages/rest/src/lib/utils/constants.ts b/packages/rest/src/lib/utils/constants.ts index 27d15cdc51ef..5451ab8b6d6f 100644 --- a/packages/rest/src/lib/utils/constants.ts +++ b/packages/rest/src/lib/utils/constants.ts @@ -1,7 +1,7 @@ import { getUserAgentAppendix } from '@discordjs/util'; import { APIVersion } from 'discord-api-types/v10'; import { getDefaultStrategy } from '../../environment.js'; -import type { RESTOptions, ResponseLike } from '../REST.js'; +import type { RESTOptions, ResponseLike } from './types.js'; export const DefaultUserAgent = `DiscordBot (https://discord.js.org, [VI]{{inject}}[/VI])` as `DiscordBot (https://discord.js.org, ${string})`; diff --git a/packages/rest/src/lib/utils/types.ts b/packages/rest/src/lib/utils/types.ts new file mode 100644 index 000000000000..5b1c60fb7216 --- /dev/null +++ b/packages/rest/src/lib/utils/types.ts @@ -0,0 +1,359 @@ +import type { Readable } from 'node:stream'; +import type { ReadableStream } from 'node:stream/web'; +import type { Collection } from '@discordjs/collection'; +import type { Agent, Dispatcher, RequestInit, BodyInit, Response } from 'undici'; +import type { IHandler } from '../interfaces/Handler.js'; + +export interface RestEvents { + handlerSweep: [sweptHandlers: Collection]; + hashSweep: [sweptHashes: Collection]; + invalidRequestWarning: [invalidRequestInfo: InvalidRequestWarningData]; + rateLimited: [rateLimitInfo: RateLimitData]; + response: [request: APIRequest, response: ResponseLike]; + restDebug: [info: string]; +} + +export type RestEventsMap = { + [K in keyof RestEvents]: RestEvents[K]; +}; + +/** + * Options to be passed when creating the REST instance + */ +export interface RESTOptions { + /** + * The agent to set globally + */ + agent: Dispatcher | null; + /** + * The base api path, without version + * + * @defaultValue `'https://discord.com/api'` + */ + api: string; + /** + * The authorization prefix to use for requests, useful if you want to use + * bearer tokens + * + * @defaultValue `'Bot'` + */ + authPrefix: 'Bearer' | 'Bot'; + /** + * The cdn path + * + * @defaultValue `'https://cdn.discordapp.com'` + */ + cdn: string; + /** + * How many requests to allow sending per second (Infinity for unlimited, 50 for the standard global limit used by Discord) + * + * @defaultValue `50` + */ + globalRequestsPerSecond: number; + /** + * The amount of time in milliseconds that passes between each hash sweep. (defaults to 1h) + * + * @defaultValue `3_600_000` + */ + handlerSweepInterval: number; + /** + * The maximum amount of time a hash can exist in milliseconds without being hit with a request (defaults to 24h) + * + * @defaultValue `86_400_000` + */ + hashLifetime: number; + /** + * The amount of time in milliseconds that passes between each hash sweep. (defaults to 4h) + * + * @defaultValue `14_400_000` + */ + hashSweepInterval: number; + /** + * Additional headers to send for all API requests + * + * @defaultValue `{}` + */ + headers: Record; + /** + * The number of invalid REST requests (those that return 401, 403, or 429) in a 10 minute window between emitted warnings (0 for no warnings). + * That is, if set to 500, warnings will be emitted at invalid request number 500, 1000, 1500, and so on. + * + * @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` + */ + makeRequest(url: string, init: RequestInit): Promise; + /** + * The extra offset to add to rate limits in milliseconds + * + * @defaultValue `50` + */ + offset: number; + /** + * Determines how rate limiting and pre-emptive throttling should be handled. + * When an array of strings, each element is treated as a prefix for the request route + * (e.g. `/channels` to match any route starting with `/channels` such as `/channels/:id/messages`) + * for which to throw {@link RateLimitError}s. All other request routes will be queued normally + * + * @defaultValue `null` + */ + rejectOnRateLimit: RateLimitQueueFilter | string[] | null; + /** + * The number of retries for errors with the 500 code, or errors + * that timeout + * + * @defaultValue `3` + */ + retries: number; + /** + * The time to wait in milliseconds before a request is aborted + * + * @defaultValue `15_000` + */ + timeout: number; + /** + * Extra information to add to the user agent + * + * @defaultValue DefaultUserAgentAppendix + */ + userAgentAppendix: string; + /** + * The version of the API to use + * + * @defaultValue `'10'` + */ + version: string; +} + +/** + * Data emitted on `RESTEvents.RateLimited` + */ +export interface RateLimitData { + /** + * Whether the rate limit that was reached was the global limit + */ + global: boolean; + /** + * The bucket hash for this request + */ + hash: string; + /** + * The amount of requests we can perform before locking requests + */ + limit: number; + /** + * The major parameter of the route + * + * For example, in `/channels/x`, this will be `x`. + * If there is no major parameter (e.g: `/bot/gateway`) this will be `global`. + */ + majorParameter: string; + /** + * The HTTP method being performed + */ + method: string; + /** + * The route being hit in this request + */ + route: string; + /** + * The time, in milliseconds, until the request-lock is reset + */ + timeToReset: number; + /** + * The full URL for this request + */ + url: string; +} + +/** + * A function that determines whether the rate limit hit should throw an Error + */ +export type RateLimitQueueFilter = (rateLimitData: RateLimitData) => Promise | boolean; + +export interface APIRequest { + /** + * The data that was used to form the body of this request + */ + data: HandlerRequestData; + /** + * The HTTP method used in this request + */ + method: string; + /** + * Additional HTTP options for this request + */ + options: RequestInit; + /** + * The full path used to make the request + */ + path: RouteLike; + /** + * The number of times this request has been attempted + */ + retries: number; + /** + * The API route identifying the ratelimit for this request + */ + 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 + */ + count: number; + /** + * Time in milliseconds remaining before the count resets + */ + remainingTime: number; +} + +/** + * Represents a file to be added to the request + */ +export interface RawFile { + /** + * Content-Type of the file + */ + contentType?: string; + /** + * The actual data for the file + */ + data: Buffer | Uint8Array | boolean | number | string; + /** + * An explicit key to use for key of the formdata field for this file. + * When not provided, the index of the file in the files array is used in the form `files[${index}]`. + * If you wish to alter the placeholder snowflake, you must provide this property in the same form (`files[${placeholder}]`) + */ + key?: string; + /** + * The name of the file + */ + name: string; +} + +/** + * Represents possible data to be given to an endpoint + */ +export interface RequestData { + /** + * Whether to append JSON data to form data instead of `payload_json` when sending files + */ + appendToFormData?: boolean; + /** + * If this request needs the `Authorization` header + * + * @defaultValue `true` + */ + auth?: boolean; + /** + * The authorization prefix to use for this request, useful if you use this with bearer tokens + * + * @defaultValue `'Bot'` + */ + authPrefix?: 'Bearer' | 'Bot'; + /** + * The body to send to this request. + * If providing as BodyInit, set `passThroughBody: true` + */ + body?: BodyInit | unknown; + /** + * The {@link https://undici.nodejs.org/#/docs/api/Agent | Agent} to use for the request. + */ + dispatcher?: Agent; + /** + * Files to be attached to this request + */ + files?: RawFile[] | undefined; + /** + * Additional headers to add to this request + */ + headers?: Record; + /** + * Whether to pass-through the body property directly to `fetch()`. + * This only applies when files is NOT present + */ + passThroughBody?: boolean; + /** + * Query string parameters to append to the called endpoint + */ + query?: URLSearchParams; + /** + * Reason to show in the audit logs + */ + reason?: string | undefined; + /** + * The signal to abort the queue entry or the REST call, where applicable + */ + signal?: AbortSignal | undefined; + /** + * If this request should be versioned + * + * @defaultValue `true` + */ + versioned?: boolean; +} + +/** + * Possible headers for an API call + */ +export interface RequestHeaders { + Authorization?: string; + 'User-Agent': string; + 'X-Audit-Log-Reason'?: string; +} + +/** + * Possible API methods to be used when doing requests + */ +export enum RequestMethod { + Delete = 'DELETE', + Get = 'GET', + Patch = 'PATCH', + Post = 'POST', + Put = 'PUT', +} + +export type RouteLike = `/${string}`; + +/** + * Internal request options + * + * @internal + */ +export interface InternalRequest extends RequestData { + fullRoute: RouteLike; + method: RequestMethod; +} + +export type HandlerRequestData = Pick; + +/** + * Parsed route data for an endpoint + * + * @internal + */ +export interface RouteData { + bucketRoute: string; + majorParameter: string; + original: RouteLike; +} + +/** + * Represents a hash and its associated fields + * + * @internal + */ +export interface HashData { + lastAccess: number; + value: string; +} diff --git a/packages/rest/src/lib/utils/utils.ts b/packages/rest/src/lib/utils/utils.ts index d7710a8ad96b..59f461827a93 100644 --- a/packages/rest/src/lib/utils/utils.ts +++ b/packages/rest/src/lib/utils/utils.ts @@ -1,7 +1,7 @@ import type { RESTPatchAPIChannelJSONBody, Snowflake } from 'discord-api-types/v10'; -import type { RateLimitData, ResponseLike } from '../REST.js'; -import { type RequestManager, RequestMethod } from '../RequestManager.js'; +import type { REST } from '../REST.js'; import { RateLimitError } from '../errors/RateLimitError.js'; +import { RequestMethod, type RateLimitData, type ResponseLike } from './types.js'; function serializeSearchParam(value: unknown): string | null { switch (typeof value) { @@ -99,7 +99,7 @@ export function shouldRetry(error: Error | NodeJS.ErrnoException) { * * @internal */ -export async function onRateLimit(manager: RequestManager, rateLimitData: RateLimitData) { +export async function onRateLimit(manager: REST, rateLimitData: RateLimitData) { const { options } = manager; if (!options.rejectOnRateLimit) return; diff --git a/packages/rest/src/shared.ts b/packages/rest/src/shared.ts index b6af7b7ef7df..a8353e2758a8 100644 --- a/packages/rest/src/shared.ts +++ b/packages/rest/src/shared.ts @@ -2,9 +2,9 @@ export * from './lib/CDN.js'; export * from './lib/errors/DiscordAPIError.js'; export * from './lib/errors/HTTPError.js'; export * from './lib/errors/RateLimitError.js'; -export * from './lib/RequestManager.js'; export * from './lib/REST.js'; export * from './lib/utils/constants.js'; +export * from './lib/utils/types.js'; export { calculateUserDefaultAvatarIndex, makeURLSearchParams, parseResponse } from './lib/utils/utils.js'; /** diff --git a/packages/rest/src/strategies/undiciRequest.ts b/packages/rest/src/strategies/undiciRequest.ts index aba78514d68d..6e6945f204b9 100644 --- a/packages/rest/src/strategies/undiciRequest.ts +++ b/packages/rest/src/strategies/undiciRequest.ts @@ -1,7 +1,7 @@ import { STATUS_CODES } from 'node:http'; import { URLSearchParams } from 'node:url'; import { types } from 'node:util'; -import { type RequestInit, request } from 'undici'; +import { type RequestInit, request, Headers } from 'undici'; import type { ResponseLike } from '../shared.js'; export type RequestOptions = Exclude[1], undefined>;