@thediscordlist/discord.js 0.1.1 → 0.2.0

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.
package/README.md CHANGED
@@ -30,7 +30,7 @@ It starts three publishers:
30
30
  | Publisher | What it sends | Cadence |
31
31
  | --- | --- | --- |
32
32
  | Stats | `server_count`, `shard_count` | 5s after ready, then every 5 min, debounced on guild join/leave |
33
- | Heartbeat | per-shard status, latency and guild counts | every ~30s, plus immediately on any shard transition |
33
+ | Heartbeat | per-shard status, latency and guild counts | every ~30s, plus soon after any shard transition |
34
34
  | Commands | your registered slash commands | once, ~10s after ready |
35
35
 
36
36
  Your **stats token** is on the listing's manage page, under the API tab. It is
@@ -38,7 +38,28 @@ scoped to one listing, so it is the only credential this package needs.
38
38
 
39
39
  `autopost` is safe to call before or after `login()`. With a missing token it
40
40
  warns once and returns an inert object — a forgotten environment variable never
41
- takes down a deploy.
41
+ takes down a deploy. A token pasted with surrounding whitespace or a leading
42
+ `Bearer ` works as is.
43
+
44
+ ### Defaults
45
+
46
+ - **Stats:** checked every 300s, and 20s after the last guild join or leave.
47
+ Never two posts within 60s. An unchanged count is skipped until 30 minutes
48
+ have passed.
49
+ - **Heartbeat:** every 30s plus 0–5s of jitter, retuned from each response to a
50
+ third of the server's grace window and kept within 15–60s. A shard
51
+ transition beats out of band, at least 10s after the previous beat; it never
52
+ moves the regular schedule.
53
+ - **Commands:** once, 10s after ready.
54
+ - **A `403`** (the endpoint needs a higher plan) pauses that publisher for 30
55
+ minutes, then it tries again.
56
+ - **Requests** time out after 10s and get three attempts, backing off
57
+ 1s · 2ⁿ (capped at 30s, jittered to 50–100%) and never past the publisher's
58
+ next run. Only network errors, `408`, `425`, `429` and `5xx` are retried. A
59
+ `429` waits out `Retry-After` or `X-RateLimit-Reset` — 60s when neither is
60
+ sent, never more than an hour. Every wait runs on a monotonic clock.
61
+ - **Webhooks:** a signature more than 300s from now is rejected. The handlers
62
+ cap the body at 1 MiB and give `onEvent` 8s before answering `202`.
42
63
 
43
64
  ## Sharding
44
65
 
@@ -142,7 +163,8 @@ app.use(express.json()); // everything else, after the webhook route
142
163
  ```
143
164
 
144
165
  The handler reads the raw stream itself, so mount it **before** any body
145
- parser. If your framework forces one in front, it must hand over raw bytes:
166
+ parser. If your framework forces one in front, it must hand over raw bytes; a
167
+ `Buffer` in `req.body` is verified as is:
146
168
 
147
169
  ```ts
148
170
  app.post('/webhooks/tdl', express.raw({ type: 'application/json' }), handler);
@@ -153,6 +175,9 @@ and silently skips JSON, leaving `req.body` undefined. The handler detects an
153
175
  already-parsed body and reports a `BODY_CONSUMED` error rather than failing
154
176
  verification for reasons nobody can see.
155
177
 
178
+ Both handlers, and `verifyWebhook`, throw a `TypeError` for an empty secret
179
+ rather than checking signatures against an empty key.
180
+
156
181
  ### Hono, Next App Router, Workers, Bun, Deno
157
182
 
158
183
  ```ts
@@ -202,6 +227,7 @@ signature covers bytes, not meaning.
202
227
  ```ts
203
228
  type TdlWebhookEventName =
204
229
  | 'listing.bumped'
230
+ | 'listing.bump_ready' // the bump cooldown lifted; data.ready_at
205
231
  | 'listing.upvoted'
206
232
  | 'server.joined' // server listings only — a bot listing never receives it
207
233
  | 'bot.added'
@@ -227,9 +253,9 @@ heartbeat's internal stats forward.
227
253
  and **a 2xx to that probe re-enables it**.
228
254
  - **Register an exact `https://` URL with no redirect.** The sender treats 3xx
229
255
  as a failure, so an `http → https` or trailing-slash redirect burns all five
230
- retries and counts toward the disable streak.
256
+ attempts and counts toward the disable streak.
231
257
  - **Deduplicate on `ctx.delivery`.** It equals `X-TDL-Delivery` and is stable
232
- across all five retries of one delivery:
258
+ across all five attempts of one delivery:
233
259
 
