lunibee 0.3.1 → 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 (79) 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/index.js +64 -17
  6. package/dist/builders/index.js.map +10 -9
  7. package/dist/collection/index.d.ts +1 -0
  8. package/dist/collection/index.js +297 -19
  9. package/dist/collection/index.js.map +5 -4
  10. package/dist/collection/redis.d.ts +98 -0
  11. package/dist/core/collector.d.ts +8 -0
  12. package/dist/core/events.d.ts +1 -0
  13. package/dist/core/index.d.ts +3 -0
  14. package/dist/core/index.js +6213 -4716
  15. package/dist/core/index.js.map +46 -39
  16. package/dist/core/permissions.d.ts +3 -402
  17. package/dist/core/redis-cache.d.ts +85 -0
  18. package/dist/formatters/index.d.ts +5 -1
  19. package/dist/formatters/index.js +7 -3
  20. package/dist/formatters/index.js.map +3 -3
  21. package/dist/handlers/index.js +11 -2
  22. package/dist/handlers/index.js.map +3 -3
  23. package/dist/index.js +8272 -6085
  24. package/dist/index.js.map +64 -58
  25. package/dist/managers/application.d.ts +10 -6
  26. package/dist/managers/base.d.ts +4 -1
  27. package/dist/managers/guild.d.ts +3 -1
  28. package/dist/managers/index.d.ts +6 -1
  29. package/dist/managers/index.js +365 -103
  30. package/dist/managers/index.js.map +19 -22
  31. package/dist/managers/member.d.ts +3 -1
  32. package/dist/managers/message.d.ts +13 -4
  33. package/dist/managers/thread.d.ts +3 -3
  34. package/dist/rest/decoder.d.ts +2 -0
  35. package/dist/rest/errors.d.ts +5 -2
  36. package/dist/rest/headers.d.ts +31 -0
  37. package/dist/rest/identity.d.ts +31 -0
  38. package/dist/rest/index.d.ts +97 -6
  39. package/dist/rest/index.js +1350 -220
  40. package/dist/rest/index.js.map +16 -13
  41. package/dist/rest/limiter.d.ts +75 -10
  42. package/dist/rest/redis.d.ts +103 -22
  43. package/dist/rest/scheduler.d.ts +13 -0
  44. package/dist/rest/store.d.ts +135 -12
  45. package/dist/rest/telemetry.d.ts +151 -0
  46. package/dist/rest/webhook.d.ts +18 -6
  47. package/dist/sharding/bus.d.ts +4 -2
  48. package/dist/sharding/cluster.d.ts +1 -1
  49. package/dist/sharding/health.d.ts +1 -1
  50. package/dist/sharding/host.d.ts +6 -0
  51. package/dist/sharding/identify.d.ts +2 -0
  52. package/dist/sharding/index.d.ts +6 -0
  53. package/dist/sharding/index.js +205 -39
  54. package/dist/sharding/index.js.map +14 -14
  55. package/dist/sharding/remote.d.ts +18 -2
  56. package/dist/sharding/supervisor.d.ts +1 -1
  57. package/dist/sharding/transport.d.ts +6 -5
  58. package/dist/sharding/worker.d.ts +11 -1
  59. package/dist/structures/base.d.ts +16 -5
  60. package/dist/structures/index.d.ts +1 -1
  61. package/dist/structures/index.js +57 -91
  62. package/dist/structures/index.js.map +10 -18
  63. package/dist/structures/options.d.ts +3 -2
  64. package/dist/structures/permissions.d.ts +409 -0
  65. package/dist/structures/resources.d.ts +4 -1
  66. package/dist/types/gateway.d.ts +4 -0
  67. package/dist/types/index.d.ts +14 -4
  68. package/dist/types/index.js.map +3 -3
  69. package/dist/utils/args.d.ts +3 -1
  70. package/dist/utils/index.d.ts +6 -1
  71. package/dist/utils/index.js +17 -10
  72. package/dist/utils/index.js.map +4 -4
  73. package/dist/voice/index.d.ts +7 -1
  74. package/dist/voice/index.js +33 -10
  75. package/dist/voice/index.js.map +3 -3
  76. package/dist/ws/index.d.ts +2 -0
  77. package/dist/ws/index.js +32 -4
  78. package/dist/ws/index.js.map +5 -5
  79. package/package.json +19 -16
