@thediscordlist/discord.js 0.1.0 → 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
@@ -1,10 +1,17 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  let node_events = require("node:events");
3
3
  let discord_js = require("discord.js");
4
+ //#region package.json
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;
4
9
  /**
5
- * Drops `undefined` keys recursively. An explicit `null` survives, and must:
6
- * the API's `prepareForValidation` only defaults keys that are *absent*, and
7
- * 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".
8
15
  */
9
16
  function stripUndefined(value) {
10
17
  if (Array.isArray(value)) return value.map((item) => stripUndefined(item));
@@ -13,6 +20,9 @@ function stripUndefined(value) {
13
20
  for (const [key, item] of Object.entries(value)) if (item !== void 0) out[key] = stripUndefined(item);
14
21
  return out;
15
22
  }
23
+ function isRetryable(outcome) {
24
+ return outcome.kind === "transient" || outcome.kind === "throttled";
25
+ }
16
26
  /** Full jitter in the upper half, capped at 30s. */
17
27
  function backoffMs(attempt, random = Math.random) {
18
28
  return Math.round(Math.min(1e3 * 2 ** attempt, 3e4) * (.5 + random() * .5));
@@ -39,10 +49,6 @@ function classify(status, body, retryAfterHeader) {
39
49
  kind: "config",
40
50
  status
41
51
  };
42
- if (status === 400 || status === 422) return {
43
- kind: "payload",
44
- status
45
- };
46
52
  if (status === 429) {
47
53
  const seconds = Number(retryAfterHeader);
48
54
  return {
@@ -51,22 +57,32 @@ function classify(status, body, retryAfterHeader) {
51
57
  retryAfterMs: Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : 6e4
52
58
  };
53
59
  }
60
+ if (status >= 400 && status < 500 && status !== 408 && status !== 425) return {
61
+ kind: "payload",
62
+ status
63
+ };
54
64
  return {
55
65
  kind: "transient",
56
66
  status,
57
67
  error: /* @__PURE__ */ new Error(`TheDiscordList responded ${status}.`)
58
68
  };
59
69
  }
70
+ function sleep(ms) {
71
+ return new Promise((resolve) => {
72
+ setTimeout(resolve, ms).unref?.();
73
+ });
74
+ }
60
75
  function createApiClient(options) {
76
+ const baseUrl = options.baseUrl.replace(/\/+$/, "");
61
77
  let suspendedUntil = 0;
62
- return { async post(path, body, priority) {
63
- if (priority !== "heartbeat" && Date.now() < suspendedUntil) return {
78
+ async function once(path, body, ignoreLimit) {
79
+ if (!ignoreLimit && performance.now() < suspendedUntil) return {
64
80
  kind: "dropped",
65
81
  status: null
66
82
  };
67
83
  let response;
68
84
  try {
69
- response = await fetch(`${options.baseUrl}${path}`, {
85
+ response = await fetch(`${baseUrl}${path}`, {
70
86
  method: "POST",
71
87
  headers: {
72
88
  authorization: `Bearer ${options.token}`,
@@ -102,8 +118,22 @@ function createApiClient(options) {
102
118
  const outcome = classify(response.status, parsed, response.headers.get("retry-after"));
103
119
  if (outcome.kind === "throttled") {
104
120
  const reset = Number(response.headers.get("x-ratelimit-reset"));
105
- const untilReset = Number.isFinite(reset) && reset > 0 ? reset * 1e3 : 0;
106
- 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);
107
137
  }
108
138
  return outcome;
109
139
  } };
@@ -243,16 +273,24 @@ async function measureServerCount(client, topology, getServerCount) {
243
273
  }
244
274
  return total;
245
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;
246
281
  const STATS_FIRST_POST_DELAY_MS = 5e3;
247
282
  const STATS_REMEASURE_MS = 6e4;
283
+ const HEARTBEAT_DEFAULT_INTERVAL_MS = 3e4;
248
284
  const HEARTBEAT_MIN_INTERVAL_MS = 15e3;
249
285
  const HEARTBEAT_MAX_INTERVAL_MS = 6e4;
250
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;
251
290
  /** Offset from the stats post: the two share one server-side rate budget. */
252
291
  const COMMANDS_DELAY_MS = 1e4;
253
292
  /** The Gold tier is derived per request from Stripe, so it can flip mid-run. */
254
293
  const PLAN_REPROBE_MS = 18e5;
255
- const MAX_ATTEMPTS = 3;
256
294
  function publisherError(message, type, status, retryable) {
257
295
  return Object.assign(new Error(message), {
258
296
  type,
@@ -260,11 +298,6 @@ function publisherError(message, type, status, retryable) {
260
298
  retryable
261
299
  });
262
300
  }
263
- function sleep(ms) {
264
- return new Promise((resolve) => {
265
- setTimeout(resolve, ms).unref?.();
266
- });
267
- }
268
301
  var Publisher = class {
269
302
  deps;
270
303
  timer;
@@ -312,27 +345,9 @@ var Publisher = class {
312
345
  this.reported.add(status);
313
346
  this.deps.emit.error(publisherError(message, this.kind, status, retryable));
314
347
  }
315
- /**
316
- * Sends one request, retrying only transient failures.
317
- *
318
- * `nextTickInMs` is the time until this publisher's next scheduled run. If a
319
- * backoff would outlast it, the attempt is dropped: the next tick carries
320
- * fresher data, and a failed request still burns rate-limit budget.
321
- */
348
+ /** `nextTickInMs` is the time until this publisher's next scheduled run; no retry outlasts it. */
322
349
  async send(path, body, priority, nextTickInMs) {
323
- let outcome = {
324
- kind: "dropped",
325
- status: null
326
- };
327
- for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
328
- outcome = await this.deps.api.post(path, body, priority);
329
- if (outcome.kind !== "transient") break;
330
- const delay = backoffMs(attempt);
331
- if (attempt === 2) break;
332
- if (nextTickInMs !== void 0 && delay >= nextTickInMs) break;
333
- await sleep(delay);
334
- }
335
- return this.react(outcome);
350
+ return this.react(await this.deps.api.post(path, body, priority, nextTickInMs));
336
351
  }
337
352
  react(outcome) {
338
353
  switch (outcome.kind) {
@@ -364,7 +379,7 @@ var Publisher = class {
364
379
  status: 401
365
380
  };
366
381
  case "plan":
367
- this.suspendedUntil = Date.now() + PLAN_REPROBE_MS;
382
+ this.suspendedUntil = performance.now() + PLAN_REPROBE_MS;
368
383
  this.reportOnce(403, `The ${this.kind} endpoint needs a higher plan. Reprobing in 30 minutes.`, false);
369
384
  return {
370
385
  ok: false,
@@ -401,7 +416,7 @@ var StatsPublisher = class extends Publisher {
401
416
  debounce;
402
417
  minGap;
403
418
  getServerCount;
404
- lastPostAt = 0;
419
+ lastPostAt = -Infinity;
405
420
  lastCount;
406
421
  lastShardCount;
407
422
  debounceCap = 0;
@@ -419,7 +434,7 @@ var StatsPublisher = class extends Publisher {
419
434
  /** Trailing debounce, hard-capped so a join storm cannot starve the post. */
420
435
  onGuildChange() {
421
436
  if (this.disabledReason !== void 0) return;
422
- const now = Date.now();
437
+ const now = performance.now();
423
438
  if (this.debounceCap === 0) this.debounceCap = now + this.minGap;
424
439
  this.schedule(Math.min(now + this.debounce, this.debounceCap) - now, () => this.tick());
425
440
  }
@@ -442,7 +457,7 @@ var StatsPublisher = class extends Publisher {
442
457
  status: null,
443
458
  skipped: "in-flight"
444
459
  };
445
- const now = Date.now();
460
+ const now = performance.now();
446
461
  if (!manual && now < this.suspendedUntil) {
447
462
  this.schedule(this.suspendedUntil - now, () => this.tick());
448
463
  return {
@@ -451,7 +466,7 @@ var StatsPublisher = class extends Publisher {
451
466
  skipped: "plan"
452
467
  };
453
468
  }
454
- if (this.lastPostAt !== 0 && now < this.lastPostAt + this.minGap) {
469
+ if (now < this.lastPostAt + this.minGap) {
455
470
  this.schedule(this.lastPostAt + this.minGap - now, () => this.tick());
456
471
  return {
457
472
  ok: false,
@@ -471,7 +486,7 @@ var StatsPublisher = class extends Publisher {
471
486
  };
472
487
  }
473
488
  const shardCount = this.deps.topology.shardCount;
474
- 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) {
475
490
  this.schedule(this.interval, () => this.tick());
476
491
  return {
477
492
  ok: false,
@@ -479,7 +494,7 @@ var StatsPublisher = class extends Publisher {
479
494
  skipped: "unchanged"
480
495
  };
481
496
  }
482
- this.lastPostAt = Date.now();
497
+ this.lastPostAt = performance.now();
483
498
  const result = await this.send(`/bots/${this.deps.botId}/stats`, {
484
499
  server_count: count,
485
500
  shard_count: shardCount
@@ -498,13 +513,14 @@ var StatsPublisher = class extends Publisher {
498
513
  var HeartbeatPublisher = class extends Publisher {
499
514
  kind = "heartbeat";
500
515
  interval;
501
- startedAt = 0;
502
- beats = 0;
516
+ nextTarget = 0;
517
+ lastBeatAt = -Infinity;
518
+ beatNowPending = false;
503
519
  inFlight = false;
504
520
  nextBeatWithinSeconds;
505
521
  constructor(deps, options = {}) {
506
522
  super(deps);
507
- this.interval = options.interval ?? 3e4;
523
+ this.interval = clampInterval(options.interval ?? 3e4);
508
524
  }
509
525
  /** Starts on construction, not on ready: a booting bot must read "connecting", not dead. */
510
526
  start() {
@@ -512,14 +528,16 @@ var HeartbeatPublisher = class extends Publisher {
512
528
  this.disable(null, "cluster-count-unknown");
513
529
  return;
514
530
  }
515
- this.startedAt = Date.now();
516
- this.beats = 0;
531
+ this.nextTarget = performance.now();
517
532
  this.schedule(0, () => this.tick());
518
533
  }
519
- /** 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. */
520
535
  beatNow() {
521
- if (this.disabledReason !== void 0) return;
522
- 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());
523
541
  }
524
542
  async post() {
525
543
  this.rearm();
@@ -531,6 +549,7 @@ var HeartbeatPublisher = class extends Publisher {
531
549
  return this.beat(true);
532
550
  }
533
551
  async tick() {
552
+ this.beatNowPending = false;
534
553
  await this.beat(false);
535
554
  }
536
555
  async beat(manual) {
@@ -565,6 +584,7 @@ var HeartbeatPublisher = class extends Publisher {
565
584
  shards
566
585
  };
567
586
  this.inFlight = true;
587
+ this.lastBeatAt = performance.now();
568
588
  let result;
569
589
  try {
570
590
  result = await this.send(`/bots/${this.deps.botId}/heartbeat`, body, "heartbeat", this.interval);
@@ -585,8 +605,7 @@ var HeartbeatPublisher = class extends Publisher {
585
605
  */
586
606
  retune() {
587
607
  if (this.nextBeatWithinSeconds === void 0) return;
588
- const proposed = Math.round(this.nextBeatWithinSeconds / 3 * 1e3);
589
- 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));
590
609
  }
591
610
  react(outcome) {
592
611
  if (outcome.kind === "ok") {
@@ -595,15 +614,22 @@ var HeartbeatPublisher = class extends Publisher {
595
614
  }
596
615
  return super.react(outcome);
597
616
  }
598
- /** 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
+ */
599
622
  scheduleNext(minDelayMs = 0) {
600
- 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;
601
625
  const jitter = Math.round(Math.random() * HEARTBEAT_JITTER_MS);
602
- const target = this.startedAt + this.beats * this.interval + jitter;
603
- 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);
604
627
  this.schedule(delay, () => this.tick());
605
628
  }
606
629
  };
630
+ function clampInterval(ms) {
631
+ return Math.min(Math.max(ms, HEARTBEAT_MIN_INTERVAL_MS), HEARTBEAT_MAX_INTERVAL_MS);
632
+ }
607
633
  var CommandsPublisher = class extends Publisher {
608
634
  rest;
609
635
  options;
@@ -628,7 +654,7 @@ var CommandsPublisher = class extends Publisher {
628
654
  status: null,
629
655
  skipped: "disabled"
630
656
  };
631
- if (Date.now() < this.suspendedUntil) return {
657
+ if (performance.now() < this.suspendedUntil) return {
632
658
  ok: false,
633
659
  status: null,
634
660
  skipped: "plan"
@@ -674,10 +700,15 @@ var CommandsPublisher = class extends Publisher {
674
700
  * client.login(process.env.DISCORD_TOKEN);
675
701
  * ```
676
702
  */
677
- /** Kept in step with package.json by `test/version.test.ts`. */
678
- const version = "0.0.0";
703
+ /** This package's version, inlined from package.json at build time. */
704
+ const version = version$1;
679
705
  const DEFAULT_BASE_URL = "https://thediscordlist.com/api";
680
706
  const LOG_PREFIX = "[@thediscordlist/discord.js]";
707
+ const DISABLED = Object.freeze({
708
+ ok: false,
709
+ status: null,
710
+ skipped: "disabled"
711
+ });
681
712
  var Autoposter = class extends node_events.EventEmitter {
682
713
  stats;
683
714
  heartbeat;
@@ -767,25 +798,13 @@ var Autoposter = class extends node_events.EventEmitter {
767
798
  this.commands?.stop();
768
799
  }
769
800
  postStats() {
770
- return this.stats?.post() ?? Promise.resolve({
771
- ok: false,
772
- status: null,
773
- skipped: "disabled"
774
- });
801
+ return this.stats?.post() ?? Promise.resolve(DISABLED);
775
802
  }
776
803
  postHeartbeat() {
777
- return this.heartbeat?.post() ?? Promise.resolve({
778
- ok: false,
779
- status: null,
780
- skipped: "disabled"
781
- });
804
+ return this.heartbeat?.post() ?? Promise.resolve(DISABLED);
782
805
  }
783
806
  postCommands() {
784
- return this.commands?.post() ?? Promise.resolve({
785
- ok: false,
786
- status: null,
787
- skipped: "disabled"
788
- });
807
+ return this.commands?.post() ?? Promise.resolve(DISABLED);
789
808
  }
790
809
  /** Clears every timer and detaches every client listener. Not restartable. */
791
810
  stop() {
@@ -808,13 +827,14 @@ function checkIntents(client, poster) {
808
827
  */
809
828
  function autopost(client, token, options = {}) {
810
829
  const poster = new Autoposter();
811
- if (!token) {
830
+ const cleaned = token?.trim().replace(/^bearer\s+/i, "");
831
+ if (!cleaned) {
812
832
  poster.warnOnce("no-token", "No stats token supplied; auto-posting is disabled.");
813
833
  return poster;
814
834
  }
815
835
  const api = createApiClient({
816
836
  baseUrl: options.baseUrl ?? "https://thediscordlist.com/api",
817
- token,
837
+ token: cleaned,
818
838
  userAgent: `@thediscordlist/discord.js/${version} (discord.js ${discord_js.version})`
819
839
  });
820
840
  if (options.botId) {
@@ -839,5 +859,9 @@ function autopost(client, token, options = {}) {
839
859
  //#endregion
840
860
  exports.Autoposter = Autoposter;
841
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;
842
866
  exports.autopost = autopost;
843
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;
@@ -115,8 +126,8 @@ interface PublisherEmitter {
115
126
  }
116
127
  //#endregion
117
128
  //#region src/index.d.ts
118
- /** Kept in step with package.json by `test/version.test.ts`. */
119
- declare const version = "0.0.0";
129
+ /** This package's version, inlined from package.json at build time. */
130
+ declare const version: string;
120
131
  declare const DEFAULT_BASE_URL = "https://thediscordlist.com/api";
121
132
  interface AutopostOptions {
122
133
  /** Discord application id. Defaults to `client.application.id`. */
@@ -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;
@@ -115,8 +126,8 @@ interface PublisherEmitter {
115
126
  }
116
127
  //#endregion
117
128
  //#region src/index.d.ts
118
- /** Kept in step with package.json by `test/version.test.ts`. */
119
- declare const version = "0.0.0";
129
+ /** This package's version, inlined from package.json at build time. */
130
+ declare const version: string;
120
131
  declare const DEFAULT_BASE_URL = "https://thediscordlist.com/api";
121
132
  interface AutopostOptions {
122
133
  /** Discord application id. Defaults to `client.application.id`. */
@@ -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,9 +1,16 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import { Events, GatewayIntentBits, Status, version as version$1 } from "discord.js";
3
+ //#region package.json
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;
3
8
  /**
4
- * Drops `undefined` keys recursively. An explicit `null` survives, and must:
5
- * the API's `prepareForValidation` only defaults keys that are *absent*, and
6
- * 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".
7
14
  */
8
15
  function stripUndefined(value) {
9
16
  if (Array.isArray(value)) return value.map((item) => stripUndefined(item));
@@ -12,6 +19,9 @@ function stripUndefined(value) {
12
19
  for (const [key, item] of Object.entries(value)) if (item !== void 0) out[key] = stripUndefined(item);
13
20
  return out;
14
21
  }
22
+ function isRetryable(outcome) {
23
+ return outcome.kind === "transient" || outcome.kind === "throttled";
24
+ }
15
25
  /** Full jitter in the upper half, capped at 30s. */
16
26
  function backoffMs(attempt, random = Math.random) {
17
27
  return Math.round(Math.min(1e3 * 2 ** attempt, 3e4) * (.5 + random() * .5));
@@ -38,10 +48,6 @@ function classify(status, body, retryAfterHeader) {
38
48
  kind: "config",
39
49
  status
40
50
  };
41
- if (status === 400 || status === 422) return {
42
- kind: "payload",
43
- status
44
- };
45
51
  if (status === 429) {
46
52
  const seconds = Number(retryAfterHeader);
47
53
  return {
@@ -50,22 +56,32 @@ function classify(status, body, retryAfterHeader) {
50
56
  retryAfterMs: Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : 6e4
51
57
  };
52
58
  }
59
+ if (status >= 400 && status < 500 && status !== 408 && status !== 425) return {
60
+ kind: "payload",
61
+ status
62
+ };
53
63
  return {
54
64
  kind: "transient",
55
65
  status,
56
66
  error: /* @__PURE__ */ new Error(`TheDiscordList responded ${status}.`)
57
67
  };
58
68
  }
69
+ function sleep(ms) {
70
+ return new Promise((resolve) => {
71
+ setTimeout(resolve, ms).unref?.();
72
+ });
73
+ }
59
74
  function createApiClient(options) {
75
+ const baseUrl = options.baseUrl.replace(/\/+$/, "");
60
76
  let suspendedUntil = 0;
61
- return { async post(path, body, priority) {
62
- if (priority !== "heartbeat" && Date.now() < suspendedUntil) return {
77
+ async function once(path, body, ignoreLimit) {
78
+ if (!ignoreLimit && performance.now() < suspendedUntil) return {
63
79
  kind: "dropped",
64
80
  status: null
65
81
  };
66
82
  let response;
67
83
  try {
68
- response = await fetch(`${options.baseUrl}${path}`, {
84
+ response = await fetch(`${baseUrl}${path}`, {
69
85
  method: "POST",
70
86
  headers: {
71
87
  authorization: `Bearer ${options.token}`,
@@ -101,8 +117,22 @@ function createApiClient(options) {
101
117
  const outcome = classify(response.status, parsed, response.headers.get("retry-after"));
102
118
  if (outcome.kind === "throttled") {
103
119
  const reset = Number(response.headers.get("x-ratelimit-reset"));
104
- const untilReset = Number.isFinite(reset) && reset > 0 ? reset * 1e3 : 0;
105
- 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);
106
136
  }
107
137
  return outcome;
108
138
  } };
@@ -242,16 +272,24 @@ async function measureServerCount(client, topology, getServerCount) {
242
272
  }
243
273
  return total;
244
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;
245
280
  const STATS_FIRST_POST_DELAY_MS = 5e3;
246
281
  const STATS_REMEASURE_MS = 6e4;
282
+ const HEARTBEAT_DEFAULT_INTERVAL_MS = 3e4;
247
283
  const HEARTBEAT_MIN_INTERVAL_MS = 15e3;
248
284
  const HEARTBEAT_MAX_INTERVAL_MS = 6e4;
249
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;
250
289
  /** Offset from the stats post: the two share one server-side rate budget. */
251
290
  const COMMANDS_DELAY_MS = 1e4;
252
291
  /** The Gold tier is derived per request from Stripe, so it can flip mid-run. */
253
292
  const PLAN_REPROBE_MS = 18e5;
254
- const MAX_ATTEMPTS = 3;
255
293
  function publisherError(message, type, status, retryable) {
256
294
  return Object.assign(new Error(message), {
257
295
  type,
@@ -259,11 +297,6 @@ function publisherError(message, type, status, retryable) {
259
297
  retryable
260
298
  });
261
299
  }
262
- function sleep(ms) {
263
- return new Promise((resolve) => {
264
- setTimeout(resolve, ms).unref?.();
265
- });
266
- }
267
300
  var Publisher = class {
268
301
  deps;
269
302
  timer;
@@ -311,27 +344,9 @@ var Publisher = class {
311
344
  this.reported.add(status);
312
345
  this.deps.emit.error(publisherError(message, this.kind, status, retryable));
313
346
  }
314
- /**
315
- * Sends one request, retrying only transient failures.
316
- *
317
- * `nextTickInMs` is the time until this publisher's next scheduled run. If a
318
- * backoff would outlast it, the attempt is dropped: the next tick carries
319
- * fresher data, and a failed request still burns rate-limit budget.
320
- */
347
+ /** `nextTickInMs` is the time until this publisher's next scheduled run; no retry outlasts it. */
321
348
  async send(path, body, priority, nextTickInMs) {
322
- let outcome = {
323
- kind: "dropped",
324
- status: null
325
- };
326
- for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
327
- outcome = await this.deps.api.post(path, body, priority);
328
- if (outcome.kind !== "transient") break;
329
- const delay = backoffMs(attempt);
330
- if (attempt === 2) break;
331
- if (nextTickInMs !== void 0 && delay >= nextTickInMs) break;
332
- await sleep(delay);
333
- }
334
- return this.react(outcome);
349
+ return this.react(await this.deps.api.post(path, body, priority, nextTickInMs));
335
350
  }
336
351
  react(outcome) {
337
352
  switch (outcome.kind) {
@@ -363,7 +378,7 @@ var Publisher = class {
363
378
  status: 401
364
379
  };
365
380
  case "plan":
366
- this.suspendedUntil = Date.now() + PLAN_REPROBE_MS;
381
+ this.suspendedUntil = performance.now() + PLAN_REPROBE_MS;
367
382
  this.reportOnce(403, `The ${this.kind} endpoint needs a higher plan. Reprobing in 30 minutes.`, false);
368
383
  return {
369
384
  ok: false,
@@ -400,7 +415,7 @@ var StatsPublisher = class extends Publisher {
400
415
  debounce;
401
416
  minGap;
402
417
  getServerCount;
403
- lastPostAt = 0;
418
+ lastPostAt = -Infinity;
404
419
  lastCount;
405
420
  lastShardCount;
406
421
  debounceCap = 0;
@@ -418,7 +433,7 @@ var StatsPublisher = class extends Publisher {
418
433
  /** Trailing debounce, hard-capped so a join storm cannot starve the post. */
419
434
  onGuildChange() {
420
435
  if (this.disabledReason !== void 0) return;
421
- const now = Date.now();
436
+ const now = performance.now();
422
437
  if (this.debounceCap === 0) this.debounceCap = now + this.minGap;
423
438
  this.schedule(Math.min(now + this.debounce, this.debounceCap) - now, () => this.tick());
424
439
  }
@@ -441,7 +456,7 @@ var StatsPublisher = class extends Publisher {
441
456
  status: null,
442
457
  skipped: "in-flight"
443
458
  };
444
- const now = Date.now();
459
+ const now = performance.now();
445
460
  if (!manual && now < this.suspendedUntil) {
446
461
  this.schedule(this.suspendedUntil - now, () => this.tick());
447
462
  return {
@@ -450,7 +465,7 @@ var StatsPublisher = class extends Publisher {
450
465
  skipped: "plan"
451
466
  };
452
467
  }
453
- if (this.lastPostAt !== 0 && now < this.lastPostAt + this.minGap) {
468
+ if (now < this.lastPostAt + this.minGap) {
454
469
  this.schedule(this.lastPostAt + this.minGap - now, () => this.tick());
455
470
  return {
456
471
  ok: false,
@@ -470,7 +485,7 @@ var StatsPublisher = class extends Publisher {
470
485
  };
471
486
  }
472
487
  const shardCount = this.deps.topology.shardCount;
473
- 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) {
474
489
  this.schedule(this.interval, () => this.tick());
475
490
  return {
476
491
  ok: false,
@@ -478,7 +493,7 @@ var StatsPublisher = class extends Publisher {
478
493
  skipped: "unchanged"
479
494
  };
480
495
  }
481
- this.lastPostAt = Date.now();
496
+ this.lastPostAt = performance.now();
482
497
  const result = await this.send(`/bots/${this.deps.botId}/stats`, {
483
498
  server_count: count,
484
499
  shard_count: shardCount
@@ -497,13 +512,14 @@ var StatsPublisher = class extends Publisher {
497
512
  var HeartbeatPublisher = class extends Publisher {
498
513
  kind = "heartbeat";
499
514
  interval;
500
- startedAt = 0;
501
- beats = 0;
515
+ nextTarget = 0;
516
+ lastBeatAt = -Infinity;
517
+ beatNowPending = false;
502
518
  inFlight = false;
503
519
  nextBeatWithinSeconds;
504
520
  constructor(deps, options = {}) {
505
521
  super(deps);
506
- this.interval = options.interval ?? 3e4;
522
+ this.interval = clampInterval(options.interval ?? 3e4);
507
523
  }
508
524
  /** Starts on construction, not on ready: a booting bot must read "connecting", not dead. */
509
525
  start() {
@@ -511,14 +527,16 @@ var HeartbeatPublisher = class extends Publisher {
511
527
  this.disable(null, "cluster-count-unknown");
512
528
  return;
513
529
  }
514
- this.startedAt = Date.now();
515
- this.beats = 0;
530
+ this.nextTarget = performance.now();
516
531
  this.schedule(0, () => this.tick());
517
532
  }
518
- /** 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. */
519
534
  beatNow() {
520
- if (this.disabledReason !== void 0) return;
521
- 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());
522
540
  }
523
541
  async post() {
524
542
  this.rearm();
@@ -530,6 +548,7 @@ var HeartbeatPublisher = class extends Publisher {
530
548
  return this.beat(true);
531
549
  }
532
550
  async tick() {
551
+ this.beatNowPending = false;
533
552
  await this.beat(false);
534
553
  }
535
554
  async beat(manual) {
@@ -564,6 +583,7 @@ var HeartbeatPublisher = class extends Publisher {
564
583
  shards
565
584
  };
566
585
  this.inFlight = true;
586
+ this.lastBeatAt = performance.now();
567
587
  let result;
568
588
  try {
569
589
  result = await this.send(`/bots/${this.deps.botId}/heartbeat`, body, "heartbeat", this.interval);
@@ -584,8 +604,7 @@ var HeartbeatPublisher = class extends Publisher {
584
604
  */
585
605
  retune() {
586
606
  if (this.nextBeatWithinSeconds === void 0) return;
587
- const proposed = Math.round(this.nextBeatWithinSeconds / 3 * 1e3);
588
- 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));
589
608
  }
590
609
  react(outcome) {
591
610
  if (outcome.kind === "ok") {
@@ -594,15 +613,22 @@ var HeartbeatPublisher = class extends Publisher {
594
613
  }
595
614
  return super.react(outcome);
596
615
  }
597
- /** 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
+ */
598
621
  scheduleNext(minDelayMs = 0) {
599
- 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;
600
624
  const jitter = Math.round(Math.random() * HEARTBEAT_JITTER_MS);
601
- const target = this.startedAt + this.beats * this.interval + jitter;
602
- 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);
603
626
  this.schedule(delay, () => this.tick());
604
627
  }
605
628
  };
629
+ function clampInterval(ms) {
630
+ return Math.min(Math.max(ms, HEARTBEAT_MIN_INTERVAL_MS), HEARTBEAT_MAX_INTERVAL_MS);
631
+ }
606
632
  var CommandsPublisher = class extends Publisher {
607
633
  rest;
608
634
  options;
@@ -627,7 +653,7 @@ var CommandsPublisher = class extends Publisher {
627
653
  status: null,
628
654
  skipped: "disabled"
629
655
  };
630
- if (Date.now() < this.suspendedUntil) return {
656
+ if (performance.now() < this.suspendedUntil) return {
631
657
  ok: false,
632
658
  status: null,
633
659
  skipped: "plan"
@@ -673,10 +699,15 @@ var CommandsPublisher = class extends Publisher {
673
699
  * client.login(process.env.DISCORD_TOKEN);
674
700
  * ```
675
701
  */
676
- /** Kept in step with package.json by `test/version.test.ts`. */
677
- const version = "0.0.0";
702
+ /** This package's version, inlined from package.json at build time. */
703
+ const version = version$2;
678
704
  const DEFAULT_BASE_URL = "https://thediscordlist.com/api";
679
705
  const LOG_PREFIX = "[@thediscordlist/discord.js]";
706
+ const DISABLED = Object.freeze({
707
+ ok: false,
708
+ status: null,
709
+ skipped: "disabled"
710
+ });
680
711
  var Autoposter = class extends EventEmitter {
681
712
  stats;
682
713
  heartbeat;
@@ -766,25 +797,13 @@ var Autoposter = class extends EventEmitter {
766
797
  this.commands?.stop();
767
798
  }
768
799
  postStats() {
769
- return this.stats?.post() ?? Promise.resolve({
770
- ok: false,
771
- status: null,
772
- skipped: "disabled"
773
- });
800
+ return this.stats?.post() ?? Promise.resolve(DISABLED);
774
801
  }
775
802
  postHeartbeat() {
776
- return this.heartbeat?.post() ?? Promise.resolve({
777
- ok: false,
778
- status: null,
779
- skipped: "disabled"
780
- });
803
+ return this.heartbeat?.post() ?? Promise.resolve(DISABLED);
781
804
  }
782
805
  postCommands() {
783
- return this.commands?.post() ?? Promise.resolve({
784
- ok: false,
785
- status: null,
786
- skipped: "disabled"
787
- });
806
+ return this.commands?.post() ?? Promise.resolve(DISABLED);
788
807
  }
789
808
  /** Clears every timer and detaches every client listener. Not restartable. */
790
809
  stop() {
@@ -807,13 +826,14 @@ function checkIntents(client, poster) {
807
826
  */
808
827
  function autopost(client, token, options = {}) {
809
828
  const poster = new Autoposter();
810
- if (!token) {
829
+ const cleaned = token?.trim().replace(/^bearer\s+/i, "");
830
+ if (!cleaned) {
811
831
  poster.warnOnce("no-token", "No stats token supplied; auto-posting is disabled.");
812
832
  return poster;
813
833
  }
814
834
  const api = createApiClient({
815
835
  baseUrl: options.baseUrl ?? "https://thediscordlist.com/api",
816
- token,
836
+ token: cleaned,
817
837
  userAgent: `@thediscordlist/discord.js/${version} (discord.js ${version$1})`
818
838
  });
819
839
  if (options.botId) {
@@ -836,4 +856,4 @@ function autopost(client, token, options = {}) {
836
856
  return poster;
837
857
  }
838
858
  //#endregion
839
- 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.0",
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",