mirror of
https://github.com/discordjs/discord.js.git
synced 2026-03-15 02:53:31 +01:00
feat: no-de-no-de, now with extra buns (#9683)
BREAKING CHANGE: The REST and RequestManager classes now extend AsyncEventEmitter from `@vladfrangu/async_event_emitter`, which aids in cross-compatibility between Node, Deno, Bun, CF Workers, Vercel Functions, etc. BREAKING CHANGE: DefaultUserAgentAppendix has been adapted to support multiple different platforms (previously mentioned Deno, Bun, CF Workers, etc) BREAKING CHANGE: the entry point for `@discordjs/rest` will now differ in non-node-like environments (CF Workers, etc.) Co-authored-by: Suneet Tipirneni <77477100+suneettipirneni@users.noreply.github.com> Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com> Co-authored-by: suneettipirneni <suneettipirneni@icloud.com>
This commit is contained in:
11
packages/rest/src/environment.ts
Normal file
11
packages/rest/src/environment.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { RESTOptions } from './shared.js';
|
||||
|
||||
let defaultStrategy: RESTOptions['makeRequest'];
|
||||
|
||||
export function setDefaultStrategy(newStrategy: RESTOptions['makeRequest']) {
|
||||
defaultStrategy = newStrategy;
|
||||
}
|
||||
|
||||
export function getDefaultStrategy() {
|
||||
return defaultStrategy;
|
||||
}
|
||||
@@ -1,15 +1,7 @@
|
||||
export * from './lib/CDN.js';
|
||||
export * from './lib/errors/DiscordAPIError.js';
|
||||
export * from './lib/errors/HTTPError.js';
|
||||
export * from './lib/errors/RateLimitError.js';
|
||||
export * from './lib/RequestManager.js';
|
||||
export * from './lib/REST.js';
|
||||
export * from './lib/utils/constants.js';
|
||||
export { calculateUserDefaultAvatarIndex, makeURLSearchParams, parseResponse } from './lib/utils/utils.js';
|
||||
import { shouldUseGlobalFetchAndWebSocket } from '@discordjs/util';
|
||||
import { setDefaultStrategy } from './environment.js';
|
||||
import { makeRequest } from './strategies/undiciRequest.js';
|
||||
|
||||
/**
|
||||
* The {@link https://github.com/discordjs/discord.js/blob/main/packages/rest/#readme | @discordjs/rest} version
|
||||
* that you are currently using.
|
||||
*/
|
||||
// This needs to explicitly be `string` so it is not typed as a "const string" that gets injected by esbuild
|
||||
export const version = '[VI]{{inject}}[/VI]' as string;
|
||||
setDefaultStrategy(shouldUseGlobalFetchAndWebSocket() ? fetch : makeRequest);
|
||||
|
||||
export * from './shared.js';
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
/* eslint-disable jsdoc/check-param-names */
|
||||
|
||||
import { URL } from 'node:url';
|
||||
import {
|
||||
ALLOWED_EXTENSIONS,
|
||||
ALLOWED_SIZES,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { Readable } from 'node:stream';
|
||||
import type { ReadableStream } from 'node:stream/web';
|
||||
import type { Collection } from '@discordjs/collection';
|
||||
import { AsyncEventEmitter } from '@vladfrangu/async_event_emitter';
|
||||
import type { Dispatcher, RequestInit, Response } from 'undici';
|
||||
import { CDN } from './CDN.js';
|
||||
import {
|
||||
@@ -204,7 +204,7 @@ export interface APIRequest {
|
||||
}
|
||||
|
||||
export interface ResponseLike
|
||||
extends Pick<Response, 'arrayBuffer' | 'bodyUsed' | 'headers' | 'json' | 'ok' | 'status' | 'text'> {
|
||||
extends Pick<Response, 'arrayBuffer' | 'bodyUsed' | 'headers' | 'json' | 'ok' | 'status' | 'statusText' | 'text'> {
|
||||
body: Readable | ReadableStream | null;
|
||||
}
|
||||
|
||||
@@ -223,31 +223,16 @@ export interface RestEvents {
|
||||
handlerSweep: [sweptHandlers: Collection<string, IHandler>];
|
||||
hashSweep: [sweptHashes: Collection<string, HashData>];
|
||||
invalidRequestWarning: [invalidRequestInfo: InvalidRequestWarningData];
|
||||
newListener: [name: string, listener: (...args: any) => void];
|
||||
rateLimited: [rateLimitInfo: RateLimitData];
|
||||
removeListener: [name: string, listener: (...args: any) => void];
|
||||
response: [request: APIRequest, response: ResponseLike];
|
||||
restDebug: [info: string];
|
||||
}
|
||||
|
||||
export interface REST {
|
||||
emit: (<K extends keyof RestEvents>(event: K, ...args: RestEvents[K]) => boolean) &
|
||||
(<S extends string | symbol>(event: Exclude<S, keyof RestEvents>, ...args: any[]) => boolean);
|
||||
export type RestEventsMap = {
|
||||
[K in keyof RestEvents]: RestEvents[K];
|
||||
};
|
||||
|
||||
off: (<K extends keyof RestEvents>(event: K, listener: (...args: RestEvents[K]) => void) => this) &
|
||||
(<S extends string | symbol>(event: Exclude<S, keyof RestEvents>, listener: (...args: any[]) => void) => this);
|
||||
|
||||
on: (<K extends keyof RestEvents>(event: K, listener: (...args: RestEvents[K]) => void) => this) &
|
||||
(<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) &
|
||||
(<S extends string | symbol>(event: Exclude<S, keyof RestEvents>, listener: (...args: any[]) => void) => this);
|
||||
|
||||
removeAllListeners: (<K extends keyof RestEvents>(event?: K) => this) &
|
||||
(<S extends string | symbol>(event?: Exclude<S, keyof RestEvents>) => this);
|
||||
}
|
||||
|
||||
export class REST extends EventEmitter {
|
||||
export class REST extends AsyncEventEmitter<RestEventsMap> {
|
||||
public readonly cdn: CDN;
|
||||
|
||||
public readonly requestManager: RequestManager;
|
||||
@@ -256,9 +241,13 @@ export class REST extends EventEmitter {
|
||||
super();
|
||||
this.cdn = new CDN(options.cdn ?? DefaultRestOptions.cdn);
|
||||
this.requestManager = new RequestManager(options)
|
||||
// @ts-expect-error For some reason ts can't infer these types
|
||||
.on(RESTEvents.Debug, this.emit.bind(this, RESTEvents.Debug))
|
||||
// @ts-expect-error For some reason ts can't infer these types
|
||||
.on(RESTEvents.RateLimited, this.emit.bind(this, RESTEvents.RateLimited))
|
||||
// @ts-expect-error For some reason ts can't infer these types
|
||||
.on(RESTEvents.InvalidRequestWarning, this.emit.bind(this, RESTEvents.InvalidRequestWarning))
|
||||
// @ts-expect-error For some reason ts can't infer these types
|
||||
.on(RESTEvents.HashSweep, this.emit.bind(this, RESTEvents.HashSweep));
|
||||
|
||||
this.on('newListener', (name, listener) => {
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { Blob, Buffer } from 'node:buffer';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { setInterval, clearInterval } from 'node:timers';
|
||||
import type { URLSearchParams } from 'node:url';
|
||||
import { Collection } from '@discordjs/collection';
|
||||
import { lazy } from '@discordjs/util';
|
||||
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, RestEvents } from './REST.js';
|
||||
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';
|
||||
@@ -17,9 +14,7 @@ import {
|
||||
OverwrittenMimeTypes,
|
||||
RESTEvents,
|
||||
} from './utils/constants.js';
|
||||
|
||||
// Make this a lazy dynamic import as file-type is a pure ESM package
|
||||
const getFileType = lazy(async () => import('file-type'));
|
||||
import { isBufferLike } from './utils/utils.js';
|
||||
|
||||
/**
|
||||
* Represents a file to be added to the request
|
||||
@@ -32,7 +27,7 @@ export interface RawFile {
|
||||
/**
|
||||
* The actual data for the file
|
||||
*/
|
||||
data: Buffer | boolean | number | string;
|
||||
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}]`.
|
||||
@@ -162,27 +157,10 @@ export interface HashData {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface RequestManager {
|
||||
emit: (<K extends keyof RestEvents>(event: K, ...args: RestEvents[K]) => boolean) &
|
||||
(<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) &
|
||||
(<S extends string | symbol>(event: Exclude<S, keyof RestEvents>, listener: (...args: any[]) => void) => this);
|
||||
|
||||
on: (<K extends keyof RestEvents>(event: K, listener: (...args: RestEvents[K]) => void) => this) &
|
||||
(<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) &
|
||||
(<S extends string | symbol>(event: Exclude<S, keyof RestEvents>, listener: (...args: any[]) => void) => this);
|
||||
|
||||
removeAllListeners: (<K extends keyof RestEvents>(event?: K) => this) &
|
||||
(<S extends string | symbol>(event?: Exclude<S, keyof RestEvents>) => this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the class that manages handlers for endpoints
|
||||
*/
|
||||
export class RequestManager extends EventEmitter {
|
||||
export class RequestManager extends AsyncEventEmitter<RestEventsMap> {
|
||||
/**
|
||||
* The {@link https://undici.nodejs.org/#/docs/api/Agent | Agent} for all requests
|
||||
* performed by this manager.
|
||||
@@ -216,9 +194,9 @@ export class RequestManager extends EventEmitter {
|
||||
|
||||
#token: string | null = null;
|
||||
|
||||
private hashTimer!: NodeJS.Timer;
|
||||
private hashTimer!: NodeJS.Timer | number;
|
||||
|
||||
private handlerTimer!: NodeJS.Timer;
|
||||
private handlerTimer!: NodeJS.Timer | number;
|
||||
|
||||
public readonly options: RESTOptions;
|
||||
|
||||
@@ -269,7 +247,9 @@ export class RequestManager extends EventEmitter {
|
||||
|
||||
// Fire event
|
||||
this.emit(RESTEvents.HashSweep, sweptHashes);
|
||||
}, this.options.hashSweepInterval).unref();
|
||||
}, this.options.hashSweepInterval);
|
||||
|
||||
this.hashTimer.unref?.();
|
||||
}
|
||||
|
||||
if (this.options.handlerSweepInterval !== 0 && this.options.handlerSweepInterval !== Number.POSITIVE_INFINITY) {
|
||||
@@ -292,7 +272,9 @@ export class RequestManager extends EventEmitter {
|
||||
|
||||
// Fire event
|
||||
this.emit(RESTEvents.HandlerSweep, sweptHandlers);
|
||||
}, this.options.handlerSweepInterval).unref();
|
||||
}, this.options.handlerSweepInterval);
|
||||
|
||||
this.handlerTimer.unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,14 +407,18 @@ export class RequestManager extends EventEmitter {
|
||||
// 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 (Buffer.isBuffer(file.data)) {
|
||||
if (isBufferLike(file.data)) {
|
||||
// Try to infer the content type from the buffer if one isn't passed
|
||||
const { fileTypeFromBuffer } = await getFileType();
|
||||
let contentType = file.contentType;
|
||||
|
||||
if (!contentType) {
|
||||
const parsedType = (await fileTypeFromBuffer(file.data))?.mime;
|
||||
const [parsedType] = filetypeinfo(file.data);
|
||||
|
||||
if (parsedType) {
|
||||
contentType = OverwrittenMimeTypes[parsedType as keyof typeof OverwrittenMimeTypes] ?? parsedType;
|
||||
contentType =
|
||||
OverwrittenMimeTypes[parsedType.mime as keyof typeof OverwrittenMimeTypes] ??
|
||||
parsedType.mime ??
|
||||
'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { STATUS_CODES } from 'node:http';
|
||||
import type { InternalRequest } from '../RequestManager.js';
|
||||
import type { RequestBody } from './DiscordAPIError.js';
|
||||
|
||||
@@ -12,18 +11,19 @@ export class HTTPError extends Error {
|
||||
|
||||
/**
|
||||
* @param status - The status code of the response
|
||||
* @param statusText - The status text 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 status: number,
|
||||
statusText: string,
|
||||
public method: string,
|
||||
public url: string,
|
||||
bodyData: Pick<InternalRequest, 'body' | 'files'>,
|
||||
) {
|
||||
super(STATUS_CODES[status]);
|
||||
|
||||
super(statusText);
|
||||
this.requestBody = { files: bodyData.files, json: bodyData.body };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import type { RequestInit } from 'undici';
|
||||
import type { ResponseLike } from '../REST.js';
|
||||
import type { HandlerRequestData, RequestManager, RouteData } from '../RequestManager.js';
|
||||
import type { IHandler } from '../interfaces/Handler.js';
|
||||
import { RESTEvents } from '../utils/constants.js';
|
||||
import { onRateLimit } from '../utils/utils.js';
|
||||
import { onRateLimit, sleep } from '../utils/utils.js';
|
||||
import { handleErrors, incrementInvalidCount, makeNetworkRequest } from './Shared.js';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { AsyncQueue } from '@sapphire/async-queue';
|
||||
import type { RequestInit } from 'undici';
|
||||
import type { RateLimitData, ResponseLike } from '../REST.js';
|
||||
import type { HandlerRequestData, RequestManager, RouteData } from '../RequestManager.js';
|
||||
import type { IHandler } from '../interfaces/Handler.js';
|
||||
import { RESTEvents } from '../utils/constants.js';
|
||||
import { hasSublimit, onRateLimit } from '../utils/utils.js';
|
||||
import { hasSublimit, onRateLimit, sleep } from '../utils/utils.js';
|
||||
import { handleErrors, incrementInvalidCount, makeNetworkRequest } from './Shared.js';
|
||||
|
||||
const enum QueueType {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { setTimeout, clearTimeout } from 'node:timers';
|
||||
import { Response } from 'undici';
|
||||
import type { RequestInit } from 'undici';
|
||||
import type { ResponseLike } from '../REST.js';
|
||||
import type { HandlerRequestData, RequestManager, RouteData } from '../RequestManager.js';
|
||||
@@ -65,7 +63,7 @@ export async function makeNetworkRequest(
|
||||
retries: number,
|
||||
) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), manager.options.timeout).unref();
|
||||
const timeout = setTimeout(() => controller.abort(), manager.options.timeout);
|
||||
if (requestData.signal) {
|
||||
// If the user signal was aborted, abort the controller, else abort the local signal.
|
||||
// The reason why we don't re-use the user's signal, is because users may use the same signal for multiple
|
||||
@@ -135,7 +133,7 @@ export async function handleErrors(
|
||||
}
|
||||
|
||||
// We are out of retries, throw an error
|
||||
throw new HTTPError(status, method, url, requestData);
|
||||
throw new HTTPError(status, res.statusText, method, url, requestData);
|
||||
} else {
|
||||
// Handle possible malformed requests
|
||||
if (status >= 400 && status < 500) {
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import process from 'node:process';
|
||||
import { lazy } from '@discordjs/util';
|
||||
import { getUserAgentAppendix } from '@discordjs/util';
|
||||
import { APIVersion } from 'discord-api-types/v10';
|
||||
import type { RESTOptions } from '../REST.js';
|
||||
|
||||
const getUndiciRequest = lazy(async () => {
|
||||
return import('../../strategies/undiciRequest.js');
|
||||
});
|
||||
import { getDefaultStrategy } from '../../environment.js';
|
||||
import type { RESTOptions, ResponseLike } from '../REST.js';
|
||||
|
||||
export const DefaultUserAgent =
|
||||
`DiscordBot (https://discord.js.org, [VI]{{inject}}[/VI])` as `DiscordBot (https://discord.js.org, ${string})`;
|
||||
@@ -13,7 +9,7 @@ export const DefaultUserAgent =
|
||||
/**
|
||||
* The default string to append onto the user agent.
|
||||
*/
|
||||
export const DefaultUserAgentAppendix = process.release?.name === 'node' ? `Node.js/${process.version}` : '';
|
||||
export const DefaultUserAgentAppendix = getUserAgentAppendix();
|
||||
|
||||
export const DefaultRestOptions = {
|
||||
agent: null,
|
||||
@@ -32,9 +28,8 @@ export const DefaultRestOptions = {
|
||||
hashSweepInterval: 14_400_000, // 4 Hours
|
||||
hashLifetime: 86_400_000, // 24 Hours
|
||||
handlerSweepInterval: 3_600_000, // 1 Hour
|
||||
async makeRequest(...args) {
|
||||
const strategy = await getUndiciRequest();
|
||||
return strategy.makeRequest(...args);
|
||||
async makeRequest(...args): Promise<ResponseLike> {
|
||||
return getDefaultStrategy()(...args);
|
||||
},
|
||||
} as const satisfies Required<RESTOptions>;
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { URLSearchParams } from 'node:url';
|
||||
import type { RESTPatchAPIChannelJSONBody, Snowflake } from 'discord-api-types/v10';
|
||||
import type { RateLimitData, ResponseLike } from '../REST.js';
|
||||
import { type RequestManager, RequestMethod } from '../RequestManager.js';
|
||||
@@ -121,3 +120,23 @@ export async function onRateLimit(manager: RequestManager, rateLimitData: RateLi
|
||||
export function calculateUserDefaultAvatarIndex(userId: Snowflake) {
|
||||
return Number(BigInt(userId) >> 22n) % 6;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleeps for a given amount of time.
|
||||
*
|
||||
* @param ms - The amount of time (in milliseconds) to sleep for
|
||||
*/
|
||||
export async function sleep(ms: number): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(() => resolve(), ms);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that a value is a buffer-like object.
|
||||
*
|
||||
* @param value - The value to check
|
||||
*/
|
||||
export function isBufferLike(value: unknown): value is ArrayBuffer | Buffer | Uint8Array | Uint8ClampedArray {
|
||||
return value instanceof ArrayBuffer || value instanceof Uint8Array || value instanceof Uint8ClampedArray;
|
||||
}
|
||||
|
||||
15
packages/rest/src/shared.ts
Normal file
15
packages/rest/src/shared.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export * from './lib/CDN.js';
|
||||
export * from './lib/errors/DiscordAPIError.js';
|
||||
export * from './lib/errors/HTTPError.js';
|
||||
export * from './lib/errors/RateLimitError.js';
|
||||
export * from './lib/RequestManager.js';
|
||||
export * from './lib/REST.js';
|
||||
export * from './lib/utils/constants.js';
|
||||
export { calculateUserDefaultAvatarIndex, makeURLSearchParams, parseResponse } from './lib/utils/utils.js';
|
||||
|
||||
/**
|
||||
* The {@link https://github.com/discordjs/discord.js/blob/main/packages/rest/#readme | @discordjs/rest} version
|
||||
* that you are currently using.
|
||||
*/
|
||||
// This needs to explicitly be `string` so it is not typed as a "const string" that gets injected by esbuild
|
||||
export const version = '[VI]{{inject}}[/VI]' as string;
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { STATUS_CODES } from 'node:http';
|
||||
import { URLSearchParams } from 'node:url';
|
||||
import { types } from 'node:util';
|
||||
import { type RequestInit, request } from 'undici';
|
||||
import type { ResponseLike } from '../index.js';
|
||||
import type { ResponseLike } from '../shared.js';
|
||||
|
||||
export type RequestOptions = Exclude<Parameters<typeof request>[1], undefined>;
|
||||
|
||||
@@ -30,6 +30,7 @@ export async function makeRequest(url: string, init: RequestInit): Promise<Respo
|
||||
},
|
||||
headers: new Headers(res.headers as Record<string, string[] | string>),
|
||||
status: res.statusCode,
|
||||
statusText: STATUS_CODES[res.statusCode]!,
|
||||
ok: res.statusCode >= 200 && res.statusCode < 300,
|
||||
};
|
||||
}
|
||||
|
||||
5
packages/rest/src/web.ts
Normal file
5
packages/rest/src/web.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { setDefaultStrategy } from './environment.js';
|
||||
|
||||
setDefaultStrategy(fetch);
|
||||
|
||||
export * from './shared.js';
|
||||
Reference in New Issue
Block a user