@@ -2,7 +2,7 @@
2
2
  // packages/rest/package.json
3
3
  var package_default = {
4
4
  name: "@lunibee/rest",
5
- version: "0.3.1",
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
 
@@ -128,15 +284,17 @@ class RESTError extends Error {
128
284
  this.path = options.path === undefined ? undefined : redactPath(options.path);
129
285
  }
130
286
  get retryable() {
131
- 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";
132
288
  }
133
289
  }
134
290
  function abortError(path, reason) {
135
291
  return new RESTError(reason instanceof Error ? reason.message : "Discord REST request was aborted", 0, undefined, undefined, { path, cause: reason, kind: "aborted" });
136
292
  }
293
+ var MAX_TIMER_MS = 2147483647;
137
294
  async function sleep(delay, signal, path) {
138
295
  if (delay <= 0)
139
296
  return;
297
+ delay = Math.min(delay, MAX_TIMER_MS);
140
298
  await new Promise((resolve, reject) => {
141
299
  const finish = (error) => {
142
300
  clearTimeout(timer);
@@ -176,7 +334,7 @@ import { createHash } from "crypto";
176
334
  function normalizeRoutePath(path) {
177
335
  const queryIndex = path.indexOf("?");
178
336
  const routePath = queryIndex === -1 ? path : path.slice(0, queryIndex);
179
- return redactPath(routePath).replace(/\/\d+(?=\/|$)/g, "/:id");
337
+ return redactPath(routePath).replace(/\/\d+(?=\/|$)/g, "/:id").replace(/\/reactions\/[^/]+/i, "/reactions/:emoji");
180
338
  }
181
339
  function majorParameter(path) {
182
340
  const match = /^\/(channels|guilds|webhooks)\/(\d+)(?:\/([^/?]+))?/i.exec(path);
@@ -215,9 +373,22 @@ function serializeQuery(query) {
215
373
  class RequestScheduler {
216
374
  static MAX_REMAPS = 2;
217
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
+ }
218
386
  get size() {
219
387
  return this.#queues.size;
220
388
  }
389
+ get queued() {
390
+ return this.#queued;
391
+ }
221
392
  async runResolved(resolveKey, signal, path, task) {
222
393
  let key = await resolveKey();
223
394
  for (let hop = 0;hop < RequestScheduler.MAX_REMAPS; hop++) {
@@ -234,7 +405,10 @@ class RequestScheduler {
234
405
  return this.run(key, signal, path, (release) => task(key, release));
235
406
  }
236
407
  async run(key, signal, path, task) {
237
- 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" });
238
412
  let open;
239
413
  const gate = new Promise((resolve) => {
240
414
  open = resolve;
@@ -250,7 +424,21 @@ class RequestScheduler {
250
424
  this.#queues.delete(key);
251
425
  };
252
426
  try {
253
- 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);
254
442
  return await task(release);
255
443
  } finally {
256
444
  release();
@@ -258,87 +446,498 @@ class RequestScheduler {
258
446
  }
259
447
  }
260
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
+
261
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
+
262
698
  class RateLimiter {
263
699
  #store;
264
- #concurrent;
700
+ #maxBackoff;
701
+ #telemetry;
702
+ #probeMs;
703
+ #routes = new Map;
704
+ #globalLimit;
705
+ #localGlobal = new Map;
265
706
  constructor(store, options = {}) {
266
707
  this.#store = store;
267
- 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);
268
724
  }
269
725
  get store() {
270
726
  return this.#store;
271
727
  }
272
- async resolveBucketKey(route, major) {
273
- return scopeBucket(await this.#store.getBucketHash(route) ?? route, major);
274
- }
275
728
  get reserves() {
276
729
  return typeof this.#store.reserve === "function";
277
730
  }
278
- async acquire(bucketKey, signal, path) {
279
- await this.#waitGlobal(signal, path);
280
- if (!this.#store.reserve) {
281
- 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));
282
764
  if (bucket) {
283
765
  const delay = bucket.resetAt - Date.now();
284
766
  if (bucket.remaining <= 0 && delay > 0)
285
- await sleep(delay, signal, path);
767
+ await this.#wait("bucket", delay, signal, path);
286
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)
287
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);
288
838
  }
289
- for (let attempt = 0;attempt < 8; attempt++) {
290
- const reservation = await this.#store.reserve(bucketKey);
291
- if (reservation.granted)
839
+ for (;; ) {
840
+ const wait = takeGlobalSlot(log, Date.now(), this.#globalLimit);
841
+ if (wait === 0)
292
842
  return;
293
- await sleep(Math.max(1, reservation.retryAfterMs), signal, path);
294
- await this.#waitGlobal(signal, path);
843
+ await this.#wait("global", wait, signal, path);
295
844
  }
296
845
  }
297
- async#waitGlobal(signal, path) {
298
- 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();
299
848
  if (globalDelay > 0)
300
- await sleep(globalDelay, signal, path);
301
- }
302
- async noteGlobalReset(resetAt) {
303
- const current = await this.#store.getGlobalReset();
304
- await this.#store.setGlobalReset(Math.max(current, resetAt));
849
+ await this.#wait("global", globalDelay, signal, path);
305
850
  }
306
- async applyResponse(response, bucketKey, route, major) {
307
- let currentKey = bucketKey;
308
- const serverBucket = response.headers.get("X-RateLimit-Bucket");
309
- if (serverBucket) {
310
- await this.#store.setBucketHash(route, serverBucket);
311
- currentKey = scopeBucket(serverBucket, major);
312
- }
313
- const stored = await this.#store.getBucket(currentKey);
314
- const bucket = stored ? { ...stored } : { remaining: 1, resetAt: 0 };
315
- const windowActive = this.#concurrent && !!stored && stored.resetAt > Date.now();
316
- const remaining = Number(response.headers.get("X-RateLimit-Remaining"));
317
- const resetAfter = Number(response.headers.get("X-RateLimit-Reset-After"));
318
- const reset = Number(response.headers.get("X-RateLimit-Reset"));
319
- if (Number.isFinite(remaining))
320
- bucket.remaining = Math.max(0, remaining);
321
- if (Number.isFinite(resetAfter))
322
- bucket.resetAt = Date.now() + Math.max(0, resetAfter) * 1000;
323
- else if (Number.isFinite(reset))
324
- bucket.resetAt = reset * 1000;
325
- if (windowActive && stored) {
326
- bucket.remaining = Math.min(bucket.remaining, stored.remaining);
327
- bucket.resetAt = Math.max(bucket.resetAt, stored.resetAt);
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;
328
856
  }
329
- if (response.status === 429) {
330
- const retryAfter = Number(response.headers.get("Retry-After"));
331
- if (Number.isFinite(retryAfter))
332
- bucket.resetAt = Math.max(bucket.resetAt, Date.now() + retryAfter * 1000);
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);
871
+ let currentKey = bucketKey;
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}`;
333
886
  }
334
- await this.#store.updateBucket(currentKey, bucket);
335
- if (response.headers.get("X-RateLimit-Global") === "true") {
336
- const retryAfter = Number(response.headers.get("Retry-After"));
337
- if (Number.isFinite(retryAfter))
338
- await this.noteGlobalReset(Date.now() + retryAfter * 1000);
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);
339
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);
340
917
  return currentKey;
341
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 };
342
941
  }
343
942
 
344
943
  // packages/rest/src/transport.ts
@@ -354,11 +953,11 @@ function parseBaseURL(raw, allowInsecure = false) {
354
953
  throw new TypeError("baseURL must use https.");
355
954
  if (url.username || url.password)
356
955
  throw new TypeError("baseURL must not contain credentials.");
357
- if (url.search || url.hash)
956
+ if (url.search || url.hash || /[?#]/.test(raw))
358
957
  throw new TypeError("baseURL must not contain a query or fragment.");
359
958
  if (url.protocol === "http:" && !allowInsecure && !LOOPBACK.test(url.hostname))
360
959
  throw new TypeError("baseURL must use https: the bot token would travel in cleartext (allowInsecureBaseURL: true overrides this for a trusted network).");
361
- return raw.replace(/\/$/, "");
960
+ return url.href.replace(/\/$/, "");
362
961
  }
363
962
 
364
963
  class TransportError extends Error {
@@ -414,6 +1013,7 @@ class HttpTransport {
414
1013
 
415
1014
  // packages/rest/src/decoder.ts
416
1015
  class ResponseDecoder {
1016
+ constructor() {}
417
1017
  async read(response) {
418
1018
  if (response.status === 204)
419
1019
  return;
@@ -582,36 +1182,185 @@ var RESTErrorCode = {
582
1182
  CannotReplyWithoutReadHistory: 160002
583
1183
  };
584
1184
  // packages/rest/src/redis.ts
585
- var RESERVE_SCRIPT = `
586
- local raw = redis.call('GET', KEYS[1])
587
- if not raw then return {1, 0} end
588
- local ok, state = pcall(cjson.decode, raw)
589
- if not ok or type(state) ~= 'table' or type(state.resetAt) ~= 'number' then
590
- 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
1225
+ end
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}
1240
+ end
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
591
1281
  end
592
- local now = tonumber(ARGV[1])
593
- local wait = state.resetAt - now
594
- if wait <= 0 then return {1, 0} end
595
- if type(state.remaining) == 'number' and state.remaining > 0 then
596
- state.remaining = state.remaining - 1
597
- redis.call('SET', KEYS[1], cjson.encode(state), 'PX', math.ceil(wait))
598
- return {1, 0}
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
599
1287
  end
600
- return {0, math.ceil(wait)}
1288
+ save(KEYS[1], n, now, linger)
1289
+ return 1
601
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";
602
1328
 
603
1329
  class RedisRateLimitStore {
604
1330
  #client;
605
1331
  #prefix;
606
1332
  #onErrorCallback;
1333
+ #policy;
1334
+ #timeout;
1335
+ #telemetry;
1336
+ #atomic;
607
1337
  #local = new MemoryRateLimitStore;
608
1338
  #lastError;
1339
+ #lastFailureAt;
1340
+ #failures = 0;
1341
+ #mode;
609
1342
  constructor(options) {
610
1343
  this.#client = options.client;
611
- this.#prefix = options.prefix ?? "lunibee:rest:";
1344
+ this.#prefix = `${options.prefix ?? "lunibee:rest:"}${SCHEMA}:`;
612
1345
  this.#onErrorCallback = options.onError;
613
- if (typeof options.client.eval === "function")
614
- 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
+ }
615
1364
  }
616
1365
  get lastError() {
617
1366
  return this.#lastError;
@@ -619,107 +1368,204 @@ class RedisRateLimitStore {
619
1368
  isHealthy() {
620
1369
  return this.#lastError === undefined;
621
1370
  }
622
- #onError(operation, error) {
623
- this.#lastError = error;
624
- console.warn(`[lunibee/rest] Redis rate-limit store ${operation} failed; ` + `falling back to local limiting for this call.`, error);
625
- try {
626
- this.#onErrorCallback?.(operation, error);
627
- } catch {}
1371
+ get mode() {
1372
+ return this.#mode;
628
1373
  }
629
- #markHealthy() {
630
- this.#lastError = undefined;
1374
+ get supportsReservation() {
1375
+ return this.#atomic;
631
1376
  }
632
- 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.");
633
1391
  try {
634
- const hash = await this.#client.get(`${this.#prefix}route:${route}`);
635
- this.#markHealthy();
636
- return hash ?? undefined;
1392
+ await this.#withTimeout(this.#client.eval(VERIFY_SCRIPT, 0));
637
1393
  } catch (error) {
638
- this.#onError("getBucketHash", error);
639
- 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) });
640
1395
  }
641
1396
  }
642
- async setBucketHash(route, hash) {
643
- 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();
644
1416
  try {
645
- await this.#client.set(`${this.#prefix}route:${route}`, hash, "EX", 604800);
646
- 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;
647
1427
  } catch (error) {
648
- 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();
649
1447
  }
650
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
+ }
651
1466
  async getBucket(key) {
652
- try {
653
- const data = await this.#client.get(`${this.#prefix}bucket:${key}`);
654
- this.#markHealthy();
1467
+ return this.#run("getBucket", async () => {
1468
+ const data = await this.#client.get(this.#bucketKey(key));
655
1469
  if (!data)
656
1470
  return;
657
- return JSON.parse(data);
658
- } catch (error) {
659
- this.#onError("getBucket", error);
660
- return this.#local.getBucket(key);
661
- }
662
- }
663
- async updateBucket(key, state) {
664
- this.#local.updateBucket(key, state);
665
- const bucketKey = `${this.#prefix}bucket:${key}`;
666
- const ttl = Math.ceil((state.resetAt - Date.now()) / 1000);
667
- try {
668
- if (ttl <= 0) {
669
- await this.#client.del(bucketKey);
670
- this.#markHealthy();
1471
+ try {
1472
+ return JSON.parse(data);
1473
+ } catch {
671
1474
  return;
672
1475
  }
673
- await this.#client.set(bucketKey, JSON.stringify(state), "EX", ttl);
674
- this.#markHealthy();
675
- } catch (error) {
676
- this.#onError("updateBucket", error);
677
- }
1476
+ }, () => this.#local.getBucket(key), true);
678
1477
  }
679
- get supportsReservation() {
680
- 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);
681
1489
  }
682
1490
  reserve;
683
- async#reserve(key) {
684
- try {
685
- const result = await this.#client.eval(RESERVE_SCRIPT, 1, `${this.#prefix}bucket:${key}`, Date.now());
686
- this.#markHealthy();
687
- if (!Array.isArray(result))
688
- return { granted: true };
689
- const [granted, retryAfterMs] = result;
690
- return Number(granted) === 1 ? { granted: true } : { granted: false, retryAfterMs: Number(retryAfterMs) || 0 };
691
- } catch (error) {
692
- this.#onError("reserve", error);
693
- 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}`);
694
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);
695
1515
  }
696
- async getGlobalReset() {
697
- try {
698
- const resetAt = await this.#client.get(`${this.#prefix}global`);
699
- this.#markHealthy();
700
- return resetAt ? Number(resetAt) : 0;
701
- } catch (error) {
702
- this.#onError("getGlobalReset", error);
703
- return this.#local.getGlobalReset();
704
- }
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);
705
1523
  }
706
- async setGlobalReset(resetAt) {
707
- this.#local.setGlobalReset(resetAt);
708
- const globalKey = `${this.#prefix}global`;
709
- const ttl = Math.ceil((resetAt - Date.now()) / 1000);
710
- try {
711
- if (ttl <= 0) {
712
- await this.#client.del(globalKey);
713
- this.#markHealthy();
714
- return;
715
- }
716
- await this.#client.set(globalKey, String(resetAt), "EX", ttl);
717
- this.#markHealthy();
718
- } catch (error) {
719
- this.#onError("setGlobalReset", error);
720
- }
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);
721
1551
  }
722
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
+ }
723
1569
  // packages/rest/src/webhook.ts
724
1570
  class WebhookClient {
725
1571
  id;
@@ -740,29 +1586,49 @@ class WebhookClient {
740
1586
  }
741
1587
  this.#rest = new REST(restOptions);
742
1588
  }
743
- async send(options) {
1589
+ #prepare(options, base = {}) {
744
1590
  const payload = typeof options === "string" ? { content: options } : options;
745
- const { thread_id, files, ...body } = payload;
746
- 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);
747
1605
  if (thread_id)
748
1606
  params.set("thread_id", String(thread_id));
749
- const query = `?${params}`;
750
- const formattedEmbeds = body.embeds?.map((e) => ("toJSON" in e) ? e.toJSON() : e);
751
- 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" });
752
1617
  const path = `${Routes.webhook(this.id, this.token)}${query}`;
753
- const json = {
754
- ...body,
755
- embeds: formattedEmbeds,
756
- components: formattedComponents
757
- };
758
1618
  return files?.length ? this.#rest.postWithFiles(path, json, files) : this.#rest.post(path, json);
759
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
+ }
760
1624
  async editMessage(messageId, options) {
761
- const payload = typeof options === "string" ? { content: options } : options;
762
- 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);
763
1628
  }
