@fedify/fedify 2.3.0 → 2.3.2-dev.1556

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +10 -7
  2. package/dist/{builder-DYXTKqvC.mjs → builder-DosLp_w5.mjs} +2 -2
  3. package/dist/{circuit-breaker-CSWsyoef.mjs → circuit-breaker-DLS_n1x5.mjs} +190 -7
  4. package/dist/circuit-breaker-test-utils-BVnv3T76.mjs +14 -0
  5. package/dist/compat/mod.d.cts +1 -1
  6. package/dist/compat/mod.d.ts +1 -1
  7. package/dist/compat/transformers.test.mjs +1 -1
  8. package/dist/{context-BBVLF7lx.d.cts → context-Bjswu3eC.d.cts} +18 -1
  9. package/dist/{context-BU6jSQdo.d.ts → context-COxc_O-c.d.ts} +18 -1
  10. package/dist/{deno-ChfuM30K.mjs → deno-Bg8I1L40.mjs} +1 -1
  11. package/dist/{docloader-Bu-Oecom.mjs → docloader-Dxsk3R8v.mjs} +2 -2
  12. package/dist/federation/builder.test.mjs +1 -1
  13. package/dist/federation/circuit-breaker.test.mjs +622 -3
  14. package/dist/federation/handler.test.mjs +3 -3
  15. package/dist/federation/idempotency.test.mjs +2 -2
  16. package/dist/federation/metrics.test.mjs +1 -1
  17. package/dist/federation/middleware.test.mjs +16 -8
  18. package/dist/federation/mod.cjs +1 -1
  19. package/dist/federation/mod.d.cts +2 -2
  20. package/dist/federation/mod.d.ts +2 -2
  21. package/dist/federation/mod.js +1 -1
  22. package/dist/federation/send.test.mjs +3 -3
  23. package/dist/federation/temporal.test.mjs +1 -1
  24. package/dist/federation/webfinger.test.mjs +1 -1
  25. package/dist/{http-Cm_e9dY7.mjs → http-BaUm6xbz.mjs} +3 -3
  26. package/dist/{http-CiPBp7Ax.js → http-Bw8wN_Uk.js} +17 -7
  27. package/dist/{http-Q2nW0DHO.cjs → http-y8Cigpuq.cjs} +17 -7
  28. package/dist/{key-nrqRBwjx.mjs → key-DOnwUeY9.mjs} +18 -8
  29. package/dist/{kv-cache-QkLiue07.cjs → kv-cache-B9AAXEOS.cjs} +1 -1
  30. package/dist/{kv-cache-B3Na5DMI.mjs → kv-cache-CE_QUNa6.mjs} +1 -1
  31. package/dist/{kv-cache-BQLjC1KN.js → kv-cache-DilyIIfz.js} +1 -1
  32. package/dist/{ld-CaRoGhYH.mjs → ld-CiONXo2U.mjs} +3 -3
  33. package/dist/{metrics-BSUZSyQU.mjs → metrics-CmX7KEfY.mjs} +1 -1
  34. package/dist/{middleware-BIj0BBxb.js → middleware-BEhF8uxY.js} +214 -14
  35. package/dist/{middleware-BIctkZ9S.mjs → middleware-CRMohX8-.mjs} +1 -1
  36. package/dist/{middleware-CwJv5KxX.mjs → middleware-Cf0dFqr9.mjs} +34 -17
  37. package/dist/{middleware-DXLo-UOS.cjs → middleware-DZf3vcun.cjs} +214 -14
  38. package/dist/{mod-C0F6kvgS.d.cts → mod-0bIqGrKQ.d.cts} +1 -1
  39. package/dist/{mod-vPYVoa5n.d.ts → mod-3H9Gym5G.d.ts} +1 -1
  40. package/dist/mod.cjs +4 -4
  41. package/dist/mod.d.cts +2 -2
  42. package/dist/mod.d.ts +2 -2
  43. package/dist/mod.js +4 -4
  44. package/dist/nodeinfo/handler.test.mjs +1 -1
  45. package/dist/{owner-CyK8YW7-.mjs → owner-C7h5_D1P.mjs} +2 -2
  46. package/dist/{proof-CIUSLcHg.cjs → proof-BKOUYRRh.cjs} +1 -1
  47. package/dist/{proof-DXHo4Yon.mjs → proof-BVuILGqP.mjs} +3 -3
  48. package/dist/{proof-Ba14ORbL.js → proof-DNlVt3rz.js} +1 -1
  49. package/dist/{send-B4Ld91pX.mjs → send-7kSXrsl4.mjs} +3 -3
  50. package/dist/sig/http.test.mjs +2 -2
  51. package/dist/sig/key.test.mjs +51 -1
  52. package/dist/sig/ld.test.mjs +2 -2
  53. package/dist/sig/mod.cjs +2 -2
  54. package/dist/sig/mod.js +2 -2
  55. package/dist/sig/owner.test.mjs +1 -1
  56. package/dist/sig/proof.test.mjs +1 -1
  57. package/dist/{temporal-E7cF2Hsh.mjs → temporal-D3Bspq6d.mjs} +1 -1
  58. package/dist/utils/docloader.test.mjs +2 -2
  59. package/dist/utils/kv-cache.test.mjs +1 -1
  60. package/dist/utils/mod.cjs +1 -1
  61. package/dist/utils/mod.js +1 -1
  62. package/package.json +6 -6
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  [![npm][npm badge]][npm]
8
8
  [![GitHub Actions][GitHub Actions badge]][GitHub Actions]
