lunibee 0.3.1 → 0.3.3

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 (81) 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 +6242 -4733
  15. package/dist/core/index.js.map +47 -40
  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 +8688 -6136
  24. package/dist/index.js.map +67 -59
  25. package/dist/managers/application.d.ts +10 -6
  26. package/dist/managers/base.d.ts +11 -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 +383 -109
  30. package/dist/managers/index.js.map +20 -23
  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 +1355 -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 +29 -1
  49. package/dist/sharding/health.d.ts +1 -1
  50. package/dist/sharding/host.d.ts +13 -2
  51. package/dist/sharding/identify.d.ts +2 -0
  52. package/dist/sharding/index.d.ts +8 -0
  53. package/dist/sharding/index.js +579 -65
  54. package/dist/sharding/index.js.map +17 -15
  55. package/dist/sharding/leader.d.ts +34 -0
  56. package/dist/sharding/remote.d.ts +36 -2
  57. package/dist/sharding/supervisor.d.ts +1 -1
  58. package/dist/sharding/thread.d.ts +30 -0
  59. package/dist/sharding/transport.d.ts +6 -5
  60. package/dist/sharding/worker.d.ts +11 -1
  61. package/dist/structures/base.d.ts +16 -5
  62. package/dist/structures/index.d.ts +1 -1
  63. package/dist/structures/index.js +57 -91
  64. package/dist/structures/index.js.map +10 -18
  65. package/dist/structures/options.d.ts +3 -2
  66. package/dist/structures/permissions.d.ts +409 -0
  67. package/dist/structures/resources.d.ts +4 -1
  68. package/dist/types/gateway.d.ts +4 -0
  69. package/dist/types/index.d.ts +14 -4
  70. package/dist/types/index.js.map +3 -3
  71. package/dist/utils/args.d.ts +3 -1
  72. package/dist/utils/index.d.ts +6 -1
  73. package/dist/utils/index.js +17 -10
  74. package/dist/utils/index.js.map +4 -4
  75. package/dist/voice/index.d.ts +7 -1
  76. package/dist/voice/index.js +33 -10
  77. package/dist/voice/index.js.map +3 -3
  78. package/dist/ws/index.d.ts +2 -0
  79. package/dist/ws/index.js +32 -4
  80. package/dist/ws/index.js.map +5 -5
  81. package/package.json +21 -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.1",
