lunibee 0.3.0 → 0.3.2

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 (87) hide show
  1. package/dist/builders/attachment.d.ts +1 -0
  2. package/dist/builders/clone.d.ts +10 -0
  3. package/dist/builders/commands.d.ts +1 -1
  4. package/dist/builders/components.d.ts +24 -5
  5. package/dist/builders/embed.d.ts +1 -0
  6. package/dist/builders/index.js +68 -17
  7. package/dist/builders/index.js.map +10 -9
  8. package/dist/collection/index.d.ts +1 -0
  9. package/dist/collection/index.js +297 -19
  10. package/dist/collection/index.js.map +5 -4
  11. package/dist/collection/redis.d.ts +98 -0
  12. package/dist/core/collector.d.ts +8 -0
  13. package/dist/core/events.d.ts +1 -0
  14. package/dist/core/index.d.ts +3 -0
  15. package/dist/core/index.js +6252 -4704
  16. package/dist/core/index.js.map +47 -40
  17. package/dist/core/permissions.d.ts +3 -402
  18. package/dist/core/redis-cache.d.ts +85 -0
  19. package/dist/formatters/index.d.ts +5 -1
  20. package/dist/formatters/index.js +7 -3
  21. package/dist/formatters/index.js.map +3 -3
  22. package/dist/handlers/index.js +11 -2
  23. package/dist/handlers/index.js.map +3 -3
  24. package/dist/index.js +10481 -6148
  25. package/dist/index.js.map +70 -56
  26. package/dist/managers/application.d.ts +10 -6
  27. package/dist/managers/base.d.ts +4 -1
  28. package/dist/managers/guild.d.ts +3 -1
  29. package/dist/managers/index.d.ts +6 -1
  30. package/dist/managers/index.js +365 -103
  31. package/dist/managers/index.js.map +19 -22
  32. package/dist/managers/member.d.ts +3 -1
  33. package/dist/managers/message.d.ts +13 -4
  34. package/dist/managers/thread.d.ts +3 -3
  35. package/dist/rest/decoder.d.ts +2 -0
  36. package/dist/rest/errors.d.ts +13 -2
  37. package/dist/rest/headers.d.ts +31 -0
  38. package/dist/rest/identity.d.ts +31 -0
  39. package/dist/rest/index.d.ts +99 -6
  40. package/dist/rest/index.js +1412 -231
  41. package/dist/rest/index.js.map +16 -13
  42. package/dist/rest/limiter.d.ts +75 -10
  43. package/dist/rest/redis.d.ts +103 -22
  44. package/dist/rest/scheduler.d.ts +13 -0
  45. package/dist/rest/store.d.ts +135 -12
  46. package/dist/rest/telemetry.d.ts +151 -0
  47. package/dist/rest/transport.d.ts +9 -0
  48. package/dist/rest/webhook.d.ts +18 -6
  49. package/dist/sharding/bus.d.ts +12 -2
  50. package/dist/sharding/cluster.d.ts +225 -15
  51. package/dist/sharding/context.d.ts +41 -0
  52. package/dist/sharding/gateway-bot.d.ts +20 -0
  53. package/dist/sharding/health.d.ts +94 -0
  54. package/dist/sharding/host.d.ts +76 -0
  55. package/dist/sharding/identify.d.ts +64 -0
  56. package/dist/sharding/index.d.ts +62 -12
  57. package/dist/sharding/index.js +2459 -202
  58. package/dist/sharding/index.js.map +21 -13
  59. package/dist/sharding/protocol.d.ts +47 -0
  60. package/dist/sharding/remote-protocol.d.ts +100 -0
  61. package/dist/sharding/remote.d.ts +134 -0
  62. package/dist/sharding/requests.d.ts +54 -0
  63. package/dist/sharding/supervisor.d.ts +1 -1
  64. package/dist/sharding/transport.d.ts +6 -5
  65. package/dist/sharding/worker.d.ts +55 -0
  66. package/dist/structures/base.d.ts +16 -5
  67. package/dist/structures/index.d.ts +1 -1
  68. package/dist/structures/index.js +57 -91
  69. package/dist/structures/index.js.map +10 -18
  70. package/dist/structures/options.d.ts +3 -2
  71. package/dist/structures/permissions.d.ts +409 -0
  72. package/dist/structures/resources.d.ts +4 -1
  73. package/dist/types/gateway.d.ts +7 -0
  74. package/dist/types/index.d.ts +14 -4
  75. package/dist/types/index.js.map +3 -3
  76. package/dist/utils/args.d.ts +3 -1
  77. package/dist/utils/index.d.ts +6 -1
  78. package/dist/utils/index.js +17 -10
  79. package/dist/utils/index.js.map +4 -4
  80. package/dist/voice/index.d.ts +7 -1
  81. package/dist/voice/index.js +33 -10
  82. package/dist/voice/index.js.map +3 -3
  83. package/dist/ws/index.d.ts +3 -1
  84. package/dist/ws/index.js +33 -5
  85. package/dist/ws/index.js.map +6 -6
  86. package/dist/ws/send-budget.d.ts +1 -1
  87. package/package.json +22 -17
@@ -2,7 +2,7 @@
2
2
  // packages/rest/package.json