9
9
  [![Matrix][Matrix badge]][Matrix]
10
- [![@fedify@hollo.social][@fedify@hollo.social badge]][@fedify@hollo.social]
10
+ [![@fedify@hackers.pub][@fedify@hackers.pub badge]][@fedify@hackers.pub]
11
11
 
12
12
  > [!NOTE]
13
13
  > Looking for a quick demo? Here it is: [Fedify Demo] on Deno Playground.
@@ -61,8 +61,8 @@ in the fediverse!
61
61
  [GitHub Actions]: https://github.com/fedify-dev/fedify/actions/workflows/build.yaml
62
62
  [Matrix badge]: https://img.shields.io/matrix/fedify%3Amatrix.org?logo=matrix
63
63
  [Matrix]: https://matrix.to/#/#fedify:matrix.org
64
- [@fedify@hollo.social badge]: https://fedi-badge.minhee.org/@fedify@hollo.social/followers.svg
65
- [@fedify@hollo.social]: https://hollo.social/@fedify
64
+ [@fedify@hackers.pub badge]: https://fedi-badge.minhee.org/@fedify@hackers.pub/followers.svg
65
+ [@fedify@hackers.pub]: https://hackers.pub/@fedify
66
66
  [Fedify Demo]: https://dash.deno.com/playground/fedify-demo
67
67
  [ActivityPub]: https://www.w3.org/TR/activitypub/
68
68
  [fediverse]: https://en.wikipedia.org/wiki/Fediverse
@@ -215,22 +215,25 @@ financial contributors:[^2]
215
215
 
216
216
  ### Corporate sponsors
217
217
 
