refactor: use eslint-config-neon for packages. (#8579)

Co-authored-by: Noel <buechler.noel@outlook.com>
This commit is contained in:
Suneet Tipirneni
2022-09-01 14:50:16 -04:00
committed by GitHub
parent 4bdb0593ae
commit edadb9fe5d
219 changed files with 2608 additions and 2053 deletions

View File

@@ -1,11 +1,11 @@
import { test, expect } from 'vitest';
import { CDN } from '../src';
import { CDN } from '../src/index.js';
const base = 'https://discord.com';
const id = '123456';
const hash = 'abcdef';
const animatedHash = 'a_bcdef';
const defaultAvatar = 1234 % 5;
const defaultAvatar = 1_234 % 5;
const cdn = new CDN(base);

View File

@@ -1,5 +1,6 @@
import { URLSearchParams } from 'node:url';
import { test, expect } from 'vitest';
import { DiscordAPIError } from '../src';
import { DiscordAPIError } from '../src/index.js';
test('Unauthorized', () => {
const error = new DiscordAPIError(
@@ -27,13 +28,13 @@ test('Unauthorized', () => {
test('Invalid Form Body Error (error.{property}._errors.{index})', () => {
const error = new DiscordAPIError(
{
code: 50035,
code: 50_035,
errors: {
username: { _errors: [{ code: 'BASE_TYPE_BAD_LENGTH', message: 'Must be between 2 and 32 in length.' }] },
},
message: 'Invalid Form Body',
},
50035,
50_035,
400,
'PATCH',
'https://discord.com/api/v10/users/@me',
@@ -45,7 +46,7 @@ test('Invalid Form Body Error (error.{property}._errors.{index})', () => {
},
);
expect(error.code).toEqual(50035);
expect(error.code).toEqual(50_035);
expect(error.message).toEqual(
['Invalid Form Body', 'username[BASE_TYPE_BAD_LENGTH]: Must be between 2 and 32 in length.'].join('\n'),
);
@@ -60,7 +61,7 @@ test('Invalid Form Body Error (error.{property}._errors.{index})', () => {
test('Invalid FormFields Error (error.errors.{property}.{property}.{index}.{property}._errors.{index})', () => {
const error = new DiscordAPIError(
{
code: 50035,
code: 50_035,
errors: {
embed: {
fields: { '0': { value: { _errors: [{ code: 'BASE_TYPE_REQUIRED', message: 'This field is required' }] } } },
@@ -68,14 +69,14 @@ test('Invalid FormFields Error (error.errors.{property}.{property}.{index}.{prop
},
message: 'Invalid Form Body',
},
50035,
50_035,
400,
'POST',
'https://discord.com/api/v10/channels/:id',
{},
);
expect(error.code).toEqual(50035);
expect(error.code).toEqual(50_035);
expect(error.message).toEqual(
['Invalid Form Body', 'embed.fields[0].value[BASE_TYPE_REQUIRED]: This field is required'].join('\n'),
);
@@ -88,7 +89,7 @@ test('Invalid FormFields Error (error.errors.{property}.{property}.{index}.{prop
test('Invalid FormFields Error (error.errors.{property}.{property}._errors.{index}._errors)', () => {
const error = new DiscordAPIError(
{
code: 50035,
code: 50_035,
errors: {
form_fields: {
label: { _errors: [{ _errors: [{ code: 'BASE_TYPE_REQUIRED', message: 'This field is required' }] }] },
@@ -96,14 +97,14 @@ test('Invalid FormFields Error (error.errors.{property}.{property}._errors.{inde
},
message: 'Invalid Form Body',
},
50035,
50_035,
400,
'PATCH',
'https://discord.com/api/v10/guilds/:id',
{},
);
expect(error.code).toEqual(50035);
expect(error.code).toEqual(50_035);
expect(error.message).toEqual(
['Invalid Form Body', 'form_fields.label[0][BASE_TYPE_REQUIRED]: This field is required'].join('\n'),
);

View File

@@ -1,10 +1,14 @@
import { Buffer } from 'node:buffer';
import { URLSearchParams } from 'node:url';
import { DiscordSnowflake } from '@sapphire/snowflake';
import { Routes, Snowflake } from 'discord-api-types/v10';
import { File, FormData, MockAgent, setGlobalDispatcher } from 'undici';
import type { Snowflake } from 'discord-api-types/v10';
import { Routes } from 'discord-api-types/v10';
import type { FormData } from 'undici';
import { File, MockAgent, setGlobalDispatcher } from 'undici';
import type { Interceptable, MockInterceptor } from 'undici/types/mock-interceptor';
import { beforeEach, afterEach, test, expect } from 'vitest';
import { genPath } from './util';
import { REST } from '../src';
import { REST } from '../src/index.js';
import { genPath } from './util.js';
const newSnowflake: Snowflake = DiscordSnowflake.generate().toString();
@@ -142,7 +146,7 @@ test('getQuery', async () => {
expect(
await api.get('/getQuery', {
query: query,
query,
}),
).toStrictEqual({ test: true });
});
@@ -153,8 +157,8 @@ test('getAuth', async () => {
path: genPath('/getAuth'),
method: 'GET',
})
.reply((t) => ({
data: { auth: (t.headers as unknown as Record<string, string | undefined>)['Authorization'] ?? null },
.reply((from) => ({
data: { auth: (from.headers as unknown as Record<string, string | undefined>).Authorization ?? null },
statusCode: 200,
responseOptions,
}))
@@ -184,8 +188,8 @@ test('getReason', async () => {
path: genPath('/getReason'),
method: 'GET',
})
.reply((t) => ({
data: { reason: (t.headers as unknown as Record<string, string | undefined>)['X-Audit-Log-Reason'] ?? null },
.reply((from) => ({
data: { reason: (from.headers as unknown as Record<string, string | undefined>)['X-Audit-Log-Reason'] ?? null },
statusCode: 200,
responseOptions,
}))
@@ -215,8 +219,8 @@ test('urlEncoded', async () => {
path: genPath('/urlEncoded'),
method: 'POST',
})
.reply((t) => ({
data: t.body!,
.reply((from) => ({
data: from.body!,
statusCode: 200,
}));
@@ -245,8 +249,8 @@ test('postEcho', async () => {
path: genPath('/postEcho'),
method: 'POST',
})
.reply((t) => ({
data: t.body!,
.reply((from) => ({
data: from.body!,
statusCode: 200,
responseOptions,
}));
@@ -260,8 +264,8 @@ test('201 status code', async () => {
path: genPath('/postNon200StatusCode'),
method: 'POST',
})
.reply((t) => ({
data: t.body!,
.reply((from) => ({
data: from.body!,
statusCode: 201,
responseOptions,
}));

View File

@@ -1,15 +1,18 @@
/* eslint-disable id-length */
/* eslint-disable promise/prefer-await-to-then */
import { performance } from 'node:perf_hooks';
import { setInterval, clearInterval } from 'node:timers';
import { MockAgent, setGlobalDispatcher } from 'undici';
import type { Interceptable, MockInterceptor } from 'undici/types/mock-interceptor';
import { beforeEach, afterEach, test, expect, vitest } from 'vitest';
import { genPath } from './util';
import { DiscordAPIError, HTTPError, RateLimitError, REST, RESTEvents } from '../src';
import { DiscordAPIError, HTTPError, RateLimitError, REST, RESTEvents } from '../src/index.js';
import { genPath } from './util.js';
let mockAgent: MockAgent;
let mockPool: Interceptable;
const api = new REST({ timeout: 2000, offset: 5 }).setToken('A-Very-Fake-Token');
const invalidAuthApi = new REST({ timeout: 2000 }).setToken('Definitely-Not-A-Fake-Token');
const api = new REST({ timeout: 2_000, offset: 5 }).setToken('A-Very-Fake-Token');
const invalidAuthApi = new REST({ timeout: 2_000 }).setToken('Definitely-Not-A-Fake-Token');
const rateLimitErrorApi = new REST({ rejectOnRateLimit: ['/channels'] }).setToken('Obviously-Not-A-Fake-Token');
beforeEach(() => {
@@ -52,7 +55,7 @@ const sublimitIntervals: {
};
const sublimit = { body: { name: 'newname' } };
const noSublimit = { body: { bitrate: 40000 } };
const noSublimit = { body: { bitrate: 40_000 } };
function startSublimitIntervals() {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
@@ -63,13 +66,14 @@ function startSublimitIntervals() {
sublimitResetAfter = Date.now() + 250;
}, 250);
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!sublimitIntervals.retry) {
retryAfter = Date.now() + 1000;
retryAfter = Date.now() + 1_000;
sublimitIntervals.retry = setInterval(() => {
sublimitHits = 0;
retryAfter = Date.now() + 1000;
}, 1000);
retryAfter = Date.now() + 1_000;
}, 1_000);
}
}
@@ -80,7 +84,7 @@ test('Significant Invalid Requests', async () => {
path: genPath('/badRequest'),
method: 'GET',
})
.reply(403, { message: 'Missing Permissions', code: 50013 }, responseOptions)
.reply(403, { message: 'Missing Permissions', code: 50_013 }, responseOptions)
.times(10);
const invalidListener = vitest.fn();
@@ -89,6 +93,7 @@ test('Significant Invalid Requests', async () => {
// Ensure listeners on REST do not get double added
api.on(RESTEvents.InvalidRequestWarning, invalidListener2);
api.off(RESTEvents.InvalidRequestWarning, invalidListener2);
const [a, b, c, d, e] = [
api.get('/badRequest'),
api.get('/badRequest'),
@@ -102,7 +107,9 @@ test('Significant Invalid Requests', async () => {
await expect(d).rejects.toThrowError('Missing Permissions');
await expect(e).rejects.toThrowError('Missing Permissions');
expect(invalidListener).toHaveBeenCalledTimes(0);
// eslint-disable-next-line require-atomic-updates
api.requestManager.options.invalidRequestWarningInterval = 2;
const [f, g, h, i, j] = [
api.get('/badRequest'),
api.get('/badRequest'),
@@ -137,7 +144,7 @@ test('Handle standard rate limits', async () => {
headers: {
'x-ratelimit-limit': '1',
'x-ratelimit-remaining': '0',
'x-ratelimit-reset-after': ((resetAfter - Date.now()) / 1000).toString(),
'x-ratelimit-reset-after': ((resetAfter - Date.now()) / 1_000).toString(),
'x-ratelimit-bucket': '80c17d2f203122d936070c88c8d10f33',
via: '1.1 google',
},
@@ -150,7 +157,7 @@ test('Handle standard rate limits', async () => {
data: {
limit: '1',
remaining: '0',
resetAfter: (resetAfter / 1000).toString(),
resetAfter: (resetAfter / 1_000).toString(),
bucket: '80c17d2f203122d936070c88c8d10f33',
retryAfter: (resetAfter - Date.now()).toString(),
},
@@ -158,7 +165,7 @@ test('Handle standard rate limits', async () => {
headers: {
'x-ratelimit-limit': '1',
'x-ratelimit-remaining': '0',
'x-ratelimit-reset-after': ((resetAfter - Date.now()) / 1000).toString(),
'x-ratelimit-reset-after': ((resetAfter - Date.now()) / 1_000).toString(),
'x-ratelimit-bucket': '80c17d2f203122d936070c88c8d10f33',
'retry-after': (resetAfter - Date.now()).toString(),
via: '1.1 google',
@@ -187,8 +194,8 @@ test('Handle sublimits', async () => {
path: genPath('/channels/:id'),
method: 'PATCH',
})
.reply((t) => {
const body = JSON.parse(t.body as string) as Record<string, unknown>;
.reply((from) => {
const body = JSON.parse(from.body as string) as Record<string, unknown>;
if ('name' in body || 'topic' in body) {
sublimitHits += 1;
@@ -204,7 +211,7 @@ test('Handle sublimits', async () => {
headers: {
'x-ratelimit-limit': '10',
'x-ratelimit-remaining': `${10 - sublimitRequests}`,
'x-ratelimit-reset-after': ((sublimitResetAfter - Date.now()) / 1000).toString(),
'x-ratelimit-reset-after': ((sublimitResetAfter - Date.now()) / 1_000).toString(),
via: '1.1 google',
},
},
@@ -216,15 +223,15 @@ test('Handle sublimits', async () => {
data: {
limit: '10',
remaining: `${10 - sublimitRequests}`,
resetAfter: (sublimitResetAfter / 1000).toString(),
retryAfter: ((retryAfter - Date.now()) / 1000).toString(),
resetAfter: (sublimitResetAfter / 1_000).toString(),
retryAfter: ((retryAfter - Date.now()) / 1_000).toString(),
},
responseOptions: {
headers: {
'x-ratelimit-limit': '10',
'x-ratelimit-remaining': `${10 - sublimitRequests}`,
'x-ratelimit-reset-after': ((sublimitResetAfter - Date.now()) / 1000).toString(),
'retry-after': ((retryAfter - Date.now()) / 1000).toString(),
'x-ratelimit-reset-after': ((sublimitResetAfter - Date.now()) / 1_000).toString(),
'retry-after': ((retryAfter - Date.now()) / 1_000).toString(),
via: '1.1 google',
...responseOptions.headers,
},
@@ -243,7 +250,7 @@ test('Handle sublimits', async () => {
headers: {
'x-ratelimit-limit': '10',
'x-ratelimit-remaining': `${10 - sublimitRequests}`,
'x-ratelimit-reset-after': ((sublimitResetAfter - Date.now()) / 1000).toString(),
'x-ratelimit-reset-after': ((sublimitResetAfter - Date.now()) / 1_000).toString(),
via: '1.1 google',
},
},
@@ -255,15 +262,15 @@ test('Handle sublimits', async () => {
data: {
limit: '10',
remaining: `${10 - sublimitRequests}`,
resetAfter: (sublimitResetAfter / 1000).toString(),
retryAfter: ((sublimitResetAfter - Date.now()) / 1000).toString(),
resetAfter: (sublimitResetAfter / 1_000).toString(),
retryAfter: ((sublimitResetAfter - Date.now()) / 1_000).toString(),
},
responseOptions: {
headers: {
'x-ratelimit-limit': '10',
'x-ratelimit-remaining': `${10 - sublimitRequests}`,
'x-ratelimit-reset-after': ((sublimitResetAfter - Date.now()) / 1000).toString(),
'retry-after': ((sublimitResetAfter - Date.now()) / 1000).toString(),
'x-ratelimit-reset-after': ((sublimitResetAfter - Date.now()) / 1_000).toString(),
'retry-after': ((sublimitResetAfter - Date.now()) / 1_000).toString(),
via: '1.1 google',
...responseOptions.headers,
},
@@ -294,6 +301,7 @@ test('Handle sublimits', async () => {
api.patch('/channels/:id', sublimit).then(() => Date.now()),
api.patch('/channels/:id', noSublimit).then(() => Date.now()),
]); // For additional sublimited checks
const e = await eP;
expect(a).toBeLessThanOrEqual(b);
@@ -314,6 +322,7 @@ test('Handle sublimits', async () => {
rateLimitErrorApi.patch('/channels/:id', sublimit),
rateLimitErrorApi.patch('/channels/:id', sublimit),
];
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(aP2).resolves;
await expect(bP2).rejects.toThrowError();
await expect(bP2).rejects.toBeInstanceOf(RateLimitError);
@@ -364,8 +373,8 @@ test('Handle unexpected 429', async () => {
expect(await unexepectedSublimit).toStrictEqual({ test: true });
expect(await queuedSublimit).toStrictEqual({ test: true });
expect(performance.now()).toBeGreaterThanOrEqual(previous + 1000);
// @ts-expect-error
expect(performance.now()).toBeGreaterThanOrEqual(previous + 1_000);
// @ts-expect-error: This is intentional
expect(secondResolvedTime).toBeGreaterThan(firstResolvedTime);
});
@@ -400,7 +409,7 @@ test('Handle unexpected 429 cloudflare', async () => {
const previous = Date.now();
expect(await api.get('/unexpected-cf')).toStrictEqual({ test: true });
expect(Date.now()).toBeGreaterThanOrEqual(previous + 1000);
expect(Date.now()).toBeGreaterThanOrEqual(previous + 1_000);
});
test('Handle global rate limits', async () => {
@@ -486,7 +495,7 @@ test('server responding too slow', async () => {
const promise = api2.get('/slow');
await expect(promise).rejects.toThrowError('Request aborted');
}, 1000);
}, 1_000);
test('Unauthorized', async () => {
mockPool
@@ -518,7 +527,7 @@ test('Bad Request', async () => {
path: genPath('/badRequest'),
method: 'GET',
})
.reply(403, { message: 'Missing Permissions', code: 50013 }, responseOptions);
.reply(403, { message: 'Missing Permissions', code: 50_013 }, responseOptions);
const promise = api.get('/badRequest');
await expect(promise).rejects.toThrowError('Missing Permissions');

View File

@@ -1,8 +1,7 @@
import { MockAgent, setGlobalDispatcher } from 'undici';
import type { Interceptable } from 'undici/types/mock-interceptor';
import { MockAgent, setGlobalDispatcher, type Interceptable } from 'undici';
import { beforeEach, afterEach, test, expect } from 'vitest';
import { genPath } from './util';
import { REST } from '../src';
import { REST } from '../src/index.js';
import { genPath } from './util.js';
const api = new REST();
@@ -35,7 +34,7 @@ test('no token', async () => {
});
test('negative offset', () => {
const badREST = new REST({ offset: -5000 });
const badREST = new REST({ offset: -5_000 });
expect(badREST.requestManager.options.offset).toEqual(0);
});

View File

@@ -1,6 +1,7 @@
import { Blob } from 'node:buffer';
import { Blob, Buffer } from 'node:buffer';
import { URLSearchParams } from 'node:url';
import { test, expect } from 'vitest';
import { resolveBody, parseHeader } from '../src/lib/utils/utils';
import { resolveBody, parseHeader } from '../src/lib/utils/utils.js';
test('GIVEN string parseHeader returns string', () => {
const header = 'application/json';
@@ -37,7 +38,7 @@ test('resolveBody', async () => {
const iterable: Iterable<Uint8Array> = {
*[Symbol.iterator]() {
for (let i = 0; i < 3; i++) {
for (let index = 0; index < 3; index++) {
yield new Uint8Array([1, 2, 3]);
}
},
@@ -46,15 +47,15 @@ test('resolveBody', async () => {
const asyncIterable: AsyncIterable<Uint8Array> = {
[Symbol.asyncIterator]() {
let i = 0;
let index = 0;
return {
next() {
if (i < 3) {
i++;
return Promise.resolve({ value: new Uint8Array([1, 2, 3]), done: false });
async next() {
if (index < 3) {
index++;
return { value: new Uint8Array([1, 2, 3]), done: false };
}
return Promise.resolve({ value: undefined, done: true });
return { value: undefined, done: true };
},
};
},

View File

@@ -1,4 +1,4 @@
import { DefaultRestOptions } from '../src';
import { DefaultRestOptions } from '../src/index.js';
export function genPath(path: `/${string}`) {
return `/api/v${DefaultRestOptions.version}${path}` as const;

View File

@@ -1,5 +1,5 @@
import { describe, test, expect } from 'vitest';
import { makeURLSearchParams } from '../src';
import { makeURLSearchParams } from '../src/index.js';
describe('makeURLSearchParams', () => {
test('GIVEN undefined THEN returns empty URLSearchParams', () => {
@@ -41,7 +41,7 @@ describe('makeURLSearchParams', () => {
describe('objects', () => {
test('GIVEN a record of date values THEN URLSearchParams with ISO string values', () => {
const params = makeURLSearchParams({ before: new Date('2022-04-04T15:43:05.108Z'), after: new Date(NaN) });
const params = makeURLSearchParams({ before: new Date('2022-04-04T15:43:05.108Z'), after: new Date(Number.NaN) });
expect([...params.entries()]).toEqual([['before', '2022-04-04T15:43:05.108Z']]);
});

View File

@@ -65,16 +65,10 @@
"@favware/cliff-jumper": "^1.8.7",
"@microsoft/api-extractor": "^7.29.5",
"@types/node": "^16.11.56",
"@typescript-eslint/eslint-plugin": "^5.36.1",
"@typescript-eslint/parser": "^5.36.1",
"@vitest/coverage-c8": "^0.22.1",
"downlevel-dts": "^0.10.1",
"eslint": "^8.23.0",
"eslint-config-marine": "^9.4.1",
"eslint-config-prettier": "^8.5.0",
"eslint-import-resolver-typescript": "^3.5.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-tsdoc": "^0.2.16",
"eslint-config-neon": "^0.1.23",
"prettier": "^2.7.1",
"rollup-plugin-typescript2": "^0.33.0",
"typescript": "^4.8.2",

View File

@@ -1,8 +1,8 @@
export * from './lib/CDN';
export * from './lib/errors/DiscordAPIError';
export * from './lib/errors/HTTPError';
export * from './lib/errors/RateLimitError';
export * from './lib/RequestManager';
export * from './lib/REST';
export * from './lib/utils/constants';
export { makeURLSearchParams, parseResponse } from './lib/utils/utils';
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 { makeURLSearchParams, parseResponse } from './lib/utils/utils.js';

View File

@@ -1,12 +1,14 @@
/* eslint-disable jsdoc/check-param-names */
import { URL } from 'node:url';
import {
ALLOWED_EXTENSIONS,
ALLOWED_SIZES,
ALLOWED_STICKER_EXTENSIONS,
DefaultRestOptions,
ImageExtension,
ImageSize,
StickerExtension,
} from './utils/constants';
type ImageExtension,
type ImageSize,
type StickerExtension,
} from './utils/constants.js';
/**
* The options used for image URLs
@@ -38,6 +40,10 @@ export interface ImageURLOptions extends BaseImageURLOptions {
* The options to use when making a CDN URL
*/
export interface MakeURLOptions {
/**
* The allowed extensions that can be used
*/
allowedExtensions?: readonly string[];
/**
* The extension to use for the image URL
*
@@ -48,10 +54,6 @@ export interface MakeURLOptions {
* The size specified in the image URL
*/
size?: ImageSize;
/**
* The allowed extensions that can be used
*/
allowedExtensions?: ReadonlyArray<string>;
}
/**
@@ -192,6 +194,7 @@ export class CDN {
/**
* Generates a URL for the icon of a role
*
* @param roleId - The id of the role that has the icon
* @param roleIconHash - The hash provided by Discord for this role icon
* @param options - Optional options for the role icon
@@ -285,6 +288,7 @@ export class CDN {
route: string,
{ allowedExtensions = ALLOWED_EXTENSIONS, extension = 'webp', size }: Readonly<MakeURLOptions> = {},
): string {
// eslint-disable-next-line no-param-reassign
extension = String(extension).toLowerCase();
if (!allowedExtensions.includes(extension)) {

View File

@@ -1,19 +1,19 @@
import { EventEmitter } from 'node:events';
import type { Collection } from '@discordjs/collection';
import type { request, Dispatcher } from 'undici';
import { CDN } from './CDN';
import { CDN } from './CDN.js';
import {
HandlerRequestData,
InternalRequest,
RequestData,
RequestManager,
RequestMethod,
RouteLike,
} from './RequestManager';
import type { HashData } from './RequestManager';
type HashData,
type HandlerRequestData,
type InternalRequest,
type RequestData,
type RouteLike,
} from './RequestManager.js';
import type { IHandler } from './handlers/IHandler';
import { DefaultRestOptions, RESTEvents } from './utils/constants';
import { parseResponse } from './utils/utils';
import { DefaultRestOptions, RESTEvents } from './utils/constants.js';
import { parseResponse } from './utils/utils.js';
/**
* Options to be passed when creating the REST instance
@@ -25,6 +25,7 @@ export interface RESTOptions {
agent: Dispatcher;
/**
* The base api path, without version
*
* @defaultValue `'https://discord.com/api'`
*/
api: string;
@@ -34,13 +35,37 @@ export interface RESTOptions {
*
* @defaultValue `'Bot'`
*/
authPrefix: 'Bot' | 'Bearer';
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
*
@@ -54,12 +79,6 @@ export interface RESTOptions {
* @defaultValue `0`
*/
invalidRequestWarningInterval: number;
/**
* 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 extra offset to add to rate limits in milliseconds
*
@@ -74,7 +93,7 @@ export interface RESTOptions {
*
* @defaultValue `null`
*/
rejectOnRateLimit: string[] | RateLimitQueueFilter | null;
rejectOnRateLimit: RateLimitQueueFilter | string[] | null;
/**
* The number of retries for errors with the 500 code, or errors
* that timeout
@@ -100,24 +119,6 @@ export interface RESTOptions {
* @defaultValue `'10'`
*/
version: string;
/**
* The amount of time in milliseconds that passes between each hash sweep. (defaults to 4h)
*
* @defaultValue `14_400_000`
*/
hashSweepInterval: 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 1h)
*
* @defaultValue `3_600_000`
*/
handlerSweepInterval: number;
}
/**
@@ -125,29 +126,17 @@ export interface RESTOptions {
*/
export interface RateLimitData {
/**
* The time, in milliseconds, until the request-lock is reset
* Whether the rate limit that was reached was the global limit
*/
timeToReset: number;
/**
* The amount of requests we can perform before locking requests
*/
limit: number;
/**
* The HTTP method being performed
*/
method: string;
global: boolean;
/**
* The bucket hash for this request
*/
hash: string;
/**
* The full URL for this request
* The amount of requests we can perform before locking requests
*/
url: string;
/**
* The route being hit in this request
*/
route: string;
limit: number;
/**
* The major parameter of the route
*
@@ -156,41 +145,53 @@ export interface RateLimitData {
*/
majorParameter: string;
/**
* Whether the rate limit that was reached was the global limit
* The HTTP method being performed
*/
global: boolean;
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) => boolean | Promise<boolean>;
export type RateLimitQueueFilter = (rateLimitData: RateLimitData) => Promise<boolean> | boolean;
export interface APIRequest {
/**
* The HTTP method used in this request
*/
method: string;
/**
* The full path used to make the request
*/
path: RouteLike;
/**
* The API route identifying the ratelimit for this request
*/
route: string;
/**
* Additional HTTP options for this request
*/
options: RequestOptions;
/**
* 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: RequestOptions;
/**
* 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 InvalidRequestWarningData {
@@ -205,29 +206,29 @@ export interface InvalidRequestWarningData {
}
export interface RestEvents {
invalidRequestWarning: [invalidRequestInfo: InvalidRequestWarningData];
restDebug: [info: string];
rateLimited: [rateLimitInfo: RateLimitData];
response: [request: APIRequest, response: Dispatcher.ResponseData];
newListener: [name: string, listener: (...args: any) => void];
removeListener: [name: string, listener: (...args: any) => void];
hashSweep: [sweptHashes: Collection<string, HashData>];
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: Dispatcher.ResponseData];
restDebug: [info: string];
}
export interface REST {
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);
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);
}
@@ -236,6 +237,7 @@ export type RequestOptions = Exclude<Parameters<typeof request>[1], undefined>;
export class REST extends EventEmitter {
public readonly cdn: CDN;
public readonly requestManager: RequestManager;
public constructor(options: Partial<RESTOptions> = {}) {
@@ -288,7 +290,7 @@ export class REST extends EventEmitter {
* @param fullRoute - The full route to query
* @param options - Optional request options
*/
public get(fullRoute: RouteLike, options: RequestData = {}) {
public async get(fullRoute: RouteLike, options: RequestData = {}) {
return this.request({ ...options, fullRoute, method: RequestMethod.Get });
}
@@ -298,7 +300,7 @@ export class REST extends EventEmitter {
* @param fullRoute - The full route to query
* @param options - Optional request options
*/
public delete(fullRoute: RouteLike, options: RequestData = {}) {
public async delete(fullRoute: RouteLike, options: RequestData = {}) {
return this.request({ ...options, fullRoute, method: RequestMethod.Delete });
}
@@ -308,7 +310,7 @@ export class REST extends EventEmitter {
* @param fullRoute - The full route to query
* @param options - Optional request options
*/
public post(fullRoute: RouteLike, options: RequestData = {}) {
public async post(fullRoute: RouteLike, options: RequestData = {}) {
return this.request({ ...options, fullRoute, method: RequestMethod.Post });
}
@@ -318,7 +320,7 @@ export class REST extends EventEmitter {
* @param fullRoute - The full route to query
* @param options - Optional request options
*/
public put(fullRoute: RouteLike, options: RequestData = {}) {
public async put(fullRoute: RouteLike, options: RequestData = {}) {
return this.request({ ...options, fullRoute, method: RequestMethod.Put });
}
@@ -328,7 +330,7 @@ export class REST extends EventEmitter {
* @param fullRoute - The full route to query
* @param options - Optional request options
*/
public patch(fullRoute: RouteLike, options: RequestData = {}) {
public async patch(fullRoute: RouteLike, options: RequestData = {}) {
return this.request({ ...options, fullRoute, method: RequestMethod.Patch });
}
@@ -347,7 +349,7 @@ export class REST extends EventEmitter {
*
* @param options - Request options
*/
public raw(options: InternalRequest) {
public async raw(options: InternalRequest) {
return this.requestManager.queueRequest(options);
}
}

View File

@@ -1,16 +1,20 @@
import { Blob } from 'node:buffer';
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 { DiscordSnowflake } from '@sapphire/snowflake';
import { FormData, type RequestInit, type BodyInit, type Dispatcher, Agent } from 'undici';
import { FormData, type RequestInit, type BodyInit, type Dispatcher, type Agent } from 'undici';
import type { RESTOptions, RestEvents, RequestOptions } from './REST';
import type { IHandler } from './handlers/IHandler';
import { SequentialHandler } from './handlers/SequentialHandler';
import { DefaultRestOptions, DefaultUserAgent, RESTEvents } from './utils/constants';
import { resolveBody } from './utils/utils';
import { SequentialHandler } from './handlers/SequentialHandler.js';
import { DefaultRestOptions, DefaultUserAgent, RESTEvents } from './utils/constants.js';
import { resolveBody } from './utils/utils.js';
// Make this a lazy dynamic import as file-type is a pure ESM package
const getFileType = (): Promise<typeof import('file-type')> => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const getFileType = async (): Promise<typeof import('file-type')> => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
let cached: Promise<typeof import('file-type')>;
return (cached ??= import('file-type'));
};
@@ -20,9 +24,13 @@ const getFileType = (): Promise<typeof import('file-type')> => {
*/
export interface RawFile {
/**
* The name of the file
* Content-Type of the file
*/
name: string;
contentType?: string;
/**
* The actual data for the file
*/
data: Buffer | 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}]`.
@@ -30,13 +38,9 @@ export interface RawFile {
*/
key?: string;
/**
* The actual data for the file
* The name of the file
*/
data: string | number | boolean | Buffer;
/**
* Content-Type of the file
*/
contentType?: string;
name: string;
}
/**
@@ -58,7 +62,7 @@ export interface RequestData {
*
* @defaultValue `'Bot'`
*/
authPrefix?: 'Bot' | 'Bearer';
authPrefix?: 'Bearer' | 'Bot';
/**
* The body to send to this request.
* If providing as BodyInit, set `passThroughBody: true`
@@ -125,11 +129,11 @@ export type RouteLike = `/${string}`;
* @internal
*/
export interface InternalRequest extends RequestData {
method: RequestMethod;
fullRoute: RouteLike;
method: RequestMethod;
}
export type HandlerRequestData = Pick<InternalRequest, 'files' | 'body' | 'auth'>;
export type HandlerRequestData = Pick<InternalRequest, 'auth' | 'body' | 'files'>;
/**
* Parsed route data for an endpoint
@@ -137,8 +141,8 @@ export type HandlerRequestData = Pick<InternalRequest, 'files' | 'body' | 'auth'
* @internal
*/
export interface RouteData {
majorParameter: string;
bucketRoute: string;
majorParameter: string;
original: RouteLike;
}
@@ -148,23 +152,23 @@ export interface RouteData {
* @internal
*/
export interface HashData {
value: string;
lastAccess: number;
value: string;
}
export interface RequestManager {
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);
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);
}
@@ -178,6 +182,7 @@ export class RequestManager extends EventEmitter {
* performed by this manager.
*/
public agent: Dispatcher | null = null;
/**
* The number of requests remaining in the global bucket
*/
@@ -207,6 +212,7 @@ export class RequestManager extends EventEmitter {
#token: string | null = null;
private hashTimer!: NodeJS.Timer;
private handlerTimer!: NodeJS.Timer;
public readonly options: RESTOptions;
@@ -223,34 +229,35 @@ export class RequestManager extends EventEmitter {
}
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 !== Infinity) {
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((v, k) => {
this.hashes.sweep((val, key) => {
// `-1` indicates a global hash
if (v.lastAccess === -1) return false;
if (val.lastAccess === -1) return false;
// Check if lifetime has been exceeded
const shouldSweep = Math.floor(currentDate - v.lastAccess) > this.options.hashLifetime;
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(k, v);
sweptHashes.set(key, val);
}
// Emit debug information
this.emit(RESTEvents.Debug, `Hash ${v.value} for ${k} swept due to lifetime being exceeded`);
this.emit(RESTEvents.Debug, `Hash ${val.value} for ${key} swept due to lifetime being exceeded`);
return shouldSweep;
});
@@ -260,21 +267,21 @@ export class RequestManager extends EventEmitter {
}, this.options.hashSweepInterval).unref();
}
if (this.options.handlerSweepInterval !== 0 && this.options.handlerSweepInterval !== Infinity) {
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((v, k) => {
const { inactive } = v;
this.handlers.sweep((val, key) => {
const { inactive } = val;
// Collect inactive handlers
if (inactive) {
sweptHandlers.set(k, v);
sweptHandlers.set(key, val);
}
this.emit(RESTEvents.Debug, `Handler ${v.id} for ${k} swept due to being inactive`);
this.emit(RESTEvents.Debug, `Handler ${val.id} for ${key} swept due to being inactive`);
return inactive;
});
@@ -308,7 +315,6 @@ export class RequestManager extends EventEmitter {
* 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<Dispatcher.ResponseData> {
@@ -341,7 +347,6 @@ export class RequestManager extends EventEmitter {
*
* @param hash - The hash for the route
* @param majorParameter - The major parameter for this handler
*
* @internal
*/
private createHandler(hash: string, majorParameter: string) {
@@ -358,7 +363,7 @@ export class RequestManager extends EventEmitter {
*
* @param request - The request data
*/
private async resolveRequest(request: InternalRequest): Promise<{ url: string; fetchOptions: RequestOptions }> {
private async resolveRequest(request: InternalRequest): Promise<{ fetchOptions: RequestOptions; url: string }> {
const { options } = this;
let query = '';
@@ -423,7 +428,7 @@ export class RequestManager extends EventEmitter {
}
// 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
// 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>)) {
@@ -437,7 +442,7 @@ export class RequestManager extends EventEmitter {
// Set the final body to the form data
finalBody = formData;
// eslint-disable-next-line no-eq-null
// eslint-disable-next-line no-eq-null, eqeqeq
} else if (request.body != null) {
if (request.passThroughBody) {
finalBody = request.body as BodyInit;
@@ -453,7 +458,7 @@ export class RequestManager extends EventEmitter {
const fetchOptions: RequestOptions = {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
headers: { ...(request.headers ?? {}), ...additionalHeaders, ...headers } as Record<string, string>,
headers: { ...request.headers, ...additionalHeaders, ...headers } as Record<string, string>,
method: request.method.toUpperCase() as Dispatcher.HttpMethod,
};
@@ -486,7 +491,6 @@ export class RequestManager extends EventEmitter {
*
* @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 {
@@ -508,7 +512,7 @@ export class RequestManager extends EventEmitter {
if (method === RequestMethod.Delete && baseRoute === '/channels/:id/messages/:id') {
const id = /\d{16,19}$/.exec(endpoint)![0]!;
const timestamp = DiscordSnowflake.timestampFrom(id);
if (Date.now() - timestamp > 1000 * 60 * 60 * 24 * 14) {
if (Date.now() - timestamp > 1_000 * 60 * 60 * 24 * 14) {
exceptions += '/Delete Old Message';
}
}

View File

@@ -1,4 +1,4 @@
import type { InternalRequest, RawFile } from '../RequestManager';
import type { InternalRequest, RawFile } from '../RequestManager.js';
interface DiscordErrorFieldInformation {
code: string;
@@ -9,12 +9,12 @@ interface DiscordErrorGroupWrapper {
_errors: DiscordError[];
}
type DiscordError = DiscordErrorGroupWrapper | DiscordErrorFieldInformation | { [k: string]: DiscordError } | string;
type DiscordError = DiscordErrorFieldInformation | DiscordErrorGroupWrapper | string | { [k: string]: DiscordError };
export interface DiscordErrorData {
code: number;
message: string;
errors?: DiscordError;
message: string;
}
export interface OAuthErrorData {
@@ -37,7 +37,6 @@ function isErrorResponse(error: DiscordError): error is DiscordErrorFieldInforma
/**
* Represents an API error returned by Discord
* @extends Error
*/
export class DiscordAPIError extends Error {
public requestBody: RequestBody;
@@ -56,7 +55,7 @@ export class DiscordAPIError extends Error {
public status: number,
public method: string,
public url: string,
bodyData: Pick<InternalRequest, 'files' | 'body'>,
bodyData: Pick<InternalRequest, 'body' | 'files'>,
) {
super(DiscordAPIError.getMessage(rawError));
@@ -76,31 +75,40 @@ export class DiscordAPIError extends Error {
if (error.errors) {
flattened = [...this.flattenDiscordError(error.errors)].join('\n');
}
return error.message && flattened
? `${error.message}\n${flattened}`
: error.message || flattened || 'Unknown Error';
}
return error.error_description ?? 'No Description';
}
// eslint-disable-next-line consistent-return
private static *flattenDiscordError(obj: DiscordError, key = ''): IterableIterator<string> {
if (isErrorResponse(obj)) {
return yield `${key.length ? `${key}[${obj.code}]` : `${obj.code}`}: ${obj.message}`.trim();
}
for (const [k, v] of Object.entries(obj)) {
const nextKey = k.startsWith('_') ? key : key ? (Number.isNaN(Number(k)) ? `${key}.${k}` : `${key}[${k}]`) : k;
for (const [otherKey, val] of Object.entries(obj)) {
const nextKey = otherKey.startsWith('_')
? key
: key
? Number.isNaN(Number(otherKey))
? `${key}.${otherKey}`
: `${key}[${otherKey}]`
: otherKey;
if (typeof v === 'string') {
yield v;
if (typeof val === 'string') {
yield val;
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
} else if (isErrorGroupWrapper(v)) {
for (const error of v._errors) {
} else if (isErrorGroupWrapper(val)) {
for (const error of val._errors) {
yield* this.flattenDiscordError(error, nextKey);
}
} else {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
yield* this.flattenDiscordError(v, nextKey);
yield* this.flattenDiscordError(val, nextKey);
}
}
}

View File

@@ -1,5 +1,5 @@
import type { RequestBody } from './DiscordAPIError';
import type { InternalRequest } from '../RequestManager';
import type { InternalRequest } from '../RequestManager.js';
import type { RequestBody } from './DiscordAPIError.js';
/**
* Represents a HTTP error
@@ -19,7 +19,7 @@ export class HTTPError extends Error {
public status: number,
public method: string,
public url: string,
bodyData: Pick<InternalRequest, 'files' | 'body'>,
bodyData: Pick<InternalRequest, 'body' | 'files'>,
) {
super();

View File

@@ -2,13 +2,21 @@ import type { RateLimitData } from '../REST';
export class RateLimitError extends Error implements RateLimitData {
public timeToReset: number;
public limit: number;
public method: string;
public hash: string;
public url: string;
public route: string;
public majorParameter: string;
public global: boolean;
public constructor({ timeToReset, limit, method, hash, url, route, majorParameter, global }: RateLimitData) {
super();
this.timeToReset = timeToReset;

View File

@@ -1,8 +1,17 @@
import type { Dispatcher } from 'undici';
import type { RequestOptions } from '../REST';
import type { HandlerRequestData, RouteData } from '../RequestManager';
import type { HandlerRequestData, RouteData } from '../RequestManager.js';
export interface IHandler {
/**
* The unique id of the handler
*/
readonly id: string;
/**
* If the bucket is currently inactive (no pending requests)
*/
// eslint-disable-next-line @typescript-eslint/method-signature-style -- This is meant to be a getter returning a bool
get inactive(): boolean;
/**
* Queues a request to be sent
*
@@ -11,19 +20,10 @@ export interface IHandler {
* @param options - All the information needed to make a request
* @param requestData - Extra data from the user's request needed for errors and additional processing
*/
queueRequest: (
queueRequest(
routeId: RouteData,
url: string,
options: RequestOptions,
requestData: HandlerRequestData,
) => Promise<Dispatcher.ResponseData>;
/**
* If the bucket is currently inactive (no pending requests)
*/
// eslint-disable-next-line @typescript-eslint/method-signature-style -- This is meant to be a getter returning a bool
get inactive(): boolean;
/**
* The unique id of the handler
*/
readonly id: string;
): Promise<Dispatcher.ResponseData>;
}

View File

@@ -1,14 +1,15 @@
import { setTimeout, clearTimeout } from 'node:timers';
import { setTimeout as sleep } from 'node:timers/promises';
import { AsyncQueue } from '@sapphire/async-queue';
import { request, type Dispatcher } from 'undici';
import type { IHandler } from './IHandler';
import type { RateLimitData, RequestOptions } from '../REST';
import type { HandlerRequestData, RequestManager, RouteData } from '../RequestManager';
import { DiscordAPIError, DiscordErrorData, OAuthErrorData } from '../errors/DiscordAPIError';
import { HTTPError } from '../errors/HTTPError';
import { RateLimitError } from '../errors/RateLimitError';
import { RESTEvents } from '../utils/constants';
import { hasSublimit, parseHeader, parseResponse } from '../utils/utils';
import { DiscordAPIError, type DiscordErrorData, type OAuthErrorData } from '../errors/DiscordAPIError.js';
import { HTTPError } from '../errors/HTTPError.js';
import { RateLimitError } from '../errors/RateLimitError.js';
import { RESTEvents } from '../utils/constants.js';
import { hasSublimit, parseHeader, parseResponse } from '../utils/utils.js';
import type { IHandler } from './IHandler.js';
/**
* Invalid request limiting is done on a per-IP basis, not a per-token basis.
@@ -47,7 +48,7 @@ export class SequentialHandler implements IHandler {
/**
* The total number of requests that can be made before we are rate limited
*/
private limit = Infinity;
private limit = Number.POSITIVE_INFINITY;
/**
* The interface used to sequence async requests sequentially
@@ -65,7 +66,7 @@ export class SequentialHandler implements IHandler {
* A promise wrapper for when the sublimited queue is finished being processed or null when not being processed
*/
// eslint-disable-next-line @typescript-eslint/explicit-member-accessibility
#sublimitPromise: { promise: Promise<void>; resolve: () => void } | null = null;
#sublimitPromise: { promise: Promise<void>; resolve(): void } | null = null;
/**
* Whether the sublimit queue needs to be shifted in the finally block
@@ -176,6 +177,7 @@ export class SequentialHandler implements IHandler {
queue = this.#sublimitedQueue!;
queueType = QueueType.Sublimit;
}
// Wait for any previous requests to be completed before this one is run
await queue.wait();
// This set handles retroactively sublimiting requests
@@ -194,6 +196,7 @@ export class SequentialHandler implements IHandler {
await this.#sublimitPromise.promise;
}
}
try {
// Make the request, and return the results
return await this.runRequest(routeId, url, options, requestData);
@@ -204,6 +207,7 @@ export class SequentialHandler implements IHandler {
this.#shiftSublimit = false;
this.#sublimitedQueue?.shift();
}
// If this request is the last request in a sublimit
if (this.#sublimitedQueue?.remaining === 0) {
this.#sublimitPromise?.resolve();
@@ -247,6 +251,7 @@ export class SequentialHandler implements IHandler {
// The global delay function clears the global delay state when it is resolved
this.manager.globalDelay = this.globalDelayFor(timeout);
}
delay = this.manager.globalDelay;
} else {
// Set RateLimitData based on the route-specific limit
@@ -254,6 +259,7 @@ export class SequentialHandler implements IHandler {
timeout = this.timeToReset;
delay = sleep(timeout);
}
const rateLimitData: RateLimitData = {
timeToReset: timeout,
limit,
@@ -274,14 +280,17 @@ export class SequentialHandler implements IHandler {
} else {
this.debug(`Waiting ${timeout}ms for rate limit to pass`);
}
// Wait the remaining time left before the rate limit resets
await delay;
}
// As the request goes out, update the global usage information
if (!this.manager.globalReset || this.manager.globalReset < Date.now()) {
this.manager.globalReset = Date.now() + 1000;
this.manager.globalReset = Date.now() + 1_000;
this.manager.globalRemaining = this.manager.options.globalRequestsPerSecond;
}
this.manager.globalRemaining--;
const method = options.method ?? 'get';
@@ -295,6 +304,7 @@ export class SequentialHandler implements IHandler {
} catch (error: unknown) {
// Retry the specified number of times for possible timed out requests
if (error instanceof Error && error.name === 'AbortError' && retries !== this.manager.options.retries) {
// eslint-disable-next-line no-param-reassign
return await this.runRequest(routeId, url, options, requestData, ++retries);
}
@@ -328,14 +338,14 @@ export class SequentialHandler implements IHandler {
const retry = parseHeader(res.headers['retry-after']);
// Update the total number of requests that can be made before the rate limit resets
this.limit = limit ? Number(limit) : Infinity;
this.limit = limit ? Number(limit) : Number.POSITIVE_INFINITY;
// Update the number of remaining requests that can be made before the rate limit resets
this.remaining = remaining ? Number(remaining) : 1;
// Update the time when this rate limit resets (reset-after is in seconds)
this.reset = reset ? Number(reset) * 1000 + Date.now() + this.manager.options.offset : Date.now();
this.reset = reset ? Number(reset) * 1_000 + Date.now() + this.manager.options.offset : Date.now();
// Amount of time in milliseconds until we should retry if rate limited (globally or otherwise)
if (retry) retryAfter = Number(retry) * 1000 + this.manager.options.offset;
if (retry) retryAfter = Number(retry) * 1_000 + this.manager.options.offset;
// Handle buckets via the hash header retroactively
if (hash && hash !== this.hash) {
@@ -373,9 +383,10 @@ export class SequentialHandler implements IHandler {
// Count the invalid requests
if (status === 401 || status === 403 || status === 429) {
if (!invalidCountResetTime || invalidCountResetTime < Date.now()) {
invalidCountResetTime = Date.now() + 1000 * 60 * 10;
invalidCountResetTime = Date.now() + 1_000 * 60 * 10;
invalidCount = 0;
}
invalidCount++;
const emitInvalid =
@@ -407,6 +418,7 @@ export class SequentialHandler implements IHandler {
limit = this.limit;
timeout = this.timeToReset;
}
await this.onRateLimit({
timeToReset: timeout,
limit,
@@ -440,10 +452,12 @@ export class SequentialHandler implements IHandler {
void this.#sublimitedQueue.wait();
this.#asyncQueue.shift();
}
this.#sublimitPromise?.resolve();
this.#sublimitPromise = null;
await sleep(sublimitTimeout, undefined, { ref: false });
let resolve: () => void;
// eslint-disable-next-line promise/param-names, no-promise-executor-return
const promise = new Promise<void>((res) => (resolve = res));
this.#sublimitPromise = { promise, resolve: resolve! };
if (firstSublimit) {
@@ -452,13 +466,16 @@ export class SequentialHandler implements IHandler {
this.#shiftSublimit = true;
}
}
// Since this is not a server side issue, the next request should pass, so we don't bump the retries counter
return this.runRequest(routeId, url, options, requestData, retries);
} else if (status >= 500 && status < 600) {
// Retry the specified number of times for possible server side issues
if (retries !== this.manager.options.retries) {
// eslint-disable-next-line no-param-reassign
return this.runRequest(routeId, url, options, requestData, ++retries);
}
// We are out of retries, throw an error
throw new HTTPError(res.constructor.name, status, method, url, requestData);
} else {
@@ -468,11 +485,13 @@ export class SequentialHandler implements IHandler {
if (status === 401 && requestData.auth) {
this.manager.setToken(null!);
}
// The request will not succeed for some reason, parse the error returned from the api
const data = (await parseResponse(res)) as DiscordErrorData | OAuthErrorData;
// throw the API error
throw new DiscordAPIError(data, 'code' in data ? data.code : data.error, status, method, url, requestData);
}
return res;
}
}

View File

@@ -1,6 +1,7 @@
import process from 'node:process';
import { APIVersion } from 'discord-api-types/v10';
import { getGlobalDispatcher } from 'undici';
import type { RESTOptions } from '../REST';
import type { RESTOptions } from '../REST.js';
// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
const Package = require('../../../package.json');
@@ -33,16 +34,16 @@ export const DefaultRestOptions: Required<RESTOptions> = {
*/
export const enum RESTEvents {
Debug = 'restDebug',
HandlerSweep = 'handlerSweep',
HashSweep = 'hashSweep',
InvalidRequestWarning = 'invalidRequestWarning',
RateLimited = 'rateLimited',
Response = 'response',
HashSweep = 'hashSweep',
HandlerSweep = 'handlerSweep',
}
export const ALLOWED_EXTENSIONS = ['webp', 'png', 'jpg', 'jpeg', 'gif'] as const;
export const ALLOWED_STICKER_EXTENSIONS = ['png', 'json'] as const;
export const ALLOWED_SIZES = [16, 32, 64, 128, 256, 512, 1024, 2048, 4096] as const;
export const ALLOWED_SIZES = [16, 32, 64, 128, 256, 512, 1_024, 2_048, 4_096] as const;
export type ImageExtension = typeof ALLOWED_EXTENSIONS[number];
export type StickerExtension = typeof ALLOWED_STICKER_EXTENSIONS[number];

View File

@@ -1,12 +1,12 @@
import { Blob } from 'node:buffer';
import { Blob, Buffer } from 'node:buffer';
import { URLSearchParams } from 'node:url';
import { types } from 'node:util';
import type { RESTPatchAPIChannelJSONBody } from 'discord-api-types/v10';
import { FormData, type Dispatcher, type RequestInit } from 'undici';
import type { RequestOptions } from '../REST';
import { RequestMethod } from '../RequestManager';
import type { RequestOptions } from '../REST.js';
import { RequestMethod } from '../RequestManager.js';
export function parseHeader(header: string | string[] | undefined): string | undefined {
export function parseHeader(header: string[] | string | undefined): string | undefined {
if (header === undefined) {
return header;
} else if (typeof header === 'string') {
@@ -29,6 +29,7 @@ function serializeSearchParam(value: unknown): string | null {
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value.toISOString();
}
// eslint-disable-next-line @typescript-eslint/no-base-to-string
if (typeof value.toString === 'function' && value.toString !== Object.prototype.toString) return value.toString();
return null;
@@ -42,7 +43,6 @@ function serializeSearchParam(value: unknown): string | null {
* out null and undefined values, while also coercing non-strings to strings.
*
* @param options - The options to use
*
* @returns A populated URLSearchParams instance
*/
export function makeURLSearchParams(options?: Record<string, unknown>) {
@@ -62,7 +62,7 @@ export function makeURLSearchParams(options?: Record<string, unknown>) {
*
* @param res - The fetch response
*/
export function parseResponse(res: Dispatcher.ResponseData): Promise<unknown> {
export async function parseResponse(res: Dispatcher.ResponseData): Promise<unknown> {
const header = parseHeader(res.headers['content-type']);
if (header?.startsWith('application/json')) {
return res.body.json();
@@ -77,7 +77,6 @@ export function parseResponse(res: Dispatcher.ResponseData): Promise<unknown> {
* @param bucketRoute - The buckets route identifier
* @param body - The options provided as JSON data
* @param method - The HTTP method that will be used to make the request
*
* @returns Whether the request falls under a sublimit
*/
export function hasSublimit(bucketRoute: string, body?: unknown, method?: string): boolean {
@@ -97,7 +96,7 @@ export function hasSublimit(bucketRoute: string, body?: unknown, method?: string
}
export async function resolveBody(body: RequestInit['body']): Promise<RequestOptions['body']> {
// eslint-disable-next-line no-eq-null
// eslint-disable-next-line no-eq-null, eqeqeq
if (body == null) {
return null;
} else if (typeof body === 'string') {