lunibee 0.1.6 → 0.1.7

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.
Files changed (55) hide show
  1. package/README.md +3 -3
  2. package/dist/builders/commands.d.ts +37 -0
  3. package/dist/builders/components.d.ts +19 -12
  4. package/dist/builders/embed.d.ts +3 -1
  5. package/dist/builders/index.d.ts +1 -1
  6. package/dist/builders/index.js +146 -19
  7. package/dist/builders/index.js.map +8 -7
  8. package/dist/collection/index.d.ts +30 -0
  9. package/dist/collection/index.js +78 -5
  10. package/dist/collection/index.js.map +4 -4
  11. package/dist/core/events.d.ts +2 -0
  12. package/dist/core/index.d.ts +38 -0
  13. package/dist/core/index.js +754 -175
  14. package/dist/core/index.js.map +24 -22
  15. package/dist/core/permissions.d.ts +112 -53
  16. package/dist/formatters/index.d.ts +43 -3
  17. package/dist/formatters/index.js +64 -1
  18. package/dist/formatters/index.js.map +3 -3
  19. package/dist/handlers/index.js.map +2 -2
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.js +1762 -291
  22. package/dist/index.js.map +43 -38
  23. package/dist/managers/emoji.d.ts +38 -0
  24. package/dist/managers/guild.d.ts +112 -1
  25. package/dist/managers/index.d.ts +31 -1
  26. package/dist/managers/index.js +803 -77
  27. package/dist/managers/index.js.map +21 -20
  28. package/dist/rest/index.d.ts +101 -11
  29. package/dist/rest/index.js +301 -45
  30. package/dist/rest/index.js.map +8 -6
  31. package/dist/rest/redis.d.ts +37 -0
  32. package/dist/rest/routes.d.ts +22 -0
  33. package/dist/rest/store.d.ts +38 -0
  34. package/dist/rest/webhook.d.ts +2 -2
  35. package/dist/sharding/cluster.d.ts +61 -0
  36. package/dist/sharding/index.d.ts +8 -0
  37. package/dist/sharding/index.js +286 -18
  38. package/dist/sharding/index.js.map +8 -7
  39. package/dist/structures/audit-log.d.ts +26 -0
  40. package/dist/structures/base.d.ts +32 -0
  41. package/dist/structures/index.d.ts +29 -1
  42. package/dist/structures/index.js +1022 -86
  43. package/dist/structures/index.js.map +14 -10
  44. package/dist/structures/resources.d.ts +192 -4
  45. package/dist/types/index.d.ts +76 -0
  46. package/dist/types/index.js.map +2 -2
  47. package/dist/utils/index.js.map +2 -2
  48. package/dist/voice/index.d.ts +88 -0
  49. package/dist/voice/index.js +204 -5
  50. package/dist/voice/index.js.map +3 -3
  51. package/dist/ws/index.d.ts +40 -1
  52. package/dist/ws/index.js +102 -18
  53. package/dist/ws/index.js.map +4 -4
  54. package/package.json +16 -16
  55. package/dist/structures/permissions.d.ts +0 -73
@@ -12,15 +12,86 @@ export declare class RESTError extends Error {
12
12
  cause?: unknown;
13
13
  });
14
14
  }
15
- /** Options controlling an individual REST request. */
16
- export interface RESTRequestOptions {
17
- /** Abort signal used to cancel the request and any queued wait. */ signal?: AbortSignal;
18
- }
15
+ import { type RateLimitStore } from "./store.js";
16
+ /** A file attachment sent as part of a multipart REST request. */
19
17
  export interface RESTFileAttachment {
20
18
  name: string;
21
19
  data: Blob | Uint8Array | ArrayBuffer;
22
20
  contentType?: string;
23
21
  }