218
- - [<img src="https://images.opencollective.com/ghost/avatar/128.png" width="64" height="64"> Ghost](https://ghost.org)
218
+ | Avatar | Name | Description |
219
+ | ------ | ---- | ----------- |
220
+ | <img src="https://images.opencollective.com/ghost/avatar/128.png" width="64" height="64"> | [Ghost](https://ghost.org) | Fiercely independent, professional publishing |
221
+ | <img src="https://images.opencollective.com/altstore/avatar/128.png" width="64" height="64"> | [AltStore](https://altstore.io) | |
219
222
 
220
223
  ### Supporters
221
224
 
222
- - [Daniel Supernault](https://pixelfed.org/)
223
225
  - [tkgka](https://opencollective.com/tkgka)
224
226
  - [Blaine](https://opencollective.com/blaine)
225
227
  - [Erick González Aguilar](https://opencollective.com/erick-gonzalez-aguilar)
228
+ - [Daniel Supernault](https://pixelfed.org/)
226
229
 
227
230
  ### Backers
228
231
 
229
- Robin Riley, yamanoku, Encyclia, taye, okin, Andy Piper, box464, Evan Prodromou, Rafael Goulart, malte
232
+ Robin Riley, Encyclia, taye, box464, Evan Prodromou, okin, yamanoku, malte, Rafael Goulart, Andy Piper
230
233
 
231
234
  ### One-time donations
232
235
 
233
- Robin Riley, Markus P, Nils Bergmann, Rameez
236
+ Robin Riley, malte, Nils Bergmann, Markus P, Rameez
234
237
 
235
238
  <!-- /DO NOT EDIT -->
236
239
 
@@ -1,7 +1,7 @@
1
1
  import "@js-temporal/polyfill";
2
2
  import "urlpattern-polyfill";
3
3
  globalThis.addEventListener = () => {};
4
- import { n as version, t as name } from "./deno-ChfuM30K.mjs";
4
+ import { n as version, t as name } from "./deno-Bg8I1L40.mjs";
5
5
  import { t as ActivityListenerSet } from "./activity-listener-tztVvlNb.mjs";
6
6
  import { getLogger } from "@logtape/logtape";
7
7
  import { Router, RouterError, assertPath } from "@fedify/uri-template";
@@ -80,7 +80,7 @@ var FederationBuilderImpl = class {
80
80
  * specified.
81
81
  */
82
82
  async build(options) {
83
- const { FederationImpl } = await import("./middleware-BIctkZ9S.mjs");
83
+ const { FederationImpl } = await import("./middleware-CRMohX8-.mjs");
84
84
  const f = new FederationImpl(options);
85
85
  const trailingSlashInsensitiveValue = f.router.trailingSlashInsensitive;
86
86
  f.router = this.router.clone();
@@ -4,6 +4,13 @@ globalThis.addEventListener = () => {};
4
4
  import { getLogger } from "@logtape/logtape";
5
5
  //#region src/federation/circuit-breaker.ts
6
6
  const MAX_CUSTOM_FAILURE_HISTORY = 100;
7
+ const LEGACY_SWEEP_MARKER = ["__fedify_meta", "circuit_breaker_state_ttl_sweep_v1"];
8
+ const LEGACY_SWEEP_DELETING_MARKER = { __fedifyDeletingCircuitBreakerLegacyState: true };
9
+ const CIRCUIT_BREAKER_STATE_VERSION = 1;
10
+ const LEGACY_SWEEP_LOCK_TTL = Temporal.Duration.from({ minutes: 5 });
11
+ const LEGACY_SWEEP_RETRY_WINDOW = Temporal.Duration.from({ hours: 168 });
12
+ const LEGACY_SWEEP_WAIT_INTERVAL = 10;
13
+ const LEGACY_SWEEP_CAS_ATTEMPTS = 3;
7
14
  /**
8
15
  * Tracks reachability state for remote outbox delivery hosts.
9
16
  * @since 2.3.0
@@ -13,6 +20,8 @@ var CircuitBreaker = class {
13
20
  #prefix;
14
21
  #options;
15
22
  #now;
23
+ #legacySweep;
24
+ #legacySweepDone;
16
25
  #stateChangeObserver;
17
26
  constructor(options) {
18
27
  this.#kv = options.kv;
@@ -29,6 +38,7 @@ var CircuitBreaker = class {
29
38
  return now.until(this.#capHeldRetryAt(now, heldSince, now.add(delay)));
30
39
  }
31
40
  async beforeSend(remoteHost, message) {
41
+ this.#sweepLegacyStates();
32
42
  const heldSince = parseHeldSince(message.circuitHeldSince);
33
43
  const now = this.#now();
34
44
  if (heldSince != null && Temporal.Instant.compare(heldSince.add(this.#options.heldActivityTtl), now) <= 0) return {
@@ -110,6 +120,7 @@ var CircuitBreaker = class {
110
120
  throw new Error(`Failed to update circuit breaker state for ${remoteHost}`);
111
121
  }
112
122
  async recordSuccess(remoteHost) {
123
+ this.#sweepLegacyStates();
113
124
  for (let attempt = 0; attempt < 10; attempt++) {
114
125
  const oldState = await this.#get(remoteHost);
115
126
  if (oldState == null) return void 0;
@@ -130,6 +141,7 @@ var CircuitBreaker = class {
130
141
  return await this.recordSuccess(remoteHost);
131
142
  }
132
143
  async recordFailure(remoteHost) {
144
+ this.#sweepLegacyStates();
133
145
  const now = this.#now();
134
146
  for (let attempt = 0; attempt < 10; attempt++) {
135
147
  const oldState = await this.#get(remoteHost);
@@ -177,26 +189,122 @@ var CircuitBreaker = class {
177
189
  }
178
190
  }
179
191
  async getState(remoteHost) {
180
- return await this.#get(remoteHost);
192
+ this.#sweepLegacyStates();
193
+ return stripStoredCircuitBreakerState(await this.#get(remoteHost));
194
+ }
195
+ /**
196
+ * The currently running background legacy sweep, if any.
197
+ * @internal
198
+ */
199
+ get pendingSweep() {
200
+ return this.#legacySweep;
181
201
  }
182
202
  #key(remoteHost) {
183
203
  return [...this.#prefix, remoteHost];
184
204
  }
205
+ #legacySweepMarkerKey() {
206
+ return [...this.#prefix, ...LEGACY_SWEEP_MARKER];
207
+ }
208
+ #sweepLegacyStates() {
209
+ if (this.#kv.cas == null) return;
210
+ if (this.#options.stateTtl == null) return;
211
+ if (this.#legacySweep != null) return;
212
+ if (this.#isLegacySweepDone()) return;
213
+ this.#legacySweep = this.#sweepLegacyStatesImpl().catch((error) => {
214
+ getLogger([
215
+ "fedify",
216
+ "federation",
217
+ "circuit"
218
+ ]).warn("Failed to sweep legacy circuit breaker state:\n{error}", { error });
219
+ }).finally(() => {
220
+ this.#legacySweep = void 0;
221
+ });
222
+ }
223
+ async #sweepLegacyStatesImpl() {
224
+ const markerKey = this.#legacySweepMarkerKey();
225
+ const marker = await this.#acquireLegacySweep(markerKey);
226
+ if (marker === "done") return;
227
+ try {
228
+ for await (const { key, value } of this.#kv.list(this.#prefix)) {
229
+ if (key.length !== this.#prefix.length + 1) continue;
230
+ await this.#migrateLegacyState(key, value);
231
+ }
232
+ } catch (error) {
233
+ await this.#deleteIfUnchanged(markerKey, marker);
234
+ throw error;
235
+ }
236
+ const finishedMarker = this.#finishLegacySweepMarker(marker);
237
+ if (await this.#kv.cas(markerKey, marker, finishedMarker)) this.#rememberLegacySweepMarker(finishedMarker);
238
+ }
239
+ #finishLegacySweepMarker(marker) {
240
+ const retryUntil = getLegacySweepRetryUntil(marker);
241
+ if (retryUntil != null && Temporal.Instant.compare(this.#now(), Temporal.Instant.from(retryUntil)) < 0) return {
242
+ state: "done",
243
+ retryUntil
244
+ };
245
+ return { state: "final" };
246
+ }
247
+ async #migrateLegacyState(key, value) {
248
+ if (isCurrentCircuitBreakerState(value)) return;
249
+ const state = parseCircuitBreakerKvState(value);
250
+ if (state != null) {
251
+ await this.#kv.cas(key, value, markCircuitBreakerState(state), this.#setOptions());
252
+ return;
253
+ }
254
+ await this.#deleteIfUnchanged(key, value);
255
+ }
256
+ async #deleteIfUnchanged(key, value) {
257
+ if (await this.#kv.cas(key, value, LEGACY_SWEEP_DELETING_MARKER, { ttl: LEGACY_SWEEP_LOCK_TTL })) await this.#kv.delete(key);
258
+ }
259
+ async #acquireLegacySweep(markerKey) {
260
+ for (let attempt = 0; attempt < LEGACY_SWEEP_CAS_ATTEMPTS; attempt++) {
261
+ const marker = await this.#kv.get(markerKey);
262
+ const now = this.#now();
263
+ if (isLegacySweepActive(marker, now)) {
264
+ this.#rememberLegacySweepMarker(marker);
265
+ return "done";
266
+ }
267
+ const retryUntil = isLegacySweepRetryDue(marker, now) ? marker.retryUntil : getLegacySweepRetryUntil(marker) ?? now.add(LEGACY_SWEEP_RETRY_WINDOW).toString();
268
+ const sweeping = {
269
+ state: "sweeping",
270
+ started: now.toString(),
271
+ retryUntil
272
+ };
273
+ if (await this.#kv.cas(markerKey, marker ?? void 0, sweeping, { ttl: LEGACY_SWEEP_LOCK_TTL })) return sweeping;
274
+ await delay(LEGACY_SWEEP_WAIT_INTERVAL);
275
+ }
276
+ return "done";
277
+ }
278
+ #isLegacySweepDone() {
279
+ if (this.#legacySweepDone === "final") return true;
280
+ if (this.#legacySweepDone == null) return false;
281
+ if (Temporal.Instant.compare(this.#now(), this.#legacySweepDone) < 0) return true;
282
+ this.#legacySweepDone = void 0;
283
+ return false;
284
+ }
285
+ #rememberLegacySweepMarker(marker) {
286
+ const doneUntil = getLegacySweepDoneUntil(marker, this.#now());
287
+ if (doneUntil != null) this.#legacySweepDone = doneUntil;
288
+ }
185
289
  #capHeldRetryAt(now, heldSince, retryAt) {
186
290
  const expiresAt = (heldSince ?? now).add(this.#options.heldActivityTtl);
187
291
  return Temporal.Instant.compare(expiresAt, retryAt) < 0 ? expiresAt : retryAt;
188
292
  }
189
293
  async #get(remoteHost) {
190
- return parseCircuitBreakerKvState(await this.#kv.get(this.#key(remoteHost)));
294
+ return parseStoredCircuitBreakerKvState(await this.#kv.get(this.#key(remoteHost)));
191
295
  }
192
296
  async #replace(remoteHost, oldState, newState) {
193
297
  const key = this.#key(remoteHost);
298
+ const storedState = newState == null ? void 0 : markCircuitBreakerState(newState);
194
299
  if (this.#kv.cas == null) {
195
- if (newState == null) await this.#kv.delete(key);
196
- else await this.#kv.set(key, newState);
300
+ if (storedState == null) await this.#kv.delete(key);
301
+ else await this.#kv.set(key, storedState, this.#setOptions());
197
302
  return true;
198
303
  }
199
- return await this.#kv.cas(key, oldState, newState);
304
+ return await this.#kv.cas(key, oldState, storedState, storedState == null ? void 0 : this.#setOptions());
305
+ }
306
+ #setOptions() {
307
+ return this.#options.stateTtl == null ? void 0 : { ttl: this.#options.stateTtl };
200
308
  }
201
309
  async #notifyStateChange(remoteHost, previousState, newState) {
202
310
  try {
@@ -236,18 +344,25 @@ var CircuitBreaker = class {
236
344
  * @param options The public circuit breaker options supplied to Fedify.
237
345
  * @returns The normalized failure predicate, failure pruning function,
238
346
  * duration values, and optional callbacks with defaults applied.
239
- * @throws {RangeError} If any configured duration is not positive.
347
+ * @throws {RangeError} If any configured duration is not positive, or if
348
+ * `stateTtl` is shorter than `recoveryDelay`.
240
349
  * @throws {TypeError} If `failureThreshold` is not a positive integer.
241
350
  */
242
351
  function normalizeCircuitBreakerOptions(options) {
243
352
  const recoveryDelay = toInstantDuration(options.recoveryDelay ?? { minutes: 30 });
244
353
  const heldActivityTtl = toInstantDuration(options.heldActivityTtl ?? { hours: 168 });
245
354
  const releaseInterval = toInstantDuration(options.releaseInterval ?? { seconds: 1 });
355
+ const configuredStateTtl = options.stateTtl == null ? void 0 : toInstantDuration(options.stateTtl);
246
356
  assertPositiveDuration(recoveryDelay, "recoveryDelay");
247
357
  assertPositiveDuration(heldActivityTtl, "heldActivityTtl");
248
358
  assertPositiveDuration(releaseInterval, "releaseInterval");
359
+ if (configuredStateTtl != null) {
360
+ assertPositiveDuration(configuredStateTtl, "stateTtl");
361
+ if (Temporal.Duration.compare(configuredStateTtl, recoveryDelay) < 0) throw new RangeError("stateTtl must be at least recoveryDelay.");
362
+ }
249
363
  let failure;
250
364
  let pruneFailures;
365
+ let stateTtl;
251
366
  if (options.failure == null) {
252
367
  const failureThreshold = options.failureThreshold ?? 5;
253
368
  if (!Number.isInteger(failureThreshold) || failureThreshold <= 0) throw new TypeError("failureThreshold must be a positive integer.");
@@ -263,9 +378,11 @@ function normalizeCircuitBreakerOptions(options) {
263
378
  const last = timestamps[timestamps.length - 1];
264
379
  return Temporal.Duration.compare(first.until(last), failureWindow) <= 0;
265
380
  };
381
+ stateTtl = configuredStateTtl ?? maxDuration(recoveryDelay.add(failureWindow), recoveryDelay.add(heldActivityTtl));
266
382
  } else {
267
383
  failure = options.failure;
268
384
  pruneFailures = (timestamps) => timestamps.slice(-MAX_CUSTOM_FAILURE_HISTORY);
385
+ stateTtl = configuredStateTtl;
269
386
  }
270
387
  return {
271
388
  failure,
@@ -273,10 +390,72 @@ function normalizeCircuitBreakerOptions(options) {
273
390
  recoveryDelay,
274
391
  heldActivityTtl,
275
392
  releaseInterval,
393
+ stateTtl,
276
394
  onStateChange: options.onStateChange,
277
395
  onActivityDrop: options.onActivityDrop
278
396
  };
279
397
  }
398
+ function maxDuration(duration, ...durations) {
399
+ return durations.reduce((max, candidate) => Temporal.Duration.compare(candidate, max) > 0 ? candidate : max, duration);
400
+ }
401
+ function isLegacySweepDone(value, now) {
402
+ return getLegacySweepDoneUntil(value, now) != null;
403
+ }
404
+ function getLegacySweepDoneUntil(value, now) {
405
+ if (typeof value !== "object" || value == null || !("state" in value)) return;
406
+ if (value.state === "final") return "final";
407
+ if (value.state === "done" && "retryUntil" in value && typeof value.retryUntil === "string") try {
408
+ const retryUntil = Temporal.Instant.from(value.retryUntil);
409
+ return Temporal.Instant.compare(now, retryUntil) < 0 ? retryUntil : void 0;
410
+ } catch {
411
+ return;
412
+ }
413
+ }
414
+ function isLegacySweepRetryDue(value, now) {
415
+ if (typeof value !== "object" || value == null || !("state" in value) || value.state !== "done" || !("retryUntil" in value) || typeof value.retryUntil !== "string") return false;
416
+ try {
417
+ return Temporal.Instant.compare(now, Temporal.Instant.from(value.retryUntil)) >= 0;
418
+ } catch {
419
+ return false;
420
+ }
421
+ }
422
+ function isLegacySweepActive(value, now) {
423
+ return isLegacySweepDone(value, now) || isLegacySweepInProgress(value, now);
424
+ }
425
+ function isLegacySweepInProgress(value, now) {
426
+ if (typeof value !== "object" || value == null || !("state" in value) || value.state !== "sweeping" || !("started" in value) || typeof value.started !== "string") return false;
427
+ try {
428
+ return Temporal.Instant.compare(now, Temporal.Instant.from(value.started).add(LEGACY_SWEEP_LOCK_TTL)) < 0;
429
+ } catch {
430
+ return false;
431
+ }
432
+ }
433
+ function getLegacySweepRetryUntil(value) {
434
+ if (typeof value !== "object" || value == null || !("retryUntil" in value) || typeof value.retryUntil !== "string") return;
435
+ try {
436
+ Temporal.Instant.from(value.retryUntil);
437
+ return value.retryUntil;
438
+ } catch {
439
+ return;
440
+ }
441
+ }
442
+ function isCurrentCircuitBreakerState(value) {
443
+ return typeof value === "object" && value != null && "__fedifyCircuitBreakerStateVersion" in value && value.__fedifyCircuitBreakerStateVersion === CIRCUIT_BREAKER_STATE_VERSION;
444
+ }
445
+ function markCircuitBreakerState(state) {
446
+ return {
447
+ ...state,
448
+ __fedifyCircuitBreakerStateVersion: CIRCUIT_BREAKER_STATE_VERSION
449
+ };
450
+ }
451
+ function stripStoredCircuitBreakerState(state) {
452
+ if (state == null) return void 0;
453
+ const { __fedifyCircuitBreakerStateVersion: _, ...publicState } = state;
454
+ return publicState;
455
+ }
456
+ function delay(milliseconds) {
457
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
458
+ }
280
459
  function toInstantDuration(duration) {
281
460
  const parsed = Temporal.Duration.from(duration);
282
461
  return Temporal.Duration.from({ milliseconds: Math.trunc(parsed.total({
@@ -311,6 +490,9 @@ function parseHeldSince(value) {
311
490
  * valid instant strings, or `undefined` when the stored value is malformed.
312
491
  */
313
492
  function parseCircuitBreakerKvState(value) {
493
+ return stripStoredCircuitBreakerState(parseStoredCircuitBreakerKvState(value));
494
+ }
495
+ function parseStoredCircuitBreakerKvState(value) {
314
496
  const isInstantString = (v) => {
315
497
  if (typeof v !== "string") return false;
316
498
  try {
@@ -330,7 +512,8 @@ function parseCircuitBreakerKvState(value) {
330
512
  state: record.state,
331
513
  failures: record.failures,
332
514
  ...record.opened == null ? {} : { opened: record.opened },
333
- ...record.halfOpened == null ? {} : { halfOpened: record.halfOpened }
515
+ ...record.halfOpened == null ? {} : { halfOpened: record.halfOpened },
516
+ ...record.__fedifyCircuitBreakerStateVersion === CIRCUIT_BREAKER_STATE_VERSION ? { __fedifyCircuitBreakerStateVersion: CIRCUIT_BREAKER_STATE_VERSION } : {}
334
517
  };
335
518
  }
336
519
  //#endregion
@@ -0,0 +1,14 @@
1
+ import "@js-temporal/polyfill";
2
+ import "urlpattern-polyfill";
3
+ globalThis.addEventListener = () => {};
4
+ //#region src/federation/circuit-breaker-test-utils.ts
5
+ function markCircuitBreakerLegacySweepDone(kv) {
6
+ return kv.set([
7
+ "_fedify",
8
+ "circuit",
9
+ "__fedify_meta",
10
+ "circuit_breaker_state_ttl_sweep_v1"
11
+ ], { state: "final" });
12
+ }
13
+ //#endregion
14
+ export { markCircuitBreakerLegacySweepDone as t };
@@ -1,5 +1,5 @@
1
1
  /// <reference lib="esnext.temporal" />
2
- import { jt as ActivityTransformer, n as Context } from "../context-BBVLF7lx.cjs";
2
+ import { jt as ActivityTransformer, n as Context } from "../context-Bjswu3eC.cjs";
3
3
  import { Activity } from "@fedify/vocab";
4
4
 
5
5
  //#region src/compat/transformers.d.ts
@@ -1,5 +1,5 @@
1
1
  /// <reference lib="esnext.temporal" />
2
- import { jt as ActivityTransformer, n as Context } from "../context-BU6jSQdo.js";
2
+ import { jt as ActivityTransformer, n as Context } from "../context-COxc_O-c.js";
3
3
  import { Activity } from "@fedify/vocab";
4
4
 
5
5
  //#region src/compat/transformers.d.ts
@@ -5,7 +5,7 @@ import { t as assertEquals } from "../assert_equals-C-ZRDbaf.mjs";
5
5
  import { t as assertInstanceOf } from "../assert_instance_of-DBC5X09g.mjs";
6
6
  import { t as assert } from "../assert-OguE97r2.mjs";
7
7
  import { t as MemoryKvStore } from "../kv-rV3vodCc.mjs";
8
- import { b as autoIdAssigner, n as FederationImpl, y as actorDehydrator } from "../middleware-CwJv5KxX.mjs";
8
+ import { b as autoIdAssigner, n as FederationImpl, y as actorDehydrator } from "../middleware-Cf0dFqr9.mjs";
9
9
  import { Follow, Person } from "@fedify/vocab";
10
10
  import { test } from "@fedify/fixture";
11
11
  //#region src/compat/transformers.test.ts
@@ -87,6 +87,16 @@ type CircuitBreakerOptions = CircuitBreakerFailurePolicy & {
87
87
  */
88
88
  readonly releaseInterval?: Temporal.Duration | Temporal.DurationLike;
89
89
  /**
90
+ * How long Fedify keeps circuit breaker state in the configured key-value
91
+ * store.
92
+ *
93
+ * When omitted, Fedify derives this from `failureWindow`, `recoveryDelay`, and
94
+ * `heldActivityTtl` for the default numeric failure policy. Custom `failure`
95
+ * callbacks do not have an inspectable time window, so custom policies do not
96
+ * expire their stored state unless this option is provided.
97
+ */
98
+ readonly stateTtl?: Temporal.Duration | Temporal.DurationLike;
99
+ /**
90
100
  * Called whenever the circuit state changes.
91
101
  */
92
102
  readonly onStateChange?: (remoteHost: string, previousState: CircuitBreakerState, newState: CircuitBreakerState) => void | Promise<void>;
@@ -105,6 +115,7 @@ interface NormalizedCircuitBreakerOptions {
105
115
  readonly recoveryDelay: Temporal.Duration;
106
116
  readonly heldActivityTtl: Temporal.Duration;
107
117
  readonly releaseInterval: Temporal.Duration;
118
+ readonly stateTtl: Temporal.Duration | undefined;
108
119
  readonly onStateChange?: CircuitBreakerOptions["onStateChange"];
109
120
  readonly onActivityDrop?: CircuitBreakerOptions["onActivityDrop"];
110
121
  }
@@ -165,6 +176,11 @@ declare class CircuitBreaker {
165
176
  recordFailure(remoteHost: string): Promise<CircuitBreakerStateChange | undefined>;
166
177
  dropActivity(remoteHost: string, details: CircuitBreakerActivityDrop): Promise<void>;
167
178
  getState(remoteHost: string): Promise<CircuitBreakerKvState | undefined>;
179
+ /**
180
+ * The currently running background legacy sweep, if any.
181
+ * @internal
182
+ */
183
+ get pendingSweep(): Promise<void> | undefined;
168
184
  }
169
185
  /**
170
186
  * Normalizes user-provided circuit breaker options into the internal policy
@@ -173,7 +189,8 @@ declare class CircuitBreaker {
173
189
  * @param options The public circuit breaker options supplied to Fedify.
174
190
  * @returns The normalized failure predicate, failure pruning function,
175
191
  * duration values, and optional callbacks with defaults applied.
176
- * @throws {RangeError} If any configured duration is not positive.
192
+ * @throws {RangeError} If any configured duration is not positive, or if
193
+ * `stateTtl` is shorter than `recoveryDelay`.
177
194
  * @throws {TypeError} If `failureThreshold` is not a positive integer.
178
195
  */
179
196
  declare function normalizeCircuitBreakerOptions(options: CircuitBreakerOptions): NormalizedCircuitBreakerOptions;
@@ -90,6 +90,16 @@ type CircuitBreakerOptions = CircuitBreakerFailurePolicy & {
90
90
  */
91
91
  readonly releaseInterval?: Temporal.Duration | Temporal.DurationLike;
92
92
  /**
93
+ * How long Fedify keeps circuit breaker state in the configured key-value
94
+ * store.
95
+ *
96
+ * When omitted, Fedify derives this from `failureWindow`, `recoveryDelay`, and
97
+ * `heldActivityTtl` for the default numeric failure policy. Custom `failure`
98
+ * callbacks do not have an inspectable time window, so custom policies do not
99
+ * expire their stored state unless this option is provided.
100
+ */
101
+ readonly stateTtl?: Temporal.Duration | Temporal.DurationLike;
102
+ /**
93
103
  * Called whenever the circuit state changes.
94
104
  */
95
105
  readonly onStateChange?: (remoteHost: string, previousState: CircuitBreakerState, newState: CircuitBreakerState) => void | Promise<void>;
@@ -108,6 +118,7 @@ interface NormalizedCircuitBreakerOptions {
108
118
  readonly recoveryDelay: Temporal.Duration;
109
119
  readonly heldActivityTtl: Temporal.Duration;
110
120
  readonly releaseInterval: Temporal.Duration;
121
+ readonly stateTtl: Temporal.Duration | undefined;
111
122
  readonly onStateChange?: CircuitBreakerOptions["onStateChange"];
112
123
  readonly onActivityDrop?: CircuitBreakerOptions["onActivityDrop"];
113
124
  }
@@ -168,6 +179,11 @@ declare class CircuitBreaker {
168
179
  recordFailure(remoteHost: string): Promise<CircuitBreakerStateChange | undefined>;
169
180
  dropActivity(remoteHost: string, details: CircuitBreakerActivityDrop): Promise<void>;
170
181
  getState(remoteHost: string): Promise<CircuitBreakerKvState | undefined>;
182
+ /**
183
+ * The currently running background legacy sweep, if any.
184
+ * @internal
185
+ */
186
+ get pendingSweep(): Promise<void> | undefined;
171
187
  }
172
188
  /**
173
189
  * Normalizes user-provided circuit breaker options into the internal policy
@@ -176,7 +192,8 @@ declare class CircuitBreaker {
176
192
  * @param options The public circuit breaker options supplied to Fedify.
177
193
  * @returns The normalized failure predicate, failure pruning function,
178
194
  * duration values, and optional callbacks with defaults applied.
179
- * @throws {RangeError} If any configured duration is not positive.
195
+ * @throws {RangeError} If any configured duration is not positive, or if
196
+ * `stateTtl` is shorter than `recoveryDelay`.
180
197
  * @throws {TypeError} If `failureThreshold` is not a positive integer.
181
198
  */
182
199
  declare function normalizeCircuitBreakerOptions(options: CircuitBreakerOptions): NormalizedCircuitBreakerOptions;
@@ -3,6 +3,6 @@ import "urlpattern-polyfill";
3
3
  globalThis.addEventListener = () => {};
4
4
  //#region deno.json
5
5
  var name = "@fedify/fedify";
6
- var version = "2.3.0";
6
+ var version = "2.3.2-dev.1556+3e14f2d7";
7
7
  //#endregion
8
8
  export { version as n, name as t };
@@ -1,8 +1,8 @@
1
1
  import "@js-temporal/polyfill";
2
2
  import "urlpattern-polyfill";
3
3
  globalThis.addEventListener = () => {};
4
- import { o as validateCryptoKey } from "./key-nrqRBwjx.mjs";
5
- import { n as doubleKnock } from "./http-Cm_e9dY7.mjs";
4
+ import { o as validateCryptoKey } from "./key-DOnwUeY9.mjs";
5
+ import { n as doubleKnock } from "./http-BaUm6xbz.mjs";
6
6
  import { getLogger } from "@logtape/logtape";
7
7
  import { UrlError, createActivityPubRequest, getRemoteDocument, logRequest, validatePublicUrl } from "@fedify/vocab-runtime";
8
8
  import { curry } from "es-toolkit";
@@ -5,7 +5,7 @@ import { t as assertEquals } from "../assert_equals-C-ZRDbaf.mjs";
5
5
  import { r as assertExists } from "../std__assert-BBjXFNOb.mjs";
6
6
  import { t as assertThrows } from "../assert_throws-BOkhLGYc.mjs";
7
7
  import { t as MemoryKvStore } from "../kv-rV3vodCc.mjs";
8
- import { r as createFederationBuilder } from "../builder-DYXTKqvC.mjs";
8
+ import { r as createFederationBuilder } from "../builder-DosLp_w5.mjs";
9
9
  import { DisallowedOperatorError, DisallowedVarSpecModifierError, DuplicateRouteVariableError, RouteTemplateOptionsNotMatchedError, RouterError } from "@fedify/uri-template";
10
10
  import { Activity, Note, Person } from "@fedify/vocab";
11
11
  import { test } from "@fedify/fixture";