5
+ version: "0.3.3",
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,186 @@ 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
+ #warnedSlots = false;
1340
+ #lastFailureAt;
1341
+ #failures = 0;
1342
+ #mode;
609
1343
  constructor(options) {
610
1344
  this.#client = options.client;
611
- this.#prefix = options.prefix ?? "lunibee:rest:";
1345
+ this.#prefix = `${options.prefix ?? "lunibee:rest:"}${SCHEMA}:`;
612
1346
  this.#onErrorCallback = options.onError;
613
- if (typeof options.client.eval === "function")
614
- this.reserve = (key) => this.#reserve(key);
1347
+ this.#policy = options.failurePolicy ?? "degraded-local";
1348
+ if (this.#policy !== "degraded-local" && this.#policy !== "fail-closed")
1349
+ throw new TypeError('failurePolicy must be "degraded-local" or "fail-closed".');
1350
+ this.#timeout = options.commandTimeout ?? 2000;
1351
+ if (!(this.#timeout > 0))
1352
+ throw new RangeError("commandTimeout must be a positive number.");
1353
+ this.#telemetry = options.telemetry;
1354
+ this.#atomic = typeof options.client.eval === "function";
1355
+ if (options.requireAtomic && !this.#atomic)
1356
+ throw new TypeError("requireAtomic: the Redis client has no eval method, so reservations cannot be atomic.");
1357
+ this.#mode = this.#atomic ? "distributed" : "observational";
1358
+ if (this.#atomic) {
1359
+ this.reserve = (key, opts) => this.#reserve(key, opts);
1360
+ this.reconcile = (key, update) => this.#reconcile(key, update);
1361
+ this.extendGlobalReset = (scope, waitMs) => this.#extendGlobal(scope, waitMs);
1362
+ this.release = (key, grant, refund) => this.#release(key, grant, refund);
1363
+ this.reservesGlobal = true;
1364
+ }
615
1365
  }
616
1366
  get lastError() {
617
1367
  return this.#lastError;
@@ -619,107 +1369,208 @@ class RedisRateLimitStore {
619
1369
  isHealthy() {
620
1370
  return this.#lastError === undefined;
621
1371
  }
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 {}
1372
+ get mode() {
1373
+ return this.#mode;
628
1374
  }
629
- #markHealthy() {
630
- this.#lastError = undefined;
1375
+ get supportsReservation() {
1376
+ return this.#atomic;
631
1377
  }
632
- async getBucketHash(route) {
1378
+ diagnostics() {
1379
+ const error = this.#lastError;
1380
+ return {
1381
+ mode: this.#mode,
1382
+ failurePolicy: this.#policy,
1383
+ atomic: this.#atomic,
1384
+ failures: this.#failures,
1385
+ lastFailureAt: this.#lastFailureAt,
1386
+ lastError: error === undefined ? undefined : String(sanitizeCause(error)?.message ?? error)
1387
+ };
1388
+ }
1389
+ async verify() {
1390
+ if (!this.#atomic)
1391
+ throw new TypeError("The Redis client has no eval method: reservations cannot be atomic.");
633
1392
  try {
634
- const hash = await this.#client.get(`${this.#prefix}route:${route}`);
635
- this.#markHealthy();
636
- return hash ?? undefined;
1393
+ await this.#withTimeout(this.#client.eval(VERIFY_SCRIPT, 0));
637
1394
  } catch (error) {
638
- this.#onError("getBucketHash", error);
639
- return this.#local.getBucketHash(route);
1395
+ throw new RESTError("The Redis rate-limit store cannot run its scripts.", 0, undefined, undefined, { kind: "coordination", cause: sanitizeCause(error) });
640
1396
  }
641
1397
  }
642
- async setBucketHash(route, hash) {
643
- this.#local.setBucketHash(route, hash);
1398
+ #setMode(mode) {
1399
+ if (mode === this.#mode)
1400
+ return;
1401
+ const previous = this.#mode;
1402
+ this.#mode = mode;
1403
+ emitTelemetry(this.#telemetry, { type: "mode", mode, previous });
1404
+ 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.");
1405
+ }
1406
+ #withTimeout(promise) {
1407
+ let timer;
1408
+ return Promise.race([
1409
+ promise,
1410
+ new Promise((_, reject) => {
1411
+ timer = setTimeout(() => reject(new Error(`Redis command timed out after ${this.#timeout}ms`)), this.#timeout);
1412
+ })
1413
+ ]).finally(() => clearTimeout(timer));
1414
+ }
1415
+ async#run(operation, command, fallback, enforcing) {
1416
+ const started = performance.now();
644
1417
  try {
645
- await this.#client.set(`${this.#prefix}route:${route}`, hash, "EX", 604800);
646
- this.#markHealthy();
1418
+ const value = await this.#withTimeout(command());
1419
+ emitTelemetry(this.#telemetry, {
1420
+ type: "store",
1421
+ operation,
1422
+ latencyMs: performance.now() - started,
1423
+ ok: true
1424
+ });
1425
+ this.#lastError = undefined;
1426
+ this.#setMode(this.#atomic ? "distributed" : "observational");
1427
+ return value;
647
1428
  } catch (error) {
648
- this.#onError("setBucketHash", error);
1429
+ emitTelemetry(this.#telemetry, {
1430
+ type: "store",
1431
+ operation,
1432
+ latencyMs: performance.now() - started,
1433
+ ok: false
1434
+ });
1435
+ this.#lastError = error;
1436
+ this.#lastFailureAt = Date.now();
1437
+ this.#failures++;
1438
+ if (!this.#warnedSlots && /CROSSSLOT/.test(String(error))) {
1439
+ this.#warnedSlots = true;
1440
+ console.warn('[lunibee/rest] Redis Cluster refused a script whose keys hash to different slots (CROSSSLOT): use a hash-tagged prefix such as "{mybot}:rest:".');
1441
+ }
1442
+ try {
1443
+ this.#onErrorCallback?.(operation, error);
1444
+ } catch {}
1445
+ if (this.#policy === "fail-closed") {
1446
+ this.#setMode("unavailable");
1447
+ if (enforcing)
1448
+ 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) });
1449
+ } else
1450
+ this.#setMode("degraded-local");
1451
+ return fallback();
649
1452
  }
650
1453
  }
1454
+ #bucketKey(key) {
1455
+ return `${this.#prefix}bucket:${key}`;
1456
+ }
1457
+ #globalKey(scope) {
1458
+ return `${this.#prefix}global:${scope}`;
1459
+ }
1460
+ async getBucketHash(route) {
1461
+ return this.#run("getBucketHash", async () => await this.#client.get(`${this.#prefix}route:${route}`) ?? undefined, () => this.#local.getBucketHash(route), false);
1462
+ }
1463
+ async setBucketHash(route, hash) {
1464
+ this.#local.setBucketHash(route, hash);
1465
+ await this.#run("setBucketHash", async () => {
1466
+ await this.#client.set(`${this.#prefix}route:${route}`, hash, "EX", 604800);
1467
+ }, () => {
1468
+ return;
1469
+ }, false);
1470
+ }
651
1471
  async getBucket(key) {
652
- try {
653
- const data = await this.#client.get(`${this.#prefix}bucket:${key}`);
654
- this.#markHealthy();
1472
+ return this.#run("getBucket", async () => {
1473
+ const data = await this.#client.get(this.#bucketKey(key));
655
1474
  if (!data)
656
1475
  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();
1476
+ try {
1477
+ return JSON.parse(data);
1478
+ } catch {
671
1479
  return;
672
1480
  }
673
- await this.#client.set(bucketKey, JSON.stringify(state), "EX", ttl);
674
- this.#markHealthy();
675
- } catch (error) {
676
- this.#onError("updateBucket", error);
677
- }
1481
+ }, () => this.#local.getBucket(key), true);
678
1482
  }