234
260
  ```ts
235
261
  if (await redis.set(ctx.delivery, 1, 'EX', 86400, 'NX') === null) return;
@@ -249,11 +275,11 @@ heartbeat's internal stats forward.
249
275
  - **Heartbeats need Gold or higher.** On a `403` the heartbeat publisher
250
276
  suspends and reprobes every 30 minutes rather than disabling: the tier is
251
277
  derived per request, so a bot can gain it mid-run.
252
- - **Rate limits are shared.** The API counts stats, heartbeats and commands
253
- against one budget keyed by client IP. This package keeps one client-side
254
- budget to match, and drops stats before heartbeats when it is scarce — a
255
- stale number beats a false "down" on your status page. On shared hosting,
256
- other bots behind the same egress IP count against you.
278
+ - **Rate limits are shared.** The API counts stats and commands against one
279
+ budget per bot (60/min; heartbeats have their own 120/min). A `429` suspends
280
+ stats and commands together for `Retry-After` (or until `X-RateLimit-Reset`,
281
+ at most an hour); heartbeats bypass it — a stale number beats a false "down"
282
+ on your status page.
257
283
  - **`404` never means "bad token".** It means the id is unknown or belongs to a
258
284
  server listing. The distinction is deliberate, so probing cannot enumerate.
259
285
  - **Guild counts can drift upward permanently.** If Discord re-IDENTIFYs a
package/dist/index.cjs CHANGED
@@ -2,11 +2,16 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  let node_events = require("node:events");
3
3
  let discord_js = require("discord.js");
4
4
  //#region package.json
5
- var version$1 = "0.1.1";
5
+ var version$1 = "0.2.0";
6
+ /** However far out Retry-After or X-RateLimit-Reset points, never go quiet for longer. */
7
+ const MAX_RATE_LIMIT_WAIT_MS = 36e5;
8
+ const MAX_ATTEMPTS = 3;
6
9
  /**
7
- * Drops `undefined` keys recursively. An explicit `null` survives, and must:
8
- * the API's `prepareForValidation` only defaults keys that are *absent*, and
9
- * none of its rules are `nullable`, so a serialised `undefined` is a 422.
10
+ * Drops `undefined` keys recursively, so an unset value arrives absent rather
11
+ * than `null`. The API's `prepareForValidation` only defaults keys that are
12
+ * *absent*, and `cluster`, `cluster_count` and `shards` are not `nullable`, so
13
+ * a `null` there is a 422. An explicit `null` survives: where a rule is
14
+ * nullable it is a deliberate "unknown".
10
15
  */
11
16
  function stripUndefined(value) {
12
17
  if (Array.isArray(value)) return value.map((item) => stripUndefined(item));
@@ -15,6 +20,9 @@ function stripUndefined(value) {
15
20
  for (const [key, item] of Object.entries(value)) if (item !== void 0) out[key] = stripUndefined(item);
16
21
  return out;
17
22
  }
23
+ function isRetryable(outcome) {
24
+ return outcome.kind === "transient" || outcome.kind === "throttled";
25
+ }
18
26
  /** Full jitter in the upper half, capped at 30s. */
19
27
  function backoffMs(attempt, random = Math.random) {
20
28
  return Math.round(Math.min(1e3 * 2 ** attempt, 3e4) * (.5 + random() * .5));
@@ -41,10 +49,6 @@ function classify(status, body, retryAfterHeader) {
41
49
  kind: "config",
42
50
  status
43
51
  };
44
- if (status === 400 || status === 422) return {
45
- kind: "payload",
46
- status
47
- };
48
52
  if (status === 429) {
49
53
  const seconds = Number(retryAfterHeader);
50
54
  return {
@@ -53,22 +57,32 @@ function classify(status, body, retryAfterHeader) {
53
57
  retryAfterMs: Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : 6e4
54
58
  };
55
59
  }
60
+ if (status >= 400 && status < 500 && status !== 408 && status !== 425) return {
61
+ kind: "payload",
62
+ status
63
+ };
56
64
  return {
57
65
  kind: "transient",
58
66
  status,
59
67
  error: /* @__PURE__ */ new Error(`TheDiscordList responded ${status}.`)
60
68
  };
61
69
  }
70
+ function sleep(ms) {
71
+ return new Promise((resolve) => {
72
+ setTimeout(resolve, ms).unref?.();
73
+ });
74
+ }
62
75
  function createApiClient(options) {
76
+ const baseUrl = options.baseUrl.replace(/\/+$/, "");
63
77
  let suspendedUntil = 0;
64
- return { async post(path, body, priority) {
65
- if (priority !== "heartbeat" && Date.now() < suspendedUntil) return {
78
+ async function once(path, body, ignoreLimit) {
79
+ if (!ignoreLimit && performance.now() < suspendedUntil) return {
66
80
  kind: "dropped",
67
81
  status: null
68
82
  };
69
83
  let response;
70
84
  try {
71
- response = await fetch(`${options.baseUrl}${path}`, {
85
+ response = await fetch(`${baseUrl}${path}`, {
72
86
  method: "POST",
73
87
  headers: {
74
88
  authorization: `Bearer ${options.token}`,
@@ -104,8 +118,22 @@ function createApiClient(options) {
104
118
  const outcome = classify(response.status, parsed, response.headers.get("retry-after"));
105
119
  if (outcome.kind === "throttled") {
106
120
  const reset = Number(response.headers.get("x-ratelimit-reset"));
107
- const untilReset = Number.isFinite(reset) && reset > 0 ? reset * 1e3 : 0;
108
- suspendedUntil = Math.max(Date.now() + outcome.retryAfterMs, untilReset) + Math.round(Math.random() * 2e3);
121
+ const untilReset = Number.isFinite(reset) && reset > 0 ? reset * 1e3 - Date.now() : 0;
122
+ suspendedUntil = performance.now() + Math.min(Math.max(outcome.retryAfterMs, untilReset), MAX_RATE_LIMIT_WAIT_MS) + Math.round(Math.random() * 2e3);
123
+ }
124
+ return outcome;
125
+ }
126
+ return { async post(path, body, priority, nextTickInMs) {
127
+ let outcome = {
128
+ kind: "dropped",
129
+ status: null
130
+ };
131
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
132
+ outcome = await once(path, body, priority === "heartbeat" || outcome.kind === "throttled");
133
+ if (!isRetryable(outcome) || attempt === 2) break;
134
+ const delay = outcome.kind === "throttled" ? suspendedUntil - performance.now() : backoffMs(attempt);
135
+ if (nextTickInMs !== void 0 && delay >= nextTickInMs) break;
136
+ await sleep(delay);
109
137
  }
110
138
  return outcome;
111
139
  } };
@@ -245,16 +273,24 @@ async function measureServerCount(client, topology, getServerCount) {
245
273
  }
246
274
  return total;
247
275
  }
276
+ //#endregion
277
+ //#region src/publishers.ts
278
+ const STATS_INTERVAL_MS = 3e5;
279
+ const STATS_DEBOUNCE_MS = 2e4;
280
+ const STATS_MIN_GAP_MS = 6e4;
248
281
  const STATS_FIRST_POST_DELAY_MS = 5e3;
249
282
  const STATS_REMEASURE_MS = 6e4;
283
+ const HEARTBEAT_DEFAULT_INTERVAL_MS = 3e4;
250
284
  const HEARTBEAT_MIN_INTERVAL_MS = 15e3;
251
285
  const HEARTBEAT_MAX_INTERVAL_MS = 6e4;
252
286
  const HEARTBEAT_JITTER_MS = 5e3;
287
+ /** An out-of-band beat never follows the previous beat more closely than this. */
288
+ const HEARTBEAT_OUT_OF_BAND_GAP_MS = 1e4;
289
+ const BEAT_NOW_DELAY_MS = 1e3;
253
290
  /** Offset from the stats post: the two share one server-side rate budget. */
254
291
  const COMMANDS_DELAY_MS = 1e4;
255
292
  /** The Gold tier is derived per request from Stripe, so it can flip mid-run. */
256
293
  const PLAN_REPROBE_MS = 18e5;
257
- const MAX_ATTEMPTS = 3;
258
294
  function publisherError(message, type, status, retryable) {
259
295
  return Object.assign(new Error(message), {
260
296
  type,
@@ -262,11 +298,6 @@ function publisherError(message, type, status, retryable) {
262
298
  retryable
263
299
  });
264
300
  }
265
- function sleep(ms) {
266
- return new Promise((resolve) => {
267
- setTimeout(resolve, ms).unref?.();
268
- });
269
- }
270
301
  var Publisher = class {
271
302
  deps;
272
303
  timer;
@@ -314,27 +345,9 @@ var Publisher = class {
314
345
  this.reported.add(status);
315
346
  this.deps.emit.error(publisherError(message, this.kind, status, retryable));
316
347
  }
317
- /**
318
- * Sends one request, retrying only transient failures.
319
- *
320
- * `nextTickInMs` is the time until this publisher's next scheduled run. If a
321
- * backoff would outlast it, the attempt is dropped: the next tick carries
322
- * fresher data, and a failed request still burns rate-limit budget.
323
- */
348
+ /** `nextTickInMs` is the time until this publisher's next scheduled run; no retry outlasts it. */
324
349
  async send(path, body, priority, nextTickInMs) {
325
- let outcome = {
326
- kind: "dropped",
327
- status: null
328
- };
329
- for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
330
- outcome = await this.deps.api.post(path, body, priority);
331
- if (outcome.kind !== "transient") break;
332
- const delay = backoffMs(attempt);
333
- if (attempt === 2) break;
334
- if (nextTickInMs !== void 0 && delay >= nextTickInMs) break;
335
- await sleep(delay);
336
- }
337
- return this.react(outcome);
350
+ return this.react(await this.deps.api.post(path, body, priority, nextTickInMs));
338
351
  }
339
352
  react(outcome) {
340
353
  switch (outcome.kind) {
@@ -366,7 +379,7 @@ var Publisher = class {
366
379
  status: 401
367
380
  };
368
381
  case "plan":
369
- this.suspendedUntil = Date.now() + PLAN_REPROBE_MS;
382
+ this.suspendedUntil = performance.now() + PLAN_REPROBE_MS;
370
383
  this.reportOnce(403, `The ${this.kind} endpoint needs a higher plan. Reprobing in 30 minutes.`, false);
371
384
  return {
372
385
  ok: false,
@@ -403,7 +416,7 @@ var StatsPublisher = class extends Publisher {
403
416
  debounce;
404
417
  minGap;
405
418
  getServerCount;
406
- lastPostAt = 0;
419
+ lastPostAt = -Infinity;
407
420
  lastCount;
408
421
  lastShardCount;
409
422
  debounceCap = 0;
@@ -421,7 +434,7 @@ var StatsPublisher = class extends Publisher {
421
434
  /** Trailing debounce, hard-capped so a join storm cannot starve the post. */
422
435
  onGuildChange() {
423
436
  if (this.disabledReason !== void 0) return;
424
- const now = Date.now();
437
+ const now = performance.now();
425
438
  if (this.debounceCap === 0) this.debounceCap = now + this.minGap;
426
439
  this.schedule(Math.min(now + this.debounce, this.debounceCap) - now, () => this.tick());
427
440
  }
@@ -444,7 +457,7 @@ var StatsPublisher = class extends Publisher {
444
457
  status: null,
445
458
  skipped: "in-flight"
446
459
  };
447
- const now = Date.now();
460
+ const now = performance.now();
448
461
  if (!manual && now < this.suspendedUntil) {
449
462
  this.schedule(this.suspendedUntil - now, () => this.tick());
450
463
  return {
@@ -453,7 +466,7 @@ var StatsPublisher = class extends Publisher {
453
466
  skipped: "plan"
454
467
  };
455
468
  }
456
- if (this.lastPostAt !== 0 && now < this.lastPostAt + this.minGap) {
469
+ if (now < this.lastPostAt + this.minGap) {
457
470
  this.schedule(this.lastPostAt + this.minGap - now, () => this.tick());
458
471
  return {
459
472
  ok: false,
@@ -473,7 +486,7 @@ var StatsPublisher = class extends Publisher {
473
486
  };
474
487
  }
475
488
  const shardCount = this.deps.topology.shardCount;
476
- if (count === this.lastCount && shardCount === this.lastShardCount && Date.now() < this.lastPostAt + 18e5) {
489
+ if (count === this.lastCount && shardCount === this.lastShardCount && performance.now() < this.lastPostAt + 18e5) {
477
490
  this.schedule(this.interval, () => this.tick());
478
491
  return {
479
492
  ok: false,
@@ -481,7 +494,7 @@ var StatsPublisher = class extends Publisher {
481
494
  skipped: "unchanged"
482
495
  };
483
496
  }
484
- this.lastPostAt = Date.now();
497
+ this.lastPostAt = performance.now();
485
498
  const result = await this.send(`/bots/${this.deps.botId}/stats`, {
486
499
  server_count: count,
487
500
  shard_count: shardCount
@@ -500,13 +513,14 @@ var StatsPublisher = class extends Publisher {
500
513
  var HeartbeatPublisher = class extends Publisher {
501
514
  kind = "heartbeat";
502
515
  interval;
503
- startedAt = 0;
504
- beats = 0;
516
+ nextTarget = 0;
517
+ lastBeatAt = -Infinity;
518
+ beatNowPending = false;
505
519
  inFlight = false;
506
520
  nextBeatWithinSeconds;
507
521
  constructor(deps, options = {}) {
508
522
  super(deps);
509
- this.interval = options.interval ?? 3e4;
523
+ this.interval = clampInterval(options.interval ?? 3e4);
510
524
  }
511
525
  /** Starts on construction, not on ready: a booting bot must read "connecting", not dead. */
512
526
  start() {
@@ -514,14 +528,16 @@ var HeartbeatPublisher = class extends Publisher {
514
528
  this.disable(null, "cluster-count-unknown");
515
529
  return;
516
530
  }
517
- this.startedAt = Date.now();
518
- this.beats = 0;
531
+ this.nextTarget = performance.now();
519
532
  this.schedule(0, () => this.tick());
520
533
  }
521
- /** Out-of-band beat after a shard event. Repeated calls coalesce into one. */
534
+ /** Out-of-band beat after a shard event. Calls before it fires coalesce into one. */
522
535
  beatNow() {
523
- if (this.disabledReason !== void 0) return;
524
- this.schedule(1e3, () => this.tick());
536
+ const now = performance.now();
537
+ if (this.disabledReason !== void 0 || this.beatNowPending || now < this.suspendedUntil) return;
538
+ this.beatNowPending = true;
539
+ const delay = Math.max(BEAT_NOW_DELAY_MS, this.lastBeatAt + HEARTBEAT_OUT_OF_BAND_GAP_MS - now);
540
+ this.schedule(delay, () => this.tick());
525
541
  }
526
542
  async post() {
527
543
  this.rearm();
@@ -533,6 +549,7 @@ var HeartbeatPublisher = class extends Publisher {
533
549
  return this.beat(true);
534
550
  }
535
551
  async tick() {
552
+ this.beatNowPending = false;
536
553
  await this.beat(false);
537
554
  }
538
555
  async beat(manual) {
@@ -567,6 +584,7 @@ var HeartbeatPublisher = class extends Publisher {
567
584
  shards
568
585
  };
569
586
  this.inFlight = true;
587
+ this.lastBeatAt = performance.now();
570
588
  let result;
571
589
  try {
572
590
  result = await this.send(`/bots/${this.deps.botId}/heartbeat`, body, "heartbeat", this.interval);
@@ -587,8 +605,7 @@ var HeartbeatPublisher = class extends Publisher {
587
605
  */
588
606
  retune() {
589
607
  if (this.nextBeatWithinSeconds === void 0) return;
590
- const proposed = Math.round(this.nextBeatWithinSeconds / 3 * 1e3);
591
- this.interval = Math.min(Math.max(proposed, HEARTBEAT_MIN_INTERVAL_MS), HEARTBEAT_MAX_INTERVAL_MS);
608
+ this.interval = clampInterval(Math.round(this.nextBeatWithinSeconds / 3 * 1e3));
592
609
  }
593
610
  react(outcome) {
594
611
  if (outcome.kind === "ok") {
@@ -597,15 +614,22 @@ var HeartbeatPublisher = class extends Publisher {
597
614
  }
598
615
  return super.react(outcome);
599
616
  }
600
- /** Absolute-time scheduling, so a slow request cannot drift the cadence. */
617
+ /**
618
+ * Absolute targets, so a slow request cannot drift the cadence and an
619
+ * out-of-band beat does not push the next regular one back. Missed slots
620
+ * are skipped, so the end of a long suspension is one beat, not a burst.
621
+ */
601
622
  scheduleNext(minDelayMs = 0) {
602
- this.beats += 1;
623
+ const now = performance.now();
624
+ if (this.nextTarget <= now) this.nextTarget += (Math.floor((now - this.nextTarget) / this.interval) + 1) * this.interval;
603
625
  const jitter = Math.round(Math.random() * HEARTBEAT_JITTER_MS);
604
- const target = this.startedAt + this.beats * this.interval + jitter;
605
- const delay = Math.max(target - Date.now(), minDelayMs, 1e3, this.suspendedUntil - Date.now());
626
+ const delay = Math.max(this.nextTarget + jitter - now, minDelayMs, BEAT_NOW_DELAY_MS, this.suspendedUntil - now);
606
627
  this.schedule(delay, () => this.tick());
607
628
  }
608
629
  };
630
+ function clampInterval(ms) {
631
+ return Math.min(Math.max(ms, HEARTBEAT_MIN_INTERVAL_MS), HEARTBEAT_MAX_INTERVAL_MS);
632
+ }
609
633
  var CommandsPublisher = class extends Publisher {
610
634
  rest;
611
635
  options;
@@ -630,7 +654,7 @@ var CommandsPublisher = class extends Publisher {
630
654
  status: null,
631
655
  skipped: "disabled"
632
656
  };
633
- if (Date.now() < this.suspendedUntil) return {
657
+ if (performance.now() < this.suspendedUntil) return {
634
658
  ok: false,
635
659
  status: null,
636
660
  skipped: "plan"
@@ -680,6 +704,11 @@ var CommandsPublisher = class extends Publisher {
680
704
  const version = version$1;
681
705
  const DEFAULT_BASE_URL = "https://thediscordlist.com/api";
682
706
  const LOG_PREFIX = "[@thediscordlist/discord.js]";
707
+ const DISABLED = Object.freeze({
708
+ ok: false,
709
+ status: null,
710
+ skipped: "disabled"
711
+ });
683
712
  var Autoposter = class extends node_events.EventEmitter {
684
713
  stats;
685
714
  heartbeat;
@@ -769,25 +798,13 @@ var Autoposter = class extends node_events.EventEmitter {
769
798
  this.commands?.stop();
770
799
  }
771
800
  postStats() {
772
- return this.stats?.post() ?? Promise.resolve({
773
- ok: false,
774
- status: null,
775
- skipped: "disabled"
776
- });
801
+ return this.stats?.post() ?? Promise.resolve(DISABLED);
777
802
  }
778
803
  postHeartbeat() {
779
- return this.heartbeat?.post() ?? Promise.resolve({
780
- ok: false,
781
- status: null,
782
- skipped: "disabled"
783
- });
804
+ return this.heartbeat?.post() ?? Promise.resolve(DISABLED);
784
805
  }
785
806
  postCommands() {
786
- return this.commands?.post() ?? Promise.resolve({
787
- ok: false,
788
- status: null,
789
- skipped: "disabled"
790
- });
807
+ return this.commands?.post() ?? Promise.resolve(DISABLED);
791
808
  }
792
809
  /** Clears every timer and detaches every client listener. Not restartable. */
793
810
  stop() {
@@ -810,13 +827,14 @@ function checkIntents(client, poster) {
810
827
  */
811
828
  function autopost(client, token, options = {}) {
812
829
  const poster = new Autoposter();
813
- if (!token) {
830
+ const cleaned = token?.trim().replace(/^bearer\s+/i, "");
831
+ if (!cleaned) {
814
832
  poster.warnOnce("no-token", "No stats token supplied; auto-posting is disabled.");
815
833
  return poster;
816
834
  }
817
835
  const api = createApiClient({
818
836
  baseUrl: options.baseUrl ?? "https://thediscordlist.com/api",
819
- token,
837
+ token: cleaned,
820
838
  userAgent: `@thediscordlist/discord.js/${version} (discord.js ${discord_js.version})`
821
839
  });
822
840
  if (options.botId) {
@@ -841,5 +859,9 @@ function autopost(client, token, options = {}) {
841
859
  //#endregion
842
860
  exports.Autoposter = Autoposter;
843
861
  exports.DEFAULT_BASE_URL = DEFAULT_BASE_URL;
862
+ exports.HEARTBEAT_DEFAULT_INTERVAL_MS = HEARTBEAT_DEFAULT_INTERVAL_MS;
863
+ exports.STATS_DEBOUNCE_MS = STATS_DEBOUNCE_MS;
864
+ exports.STATS_INTERVAL_MS = STATS_INTERVAL_MS;
865
+ exports.STATS_MIN_GAP_MS = STATS_MIN_GAP_MS;
844
866
  exports.autopost = autopost;
845
867
  exports.version = version;
package/dist/index.d.cts CHANGED
@@ -31,7 +31,14 @@ type Outcome = {
31
31
  };
32
32
  type Priority = 'normal' | 'heartbeat';
33
33
  interface ApiClient {
34
- post(path: string, body: unknown, priority: Priority): Promise<Outcome>;
34
+ /**
35
+ * One request, retrying transient failures and, once its window lifts, a 429.
36
+ *
37
+ * `nextTickInMs` is the time until the caller runs again. If a wait would
38
+ * outlast it, the attempt is dropped: the next tick carries fresher data,
39
+ * and a failed request still burns rate-limit budget.
40
+ */
41
+ post(path: string, body: unknown, priority: Priority, nextTickInMs?: number): Promise<Outcome>;
35
42
  }
36
43
  //#endregion
37
44
  //#region src/topology.d.ts
@@ -89,6 +96,10 @@ interface ClientLike {
89
96
  }
90
97
  //#endregion
91
98
  //#region src/publishers.d.ts
99
+ declare const STATS_INTERVAL_MS = 300000;
100
+ declare const STATS_DEBOUNCE_MS = 20000;
101
+ declare const STATS_MIN_GAP_MS = 60000;
102
+ declare const HEARTBEAT_DEFAULT_INTERVAL_MS = 30000;
92
103
  type PublisherKind = 'stats' | 'heartbeat' | 'commands';
93
104
  interface PostResult {
94
105
  ok: boolean;
@@ -126,17 +137,20 @@ interface AutopostOptions {
126
137
  stats?: {
127
138
  /** Default: true. */
128
139
  enabled?: boolean | undefined;
129
- /** Default: 300000. */
140
+ /** Default: {@link STATS_INTERVAL_MS}. */
130
141
  interval?: number | undefined;
131
- /** Trailing debounce after guildCreate/guildDelete. Default: 20000. */
142
+ /** Trailing debounce after guildCreate/guildDelete. Default: {@link STATS_DEBOUNCE_MS}. */
132
143
  debounce?: number | undefined;
133
- /** Hard floor between two POSTs. Default: 60000. */
144
+ /** Hard floor between two POSTs. Default: {@link STATS_MIN_GAP_MS}. */
134
145
  minGap?: number | undefined;
135
146
  } | undefined;
136
147
  heartbeat?: {
137
148
  /** Default: true. */
138
149
  enabled?: boolean | undefined;
139
- /** Starting cadence; retuned from the API response. Default: 30000. */
150
+ /**
151
+ * Starting cadence, clamped to 15–60s and retuned from the API response.
152
+ * Default: {@link HEARTBEAT_DEFAULT_INTERVAL_MS}.
153
+ */
140
154
  interval?: number | undefined;
141
155
  } | undefined;
142
156
  commands?: {
@@ -203,4 +217,4 @@ declare class Autoposter extends EventEmitter<AutoposterEvents> {
203
217
  */
204
218
  declare function autopost(client: Client, token: string | undefined, options?: AutopostOptions): Autoposter;
205
219
  //#endregion
206
- export { AutopostOptions, Autoposter, AutoposterEvents, type ClientLike, DEFAULT_BASE_URL, type Outcome, type PostResult, type Priority, type PublisherError, type PublisherKind, type ShardReport, type ShardStatus, type Topology, type TopologyKind, autopost, version };
220
+ export { AutopostOptions, Autoposter, AutoposterEvents, type ClientLike, DEFAULT_BASE_URL, HEARTBEAT_DEFAULT_INTERVAL_MS, type Outcome, type PostResult, type Priority, type PublisherError, type PublisherKind, STATS_DEBOUNCE_MS, STATS_INTERVAL_MS, STATS_MIN_GAP_MS, type ShardReport, type ShardStatus, type Topology, type TopologyKind, autopost, version };
package/dist/index.d.mts CHANGED
@@ -31,7 +31,14 @@ type Outcome = {
31
31
  };
32
32
  type Priority = 'normal' | 'heartbeat';
33
33
  interface ApiClient {
34
- post(path: string, body: unknown, priority: Priority): Promise<Outcome>;
34
+ /**
35
+ * One request, retrying transient failures and, once its window lifts, a 429.
36
+ *
37
+ * `nextTickInMs` is the time until the caller runs again. If a wait would
38
+ * outlast it, the attempt is dropped: the next tick carries fresher data,
39
+ * and a failed request still burns rate-limit budget.
40
+ */
41
+ post(path: string, body: unknown, priority: Priority, nextTickInMs?: number): Promise<Outcome>;
35
42
  }
36
43
  //#endregion
37
44
  //#region src/topology.d.ts
@@ -89,6 +96,10 @@ interface ClientLike {
89
96
  }
90
97
  //#endregion
91
98
  //#region src/publishers.d.ts
99
+ declare const STATS_INTERVAL_MS = 300000;
100
+ declare const STATS_DEBOUNCE_MS = 20000;
101
+ declare const STATS_MIN_GAP_MS = 60000;
102
+ declare const HEARTBEAT_DEFAULT_INTERVAL_MS = 30000;
92
103
  type PublisherKind = 'stats' | 'heartbeat' | 'commands';
93
104
  interface PostResult {
94
105
  ok: boolean;
@@ -126,17 +137,20 @@ interface AutopostOptions {
126
137
  stats?: {
127
138
  /** Default: true. */
128
139
  enabled?: boolean | undefined;
129
- /** Default: 300000. */
140
+ /** Default: {@link STATS_INTERVAL_MS}. */
130
141
  interval?: number | undefined;
131
- /** Trailing debounce after guildCreate/guildDelete. Default: 20000. */
142
+ /** Trailing debounce after guildCreate/guildDelete. Default: {@link STATS_DEBOUNCE_MS}. */
132
143
  debounce?: number | undefined;
133
- /** Hard floor between two POSTs. Default: 60000. */
144
+ /** Hard floor between two POSTs. Default: {@link STATS_MIN_GAP_MS}. */
134
145
  minGap?: number | undefined;
135
146
  } | undefined;
136
147
  heartbeat?: {
137
148
  /** Default: true. */
138
149
  enabled?: boolean | undefined;
139
- /** Starting cadence; retuned from the API response. Default: 30000. */
150
+ /**
151
+ * Starting cadence, clamped to 15–60s and retuned from the API response.
152
+ * Default: {@link HEARTBEAT_DEFAULT_INTERVAL_MS}.
153
+ */
140
154
  interval?: number | undefined;
141
155
  } | undefined;
142
156
  commands?: {
@@ -203,4 +217,4 @@ declare class Autoposter extends EventEmitter<AutoposterEvents> {
203
217
  */
204
218
  declare function autopost(client: Client, token: string | undefined, options?: AutopostOptions): Autoposter;
205
219
  //#endregion
206
- export { AutopostOptions, Autoposter, AutoposterEvents, type ClientLike, DEFAULT_BASE_URL, type Outcome, type PostResult, type Priority, type PublisherError, type PublisherKind, type ShardReport, type ShardStatus, type Topology, type TopologyKind, autopost, version };
220
+ export { AutopostOptions, Autoposter, AutoposterEvents, type ClientLike, DEFAULT_BASE_URL, HEARTBEAT_DEFAULT_INTERVAL_MS, type Outcome, type PostResult, type Priority, type PublisherError, type PublisherKind, STATS_DEBOUNCE_MS, STATS_INTERVAL_MS, STATS_MIN_GAP_MS, type ShardReport, type ShardStatus, type Topology, type TopologyKind, autopost, version };
package/dist/index.mjs CHANGED
@@ -1,11 +1,16 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import { Events, GatewayIntentBits, Status, version as version$1 } from "discord.js";
3
3
  //#region package.json
4
- var version$2 = "0.1.1";
4
+ var version$2 = "0.2.0";
5
+ /** However far out Retry-After or X-RateLimit-Reset points, never go quiet for longer. */
6
+ const MAX_RATE_LIMIT_WAIT_MS = 36e5;
7
+ const MAX_ATTEMPTS = 3;
5
8
  /**
6
- * Drops `undefined` keys recursively. An explicit `null` survives, and must:
7
- * the API's `prepareForValidation` only defaults keys that are *absent*, and
8
- * none of its rules are `nullable`, so a serialised `undefined` is a 422.
9
+ * Drops `undefined` keys recursively, so an unset value arrives absent rather
10
+ * than `null`. The API's `prepareForValidation` only defaults keys that are
11
+ * *absent*, and `cluster`, `cluster_count` and `shards` are not `nullable`, so
12
+ * a `null` there is a 422. An explicit `null` survives: where a rule is
13
+ * nullable it is a deliberate "unknown".
9
14
  */
10
15
  function stripUndefined(value) {
11
16
  if (Array.isArray(value)) return value.map((item) => stripUndefined(item));
@@ -14,6 +19,9 @@ function stripUndefined(value) {
14
19
  for (const [key, item] of Object.entries(value)) if (item !== void 0) out[key] = stripUndefined(item);
15
20
  return out;
16
21
  }
22
+ function isRetryable(outcome) {
23
+ return outcome.kind === "transient" || outcome.kind === "throttled";
24
+ }
17
25
  /** Full jitter in the upper half, capped at 30s. */
18
26
  function backoffMs(attempt, random = Math.random) {
19
27
  return Math.round(Math.min(1e3 * 2 ** attempt, 3e4) * (.5 + random() * .5));
@@ -40,10 +48,6 @@ function classify(status, body, retryAfterHeader) {
40
48
  kind: "config",
41
49
  status
42
50
  };
43
- if (status === 400 || status === 422) return {
44
- kind: "payload",
45
- status
46
- };
47
51
  if (status === 429) {
48
52
  const seconds = Number(retryAfterHeader);
49
53
  return {
@@ -52,22 +56,32 @@ function classify(status, body, retryAfterHeader) {
52
56
  retryAfterMs: Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : 6e4
53
57
  };
54
58
  }
59
+ if (status >= 400 && status < 500 && status !== 408 && status !== 425) return {
60
+ kind: "payload",
61
+ status
62
+ };
55
63
  return {
56
64
  kind: "transient",
57
65
  status,
58
66
  error: /* @__PURE__ */ new Error(`TheDiscordList responded ${status}.`)
59
67
  };
60
68
  }
69
+ function sleep(ms) {
70
+ return new Promise((resolve) => {
71
+ setTimeout(resolve, ms).unref?.();
72
+ });
73
+ }
61
74
  function createApiClient(options) {
75
+ const baseUrl = options.baseUrl.replace(/\/+$/, "");
62
76
  let suspendedUntil = 0;
63
- return { async post(path, body, priority) {
64
- if (priority !== "heartbeat" && Date.now() < suspendedUntil) return {
77
+ async function once(path, body, ignoreLimit) {
78
+ if (!ignoreLimit && performance.now() < suspendedUntil) return {
65
79
  kind: "dropped",
66
80
  status: null
67
81
  };
68
82
  let response;
69
83
  try {
70
- response = await fetch(`${options.baseUrl}${path}`, {
84
+ response = await fetch(`${baseUrl}${path}`, {
71
85
  method: "POST",
72
86
  headers: {
73
87
  authorization: `Bearer ${options.token}`,
@@ -103,8 +117,22 @@ function createApiClient(options) {
103
117
  const outcome = classify(response.status, parsed, response.headers.get("retry-after"));
104
118
  if (outcome.kind === "throttled") {
105
119
  const reset = Number(response.headers.get("x-ratelimit-reset"));
106
- const untilReset = Number.isFinite(reset) && reset > 0 ? reset * 1e3 : 0;
107
- suspendedUntil = Math.max(Date.now() + outcome.retryAfterMs, untilReset) + Math.round(Math.random() * 2e3);
120
+ const untilReset = Number.isFinite(reset) && reset > 0 ? reset * 1e3 - Date.now() : 0;
121
+ suspendedUntil = performance.now() + Math.min(Math.max(outcome.retryAfterMs, untilReset), MAX_RATE_LIMIT_WAIT_MS) + Math.round(Math.random() * 2e3);
122
+ }
123
+ return outcome;
124
+ }
125
+ return { async post(path, body, priority, nextTickInMs) {
126
+ let outcome = {
127
+ kind: "dropped",
128
+ status: null
129
+ };
130
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
131
+ outcome = await once(path, body, priority === "heartbeat" || outcome.kind === "throttled");
132
+ if (!isRetryable(outcome) || attempt === 2) break;
133
+ const delay = outcome.kind === "throttled" ? suspendedUntil - performance.now() : backoffMs(attempt);
134
+ if (nextTickInMs !== void 0 && delay >= nextTickInMs) break;
135
+ await sleep(delay);
108
136
  }
109
137
  return outcome;
110
138
  } };
@@ -244,16 +272,24 @@ async function measureServerCount(client, topology, getServerCount) {
244
272
  }
245
273
  return total;
246
274
  }
275
+ //#endregion
276
+ //#region src/publishers.ts
277
+ const STATS_INTERVAL_MS = 3e5;
278
+ const STATS_DEBOUNCE_MS = 2e4;
279
+ const STATS_MIN_GAP_MS = 6e4;
247
280
  const STATS_FIRST_POST_DELAY_MS = 5e3;
248
281
  const STATS_REMEASURE_MS = 6e4;
282
+ const HEARTBEAT_DEFAULT_INTERVAL_MS = 3e4;
249
283
  const HEARTBEAT_MIN_INTERVAL_MS = 15e3;
250
284
  const HEARTBEAT_MAX_INTERVAL_MS = 6e4;
251
285
  const HEARTBEAT_JITTER_MS = 5e3;
286
+ /** An out-of-band beat never follows the previous beat more closely than this. */
287
+ const HEARTBEAT_OUT_OF_BAND_GAP_MS = 1e4;
288
+ const BEAT_NOW_DELAY_MS = 1e3;
252
289
  /** Offset from the stats post: the two share one server-side rate budget. */
253
290
  const COMMANDS_DELAY_MS = 1e4;
254
291
  /** The Gold tier is derived per request from Stripe, so it can flip mid-run. */
255
292
  const PLAN_REPROBE_MS = 18e5;
256
- const MAX_ATTEMPTS = 3;
257
293
  function publisherError(message, type, status, retryable) {
258
294
  return Object.assign(new Error(message), {
259
295
  type,
@@ -261,11 +297,6 @@ function publisherError(message, type, status, retryable) {
261
297
  retryable
262
298
  });
263
299
  }
264
- function sleep(ms) {
265
- return new Promise((resolve) => {
266
- setTimeout(resolve, ms).unref?.();
267
- });
268
- }
269
300
  var Publisher = class {
270
301
  deps;
271
302
  timer;
@@ -313,27 +344,9 @@ var Publisher = class {
313
344
  this.reported.add(status);
314
345
  this.deps.emit.error(publisherError(message, this.kind, status, retryable));
315
346
  }
316
- /**
317
- * Sends one request, retrying only transient failures.
318
- *
319
- * `nextTickInMs` is the time until this publisher's next scheduled run. If a
320
- * backoff would outlast it, the attempt is dropped: the next tick carries
321
- * fresher data, and a failed request still burns rate-limit budget.
322
- */
347
+ /** `nextTickInMs` is the time until this publisher's next scheduled run; no retry outlasts it. */
323
348
  async send(path, body, priority, nextTickInMs) {
324
- let outcome = {
325
- kind: "dropped",
326
- status: null
327
- };
328
- for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
329
- outcome = await this.deps.api.post(path, body, priority);
330
- if (outcome.kind !== "transient") break;
331
- const delay = backoffMs(attempt);
332
- if (attempt === 2) break;
333
- if (nextTickInMs !== void 0 && delay >= nextTickInMs) break;
334
- await sleep(delay);
335
- }
336
- return this.react(outcome);
349
+ return this.react(await this.deps.api.post(path, body, priority, nextTickInMs));
337
350
  }
338
351
  react(outcome) {
339
352
  switch (outcome.kind) {
@@ -365,7 +378,7 @@ var Publisher = class {
365
378
  status: 401
366
379
  };
367
380
  case "plan":
368
- this.suspendedUntil = Date.now() + PLAN_REPROBE_MS;
381
+ this.suspendedUntil = performance.now() + PLAN_REPROBE_MS;
369
382
  this.reportOnce(403, `The ${this.kind} endpoint needs a higher plan. Reprobing in 30 minutes.`, false);
370
383
  return {
371
384
  ok: false,
@@ -402,7 +415,7 @@ var StatsPublisher = class extends Publisher {
402
415
  debounce;
403
416
  minGap;
404
417
  getServerCount;
405
- lastPostAt = 0;
418
+ lastPostAt = -Infinity;
406
419
  lastCount;
407
420
  lastShardCount;
408
421
  debounceCap = 0;
@@ -420,7 +433,7 @@ var StatsPublisher = class extends Publisher {
420
433
  /** Trailing debounce, hard-capped so a join storm cannot starve the post. */
421
434
  onGuildChange() {
422
435
  if (this.disabledReason !== void 0) return;
423
- const now = Date.now();
436
+ const now = performance.now();
424
437
  if (this.debounceCap === 0) this.debounceCap = now + this.minGap;
425
438
  this.schedule(Math.min(now + this.debounce, this.debounceCap) - now, () => this.tick());
426
439
  }
@@ -443,7 +456,7 @@ var StatsPublisher = class extends Publisher {
443
456
  status: null,
444
457
  skipped: "in-flight"
445
458
  };
446
- const now = Date.now();
459
+ const now = performance.now();
447
460
  if (!manual && now < this.suspendedUntil) {
448
461
  this.schedule(this.suspendedUntil - now, () => this.tick());
449
462
  return {
@@ -452,7 +465,7 @@ var StatsPublisher = class extends Publisher {
452
465
  skipped: "plan"
453
466
  };
454
467
  }
455
- if (this.lastPostAt !== 0 && now < this.lastPostAt + this.minGap) {
468
+ if (now < this.lastPostAt + this.minGap) {
456
469
  this.schedule(this.lastPostAt + this.minGap - now, () => this.tick());
457
470
  return {
458
471
  ok: false,
@@ -472,7 +485,7 @@ var StatsPublisher = class extends Publisher {
472
485
  };
473
486
  }
474
487
  const shardCount = this.deps.topology.shardCount;
475
- if (count === this.lastCount && shardCount === this.lastShardCount && Date.now() < this.lastPostAt + 18e5) {
488
+ if (count === this.lastCount && shardCount === this.lastShardCount && performance.now() < this.lastPostAt + 18e5) {
476
489
  this.schedule(this.interval, () => this.tick());
477
490
  return {
478
491
  ok: false,
@@ -480,7 +493,7 @@ var StatsPublisher = class extends Publisher {
480
493
  skipped: "unchanged"
481
494
  };
482
495
  }
483
- this.lastPostAt = Date.now();
496
+ this.lastPostAt = performance.now();
484
497
  const result = await this.send(`/bots/${this.deps.botId}/stats`, {
485
498
  server_count: count,
486
499
  shard_count: shardCount
@@ -499,13 +512,14 @@ var StatsPublisher = class extends Publisher {
499
512
  var HeartbeatPublisher = class extends Publisher {
500
513
  kind = "heartbeat";
501
514
  interval;
502
- startedAt = 0;
503
- beats = 0;
515
+ nextTarget = 0;
516
+ lastBeatAt = -Infinity;
517
+ beatNowPending = false;
504
518
  inFlight = false;
505
519
  nextBeatWithinSeconds;
506
520
  constructor(deps, options = {}) {
507
521
  super(deps);
508
- this.interval = options.interval ?? 3e4;
522
+ this.interval = clampInterval(options.interval ?? 3e4);
509
523
  }
510
524
  /** Starts on construction, not on ready: a booting bot must read "connecting", not dead. */
511
525
  start() {
@@ -513,14 +527,16 @@ var HeartbeatPublisher = class extends Publisher {
513
527
  this.disable(null, "cluster-count-unknown");
514
528
  return;
515
529
  }
516
- this.startedAt = Date.now();
517
- this.beats = 0;
530
+ this.nextTarget = performance.now();
518
531
  this.schedule(0, () => this.tick());
519
532
  }
520
- /** Out-of-band beat after a shard event. Repeated calls coalesce into one. */
533
+ /** Out-of-band beat after a shard event. Calls before it fires coalesce into one. */
521
534
  beatNow() {
522
- if (this.disabledReason !== void 0) return;
523
- this.schedule(1e3, () => this.tick());
535
+ const now = performance.now();
536
+ if (this.disabledReason !== void 0 || this.beatNowPending || now < this.suspendedUntil) return;
537
+ this.beatNowPending = true;
538
+ const delay = Math.max(BEAT_NOW_DELAY_MS, this.lastBeatAt + HEARTBEAT_OUT_OF_BAND_GAP_MS - now);
539
+ this.schedule(delay, () => this.tick());
524
540
  }
525
541
  async post() {
526
542
  this.rearm();
@@ -532,6 +548,7 @@ var HeartbeatPublisher = class extends Publisher {
532
548
  return this.beat(true);
533
549
  }
534
550
  async tick() {
551
+ this.beatNowPending = false;
535
552
  await this.beat(false);
536
553
  }
537
554
  async beat(manual) {
@@ -566,6 +583,7 @@ var HeartbeatPublisher = class extends Publisher {
566
583
  shards
567
584
  };
568
585
  this.inFlight = true;
586
+ this.lastBeatAt = performance.now();
569
587
  let result;
570
588
  try {
571
589
  result = await this.send(`/bots/${this.deps.botId}/heartbeat`, body, "heartbeat", this.interval);
@@ -586,8 +604,7 @@ var HeartbeatPublisher = class extends Publisher {
586
604
  */
587
605
  retune() {
588
606
  if (this.nextBeatWithinSeconds === void 0) return;
589
- const proposed = Math.round(this.nextBeatWithinSeconds / 3 * 1e3);
590
- this.interval = Math.min(Math.max(proposed, HEARTBEAT_MIN_INTERVAL_MS), HEARTBEAT_MAX_INTERVAL_MS);
607
+ this.interval = clampInterval(Math.round(this.nextBeatWithinSeconds / 3 * 1e3));
591
608
  }
592
609
  react(outcome) {
593
610
  if (outcome.kind === "ok") {
@@ -596,15 +613,22 @@ var HeartbeatPublisher = class extends Publisher {
596
613
  }
597
614
  return super.react(outcome);
598
615
  }
599
- /** Absolute-time scheduling, so a slow request cannot drift the cadence. */
616
+ /**
617
+ * Absolute targets, so a slow request cannot drift the cadence and an
618
+ * out-of-band beat does not push the next regular one back. Missed slots
619
+ * are skipped, so the end of a long suspension is one beat, not a burst.
620
+ */
600
621
  scheduleNext(minDelayMs = 0) {
601
- this.beats += 1;
622
+ const now = performance.now();
623
+ if (this.nextTarget <= now) this.nextTarget += (Math.floor((now - this.nextTarget) / this.interval) + 1) * this.interval;
602
624
  const jitter = Math.round(Math.random() * HEARTBEAT_JITTER_MS);
603
- const target = this.startedAt + this.beats * this.interval + jitter;
604
- const delay = Math.max(target - Date.now(), minDelayMs, 1e3, this.suspendedUntil - Date.now());
625
+ const delay = Math.max(this.nextTarget + jitter - now, minDelayMs, BEAT_NOW_DELAY_MS, this.suspendedUntil - now);
605
626
  this.schedule(delay, () => this.tick());
606
627
  }
607
628
  };
629
+ function clampInterval(ms) {
630
+ return Math.min(Math.max(ms, HEARTBEAT_MIN_INTERVAL_MS), HEARTBEAT_MAX_INTERVAL_MS);
631
+ }
608
632
  var CommandsPublisher = class extends Publisher {
609
633
  rest;
610
634
  options;
@@ -629,7 +653,7 @@ var CommandsPublisher = class extends Publisher {
629
653
  status: null,
630
654
  skipped: "disabled"
631
655
  };
632
- if (Date.now() < this.suspendedUntil) return {
656
+ if (performance.now() < this.suspendedUntil) return {
633
657
  ok: false,
634
658
  status: null,
635
659
  skipped: "plan"
@@ -679,6 +703,11 @@ var CommandsPublisher = class extends Publisher {
679
703
  const version = version$2;
680
704
  const DEFAULT_BASE_URL = "https://thediscordlist.com/api";
681
705
  const LOG_PREFIX = "[@thediscordlist/discord.js]";
706
+ const DISABLED = Object.freeze({
707
+ ok: false,
708
+ status: null,
709
+ skipped: "disabled"
710
+ });
682
711
  var Autoposter = class extends EventEmitter {
683
712
  stats;
684
713
  heartbeat;
@@ -768,25 +797,13 @@ var Autoposter = class extends EventEmitter {
768
797
  this.commands?.stop();
769
798
  }
770
799
  postStats() {
771
- return this.stats?.post() ?? Promise.resolve({
772
- ok: false,
773
- status: null,
774
- skipped: "disabled"
775
- });
800
+ return this.stats?.post() ?? Promise.resolve(DISABLED);
776
801
  }
777
802
  postHeartbeat() {
778
- return this.heartbeat?.post() ?? Promise.resolve({
779
- ok: false,
780
- status: null,
781
- skipped: "disabled"
782
- });
803
+ return this.heartbeat?.post() ?? Promise.resolve(DISABLED);
783
804
  }
784
805
  postCommands() {
785
- return this.commands?.post() ?? Promise.resolve({
786
- ok: false,
787
- status: null,
788
- skipped: "disabled"
789
- });
806
+ return this.commands?.post() ?? Promise.resolve(DISABLED);
790
807
  }
791
808
  /** Clears every timer and detaches every client listener. Not restartable. */
792
809
  stop() {
@@ -809,13 +826,14 @@ function checkIntents(client, poster) {
809
826
  */
810
827
  function autopost(client, token, options = {}) {
811
828
  const poster = new Autoposter();
812
- if (!token) {
829
+ const cleaned = token?.trim().replace(/^bearer\s+/i, "");
830
+ if (!cleaned) {
813
831
  poster.warnOnce("no-token", "No stats token supplied; auto-posting is disabled.");
814
832
  return poster;
815
833
  }
816
834
  const api = createApiClient({
817
835
  baseUrl: options.baseUrl ?? "https://thediscordlist.com/api",
818
- token,
836
+ token: cleaned,
819
837
  userAgent: `@thediscordlist/discord.js/${version} (discord.js ${version$1})`
820
838
  });
821
839
  if (options.botId) {
@@ -838,4 +856,4 @@ function autopost(client, token, options = {}) {
838
856
  return poster;
839
857
  }
840
858
  //#endregion
841
- export { Autoposter, DEFAULT_BASE_URL, autopost, version };
859
+ export { Autoposter, DEFAULT_BASE_URL, HEARTBEAT_DEFAULT_INTERVAL_MS, STATS_DEBOUNCE_MS, STATS_INTERVAL_MS, STATS_MIN_GAP_MS, autopost, version };
package/dist/webhooks.cjs CHANGED
@@ -20,6 +20,10 @@ var TdlWebhookError = class extends Error {
20
20
  this.name = "TdlWebhookError";
21
21
  }
22
22
  };
23
+ /** An empty key would make every signature computable by anyone, so it is a setup error. */
24
+ function requireSecret(secret) {
25
+ if (typeof secret !== "string" || secret.trim() === "") throw new TypeError("A webhook secret is required.");
26
+ }
23
27
  /** Length is not secret, and `timingSafeEqual` throws when lengths differ. */
24
28
  function safeEqual(a, b) {
25
29
  if (a.length !== b.length || a.length === 0) return false;
@@ -33,9 +37,11 @@ function safeEqual(a, b) {
33
37
  * and posts that same string, so `JSON.stringify(req.body)` re-escapes slashes
34
38
  * and non-ASCII and can never verify.
35
39
  *
36
- * @throws {TdlWebhookError} and nothing else.
40
+ * @throws {TdlWebhookError} when the delivery does not verify.
41
+ * @throws {TypeError} when `secret` is empty.
37
42
  */
38
43
  function verifyWebhook(rawBody, signatureHeader, secret, options = {}) {
44
+ requireSecret(secret);
39
45
  const header = Array.isArray(signatureHeader) ? signatureHeader[0] : signatureHeader;
40
46
  if (!header) throw new TdlWebhookError("MISSING_SIGNATURE", `Missing ${WEBHOOK_SIGNATURE_HEADER} header.`);
41
47
  let timestamp;
@@ -110,6 +116,7 @@ function verifyOptions(options) {
110
116
  * ```
