@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.
@@ -0,0 +1,205 @@
1
+
2
+
3
+ import type { REST } from '../REST.js';
4
+ import type { DiscordErrorData, OAuthErrorData } from '../errors/DiscordAPIError.js';
5
+ import { DiscordAPIError } from '../errors/DiscordAPIError.js';
6
+ import { HTTPError } from '../errors/HTTPError.js';
7
+ import { RESTEvents } from '../utils/constants.js';
8
+ import type { ResponseLike, HandlerRequestData, RouteData } from '../utils/types.js';
9
+ import { normalizeRetryBackoff, normalizeTimeout, parseResponse, shouldRetry, sleep } from '../utils/utils.js';
10
+
11
+ let authFalseWarningEmitted = false;
12
+
13
+ /**
14
+ * Invalid request limiting is done on a per-IP basis, not a per-token basis.
15
+ * The best we can do is track invalid counts process-wide (on the theory that
16
+ * users could have multiple bots run from one process) rather than per-bot.
17
+ * Therefore, store these at file scope here rather than in the client's
18
+ * RESTManager object.
19
+ */
20
+ let invalidCount = 0;
21
+ let invalidCountResetTime: number | null = null;
22
+
23
+ /**
24
+ * Increment the invalid request count and emit warning if necessary
25
+ *
26
+ * @internal
27
+ */
28
+ export function incrementInvalidCount(manager: REST) {
29
+ if (!invalidCountResetTime || invalidCountResetTime < Date.now()) {
30
+ invalidCountResetTime = Date.now() + 1_000 * 60 * 10;
31
+ invalidCount = 0;
32
+ }
33
+
34
+ invalidCount++;
35
+
36
+ const emitInvalid =
37
+ manager.options.invalidRequestWarningInterval > 0 &&
38
+ invalidCount % manager.options.invalidRequestWarningInterval === 0;
39
+ if (emitInvalid) {
40
+ // Let library users know periodically about invalid requests
41
+ manager.emit(RESTEvents.InvalidRequestWarning, {
42
+ count: invalidCount,
43
+ remainingTime: invalidCountResetTime - Date.now(),
44
+ });
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Performs the actual network request for a request handler
50
+ *
51
+ * @param manager - The manager that holds options and emits informational events
52
+ * @param routeId - The generalized api route with literal ids for major parameters
53
+ * @param url - The fully resolved url to make the request to
54
+ * @param options - The fetch options needed to make the request
55
+ * @param requestData - Extra data from the user's request needed for errors and additional processing
56
+ * @param retries - The number of retries this request has already attempted (recursion occurs on the handler)
57
+ * @returns The respond from the network or `null` when the request should be retried
58
+ * @internal
59
+ */
60
+ export async function makeNetworkRequest(
61
+ manager: REST,
62
+ routeId: RouteData,
63
+ url: string,
64
+ options: RequestInit,
65
+ requestData: HandlerRequestData,
66
+ retries: number,
67
+ ) {
68
+ const controller = new AbortController();
69
+ const timeout = setTimeout(
70
+ () => controller.abort(),
71
+ normalizeTimeout(manager.options.timeout, routeId.bucketRoute, requestData.body),
72
+ );
73
+ if (requestData.signal) {
74
+ // If the user signal was aborted, abort the controller, else abort the local signal.
75
+ // The reason why we don't re-use the user's signal, is because users may use the same signal for multiple
76
+ // requests, and we do not want to cause unexpected side-effects.
77
+ if (requestData.signal.aborted) controller.abort();
78
+ else requestData.signal.addEventListener('abort', () => controller.abort());
79
+ }
80
+
81
+ let res: ResponseLike;
82
+ try {
83
+ res = await manager.options.makeRequest(url, { ...options, signal: controller.signal });
84
+ } catch (error: unknown) {
85
+ if (!(error instanceof Error)) throw error;
86
+ // Retry the specified number of times if needed
87
+ if (shouldRetry(error) && retries !== manager.options.retries) {
88
+ const backoff = normalizeRetryBackoff(
89
+ manager.options.retryBackoff,
90
+ routeId.bucketRoute,
91
+ null,
92
+ retries,
93
+ requestData.body,
94
+ );
95
+ if (backoff === null) {
96
+ throw error;
97
+ }
98
+
99
+ if (backoff > 0) {
100
+ await sleep(backoff);
101
+ }
102
+
103
+ // Retry is handled by the handler upon receiving null
104
+ return null;
105
+ }
106
+
107
+ throw error;
108
+ } finally {
109
+ clearTimeout(timeout);
110
+ }
111
+
112
+ if (manager.listenerCount(RESTEvents.Response)) {
113
+ manager.emit(
114
+ RESTEvents.Response,
115
+ {
116
+ method: options.method ?? 'get',
117
+ path: routeId.original,
118
+ route: routeId.bucketRoute,
119
+ options,
120
+ data: requestData,
121
+ retries,
122
+ },
123
+ res instanceof Response ? res.clone() : { ...res },
124
+ );
125
+ }
126
+
127
+ return res;
128
+ }
129
+
130
+ /**
131
+ * Handles 5xx and 4xx errors (not 429's) conventionally. 429's should be handled before calling this function
132
+ *
133
+ * @param manager - The manager that holds options and emits informational events
134
+ * @param res - The response received from {@link makeNetworkRequest}
135
+ * @param method - The method used to make the request
136
+ * @param url - The fully resolved url to make the request to
137
+ * @param requestData - Extra data from the user's request needed for errors and additional processing
138
+ * @param retries - The number of retries this request has already attempted (recursion occurs on the handler)
139
+ * @param routeId - The generalized API route with literal ids for major parameters
140
+ * @returns The response if the status code is not handled or null to request a retry
141
+ */
142
+ export async function handleErrors(
143
+ manager: REST,
144
+ res: ResponseLike,
145
+ method: string,
146
+ url: string,
147
+ requestData: HandlerRequestData,
148
+ retries: number,
149
+ routeId: RouteData,
150
+ ) {
151
+ const status = res.status;
152
+ if (status >= 500 && status < 600) {
153
+ // Retry the specified number of times for possible server side issues
154
+ if (retries !== manager.options.retries) {
155
+ const backoff = normalizeRetryBackoff(
156
+ manager.options.retryBackoff,
157
+ routeId.bucketRoute,
158
+ status,
159
+ retries,
160
+ requestData.body,
161
+ );
162
+ if (backoff === null) {
163
+ throw new HTTPError(status, res.statusText, method, url, requestData);
164
+ }
165
+
166
+ if (backoff > 0) {
167
+ await sleep(backoff);
168
+ }
169
+
170
+ return null;
171
+ }
172
+
173
+ // We are out of retries, throw an error
174
+ throw new HTTPError(status, res.statusText, method, url, requestData);
175
+ } else {
176
+ // Handle possible malformed requests
177
+ if (status >= 400 && status < 500) {
178
+ // The request will not succeed for some reason, parse the error returned from the api
179
+ const data = (await parseResponse(res)) as DiscordErrorData | OAuthErrorData;
180
+ const isDiscordError = 'code' in data;
181
+
182
+ // If we receive this status code, it means the token we had is no longer valid.
183
+ if (status === 401 && requestData.auth === true) {
184
+ if (isDiscordError && data.code !== 0 && !authFalseWarningEmitted) {
185
+ const errorText = `Encountered HTTP 401 with error ${data.code}: ${data.message}. Your token will be removed from this REST instance. If you are using @ovencord/rest directly, consider adding 'auth: false' to the request. Open an issue with your library if not.`;
186
+ // Use emitWarning if possible, probably not available in edge / web
187
+ if (typeof globalThis.process !== 'undefined' && typeof globalThis.process.emitWarning === 'function') {
188
+ globalThis.process.emitWarning(errorText);
189
+ } else {
190
+ console.warn(errorText);
191
+ }
192
+
193
+ authFalseWarningEmitted = true;
194
+ }
195
+
196
+ manager.setToken(null!);
197
+ }
198
+
199
+ // throw the API error
200
+ throw new DiscordAPIError(data, isDiscordError ? data.code : data.error, status, method, url, requestData);
201
+ }
202
+
203
+ return res;
204
+ }
205
+ }
@@ -0,0 +1,27 @@
1
+
2
+ import type { HandlerRequestData, RouteData, ResponseLike } from '../utils/types.js';
3
+
4
+ export interface IHandler {
5
+ /**
6
+ * The unique id of the handler
7
+ */
8
+ readonly id: string;
9
+ /**
10
+ * If the bucket is currently inactive (no pending requests)
11
+ */
12
+ get inactive(): boolean;
13
+ /**
14
+ * Queues a request to be sent
15
+ *
16
+ * @param routeId - The generalized api route with literal ids for major parameters
17
+ * @param url - The url to do the request on
18
+ * @param options - All the information needed to make a request
19
+ * @param requestData - Extra data from the user's request needed for errors and additional processing
20
+ */
21
+ queueRequest(
22
+ routeId: RouteData,
23
+ url: string,
24
+ options: RequestInit,
25
+ requestData: HandlerRequestData,
26
+ ): Promise<ResponseLike>;
27
+ }
@@ -0,0 +1,2 @@
1
+ export { AsyncEventEmitter } from '@ovencord/util';
2
+
@@ -0,0 +1,64 @@
1
+ /**
2
+ * A simple asynchronous queue implementation
3
+ */
4
+ export class AsyncQueue {
5
+ #promises: Array<{ promise: Promise<void>; resolve: () => void }> = [];
6
+
7
+ /**
8
+ * The remaining number of requests in the queue
9
+ */
10
+ public get remaining(): number {
11
+ return this.#promises.length;
12
+ }
13
+
14
+ /**
15
+ * Waits for the queue to be free
16
+ * @param options - Options for the wait
17
+ * @param options.signal - An optional abort signal
18
+ */
19
+ public wait(options?: { signal?: AbortSignal }): Promise<void> {
20
+ const next = this.#promises.length ? this.#promises.at(-1)!.promise : Promise.resolve();
21
+
22
+ let resolve!: () => void;
23
+ const promise = new Promise<void>((res) => {
24
+ resolve = res;
25
+ });
26
+
27
+ this.#promises.push({ promise, resolve });
28
+
29
+ // If no signal, just return the promise we wait on
30
+ if (!options?.signal) {
31
+ return next;
32
+ }
33
+
34
+ return new Promise((res, rej) => {
35
+ if (options.signal!.aborted) {
36
+ // Bridge immediately: when next resolves, we resolve our token
37
+ next.then(() => resolve());
38
+ rej(new Error('AbortError')); // TODO: Use DOMException or standard AbortError if available
39
+ return;
40
+ }
41
+
42
+ const abortHandler = () => {
43
+ // Bridge: when next resolves, we resolve our token
44
+ next.then(() => resolve());
45
+ rej(new Error('AbortError'));
46
+ };
47
+
48
+ options.signal!.addEventListener('abort', abortHandler, { once: true });
49
+
50
+ next.then(() => {
51
+ options.signal!.removeEventListener('abort', abortHandler);
52
+ res();
53
+ });
54
+ });
55
+ }
56
+
57
+ /**
58
+ * Shifts the queue, allowing the next task to run
59
+ */
60
+ public shift(): void {
61
+ const item = this.#promises.shift();
62
+ item?.resolve();
63
+ }
64
+ }
@@ -0,0 +1,65 @@
1
+ import { getUserAgentAppendix } from '@ovencord/util';
2
+ import type { ImageSize } from 'discord-api-types/v10';
3
+ import { APIVersion } from 'discord-api-types/v10';
4
+ import { getDefaultStrategy } from '../../environment.js';
5
+ import type { RESTOptions, ResponseLike } from './types.js';
6
+
7
+ export type { ImageSize } from 'discord-api-types/v10';
8
+
9
+ export const DefaultUserAgent =
10
+ `DiscordBot (https://discord.js.org, [VI]{{inject}}[/VI])` as `DiscordBot (https://discord.js.org, ${string})`;
11
+
12
+ /**
13
+ * The default string to append onto the user agent.
14
+ */
15
+ export const DefaultUserAgentAppendix = getUserAgentAppendix();
16
+
17
+ export const DefaultRestOptions = {
18
+ agent: null,
19
+ api: 'https://discord.com/api',
20
+ authPrefix: 'Bot',
21
+ cdn: 'https://cdn.discordapp.com',
22
+ headers: {},
23
+ invalidRequestWarningInterval: 0,
24
+ globalRequestsPerSecond: 50,
25
+ offset: 50,
26
+ rejectOnRateLimit: null,
27
+ retries: 3,
28
+ retryBackoff: 0,
29
+ timeout: 15_000,
30
+ userAgentAppendix: DefaultUserAgentAppendix,
31
+ version: APIVersion,
32
+ hashSweepInterval: 14_400_000, // 4 Hours
33
+ hashLifetime: 86_400_000, // 24 Hours
34
+ handlerSweepInterval: 3_600_000, // 1 Hour
35
+ async makeRequest(...args): Promise<ResponseLike> {
36
+ return getDefaultStrategy()(...args);
37
+ },
38
+ mediaProxy: 'https://media.discordapp.net',
39
+ } as const satisfies Required<RESTOptions>;
40
+
41
+ /**
42
+ * The events that the REST manager emits
43
+ */
44
+ export enum RESTEvents {
45
+ Debug = 'restDebug',
46
+ HandlerSweep = 'handlerSweep',
47
+ HashSweep = 'hashSweep',
48
+ InvalidRequestWarning = 'invalidRequestWarning',
49
+ RateLimited = 'rateLimited',
50
+ Response = 'response',
51
+ }
52
+
53
+ export const ALLOWED_EXTENSIONS = ['webp', 'png', 'jpg', 'jpeg', 'gif'] as const satisfies readonly string[];
54
+ export const ALLOWED_STICKER_EXTENSIONS = ['png', 'json', 'gif'] as const satisfies readonly string[];
55
+ export const ALLOWED_SIZES: readonly number[] = [
56
+ 16, 32, 64, 128, 256, 512, 1_024, 2_048, 4_096,
57
+ ] satisfies readonly ImageSize[];
58
+
59
+ export type ImageExtension = (typeof ALLOWED_EXTENSIONS)[number];
60
+ export type StickerExtension = (typeof ALLOWED_STICKER_EXTENSIONS)[number];
61
+
62
+
63
+ export const BurstHandlerMajorIdKey = 'burst';
64
+
65
+ export const AUTH_UUID_NAMESPACE = 'acc82a4c-f887-417b-a69c-f74096ff7e59';