679
- get supportsReservation() {
680
- return typeof this.#client.eval === "function";
1483
+ async updateBucket(key, state) {
1484
+ this.#local.updateBucket(key, state);
1485
+ const ttl = Math.ceil(state.resetAt - Date.now());
1486
+ await this.#run("updateBucket", async () => {
1487
+ if (ttl <= 0)
1488
+ await this.#client.del(this.#bucketKey(key));
1489
+ else
1490
+ await this.#client.set(this.#bucketKey(key), JSON.stringify(state), "PX", ttl);
1491
+ }, () => {
1492
+ return;
1493
+ }, false);
681
1494
  }
682
1495
  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);
1496
+ reconcile;
1497
+ extendGlobalReset;
1498
+ release;
1499
+ reservesGlobal;
1500
+ async#reserve(key, options = {}) {
1501
+ const keys = [this.#bucketKey(key)];
1502
+ if (options.global !== undefined) {
1503
+ keys.push(this.#globalKey(options.global));
1504
+ if (options.globalLimit)
1505
+ keys.push(`${this.#prefix}global-log:${options.global}`);
694
1506
  }
1507
+ return this.#run("reserve", async () => {
1508
+ 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);
1509
+ return parseReservation(result);
1510
+ }, () => this.#local.reserve(key, options), true);
1511
+ }
1512
+ async#reconcile(key, update) {
1513
+ this.#local.reconcile(key, update);
1514
+ const arg = (value) => value === undefined ? "" : String(value);
1515
+ await this.#run("reconcile", async () => {
1516
+ 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);
1517
+ }, () => {
1518
+ return;
1519
+ }, false);
695
1520
  }
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
- }
1521
+ async#extendGlobal(scope, waitMs) {
1522
+ this.#local.extendGlobalReset(scope, waitMs);
1523
+ await this.#run("extendGlobalReset", async () => {
1524
+ await this.#client.eval(EXTEND_GLOBAL_SCRIPT, 1, this.#globalKey(scope), Math.max(0, Math.ceil(waitMs)));
1525
+ }, () => {
1526
+ return;
1527
+ }, false);
705
1528
  }
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
- }
1529
+ async#release(key, grant, refund) {
1530
+ this.#local.release(key, grant, refund);
1531
+ if (grant.resetAt === undefined)
1532
+ return;
1533
+ await this.#run("release", async () => {
1534
+ await this.#client.eval(RELEASE_SCRIPT, 1, this.#bucketKey(key), grant.resetAt, refund ? "1" : "0");
1535
+ }, () => {
1536
+ return;
1537
+ }, false);
1538
+ }
1539
+ async getGlobalReset(scope = "") {
1540
+ return this.#run("getGlobalReset", async () => {
1541
+ const resetAt = await this.#client.get(this.#globalKey(scope));
1542
+ return resetAt ? Number(resetAt) || 0 : 0;
1543
+ }, () => this.#local.getGlobalReset(scope), true);
1544
+ }
1545
+ async setGlobalReset(resetAt, scope = "") {
1546
+ this.#local.setGlobalReset(resetAt, scope);
1547
+ const ttl = Math.ceil(resetAt - Date.now());
1548
+ await this.#run("setGlobalReset", async () => {
1549
+ if (ttl <= 0)
1550
+ await this.#client.del(this.#globalKey(scope));
1551
+ else
1552
+ await this.#client.set(this.#globalKey(scope), String(resetAt), "PX", ttl);
1553
+ }, () => {
1554
+ return;
1555
+ }, false);
721
1556
  }
722
1557
  }
1558
+ function parseReservation(result) {
1559
+ if (!Array.isArray(result) || result.length < 3)
1560
+ throw new Error("Unexpected reply from the reservation script.");
1561
+ const [granted, value, flag] = result;
1562
+ const n = Number(value);
1563
+ if (!Number.isFinite(n))
1564
+ throw new Error("Unexpected reply from the reservation script.");
1565
+ if (Number(granted) === 1)
1566
+ return { granted: true, known: Number(flag) === 1, resetAt: n };
1567
+ const reason = String(flag);
1568
+ return {
1569
+ granted: false,
1570
+ retryAfterMs: Math.max(0, n),
1571
+ reason: reason === "global" || reason === "probe" ? reason : "bucket"
1572
+ };
1573
+ }
723
1574
  // packages/rest/src/webhook.ts
724
1575
  class WebhookClient {
725
1576
  id;
@@ -740,29 +1591,49 @@ class WebhookClient {
740
1591
  }
741
1592
  this.#rest = new REST(restOptions);
742
1593
  }
