mirror of
https://github.com/discordjs/discord.js.git
synced 2026-03-11 09:03:29 +01:00
chore: monorepo setup (#7175)
This commit is contained in:
10
packages/rest/src/index.ts
Normal file
10
packages/rest/src/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// eslint-disable-next-line spaced-comment
|
||||
/// <reference lib="dom" />
|
||||
|
||||
export * from './lib/CDN';
|
||||
export * from './lib/errors/DiscordAPIError';
|
||||
export * from './lib/errors/HTTPError';
|
||||
export * from './lib/errors/RateLimitError';
|
||||
export * from './lib/RequestManager';
|
||||
export * from './lib/REST';
|
||||
export * from './lib/utils/constants';
|
||||
224
packages/rest/src/lib/CDN.ts
Normal file
224
packages/rest/src/lib/CDN.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import {
|
||||
ALLOWED_EXTENSIONS,
|
||||
ALLOWED_SIZES,
|
||||
ALLOWED_STICKER_EXTENSIONS,
|
||||
DefaultRestOptions,
|
||||
ImageExtension,
|
||||
ImageSize,
|
||||
StickerExtension,
|
||||
} from './utils/constants';
|
||||
|
||||
export interface BaseImageURLOptions {
|
||||
extension?: ImageExtension;
|
||||
size?: ImageSize;
|
||||
}
|
||||
|
||||
export interface ImageURLOptions extends BaseImageURLOptions {
|
||||
dynamic?: boolean;
|
||||
}
|
||||
|
||||
export interface MakeURLOptions {
|
||||
extension?: string | undefined;
|
||||
size?: ImageSize;
|
||||
allowedExtensions?: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The CDN link builder
|
||||
*/
|
||||
export class CDN {
|
||||
public constructor(private readonly base: string = DefaultRestOptions.cdn) {}
|
||||
|
||||
/**
|
||||
* Generates an app asset URL for a client's asset.
|
||||
* @param clientId The client id that has the asset
|
||||
* @param assetHash The hash provided by Discord for this asset
|
||||
* @param options Optional options for the asset
|
||||
*/
|
||||
public appAsset(clientId: string, assetHash: string, options?: Readonly<BaseImageURLOptions>): string {
|
||||
return this.makeURL(`/app-assets/${clientId}/${assetHash}`, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an app icon URL for a client's icon.
|
||||
* @param clientId The client id that has the icon
|
||||
* @param iconHash The hash provided by Discord for this icon
|
||||
* @param options Optional options for the icon
|
||||
*/
|
||||
public appIcon(clientId: string, iconHash: string, options?: Readonly<BaseImageURLOptions>): string {
|
||||
return this.makeURL(`/app-icons/${clientId}/${iconHash}`, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an avatar URL, e.g. for a user or a webhook.
|
||||
* @param id The id that has the icon
|
||||
* @param avatarHash The hash provided by Discord for this avatar
|
||||
* @param options Optional options for the avatar
|
||||
*/
|
||||
public avatar(id: string, avatarHash: string, options?: Readonly<ImageURLOptions>): string {
|
||||
return this.dynamicMakeURL(`/avatars/${id}/${avatarHash}`, avatarHash, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a banner URL, e.g. for a user or a guild.
|
||||
* @param id The id that has the banner splash
|
||||
* @param bannerHash The hash provided by Discord for this banner
|
||||
* @param options Optional options for the banner
|
||||
*/
|
||||
public banner(id: string, bannerHash: string, options?: Readonly<ImageURLOptions>): string {
|
||||
return this.dynamicMakeURL(`/banners/${id}/${bannerHash}`, bannerHash, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an icon URL for a channel, e.g. a group DM.
|
||||
* @param channelId The channel id that has the icon
|
||||
* @param iconHash The hash provided by Discord for this channel
|
||||
* @param options Optional options for the icon
|
||||
*/
|
||||
public channelIcon(channelId: string, iconHash: string, options?: Readonly<BaseImageURLOptions>): string {
|
||||
return this.makeURL(`/channel-icons/${channelId}/${iconHash}`, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the default avatar URL for a discriminator.
|
||||
* @param discriminator The discriminator modulo 5
|
||||
*/
|
||||
public defaultAvatar(discriminator: number): string {
|
||||
return this.makeURL(`/embed/avatars/${discriminator}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a discovery splash URL for a guild's discovery splash.
|
||||
* @param guildId The guild id that has the discovery splash
|
||||
* @param splashHash The hash provided by Discord for this splash
|
||||
* @param options Optional options for the splash
|
||||
*/
|
||||
public discoverySplash(guildId: string, splashHash: string, options?: Readonly<BaseImageURLOptions>): string {
|
||||
return this.makeURL(`/discovery-splashes/${guildId}/${splashHash}`, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an emoji's URL for an emoji.
|
||||
* @param emojiId The emoji id
|
||||
* @param extension The extension of the emoji
|
||||
*/
|
||||
public emoji(emojiId: string, extension?: ImageExtension): string {
|
||||
return this.makeURL(`/emojis/${emojiId}`, { extension });
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a guild member avatar URL.
|
||||
* @param guildId The id of the guild
|
||||
* @param userId The id of the user
|
||||
* @param avatarHash The hash provided by Discord for this avatar
|
||||
* @param options Optional options for the avatar
|
||||
*/
|
||||
public guildMemberAvatar(
|
||||
guildId: string,
|
||||
userId: string,
|
||||
avatarHash: string,
|
||||
options?: Readonly<ImageURLOptions>,
|
||||
): string {
|
||||
return this.dynamicMakeURL(`/guilds/${guildId}/users/${userId}/avatars/${avatarHash}`, avatarHash, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an icon URL, e.g. for a guild.
|
||||
* @param id The id that has the icon splash
|
||||
* @param iconHash The hash provided by Discord for this icon
|
||||
* @param options Optional options for the icon
|
||||
*/
|
||||
public icon(id: string, iconHash: string, options?: Readonly<ImageURLOptions>): string {
|
||||
return this.dynamicMakeURL(`/icons/${id}/${iconHash}`, iconHash, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a URL for the icon of a role
|
||||
* @param roleId The id of the role that has the icon
|
||||
* @param roleIconHash The hash provided by Discord for this role icon
|
||||
* @param options Optional options for the role icon
|
||||
*/
|
||||
public roleIcon(roleId: string, roleIconHash: string, options?: Readonly<BaseImageURLOptions>): string {
|
||||
return this.makeURL(`/role-icons/${roleId}/${roleIconHash}`, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a guild invite splash URL for a guild's invite splash.
|
||||
* @param guildId The guild id that has the invite splash
|
||||
* @param splashHash The hash provided by Discord for this splash
|
||||
* @param options Optional options for the splash
|
||||
*/
|
||||
public splash(guildId: string, splashHash: string, options?: Readonly<BaseImageURLOptions>): string {
|
||||
return this.makeURL(`/splashes/${guildId}/${splashHash}`, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a sticker URL.
|
||||
* @param stickerId The sticker id
|
||||
* @param extension The extension of the sticker
|
||||
*/
|
||||
public sticker(stickerId: string, extension?: StickerExtension): string {
|
||||
return this.makeURL(`/stickers/${stickerId}`, { allowedExtensions: ALLOWED_STICKER_EXTENSIONS, extension });
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a sticker pack banner URL.
|
||||
* @param bannerId The banner id
|
||||
* @param options Optional options for the banner
|
||||
*/
|
||||
public stickerPackBanner(bannerId: string, options?: Readonly<BaseImageURLOptions>): string {
|
||||
return this.makeURL(`/app-assets/710982414301790216/store/${bannerId}`, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a team icon URL for a team's icon.
|
||||
* @param teamId The team id that has the icon
|
||||
* @param iconHash The hash provided by Discord for this icon
|
||||
* @param options Optional options for the icon
|
||||
*/
|
||||
public teamIcon(teamId: string, iconHash: string, options?: Readonly<BaseImageURLOptions>): string {
|
||||
return this.makeURL(`/team-icons/${teamId}/${iconHash}`, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs the URL for the resource, checking whether or not `hash` starts with `a_` if `dynamic` is set to `true`.
|
||||
* @param route The base cdn route
|
||||
* @param hash The hash provided by Discord for this icon
|
||||
* @param options Optional options for the link
|
||||
*/
|
||||
private dynamicMakeURL(
|
||||
route: string,
|
||||
hash: string,
|
||||
{ dynamic = false, ...options }: Readonly<ImageURLOptions> = {},
|
||||
): string {
|
||||
return this.makeURL(route, dynamic && hash.startsWith('a_') ? { ...options, extension: 'gif' } : options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs the URL for the resource
|
||||
* @param route The base cdn route
|
||||
* @param options The extension/size options for the link
|
||||
*/
|
||||
private makeURL(
|
||||
route: string,
|
||||
{ allowedExtensions = ALLOWED_EXTENSIONS, extension = 'png', size }: Readonly<MakeURLOptions> = {},
|
||||
): string {
|
||||
extension = String(extension).toLowerCase();
|
||||
|
||||
if (!allowedExtensions.includes(extension)) {
|
||||
throw new RangeError(`Invalid extension provided: ${extension}\nMust be one of: ${allowedExtensions.join(', ')}`);
|
||||
}
|
||||
|
||||
if (size && !ALLOWED_SIZES.includes(size)) {
|
||||
throw new RangeError(`Invalid size provided: ${size}\nMust be one of: ${ALLOWED_SIZES.join(', ')}`);
|
||||
}
|
||||
|
||||
const url = new URL(`${this.base}${route}.${extension}`);
|
||||
|
||||
if (size) {
|
||||
url.searchParams.set('size', String(size));
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
}
|
||||
271
packages/rest/src/lib/REST.ts
Normal file
271
packages/rest/src/lib/REST.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { CDN } from './CDN';
|
||||
import { InternalRequest, RequestData, RequestManager, RequestMethod, RouteLike } from './RequestManager';
|
||||
import { DefaultRestOptions, RESTEvents } from './utils/constants';
|
||||
import type { AgentOptions } from 'node:https';
|
||||
import type { RequestInit, Response } from 'node-fetch';
|
||||
|
||||
/**
|
||||
* Options to be passed when creating the REST instance
|
||||
*/
|
||||
export interface RESTOptions {
|
||||
/**
|
||||
* HTTPS Agent options
|
||||
* @default {}
|
||||
*/
|
||||
agent: Omit<AgentOptions, 'keepAlive'>;
|
||||
/**
|
||||
* The base api path, without version
|
||||
* @default 'https://discord.com/api'
|
||||
*/
|
||||
api: string;
|
||||
/**
|
||||
* The cdn path
|
||||
* @default 'https://cdn.discordapp.com'
|
||||
*/
|
||||
cdn: string;
|
||||
/**
|
||||
* Additional headers to send for all API requests
|
||||
* @default {}
|
||||
*/
|
||||
headers: Record<string, string>;
|
||||
/**
|
||||
* 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.
|
||||
* @default 0
|
||||
*/
|
||||
invalidRequestWarningInterval: number;
|
||||
/**
|
||||
* How many requests to allow sending per second (Infinity for unlimited, 50 for the standard global limit used by Discord)
|
||||
* @default 50
|
||||
*/
|
||||
globalRequestsPerSecond: number;
|
||||
/**
|
||||
* The extra offset to add to rate limits in milliseconds
|
||||
* @default 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
|
||||
* @default null
|
||||
*/
|
||||
rejectOnRateLimit: string[] | RateLimitQueueFilter | null;
|
||||
/**
|
||||
* The number of retries for errors with the 500 code, or errors
|
||||
* that timeout
|
||||
* @default 3
|
||||
*/
|
||||
retries: number;
|
||||
/**
|
||||
* The time to wait in milliseconds before a request is aborted
|
||||
* @default 15_000
|
||||
*/
|
||||
timeout: number;
|
||||
/**
|
||||
* Extra information to add to the user agent
|
||||
* @default `Node.js ${process.version}`
|
||||
*/
|
||||
userAgentAppendix: string;
|
||||
/**
|
||||
* The version of the API to use
|
||||
* @default '9'
|
||||
*/
|
||||
version: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data emitted on `RESTEvents.RateLimited`
|
||||
*/
|
||||
export interface RateLimitData {
|
||||
/**
|
||||
* The time, in milliseconds, until the request-lock is reset
|
||||
*/
|
||||
timeToReset: number;
|
||||
/**
|
||||
* The amount of requests we can perform before locking requests
|
||||
*/
|
||||
limit: number;
|
||||
/**
|
||||
* The HTTP method being performed
|
||||
*/
|
||||
method: string;
|
||||
/**
|
||||
* The bucket hash for this request
|
||||
*/
|
||||
hash: string;
|
||||
/**
|
||||
* The full URL for this request
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* The route being hit in this request
|
||||
*/
|
||||
route: string;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* Whether the rate limit that was reached was the global limit
|
||||
*/
|
||||
global: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A function that determines whether the rate limit hit should throw an Error
|
||||
*/
|
||||
export type RateLimitQueueFilter = (rateLimitData: RateLimitData) => boolean | Promise<boolean>;
|
||||
|
||||
export interface APIRequest {
|
||||
/**
|
||||
* The HTTP method used in this request
|
||||
*/
|
||||
method: string;
|
||||
/**
|
||||
* The full path used to make the request
|
||||
*/
|
||||
path: RouteLike;
|
||||
/**
|
||||
* The API route identifying the ratelimit for this request
|
||||
*/
|
||||
route: string;
|
||||
/**
|
||||
* Additional HTTP options for this request
|
||||
*/
|
||||
options: RequestInit;
|
||||
/**
|
||||
* The data that was used to form the body of this request
|
||||
*/
|
||||
data: Pick<InternalRequest, 'attachments' | 'body'>;
|
||||
/**
|
||||
* The number of times this request has been attempted
|
||||
*/
|
||||
retries: number;
|
||||
}
|
||||
|
||||
export interface InvalidRequestWarningData {
|
||||
/**
|
||||
* Number of invalid requests that have been made in the window
|
||||
*/
|
||||
count: number;
|
||||
/**
|
||||
* Time in ms remaining before the count resets
|
||||
*/
|
||||
remainingTime: number;
|
||||
}
|
||||
|
||||
export interface RestEvents {
|
||||
invalidRequestWarning: [invalidRequestInfo: InvalidRequestWarningData];
|
||||
restDebug: [info: string];
|
||||
rateLimited: [rateLimitInfo: RateLimitData];
|
||||
request: [request: APIRequest];
|
||||
response: [request: APIRequest, response: Response];
|
||||
newListener: [name: string, listener: (...args: any) => void];
|
||||
removeListener: [name: string, listener: (...args: any) => void];
|
||||
}
|
||||
|
||||
export interface REST {
|
||||
on<K extends keyof RestEvents>(event: K, listener: (...args: RestEvents[K]) => void): this;
|
||||
on<S extends string | symbol>(event: Exclude<S, keyof RestEvents>, listener: (...args: any[]) => void): this;
|
||||
|
||||
once<K extends keyof RestEvents>(event: K, listener: (...args: RestEvents[K]) => void): this;
|
||||
once<S extends string | symbol>(event: Exclude<S, keyof RestEvents>, listener: (...args: any[]) => void): this;
|
||||
|
||||
emit<K extends keyof RestEvents>(event: K, ...args: RestEvents[K]): boolean;
|
||||
emit<S extends string | symbol>(event: Exclude<S, keyof RestEvents>, ...args: any[]): boolean;
|
||||
|
||||
off<K extends keyof RestEvents>(event: K, listener: (...args: RestEvents[K]) => void): this;
|
||||
off<S extends string | symbol>(event: Exclude<S, keyof RestEvents>, listener: (...args: any[]) => void): this;
|
||||
|
||||
removeAllListeners<K extends keyof RestEvents>(event?: K): this;
|
||||
removeAllListeners<S extends string | symbol>(event?: Exclude<S, keyof RestEvents>): this;
|
||||
}
|
||||
|
||||
export class REST extends EventEmitter {
|
||||
public readonly cdn: CDN;
|
||||
public readonly requestManager: RequestManager;
|
||||
|
||||
public constructor(options: Partial<RESTOptions> = {}) {
|
||||
super();
|
||||
this.cdn = new CDN(options.cdn ?? DefaultRestOptions.cdn);
|
||||
this.requestManager = new RequestManager(options)
|
||||
.on(RESTEvents.Debug, this.emit.bind(this, RESTEvents.Debug))
|
||||
.on(RESTEvents.RateLimited, this.emit.bind(this, RESTEvents.RateLimited))
|
||||
.on(RESTEvents.InvalidRequestWarning, this.emit.bind(this, RESTEvents.InvalidRequestWarning));
|
||||
|
||||
this.on('newListener', (name, listener) => {
|
||||
if (name === RESTEvents.Request || name === RESTEvents.Response) this.requestManager.on(name, listener);
|
||||
});
|
||||
this.on('removeListener', (name, listener) => {
|
||||
if (name === RESTEvents.Request || name === RESTEvents.Response) this.requestManager.off(name, listener);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a get request from the api
|
||||
* @param fullRoute The full route to query
|
||||
* @param options Optional request options
|
||||
*/
|
||||
public get(fullRoute: RouteLike, options: RequestData = {}) {
|
||||
return this.request({ ...options, fullRoute, method: RequestMethod.Get });
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a delete request from the api
|
||||
* @param fullRoute The full route to query
|
||||
* @param options Optional request options
|
||||
*/
|
||||
public delete(fullRoute: RouteLike, options: RequestData = {}) {
|
||||
return this.request({ ...options, fullRoute, method: RequestMethod.Delete });
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a post request from the api
|
||||
* @param fullRoute The full route to query
|
||||
* @param options Optional request options
|
||||
*/
|
||||
public post(fullRoute: RouteLike, options: RequestData = {}) {
|
||||
return this.request({ ...options, fullRoute, method: RequestMethod.Post });
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a put request from the api
|
||||
* @param fullRoute The full route to query
|
||||
* @param options Optional request options
|
||||
*/
|
||||
public put(fullRoute: RouteLike, options: RequestData = {}) {
|
||||
return this.request({ ...options, fullRoute, method: RequestMethod.Put });
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a patch request from the api
|
||||
* @param fullRoute The full route to query
|
||||
* @param options Optional request options
|
||||
*/
|
||||
public patch(fullRoute: RouteLike, options: RequestData = {}) {
|
||||
return this.request({ ...options, fullRoute, method: RequestMethod.Patch });
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a request from the api
|
||||
* @param options Request options
|
||||
*/
|
||||
public request(options: InternalRequest) {
|
||||
return this.requestManager.queueRequest(options);
|
||||
}
|
||||
}
|
||||
362
packages/rest/src/lib/RequestManager.ts
Normal file
362
packages/rest/src/lib/RequestManager.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
import Collection from '@discordjs/collection';
|
||||
import FormData from 'form-data';
|
||||
import { DiscordSnowflake } from '@sapphire/snowflake';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Agent } from 'node:https';
|
||||
import type { RequestInit, BodyInit } from 'node-fetch';
|
||||
import type { IHandler } from './handlers/IHandler';
|
||||
import { SequentialHandler } from './handlers/SequentialHandler';
|
||||
import type { RESTOptions, RestEvents } from './REST';
|
||||
import { DefaultRestOptions, DefaultUserAgent } from './utils/constants';
|
||||
|
||||
let agent: Agent | null = null;
|
||||
|
||||
/**
|
||||
* Represents an attachment to be added to the request
|
||||
*/
|
||||
export interface RawAttachment {
|
||||
/**
|
||||
* The name of the file
|
||||
*/
|
||||
fileName: string;
|
||||
/**
|
||||
* An explicit key to use for key of the formdata field for this attachment.
|
||||
* When not provided, the index of the file in the attachments 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 actual data for the attachment
|
||||
*/
|
||||
rawBuffer: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 attachments
|
||||
*/
|
||||
appendToFormData?: boolean;
|
||||
/**
|
||||
* Files to be attached to this request
|
||||
*/
|
||||
attachments?: RawAttachment[] | undefined;
|
||||
/**
|
||||
* If this request needs the `Authorization` header
|
||||
* @default true
|
||||
*/
|
||||
auth?: boolean;
|
||||
/**
|
||||
* The authorization prefix to use for this request, useful if you use this with bearer tokens
|
||||
* @default 'Bot'
|
||||
*/
|
||||
authPrefix?: 'Bot' | 'Bearer';
|
||||
/**
|
||||
* The body to send to this request.
|
||||
* If providing as BodyInit, set `passThroughBody: true`
|
||||
*/
|
||||
body?: BodyInit | unknown;
|
||||
/**
|
||||
* Additional headers to add to this request
|
||||
*/
|
||||
headers?: Record<string, string>;
|
||||
/**
|
||||
* Whether to pass-through the body property directly to `fetch()`.
|
||||
* <warn>This only applies when attachments is NOT present</warn>
|
||||
*/
|
||||
passThroughBody?: boolean;
|
||||
/**
|
||||
* Query string parameters to append to the called endpoint
|
||||
*/
|
||||
query?: URLSearchParams;
|
||||
/**
|
||||
* Reason to show in the audit logs
|
||||
*/
|
||||
reason?: string;
|
||||
/**
|
||||
* If this request should be versioned
|
||||
* @default 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 const 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 {
|
||||
method: RequestMethod;
|
||||
fullRoute: RouteLike;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed route data for an endpoint
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export interface RouteData {
|
||||
majorParameter: string;
|
||||
bucketRoute: string;
|
||||
original: RouteLike;
|
||||
}
|
||||
|
||||
export interface RequestManager {
|
||||
on<K extends keyof RestEvents>(event: K, listener: (...args: RestEvents[K]) => void): this;
|
||||
on<S extends string | symbol>(event: Exclude<S, keyof RestEvents>, listener: (...args: any[]) => void): this;
|
||||
|
||||
once<K extends keyof RestEvents>(event: K, listener: (...args: RestEvents[K]) => void): this;
|
||||
once<S extends string | symbol>(event: Exclude<S, keyof RestEvents>, listener: (...args: any[]) => void): this;
|
||||
|
||||
emit<K extends keyof RestEvents>(event: K, ...args: RestEvents[K]): boolean;
|
||||
emit<S extends string | symbol>(event: Exclude<S, keyof RestEvents>, ...args: any[]): boolean;
|
||||
|
||||
off<K extends keyof RestEvents>(event: K, listener: (...args: RestEvents[K]) => void): this;
|
||||
off<S extends string | symbol>(event: Exclude<S, keyof RestEvents>, listener: (...args: any[]) => void): this;
|
||||
|
||||
removeAllListeners<K extends keyof RestEvents>(event?: K): this;
|
||||
removeAllListeners<S extends string | symbol>(event?: Exclude<S, keyof RestEvents>): this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the class that manages handlers for endpoints
|
||||
*/
|
||||
export class RequestManager extends EventEmitter {
|
||||
/**
|
||||
* 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<void> | 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<string, string>();
|
||||
|
||||
/**
|
||||
* Request handlers created from the bucket hash and the major parameters
|
||||
*/
|
||||
public readonly handlers = new Collection<string, IHandler>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-member-accessibility
|
||||
#token: string | null = null;
|
||||
|
||||
public readonly options: RESTOptions;
|
||||
|
||||
public constructor(options: Partial<RESTOptions>) {
|
||||
super();
|
||||
this.options = { ...DefaultRestOptions, ...options };
|
||||
this.options.offset = Math.max(0, this.options.offset);
|
||||
this.globalRemaining = this.options.globalRequestsPerSecond;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<unknown> {
|
||||
// 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}`) ?? `Global(${request.method}:${routeId.bucketRoute})`;
|
||||
|
||||
// Get the request handler for the obtained hash, with its major parameter
|
||||
const handler =
|
||||
this.handlers.get(`${hash}:${routeId.majorParameter}`) ?? this.createHandler(hash, routeId.majorParameter);
|
||||
|
||||
// Resolve the request into usable fetch/node-fetch options
|
||||
const { url, fetchOptions } = this.resolveRequest(request);
|
||||
|
||||
// Queue the request
|
||||
return handler.queueRequest(routeId, url, fetchOptions, { body: request.body, attachments: request.attachments });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @private
|
||||
*/
|
||||
private createHandler(hash: string, majorParameter: string) {
|
||||
// Create the async request queue to handle requests
|
||||
const queue = 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 resolveRequest(request: InternalRequest): { url: string; fetchOptions: RequestInit } {
|
||||
const { options } = this;
|
||||
|
||||
agent ??= new Agent({ ...options.agent, keepAlive: true });
|
||||
|
||||
let query = '';
|
||||
|
||||
// If a query option is passed, use it
|
||||
if (request.query) {
|
||||
query = `?${request.query.toString()}`;
|
||||
}
|
||||
|
||||
// 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 ?? 'Bot'} ${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<string, string> = {};
|
||||
|
||||
if (request.attachments?.length) {
|
||||
const formData = new FormData();
|
||||
|
||||
// Attach all files to the request
|
||||
for (const [index, attachment] of request.attachments.entries()) {
|
||||
formData.append(attachment.key ?? `files[${index}]`, attachment.rawBuffer, attachment.fileName);
|
||||
}
|
||||
|
||||
// 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
|
||||
if (request.body != null) {
|
||||
if (request.appendToFormData) {
|
||||
for (const [key, value] of Object.entries(request.body as Record<string, unknown>)) {
|
||||
formData.append(key, value);
|
||||
}
|
||||
} else {
|
||||
formData.append('payload_json', JSON.stringify(request.body));
|
||||
}
|
||||
}
|
||||
|
||||
// Set the final body to the form data
|
||||
finalBody = formData;
|
||||
// Set the additional headers to the form data ones
|
||||
additionalHeaders = formData.getHeaders();
|
||||
|
||||
// eslint-disable-next-line no-eq-null
|
||||
} 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 fetchOptions = {
|
||||
agent,
|
||||
body: finalBody,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
headers: { ...(request.headers ?? {}), ...additionalHeaders, ...headers } as Record<string, string>,
|
||||
method: request.method,
|
||||
};
|
||||
|
||||
return { url, fetchOptions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates route data for an endpoint:method
|
||||
* @param endpoint The raw endpoint to generalize
|
||||
* @param method The HTTP method this endpoint is called without
|
||||
* @private
|
||||
*/
|
||||
private static generateRouteData(endpoint: RouteLike, method: RequestMethod): RouteData {
|
||||
const majorIdMatch = /^\/(?:channels|guilds|webhooks)\/(\d{16,19})/.exec(endpoint);
|
||||
|
||||
// Get the major id for this route - global otherwise
|
||||
const majorId = majorIdMatch?.[1] ?? 'global';
|
||||
|
||||
const baseRoute = endpoint
|
||||
// Strip out all ids
|
||||
.replace(/\d{16,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{16,19}$/.exec(endpoint)![0];
|
||||
const snowflake = DiscordSnowflake.deconstruct(id);
|
||||
if (Date.now() - Number(snowflake.timestamp) > 1000 * 60 * 60 * 24 * 14) {
|
||||
exceptions += '/Delete Old Message';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
majorParameter: majorId,
|
||||
bucketRoute: baseRoute + exceptions,
|
||||
original: endpoint,
|
||||
};
|
||||
}
|
||||
}
|
||||
107
packages/rest/src/lib/errors/DiscordAPIError.ts
Normal file
107
packages/rest/src/lib/errors/DiscordAPIError.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { InternalRequest, RawAttachment } from '../RequestManager';
|
||||
|
||||
interface DiscordErrorFieldInformation {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface DiscordErrorGroupWrapper {
|
||||
_errors: DiscordError[];
|
||||
}
|
||||
|
||||
type DiscordError = DiscordErrorGroupWrapper | DiscordErrorFieldInformation | { [k: string]: DiscordError } | string;
|
||||
|
||||
export interface DiscordErrorData {
|
||||
code: number;
|
||||
message: string;
|
||||
errors?: DiscordError;
|
||||
}
|
||||
|
||||
export interface OAuthErrorData {
|
||||
error: string;
|
||||
error_description?: string;
|
||||
}
|
||||
|
||||
export interface RequestBody {
|
||||
attachments: RawAttachment[] | undefined;
|
||||
json: unknown | undefined;
|
||||
}
|
||||
|
||||
function isErrorGroupWrapper(error: DiscordError): error is DiscordErrorGroupWrapper {
|
||||
return Reflect.has(error as Record<string, unknown>, '_errors');
|
||||
}
|
||||
|
||||
function isErrorResponse(error: DiscordError): error is DiscordErrorFieldInformation {
|
||||
return typeof Reflect.get(error as Record<string, unknown>, 'message') === 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an API error returned by Discord
|
||||
* @extends Error
|
||||
*/
|
||||
export class DiscordAPIError extends Error {
|
||||
public requestBody: RequestBody;
|
||||
|
||||
/**
|
||||
* @param rawError The error reported by Discord
|
||||
* @param code The error code reported by Discord
|
||||
* @param status The status code of the response
|
||||
* @param method The method of the request that erred
|
||||
* @param url The url of the request that erred
|
||||
* @param bodyData The unparsed data for the request that errored
|
||||
*/
|
||||
public constructor(
|
||||
public rawError: DiscordErrorData | OAuthErrorData,
|
||||
public code: number | string,
|
||||
public status: number,
|
||||
public method: string,
|
||||
public url: string,
|
||||
bodyData: Pick<InternalRequest, 'attachments' | 'body'>,
|
||||
) {
|
||||
super(DiscordAPIError.getMessage(rawError));
|
||||
|
||||
this.requestBody = { attachments: bodyData.attachments, json: bodyData.body };
|
||||
}
|
||||
|
||||
/**
|
||||
* The name of the error
|
||||
*/
|
||||
public override get name(): string {
|
||||
return `${DiscordAPIError.name}[${this.code}]`;
|
||||
}
|
||||
|
||||
private static getMessage(error: DiscordErrorData | OAuthErrorData) {
|
||||
let flattened = '';
|
||||
if ('code' in error) {
|
||||
if (error.errors) {
|
||||
flattened = [...this.flattenDiscordError(error.errors)].join('\n');
|
||||
}
|
||||
return error.message && flattened
|
||||
? `${error.message}\n${flattened}`
|
||||
: error.message || flattened || 'Unknown Error';
|
||||
}
|
||||
return error.error_description ?? 'No Description';
|
||||
}
|
||||
|
||||
private static *flattenDiscordError(obj: DiscordError, key = ''): IterableIterator<string> {
|
||||
if (isErrorResponse(obj)) {
|
||||
return yield `${key.length ? `${key}[${obj.code}]` : `${obj.code}`}: ${obj.message}`.trim();
|
||||
}
|
||||
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const nextKey = k.startsWith('_') ? key : key ? (Number.isNaN(Number(k)) ? `${key}.${k}` : `${key}[${k}]`) : k;
|
||||
|
||||
if (typeof v === 'string') {
|
||||
yield v;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
} else if (isErrorGroupWrapper(v)) {
|
||||
for (const error of v._errors) {
|
||||
yield* this.flattenDiscordError(error, nextKey);
|
||||
}
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
yield* this.flattenDiscordError(v, nextKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
30
packages/rest/src/lib/errors/HTTPError.ts
Normal file
30
packages/rest/src/lib/errors/HTTPError.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { InternalRequest } from '../RequestManager';
|
||||
import type { RequestBody } from './DiscordAPIError';
|
||||
|
||||
/**
|
||||
* Represents a HTTP error
|
||||
*/
|
||||
export class HTTPError extends Error {
|
||||
public requestBody: RequestBody;
|
||||
|
||||
/**
|
||||
* @param message The error message
|
||||
* @param name The name of the error
|
||||
* @param status The status code of the response
|
||||
* @param method The method of the request that erred
|
||||
* @param url The url of the request that erred
|
||||
* @param bodyData The unparsed data for the request that errored
|
||||
*/
|
||||
public constructor(
|
||||
message: string,
|
||||
public override name: string,
|
||||
public status: number,
|
||||
public method: string,
|
||||
public url: string,
|
||||
bodyData: Pick<InternalRequest, 'attachments' | 'body'>,
|
||||
) {
|
||||
super(message);
|
||||
|
||||
this.requestBody = { attachments: bodyData.attachments, json: bodyData.body };
|
||||
}
|
||||
}
|
||||
30
packages/rest/src/lib/errors/RateLimitError.ts
Normal file
30
packages/rest/src/lib/errors/RateLimitError.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { RateLimitData } from '../REST';
|
||||
|
||||
export class RateLimitError extends Error implements RateLimitData {
|
||||
public timeToReset: number;
|
||||
public limit: number;
|
||||
public method: string;
|
||||
public hash: string;
|
||||
public url: string;
|
||||
public route: string;
|
||||
public majorParameter: string;
|
||||
public global: boolean;
|
||||
public constructor({ timeToReset, limit, method, hash, url, route, majorParameter, global }: RateLimitData) {
|
||||
super();
|
||||
this.timeToReset = timeToReset;
|
||||
this.limit = limit;
|
||||
this.method = method;
|
||||
this.hash = hash;
|
||||
this.url = url;
|
||||
this.route = route;
|
||||
this.majorParameter = majorParameter;
|
||||
this.global = global;
|
||||
}
|
||||
|
||||
/**
|
||||
* The name of the error
|
||||
*/
|
||||
public override get name(): string {
|
||||
return `${RateLimitError.name}[${this.route}]`;
|
||||
}
|
||||
}
|
||||
11
packages/rest/src/lib/handlers/IHandler.ts
Normal file
11
packages/rest/src/lib/handlers/IHandler.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { RequestInit } from 'node-fetch';
|
||||
import type { InternalRequest, RouteData } from '../RequestManager';
|
||||
|
||||
export interface IHandler {
|
||||
queueRequest(
|
||||
routeId: RouteData,
|
||||
url: string,
|
||||
options: RequestInit,
|
||||
bodyData: Pick<InternalRequest, 'attachments' | 'body'>,
|
||||
): Promise<unknown>;
|
||||
}
|
||||
482
packages/rest/src/lib/handlers/SequentialHandler.ts
Normal file
482
packages/rest/src/lib/handlers/SequentialHandler.ts
Normal file
@@ -0,0 +1,482 @@
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { AsyncQueue } from '@sapphire/async-queue';
|
||||
import fetch, { RequestInit, Response } from 'node-fetch';
|
||||
import { DiscordAPIError, DiscordErrorData, OAuthErrorData } from '../errors/DiscordAPIError';
|
||||
import { HTTPError } from '../errors/HTTPError';
|
||||
import { RateLimitError } from '../errors/RateLimitError';
|
||||
import type { InternalRequest, RequestManager, RouteData } from '../RequestManager';
|
||||
import { RESTEvents } from '../utils/constants';
|
||||
import { hasSublimit, parseResponse } from '../utils/utils';
|
||||
import type { RateLimitData } from '../REST';
|
||||
|
||||
/* Invalid request limiting is done on a per-IP basis, not a per-token basis.
|
||||
* The best we can do is track invalid counts process-wide (on the theory that
|
||||
* users could have multiple bots run from one process) rather than per-bot.
|
||||
* Therefore, store these at file scope here rather than in the client's
|
||||
* RESTManager object.
|
||||
*/
|
||||
let invalidCount = 0;
|
||||
let invalidCountResetTime: number | null = null;
|
||||
|
||||
const enum QueueType {
|
||||
Standard,
|
||||
Sublimit,
|
||||
}
|
||||
|
||||
/**
|
||||
* The structure used to handle requests for a given bucket
|
||||
*/
|
||||
export class SequentialHandler {
|
||||
/**
|
||||
* The unique id of the handler
|
||||
*/
|
||||
public readonly id: string;
|
||||
|
||||
/**
|
||||
* The time this rate limit bucket will reset
|
||||
*/
|
||||
private reset = -1;
|
||||
|
||||
/**
|
||||
* The remaining requests that can be made before we are rate limited
|
||||
*/
|
||||
private remaining = 1;
|
||||
|
||||
/**
|
||||
* The total number of requests that can be made before we are rate limited
|
||||
*/
|
||||
private limit = Infinity;
|
||||
|
||||
/**
|
||||
* The interface used to sequence async requests sequentially
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-member-accessibility
|
||||
#asyncQueue = new AsyncQueue();
|
||||
|
||||
/**
|
||||
* The interface used to sequence sublimited async requests sequentially
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-member-accessibility
|
||||
#sublimitedQueue: AsyncQueue | null = null;
|
||||
|
||||
/**
|
||||
* A promise wrapper for when the sublimited queue is finished being processed or null when not being processed
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-member-accessibility
|
||||
#sublimitPromise: { promise: Promise<void>; resolve: () => void } | null = null;
|
||||
|
||||
/**
|
||||
* Whether the sublimit queue needs to be shifted in the finally block
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-member-accessibility
|
||||
#shiftSublimit = false;
|
||||
|
||||
/**
|
||||
* @param manager The request manager
|
||||
* @param hash The hash that this RequestHandler handles
|
||||
* @param majorParameter The major parameter for this handler
|
||||
*/
|
||||
public constructor(
|
||||
private readonly manager: RequestManager,
|
||||
private readonly hash: string,
|
||||
private readonly majorParameter: string,
|
||||
) {
|
||||
this.id = `${hash}:${majorParameter}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the bucket is currently inactive (no pending requests)
|
||||
*/
|
||||
public get inactive(): boolean {
|
||||
return (
|
||||
this.#asyncQueue.remaining === 0 &&
|
||||
(this.#sublimitedQueue === null || this.#sublimitedQueue.remaining === 0) &&
|
||||
!this.limited
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* If the rate limit bucket is currently limited by the global limit
|
||||
*/
|
||||
private get globalLimited(): boolean {
|
||||
return this.manager.globalRemaining <= 0 && Date.now() < this.manager.globalReset;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the rate limit bucket is currently limited by its limit
|
||||
*/
|
||||
private get localLimited(): boolean {
|
||||
return this.remaining <= 0 && Date.now() < this.reset;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the rate limit bucket is currently limited
|
||||
*/
|
||||
private get limited(): boolean {
|
||||
return this.globalLimited || this.localLimited;
|
||||
}
|
||||
|
||||
/**
|
||||
* The time until queued requests can continue
|
||||
*/
|
||||
private get timeToReset(): number {
|
||||
return this.reset + this.manager.options.offset - Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits a debug message
|
||||
* @param message The message to debug
|
||||
*/
|
||||
private debug(message: string) {
|
||||
this.manager.emit(RESTEvents.Debug, `[REST ${this.id}] ${message}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delay all requests for the specified amount of time, handling global rate limits
|
||||
* @param time The amount of time to delay all requests for
|
||||
* @returns
|
||||
*/
|
||||
private async globalDelayFor(time: number): Promise<void> {
|
||||
await sleep(time, undefined, { ref: false });
|
||||
this.manager.globalDelay = null;
|
||||
}
|
||||
|
||||
/*
|
||||
* Determines whether the request should be queued or whether a RateLimitError should be thrown
|
||||
*/
|
||||
private async onRateLimit(rateLimitData: RateLimitData) {
|
||||
const { options } = this.manager;
|
||||
if (!options.rejectOnRateLimit) return;
|
||||
|
||||
const shouldThrow =
|
||||
typeof options.rejectOnRateLimit === 'function'
|
||||
? await options.rejectOnRateLimit(rateLimitData)
|
||||
: options.rejectOnRateLimit.some((route) => rateLimitData.route.startsWith(route.toLowerCase()));
|
||||
if (shouldThrow) {
|
||||
throw new RateLimitError(rateLimitData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues a request to be sent
|
||||
* @param routeId The generalized api route with literal ids for major parameters
|
||||
* @param url The url to do the request on
|
||||
* @param options All the information needed to make a request
|
||||
* @param bodyData The data that was used to form the body, passed to any errors generated and for determining whether to sublimit
|
||||
*/
|
||||
public async queueRequest(
|
||||
routeId: RouteData,
|
||||
url: string,
|
||||
options: RequestInit,
|
||||
bodyData: Pick<InternalRequest, 'attachments' | 'body'>,
|
||||
): Promise<unknown> {
|
||||
let queue = this.#asyncQueue;
|
||||
let queueType = QueueType.Standard;
|
||||
// Separate sublimited requests when already sublimited
|
||||
if (this.#sublimitedQueue && hasSublimit(routeId.bucketRoute, bodyData.body, options.method)) {
|
||||
queue = this.#sublimitedQueue!;
|
||||
queueType = QueueType.Sublimit;
|
||||
}
|
||||
// Wait for any previous requests to be completed before this one is run
|
||||
await queue.wait();
|
||||
// This set handles retroactively sublimiting requests
|
||||
if (queueType === QueueType.Standard) {
|
||||
if (this.#sublimitedQueue && hasSublimit(routeId.bucketRoute, bodyData.body, options.method)) {
|
||||
/**
|
||||
* Remove the request from the standard queue, it should never be possible to get here while processing the
|
||||
* sublimit queue so there is no need to worry about shifting the wrong request
|
||||
*/
|
||||
queue = this.#sublimitedQueue!;
|
||||
const wait = queue.wait();
|
||||
this.#asyncQueue.shift();
|
||||
await wait;
|
||||
} else if (this.#sublimitPromise) {
|
||||
// Stall requests while the sublimit queue gets processed
|
||||
await this.#sublimitPromise.promise;
|
||||
}
|
||||
}
|
||||
try {
|
||||
// Make the request, and return the results
|
||||
return await this.runRequest(routeId, url, options, bodyData);
|
||||
} finally {
|
||||
// Allow the next request to fire
|
||||
queue.shift();
|
||||
if (this.#shiftSublimit) {
|
||||
this.#shiftSublimit = false;
|
||||
this.#sublimitedQueue?.shift();
|
||||
}
|
||||
// If this request is the last request in a sublimit
|
||||
if (this.#sublimitedQueue?.remaining === 0) {
|
||||
this.#sublimitPromise?.resolve();
|
||||
this.#sublimitedQueue = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The method that actually makes the request to the api, and updates info about the bucket accordingly
|
||||
* @param routeId The generalized api route with literal ids for major parameters
|
||||
* @param url The fully resolved url to make the request to
|
||||
* @param options The node-fetch options needed to make the request
|
||||
* @param bodyData The data that was used to form the body, passed to any errors generated
|
||||
* @param retries The number of retries this request has already attempted (recursion)
|
||||
*/
|
||||
private async runRequest(
|
||||
routeId: RouteData,
|
||||
url: string,
|
||||
options: RequestInit,
|
||||
bodyData: Pick<InternalRequest, 'attachments' | 'body'>,
|
||||
retries = 0,
|
||||
): Promise<unknown> {
|
||||
/*
|
||||
* 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
|
||||
*/
|
||||
while (this.limited) {
|
||||
const isGlobal = this.globalLimited;
|
||||
let limit: number;
|
||||
let timeout: number;
|
||||
let delay: Promise<void>;
|
||||
|
||||
if (isGlobal) {
|
||||
// Set RateLimitData based on the globl limit
|
||||
limit = this.manager.options.globalRequestsPerSecond;
|
||||
timeout = this.manager.globalReset + this.manager.options.offset - Date.now();
|
||||
// If this is the first task to reach the global timeout, set the global delay
|
||||
if (!this.manager.globalDelay) {
|
||||
// The global delay function clears the global delay state when it is resolved
|
||||
this.manager.globalDelay = this.globalDelayFor(timeout);
|
||||
}
|
||||
delay = this.manager.globalDelay;
|
||||
} else {
|
||||
// Set RateLimitData based on the route-specific limit
|
||||
limit = this.limit;
|
||||
timeout = this.timeToReset;
|
||||
delay = sleep(timeout, undefined, { ref: false });
|
||||
}
|
||||
const rateLimitData: RateLimitData = {
|
||||
timeToReset: timeout,
|
||||
limit,
|
||||
method: options.method ?? 'get',
|
||||
hash: this.hash,
|
||||
url,
|
||||
route: routeId.bucketRoute,
|
||||
majorParameter: this.majorParameter,
|
||||
global: isGlobal,
|
||||
};
|
||||
// Let library users know they have hit a rate limit
|
||||
this.manager.emit(RESTEvents.RateLimited, rateLimitData);
|
||||
// Determine whether a RateLimitError should be thrown
|
||||
await this.onRateLimit(rateLimitData);
|
||||
// When not erroring, emit debug for what is happening
|
||||
if (isGlobal) {
|
||||
this.debug(`Global rate limit hit, blocking all requests for ${timeout}ms`);
|
||||
} else {
|
||||
this.debug(`Waiting ${timeout}ms for rate limit to pass`);
|
||||
}
|
||||
// Wait the remaining time left before the rate limit resets
|
||||
await delay;
|
||||
}
|
||||
// As the request goes out, update the global usage information
|
||||
if (!this.manager.globalReset || this.manager.globalReset < Date.now()) {
|
||||
this.manager.globalReset = Date.now() + 1000;
|
||||
this.manager.globalRemaining = this.manager.options.globalRequestsPerSecond;
|
||||
}
|
||||
this.manager.globalRemaining--;
|
||||
|
||||
const method = options.method ?? 'get';
|
||||
|
||||
if (this.manager.listenerCount(RESTEvents.Request)) {
|
||||
this.manager.emit(RESTEvents.Request, {
|
||||
method,
|
||||
path: routeId.original,
|
||||
route: routeId.bucketRoute,
|
||||
options,
|
||||
data: bodyData,
|
||||
retries,
|
||||
});
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), this.manager.options.timeout).unref();
|
||||
let res: Response;
|
||||
|
||||
try {
|
||||
// node-fetch typings are a bit weird, so we have to cast to any to get the correct signature
|
||||
// Type 'AbortSignal' is not assignable to type 'import("discord.js-modules/node_modules/@types/node-fetch/externals").AbortSignal'
|
||||
res = await fetch(url, { ...options, signal: controller.signal as any });
|
||||
} catch (error: unknown) {
|
||||
// Retry the specified number of times for possible timed out requests
|
||||
if (error instanceof Error && error.name === 'AbortError' && retries !== this.manager.options.retries) {
|
||||
return this.runRequest(routeId, url, options, bodyData, ++retries);
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
if (this.manager.listenerCount(RESTEvents.Response)) {
|
||||
this.manager.emit(
|
||||
RESTEvents.Response,
|
||||
{
|
||||
method,
|
||||
path: routeId.original,
|
||||
route: routeId.bucketRoute,
|
||||
options,
|
||||
data: bodyData,
|
||||
retries,
|
||||
},
|
||||
res.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
let retryAfter = 0;
|
||||
|
||||
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) : Infinity;
|
||||
// Update the number of remaining requests that can be made before the rate limit resets
|
||||
this.remaining = remaining ? Number(remaining) : 1;
|
||||
// Update the time when this rate limit resets (reset-after is in seconds)
|
||||
this.reset = reset ? Number(reset) * 1000 + Date.now() + this.manager.options.offset : Date.now();
|
||||
|
||||
// Amount of time in milliseconds until we should retry if rate limited (globally or otherwise)
|
||||
if (retry) retryAfter = Number(retry) * 1000 + this.manager.options.offset;
|
||||
|
||||
// Handle buckets via the hash header retroactively
|
||||
if (hash && hash !== this.hash) {
|
||||
// Let library users know when rate limit buckets have been updated
|
||||
this.debug(['Received bucket hash update', ` Old Hash : ${this.hash}`, ` New Hash : ${hash}`].join('\n'));
|
||||
// This queue will eventually be eliminated via attrition
|
||||
this.manager.hashes.set(`${method}:${routeId.bucketRoute}`, hash);
|
||||
}
|
||||
|
||||
// Handle retryAfter, which means we have actually hit a rate limit
|
||||
let sublimitTimeout: number | null = null;
|
||||
if (retryAfter > 0) {
|
||||
if (res.headers.get('X-RateLimit-Global')) {
|
||||
this.manager.globalRemaining = 0;
|
||||
this.manager.globalReset = Date.now() + retryAfter;
|
||||
} else if (!this.localLimited) {
|
||||
/*
|
||||
* This is a sublimit (e.g. 2 channel name changes/10 minutes) since the headers don't indicate a
|
||||
* route-wide rate limit. Don't update remaining or reset to avoid rate limiting the whole
|
||||
* endpoint, just set a reset time on the request itself to avoid retrying too soon.
|
||||
*/
|
||||
sublimitTimeout = retryAfter;
|
||||
}
|
||||
}
|
||||
|
||||
// Count the invalid requests
|
||||
if (res.status === 401 || res.status === 403 || res.status === 429) {
|
||||
if (!invalidCountResetTime || invalidCountResetTime < Date.now()) {
|
||||
invalidCountResetTime = Date.now() + 1000 * 60 * 10;
|
||||
invalidCount = 0;
|
||||
}
|
||||
invalidCount++;
|
||||
|
||||
const emitInvalid =
|
||||
this.manager.options.invalidRequestWarningInterval > 0 &&
|
||||
invalidCount % this.manager.options.invalidRequestWarningInterval === 0;
|
||||
if (emitInvalid) {
|
||||
// Let library users know periodically about invalid requests
|
||||
this.manager.emit(RESTEvents.InvalidRequestWarning, {
|
||||
count: invalidCount,
|
||||
remainingTime: invalidCountResetTime - Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (res.ok) {
|
||||
return parseResponse(res);
|
||||
} else if (res.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
|
||||
const isGlobal = this.globalLimited;
|
||||
let limit: number;
|
||||
let timeout: number;
|
||||
|
||||
if (isGlobal) {
|
||||
// Set RateLimitData based on the global limit
|
||||
limit = this.manager.options.globalRequestsPerSecond;
|
||||
timeout = this.manager.globalReset + this.manager.options.offset - Date.now();
|
||||
} else {
|
||||
// Set RateLimitData based on the route-specific limit
|
||||
limit = this.limit;
|
||||
timeout = this.timeToReset;
|
||||
}
|
||||
await this.onRateLimit({
|
||||
timeToReset: timeout,
|
||||
limit,
|
||||
method,
|
||||
hash: this.hash,
|
||||
url,
|
||||
route: routeId.bucketRoute,
|
||||
majorParameter: this.majorParameter,
|
||||
global: isGlobal,
|
||||
});
|
||||
this.debug(
|
||||
[
|
||||
'Encountered unexpected 429 rate limit',
|
||||
` Global : ${isGlobal.toString()}`,
|
||||
` Method : ${method}`,
|
||||
` URL : ${url}`,
|
||||
` Bucket : ${routeId.bucketRoute}`,
|
||||
` Major parameter: ${routeId.majorParameter}`,
|
||||
` Hash : ${this.hash}`,
|
||||
` Limit : ${limit}`,
|
||||
` Retry After : ${retryAfter}ms`,
|
||||
` Sublimit : ${sublimitTimeout ? `${sublimitTimeout}ms` : 'None'}`,
|
||||
].join('\n'),
|
||||
);
|
||||
// If caused by a sublimit, wait it out here so other requests on the route can be handled
|
||||
if (sublimitTimeout) {
|
||||
// Normally the sublimit queue will not exist, however, if a sublimit is hit while in the sublimit queue, it will
|
||||
const firstSublimit = !this.#sublimitedQueue;
|
||||
if (firstSublimit) {
|
||||
this.#sublimitedQueue = new AsyncQueue();
|
||||
void this.#sublimitedQueue.wait();
|
||||
this.#asyncQueue.shift();
|
||||
}
|
||||
this.#sublimitPromise?.resolve();
|
||||
this.#sublimitPromise = null;
|
||||
await sleep(sublimitTimeout, undefined, { ref: false });
|
||||
let resolve: () => void;
|
||||
const promise = new Promise<void>((res) => (resolve = res));
|
||||
this.#sublimitPromise = { promise, resolve: resolve! };
|
||||
if (firstSublimit) {
|
||||
// Re-queue this request so it can be shifted by the finally
|
||||
await this.#asyncQueue.wait();
|
||||
this.#shiftSublimit = true;
|
||||
}
|
||||
}
|
||||
// Since this is not a server side issue, the next request should pass, so we don't bump the retries counter
|
||||
return this.runRequest(routeId, url, options, bodyData, retries);
|
||||
} else if (res.status >= 500 && res.status < 600) {
|
||||
// Retry the specified number of times for possible server side issues
|
||||
if (retries !== this.manager.options.retries) {
|
||||
return this.runRequest(routeId, url, options, bodyData, ++retries);
|
||||
}
|
||||
// We are out of retries, throw an error
|
||||
throw new HTTPError(res.statusText, res.constructor.name, res.status, method, url, bodyData);
|
||||
} else {
|
||||
// Handle possible malformed requests
|
||||
if (res.status >= 400 && res.status < 500) {
|
||||
// If we receive this status code, it means the token we had is no longer valid.
|
||||
if (res.status === 401) {
|
||||
this.manager.setToken(null!);
|
||||
}
|
||||
// The request will not succeed for some reason, parse the error returned from the api
|
||||
const data = (await parseResponse(res)) as DiscordErrorData | OAuthErrorData;
|
||||
// throw the API error
|
||||
throw new DiscordAPIError(data, 'code' in data ? data.code : data.error, res.status, method, url, bodyData);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
41
packages/rest/src/lib/utils/constants.ts
Normal file
41
packages/rest/src/lib/utils/constants.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { APIVersion } from 'discord-api-types/v9';
|
||||
import type { RESTOptions } from '../REST';
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports
|
||||
const Package = require('../../../package.json');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
export const DefaultUserAgent = `DiscordBot (${Package.homepage}, ${Package.version})`;
|
||||
|
||||
export const DefaultRestOptions: Required<RESTOptions> = {
|
||||
agent: {},
|
||||
api: 'https://discord.com/api',
|
||||
cdn: 'https://cdn.discordapp.com',
|
||||
headers: {},
|
||||
invalidRequestWarningInterval: 0,
|
||||
globalRequestsPerSecond: 50,
|
||||
offset: 50,
|
||||
rejectOnRateLimit: null,
|
||||
retries: 3,
|
||||
timeout: 15_000,
|
||||
userAgentAppendix: `Node.js ${process.version}`,
|
||||
version: APIVersion,
|
||||
};
|
||||
|
||||
/**
|
||||
* The events that the REST manager emits
|
||||
*/
|
||||
export const enum RESTEvents {
|
||||
Debug = 'restDebug',
|
||||
InvalidRequestWarning = 'invalidRequestWarning',
|
||||
RateLimited = 'rateLimited',
|
||||
Request = 'request',
|
||||
Response = 'response',
|
||||
}
|
||||
|
||||
export const ALLOWED_EXTENSIONS = ['webp', 'png', 'jpg', 'jpeg', 'gif'] as const;
|
||||
export const ALLOWED_STICKER_EXTENSIONS = ['png', 'json'] as const;
|
||||
export const ALLOWED_SIZES = [16, 32, 64, 128, 256, 512, 1024, 2048, 4096] as const;
|
||||
|
||||
export type ImageExtension = typeof ALLOWED_EXTENSIONS[number];
|
||||
export type StickerExtension = typeof ALLOWED_STICKER_EXTENSIONS[number];
|
||||
export type ImageSize = typeof ALLOWED_SIZES[number];
|
||||
37
packages/rest/src/lib/utils/utils.ts
Normal file
37
packages/rest/src/lib/utils/utils.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { RESTPatchAPIChannelJSONBody } from 'discord-api-types/v9';
|
||||
import type { Response } from 'node-fetch';
|
||||
import { RequestMethod } from '../RequestManager';
|
||||
|
||||
/**
|
||||
* Converts the response to usable data
|
||||
* @param res The node-fetch response
|
||||
*/
|
||||
export function parseResponse(res: Response): Promise<unknown> {
|
||||
if (res.headers.get('Content-Type')?.startsWith('application/json')) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
return res.buffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a request falls under a sublimit
|
||||
* @param bucketRoute The buckets route identifier
|
||||
* @param body The options provided as JSON data
|
||||
* @param method The HTTP method that will be used to make the request
|
||||
* @returns Whether the request falls under a sublimit
|
||||
*/
|
||||
export function hasSublimit(bucketRoute: string, body?: unknown, method?: string): boolean {
|
||||
// TODO: Update for new sublimits
|
||||
// Currently known sublimits:
|
||||
// Editing channel `name` or `topic`
|
||||
if (bucketRoute === '/channels/:id') {
|
||||
if (typeof body !== 'object' || body === null) return false;
|
||||
// This should never be a POST body, but just in case
|
||||
if (method !== RequestMethod.Patch) return false;
|
||||
const castedBody = body as RESTPatchAPIChannelJSONBody;
|
||||
return ['name', 'topic'].some((key) => Reflect.has(castedBody, key));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user