764
- async deleteMessage(messageId) {
765
- 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}`);
766
1632
  }
767
1633
  }
768
1634
 
@@ -771,7 +1637,9 @@ var LIBRARY_VERSION = package_default.version;
771
1637
  var LIBRARY_URL = "https://github.com/Ekretos/lunibee";
772
1638
  var USER_AGENT = `DiscordBot (${LIBRARY_URL}, ${LIBRARY_VERSION})`;
773
1639
  function assertSafePath(path) {
774
- 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("?");
775
1643
  const pathname = end === -1 ? path : path.slice(0, end);
776
1644
  if (pathname.includes("\\"))
777
1645
  throw new TypeError("REST paths must not contain backslashes.");
@@ -782,7 +1650,11 @@ function assertSafePath(path) {
782
1650
  } catch {
783
1651
  throw new TypeError("REST path has malformed percent-encoding.");
784
1652
  }
785
- 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
+ }))
786
1658
  throw new TypeError("REST paths must not contain '.' or '..' segments.");
787
1659
  }
788
1660
  }
@@ -793,7 +1665,8 @@ var REQUEST_DATA_KEYS = new Set([
793
1665
  "headers",
794
1666
  "reason",
795
1667
  "auth",
796
- "signal"
1668
+ "signal",
1669
+ "maxQueueWait"
797
1670
  ]);
798
1671
  function isRequestData(value) {
799
1672
  if (value === null || typeof value !== "object")
@@ -829,17 +1702,49 @@ function createRetryPolicy(maxRetries = 2) {
829
1702
  };
830
1703
  }
831
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
+
832
1720
  class REST {
833
1721
  #retryPolicy;
834
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;
835
1734
  #limiter;
836
- #scheduler = new RequestScheduler;
1735
+ #scheduler;
837
1736
  #transport;
838
1737
  #decoder = new ResponseDecoder;
839
1738
  #hooks = {};
840
1739
  #concurrentBuckets;
841
1740
  constructor(options = {}) {
842
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
+ });
843
1748
  this.#retryPolicy = options.retryPolicy ?? createRetryPolicy(options.retries ?? 2);
844
1749
  this.#transport = options.transport ?? new HttpTransport({
845
1750
  baseURL: options.baseURL,
@@ -849,8 +1754,14 @@ class REST {
849
1754
  const store = options.store ?? new MemoryRateLimitStore;
850
1755
  this.#concurrentBuckets = (options.concurrentBuckets ?? true) && typeof store.reserve === "function";
851
1756
  this.#limiter = new RateLimiter(store, {
852
- 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
853
1762
  });
1763
+ this.#stopOnInvalidToken = options.stopOnInvalidToken ?? true;
1764
+ this.#maxQueueWait = checkWait(options.maxQueueWait);
854
1765
  if (options.hooks)
855
1766
  this.#hooks = options.hooks;
856
1767
  }
@@ -865,9 +1776,64 @@ class REST {
865
1776
  if (!token.trim())
866
1777
  throw new TypeError("A Discord bot token is required.");
867
1778
  this.#token = token;
1779
+ this.#fingerprint = tokenFingerprint(token);
1780
+ this.#rejected = undefined;
1781
+ }
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
+ };
868
1793
  }
869
- async request(method, path, body, options = {}) {
870
- if (!method.toUpperCase())
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())
871
1837
  throw new TypeError("REST method is required.");
872
1838
  if (!path.startsWith("/"))
873
1839
  throw new TypeError("REST paths must start with '/'.");
@@ -875,22 +1841,101 @@ class REST {
875
1841
  const route = createRouteKey(method, path);
876
1842
  const files = options.files;
877
1843
  if (files && files.length > 0 && !(body instanceof FormData))
878
- 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
+ }))));
879
1848
  const query = serializeQuery(options.query);
880
1849
  const requestPath = query ? `${path}${query}` : path;
881
- 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
+ }
882
1909
  }
883
- async#attempts(route, requestPath, body, options, initialBucketKey, release) {
1910
+ async#attempts(route, requestPath, body, options, context, initialBucketKey, release) {
884
1911
  const { method, path } = route;
1912
+ const { identity, waitSignal, state } = context;
1913
+ const label = route.route;
885
1914
  let bucketKey = initialBucketKey;
886
1915
  for (let attempt = 0;attempt <= this.#retryPolicy.maxRetries; attempt++) {
887
- await this.#limiter.acquire(bucketKey, options.signal, path);
888
- if (this.#concurrentBuckets) {
889
- const bucket = await this.#limiter.store.getBucket(bucketKey);
890
- if (bucket && bucket.resetAt > Date.now())
891
- 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);
892
1936
  }
893
1937
  this.#emit(this.#hooks.onRequest, { method, path, attempt });
1938
+ state.attempts = attempt + 1;
894
1939
  const requestStart = Date.now();
895
1940
  let response;
896
1941
  try {
@@ -902,13 +1947,44 @@ class REST {
902
1947
  signal: options.signal
903
1948
  });
904
1949
  } catch (error) {
905
- 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
+ });
906
1958
  throw error;
907
- if (options.signal?.aborted)
1959
+ }
1960
+ if (options.signal?.aborted) {
1961
+ emitTelemetry(this.#telemetry, {
1962
+ type: "cancelled",
1963
+ stage: "transport"
1964
+ });
908
1965
  throw abortError(path, options.signal.reason);
909
- 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
+ });
910
1972
  if (!failure.timedOut && attempt < this.#retryPolicy.maxRetries && this.#retryPolicy.shouldRetry(method, 0)) {
911
- 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);
912
1988
  continue;
913
1989
  }
914
1990
  throw new RESTError(failure.message, 0, undefined, undefined, {
@@ -918,30 +1994,60 @@ class REST {
918
1994
  kind: failure.timedOut ? "timeout" : "network"
919
1995
  });
920
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;
921
2005
  this.#emit(this.#hooks.onResponse, {
922
2006
  method,
923
2007
  path,
924
2008
  status: response.status,
925
- 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
926
2017
  });
927
- bucketKey = await this.#limiter.applyResponse(response, bucketKey, route.route, route.major);
928
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
+ });
929
2026
  if (response.ok)
930
2027
  return response.status === 204 ? undefined : payload;
931
- const data = this.#decoder.errorData(payload);
932
- const retryAfter = response.status === 429 ? this.#decoder.retryAfter(response, data) : undefined;
933
- if (data.global && retryAfter !== undefined)
934
- await this.#limiter.noteGlobalReset(Date.now() + retryAfter * 1000);
935
- 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
+ });
936
2039
  this.#emit(this.#hooks.onRateLimit, {
937
2040
  method,
938
2041
  path,
939
- retryAfterMs: (retryAfter ?? 1) * 1000,
940
- global: data.global ?? false,
941
- 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)
942
2045
  });
2046
+ }
943
2047
  if (attempt < this.#retryPolicy.maxRetries && this.#retryPolicy.shouldRetry(method, response.status)) {
944
- 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 });
945
2051
  this.#emit(this.#hooks.onRetry, {
946
2052
  method,
947
2053
  path,
@@ -949,13 +2055,30 @@ class REST {
949
2055
  status: response.status,
950
2056
  delayMs: delay
951
2057
  });
952
- 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);
953
2065
  continue;
954
2066
  }
955
2067
  throw new RESTError(data.message ?? response.statusText ?? `Discord REST request failed with status ${response.status}`, response.status, data.code, payload, { method, path });
956
2068
  }
957
2069
  throw new RESTError("Discord REST request exhausted its retry attempts", 0, undefined, undefined, { method, path });
958
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
+ }
959
2082
  #headers(body, options) {
960
2083
  const isFormData = typeof FormData !== "undefined" && body instanceof FormData;
961
2084
  const headers = { ...options.headers };
@@ -1007,15 +2130,15 @@ class REST {
1007
2130
  const blob = file.data instanceof Blob ? file.data : new Blob([file.data], {
1008
2131
  type: file.contentType ?? "application/octet-stream"
1009
2132
  });
1010
- form.append(`files[${i}]`, blob, file.name);
2133
+ form.append(`files[${i}]`, blob, file.name.replace(/[\r\n"\\]/g, "_"));
1011
2134
  }
1012
2135
  return form;
1013
2136
  }
1014
2137
  postWithFiles(path, payload, files, options) {
1015
- return this.request("POST", path, this.#fileForm(payload, files), options);
2138
+ return this.request("POST", path, payload, { ...options, files });
1016
2139
  }
1017
2140
  patchWithFiles(path, payload, files, options) {
1018
- return this.request("PATCH", path, this.#fileForm(payload, files), options);
2141
+ return this.request("PATCH", path, payload, { ...options, files });
1019
2142
  }
1020
2143
  #emit(hook, ctx) {
1021
2144
  if (!hook)
@@ -1027,12 +2150,14 @@ class REST {
1027
2150
  }
1028
2151
  }
1029
2152
  export {
2153
+ DEFAULT_MAX_BACKOFF_MS,
1030
2154
  RESTError as DiscordAPIError,
1031
2155
  HttpTransport,
1032
2156
  MemoryRateLimitStore,
1033
2157
  REST,
1034
2158
  RESTError,
1035
2159
  RESTErrorCode,
2160
+ RESTMetrics,
1036
2161
  RateLimiter,
1037
2162
  RedisRateLimitStore,
1038
2163
  RequestScheduler,
@@ -1043,11 +2168,16 @@ export {
1043
2168
  abortError,
1044
2169
  createRetryPolicy,
1045
2170
  createRouteKey,
2171
+ fromBunRedis,
2172
+ headerNumber,
1046
2173
  majorParameter,
1047
2174
  normalizeRoutePath,
2175
+ parseRateLimitHeaders,
1048
2176
  redactPath,
2177
+ requestIdentity,
1049
2178
  scopeBucket,
1050
- serializeQuery
2179
+ serializeQuery,
2180
+ tokenFingerprint
1051
2181
  };
1052
2182
 
1053
- //# debugId=67C38AAB1AEA94A764756E2164756E21
2183
+ //# debugId=8464D7D05D07B1F864756E2164756E21