22
+ /** Query-string value accepted by {@link RESTRequestOptions.query}. */
23
+ export type RESTQuery = URLSearchParams | Record<string, string | number | boolean | null | undefined> | string;
24
+ /**
25
+ * Options controlling an individual REST request.
26
+ *
27
+ * Lunibee's canonical call shape is positional (`post(path, body)`); these options
28
+ * add the Discord.js-`@discordjs/rest`-familiar per-request knobs (`query`, `headers`,
29
+ * `reason`, `auth`) additively without changing that positional default.
30
+ */
31
+ export interface RESTRequestOptions {
32
+ /** Abort signal used to cancel the request and any queued wait. */ signal?: AbortSignal;
33
+ /** Query-string appended to the path (`URLSearchParams`, a record, or a raw string). */ query?: RESTQuery;
34
+ /** Extra request headers merged in (library headers such as `Authorization` win). */ headers?: Record<string, string>;
35
+ /** Audit-log reason, sent as the `X-Audit-Log-Reason` header. */ reason?: string;
36
+ /** Set `false` to omit the `Authorization` header for this request. Defaults to `true`. */ auth?: boolean;
37
+ }
38
+ /**
39
+ * Discord.js-familiar request payload. Superset of {@link RESTRequestOptions} that also
40
+ * carries the `body` and `files`, matching `@discordjs/rest`'s `RequestData`. It powers the
41
+ * additive `method(path, { body })` overloads without breaking the positional signature.
42
+ */
43
+ export interface RequestData extends RESTRequestOptions {
44
+ /** Request body. Encoded as JSON unless `files` are present (then multipart). */ body?: unknown;
45
+ /** File attachments; presence upgrades the request to `multipart/form-data`. */ files?: RESTFileAttachment[];
46
+ }
47
+ /** Context passed to `RESTHooks.onRequest`. */
48
+ export interface RESTRequestContext {
49
+ method: string;
50
+ path: string;
51
+ attempt: number;
52
+ }
53
+ /** Context passed to `RESTHooks.onResponse`. */
54
+ export interface RESTResponseContext {
55
+ method: string;
56
+ path: string;
57
+ status: number;
58
+ durationMs: number;
59
+ }
60
+ /** Context passed to `RESTHooks.onRateLimit`. */
61
+ export interface RESTRateLimitContext {
62
+ method: string;
63
+ path: string;
64
+ retryAfterMs: number;
65
+ global: boolean;
66
+ bucket?: string;
67
+ }
68
+ /** Context passed to `RESTHooks.onRetry`. */
69
+ export interface RESTRetryContext {
70
+ method: string;
71
+ path: string;
72
+ attempt: number;
73
+ status: number;
74
+ delayMs: number;
75
+ }
76
+ /**
77
+ * Lightweight callback hooks for REST request lifecycle events.
78
+ *
79
+ * All callbacks are optional. They are dispatched in isolation from the request
80
+ * path (deferred to a microtask, and any thrown error is swallowed), so a hook
81
+ * cannot break or reject a request. Even so, hooks **must** be fast and
82
+ * non-blocking: heavy synchronous work in a hook still steals event-loop time
83
+ * from in-flight requests. Offload anything expensive to your own queue.
84
+ */
85
+ export interface RESTHooks {
86
+ /** Called just before every HTTP attempt. */
87
+ onRequest?(ctx: RESTRequestContext): void;
88
+ /** Called after a successful or failed response is received. */
89
+ onResponse?(ctx: RESTResponseContext): void;
90
+ /** Called when a rate limit is encountered and the client is about to wait. */
91
+ onRateLimit?(ctx: RESTRateLimitContext): void;
92
+ /** Called when a failed request is about to be retried. */
93
+ onRetry?(ctx: RESTRetryContext): void;
94
+ }
24
95
  /** Configures which REST failures may be retried. */
25
96
  export interface RetryPolicy {
26
97
  /** Maximum retry attempts. */ maxRetries: number;
@@ -39,7 +110,11 @@ export declare class REST {
39
110
  retries?: number;
40
111
  retryPolicy?: RetryPolicy;
41
112
  baseURL?: string;
113
+ hooks?: RESTHooks;
114
+ store?: RateLimitStore;
42
115
  });
116
+ /** Replaces the current hook set. Pass an empty object to clear all hooks. */
117
+ setHooks(hooks: RESTHooks): this;
43
118
  /** Sets the authentication token. @param token Discord bot token. @returns Nothing. @throws {TypeError} If token is empty. */
44
119
  setToken(token: string): void;
45
120
  /** Executes an HTTP request with Discord bucket/global rate limits, retry handling, and AbortSignal support.
@@ -47,16 +122,23 @@ export declare class REST {
47
122
  * @param method HTTP method.
48
123
  * @param path API path.
49
124
  * @param body Optional JSON body, FormData for file uploads, or undefined.
50
- * @param options Cancellation options.
125
+ * @param options Request options: cancellation `signal` plus the Discord.js-familiar
126
+ * `query`, `headers`, `reason`, `auth`, and `files` (which upgrade to multipart).
51
127
  * @returns Decoded response body.
52
128
  * @throws {RESTError} If Discord or transport rejects the request.
53
129
  */
