@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 +36 -10
- package/dist/index.cjs +103 -79
- package/dist/index.d.cts +22 -8
- package/dist/index.d.mts +22 -8
- package/dist/index.mjs +100 -80
- package/dist/webhooks.cjs +31 -16
- package/dist/webhooks.d.cts +7 -3
- package/dist/webhooks.d.mts +7 -3
- package/dist/webhooks.mjs +31 -16
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
|
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
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
stale number beats a false "down"
|
|
256
|
-
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
63
|
-
if (
|
|
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(`${
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 (
|
|
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 &&
|
|
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 =
|
|
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
|
-
|
|
502
|
-
|
|
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.
|
|
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.
|
|
534
|
+
/** Out-of-band beat after a shard event. Calls before it fires coalesce into one. */
|
|
520
535
|
beatNow() {
|
|
521
|
-
|
|
522
|
-
this.
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
/**
|
|
678
|
-
const version =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
119
|
-
declare const version
|
|
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:
|
|
140
|
+
/** Default: {@link STATS_INTERVAL_MS}. */
|
|
130
141
|
interval?: number | undefined;
|
|
131
|
-
/** Trailing debounce after guildCreate/guildDelete. Default:
|
|
142
|
+
/** Trailing debounce after guildCreate/guildDelete. Default: {@link STATS_DEBOUNCE_MS}. */
|
|
132
143
|
debounce?: number | undefined;
|
|
133
|
-
/** Hard floor between two POSTs. Default:
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
119
|
-
declare const version
|
|
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:
|
|
140
|
+
/** Default: {@link STATS_INTERVAL_MS}. */
|
|
130
141
|
interval?: number | undefined;
|
|
131
|
-
/** Trailing debounce after guildCreate/guildDelete. Default:
|
|
142
|
+
/** Trailing debounce after guildCreate/guildDelete. Default: {@link STATS_DEBOUNCE_MS}. */
|
|
132
143
|
debounce?: number | undefined;
|
|
133
|
-
/** Hard floor between two POSTs. Default:
|
|
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
|
-
/**
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
62
|
-
if (
|
|
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(`${
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 (
|
|
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 &&
|
|
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 =
|
|
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
|
-
|
|
501
|
-
|
|
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.
|
|
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.
|
|
533
|
+
/** Out-of-band beat after a shard event. Calls before it fires coalesce into one. */
|
|
519
534
|
beatNow() {
|
|
520
|
-
|
|
521
|
-
this.
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
/**
|
|
677
|
-
const version =
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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(
|
|
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));
|
package/dist/webhooks.d.cts
CHANGED
|
@@ -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
|
|
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}
|
|
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.d.mts
CHANGED
|
@@ -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
|
|
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}
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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(
|
|
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