3
3
  var package_default = {
4
4
  name: "@lunibee/rest",
5
- version: "0.3.0",
5
+ version: "0.3.2",
6
6
  type: "module",
7
7
  module: "./src/index.ts",
8
8
  types: "./src/index.ts",
@@ -15,38 +15,179 @@ var package_default = {
15
15
  };
16
16
 
17
17
  // packages/rest/src/store.ts
18
+ var GLOBAL_WINDOW_MS = 1050;
19
+ function takeGlobalSlot(log, now, limit, window = GLOBAL_WINDOW_MS) {
20
+ while (log.length > 0 && log[0] <= now - window)
21
+ log.shift();
22
+ if (log.length >= limit)
23
+ return Math.max(1, log[0] + window - now + 1);
24
+ log.push(now);
25
+ return 0;
26
+ }
27
+ var PROBE_POLL_MS = 50;
28
+ var DEFAULT_PROBE_MS = 16000;
29
+ var BUCKET_LINGER_MS = 60000;
30
+ var WINDOW_TOLERANCE_MS = 50;
31
+ function reserveState(state, now, probeMs) {
32
+ if (state && state.resetAt > now) {
33
+ if (state.remaining > 0)
34
+ return {
35
+ reservation: {
36
+ granted: true,
37
+ known: true,
38
+ resetAt: state.resetAt
39
+ },
40
+ next: { ...state, remaining: state.remaining - 1 }
41
+ };
42
+ const wait = state.resetAt - now;
43
+ return {
44
+ reservation: {
45
+ granted: false,
46
+ retryAfterMs: state.provisional ? Math.min(wait, PROBE_POLL_MS) : wait,
47
+ reason: state.probe ? "probe" : "bucket"
48
+ }
49
+ };
50
+ }
51
+ const resetAt = now + probeMs;
52
+ if (state?.limit !== undefined && state.limit > 0)
53
+ return {
54
+ reservation: { granted: true, known: true, resetAt },
55
+ next: {
56
+ remaining: state.limit - 1,
57
+ resetAt,
58
+ limit: state.limit,
59
+ provisional: true
60
+ }
61
+ };
62
+ return {
63
+ reservation: { granted: true, known: false, resetAt },
64
+ next: { remaining: 0, resetAt, provisional: true, probe: true }
65
+ };
66
+ }
67
+ function reconcileState(state, update, now) {
68
+ const incoming = update.resetAfterMs === undefined ? undefined : Math.ceil(now + update.resetAfterMs);
69
+ const active = !!state && state.resetAt > now && !state.probe;
70
+ let next;
71
+ if (active) {
72
+ next = { ...state };
73
+ const stale = update.window !== undefined && next.window !== undefined && update.window < next.window - WINDOW_TOLERANCE_MS;
74
+ if (!stale) {
75
+ if (update.remaining !== undefined)
76
+ next.remaining = Math.min(next.remaining, update.remaining);
77
+ if (incoming !== undefined) {
78
+ const newer = update.window !== undefined && next.window !== undefined && update.window > next.window + WINDOW_TOLERANCE_MS;
79
+ next.resetAt = next.provisional || newer ? incoming : Math.max(next.resetAt, incoming);
80
+ delete next.provisional;
81
+ }
82
+ if (update.window !== undefined)
83
+ next.window = Math.max(next.window ?? 0, update.window);
84
+ }
85
+ } else {
86
+ if (update.remaining === undefined && update.retryAfterMs === undefined) {
87
+ return state?.probe ? null : undefined;
88
+ }
89
+ next = {
90
+ remaining: update.remaining ?? 0,
91
+ resetAt: incoming ?? now
92
+ };
93
+ if (state?.limit !== undefined)
94
+ next.limit = state.limit;
95
+ if (update.window !== undefined)
96
+ next.window = update.window;
97
+ }
98
+ if (update.limit !== undefined && update.limit > 0)
99
+ next.limit = update.limit;
100
+ if (update.retryAfterMs !== undefined) {
101
+ next.remaining = 0;
102
+ next.resetAt = Math.max(next.resetAt, Math.ceil(now + update.retryAfterMs));
103
+ delete next.provisional;
104
+ }
105
+ return next;
106
+ }
107
+
18
108
  class MemoryRateLimitStore {
19
109
  #buckets = new Map;
20
110
  #routeBuckets = new Map;
21
- #globalResetAt = 0;
111
+ #globals = new Map;
112
+ #globalLogs = new Map;
22
113
  static #PRUNE_INTERVAL = 60000;
23
114
  #lastPruneAt = 0;
115
+ static #MAX_ENTRIES = 1e4;
116
+ reservesGlobal = true;
117
+ constructor() {}
118
+ static #evict(map) {
119
+ while (map.size > MemoryRateLimitStore.#MAX_ENTRIES)
120
+ map.delete(map.keys().next().value);
121
+ }
24
122
  getBucketHash(route) {
25
123
  return this.#routeBuckets.get(route);
26
124
  }
27
125
  setBucketHash(route, hash) {
126
+ this.#routeBuckets.delete(route);
28
127
  this.#routeBuckets.set(route, hash);
128
+ MemoryRateLimitStore.#evict(this.#routeBuckets);
29
129
  }
30
130
  getBucket(key) {
31
131
  return this.#buckets.get(key);
32
132
  }
33
- reserve(key) {
34
- const state = this.#buckets.get(key);
133
+ reserve(key, options = {}) {
35
134
  const now = Date.now();
36
- if (!state || state.resetAt <= now)
37
- return { granted: true };
38
- if (state.remaining > 0) {
39
- state.remaining -= 1;
40
- return { granted: true };
135
+ if (options.global !== undefined) {
136
+ const globalWait = this.getGlobalReset(options.global) - now;
137
+ if (globalWait > 0)
138
+ return {
139
+ granted: false,
140
+ retryAfterMs: globalWait,
141
+ reason: "global"
142
+ };
41
143
  }
42
- return { granted: false, retryAfterMs: state.resetAt - now };
144
+ const limit = options.global === undefined ? 0 : options.globalLimit ?? 0;
145
+ let log;
146
+ if (limit > 0) {
147
+ log = this.#globalLogs.get(options.global);
148
+ if (!log) {
149
+ this.#globalLogs.set(options.global, log = []);
150
+ MemoryRateLimitStore.#evict(this.#globalLogs);
151
+ }
152
+ const wait = takeGlobalSlot(log, now, limit);
153
+ if (wait > 0)
154
+ return { granted: false, retryAfterMs: wait, reason: "global" };
155
+ log.pop();
156
+ }
157
+ const { reservation, next } = reserveState(this.#buckets.get(key), now, options.probeMs ?? DEFAULT_PROBE_MS);
158
+ if (log && reservation.granted)
159
+ log.push(now);
160
+ if (next)
161
+ this.#write(key, next);
162
+ return reservation;
163
+ }
164
+ reconcile(key, update) {
165
+ const next = reconcileState(this.#buckets.get(key), update, Date.now());
166
+ if (next === null)
167
+ this.#buckets.delete(key);
168
+ else if (next)
169
+ this.#write(key, next);
170
+ }
171
+ release(key, grant, refund) {
172
+ const state = this.#buckets.get(key);
173
+ if (!state || state.resetAt !== grant.resetAt)
174
+ return;
175
+ if (state.probe)
176
+ this.#buckets.delete(key);
177
+ else if (refund)
178
+ state.remaining = Math.min(state.remaining + 1, state.limit ?? Number.POSITIVE_INFINITY);
43
179
  }
44
180
  updateBucket(key, state) {
181
+ this.#write(key, state);
182
+ }
183
+ #write(key, state) {
184
+ this.#buckets.delete(key);
45
185
  this.#buckets.set(key, state);
186
+ MemoryRateLimitStore.#evict(this.#buckets);
46
187
  const now = Date.now();
47
188
  if (now - this.#lastPruneAt >= MemoryRateLimitStore.#PRUNE_INTERVAL) {
48
189
  this.#lastPruneAt = now;
49
- this.prune();
190
+ this.prune(BUCKET_LINGER_MS);
50
191
  }
51
192
  }
52
193
  prune(maxAge = 0) {
@@ -55,12 +196,27 @@ class MemoryRateLimitStore {
55
196
  if (state.resetAt <= cutoff)
56
197
  this.#buckets.delete(key);
57
198
  }
199
+ for (const [scope, resetAt] of this.#globals)
200
+ if (resetAt <= Date.now())
201
+ this.#globals.delete(scope);
202
+ for (const [scope, log] of this.#globalLogs)
203
+ if ((log.at(-1) ?? 0) <= Date.now() - GLOBAL_WINDOW_MS)
204
+ this.#globalLogs.delete(scope);
205
+ }
206
+ getGlobalReset(scope = "") {
207
+ return this.#globals.get(scope) ?? 0;
58
208
  }
59
- getGlobalReset() {
60
- return this.#globalResetAt;
209
+ setGlobalReset(resetAt, scope = "") {
210
+ this.#globals.set(scope, resetAt);
211
+ MemoryRateLimitStore.#evict(this.#globals);
61
212
  }
62
- setGlobalReset(resetAt) {
63
- this.#globalResetAt = resetAt;
213
+ extendGlobalReset(scope, waitMs) {
214
+ const resetAt = Date.now() + Math.max(0, waitMs);
215
+ if (resetAt > this.getGlobalReset(scope))
216
+ this.setGlobalReset(resetAt, scope);
217
+ }
218
+ get size() {
219
+ return this.#buckets.size;
64
220
  }
65
221
  }
66
222
 
@@ -85,6 +241,24 @@ function kindFromStatus(status) {
85
241
  function redactPath(path) {
86
242
  return path.replace(/^(\/webhooks\/[^/?]+\/)[^/?]+/i, "$1:token").replace(/^(\/interactions\/[^/?]+\/)[^/?]+/i, "$1:token");
87
243
  }
244
+ var TOKEN_IN_TEXT = /(\/(?:webhooks|interactions)\/[^/\s?"'`]+\/)[^/\s?"'`]+/gi;
245
+ function redactText(text) {
246
+ return text.replace(TOKEN_IN_TEXT, "$1:token");
247
+ }
248
+ function sanitizeCause(error) {
249
+ if (!(error instanceof Error))
250
+ return;
251
+ const copy = new Error(redactText(error.message));
252
+ copy.name = error.name;
253
+ if (error.stack)
254
+ copy.stack = redactText(error.stack);
255
+ for (const key of ["code", "errno", "syscall"]) {
256
+ const value = error[key];
257
+ if (typeof value === "string" || typeof value === "number")
258
+ copy[key] = value;
259
+ }
260
+ return copy;
261
+ }
88
262
  var HINTS = {
89
263
  50001: "The bot cannot see this resource: check that it is in the guild and has View Channel there.",
90
264
  50013: "The bot lacks a permission for this action, or the target role or member is at or above the bot's highest role."
@@ -110,24 +284,31 @@ class RESTError extends Error {
110
284
  this.path = options.path === undefined ? undefined : redactPath(options.path);
111
285
  }
112
286
  get retryable() {
113
- return this.kind === "rateLimited" || this.kind === "server" || this.kind === "network" || this.kind === "timeout";
287
+ return this.kind === "rateLimited" || this.kind === "server" || this.kind === "network" || this.kind === "timeout" || this.kind === "coordination" || this.kind === "overloaded";
114
288
  }
115
289
  }
116
290
  function abortError(path, reason) {
117
291
  return new RESTError(reason instanceof Error ? reason.message : "Discord REST request was aborted", 0, undefined, undefined, { path, cause: reason, kind: "aborted" });
118
292
  }
293
+ var MAX_TIMER_MS = 2147483647;
119
294
  async function sleep(delay, signal, path) {
120
295
  if (delay <= 0)
121
296
  return;
297
+ delay = Math.min(delay, MAX_TIMER_MS);
122
298
  await new Promise((resolve, reject) => {
123
- const timer = setTimeout(resolve, delay);
124
- const abort = () => {
299
+ const finish = (error) => {
125
300
  clearTimeout(timer);
126
- reject(abortError(path, signal?.reason));
301
+ signal?.removeEventListener("abort", onAbort);
302
+ if (error)
303
+ reject(error);
304
+ else
305
+ resolve();
127
306
  };
307
+ const onAbort = () => finish(abortError(path, signal?.reason));
308
+ const timer = setTimeout(finish, delay);
128
309
  if (signal?.aborted)
129
- return abort();
130
- signal?.addEventListener("abort", abort, { once: true });
310
+ return onAbort();
311
+ signal?.addEventListener("abort", onAbort, { once: true });
131
312
  });
132
313
  }
133
314
  async function abortable(promise, signal, path) {
@@ -135,10 +316,17 @@ async function abortable(promise, signal, path) {
135
316
  return promise;
136
317
  if (signal.aborted)
137
318
  throw abortError(path, signal.reason);
138
- return Promise.race([
139
- promise,
140
- new Promise((_, reject) => signal.addEventListener("abort", () => reject(abortError(path, signal.reason)), { once: true }))
141
- ]);
319
+ return new Promise((resolve, reject) => {
320
+ const onAbort = () => reject(abortError(path, signal.reason));
321
+ signal.addEventListener("abort", onAbort, { once: true });
322
+ promise.then(() => {
323
+ signal.removeEventListener("abort", onAbort);
324
+ resolve();
325
+ }, (error) => {
326
+ signal.removeEventListener("abort", onAbort);
327
+ reject(error);
328
+ });
329
+ });
142
330
  }
143
331
 
144
332
  // packages/rest/src/route.ts
@@ -146,7 +334,7 @@ import { createHash } from "crypto";
146
334
  function normalizeRoutePath(path) {
147
335
  const queryIndex = path.indexOf("?");
148
336
  const routePath = queryIndex === -1 ? path : path.slice(0, queryIndex);
149
- return redactPath(routePath).replace(/\/\d+(?=\/|$)/g, "/:id");
337
+ return redactPath(routePath).replace(/\/\d+(?=\/|$)/g, "/:id").replace(/\/reactions\/[^/]+/i, "/reactions/:emoji");
150
338
  }
151
339
  function majorParameter(path) {
152
340
  const match = /^\/(channels|guilds|webhooks)\/(\d+)(?:\/([^/?]+))?/i.exec(path);
@@ -185,9 +373,22 @@ function serializeQuery(query) {
185
373
  class RequestScheduler {
186
374
  static MAX_REMAPS = 2;
187
375
  #queues = new Map;
376
+ #waiting = new Map;
377
+ #queued = 0;
378
+ #maxQueued;
379
+ #maxQueuedPerBucket;
380
+ constructor(options = {}) {
381
+ this.#maxQueued = options.maxQueued ?? Number.POSITIVE_INFINITY;
382
+ this.#maxQueuedPerBucket = options.maxQueuedPerBucket ?? Number.POSITIVE_INFINITY;
383
+ if (!(this.#maxQueued >= 0) || !(this.#maxQueuedPerBucket >= 0))
384
+ throw new RangeError("Queue limits must be non-negative numbers.");
385
+ }
188
386
  get size() {
189
387
  return this.#queues.size;
190
388
  }
389
+ get queued() {
390
+ return this.#queued;
391
+ }
191
392
  async runResolved(resolveKey, signal, path, task) {
192
393
  let key = await resolveKey();
193
394
  for (let hop = 0;hop < RequestScheduler.MAX_REMAPS; hop++) {
@@ -204,7 +405,10 @@ class RequestScheduler {
204
405
  return this.run(key, signal, path, (release) => task(key, release));
205
406
  }
206
407
  async run(key, signal, path, task) {
207
- const previous = this.#queues.get(key) ?? Promise.resolve();
408
+ const previous = this.#queues.get(key);
409
+ const waiting = this.#waiting.get(key) ?? 0;
410
+ if (previous && (this.#queued >= this.#maxQueued || waiting >= this.#maxQueuedPerBucket))
411
+ throw new RESTError("The REST request queue is full; request not sent.", 0, undefined, undefined, { path, kind: "overloaded" });
208
412
  let open;
209
413
  const gate = new Promise((resolve) => {
210
414
  open = resolve;
@@ -220,7 +424,21 @@ class RequestScheduler {
220
424
  this.#queues.delete(key);
221
425
  };
222
426
  try {
223
- await abortable(previous, signal, path);
427
+ if (previous) {
428
+ this.#queued++;
429
+ this.#waiting.set(key, waiting + 1);
430
+ try {
431
+ await abortable(previous, signal, path);
432
+ } finally {
433
+ this.#queued--;
434
+ const left = (this.#waiting.get(key) ?? 1) - 1;
435
+ if (left > 0)
436
+ this.#waiting.set(key, left);
437
+ else
438
+ this.#waiting.delete(key);
439
+ }
440
+ } else
441
+ await abortable(Promise.resolve(), signal, path);
224
442
  return await task(release);
225
443
  } finally {
226
444
  release();
@@ -228,90 +446,520 @@ class RequestScheduler {
228
446
  }
229
447
  }
230
448
 
449
+ // packages/rest/src/headers.ts
450
+ function headerNumber(headers, name) {
451
+ const raw = headers.get(name);
452
+ if (raw === null || raw.trim() === "")
453
+ return;
454
+ const value = Number(raw);
455
+ return Number.isFinite(value) && value >= 0 ? value : undefined;
456
+ }
457
+ var BUCKET_HASH = /^[A-Za-z0-9:_\-.]{1,64}$/;
458
+ var MAX_COUNT = 1e5;
459
+ function count(value) {
460
+ return value === undefined || value > MAX_COUNT ? undefined : Math.floor(value);
461
+ }
462
+ function parseRateLimitHeaders(headers) {
463
+ const remaining = headerNumber(headers, "X-RateLimit-Remaining");
464
+ const limit = headerNumber(headers, "X-RateLimit-Limit");
465
+ const resetAfter = headerNumber(headers, "X-RateLimit-Reset-After");
466
+ const reset = headerNumber(headers, "X-RateLimit-Reset");
467
+ const retryAfter = headerNumber(headers, "Retry-After");
468
+ const bucket = headers.get("X-RateLimit-Bucket")?.trim();
469
+ const scope = headers.get("X-RateLimit-Scope")?.trim().toLowerCase();
470
+ return {
471
+ limit: count(limit),
472
+ remaining: count(remaining),
473
+ resetAfterMs: resetAfter === undefined ? undefined : resetAfter * 1000,
474
+ resetAtMs: reset === undefined ? undefined : reset * 1000,
475
+ bucket: bucket && BUCKET_HASH.test(bucket) ? bucket : undefined,
476
+ scope: scope === "user" || scope === "global" || scope === "shared" ? scope : undefined,
477
+ global: headers.get("X-RateLimit-Global")?.trim().toLowerCase() === "true",
478
+ retryAfterMs: retryAfter === undefined ? undefined : retryAfter * 1000
479
+ };
480
+ }
481
+
482
+ // packages/rest/src/telemetry.ts
483
+ function emitTelemetry(sink, event) {
484
+ if (!sink)
485
+ return;
486
+ try {
487
+ sink.emit(event);
488
+ } catch {}
489
+ }
490
+ var LATENCY_BOUNDS = [
491
+ 1,
492
+ 5,
493
+ 10,
494
+ 25,
495
+ 50,
496
+ 100,
497
+ 250,
498
+ 500,
499
+ 1000,
500
+ 2500,
501
+ 5000,
502
+ 1e4,
503
+ 30000,
504
+ 60000
505
+ ];
506
+
507
+ class RESTMetrics {
508
+ #counters = new Map;
509
+ #gauges = new Map;
510
+ #histograms = new Map;
511
+ #routes = new Set;
512
+ #maxRoutes;
513
+ constructor(options = {}) {
514
+ this.#maxRoutes = options.maxRoutes ?? 500;
515
+ }
516
+ #route(route) {
517
+ if (this.#routes.has(route))
518
+ return route;
519
+ if (this.#routes.size >= this.#maxRoutes)
520
+ return "other";
521
+ this.#routes.add(route);
522
+ return route;
523
+ }
524
+ #inc(name, labels = {}, by = 1) {
525
+ const key = metricKey(name, labels);
526
+ this.#counters.set(key, (this.#counters.get(key) ?? 0) + by);
527
+ }
528
+ #observe(name, value, labels = {}) {
529
+ const key = metricKey(name, labels);
530
+ let h = this.#histograms.get(key);
531
+ if (!h) {
532
+ h = {
533
+ counts: new Array(LATENCY_BOUNDS.length + 1).fill(0),
534
+ sum: 0,
535
+ count: 0
536
+ };
537
+ this.#histograms.set(key, h);
538
+ }
539
+ let i = LATENCY_BOUNDS.findIndex((bound) => value <= bound);
540
+ if (i === -1)
541
+ i = LATENCY_BOUNDS.length;
542
+ h.counts[i]++;
543
+ h.sum += value;
544
+ h.count++;
545
+ }
546
+ emit(event) {
547
+ switch (event.type) {
548
+ case "response":
549
+ this.#inc("responses_total", {
550
+ method: event.method,
551
+ route: this.#route(event.route),
552
+ status: String(event.status)
553
+ });
554
+ this.#observe("response_latency_ms", event.durationMs, {
555
+ method: event.method
556
+ });
557
+ break;
558
+ case "request":
559
+ this.#inc("requests_total", {
560
+ method: event.method,
561
+ outcome: event.ok ? "ok" : "error"
562
+ });
563
+ this.#observe("request_latency_ms", event.durationMs, {
564
+ method: event.method
565
+ });
566
+ break;
567
+ case "rateLimited":
568
+ this.#inc("rate_limited_total", {
569
+ scope: event.scope,
570
+ route: this.#route(event.route)
571
+ });
572
+ break;
573
+ case "wait":
574
+ this.#inc("waits_total", { reason: event.reason });
575
+ this.#observe("wait_ms", event.durationMs, {
576
+ reason: event.reason
577
+ });
578
+ break;
579
+ case "reservation":
580
+ this.#inc("reservations_total", {
581
+ outcome: event.granted ? "granted" : "refused"
582
+ });
583
+ this.#observe("reservation_latency_ms", event.latencyMs);
584
+ break;
585
+ case "retry":
586
+ this.#inc("retries_total", { reason: event.reason });
587
+ break;
588
+ case "cancelled":
589
+ this.#inc("cancellations_total", { stage: event.stage });
590
+ break;
591
+ case "overloaded":
592
+ this.#inc("overloaded_total");
593
+ break;
594
+ case "tokenRejected":
595
+ this.#inc("token_rejected_total");
596
+ break;
597
+ case "networkError":
598
+ this.#inc("network_errors_total", {
599
+ timeout: String(event.timedOut)
600
+ });
601
+ break;
602
+ case "bucket":
603
+ this.#inc("bucket_changes_total", { change: event.change });
604
+ break;
605
+ case "store":
606
+ this.#inc("store_commands_total", {
607
+ operation: event.operation,
608
+ ok: String(event.ok)
609
+ });
610
+ this.#observe("store_latency_ms", event.latencyMs, {
611
+ operation: event.operation
612
+ });
613
+ break;
614
+ case "mode":
615
+ this.#inc("mode_changes_total", { mode: event.mode });
616
+ this.#gauges.set("degraded", event.mode === "degraded-local" || event.mode === "unavailable" ? 1 : 0);
617
+ break;
618
+ case "queue":
619
+ this.#gauges.set("queued", event.queued);
620
+ this.#gauges.set("in_flight", event.inFlight);
621
+ break;
622
+ }
623
+ }
624
+ snapshot() {
625
+ const histograms = {};
626
+ for (const [key, h] of this.#histograms) {
627
+ const buckets = {};
628
+ let running = 0;
629
+ LATENCY_BOUNDS.forEach((bound, i) => {
630
+ running += h.counts[i];
631
+ buckets[String(bound)] = running;
632
+ });
633
+ buckets["+Inf"] = h.count;
634
+ histograms[key] = { count: h.count, sum: h.sum, buckets };
635
+ }
636
+ return {
637
+ counters: Object.fromEntries(this.#counters),
638
+ gauges: Object.fromEntries(this.#gauges),
639
+ histograms
640
+ };
641
+ }
642
+ reset() {
643
+ this.#counters.clear();
644
+ this.#gauges.clear();
645
+ this.#histograms.clear();
646
+ this.#routes.clear();
647
+ }
648
+ toPrometheus(prefix = "lunibee_rest") {
649
+ const lines = [];
650
+ const typed = new Set;
651
+ const type = (name, kind) => {
652
+ if (typed.has(name))
653
+ return;
654
+ typed.add(name);
655
+ lines.push(`# TYPE ${name} ${kind}`);
656
+ };
657
+ for (const [key, value] of this.#counters) {
658
+ const name = `${prefix}_${key.split("{")[0]}`;
659
+ type(name, "counter");
660
+ lines.push(`${prefix}_${key} ${value}`);
661
+ }
662
+ for (const [key, value] of this.#gauges) {
663
+ type(`${prefix}_${key}`, "gauge");
664
+ lines.push(`${prefix}_${key} ${value}`);
665
+ }
666
+ for (const [key, h] of this.#histograms) {
667
+ const [base, rawLabels = ""] = key.split("{");
668
+ const labels = rawLabels.replace(/\}$/, "");
669
+ const name = `${prefix}_${base}`;
670
+ type(name, "histogram");
671
+ const join = (extra) => `{${[labels, extra].filter(Boolean).join(",")}}`;
672
+ let running = 0;
673
+ LATENCY_BOUNDS.forEach((bound, i) => {
674
+ running += h.counts[i];
675
+ lines.push(`${name}_bucket${join(`le="${bound}"`)} ${running}`);
676
+ });
677
+ lines.push(`${name}_bucket${join('le="+Inf"')} ${h.count}`);
678
+ lines.push(`${name}_sum${labels ? `{${labels}}` : ""} ${h.sum}`);
679
+ lines.push(`${name}_count${labels ? `{${labels}}` : ""} ${h.count}`);
680
+ }
681
+ return `${lines.join(`
682
+ `)}
683
+ `;
684
+ }
685
+ }
686
+ function metricKey(name, labels) {
687
+ const entries = Object.entries(labels);
688
+ if (entries.length === 0)
689
+ return name;
690
+ return `${name}{${entries.map(([k, v]) => `${k}="${v.replace(/["\\\n]/g, "_")}"`).join(",")}}`;
691
+ }
692
+
231
693
  // packages/rest/src/limiter.ts
694
+ var DEFAULT_MAX_BACKOFF_MS = 600000;
695
+ var MAX_IDLE_REFUSALS = 16;
696
+ var MAX_CACHED_ROUTES = 1e4;
697
+
232
698
  class RateLimiter {
233
699
  #store;
234
- #concurrent;
700
+ #maxBackoff;
701
+ #telemetry;
702
+ #probeMs;
703
+ #routes = new Map;
704
+ #globalLimit;
705
+ #localGlobal = new Map;
235
706
  constructor(store, options = {}) {
236
707
  this.#store = store;
237
- this.#concurrent = options.concurrent ?? false;
708
+ this.#maxBackoff = options.maxBackoff ?? DEFAULT_MAX_BACKOFF_MS;
709
+ if (!(this.#maxBackoff >= 0))
710
+ throw new RangeError("maxBackoff must be a non-negative number.");
711
+ this.#probeMs = options.probeMs ?? DEFAULT_PROBE_MS;
712
+ if (!(this.#probeMs > 0))
713
+ throw new RangeError("probeMs must be a positive number.");
714
+ this.#telemetry = options.telemetry;
715
+ this.#globalLimit = options.globalLimit ?? 0;
716
+ if (!(this.#globalLimit >= 0) || !Number.isFinite(this.#globalLimit))
717
+ throw new RangeError("globalLimit must be a non-negative number.");
718
+ }
719
+ get maxBackoff() {
720
+ return this.#maxBackoff;
721
+ }
722
+ #capWait(ms) {
723
+ return Math.min(Math.max(0, ms), this.#maxBackoff);
238
724
  }
239
725
  get store() {
240
726
  return this.#store;
241
727
  }
242
- async resolveBucketKey(route, major) {
243
- return scopeBucket(await this.#store.getBucketHash(route) ?? route, major);
244
- }
245
728
  get reserves() {
246
729
  return typeof this.#store.reserve === "function";
247
730
  }
248
- async acquire(bucketKey, signal, path) {
249
- await this.#waitGlobal(signal, path);
250
- if (!this.#store.reserve) {
251
- const bucket = await this.#store.getBucket(bucketKey);
731
+ #cacheRoute(route, hash) {
732
+ this.#routes.delete(route);
733
+ this.#routes.set(route, hash);
734
+ if (this.#routes.size > MAX_CACHED_ROUTES)
735
+ this.#routes.delete(this.#routes.keys().next().value);
736
+ }
737
+ async resolveBucketKey(route, major, identity) {
738
+ let hash = this.#routes.get(`${route}\x00${major}`) ?? this.#routes.get(route);
739
+ if (hash === undefined) {
740
+ hash = await this.#store.getBucketHash(route);
741
+ if (hash !== undefined)
742
+ this.#cacheRoute(route, hash);
743
+ }
744
+ const key = scopeBucket(hash ?? route, major);
745
+ return identity === undefined ? key : `${identity}/${key}`;
746
+ }
747
+ async#call(path, operation, call) {
748
+ try {
749
+ return await call();
750
+ } catch (error) {
751
+ if (error instanceof RESTError)
752
+ throw error;
753
+ throw new RESTError(`The rate-limit store failed (${operation}); request not sent.`, 0, undefined, undefined, { path, kind: "coordination", cause: sanitizeCause(error) });
754
+ }
755
+ }
756
+ async acquire(bucketKey, signal, path, options = {}) {
757
+ const store = this.#store;
758
+ const global = "global" in options ? options.global : "";
759
+ if (!store.reserve) {
760
+ await this.#waitGlobal(global ?? "", signal, path);
761
+ if (global !== undefined)
762
+ await this.#localGlobalSlot(global, signal, path);
763
+ const bucket = await this.#call(path, "getBucket", () => store.getBucket(bucketKey));
252
764
  if (bucket) {
253
765
  const delay = bucket.resetAt - Date.now();
254
766
  if (bucket.remaining <= 0 && delay > 0)
255
- await sleep(delay, signal, path);
767
+ await this.#wait("bucket", delay, signal, path);
256
768
  }
769
+ return {
770
+ reserved: false,
771
+ known: !!bucket && bucket.resetAt > Date.now()
772
+ };
773
+ }
774
+ const started = performance.now();
775
+ let idle = 0;
776
+ for (;; ) {
777
+ if (global !== undefined && !store.reservesGlobal) {
778
+ await this.#waitGlobal(global, signal, path);
779
+ await this.#localGlobalSlot(global, signal, path);
780
+ }
781
+ const asked = performance.now();
782
+ const reservation = await this.#call(path, "reserve", () => store.reserve(bucketKey, {
783
+ global,
784
+ probeMs: this.#probeMs,
785
+ globalLimit: this.#globalLimit || undefined
786
+ }));
787
+ if (!reservation || typeof reservation !== "object" || typeof reservation.granted !== "boolean")
788
+ throw new RESTError("The rate-limit store returned an invalid reservation; request not sent.", 0, undefined, undefined, { path, kind: "coordination" });
789
+ if (reservation.granted) {
790
+ let known = reservation.known;
791
+ if (known === undefined) {
792
+ const bucket = await this.#call(path, "getBucket", () => store.getBucket(bucketKey));
793
+ known = !!bucket && bucket.resetAt > Date.now();
794
+ }
795
+ emitTelemetry(this.#telemetry, {
796
+ type: "reservation",
797
+ granted: true,
798
+ known,
799
+ latencyMs: performance.now() - asked
800
+ });
801
+ return { reserved: true, known, resetAt: reservation.resetAt };
802
+ }
803
+ emitTelemetry(this.#telemetry, {
804
+ type: "reservation",
805
+ granted: false,
806
+ known: true,
807
+ latencyMs: performance.now() - asked
808
+ });
809
+ let wait = Number(reservation.retryAfterMs);
810
+ if (!(wait > 0)) {
811
+ if (++idle > MAX_IDLE_REFUSALS)
812
+ throw new RESTError("The rate-limit store keeps refusing without a wait; request not sent.", 0, undefined, undefined, { path, kind: "coordination" });
813
+ wait = Math.min(100, 2 ** idle);
814
+ } else
815
+ idle = 0;
816
+ if (performance.now() - started + wait > 2 * this.#maxBackoff)
817
+ throw new RESTError(`Waited for the rate limit longer than twice maxBackoff (${Math.ceil(this.#maxBackoff / 1000)}s); request not sent.`, 429, undefined, undefined, { path });
818
+ await this.#wait(reservation.reason ?? "bucket", wait, signal, path);
819
+ }
820
+ }
821
+ async#wait(reason, delay, signal, path) {
822
+ const ms = Math.min(delay, this.#maxBackoff);
823
+ await sleep(ms, signal, path);
824
+ emitTelemetry(this.#telemetry, {
825
+ type: "wait",
826
+ reason,
827
+ durationMs: ms
828
+ });
829
+ }
830
+ async#localGlobalSlot(scope, signal, path) {
831
+ if (!this.#globalLimit)
257
832
  return;
833
+ let log = this.#localGlobal.get(scope);
834
+ if (!log) {
835
+ this.#localGlobal.set(scope, log = []);
836
+ if (this.#localGlobal.size > MAX_CACHED_ROUTES)
837
+ this.#localGlobal.delete(this.#localGlobal.keys().next().value);
258
838
  }
259
- for (let attempt = 0;attempt < 8; attempt++) {
260
- const reservation = await this.#store.reserve(bucketKey);
261
- if (reservation.granted)
839
+ for (;; ) {
840
+ const wait = takeGlobalSlot(log, Date.now(), this.#globalLimit);
841
+ if (wait === 0)
262
842
  return;
263
- await sleep(Math.max(1, reservation.retryAfterMs), signal, path);
264
- await this.#waitGlobal(signal, path);
843
+ await this.#wait("global", wait, signal, path);
265
844
  }
266
845
  }
267
- async#waitGlobal(signal, path) {
268
- const globalDelay = await this.#store.getGlobalReset() - Date.now();
846
+ async#waitGlobal(scope, signal, path) {
847
+ const globalDelay = await this.#call(path, "getGlobalReset", () => this.#store.getGlobalReset(scope)) - Date.now();
269
848
  if (globalDelay > 0)
270
- await sleep(globalDelay, signal, path);
849
+ await this.#wait("global", globalDelay, signal, path);
271
850
  }
272
- async noteGlobalReset(resetAt) {
273
- const current = await this.#store.getGlobalReset();
274
- await this.#store.setGlobalReset(Math.max(current, resetAt));
275
- }
276
- async applyResponse(response, bucketKey, route, major) {
851
+ async noteGlobalReset(resetAt, scope = "") {
852
+ const waitMs = this.#capWait(resetAt - Date.now());
853
+ if (this.#store.extendGlobalReset) {
854
+ await this.#store.extendGlobalReset(scope, waitMs);
855
+ return;
856
+ }
857
+ const current = await this.#store.getGlobalReset(scope);
858
+ const target = Date.now() + waitMs;
859
+ if (target > current)
860
+ await this.#store.setGlobalReset(target, scope);
861
+ }
862
+ retryAfterMs(response, body = {}) {
863
+ const fromBody = body.retry_after;
864
+ if (typeof fromBody === "number" && Number.isFinite(fromBody) && fromBody >= 0)
865
+ return fromBody * 1000;
866
+ const headers = parseRateLimitHeaders(response.headers);
867
+ return headers.retryAfterMs ?? headers.resetAfterMs ?? 1000;
868
+ }
869
+ async applyResponse(response, bucketKey, route, major, options = {}) {
870
+ const headers = parseRateLimitHeaders(response.headers);
277
871
  let currentKey = bucketKey;
278
- const serverBucket = response.headers.get("X-RateLimit-Bucket");
279
- if (serverBucket) {
280
- await this.#store.setBucketHash(route, serverBucket);
281
- currentKey = scopeBucket(serverBucket, major);
282
- }
283
- const stored = await this.#store.getBucket(currentKey);
284
- const bucket = stored ? { ...stored } : { remaining: 1, resetAt: 0 };
285
- const windowActive = this.#concurrent && !!stored && stored.resetAt > Date.now();
286
- const remaining = Number(response.headers.get("X-RateLimit-Remaining"));
287
- const resetAfter = Number(response.headers.get("X-RateLimit-Reset-After"));
288
- const reset = Number(response.headers.get("X-RateLimit-Reset"));
289
- if (Number.isFinite(remaining))
290
- bucket.remaining = Math.max(0, remaining);
291
- if (Number.isFinite(resetAfter))
292
- bucket.resetAt = Date.now() + Math.max(0, resetAfter) * 1000;
293
- else if (Number.isFinite(reset))
294
- bucket.resetAt = reset * 1000;
295
- if (windowActive && stored) {
296
- bucket.remaining = Math.min(bucket.remaining, stored.remaining);
297
- bucket.resetAt = Math.max(bucket.resetAt, stored.resetAt);
298
- }
299
- if (response.status === 429) {
300
- const retryAfter = Number(response.headers.get("Retry-After"));
301
- if (Number.isFinite(retryAfter))
302
- bucket.resetAt = Math.max(bucket.resetAt, Date.now() + retryAfter * 1000);
303
- }
304
- await this.#store.updateBucket(currentKey, bucket);
305
- if (response.headers.get("X-RateLimit-Global") === "true") {
306
- const retryAfter = Number(response.headers.get("Retry-After"));
307
- if (Number.isFinite(retryAfter))
308
- await this.noteGlobalReset(Date.now() + retryAfter * 1000);
872
+ if (headers.bucket) {
873
+ const previous = this.#routes.get(route);
874
+ this.#cacheRoute(`${route}\x00${major}`, headers.bucket);
875
+ if (previous !== headers.bucket) {
876
+ await this.#store.setBucketHash(route, headers.bucket);
877
+ this.#cacheRoute(route, headers.bucket);
878
+ emitTelemetry(this.#telemetry, {
879
+ type: "bucket",
880
+ change: previous === undefined ? "discovered" : "remapped",
881
+ route
882
+ });
883
+ }
884
+ const key = scopeBucket(headers.bucket, major);
885
+ currentKey = options.identity === undefined ? key : `${options.identity}/${key}`;
309
886
  }
887
+ const limited = response.status === 429;
888
+ const retryAfterMs = limited ? this.#capWait(this.retryAfterMs(response, options.body)) : undefined;
889
+ const global = limited && (options.body?.global === true || headers.global || headers.scope === "global");
890
+ const onGlobal = global && options.global !== undefined;
891
+ let resetAfterMs = headers.resetAfterMs;
892
+ if (resetAfterMs === undefined && headers.resetAtMs !== undefined)
893
+ resetAfterMs = Math.max(0, headers.resetAtMs - Date.now());
894
+ const update = {
895
+ remaining: headers.remaining,
896
+ resetAfterMs: resetAfterMs === undefined ? undefined : this.#capWait(resetAfterMs),
897
+ limit: headers.limit,
898
+ window: headers.resetAtMs !== undefined && Math.abs(headers.resetAtMs - Date.now()) <= this.#maxBackoff ? headers.resetAtMs : undefined,
899
+ retryAfterMs: limited && !onGlobal ? retryAfterMs : undefined
900
+ };
901
+ if (this.#store.reconcile)
902
+ await this.#store.reconcile(currentKey, update);
903
+ else {
904
+ const next = reconcileState(await this.#store.getBucket(currentKey), update, Date.now());
905
+ if (next === null)
906
+ await this.#store.updateBucket(currentKey, {
907
+ remaining: 0,
908
+ resetAt: 0
909
+ });
910
+ else if (next)
911
+ await this.#store.updateBucket(currentKey, next);
912
+ }
913
+ if (currentKey !== bucketKey && options.grant?.reserved && this.#store.release)
914
+ await this.#store.release(bucketKey, options.grant, false);
915
+ if (onGlobal)
916
+ await this.noteGlobalReset(Date.now() + retryAfterMs, options.global);
310
917
  return currentKey;
311
918
  }
919
+ async releaseGrant(bucketKey, grant, notSent) {
920
+ if (!grant.reserved || !this.#store.release)
921
+ return;
922
+ try {
923
+ await this.#store.release(bucketKey, grant, notSent);
924
+ } catch {}
925
+ }
926
+ }
927
+
928
+ // packages/rest/src/identity.ts
929
+ import { createHash as createHash2 } from "crypto";
930
+ function tokenFingerprint(token) {
931
+ return createHash2("sha256").update(token).digest("hex").slice(0, 16);
932
+ }
933
+ function requestIdentity(path, fingerprint) {
934
+ if (/^\/interactions\//i.test(path))
935
+ return { bucket: "shared" };
936
+ if (/^\/webhooks\/[^/?]+\/[^/?]+/i.test(path))
937
+ return { bucket: "shared", global: fingerprint ?? "anon" };
938
+ if (!fingerprint)
939
+ return { bucket: "anon", global: "anon" };
940
+ return { bucket: fingerprint, global: fingerprint };
312
941
  }
313
942
 
314
943
  // packages/rest/src/transport.ts
944
+ var LOOPBACK = /^(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])$/i;
945
+ function parseBaseURL(raw, allowInsecure = false) {
946
+ let url;
947
+ try {
948
+ url = new URL(raw);
949
+ } catch {
950
+ throw new TypeError("baseURL must be an absolute http(s) URL.");
951
+ }
952
+ if (url.protocol !== "https:" && url.protocol !== "http:")
953
+ throw new TypeError("baseURL must use https.");
954
+ if (url.username || url.password)
955
+ throw new TypeError("baseURL must not contain credentials.");
956
+ if (url.search || url.hash || /[?#]/.test(raw))
957
+ throw new TypeError("baseURL must not contain a query or fragment.");
958
+ if (url.protocol === "http:" && !allowInsecure && !LOOPBACK.test(url.hostname))
959
+ throw new TypeError("baseURL must use https: the bot token would travel in cleartext (allowInsecureBaseURL: true overrides this for a trusted network).");
960
+ return url.href.replace(/\/$/, "");
961
+ }
962
+
315
963
  class TransportError extends Error {
316
964
  timedOut;
317
965
  constructor(message, timedOut, cause) {
@@ -326,7 +974,7 @@ class HttpTransport {
326
974
  #timeout;
327
975
  #fetch;
328
976
  constructor(options = {}) {
329
- this.#baseURL = (options.baseURL ?? "https://discord.com/api/v10").replace(/\/$/, "");
977
+ this.#baseURL = parseBaseURL(options.baseURL ?? "https://discord.com/api/v10", options.allowInsecureBaseURL);
330
978
  this.#timeout = Math.max(1, options.timeout ?? 15000);
331
979
  this.#fetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
332
980
  }
@@ -348,13 +996,14 @@ class HttpTransport {
348
996
  method: request.method,
349
997
  headers: request.headers,
350
998
  body: request.body,
351
- signal: controller.signal
999
+ signal: controller.signal,
1000
+ redirect: "error"
352
1001
  });
353
1002
  } catch (error) {
354
1003
  if (error instanceof RESTError)
355
1004
  throw error;
356
1005
  const timedOut = error instanceof DOMException && (error.name === "TimeoutError" || error.name === "AbortError");
357
- throw new TransportError(timedOut ? `Discord REST request timed out after ${this.#timeout}ms` : "Discord REST request failed", timedOut, error);
1006
+ throw new TransportError(timedOut ? `Discord REST request timed out after ${this.#timeout}ms` : "Discord REST request failed", timedOut, sanitizeCause(error));
358
1007
  } finally {
359
1008
  clearTimeout(timer);
360
1009
  request.signal?.removeEventListener("abort", onAbort);
@@ -364,6 +1013,7 @@ class HttpTransport {
364
1013
 
365
1014
  // packages/rest/src/decoder.ts
366
1015
  class ResponseDecoder {
1016
+ constructor() {}
367
1017
  async read(response) {
368
1018
  if (response.status === 204)
369
1019
  return;
@@ -532,36 +1182,185 @@ var RESTErrorCode = {
532
1182
  CannotReplyWithoutReadHistory: 160002
533
1183
  };
534
1184
  // packages/rest/src/redis.ts
535
- var RESERVE_SCRIPT = `
536
- local raw = redis.call('GET', KEYS[1])
537
- if not raw then return {1, 0} end
538
- local ok, state = pcall(cjson.decode, raw)
539
- if not ok or type(state) ~= 'table' or type(state.resetAt) ~= 'number' then
540
- return {1, 0}
1185
+ var LUA_PRELUDE = `
1186
+ local function now_ms()
1187
+ local t = redis.call('TIME')
1188
+ return tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
1189
+ end
1190
+ local function load(key)
1191
+ local raw = redis.call('GET', key)
1192
+ if not raw then return nil end
1193
+ local ok, s = pcall(cjson.decode, raw)
1194
+ if ok and type(s) == 'table' and type(s.resetAt) == 'number'
1195
+ and type(s.remaining) == 'number' then return s end
1196
+ return nil
1197
+ end
1198
+ local function save(key, s, now, linger)
1199
+ local ttl = math.max(1, math.ceil(s.resetAt - now)) + linger
1200
+ redis.call('SET', key, cjson.encode(s), 'PX', ttl)
1201
+ end
1202
+ `;
1203
+ var RESERVE_SCRIPT = `${LUA_PRELUDE}
1204
+ local now = now_ms()
1205
+ local glimit, gwindow = tonumber(ARGV[4]) or 0, tonumber(ARGV[5]) or 1000
1206
+ if #KEYS > 1 then
1207
+ local g = tonumber(redis.call('GET', KEYS[2]) or '0') or 0
1208
+ if g > now then return {0, math.ceil(g - now), 'global'} end
1209
+ end
1210
+ if #KEYS < 3 then glimit = 0 end
1211
+ -- A sliding log of send times: at most glimit in any gwindow span.
1212
+ if glimit > 0 then
1213
+ redis.call('ZREMRANGEBYSCORE', KEYS[3], '-inf', now - gwindow)
1214
+ if redis.call('ZCARD', KEYS[3]) >= glimit then
1215
+ local oldest = redis.call('ZRANGE', KEYS[3], 0, 0, 'WITHSCORES')
1216
+ return {0, math.max(1, tonumber(oldest[2]) + gwindow - now + 1), 'global'}
1217
+ end
1218
+ end
1219
+ local function count()
1220
+ if glimit > 0 then
1221
+ local seq = redis.call('ZCARD', KEYS[3])
1222
+ redis.call('ZADD', KEYS[3], now, now .. ':' .. seq)
1223
+ redis.call('PEXPIRE', KEYS[3], gwindow + 1000)
1224
+ end
541
1225
  end
542
- local now = tonumber(ARGV[1])
543
- local wait = state.resetAt - now
544
- if wait <= 0 then return {1, 0} end
545
- if type(state.remaining) == 'number' and state.remaining > 0 then
546
- state.remaining = state.remaining - 1
547
- redis.call('SET', KEYS[1], cjson.encode(state), 'PX', math.ceil(wait))
548
- return {1, 0}
1226
+ local probe, poll, linger = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
1227
+ local s = load(KEYS[1])
1228
+ if s and s.resetAt > now then
1229
+ if s.remaining > 0 then
1230
+ s.remaining = s.remaining - 1
1231
+ save(KEYS[1], s, now, linger)
1232
+ count()
1233
+ return {1, s.resetAt, 1}
1234
+ end
1235
+ local wait = s.resetAt - now
1236
+ if s.provisional then wait = math.min(wait, poll) end
1237
+ local reason = 'bucket'
1238
+ if s.probe then reason = 'probe' end
1239
+ return {0, math.ceil(wait), reason}
549
1240
  end
550
- return {0, math.ceil(wait)}
1241
+ local resetAt = now + probe
1242
+ if s and type(s.limit) == 'number' and s.limit > 0 then
1243
+ save(KEYS[1], {remaining = s.limit - 1, resetAt = resetAt, limit = s.limit, provisional = true}, now, linger)
1244
+ count()
1245
+ return {1, resetAt, 1}
1246
+ end
1247
+ save(KEYS[1], {remaining = 0, resetAt = resetAt, provisional = true, probe = true}, now, 0)
1248
+ count()
1249
+ return {1, resetAt, 0}
1250
+ `;
1251
+ var RECONCILE_SCRIPT = `${LUA_PRELUDE}
1252
+ local now = now_ms()
1253
+ local rem, after, limit = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
1254
+ local win, retry, linger = tonumber(ARGV[4]), tonumber(ARGV[5]), tonumber(ARGV[6])
1255
+ local tol = 50
1256
+ local incoming = nil
1257
+ if after then incoming = math.ceil(now + after) end
1258
+ local s = load(KEYS[1])
1259
+ local n
1260
+ if s and s.resetAt > now and not s.probe then
1261
+ n = s
1262
+ local stale = win and n.window and win < n.window - tol
1263
+ if not stale then
1264
+ if rem then n.remaining = math.min(n.remaining, rem) end
1265
+ if incoming then
1266
+ local newer = win and n.window and win > n.window + tol
1267
+ if n.provisional or newer then n.resetAt = incoming
1268
+ else n.resetAt = math.max(n.resetAt, incoming) end
1269
+ n.provisional = nil
1270
+ end
1271
+ if win then n.window = math.max(n.window or 0, win) end
1272
+ end
1273
+ else
1274
+ if not rem and not retry then
1275
+ if s and s.probe then redis.call('DEL', KEYS[1]) end
1276
+ return 0
1277
+ end
1278
+ n = {remaining = rem or 0, resetAt = incoming or now}
1279
+ if s and type(s.limit) == 'number' then n.limit = s.limit end
1280
+ if win then n.window = win end
1281
+ end
1282
+ if limit and limit > 0 then n.limit = limit end
1283
+ if retry then
1284
+ n.remaining = 0
1285
+ n.resetAt = math.max(n.resetAt, math.ceil(now + retry))
1286
+ n.provisional = nil
1287
+ end
1288
+ save(KEYS[1], n, now, linger)
1289
+ return 1
551
1290
  `;
1291
+ var EXTEND_GLOBAL_SCRIPT = `${LUA_PRELUDE}
1292
+ local now = now_ms()
1293
+ local target = math.ceil(now + tonumber(ARGV[1]))
1294
+ local cur = tonumber(redis.call('GET', KEYS[1]) or '0') or 0
1295
+ if target > cur then
1296
+ redis.call('SET', KEYS[1], tostring(target), 'PX', math.max(1, target - now))
1297
+ return target - now
1298
+ end
1299
+ return math.max(0, cur - now)
1300
+ `;
1301
+ var RELEASE_SCRIPT = `${LUA_PRELUDE}
1302
+ local s = load(KEYS[1])
1303
+ if not s or s.resetAt ~= tonumber(ARGV[1]) then return 0 end
1304
+ if s.probe then
1305
+ redis.call('DEL', KEYS[1])
1306
+ return 1
1307
+ end
1308
+ if ARGV[2] == '1' then
1309
+ local cap = s.remaining + 1
1310
+ if type(s.limit) == 'number' then cap = math.min(cap, s.limit) end
1311
+ s.remaining = cap
1312
+ redis.call('SET', KEYS[1], cjson.encode(s), 'KEEPTTL')
1313
+ return 1
1314
+ end
1315
+ return 0
1316
+ `;
1317
+ var VERIFY_SCRIPT = "return redis.call('TIME')[1]";
1318
+ function fromBunRedis(client) {
1319
+ return {
1320
+ get: (key) => client.send("GET", [key]),
1321
+ mget: (keys) => client.send("MGET", keys),
1322
+ set: (key, value, mode, duration) => client.send("SET", mode === undefined ? [key, String(value)] : [key, String(value), mode, String(duration)]),
1323
+ del: (...keys) => client.send("DEL", keys),
1324
+ eval: (script, numKeys, ...args) => client.send("EVAL", [script, String(numKeys), ...args.map(String)])
1325
+ };
1326
+ }
1327
+ var SCHEMA = "v1";
552
1328
 
553
1329
  class RedisRateLimitStore {
554
1330
  #client;
555
1331
  #prefix;
556
1332
  #onErrorCallback;
1333
+ #policy;
1334
+ #timeout;
1335
+ #telemetry;
1336
+ #atomic;
557
1337
  #local = new MemoryRateLimitStore;
558
1338
  #lastError;
1339
+ #lastFailureAt;
1340
+ #failures = 0;
1341
+ #mode;
559
1342
  constructor(options) {
560
1343
  this.#client = options.client;
561
- this.#prefix = options.prefix ?? "lunibee:rest:";
1344
+ this.#prefix = `${options.prefix ?? "lunibee:rest:"}${SCHEMA}:`;
562
1345
  this.#onErrorCallback = options.onError;
563
- if (typeof options.client.eval === "function")
564
- this.reserve = (key) => this.#reserve(key);
1346
+ this.#policy = options.failurePolicy ?? "degraded-local";
1347
+ if (this.#policy !== "degraded-local" && this.#policy !== "fail-closed")
1348
+ throw new TypeError('failurePolicy must be "degraded-local" or "fail-closed".');
1349
+ this.#timeout = options.commandTimeout ?? 2000;
1350
+ if (!(this.#timeout > 0))
1351
+ throw new RangeError("commandTimeout must be a positive number.");
1352
+ this.#telemetry = options.telemetry;
1353
+ this.#atomic = typeof options.client.eval === "function";
1354
+ if (options.requireAtomic && !this.#atomic)
1355
+ throw new TypeError("requireAtomic: the Redis client has no eval method, so reservations cannot be atomic.");
1356
+ this.#mode = this.#atomic ? "distributed" : "observational";
1357
+ if (this.#atomic) {
1358
+ this.reserve = (key, opts) => this.#reserve(key, opts);
1359
+ this.reconcile = (key, update) => this.#reconcile(key, update);
1360
+ this.extendGlobalReset = (scope, waitMs) => this.#extendGlobal(scope, waitMs);
1361
+ this.release = (key, grant, refund) => this.#release(key, grant, refund);
1362
+ this.reservesGlobal = true;
1363
+ }
565
1364
  }
566
1365
  get lastError() {
567
1366
  return this.#lastError;
@@ -569,107 +1368,204 @@ class RedisRateLimitStore {
569
1368
  isHealthy() {
570
1369
  return this.#lastError === undefined;
571
1370
  }
572
- #onError(operation, error) {
573
- this.#lastError = error;
574
- console.warn(`[lunibee/rest] Redis rate-limit store ${operation} failed; ` + `falling back to local limiting for this call.`, error);
575
- try {
576
- this.#onErrorCallback?.(operation, error);
577
- } catch {}
1371
+ get mode() {
1372
+ return this.#mode;
578
1373
  }
579
- #markHealthy() {
580
- this.#lastError = undefined;
1374
+ get supportsReservation() {
1375
+ return this.#atomic;
581
1376
  }
582
- async getBucketHash(route) {
1377
+ diagnostics() {
1378
+ const error = this.#lastError;
1379
+ return {
1380
+ mode: this.#mode,
1381
+ failurePolicy: this.#policy,
1382
+ atomic: this.#atomic,
1383
+ failures: this.#failures,
1384
+ lastFailureAt: this.#lastFailureAt,
1385
+ lastError: error === undefined ? undefined : String(sanitizeCause(error)?.message ?? error)
1386
+ };
1387
+ }
1388
+ async verify() {
1389
+ if (!this.#atomic)
1390
+ throw new TypeError("The Redis client has no eval method: reservations cannot be atomic.");
583
1391
  try {
584
- const hash = await this.#client.get(`${this.#prefix}route:${route}`);
585
- this.#markHealthy();
586
- return hash ?? undefined;
1392
+ await this.#withTimeout(this.#client.eval(VERIFY_SCRIPT, 0));
587
1393
  } catch (error) {
588
- this.#onError("getBucketHash", error);
589
- return this.#local.getBucketHash(route);
1394
+ throw new RESTError("The Redis rate-limit store cannot run its scripts.", 0, undefined, undefined, { kind: "coordination", cause: sanitizeCause(error) });
590
1395
  }
591
1396
  }
592
- async setBucketHash(route, hash) {
593
- this.#local.setBucketHash(route, hash);
1397
+ #setMode(mode) {
1398
+ if (mode === this.#mode)
1399
+ return;
1400
+ const previous = this.#mode;
1401
+ this.#mode = mode;
1402
+ emitTelemetry(this.#telemetry, { type: "mode", mode, previous });
1403
+ console.warn(mode === "degraded-local" ? "[lunibee/rest] Redis rate-limit store unreachable: limiting locally in this process until it answers." : mode === "unavailable" ? "[lunibee/rest] Redis rate-limit store unreachable: requests fail until it answers (fail-closed)." : "[lunibee/rest] Redis rate-limit store reachable again: limits are shared.");
1404
+ }
1405
+ #withTimeout(promise) {
1406
+ let timer;
1407
+ return Promise.race([
1408
+ promise,
1409
+ new Promise((_, reject) => {
1410
+ timer = setTimeout(() => reject(new Error(`Redis command timed out after ${this.#timeout}ms`)), this.#timeout);
1411
+ })
1412
+ ]).finally(() => clearTimeout(timer));
1413
+ }
1414
+ async#run(operation, command, fallback, enforcing) {
1415
+ const started = performance.now();
594
1416
  try {
595
- await this.#client.set(`${this.#prefix}route:${route}`, hash, "EX", 604800);
596
- this.#markHealthy();
1417
+ const value = await this.#withTimeout(command());
1418
+ emitTelemetry(this.#telemetry, {
1419
+ type: "store",
1420
+ operation,
1421
+ latencyMs: performance.now() - started,
1422
+ ok: true
1423
+ });
1424
+ this.#lastError = undefined;
1425
+ this.#setMode(this.#atomic ? "distributed" : "observational");
1426
+ return value;
597
1427
  } catch (error) {
598
- this.#onError("setBucketHash", error);
1428
+ emitTelemetry(this.#telemetry, {
1429
+ type: "store",
1430
+ operation,
1431
+ latencyMs: performance.now() - started,
1432
+ ok: false
1433
+ });
1434
+ this.#lastError = error;
1435
+ this.#lastFailureAt = Date.now();
1436
+ this.#failures++;
1437
+ try {
1438
+ this.#onErrorCallback?.(operation, error);
1439
+ } catch {}
1440
+ if (this.#policy === "fail-closed") {
1441
+ this.#setMode("unavailable");
1442
+ if (enforcing)
1443
+ throw new RESTError(`The Redis rate-limit store is unavailable (${operation}); request not sent (failurePolicy: "fail-closed").`, 0, undefined, undefined, { kind: "coordination", cause: sanitizeCause(error) });
1444
+ } else
1445
+ this.#setMode("degraded-local");
1446
+ return fallback();
599
1447
  }
600
1448
  }
1449
+ #bucketKey(key) {
1450
+ return `${this.#prefix}bucket:${key}`;
1451
+ }
1452
+ #globalKey(scope) {
1453
+ return `${this.#prefix}global:${scope}`;
1454
+ }
1455
+ async getBucketHash(route) {
1456
+ return this.#run("getBucketHash", async () => await this.#client.get(`${this.#prefix}route:${route}`) ?? undefined, () => this.#local.getBucketHash(route), false);
1457
+ }
1458
+ async setBucketHash(route, hash) {
1459
+ this.#local.setBucketHash(route, hash);
1460
+ await this.#run("setBucketHash", async () => {
1461
+ await this.#client.set(`${this.#prefix}route:${route}`, hash, "EX", 604800);
1462
+ }, () => {
1463
+ return;
1464
+ }, false);
1465
+ }
601
1466
  async getBucket(key) {
602
- try {
603
- const data = await this.#client.get(`${this.#prefix}bucket:${key}`);
604
- this.#markHealthy();
1467
+ return this.#run("getBucket", async () => {
1468
+ const data = await this.#client.get(this.#bucketKey(key));
605
1469
  if (!data)
606
1470
  return;
607
- return JSON.parse(data);
608
- } catch (error) {
609
- this.#onError("getBucket", error);
610
- return this.#local.getBucket(key);
611
- }
612
- }
613
- async updateBucket(key, state) {
614
- this.#local.updateBucket(key, state);
615
- const bucketKey = `${this.#prefix}bucket:${key}`;
616
- const ttl = Math.ceil((state.resetAt - Date.now()) / 1000);
617
- try {
618
- if (ttl <= 0) {
619
- await this.#client.del(bucketKey);
620
- this.#markHealthy();
1471
+ try {
1472
+ return JSON.parse(data);
1473
+ } catch {
621
1474
  return;
622
1475
  }
623
- await this.#client.set(bucketKey, JSON.stringify(state), "EX", ttl);
624
- this.#markHealthy();
625
- } catch (error) {
626
- this.#onError("updateBucket", error);
627
- }
1476
+ }, () => this.#local.getBucket(key), true);
628
1477
  }
629
- get supportsReservation() {
630
- return typeof this.#client.eval === "function";
1478
+ async updateBucket(key, state) {
1479
+ this.#local.updateBucket(key, state);
1480
+ const ttl = Math.ceil(state.resetAt - Date.now());
1481
+ await this.#run("updateBucket", async () => {
1482
+ if (ttl <= 0)
1483
+ await this.#client.del(this.#bucketKey(key));
1484
+ else
1485
+ await this.#client.set(this.#bucketKey(key), JSON.stringify(state), "PX", ttl);
1486
+ }, () => {
1487
+ return;
1488
+ }, false);
631
1489
  }
632
1490
  reserve;
633
- async#reserve(key) {
634
- try {
635
- const result = await this.#client.eval(RESERVE_SCRIPT, 1, `${this.#prefix}bucket:${key}`, Date.now());
636
- this.#markHealthy();
637
- if (!Array.isArray(result))
638
- return { granted: true };
639
- const [granted, retryAfterMs] = result;
640
- return Number(granted) === 1 ? { granted: true } : { granted: false, retryAfterMs: Number(retryAfterMs) || 0 };
641
- } catch (error) {
642
- this.#onError("reserve", error);
643
- return this.#local.reserve(key);
1491
+ reconcile;
1492
+ extendGlobalReset;
1493
+ release;
1494
+ reservesGlobal;
1495
+ async#reserve(key, options = {}) {
1496
+ const keys = [this.#bucketKey(key)];
1497
+ if (options.global !== undefined) {
1498
+ keys.push(this.#globalKey(options.global));
1499
+ if (options.globalLimit)
1500
+ keys.push(`${this.#prefix}global-log:${options.global}`);
644
1501
  }
1502
+ return this.#run("reserve", async () => {
1503
+ const result = await this.#client.eval(RESERVE_SCRIPT, keys.length, ...keys, options.probeMs ?? DEFAULT_PROBE_MS, PROBE_POLL_MS, BUCKET_LINGER_MS, options.globalLimit ?? 0, GLOBAL_WINDOW_MS);
1504
+ return parseReservation(result);
1505
+ }, () => this.#local.reserve(key, options), true);
1506
+ }
1507
+ async#reconcile(key, update) {
1508
+ this.#local.reconcile(key, update);
1509
+ const arg = (value) => value === undefined ? "" : String(value);
1510
+ await this.#run("reconcile", async () => {
1511
+ await this.#client.eval(RECONCILE_SCRIPT, 1, this.#bucketKey(key), arg(update.remaining), arg(update.resetAfterMs), arg(update.limit), arg(update.window), arg(update.retryAfterMs), BUCKET_LINGER_MS);
1512
+ }, () => {
1513
+ return;
1514
+ }, false);
645
1515
  }
646
- async getGlobalReset() {
647
- try {
648
- const resetAt = await this.#client.get(`${this.#prefix}global`);
649
- this.#markHealthy();
650
- return resetAt ? Number(resetAt) : 0;
651
- } catch (error) {
652
- this.#onError("getGlobalReset", error);
653
- return this.#local.getGlobalReset();
654
- }
1516
+ async#extendGlobal(scope, waitMs) {
1517
+ this.#local.extendGlobalReset(scope, waitMs);
1518
+ await this.#run("extendGlobalReset", async () => {
1519
+ await this.#client.eval(EXTEND_GLOBAL_SCRIPT, 1, this.#globalKey(scope), Math.max(0, Math.ceil(waitMs)));
1520
+ }, () => {
1521
+ return;
1522
+ }, false);
655
1523
  }
656
- async setGlobalReset(resetAt) {
657
- this.#local.setGlobalReset(resetAt);
658
- const globalKey = `${this.#prefix}global`;
659
- const ttl = Math.ceil((resetAt - Date.now()) / 1000);
660
- try {
661
- if (ttl <= 0) {
662
- await this.#client.del(globalKey);
663
- this.#markHealthy();
664
- return;
665
- }
666
- await this.#client.set(globalKey, String(resetAt), "EX", ttl);
667
- this.#markHealthy();
668
- } catch (error) {
669
- this.#onError("setGlobalReset", error);
670
- }
1524
+ async#release(key, grant, refund) {
1525
+ this.#local.release(key, grant, refund);
1526
+ if (grant.resetAt === undefined)
1527
+ return;
1528
+ await this.#run("release", async () => {
1529
+ await this.#client.eval(RELEASE_SCRIPT, 1, this.#bucketKey(key), grant.resetAt, refund ? "1" : "0");
1530
+ }, () => {
1531
+ return;
1532
+ }, false);
1533
+ }
1534
+ async getGlobalReset(scope = "") {
1535
+ return this.#run("getGlobalReset", async () => {
1536
+ const resetAt = await this.#client.get(this.#globalKey(scope));
1537
+ return resetAt ? Number(resetAt) || 0 : 0;
1538
+ }, () => this.#local.getGlobalReset(scope), true);
1539
+ }
1540
+ async setGlobalReset(resetAt, scope = "") {
1541
+ this.#local.setGlobalReset(resetAt, scope);
1542
+ const ttl = Math.ceil(resetAt - Date.now());
1543
+ await this.#run("setGlobalReset", async () => {
1544
+ if (ttl <= 0)
1545
+ await this.#client.del(this.#globalKey(scope));
1546
+ else
1547
+ await this.#client.set(this.#globalKey(scope), String(resetAt), "PX", ttl);
1548
+ }, () => {
1549
+ return;
1550
+ }, false);
671
1551
  }
672
1552
  }
1553
+ function parseReservation(result) {
1554
+ if (!Array.isArray(result) || result.length < 3)
1555
+ throw new Error("Unexpected reply from the reservation script.");
1556
+ const [granted, value, flag] = result;
1557
+ const n = Number(value);
1558
+ if (!Number.isFinite(n))
1559
+ throw new Error("Unexpected reply from the reservation script.");
1560
+ if (Number(granted) === 1)
1561
+ return { granted: true, known: Number(flag) === 1, resetAt: n };
1562
+ const reason = String(flag);
1563
+ return {
1564
+ granted: false,
1565
+ retryAfterMs: Math.max(0, n),
1566
+ reason: reason === "global" || reason === "probe" ? reason : "bucket"
1567
+ };
1568
+ }
673
1569
  // packages/rest/src/webhook.ts
674
1570
  class WebhookClient {
675
1571
  id;
@@ -690,29 +1586,49 @@ class WebhookClient {
690
1586
  }
691
1587
  this.#rest = new REST(restOptions);
692
1588
  }
693
- async send(options) {
1589
+ #prepare(options, base = {}) {
694
1590
  const payload = typeof options === "string" ? { content: options } : options;
695
- const { thread_id, files, ...body } = payload;
696
- const params = new URLSearchParams({ wait: "true" });
1591
+ const {
1592
+ thread_id: threadWire,
1593
+ threadId,
1594
+ avatarURL,
1595
+ allowedMentions,
1596
+ files,
1597
+ ...body
1598
+ } = payload;
1599
+ const thread_id = threadWire ?? threadId;
1600
+ if (avatarURL !== undefined && body.avatar_url === undefined)
1601
+ body.avatar_url = avatarURL;
1602
+ if (allowedMentions !== undefined && body.allowed_mentions === undefined)
1603
+ body.allowed_mentions = allowedMentions;
1604
+ const params = new URLSearchParams(base);
697
1605
  if (thread_id)
698
1606
  params.set("thread_id", String(thread_id));
699
- const query = `?${params}`;
700
- const formattedEmbeds = body.embeds?.map((e) => ("toJSON" in e) ? e.toJSON() : e);
701
- const formattedComponents = body.components?.map((c) => ("toJSON" in c) ? c.toJSON() : c);
1607
+ const json = { ...body };
1608
+ if (body.embeds)
1609
+ json.embeds = body.embeds.map((e) => ("toJSON" in e) ? e.toJSON() : e);
1610
+ if (body.components)
1611
+ json.components = body.components.map((c) => ("toJSON" in c) ? c.toJSON() : c);
1612
+ const text = params.toString();
1613
+ return { query: text ? `?${text}` : "", json, files };
1614
+ }
1615
+ async send(options) {
1616
+ const { query, json, files } = this.#prepare(options, { wait: "true" });
702
1617
  const path = `${Routes.webhook(this.id, this.token)}${query}`;
703
- const json = {
704
- ...body,
705
- embeds: formattedEmbeds,
706
- components: formattedComponents
707
- };
708
1618
  return files?.length ? this.#rest.postWithFiles(path, json, files) : this.#rest.post(path, json);
709
1619
  }
1620
+ async fetchMessage(messageId, options = {}) {
1621
+ const query = options.threadId ? `?${new URLSearchParams({ thread_id: options.threadId })}` : "";
1622
+ return this.#rest.get(`${Routes.webhookMessage(this.id, this.token, messageId)}${query}`);
1623
+ }
710
1624
  async editMessage(messageId, options) {
711
- const payload = typeof options === "string" ? { content: options } : options;
712
- return this.#rest.patch(Routes.webhookMessage(this.id, this.token, messageId), payload);
1625
+ const { query, json, files } = this.#prepare(options);
1626
+ const path = `${Routes.webhookMessage(this.id, this.token, messageId)}${query}`;
1627
+ return files?.length ? this.#rest.patchWithFiles(path, json, files) : this.#rest.patch(path, json);
713
1628
  }
714
- async deleteMessage(messageId) {
715
- await this.#rest.delete(Routes.webhookMessage(this.id, this.token, messageId));
1629
+ async deleteMessage(messageId, options = {}) {
1630
+ const query = options.threadId ? `?${new URLSearchParams({ thread_id: options.threadId })}` : "";
1631
+ await this.#rest.delete(`${Routes.webhookMessage(this.id, this.token, messageId)}${query}`);
716
1632
  }
717
1633
  }
718
1634
 
@@ -721,7 +1637,9 @@ var LIBRARY_VERSION = package_default.version;
721
1637
  var LIBRARY_URL = "https://github.com/Ekretos/lunibee";
722
1638
  var USER_AGENT = `DiscordBot (${LIBRARY_URL}, ${LIBRARY_VERSION})`;
723
1639
  function assertSafePath(path) {
724
- const end = path.search(/[?#]/);
1640
+ if (/[\u0000-\u0020\u007f#]/.test(path))
1641
+ throw new TypeError("REST paths must not contain control characters, spaces or '#'.");
1642
+ const end = path.indexOf("?");
725
1643
  const pathname = end === -1 ? path : path.slice(0, end);
726
1644
  if (pathname.includes("\\"))
727
1645
  throw new TypeError("REST paths must not contain backslashes.");
@@ -732,7 +1650,11 @@ function assertSafePath(path) {
732
1650
  } catch {
733
1651
  throw new TypeError("REST path has malformed percent-encoding.");
734
1652
  }
735
- if (decoded === "." || decoded === "..")
1653
+ const bare = decoded.replace(/[\u0000-\u0020\u007f]/g, "");
1654
+ if (bare === "." || bare === ".." || /[\\/]/.test(decoded) && decoded.split(/[\\/]/).some((s) => {
1655
+ const part = s.replace(/[\u0000-\u0020\u007f]/g, "");
1656
+ return part === "." || part === "..";
1657
+ }))
736
1658
  throw new TypeError("REST paths must not contain '.' or '..' segments.");
737
1659
  }
738
1660
  }
@@ -743,7 +1665,8 @@ var REQUEST_DATA_KEYS = new Set([
743
1665
  "headers",
744
1666
  "reason",
745
1667
  "auth",
746
- "signal"
1668
+ "signal",
1669
+ "maxQueueWait"
747
1670
  ]);
748
1671
  function isRequestData(value) {
749
1672
  if (value === null || typeof value !== "object")
@@ -779,27 +1702,66 @@ function createRetryPolicy(maxRetries = 2) {
779
1702
  };
780
1703
  }
781
1704
 
1705
+ class QueueWaitExceeded extends Error {
1706
+ ms;
1707
+ constructor(ms) {
1708
+ super("maxQueueWait exceeded");
1709
+ this.ms = ms;
1710
+ }
1711
+ }
1712
+ function checkWait(ms) {
1713
+ if (ms === undefined)
1714
+ return;
1715
+ if (!(ms >= 0) || !Number.isFinite(ms))
1716
+ throw new RangeError("maxQueueWait must be a non-negative number.");
1717
+ return ms;
1718
+ }
1719
+
782
1720
  class REST {
783
1721
  #retryPolicy;
784
1722
  #token;
1723
+ #fingerprint;
1724
+ #telemetry;
1725
+ #closing = new AbortController;
1726
+ #inFlight = 0;
1727
+ #idle;
1728
+ #rejected;
1729
+ #stopOnInvalidToken;
1730
+ #maxQueueWait;
1731
+ #invalid = new Array(10).fill(0);
1732
+ #invalidMinute = 0;
1733
+ #drained;
785
1734
  #limiter;
786
- #scheduler = new RequestScheduler;
1735
+ #scheduler;
787
1736
  #transport;
788
1737
  #decoder = new ResponseDecoder;
789
1738
  #hooks = {};
790
1739
  #concurrentBuckets;
791
1740
  constructor(options = {}) {
792
1741
  this.#token = options.token;
1742
+ this.#fingerprint = options.token ? tokenFingerprint(options.token) : undefined;
1743
+ this.#telemetry = options.telemetry;
1744
+ this.#scheduler = new RequestScheduler({
1745
+ maxQueued: options.maxQueued,
1746
+ maxQueuedPerBucket: options.maxQueuedPerBucket
1747
+ });
793
1748
  this.#retryPolicy = options.retryPolicy ?? createRetryPolicy(options.retries ?? 2);
794
1749
  this.#transport = options.transport ?? new HttpTransport({
795
1750
  baseURL: options.baseURL,
1751
+ allowInsecureBaseURL: options.allowInsecureBaseURL,
796
1752
  timeout: options.timeout
797
1753
  });
798
1754
  const store = options.store ?? new MemoryRateLimitStore;
799
1755
  this.#concurrentBuckets = (options.concurrentBuckets ?? true) && typeof store.reserve === "function";
800
1756
  this.#limiter = new RateLimiter(store, {
801
- concurrent: this.#concurrentBuckets
1757
+ concurrent: this.#concurrentBuckets,
1758
+ maxBackoff: options.maxBackoff,
1759
+ probeMs: this.#transport.timeout > 0 ? this.#transport.timeout + 1000 : undefined,
1760
+ telemetry: options.telemetry,
1761
+ globalLimit: options.globalLimit === false ? undefined : options.globalLimit ?? 50
802
1762
  });