54
- request<T>(method: string, path: string, body?: unknown, options?: RESTRequestOptions): Promise<T>;
55
- /** Sends a GET request. @param path API path. @param options Cancellation options. @returns Decoded response. @throws {RESTError} When the request fails. */ get<T>(path: string, options?: RESTRequestOptions): Promise<T>;
56
- /** Sends a POST request. @param path API path. @param body Optional JSON body. @param options Cancellation options. @returns Decoded response. @throws {RESTError} When the request fails. */ post<T>(path: string, body?: unknown, options?: RESTRequestOptions): Promise<T>;
57
- /** Sends a PATCH request. @param path API path. @param body Optional JSON body. @param options Cancellation options. @returns Decoded response. @throws {RESTError} When the request fails. */ patch<T>(path: string, body?: unknown, options?: RESTRequestOptions): Promise<T>;
58
- /** Sends a PUT request. @param path API path. @param body Optional JSON body. @param options Cancellation options. @returns Decoded response. @throws {RESTError} When the request fails. */ put<T>(path: string, body?: unknown, options?: RESTRequestOptions): Promise<T>;
59
- /** Sends a DELETE request. @param path API path. @param options Cancellation options. @returns Decoded response. @throws {RESTError} When the request fails. */ delete<T>(path: string, options?: RESTRequestOptions): Promise<T>;
130
+ request<T>(method: string, path: string, body?: unknown, options?: RequestData): Promise<T>;
131
+ /** Sends a GET request. @param path API path. @param options Request options (`query`, `headers`, `signal`, …). @returns Decoded response. @throws {RESTError} When the request fails. */ get<T>(path: string, options?: RequestData): Promise<T>;
132
+ /** Sends a POST request. Accepts a raw body (`post(path, body)`) or a Discord.js-familiar `post(path, { body })`. @param path API path. @param body Optional JSON body, or a `RequestData` wrapper. @param options Request options. @returns Decoded response. @throws {RESTError} When the request fails. */
133
+ post<T>(path: string, options: RequestData): Promise<T>;
134
+ post<T>(path: string, body?: unknown, options?: RequestData): Promise<T>;
135
+ /** Sends a PATCH request. Accepts a raw body (`patch(path, body)`) or a Discord.js-familiar `patch(path, { body })`. @param path API path. @param body Optional JSON body, or a `RequestData` wrapper. @param options Request options. @returns Decoded response. @throws {RESTError} When the request fails. */
136
+ patch<T>(path: string, options: RequestData): Promise<T>;
137
+ patch<T>(path: string, body?: unknown, options?: RequestData): Promise<T>;
138
+ /** Sends a PUT request. Accepts a raw body (`put(path, body)`) or a Discord.js-familiar `put(path, { body })`. @param path API path. @param body Optional JSON body, or a `RequestData` wrapper. @param options Request options. @returns Decoded response. @throws {RESTError} When the request fails. */
139
+ put<T>(path: string, options: RequestData): Promise<T>;
140
+ put<T>(path: string, body?: unknown, options?: RequestData): Promise<T>;
141
+ /** Sends a DELETE request. @param path API path. @param options Request options (`reason`, `headers`, `signal`, …). @returns Decoded response. @throws {RESTError} When the request fails. */ delete<T>(path: string, options?: RequestData): Promise<T>;
60
142
  /**
61
143
  * Sends a multipart/form-data POST request for file uploads.
62
144
  * Attaches a JSON payload as `payload_json` and files as additional fields.
@@ -80,4 +162,12 @@ export declare class REST {
80
162
  patchWithFiles<T>(path: string, payload: unknown, files: RESTFileAttachment[], options?: RESTRequestOptions): Promise<T>;
81
163
  }
82
164
  export { Routes } from "./routes.js";
165
+ export { type RateLimitStore, type BucketState, MemoryRateLimitStore, } from "./store.js";
166
+ export { RedisRateLimitStore, type RedisRateLimitStoreOptions, type MinimalRedisClient, } from "./redis.js";
83
167
  export { WebhookClient, type WebhookClientOptions, type WebhookMessageOptions, } from "./webhook.js";
168
+ /**
169
+ * Discord.js-familiar alias for {@link RESTError}. `@discordjs/rest` throws `DiscordAPIError`
170
+ * on a Discord-rejected request; Lunibee keeps `RESTError` as the canonical class and exports
171
+ * this alias so Discord.js-shaped `catch (err) { if (err instanceof DiscordAPIError) … }` works.
172
+ */
173
+ export { RESTError as DiscordAPIError };
@@ -2,7 +2,7 @@
2
2
  // packages/rest/package.json
3
3
  var package_default = {
4
4
  name: "@lunibee/rest",
5
- version: "0.1.6",
5
+ version: "0.1.7",
6
6
  type: "module",
7
7
  module: "./src/index.ts",
8
8
  types: "./src/index.ts",
@@ -11,6 +11,45 @@ var package_default = {
11
11
  }
12
12
  };
13
13
 
