feat(api): added multiple things. see below
- Added ratelimiting - Rewrote api handling and take advantage of bucket hash - Removed the word "api" in api client options BREAKING CHANGE: Removed the word "api" in client options
This commit is contained in:
parent
8f2abedef0
commit
eb5c3e0090
10 changed files with 220 additions and 59 deletions
|
@ -66,6 +66,12 @@
|
|||
"always"
|
||||
],
|
||||
"no-var": "error",
|
||||
"eqeqeq": "error",
|
||||
"no-eq-null": "error",
|
||||
"arrow-parens": [
|
||||
"error",
|
||||
"always"
|
||||
],
|
||||
// TypeScript Rules
|
||||
"@typescript-eslint/naming-convention": [
|
||||
"error",
|
||||
|
@ -127,8 +133,8 @@
|
|||
"builtin",
|
||||
"external",
|
||||
"internal",
|
||||
"sibling",
|
||||
"parent",
|
||||
"sibling",
|
||||
"index",
|
||||
"object",
|
||||
"type"
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import { ApiHandler } from './apiHandler';
|
||||
import { ApiManager } from './apiManager';
|
||||
|
||||
import type { Client } from '../client/client';
|
||||
import type { IRequestOptions } from '../utils/types';
|
||||
|
||||
export class ApiClient {
|
||||
public client: Client;
|
||||
public handler: ApiHandler;
|
||||
public manager: ApiManager;
|
||||
|
||||
private _token: string;
|
||||
|
||||
|
@ -13,11 +13,11 @@ export class ApiClient {
|
|||
this.client = client;
|
||||
this._token = token;
|
||||
|
||||
this.handler = new ApiHandler(this.client, this._token);
|
||||
this.manager = new ApiManager(this.client, this._token);
|
||||
}
|
||||
|
||||
public async delete<T>(options: IRequestOptions): Promise<T | null> {
|
||||
return this.handler.makeRequest<T>({
|
||||
public async delete<T>(options: IRequestOptions): Promise<T> {
|
||||
return this.manager.createRequest<T>({
|
||||
method: 'DELETE',
|
||||
body: options.body,
|
||||
headers: options.headers,
|
||||
|
@ -28,8 +28,8 @@ export class ApiClient {
|
|||
});
|
||||
}
|
||||
|
||||
public async get<T>(options: IRequestOptions): Promise<T | null> {
|
||||
return this.handler.makeRequest<T>({
|
||||
public async get<T>(options: IRequestOptions): Promise<T> {
|
||||
return this.manager.createRequest<T>({
|
||||
method: 'GET',
|
||||
body: options.body,
|
||||
headers: options.headers,
|
||||
|
@ -40,8 +40,8 @@ export class ApiClient {
|
|||
});
|
||||
}
|
||||
|
||||
public async patch<T>(options: IRequestOptions): Promise<T | null> {
|
||||
return this.handler.makeRequest<T>({
|
||||
public async patch<T>(options: IRequestOptions): Promise<T> {
|
||||
return this.manager.createRequest<T>({
|
||||
method: 'PATCH',
|
||||
body: options.body,
|
||||
headers: options.headers,
|
||||
|
@ -52,8 +52,8 @@ export class ApiClient {
|
|||
});
|
||||
}
|
||||
|
||||
public async post<T>(options: IRequestOptions): Promise<T | null> {
|
||||
return this.handler.makeRequest<T>({
|
||||
public async post<T>(options: IRequestOptions): Promise<T> {
|
||||
return this.manager.createRequest<T>({
|
||||
method: 'POST',
|
||||
body: options.body,
|
||||
headers: options.headers,
|
||||
|
@ -64,8 +64,8 @@ export class ApiClient {
|
|||
});
|
||||
}
|
||||
|
||||
public async put<T>(options: IRequestOptions): Promise<T | null> {
|
||||
return this.handler.makeRequest<T>({
|
||||
public async put<T>(options: IRequestOptions): Promise<T> {
|
||||
return this.manager.createRequest<T>({
|
||||
method: 'PUT',
|
||||
body: options.body,
|
||||
headers: options.headers,
|
||||
|
|
|
@ -3,68 +3,134 @@ import { queue, QueueObject } from 'async';
|
|||
import FormData from 'form-data';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
import type { ApiManager } from './apiManager';
|
||||
import type { Client } from '../client/client';
|
||||
import type { IMakeRequestOptions } from '../utils/types';
|
||||
import type { IMakeRequestOptions, IRequestOptions, IRouteIdentifier } from '../utils/types';
|
||||
import { sleep } from '../utils/sleep';
|
||||
|
||||
export class ApiHandler {
|
||||
public client: Client;
|
||||
public hash: string;
|
||||
public id: string;
|
||||
public limit: number;
|
||||
public majorParameter: string;
|
||||
public manager: ApiManager;
|
||||
public queue: QueueObject<unknown>;
|
||||
public remaining: number;
|
||||
public reset: number;
|
||||
|
||||
private _token: string;
|
||||
|
||||
public constructor(client: Client, token: string) {
|
||||
public constructor(client: Client, manager: ApiManager, token: string, hash: string, majorParameter: string) {
|
||||
this.client = client;
|
||||
this._token = token;
|
||||
|
||||
this.queue = queue(async (requestOptions: IMakeRequestOptions, callback) => {
|
||||
callback(await this.makeRequest(requestOptions));
|
||||
this.hash = hash;
|
||||
this.id = `${hash}:${majorParameter}`;
|
||||
this.limit = -1;
|
||||
this.majorParameter = majorParameter;
|
||||
this.manager = manager;
|
||||
this.queue = queue(async (task: { routeId: IRouteIdentifier, requestOptions: IMakeRequestOptions; }, callback) => {
|
||||
if (this.limited) await sleep(this.timeToReset);
|
||||
callback(await this.makeRequest(task.routeId, task.requestOptions));
|
||||
});
|
||||
this.remaining = -1;
|
||||
this.reset = -1;
|
||||
}
|
||||
|
||||
public get baseApiUrl(): string {
|
||||
return `${this.client.options.api.apiUrl}/v${this.client.options.api.apiVersion}`;
|
||||
}
|
||||
|
||||
public async makeRequest<T>(options: IMakeRequestOptions): Promise<T | null> {
|
||||
public async makeRequest<T>(routeId: IRouteIdentifier, options: IMakeRequestOptions, retries = 0): Promise<T> {
|
||||
const headers: Map<string, string> = new Map();
|
||||
|
||||
if (options.headers) for (const prop in options.headers) headers.set(prop, options.headers[prop]!);
|
||||
|
||||
if (options.requireAuth) headers.set('Authorization', `Bot ${this._token}`);
|
||||
if (options.reason) headers.set('X-Audit-Log-Reason', encodeURIComponent(options.reason));
|
||||
if (options.requireAuth) headers.set('Authorization', `Bot ${this._token}`);
|
||||
|
||||
let body: FormData | string;
|
||||
if (options.files && options.files.length) {
|
||||
body = new FormData();
|
||||
for (const file of options.files) if (file && file.file) body.append(file.name, file.file, file.name);
|
||||
|
||||
if (options.body) body.append('payload_json', JSON.stringify(options.body));
|
||||
|
||||
for (const file of options.files) if (file && file.file) body.append(file.name, file.file, file.name);
|
||||
} else if (options.body) {
|
||||
body = JSON.stringify(options.body);
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), this.client.options.api.apiRequestTimeout);
|
||||
const timeout = setTimeout(() => controller.abort(), this.client.options.api.requestTimeout);
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const res = await fetch(`${this.baseApiUrl}${options.path}`, { method: options.method, headers, signal: controller.signal, body });
|
||||
|
||||
// TODO: handle Ratelimits
|
||||
const bucketHash = res.headers.get('x-ratelimit-bucket');
|
||||
const globalRatelimit = res.headers.get('x-ratelimit-global');
|
||||
const limit = res.headers.get('x-ratelimit-limit');
|
||||
const remaining = res.headers.get('x-ratelimit-remaining');
|
||||
const reset = res.headers.get('x-ratelimit-reset');
|
||||
const resetAfter = res.headers.get('x-ratelimit-reset-after');
|
||||
const retry = res.headers.get('retry-after');
|
||||
const serverDate = res.headers.get('date');
|
||||
|
||||
if (!resetAfter && options.path.includes('reactions')) {
|
||||
this.reset = new Date(Number(serverDate)).getTime() - (new Date(Number(serverDate)).getTime() - Date.now()) + 250;
|
||||
}
|
||||
|
||||
let retryAfter = 0;
|
||||
|
||||
this.limit = limit !== null ? Number(limit) : Number.MAX_VALUE;
|
||||
this.remaining = remaining !== null ? Number(remaining) : Number.MAX_VALUE;
|
||||
this.reset = reset || resetAfter
|
||||
? Number(reset) * 1000 + Date.now() + this.client.options.api.offset
|
||||
: Date.now();
|
||||
|
||||
if (retry !== null)
|
||||
retryAfter = retry ? Number(retry) * 1000 : -1;
|
||||
|
||||
if (bucketHash !== null && bucketHash !== this.hash) {
|
||||
if (!this.manager.hashes.has(`${options.method}-${routeId.route}`))
|
||||
this.manager.hashes.set(`${options.method}-${routeId.route}`, bucketHash);
|
||||
}
|
||||
|
||||
if (globalRatelimit) {
|
||||
this.manager.globalTimeout = true;
|
||||
this.manager.globalReset = Date.now() + retryAfter;
|
||||
}
|
||||
|
||||
// TODO: Handle non-2xx responses (Will slowly add them as i get them)
|
||||
if (res.ok) {
|
||||
return res.json() as unknown as T;
|
||||
} else if (res.status === 429) {
|
||||
await sleep(retryAfter);
|
||||
return this.makeRequest(routeId, options, retries++);
|
||||
} else {
|
||||
return null;
|
||||
throw new Error('An unknown status code was returned!');
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
public async push<T>(requestOptions: IMakeRequestOptions): Promise<T> {
|
||||
return await this.queue.pushAsync<T>(requestOptions);
|
||||
public async push<T>(routeId: IRouteIdentifier, requestOptions: IRequestOptions): Promise<T> {
|
||||
return await this.queue.pushAsync<T>({ routeId, requestOptions });
|
||||
}
|
||||
|
||||
public get baseApiUrl(): string {
|
||||
return `${this.client.options.api.url}/v${this.client.options.api.version}`;
|
||||
}
|
||||
|
||||
public get inactive(): boolean {
|
||||
return this.queue.length() === 0 && !this.limited;
|
||||
}
|
||||
|
||||
public get limited(): boolean {
|
||||
if (typeof this.manager.globalTimeout === 'number') return this.manager.globalTimeout <= 0 && Date.now() < this.manager.globalReset;
|
||||
else return this.remaining <= 0 && Date.now() < this.reset;
|
||||
}
|
||||
|
||||
public get timeToReset(): number {
|
||||
return this.reset - Date.now();
|
||||
}
|
||||
}
|
||||
|
|
64
src/api/apiManager.ts
Normal file
64
src/api/apiManager.ts
Normal file
|
@ -0,0 +1,64 @@
|
|||
import { ApiHandler } from './apiHandler';
|
||||
import { Snowflake } from '../utils/snowflake';
|
||||
|
||||
import type { Client } from '../client/client';
|
||||
import type { ApiMethods, IMakeRequestOptions, IRouteIdentifier } from '../utils/types';
|
||||
|
||||
export class ApiManager {
|
||||
public client: Client;
|
||||
public globalReset: number;
|
||||
public globalTimeout: boolean | null;
|
||||
public hashes: Map<string, string>;
|
||||
public queues: Map<string, ApiHandler>;
|
||||
|
||||
private _token: string;
|
||||
|
||||
public constructor(client: Client, token: string) {
|
||||
this.client = client;
|
||||
this._token = token;
|
||||
|
||||
this.globalReset = 0;
|
||||
this.globalTimeout = null;
|
||||
this.hashes = new Map();
|
||||
this.queues = new Map();
|
||||
}
|
||||
|
||||
public async createRequest<T>(requestOptions: IMakeRequestOptions): Promise<T> {
|
||||
const routeId = this.generateRouteIdentifiers(requestOptions.path, requestOptions.method);
|
||||
let hash = this.hashes.get(`${requestOptions.method}-${routeId.route}`);
|
||||
|
||||
if (!hash) hash = `UnknownHash(${routeId.route})`;
|
||||
|
||||
let queue = this.queues.get(`${hash}:${routeId.majorParameter}`);
|
||||
|
||||
if (queue === null) {
|
||||
const apiHandler = new ApiHandler(this.client, this, this._token, hash, routeId.majorParameter);
|
||||
this.queues.set(apiHandler.id, apiHandler);
|
||||
|
||||
queue = this.queues.get(`${hash}:${routeId.majorParameter}`);
|
||||
}
|
||||
|
||||
return await queue!.push<T>(routeId, requestOptions);
|
||||
}
|
||||
|
||||
public generateRouteIdentifiers(endpoint: string, method: ApiMethods): IRouteIdentifier {
|
||||
const result = new RegExp('^/(?:channels|guilds|webhooks)/(d{16,19})').exec(endpoint);
|
||||
const majorParameter = result && result.length !== 0 ? result[1]!.toString() : 'global';
|
||||
const baseRoute = endpoint.replace(new RegExp('d{16,19}'), ':id');
|
||||
|
||||
let exceptions = '';
|
||||
|
||||
if (method === 'DELETE' && baseRoute === '/channels/:id/messages/:id') {
|
||||
const id = new RegExp('d{16,19}$').exec(endpoint);
|
||||
const snowflake = Snowflake.fromSnowflake(id![0]!);
|
||||
|
||||
if (Date.now() - (new Date(snowflake).getTime() / 1000) > 1000 * 60 * 60 * 24 * 14)
|
||||
exceptions += '[Delete Old Message]';
|
||||
}
|
||||
|
||||
return {
|
||||
route: `${baseRoute}${exceptions}`,
|
||||
majorParameter,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -12,4 +12,3 @@ export {
|
|||
IMakeRequestOptions,
|
||||
Primitives,
|
||||
} from './utils/types';
|
||||
export { } from './utils/utils';
|
||||
|
|
|
@ -3,9 +3,12 @@ import type { IDefaultOptions } from './types';
|
|||
export const defaults: IDefaultOptions = {
|
||||
clientOptions: {
|
||||
api: {
|
||||
apiRequestTimeout: 5000,
|
||||
apiUrl: 'https://discord.com/api',
|
||||
apiVersion: 9,
|
||||
offset: 500,
|
||||
requestTimeout: 5000,
|
||||
retryLimit: 5,
|
||||
sweepInterval: 60000,
|
||||
url: 'https://discord.com/api',
|
||||
version: 9,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
5
src/utils/sleep.ts
Normal file
5
src/utils/sleep.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export const sleep = async (ms: number): Promise<void> => {
|
||||
return new Promise((res) => {
|
||||
setTimeout(() => res(), ms);
|
||||
});
|
||||
};
|
9
src/utils/snowflake.ts
Normal file
9
src/utils/snowflake.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
export class Snowflake {
|
||||
public static fromSnowflake(value: string): number {
|
||||
return new Date((Number(value) >> 22) + 1420070400000).getTime() / 1000;
|
||||
}
|
||||
|
||||
public static toSnowflake(value: number): string {
|
||||
return (((new Date(value).getTime() / 1000) - 1420070400000) << 22).toString();
|
||||
}
|
||||
}
|
|
@ -1,8 +1,11 @@
|
|||
// Interfaces
|
||||
export interface IApiClientOptions {
|
||||
apiUrl?: string;
|
||||
apiRequestTimeout?: number;
|
||||
apiVersion?: number;
|
||||
offset?: number;
|
||||
requestTimeout?: number;
|
||||
retryLimit?: number;
|
||||
sweepInterval?: number;
|
||||
url?: string;
|
||||
version?: number;
|
||||
}
|
||||
|
||||
export interface IClientOptions {
|
||||
|
@ -23,48 +26,54 @@ export interface IMakeRequestOptions extends IRequestOptions {
|
|||
}
|
||||
|
||||
export interface IMessageEmbed {
|
||||
description: string;
|
||||
title?: string;
|
||||
type?: 'rich';
|
||||
url?: string;
|
||||
color?: number;
|
||||
timestamp?: number;
|
||||
author?: { name?: string, url?: string };
|
||||
fields?: { name: string, value: string, inline?: boolean }[];
|
||||
footer?: { text?: string, icon_url?: string };
|
||||
color?: number;
|
||||
description?: string;
|
||||
fields?: { inline?: boolean, name: string, value: string }[];
|
||||
footer?: { icon_url?: string, text?: string };
|
||||
image?: { url?: string };
|
||||
provider?: { name?: string, url?: string };
|
||||
timestamp?: number;
|
||||
title?: string;
|
||||
thumbnail?: { url?: string };
|
||||
type?: 'rich';
|
||||
url?: string;
|
||||
video?: { url?: string };
|
||||
}
|
||||
|
||||
export interface IRequestOptions {
|
||||
body?: unknown;
|
||||
files?: IFile[];
|
||||
headers?: Record<string, string>;
|
||||
path: string;
|
||||
reason?: string;
|
||||
requireAuth?: boolean;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
files?: IFile[];
|
||||
}
|
||||
|
||||
export interface IRouteIdentifier {
|
||||
majorParameter: string;
|
||||
route: string;
|
||||
}
|
||||
|
||||
// Api Rest Request Interfaces
|
||||
// TODO: Add message components
|
||||
export interface IApiCreateMessage {
|
||||
content?: string;
|
||||
tts?: boolean;
|
||||
embeds?: IMessageEmbed[];
|
||||
allowed_mentions: {
|
||||
allowed_mentions?: {
|
||||
parse?: 'everyone' | 'roles' | 'users'[];
|
||||
replied_user?: boolean;
|
||||
roles?: string[];
|
||||
users?: string[];
|
||||
replied_user?: boolean;
|
||||
parse?: 'everyone' | 'roles' | 'users'[];
|
||||
};
|
||||
message_reference: {
|
||||
content?: string;
|
||||
embeds?: IMessageEmbed[];
|
||||
files?: IFile[];
|
||||
message_reference?: {
|
||||
channel_id?: string;
|
||||
fail_if_not_exists?: boolean;
|
||||
guild_id?: string;
|
||||
message_id?: string;
|
||||
fail_if_not_exists?: boolean;
|
||||
};
|
||||
tts?: boolean;
|
||||
}
|
||||
|
||||
// Enums
|
||||
|
@ -72,7 +81,7 @@ export interface IApiCreateMessage {
|
|||
// Type Aliases
|
||||
export type ApiMethods = 'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT' ;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
export type Builtin = Primitives | Function | Date | Error | RegExp;
|
||||
export type Builtin = Date | Error | Function | Primitives | RegExp;
|
||||
export type DeepRequired<T> = T extends Builtin
|
||||
? NonNullable<T>
|
||||
: T extends Map<infer K, infer V>
|
||||
|
@ -93,4 +102,4 @@ export type DeepRequired<T> = T extends Builtin
|
|||
: T extends {}
|
||||
? { [K in keyof T]-?: DeepRequired<T[K]> }
|
||||
: NonNullable<T>;
|
||||
export type Primitives = string | number | boolean | bigint | symbol | undefined | null;
|
||||
export type Primitives = bigint | boolean | null | number | string | symbol | undefined;
|
||||
|
|
Reference in a new issue