1763
+ this.#stopOnInvalidToken = options.stopOnInvalidToken ?? true;
1764
+ this.#maxQueueWait = checkWait(options.maxQueueWait);
803
1765
  if (options.hooks)
804
1766
  this.#hooks = options.hooks;
805
1767
  }
@@ -814,9 +1776,64 @@ class REST {
814
1776
  if (!token.trim())
815
1777
  throw new TypeError("A Discord bot token is required.");
816
1778
  this.#token = token;
1779
+ this.#fingerprint = tokenFingerprint(token);
1780
+ this.#rejected = undefined;
817
1781
  }
818
- async request(method, path, body, options = {}) {
819
- if (!method.toUpperCase())
1782
+ diagnostics() {
1783
+ const store = this.#limiter.store;
1784
+ return {
1785
+ mode: store.mode ?? (store instanceof MemoryRateLimitStore ? "local" : "distributed"),
1786
+ queued: this.#scheduler.queued,
1787
+ inFlight: this.#inFlight,
1788
+ buckets: this.#scheduler.size,
1789
+ closed: this.#closing.signal.aborted,
1790
+ invalidRequests: this.#invalidCount(0),
1791
+ tokenRejected: this.#rejected !== undefined && this.#rejected === this.#fingerprint
1792
+ };
1793
+ }
1794
+ async close() {
1795
+ if (!this.#closing.signal.aborted)
1796
+ this.#closing.abort(new Error("The REST client was closed."));
1797
+ if (this.#inFlight === 0)
1798
+ return;
1799
+ this.#drained ??= new Promise((resolve) => {
1800
+ this.#idle = resolve;
1801
+ });
1802
+ await this.#drained;
1803
+ }
1804
+ #invalidCount(add) {
1805
+ const minute = Math.floor(Date.now() / 60000);
1806
+ const elapsed = Math.min(10, minute - this.#invalidMinute);
1807
+ for (let i = 1;i <= elapsed; i++)
1808
+ this.#invalid[(this.#invalidMinute + i) % 10] = 0;
1809
+ if (elapsed > 0)
1810
+ this.#invalidMinute = minute;
1811
+ this.#invalid[minute % 10] += add;
1812
+ return this.#invalid.reduce((a, b) => a + b, 0);
1813
+ }
1814
+ #queueChanged() {
1815
+ emitTelemetry(this.#telemetry, {
1816
+ type: "queue",
1817
+ queued: this.#scheduler.queued,
1818
+ inFlight: this.#inFlight
1819
+ });
1820
+ }
1821
+ async request(methodOrOptions, pathArg, bodyArg, optionsArg = {}) {
1822
+ let method;
1823
+ let path;
1824
+ let body = bodyArg;
1825
+ let options = optionsArg;
1826
+ if (typeof methodOrOptions === "object" && methodOrOptions !== null) {
1827
+ const { method: m, path: p, fullRoute, ...rest } = methodOrOptions;
1828
+ method = m;
1829
+ path = p ?? fullRoute ?? "";
1830
+ body = rest.body;
1831
+ options = rest;
1832
+ } else {
1833
+ method = methodOrOptions;
1834
+ path = pathArg;
1835
+ }
1836
+ if (typeof method !== "string" || !method.toUpperCase())
820
1837
  throw new TypeError("REST method is required.");
821
1838
  if (!path.startsWith("/"))
822
1839
  throw new TypeError("REST paths must start with '/'.");
@@ -824,22 +1841,101 @@ class REST {
824
1841
  const route = createRouteKey(method, path);
825
1842
  const files = options.files;
826
1843
  if (files && files.length > 0 && !(body instanceof FormData))
827
- body = this.#fileForm(body, files);
1844
+ body = this.#fileForm(body, await Promise.all(files.map(async (file) => ({
1845
+ ...file,
1846
+ data: await file.data
1847
+ }))));
828
1848
  const query = serializeQuery(options.query);