14
+ // packages/rest/src/store.ts
15
+ class MemoryRateLimitStore {
16
+ #buckets = new Map;
17
+ #routeBuckets = new Map;
18
+ #globalResetAt = 0;
19
+ static #PRUNE_INTERVAL = 60000;
20
+ #lastPruneAt = 0;
21
+ getBucketHash(route) {
22
+ return this.#routeBuckets.get(route);
23
+ }
24
+ setBucketHash(route, hash) {
25
+ this.#routeBuckets.set(route, hash);
26
+ }
27
+ getBucket(key) {
28
+ return this.#buckets.get(key);
29
+ }
30
+ updateBucket(key, state) {
31
+ this.#buckets.set(key, state);
32
+ const now = Date.now();
33
+ if (now - this.#lastPruneAt >= MemoryRateLimitStore.#PRUNE_INTERVAL) {
34
+ this.#lastPruneAt = now;
35
+ this.prune();
36
+ }
37
+ }
38
+ prune(maxAge = 0) {
39
+ const cutoff = Date.now() - Math.max(0, maxAge);
40
+ for (const [key, state] of this.#buckets) {
41
+ if (state.resetAt <= cutoff)
42
+ this.#buckets.delete(key);
43
+ }
44
+ }
45
+ getGlobalReset() {
46
+ return this.#globalResetAt;
47
+ }
48
+ setGlobalReset(resetAt) {
49
+ this.#globalResetAt = resetAt;
50
+ }
51
+ }
52
+
14
53
  // packages/rest/src/routes.ts
15
54
  var Routes = {
16
55
  user: () => "/users/@me",
@@ -56,13 +95,122 @@ var Routes = {
56
95
  guildAutoModerationRule: (guildId, ruleId) => `/guilds/${snowflake(guildId, "Guild ID")}/auto-moderation/rules/${snowflake(ruleId, "Rule ID")}`,
57
96
  voiceRegions: () => "/voice/regions",
58
97
  gateway: () => "/gateway",
59
- gatewayBot: () => "/gateway/bot"
98
+ gatewayBot: () => "/gateway/bot",
99
+ guildAuditLog: (guildId) => `/guilds/${snowflake(guildId, "Guild ID")}/audit-logs`,
100
+ guildMembers: (guildId) => `/guilds/${snowflake(guildId, "Guild ID")}/members`,
101
+ guildWelcomeScreen: (guildId) => `/guilds/${snowflake(guildId, "Guild ID")}/welcome-screen`,
102
+ guildOnboarding: (guildId) => `/guilds/${snowflake(guildId, "Guild ID")}/onboarding`,
103
+ guildPrune: (guildId) => `/guilds/${snowflake(guildId, "Guild ID")}/prune`,
104
+ stageInstances: () => "/stage-instances",
105
+ stageInstanceByChannel: (channelId) => `/stage-instances/${snowflake(channelId, "Channel ID")}`,
106
+ invite: (code) => `/invites/${encodeURIComponent(code)}`,
107
+ guildVanity: (guildId) => `/guilds/${snowflake(guildId, "Guild ID")}/vanity-url`,
108
+ guildEmoji: (guildId, emojiId) => emojiId ? `/guilds/${snowflake(guildId, "Guild ID")}/emojis/${snowflake(emojiId, "Emoji ID")}` : `/guilds/${snowflake(guildId, "Guild ID")}/emojis`,
109
+ applicationEmoji: (applicationId, emojiId) => emojiId ? `/applications/${snowflake(applicationId, "Application ID")}/emojis/${snowflake(emojiId, "Emoji ID")}` : `/applications/${snowflake(applicationId, "Application ID")}/emojis`
60
110
  };
61
111
  function snowflake(value, field) {
62
112
  if (!/^\d{1,20}$/.test(value))
63
113
  throw new TypeError(`${field} must be a valid Discord snowflake.`);
64
114
  return value;
65
115
  }
116
+ // packages/rest/src/redis.ts
117
+ class RedisRateLimitStore {
118
+ #client;
119
+ #prefix;
120
+ #onErrorCallback;
121
+ #lastError;
122
+ constructor(options) {
123
+ this.#client = options.client;
124
+ this.#prefix = options.prefix ?? "lunibee:rest:";
125
+ this.#onErrorCallback = options.onError;
126
+ }
127
+ get lastError() {
128
+ return this.#lastError;
129
+ }
130
+ isHealthy() {
131
+ return this.#lastError === undefined;
132
+ }
133
+ #onError(operation, error) {
134
+ this.#lastError = error;
135
+ console.warn(`[lunibee/rest] Redis rate-limit store ${operation} failed; ` + `falling back to local limiting for this call.`, error);
136
+ try {
137
+ this.#onErrorCallback?.(operation, error);
138
+ } catch {}
139
+ }
140
+ #markHealthy() {
141
+ this.#lastError = undefined;
142
+ }
143
+ async getBucketHash(route) {
144
+ try {
145
+ const hash = await this.#client.get(`${this.#prefix}route:${route}`);
146
+ this.#markHealthy();
147
+ return hash ?? undefined;
148
+ } catch (error) {
149
+ this.#onError("getBucketHash", error);
150
+ return;
151
+ }
152
+ }
153
+ async setBucketHash(route, hash) {
154
+ try {
155
+ await this.#client.set(`${this.#prefix}route:${route}`, hash, "EX", 604800);
156
+ this.#markHealthy();
157
+ } catch (error) {
158
+ this.#onError("setBucketHash", error);
159
+ }
160
+ }
161
+ async getBucket(key) {
162
+ try {
163
+ const data = await this.#client.get(`${this.#prefix}bucket:${key}`);
164
+ this.#markHealthy();
165
+ if (!data)
166
+ return;
167
+ return JSON.parse(data);
168
+ } catch (error) {
169
+ this.#onError("getBucket", error);
170
+ return;
171
+ }
172
+ }
173
+ async updateBucket(key, state) {
174
+ const bucketKey = `${this.#prefix}bucket:${key}`;
175
+ const ttl = Math.ceil((state.resetAt - Date.now()) / 1000);
176
+ try {
177
+ if (ttl <= 0) {
178
+ await this.#client.del(bucketKey);
179
+ this.#markHealthy();
180
+ return;
181
+ }
182
+ await this.#client.set(bucketKey, JSON.stringify(state), "EX", ttl);
183
+ this.#markHealthy();
184
+ } catch (error) {
185
+ this.#onError("updateBucket", error);
186
+ }
187
+ }
188
+ async getGlobalReset() {
189
+ try {
190
+ const resetAt = await this.#client.get(`${this.#prefix}global`);
191
+ this.#markHealthy();
192
+ return resetAt ? Number(resetAt) : 0;
193
+ } catch (error) {
194
+ this.#onError("getGlobalReset", error);
195
+ return 0;
196
+ }
197
+ }
198
+ async setGlobalReset(resetAt) {
199
+ const globalKey = `${this.#prefix}global`;
200
+ const ttl = Math.ceil((resetAt - Date.now()) / 1000);
201
+ try {
202
+ if (ttl <= 0) {
203
+ await this.#client.del(globalKey);
204
+ this.#markHealthy();
205
+ return;
206
+ }
207
+ await this.#client.set(globalKey, String(resetAt), "EX", ttl);
208
+ this.#markHealthy();
209
+ } catch (error) {
210
+ this.#onError("setGlobalReset", error);
211
+ }
212
+ }
213
+ }
66
214
  // packages/rest/src/webhook.ts