743
- async send(options) {
1594
+ #prepare(options, base = {}) {
744
1595
  const payload = typeof options === "string" ? { content: options } : options;
745
- const { thread_id, files, ...body } = payload;
746
- const params = new URLSearchParams({ wait: "true" });
1596
+ const {
1597
+ thread_id: threadWire,
1598
+ threadId,
1599
+ avatarURL,
1600
+ allowedMentions,
1601
+ files,
1602
+ ...body
1603
+ } = payload;
1604
+ const thread_id = threadWire ?? threadId;
1605
+ if (avatarURL !== undefined && body.avatar_url === undefined)
1606
+ body.avatar_url = avatarURL;
1607
+ if (allowedMentions !== undefined && body.allowed_mentions === undefined)
1608
+ body.allowed_mentions = allowedMentions;
1609
+ const params = new URLSearchParams(base);
747
1610
  if (thread_id)
748
1611
  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);
1612
+ const json = { ...body };
1613
+ if (body.embeds)
1614
+ json.embeds = body.embeds.map((e) => ("toJSON" in e) ? e.toJSON() : e);
1615
+ if (body.components)
1616
+ json.components = body.components.map((c) => ("toJSON" in c) ? c.toJSON() : c);
1617
+ const text = params.toString();
1618
+ return { query: text ? `?${text}` : "", json, files };
1619
+ }
1620
+ async send(options) {
1621
+ const { query, json, files } = this.#prepare(options, { wait: "true" });
752
1622
  const path = `${Routes.webhook(this.id, this.token)}${query}`;
753
- const json = {
754
- ...body,
755
- embeds: formattedEmbeds,
756
- components: formattedComponents
757
- };
758
1623
  return files?.length ? this.#rest.postWithFiles(path, json, files) : this.#rest.post(path, json);
759
1624
  }
1625
+ async fetchMessage(messageId, options = {}) {
1626
+ const query = options.threadId ? `?${new URLSearchParams({ thread_id: options.threadId })}` : "";
1627
+ return this.#rest.get(`${Routes.webhookMessage(this.id, this.token, messageId)}${query}`);
1628
+ }
760
1629
  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);
1630
+ const { query, json, files } = this.#prepare(options);
1631
+ const path = `${Routes.webhookMessage(this.id, this.token, messageId)}${query}`;
1632
+ return files?.length ? this.#rest.patchWithFiles(path, json, files) : this.#rest.patch(path, json);
763
1633
  }
764
- async deleteMessage(messageId) {
765
- await this.#rest.delete(Routes.webhookMessage(this.id, this.token, messageId));
1634
+ async deleteMessage(messageId, options = {}) {
1635
+ const query = options.threadId ? `?${new URLSearchParams({ thread_id: options.threadId })}` : "";
1636
+ await this.#rest.delete(`${Routes.webhookMessage(this.id, this.token, messageId)}${query}`);
766
1637
  }
767
1638
  }
768
1639
 
@@ -771,7 +1642,9 @@ var LIBRARY_VERSION = package_default.version;
771
1642
  var LIBRARY_URL = "https://github.com/Ekretos/lunibee";
772
1643
  var USER_AGENT = `DiscordBot (${LIBRARY_URL}, ${LIBRARY_VERSION})`;
