@ovencord/rest 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +192 -0
- package/README.md +94 -0
- package/package.json +58 -0
- package/src/environment.ts +11 -0
- package/src/index.ts +8 -0
- package/src/lib/CDN.ts +407 -0
- package/src/lib/REST.ts +472 -0
- package/src/lib/errors/DiscordAPIError.ts +116 -0
- package/src/lib/errors/HTTPError.ts +29 -0
- package/src/lib/errors/RateLimitError.ts +47 -0
- package/src/lib/handlers/BurstHandler.ts +153 -0
- package/src/lib/handlers/SequentialHandler.ts +431 -0
- package/src/lib/handlers/Shared.ts +205 -0
- package/src/lib/interfaces/Handler.ts +27 -0
- package/src/lib/utils/AsyncEventEmitter.ts +2 -0
- package/src/lib/utils/AsyncQueue.ts +64 -0
- package/src/lib/utils/constants.ts +65 -0
- package/src/lib/utils/types.ts +406 -0
- package/src/lib/utils/utils.ts +248 -0
- package/src/shared.ts +16 -0
- package/src/strategies/bunRequest.ts +32 -0
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import type { Collection } from '@ovencord/collection';
|
|
2
|
+
import type { Awaitable, RawFile } from '@ovencord/util';
|
|
3
|
+
import type { IHandler } from '../interfaces/Handler.js';
|
|
4
|
+
|
|
5
|
+
export interface RestEvents {
|
|
6
|
+
handlerSweep: [sweptHandlers: Collection<string, IHandler>];
|
|
7
|
+
hashSweep: [sweptHashes: Collection<string, HashData>];
|
|
8
|
+
invalidRequestWarning: [invalidRequestInfo: InvalidRequestWarningData];
|
|
9
|
+
rateLimited: [rateLimitInfo: RateLimitData];
|
|
10
|
+
response: [request: APIRequest, response: ResponseLike];
|
|
11
|
+
restDebug: [info: string];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
15
|
+
export interface RestEventsMap extends RestEvents {}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Options to be passed when creating the REST instance
|
|
19
|
+
*/
|
|
20
|
+
export interface RESTOptions {
|
|
21
|
+
/**
|
|
22
|
+
* The agent to set globally
|
|
23
|
+
*
|
|
24
|
+
* @deprecated This property is deprecated and has no effect when using Bun native fetch.
|
|
25
|
+
* It is kept for API compatibility but will always be `null`.
|
|
26
|
+
*/
|
|
27
|
+
agent: null;
|
|
28
|
+
/**
|
|
29
|
+
* The base api path, without version
|
|
30
|
+
*
|
|
31
|
+
* @defaultValue `'https://discord.com/api'`
|
|
32
|
+
*/
|
|
33
|
+
api: string;
|
|
34
|
+
/**
|
|
35
|
+
* The authorization prefix to use for requests, useful if you want to use
|
|
36
|
+
* bearer tokens
|
|
37
|
+
*
|
|
38
|
+
* @defaultValue `'Bot'`
|
|
39
|
+
*/
|
|
40
|
+
authPrefix: 'Bearer' | 'Bot';
|
|
41
|
+
/**
|
|
42
|
+
* The cdn path
|
|
43
|
+
*
|
|
44
|
+
* @defaultValue `'https://cdn.discordapp.com'`
|
|
45
|
+
*/
|
|
46
|
+
cdn: string;
|
|
47
|
+
/**
|
|
48
|
+
* How many requests to allow sending per second (Infinity for unlimited, 50 for the standard global limit used by Discord)
|
|
49
|
+
*
|
|
50
|
+
* @defaultValue `50`
|
|
51
|
+
*/
|
|
52
|
+
globalRequestsPerSecond: number;
|
|
53
|
+
/**
|
|
54
|
+
* The amount of time in milliseconds that passes between each hash sweep. (defaults to 1h)
|
|
55
|
+
*
|
|
56
|
+
* @defaultValue `3_600_000`
|
|
57
|
+
*/
|
|
58
|
+
handlerSweepInterval: number;
|
|
59
|
+
/**
|
|
60
|
+
* The maximum amount of time a hash can exist in milliseconds without being hit with a request (defaults to 24h)
|
|
61
|
+
*
|
|
62
|
+
* @defaultValue `86_400_000`
|
|
63
|
+
*/
|
|
64
|
+
hashLifetime: number;
|
|
65
|
+
/**
|
|
66
|
+
* The amount of time in milliseconds that passes between each hash sweep. (defaults to 4h)
|
|
67
|
+
*
|
|
68
|
+
* @defaultValue `14_400_000`
|
|
69
|
+
*/
|
|
70
|
+
hashSweepInterval: number;
|
|
71
|
+
/**
|
|
72
|
+
* Additional headers to send for all API requests
|
|
73
|
+
*
|
|
74
|
+
* @defaultValue `{}`
|
|
75
|
+
*/
|
|
76
|
+
headers: Record<string, string>;
|
|
77
|
+
/**
|
|
78
|
+
* 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).
|
|
79
|
+
* That is, if set to 500, warnings will be emitted at invalid request number 500, 1000, 1500, and so on.
|
|
80
|
+
*
|
|
81
|
+
* @defaultValue `0`
|
|
82
|
+
*/
|
|
83
|
+
invalidRequestWarningInterval: number;
|
|
84
|
+
/**
|
|
85
|
+
* The method called to perform the actual HTTP request given a url and web `fetch` options
|
|
86
|
+
* For example, to use global fetch, simply provide `makeRequest: fetch`
|
|
87
|
+
*/
|
|
88
|
+
makeRequest(url: string, init: RequestInit): Promise<ResponseLike>;
|
|
89
|
+
/**
|
|
90
|
+
* The media proxy path
|
|
91
|
+
*
|
|
92
|
+
* @defaultValue `'https://media.discordapp.net'`
|
|
93
|
+
*/
|
|
94
|
+
mediaProxy: string;
|
|
95
|
+
/**
|
|
96
|
+
* The extra offset to add to rate limits in milliseconds
|
|
97
|
+
*
|
|
98
|
+
* @defaultValue `50`
|
|
99
|
+
*/
|
|
100
|
+
offset: GetRateLimitOffsetFunction | number;
|
|
101
|
+
/**
|
|
102
|
+
* Determines how rate limiting and pre-emptive throttling should be handled.
|
|
103
|
+
* When an array of strings, each element is treated as a prefix for the request route
|
|
104
|
+
* (e.g. `/channels` to match any route starting with `/channels` such as `/channels/:id/messages`)
|
|
105
|
+
* for which to throw {@link RateLimitError}s. All other request routes will be queued normally
|
|
106
|
+
*
|
|
107
|
+
* @defaultValue `null`
|
|
108
|
+
*/
|
|
109
|
+
rejectOnRateLimit: RateLimitQueueFilter | string[] | null;
|
|
110
|
+
/**
|
|
111
|
+
* The number of retries for errors with the 500 code, or errors
|
|
112
|
+
* that timeout
|
|
113
|
+
*
|
|
114
|
+
* @defaultValue `3`
|
|
115
|
+
*/
|
|
116
|
+
retries: number;
|
|
117
|
+
/**
|
|
118
|
+
* The time to exponentially add before retrying a 5xx or aborted request
|
|
119
|
+
*
|
|
120
|
+
* @defaultValue `0`
|
|
121
|
+
*/
|
|
122
|
+
retryBackoff: GetRetryBackoffFunction | number;
|
|
123
|
+
/**
|
|
124
|
+
* The time to wait in milliseconds before a request is aborted
|
|
125
|
+
*
|
|
126
|
+
* @defaultValue `15_000`
|
|
127
|
+
*/
|
|
128
|
+
timeout: GetTimeoutFunction | number;
|
|
129
|
+
/**
|
|
130
|
+
* Extra information to add to the user agent
|
|
131
|
+
*
|
|
132
|
+
* @defaultValue DefaultUserAgentAppendix
|
|
133
|
+
*/
|
|
134
|
+
userAgentAppendix: string;
|
|
135
|
+
/**
|
|
136
|
+
* The version of the API to use
|
|
137
|
+
*
|
|
138
|
+
* @defaultValue `'10'`
|
|
139
|
+
*/
|
|
140
|
+
version: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Data emitted on `RESTEvents.RateLimited`
|
|
145
|
+
*/
|
|
146
|
+
export interface RateLimitData {
|
|
147
|
+
/**
|
|
148
|
+
* Whether the rate limit that was reached was the global limit
|
|
149
|
+
*/
|
|
150
|
+
global: boolean;
|
|
151
|
+
/**
|
|
152
|
+
* The bucket hash for this request
|
|
153
|
+
*/
|
|
154
|
+
hash: string;
|
|
155
|
+
/**
|
|
156
|
+
* The amount of requests we can perform before locking requests
|
|
157
|
+
*/
|
|
158
|
+
limit: number;
|
|
159
|
+
/**
|
|
160
|
+
* The major parameter of the route
|
|
161
|
+
*
|
|
162
|
+
* For example, in `/channels/x`, this will be `x`.
|
|
163
|
+
* If there is no major parameter (e.g: `/bot/gateway`) this will be `global`.
|
|
164
|
+
*/
|
|
165
|
+
majorParameter: string;
|
|
166
|
+
/**
|
|
167
|
+
* The HTTP method being performed
|
|
168
|
+
*/
|
|
169
|
+
method: string;
|
|
170
|
+
/**
|
|
171
|
+
* The time, in milliseconds, that will need to pass before this specific request can be retried
|
|
172
|
+
*/
|
|
173
|
+
retryAfter: number;
|
|
174
|
+
/**
|
|
175
|
+
* The route being hit in this request
|
|
176
|
+
*/
|
|
177
|
+
route: string;
|
|
178
|
+
/**
|
|
179
|
+
* The scope of the rate limit that was hit.
|
|
180
|
+
*
|
|
181
|
+
* This can be `user` for rate limits that are per client, `global` for rate limits that affect all clients or `shared` for rate limits that
|
|
182
|
+
* are shared per resource.
|
|
183
|
+
*/
|
|
184
|
+
scope: 'global' | 'shared' | 'user';
|
|
185
|
+
/**
|
|
186
|
+
* The time, in milliseconds, that will need to pass before the sublimit lock for the route resets, and requests that fall under a sublimit
|
|
187
|
+
* can be retried
|
|
188
|
+
*
|
|
189
|
+
* This is only present on certain sublimits, and `0` otherwise
|
|
190
|
+
*/
|
|
191
|
+
sublimitTimeout: number;
|
|
192
|
+
/**
|
|
193
|
+
* The time, in milliseconds, until the route's request-lock is reset
|
|
194
|
+
*/
|
|
195
|
+
timeToReset: number;
|
|
196
|
+
/**
|
|
197
|
+
* The full URL for this request
|
|
198
|
+
*/
|
|
199
|
+
url: string;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* A function that determines whether the rate limit hit should throw an Error
|
|
204
|
+
*/
|
|
205
|
+
export type RateLimitQueueFilter = (rateLimitData: RateLimitData) => Awaitable<boolean>;
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* A function that determines the rate limit offset for a given request.
|
|
209
|
+
*/
|
|
210
|
+
export type GetRateLimitOffsetFunction = (route: string) => number;
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* A function that determines the backoff for a retry for a given request.
|
|
214
|
+
*
|
|
215
|
+
* @param route - The route that has encountered a server-side error
|
|
216
|
+
* @param statusCode - The status code received or `null` if aborted
|
|
217
|
+
* @param retryCount - The number of retries that have been attempted so far. The first call will be `0`
|
|
218
|
+
* @param requestBody - The body that was sent with the request
|
|
219
|
+
* @returns The delay for the current request or `null` to throw an error instead of retrying
|
|
220
|
+
*/
|
|
221
|
+
export type GetRetryBackoffFunction = (
|
|
222
|
+
route: string,
|
|
223
|
+
statusCode: number | null,
|
|
224
|
+
retryCount: number,
|
|
225
|
+
requestBody: unknown,
|
|
226
|
+
) => number | null;
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* A function that determines the timeout for a given request.
|
|
230
|
+
*
|
|
231
|
+
* @param route - The route that is being processed
|
|
232
|
+
* @param body - The body that will be sent with the request
|
|
233
|
+
*/
|
|
234
|
+
export type GetTimeoutFunction = (route: string, body: unknown) => number;
|
|
235
|
+
|
|
236
|
+
export interface APIRequest {
|
|
237
|
+
/**
|
|
238
|
+
* The data that was used to form the body of this request
|
|
239
|
+
*/
|
|
240
|
+
data: HandlerRequestData;
|
|
241
|
+
/**
|
|
242
|
+
* The HTTP method used in this request
|
|
243
|
+
*/
|
|
244
|
+
method: string;
|
|
245
|
+
/**
|
|
246
|
+
* Additional HTTP options for this request
|
|
247
|
+
*/
|
|
248
|
+
options: RequestInit;
|
|
249
|
+
/**
|
|
250
|
+
* The full path used to make the request
|
|
251
|
+
*/
|
|
252
|
+
path: RouteLike;
|
|
253
|
+
/**
|
|
254
|
+
* The number of times this request has been attempted
|
|
255
|
+
*/
|
|
256
|
+
retries: number;
|
|
257
|
+
/**
|
|
258
|
+
* The API route identifying the ratelimit for this request
|
|
259
|
+
*/
|
|
260
|
+
route: string;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export interface ResponseLike extends Pick<
|
|
264
|
+
globalThis.Response,
|
|
265
|
+
'arrayBuffer' | 'bodyUsed' | 'headers' | 'json' | 'ok' | 'status' | 'statusText' | 'text'
|
|
266
|
+
> {
|
|
267
|
+
body: ReadableStream | null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export interface InvalidRequestWarningData {
|
|
271
|
+
/**
|
|
272
|
+
* Number of invalid requests that have been made in the window
|
|
273
|
+
*/
|
|
274
|
+
count: number;
|
|
275
|
+
/**
|
|
276
|
+
* Time in milliseconds remaining before the count resets
|
|
277
|
+
*/
|
|
278
|
+
remainingTime: number;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export type { RawFile } from '@ovencord/util';
|
|
282
|
+
|
|
283
|
+
export interface AuthData {
|
|
284
|
+
/**
|
|
285
|
+
* The authorization prefix to use for this request, useful if you use this with bearer tokens
|
|
286
|
+
*
|
|
287
|
+
* @defaultValue `REST.options.authPrefix`
|
|
288
|
+
*/
|
|
289
|
+
prefix?: 'Bearer' | 'Bot';
|
|
290
|
+
/**
|
|
291
|
+
* The authorization token to use for this request
|
|
292
|
+
*/
|
|
293
|
+
token: string;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Represents possible data to be given to an endpoint
|
|
298
|
+
*/
|
|
299
|
+
export interface RequestData {
|
|
300
|
+
/**
|
|
301
|
+
* Whether to append JSON data to form data instead of `payload_json` when sending files
|
|
302
|
+
*/
|
|
303
|
+
appendToFormData?: boolean;
|
|
304
|
+
/**
|
|
305
|
+
* Alternate authorization data to use for this request only, or `false` to disable the Authorization header.
|
|
306
|
+
* When making a request to a route that includes a token (such as interactions or webhooks), set to `false`
|
|
307
|
+
* to avoid accidentally unsetting the instance token if a 401 is encountered.
|
|
308
|
+
*
|
|
309
|
+
* @defaultValue `true`
|
|
310
|
+
*/
|
|
311
|
+
auth?: AuthData | boolean | undefined;
|
|
312
|
+
/**
|
|
313
|
+
* The body to send to this request.
|
|
314
|
+
* If providing as BodyInit, set `passThroughBody: true`
|
|
315
|
+
*/
|
|
316
|
+
body?: BodyInit | unknown;
|
|
317
|
+
/**
|
|
318
|
+
* The {@link https://undici.nodejs.org/#/docs/api/Agent | Agent} to use for the request.
|
|
319
|
+
*
|
|
320
|
+
* @deprecated This property is deprecated and has no effect when using Bun native fetch.
|
|
321
|
+
* It is kept for API compatibility but will be ignored.
|
|
322
|
+
*/
|
|
323
|
+
dispatcher?: never;
|
|
324
|
+
/**
|
|
325
|
+
* Files to be attached to this request
|
|
326
|
+
*/
|
|
327
|
+
files?: RawFile[] | undefined;
|
|
328
|
+
/**
|
|
329
|
+
* Additional headers to add to this request
|
|
330
|
+
*/
|
|
331
|
+
headers?: Record<string, string>;
|
|
332
|
+
/**
|
|
333
|
+
* Whether to pass-through the body property directly to `fetch()`.
|
|
334
|
+
* <warn>This only applies when files is NOT present</warn>
|
|
335
|
+
*/
|
|
336
|
+
passThroughBody?: boolean;
|
|
337
|
+
/**
|
|
338
|
+
* Query string parameters to append to the called endpoint
|
|
339
|
+
*/
|
|
340
|
+
query?: URLSearchParams;
|
|
341
|
+
/**
|
|
342
|
+
* Reason to show in the audit logs
|
|
343
|
+
*/
|
|
344
|
+
reason?: string | undefined;
|
|
345
|
+
/**
|
|
346
|
+
* The signal to abort the queue entry or the REST call, where applicable
|
|
347
|
+
*/
|
|
348
|
+
signal?: AbortSignal | undefined;
|
|
349
|
+
/**
|
|
350
|
+
* If this request should be versioned
|
|
351
|
+
*
|
|
352
|
+
* @defaultValue `true`
|
|
353
|
+
*/
|
|
354
|
+
versioned?: boolean;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Possible headers for an API call
|
|
359
|
+
*/
|
|
360
|
+
export interface RequestHeaders {
|
|
361
|
+
Authorization?: string;
|
|
362
|
+
'User-Agent': string;
|
|
363
|
+
'X-Audit-Log-Reason'?: string;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Possible API methods to be used when doing requests
|
|
368
|
+
*/
|
|
369
|
+
export enum RequestMethod {
|
|
370
|
+
Delete = 'DELETE',
|
|
371
|
+
Get = 'GET',
|
|
372
|
+
Patch = 'PATCH',
|
|
373
|
+
Post = 'POST',
|
|
374
|
+
Put = 'PUT',
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export type RouteLike = `/${string}`;
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Internal request options
|
|
381
|
+
*/
|
|
382
|
+
export interface InternalRequest extends RequestData {
|
|
383
|
+
fullRoute: RouteLike;
|
|
384
|
+
method: RequestMethod;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export interface HandlerRequestData extends Pick<InternalRequest, 'body' | 'files' | 'signal'> {
|
|
388
|
+
auth: boolean | string;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Parsed route data for an endpoint
|
|
393
|
+
*/
|
|
394
|
+
export interface RouteData {
|
|
395
|
+
bucketRoute: string;
|
|
396
|
+
majorParameter: string;
|
|
397
|
+
original: RouteLike;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Represents a hash and its associated fields
|
|
402
|
+
*/
|
|
403
|
+
export interface HashData {
|
|
404
|
+
lastAccess: number;
|
|
405
|
+
value: string;
|
|
406
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import type { RESTPatchAPIChannelJSONBody, Snowflake } from 'discord-api-types/v10';
|
|
3
|
+
import type { REST } from '../REST.js';
|
|
4
|
+
import { RateLimitError } from '../errors/RateLimitError.js';
|
|
5
|
+
import { RequestMethod } from './types.js';
|
|
6
|
+
import type {
|
|
7
|
+
GetRateLimitOffsetFunction,
|
|
8
|
+
GetRetryBackoffFunction,
|
|
9
|
+
GetTimeoutFunction,
|
|
10
|
+
RateLimitData,
|
|
11
|
+
ResponseLike,
|
|
12
|
+
} from './types.js';
|
|
13
|
+
|
|
14
|
+
function serializeSearchParam(value: unknown): string | null {
|
|
15
|
+
switch (typeof value) {
|
|
16
|
+
case 'string':
|
|
17
|
+
return value;
|
|
18
|
+
case 'number':
|
|
19
|
+
case 'bigint':
|
|
20
|
+
case 'boolean':
|
|
21
|
+
return value.toString();
|
|
22
|
+
case 'object':
|
|
23
|
+
if (value === null) return null;
|
|
24
|
+
if (value instanceof Date) {
|
|
25
|
+
return Number.isNaN(value.getTime()) ? null : value.toISOString();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (typeof value.toString === 'function' && value.toString !== Object.prototype.toString) return value.toString();
|
|
29
|
+
return null;
|
|
30
|
+
default:
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Creates and populates an URLSearchParams instance from an object, stripping
|
|
37
|
+
* out null and undefined values, while also coercing non-strings to strings.
|
|
38
|
+
*
|
|
39
|
+
* @param options - The options to use
|
|
40
|
+
* @returns A populated URLSearchParams instance
|
|
41
|
+
*/
|
|
42
|
+
export function makeURLSearchParams<OptionsType extends object>(options?: Readonly<OptionsType>) {
|
|
43
|
+
const params = new URLSearchParams();
|
|
44
|
+
if (!options) return params;
|
|
45
|
+
|
|
46
|
+
for (const [key, value] of Object.entries(options)) {
|
|
47
|
+
const serialized = serializeSearchParam(value);
|
|
48
|
+
if (serialized !== null) params.append(key, serialized);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return params;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Converts the response to usable data
|
|
56
|
+
*
|
|
57
|
+
* @param res - The fetch response
|
|
58
|
+
*/
|
|
59
|
+
export async function parseResponse(res: ResponseLike): Promise<unknown> {
|
|
60
|
+
if (res.headers.get('Content-Type')?.startsWith('application/json')) {
|
|
61
|
+
return res.json();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return res.arrayBuffer();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Check whether a request falls under a sublimit
|
|
69
|
+
*
|
|
70
|
+
* @param bucketRoute - The buckets route identifier
|
|
71
|
+
* @param body - The options provided as JSON data
|
|
72
|
+
* @param method - The HTTP method that will be used to make the request
|
|
73
|
+
* @returns Whether the request falls under a sublimit
|
|
74
|
+
*/
|
|
75
|
+
export function hasSublimit(bucketRoute: string, body?: unknown, method?: string): boolean {
|
|
76
|
+
// TODO: Update for new sublimits
|
|
77
|
+
// Currently known sublimits:
|
|
78
|
+
// Editing channel `name` or `topic`
|
|
79
|
+
if (bucketRoute === '/channels/:id') {
|
|
80
|
+
if (typeof body !== 'object' || body === null) return false;
|
|
81
|
+
// This should never be a POST body, but just in case
|
|
82
|
+
if (method !== RequestMethod.Patch) return false;
|
|
83
|
+
const castedBody = body as RESTPatchAPIChannelJSONBody;
|
|
84
|
+
return ['name', 'topic'].some((key) => Reflect.has(castedBody, key));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// If we are checking if a request has a sublimit on a route not checked above, sublimit all requests to avoid a flood of 429s
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check whether an error indicates that a retry can be attempted
|
|
93
|
+
*
|
|
94
|
+
* @param error - The error thrown by the network request
|
|
95
|
+
* @returns Whether the error indicates a retry should be attempted
|
|
96
|
+
*/
|
|
97
|
+
export function shouldRetry(error: Error & { code?: string }) {
|
|
98
|
+
// Retry for possible timed out requests
|
|
99
|
+
if (error.name === 'AbortError') return true;
|
|
100
|
+
// Downlevel ECONNRESET to retry as it may be recoverable
|
|
101
|
+
return ('code' in error && error.code === 'ECONNRESET') || error.message.includes('ECONNRESET');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Determines whether the request should be queued or whether a RateLimitError should be thrown
|
|
106
|
+
*
|
|
107
|
+
* @internal
|
|
108
|
+
*/
|
|
109
|
+
export async function onRateLimit(manager: REST, rateLimitData: RateLimitData) {
|
|
110
|
+
const { options } = manager;
|
|
111
|
+
if (!options.rejectOnRateLimit) return;
|
|
112
|
+
|
|
113
|
+
const shouldThrow =
|
|
114
|
+
typeof options.rejectOnRateLimit === 'function'
|
|
115
|
+
? await options.rejectOnRateLimit(rateLimitData)
|
|
116
|
+
: options.rejectOnRateLimit.some((route) => rateLimitData.route.startsWith(route.toLowerCase()));
|
|
117
|
+
if (shouldThrow) {
|
|
118
|
+
throw new RateLimitError(rateLimitData);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Calculates the default avatar index for a given user id.
|
|
124
|
+
*
|
|
125
|
+
* @param userId - The user id to calculate the default avatar index for
|
|
126
|
+
*/
|
|
127
|
+
export function calculateUserDefaultAvatarIndex(userId: Snowflake) {
|
|
128
|
+
return Number(BigInt(userId) >> 22n) % 6;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Sleeps for a given amount of time.
|
|
133
|
+
*
|
|
134
|
+
* @param ms - The amount of time (in milliseconds) to sleep for
|
|
135
|
+
*/
|
|
136
|
+
export async function sleep(ms: number): Promise<void> {
|
|
137
|
+
return new Promise<void>((resolve) => {
|
|
138
|
+
setTimeout(() => resolve(), ms);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Verifies that a value is a buffer-like object.
|
|
144
|
+
*
|
|
145
|
+
* @param value - The value to check
|
|
146
|
+
*/
|
|
147
|
+
export function isBufferLike(value: unknown): value is ArrayBuffer | Uint8Array | Uint8ClampedArray {
|
|
148
|
+
return value instanceof ArrayBuffer || value instanceof Uint8Array || value instanceof Uint8ClampedArray;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Normalizes the offset for rate limits. Applies a Math.max(0, N) to prevent negative offsets,
|
|
153
|
+
* also deals with callbacks.
|
|
154
|
+
*
|
|
155
|
+
* @internal
|
|
156
|
+
*/
|
|
157
|
+
export function normalizeRateLimitOffset(offset: GetRateLimitOffsetFunction | number, route: string): number {
|
|
158
|
+
if (typeof offset === 'number') {
|
|
159
|
+
return Math.max(0, offset);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const result = offset(route);
|
|
163
|
+
return Math.max(0, result);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Normalizes the retry backoff used to add delay to retrying 5xx and aborted requests.
|
|
168
|
+
* Applies a Math.max(0, N) to prevent negative backoffs, also deals with callbacks.
|
|
169
|
+
*
|
|
170
|
+
* @internal
|
|
171
|
+
*/
|
|
172
|
+
export function normalizeRetryBackoff(
|
|
173
|
+
retryBackoff: GetRetryBackoffFunction | number,
|
|
174
|
+
route: string,
|
|
175
|
+
statusCode: number | null,
|
|
176
|
+
retryCount: number,
|
|
177
|
+
requestBody: unknown,
|
|
178
|
+
): number | null {
|
|
179
|
+
if (typeof retryBackoff === 'number') {
|
|
180
|
+
return Math.max(0, retryBackoff) * (1 << retryCount);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// No need to Math.max as we'll only set the sleep timer if the value is > 0 (and not equal)
|
|
184
|
+
return retryBackoff(route, statusCode, retryCount, requestBody);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Normalizes the timeout for aborting requests. Applies a Math.max(0, N) to prevent negative timeouts,
|
|
189
|
+
* also deals with callbacks.
|
|
190
|
+
*
|
|
191
|
+
* @internal
|
|
192
|
+
*/
|
|
193
|
+
export function normalizeTimeout(timeout: GetTimeoutFunction | number, route: string, requestBody: unknown): number {
|
|
194
|
+
if (typeof timeout === 'number') {
|
|
195
|
+
return Math.max(0, timeout);
|
|
196
|
+
}
|
|
197
|
+
const result = timeout(route, requestBody);
|
|
198
|
+
return Math.max(0, result);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Generates a UUID v5 according to RFC 4122
|
|
203
|
+
*
|
|
204
|
+
* @param value - The value to hash
|
|
205
|
+
* @param namespace - The namespace UUID
|
|
206
|
+
*/
|
|
207
|
+
export function uuidv5(value: string | Uint8Array, namespace: string): string {
|
|
208
|
+
// 1. Verify namespace is a valid UUID
|
|
209
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(namespace)) {
|
|
210
|
+
throw new TypeError('Invalid namespace UUID');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 2. Parse namespace UUID into bytes
|
|
214
|
+
const namespaceBytes = new Uint8Array(16);
|
|
215
|
+
const hex = namespace.replace(/-/g, '');
|
|
216
|
+
for (let i = 0; i < 16; i++) {
|
|
217
|
+
namespaceBytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 3. Convert value to bytes if string
|
|
221
|
+
const valueBytes = typeof value === 'string' ? new TextEncoder().encode(value) : value;
|
|
222
|
+
|
|
223
|
+
// 4. Concatenate namespace and value
|
|
224
|
+
const data = new Uint8Array(namespaceBytes.length + valueBytes.length);
|
|
225
|
+
data.set(namespaceBytes);
|
|
226
|
+
data.set(valueBytes, namespaceBytes.length);
|
|
227
|
+
|
|
228
|
+
// 5. Hash with SHA-1
|
|
229
|
+
const buffer = createHash('sha1').update(data).digest();
|
|
230
|
+
const hash = new Uint8Array(buffer);
|
|
231
|
+
|
|
232
|
+
// 6. Set version to 5 (0101)
|
|
233
|
+
hash[6] = (hash[6]! & 0x0f) | 0x50;
|
|
234
|
+
|
|
235
|
+
// 7. Set variant to RFC 4122 (10xx)
|
|
236
|
+
hash[8] = (hash[8]! & 0x3f) | 0x80;
|
|
237
|
+
|
|
238
|
+
// 8. Convert to hex string with dashes
|
|
239
|
+
const hexHash = Array.from(hash, (byte) => byte.toString(16).padStart(2, '0'));
|
|
240
|
+
|
|
241
|
+
return [
|
|
242
|
+
hexHash.slice(0, 4).join(''),
|
|
243
|
+
hexHash.slice(4, 6).join(''),
|
|
244
|
+
hexHash.slice(6, 8).join(''),
|
|
245
|
+
hexHash.slice(8, 10).join(''),
|
|
246
|
+
hexHash.slice(10, 16).join('')
|
|
247
|
+
].join('-');
|
|
248
|
+
}
|
package/src/shared.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export * from './lib/CDN.js';
|
|
2
|
+
export * from './lib/errors/DiscordAPIError.js';
|
|
3
|
+
export * from './lib/errors/HTTPError.js';
|
|
4
|
+
export * from './lib/errors/RateLimitError.js';
|
|
5
|
+
export type * from './lib/interfaces/Handler.js';
|
|
6
|
+
export * from './lib/REST.js';
|
|
7
|
+
export * from './lib/utils/constants.js';
|
|
8
|
+
export * from './lib/utils/types.js';
|
|
9
|
+
export { calculateUserDefaultAvatarIndex, makeURLSearchParams, parseResponse } from './lib/utils/utils.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The {@link https://github.com/ovencord/ovencord/blob/main/packages/rest#readme | @ovencord/rest} version
|
|
13
|
+
* that you are currently using.
|
|
14
|
+
*/
|
|
15
|
+
// This needs to explicitly be `string` so it is not typed as a "const string" that gets injected by esbuild
|
|
16
|
+
export const version = '[VI]{{inject}}[/VI]' as string;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ResponseLike } from '../shared.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Makes an HTTP request using Bun's native fetch API
|
|
5
|
+
*
|
|
6
|
+
* @param url - The URL to request
|
|
7
|
+
* @param init - Fetch options
|
|
8
|
+
* @returns A response-like object compatible with the REST manager
|
|
9
|
+
*/
|
|
10
|
+
export async function makeRequest(url: string, init: RequestInit): Promise<ResponseLike> {
|
|
11
|
+
const res = await fetch(url, init);
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
body: res.body,
|
|
15
|
+
async arrayBuffer() {
|
|
16
|
+
return res.arrayBuffer();
|
|
17
|
+
},
|
|
18
|
+
async json() {
|
|
19
|
+
return res.json();
|
|
20
|
+
},
|
|
21
|
+
async text() {
|
|
22
|
+
return res.text();
|
|
23
|
+
},
|
|
24
|
+
get bodyUsed() {
|
|
25
|
+
return res.bodyUsed;
|
|
26
|
+
},
|
|
27
|
+
headers: res.headers,
|
|
28
|
+
status: res.status,
|
|
29
|
+
statusText: res.statusText,
|
|
30
|
+
ok: res.ok,
|
|
31
|
+
};
|
|
32
|
+
}
|