67
215
  class WebhookClient {
68
216
  id;
@@ -87,7 +235,7 @@ class WebhookClient {
87
235
  const payload = typeof options === "string" ? { content: options } : options;
88
236
  const { thread_id, ...body } = payload;
89
237
  const query = thread_id ? `?thread_id=${thread_id}&wait=true` : `?wait=true`;
90
- const formattedEmbeds = body.embeds?.map((e) => typeof e === "object" && e !== null && ("toJSON" in e) ? e.toJSON() : e);
238
+ const formattedEmbeds = body.embeds?.map((e) => ("toJSON" in e) ? e.toJSON() : e);
91
239
  return this.#rest.post(`${Routes.webhook(this.id, this.token)}${query}`, {
92
240
  ...body,
93
241
  embeds: formattedEmbeds
@@ -122,6 +270,49 @@ class RESTError extends Error {
122
270
  var LIBRARY_VERSION = package_default.version;
123
271
  var LIBRARY_URL = "https://github.com/Ekretos/lunibee";
124
272
  var USER_AGENT = `DiscordBot (${LIBRARY_URL}, ${package_default.version})`;
273
+ var REQUEST_DATA_KEYS = new Set([
274
+ "body",
275
+ "files",
276
+ "query",
277
+ "headers",
278
+ "reason",
279
+ "auth",
280
+ "signal"
281
+ ]);
282
+ function isRequestData(value) {
283
+ if (value === null || typeof value !== "object")
284
+ return false;
285
+ if (Array.isArray(value))
286
+ return false;
287
+ if (typeof FormData !== "undefined" && value instanceof FormData)
288
+ return false;
289
+ if (value instanceof Blob || value instanceof Uint8Array || value instanceof ArrayBuffer)
290
+ return false;
291
+ const keys = Object.keys(value);
292
+ if (keys.length === 0)
293
+ return false;
294
+ return keys.every((key) => REQUEST_DATA_KEYS.has(key));
295
+ }
296
+ function serializeQuery(query) {
297
+ if (query === undefined)
298
+ return "";
299
+ if (typeof query === "string")
300
+ return query.length === 0 || query.startsWith("?") ? query : `?${query}`;
301
+ const params = query instanceof URLSearchParams ? query : new URLSearchParams(Object.entries(query).flatMap(([key, value]) => value === undefined || value === null ? [] : [[key, String(value)]]));
302
+ const serialized = params.toString();
303
+ return serialized.length === 0 ? "" : `?${serialized}`;
304
+ }
305
+ function majorParameter(path) {
306
+ const match = /^\/(channels|guilds|webhooks)\/(\d+)(?:\/([^/?]+))?/.exec(path);
307
+ if (!match)
308
+ return "@none";
309
+ if (match[1] === "webhooks" && match[3] !== undefined)
310
+ return `${match[2]}:${match[3]}`;
311
+ return match[2];
312
+ }
313
+ function scopeBucket(hashOrRoute, major) {
314
+ return `${hashOrRoute}|${major}`;
315
+ }
125
316
  function createRetryPolicy(maxRetries = 2) {
126
317
  if (!Number.isFinite(maxRetries) || maxRetries < 0)
127
318
  throw new TypeError("maxRetries must be a non-negative finite number.");
@@ -144,14 +335,21 @@ class REST {
144
335
  #timeout;
145
336
  #retryPolicy;
146
337
  #token;
147
- #globalResetAt = 0;
148
- #buckets = new Map;
149
- #routeBuckets = new Map;
338
+ #store;
339
+ #localQueues = new Map;
340
+ #hooks = {};
150
341
  constructor(options = {}) {
151
342
  this.#token = options.token;
152
343
  this.#timeout = Math.max(1, options.timeout ?? 15000);
153
344
  this.#retryPolicy = options.retryPolicy ?? createRetryPolicy(options.retries ?? 2);
154
345
  this.#baseURL = (options.baseURL ?? "https://discord.com/api/v10").replace(/\/$/, "");
346
+ this.#store = options.store ?? new MemoryRateLimitStore;
347
+ if (options.hooks)
348
+ this.#hooks = options.hooks;
349
+ }
350
+ setHooks(hooks) {
351
+ this.#hooks = hooks;
352
+ return this;
155
353
  }
156
354
  setToken(token) {
157
355
  if (!token.trim())
@@ -164,56 +362,91 @@ class REST {
164
362
  throw new TypeError("REST method is required.");
165
363
  if (!path.startsWith("/"))
166
364
  throw new TypeError("REST paths must start with '/'.");
365
+ const files = options.files;
366
+ if (files && files.length > 0 && !(body instanceof FormData))
367
+ body = this.#fileForm(body, files);
368
+ const query = serializeQuery(options.query);
369
+ const finalPath = query ? `${path}${query}` : path;
167
370
  const route = `${normalizedMethod}:${this.#normalizeRoute(path)}`;
168
- let bucketKey = this.#routeBuckets.get(route) ?? route;
169
- let bucket = this.#buckets.get(bucketKey) ?? {
170
- remaining: 1,
171
- resetAt: 0,
172
- queue: Promise.resolve()
173
- };
174
- this.#buckets.set(bucketKey, bucket);
175
- const previous = bucket.queue;
371
+ const major = majorParameter(path);
372
+ let bucketKey = scopeBucket(await this.#store.getBucketHash(route) ?? route, major);
373
+ const queueKey = bucketKey;
374
+ const previous = this.#localQueues.get(queueKey) ?? Promise.resolve();
176
375
  let release;
177
- bucket.queue = new Promise((resolve) => {
376
+ const gate = new Promise((resolve) => {
178
377
  release = resolve;
179
378
  });
379
+ this.#localQueues.set(queueKey, gate);
180
380
  await this.#abortable(previous, options.signal, path);
181
381
  try {
182
382
  for (let attempt = 0;attempt <= this.#retryPolicy.maxRetries; attempt++) {
183
- await this.#wait(bucket, options.signal, path);
383
+ await this.#wait(bucketKey, options.signal, path);
184
384
  await this.#waitGlobal(options.signal, path);
185
385
  const controller = new AbortController;
186
386
  const onAbort = () => controller.abort(options.signal?.reason);
187
387
  if (options.signal?.aborted)
188
388
  throw this.#abortError(path, options.signal.reason);
189
- options.signal?.addEventListener("abort", onAbort, { once: true });
389
+ options.signal?.addEventListener("abort", onAbort, {
390
+ once: true
391
+ });
190
392
  const timer = setTimeout(() => controller.abort(new DOMException("REST request timeout", "TimeoutError")), this.#timeout);
191
393
  const isFormData = typeof FormData !== "undefined" && body instanceof FormData;
192
- const headers = {
193
- ...this.#token ? { Authorization: `Bot ${this.#token}` } : {},
194
- "User-Agent": USER_AGENT
195
- };
394
+ const headers = { ...options.headers };
395
+ if (options.auth !== false && this.#token)
396
+ headers["Authorization"] = `Bot ${this.#token}`;
397
+ headers["User-Agent"] = USER_AGENT;
398
+ if (options.reason !== undefined)
399
+ headers["X-Audit-Log-Reason"] = encodeURIComponent(options.reason);
196
400
  if (!isFormData)
197
401
  headers["Content-Type"] = "application/json";
198
402
  const fetchBody = isFormData ? body : body === undefined ? undefined : JSON.stringify(body);
199
403
  try {
200
- const response = await fetch(`${this.#baseURL}${path}`, {
404
+ this.#emit(this.#hooks.onRequest, {
405
+ method: normalizedMethod,
406
+ path,
407
+ attempt
408
+ });
409
+ const requestStart = Date.now();
410
+ const response = await fetch(`${this.#baseURL}${finalPath}`, {
201
411
  method: normalizedMethod,
202
412
  headers,
203
413
  body: fetchBody,
204
414
  signal: controller.signal
205
415
  });
206
- bucket = this.#update(response, bucket, route, bucketKey);
207
- bucketKey = this.#routeBuckets.get(route) ?? bucketKey;
416
+ this.#emit(this.#hooks.onResponse, {
417
+ method: normalizedMethod,
418
+ path,
419
+ status: response.status,
420
+ durationMs: Date.now() - requestStart
421
+ });
422
+ bucketKey = await this.#update(response, bucketKey, route, major);
208
423
  const payload = await this.#readPayload(response);
209
424
  if (response.ok)
210
425
  return response.status === 204 ? undefined : payload;
211
426
  const data = this.#errorData(payload);
212
427
  const retryAfter = response.status === 429 ? this.#retryAfter(response, data) : undefined;
213
- if (data.global && retryAfter !== undefined)
214
- this.#globalResetAt = Date.now() + retryAfter * 1000;
428
+ if (data.global && retryAfter !== undefined) {
429
+ const currentGlobal = await this.#store.getGlobalReset();
430
+ await this.#store.setGlobalReset(Math.max(currentGlobal, Date.now() + retryAfter * 1000));
431
+ }
432
+ if (response.status === 429)
433
+ this.#emit(this.#hooks.onRateLimit, {
434
+ method: normalizedMethod,
435
+ path,
436
+ retryAfterMs: (retryAfter ?? 1) * 1000,
437
+ global: data.global ?? false,
438
+ bucket: await this.#store.getBucketHash(route)
439
+ });
215
440
  if (attempt < this.#retryPolicy.maxRetries && this.#retryPolicy.shouldRetry(normalizedMethod, response.status)) {
216
- await this.#sleep(this.#retryPolicy.getDelay(attempt, retryAfter), options.signal, path);
441
+ const delay = this.#retryPolicy.getDelay(attempt, retryAfter);
442
+ this.#emit(this.#hooks.onRetry, {
443
+ method: normalizedMethod,
444
+ path,
445
+ attempt,
446
+ status: response.status,
447
+ delayMs: delay
448
+ });
449
+ await this.#sleep(delay, options.signal, path);
217
450
  continue;
218
451
  }
219
452
  throw new RESTError(data.message ?? response.statusText ?? `Discord REST request failed with status ${response.status}`, response.status, data.code, payload, { method: normalizedMethod, path });
@@ -236,22 +469,29 @@ class REST {
236
469
  throw new RESTError("Discord REST request exhausted its retry attempts", 0, undefined, undefined, { method: normalizedMethod, path });
237
470
  } finally {
238
471
  release();
472
+ if (this.#localQueues.get(queueKey) === gate)
473
+ this.#localQueues.delete(queueKey);
239
474
  }
240
475
  }
476
+ #dispatch(method, path, bodyOrOptions, options) {
477
+ if (options === undefined && isRequestData(bodyOrOptions))
478
+ return this.request(method, path, bodyOrOptions.body, bodyOrOptions);
479
+ return this.request(method, path, bodyOrOptions, options);
480
+ }
241
481
  get(path, options) {
242
- return this.request("GET", path, undefined, options);
482
+ return this.request("GET", path, options?.body, options);
243
483
  }
244
484
  post(path, body, options) {
245
- return this.request("POST", path, body, options);
485
+ return this.#dispatch("POST", path, body, options);
246
486
  }
247
487
  patch(path, body, options) {
248
- return this.request("PATCH", path, body, options);
488
+ return this.#dispatch("PATCH", path, body, options);
249
489
  }
250
490
  put(path, body, options) {
251
- return this.request("PUT", path, body, options);
491
+ return this.#dispatch("PUT", path, body, options);
252
492
  }
253
493
  delete(path, options) {
254
- return this.request("DELETE", path, undefined, options);
494
+ return this.request("DELETE", path, options?.body, options);
255
495
  }
256
496
  #fileForm(payload, files) {
257
497
  const form = new FormData;
@@ -271,13 +511,22 @@ class REST {
271
511
  patchWithFiles(path, payload, files, options) {
272
512
  return this.request("PATCH", path, this.#fileForm(payload, files), options);
273
513
  }
274
- async#wait(bucket, signal, path) {
514
+ #emit(hook, ctx) {
515
+ if (!hook)
516
+ return;
517
+ Promise.resolve().then(() => hook(ctx)).catch(() => {});
518
+ }
519
+ async#wait(bucketKey, signal, path) {
520
+ const bucket = await this.#store.getBucket(bucketKey);
521
+ if (!bucket)
522
+ return;
275
523
  const delay = bucket.resetAt - Date.now();
276
524
  if (bucket.remaining <= 0 && delay > 0)
277
525
  await this.#sleep(delay, signal, path);
278
526
  }
279
527
  async#waitGlobal(signal, path) {
280
- const delay = this.#globalResetAt - Date.now();
528
+ const globalResetAt = await this.#store.getGlobalReset();
529
+ const delay = globalResetAt - Date.now();
281
530
  if (delay > 0)
282
531
  await this.#sleep(delay, signal, path);
283
532
  }
@@ -341,16 +590,17 @@ class REST {
341
590
  const routePath = queryIndex === -1 ? path : path.slice(0, queryIndex);
342
591
  return routePath.replace(/\/\d+(?=\/|$)/g, "/:id");
343
592
  }
344
- #update(response, bucket, route, bucketKey) {
593
+ async#update(response, bucketKey, route, major) {
594
+ let currentKey = bucketKey;
345
595
  const serverBucket = response.headers.get("X-RateLimit-Bucket");
346
596
  if (serverBucket) {
347
- this.#routeBuckets.set(route, serverBucket);
348
- if (serverBucket !== bucketKey) {
349
- const shared = this.#buckets.get(serverBucket) ?? bucket;
350
- this.#buckets.set(serverBucket, shared);
351
- bucket = shared;
352
- }
597
+ await this.#store.setBucketHash(route, serverBucket);
598
+ currentKey = scopeBucket(serverBucket, major);
353
599
  }
600
+ let bucket = await this.#store.getBucket(currentKey) ?? {
601
+ remaining: 1,
602
+ resetAt: 0
603
+ };
354
604
  const remaining = Number(response.headers.get("X-RateLimit-Remaining"));
355
605
  const resetAfter = Number(response.headers.get("X-RateLimit-Reset-After"));
356
606
  const reset = Number(response.headers.get("X-RateLimit-Reset"));
@@ -365,20 +615,26 @@ class REST {
365
615
  if (Number.isFinite(retryAfter))
366
616
  bucket.resetAt = Math.max(bucket.resetAt, Date.now() + retryAfter * 1000);
367
617
  }
618
+ await this.#store.updateBucket(currentKey, bucket);
368
619
  if (response.headers.get("X-RateLimit-Global") === "true") {
369
620
  const retryAfter = Number(response.headers.get("Retry-After"));
370
- if (Number.isFinite(retryAfter))
371
- this.#globalResetAt = Math.max(this.#globalResetAt, Date.now() + retryAfter * 1000);
621
+ if (Number.isFinite(retryAfter)) {
622
+ const currentGlobal = await this.#store.getGlobalReset();
623
+ await this.#store.setGlobalReset(Math.max(currentGlobal, Date.now() + retryAfter * 1000));
624
+ }
372
625
  }
373
- return bucket;
626
+ return currentKey;
374
627
  }
375
628
  }
376
629
  export {
630
+ RESTError as DiscordAPIError,
631
+ MemoryRateLimitStore,
377
632
  REST,
378
633
  RESTError,
634
+ RedisRateLimitStore,
379
635
  Routes,
380
636
  WebhookClient,
381
637
  createRetryPolicy
382
638
  };
383
639
 
384
- //# debugId=429E85E3F97473C964756E2164756E21
640
+ //# debugId=7273A79583E1900164756E2164756E21