chore: monorepo setup (#7175)

This commit is contained in:
Noel
2022-01-07 17:18:25 +01:00
committed by GitHub
parent 780b7ed39f
commit 16390efe6e
504 changed files with 25459 additions and 22830 deletions

View File

@@ -0,0 +1,15 @@
{
"root": true,
"extends": "marine/prettier/node",
"parserOptions": {
"project": "./tsconfig.eslint.json"
},
"ignorePatterns": ["**/dist/*"],
"env": {
"jest": true
},
"rules": {
"no-redeclare": 0,
"@typescript-eslint/naming-convention": 0
}
}

30
packages/rest/.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# Packages
node_modules/
# Log files
logs/
*.log
npm-debug.log*
# Runtime data
pids
*.pid
*.seed
# Env
.env
# Dist
dist/
typings/
docs/
# Miscellaneous
.tmp/
coverage/
tsconfig.tsbuildinfo
.turbo
# Yarn files
.yarn/install-state.gz
.yarn/build-state.yml

View File

@@ -0,0 +1,8 @@
{
"printWidth": 120,
"useTabs": true,
"singleQuote": true,
"quoteProps": "as-needed",
"trailingComma": "all",
"endOfLine": "lf"
}

View File

@@ -0,0 +1,54 @@
# Changelog
All notable changes to this project will be documented in this file.
# [0.2.0-canary.0](https://github.com/discordjs/discord.js-modules/compare/@discordjs/rest@0.1.1-canary.0...@discordjs/rest@0.2.0-canary.0) (2021-12-08)
### Bug Fixes
* **CDN#icon:** remove `guild` prefixes ([#67](https://github.com/discordjs/discord.js-modules/issues/67)) ([8882686](https://github.com/discordjs/discord.js-modules/commit/88826869d8ed3695f2b9475bea8d3b851df270bd))
* **Cdn:** make parameters immutable ([#84](https://github.com/discordjs/discord.js-modules/issues/84)) ([3105b61](https://github.com/discordjs/discord.js-modules/commit/3105b614da603dd3c8479dea089b5953d3c8b89b))
* **CDN:** use correct types ([#86](https://github.com/discordjs/discord.js-modules/issues/86)) ([64b02d4](https://github.com/discordjs/discord.js-modules/commit/64b02d4649a38802dd1a4e7a738ec64c27dea760))
* **Rest:** lint errors ([53c0cce](https://github.com/discordjs/discord.js-modules/commit/53c0ccefee80225ca7640cf88f44c68da99f31e7))
* use hash instead of animatedHash for default avatar test ([#74](https://github.com/discordjs/discord.js-modules/issues/74)) ([4852838](https://github.com/discordjs/discord.js-modules/commit/485283824cf368874096d59a64131970401218e9))
### Features
* **CDN#guildIcon:** implement dynamic logic ([#53](https://github.com/discordjs/discord.js-modules/issues/53)) ([c4b2803](https://github.com/discordjs/discord.js-modules/commit/c4b280366b0c5920c147126ccb9068f16fc898aa))
* **CDN:** add role icon endpoint ([#64](https://github.com/discordjs/discord.js-modules/issues/64)) ([4d7d692](https://github.com/discordjs/discord.js-modules/commit/4d7d692b4954c373941d2d8f3e3335a9a8543220))
* **CDN:** add sticker endpoints ([#60](https://github.com/discordjs/discord.js-modules/issues/60)) ([3b714ba](https://github.com/discordjs/discord.js-modules/commit/3b714bada415a7987dd6aa50c938751c66dc05be))
* **CDN:** guild member avatars ([#68](https://github.com/discordjs/discord.js-modules/issues/68)) ([90c25ad](https://github.com/discordjs/discord.js-modules/commit/90c25ad4afa5ec5906867f431afcaf11fb56355a))
* **Errors:** show data sent when an error occurs ([#72](https://github.com/discordjs/discord.js-modules/issues/72)) ([3e2edc8](https://github.com/discordjs/discord.js-modules/commit/3e2edc8974e2c62c324db0c151da4d34c289c40a))
* expose https agent options ([#82](https://github.com/discordjs/discord.js-modules/issues/82)) ([7f1c9be](https://github.com/discordjs/discord.js-modules/commit/7f1c9be817bbc6a4a11a726c952580dd3cb7b149))
* **RateLimits:** optionally error on ratelimits ([#77](https://github.com/discordjs/discord.js-modules/issues/77)) ([a371f0b](https://github.com/discordjs/discord.js-modules/commit/a371f0bc6c76cffaf048fd0fbf9c64a6c4d6619e))
* **RequestManager:** support setting global headers in options ([#70](https://github.com/discordjs/discord.js-modules/issues/70)) ([d1758c7](https://github.com/discordjs/discord.js-modules/commit/d1758c74b00a3f83c39745cd9af147a7f8f2b12b))
* **Requests:** add attachment keys and form data for stickers ([#81](https://github.com/discordjs/discord.js-modules/issues/81)) ([7c2b0c0](https://github.com/discordjs/discord.js-modules/commit/7c2b0c0e432b82776bb57c1708f3be6b4affde56))
* **Rest:** add response and request events ([#85](https://github.com/discordjs/discord.js-modules/issues/85)) ([c3aba56](https://github.com/discordjs/discord.js-modules/commit/c3aba567572e73548c38cd7c7f9945e9361833de))
* **REST:** change api version to v9 ([#62](https://github.com/discordjs/discord.js-modules/issues/62)) ([4c980e6](https://github.com/discordjs/discord.js-modules/commit/4c980e6ad6c0297519ec0f09ec27953764a4a12d))
* **Rest:** improve global rate limit and invalid request tracking ([#51](https://github.com/discordjs/discord.js-modules/issues/51)) ([b73cc06](https://github.com/discordjs/discord.js-modules/commit/b73cc060daa701de71815a824ebaccdc9ebf2859))
* **Rest:** use native Node.js AbortController ([#66](https://github.com/discordjs/discord.js-modules/issues/66)) ([3b53910](https://github.com/discordjs/discord.js-modules/commit/3b539102f07c413ffd3ee60718ac8e5a709bdd0e))
* **SequentialHandler:** add more info to debug when rate limit was hit ([#78](https://github.com/discordjs/discord.js-modules/issues/78)) ([a4e404b](https://github.com/discordjs/discord.js-modules/commit/a4e404b2e6df625a48176b9f1bfac6cfe86c5d66))
## [0.1.1-canary.0](https://github.com/discordjs/discord.js-modules/compare/@discordjs/rest@0.1.0-canary.0...@discordjs/rest@0.1.1-canary.0) (2021-08-24)
### Bug Fixes
* **Rest:** use reference type for DOM ([#55](https://github.com/discordjs/discord.js-modules/issues/55)) ([07f5aa7](https://github.com/discordjs/discord.js-modules/commit/07f5aa744092c16b0f05b05055e5d4bbd49754e7))
# 0.1.0-canary.0 (2021-06-29)
### Features
* **rest:** Implement rest module ([#34](https://github.com/discordjs/discord.js-modules/issues/34)) ([6990f0f](https://github.com/discordjs/discord.js-modules/commit/6990f0f7f3ca958a95f9b1b19681b42669743427))

3
packages/rest/README.md Normal file
View File

@@ -0,0 +1,3 @@
# `@discordjs/rest`
> The REST API module for Discord.js

View File

@@ -0,0 +1,115 @@
import { CDN } from '../src';
const base = 'https://discord.com';
const id = '123456';
const hash = 'abcdef';
const animatedHash = 'a_bcdef';
const defaultAvatar = 1234 % 5;
const cdn = new CDN(base);
test('appAsset default', () => {
expect(cdn.appAsset(id, hash)).toBe(`${base}/app-assets/${id}/${hash}.png`);
});
test('appIcon default', () => {
expect(cdn.appIcon(id, hash)).toBe(`${base}/app-icons/${id}/${hash}.png`);
});
test('avatar default', () => {
expect(cdn.avatar(id, hash)).toBe(`${base}/avatars/${id}/${hash}.png`);
});
test('avatar dynamic-animated', () => {
expect(cdn.avatar(id, animatedHash, { dynamic: true })).toBe(`${base}/avatars/${id}/${animatedHash}.gif`);
});
test('avatar dynamic-not-animated', () => {
expect(cdn.avatar(id, hash, { dynamic: true })).toBe(`${base}/avatars/${id}/${hash}.png`);
});
test('banner default', () => {
expect(cdn.banner(id, hash)).toBe(`${base}/banners/${id}/${hash}.png`);
});
test('channelIcon default', () => {
expect(cdn.channelIcon(id, hash)).toBe(`${base}/channel-icons/${id}/${hash}.png`);
});
test('defaultAvatar default', () => {
expect(cdn.defaultAvatar(defaultAvatar)).toBe(`${base}/embed/avatars/${defaultAvatar}.png`);
});
test('discoverySplash default', () => {
expect(cdn.discoverySplash(id, hash)).toBe(`${base}/discovery-splashes/${id}/${hash}.png`);
});
test('emoji default', () => {
expect(cdn.emoji(id)).toBe(`${base}/emojis/${id}.png`);
});
test('emoji gif', () => {
expect(cdn.emoji(id, 'gif')).toBe(`${base}/emojis/${id}.gif`);
});
test('guildMemberAvatar default', () => {
expect(cdn.guildMemberAvatar(id, id, hash)).toBe(`${base}/guilds/${id}/users/${id}/avatars/${hash}.png`);
});
test('guildMemberAvatar dynamic-animated', () => {
expect(cdn.guildMemberAvatar(id, id, animatedHash, { dynamic: true })).toBe(
`${base}/guilds/${id}/users/${id}/avatars/${animatedHash}.gif`,
);
});
test('guildMemberAvatar dynamic-not-animated', () => {
expect(cdn.guildMemberAvatar(id, id, hash, { dynamic: true })).toBe(
`${base}/guilds/${id}/users/${id}/avatars/${hash}.png`,
);
});
test('icon default', () => {
expect(cdn.icon(id, hash)).toBe(`${base}/icons/${id}/${hash}.png`);
});
test('icon dynamic-animated', () => {
expect(cdn.icon(id, animatedHash, { dynamic: true })).toBe(`${base}/icons/${id}/${animatedHash}.gif`);
});
test('icon dynamic-not-animated', () => {
expect(cdn.icon(id, hash, { dynamic: true })).toBe(`${base}/icons/${id}/${hash}.png`);
});
test('role icon default', () => {
expect(cdn.roleIcon(id, hash)).toBe(`${base}/role-icons/${id}/${hash}.png`);
});
test('splash default', () => {
expect(cdn.splash(id, hash)).toBe(`${base}/splashes/${id}/${hash}.png`);
});
test('sticker default', () => {
expect(cdn.sticker(id)).toBe(`${base}/stickers/${id}.png`);
});
test('stickerPackBanner default', () => {
expect(cdn.stickerPackBanner(id)).toBe(`${base}/app-assets/710982414301790216/store/${id}.png`);
});
test('teamIcon default', () => {
expect(cdn.teamIcon(id, hash)).toBe(`${base}/team-icons/${id}/${hash}.png`);
});
test('makeURL throws on invalid size', () => {
// @ts-expect-error: Invalid size
expect(() => cdn.avatar(id, animatedHash, { size: 5 })).toThrow(RangeError);
});
test('makeURL throws on invalid extension', () => {
// @ts-expect-error: Invalid extension
expect(() => cdn.avatar(id, animatedHash, { extension: 'tif' })).toThrow(RangeError);
});
test('makeURL valid size', () => {
expect(cdn.avatar(id, animatedHash, { size: 512 })).toBe(`${base}/avatars/${id}/${animatedHash}.png?size=512`);
});

View File

@@ -0,0 +1,142 @@
import { DiscordAPIError } from '../src';
test('Unauthorized', () => {
const error = new DiscordAPIError(
{ message: '401: Unauthorized', code: 0 },
0,
401,
'PATCH',
'https://discord.com/api/v9/guilds/:id',
{
attachments: undefined,
body: undefined,
},
);
expect(error.code).toBe(0);
expect(error.message).toBe('401: Unauthorized');
expect(error.method).toBe('PATCH');
expect(error.name).toBe('DiscordAPIError[0]');
expect(error.status).toBe(401);
expect(error.url).toBe('https://discord.com/api/v9/guilds/:id');
expect(error.requestBody.attachments).toBe(undefined);
expect(error.requestBody.json).toBe(undefined);
});
test('Invalid Form Body Error (error.{property}._errors.{index})', () => {
const error = new DiscordAPIError(
{
code: 50035,
errors: {
username: { _errors: [{ code: 'BASE_TYPE_BAD_LENGTH', message: 'Must be between 2 and 32 in length.' }] },
},
message: 'Invalid Form Body',
},
50035,
400,
'PATCH',
'https://discord.com/api/v9/users/@me',
{
attachments: undefined,
body: {
username: 'a',
},
},
);
expect(error.code).toBe(50035);
expect(error.message).toBe(
['Invalid Form Body', 'username[BASE_TYPE_BAD_LENGTH]: Must be between 2 and 32 in length.'].join('\n'),
);
expect(error.method).toBe('PATCH');
expect(error.name).toBe('DiscordAPIError[50035]');
expect(error.status).toBe(400);
expect(error.url).toBe('https://discord.com/api/v9/users/@me');
expect(error.requestBody.attachments).toBe(undefined);
expect(error.requestBody.json).toStrictEqual({ username: 'a' });
});
test('Invalid FormFields Error (error.errors.{property}.{property}.{index}.{property}._errors.{index})', () => {
const error = new DiscordAPIError(
{
code: 50035,
errors: {
embed: {
fields: { '0': { value: { _errors: [{ code: 'BASE_TYPE_REQUIRED', message: 'This field is required' }] } } },
},
},
message: 'Invalid Form Body',
},
50035,
400,
'POST',
'https://discord.com/api/v9/channels/:id',
{},
);
expect(error.code).toBe(50035);
expect(error.message).toBe(
['Invalid Form Body', 'embed.fields[0].value[BASE_TYPE_REQUIRED]: This field is required'].join('\n'),
);
expect(error.method).toBe('POST');
expect(error.name).toBe('DiscordAPIError[50035]');
expect(error.status).toBe(400);
expect(error.url).toBe('https://discord.com/api/v9/channels/:id');
});
test('Invalid FormFields Error (error.errors.{property}.{property}._errors.{index}._errors)', () => {
const error = new DiscordAPIError(
{
code: 50035,
errors: {
form_fields: {
label: { _errors: [{ _errors: [{ code: 'BASE_TYPE_REQUIRED', message: 'This field is required' }] }] },
},
},
message: 'Invalid Form Body',
},
50035,
400,
'PATCH',
'https://discord.com/api/v9/guilds/:id',
{},
);
expect(error.code).toBe(50035);
expect(error.message).toBe(
['Invalid Form Body', 'form_fields.label[0][BASE_TYPE_REQUIRED]: This field is required'].join('\n'),
);
expect(error.method).toBe('PATCH');
expect(error.name).toBe('DiscordAPIError[50035]');
expect(error.status).toBe(400);
expect(error.url).toBe('https://discord.com/api/v9/guilds/:id');
});
test('Invalid Oauth Code Error (error.error)', () => {
const error = new DiscordAPIError(
{
error: 'invalid_request',
error_description: 'Invalid "code" in request.',
},
'invalid_request',
400,
'POST',
'https://discord.com/api/v9/oauth2/token',
{
body: new URLSearchParams([
['client_id', '1234567890123545678'],
['client_secret', 'totally-valid-secret'],
['redirect_uri', 'http://localhost'],
['grant_type', 'authorization_code'],
['code', 'very-invalid-code'],
]),
},
);
expect(error.code).toBe('invalid_request');
expect(error.message).toBe('Invalid "code" in request.');
expect(error.method).toBe('POST');
expect(error.name).toBe('DiscordAPIError[invalid_request]');
expect(error.status).toBe(400);
expect(error.url).toBe('https://discord.com/api/v9/oauth2/token');
});

View File

@@ -0,0 +1,267 @@
import nock from 'nock';
import { DiscordSnowflake } from '@sapphire/snowflake';
import { REST, DefaultRestOptions, APIRequest } from '../src';
import { Routes, Snowflake } from 'discord-api-types/v9';
import { Response } from 'node-fetch';
const newSnowflake: Snowflake = DiscordSnowflake.generate().toString();
const api = new REST().setToken('A-Very-Fake-Token');
nock(`${DefaultRestOptions.api}/v${DefaultRestOptions.version}`)
.get('/simpleGet')
.reply(200, { test: true })
.delete('/simpleDelete')
.reply(200, { test: true })
.patch('/simplePatch')
.reply(200, { test: true })
.put('/simplePut')
.reply(200, { test: true })
.post('/simplePost')
.reply(200, { test: true })
.get('/getQuery')
.query({ foo: 'bar', hello: 'world' })
.reply(200, { test: true })
.get('/getAuth')
.times(3)
.reply(200, function handler() {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return { auth: this.req.headers.authorization?.[0] ?? null };
})
.get('/getReason')
.times(3)
.reply(200, function handler() {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return { reason: this.req.headers['x-audit-log-reason']?.[0] ?? null };
})
.post('/urlEncoded')
.reply(200, (_, body) => body)
.post('/postEcho')
.reply(200, (_, body) => body)
.post('/postAttachment')
.times(5)
.reply(200, (_, body) => ({
body: body
.replace(/\r\n/g, '\n')
.replace(/-+\d+-*\n?/g, '')
.trim(),
}))
.delete('/channels/339942739275677727/messages/392063687801700356')
.reply(200, { test: true })
.delete(`/channels/339942739275677727/messages/${newSnowflake}`)
.reply(200, { test: true })
.get('/request')
.times(2)
.reply(200, { test: true });
test('simple GET', async () => {
expect(await api.get('/simpleGet')).toStrictEqual({ test: true });
});
test('simple DELETE', async () => {
expect(await api.delete('/simpleDelete')).toStrictEqual({ test: true });
});
test('simple PATCH', async () => {
expect(await api.patch('/simplePatch')).toStrictEqual({ test: true });
});
test('simple PUT', async () => {
expect(await api.put('/simplePut')).toStrictEqual({ test: true });
});
test('simple POST', async () => {
expect(await api.post('/simplePost')).toStrictEqual({ test: true });
});
test('getQuery', async () => {
expect(
await api.get('/getQuery', {
query: new URLSearchParams([
['foo', 'bar'],
['hello', 'world'],
]),
}),
).toStrictEqual({ test: true });
});
test('getAuth default', async () => {
expect(await api.get('/getAuth')).toStrictEqual({ auth: 'Bot A-Very-Fake-Token' });
});
test('getAuth unauthorized', async () => {
expect(await api.get('/getAuth', { auth: false })).toStrictEqual({ auth: null });
});
test('getAuth authorized', async () => {
expect(await api.get('/getAuth', { auth: true })).toStrictEqual({ auth: 'Bot A-Very-Fake-Token' });
});
test('getReason default', async () => {
expect(await api.get('/getReason')).toStrictEqual({ reason: null });
});
test('getReason plain text', async () => {
expect(await api.get('/getReason', { reason: 'Hello' })).toStrictEqual({ reason: 'Hello' });
});
test('getReason encoded', async () => {
expect(await api.get('/getReason', { reason: '😄' })).toStrictEqual({ reason: '%F0%9F%98%84' });
});
test('postAttachment empty', async () => {
expect(await api.post('/postAttachment', { attachments: [] })).toStrictEqual({
body: '',
});
});
test('postAttachment attachment', async () => {
expect(
await api.post('/postAttachment', {
attachments: [{ fileName: 'out.txt', rawBuffer: Buffer.from('Hello') }],
}),
).toStrictEqual({
body: [
'Content-Disposition: form-data; name="files[0]"; filename="out.txt"',
'Content-Type: text/plain',
'',
'Hello',
].join('\n'),
});
});
test('postAttachment attachment and JSON', async () => {
expect(
await api.post('/postAttachment', {
attachments: [{ fileName: 'out.txt', rawBuffer: Buffer.from('Hello') }],
body: { foo: 'bar' },
}),
).toStrictEqual({
body: [
'Content-Disposition: form-data; name="files[0]"; filename="out.txt"',
'Content-Type: text/plain',
'',
'Hello',
'Content-Disposition: form-data; name="payload_json"',
'',
'{"foo":"bar"}',
].join('\n'),
});
});
test('postAttachment attachments and JSON', async () => {
expect(
await api.post('/postAttachment', {
attachments: [
{ fileName: 'out.txt', rawBuffer: Buffer.from('Hello') },
{ fileName: 'out.txt', rawBuffer: Buffer.from('Hi') },
],
body: { attachments: [{ id: 0, description: 'test' }] },
}),
).toStrictEqual({
body: [
'Content-Disposition: form-data; name="files[0]"; filename="out.txt"',
'Content-Type: text/plain',
'',
'Hello',
'Content-Disposition: form-data; name="files[1]"; filename="out.txt"',
'Content-Type: text/plain',
'',
'Hi',
'Content-Disposition: form-data; name="payload_json"',
'',
'{"attachments":[{"id":0,"description":"test"}]}',
].join('\n'),
});
});
test('postAttachment sticker and JSON', async () => {
expect(
await api.post('/postAttachment', {
attachments: [{ key: 'file', fileName: 'sticker.png', rawBuffer: Buffer.from('Sticker') }],
body: { foo: 'bar' },
appendToFormData: true,
}),
).toStrictEqual({
body: [
'Content-Disposition: form-data; name="file"; filename="sticker.png"',
'Content-Type: image/png',
'',
'Sticker',
'Content-Disposition: form-data; name="foo"',
'',
'bar',
].join('\n'),
});
});
test('urlEncoded', async () => {
const body = new URLSearchParams([
['client_id', '1234567890123545678'],
['client_secret', 'totally-valid-secret'],
['redirect_uri', 'http://localhost'],
['grant_type', 'authorization_code'],
['code', 'very-invalid-code'],
]);
expect(
await api.post('/urlEncoded', {
body,
passThroughBody: true,
auth: false,
}),
).toStrictEqual(Buffer.from(body.toString()));
});
test('postEcho', async () => {
expect(await api.post('/postEcho', { body: { foo: 'bar' } })).toStrictEqual({ foo: 'bar' });
});
test('Old Message Delete Edge-Case: Old message', async () => {
expect(await api.delete(Routes.channelMessage('339942739275677727', '392063687801700356'))).toStrictEqual({
test: true,
});
});
test('Old Message Delete Edge-Case: New message', async () => {
expect(await api.delete(Routes.channelMessage('339942739275677727', newSnowflake))).toStrictEqual({ test: true });
});
test('Request and Response Events', async () => {
const requestListener = jest.fn();
const responseListener = jest.fn();
api.on('request', requestListener);
api.on('response', responseListener);
await api.get('/request');
expect(requestListener).toHaveBeenCalledTimes(1);
expect(responseListener).toHaveBeenCalledTimes(1);
expect(requestListener).toHaveBeenLastCalledWith<[APIRequest]>(
expect.objectContaining({
method: 'get',
path: '/request',
route: '/request',
data: { attachments: undefined, body: undefined },
retries: 0,
}) as APIRequest,
);
expect(responseListener).toHaveBeenLastCalledWith<[APIRequest, Response]>(
expect.objectContaining({
method: 'get',
path: '/request',
route: '/request',
data: { attachments: undefined, body: undefined },
retries: 0,
}) as APIRequest,
expect.objectContaining({ status: 200, statusText: 'OK' }) as Response,
);
api.off('request', requestListener);
api.off('response', responseListener);
await api.get('/request');
expect(requestListener).toHaveBeenCalledTimes(1);
expect(responseListener).toHaveBeenCalledTimes(1);
});

View File

@@ -0,0 +1,366 @@
import nock from 'nock';
import { DefaultRestOptions, DiscordAPIError, HTTPError, RateLimitError, REST, RESTEvents } from '../src';
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 rateLimitErrorApi = new REST({ rejectOnRateLimit: ['/channels'] }).setToken('Obviously-Not-A-Fake-Token');
let resetAfter = 0;
let sublimitResetAfter = 0;
let retryAfter = 0;
let sublimitRequests = 0;
let sublimitHits = 0;
let serverOutage = true;
let unexpected429 = true;
let unexpected429cf = true;
const sublimitIntervals = {
reset: null,
retry: null,
};
const sublimit = { body: { name: 'newname' } };
const noSublimit = { body: { bitrate: 40000 } };
nock(`${DefaultRestOptions.api}/v${DefaultRestOptions.version}`)
.persist()
.replyDate()
.get('/standard')
.times(3)
.reply(function handler(): nock.ReplyFnResult {
const response = Date.now() >= resetAfter ? 204 : 429;
resetAfter = Date.now() + 250;
if (response === 204) {
return [
204,
undefined,
{
'x-ratelimit-limit': '1',
'x-ratelimit-remaining': '0',
'x-ratelimit-reset-after': ((resetAfter - Date.now()) / 1000).toString(),
'x-ratelimit-bucket': '80c17d2f203122d936070c88c8d10f33',
via: '1.1 google',
},
];
}
return [
429,
{
limit: '1',
remaining: '0',
resetAfter: (resetAfter / 1000).toString(),
bucket: '80c17d2f203122d936070c88c8d10f33',
retryAfter: (resetAfter - Date.now()).toString(),
},
{
'x-ratelimit-limit': '1',
'x-ratelimit-remaining': '0',
'x-ratelimit-reset-after': ((resetAfter - Date.now()) / 1000).toString(),
'x-ratelimit-bucket': '80c17d2f203122d936070c88c8d10f33',
'retry-after': (resetAfter - Date.now()).toString(),
via: '1.1 google',
},
];
})
.get('/triggerGlobal')
.reply(function handler(): nock.ReplyFnResult {
return [
204,
{ global: true },
{
'x-ratelimit-global': 'true',
'retry-after': '1',
via: '1.1 google',
},
];
})
.get('/regularRequest')
.reply(204, { test: true })
.patch('/channels/:id', (body) => ['name', 'topic'].some((key) => Reflect.has(body as Record<string, unknown>, key)))
.reply(function handler(): nock.ReplyFnResult {
sublimitHits += 1;
sublimitRequests += 1;
const response = 2 - sublimitHits >= 0 && 10 - sublimitRequests >= 0 ? 204 : 429;
startSublimitIntervals();
if (response === 204) {
return [
204,
undefined,
{
'x-ratelimit-limit': '10',
'x-ratelimit-remaining': `${10 - sublimitRequests}`,
'x-ratelimit-reset-after': ((sublimitResetAfter - Date.now()) / 1000).toString(),
via: '1.1 google',
},
];
}
return [
429,
{
limit: '10',
remaining: `${10 - sublimitRequests}`,
resetAfter: (sublimitResetAfter / 1000).toString(),
retryAfter: ((retryAfter - Date.now()) / 1000).toString(),
},
{
'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(),
via: '1.1 google',
},
];
})
.patch('/channels/:id', (body) =>
['name', 'topic'].every((key) => !Reflect.has(body as Record<string, unknown>, key)),
)
.reply(function handler(): nock.ReplyFnResult {
sublimitRequests += 1;
const response = 10 - sublimitRequests >= 0 ? 204 : 429;
startSublimitIntervals();
if (response === 204) {
return [
204,
undefined,
{
'x-ratelimit-limit': '10',
'x-ratelimit-remaining': `${10 - sublimitRequests}`,
'x-ratelimit-reset-after': ((sublimitResetAfter - Date.now()) / 1000).toString(),
via: '1.1 google',
},
];
}
return [
429,
{
limit: '10',
remaining: `${10 - sublimitRequests}`,
resetAfter: (sublimitResetAfter / 1000).toString(),
retryAfter: ((sublimitResetAfter - Date.now()) / 1000).toString(),
},
{
'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(),
via: '1.1 google',
},
];
})
.get('/unexpected')
.times(2)
.reply(function handler(): nock.ReplyFnResult {
if (unexpected429) {
unexpected429 = false;
return [
429,
undefined,
{
'retry-after': '1',
via: '1.1 google',
},
];
}
return [204, { test: true }];
})
.get('/unexpected-cf')
.times(2)
.reply(function handler(): nock.ReplyFnResult {
if (unexpected429cf) {
unexpected429cf = false;
return [
429,
undefined,
{
'retry-after': '1',
},
];
}
return [204, { test: true }];
})
.get('/temp')
.times(2)
.reply(function handler(): nock.ReplyFnResult {
if (serverOutage) {
serverOutage = false;
return [500];
}
return [204, { test: true }];
})
.get('/outage')
.times(2)
.reply(500)
.get('/slow')
.times(2)
.delay(3000)
.reply(200)
.get('/badRequest')
.reply(403, { message: 'Missing Permissions', code: 50013 })
.get('/unauthorized')
.reply(401, { message: '401: Unauthorized', code: 0 })
.get('/malformedRequest')
.reply(601);
// This is tested first to ensure the count remains accurate
test('Significant Invalid Requests', async () => {
const invalidListener = jest.fn();
const invalidListener2 = jest.fn();
api.on(RESTEvents.InvalidRequestWarning, invalidListener);
// 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'),
api.get('/badRequest'),
api.get('/badRequest'),
api.get('/badRequest'),
];
await expect(a).rejects.toThrowError('Missing Permissions');
await expect(b).rejects.toThrowError('Missing Permissions');
await expect(c).rejects.toThrowError('Missing Permissions');
await expect(d).rejects.toThrowError('Missing Permissions');
await expect(e).rejects.toThrowError('Missing Permissions');
expect(invalidListener).toHaveBeenCalledTimes(0);
api.requestManager.options.invalidRequestWarningInterval = 2;
const [f, g, h, i, j] = [
api.get('/badRequest'),
api.get('/badRequest'),
api.get('/badRequest'),
api.get('/badRequest'),
api.get('/badRequest'),
];
await expect(f).rejects.toThrowError('Missing Permissions');
await expect(g).rejects.toThrowError('Missing Permissions');
await expect(h).rejects.toThrowError('Missing Permissions');
await expect(i).rejects.toThrowError('Missing Permissions');
await expect(j).rejects.toThrowError('Missing Permissions');
expect(invalidListener).toHaveBeenCalledTimes(3);
api.off(RESTEvents.InvalidRequestWarning, invalidListener);
});
test('Handle standard rate limits', async () => {
const [a, b, c] = [api.get('/standard'), api.get('/standard'), api.get('/standard')];
expect(await a).toStrictEqual(Buffer.alloc(0));
const previous1 = Date.now();
expect(await b).toStrictEqual(Buffer.alloc(0));
const previous2 = Date.now();
expect(await c).toStrictEqual(Buffer.alloc(0));
const now = Date.now();
expect(previous2).toBeGreaterThanOrEqual(previous1 + 250);
expect(now).toBeGreaterThanOrEqual(previous2 + 250);
});
test('Handle global rate limits', async () => {
const earlier = Date.now();
expect(await api.get('/triggerGlobal')).toStrictEqual({ global: true });
expect(await api.get('/regularRequest')).toStrictEqual({ test: true });
expect(Date.now()).toBeGreaterThanOrEqual(earlier + 100);
});
test('Handle sublimits', async () => {
// Return the current time on these results as their response does not indicate anything
// Queue all requests, don't wait, to allow retroactive check
const [aP, bP, cP, dP, eP] = [
api.patch('/channels/:id', sublimit).then(() => Date.now()),
api.patch('/channels/:id', sublimit).then(() => Date.now()),
api.patch('/channels/:id', sublimit).then(() => Date.now()), // Limit hits
api.patch('/channels/:id', noSublimit).then(() => Date.now()), // Ensure normal request passes
api.patch('/channels/:id', sublimit).then(() => Date.now()), // For retroactive check
];
const [a, b, c, d] = await Promise.all([aP, bP, cP, dP]);
const [f, g] = await Promise.all([
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).toBeLessThan(b);
expect(b).toBeLessThan(c);
expect(d).toBeLessThan(c);
expect(c).toBeLessThan(e);
expect(d).toBeLessThan(e);
expect(e).toBeLessThan(f);
expect(e).toBeLessThan(g);
expect(g).toBeLessThan(f);
clearInterval(sublimitIntervals.reset);
clearInterval(sublimitIntervals.retry);
});
test('Handle unexpected 429', async () => {
const previous = Date.now();
expect(await api.get('/unexpected')).toStrictEqual({ test: true });
expect(Date.now()).toBeGreaterThanOrEqual(previous + 1000);
});
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);
});
test('Handle temp server outage', async () => {
expect(await api.get('/temp')).toStrictEqual({ test: true });
});
test('perm server outage', async () => {
const promise = api.get('/outage');
await expect(promise).rejects.toThrowError();
await expect(promise).rejects.toBeInstanceOf(HTTPError);
});
test('server responding too slow', async () => {
const promise = api.get('/slow');
await expect(promise).rejects.toThrowError('The user aborted a request.');
}, 10000);
test('Bad Request', async () => {
const promise = api.get('/badRequest');
await expect(promise).rejects.toThrowError('Missing Permissions');
await expect(promise).rejects.toBeInstanceOf(DiscordAPIError);
});
test('Unauthorized', async () => {
const promise = invalidAuthApi.get('/unauthorized');
await expect(promise).rejects.toThrowError('401: Unauthorized');
await expect(promise).rejects.toBeInstanceOf(DiscordAPIError);
});
test('Reject on RateLimit', async () => {
const [aP, bP, cP] = [
rateLimitErrorApi.patch('/channels/:id', sublimit),
rateLimitErrorApi.patch('/channels/:id', sublimit),
rateLimitErrorApi.patch('/channels/:id', sublimit),
];
await expect(aP).resolves;
await expect(bP).rejects.toThrowError();
await expect(bP).rejects.toBeInstanceOf(RateLimitError);
await expect(cP).rejects.toThrowError();
await expect(cP).rejects.toBeInstanceOf(RateLimitError);
});
test('malformedRequest', async () => {
expect(await api.get('/malformedRequest')).toBe(null);
});
function startSublimitIntervals() {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!sublimitIntervals.reset) {
sublimitResetAfter = Date.now() + 250;
sublimitIntervals.reset = setInterval(() => {
sublimitRequests = 0;
sublimitResetAfter = Date.now() + 250;
}, 250);
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!sublimitIntervals.retry) {
retryAfter = Date.now() + 1000;
sublimitIntervals.retry = setInterval(() => {
sublimitHits = 0;
retryAfter = Date.now() + 1000;
}, 1000);
}
}

View File

@@ -0,0 +1,18 @@
import nock from 'nock';
import { DefaultRestOptions, REST } from '../src';
const api = new REST();
nock(`${DefaultRestOptions.api}/v${DefaultRestOptions.version}`).get('/simpleGet').reply(200, { test: true });
test('no token', async () => {
const promise = api.get('/simpleGet');
await expect(promise).rejects.toThrowError('Expected token to be set for this request, but none was present');
await expect(promise).rejects.toBeInstanceOf(Error);
});
test('negative offset', () => {
const badREST = new REST({ offset: -5000 });
expect(badREST.requestManager.options.offset).toBe(0);
});

View File

@@ -0,0 +1,22 @@
/**
* @type {import('@babel/core').TransformOptions}
*/
module.exports = {
parserOpts: { strictMode: true },
sourceMaps: 'inline',
presets: [
[
'@babel/preset-env',
{
targets: { node: 'current' },
modules: 'commonjs',
},
],
'@babel/preset-typescript',
],
plugins: [
['const-enum', { transform: 'constObject' }],
'babel-plugin-transform-typescript-metadata',
['@babel/plugin-proposal-decorators', { legacy: true }],
],
};

10
packages/rest/codecov.yml Normal file
View File

@@ -0,0 +1,10 @@
coverage:
status:
project:
default:
target: 75%
threshold: 5%
patch:
default:
target: 75%
threshold: 5%

View File

@@ -0,0 +1,19 @@
/**
* @type {import('@jest/types').Config.InitialOptions}
*/
module.exports = {
testMatch: ['**/+(*.)+(spec|test).+(ts|js)?(x)'],
testEnvironment: 'node',
collectCoverage: true,
coverageProvider: 'v8',
coverageDirectory: 'coverage',
coverageReporters: ['html', 'text', 'clover'],
coverageThreshold: {
global: {
branches: 70,
lines: 70,
statements: 70,
},
},
setupFilesAfterEnv: ['./jest.setup.js'],
};

View File

@@ -0,0 +1,10 @@
// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports
const nock = require('nock');
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.restore();
});

View File

@@ -0,0 +1,87 @@
{
"name": "@discordjs/rest",
"version": "0.2.0-canary.0",
"description": "The REST API for discord.js",
"scripts": {
"build": "tsup && tsc --emitDeclarationOnly --incremental",
"test": " jest --pass-with-no-tests --collect-coverage",
"lint": "eslint src __tests__ --ext mjs,js,ts",
"lint:fix": "eslint src __tests__ --ext mjs,js,ts --fix",
"format": "prettier --write **/*.{ts,js,json,yml,yaml}",
"prepublishOnly": "yarn build && yarn lint && yarn test",
"changelog": "git cliff --prepend ./CHANGELOG.md -l -c ../../cliff.toml -r ../../ --include-path './*'"
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"typings": "./dist/index.d.ts",
"exports": {
"import": "./dist/index.mjs",
"require": "./dist/index.js"
},
"directories": {
"lib": "src",
"test": "__tests__"
},
"files": [
"dist"
],
"contributors": [
"Crawl <icrawltogo@gmail.com>",
"Amish Shah <amishshah.2k@gmail.com>",
"SpaceEEC <spaceeec@yahoo.com>",
"Vlad Frangu <kingdgrizzle@gmail.com>",
"Antonio Roman <kyradiscord@gmail.com>"
],
"license": "Apache-2.0",
"keywords": [
"discord",
"api",
"rest",
"discordapp",
"discordjs"
],
"repository": {
"type": "git",
"url": "git+https://github.com/discordjs/discord.js.git"
},
"bugs": {
"url": "https://github.com/discordjs/discord.js/issues"
},
"homepage": "https://discord.js.org",
"dependencies": {
"@discordjs/collection": "^0.4.0",
"@sapphire/async-queue": "^1.1.9",
"@sapphire/snowflake": "^3.0.0",
"discord-api-types": "^0.26.0",
"form-data": "^4.0.0",
"node-fetch": "^2.6.5",
"tslib": "^2.3.1"
},
"devDependencies": {
"@babel/core": "^7.16.7",
"@babel/plugin-proposal-decorators": "^7.16.7",
"@babel/preset-env": "^7.16.7",
"@babel/preset-typescript": "^7.16.7",
"@types/jest": "^27.4.0",
"@types/node-fetch": "^2.5.10",
"@typescript-eslint/eslint-plugin": "^5.8.1",
"@typescript-eslint/parser": "^5.8.1",
"babel-plugin-const-enum": "^1.2.0",
"babel-plugin-transform-typescript-metadata": "^0.3.2",
"eslint": "^8.5.0",
"eslint-config-marine": "^9.1.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"jest": "^27.4.5",
"nock": "^13.2.1",
"prettier": "^2.5.1",
"tsup": "^5.11.9",
"typescript": "^4.5.4"
},
"engines": {
"node": ">=16.0.0"
},
"publishConfig": {
"access": "public"
}
}

View File

@@ -0,0 +1,10 @@
// eslint-disable-next-line spaced-comment
/// <reference lib="dom" />
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';

View File

@@ -0,0 +1,224 @@
import {
ALLOWED_EXTENSIONS,
ALLOWED_SIZES,
ALLOWED_STICKER_EXTENSIONS,
DefaultRestOptions,
ImageExtension,
ImageSize,
StickerExtension,
} from './utils/constants';
export interface BaseImageURLOptions {
extension?: ImageExtension;
size?: ImageSize;
}
export interface ImageURLOptions extends BaseImageURLOptions {
dynamic?: boolean;
}
export interface MakeURLOptions {
extension?: string | undefined;
size?: ImageSize;
allowedExtensions?: readonly string[];
}
/**
* The CDN link builder
*/
export class CDN {
public constructor(private readonly base: string = DefaultRestOptions.cdn) {}
/**
* Generates an app asset URL for a client's asset.
* @param clientId The client id that has the asset
* @param assetHash The hash provided by Discord for this asset
* @param options Optional options for the asset
*/
public appAsset(clientId: string, assetHash: string, options?: Readonly<BaseImageURLOptions>): string {
return this.makeURL(`/app-assets/${clientId}/${assetHash}`, options);
}
/**
* Generates an app icon URL for a client's icon.
* @param clientId The client id that has the icon
* @param iconHash The hash provided by Discord for this icon
* @param options Optional options for the icon
*/
public appIcon(clientId: string, iconHash: string, options?: Readonly<BaseImageURLOptions>): string {
return this.makeURL(`/app-icons/${clientId}/${iconHash}`, options);
}
/**
* Generates an avatar URL, e.g. for a user or a webhook.
* @param id The id that has the icon
* @param avatarHash The hash provided by Discord for this avatar
* @param options Optional options for the avatar
*/
public avatar(id: string, avatarHash: string, options?: Readonly<ImageURLOptions>): string {
return this.dynamicMakeURL(`/avatars/${id}/${avatarHash}`, avatarHash, options);
}
/**
* Generates a banner URL, e.g. for a user or a guild.
* @param id The id that has the banner splash
* @param bannerHash The hash provided by Discord for this banner
* @param options Optional options for the banner
*/
public banner(id: string, bannerHash: string, options?: Readonly<ImageURLOptions>): string {
return this.dynamicMakeURL(`/banners/${id}/${bannerHash}`, bannerHash, options);
}
/**
* Generates an icon URL for a channel, e.g. a group DM.
* @param channelId The channel id that has the icon
* @param iconHash The hash provided by Discord for this channel
* @param options Optional options for the icon
*/
public channelIcon(channelId: string, iconHash: string, options?: Readonly<BaseImageURLOptions>): string {
return this.makeURL(`/channel-icons/${channelId}/${iconHash}`, options);
}
/**
* Generates the default avatar URL for a discriminator.
* @param discriminator The discriminator modulo 5
*/
public defaultAvatar(discriminator: number): string {
return this.makeURL(`/embed/avatars/${discriminator}`);
}
/**
* Generates a discovery splash URL for a guild's discovery splash.
* @param guildId The guild id that has the discovery splash
* @param splashHash The hash provided by Discord for this splash
* @param options Optional options for the splash
*/
public discoverySplash(guildId: string, splashHash: string, options?: Readonly<BaseImageURLOptions>): string {
return this.makeURL(`/discovery-splashes/${guildId}/${splashHash}`, options);
}
/**
* Generates an emoji's URL for an emoji.
* @param emojiId The emoji id
* @param extension The extension of the emoji
*/
public emoji(emojiId: string, extension?: ImageExtension): string {
return this.makeURL(`/emojis/${emojiId}`, { extension });
}
/**
* Generates a guild member avatar URL.
* @param guildId The id of the guild
* @param userId The id of the user
* @param avatarHash The hash provided by Discord for this avatar
* @param options Optional options for the avatar
*/
public guildMemberAvatar(
guildId: string,
userId: string,
avatarHash: string,
options?: Readonly<ImageURLOptions>,
): string {
return this.dynamicMakeURL(`/guilds/${guildId}/users/${userId}/avatars/${avatarHash}`, avatarHash, options);
}
/**
* Generates an icon URL, e.g. for a guild.
* @param id The id that has the icon splash
* @param iconHash The hash provided by Discord for this icon
* @param options Optional options for the icon
*/
public icon(id: string, iconHash: string, options?: Readonly<ImageURLOptions>): string {
return this.dynamicMakeURL(`/icons/${id}/${iconHash}`, iconHash, options);
}
/**
* 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
*/
public roleIcon(roleId: string, roleIconHash: string, options?: Readonly<BaseImageURLOptions>): string {
return this.makeURL(`/role-icons/${roleId}/${roleIconHash}`, options);
}
/**
* Generates a guild invite splash URL for a guild's invite splash.
* @param guildId The guild id that has the invite splash
* @param splashHash The hash provided by Discord for this splash
* @param options Optional options for the splash
*/
public splash(guildId: string, splashHash: string, options?: Readonly<BaseImageURLOptions>): string {
return this.makeURL(`/splashes/${guildId}/${splashHash}`, options);
}
/**
* Generates a sticker URL.
* @param stickerId The sticker id
* @param extension The extension of the sticker
*/
public sticker(stickerId: string, extension?: StickerExtension): string {
return this.makeURL(`/stickers/${stickerId}`, { allowedExtensions: ALLOWED_STICKER_EXTENSIONS, extension });
}
/**
* Generates a sticker pack banner URL.
* @param bannerId The banner id
* @param options Optional options for the banner
*/
public stickerPackBanner(bannerId: string, options?: Readonly<BaseImageURLOptions>): string {
return this.makeURL(`/app-assets/710982414301790216/store/${bannerId}`, options);
}
/**
* Generates a team icon URL for a team's icon.
* @param teamId The team id that has the icon
* @param iconHash The hash provided by Discord for this icon
* @param options Optional options for the icon
*/
public teamIcon(teamId: string, iconHash: string, options?: Readonly<BaseImageURLOptions>): string {
return this.makeURL(`/team-icons/${teamId}/${iconHash}`, options);
}
/**
* Constructs the URL for the resource, checking whether or not `hash` starts with `a_` if `dynamic` is set to `true`.
* @param route The base cdn route
* @param hash The hash provided by Discord for this icon
* @param options Optional options for the link
*/
private dynamicMakeURL(
route: string,
hash: string,
{ dynamic = false, ...options }: Readonly<ImageURLOptions> = {},
): string {
return this.makeURL(route, dynamic && hash.startsWith('a_') ? { ...options, extension: 'gif' } : options);
}
/**
* Constructs the URL for the resource
* @param route The base cdn route
* @param options The extension/size options for the link
*/
private makeURL(
route: string,
{ allowedExtensions = ALLOWED_EXTENSIONS, extension = 'png', size }: Readonly<MakeURLOptions> = {},
): string {
extension = String(extension).toLowerCase();
if (!allowedExtensions.includes(extension)) {
throw new RangeError(`Invalid extension provided: ${extension}\nMust be one of: ${allowedExtensions.join(', ')}`);
}
if (size && !ALLOWED_SIZES.includes(size)) {
throw new RangeError(`Invalid size provided: ${size}\nMust be one of: ${ALLOWED_SIZES.join(', ')}`);
}
const url = new URL(`${this.base}${route}.${extension}`);
if (size) {
url.searchParams.set('size', String(size));
}
return url.toString();
}
}

View File

@@ -0,0 +1,271 @@
import { EventEmitter } from 'node:events';
import { CDN } from './CDN';
import { InternalRequest, RequestData, RequestManager, RequestMethod, RouteLike } from './RequestManager';
import { DefaultRestOptions, RESTEvents } from './utils/constants';
import type { AgentOptions } from 'node:https';
import type { RequestInit, Response } from 'node-fetch';
/**
* Options to be passed when creating the REST instance
*/
export interface RESTOptions {
/**
* HTTPS Agent options
* @default {}
*/
agent: Omit<AgentOptions, 'keepAlive'>;
/**
* The base api path, without version
* @default 'https://discord.com/api'
*/
api: string;
/**
* The cdn path
* @default 'https://cdn.discordapp.com'
*/
cdn: string;
/**
* Additional headers to send for all API requests
* @default {}
*/
headers: Record<string, string>;
/**
* The number of invalid REST requests (those that return 401, 403, or 429) in a 10 minute window between emitted warnings (0 for no warnings).
* That is, if set to 500, warnings will be emitted at invalid request number 500, 1000, 1500, and so on.
* @default 0
*/
invalidRequestWarningInterval: number;
/**
* How many requests to allow sending per second (Infinity for unlimited, 50 for the standard global limit used by Discord)
* @default 50
*/
globalRequestsPerSecond: number;
/**
* The extra offset to add to rate limits in milliseconds
* @default 50
*/
offset: number;
/**
* Determines how rate limiting and pre-emptive throttling should be handled.
* When an array of strings, each element is treated as a prefix for the request route
* (e.g. `/channels` to match any route starting with `/channels` such as `/channels/:id/messages`)
* for which to throw {@link RateLimitError}s. All other request routes will be queued normally
* @default null
*/
rejectOnRateLimit: string[] | RateLimitQueueFilter | null;
/**
* The number of retries for errors with the 500 code, or errors
* that timeout
* @default 3
*/
retries: number;
/**
* The time to wait in milliseconds before a request is aborted
* @default 15_000
*/
timeout: number;
/**
* Extra information to add to the user agent
* @default `Node.js ${process.version}`
*/
userAgentAppendix: string;
/**
* The version of the API to use
* @default '9'
*/
version: string;
}
/**
* Data emitted on `RESTEvents.RateLimited`
*/
export interface RateLimitData {
/**
* The time, in milliseconds, until the request-lock is reset
*/
timeToReset: number;
/**
* The amount of requests we can perform before locking requests
*/
limit: number;
/**
* The HTTP method being performed
*/
method: string;
/**
* The bucket hash for this request
*/
hash: string;
/**
* The full URL for this request
*/
url: string;
/**
* The route being hit in this request
*/
route: string;
/**
* The major parameter of the route
*
* For example, in `/channels/x`, this will be `x`.
* If there is no major parameter (e.g: `/bot/gateway`) this will be `global`.
*/
majorParameter: string;
/**
* Whether the rate limit that was reached was the global limit
*/
global: boolean;
}
/**
* A function that determines whether the rate limit hit should throw an Error
*/
export type RateLimitQueueFilter = (rateLimitData: RateLimitData) => boolean | Promise<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: RequestInit;
/**
* The data that was used to form the body of this request
*/
data: Pick<InternalRequest, 'attachments' | 'body'>;
/**
* The number of times this request has been attempted
*/
retries: number;
}
export interface InvalidRequestWarningData {
/**
* Number of invalid requests that have been made in the window
*/
count: number;
/**
* Time in ms remaining before the count resets
*/
remainingTime: number;
}
export interface RestEvents {
invalidRequestWarning: [invalidRequestInfo: InvalidRequestWarningData];
restDebug: [info: string];
rateLimited: [rateLimitInfo: RateLimitData];
request: [request: APIRequest];
response: [request: APIRequest, response: Response];
newListener: [name: string, listener: (...args: any) => void];
removeListener: [name: string, listener: (...args: any) => void];
}
export interface REST {
on<K extends keyof RestEvents>(event: K, listener: (...args: RestEvents[K]) => void): this;
on<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;
once<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;
emit<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;
off<S extends string | symbol>(event: Exclude<S, keyof RestEvents>, listener: (...args: any[]) => void): this;
removeAllListeners<K extends keyof RestEvents>(event?: K): this;
removeAllListeners<S extends string | symbol>(event?: Exclude<S, keyof RestEvents>): this;
}
export class REST extends EventEmitter {
public readonly cdn: CDN;
public readonly requestManager: RequestManager;
public constructor(options: Partial<RESTOptions> = {}) {
super();
this.cdn = new CDN(options.cdn ?? DefaultRestOptions.cdn);
this.requestManager = new RequestManager(options)
.on(RESTEvents.Debug, this.emit.bind(this, RESTEvents.Debug))
.on(RESTEvents.RateLimited, this.emit.bind(this, RESTEvents.RateLimited))
.on(RESTEvents.InvalidRequestWarning, this.emit.bind(this, RESTEvents.InvalidRequestWarning));
this.on('newListener', (name, listener) => {
if (name === RESTEvents.Request || name === RESTEvents.Response) this.requestManager.on(name, listener);
});
this.on('removeListener', (name, listener) => {
if (name === RESTEvents.Request || name === RESTEvents.Response) this.requestManager.off(name, listener);
});
}
/**
* Sets the authorization token that should be used for requests
* @param token The authorization token to use
*/
public setToken(token: string) {
this.requestManager.setToken(token);
return this;
}
/**
* Runs a get request from the api
* @param fullRoute The full route to query
* @param options Optional request options
*/
public get(fullRoute: RouteLike, options: RequestData = {}) {
return this.request({ ...options, fullRoute, method: RequestMethod.Get });
}
/**
* Runs a delete request from the api
* @param fullRoute The full route to query
* @param options Optional request options
*/
public delete(fullRoute: RouteLike, options: RequestData = {}) {
return this.request({ ...options, fullRoute, method: RequestMethod.Delete });
}
/**
* Runs a post request from the api
* @param fullRoute The full route to query
* @param options Optional request options
*/
public post(fullRoute: RouteLike, options: RequestData = {}) {
return this.request({ ...options, fullRoute, method: RequestMethod.Post });
}
/**
* Runs a put request from the api
* @param fullRoute The full route to query
* @param options Optional request options
*/
public put(fullRoute: RouteLike, options: RequestData = {}) {
return this.request({ ...options, fullRoute, method: RequestMethod.Put });
}
/**
* Runs a patch request from the api
* @param fullRoute The full route to query
* @param options Optional request options
*/
public patch(fullRoute: RouteLike, options: RequestData = {}) {
return this.request({ ...options, fullRoute, method: RequestMethod.Patch });
}
/**
* Runs a request from the api
* @param options Request options
*/
public request(options: InternalRequest) {
return this.requestManager.queueRequest(options);
}
}

View File

@@ -0,0 +1,362 @@
import Collection from '@discordjs/collection';
import FormData from 'form-data';
import { DiscordSnowflake } from '@sapphire/snowflake';
import { EventEmitter } from 'node:events';
import { Agent } from 'node:https';
import type { RequestInit, BodyInit } from 'node-fetch';
import type { IHandler } from './handlers/IHandler';
import { SequentialHandler } from './handlers/SequentialHandler';
import type { RESTOptions, RestEvents } from './REST';
import { DefaultRestOptions, DefaultUserAgent } from './utils/constants';
let agent: Agent | null = null;
/**
* Represents an attachment to be added to the request
*/
export interface RawAttachment {
/**
* The name of the file
*/
fileName: string;
/**
* An explicit key to use for key of the formdata field for this attachment.
* When not provided, the index of the file in the attachments array is used in the form `files[${index}]`.
* If you wish to alter the placeholder snowflake, you must provide this property in the same form (`files[${placeholder}]`)
*/
key?: string;
/**
* The actual data for the attachment
*/
rawBuffer: Buffer;
}
/**
* Represents possible data to be given to an endpoint
*/
export interface RequestData {
/**
* Whether to append JSON data to form data instead of `payload_json` when sending attachments
*/
appendToFormData?: boolean;
/**
* Files to be attached to this request
*/
attachments?: RawAttachment[] | undefined;
/**
* If this request needs the `Authorization` header
* @default true
*/
auth?: boolean;
/**
* The authorization prefix to use for this request, useful if you use this with bearer tokens
* @default 'Bot'
*/
authPrefix?: 'Bot' | 'Bearer';
/**
* The body to send to this request.
* If providing as BodyInit, set `passThroughBody: true`
*/
body?: BodyInit | unknown;
/**
* Additional headers to add to this request
*/
headers?: Record<string, string>;
/**
* Whether to pass-through the body property directly to `fetch()`.
* <warn>This only applies when attachments is NOT present</warn>
*/
passThroughBody?: boolean;
/**
* Query string parameters to append to the called endpoint
*/
query?: URLSearchParams;
/**
* Reason to show in the audit logs
*/
reason?: string;
/**
* If this request should be versioned
* @default true
*/
versioned?: boolean;
}
/**
* Possible headers for an API call
*/
export interface RequestHeaders {
Authorization?: string;
'User-Agent': string;
'X-Audit-Log-Reason'?: string;
}
/**
* Possible API methods to be used when doing requests
*/
export const enum RequestMethod {
Delete = 'delete',
Get = 'get',
Patch = 'patch',
Post = 'post',
Put = 'put',
}
export type RouteLike = `/${string}`;
/**
* Internal request options
*
* @internal
*/
export interface InternalRequest extends RequestData {
method: RequestMethod;
fullRoute: RouteLike;
}
/**
* Parsed route data for an endpoint
*
* @internal
*/
export interface RouteData {
majorParameter: string;
bucketRoute: string;
original: RouteLike;
}
export interface RequestManager {
on<K extends keyof RestEvents>(event: K, listener: (...args: RestEvents[K]) => void): this;
on<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;
once<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;
emit<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;
off<S extends string | symbol>(event: Exclude<S, keyof RestEvents>, listener: (...args: any[]) => void): this;
removeAllListeners<K extends keyof RestEvents>(event?: K): this;
removeAllListeners<S extends string | symbol>(event?: Exclude<S, keyof RestEvents>): this;
}
/**
* Represents the class that manages handlers for endpoints
*/
export class RequestManager extends EventEmitter {
/**
* The number of requests remaining in the global bucket
*/
public globalRemaining: number;
/**
* The promise used to wait out the global rate limit
*/
public globalDelay: Promise<void> | null = null;
/**
* The timestamp at which the global bucket resets
*/
public globalReset = -1;
/**
* API bucket hashes that are cached from provided routes
*/
public readonly hashes = new Collection<string, string>();
/**
* Request handlers created from the bucket hash and the major parameters
*/
public readonly handlers = new Collection<string, IHandler>();
// eslint-disable-next-line @typescript-eslint/explicit-member-accessibility
#token: string | null = null;
public readonly options: RESTOptions;
public constructor(options: Partial<RESTOptions>) {
super();
this.options = { ...DefaultRestOptions, ...options };
this.options.offset = Math.max(0, this.options.offset);
this.globalRemaining = this.options.globalRequestsPerSecond;
}
/**
* Sets the authorization token that should be used for requests
* @param token The authorization token to use
*/
public setToken(token: string) {
this.#token = token;
return this;
}
/**
* Queues a request to be sent
* @param request All the information needed to make a request
* @returns The response from the api request
*/
public async queueRequest(request: InternalRequest): Promise<unknown> {
// Generalize the endpoint to its route data
const routeId = RequestManager.generateRouteData(request.fullRoute, request.method);
// Get the bucket hash for the generic route, or point to a global route otherwise
const hash =
this.hashes.get(`${request.method}:${routeId.bucketRoute}`) ?? `Global(${request.method}:${routeId.bucketRoute})`;
// Get the request handler for the obtained hash, with its major parameter
const handler =
this.handlers.get(`${hash}:${routeId.majorParameter}`) ?? this.createHandler(hash, routeId.majorParameter);
// Resolve the request into usable fetch/node-fetch options
const { url, fetchOptions } = this.resolveRequest(request);
// Queue the request
return handler.queueRequest(routeId, url, fetchOptions, { body: request.body, attachments: request.attachments });
}
/**
* Creates a new rate limit handler from a hash, based on the hash and the major parameter
* @param hash The hash for the route
* @param majorParameter The major parameter for this handler
* @private
*/
private createHandler(hash: string, majorParameter: string) {
// Create the async request queue to handle requests
const queue = new SequentialHandler(this, hash, majorParameter);
// Save the queue based on its id
this.handlers.set(queue.id, queue);
return queue;
}
/**
* Formats the request data to a usable format for fetch
* @param request The request data
*/
private resolveRequest(request: InternalRequest): { url: string; fetchOptions: RequestInit } {
const { options } = this;
agent ??= new Agent({ ...options.agent, keepAlive: true });
let query = '';
// If a query option is passed, use it
if (request.query) {
query = `?${request.query.toString()}`;
}
// Create the required headers
const headers: RequestHeaders = {
...this.options.headers,
'User-Agent': `${DefaultUserAgent} ${options.userAgentAppendix}`.trim(),
};
// If this request requires authorization (allowing non-"authorized" requests for webhooks)
if (request.auth !== false) {
// If we haven't received a token, throw an error
if (!this.#token) {
throw new Error('Expected token to be set for this request, but none was present');
}
headers.Authorization = `${request.authPrefix ?? 'Bot'} ${this.#token}`;
}
// If a reason was set, set it's appropriate header
if (request.reason?.length) {
headers['X-Audit-Log-Reason'] = encodeURIComponent(request.reason);
}
// Format the full request URL (api base, optional version, endpoint, optional querystring)
const url = `${options.api}${request.versioned === false ? '' : `/v${options.version}`}${
request.fullRoute
}${query}`;
let finalBody: RequestInit['body'];
let additionalHeaders: Record<string, string> = {};
if (request.attachments?.length) {
const formData = new FormData();
// Attach all files to the request
for (const [index, attachment] of request.attachments.entries()) {
formData.append(attachment.key ?? `files[${index}]`, attachment.rawBuffer, attachment.fileName);
}
// 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
if (request.body != null) {
if (request.appendToFormData) {
for (const [key, value] of Object.entries(request.body as Record<string, unknown>)) {
formData.append(key, value);
}
} else {
formData.append('payload_json', JSON.stringify(request.body));
}
}
// Set the final body to the form data
finalBody = formData;
// Set the additional headers to the form data ones
additionalHeaders = formData.getHeaders();
// eslint-disable-next-line no-eq-null
} else if (request.body != null) {
if (request.passThroughBody) {
finalBody = request.body as BodyInit;
} else {
// Stringify the JSON data
finalBody = JSON.stringify(request.body);
// Set the additional headers to specify the content-type
additionalHeaders = { 'Content-Type': 'application/json' };
}
}
const fetchOptions = {
agent,
body: finalBody,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
headers: { ...(request.headers ?? {}), ...additionalHeaders, ...headers } as Record<string, string>,
method: request.method,
};
return { url, fetchOptions };
}
/**
* Generates route data for an endpoint:method
* @param endpoint The raw endpoint to generalize
* @param method The HTTP method this endpoint is called without
* @private
*/
private static generateRouteData(endpoint: RouteLike, method: RequestMethod): RouteData {
const majorIdMatch = /^\/(?:channels|guilds|webhooks)\/(\d{16,19})/.exec(endpoint);
// Get the major id for this route - global otherwise
const majorId = majorIdMatch?.[1] ?? 'global';
const baseRoute = endpoint
// Strip out all ids
.replace(/\d{16,19}/g, ':id')
// Strip out reaction as they fall under the same bucket
.replace(/\/reactions\/(.*)/, '/reactions/:reaction');
let exceptions = '';
// Hard-Code Old Message Deletion Exception (2 week+ old messages are a different bucket)
// https://github.com/discord/discord-api-docs/issues/1295
if (method === RequestMethod.Delete && baseRoute === '/channels/:id/messages/:id') {
const id = /\d{16,19}$/.exec(endpoint)![0];
const snowflake = DiscordSnowflake.deconstruct(id);
if (Date.now() - Number(snowflake.timestamp) > 1000 * 60 * 60 * 24 * 14) {
exceptions += '/Delete Old Message';
}
}
return {
majorParameter: majorId,
bucketRoute: baseRoute + exceptions,
original: endpoint,
};
}
}

View File

@@ -0,0 +1,107 @@
import type { InternalRequest, RawAttachment } from '../RequestManager';
interface DiscordErrorFieldInformation {
code: string;
message: string;
}
interface DiscordErrorGroupWrapper {
_errors: DiscordError[];
}
type DiscordError = DiscordErrorGroupWrapper | DiscordErrorFieldInformation | { [k: string]: DiscordError } | string;
export interface DiscordErrorData {
code: number;
message: string;
errors?: DiscordError;
}
export interface OAuthErrorData {
error: string;
error_description?: string;
}
export interface RequestBody {
attachments: RawAttachment[] | undefined;
json: unknown | undefined;
}
function isErrorGroupWrapper(error: DiscordError): error is DiscordErrorGroupWrapper {
return Reflect.has(error as Record<string, unknown>, '_errors');
}
function isErrorResponse(error: DiscordError): error is DiscordErrorFieldInformation {
return typeof Reflect.get(error as Record<string, unknown>, 'message') === 'string';
}
/**
* Represents an API error returned by Discord
* @extends Error
*/
export class DiscordAPIError extends Error {
public requestBody: RequestBody;
/**
* @param rawError The error reported by Discord
* @param code The error code reported by Discord
* @param status The status code 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 rawError: DiscordErrorData | OAuthErrorData,
public code: number | string,
public status: number,
public method: string,
public url: string,
bodyData: Pick<InternalRequest, 'attachments' | 'body'>,
) {
super(DiscordAPIError.getMessage(rawError));
this.requestBody = { attachments: bodyData.attachments, json: bodyData.body };
}
/**
* The name of the error
*/
public override get name(): string {
return `${DiscordAPIError.name}[${this.code}]`;
}
private static getMessage(error: DiscordErrorData | OAuthErrorData) {
let flattened = '';
if ('code' in 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';
}
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;
if (typeof v === 'string') {
yield v;
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
} else if (isErrorGroupWrapper(v)) {
for (const error of v._errors) {
yield* this.flattenDiscordError(error, nextKey);
}
} else {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
yield* this.flattenDiscordError(v, nextKey);
}
}
}
}

View File

@@ -0,0 +1,30 @@
import type { InternalRequest } from '../RequestManager';
import type { RequestBody } from './DiscordAPIError';
/**
* Represents a HTTP error
*/
export class HTTPError extends Error {
public requestBody: RequestBody;
/**
* @param message The error message
* @param name The name of the error
* @param status The status code 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(
message: string,
public override name: string,
public status: number,
public method: string,
public url: string,
bodyData: Pick<InternalRequest, 'attachments' | 'body'>,
) {
super(message);
this.requestBody = { attachments: bodyData.attachments, json: bodyData.body };
}
}

View File

@@ -0,0 +1,30 @@
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;
this.limit = limit;
this.method = method;
this.hash = hash;
this.url = url;
this.route = route;
this.majorParameter = majorParameter;
this.global = global;
}
/**
* The name of the error
*/
public override get name(): string {
return `${RateLimitError.name}[${this.route}]`;
}
}

View File

@@ -0,0 +1,11 @@
import type { RequestInit } from 'node-fetch';
import type { InternalRequest, RouteData } from '../RequestManager';
export interface IHandler {
queueRequest(
routeId: RouteData,
url: string,
options: RequestInit,
bodyData: Pick<InternalRequest, 'attachments' | 'body'>,
): Promise<unknown>;
}

View File

@@ -0,0 +1,482 @@
import { setTimeout as sleep } from 'node:timers/promises';
import { AsyncQueue } from '@sapphire/async-queue';
import fetch, { RequestInit, Response } from 'node-fetch';
import { DiscordAPIError, DiscordErrorData, OAuthErrorData } from '../errors/DiscordAPIError';
import { HTTPError } from '../errors/HTTPError';
import { RateLimitError } from '../errors/RateLimitError';
import type { InternalRequest, RequestManager, RouteData } from '../RequestManager';
import { RESTEvents } from '../utils/constants';
import { hasSublimit, parseResponse } from '../utils/utils';
import type { RateLimitData } from '../REST';
/* Invalid request limiting is done on a per-IP basis, not a per-token basis.
* The best we can do is track invalid counts process-wide (on the theory that
* users could have multiple bots run from one process) rather than per-bot.
* Therefore, store these at file scope here rather than in the client's
* RESTManager object.
*/
let invalidCount = 0;
let invalidCountResetTime: number | null = null;
const enum QueueType {
Standard,
Sublimit,
}
/**
* The structure used to handle requests for a given bucket
*/
export class SequentialHandler {
/**
* The unique id of the handler
*/
public readonly id: string;
/**
* The time this rate limit bucket will reset
*/
private reset = -1;
/**
* The remaining requests that can be made before we are rate limited
*/
private remaining = 1;
/**
* The total number of requests that can be made before we are rate limited
*/
private limit = Infinity;
/**
* The interface used to sequence async requests sequentially
*/
// eslint-disable-next-line @typescript-eslint/explicit-member-accessibility
#asyncQueue = new AsyncQueue();
/**
* The interface used to sequence sublimited async requests sequentially
*/
// eslint-disable-next-line @typescript-eslint/explicit-member-accessibility
#sublimitedQueue: AsyncQueue | null = null;
/**
* 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;
/**
* Whether the sublimit queue needs to be shifted in the finally block
*/
// eslint-disable-next-line @typescript-eslint/explicit-member-accessibility
#shiftSublimit = false;
/**
* @param manager The request manager
* @param hash The hash that this RequestHandler handles
* @param majorParameter The major parameter for this handler
*/
public constructor(
private readonly manager: RequestManager,
private readonly hash: string,
private readonly majorParameter: string,
) {
this.id = `${hash}:${majorParameter}`;
}
/**
* If the bucket is currently inactive (no pending requests)
*/
public get inactive(): boolean {
return (
this.#asyncQueue.remaining === 0 &&
(this.#sublimitedQueue === null || this.#sublimitedQueue.remaining === 0) &&
!this.limited
);
}
/**
* If the rate limit bucket is currently limited by the global limit
*/
private get globalLimited(): boolean {
return this.manager.globalRemaining <= 0 && Date.now() < this.manager.globalReset;
}
/**
* If the rate limit bucket is currently limited by its limit
*/
private get localLimited(): boolean {
return this.remaining <= 0 && Date.now() < this.reset;
}
/**
* If the rate limit bucket is currently limited
*/
private get limited(): boolean {
return this.globalLimited || this.localLimited;
}
/**
* The time until queued requests can continue
*/
private get timeToReset(): number {
return this.reset + this.manager.options.offset - Date.now();
}
/**
* Emits a debug message
* @param message The message to debug
*/
private debug(message: string) {
this.manager.emit(RESTEvents.Debug, `[REST ${this.id}] ${message}`);
}
/**
* Delay all requests for the specified amount of time, handling global rate limits
* @param time The amount of time to delay all requests for
* @returns
*/
private async globalDelayFor(time: number): Promise<void> {
await sleep(time, undefined, { ref: false });
this.manager.globalDelay = null;
}
/*
* Determines whether the request should be queued or whether a RateLimitError should be thrown
*/
private async onRateLimit(rateLimitData: RateLimitData) {
const { options } = this.manager;
if (!options.rejectOnRateLimit) return;
const shouldThrow =
typeof options.rejectOnRateLimit === 'function'
? await options.rejectOnRateLimit(rateLimitData)
: options.rejectOnRateLimit.some((route) => rateLimitData.route.startsWith(route.toLowerCase()));
if (shouldThrow) {
throw new RateLimitError(rateLimitData);
}
}
/**
* Queues a request to be sent
* @param routeId The generalized api route with literal ids for major parameters
* @param url The url to do the request on
* @param options All the information needed to make a request
* @param bodyData The data that was used to form the body, passed to any errors generated and for determining whether to sublimit
*/
public async queueRequest(
routeId: RouteData,
url: string,
options: RequestInit,
bodyData: Pick<InternalRequest, 'attachments' | 'body'>,
): Promise<unknown> {
let queue = this.#asyncQueue;
let queueType = QueueType.Standard;
// Separate sublimited requests when already sublimited
if (this.#sublimitedQueue && hasSublimit(routeId.bucketRoute, bodyData.body, options.method)) {
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
if (queueType === QueueType.Standard) {
if (this.#sublimitedQueue && hasSublimit(routeId.bucketRoute, bodyData.body, options.method)) {
/**
* Remove the request from the standard queue, it should never be possible to get here while processing the
* sublimit queue so there is no need to worry about shifting the wrong request
*/
queue = this.#sublimitedQueue!;
const wait = queue.wait();
this.#asyncQueue.shift();
await wait;
} else if (this.#sublimitPromise) {
// Stall requests while the sublimit queue gets processed
await this.#sublimitPromise.promise;
}
}
try {
// Make the request, and return the results
return await this.runRequest(routeId, url, options, bodyData);
} finally {
// Allow the next request to fire
queue.shift();
if (this.#shiftSublimit) {
this.#shiftSublimit = false;
this.#sublimitedQueue?.shift();
}
// If this request is the last request in a sublimit
if (this.#sublimitedQueue?.remaining === 0) {
this.#sublimitPromise?.resolve();
this.#sublimitedQueue = null;
}
}
}
/**
* The method that actually makes the request to the api, and updates info about the bucket accordingly
* @param routeId The generalized api route with literal ids for major parameters
* @param url The fully resolved url to make the request to
* @param options The node-fetch options needed to make the request
* @param bodyData The data that was used to form the body, passed to any errors generated
* @param retries The number of retries this request has already attempted (recursion)
*/
private async runRequest(
routeId: RouteData,
url: string,
options: RequestInit,
bodyData: Pick<InternalRequest, 'attachments' | 'body'>,
retries = 0,
): Promise<unknown> {
/*
* After calculations have been done, pre-emptively stop further requests
* Potentially loop until this task can run if e.g. the global rate limit is hit twice
*/
while (this.limited) {
const isGlobal = this.globalLimited;
let limit: number;
let timeout: number;
let delay: Promise<void>;
if (isGlobal) {
// Set RateLimitData based on the globl limit
limit = this.manager.options.globalRequestsPerSecond;
timeout = this.manager.globalReset + this.manager.options.offset - Date.now();
// If this is the first task to reach the global timeout, set the global delay
if (!this.manager.globalDelay) {
// 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
limit = this.limit;
timeout = this.timeToReset;
delay = sleep(timeout, undefined, { ref: false });
}
const rateLimitData: RateLimitData = {
timeToReset: timeout,
limit,
method: options.method ?? 'get',
hash: this.hash,
url,
route: routeId.bucketRoute,
majorParameter: this.majorParameter,
global: isGlobal,
};
// Let library users know they have hit a rate limit
this.manager.emit(RESTEvents.RateLimited, rateLimitData);
// Determine whether a RateLimitError should be thrown
await this.onRateLimit(rateLimitData);
// When not erroring, emit debug for what is happening
if (isGlobal) {
this.debug(`Global rate limit hit, blocking all requests for ${timeout}ms`);
} 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.globalRemaining = this.manager.options.globalRequestsPerSecond;
}
this.manager.globalRemaining--;
const method = options.method ?? 'get';
if (this.manager.listenerCount(RESTEvents.Request)) {
this.manager.emit(RESTEvents.Request, {
method,
path: routeId.original,
route: routeId.bucketRoute,
options,
data: bodyData,
retries,
});
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.manager.options.timeout).unref();
let res: Response;
try {
// node-fetch typings are a bit weird, so we have to cast to any to get the correct signature
// Type 'AbortSignal' is not assignable to type 'import("discord.js-modules/node_modules/@types/node-fetch/externals").AbortSignal'
res = await fetch(url, { ...options, signal: controller.signal as any });
} 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) {
return this.runRequest(routeId, url, options, bodyData, ++retries);
}
throw error;
} finally {
clearTimeout(timeout);
}
if (this.manager.listenerCount(RESTEvents.Response)) {
this.manager.emit(
RESTEvents.Response,
{
method,
path: routeId.original,
route: routeId.bucketRoute,
options,
data: bodyData,
retries,
},
res.clone(),
);
}
let retryAfter = 0;
const limit = res.headers.get('X-RateLimit-Limit');
const remaining = res.headers.get('X-RateLimit-Remaining');
const reset = res.headers.get('X-RateLimit-Reset-After');
const hash = res.headers.get('X-RateLimit-Bucket');
const retry = res.headers.get('Retry-After');
// Update the total number of requests that can be made before the rate limit resets
this.limit = limit ? Number(limit) : 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();
// 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;
// Handle buckets via the hash header retroactively
if (hash && hash !== this.hash) {
// Let library users know when rate limit buckets have been updated
this.debug(['Received bucket hash update', ` Old Hash : ${this.hash}`, ` New Hash : ${hash}`].join('\n'));
// This queue will eventually be eliminated via attrition
this.manager.hashes.set(`${method}:${routeId.bucketRoute}`, hash);
}
// Handle retryAfter, which means we have actually hit a rate limit
let sublimitTimeout: number | null = null;
if (retryAfter > 0) {
if (res.headers.get('X-RateLimit-Global')) {
this.manager.globalRemaining = 0;
this.manager.globalReset = Date.now() + retryAfter;
} else if (!this.localLimited) {
/*
* This is a sublimit (e.g. 2 channel name changes/10 minutes) since the headers don't indicate a
* route-wide rate limit. Don't update remaining or reset to avoid rate limiting the whole
* endpoint, just set a reset time on the request itself to avoid retrying too soon.
*/
sublimitTimeout = retryAfter;
}
}
// Count the invalid requests
if (res.status === 401 || res.status === 403 || res.status === 429) {
if (!invalidCountResetTime || invalidCountResetTime < Date.now()) {
invalidCountResetTime = Date.now() + 1000 * 60 * 10;
invalidCount = 0;
}
invalidCount++;
const emitInvalid =
this.manager.options.invalidRequestWarningInterval > 0 &&
invalidCount % this.manager.options.invalidRequestWarningInterval === 0;
if (emitInvalid) {
// Let library users know periodically about invalid requests
this.manager.emit(RESTEvents.InvalidRequestWarning, {
count: invalidCount,
remainingTime: invalidCountResetTime - Date.now(),
});
}
}
if (res.ok) {
return parseResponse(res);
} else if (res.status === 429) {
// A rate limit was hit - this may happen if the route isn't associated with an official bucket hash yet, or when first globally rate limited
const isGlobal = this.globalLimited;
let limit: number;
let timeout: number;
if (isGlobal) {
// Set RateLimitData based on the global limit
limit = this.manager.options.globalRequestsPerSecond;
timeout = this.manager.globalReset + this.manager.options.offset - Date.now();
} else {
// Set RateLimitData based on the route-specific limit
limit = this.limit;
timeout = this.timeToReset;
}
await this.onRateLimit({
timeToReset: timeout,
limit,
method,
hash: this.hash,
url,
route: routeId.bucketRoute,
majorParameter: this.majorParameter,
global: isGlobal,
});
this.debug(
[
'Encountered unexpected 429 rate limit',
` Global : ${isGlobal.toString()}`,
` Method : ${method}`,
` URL : ${url}`,
` Bucket : ${routeId.bucketRoute}`,
` Major parameter: ${routeId.majorParameter}`,
` Hash : ${this.hash}`,
` Limit : ${limit}`,
` Retry After : ${retryAfter}ms`,
` Sublimit : ${sublimitTimeout ? `${sublimitTimeout}ms` : 'None'}`,
].join('\n'),
);
// If caused by a sublimit, wait it out here so other requests on the route can be handled
if (sublimitTimeout) {
// Normally the sublimit queue will not exist, however, if a sublimit is hit while in the sublimit queue, it will
const firstSublimit = !this.#sublimitedQueue;
if (firstSublimit) {
this.#sublimitedQueue = new AsyncQueue();
void this.#sublimitedQueue.wait();
this.#asyncQueue.shift();
}
this.#sublimitPromise?.resolve();
this.#sublimitPromise = null;
await sleep(sublimitTimeout, undefined, { ref: false });
let resolve: () => void;
const promise = new Promise<void>((res) => (resolve = res));
this.#sublimitPromise = { promise, resolve: resolve! };
if (firstSublimit) {
// Re-queue this request so it can be shifted by the finally
await this.#asyncQueue.wait();
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, bodyData, retries);
} else if (res.status >= 500 && res.status < 600) {
// Retry the specified number of times for possible server side issues
if (retries !== this.manager.options.retries) {
return this.runRequest(routeId, url, options, bodyData, ++retries);
}
// We are out of retries, throw an error
throw new HTTPError(res.statusText, res.constructor.name, res.status, method, url, bodyData);
} else {
// Handle possible malformed requests
if (res.status >= 400 && res.status < 500) {
// If we receive this status code, it means the token we had is no longer valid.
if (res.status === 401) {
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, res.status, method, url, bodyData);
}
return null;
}
}
}

View File

@@ -0,0 +1,41 @@
import { APIVersion } from 'discord-api-types/v9';
import type { RESTOptions } from '../REST';
// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports
const Package = require('../../../package.json');
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
export const DefaultUserAgent = `DiscordBot (${Package.homepage}, ${Package.version})`;
export const DefaultRestOptions: Required<RESTOptions> = {
agent: {},
api: 'https://discord.com/api',
cdn: 'https://cdn.discordapp.com',
headers: {},
invalidRequestWarningInterval: 0,
globalRequestsPerSecond: 50,
offset: 50,
rejectOnRateLimit: null,
retries: 3,
timeout: 15_000,
userAgentAppendix: `Node.js ${process.version}`,
version: APIVersion,
};
/**
* The events that the REST manager emits
*/
export const enum RESTEvents {
Debug = 'restDebug',
InvalidRequestWarning = 'invalidRequestWarning',
RateLimited = 'rateLimited',
Request = 'request',
Response = 'response',
}
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 type ImageExtension = typeof ALLOWED_EXTENSIONS[number];
export type StickerExtension = typeof ALLOWED_STICKER_EXTENSIONS[number];
export type ImageSize = typeof ALLOWED_SIZES[number];

View File

@@ -0,0 +1,37 @@
import type { RESTPatchAPIChannelJSONBody } from 'discord-api-types/v9';
import type { Response } from 'node-fetch';
import { RequestMethod } from '../RequestManager';
/**
* Converts the response to usable data
* @param res The node-fetch response
*/
export function parseResponse(res: Response): Promise<unknown> {
if (res.headers.get('Content-Type')?.startsWith('application/json')) {
return res.json();
}
return res.buffer();
}
/**
* Check whether a request falls under a sublimit
* @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 {
// TODO: Update for new sublimits
// Currently known sublimits:
// Editing channel `name` or `topic`
if (bucketRoute === '/channels/:id') {
if (typeof body !== 'object' || body === null) return false;
// This should never be a POST body, but just in case
if (method !== RequestMethod.Patch) return false;
const castedBody = body as RESTPatchAPIChannelJSONBody;
return ['name', 'topic'].some((key) => Reflect.has(castedBody, key));
}
return false;
}

View File

@@ -0,0 +1,20 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"allowJs": true
},
"include": [
"**/*.ts",
"**/*.tsx",
"**/*.js",
"**/*.mjs",
"**/*.jsx",
"**/*.test.ts",
"**/*.test.js",
"**/*.test.mjs",
"**/*.spec.ts",
"**/*.spec.js",
"**/*.spec.mjs"
],
"exclude": []
}

View File

@@ -0,0 +1,18 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
// Type Checking
"exactOptionalPropertyTypes": true,
// Modules
"rootDir": "./src",
// Emit
"outDir": "./dist",
"sourceRoot": "./",
// Projects
"composite": true
},
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,12 @@
import type { Options } from 'tsup';
export const tsup: Options = {
clean: true,
dts: false,
entryPoints: ['src/index.ts'],
format: ['esm', 'cjs'],
minify: true,
skipNodeModulesBundle: true,
sourcemap: true,
target: 'es2021',
};