773
1644
  function assertSafePath(path) {
774
- const end = path.search(/[?#]/);
1645
+ if (/[\u0000-\u0020\u007f#]/.test(path))
1646
+ throw new TypeError("REST paths must not contain control characters, spaces or '#'.");
1647
+ const end = path.indexOf("?");
775
1648
  const pathname = end === -1 ? path : path.slice(0, end);
776
1649
  if (pathname.includes("\\"))
777
1650
  throw new TypeError("REST paths must not contain backslashes.");
@@ -782,7 +1655,11 @@ function assertSafePath(path) {
782
1655
  } catch {
783
1656
  throw new TypeError("REST path has malformed percent-encoding.");
784
1657
  }
785
- if (decoded === "." || decoded === "..")
1658
+ const bare = decoded.replace(/[\u0000-\u0020\u007f]/g, "");
1659
+ if (bare === "." || bare === ".." || /[\\/]/.test(decoded) && decoded.split(/[\\/]/).some((s) => {
1660
+ const part = s.replace(/[\u0000-\u0020\u007f]/g, "");
1661
+ return part === "." || part === "..";
1662
+ }))
786
1663
  throw new TypeError("REST paths must not contain '.' or '..' segments.");
787
1664
  }
788
1665
  }
@@ -793,7 +1670,8 @@ var REQUEST_DATA_KEYS = new Set([
793
1670
  "headers",
794
1671
  "reason",
795
1672
  "auth",
796
- "signal"
1673
+ "signal",
1674
+ "maxQueueWait"
797
1675
  ]);
798
1676
  function isRequestData(value) {
799
1677
  if (value === null || typeof value !== "object")
@@ -829,17 +1707,49 @@ function createRetryPolicy(maxRetries = 2) {
829
1707
  };
830
1708
  }
831
1709
 
1710
+ class QueueWaitExceeded extends Error {
1711
+ ms;
1712
+ constructor(ms) {
1713
+ super("maxQueueWait exceeded");
1714
+ this.ms = ms;
1715
+ }
1716
+ }
1717
+ function checkWait(ms) {
1718
+ if (ms === undefined)
1719
+ return;
1720
+ if (!(ms >= 0) || !Number.isFinite(ms))
1721
+ throw new RangeError("maxQueueWait must be a non-negative number.");
1722
+ return ms;
1723
+ }
1724
+
832
1725
  class REST {
833
1726
  #retryPolicy;
834
1727
  #token;
1728
+ #fingerprint;
1729
+ #telemetry;
1730
+ #closing = new AbortController;
1731
+ #inFlight = 0;
1732
+ #idle;
1733
+ #rejected;
1734
+ #stopOnInvalidToken;
1735
+ #maxQueueWait;
1736
+ #invalid = new Array(10).fill(0);
1737
+ #invalidMinute = 0;
1738
+ #drained;
835
1739
  #limiter;
836
- #scheduler = new RequestScheduler;
1740
+ #scheduler;
837
1741
  #transport;
838
1742
  #decoder = new ResponseDecoder;
839
1743
  #hooks = {};
840
1744
  #concurrentBuckets;
841
1745
  constructor(options = {}) {
842
1746
  this.#token = options.token;
1747
+ this.#fingerprint = options.token ? tokenFingerprint(options.token) : undefined;
1748
+ this.#telemetry = options.telemetry;
1749
+ this.#scheduler = new RequestScheduler({
1750
+ maxQueued: options.maxQueued,
1751
+ maxQueuedPerBucket: options.maxQueuedPerBucket
1752
+ });
843
1753
  this.#retryPolicy = options.retryPolicy ?? createRetryPolicy(options.retries ?? 2);
844
1754
  this.#transport = options.transport ?? new HttpTransport({
845
1755
  baseURL: options.baseURL,
@@ -849,8 +1759,14 @@ class REST {
849
1759
  const store = options.store ?? new MemoryRateLimitStore;
850
1760
  this.#concurrentBuckets = (options.concurrentBuckets ?? true) && typeof store.reserve === "function";
851
1761
  this.#limiter = new RateLimiter(store, {
852
- concurrent: this.#concurrentBuckets
1762
+ concurrent: this.#concurrentBuckets,
1763
+ maxBackoff: options.maxBackoff,
1764
+ probeMs: this.#transport.timeout > 0 ? this.#transport.timeout + 1000 : undefined,
1765
+ telemetry: options.telemetry,
1766
+ globalLimit: options.globalLimit === false ? undefined : options.globalLimit ?? 50
853
1767
  });
1768
+ this.#stopOnInvalidToken = options.stopOnInvalidToken ?? true;
1769
+ this.#maxQueueWait = checkWait(options.maxQueueWait);
854
1770
  if (options.hooks)
855
1771
  this.#hooks = options.hooks;
856
1772
  }
@@ -865,9 +1781,64 @@ class REST {
865
1781
  if (!token.trim())
866
1782
  throw new TypeError("A Discord bot token is required.");
867
1783
  this.#token = token;
1784
+ this.#fingerprint = tokenFingerprint(token);
1785
+ this.#rejected = undefined;
868
1786
  }
869
- async request(method, path, body, options = {}) {
870
- if (!method.toUpperCase())
1787
+ diagnostics() {
1788
+ const store = this.#limiter.store;
1789
+ return {
1790
+ mode: store.mode ?? (store instanceof MemoryRateLimitStore ? "local" : "distributed"),
1791
+ queued: this.#scheduler.queued,
1792
+ inFlight: this.#inFlight,
1793
+ buckets: this.#scheduler.size,
1794
+ closed: this.#closing.signal.aborted,
1795
+ invalidRequests: this.#invalidCount(0),
1796
+ tokenRejected: this.#rejected !== undefined && this.#rejected === this.#fingerprint
1797
+ };
1798
+ }
1799
+ async close() {
1800
+ if (!this.#closing.signal.aborted)
1801
+ this.#closing.abort(new Error("The REST client was closed."));
1802
+ if (this.#inFlight === 0)
1803
+ return;
1804
+ this.#drained ??= new Promise((resolve) => {
1805
+ this.#idle = resolve;
1806
+ });
1807
+ await this.#drained;
1808
+ }
1809
+ #invalidCount(add) {
1810
+ const minute = Math.floor(Date.now() / 60000);
1811
+ const elapsed = Math.min(10, minute - this.#invalidMinute);
1812
+ for (let i = 1;i <= elapsed; i++)
1813
+ this.#invalid[(this.#invalidMinute + i) % 10] = 0;
1814
+ if (elapsed > 0)
1815
+ this.#invalidMinute = minute;
1816
+ this.#invalid[minute % 10] += add;
1817
+ return this.#invalid.reduce((a, b) => a + b, 0);
1818
+ }
1819
+ #queueChanged() {
1820
+ emitTelemetry(this.#telemetry, {
1821
+ type: "queue",
1822
+ queued: this.#scheduler.queued,
1823
+ inFlight: this.#inFlight
1824
+ });
1825
+ }
1826
+ async request(methodOrOptions, pathArg, bodyArg, optionsArg = {}) {
1827
+ let method;
1828
+ let path;
1829
+ let body = bodyArg;
1830
+ let options = optionsArg;
1831
+ if (typeof methodOrOptions === "object" && methodOrOptions !== null) {
1832
+ const { method: m, path: p, fullRoute, ...rest } = methodOrOptions;
1833
+ method = m;
1834
+ path = p ?? fullRoute ?? "";
1835
+ body = rest.body;
1836
+ options = rest;
1837
+ } else {
1838
+ method = methodOrOptions;
1839
+ path = pathArg;
1840
+ }
1841
+ if (typeof method !== "string" || !method.toUpperCase())
871
1842
  throw new TypeError("REST method is required.");
872
1843
  if (!path.startsWith("/"))
873
1844
  throw new TypeError("REST paths must start with '/'.");
@@ -875,22 +1846,101 @@ class REST {
875
1846
  const route = createRouteKey(method, path);
876
1847
  const files = options.files;
877
1848
  if (files && files.length > 0 && !(body instanceof FormData))
878
- body = this.#fileForm(body, files);
1849
+ body = this.#fileForm(body, await Promise.all(files.map(async (file) => ({
1850
+ ...file,
1851
+ data: await file.data
1852
+ }))));
879
1853
  const query = serializeQuery(options.query);