829
1849
  const requestPath = query ? `${path}${query}` : path;
830
- return this.#scheduler.runResolved(() => this.#limiter.resolveBucketKey(route.route, route.major), options.signal, path, (bucketKey, release) => this.#attempts(route, requestPath, body, options, bucketKey, release));
1850
+ if (this.#closing.signal.aborted)
1851
+ throw abortError(path, this.#closing.signal.reason);
1852
+ const identity = requestIdentity(path, options.auth !== false && this.#token ? this.#fingerprint : undefined);
1853
+ if (identity.bucket === this.#fingerprint && this.#rejected === this.#fingerprint)
1854
+ throw new RESTError("Discord rejected this bot token (401); request not sent. Call setToken() with a valid token.", 401, undefined, undefined, { method: route.method, path, kind: "authentication" });
1855
+ const queueWait = checkWait(options.maxQueueWait) ?? this.#maxQueueWait;
1856
+ let deadline;
1857
+ const sources = [this.#closing.signal];
1858
+ if (options.signal)
1859
+ sources.push(options.signal);
1860
+ if (queueWait !== undefined) {
1861
+ const expired = new AbortController;
1862
+ deadline = setTimeout(() => expired.abort(new QueueWaitExceeded(queueWait)), queueWait);
1863
+ sources.push(expired.signal);
1864
+ }
1865
+ const waitSignal = sources.length === 1 ? sources[0] : AbortSignal.any(sources);
1866
+ const state = { attempts: 0, status: 0 };
1867
+ const started = performance.now();
1868
+ this.#inFlight++;
1869
+ this.#queueChanged();
1870
+ try {
1871
+ return await this.#scheduler.runResolved(() => this.#limiter.resolveBucketKey(route.route, route.major, identity.bucket), waitSignal, path, (bucketKey, release) => {
1872
+ this.#queueChanged();
1873
+ return this.#attempts(route, requestPath, body, options, { identity, waitSignal, state }, bucketKey, release);
1874
+ });
1875
+ } catch (caught) {
1876
+ let error = caught;
1877
+ if (error instanceof RESTError && error.kind === "aborted" && error.cause instanceof QueueWaitExceeded)
1878
+ error = new RESTError(`The request waited longer than maxQueueWait (${error.cause.ms}ms) before it could be sent.`, 0, undefined, undefined, { method: route.method, path, kind: "overloaded" });
1879
+ if (error instanceof RESTError) {
1880
+ if (error.kind === "aborted" && state.attempts === 0)
1881
+ emitTelemetry(this.#telemetry, {
1882
+ type: "cancelled",
1883
+ stage: "queued"
1884
+ });
1885
+ else if (error.kind === "overloaded")
1886
+ emitTelemetry(this.#telemetry, { type: "overloaded" });
1887
+ if (state.status === 0)
1888
+ state.status = error.status;
1889
+ }
1890
+ throw error;
1891
+ } finally {
1892
+ clearTimeout(deadline);
1893
+ emitTelemetry(this.#telemetry, {
1894
+ type: "request",
1895
+ method: route.method,
1896
+ route: route.route,
1897
+ status: state.status,
1898
+ attempts: state.attempts,
1899
+ durationMs: performance.now() - started,
1900
+ ok: state.status >= 200 && state.status < 300
1901
+ });
1902
+ if (--this.#inFlight === 0 && this.#idle) {
1903
+ this.#idle();
1904
+ this.#idle = undefined;
1905
+ this.#drained = undefined;
1906
+ }
1907
+ this.#queueChanged();
1908
+ }
831
1909
  }
832
- async#attempts(route, requestPath, body, options, initialBucketKey, release) {
1910
+ async#attempts(route, requestPath, body, options, context, initialBucketKey, release) {
833
1911
  const { method, path } = route;
1912
+ const { identity, waitSignal, state } = context;
1913
+ const label = route.route;
834
1914
  let bucketKey = initialBucketKey;
835
1915
  for (let attempt = 0;attempt <= this.#retryPolicy.maxRetries; attempt++) {
836
- await this.#limiter.acquire(bucketKey, options.signal, path);
837
- if (this.#concurrentBuckets) {
838
- const bucket = await this.#limiter.store.getBucket(bucketKey);
839
- if (bucket && bucket.resetAt > Date.now())
840
- release();
1916
+ let grant;
1917
+ try {
1918
+ grant = await this.#limiter.acquire(bucketKey, waitSignal, path, { global: identity.global });
1919
+ } catch (error) {
1920
+ if (error instanceof RESTError && error.kind === "aborted")
1921
+ emitTelemetry(this.#telemetry, {
1922
+ type: "cancelled",
1923
+ stage: "waiting"
1924
+ });
1925
+ throw error;
1926
+ }
1927
+ if (this.#concurrentBuckets && grant.known)
1928
+ release();
1929
+ if (waitSignal.aborted) {
1930
+ await this.#limiter.releaseGrant(bucketKey, grant, true);
1931
+ emitTelemetry(this.#telemetry, {
1932
+ type: "cancelled",
1933
+ stage: "waiting"
1934
+ });
1935
+ throw abortError(path, waitSignal.reason);
841
1936
  }
842
1937
  this.#emit(this.#hooks.onRequest, { method, path, attempt });
1938
+ state.attempts = attempt + 1;
843
1939
  const requestStart = Date.now();
844
1940
  let response;
845
1941
  try {
@@ -851,13 +1947,44 @@ class REST {
851
1947
  signal: options.signal
852
1948
  });
853
1949
  } catch (error) {
854
- if (error instanceof RESTError)
1950
+ const notSent = error instanceof RESTError && error.kind === "aborted";
1951
+ await this.#limiter.releaseGrant(bucketKey, grant, notSent);
1952
+ if (error instanceof RESTError) {
1953
+ if (error.kind === "aborted")
1954
+ emitTelemetry(this.#telemetry, {
1955
+ type: "cancelled",
1956
+ stage: "transport"
1957
+ });
855
1958
  throw error;
856
- if (options.signal?.aborted)
1959
+ }
1960
+ if (options.signal?.aborted) {
1961
+ emitTelemetry(this.#telemetry, {
1962
+ type: "cancelled",
1963
+ stage: "transport"
1964
+ });
857
1965
  throw abortError(path, options.signal.reason);
858
- const failure = error instanceof TransportError ? error : new TransportError("Discord REST request failed", false, error);
1966
+ }
1967
+ const failure = error instanceof TransportError ? error : new TransportError("Discord REST request failed", false, sanitizeCause(error));
1968
+ emitTelemetry(this.#telemetry, {
1969
+ type: "networkError",
1970
+ timedOut: failure.timedOut
1971
+ });
859
1972
  if (!failure.timedOut && attempt < this.#retryPolicy.maxRetries && this.#retryPolicy.shouldRetry(method, 0)) {
860
- await sleep(this.#retryPolicy.getDelay(attempt), options.signal, path);
1973
+ const delay = this.#retryPolicy.getDelay(attempt);
1974
+ this.#emit(this.#hooks.onRetry, {
1975
+ method,
1976
+ path,
1977
+ attempt,
1978
+ status: 0,
1979
+ delayMs: delay
1980
+ });
1981
+ emitTelemetry(this.#telemetry, {
1982
+ type: "retry",
1983
+ route: label,
1984
+ reason: "network",
1985
+ attempt
1986
+ });
1987
+ await this.#retrySleep(delay, waitSignal, path);
861
1988
  continue;
