forked from discordjs/discord.js-modules
-
Notifications
You must be signed in to change notification settings - Fork 0
/
RequestManager.ts
320 lines (275 loc) · 9.14 KB
/
RequestManager.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
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 } from 'node-fetch';
import type { IHandler } from './handlers/IHandler';
import { SequentialHandler } from './handlers/SequentialHandler';
import type { RESTOptions } from './REST';
import { DefaultRestOptions, DefaultUserAgent } from './utils/constants';
const agent = new Agent({ keepAlive: true });
/**
* Represents an attachment to be added to the request
*/
export interface RawAttachment {
fileName: string;
key?: string;
rawBuffer: Buffer;
}
/**
* Represents possible data to be given to an endpoint
*/
export interface RequestData {
/**
* 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
*/
body?: unknown;
/**
* Whether to append JSON data to form data isntead of `payload_json` when sending attachments
*/
dontUsePayloadJSON?: boolean;
/**
* Additional headers to add to this request
*/
headers?: Record<string, string>;
/**
* 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: string;
}
/**
* 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;
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 attachment of request.attachments) {
formData.append(attachment.key ?? attachment.fileName, 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.dontUsePayloadJSON) {
for (const [key, value] of Object.entries(request.body as any)) 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) {
// 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: string, 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,
};
}
}