880
1854
  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));
1855
+ if (this.#closing.signal.aborted)
1856
+ throw abortError(path, this.#closing.signal.reason);
1857
+ const identity = requestIdentity(path, options.auth !== false && this.#token ? this.#fingerprint : undefined);
1858
+ if (identity.bucket === this.#fingerprint && this.#rejected === this.#fingerprint)
1859
+ 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" });
1860
+ const queueWait = checkWait(options.maxQueueWait) ?? this.#maxQueueWait;
1861
+ let deadline;
1862
+ const sources = [this.#closing.signal];
1863
+ if (options.signal)
1864
+ sources.push(options.signal);
1865
+ if (queueWait !== undefined) {
1866
+ const expired = new AbortController;
1867
+ deadline = setTimeout(() => expired.abort(new QueueWaitExceeded(queueWait)), queueWait);
1868
+ sources.push(expired.signal);
1869
+ }
1870
+ const waitSignal = sources.length === 1 ? sources[0] : AbortSignal.any(sources);
1871
+ const state = { attempts: 0, status: 0 };
1872
+ const started = performance.now();
1873
+ this.#inFlight++;
1874
+ this.#queueChanged();
1875
+ try {
1876
+ return await this.#scheduler.runResolved(() => this.#limiter.resolveBucketKey(route.route, route.major, identity.bucket), waitSignal, path, (bucketKey, release) => {
1877
+ this.#queueChanged();
1878
+ return this.#attempts(route, requestPath, body, options, { identity, waitSignal, state }, bucketKey, release);
1879
+ });
1880
+ } catch (caught) {
1881
+ let error = caught;
1882
+ if (error instanceof RESTError && error.kind === "aborted" && error.cause instanceof QueueWaitExceeded)
1883
+ 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" });
1884
+ if (error instanceof RESTError) {
1885
+ if (error.kind === "aborted" && state.attempts === 0)
1886
+ emitTelemetry(this.#telemetry, {
1887
+ type: "cancelled",
1888
+ stage: "queued"
1889
+ });
1890
+ else if (error.kind === "overloaded")
1891
+ emitTelemetry(this.#telemetry, { type: "overloaded" });
1892
+ if (state.status === 0)
1893
+ state.status = error.status;
1894
+ }
1895
+ throw error;
1896
+ } finally {
1897
+ clearTimeout(deadline);
1898
+ emitTelemetry(this.#telemetry, {
1899
+ type: "request",
1900
+ method: route.method,
1901
+ route: route.route,
1902
+ status: state.status,
1903
+ attempts: state.attempts,
1904
+ durationMs: performance.now() - started,
1905
+ ok: state.status >= 200 && state.status < 300
1906
+ });
1907
+ if (--this.#inFlight === 0 && this.#idle) {
1908
+ this.#idle();
1909
+ this.#idle = undefined;
1910
+ this.#drained = undefined;
1911
+ }
1912
+ this.#queueChanged();
1913
+ }
882
1914
  }
883
- async#attempts(route, requestPath, body, options, initialBucketKey, release) {
1915
+ async#attempts(route, requestPath, body, options, context, initialBucketKey, release) {
884
1916
  const { method, path } = route;
1917
+ const { identity, waitSignal, state } = context;
1918
+ const label = route.route;
885
1919
  let bucketKey = initialBucketKey;
886
1920
  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();
1921
+ let grant;
1922
+ try {
1923
+ grant = await this.#limiter.acquire(bucketKey, waitSignal, path, { global: identity.global });
1924
+ } catch (error) {
1925
+ if (error instanceof RESTError && error.kind === "aborted")
1926
+ emitTelemetry(this.#telemetry, {
1927
+ type: "cancelled",
1928
+ stage: "waiting"
1929
+ });
1930
+ throw error;
1931
+ }
1932
+ if (this.#concurrentBuckets && grant.known)
1933
+ release();
1934
+ if (waitSignal.aborted) {
1935
+ await this.#limiter.releaseGrant(bucketKey, grant, true);
1936
+ emitTelemetry(this.#telemetry, {
1937
+ type: "cancelled",
1938
+ stage: "waiting"
1939
+ });
1940
+ throw abortError(path, waitSignal.reason);
892
1941
  }