862
1989
  }
863
1990
  throw new RESTError(failure.message, 0, undefined, undefined, {
@@ -867,30 +1994,60 @@ class REST {
867
1994
  kind: failure.timedOut ? "timeout" : "network"
868
1995
  });
869
1996
  }
1997
+ state.status = response.status;
1998
+ if (response.status === 401 || response.status === 403 || response.status === 429 && response.headers.get("X-RateLimit-Scope") !== "shared")
1999
+ this.#invalidCount(1);
2000
+ if (response.status === 401 && this.#stopOnInvalidToken && identity.bucket === this.#fingerprint) {
2001
+ this.#rejected = this.#fingerprint;
2002
+ emitTelemetry(this.#telemetry, { type: "tokenRejected" });
2003
+ }
2004
+ const durationMs = Date.now() - requestStart;
870
2005
  this.#emit(this.#hooks.onResponse, {
871
2006
  method,
872
2007
  path,
873
2008
  status: response.status,
874
- durationMs: Date.now() - requestStart
2009
+ durationMs
2010
+ });
2011
+ emitTelemetry(this.#telemetry, {
2012
+ type: "response",
2013
+ method,
2014
+ route: label,
2015
+ status: response.status,
2016
+ durationMs
875
2017
  });
876
- bucketKey = await this.#limiter.applyResponse(response, bucketKey, route.route, route.major);
877
2018
  const payload = await this.#decoder.read(response);
