Archived
0
0
Fork 0

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:
Daryl Ronningen 2021-07-07 11:53:55 -05:00
parent 8f2abedef0
commit eb5c3e0090
Signed by: Daryl Ronningen
GPG key ID: FD23F0C934A5EC6B
10 changed files with 220 additions and 59 deletions

View file

@ -66,6 +66,12 @@
"always" "always"
], ],
"no-var": "error", "no-var": "error",
"eqeqeq": "error",
"no-eq-null": "error",
"arrow-parens": [
"error",
"always"
],
// TypeScript Rules // TypeScript Rules
"@typescript-eslint/naming-convention": [ "@typescript-eslint/naming-convention": [
"error", "error",
@ -127,8 +133,8 @@
"builtin", "builtin",
"external", "external",
"internal", "internal",
"sibling",
"parent", "parent",
"sibling",
"index", "index",
"object", "object",
"type" "type"

View file

@ -1,11 +1,11 @@
import { ApiHandler } from './apiHandler'; import { ApiManager } from './apiManager';
import type { Client } from '../client/client'; import type { Client } from '../client/client';
import type { IRequestOptions } from '../utils/types'; import type { IRequestOptions } from '../utils/types';
export class ApiClient { export class ApiClient {
public client: Client; public client: Client;
public handler: ApiHandler; public manager: ApiManager;
private _token: string; private _token: string;
@ -13,11 +13,11 @@ export class ApiClient {
this.client = client; this.client = client;
this._token = token; 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> { public async delete<T>(options: IRequestOptions): Promise<T> {
return this.handler.makeRequest<T>({ return this.manager.createRequest<T>({
method: 'DELETE', method: 'DELETE',
body: options.body, body: options.body,
headers: options.headers, headers: options.headers,
@ -28,8 +28,8 @@ export class ApiClient {
}); });
} }
public async get<T>(options: IRequestOptions): Promise<T | null> { public async get<T>(options: IRequestOptions): Promise<T> {
return this.handler.makeRequest<T>({ return this.manager.createRequest<T>({
method: 'GET', method: 'GET',
body: options.body, body: options.body,
headers: options.headers, headers: options.headers,
@ -40,8 +40,8 @@ export class ApiClient {
}); });
} }
public async patch<T>(options: IRequestOptions): Promise<T | null> { public async patch<T>(options: IRequestOptions): Promise<T> {
return this.handler.makeRequest<T>({ return this.manager.createRequest<T>({
method: 'PATCH', method: 'PATCH',
body: options.body, body: options.body,
headers: options.headers, headers: options.headers,
@ -52,8 +52,8 @@ export class ApiClient {
}); });
} }
public async post<T>(options: IRequestOptions): Promise<T | null> { public async post<T>(options: IRequestOptions): Promise<T> {
return this.handler.makeRequest<T>({ return this.manager.createRequest<T>({
method: 'POST', method: 'POST',
body: options.body, body: options.body,
headers: options.headers, headers: options.headers,
@ -64,8 +64,8 @@ export class ApiClient {
}); });
} }
public async put<T>(options: IRequestOptions): Promise<T | null> { public async put<T>(options: IRequestOptions): Promise<T> {
return this.handler.makeRequest<T>({ return this.manager.createRequest<T>({
method: 'PUT', method: 'PUT',
body: options.body, body: options.body,
headers: options.headers, headers: options.headers,

View file

@ -3,68 +3,134 @@ import { queue, QueueObject } from 'async';
import FormData from 'form-data'; import FormData from 'form-data';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
import type { ApiManager } from './apiManager';
import type { Client } from '../client/client'; 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 { export class ApiHandler {
public client: Client; public client: Client;
public hash: string;
public id: string;
public limit: number;
public majorParameter: string;
public manager: ApiManager;
public queue: QueueObject<unknown>; public queue: QueueObject<unknown>;
public remaining: number;
public reset: number;
private _token: string; 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.client = client;
this._token = token; this._token = token;
this.queue = queue(async (requestOptions: IMakeRequestOptions, callback) => { this.hash = hash;
callback(await this.makeRequest(requestOptions)); 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 { public async makeRequest<T>(routeId: IRouteIdentifier, options: IMakeRequestOptions, retries = 0): Promise<T> {
return `${this.client.options.api.apiUrl}/v${this.client.options.api.apiVersion}`;
}
public async makeRequest<T>(options: IMakeRequestOptions): Promise<T | null> {
const headers: Map<string, string> = new Map(); const headers: Map<string, string> = new Map();
if (options.headers) for (const prop in options.headers) headers.set(prop, options.headers[prop]!); 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.reason) headers.set('X-Audit-Log-Reason', encodeURIComponent(options.reason));
if (options.requireAuth) headers.set('Authorization', `Bot ${this._token}`);
let body: FormData | string; let body: FormData | string;
if (options.files && options.files.length) { if (options.files && options.files.length) {
body = new FormData(); 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)); 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) { } else if (options.body) {
body = JSON.stringify(options.body); body = JSON.stringify(options.body);
headers.set('Content-Type', 'application/json'); headers.set('Content-Type', 'application/json');
} }
const controller = new AbortController(); 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 { try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
const res = await fetch(`${this.baseApiUrl}${options.path}`, { method: options.method, headers, signal: controller.signal, body }); 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) // TODO: Handle non-2xx responses (Will slowly add them as i get them)
if (res.ok) { if (res.ok) {
return res.json() as unknown as T; return res.json() as unknown as T;
} else if (res.status === 429) {
await sleep(retryAfter);
return this.makeRequest(routeId, options, retries++);
} else { } else {
return null; throw new Error('An unknown status code was returned!');
} }
} finally { } finally {
clearTimeout(timeout); clearTimeout(timeout);
} }
} }
public async push<T>(requestOptions: IMakeRequestOptions): Promise<T> { public async push<T>(routeId: IRouteIdentifier, requestOptions: IRequestOptions): Promise<T> {
return await this.queue.pushAsync<T>(requestOptions); 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
View 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,
};
}
}

View file

@ -12,4 +12,3 @@ export {
IMakeRequestOptions, IMakeRequestOptions,
Primitives, Primitives,
} from './utils/types'; } from './utils/types';
export { } from './utils/utils';

View file

@ -3,9 +3,12 @@ import type { IDefaultOptions } from './types';
export const defaults: IDefaultOptions = { export const defaults: IDefaultOptions = {
clientOptions: { clientOptions: {
api: { api: {
apiRequestTimeout: 5000, offset: 500,
apiUrl: 'https://discord.com/api', requestTimeout: 5000,
apiVersion: 9, retryLimit: 5,
sweepInterval: 60000,
url: 'https://discord.com/api',
version: 9,
}, },
}, },
}; };

5
src/utils/sleep.ts Normal file
View 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
View 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();
}
}

View file

@ -1,8 +1,11 @@
// Interfaces // Interfaces
export interface IApiClientOptions { export interface IApiClientOptions {
apiUrl?: string; offset?: number;
apiRequestTimeout?: number; requestTimeout?: number;
apiVersion?: number; retryLimit?: number;
sweepInterval?: number;
url?: string;
version?: number;
} }
export interface IClientOptions { export interface IClientOptions {
@ -23,48 +26,54 @@ export interface IMakeRequestOptions extends IRequestOptions {
} }
export interface IMessageEmbed { export interface IMessageEmbed {
description: string;
title?: string;
type?: 'rich';
url?: string;
color?: number;
timestamp?: number;
author?: { name?: string, url?: string }; author?: { name?: string, url?: string };
fields?: { name: string, value: string, inline?: boolean }[]; color?: number;
footer?: { text?: string, icon_url?: string }; description?: string;
fields?: { inline?: boolean, name: string, value: string }[];
footer?: { icon_url?: string, text?: string };
image?: { url?: string }; image?: { url?: string };
provider?: { name?: string, url?: string }; provider?: { name?: string, url?: string };
timestamp?: number;
title?: string;
thumbnail?: { url?: string }; thumbnail?: { url?: string };
type?: 'rich';
url?: string;
video?: { url?: string }; video?: { url?: string };
} }
export interface IRequestOptions { export interface IRequestOptions {
body?: unknown;
files?: IFile[];
headers?: Record<string, string>;
path: string; path: string;
reason?: string; reason?: string;
requireAuth?: boolean; requireAuth?: boolean;
body?: unknown; }
headers?: Record<string, string>;
files?: IFile[]; export interface IRouteIdentifier {
majorParameter: string;
route: string;
} }
// Api Rest Request Interfaces // Api Rest Request Interfaces
// TODO: Add message components // TODO: Add message components
export interface IApiCreateMessage { export interface IApiCreateMessage {
content?: string; allowed_mentions?: {
tts?: boolean; parse?: 'everyone' | 'roles' | 'users'[];
embeds?: IMessageEmbed[]; replied_user?: boolean;
allowed_mentions: {
roles?: string[]; roles?: string[];
users?: string[]; users?: string[];
replied_user?: boolean;
parse?: 'everyone' | 'roles' | 'users'[];
}; };
message_reference: { content?: string;
embeds?: IMessageEmbed[];
files?: IFile[];
message_reference?: {
channel_id?: string; channel_id?: string;
fail_if_not_exists?: boolean;
guild_id?: string; guild_id?: string;
message_id?: string; message_id?: string;
fail_if_not_exists?: boolean;
}; };
tts?: boolean;
} }
// Enums // Enums
@ -72,7 +81,7 @@ export interface IApiCreateMessage {
// Type Aliases // Type Aliases
export type ApiMethods = 'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT' ; export type ApiMethods = 'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT' ;
// eslint-disable-next-line @typescript-eslint/ban-types // 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 export type DeepRequired<T> = T extends Builtin
? NonNullable<T> ? NonNullable<T>
: T extends Map<infer K, infer V> : T extends Map<infer K, infer V>
@ -93,4 +102,4 @@ export type DeepRequired<T> = T extends Builtin
: T extends {} : T extends {}
? { [K in keyof T]-?: DeepRequired<T[K]> } ? { [K in keyof T]-?: DeepRequired<T[K]> }
: NonNullable<T>; : NonNullable<T>;
export type Primitives = string | number | boolean | bigint | symbol | undefined | null; export type Primitives = bigint | boolean | null | number | string | symbol | undefined;

View file