mirror of
https://github.com/discordjs/discord.js.git
synced 2026-03-09 16:13: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:
@@ -1,3 +1,11 @@
|
||||
{
|
||||
"extends": "../../.eslintrc.json"
|
||||
"extends": "../../.eslintrc.json",
|
||||
"rules": {
|
||||
"n/prefer-global/url": 0,
|
||||
"n/prefer-global/url-search-params": 0,
|
||||
"n/prefer-global/buffer": 0,
|
||||
"n/prefer-global/process": 0,
|
||||
"no-restricted-globals": 0,
|
||||
"unicorn/prefer-node-protocol": 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
module.exports = require('../../.lintstagedrc.json');
|
||||
module.exports = {
|
||||
...require('../../.lintstagedrc.json'),
|
||||
'src/**.ts': 'vitest related --run --config ./vitest.config.ts',
|
||||
};
|
||||
|
||||
4
packages/rest/__tests__/setup.ts
Normal file
4
packages/rest/__tests__/setup.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { setDefaultStrategy } from '../src/environment.js';
|
||||
import { makeRequest } from '../src/strategies/undiciRequest.js';
|
||||
|
||||
setDefaultStrategy(makeRequest);
|
||||
@@ -14,15 +14,20 @@
|
||||
"changelog": "git cliff --prepend ./CHANGELOG.md -u -c ./cliff.toml -r ../../ --include-path 'packages/rest/*'",
|
||||
"release": "cliff-jumper"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"typings": "./dist/index.d.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"node": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.js"
|
||||
},
|
||||
"default": {
|
||||
"types": "./dist/web.d.ts",
|
||||
"import": "./dist/web.mjs",
|
||||
"require": "./dist/web.js"
|
||||
}
|
||||
},
|
||||
"./*": {
|
||||
"types": "./dist/strategies/*.d.ts",
|
||||
"import": "./dist/strategies/*.mjs",
|
||||
@@ -65,8 +70,9 @@
|
||||
"@discordjs/util": "workspace:^",
|
||||
"@sapphire/async-queue": "^1.5.0",
|
||||
"@sapphire/snowflake": "^3.5.1",
|
||||
"@vladfrangu/async_event_emitter": "^2.2.2",
|
||||
"discord-api-types": "^0.37.45",
|
||||
"file-type": "^18.4.0",
|
||||
"magic-bytes.js": "^1.0.14",
|
||||
"tslib": "^2.5.2",
|
||||
"undici": "^5.22.1"
|
||||
},
|
||||
|
||||
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';
|
||||
@@ -2,6 +2,6 @@ import { esbuildPluginVersionInjector } from 'esbuild-plugin-version-injector';
|
||||
import { createTsupConfig } from '../../tsup.config.js';
|
||||
|
||||
export default createTsupConfig({
|
||||
entry: ['src/index.ts', 'src/strategies/*.ts'],
|
||||
entry: ['src/index.ts', 'src/web.ts', 'src/strategies/*.ts'],
|
||||
esbuildPlugins: [esbuildPluginVersionInjector()],
|
||||
});
|
||||
|
||||
11
packages/rest/vitest.config.ts
Normal file
11
packages/rest/vitest.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineProject, mergeConfig } from 'vitest/config';
|
||||
import configShared from '../../vitest.config.js';
|
||||
|
||||
export default mergeConfig(
|
||||
configShared,
|
||||
defineProject({
|
||||
test: {
|
||||
setupFiles: ['./__tests__/setup.ts'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from './lazy.js';
|
||||
export * from './range.js';
|
||||
export * from './calculateShardId.js';
|
||||
export * from './runtime.js';
|
||||
export * from './userAgentAppendix.js';
|
||||
|
||||
12
packages/util/src/functions/runtime.ts
Normal file
12
packages/util/src/functions/runtime.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function shouldUseGlobalFetchAndWebSocket() {
|
||||
// Browser env and deno when ran directly
|
||||
if (typeof globalThis.process === 'undefined') {
|
||||
return 'fetch' in globalThis && 'WebSocket' in globalThis;
|
||||
}
|
||||
|
||||
if ('versions' in globalThis.process) {
|
||||
return 'deno' in globalThis.process.versions || 'bun' in globalThis.process.versions;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
52
packages/util/src/functions/userAgentAppendix.ts
Normal file
52
packages/util/src/functions/userAgentAppendix.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/* eslint-disable n/prefer-global/process */
|
||||
/* eslint-disable no-restricted-globals */
|
||||
|
||||
/**
|
||||
* Resolves the user agent appendix string for the current environment.
|
||||
*/
|
||||
export function getUserAgentAppendix(): string {
|
||||
// https://vercel.com/docs/concepts/functions/edge-functions/edge-runtime#check-if-you're-running-on-the-edge-runtime
|
||||
// @ts-expect-error Vercel Edge functions
|
||||
if (typeof globalThis.EdgeRuntime !== 'undefined') {
|
||||
return 'Vercel-Edge-Functions';
|
||||
}
|
||||
|
||||
// @ts-expect-error Cloudflare Workers
|
||||
if (typeof globalThis.R2 !== 'undefined' && typeof globalThis.WebSocketPair !== 'undefined') {
|
||||
// https://developers.cloudflare.com/workers/runtime-apis/web-standards/#navigatoruseragent
|
||||
return 'Cloudflare-Workers';
|
||||
}
|
||||
|
||||
// https://docs.netlify.com/edge-functions/api/#netlify-global-object
|
||||
// @ts-expect-error Netlify Edge functions
|
||||
if (typeof globalThis.Netlify !== 'undefined') {
|
||||
return 'Netlify-Edge-Functions';
|
||||
}
|
||||
|
||||
// Most (if not all) edge environments will have `process` defined. Within a web browser we'll extract it using `navigator.userAgent`.
|
||||
if (typeof globalThis.process !== 'object') {
|
||||
// @ts-expect-error web env
|
||||
if (typeof globalThis.navigator === 'object') {
|
||||
// @ts-expect-error web env
|
||||
return globalThis.navigator.userAgent;
|
||||
}
|
||||
|
||||
return 'UnknownEnvironment';
|
||||
}
|
||||
|
||||
if ('versions' in globalThis.process) {
|
||||
if ('deno' in globalThis.process.versions) {
|
||||
return `Deno/${globalThis.process.versions.deno}`;
|
||||
}
|
||||
|
||||
if ('bun' in globalThis.process.versions) {
|
||||
return `Bun/${globalThis.process.versions.bun}`;
|
||||
}
|
||||
|
||||
if ('node' in globalThis.process.versions) {
|
||||
return `Node.js/${globalThis.process.versions.node}`;
|
||||
}
|
||||
}
|
||||
|
||||
return 'UnknownEnvironment';
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
type GatewayReceivePayload,
|
||||
type GatewaySendPayload,
|
||||
} from 'discord-api-types/v10';
|
||||
import { WebSocket, type RawData } from 'ws';
|
||||
import { WebSocket, type Data } from 'ws';
|
||||
import type { Inflate } from 'zlib-sync';
|
||||
import type { IContextFetchingStrategy } from '../strategies/context/IContextFetchingStrategy.js';
|
||||
import { ImportantGatewayOpcodes, getInitialSendRateLimitState } from '../utils/constants.js';
|
||||
@@ -80,6 +80,12 @@ export interface SendRateLimitState {
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
// TODO(vladfrangu): enable this once https://github.com/oven-sh/bun/issues/3392 is solved
|
||||
// const WebSocketConstructor: typeof WebSocket = shouldUseGlobalFetchAndWebSocket()
|
||||
// ? (globalThis as any).WebSocket
|
||||
// : WebSocket;
|
||||
const WebSocketConstructor: typeof WebSocket = WebSocket;
|
||||
|
||||
export class WebSocketShard extends AsyncEventEmitter<WebSocketShardEventsMap> {
|
||||
private connection: WebSocket | null = null;
|
||||
|
||||
@@ -179,13 +185,27 @@ export class WebSocketShard extends AsyncEventEmitter<WebSocketShardEventsMap> {
|
||||
const session = await this.strategy.retrieveSessionInfo(this.id);
|
||||
|
||||
const url = `${session?.resumeURL ?? this.strategy.options.gatewayInformation.url}?${params.toString()}`;
|
||||
|
||||
this.debug([`Connecting to ${url}`]);
|
||||
const connection = new WebSocket(url, { handshakeTimeout: this.strategy.options.handshakeTimeout ?? undefined })
|
||||
.on('message', this.onMessage.bind(this))
|
||||
.on('error', this.onError.bind(this))
|
||||
.on('close', this.onClose.bind(this));
|
||||
|
||||
const connection = new WebSocketConstructor(url, {
|
||||
handshakeTimeout: this.strategy.options.handshakeTimeout ?? undefined,
|
||||
});
|
||||
|
||||
connection.binaryType = 'arraybuffer';
|
||||
|
||||
connection.onmessage = (event) => {
|
||||
void this.onMessage(event.data, event.data instanceof ArrayBuffer);
|
||||
};
|
||||
|
||||
connection.onerror = (event) => {
|
||||
this.onError(event.error);
|
||||
};
|
||||
|
||||
connection.onclose = (event) => {
|
||||
void this.onClose(event.code);
|
||||
};
|
||||
|
||||
this.connection = connection;
|
||||
|
||||
this.#status = WebSocketShardStatus.Connecting;
|
||||
@@ -249,9 +269,9 @@ export class WebSocketShard extends AsyncEventEmitter<WebSocketShardEventsMap> {
|
||||
|
||||
if (this.connection) {
|
||||
// No longer need to listen to messages
|
||||
this.connection.removeAllListeners('message');
|
||||
this.connection.onmessage = null;
|
||||
// Prevent a reconnection loop by unbinding the main close event
|
||||
this.connection.removeAllListeners('close');
|
||||
this.connection.onclose = null;
|
||||
|
||||
const shouldClose = this.connection.readyState === WebSocket.OPEN;
|
||||
|
||||
@@ -262,14 +282,22 @@ export class WebSocketShard extends AsyncEventEmitter<WebSocketShardEventsMap> {
|
||||
]);
|
||||
|
||||
if (shouldClose) {
|
||||
let outerResolve: () => void;
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
outerResolve = resolve;
|
||||
});
|
||||
|
||||
this.connection.onclose = outerResolve!;
|
||||
|
||||
this.connection.close(options.code, options.reason);
|
||||
await once(this.connection, 'close');
|
||||
|
||||
await promise;
|
||||
this.emit(WebSocketShardEvents.Closed, { code: options.code });
|
||||
}
|
||||
|
||||
// Lastly, remove the error event.
|
||||
// Doing this earlier would cause a hard crash in case an error event fired on our `close` call
|
||||
this.connection.removeAllListeners('error');
|
||||
this.connection.onerror = null;
|
||||
} else {
|
||||
this.debug(['Destroying a shard that has no connection; please open an issue on GitHub']);
|
||||
}
|
||||
@@ -476,17 +504,23 @@ export class WebSocketShard extends AsyncEventEmitter<WebSocketShardEventsMap> {
|
||||
this.isAck = false;
|
||||
}
|
||||
|
||||
private async unpackMessage(data: ArrayBuffer | Buffer, isBinary: boolean): Promise<GatewayReceivePayload | null> {
|
||||
const decompressable = new Uint8Array(data);
|
||||
|
||||
private async unpackMessage(data: Data, isBinary: boolean): Promise<GatewayReceivePayload | null> {
|
||||
// Deal with no compression
|
||||
if (!isBinary) {
|
||||
return JSON.parse(this.textDecoder.decode(decompressable)) as GatewayReceivePayload;
|
||||
try {
|
||||
return JSON.parse(data as string) as GatewayReceivePayload;
|
||||
} catch {
|
||||
// This is a non-JSON payload / (at the time of writing this comment) emitted by bun wrongly interpreting custom close codes https://github.com/oven-sh/bun/issues/3392
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const decompressable = new Uint8Array(data as ArrayBuffer);
|
||||
|
||||
// Deal with identify compress
|
||||
if (this.useIdentifyCompress) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// eslint-disable-next-line promise/prefer-await-to-callbacks
|
||||
inflate(decompressable, { chunkSize: 65_535 }, (err, result) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
@@ -539,8 +573,8 @@ export class WebSocketShard extends AsyncEventEmitter<WebSocketShardEventsMap> {
|
||||
return null;
|
||||
}
|
||||
|
||||
private async onMessage(data: RawData, isBinary: boolean) {
|
||||
const payload = await this.unpackMessage(data as ArrayBuffer | Buffer, isBinary);
|
||||
private async onMessage(data: Data, isBinary: boolean) {
|
||||
const payload = await this.unpackMessage(data, isBinary);
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
66
yarn.lock
66
yarn.lock
@@ -2318,13 +2318,14 @@ __metadata:
|
||||
"@sapphire/snowflake": ^3.5.1
|
||||
"@types/node": 18.16.14
|
||||
"@vitest/coverage-c8": ^0.31.1
|
||||
"@vladfrangu/async_event_emitter": ^2.2.2
|
||||
cross-env: ^7.0.3
|
||||
discord-api-types: ^0.37.45
|
||||
esbuild-plugin-version-injector: ^1.1.0
|
||||
eslint: ^8.41.0
|
||||
eslint-config-neon: ^0.1.47
|
||||
eslint-formatter-pretty: ^5.0.0
|
||||
file-type: ^18.4.0
|
||||
magic-bytes.js: ^1.0.14
|
||||
prettier: ^2.8.8
|
||||
tslib: ^2.5.2
|
||||
tsup: ^6.7.0
|
||||
@@ -6039,13 +6040,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tokenizer/token@npm:^0.3.0":
|
||||
version: 0.3.0
|
||||
resolution: "@tokenizer/token@npm:0.3.0"
|
||||
checksum: 1d575d02d2a9f0c5a4ca5180635ebd2ad59e0f18b42a65f3d04844148b49b3db35cf00b6012a1af2d59c2ab3caca59451c5689f747ba8667ee586ad717ee58e1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tootallnate/once@npm:1":
|
||||
version: 1.1.2
|
||||
resolution: "@tootallnate/once@npm:1.1.2"
|
||||
@@ -13540,17 +13534,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"file-type@npm:^18.4.0":
|
||||
version: 18.4.0
|
||||
resolution: "file-type@npm:18.4.0"
|
||||
dependencies:
|
||||
readable-web-to-node-stream: ^3.0.2
|
||||
strtok3: ^7.0.0
|
||||
token-types: ^5.0.1
|
||||
checksum: 191aa44b662417d496efc51bfb061da4c51cddfe2e3f7467b580964c3d83dbd88f76662368ea231a84d489a7d8cfc0bc2df9fefc439b519c2e6ddc498122dae0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"file-uri-to-path@npm:1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "file-uri-to-path@npm:1.0.0"
|
||||
@@ -15137,7 +15120,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ieee754@npm:^1.1.13, ieee754@npm:^1.2.1":
|
||||
"ieee754@npm:^1.1.13":
|
||||
version: 1.2.1
|
||||
resolution: "ieee754@npm:1.2.1"
|
||||
checksum: 5144c0c9815e54ada181d80a0b810221a253562422e7c6c3a60b1901154184f49326ec239d618c416c1c5945a2e197107aee8d986a3dd836b53dffefd99b5e7e
|
||||
@@ -17760,6 +17743,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"magic-bytes.js@npm:^1.0.14":
|
||||
version: 1.0.14
|
||||
resolution: "magic-bytes.js@npm:1.0.14"
|
||||
checksum: 5431948f5134ea27134a2e9c197ce5fdc89677682d365f275b0193a816a037cb9fc1c5eeeb541920d653e3c44f9022d007ef4b159ec4f1a814945be9f6be8abc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"magic-string@npm:^0.27.0":
|
||||
version: 0.27.0
|
||||
resolution: "magic-string@npm:0.27.0"
|
||||
@@ -20526,13 +20516,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"peek-readable@npm:^5.0.0":
|
||||
version: 5.0.0
|
||||
resolution: "peek-readable@npm:5.0.0"
|
||||
checksum: bef5ceb50586eb42e14efba274ac57ffe97f0ed272df9239ce029f688f495d9bf74b2886fa27847c706a9db33acda4b7d23bbd09a2d21eb4c2a54da915117414
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"peek-stream@npm:^1.1.0":
|
||||
version: 1.1.3
|
||||
resolution: "peek-stream@npm:1.1.3"
|
||||
@@ -21719,15 +21702,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"readable-web-to-node-stream@npm:^3.0.2":
|
||||
version: 3.0.2
|
||||
resolution: "readable-web-to-node-stream@npm:3.0.2"
|
||||
dependencies:
|
||||
readable-stream: ^3.6.0
|
||||
checksum: 8c56cc62c68513425ddfa721954875b382768f83fa20e6b31e365ee00cbe7a3d6296f66f7f1107b16cd3416d33aa9f1680475376400d62a081a88f81f0ea7f9c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"readdirp@npm:~3.6.0":
|
||||
version: 3.6.0
|
||||
resolution: "readdirp@npm:3.6.0"
|
||||
@@ -23720,16 +23694,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"strtok3@npm:^7.0.0":
|
||||
version: 7.0.0
|
||||
resolution: "strtok3@npm:7.0.0"
|
||||
dependencies:
|
||||
"@tokenizer/token": ^0.3.0
|
||||
peek-readable: ^5.0.0
|
||||
checksum: 2ebe7ad8f2aea611dec6742cf6a42e82764892a362907f7ce493faf334501bf981ce21c828dcc300457e6d460dc9c34d644ededb3b01dcb9e37559203cf1748c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"style-loader@npm:^3.3.2":
|
||||
version: 3.3.3
|
||||
resolution: "style-loader@npm:3.3.3"
|
||||
@@ -24271,16 +24235,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"token-types@npm:^5.0.1":
|
||||
version: 5.0.1
|
||||
resolution: "token-types@npm:5.0.1"
|
||||
dependencies:
|
||||
"@tokenizer/token": ^0.3.0
|
||||
ieee754: ^1.2.1
|
||||
checksum: 32780123bc6ce8b6a2231d860445c994a02a720abf38df5583ea957aa6626873cd1c4dd8af62314da4cf16ede00c379a765707a3b06f04b8808c38efdae1c785
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"toml@npm:^3.0.0":
|
||||
version: 3.0.0
|
||||
resolution: "toml@npm:3.0.0"
|
||||
|
||||
Reference in New Issue
Block a user