2019
+ const data = response.ok ? {} : this.#decoder.errorData(payload);
2020
+ bucketKey = await this.#limiter.applyResponse(response, bucketKey, route.route, route.major, {
2021
+ identity: identity.bucket,
2022
+ global: identity.global,
2023
+ body: data,
2024
+ grant
2025
+ });
878
2026
  if (response.ok)
879
2027
  return response.status === 204 ? undefined : payload;
880
- const data = this.#decoder.errorData(payload);
881
- const retryAfter = response.status === 429 ? this.#decoder.retryAfter(response, data) : undefined;
882
- if (data.global && retryAfter !== undefined)
883
- await this.#limiter.noteGlobalReset(Date.now() + retryAfter * 1000);
884
- if (response.status === 429)
2028
+ const limited = response.status === 429;
2029
+ const retryAfterMs = limited ? this.#limiter.retryAfterMs(response, data) : undefined;
2030
+ if (limited) {
2031
+ const global = data.global === true || response.headers.get("X-RateLimit-Global") === "true";
2032
+ const scope = response.headers.get("X-RateLimit-Scope")?.toLowerCase();
2033
+ emitTelemetry(this.#telemetry, {
2034
+ type: "rateLimited",
2035
+ route: label,
2036
+ scope: global ? "global" : scope === "user" || scope === "shared" ? scope : "unknown",
2037
+ retryAfterMs
2038
+ });
885
2039
  this.#emit(this.#hooks.onRateLimit, {
