@thediscordlist/discord.js 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -10
- package/dist/index.cjs +100 -78
- package/dist/index.d.cts +20 -6
- package/dist/index.d.mts +20 -6
- package/dist/index.mjs +97 -79
- 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
|
@@ -2,11 +2,16 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
|
2
2
|
let node_events = require("node:events");
|
|
3
3
|
let discord_js = require("discord.js");
|
|
4
4
|
//#region package.json
|
|
5
|
-
var version$1 = "0.
|
|
5
|
+
var version$1 = "0.2.0";
|
|
6
|
+
/** However far out Retry-After or X-RateLimit-Reset points, never go quiet for longer. */
|
|
7
|
+
const MAX_RATE_LIMIT_WAIT_MS = 36e5;
|
|
8
|
+
const MAX_ATTEMPTS = 3;
|
|
6
9
|
/**
|
|
7
|
-
* Drops `undefined` keys recursively
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
+
* Drops `undefined` keys recursively, so an unset value arrives absent rather
|
|
11
|
+
* than `null`. The API's `prepareForValidation` only defaults keys that are
|
|
12
|
+
* *absent*, and `cluster`, `cluster_count` and `shards` are not `nullable`, so
|
|
13
|
+
* a `null` there is a 422. An explicit `null` survives: where a rule is
|
|
14
|
+
* nullable it is a deliberate "unknown".
|
|
10
15
|
*/
|
|
11
16
|
function stripUndefined(value) {
|
|
12
17
|
if (Array.isArray(value)) return value.map((item) => stripUndefined(item));
|
|
@@ -15,6 +20,9 @@ function stripUndefined(value) {
|
|
|
15
20
|
for (const [key, item] of Object.entries(value)) if (item !== void 0) out[key] = stripUndefined(item);
|
|
16
21
|
return out;
|
|
17
22
|
}
|
|
23
|
+
function isRetryable(outcome) {
|
|
24
|
+
return outcome.kind === "transient" || outcome.kind === "throttled";
|
|
25
|
+
}
|
|
18
26
|
/** Full jitter in the upper half, capped at 30s. */
|
|
19
27
|
function backoffMs(attempt, random = Math.random) {
|
|
20
28
|
return Math.round(Math.min(1e3 * 2 ** attempt, 3e4) * (.5 + random() * .5));
|
|
@@ -41,10 +49,6 @@ function classify(status, body, retryAfterHeader) {
|
|
|
41
49
|
kind: "config",
|
|
42
50
|
status
|
|
43
51
|
};
|
|
44
|
-
if (status === 400 || status === 422) return {
|
|
45
|
-
kind: "payload",
|
|
46
|
-
status
|
|
47
|
-
};
|
|
48
52
|
if (status === 429) {
|
|
49
53
|
const seconds = Number(retryAfterHeader);
|
|
50
54
|
return {
|
|
@@ -53,22 +57,32 @@ function classify(status, body, retryAfterHeader) {
|
|
|
53
57
|
retryAfterMs: Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : 6e4
|
|
54
58
|
};
|
|
55
59
|
}
|
|
60
|
+
if (status >= 400 && status < 500 && status !== 408 && status !== 425) return {
|
|
61
|
+
kind: "payload",
|
|
62
|
+
status
|
|
63
|
+
};
|
|
56
64
|
return {
|
|
57
65
|
kind: "transient",
|
|
58
66
|
status,
|
|
59
67
|
error: /* @__PURE__ */ new Error(`TheDiscordList responded ${status}.`)
|
|
60
68
|
};
|
|
61
69
|
}
|
|
70
|
+
function sleep(ms) {
|
|
71
|
+
return new Promise((resolve) => {
|
|
72
|
+
setTimeout(resolve, ms).unref?.();
|
|
73
|
+
});
|
|
74
|
+
}
|
|
62
75
|
function createApiClient(options) {
|
|
76
|
+
const baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
63
77
|
let suspendedUntil = 0;
|
|
64
|
-
|
|
65
|
-
if (
|
|
78
|
+
async function once(path, body, ignoreLimit) {
|
|
79
|
+
if (!ignoreLimit && performance.now() < suspendedUntil) return {
|
|
66
80
|
kind: "dropped",
|
|
67
81
|
status: null
|
|
68
82
|
};
|
|
69
83
|
let response;
|
|
70
84
|
try {
|
|
71
|
-
response = await fetch(`${
|
|
85
|
+
response = await fetch(`${baseUrl}${path}`, {
|
|
72
86
|
method: "POST",
|
|
73
87
|
headers: {
|
|
74
88
|
authorization: `Bearer ${options.token}`,
|
|
@@ -104,8 +118,22 @@ function createApiClient(options) {
|
|
|
104
118
|
const outcome = classify(response.status, parsed, response.headers.get("retry-after"));
|
|
105
119
|
if (outcome.kind === "throttled") {
|
|
106
120
|
const reset = Number(response.headers.get("x-ratelimit-reset"));
|
|
107
|
-
const untilReset = Number.isFinite(reset) && reset > 0 ? reset * 1e3 : 0;
|
|
108
|
-
suspendedUntil =
|
|
121
|
+
const untilReset = Number.isFinite(reset) && reset > 0 ? reset * 1e3 - Date.now() : 0;
|
|
122
|
+
suspendedUntil = performance.now() + Math.min(Math.max(outcome.retryAfterMs, untilReset), MAX_RATE_LIMIT_WAIT_MS) + Math.round(Math.random() * 2e3);
|
|
123
|
+
}
|
|
124
|
+
return outcome;
|
|
125
|
+
}
|
|
126
|
+
return { async post(path, body, priority, nextTickInMs) {
|
|
127
|
+
let outcome = {
|
|
128
|
+
kind: "dropped",
|
|
129
|
+
status: null
|
|
130
|
+
};
|
|
131
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
132
|
+
outcome = await once(path, body, priority === "heartbeat" || outcome.kind === "throttled");
|
|
133
|
+
if (!isRetryable(outcome) || attempt === 2) break;
|
|
134
|
+
const delay = outcome.kind === "throttled" ? suspendedUntil - performance.now() : backoffMs(attempt);
|
|
135
|
+
if (nextTickInMs !== void 0 && delay >= nextTickInMs) break;
|
|
136
|
+
await sleep(delay);
|
|
109
137
|
}
|
|
110
138
|
return outcome;
|
|
111
139
|
} };
|
|
@@ -245,16 +273,24 @@ async function measureServerCount(client, topology, getServerCount) {
|
|
|
245
273
|
}
|
|
246
274
|
return total;
|
|
247
275
|
}
|
|
276
|
+
//#endregion
|
|
277
|
+
//#region src/publishers.ts
|
|
278
|
+
const STATS_INTERVAL_MS = 3e5;
|
|
279
|
+
const STATS_DEBOUNCE_MS = 2e4;
|
|
280
|
+
const STATS_MIN_GAP_MS = 6e4;
|
|
248
281
|
const STATS_FIRST_POST_DELAY_MS = 5e3;
|
|
249
282
|
const STATS_REMEASURE_MS = 6e4;
|
|
283
|
+
const HEARTBEAT_DEFAULT_INTERVAL_MS = 3e4;
|
|
250
284
|
const HEARTBEAT_MIN_INTERVAL_MS = 15e3;
|
|
251
285
|
const HEARTBEAT_MAX_INTERVAL_MS = 6e4;
|
|
252
286
|
const HEARTBEAT_JITTER_MS = 5e3;
|
|
287
|
+
/** An out-of-band beat never follows the previous beat more closely than this. */
|
|
288
|
+
const HEARTBEAT_OUT_OF_BAND_GAP_MS = 1e4;
|
|
289
|
+
const BEAT_NOW_DELAY_MS = 1e3;
|
|
253
290
|
/** Offset from the stats post: the two share one server-side rate budget. */
|
|
254
291
|
const COMMANDS_DELAY_MS = 1e4;
|
|
255
292
|
/** The Gold tier is derived per request from Stripe, so it can flip mid-run. */
|
|
256
293
|
const PLAN_REPROBE_MS = 18e5;
|
|
257
|
-
const MAX_ATTEMPTS = 3;
|
|
258
294
|
function publisherError(message, type, status, retryable) {
|
|
259
295
|
return Object.assign(new Error(message), {
|
|
260
296
|
type,
|
|
@@ -262,11 +298,6 @@ function publisherError(message, type, status, retryable) {
|
|
|
262
298
|
retryable
|
|
263
299
|
});
|
|
264
300
|
}
|
|
265
|
-
function sleep(ms) {
|
|
266
|
-
return new Promise((resolve) => {
|
|
267
|
-
setTimeout(resolve, ms).unref?.();
|
|
268
|
-
});
|
|
269
|
-
}
|
|
270
301
|
var Publisher = class {
|
|
271
302
|
deps;
|
|
272
303
|
timer;
|
|
@@ -314,27 +345,9 @@ var Publisher = class {
|
|
|
314
345
|
this.reported.add(status);
|
|
315
346
|
this.deps.emit.error(publisherError(message, this.kind, status, retryable));
|
|
316
347
|
}
|
|
317
|
-
/**
|
|
318
|
-
* Sends one request, retrying only transient failures.
|
|
319
|
-
*
|
|
320
|
-
* `nextTickInMs` is the time until this publisher's next scheduled run. If a
|
|
321
|
-
* backoff would outlast it, the attempt is dropped: the next tick carries
|
|
322
|
-
* fresher data, and a failed request still burns rate-limit budget.
|
|
323
|
-
*/
|
|
348
|
+
/** `nextTickInMs` is the time until this publisher's next scheduled run; no retry outlasts it. */
|
|
324
349
|
async send(path, body, priority, nextTickInMs) {
|
|
325
|
-
|
|
326
|
-
kind: "dropped",
|
|
327
|
-
status: null
|
|
328
|
-
};
|
|
329
|
-
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
330
|
-
outcome = await this.deps.api.post(path, body, priority);
|
|
331
|
-
if (outcome.kind !== "transient") break;
|
|
332
|
-
const delay = backoffMs(attempt);
|
|
333
|
-
if (attempt === 2) break;
|
|
334
|
-
if (nextTickInMs !== void 0 && delay >= nextTickInMs) break;
|
|
335
|
-
await sleep(delay);
|
|
336
|
-
}
|
|
337
|
-
return this.react(outcome);
|
|
350
|
+
return this.react(await this.deps.api.post(path, body, priority, nextTickInMs));
|
|
338
351
|
}
|
|
339
352
|
react(outcome) {
|
|
340
353
|
switch (outcome.kind) {
|
|
@@ -366,7 +379,7 @@ var Publisher = class {
|
|
|
366
379
|
status: 401
|
|
367
380
|
};
|
|
368
381
|
case "plan":
|
|
369
|
-
this.suspendedUntil =
|
|
382
|
+
this.suspendedUntil = performance.now() + PLAN_REPROBE_MS;
|
|
370
383
|
this.reportOnce(403, `The ${this.kind} endpoint needs a higher plan. Reprobing in 30 minutes.`, false);
|
|
371
384
|
return {
|
|
372
385
|
ok: false,
|
|
@@ -403,7 +416,7 @@ var StatsPublisher = class extends Publisher {
|
|
|
403
416
|
debounce;
|
|
404
417
|
minGap;
|
|
405
418
|
getServerCount;
|
|
406
|
-
lastPostAt =
|
|
419
|
+
lastPostAt = -Infinity;
|
|
407
420
|
lastCount;
|
|
408
421
|
lastShardCount;
|
|
409
422
|
debounceCap = 0;
|
|
@@ -421,7 +434,7 @@ var StatsPublisher = class extends Publisher {
|
|
|
421
434
|
/** Trailing debounce, hard-capped so a join storm cannot starve the post. */
|
|
422
435
|
onGuildChange() {
|
|
423
436
|
if (this.disabledReason !== void 0) return;
|
|
424
|
-
const now =
|
|
437
|
+
const now = performance.now();
|
|
425
438
|
if (this.debounceCap === 0) this.debounceCap = now + this.minGap;
|
|
426
439
|
this.schedule(Math.min(now + this.debounce, this.debounceCap) - now, () => this.tick());
|
|
427
440
|
}
|
|
@@ -444,7 +457,7 @@ var StatsPublisher = class extends Publisher {
|
|
|
444
457
|
status: null,
|
|
445
458
|
skipped: "in-flight"
|
|
446
459
|
};
|
|
447
|
-
const now =
|
|
460
|
+
const now = performance.now();
|
|
448
461
|
if (!manual && now < this.suspendedUntil) {
|
|
449
462
|
this.schedule(this.suspendedUntil - now, () => this.tick());
|
|
450
463
|
return {
|
|
@@ -453,7 +466,7 @@ var StatsPublisher = class extends Publisher {
|
|
|
453
466
|
skipped: "plan"
|
|
454
467
|
};
|
|
455
468
|
}
|
|
456
|
-
if (
|
|
469
|
+
if (now < this.lastPostAt + this.minGap) {
|
|
457
470
|
this.schedule(this.lastPostAt + this.minGap - now, () => this.tick());
|
|
458
471
|
return {
|
|
459
472
|
ok: false,
|
|
@@ -473,7 +486,7 @@ var StatsPublisher = class extends Publisher {
|
|
|
473
486
|
};
|
|
474
487
|
}
|
|
475
488
|
const shardCount = this.deps.topology.shardCount;
|
|
476
|
-
if (count === this.lastCount && shardCount === this.lastShardCount &&
|
|
489
|
+
if (count === this.lastCount && shardCount === this.lastShardCount && performance.now() < this.lastPostAt + 18e5) {
|
|
477
490
|
this.schedule(this.interval, () => this.tick());
|
|
478
491
|
return {
|
|
479
492
|
ok: false,
|
|
@@ -481,7 +494,7 @@ var StatsPublisher = class extends Publisher {
|
|
|
481
494
|
skipped: "unchanged"
|
|
482
495
|
};
|
|
483
496
|
}
|
|
484
|
-
this.lastPostAt =
|
|
497
|
+
this.lastPostAt = performance.now();
|
|
485
498
|
const result = await this.send(`/bots/${this.deps.botId}/stats`, {
|
|
486
499
|
server_count: count,
|
|
487
500
|
shard_count: shardCount
|
|
@@ -500,13 +513,14 @@ var StatsPublisher = class extends Publisher {
|
|
|
500
513
|
var HeartbeatPublisher = class extends Publisher {
|
|
501
514
|
kind = "heartbeat";
|
|
502
515
|
interval;
|
|
503
|
-
|
|
504
|
-
|
|
516
|
+
nextTarget = 0;
|
|
517
|
+
lastBeatAt = -Infinity;
|
|
518
|
+
beatNowPending = false;
|
|
505
519
|
inFlight = false;
|
|
506
520
|
nextBeatWithinSeconds;
|
|
507
521
|
constructor(deps, options = {}) {
|
|
508
522
|
super(deps);
|
|
509
|
-
this.interval = options.interval ?? 3e4;
|
|
523
|
+
this.interval = clampInterval(options.interval ?? 3e4);
|
|
510
524
|
}
|
|
511
525
|
/** Starts on construction, not on ready: a booting bot must read "connecting", not dead. */
|
|
512
526
|
start() {
|
|
@@ -514,14 +528,16 @@ var HeartbeatPublisher = class extends Publisher {
|
|
|
514
528
|
this.disable(null, "cluster-count-unknown");
|
|
515
529
|
return;
|
|
516
530
|
}
|
|
517
|
-
this.
|
|
518
|
-
this.beats = 0;
|
|
531
|
+
this.nextTarget = performance.now();
|
|
519
532
|
this.schedule(0, () => this.tick());
|
|
520
533
|
}
|
|
521
|
-
/** Out-of-band beat after a shard event.
|
|
534
|
+
/** Out-of-band beat after a shard event. Calls before it fires coalesce into one. */
|
|
522
535
|
beatNow() {
|
|
523
|
-
|
|
524
|
-
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());
|
|
525
541
|
}
|
|
526
542
|
async post() {
|
|
527
543
|
this.rearm();
|
|
@@ -533,6 +549,7 @@ var HeartbeatPublisher = class extends Publisher {
|
|
|
533
549
|
return this.beat(true);
|
|
534
550
|
}
|
|
535
551
|
async tick() {
|
|
552
|
+
this.beatNowPending = false;
|
|
536
553
|
await this.beat(false);
|
|
537
554
|
}
|
|
538
555
|
async beat(manual) {
|
|
@@ -567,6 +584,7 @@ var HeartbeatPublisher = class extends Publisher {
|
|
|
567
584
|
shards
|
|
568
585
|
};
|
|
569
586
|
this.inFlight = true;
|
|
587
|
+
this.lastBeatAt = performance.now();
|
|
570
588
|
let result;
|
|
571
589
|
try {
|
|
572
590
|
result = await this.send(`/bots/${this.deps.botId}/heartbeat`, body, "heartbeat", this.interval);
|
|
@@ -587,8 +605,7 @@ var HeartbeatPublisher = class extends Publisher {
|
|
|
587
605
|
*/
|
|
588
606
|
retune() {
|
|
589
607
|
if (this.nextBeatWithinSeconds === void 0) return;
|
|
590
|
-
|
|
591
|
-
this.interval = Math.min(Math.max(proposed, HEARTBEAT_MIN_INTERVAL_MS), HEARTBEAT_MAX_INTERVAL_MS);
|
|
608
|
+
this.interval = clampInterval(Math.round(this.nextBeatWithinSeconds / 3 * 1e3));
|
|
592
609
|
}
|
|
593
610
|
react(outcome) {
|
|
594
611
|
if (outcome.kind === "ok") {
|
|
@@ -597,15 +614,22 @@ var HeartbeatPublisher = class extends Publisher {
|
|
|
597
614
|
}
|
|
598
615
|
return super.react(outcome);
|
|
599
616
|
}
|
|
600
|
-
/**
|
|
617
|
+
/**
|
|
618
|
+
* Absolute targets, so a slow request cannot drift the cadence and an
|
|
619
|
+
* out-of-band beat does not push the next regular one back. Missed slots
|
|
620
|
+
* are skipped, so the end of a long suspension is one beat, not a burst.
|
|
621
|
+
*/
|
|
601
622
|
scheduleNext(minDelayMs = 0) {
|
|
602
|
-
|
|
623
|
+
const now = performance.now();
|
|
624
|
+
if (this.nextTarget <= now) this.nextTarget += (Math.floor((now - this.nextTarget) / this.interval) + 1) * this.interval;
|
|
603
625
|
const jitter = Math.round(Math.random() * HEARTBEAT_JITTER_MS);
|
|
604
|
-
const
|
|
605
|
-
const delay = Math.max(target - Date.now(), minDelayMs, 1e3, this.suspendedUntil - Date.now());
|
|
626
|
+
const delay = Math.max(this.nextTarget + jitter - now, minDelayMs, BEAT_NOW_DELAY_MS, this.suspendedUntil - now);
|
|
606
627
|
this.schedule(delay, () => this.tick());
|
|
607
628
|
}
|
|
608
629
|
};
|
|
630
|
+
function clampInterval(ms) {
|
|
631
|
+
return Math.min(Math.max(ms, HEARTBEAT_MIN_INTERVAL_MS), HEARTBEAT_MAX_INTERVAL_MS);
|
|
632
|
+
}
|
|
609
633
|
var CommandsPublisher = class extends Publisher {
|
|
610
634
|
rest;
|
|
611
635
|
options;
|
|
@@ -630,7 +654,7 @@ var CommandsPublisher = class extends Publisher {
|
|
|
630
654
|
status: null,
|
|
631
655
|
skipped: "disabled"
|
|
632
656
|
};
|
|
633
|
-
if (
|
|
657
|
+
if (performance.now() < this.suspendedUntil) return {
|
|
634
658
|
ok: false,
|
|
635
659
|
status: null,
|
|
636
660
|
skipped: "plan"
|
|
@@ -680,6 +704,11 @@ var CommandsPublisher = class extends Publisher {
|
|
|
680
704
|
const version = version$1;
|
|
681
705
|
const DEFAULT_BASE_URL = "https://thediscordlist.com/api";
|
|
682
706
|
const LOG_PREFIX = "[@thediscordlist/discord.js]";
|
|
707
|
+
const DISABLED = Object.freeze({
|
|
708
|
+
ok: false,
|
|
709
|
+
status: null,
|
|
710
|
+
skipped: "disabled"
|
|
711
|
+
});
|
|
683
712
|
var Autoposter = class extends node_events.EventEmitter {
|
|
684
713
|
stats;
|
|
685
714
|
heartbeat;
|
|
@@ -769,25 +798,13 @@ var Autoposter = class extends node_events.EventEmitter {
|
|
|
769
798
|
this.commands?.stop();
|
|
770
799
|
}
|
|
771
800
|
postStats() {
|
|
772
|
-
return this.stats?.post() ?? Promise.resolve(
|
|
773
|
-
ok: false,
|
|
774
|
-
status: null,
|
|
775
|
-
skipped: "disabled"
|
|
776
|
-
});
|
|
801
|
+
return this.stats?.post() ?? Promise.resolve(DISABLED);
|
|
777
802
|
}
|
|
778
803
|
postHeartbeat() {
|
|
779
|
-
return this.heartbeat?.post() ?? Promise.resolve(
|
|
780
|
-
ok: false,
|
|
781
|
-
status: null,
|
|
782
|
-
skipped: "disabled"
|
|
783
|
-
});
|
|
804
|
+
return this.heartbeat?.post() ?? Promise.resolve(DISABLED);
|
|
784
805
|
}
|
|
785
806
|
postCommands() {
|
|
786
|
-
return this.commands?.post() ?? Promise.resolve(
|
|
787
|
-
ok: false,
|
|
788
|
-
status: null,
|
|
789
|
-
skipped: "disabled"
|
|
790
|
-
});
|
|
807
|
+
return this.commands?.post() ?? Promise.resolve(DISABLED);
|
|
791
808
|
}
|
|
792
809
|
/** Clears every timer and detaches every client listener. Not restartable. */
|
|
793
810
|
stop() {
|
|
@@ -810,13 +827,14 @@ function checkIntents(client, poster) {
|
|
|
810
827
|
*/
|
|
811
828
|
function autopost(client, token, options = {}) {
|
|
812
829
|
const poster = new Autoposter();
|
|
813
|
-
|
|
830
|
+
const cleaned = token?.trim().replace(/^bearer\s+/i, "");
|
|
831
|
+
if (!cleaned) {
|
|
814
832
|
poster.warnOnce("no-token", "No stats token supplied; auto-posting is disabled.");
|
|
815
833
|
return poster;
|
|
816
834
|
}
|
|
817
835
|
const api = createApiClient({
|
|
818
836
|
baseUrl: options.baseUrl ?? "https://thediscordlist.com/api",
|
|
819
|
-
token,
|
|
837
|
+
token: cleaned,
|
|
820
838
|
userAgent: `@thediscordlist/discord.js/${version} (discord.js ${discord_js.version})`
|
|
821
839
|
});
|
|
822
840
|
if (options.botId) {
|
|
@@ -841,5 +859,9 @@ function autopost(client, token, options = {}) {
|
|
|
841
859
|
//#endregion
|
|
842
860
|
exports.Autoposter = Autoposter;
|
|
843
861
|
exports.DEFAULT_BASE_URL = DEFAULT_BASE_URL;
|
|
862
|
+
exports.HEARTBEAT_DEFAULT_INTERVAL_MS = HEARTBEAT_DEFAULT_INTERVAL_MS;
|
|
863
|
+
exports.STATS_DEBOUNCE_MS = STATS_DEBOUNCE_MS;
|
|
864
|
+
exports.STATS_INTERVAL_MS = STATS_INTERVAL_MS;
|
|
865
|
+
exports.STATS_MIN_GAP_MS = STATS_MIN_GAP_MS;
|
|
844
866
|
exports.autopost = autopost;
|
|
845
867
|
exports.version = version;
|
package/dist/index.d.cts
CHANGED
|
@@ -31,7 +31,14 @@ type Outcome = {
|
|
|
31
31
|
};
|
|
32
32
|
type Priority = 'normal' | 'heartbeat';
|
|
33
33
|
interface ApiClient {
|
|
34
|
-
|
|
34
|
+
/**
|
|
35
|
+
* One request, retrying transient failures and, once its window lifts, a 429.
|
|
36
|
+
*
|
|
37
|
+
* `nextTickInMs` is the time until the caller runs again. If a wait would
|
|
38
|
+
* outlast it, the attempt is dropped: the next tick carries fresher data,
|
|
39
|
+
* and a failed request still burns rate-limit budget.
|
|
40
|
+
*/
|
|
41
|
+
post(path: string, body: unknown, priority: Priority, nextTickInMs?: number): Promise<Outcome>;
|
|
35
42
|
}
|
|
36
43
|
//#endregion
|
|
37
44
|
//#region src/topology.d.ts
|
|
@@ -89,6 +96,10 @@ interface ClientLike {
|
|
|
89
96
|
}
|
|
90
97
|
//#endregion
|
|
91
98
|
//#region src/publishers.d.ts
|
|
99
|
+
declare const STATS_INTERVAL_MS = 300000;
|
|
100
|
+
declare const STATS_DEBOUNCE_MS = 20000;
|
|
101
|
+
declare const STATS_MIN_GAP_MS = 60000;
|
|
102
|
+
declare const HEARTBEAT_DEFAULT_INTERVAL_MS = 30000;
|
|
92
103
|
type PublisherKind = 'stats' | 'heartbeat' | 'commands';
|
|
93
104
|
interface PostResult {
|
|
94
105
|
ok: boolean;
|
|
@@ -126,17 +137,20 @@ interface AutopostOptions {
|
|
|
126
137
|
stats?: {
|
|
127
138
|
/** Default: true. */
|
|
128
139
|
enabled?: boolean | undefined;
|
|
129
|
-
/** Default:
|
|
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;
|
|
@@ -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,11 +1,16 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
2
|
import { Events, GatewayIntentBits, Status, version as version$1 } from "discord.js";
|
|
3
3
|
//#region package.json
|
|
4
|
-
var version$2 = "0.
|
|
4
|
+
var version$2 = "0.2.0";
|
|
5
|
+
/** However far out Retry-After or X-RateLimit-Reset points, never go quiet for longer. */
|
|
6
|
+
const MAX_RATE_LIMIT_WAIT_MS = 36e5;
|
|
7
|
+
const MAX_ATTEMPTS = 3;
|
|
5
8
|
/**
|
|
6
|
-
* Drops `undefined` keys recursively
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
+
* Drops `undefined` keys recursively, so an unset value arrives absent rather
|
|
10
|
+
* than `null`. The API's `prepareForValidation` only defaults keys that are
|
|
11
|
+
* *absent*, and `cluster`, `cluster_count` and `shards` are not `nullable`, so
|
|
12
|
+
* a `null` there is a 422. An explicit `null` survives: where a rule is
|
|
13
|
+
* nullable it is a deliberate "unknown".
|
|
9
14
|
*/
|
|
10
15
|
function stripUndefined(value) {
|
|
11
16
|
if (Array.isArray(value)) return value.map((item) => stripUndefined(item));
|
|
@@ -14,6 +19,9 @@ function stripUndefined(value) {
|
|
|
14
19
|
for (const [key, item] of Object.entries(value)) if (item !== void 0) out[key] = stripUndefined(item);
|
|
15
20
|
return out;
|
|
16
21
|
}
|
|
22
|
+
function isRetryable(outcome) {
|
|
23
|
+
return outcome.kind === "transient" || outcome.kind === "throttled";
|
|
24
|
+
}
|
|
17
25
|
/** Full jitter in the upper half, capped at 30s. */
|
|
18
26
|
function backoffMs(attempt, random = Math.random) {
|
|
19
27
|
return Math.round(Math.min(1e3 * 2 ** attempt, 3e4) * (.5 + random() * .5));
|
|
@@ -40,10 +48,6 @@ function classify(status, body, retryAfterHeader) {
|
|
|
40
48
|
kind: "config",
|
|
41
49
|
status
|
|
42
50
|
};
|
|
43
|
-
if (status === 400 || status === 422) return {
|
|
44
|
-
kind: "payload",
|
|
45
|
-
status
|
|
46
|
-
};
|
|
47
51
|
if (status === 429) {
|
|
48
52
|
const seconds = Number(retryAfterHeader);
|
|
49
53
|
return {
|
|
@@ -52,22 +56,32 @@ function classify(status, body, retryAfterHeader) {
|
|
|
52
56
|
retryAfterMs: Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : 6e4
|
|
53
57
|
};
|
|
54
58
|
}
|
|
59
|
+
if (status >= 400 && status < 500 && status !== 408 && status !== 425) return {
|
|
60
|
+
kind: "payload",
|
|
61
|
+
status
|
|
62
|
+
};
|
|
55
63
|
return {
|
|
56
64
|
kind: "transient",
|
|
57
65
|
status,
|
|
58
66
|
error: /* @__PURE__ */ new Error(`TheDiscordList responded ${status}.`)
|
|
59
67
|
};
|
|
60
68
|
}
|
|
69
|
+
function sleep(ms) {
|
|
70
|
+
return new Promise((resolve) => {
|
|
71
|
+
setTimeout(resolve, ms).unref?.();
|
|
72
|
+
});
|
|
73
|
+
}
|
|
61
74
|
function createApiClient(options) {
|
|
75
|
+
const baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
62
76
|
let suspendedUntil = 0;
|
|
63
|
-
|
|
64
|
-
if (
|
|
77
|
+
async function once(path, body, ignoreLimit) {
|
|
78
|
+
if (!ignoreLimit && performance.now() < suspendedUntil) return {
|
|
65
79
|
kind: "dropped",
|
|
66
80
|
status: null
|
|
67
81
|
};
|
|
68
82
|
let response;
|
|
69
83
|
try {
|
|
70
|
-
response = await fetch(`${
|
|
84
|
+
response = await fetch(`${baseUrl}${path}`, {
|
|
71
85
|
method: "POST",
|
|
72
86
|
headers: {
|
|
73
87
|
authorization: `Bearer ${options.token}`,
|
|
@@ -103,8 +117,22 @@ function createApiClient(options) {
|
|
|
103
117
|
const outcome = classify(response.status, parsed, response.headers.get("retry-after"));
|
|
104
118
|
if (outcome.kind === "throttled") {
|
|
105
119
|
const reset = Number(response.headers.get("x-ratelimit-reset"));
|
|
106
|
-
const untilReset = Number.isFinite(reset) && reset > 0 ? reset * 1e3 : 0;
|
|
107
|
-
suspendedUntil =
|
|
120
|
+
const untilReset = Number.isFinite(reset) && reset > 0 ? reset * 1e3 - Date.now() : 0;
|
|
121
|
+
suspendedUntil = performance.now() + Math.min(Math.max(outcome.retryAfterMs, untilReset), MAX_RATE_LIMIT_WAIT_MS) + Math.round(Math.random() * 2e3);
|
|
122
|
+
}
|
|
123
|
+
return outcome;
|
|
124
|
+
}
|
|
125
|
+
return { async post(path, body, priority, nextTickInMs) {
|
|
126
|
+
let outcome = {
|
|
127
|
+
kind: "dropped",
|
|
128
|
+
status: null
|
|
129
|
+
};
|
|
130
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
131
|
+
outcome = await once(path, body, priority === "heartbeat" || outcome.kind === "throttled");
|
|
132
|
+
if (!isRetryable(outcome) || attempt === 2) break;
|
|
133
|
+
const delay = outcome.kind === "throttled" ? suspendedUntil - performance.now() : backoffMs(attempt);
|
|
134
|
+
if (nextTickInMs !== void 0 && delay >= nextTickInMs) break;
|
|
135
|
+
await sleep(delay);
|
|
108
136
|
}
|
|
109
137
|
return outcome;
|
|
110
138
|
} };
|
|
@@ -244,16 +272,24 @@ async function measureServerCount(client, topology, getServerCount) {
|
|
|
244
272
|
}
|
|
245
273
|
return total;
|
|
246
274
|
}
|
|
275
|
+
//#endregion
|
|
276
|
+
//#region src/publishers.ts
|
|
277
|
+
const STATS_INTERVAL_MS = 3e5;
|
|
278
|
+
const STATS_DEBOUNCE_MS = 2e4;
|
|
279
|
+
const STATS_MIN_GAP_MS = 6e4;
|
|
247
280
|
const STATS_FIRST_POST_DELAY_MS = 5e3;
|
|
248
281
|
const STATS_REMEASURE_MS = 6e4;
|
|
282
|
+
const HEARTBEAT_DEFAULT_INTERVAL_MS = 3e4;
|
|
249
283
|
const HEARTBEAT_MIN_INTERVAL_MS = 15e3;
|
|
250
284
|
const HEARTBEAT_MAX_INTERVAL_MS = 6e4;
|
|
251
285
|
const HEARTBEAT_JITTER_MS = 5e3;
|
|
286
|
+
/** An out-of-band beat never follows the previous beat more closely than this. */
|
|
287
|
+
const HEARTBEAT_OUT_OF_BAND_GAP_MS = 1e4;
|
|
288
|
+
const BEAT_NOW_DELAY_MS = 1e3;
|
|
252
289
|
/** Offset from the stats post: the two share one server-side rate budget. */
|
|
253
290
|
const COMMANDS_DELAY_MS = 1e4;
|
|
254
291
|
/** The Gold tier is derived per request from Stripe, so it can flip mid-run. */
|
|
255
292
|
const PLAN_REPROBE_MS = 18e5;
|
|
256
|
-
const MAX_ATTEMPTS = 3;
|
|
257
293
|
function publisherError(message, type, status, retryable) {
|
|
258
294
|
return Object.assign(new Error(message), {
|
|
259
295
|
type,
|
|
@@ -261,11 +297,6 @@ function publisherError(message, type, status, retryable) {
|
|
|
261
297
|
retryable
|
|
262
298
|
});
|
|
263
299
|
}
|
|
264
|
-
function sleep(ms) {
|
|
265
|
-
return new Promise((resolve) => {
|
|
266
|
-
setTimeout(resolve, ms).unref?.();
|
|
267
|
-
});
|
|
268
|
-
}
|
|
269
300
|
var Publisher = class {
|
|
270
301
|
deps;
|
|
271
302
|
timer;
|
|
@@ -313,27 +344,9 @@ var Publisher = class {
|
|
|
313
344
|
this.reported.add(status);
|
|
314
345
|
this.deps.emit.error(publisherError(message, this.kind, status, retryable));
|
|
315
346
|
}
|
|
316
|
-
/**
|
|
317
|
-
* Sends one request, retrying only transient failures.
|
|
318
|
-
*
|
|
319
|
-
* `nextTickInMs` is the time until this publisher's next scheduled run. If a
|
|
320
|
-
* backoff would outlast it, the attempt is dropped: the next tick carries
|
|
321
|
-
* fresher data, and a failed request still burns rate-limit budget.
|
|
322
|
-
*/
|
|
347
|
+
/** `nextTickInMs` is the time until this publisher's next scheduled run; no retry outlasts it. */
|
|
323
348
|
async send(path, body, priority, nextTickInMs) {
|
|
324
|
-
|
|
325
|
-
kind: "dropped",
|
|
326
|
-
status: null
|
|
327
|
-
};
|
|
328
|
-
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
329
|
-
outcome = await this.deps.api.post(path, body, priority);
|
|
330
|
-
if (outcome.kind !== "transient") break;
|
|
331
|
-
const delay = backoffMs(attempt);
|
|
332
|
-
if (attempt === 2) break;
|
|
333
|
-
if (nextTickInMs !== void 0 && delay >= nextTickInMs) break;
|
|
334
|
-
await sleep(delay);
|
|
335
|
-
}
|
|
336
|
-
return this.react(outcome);
|
|
349
|
+
return this.react(await this.deps.api.post(path, body, priority, nextTickInMs));
|
|
337
350
|
}
|
|
338
351
|
react(outcome) {
|
|
339
352
|
switch (outcome.kind) {
|
|
@@ -365,7 +378,7 @@ var Publisher = class {
|
|
|
365
378
|
status: 401
|
|
366
379
|
};
|
|
367
380
|
case "plan":
|
|
368
|
-
this.suspendedUntil =
|
|
381
|
+
this.suspendedUntil = performance.now() + PLAN_REPROBE_MS;
|
|
369
382
|
this.reportOnce(403, `The ${this.kind} endpoint needs a higher plan. Reprobing in 30 minutes.`, false);
|
|
370
383
|
return {
|
|
371
384
|
ok: false,
|
|
@@ -402,7 +415,7 @@ var StatsPublisher = class extends Publisher {
|
|
|
402
415
|
debounce;
|
|
403
416
|
minGap;
|
|
404
417
|
getServerCount;
|
|
405
|
-
lastPostAt =
|
|
418
|
+
lastPostAt = -Infinity;
|
|
406
419
|
lastCount;
|
|
407
420
|
lastShardCount;
|
|
408
421
|
debounceCap = 0;
|
|
@@ -420,7 +433,7 @@ var StatsPublisher = class extends Publisher {
|
|
|
420
433
|
/** Trailing debounce, hard-capped so a join storm cannot starve the post. */
|
|
421
434
|
onGuildChange() {
|
|
422
435
|
if (this.disabledReason !== void 0) return;
|
|
423
|
-
const now =
|
|
436
|
+
const now = performance.now();
|
|
424
437
|
if (this.debounceCap === 0) this.debounceCap = now + this.minGap;
|
|
425
438
|
this.schedule(Math.min(now + this.debounce, this.debounceCap) - now, () => this.tick());
|
|
426
439
|
}
|
|
@@ -443,7 +456,7 @@ var StatsPublisher = class extends Publisher {
|
|
|
443
456
|
status: null,
|
|
444
457
|
skipped: "in-flight"
|
|
445
458
|
};
|
|
446
|
-
const now =
|
|
459
|
+
const now = performance.now();
|
|
447
460
|
if (!manual && now < this.suspendedUntil) {
|
|
448
461
|
this.schedule(this.suspendedUntil - now, () => this.tick());
|
|
449
462
|
return {
|
|
@@ -452,7 +465,7 @@ var StatsPublisher = class extends Publisher {
|
|
|
452
465
|
skipped: "plan"
|
|
453
466
|
};
|
|
454
467
|
}
|
|
455
|
-
if (
|
|
468
|
+
if (now < this.lastPostAt + this.minGap) {
|
|
456
469
|
this.schedule(this.lastPostAt + this.minGap - now, () => this.tick());
|
|
457
470
|
return {
|
|
458
471
|
ok: false,
|
|
@@ -472,7 +485,7 @@ var StatsPublisher = class extends Publisher {
|
|
|
472
485
|
};
|
|
473
486
|
}
|
|
474
487
|
const shardCount = this.deps.topology.shardCount;
|
|
475
|
-
if (count === this.lastCount && shardCount === this.lastShardCount &&
|
|
488
|
+
if (count === this.lastCount && shardCount === this.lastShardCount && performance.now() < this.lastPostAt + 18e5) {
|
|
476
489
|
this.schedule(this.interval, () => this.tick());
|
|
477
490
|
return {
|
|
478
491
|
ok: false,
|
|
@@ -480,7 +493,7 @@ var StatsPublisher = class extends Publisher {
|
|
|
480
493
|
skipped: "unchanged"
|
|
481
494
|
};
|
|
482
495
|
}
|
|
483
|
-
this.lastPostAt =
|
|
496
|
+
this.lastPostAt = performance.now();
|
|
484
497
|
const result = await this.send(`/bots/${this.deps.botId}/stats`, {
|
|
485
498
|
server_count: count,
|
|
486
499
|
shard_count: shardCount
|
|
@@ -499,13 +512,14 @@ var StatsPublisher = class extends Publisher {
|
|
|
499
512
|
var HeartbeatPublisher = class extends Publisher {
|
|
500
513
|
kind = "heartbeat";
|
|
501
514
|
interval;
|
|
502
|
-
|
|
503
|
-
|
|
515
|
+
nextTarget = 0;
|
|
516
|
+
lastBeatAt = -Infinity;
|
|
517
|
+
beatNowPending = false;
|
|
504
518
|
inFlight = false;
|
|
505
519
|
nextBeatWithinSeconds;
|
|
506
520
|
constructor(deps, options = {}) {
|
|
507
521
|
super(deps);
|
|
508
|
-
this.interval = options.interval ?? 3e4;
|
|
522
|
+
this.interval = clampInterval(options.interval ?? 3e4);
|
|
509
523
|
}
|
|
510
524
|
/** Starts on construction, not on ready: a booting bot must read "connecting", not dead. */
|
|
511
525
|
start() {
|
|
@@ -513,14 +527,16 @@ var HeartbeatPublisher = class extends Publisher {
|
|
|
513
527
|
this.disable(null, "cluster-count-unknown");
|
|
514
528
|
return;
|
|
515
529
|
}
|
|
516
|
-
this.
|
|
517
|
-
this.beats = 0;
|
|
530
|
+
this.nextTarget = performance.now();
|
|
518
531
|
this.schedule(0, () => this.tick());
|
|
519
532
|
}
|
|
520
|
-
/** Out-of-band beat after a shard event.
|
|
533
|
+
/** Out-of-band beat after a shard event. Calls before it fires coalesce into one. */
|
|
521
534
|
beatNow() {
|
|
522
|
-
|
|
523
|
-
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());
|
|
524
540
|
}
|
|
525
541
|
async post() {
|
|
526
542
|
this.rearm();
|
|
@@ -532,6 +548,7 @@ var HeartbeatPublisher = class extends Publisher {
|
|
|
532
548
|
return this.beat(true);
|
|
533
549
|
}
|
|
534
550
|
async tick() {
|
|
551
|
+
this.beatNowPending = false;
|
|
535
552
|
await this.beat(false);
|
|
536
553
|
}
|
|
537
554
|
async beat(manual) {
|
|
@@ -566,6 +583,7 @@ var HeartbeatPublisher = class extends Publisher {
|
|
|
566
583
|
shards
|
|
567
584
|
};
|
|
568
585
|
this.inFlight = true;
|
|
586
|
+
this.lastBeatAt = performance.now();
|
|
569
587
|
let result;
|
|
570
588
|
try {
|
|
571
589
|
result = await this.send(`/bots/${this.deps.botId}/heartbeat`, body, "heartbeat", this.interval);
|
|
@@ -586,8 +604,7 @@ var HeartbeatPublisher = class extends Publisher {
|
|
|
586
604
|
*/
|
|
587
605
|
retune() {
|
|
588
606
|
if (this.nextBeatWithinSeconds === void 0) return;
|
|
589
|
-
|
|
590
|
-
this.interval = Math.min(Math.max(proposed, HEARTBEAT_MIN_INTERVAL_MS), HEARTBEAT_MAX_INTERVAL_MS);
|
|
607
|
+
this.interval = clampInterval(Math.round(this.nextBeatWithinSeconds / 3 * 1e3));
|
|
591
608
|
}
|
|
592
609
|
react(outcome) {
|
|
593
610
|
if (outcome.kind === "ok") {
|
|
@@ -596,15 +613,22 @@ var HeartbeatPublisher = class extends Publisher {
|
|
|
596
613
|
}
|
|
597
614
|
return super.react(outcome);
|
|
598
615
|
}
|
|
599
|
-
/**
|
|
616
|
+
/**
|
|
617
|
+
* Absolute targets, so a slow request cannot drift the cadence and an
|
|
618
|
+
* out-of-band beat does not push the next regular one back. Missed slots
|
|
619
|
+
* are skipped, so the end of a long suspension is one beat, not a burst.
|
|
620
|
+
*/
|
|
600
621
|
scheduleNext(minDelayMs = 0) {
|
|
601
|
-
|
|
622
|
+
const now = performance.now();
|
|
623
|
+
if (this.nextTarget <= now) this.nextTarget += (Math.floor((now - this.nextTarget) / this.interval) + 1) * this.interval;
|
|
602
624
|
const jitter = Math.round(Math.random() * HEARTBEAT_JITTER_MS);
|
|
603
|
-
const
|
|
604
|
-
const delay = Math.max(target - Date.now(), minDelayMs, 1e3, this.suspendedUntil - Date.now());
|
|
625
|
+
const delay = Math.max(this.nextTarget + jitter - now, minDelayMs, BEAT_NOW_DELAY_MS, this.suspendedUntil - now);
|
|
605
626
|
this.schedule(delay, () => this.tick());
|
|
606
627
|
}
|
|
607
628
|
};
|
|
629
|
+
function clampInterval(ms) {
|
|
630
|
+
return Math.min(Math.max(ms, HEARTBEAT_MIN_INTERVAL_MS), HEARTBEAT_MAX_INTERVAL_MS);
|
|
631
|
+
}
|
|
608
632
|
var CommandsPublisher = class extends Publisher {
|
|
609
633
|
rest;
|
|
610
634
|
options;
|
|
@@ -629,7 +653,7 @@ var CommandsPublisher = class extends Publisher {
|
|
|
629
653
|
status: null,
|
|
630
654
|
skipped: "disabled"
|
|
631
655
|
};
|
|
632
|
-
if (
|
|
656
|
+
if (performance.now() < this.suspendedUntil) return {
|
|
633
657
|
ok: false,
|
|
634
658
|
status: null,
|
|
635
659
|
skipped: "plan"
|
|
@@ -679,6 +703,11 @@ var CommandsPublisher = class extends Publisher {
|
|
|
679
703
|
const version = version$2;
|
|
680
704
|
const DEFAULT_BASE_URL = "https://thediscordlist.com/api";
|
|
681
705
|
const LOG_PREFIX = "[@thediscordlist/discord.js]";
|
|
706
|
+
const DISABLED = Object.freeze({
|
|
707
|
+
ok: false,
|
|
708
|
+
status: null,
|
|
709
|
+
skipped: "disabled"
|
|
710
|
+
});
|
|
682
711
|
var Autoposter = class extends EventEmitter {
|
|
683
712
|
stats;
|
|
684
713
|
heartbeat;
|
|
@@ -768,25 +797,13 @@ var Autoposter = class extends EventEmitter {
|
|
|
768
797
|
this.commands?.stop();
|
|
769
798
|
}
|
|
770
799
|
postStats() {
|
|
771
|
-
return this.stats?.post() ?? Promise.resolve(
|
|
772
|
-
ok: false,
|
|
773
|
-
status: null,
|
|
774
|
-
skipped: "disabled"
|
|
775
|
-
});
|
|
800
|
+
return this.stats?.post() ?? Promise.resolve(DISABLED);
|
|
776
801
|
}
|
|
777
802
|
postHeartbeat() {
|
|
778
|
-
return this.heartbeat?.post() ?? Promise.resolve(
|
|
779
|
-
ok: false,
|
|
780
|
-
status: null,
|
|
781
|
-
skipped: "disabled"
|
|
782
|
-
});
|
|
803
|
+
return this.heartbeat?.post() ?? Promise.resolve(DISABLED);
|
|
783
804
|
}
|
|
784
805
|
postCommands() {
|
|
785
|
-
return this.commands?.post() ?? Promise.resolve(
|
|
786
|
-
ok: false,
|
|
787
|
-
status: null,
|
|
788
|
-
skipped: "disabled"
|
|
789
|
-
});
|
|
806
|
+
return this.commands?.post() ?? Promise.resolve(DISABLED);
|
|
790
807
|
}
|
|
791
808
|
/** Clears every timer and detaches every client listener. Not restartable. */
|
|
792
809
|
stop() {
|
|
@@ -809,13 +826,14 @@ function checkIntents(client, poster) {
|
|
|
809
826
|
*/
|
|
810
827
|
function autopost(client, token, options = {}) {
|
|
811
828
|
const poster = new Autoposter();
|
|
812
|
-
|
|
829
|
+
const cleaned = token?.trim().replace(/^bearer\s+/i, "");
|
|
830
|
+
if (!cleaned) {
|
|
813
831
|
poster.warnOnce("no-token", "No stats token supplied; auto-posting is disabled.");
|
|
814
832
|
return poster;
|
|
815
833
|
}
|
|
816
834
|
const api = createApiClient({
|
|
817
835
|
baseUrl: options.baseUrl ?? "https://thediscordlist.com/api",
|
|
818
|
-
token,
|
|
836
|
+
token: cleaned,
|
|
819
837
|
userAgent: `@thediscordlist/discord.js/${version} (discord.js ${version$1})`
|
|
820
838
|
});
|
|
821
839
|
if (options.botId) {
|
|
@@ -838,4 +856,4 @@ function autopost(client, token, options = {}) {
|
|
|
838
856
|
return poster;
|
|
839
857
|
}
|
|
840
858
|
//#endregion
|
|
841
|
-
export { Autoposter, DEFAULT_BASE_URL, autopost, version };
|
|
859
|
+
export { Autoposter, DEFAULT_BASE_URL, HEARTBEAT_DEFAULT_INTERVAL_MS, STATS_DEBOUNCE_MS, STATS_INTERVAL_MS, STATS_MIN_GAP_MS, autopost, version };
|
package/dist/webhooks.cjs
CHANGED
|
@@ -20,6 +20,10 @@ var TdlWebhookError = class extends Error {
|
|
|
20
20
|
this.name = "TdlWebhookError";
|
|
21
21
|
}
|
|
22
22
|
};
|
|
23
|
+
/** An empty key would make every signature computable by anyone, so it is a setup error. */
|
|
24
|
+
function requireSecret(secret) {
|
|
25
|
+
if (typeof secret !== "string" || secret.trim() === "") throw new TypeError("A webhook secret is required.");
|
|
26
|
+
}
|
|
23
27
|
/** Length is not secret, and `timingSafeEqual` throws when lengths differ. */
|
|
24
28
|
function safeEqual(a, b) {
|
|
25
29
|
if (a.length !== b.length || a.length === 0) return false;
|
|
@@ -33,9 +37,11 @@ function safeEqual(a, b) {
|
|
|
33
37
|
* and posts that same string, so `JSON.stringify(req.body)` re-escapes slashes
|
|
34
38
|
* and non-ASCII and can never verify.
|
|
35
39
|
*
|
|
36
|
-
* @throws {TdlWebhookError}
|
|
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