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:
Vlad Frangu
2023-07-17 09:27:57 +03:00
committed by GitHub
parent 351a18bc35
commit 386f206caf
25 changed files with 272 additions and 179 deletions

View 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;
}

View File

@@ -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';

View File

@@ -1,6 +1,4 @@
/* eslint-disable jsdoc/check-param-names */
import { URL } from 'node:url';
import {
ALLOWED_EXTENSIONS,
ALLOWED_SIZES,

View File

@@ -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) => {

View File

@@ -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';
}
}

View File

@@ -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 };
}
}

View File

@@ -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';
/**

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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>;

View File

@@ -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;
}

View 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;

View File

@@ -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
View File

@@ -0,0 +1,5 @@
import { setDefaultStrategy } from './environment.js';
setDefaultStrategy(fetch);
export * from './shared.js';