886
2040
  method,
887
2041
  path,
888
- retryAfterMs: (retryAfter ?? 1) * 1000,
889
- global: data.global ?? false,
890
- bucket: await this.#limiter.store.getBucketHash(route.route)
2042
+ retryAfterMs,
2043
+ global,
2044
+ bucket: response.headers.get("X-RateLimit-Bucket") ?? await this.#limiter.store.getBucketHash(route.route)
891
2045
  });
2046
+ }
892
2047
  if (attempt < this.#retryPolicy.maxRetries && this.#retryPolicy.shouldRetry(method, response.status)) {
893
- const delay = this.#retryPolicy.getDelay(attempt, retryAfter);
2048
+ const delay = this.#retryPolicy.getDelay(attempt, limited ? retryAfterMs / 1000 : undefined);
2049
+ if (delay > this.#limiter.maxBackoff)
2050
+ throw new RESTError(`Discord asked to wait ${Math.ceil(delay / 1000)}s before retrying, longer than maxBackoff (${Math.ceil(this.#limiter.maxBackoff / 1000)}s).`, response.status, data.code, payload, { method, path });
894
2051
  this.#emit(this.#hooks.onRetry, {
895
2052
  method,
896
2053
  path,
@@ -898,13 +2055,30 @@ class REST {
898
2055
  status: response.status,
899
2056
  delayMs: delay
900
2057
  });
901
- await sleep(delay, options.signal, path);
2058
+ emitTelemetry(this.#telemetry, {
2059
+ type: "retry",
2060
+ route: label,
2061
+ reason: limited ? "rateLimited" : "server",
2062
+ attempt
2063
+ });
2064
+ await this.#retrySleep(limited ? delay - retryAfterMs : delay, waitSignal, path);
902
2065
  continue;
