Skip to content
This repository was archived by the owner on Apr 17, 2023. It is now read-only.

Commit 70273fd

Browse files
committed
feat(Rest): custom bucket support
1 parent d53973f commit 70273fd

File tree

11 files changed

+170
-71
lines changed

11 files changed

+170
-71
lines changed
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { DiscordFetchOptions } from './Fetch';
2+
import type { Rest } from '../struct';
3+
4+
export interface BucketConstructor {
5+
makeRoute(method: string, url: string): string;
6+
new (rest: Rest, route: string): BaseBucket;
7+
}
8+
9+
/**
10+
* Base bucket class - used to deal with all of the HTTP semantics
11+
*/
12+
export abstract class BaseBucket {
13+
/**
14+
* Time after a Bucket is destroyed if unused
15+
*/
16+
public static readonly BUCKET_TTL = 1e4;
17+
18+
/**
19+
* Creates a simple API route representation (e.g. /users/:id), used as an identifier for each bucket.
20+
*
21+
* Credit to https://github.com/abalabahaha/eris
22+
*/
23+
public static makeRoute(method: string, url: string) {
24+
let route = url
25+
.replace(/\/([a-z-]+)\/(?:[0-9]{17,19})/g, (match, p) => (['channels', 'guilds', 'webhook'].includes(p) ? match : `/${p}/:id`))
26+
.replace(/\/invites\/[\w\d-]{2,}/g, '/invites/:code')
27+
.replace(/\/reactions\/[^/]+/g, '/reactions/:id')
28+
.replace(/^\/webhooks\/(\d+)\/[A-Za-z0-9-_]{64,}/, '/webhooks/$1/:token')
29+
.replace(/\?.*$/, '');
30+
31+
// Message deletes have their own rate limit
32+
if (method === 'delete' && route.endsWith('/messages/:id')) {
33+
route = method + route;
34+
}
35+
36+
// In this case, /channels/[idHere]/messages is correct,
37+
// however /channels/[idHere] is not. we need "/channels/:id"
38+
if (/^\/channels\/[0-9]{17,19}$/.test(route)) {
39+
route = route.replace(/[0-9]{17,19}/, ':id');
40+
}
41+
42+
return route;
43+
}
44+
45+
protected readonly _destroyTimeout: NodeJS.Timeout;
46+
47+
public ['constructor']!: typeof BaseBucket;
48+
49+
public constructor(
50+
public readonly rest: Rest,
51+
public readonly route: string
52+
) {
53+
// This is in the base constructor for backwards compatibility - in the future it'll be only in the Bucket class
54+
this._destroyTimeout = setTimeout(() => this.rest.buckets.delete(this.route), this.constructor.BUCKET_TTL).unref();
55+
}
56+
57+
/**
58+
* Shortcut for the manager mutex
59+
*/
60+
public get mutex() {
61+
return this.rest.mutex;
62+
}
63+
64+
/**
65+
* Makes a request to Discord
66+
* @param req Request options
67+
*/
68+
public abstract make<T, D, Q>(req: DiscordFetchOptions<D, Q>): Promise<T>;
69+
}

libs/rest/src/struct/Bucket.ts renamed to libs/rest/src/fetcher/Bucket.ts

Lines changed: 4 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { discordFetch, DiscordFetchOptions } from '../Fetch';
1+
import { discordFetch, DiscordFetchOptions } from './Fetch';
22
import { CordisRestError, HTTPError } from '../Error';
3-
import type { Rest } from './Rest';
3+
import { BaseBucket } from './BaseBucket';
44