893
1942
  this.#emit(this.#hooks.onRequest, { method, path, attempt });
1943
+ state.attempts = attempt + 1;
894
1944
  const requestStart = Date.now();
895
1945
  let response;
896
1946
  try {
@@ -902,13 +1952,44 @@ class REST {
902
1952
  signal: options.signal
903
1953
  });
904
1954
  } catch (error) {
905
- if (error instanceof RESTError)
1955
+ const notSent = error instanceof RESTError && error.kind === "aborted";
1956
+ await this.#limiter.releaseGrant(bucketKey, grant, notSent);
1957
+ if (error instanceof RESTError) {
1958
+ if (error.kind === "aborted")
1959
+ emitTelemetry(this.#telemetry, {
1960
+ type: "cancelled",
1961
+ stage: "transport"
1962
+ });
906
1963
  throw error;
907
- if (options.signal?.aborted)
1964
+ }
1965
+ if (options.signal?.aborted) {
1966
+ emitTelemetry(this.#telemetry, {
1967
+ type: "cancelled",
1968
+ stage: "transport"
1969
+ });
908
1970
  throw abortError(path, options.signal.reason);
909
- const failure = error instanceof TransportError ? error : new TransportError("Discord REST request failed", false, error);
1971
+ }
1972
+ const failure = error instanceof TransportError ? error : new TransportError("Discord REST request failed", false, sanitizeCause(error));
1973
+ emitTelemetry(this.#telemetry, {
1974
+ type: "networkError",
1975
+ timedOut: failure.timedOut
1976
+ });
910
1977
  if (!failure.timedOut && attempt < this.#retryPolicy.maxRetries && this.#retryPolicy.shouldRetry(method, 0)) {
911
- await sleep(this.#retryPolicy.getDelay(attempt), options.signal, path);
1978
+ const delay = this.#retryPolicy.getDelay(attempt);
1979
+ this.#emit(this.#hooks.onRetry, {
1980
+ method,
1981
+ path,
1982
+ attempt,
1983
+ status: 0,
1984
+ delayMs: delay
1985
+ });
1986
+ emitTelemetry(this.#telemetry, {
1987
+ type: "retry",
1988
+ route: label,
1989
+ reason: "network",
1990
+ attempt
1991
+ });
1992
+ await this.#retrySleep(delay, waitSignal, path);
912
1993
  continue;
913
1994
  }
914
1995
  throw new RESTError(failure.message, 0, undefined, undefined, {
@@ -918,30 +1999,60 @@ class REST {
918
1999
  kind: failure.timedOut ? "timeout" : "network"
919
2000
  });
920
2001
  }
2002
+ state.status = response.status;
2003
+ if (response.status === 401 || response.status === 403 || response.status === 429 && response.headers.get("X-RateLimit-Scope") !== "shared")
2004
+ this.#invalidCount(1);
2005
+ if (response.status === 401 && this.#stopOnInvalidToken && identity.bucket === this.#fingerprint) {
2006
+ this.#rejected = this.#fingerprint;
2007
+ emitTelemetry(this.#telemetry, { type: "tokenRejected" });
2008
+ }
2009
+ const durationMs = Date.now() - requestStart;
921
2010
  this.#emit(this.#hooks.onResponse, {
922
2011
  method,
923
2012
  path,
924
2013
  status: response.status,
925
- durationMs: Date.now() - requestStart
2014
+ durationMs
2015
+ });
2016
+ emitTelemetry(this.#telemetry, {
2017
+ type: "response",
2018
+ method,
2019
+ route: label,
2020
+ status: response.status,
2021
+ durationMs
926
2022
  });
927
- bucketKey = await this.#limiter.applyResponse(response, bucketKey, route.route, route.major);
928
2023
  const payload = await this.#decoder.read(response);
2024
+ const data = response.ok ? {} : this.#decoder.errorData(payload);
2025
+ bucketKey = await this.#limiter.applyResponse(response, bucketKey, route.route, route.major, {
2026
+ identity: identity.bucket,
2027
+ global: identity.global,
2028
+ body: data,
2029
+ grant
2030
+ });
929
2031
  if (response.ok)
930
2032
  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)
2033
+ const limited = response.status === 429;
2034
+ const retryAfterMs = limited ? this.#limiter.retryAfterMs(response, data) : undefined;
2035
+ if (limited) {
2036
+ const global = data.global === true || response.headers.get("X-RateLimit-Global") === "true";
2037
+ const scope = response.headers.get("X-RateLimit-Scope")?.toLowerCase();
2038
+ emitTelemetry(this.#telemetry, {
2039
+ type: "rateLimited",
2040
+ route: label,
2041
+ scope: global ? "global" : scope === "user" || scope === "shared" ? scope : "unknown",
2042
+ retryAfterMs
2043
+ });
936
2044
  this.#emit(this.#hooks.onRateLimit, {