903
2066
  }
904
2067
  throw new RESTError(data.message ?? response.statusText ?? `Discord REST request failed with status ${response.status}`, response.status, data.code, payload, { method, path });
905
2068
  }
906
2069
  throw new RESTError("Discord REST request exhausted its retry attempts", 0, undefined, undefined, { method, path });
907
2070
  }
2071
+ async#retrySleep(delay, signal, path) {
2072
+ try {
2073
+ await sleep(delay, signal, path);
2074
+ } catch (error) {
2075
+ emitTelemetry(this.#telemetry, {
2076
+ type: "cancelled",
2077
+ stage: "retry"
2078
+ });
2079
+ throw error;
2080
+ }
2081
+ }
908
2082
  #headers(body, options) {
909
2083
  const isFormData = typeof FormData !== "undefined" && body instanceof FormData;
910
2084
  const headers = { ...options.headers };
@@ -956,15 +2130,15 @@ class REST {
956
2130
  const blob = file.data instanceof Blob ? file.data : new Blob([file.data], {
957
2131
  type: file.contentType ?? "application/octet-stream"
958
2132
  });
959
- form.append(`files[${i}]`, blob, file.name);
2133
+ form.append(`files[${i}]`, blob, file.name.replace(/[\r\n"\\]/g, "_"));
960
2134
  }
961
2135
  return form;
962
2136
  }
963
2137
  postWithFiles(path, payload, files, options) {
964
- return this.request("POST", path, this.#fileForm(payload, files), options);
2138
+ return this.request("POST", path, payload, { ...options, files });
965
2139
  }
966
2140
  patchWithFiles(path, payload, files, options) {
967
- return this.request("PATCH", path, this.#fileForm(payload, files), options);
2141
+ return this.request("PATCH", path, payload, { ...options, files });
968
2142
  }
969
2143
  #emit(hook, ctx) {
970
2144
  if (!hook)
@@ -976,12 +2150,14 @@ class REST {
976
2150
  }
977
2151
  }
978
2152
  export {
2153
+ DEFAULT_MAX_BACKOFF_MS,
979
2154
  RESTError as DiscordAPIError,
980
2155
  HttpTransport,
981
2156
  MemoryRateLimitStore,
982
2157
  REST,
983
2158
  RESTError,
984
2159
  RESTErrorCode,
2160
+ RESTMetrics,
985
2161
  RateLimiter,
986
2162
  RedisRateLimitStore,
987
2163
  RequestScheduler,
@@ -992,11 +2168,16 @@ export {
992
2168
  abortError,
993
2169
  createRetryPolicy,
994
2170
  createRouteKey,
2171
+ fromBunRedis,
2172
+ headerNumber,
995
2173
  majorParameter,
996
2174
  normalizeRoutePath,
2175
+ parseRateLimitHeaders,
997
2176
  redactPath,
2177
+ requestIdentity,
998
2178
  scopeBucket,
999
- serializeQuery
2179
+ serializeQuery,
2180
+ tokenFingerprint
1000
2181
  };
1001
2182
 
1002
- //# debugId=76878940534439C564756E2164756E21
2183
+ //# debugId=8464D7D05D07B1F864756E2164756E21