111
117
  */
112
118
  function createFetchWebhookHandler(options) {
119
+ requireSecret(options.secret);
113
120
  return async (request) => {
114
121
  if (request.method !== "POST") return new Response(null, { status: 405 });
115
122
  const raw = await request.text();
@@ -127,13 +134,15 @@ function createFetchWebhookHandler(options) {
127
134
  }
128
135
  /**
129
136
  * `node:http` and express/connect handler. It reads the stream itself, so it
130
- * must be mounted before any JSON body parser.
137
+ * must be mounted before any JSON body parser; a raw `Buffer` body from
138
+ * `express.raw()` is used as is.
131
139
  *
132
140
  * ```ts
133
141
  * app.post('/tdl', createWebhookHandler({ secret, onEvent }));
134
142
  * ```
135
143
  */
136
144
  function createWebhookHandler(options) {
145
+ requireSecret(options.secret);
137
146
  return (req, res) => {
138
147
  (async () => {
139
148
  const end = (status) => {
@@ -141,26 +150,32 @@ function createWebhookHandler(options) {
141
150
  res.end();
142
151
  };
143
152
  if (req.method !== "POST") return end(405);
144
- if (req.body !== void 0) {
153
+ let raw;
154
+ if (req.body instanceof Uint8Array) {
155
+ raw = req.body;
156
+ if (raw.byteLength > 1048576) return end(413);
157
+ } else if (req.body !== void 0) {
145
158
  options.onError?.(new TdlWebhookError("BODY_CONSUMED", "Request body was already parsed, so the signed bytes are gone. Mount this handler before any JSON body parser, or use express.raw({ type: 'application/json' }) on this route."));
146
159
  return end(500);
147
- }
148
- let size = 0;
149
- const chunks = [];
150
- try {
151
- for await (const chunk of req) {
152
- const buf = Buffer.from(chunk);
153
- size += buf.length;
154
- if (size > 1048576) return end(413);
155
- chunks.push(buf);
160
+ } else {
161
+ let size = 0;
162
+ const chunks = [];
163
+ try {
164
+ for await (const chunk of req) {
165
+ const buf = Buffer.from(chunk);
166
+ size += buf.length;
167
+ if (size > 1048576) return end(413);
168
+ chunks.push(buf);
169
+ }
170
+ } catch (error) {
171
+ options.onError?.(error);
172
+ return end(400);
156
173
  }
157
- } catch (error) {
158
- options.onError?.(error);
159
- return end(400);
174
+ raw = Buffer.concat(chunks);
160
175
  }
161
176
  let event;
162
177
  try {
163
- event = verifyWebhook(Buffer.concat(chunks), req.headers[WEBHOOK_SIGNATURE_HEADER], options.secret, verifyOptions(options));
178
+ event = verifyWebhook(raw, req.headers[WEBHOOK_SIGNATURE_HEADER], options.secret, verifyOptions(options));
164
179
  } catch (error) {
165
180
  options.onError?.(error);
166
181
  return end(statusForError(error));
@@ -25,7 +25,7 @@ interface TdlWebhookListing {
25
25
  url: string;
26
26
  }
27
27
  interface TdlEnvelope<E extends string, D> {
28
- /** UUID, equals `X-TDL-Delivery`, stable across all 5 retries. Idempotency key. */
28
+ /** UUID, equals `X-TDL-Delivery`, stable across all 5 attempts. Idempotency key. */
29
29
  id: string;
30
30
  event: E;
31
31
  /** Enqueue time, NOT send time. Do not use it for freshness. */
@@ -38,6 +38,8 @@ type TdlWebhookPayload = TdlEnvelope<'listing.bumped', {
38
38
  bumped_at: TdlIsoDate;
39
39
  next_bump_at: TdlIsoDate;
40
40
  user: TdlWebhookUser | null;
41
+ }> | TdlEnvelope<'listing.bump_ready', {
42
+ ready_at: TdlIsoDate;
41
43
  }> | TdlEnvelope<'listing.upvoted', {
42
44
  voted_at: TdlIsoDate;
43
45
  total_upvotes: number;
@@ -75,7 +77,8 @@ interface VerifyWebhookOptions {
75
77
  * and posts that same string, so `JSON.stringify(req.body)` re-escapes slashes
76
78
  * and non-ASCII and can never verify.
77
79
  *
78
- * @throws {TdlWebhookError} and nothing else.
80
+ * @throws {TdlWebhookError} when the delivery does not verify.
81
+ * @throws {TypeError} when `secret` is empty.
79
82
  */
80
83
  declare function verifyWebhook(rawBody: string | Uint8Array, signatureHeader: string | string[] | undefined, secret: string, options?: VerifyWebhookOptions): TdlWebhookPayload;
81
84
  interface WebhookContext {
@@ -103,7 +106,8 @@ interface WebhookHandlerOptions {
103
106
  declare function createFetchWebhookHandler(options: WebhookHandlerOptions): (request: Request) => Promise<Response>;
104
107
  /**
105
108
  * `node:http` and express/connect handler. It reads the stream itself, so it
106
- * must be mounted before any JSON body parser.
109
+ * must be mounted before any JSON body parser; a raw `Buffer` body from
110
+ * `express.raw()` is used as is.
107
111
  *
108
112
  * ```ts
109
113
  * app.post('/tdl', createWebhookHandler({ secret, onEvent }));
@@ -25,7 +25,7 @@ interface TdlWebhookListing {
25
25
  url: string;
26
26
  }
27
27
  interface TdlEnvelope<E extends string, D> {
28
- /** UUID, equals `X-TDL-Delivery`, stable across all 5 retries. Idempotency key. */
28
+ /** UUID, equals `X-TDL-Delivery`, stable across all 5 attempts. Idempotency key. */
29
29
  id: string;
30
30
  event: E;
31
31
  /** Enqueue time, NOT send time. Do not use it for freshness. */
@@ -38,6 +38,8 @@ type TdlWebhookPayload = TdlEnvelope<'listing.bumped', {
38
38
  bumped_at: TdlIsoDate;
39
39
  next_bump_at: TdlIsoDate;
40
40
  user: TdlWebhookUser | null;
41
+ }> | TdlEnvelope<'listing.bump_ready', {
42
+ ready_at: TdlIsoDate;
41
43
  }> | TdlEnvelope<'listing.upvoted', {
42
44
  voted_at: TdlIsoDate;
43
45
  total_upvotes: number;
@@ -75,7 +77,8 @@ interface VerifyWebhookOptions {
75
77
  * and posts that same string, so `JSON.stringify(req.body)` re-escapes slashes
76
78
  * and non-ASCII and can never verify.
77
79
  *
78
- * @throws {TdlWebhookError} and nothing else.
80
+ * @throws {TdlWebhookError} when the delivery does not verify.
81
+ * @throws {TypeError} when `secret` is empty.
79
82
  */
80
83
  declare function verifyWebhook(rawBody: string | Uint8Array, signatureHeader: string | string[] | undefined, secret: string, options?: VerifyWebhookOptions): TdlWebhookPayload;
81
84
  interface WebhookContext {
@@ -103,7 +106,8 @@ interface WebhookHandlerOptions {
103
106
  declare function createFetchWebhookHandler(options: WebhookHandlerOptions): (request: Request) => Promise<Response>;
104
107
  /**
105
108
  * `node:http` and express/connect handler. It reads the stream itself, so it
106
- * must be mounted before any JSON body parser.
109
+ * must be mounted before any JSON body parser; a raw `Buffer` body from
110
+ * `express.raw()` is used as is.
107
111
  *
108
112
  * ```ts
109
113
  * app.post('/tdl', createWebhookHandler({ secret, onEvent }));
package/dist/webhooks.mjs CHANGED
@@ -19,6 +19,10 @@ var TdlWebhookError = class extends Error {
19
19
  this.name = "TdlWebhookError";
20
20
  }
21
21
  };
22
+ /** An empty key would make every signature computable by anyone, so it is a setup error. */
23
+ function requireSecret(secret) {
24
+ if (typeof secret !== "string" || secret.trim() === "") throw new TypeError("A webhook secret is required.");
25
+ }
22
26
  /** Length is not secret, and `timingSafeEqual` throws when lengths differ. */
23
27
  function safeEqual(a, b) {
24
28
  if (a.length !== b.length || a.length === 0) return false;
@@ -32,9 +36,11 @@ function safeEqual(a, b) {
32
36
  * and posts that same string, so `JSON.stringify(req.body)` re-escapes slashes
33
37
  * and non-ASCII and can never verify.
34
38
  *
35
- * @throws {TdlWebhookError} and nothing else.
39
+ * @throws {TdlWebhookError} when the delivery does not verify.
40
+ * @throws {TypeError} when `secret` is empty.
36
41
  */
37
42
  function verifyWebhook(rawBody, signatureHeader, secret, options = {}) {
43
+ requireSecret(secret);
38
44
  const header = Array.isArray(signatureHeader) ? signatureHeader[0] : signatureHeader;
39
45
  if (!header) throw new TdlWebhookError("MISSING_SIGNATURE", `Missing ${WEBHOOK_SIGNATURE_HEADER} header.`);
40
46
  let timestamp;
@@ -109,6 +115,7 @@ function verifyOptions(options) {
109
115
  * ```
110
116
  */
111
117
  function createFetchWebhookHandler(options) {
118
+ requireSecret(options.secret);
112
119
  return async (request) => {
113
120
  if (request.method !== "POST") return new Response(null, { status: 405 });
114
121
  const raw = await request.text();
@@ -126,13 +133,15 @@ function createFetchWebhookHandler(options) {
126
133
  }
127
134
  /**
128
135
  * `node:http` and express/connect handler. It reads the stream itself, so it
129
- * must be mounted before any JSON body parser.
136
+ * must be mounted before any JSON body parser; a raw `Buffer` body from
137
+ * `express.raw()` is used as is.
130
138
  *
131
139
  * ```ts
132
140
  * app.post('/tdl', createWebhookHandler({ secret, onEvent }));
133
141
  * ```
134
142
  */
135
143
  function createWebhookHandler(options) {
144
+ requireSecret(options.secret);
136
145
  return (req, res) => {
137
146
  (async () => {
138
147
  const end = (status) => {
@@ -140,26 +149,32 @@ function createWebhookHandler(options) {
140
149
  res.end();
141
150
  };
142
151
  if (req.method !== "POST") return end(405);
143
- if (req.body !== void 0) {
152
+ let raw;
153
+ if (req.body instanceof Uint8Array) {
154
+ raw = req.body;
155
+ if (raw.byteLength > 1048576) return end(413);
156
+ } else if (req.body !== void 0) {
144
157
  options.onError?.(new TdlWebhookError("BODY_CONSUMED", "Request body was already parsed, so the signed bytes are gone. Mount this handler before any JSON body parser, or use express.raw({ type: 'application/json' }) on this route."));
145
158
  return end(500);
146
- }
147
- let size = 0;
148
- const chunks = [];
149
- try {
150
- for await (const chunk of req) {
151
- const buf = Buffer.from(chunk);
152
- size += buf.length;
153
- if (size > 1048576) return end(413);
154
- chunks.push(buf);
159
+ } else {
160
+ let size = 0;
161
+ const chunks = [];
162
+ try {
163
+ for await (const chunk of req) {
164
+ const buf = Buffer.from(chunk);
165
+ size += buf.length;
166
+ if (size > 1048576) return end(413);
167
+ chunks.push(buf);
168
+ }
169
+ } catch (error) {
170
+ options.onError?.(error);
171
+ return end(400);
155
172
  }
156
- } catch (error) {
157
- options.onError?.(error);
158
- return end(400);
173
+ raw = Buffer.concat(chunks);
159
174
  }
160
175
  let event;
161
176
  try {
162
- event = verifyWebhook(Buffer.concat(chunks), req.headers[WEBHOOK_SIGNATURE_HEADER], options.secret, verifyOptions(options));
177
+ event = verifyWebhook(raw, req.headers[WEBHOOK_SIGNATURE_HEADER], options.secret, verifyOptions(options));
163
178
  } catch (error) {
164
179
  options.onError?.(error);
165
180
  return end(statusForError(error));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thediscordlist/discord.js",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Publish your discord.js bot's stats, heartbeat and commands to TheDiscordList automatically, and verify its webhooks.",
5
5
  "license": "MIT",
6
6
  "type": "module",