937
2045
  method,
938
2046
  path,
939
- retryAfterMs: (retryAfter ?? 1) * 1000,
940
- global: data.global ?? false,
941
- bucket: await this.#limiter.store.getBucketHash(route.route)
2047
+ retryAfterMs,
2048
+ global,
2049
+ bucket: response.headers.get("X-RateLimit-Bucket") ?? await this.#limiter.store.getBucketHash(route.route)
942
2050
  });
2051
+ }
943
2052
  if (attempt < this.#retryPolicy.maxRetries && this.#retryPolicy.shouldRetry(method, response.status)) {
944
- const delay = this.#retryPolicy.getDelay(attempt, retryAfter);
2053
+ const delay = this.#retryPolicy.getDelay(attempt, limited ? retryAfterMs / 1000 : undefined);
2054
+ if (delay > this.#limiter.maxBackoff)
2055
+ 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
2056
  this.#emit(this.#hooks.onRetry, {
946
2057
  method,
947
2058
  path,
@@ -949,13 +2060,30 @@ class REST {
949
2060
  status: response.status,
950
2061
  delayMs: delay
951
2062
  });
952
- await sleep(delay, options.signal, path);
2063
+ emitTelemetry(this.#telemetry, {
2064
+ type: "retry",
2065
+ route: label,
2066
+ reason: limited ? "rateLimited" : "server",
2067
+ attempt
2068
+ });
2069
+ await this.#retrySleep(limited ? delay - retryAfterMs : delay, waitSignal, path);
953
2070
  continue;
954
2071
  }
955
2072
  throw new RESTError(data.message ?? response.statusText ?? `Discord REST request failed with status ${response.status}`, response.status, data.code, payload, { method, path });
956
2073
  }
957
2074
  throw new RESTError("Discord REST request exhausted its retry attempts", 0, undefined, undefined, { method, path });
958
2075
  }
2076
+ async#retrySleep(delay, signal, path) {
2077
+ try {
2078
+ await sleep(delay, signal, path);
2079
+ } catch (error) {
2080
+ emitTelemetry(this.#telemetry, {
2081
+ type: "cancelled",
2082
+ stage: "retry"
2083
+ });
2084
+ throw error;
2085
+ }
2086
+ }
959
2087
  #headers(body, options) {
960
2088
  const isFormData = typeof FormData !== "undefined" && body instanceof FormData;
961
2089
  const headers = { ...options.headers };
@@ -1007,15 +2135,15 @@ class REST {
1007
2135
  const blob = file.data instanceof Blob ? file.data : new Blob([file.data], {
1008
2136
  type: file.contentType ?? "application/octet-stream"
1009
2137
  });
1010
- form.append(`files[${i}]`, blob, file.name);
2138
+ form.append(`files[${i}]`, blob, file.name.replace(/[\r\n"\\]/g, "_"));
1011
2139
  }
1012
2140
  return form;
1013
2141
  }
1014
2142
  postWithFiles(path, payload, files, options) {
1015
- return this.request("POST", path, this.#fileForm(payload, files), options);
2143
+ return this.request("POST", path, payload, { ...options, files });
1016
2144
  }
1017
2145
  patchWithFiles(path, payload, files, options) {
1018
- return this.request("PATCH", path, this.#fileForm(payload, files), options);
2146
+ return this.request("PATCH", path, payload, { ...options, files });
1019
2147
  }
1020
2148
  #emit(hook, ctx) {
1021
2149
  if (!hook)
@@ -1027,12 +2155,14 @@ class REST {
1027
2155
  }
1028
2156
  }
1029
2157
  export {
2158
+ DEFAULT_MAX_BACKOFF_MS,
1030
2159
  RESTError as DiscordAPIError,
1031
2160
  HttpTransport,
1032
2161
  MemoryRateLimitStore,
1033
2162
  REST,
1034
2163
  RESTError,
1035
2164
  RESTErrorCode,
2165
+ RESTMetrics,
1036
2166
  RateLimiter,
1037
2167
  RedisRateLimitStore,
1038
2168
  RequestScheduler,
@@ -1043,11 +2173,16 @@ export {
1043
2173
  abortError,
1044
2174
  createRetryPolicy,
1045
2175
  createRouteKey,
2176
+ fromBunRedis,
2177
+ headerNumber,
1046
2178
  majorParameter,
1047
2179
  normalizeRoutePath,
2180
+ parseRateLimitHeaders,
1048
2181
  redactPath,
2182
+ requestIdentity,
1049
2183
  scopeBucket,
1050
- serializeQuery
2184
+ serializeQuery,
2185
+ tokenFingerprint
1051
2186
  };
1052
2187
 
1053
- //# debugId=67C38AAB1AEA94A764756E2164756E21
2188
+ //# debugId=94C16174A190B15864756E2164756E21