55
/**
66
* Data held to represent ratelimit state for a Bucket
@@ -13,62 +13,9 @@ export interface RatelimitData {
1313
}
1414

1515
/**
16-
* Represents a rate limiting bucket for Discord's API
16+
* Simple, default sequential bucket
1717
*/
18-
export class Bucket {
19-
public static readonly BUCKET_TTL = 1e4;
20-
21-
/**
22-
* Creates a simple API route representation (e.g. /users/:id), used as an identifier for each bucket.
23-
*
24-
* Credit to https://github.com/abalabahaha/eris
25-
*/
26-
public static makeRoute(method: string, url: string) {
27-
let route = url
28-
.replace(/\/([a-z-]+)\/(?:[0-9]{17,19})/g, (match, p) => (['channels', 'guilds', 'webhook'].includes(p) ? match : `/${p}/:id`))
29-
.replace(/\/invites\/[\w\d-]{2,}/g, '/invites/:code')
30-
.replace(/\/reactions\/[^/]+/g, '/reactions/:id')
31-
.replace(/^\/webhooks\/(\d+)\/[A-Za-z0-9-_]{64,}/, '/webhooks/$1/:token')
32-
.replace(/\?.*$/, '');
33-
34-
// Message deletes have their own rate limit
35-
if (method === 'delete' && route.endsWith('/messages/:id')) {
36-
route = method + route;
37-
}
38-
39-
// In this case, /channels/[idHere]/messages is correct,
40-
// however /channels/[idHere] is not. we need "/channels/:id"
41-
if (/^\/channels\/[0-9]{17,19}$/.test(route)) {
42-
route = route.replace(/[0-9]{17,19}/, ':id');
43-
}
44-
45-
return route;
46-
}
47-
48-
private readonly _destroyTimeout: NodeJS.Timeout;
49-
50-
/**
51-
* @param rest The rest manager using this bucket instance
52-
* @param route The identifier of this bucket
53-
*/
54-
public constructor(
55-
public readonly rest: Rest,
56-
public readonly route: string
57-
) {
58-
this._destroyTimeout = setTimeout(() => this.rest.buckets.delete(this.route), Bucket.BUCKET_TTL).unref();
59-
}
60-
61-
/**
62-
* Shortcut for the manager mutex
63-
*/
64-
public get mutex() {
65-
return this.rest.mutex;
66-
}
67-
68-
/**
69-
* Makes a request to Discord
70-
* @param req Request options
71-
*/
18+
export class Bucket extends BaseBucket {
7219
public async make<T, D, Q>(req: DiscordFetchOptions<D, Q>): Promise<T> {
7320
this._destroyTimeout.refresh();
7421

libs/rest/src/Fetch.ts renamed to libs/rest/src/fetcher/Fetch.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import FormData from 'form-data';
33
import { URLSearchParams } from 'url';
44
import AbortController from 'abort-controller';
55
import { RouteBases } from 'discord-api-types/v9';
6+
import type { Readable } from 'stream';
67

78
/**
89
* Represents a file that can be sent to Discord
@@ -37,15 +38,16 @@ export interface DiscordFetchOptions<D = RequestBodyData, Q = StringRecord> {
3738
isRetryAfterRatelimit: boolean;
3839
query?: Q | string;
3940
files?: File[];
40-
data?: D;
41+
data?: D | Readable;
42+
domain?: string;
4143
}
4244

4345
/**
4446
* Makes the actual HTTP request
4547
* @param options Options for the request
4648
*/
4749
export const discordFetch = async <D, Q>(options: DiscordFetchOptions<D, Q>) => {
48-
let { path, method, headers, controller, query, files, data } = options;
50+
let { path, method, headers, controller, query, files, data, domain = RouteBases.api } = options;
4951

5052
let queryString: string | null = null;
5153
if (query) {
@@ -59,7 +61,7 @@ export const discordFetch = async <D, Q>(options: DiscordFetchOptions<D, Q>) =>
5961
).toString();
6062
}
6163

62-
const url = `${RouteBases.api}${path}${queryString ? `?${queryString}` : ''}`;
64+
const url = `${domain}${path}${queryString ? `?${queryString}` : ''}`;
6365

6466
let body: string | FormData;
6567
if (files?.length) {
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { discordFetch, DiscordFetchOptions } from './Fetch';
2+
import { CordisRestError, HTTPError } from '../Error';
3+
import { BaseBucket } from './BaseBucket';
4+
import type { Rest } from '../struct';
5+
6+
/**
7+
* Unconventional Bucket implementation that will hijack all requests (i.e. there is no seperate bucket depending on the route)
8+
*
9+
* This is meant for proxying requests, but will not handle any ratelimiting and will entirely ignore mutexes
10+
*/
11+
export class ProxyBucket extends BaseBucket {
12+
public static override makeRoute() {
13+
return 'proxy';
14+
}
15+
16+
public constructor(
17+
rest: Rest,
18+
route: string
19+
) {
20+
super(rest, route);
21+
// This shouldn't be needed - but for backwards compatibility BaseBucket sets this timeout still
22+
clearTimeout(this._destroyTimeout);
23+
}
24+
25+
public async make<T, D, Q>(req: DiscordFetchOptions<D, Q>): Promise<T> {
26+
let timeout: NodeJS.Timeout;
27+
if (req.implicitAbortBehavior) {
28+
timeout = setTimeout(() => req.controller.abort(), this.rest.abortAfter);
29+
}
30+
31+
const res = await discordFetch(req).finally(() => clearTimeout(timeout));
32+
33+
if (res.status === 429) {
34+
return Promise.reject(new CordisRestError('rateLimited', `${req.method.toUpperCase()} ${req.path}`));
35+
} else if (res.status >= 500 && res.status < 600) {
36+
return Promise.reject(new CordisRestError('internal', `${req.method.toUpperCase()} ${req.path}`));
37+
} else if (!res.ok) {
38+
return Promise.reject(new HTTPError(res.clone(), await res.text()));
39+
}
40+
41+
if (res.headers.get('content-type')?.startsWith('application/json')) {
42+
return res.json() as Promise<T>;
43+
}
44+
45+
return res.blob() as Promise<unknown> as Promise<T>;
46+
}
47+
}

libs/rest/src/fetcher/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from './BaseBucket';
2+
export * from './Bucket';
3+
export * from './Fetch';
4+
export * from './ProxyBucket';

libs/rest/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1+
export * from './fetcher';
12
export * from './mutex';
23
export * from './struct';
34
export * from './Constants';
45
export * from './Error';
5-
export * from './Fetch';

libs/rest/src/mutex/MemoryMutex.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// ? Anything ignored from coverage in this file are just weird edge cases - nothing to really cover.
22

33
import { Mutex } from './Mutex';
4-
import type { RatelimitData } from '../struct';
4+
import type { RatelimitData } from '../fetcher';
55

66
export interface MemoryRatelimitData extends RatelimitData {
77
expiresAt: Date;

libs/rest/src/mutex/Mutex.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { halt } from '@cordis/common';
22
import { CordisRestError } from '../Error';
3-
import type { RatelimitData } from '../struct';
3+
import type { RatelimitData } from '../fetcher';
44

55
/**
66
* "Mutex" used to ensure requests don't go through when a ratelimit is about to happen

libs/rest/src/struct/Rest.ts

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,31 @@
1-
import { Bucket, RatelimitData } from './Bucket';
1+
import {
2+
BaseBucket,
3+
BucketConstructor,
4+
Bucket,
5+
RatelimitData,
6+
DiscordFetchOptions,
7+
File,
8+
RequestBodyData,
9+
StringRecord
10+
} from '../fetcher';
211
import { USER_AGENT } from '../Constants';
312
import { EventEmitter } from 'events';
413
import { Headers, Response } from 'node-fetch';
514
import { Mutex, MemoryMutex } from '../mutex';
615
import AbortController from 'abort-controller';
716
import { CordisRestError, HTTPError } from '../Error';
817
import { halt } from '@cordis/common';
9-
import type { DiscordFetchOptions, File, RequestBodyData, StringRecord } from '../Fetch';
18+
import type { Readable } from 'stream';
19+
import { RouteBases } from 'discord-api-types/v9';
1020

1121
/**
1222
* Options for constructing a rest manager
1323
*/
1424
export interface RestOptions {
1525
/**
1626
* How many times to retry making a request before giving up
27+
*
28+
* Tip: If using ProxyBucket you should probably set this to 1 depending on your proxy server's implementation
1729
*/
1830
retries?: number;
1931
/**
@@ -34,6 +46,14 @@ export interface RestOptions {
3446
* Overwrites the default for `{@link RequestOptions.cacheTime}`
3547
*/
3648
cacheTime?: number;
49+
/**
50+
* Bucket constructor to use
51+
*/
52+
bucket?: BucketConstructor;
53+
/**
54+
* Overwrites the default domain used for every request
55+
*/
56+
domain?: string;
3757
}
3858

3959
export interface Rest {
@@ -114,7 +134,7 @@ export interface RequestOptions<D, Q> {
114134
/**
115135
* Body to send, if any
116136
*/
117-
data?: D;
137+
data?: D | Readable;
118138
/**
119139
* Wether or not this request should be re-attempted after a ratelimit is waited out
120140
*/
@@ -132,6 +152,10 @@ export interface RequestOptions<D, Q> {
132152
* @default 10000
133153
*/
134154
cacheTime?: number;
155+
/**
156+
* Overwrites the domain used for this request - not taking into account the option passed into {@link RestOptions}
157+
*/
158+
domain?: string;
135159
}
136160

137161
/**
@@ -151,13 +175,15 @@ export class Rest extends EventEmitter {
151175
/**
152176
* Current active rate limiting Buckets
153177
*/
154-
public readonly buckets = new Map<string, Bucket>();
178+
public readonly buckets = new Map<string, BaseBucket>();
155179

156180
public readonly retries: number;
157181
public readonly abortAfter: number;
158182
public readonly mutex: Mutex;
159183
public readonly retryAfterRatelimit: boolean;
160184
public readonly cacheTime: number;
185+
public readonly bucket: BucketConstructor;
186+
public readonly domain: string;
161187

162188
/**
163189
* @param auth Your bot's Discord token
@@ -174,26 +200,30 @@ export class Rest extends EventEmitter {
174200
mutex = new MemoryMutex(),
175201
retryAfterRatelimit = true,
176202
cacheTime = 10000,
203+
bucket = Bucket,
204+
domain = RouteBases.api
177205
} = options;
178206

179207
this.retries = retries;
180208
this.abortAfter = abortAfter;
181209
this.mutex = mutex;
182210
this.retryAfterRatelimit = retryAfterRatelimit;
183211
this.cacheTime = cacheTime;
212+
this.bucket = bucket;
213+
this.domain = domain;
184214
}
185215

186216
/**
187217
* Prepares a request to Discord, associating it to the correct Bucket and attempting to prevent rate limits
188218
* @param options Options needed for making a request; only the path is required
189219
*/
190220
public async make<T, D = RequestBodyData, Q = StringRecord>(options: RequestOptions<D, Q>): Promise<T> {
191-
const route = Bucket.makeRoute(options.method, options.path);
221+
const route = this.bucket.makeRoute(options.method, options.path);
192222

193223
let bucket = this.buckets.get(route);
194224

195225
if (!bucket) {
196-
bucket = new Bucket(this, route);
226+
bucket = new this.bucket(this, route);
197227
this.buckets.set(route, bucket);
198228
}
199229

@@ -209,6 +239,7 @@ export class Rest extends EventEmitter {
209239
}
210240

211241
options.cacheTime ??= this.cacheTime;
242+
options.domain ??= this.domain;
212243

213244
let isRetryAfterRatelimit = false;
214245

libs/rest/src/struct/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1 @@
1-
export * from './Bucket';
21
export * from './Rest';

0 commit comments

Comments
 (0)