mirror of
https://github.com/discordjs/discord.js.git
synced 2026-03-15 11:03:30 +01:00
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
This commit is contained in:
@@ -33,7 +33,7 @@ export function proxyRequests(rest: REST): RequestHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const discordResponse = await rest.raw({
|
const discordResponse = await rest.queueRequest({
|
||||||
body: req,
|
body: req,
|
||||||
fullRoute,
|
fullRoute,
|
||||||
// This type cast is technically incorrect, but we want Discord to throw Method Not Allowed for us
|
// This type cast is technically incorrect, but we want Discord to throw Method Not Allowed for us
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const responseOptions: MockInterceptor.MockResponseOptions = {
|
|||||||
test('Interaction callback creates burst handler', async () => {
|
test('Interaction callback creates burst handler', async () => {
|
||||||
mockPool.intercept({ path: callbackPath, method: 'POST' }).reply(200);
|
mockPool.intercept({ path: callbackPath, method: 'POST' }).reply(200);
|
||||||
|
|
||||||
expect(api.requestManager.handlers.get(callbackKey)).toBe(undefined);
|
expect(api.handlers.get(callbackKey)).toBe(undefined);
|
||||||
expect(
|
expect(
|
||||||
await api.post('/interactions/1234567890123456789/totallyarealtoken/callback', {
|
await api.post('/interactions/1234567890123456789/totallyarealtoken/callback', {
|
||||||
auth: false,
|
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
|
// TODO: This should be ArrayBuffer, there is a bug in undici request
|
||||||
).toBeInstanceOf(Uint8Array);
|
).toBeInstanceOf(Uint8Array);
|
||||||
expect(api.requestManager.handlers.get(callbackKey)).toBeInstanceOf(BurstHandler);
|
expect(api.handlers.get(callbackKey)).toBeInstanceOf(BurstHandler);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Requests are handled in bursts', async () => {
|
test('Requests are handled in bursts', async () => {
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ test('Significant Invalid Requests', async () => {
|
|||||||
await expect(e).rejects.toThrowError('Missing Permissions');
|
await expect(e).rejects.toThrowError('Missing Permissions');
|
||||||
expect(invalidListener).toHaveBeenCalledTimes(0);
|
expect(invalidListener).toHaveBeenCalledTimes(0);
|
||||||
// eslint-disable-next-line require-atomic-updates
|
// eslint-disable-next-line require-atomic-updates
|
||||||
api.requestManager.options.invalidRequestWarningInterval = 2;
|
api.options.invalidRequestWarningInterval = 2;
|
||||||
|
|
||||||
const [f, g, h, i, j] = [
|
const [f, g, h, i, j] = [
|
||||||
api.get('/badRequest'),
|
api.get('/badRequest'),
|
||||||
@@ -504,7 +504,7 @@ test('Unauthorized', async () => {
|
|||||||
.reply(401, { message: '401: Unauthorized', code: 0 }, responseOptions)
|
.reply(401, { message: '401: Unauthorized', code: 0 }, responseOptions)
|
||||||
.times(2);
|
.times(2);
|
||||||
|
|
||||||
const setTokenSpy = vitest.spyOn(invalidAuthApi.requestManager, 'setToken');
|
const setTokenSpy = vitest.spyOn(invalidAuthApi, 'setToken');
|
||||||
|
|
||||||
// Ensure authless requests don't reset the token
|
// Ensure authless requests don't reset the token
|
||||||
const promiseWithoutTokenClear = invalidAuthApi.get('/unauthorized', { auth: false });
|
const promiseWithoutTokenClear = invalidAuthApi.get('/unauthorized', { auth: false });
|
||||||
|
|||||||
@@ -36,5 +36,5 @@ test('no token', async () => {
|
|||||||
test('negative offset', () => {
|
test('negative offset', () => {
|
||||||
const badREST = new REST({ offset: -5_000 });
|
const badREST = new REST({ offset: -5_000 });
|
||||||
|
|
||||||
expect(badREST.requestManager.options.offset).toEqual(0);
|
expect(badREST.options.offset).toEqual(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
|
import { Blob } from 'node:buffer';
|
||||||
import { shouldUseGlobalFetchAndWebSocket } from '@discordjs/util';
|
import { shouldUseGlobalFetchAndWebSocket } from '@discordjs/util';
|
||||||
|
import { FormData } from 'undici';
|
||||||
import { setDefaultStrategy } from './environment.js';
|
import { setDefaultStrategy } from './environment.js';
|
||||||
import { makeRequest } from './strategies/undiciRequest.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);
|
setDefaultStrategy(shouldUseGlobalFetchAndWebSocket() ? fetch : makeRequest);
|
||||||
|
|
||||||
export * from './shared.js';
|
export * from './shared.js';
|
||||||
|
|||||||
@@ -1,288 +1,155 @@
|
|||||||
import type { Readable } from 'node:stream';
|
import { Collection } from '@discordjs/collection';
|
||||||
import type { ReadableStream } from 'node:stream/web';
|
import { DiscordSnowflake } from '@sapphire/snowflake';
|
||||||
import type { Collection } from '@discordjs/collection';
|
|
||||||
import { AsyncEventEmitter } from '@vladfrangu/async_event_emitter';
|
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 { CDN } from './CDN.js';
|
||||||
import {
|
import { BurstHandler } from './handlers/BurstHandler.js';
|
||||||
RequestManager,
|
import { SequentialHandler } from './handlers/SequentialHandler.js';
|
||||||
RequestMethod,
|
|
||||||
type HashData,
|
|
||||||
type HandlerRequestData,
|
|
||||||
type InternalRequest,
|
|
||||||
type RequestData,
|
|
||||||
type RouteLike,
|
|
||||||
} from './RequestManager.js';
|
|
||||||
import type { IHandler } from './interfaces/Handler.js';
|
import type { IHandler } from './interfaces/Handler.js';
|
||||||
import { DefaultRestOptions, RESTEvents } from './utils/constants.js';
|
import {
|
||||||
import { parseResponse } from './utils/utils.js';
|
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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options to be passed when creating the REST instance
|
* Represents the class that manages handlers for endpoints
|
||||||
*/
|
*/
|
||||||
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<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.
|
|
||||||
*
|
|
||||||
* @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<ResponseLike>;
|
|
||||||
/**
|
|
||||||
* 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> | 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<Response, 'arrayBuffer' | 'bodyUsed' | 'headers' | 'json' | 'ok' | 'status' | 'statusText' | 'text'> {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RestEvents {
|
|
||||||
handlerSweep: [sweptHandlers: Collection<string, IHandler>];
|
|
||||||
hashSweep: [sweptHashes: Collection<string, HashData>];
|
|
||||||
invalidRequestWarning: [invalidRequestInfo: InvalidRequestWarningData];
|
|
||||||
rateLimited: [rateLimitInfo: RateLimitData];
|
|
||||||
response: [request: APIRequest, response: ResponseLike];
|
|
||||||
restDebug: [info: string];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RestEventsMap = {
|
|
||||||
[K in keyof RestEvents]: RestEvents[K];
|
|
||||||
};
|
|
||||||
|
|
||||||
export class REST extends AsyncEventEmitter<RestEventsMap> {
|
export class REST extends AsyncEventEmitter<RestEventsMap> {
|
||||||
|
/**
|
||||||
|
* The {@link https://undici.nodejs.org/#/docs/api/Agent | Agent} for all requests
|
||||||
|
* performed by this manager.
|
||||||
|
*/
|
||||||
|
public agent: Dispatcher | null = null;
|
||||||
|
|
||||||
public readonly cdn: CDN;
|
public readonly cdn: CDN;
|
||||||
|
|
||||||
public readonly requestManager: RequestManager;
|
/**
|
||||||
|
* 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, HashData>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request handlers created from the bucket hash and the major parameters
|
||||||
|
*/
|
||||||
|
public readonly handlers = new Collection<string, IHandler>();
|
||||||
|
|
||||||
|
#token: string | null = null;
|
||||||
|
|
||||||
|
private hashTimer!: NodeJS.Timer | number;
|
||||||
|
|
||||||
|
private handlerTimer!: NodeJS.Timer | number;
|
||||||
|
|
||||||
|
public readonly options: RESTOptions;
|
||||||
|
|
||||||
public constructor(options: Partial<RESTOptions> = {}) {
|
public constructor(options: Partial<RESTOptions> = {}) {
|
||||||
super();
|
super();
|
||||||
this.cdn = new CDN(options.cdn ?? DefaultRestOptions.cdn);
|
this.cdn = new CDN(options.cdn ?? DefaultRestOptions.cdn);
|
||||||
this.requestManager = new RequestManager(options)
|
this.options = { ...DefaultRestOptions, ...options };
|
||||||
// @ts-expect-error For some reason ts can't infer these types
|
this.options.offset = Math.max(0, this.options.offset);
|
||||||
.on(RESTEvents.Debug, this.emit.bind(this, RESTEvents.Debug))
|
this.globalRemaining = Math.max(1, this.options.globalRequestsPerSecond);
|
||||||
// @ts-expect-error For some reason ts can't infer these types
|
this.agent = options.agent ?? null;
|
||||||
.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) => {
|
// Start sweepers
|
||||||
if (name === RESTEvents.Response) this.requestManager.on(name, listener);
|
this.setupSweepers();
|
||||||
});
|
|
||||||
this.on('removeListener', (name, listener) => {
|
|
||||||
if (name === RESTEvents.Response) this.requestManager.off(name, listener);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private setupSweepers() {
|
||||||
* Gets the agent set for this instance
|
// eslint-disable-next-line unicorn/consistent-function-scoping
|
||||||
*/
|
const validateMaxInterval = (interval: number) => {
|
||||||
public getAgent() {
|
if (interval > 14_400_000) {
|
||||||
return this.requestManager.agent;
|
throw new Error('Cannot set an interval greater than 4 hours');
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
if (this.options.hashSweepInterval !== 0 && this.options.hashSweepInterval !== Number.POSITIVE_INFINITY) {
|
||||||
* Sets the default agent to use for requests performed by this instance
|
validateMaxInterval(this.options.hashSweepInterval);
|
||||||
*
|
this.hashTimer = setInterval(() => {
|
||||||
* @param agent - Sets the agent to use
|
const sweptHashes = new Collection<string, HashData>();
|
||||||
*/
|
const currentDate = Date.now();
|
||||||
public setAgent(agent: Dispatcher) {
|
|
||||||
this.requestManager.setAgent(agent);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
// Begin sweeping hash based on lifetimes
|
||||||
* Sets the authorization token that should be used for requests
|
this.hashes.sweep((val, key) => {
|
||||||
*
|
// `-1` indicates a global hash
|
||||||
* @param token - The authorization token to use
|
if (val.lastAccess === -1) return false;
|
||||||
*/
|
|
||||||
public setToken(token: string) {
|
// Check if lifetime has been exceeded
|
||||||
this.requestManager.setToken(token);
|
const shouldSweep = Math.floor(currentDate - val.lastAccess) > this.options.hashLifetime;
|
||||||
return this;
|
|
||||||
|
// 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<string, IHandler>();
|
||||||
|
|
||||||
|
// 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<RestEventsMap> {
|
|||||||
* @param options - Request options
|
* @param options - Request options
|
||||||
*/
|
*/
|
||||||
public async request(options: InternalRequest) {
|
public async request(options: InternalRequest) {
|
||||||
const response = await this.raw(options);
|
const response = await this.queueRequest(options);
|
||||||
return parseResponse(response);
|
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 async raw(options: InternalRequest) {
|
public setAgent(agent: Dispatcher) {
|
||||||
return this.requestManager.queueRequest(options);
|
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<ResponseLike> {
|
||||||
|
// 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<string, string> = {};
|
||||||
|
|
||||||
|
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<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;
|
||||||
|
|
||||||
|
// 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<string, string>,
|
||||||
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, string>;
|
|
||||||
/**
|
|
||||||
* Whether to pass-through the body property directly to `fetch()`.
|
|
||||||
* <warn>This only applies when files 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 | 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<InternalRequest, 'auth' | 'body' | 'files' | 'signal'>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<RestEventsMap> {
|
|
||||||
/**
|
|
||||||
* 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<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, HashData>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request handlers created from the bucket hash and the major parameters
|
|
||||||
*/
|
|
||||||
public readonly handlers = new Collection<string, IHandler>();
|
|
||||||
|
|
||||||
#token: string | null = null;
|
|
||||||
|
|
||||||
private hashTimer!: NodeJS.Timer | number;
|
|
||||||
|
|
||||||
private handlerTimer!: NodeJS.Timer | number;
|
|
||||||
|
|
||||||
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;
|
|
||||||
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<string, HashData>();
|
|
||||||
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<string, IHandler>();
|
|
||||||
|
|
||||||
// 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<ResponseLike> {
|
|
||||||
// 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<string, string> = {};
|
|
||||||
|
|
||||||
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<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;
|
|
||||||
|
|
||||||
// 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<string, string>,
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { InternalRequest, RawFile } from '../RequestManager.js';
|
import type { InternalRequest, RawFile } from '../utils/types.js';
|
||||||
|
|
||||||
interface DiscordErrorFieldInformation {
|
interface DiscordErrorFieldInformation {
|
||||||
code: string;
|
code: string;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { InternalRequest } from '../RequestManager.js';
|
import type { InternalRequest } from '../utils/types.js';
|
||||||
import type { RequestBody } from './DiscordAPIError.js';
|
import type { RequestBody } from './DiscordAPIError.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { RateLimitData } from '../REST.js';
|
import type { RateLimitData } from '../utils/types.js';
|
||||||
|
|
||||||
export class RateLimitError extends Error implements RateLimitData {
|
export class RateLimitError extends Error implements RateLimitData {
|
||||||
public timeToReset: number;
|
public timeToReset: number;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { RequestInit } from 'undici';
|
import type { RequestInit } from 'undici';
|
||||||
import type { ResponseLike } from '../REST.js';
|
import type { REST } from '../REST.js';
|
||||||
import type { HandlerRequestData, RequestManager, RouteData } from '../RequestManager.js';
|
|
||||||
import type { IHandler } from '../interfaces/Handler.js';
|
import type { IHandler } from '../interfaces/Handler.js';
|
||||||
import { RESTEvents } from '../utils/constants.js';
|
import { RESTEvents } from '../utils/constants.js';
|
||||||
|
import type { ResponseLike, HandlerRequestData, RouteData } from '../utils/types.js';
|
||||||
import { onRateLimit, sleep } from '../utils/utils.js';
|
import { onRateLimit, sleep } from '../utils/utils.js';
|
||||||
import { handleErrors, incrementInvalidCount, makeNetworkRequest } from './Shared.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
|
* @param majorParameter - The major parameter for this handler
|
||||||
*/
|
*/
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly manager: RequestManager,
|
private readonly manager: REST,
|
||||||
private readonly hash: string,
|
private readonly hash: string,
|
||||||
private readonly majorParameter: string,
|
private readonly majorParameter: string,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { AsyncQueue } from '@sapphire/async-queue';
|
import { AsyncQueue } from '@sapphire/async-queue';
|
||||||
import type { RequestInit } from 'undici';
|
import type { RequestInit } from 'undici';
|
||||||
import type { RateLimitData, ResponseLike } from '../REST.js';
|
import type { REST } from '../REST.js';
|
||||||
import type { HandlerRequestData, RequestManager, RouteData } from '../RequestManager.js';
|
|
||||||
import type { IHandler } from '../interfaces/Handler.js';
|
import type { IHandler } from '../interfaces/Handler.js';
|
||||||
import { RESTEvents } from '../utils/constants.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 { hasSublimit, onRateLimit, sleep } from '../utils/utils.js';
|
||||||
import { handleErrors, incrementInvalidCount, makeNetworkRequest } from './Shared.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
|
* @param majorParameter - The major parameter for this handler
|
||||||
*/
|
*/
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly manager: RequestManager,
|
private readonly manager: REST,
|
||||||
private readonly hash: string,
|
private readonly hash: string,
|
||||||
private readonly majorParameter: string,
|
private readonly majorParameter: string,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { RequestInit } from 'undici';
|
import type { RequestInit } from 'undici';
|
||||||
import type { ResponseLike } from '../REST.js';
|
import type { REST } from '../REST.js';
|
||||||
import type { HandlerRequestData, RequestManager, RouteData } from '../RequestManager.js';
|
|
||||||
import type { DiscordErrorData, OAuthErrorData } from '../errors/DiscordAPIError.js';
|
import type { DiscordErrorData, OAuthErrorData } from '../errors/DiscordAPIError.js';
|
||||||
import { DiscordAPIError } from '../errors/DiscordAPIError.js';
|
import { DiscordAPIError } from '../errors/DiscordAPIError.js';
|
||||||
import { HTTPError } from '../errors/HTTPError.js';
|
import { HTTPError } from '../errors/HTTPError.js';
|
||||||
import { RESTEvents } from '../utils/constants.js';
|
import { RESTEvents } from '../utils/constants.js';
|
||||||
|
import type { ResponseLike, HandlerRequestData, RouteData } from '../utils/types.js';
|
||||||
import { parseResponse, shouldRetry } from '../utils/utils.js';
|
import { parseResponse, shouldRetry } from '../utils/utils.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,7 +22,7 @@ let invalidCountResetTime: number | null = null;
|
|||||||
*
|
*
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
export function incrementInvalidCount(manager: RequestManager) {
|
export function incrementInvalidCount(manager: REST) {
|
||||||
if (!invalidCountResetTime || invalidCountResetTime < Date.now()) {
|
if (!invalidCountResetTime || invalidCountResetTime < Date.now()) {
|
||||||
invalidCountResetTime = Date.now() + 1_000 * 60 * 10;
|
invalidCountResetTime = Date.now() + 1_000 * 60 * 10;
|
||||||
invalidCount = 0;
|
invalidCount = 0;
|
||||||
@@ -55,7 +55,7 @@ export function incrementInvalidCount(manager: RequestManager) {
|
|||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
export async function makeNetworkRequest(
|
export async function makeNetworkRequest(
|
||||||
manager: RequestManager,
|
manager: REST,
|
||||||
routeId: RouteData,
|
routeId: RouteData,
|
||||||
url: string,
|
url: string,
|
||||||
options: RequestInit,
|
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
|
* @returns - The response if the status code is not handled or null to request a retry
|
||||||
*/
|
*/
|
||||||
export async function handleErrors(
|
export async function handleErrors(
|
||||||
manager: RequestManager,
|
manager: REST,
|
||||||
res: ResponseLike,
|
res: ResponseLike,
|
||||||
method: string,
|
method: string,
|
||||||
url: string,
|
url: string,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { RequestInit } from 'undici';
|
import type { RequestInit } from 'undici';
|
||||||
import type { ResponseLike } from '../REST.js';
|
import type { HandlerRequestData, RouteData, ResponseLike } from '../utils/types.js';
|
||||||
import type { HandlerRequestData, RouteData } from '../RequestManager.js';
|
|
||||||
|
|
||||||
export interface IHandler {
|
export interface IHandler {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getUserAgentAppendix } from '@discordjs/util';
|
import { getUserAgentAppendix } from '@discordjs/util';
|
||||||
import { APIVersion } from 'discord-api-types/v10';
|
import { APIVersion } from 'discord-api-types/v10';
|
||||||
import { getDefaultStrategy } from '../../environment.js';
|
import { getDefaultStrategy } from '../../environment.js';
|
||||||
import type { RESTOptions, ResponseLike } from '../REST.js';
|
import type { RESTOptions, ResponseLike } from './types.js';
|
||||||
|
|
||||||
export const DefaultUserAgent =
|
export const DefaultUserAgent =
|
||||||
`DiscordBot (https://discord.js.org, [VI]{{inject}}[/VI])` as `DiscordBot (https://discord.js.org, ${string})`;
|
`DiscordBot (https://discord.js.org, [VI]{{inject}}[/VI])` as `DiscordBot (https://discord.js.org, ${string})`;
|
||||||
|
|||||||
359
packages/rest/src/lib/utils/types.ts
Normal file
359
packages/rest/src/lib/utils/types.ts
Normal file
@@ -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<string, IHandler>];
|
||||||
|
hashSweep: [sweptHashes: Collection<string, HashData>];
|
||||||
|
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<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.
|
||||||
|
*
|
||||||
|
* @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<ResponseLike>;
|
||||||
|
/**
|
||||||
|
* 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> | 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<Response, 'arrayBuffer' | 'bodyUsed' | 'headers' | 'json' | 'ok' | 'status' | 'statusText' | 'text'> {
|
||||||
|
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<string, string>;
|
||||||
|
/**
|
||||||
|
* Whether to pass-through the body property directly to `fetch()`.
|
||||||
|
* <warn>This only applies when files 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 | 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<InternalRequest, 'auth' | 'body' | 'files' | 'signal'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { RESTPatchAPIChannelJSONBody, Snowflake } from 'discord-api-types/v10';
|
import type { RESTPatchAPIChannelJSONBody, Snowflake } from 'discord-api-types/v10';
|
||||||
import type { RateLimitData, ResponseLike } from '../REST.js';
|
import type { REST } from '../REST.js';
|
||||||
import { type RequestManager, RequestMethod } from '../RequestManager.js';
|
|
||||||
import { RateLimitError } from '../errors/RateLimitError.js';
|
import { RateLimitError } from '../errors/RateLimitError.js';
|
||||||
|
import { RequestMethod, type RateLimitData, type ResponseLike } from './types.js';
|
||||||
|
|
||||||
function serializeSearchParam(value: unknown): string | null {
|
function serializeSearchParam(value: unknown): string | null {
|
||||||
switch (typeof value) {
|
switch (typeof value) {
|
||||||
@@ -99,7 +99,7 @@ export function shouldRetry(error: Error | NodeJS.ErrnoException) {
|
|||||||
*
|
*
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
export async function onRateLimit(manager: RequestManager, rateLimitData: RateLimitData) {
|
export async function onRateLimit(manager: REST, rateLimitData: RateLimitData) {
|
||||||
const { options } = manager;
|
const { options } = manager;
|
||||||
if (!options.rejectOnRateLimit) return;
|
if (!options.rejectOnRateLimit) return;
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ export * from './lib/CDN.js';
|
|||||||
export * from './lib/errors/DiscordAPIError.js';
|
export * from './lib/errors/DiscordAPIError.js';
|
||||||
export * from './lib/errors/HTTPError.js';
|
export * from './lib/errors/HTTPError.js';
|
||||||
export * from './lib/errors/RateLimitError.js';
|
export * from './lib/errors/RateLimitError.js';
|
||||||
export * from './lib/RequestManager.js';
|
|
||||||
export * from './lib/REST.js';
|
export * from './lib/REST.js';
|
||||||
export * from './lib/utils/constants.js';
|
export * from './lib/utils/constants.js';
|
||||||
|
export * from './lib/utils/types.js';
|
||||||
export { calculateUserDefaultAvatarIndex, makeURLSearchParams, parseResponse } from './lib/utils/utils.js';
|
export { calculateUserDefaultAvatarIndex, makeURLSearchParams, parseResponse } from './lib/utils/utils.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { STATUS_CODES } from 'node:http';
|
import { STATUS_CODES } from 'node:http';
|
||||||
import { URLSearchParams } from 'node:url';
|
import { URLSearchParams } from 'node:url';
|
||||||
import { types } from 'node:util';
|
import { types } from 'node:util';
|
||||||
import { type RequestInit, request } from 'undici';
|
import { type RequestInit, request, Headers } from 'undici';
|
||||||
import type { ResponseLike } from '../shared.js';
|
import type { ResponseLike } from '../shared.js';
|
||||||
|
|
||||||
export type RequestOptions = Exclude<Parameters<typeof request>[1], undefined>;
|
export type RequestOptions = Exclude<Parameters<typeof request>[1], undefined>;
|
||||||
|
|||||||
Reference in New Issue
Block a user