@fedify/fedify 2.3.0-dev.994 → 2.3.0-pr.809.36

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 (138) hide show
  1. package/README.md +16 -7
  2. package/dist/{assert-DikXweDx.mjs → assert-OguE97r2.mjs} +1 -1
  3. package/dist/{assert_instance_of-C4Ri6VuN.mjs → assert_instance_of-DBC5X09g.mjs} +1 -1
  4. package/dist/{assert_not_equals--wG9hV7u.mjs → assert_not_equals-DkVK8oqV.mjs} +1 -1
  5. package/dist/{assert_rejects-B-qJtC9Z.mjs → assert_rejects-DN60FHPX.mjs} +28 -3
  6. package/dist/{assert_strict_equals-Dmjbg-bA.mjs → assert_strict_equals-XEgZAlrj.mjs} +1 -1
  7. package/dist/{assert_throws-4NwKEy2q.mjs → assert_throws-BOkhLGYc.mjs} +1 -1
  8. package/dist/{builder-yAlpiIqP.mjs → builder-DSZ7FZmg.mjs} +96 -42
  9. package/dist/{chunk-nlSIicah.js → chunk-CRNNMoPX.js} +2 -2
  10. package/dist/circuit-breaker-CSWsyoef.mjs +337 -0
  11. package/dist/{client-z-8dc-e1.d.cts → client-CAM_bQXx.d.cts} +1 -0
  12. package/dist/{client-AtlibPOU.d.ts → client-CSddvgWN.d.ts} +1 -2
  13. package/dist/compat/mod.d.cts +2 -1
  14. package/dist/compat/mod.d.ts +2 -3
  15. package/dist/compat/mod.js +3 -3
  16. package/dist/compat/outgoing-jsonld.test.mjs +4 -4
  17. package/dist/compat/public-audience.test.mjs +4 -4
  18. package/dist/compat/transformers.test.mjs +5 -5
  19. package/dist/{context-BzH2-ajs.d.ts → context-BBVLF7lx.d.cts} +342 -269
  20. package/dist/{context-DJGagtNd.d.cts → context-BU6jSQdo.d.ts} +344 -266
  21. package/dist/{context-Dk_tacqz.mjs → context-DVoTs_wM.mjs} +64 -5
  22. package/dist/{deno-XBVlKnOX.mjs → deno-DCdM7d93.mjs} +1 -1
  23. package/dist/{docloader-DPp-X6gk.mjs → docloader-B7mVwA5_.mjs} +4 -4
  24. package/dist/{esm-DVILvP5e.mjs → esm-vrlUxr60.mjs} +4 -7
  25. package/dist/federation/builder.test.mjs +163 -11
  26. package/dist/federation/circuit-breaker.test.d.mts +2 -0
  27. package/dist/federation/circuit-breaker.test.mjs +446 -0
  28. package/dist/federation/collection.test.mjs +3 -3
  29. package/dist/federation/handler.test.mjs +1770 -29
  30. package/dist/federation/idempotency.test.mjs +5 -5
  31. package/dist/federation/inbox.test.mjs +4 -4
  32. package/dist/federation/keycache.test.mjs +5 -5
  33. package/dist/federation/kv.test.mjs +2 -2
  34. package/dist/federation/metrics.test.d.mts +2 -0
  35. package/dist/federation/metrics.test.mjs +634 -0
  36. package/dist/federation/middleware.test.mjs +4036 -146
  37. package/dist/federation/mod.cjs +195 -14
  38. package/dist/federation/mod.d.cts +6 -4
  39. package/dist/federation/mod.d.ts +6 -6
  40. package/dist/federation/mod.js +192 -14
  41. package/dist/federation/mq.test.mjs +176 -15
  42. package/dist/federation/negotiation.test.mjs +4 -4
  43. package/dist/federation/retry.test.mjs +3 -3
  44. package/dist/federation/router.test.mjs +190 -9
  45. package/dist/federation/send.test.mjs +153 -15
  46. package/dist/federation/temporal.test.d.mts +2 -0
  47. package/dist/federation/temporal.test.mjs +71 -0
  48. package/dist/federation/webfinger.test.mjs +150 -5
  49. package/dist/{http-CSlLA54v.mjs → http-BufbkLuW.mjs} +146 -47
  50. package/dist/{http-B4rrzxtg.js → http-Dh18nwJg.js} +994 -64
  51. package/dist/{http-aQzN9Ayi.d.ts → http-VyDTd4G3.d.cts} +15 -3
  52. package/dist/{http-BrGqJDXL.cjs → http-gOiudB3g.cjs} +1099 -61
  53. package/dist/{http-CrGuipxe.d.cts → http-lf8Hsd91.d.ts} +15 -1
  54. package/dist/{key-Bzx--sHD.mjs → key-DZdKLJXT.mjs} +43 -18
  55. package/dist/{kv-CbLNp3zQ.d.cts → kv-D6hNiMTK.d.ts} +1 -0
  56. package/dist/{kv-cache-DI-Sk7-I.cjs → kv-cache-BuK_nlpY.cjs} +20 -3
  57. package/dist/{kv-cache-U__xU4qR.mjs → kv-cache-C7SQnkGI.mjs} +21 -3
  58. package/dist/{kv-cache-EMIqoIuB.js → kv-cache-DNDSb6Po.js} +20 -3
  59. package/dist/{kv-GFYnFoOl.d.ts → kv-gJ8LYbxX.d.cts} +1 -3
  60. package/dist/ld-0Lsznacf.mjs +626 -0
  61. package/dist/metrics-BHeWd24f.mjs +815 -0
  62. package/dist/{middleware-zjZ6AFCW.mjs → middleware-BNANvDh2.mjs} +1 -1
  63. package/dist/{middleware-nidH7VZH.js → middleware-BgzRkDcm.js} +2260 -556
  64. package/dist/{middleware-An4DjES4.cjs → middleware-D-iES1nQ.cjs} +2318 -623
  65. package/dist/{middleware-K1g6bEkV.mjs → middleware-EV6J_eAc.mjs} +1639 -373
  66. package/dist/{mod-CR8soWa9.d.ts → mod-B0hW12_O.d.cts} +26 -4
  67. package/dist/mod-C0F6kvgS.d.cts +182 -0
  68. package/dist/{mod-Cr3f-ACa.d.cts → mod-COIAjwRS.d.ts} +26 -2
  69. package/dist/{mod-CMEbIaNh.d.cts → mod-DFvNJcNb.d.ts} +56 -4
  70. package/dist/mod-vPYVoa5n.d.ts +182 -0
  71. package/dist/{mod-CLgIXe9w.d.ts → mod-yvIXFAEi.d.cts} +56 -6
  72. package/dist/mod.cjs +9 -6
  73. package/dist/mod.d.cts +12 -10
  74. package/dist/mod.d.ts +12 -12
  75. package/dist/mod.js +11 -11
  76. package/dist/mq-D-nlpY04.d.ts +208 -0
  77. package/dist/mq-D8uSFzxe.d.cts +208 -0
  78. package/dist/{negotiation-SQvQgUqe.mjs → negotiation-DDstyBvc.mjs} +29 -0
  79. package/dist/nodeinfo/client.test.mjs +4 -4
  80. package/dist/nodeinfo/handler.test.mjs +4 -4
  81. package/dist/nodeinfo/mod.d.cts +2 -1
  82. package/dist/nodeinfo/mod.d.ts +2 -3
  83. package/dist/nodeinfo/mod.js +3 -3
  84. package/dist/nodeinfo/types.test.mjs +3 -3
  85. package/dist/otel/exporter.test.mjs +28 -25
  86. package/dist/otel/mod.cjs +6 -5
  87. package/dist/otel/mod.d.cts +5 -3
  88. package/dist/otel/mod.d.ts +5 -5
  89. package/dist/otel/mod.js +8 -7
  90. package/dist/{outgoing-jsonld-CNmZLixq.mjs → outgoing-jsonld-L_DbOaFe.mjs} +2 -2
  91. package/dist/{owner-C-tqfvxn.mjs → owner-Cl-1cAR8.mjs} +2 -2
  92. package/dist/{owner-CptqhsOy.d.cts → owner-CnngXDNJ.d.ts} +2 -1
  93. package/dist/{owner-74ARJ5TL.d.ts → owner-DEvZuyOE.d.cts} +2 -3
  94. package/dist/{proof-Bbbwf8_x.js → proof-CyXndf6-.js} +378 -15
  95. package/dist/{proof-BlmRPzrK.mjs → proof-Dnz8jtiQ.mjs} +26 -8
  96. package/dist/{proof-BW89QMVB.cjs → proof-tz35unOj.cjs} +432 -15
  97. package/dist/runtime/mod.d.cts +1 -0
  98. package/dist/runtime/mod.d.ts +1 -2
  99. package/dist/runtime/mod.js +3 -3
  100. package/dist/{send-05pJLcb6.mjs → send-DIbBYN5P.mjs} +72 -26
  101. package/dist/sig/http.test.mjs +356 -33
  102. package/dist/sig/key.test.mjs +103 -6
  103. package/dist/sig/ld.test.mjs +696 -7
  104. package/dist/sig/mod.cjs +2 -2
  105. package/dist/sig/mod.d.cts +4 -3
  106. package/dist/sig/mod.d.ts +4 -5
  107. package/dist/sig/mod.js +4 -4
  108. package/dist/sig/owner.test.mjs +6 -6
  109. package/dist/sig/proof.test.mjs +169 -8
  110. package/dist/{std__assert-CRDpx_HF.mjs → std__assert-BBjXFNOb.mjs} +5 -30
  111. package/dist/temporal-BEwob1Vg.mjs +95 -0
  112. package/dist/testing/mod.d.mts +110 -10
  113. package/dist/testing/mod.mjs +1 -2
  114. package/dist/{transformers-ve6e2xcg.js → transformers-BGMIq1cs.js} +2 -2
  115. package/dist/{types-hvL8ElAs.js → types-CAY3OdLq.js} +2 -2
  116. package/dist/utils/docloader.test.mjs +6 -6
  117. package/dist/utils/kv-cache.test.mjs +67 -2
  118. package/dist/utils/mod.cjs +1 -1
  119. package/dist/utils/mod.d.cts +2 -1
  120. package/dist/utils/mod.d.ts +2 -3
  121. package/dist/utils/mod.js +3 -3
  122. package/dist/vocab/cjs.test.mjs +1 -1
  123. package/dist/vocab/mod.d.cts +1 -0
  124. package/dist/vocab/mod.d.ts +1 -2
  125. package/dist/vocab/mod.js +2 -2
  126. package/package.json +16 -16
  127. package/dist/ld-DR78beiv.mjs +0 -279
  128. package/dist/middleware-BFBaR0mF.cjs +0 -4
  129. package/dist/mod-2d12ffz3.d.ts +0 -64
  130. package/dist/mod-D35TRn09.d.cts +0 -62
  131. package/dist/router-CrMLXoOr.mjs +0 -114
  132. package/dist/{activity-listener-ell7W1s9.mjs → activity-listener-tztVvlNb.mjs} +0 -0
  133. package/dist/{assert_equals-Ew3jOFa3.mjs → assert_equals-C-ZRDbaf.mjs} +0 -0
  134. package/dist/{client-D_1QpnWt.mjs → client-ByXmQhYD.mjs} +1 -1
  135. package/dist/{collection-D-HqUuA2.mjs → collection-Cc3DVAhE.mjs} +0 -0
  136. package/dist/{keycache-EGATflN-.mjs → keycache-BeU0LCII.mjs} +0 -0
  137. package/dist/{public-audience-DYFHzm_c.mjs → public-audience-Cvbr2Gzt.mjs} +1 -1
  138. /package/dist/{retry-bMXBL97A.mjs → retry-CXg_MBI-.mjs} +0 -0
@@ -1,21 +1,22 @@
1
1
  import { Temporal } from "@js-temporal/polyfill";
2
- import "urlpattern-polyfill";
3
- import { t as __exportAll } from "./chunk-nlSIicah.js";
4
- import { r as getDefaultActivityTransformers } from "./transformers-ve6e2xcg.js";
5
- import { _ as version, a as verifyRequestDetailed, d as validateCryptoKey, f as formatAcceptSignature, g as name, i as verifyRequest, n as parseRfc9421SignatureInput, o as exportJwk, t as doubleKnock, u as importJwk } from "./http-B4rrzxtg.js";
6
- import { c as getKeyOwner, d as detachSignature, f as hasSignatureLike, i as verifyObject, m as verifyJsonLd, n as hasProofLike, o as normalizeOutgoingActivityJsonLd, p as signJsonLd, r as signObject, s as doesActorOwnKey } from "./proof-Bbbwf8_x.js";
7
- import { n as getNodeInfo, t as nodeInfoToJson } from "./types-hvL8ElAs.js";
8
- import { n as getAuthenticatedDocumentLoader, t as kvCache } from "./kv-cache-EMIqoIuB.js";
2
+ import { URLPattern } from "urlpattern-polyfill";
3
+ import { t as __exportAll } from "./chunk-CRNNMoPX.js";
4
+ import { r as getDefaultActivityTransformers } from "./transformers-BGMIq1cs.js";
5
+ import { A as formatAcceptSignature, D as recordOutboxEnqueue, E as recordOutboxActivity, F as version, O as recordWebFingerHandle, P as name, S as recordCollectionTotalItems, T as recordInboxActivity, a as verifyRequestDetailed, b as recordCollectionPageItems, d as validateCryptoKey, f as getDurationMs, g as isAbortError, h as instrumentDocumentLoader, i as verifyRequest, k as registerQueueDepthGauge, m as getRemoteHost, n as parseRfc9421SignatureInput, o as exportJwk, p as getFederationMetrics, t as doubleKnock, u as importJwk, v as recordCircuitBreakerStateChange, w as recordFanoutRecipients, x as recordCollectionRequest, y as recordCollectionDispatchDuration } from "./http-Dh18nwJg.js";
6
+ import { _ as hasSignatureLike, b as signJsonLd, c as getKeyOwner, f as compactJsonLd, g as hasSignature, h as getNormalizationContextLoader, i as verifyObject, l as InvalidContextReferenceError, m as detachSignature, n as hasProofLike, o as normalizeOutgoingActivityJsonLd, r as signObject, s as doesActorOwnKey, u as assertSafeJsonLd, v as isClearlyMalformedContextReference, w as wrapContextLoaderForJsonLd, x as verifyCompactJsonLd, y as isInvalidUrlTypeError } from "./proof-CyXndf6-.js";
7
+ import { n as getNodeInfo, t as nodeInfoToJson } from "./types-CAY3OdLq.js";
8
+ import { n as getAuthenticatedDocumentLoader, t as kvCache } from "./kv-cache-DNDSb6Po.js";
9
9
  import { getLogger, withContext } from "@logtape/logtape";
10
+ import { Router, RouterError, assertPath } from "@fedify/uri-template";
10
11
  import { Activity, Collection, CollectionPage, CryptographicKey, Link, Multikey, Object as Object$1, OrderedCollection, OrderedCollectionPage, Tombstone, getTypeId, lookupObject, traverseCollection } from "@fedify/vocab";
11
- import { SpanKind, SpanStatusCode, context, propagation, trace } from "@opentelemetry/api";
12
- import { cloneDeep, uniq } from "es-toolkit";
13
- import { Router } from "uri-template-router";
14
- import { parseTemplate } from "url-template";
12
+ import { SpanKind, SpanStatusCode, context, metrics, propagation, trace } from "@opentelemetry/api";
15
13
  import { encodeHex } from "byte-encodings/hex";
14
+ import { uniq } from "es-toolkit";
16
15
  import { FetchError, getDocumentLoader } from "@fedify/vocab-runtime";
17
16
  import { ATTR_HTTP_REQUEST_HEADER, ATTR_HTTP_REQUEST_METHOD, ATTR_HTTP_RESPONSE_HEADER, ATTR_HTTP_RESPONSE_STATUS_CODE, ATTR_URL_FULL } from "@opentelemetry/semantic-conventions";
17
+ import jsonld from "@fedify/vocab-runtime/jsonld";
18
18
  import { lookupWebFinger } from "@fedify/webfinger";
19
+ import { DataPointType, MeterProvider as MeterProvider$1, MetricReader } from "@opentelemetry/sdk-metrics";
19
20
  import { domainToASCII } from "node:url";
20
21
  //#region src/federation/activity-listener.ts
21
22
  var ActivityListenerSet = class {
@@ -52,125 +53,33 @@ var ActivityListenerSet = class {
52
53
  }
53
54
  };
54
55
  //#endregion
55
- //#region src/federation/router.ts
56
- function cloneInnerRouter(router) {
57
- const clone = new Router();
58
- clone.nid = router.nid;
59
- clone.fsm = cloneDeep(router.fsm);
60
- clone.routeSet = new Set(router.routeSet);
61
- clone.templateRouteMap = new Map(router.templateRouteMap);
62
- clone.valueRouteMap = new Map(router.valueRouteMap);
63
- clone.hierarchy = cloneDeep(router.hierarchy);
64
- return clone;
65
- }
56
+ //#region src/federation/builder.ts
57
+ const ACTOR_ALIAS_PREFIX = "actorAlias:";
66
58
  /**
67
- * URL router and constructor based on URI Template
68
- * ([RFC 6570](https://tools.ietf.org/html/rfc6570)).
59
+ * Route options shared by every dispatcher whose path must expose exactly
60
+ * one `{identifier}` variable bound to a single, non-empty value
61
+ * for `setOutboxDispatcher/Listener()`.
69
62
  */
70
- var Router$1 = class Router$1 {
71
- #router;
72
- #templates;
73
- #templateStrings;
74
- /**
75
- * Whether to ignore trailing slashes when matching paths.
76
- * @since 1.6.0
77
- */
78
- trailingSlashInsensitive;
79
- /**
80
- * Create a new {@link Router}.
81
- * @param options Options for the router.
82
- */
83
- constructor(options = {}) {
84
- this.#router = new Router();
85
- this.#templates = {};
86
- this.#templateStrings = {};
87
- this.trailingSlashInsensitive = options.trailingSlashInsensitive ?? false;
88
- }
89
- clone() {
90
- const clone = new Router$1({ trailingSlashInsensitive: this.trailingSlashInsensitive });
91
- clone.#router = cloneInnerRouter(this.#router);
92
- clone.#templates = { ...this.#templates };
93
- clone.#templateStrings = { ...this.#templateStrings };
94
- return clone;
95
- }
96
- /**
97
- * Checks if a path name exists in the router.
98
- * @param name The name of the path.
99
- * @returns `true` if the path name exists, otherwise `false`.
100
- */
101
- has(name) {
102
- return name in this.#templates;
103
- }
104
- /**
105
- * Adds a new path rule to the router.
106
- * @param template The path pattern.
107
- * @param name The name of the path.
108
- * @returns The names of the variables in the path pattern.
109
- */
110
- add(template, name) {
111
- if (!template.startsWith("/")) throw new RouterError("Path must start with a slash.");
112
- const rule = this.#router.addTemplate(template, {}, name);
113
- this.#templates[name] = parseTemplate(template);
114
- this.#templateStrings[name] = template;
115
- return new Set(rule.variables.map((v) => v.varname));
116
- }
117
- /**
118
- * Resolves a path name and values from a URL, if any match.
119
- * @param url The URL to resolve.
120
- * @returns The name of the path and its values, if any match. Otherwise,
121
- * `null`.
122
- */
123
- route(url) {
124
- let match = this.#router.resolveURI(url);
125
- if (match == null) {
126
- if (!this.trailingSlashInsensitive) return null;
127
- url = url.endsWith("/") ? url.replace(/\/+$/, "") : `${url}/`;
128
- match = this.#router.resolveURI(url);
129
- if (match == null) return null;
130
- }
131
- return {
132
- name: match.matchValue,
133
- template: this.#templateStrings[match.matchValue],
134
- values: match.params
135
- };
136
- }
137
- /**
138
- * Constructs a URL/path from a path name and values.
139
- * @param name The name of the path.
140
- * @param values The values to expand the path with.
141
- * @returns The URL/path, if the name exists. Otherwise, `null`.
142
- */
143
- build(name, values) {
144
- if (name in this.#templates) return this.#templates[name].expand(values);
145
- return null;
146
- }
63
+ const identifierSingular = {
64
+ exact: true,
65
+ variables: { identifier: { operatables: [""] } }
147
66
  };
148
67
  /**
149
- * An error thrown by the {@link Router}.
68
+ * Route options shared by every dispatcher whose path must expose
69
+ * `{identifier}` and `{+identifier}` variables bound to the same single,
70
+ * non-empty value for following setters:
71
+ * - `setActorDispatcher()` (actor path)
72
+ * - `setInboxDispatcher/Listener()` (inbox path)
73
+ * - `setFollowingDispatcher()` (following path)
74
+ * - `setFollowersDispatcher()` (followers path)
75
+ * - `setLikedDispatcher()` (liked path)
76
+ * - `setFeaturedDispatcher()` (featured path)
77
+ * - `setFeaturedTagsDispatcher()` (featured tags path)
150
78
  */
151
- var RouterError = class extends Error {
152
- /**
153
- * Create a new {@link RouterError}.
154
- * @param message The error message.
155
- */
156
- constructor(message) {
157
- super(message);
158
- this.name = "RouterError";
159
- }
79
+ const identifierSingularAllowPlus = {
80
+ exact: true,
81
+ variables: { identifier: { operatables: ["", "+"] } }
160
82
  };
161
- //#endregion
162
- //#region src/federation/builder.ts
163
- function validateSingleIdentifierVariablePath(path, errorMessage) {
164
- const operatorMatches = globalThis.Array.from(path.matchAll(/{([+#./;?&]?)([A-Za-z_][A-Za-z0-9_]*)}/g));
165
- if (operatorMatches.length !== 1 || operatorMatches[0]?.[2] !== "identifier") throw new RouterError(errorMessage);
166
- if (operatorMatches.some((match) => [
167
- "?",
168
- "&",
169
- "#"
170
- ].includes(match[1]) && match[2] === "identifier")) throw new RouterError(errorMessage);
171
- const variables = new Router$1().add(path, "outbox");
172
- if (variables.size !== 1 || !variables.has("identifier")) throw new RouterError(errorMessage);
173
- }
174
83
  var FederationBuilderImpl = class {
175
84
  router;
176
85
  actorCallbacks;
@@ -203,12 +112,19 @@ var FederationBuilderImpl = class {
203
112
  */
204
113
  #symbolRegistry = /* @__PURE__ */ new Map();
205
114
  constructor() {
206
- this.router = new Router$1();
115
+ this.router = new Router();
207
116
  this.objectCallbacks = {};
208
117
  this.objectTypeIds = {};
209
118
  this.collectionCallbacks = {};
210
119
  this.collectionTypeIds = {};
211
120
  }
121
+ /**
122
+ * Builds the federation object.
123
+ * @param options Parameters for initializing the federation object.
124
+ * @returns The federation object.
125
+ * @throws {TypeError} If benchmark mode and `meterProvider` are both
126
+ * specified.
127
+ */
212
128
  async build(options) {
213
129
  const { FederationImpl } = await Promise.resolve().then(() => middleware_exports);
214
130
  const f = new FederationImpl(options);
@@ -246,8 +162,8 @@ var FederationBuilderImpl = class {
246
162
  }
247
163
  setActorDispatcher(path, dispatcher) {
248
164
  if (this.router.has("actor")) throw new RouterError("Actor dispatcher already set.");
249
- const variables = this.router.add(path, "actor");
250
- if (variables.size !== 1 || !variables.has("identifier")) throw new RouterError("Path for actor dispatcher must have one variable: {identifier}");
165
+ assertPath(path);
166
+ this.router.add(path, "actor", identifierSingularAllowPlus);
251
167
  const callbacks = { dispatcher: async (context, identifier) => {
252
168
  const actor = await this._getTracer().startActiveSpan("activitypub.dispatch_actor", {
253
169
  kind: SpanKind.SERVER,
@@ -346,6 +262,15 @@ var FederationBuilderImpl = class {
346
262
  callbacks.aliasMapper = mapper;
347
263
  return setters;
348
264
  },
265
+ mapActorAlias: (path, identifier) => {
266
+ if (identifier === "") throw new RouterError("Identifier cannot be empty.");
267
+ if (this.router.has(`actorAlias:${identifier}`)) throw new RouterError(`Actor alias for "${identifier}" already set.`);
268
+ if (Router.variables(path).size > 0) throw new RouterError("Path for actor alias must have no variables.");
269
+ const existingRoute = this.router.route(path);
270
+ if (existingRoute != null) throw new RouterError(`Actor alias path "${path}" conflicts with existing route "${existingRoute.name}".`);
271
+ this.router.add(path, `${ACTOR_ALIAS_PREFIX}${identifier}`);
272
+ return setters;
273
+ },
349
274
  authorize(predicate) {
350
275
  callbacks.authorizePredicate = predicate;
351
276
  return setters;
@@ -355,17 +280,25 @@ var FederationBuilderImpl = class {
355
280
  }
356
281
  setNodeInfoDispatcher(path, dispatcher) {
357
282
  if (this.router.has("nodeInfo")) throw new RouterError("NodeInfo dispatcher already set.");
358
- if (this.router.add(path, "nodeInfo").size !== 0) throw new RouterError("Path for NodeInfo dispatcher must have no variables.");
283
+ if (Router.variables(path).size !== 0) throw new RouterError("Path for NodeInfo dispatcher must have no variables.");
284
+ this.router.add(path, "nodeInfo");
359
285
  this.nodeInfoDispatcher = dispatcher;
360
286
  }
361
287
  setWebFingerLinksDispatcher(dispatcher) {
362
288
  this.webFingerLinksDispatcher = dispatcher;
363
289
  }
290
+ /**
291
+ * The RFC 6570 template-literal `path` overloads were removed for
292
+ * type-checking efficiency, so the URI variable types can no longer be
293
+ * inferred from `path` (now typed as a plain `string`); to use them, specify
294
+ * the variable name through the `TParam` generic argument.
295
+ */
364
296
  setObjectDispatcher(cls, path, dispatcher) {
365
297
  const routeName = `object:${cls.typeId.href}`;
366
298
  if (this.router.has(routeName)) throw new RouterError(`Object dispatcher for ${cls.name} already set.`);
367
- const variables = this.router.add(path, routeName);
368
- if (variables.size < 1) throw new RouterError("Path for object dispatcher must have at least one variable.");
299
+ assertPath(path);
300
+ const variables = Router.variables(path);
301
+ this.router.add(path, routeName);
369
302
  const callbacks = {
370
303
  dispatcher: (ctx, values) => {
371
304
  return this._getTracer().startActiveSpan("activitypub.dispatch_object", {
@@ -407,8 +340,8 @@ var FederationBuilderImpl = class {
407
340
  if (this.router.has("inbox")) {
408
341
  if (this.inboxPath !== path) throw new RouterError("Inbox dispatcher path must match inbox listener path.");
409
342
  } else {
410
- const variables = this.router.add(path, "inbox");
411
- if (variables.size !== 1 || !variables.has("identifier")) throw new RouterError("Path for inbox dispatcher must have one variable: {identifier}");
343
+ assertPath(path);
344
+ this.router.add(path, "inbox", identifierSingularAllowPlus);
412
345
  this.inboxPath = path;
413
346
  }
414
347
  const callbacks = { dispatcher };
@@ -438,8 +371,8 @@ var FederationBuilderImpl = class {
438
371
  if (this.router.has("outbox")) {
439
372
  if (this.outboxPath !== path) throw new RouterError("Outbox dispatcher path must match outbox listener path.");
440
373
  } else {
441
- validateSingleIdentifierVariablePath(path, "Path for outbox dispatcher must have one variable: {identifier}");
442
- this.router.add(path, "outbox");
374
+ assertPath(path);
375
+ this.router.add(path, "outbox", identifierSingular);
443
376
  this.outboxPath = path;
444
377
  }
445
378
  const callbacks = { dispatcher };
@@ -469,8 +402,8 @@ var FederationBuilderImpl = class {
469
402
  if (this.router.has("outbox")) {
470
403
  if (this.outboxPath !== outboxPath) throw new RouterError("Outbox listener path must match outbox dispatcher path.");
471
404
  } else {
472
- validateSingleIdentifierVariablePath(outboxPath, "Path for outbox must have one variable: {identifier}");
473
- this.router.add(outboxPath, "outbox");
405
+ assertPath(outboxPath);
406
+ this.router.add(outboxPath, "outbox", identifierSingular);
474
407
  this.outboxPath = outboxPath;
475
408
  }
476
409
  const listeners = this.outboxListeners = new ActivityListenerSet();
@@ -492,8 +425,8 @@ var FederationBuilderImpl = class {
492
425
  }
493
426
  setFollowingDispatcher(path, dispatcher) {
494
427
  if (this.router.has("following")) throw new RouterError("Following collection dispatcher already set.");
495
- const variables = this.router.add(path, "following");
496
- if (variables.size !== 1 || !variables.has("identifier")) throw new RouterError("Path for following collection dispatcher must have one variable: {identifier}");
428
+ assertPath(path);
429
+ this.router.add(path, "following", identifierSingularAllowPlus);
497
430
  const callbacks = { dispatcher };
498
431
  this.followingCallbacks = callbacks;
499
432
  const setters = {
@@ -518,8 +451,8 @@ var FederationBuilderImpl = class {
518
451
  }
519
452
  setFollowersDispatcher(path, dispatcher) {
520
453
  if (this.router.has("followers")) throw new RouterError("Followers collection dispatcher already set.");
521
- const variables = this.router.add(path, "followers");
522
- if (variables.size !== 1 || !variables.has("identifier")) throw new RouterError("Path for followers collection dispatcher must have one variable: {identifier}");
454
+ assertPath(path);
455
+ this.router.add(path, "followers", identifierSingularAllowPlus);
523
456
  const callbacks = { dispatcher };
524
457
  this.followersCallbacks = callbacks;
525
458
  const setters = {
@@ -544,8 +477,8 @@ var FederationBuilderImpl = class {
544
477
  }
545
478
  setLikedDispatcher(path, dispatcher) {
546
479
  if (this.router.has("liked")) throw new RouterError("Liked collection dispatcher already set.");
547
- const variables = this.router.add(path, "liked");
548
- if (variables.size !== 1 || !variables.has("identifier")) throw new RouterError("Path for liked collection dispatcher must have one variable: {identifier}");
480
+ assertPath(path);
481
+ this.router.add(path, "liked", identifierSingularAllowPlus);
549
482
  const callbacks = { dispatcher };
550
483
  this.likedCallbacks = callbacks;
551
484
  const setters = {
@@ -570,8 +503,8 @@ var FederationBuilderImpl = class {
570
503
  }
571
504
  setFeaturedDispatcher(path, dispatcher) {
572
505
  if (this.router.has("featured")) throw new RouterError("Featured collection dispatcher already set.");
573
- const variables = this.router.add(path, "featured");
574
- if (variables.size !== 1 || !variables.has("identifier")) throw new RouterError("Path for featured collection dispatcher must have one variable: {identifier}");
506
+ assertPath(path);
507
+ this.router.add(path, "featured", identifierSingularAllowPlus);
575
508
  const callbacks = { dispatcher };
576
509
  this.featuredCallbacks = callbacks;
577
510
  const setters = {
@@ -596,8 +529,8 @@ var FederationBuilderImpl = class {
596
529
  }
597
530
  setFeaturedTagsDispatcher(path, dispatcher) {
598
531
  if (this.router.has("featuredTags")) throw new RouterError("Featured tags collection dispatcher already set.");
599
- const variables = this.router.add(path, "featuredTags");
600
- if (variables.size !== 1 || !variables.has("identifier")) throw new RouterError("Path for featured tags collection dispatcher must have one variable: {identifier}");
532
+ assertPath(path);
533
+ this.router.add(path, "featuredTags", identifierSingularAllowPlus);
601
534
  const callbacks = { dispatcher };
602
535
  this.featuredTagsCallbacks = callbacks;
603
536
  const setters = {
@@ -625,12 +558,13 @@ var FederationBuilderImpl = class {
625
558
  if (this.router.has("inbox")) {
626
559
  if (this.inboxPath !== inboxPath) throw new RouterError("Inbox listener path must match inbox dispatcher path.");
627
560
  } else {
628
- const variables = this.router.add(inboxPath, "inbox");
629
- if (variables.size !== 1 || !variables.has("identifier")) throw new RouterError("Path for inbox must have one variable: {identifier}");
561
+ assertPath(inboxPath);
562
+ this.router.add(inboxPath, "inbox", identifierSingularAllowPlus);
630
563
  this.inboxPath = inboxPath;
631
564
  }
632
565
  if (sharedInboxPath != null) {
633
- if (this.router.add(sharedInboxPath, "sharedInbox").size !== 0) throw new RouterError("Path for shared inbox must have no variables.");
566
+ if (Router.variables(sharedInboxPath).size !== 0) throw new RouterError("Path for shared inbox must have no variables.");
567
+ this.router.add(sharedInboxPath, "sharedInbox");
634
568
  }
635
569
  const listeners = this.inboxListeners = new ActivityListenerSet();
636
570
  const setters = {
@@ -657,9 +591,21 @@ var FederationBuilderImpl = class {
657
591
  };
658
592
  return setters;
659
593
  }
594
+ /**
595
+ * The RFC 6570 template-literal `path` overloads were removed for
596
+ * type-checking efficiency, so the URI variable types can no longer be
597
+ * inferred from `path` (now typed as a plain `string`); to use them, specify
598
+ * the variable name through the `TParam` generic argument.
599
+ */
660
600
  setCollectionDispatcher(name, itemType, path, dispatcher) {
661
601
  return this.#setCustomCollectionDispatcher(name, "collection", itemType, path, dispatcher);
662
602
  }
603
+ /**
604
+ * The RFC 6570 template-literal `path` overloads were removed for
605
+ * type-checking efficiency, so the URI variable types can no longer be
606
+ * inferred from `path` (now typed as a plain `string`); to use them, specify
607
+ * the variable name through the `TParam` generic argument.
608
+ */
663
609
  setOrderedCollectionDispatcher(name, itemType, path, dispatcher) {
664
610
  return this.#setCustomCollectionDispatcher(name, "orderedCollection", itemType, path, dispatcher);
665
611
  }
@@ -668,7 +614,9 @@ var FederationBuilderImpl = class {
668
614
  const routeName = `${collectionType}:${this.#uniqueCollectionId(name)}`;
669
615
  if (this.router.has(routeName)) throw new RouterError(`Collection dispatcher for ${strName} already set.`);
670
616
  if (this.collectionCallbacks[name] != null) throw new RouterError(`Collection dispatcher for ${strName} already set.`);
671
- if (this.router.add(path, routeName).size < 1) throw new RouterError("Path for collection dispatcher must have at least one variable.");
617
+ assertPath(path);
618
+ if (Router.variables(path).size < 1) throw new RouterError("Path for collection dispatcher must have at least one variable.");
619
+ this.router.add(path, routeName);
672
620
  const callbacks = { dispatcher };
673
621
  this.collectionCallbacks[name] = callbacks;
674
622
  this.collectionTypeIds[name] = itemType;
@@ -730,6 +678,338 @@ function createFederationBuilder() {
730
678
  return new FederationBuilderImpl();
731
679
  }
732
680
  //#endregion
681
+ //#region src/federation/circuit-breaker.ts
682
+ const MAX_CUSTOM_FAILURE_HISTORY = 100;
683
+ /**
684
+ * Tracks reachability state for remote outbox delivery hosts.
685
+ * @since 2.3.0
686
+ */
687
+ var CircuitBreaker = class {
688
+ #kv;
689
+ #prefix;
690
+ #options;
691
+ #now;
692
+ #stateChangeObserver;
693
+ constructor(options) {
694
+ this.#kv = options.kv;
695
+ this.#prefix = options.prefix;
696
+ this.#options = normalizeCircuitBreakerOptions(options.options ?? {});
697
+ this.#now = options.now ?? (() => Temporal.Now.instant());
698
+ this.#stateChangeObserver = options.stateChangeObserver;
699
+ }
700
+ get options() {
701
+ return this.#options;
702
+ }
703
+ capHeldDelay(heldSince, delay) {
704
+ const now = this.#now();
705
+ return now.until(this.#capHeldRetryAt(now, heldSince, now.add(delay)));
706
+ }
707
+ async beforeSend(remoteHost, message) {
708
+ const heldSince = parseHeldSince(message.circuitHeldSince);
709
+ const now = this.#now();
710
+ if (heldSince != null && Temporal.Instant.compare(heldSince.add(this.#options.heldActivityTtl), now) <= 0) return {
711
+ type: "drop",
712
+ heldSince
713
+ };
714
+ let lastConflictingState;
715
+ for (let attempt = 0; attempt < 10; attempt++) {
716
+ const oldState = await this.#get(remoteHost);
717
+ if (oldState == null || oldState.state === "closed") return {
718
+ type: "send",
719
+ probe: false
720
+ };
721
+ if (oldState.state === "half-open") {
722
+ const halfOpened = oldState.halfOpened == null ? void 0 : Temporal.Instant.from(oldState.halfOpened);
723
+ if (halfOpened != null) {
724
+ const staleAt = halfOpened.add(this.#options.recoveryDelay);
725
+ if (Temporal.Instant.compare(now, staleAt) < 0) {
726
+ const releaseAt = now.add(this.#options.releaseInterval);
727
+ const retryAt = Temporal.Instant.compare(releaseAt, staleAt) < 0 ? releaseAt : staleAt;
728
+ const cappedRetryAt = this.#capHeldRetryAt(now, heldSince, retryAt);
729
+ return {
730
+ type: "hold",
731
+ state: "half-open",
732
+ delay: now.until(cappedRetryAt),
733
+ heldSince: heldSince ?? now
734
+ };
735
+ }
736
+ }
737
+ const newState = {
738
+ ...oldState,
739
+ state: "half-open",
740
+ halfOpened: now.toString()
741
+ };
742
+ if (await this.#replace(remoteHost, oldState, newState)) return {
743
+ type: "send",
744
+ probe: true
745
+ };
746
+ lastConflictingState = "half-open";
747
+ continue;
748
+ }
749
+ const probeAt = (oldState.opened == null ? now : Temporal.Instant.from(oldState.opened)).add(this.#options.recoveryDelay);
750
+ if (Temporal.Instant.compare(now, probeAt) < 0) {
751
+ const retryAt = this.#capHeldRetryAt(now, heldSince, probeAt);
752
+ return {
753
+ type: "hold",
754
+ state: "open",
755
+ delay: now.until(retryAt),
756
+ heldSince: heldSince ?? now
757
+ };
758
+ }
759
+ const newState = {
760
+ ...oldState,
761
+ state: "half-open",
762
+ halfOpened: now.toString()
763
+ };
764
+ if (await this.#replace(remoteHost, oldState, newState)) {
765
+ await this.#notifyStateChange(remoteHost, "open", "half-open");
766
+ return {
767
+ type: "send",
768
+ probe: true,
769
+ stateChange: {
770
+ previousState: "open",
771
+ newState: "half-open"
772
+ }
773
+ };
774
+ }
775
+ lastConflictingState = "open";
776
+ }
777
+ if (lastConflictingState != null) {
778
+ const retryAt = this.#capHeldRetryAt(now, heldSince, now.add(this.#options.releaseInterval));
779
+ return {
780
+ type: "hold",
781
+ state: lastConflictingState,
782
+ delay: now.until(retryAt),
783
+ heldSince: heldSince ?? now
784
+ };
785
+ }
786
+ throw new Error(`Failed to update circuit breaker state for ${remoteHost}`);
787
+ }
788
+ async recordSuccess(remoteHost) {
789
+ for (let attempt = 0; attempt < 10; attempt++) {
790
+ const oldState = await this.#get(remoteHost);
791
+ if (oldState == null) return void 0;
792
+ if (await this.#replace(remoteHost, oldState, void 0)) {
793
+ if (oldState.state !== "closed") {
794
+ await this.#notifyStateChange(remoteHost, oldState.state, "closed");
795
+ return {
796
+ previousState: oldState.state,
797
+ newState: "closed"
798
+ };
799
+ }
800
+ return;
801
+ }
802
+ }
803
+ throw new Error(`Failed to update circuit breaker state for ${remoteHost}`);
804
+ }
805
+ async recordReachableFailure(remoteHost) {
806
+ return await this.recordSuccess(remoteHost);
807
+ }
808
+ async recordFailure(remoteHost) {
809
+ const now = this.#now();
810
+ for (let attempt = 0; attempt < 10; attempt++) {
811
+ const oldState = await this.#get(remoteHost);
812
+ if (oldState?.state === "open") return void 0;
813
+ const oldFailures = oldState?.failures.map(Temporal.Instant.from) ?? [];
814
+ const failures = this.#options.pruneFailures([...oldFailures, now], now);
815
+ let newState;
816
+ let transition;
817
+ if (oldState?.state === "half-open" || this.#options.failure(failures)) {
818
+ newState = {
819
+ state: "open",
820
+ failures: failures.map((t) => t.toString()),
821
+ opened: now.toString()
822
+ };
823
+ transition = [oldState?.state ?? "closed", "open"];
824
+ } else newState = {
825
+ state: "closed",
826
+ failures: failures.map((t) => t.toString())
827
+ };
828
+ if (await this.#replace(remoteHost, oldState, newState)) {
829
+ if (transition != null) {
830
+ await this.#notifyStateChange(remoteHost, transition[0], transition[1]);
831
+ return {
832
+ previousState: transition[0],
833
+ newState: transition[1]
834
+ };
835
+ }
836
+ return;
837
+ }
838
+ }
839
+ throw new Error(`Failed to update circuit breaker state for ${remoteHost}`);
840
+ }
841
+ async dropActivity(remoteHost, details) {
842
+ try {
843
+ await this.#options.onActivityDrop?.(remoteHost, details);
844
+ } catch (error) {
845
+ getLogger([
846
+ "fedify",
847
+ "federation",
848
+ "circuit"
849
+ ]).error("An unexpected error occurred in circuit breaker activity drop handler:\n{error}", {
850
+ remoteHost,
851
+ error
852
+ });
853
+ }
854
+ }
855
+ async getState(remoteHost) {
856
+ return await this.#get(remoteHost);
857
+ }
858
+ #key(remoteHost) {
859
+ return [...this.#prefix, remoteHost];
860
+ }
861
+ #capHeldRetryAt(now, heldSince, retryAt) {
862
+ const expiresAt = (heldSince ?? now).add(this.#options.heldActivityTtl);
863
+ return Temporal.Instant.compare(expiresAt, retryAt) < 0 ? expiresAt : retryAt;
864
+ }
865
+ async #get(remoteHost) {
866
+ return parseCircuitBreakerKvState(await this.#kv.get(this.#key(remoteHost)));
867
+ }
868
+ async #replace(remoteHost, oldState, newState) {
869
+ const key = this.#key(remoteHost);
870
+ if (this.#kv.cas == null) {
871
+ if (newState == null) await this.#kv.delete(key);
872
+ else await this.#kv.set(key, newState);
873
+ return true;
874
+ }
875
+ return await this.#kv.cas(key, oldState, newState);
876
+ }
877
+ async #notifyStateChange(remoteHost, previousState, newState) {
878
+ try {
879
+ await this.#options.onStateChange?.(remoteHost, previousState, newState);
880
+ } catch (error) {
881
+ getLogger([
882
+ "fedify",
883
+ "federation",
884
+ "circuit"
885
+ ]).error("An unexpected error occurred in circuit breaker state change handler:\n{error}", {
886
+ remoteHost,
887
+ previousState,
888
+ newState,
889
+ error
890
+ });
891
+ }
892
+ try {
893
+ await this.#stateChangeObserver?.(remoteHost, previousState, newState);
894
+ } catch (error) {
895
+ getLogger([
896
+ "fedify",
897
+ "federation",
898
+ "circuit"
899
+ ]).error("An unexpected error occurred in circuit breaker state change observer:\n{error}", {
900
+ remoteHost,
901
+ previousState,
902
+ newState,
903
+ error
904
+ });
905
+ }
906
+ }
907
+ };
908
+ /**
909
+ * Normalizes user-provided circuit breaker options into the internal policy
910
+ * shape used while processing queued outbox deliveries.
911
+ *
912
+ * @param options The public circuit breaker options supplied to Fedify.
913
+ * @returns The normalized failure predicate, failure pruning function,
914
+ * duration values, and optional callbacks with defaults applied.
915
+ * @throws {RangeError} If any configured duration is not positive.
916
+ * @throws {TypeError} If `failureThreshold` is not a positive integer.
917
+ */
918
+ function normalizeCircuitBreakerOptions(options) {
919
+ const recoveryDelay = toInstantDuration(options.recoveryDelay ?? { minutes: 30 });
920
+ const heldActivityTtl = toInstantDuration(options.heldActivityTtl ?? { hours: 168 });
921
+ const releaseInterval = toInstantDuration(options.releaseInterval ?? { seconds: 1 });
922
+ assertPositiveDuration(recoveryDelay, "recoveryDelay");
923
+ assertPositiveDuration(heldActivityTtl, "heldActivityTtl");
924
+ assertPositiveDuration(releaseInterval, "releaseInterval");
925
+ let failure;
926
+ let pruneFailures;
927
+ if (options.failure == null) {
928
+ const failureThreshold = options.failureThreshold ?? 5;
929
+ if (!Number.isInteger(failureThreshold) || failureThreshold <= 0) throw new TypeError("failureThreshold must be a positive integer.");
930
+ const failureWindow = toInstantDuration(options.failureWindow ?? { minutes: 10 });
931
+ assertPositiveDuration(failureWindow, "failureWindow");
932
+ pruneFailures = (timestamps, now) => {
933
+ const earliest = now.subtract(failureWindow);
934
+ return timestamps.filter((timestamp) => Temporal.Instant.compare(timestamp, earliest) >= 0).slice(-failureThreshold);
935
+ };
936
+ failure = (timestamps) => {
937
+ if (timestamps.length < failureThreshold) return false;
938
+ const first = timestamps[timestamps.length - failureThreshold];
939
+ const last = timestamps[timestamps.length - 1];
940
+ return Temporal.Duration.compare(first.until(last), failureWindow) <= 0;
941
+ };
942
+ } else {
943
+ failure = options.failure;
944
+ pruneFailures = (timestamps) => timestamps.slice(-MAX_CUSTOM_FAILURE_HISTORY);
945
+ }
946
+ return {
947
+ failure,
948
+ pruneFailures,
949
+ recoveryDelay,
950
+ heldActivityTtl,
951
+ releaseInterval,
952
+ onStateChange: options.onStateChange,
953
+ onActivityDrop: options.onActivityDrop
954
+ };
955
+ }
956
+ function toInstantDuration(duration) {
957
+ const parsed = Temporal.Duration.from(duration);
958
+ return Temporal.Duration.from({ milliseconds: Math.trunc(parsed.total({
959
+ unit: "millisecond",
960
+ relativeTo: Temporal.PlainDateTime.from("2026-01-01T00:00:00")
961
+ })) });
962
+ }
963
+ function assertPositiveDuration(duration, name) {
964
+ if (Temporal.Duration.compare(duration, { seconds: 0 }) <= 0) throw new RangeError(`${name} must be a positive duration.`);
965
+ }
966
+ function parseHeldSince(value) {
967
+ if (value == null) return void 0;
968
+ try {
969
+ return Temporal.Instant.from(value);
970
+ } catch (error) {
971
+ getLogger([
972
+ "fedify",
973
+ "federation",
974
+ "circuit"
975
+ ]).warn("Invalid circuitHeldSince value in queued outbox message: {value}", {
976
+ value,
977
+ error
978
+ });
979
+ return;
980
+ }
981
+ }
982
+ /**
983
+ * Parses a value loaded from the circuit breaker KV store.
984
+ *
985
+ * @param value The raw KV value to validate.
986
+ * @returns A circuit breaker state when `value` has a recognized state and
987
+ * valid instant strings, or `undefined` when the stored value is malformed.
988
+ */
989
+ function parseCircuitBreakerKvState(value) {
990
+ const isInstantString = (v) => {
991
+ if (typeof v !== "string") return false;
992
+ try {
993
+ Temporal.Instant.from(v);
994
+ return true;
995
+ } catch {
996
+ return false;
997
+ }
998
+ };
999
+ if (typeof value !== "object" || value == null) return void 0;
1000
+ const record = value;
1001
+ if (record.state !== "closed" && record.state !== "open" && record.state !== "half-open") return;
1002
+ if (!Array.isArray(record.failures) || !record.failures.every((failure) => isInstantString(failure))) return;
1003
+ if (record.opened != null && !isInstantString(record.opened)) return;
1004
+ if (record.halfOpened != null && !isInstantString(record.halfOpened)) return;
1005
+ return {
1006
+ state: record.state,
1007
+ failures: record.failures,
1008
+ ...record.opened == null ? {} : { opened: record.opened },
1009
+ ...record.halfOpened == null ? {} : { halfOpened: record.halfOpened }
1010
+ };
1011
+ }
1012
+ //#endregion
733
1013
  //#region src/federation/collection.ts
734
1014
  /**
735
1015
  * Calculates the [partial follower collection digest][1].
@@ -770,7 +1050,7 @@ async function buildCollectionSynchronizationHeader(collectionId, actorIds) {
770
1050
  }
771
1051
  //#endregion
772
1052
  //#region src/federation/inbox.ts
773
- async function routeActivity({ context: ctx, json, activity, recipient, inboxListeners, inboxContextFactory, inboxErrorHandler, kv, kvPrefixes, queue, span, tracerProvider, idempotencyStrategy }) {
1053
+ async function routeActivity({ context: ctx, json, originalJson, normalizedActivity, ldSignatureVerified, activity, recipient, inboxListeners, inboxContextFactory, listenerInboxContextFactory, inboxErrorHandler, kv, kvPrefixes, queue, span, meterProvider, tracerProvider, idempotencyStrategy }) {
774
1054
  const logger = getLogger([
775
1055
  "fedify",
776
1056
  "federation",
@@ -807,6 +1087,7 @@ async function routeActivity({ context: ctx, json, activity, recipient, inboxLis
807
1087
  code: SpanStatusCode.UNSET,
808
1088
  message: `Activity ${activity.id?.href} has already been processed.`
809
1089
  });
1090
+ recordInboxActivity(meterProvider, "rejected", getTypeId(activity).href);
810
1091
  return "alreadyProcessed";
811
1092
  }
812
1093
  }
@@ -816,6 +1097,7 @@ async function routeActivity({ context: ctx, json, activity, recipient, inboxLis
816
1097
  code: SpanStatusCode.ERROR,
817
1098
  message: "Missing actor."
818
1099
  });
1100
+ recordInboxActivity(meterProvider, "rejected", getTypeId(activity).href);
819
1101
  return "missingActor";
820
1102
  }
821
1103
  span.setAttribute("activitypub.actor.id", activity.actorId.href);
@@ -827,7 +1109,9 @@ async function routeActivity({ context: ctx, json, activity, recipient, inboxLis
827
1109
  type: "inbox",
828
1110
  id: crypto.randomUUID(),
829
1111
  baseUrl: ctx.origin,
830
- activity: json,
1112
+ activity: originalJson ?? json,
1113
+ ...normalizedActivity == null ? {} : { normalizedActivity },
1114
+ ...ldSignatureVerified == null ? {} : { ldSignatureVerified },
831
1115
  identifier: recipient,
832
1116
  attempt: 0,
833
1117
  started: (/* @__PURE__ */ new Date()).toISOString(),
@@ -846,6 +1130,12 @@ async function routeActivity({ context: ctx, json, activity, recipient, inboxLis
846
1130
  });
847
1131
  throw error;
848
1132
  }
1133
+ getFederationMetrics(meterProvider).recordQueueTaskEnqueued({
1134
+ role: "inbox",
1135
+ queue,
1136
+ activityType: getTypeId(activity).href
1137
+ }, 0);
1138
+ recordInboxActivity(meterProvider, "queued", getTypeId(activity).href);
849
1139
  logger.info("Activity {activityId} is enqueued.", {
850
1140
  activityId: activity.id?.href,
851
1141
  activity: json,
@@ -865,13 +1155,21 @@ async function routeActivity({ context: ctx, json, activity, recipient, inboxLis
865
1155
  code: SpanStatusCode.UNSET,
866
1156
  message: `Unsupported activity type: ${getTypeId(activity).href}`
867
1157
  });
1158
+ recordInboxActivity(meterProvider, "rejected", getTypeId(activity).href);
868
1159
  span.end();
869
1160
  return "unsupportedActivity";
870
1161
  }
871
1162
  const { class: cls, listener } = dispatched;
872
1163
  span.updateName(`activitypub.dispatch_inbox_listener ${cls.name}`);
873
1164
  try {
874
- await listener(inboxContextFactory(recipient, json, activity?.id?.href, getTypeId(activity).href), activity);
1165
+ const activityType = getTypeId(activity).href;
1166
+ const started = performance.now();
1167
+ try {
1168
+ const contextFactory = listenerInboxContextFactory ?? inboxContextFactory;
1169
+ await listener(contextFactory(recipient, contextFactory === inboxContextFactory ? json : originalJson ?? json, activity.id?.href, activityType), activity);
1170
+ } finally {
1171
+ getFederationMetrics(meterProvider).recordInboxProcessingDuration(activityType, getDurationMs(started));
1172
+ }
875
1173
  } catch (error) {
876
1174
  try {
877
1175
  await inboxErrorHandler?.(ctx, error);
@@ -893,9 +1191,11 @@ async function routeActivity({ context: ctx, json, activity, recipient, inboxLis
893
1191
  code: SpanStatusCode.ERROR,
894
1192
  message: String(error)
895
1193
  });
1194
+ recordInboxActivity(meterProvider, "rejected", getTypeId(activity).href);
896
1195
  span.end();
897
1196
  return "error";
898
1197
  }
1198
+ recordInboxActivity(meterProvider, "processed", getTypeId(activity).href);
899
1199
  if (cacheKey != null) await kv.set(cacheKey, true, { ttl: Temporal.Duration.from({ days: 1 }) });
900
1200
  logger.info("Activity {activityId} has been processed.", {
901
1201
  activityId: activity.id?.href,
@@ -1000,6 +1300,35 @@ var KvKeyCache = class {
1000
1300
  };
1001
1301
  //#endregion
1002
1302
  //#region src/federation/negotiation.ts
1303
+ /*!
1304
+ * Adapted directly from negotiator at https://github.com/jshttp/negotiator/
1305
+ * which is licensed as follows:
1306
+ *
1307
+ * (The MIT License)
1308
+ *
1309
+ * Copyright (c) 2012-2014 Federico Romero
1310
+ * Copyright (c) 2012-2014 Isaac Z. Schlueter
1311
+ * Copyright (c) 2014-2015 Douglas Christopher Wilson
1312
+ *
1313
+ * Permission is hereby granted, free of charge, to any person obtaining
1314
+ * a copy of this software and associated documentation files (the
1315
+ * 'Software'), to deal in the Software without restriction, including
1316
+ * without limitation the rights to use, copy, modify, merge, publish,
1317
+ * distribute, sublicense, and/or sell copies of the Software, and to
1318
+ * permit persons to whom the Software is furnished to do so, subject to
1319
+ * the following conditions:
1320
+ *
1321
+ * The above copyright notice and this permission notice shall be
1322
+ * included in all copies or substantial portions of the Software.
1323
+ *
1324
+ * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
1325
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
1326
+ * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
1327
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
1328
+ * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
1329
+ * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
1330
+ * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
1331
+ */
1003
1332
  function compareSpecs(a, b) {
1004
1333
  return b.q - a.q || (b.s ?? 0) - (a.s ?? 0) || (a.o ?? 0) - (b.o ?? 0) || a.i - b.i || 0;
1005
1334
  }
@@ -1062,7 +1391,123 @@ function acceptsJsonLd(request) {
1062
1391
  return types.includes("application/activity+json") || types.includes("application/ld+json") || types.includes("application/json");
1063
1392
  }
1064
1393
  //#endregion
1394
+ //#region src/federation/temporal.ts
1395
+ function isPlainObject(value) {
1396
+ return typeof value === "object" && value != null && !Array.isArray(value);
1397
+ }
1398
+ function normalizeDateTimeLiteral(value) {
1399
+ return value.substring(19).match(/[Z+-]/) ? value : value + "Z";
1400
+ }
1401
+ function isMalformedDateTimeLiteral(value) {
1402
+ if (typeof value !== "string") return false;
1403
+ try {
1404
+ Temporal.Instant.from(normalizeDateTimeLiteral(value));
1405
+ return false;
1406
+ } catch {
1407
+ return true;
1408
+ }
1409
+ }
1410
+ function isMalformedDurationLiteral(value) {
1411
+ if (typeof value !== "string") return false;
1412
+ try {
1413
+ Temporal.Duration.from(value);
1414
+ return false;
1415
+ } catch {
1416
+ return true;
1417
+ }
1418
+ }
1419
+ const TEMPORAL_DATE_TIME_IRIS = new Set([
1420
+ "https://www.w3.org/ns/activitystreams#deleted",
1421
+ "https://www.w3.org/ns/activitystreams#endTime",
1422
+ "https://www.w3.org/ns/activitystreams#published",
1423
+ "https://www.w3.org/ns/activitystreams#startTime",
1424
+ "https://www.w3.org/ns/activitystreams#updated",
1425
+ "http://purl.org/dc/terms/created",
1426
+ "https://w3id.org/security#created"
1427
+ ]);
1428
+ const TEMPORAL_DURATION_IRIS = new Set(["https://www.w3.org/ns/activitystreams#duration"]);
1429
+ const QUESTION_CLOSED_IRI = "https://www.w3.org/ns/activitystreams#closed";
1430
+ const XSD_DATE_TIME_IRI = "http://www.w3.org/2001/XMLSchema#dateTime";
1431
+ function hasMalformedExpandedDateTimeLiteral(value) {
1432
+ if (Array.isArray(value)) return value.some(hasMalformedExpandedDateTimeLiteral);
1433
+ return isPlainObject(value) && "@value" in value && isMalformedDateTimeLiteral(value["@value"]);
1434
+ }
1435
+ function hasMalformedExpandedQuestionClosedLiteral(value) {
1436
+ if (Array.isArray(value)) return value.some(hasMalformedExpandedQuestionClosedLiteral);
1437
+ if (!isPlainObject(value) || !("@value" in value)) return false;
1438
+ const literal = value["@value"];
1439
+ if (typeof literal === "boolean") return false;
1440
+ if (typeof literal !== "string") return false;
1441
+ if (value["@type"] !== XSD_DATE_TIME_IRI) return false;
1442
+ if (new Date(literal).toString() === "Invalid Date") return false;
1443
+ return isMalformedDateTimeLiteral(literal);
1444
+ }
1445
+ function hasMalformedExpandedDurationLiteral(value) {
1446
+ if (Array.isArray(value)) return value.some(hasMalformedExpandedDurationLiteral);
1447
+ return isPlainObject(value) && "@value" in value && isMalformedDurationLiteral(value["@value"]);
1448
+ }
1449
+ function hasMalformedKnownTemporalLiteralInternal(value, visited) {
1450
+ if (Array.isArray(value)) return value.some((item) => hasMalformedKnownTemporalLiteralInternal(item, visited));
1451
+ if (!isPlainObject(value)) return false;
1452
+ if (visited.has(value)) return false;
1453
+ visited.add(value);
1454
+ if ("@value" in value) return false;
1455
+ for (const [key, child] of Object.entries(value)) {
1456
+ if (TEMPORAL_DATE_TIME_IRIS.has(key)) {
1457
+ if (hasMalformedExpandedDateTimeLiteral(child)) return true;
1458
+ continue;
1459
+ }
1460
+ if (key === QUESTION_CLOSED_IRI) {
1461
+ if (hasMalformedExpandedQuestionClosedLiteral(child)) return true;
1462
+ continue;
1463
+ }
1464
+ if (TEMPORAL_DURATION_IRIS.has(key)) {
1465
+ if (hasMalformedExpandedDurationLiteral(child)) return true;
1466
+ continue;
1467
+ }
1468
+ if (hasMalformedKnownTemporalLiteralInternal(child, visited)) return true;
1469
+ }
1470
+ return false;
1471
+ }
1472
+ async function hasMalformedKnownTemporalLiteral(value, contextLoader) {
1473
+ try {
1474
+ return hasMalformedKnownTemporalLiteralInternal(await jsonld.expand(value, {
1475
+ documentLoader: getNormalizationContextLoader(contextLoader),
1476
+ keepFreeFloatingNodes: true
1477
+ }), /* @__PURE__ */ new Set());
1478
+ } catch {
1479
+ return false;
1480
+ }
1481
+ }
1482
+ //#endregion
1065
1483
  //#region src/federation/handler.ts
1484
+ const rawInboxContextFactorySymbol = Symbol("fedify.rawInboxContextFactory");
1485
+ function isRemoteContextLoadingFailure$1(error) {
1486
+ return error instanceof Error && typeof error.details === "object" && error.details != null && error.details.code === "loading remote context failed";
1487
+ }
1488
+ function isPermanentRemoteContextError$1(error) {
1489
+ if (!(error instanceof Error) || error.name !== "jsonld.InvalidUrl") return false;
1490
+ const details = error.details;
1491
+ if (details?.code === "invalid remote context") return true;
1492
+ return isRemoteContextLoadingFailure$1(error) && typeof details?.url === "string" && !URL.canParse(details.url) && isClearlyMalformedContextReference(details.url);
1493
+ }
1494
+ function isInvalidJsonLdError(error) {
1495
+ if (!(error instanceof Error)) return false;
1496
+ const name = error.name;
1497
+ return name === "UnsafeJsonLdError" || error instanceof InvalidContextReferenceError || isPermanentRemoteContextError$1(error) || name === "jsonld.SyntaxError" && !isRemoteContextLoadingFailure$1(error);
1498
+ }
1499
+ function isValidationTypeError(error) {
1500
+ return error instanceof TypeError && (/^(Invalid JSON-LD:|Invalid type:|Unexpected type:|Invalid @id:)/.test(error.message) || isInvalidUrlTypeError(error));
1501
+ }
1502
+ function isPermanentActivityParseError(error) {
1503
+ return isInvalidJsonLdError(error) || isValidationTypeError(error);
1504
+ }
1505
+ function hasHttpSignatureHeaders(request) {
1506
+ return request.headers.has("Signature") || request.headers.has("Signature-Input");
1507
+ }
1508
+ function hasObjectIntegrityProof(json) {
1509
+ return typeof json === "object" && json != null && "proof" in json;
1510
+ }
1066
1511
  /**
1067
1512
  * Handles an actor request.
1068
1513
  * @template TContextData The context data to pass to the context.
@@ -1124,6 +1569,33 @@ async function handleObject(request, { values, context, objectDispatcher, author
1124
1569
  Vary: "Accept"
1125
1570
  } });
1126
1571
  }
1572
+ const BUILT_IN_COLLECTION_METRIC_KINDS = new Set([
1573
+ "inbox",
1574
+ "outbox",
1575
+ "following",
1576
+ "followers",
1577
+ "liked",
1578
+ "featured",
1579
+ "featured_tags"
1580
+ ]);
1581
+ function getCollectionMetricKind(name) {
1582
+ const normalized = name.trim().replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().replace(/\s+/g, "_");
1583
+ return BUILT_IN_COLLECTION_METRIC_KINDS.has(normalized) ? normalized : "custom";
1584
+ }
1585
+ function collectionAttributes(base, result, response) {
1586
+ return {
1587
+ ...base,
1588
+ result,
1589
+ ...response == null ? {} : { statusCode: response.status }
1590
+ };
1591
+ }
1592
+ function recordCollectionMetrics(meterProvider, base, result, options = {}) {
1593
+ const attrs = collectionAttributes(base, result, options.response);
1594
+ recordCollectionRequest(meterProvider, attrs);
1595
+ if (options.dispatchDurationMs != null) recordCollectionDispatchDuration(meterProvider, options.dispatchDurationMs, attrs);
1596
+ if (options.itemCount != null) recordCollectionPageItems(meterProvider, options.itemCount, attrs);
1597
+ if (options.totalItems != null) recordCollectionTotalItems(meterProvider, options.totalItems, attrs);
1598
+ }
1127
1599
  /**
1128
1600
  * Handles a collection request.
1129
1601
  * @template TItem The type of items in the collection.
@@ -1134,36 +1606,118 @@ async function handleObject(request, { values, context, objectDispatcher, author
1134
1606
  * @param parameters The parameters for handling the collection.
1135
1607
  * @returns A promise that resolves to an HTTP response.
1136
1608
  */
1137
- async function handleCollection(request, { name: name$1, identifier, uriGetter, filter, filterPredicate, context, collectionCallbacks, tracerProvider, onUnauthorized, onNotFound }) {
1609
+ async function handleCollection(request, { name: name$1, identifier, uriGetter, filter, filterPredicate, context, collectionCallbacks, tracerProvider, meterProvider, onUnauthorized, onNotFound }) {
1138
1610
  const spanName = name$1.trim().replace(/\s+/g, "_");
1139
1611
  tracerProvider = tracerProvider ?? trace.getTracerProvider();
1140
1612
  const tracer = tracerProvider.getTracer(name, version);
1141
1613
  const cursor = new URL(request.url).searchParams.get("cursor");
1142
- if (collectionCallbacks == null) return await onNotFound(request);
1143
- let collection;
1144
- const baseUri = uriGetter(identifier);
1145
- if (cursor == null) {
1146
- const firstCursor = await collectionCallbacks.firstCursor?.(context, identifier);
1147
- const totalItems = filter == null ? await collectionCallbacks.counter?.(context, identifier) : void 0;
1148
- if (firstCursor == null) {
1149
- const itemsOrResponse = await tracer.startActiveSpan(`activitypub.dispatch_collection ${spanName}`, {
1614
+ const metricBase = {
1615
+ kind: getCollectionMetricKind(name$1),
1616
+ page: cursor != null,
1617
+ dispatcher: "built_in"
1618
+ };
1619
+ let dispatchDurationMs;
1620
+ let itemCount;
1621
+ let totalItemCount;
1622
+ const finish = (response, result) => {
1623
+ recordCollectionMetrics(meterProvider, metricBase, result, {
1624
+ response,
1625
+ dispatchDurationMs,
1626
+ itemCount,
1627
+ totalItems: totalItemCount
1628
+ });
1629
+ return response;
1630
+ };
1631
+ try {
1632
+ if (collectionCallbacks == null) return finish(await onNotFound(request), "not_found");
1633
+ let collection;
1634
+ const baseUri = uriGetter(identifier);
1635
+ if (cursor == null) {
1636
+ const firstCursor = await collectionCallbacks.firstCursor?.(context, identifier);
1637
+ const totalItems = filter == null ? await collectionCallbacks.counter?.(context, identifier) : void 0;
1638
+ totalItemCount = totalItems == null ? void 0 : Number(totalItems);
1639
+ if (firstCursor == null) {
1640
+ const itemsOrResponse = await tracer.startActiveSpan(`activitypub.dispatch_collection ${spanName}`, {
1641
+ kind: SpanKind.SERVER,
1642
+ attributes: {
1643
+ "activitypub.collection.id": baseUri.href,
1644
+ "activitypub.collection.type": OrderedCollection.typeId.href
1645
+ }
1646
+ }, async (span) => {
1647
+ if (totalItemCount != null) span.setAttribute("activitypub.collection.total_items", totalItemCount);
1648
+ const started = performance.now();
1649
+ try {
1650
+ const page = await collectionCallbacks.dispatcher(context, identifier, null, filter);
1651
+ dispatchDurationMs = getDurationMs(started);
1652
+ if (page == null) {
1653
+ span.setStatus({ code: SpanStatusCode.ERROR });
1654
+ return await onNotFound(request);
1655
+ }
1656
+ const items = filterCollectionItems(page.items, name$1, filterPredicate);
1657
+ itemCount = items.length;
1658
+ span.setAttribute("fedify.collection.items", itemCount);
1659
+ return items;
1660
+ } catch (e) {
1661
+ if (dispatchDurationMs == null) dispatchDurationMs = getDurationMs(started);
1662
+ span.setStatus({
1663
+ code: SpanStatusCode.ERROR,
1664
+ message: String(e)
1665
+ });
1666
+ throw e;
1667
+ } finally {
1668
+ span.end();
1669
+ }
1670
+ });
1671
+ if (itemsOrResponse instanceof Response) return finish(itemsOrResponse, "not_found");
1672
+ collection = new OrderedCollection({
1673
+ id: baseUri,
1674
+ totalItems: totalItemCount ?? null,
1675
+ items: itemsOrResponse
1676
+ });
1677
+ } else {
1678
+ const lastCursor = await collectionCallbacks.lastCursor?.(context, identifier);
1679
+ const first = new URL(context.url);
1680
+ first.searchParams.set("cursor", firstCursor);
1681
+ let last = null;
1682
+ if (lastCursor != null) {
1683
+ last = new URL(context.url);
1684
+ last.searchParams.set("cursor", lastCursor);
1685
+ }
1686
+ collection = new OrderedCollection({
1687
+ id: baseUri,
1688
+ totalItems: totalItemCount ?? null,
1689
+ first,
1690
+ last
1691
+ });
1692
+ }
1693
+ } else {
1694
+ const uri = new URL(baseUri);
1695
+ uri.searchParams.set("cursor", cursor);
1696
+ const pageOrResponse = await tracer.startActiveSpan(`activitypub.dispatch_collection_page ${name$1}`, {
1150
1697
  kind: SpanKind.SERVER,
1151
1698
  attributes: {
1152
- "activitypub.collection.id": baseUri.href,
1153
- "activitypub.collection.type": OrderedCollection.typeId.href
1699
+ "activitypub.collection.id": uri.href,
1700
+ "activitypub.collection.type": OrderedCollectionPage.typeId.href,
1701
+ "fedify.collection.cursor": cursor
1154
1702
  }
1155
1703
  }, async (span) => {
1156
- if (totalItems != null) span.setAttribute("activitypub.collection.total_items", Number(totalItems));
1704
+ const started = performance.now();
1157
1705
  try {
1158
- const page = await collectionCallbacks.dispatcher(context, identifier, null, filter);
1706
+ const page = await collectionCallbacks.dispatcher(context, identifier, cursor, filter);
1707
+ dispatchDurationMs = getDurationMs(started);
1159
1708
  if (page == null) {
1160
1709
  span.setStatus({ code: SpanStatusCode.ERROR });
1161
1710
  return await onNotFound(request);
1162
1711
  }
1163
- const { items } = page;
1164
- span.setAttribute("fedify.collection.items", items.length);
1165
- return items;
1712
+ const items = filterCollectionItems(page.items, name$1, filterPredicate);
1713
+ itemCount = items.length;
1714
+ span.setAttribute("fedify.collection.items", itemCount);
1715
+ return {
1716
+ ...page,
1717
+ items
1718
+ };
1166
1719
  } catch (e) {
1720
+ if (dispatchDurationMs == null) dispatchDurationMs = getDurationMs(started);
1167
1721
  span.setStatus({
1168
1722
  code: SpanStatusCode.ERROR,
1169
1723
  message: String(e)
@@ -1173,87 +1727,44 @@ async function handleCollection(request, { name: name$1, identifier, uriGetter,
1173
1727
  span.end();
1174
1728
  }
1175
1729
  });
1176
- if (itemsOrResponse instanceof Response) return itemsOrResponse;
1177
- collection = new OrderedCollection({
1178
- id: baseUri,
1179
- totalItems: totalItems == null ? null : Number(totalItems),
1180
- items: filterCollectionItems(itemsOrResponse, name$1, filterPredicate)
1181
- });
1182
- } else {
1183
- const lastCursor = await collectionCallbacks.lastCursor?.(context, identifier);
1184
- const first = new URL(context.url);
1185
- first.searchParams.set("cursor", firstCursor);
1186
- let last = null;
1187
- if (lastCursor != null) {
1188
- last = new URL(context.url);
1189
- last.searchParams.set("cursor", lastCursor);
1190
- }
1191
- collection = new OrderedCollection({
1192
- id: baseUri,
1193
- totalItems: totalItems == null ? null : Number(totalItems),
1194
- first,
1195
- last
1196
- });
1197
- }
1198
- } else {
1199
- const uri = new URL(baseUri);
1200
- uri.searchParams.set("cursor", cursor);
1201
- const pageOrResponse = await tracer.startActiveSpan(`activitypub.dispatch_collection_page ${name$1}`, {
1202
- kind: SpanKind.SERVER,
1203
- attributes: {
1204
- "activitypub.collection.id": uri.href,
1205
- "activitypub.collection.type": OrderedCollectionPage.typeId.href,
1206
- "fedify.collection.cursor": cursor
1730
+ if (pageOrResponse instanceof Response) return finish(pageOrResponse, "not_found");
1731
+ const { items, prevCursor, nextCursor } = pageOrResponse;
1732
+ let prev = null;
1733
+ if (prevCursor != null) {
1734
+ prev = new URL(context.url);
1735
+ prev.searchParams.set("cursor", prevCursor);
1207
1736
  }
1208
- }, async (span) => {
1209
- try {
1210
- const page = await collectionCallbacks.dispatcher(context, identifier, cursor, filter);
1211
- if (page == null) {
1212
- span.setStatus({ code: SpanStatusCode.ERROR });
1213
- return await onNotFound(request);
1214
- }
1215
- span.setAttribute("fedify.collection.items", page.items.length);
1216
- return page;
1217
- } catch (e) {
1218
- span.setStatus({
1219
- code: SpanStatusCode.ERROR,
1220
- message: String(e)
1221
- });
1222
- throw e;
1223
- } finally {
1224
- span.end();
1737
+ let next = null;
1738
+ if (nextCursor != null) {
1739
+ next = new URL(context.url);
1740
+ next.searchParams.set("cursor", nextCursor);
1225
1741
  }
1226
- });
1227
- if (pageOrResponse instanceof Response) return pageOrResponse;
1228
- const { items, prevCursor, nextCursor } = pageOrResponse;
1229
- let prev = null;
1230
- if (prevCursor != null) {
1231
- prev = new URL(context.url);
1232
- prev.searchParams.set("cursor", prevCursor);
1742
+ const partOf = new URL(context.url);
1743
+ partOf.searchParams.delete("cursor");
1744
+ collection = new OrderedCollectionPage({
1745
+ id: uri,
1746
+ prev,
1747
+ next,
1748
+ items,
1749
+ partOf
1750
+ });
1233
1751
  }
1234
- let next = null;
1235
- if (nextCursor != null) {
1236
- next = new URL(context.url);
1237
- next.searchParams.set("cursor", nextCursor);
1752
+ if (collectionCallbacks.authorizePredicate != null) {
1753
+ if (!await collectionCallbacks.authorizePredicate(context, identifier)) return finish(await onUnauthorized(request), "unauthorized");
1238
1754
  }
1239
- const partOf = new URL(context.url);
1240
- partOf.searchParams.delete("cursor");
1241
- collection = new OrderedCollectionPage({
1242
- id: uri,
1243
- prev,
1244
- next,
1245
- items: filterCollectionItems(items, name$1, filterPredicate),
1246
- partOf
1755
+ const jsonLd = await collection.toJsonLd(context);
1756
+ return finish(new Response(JSON.stringify(jsonLd), { headers: {
1757
+ "Content-Type": "application/activity+json",
1758
+ Vary: "Accept"
1759
+ } }), "served");
1760
+ } catch (e) {
1761
+ recordCollectionMetrics(meterProvider, metricBase, "error", {
1762
+ dispatchDurationMs,
1763
+ itemCount,
1764
+ totalItems: totalItemCount
1247
1765
  });
1766
+ throw e;
1248
1767
  }
1249
- if (collectionCallbacks.authorizePredicate != null) {
1250
- if (!await collectionCallbacks.authorizePredicate(context, identifier)) return await onUnauthorized(request);
1251
- }
1252
- const jsonLd = await collection.toJsonLd(context);
1253
- return new Response(JSON.stringify(jsonLd), { headers: {
1254
- "Content-Type": "application/activity+json",
1255
- Vary: "Accept"
1256
- } });
1257
1768
  }
1258
1769
  /**
1259
1770
  * Filters collection items based on the provided predicate.
@@ -1517,7 +2028,7 @@ async function handleInbox(request, options) {
1517
2028
  * @returns A promise that resolves to an HTTP response.
1518
2029
  */
1519
2030
  async function handleInboxInternal(request, parameters, span) {
1520
- const { recipient, context: ctx, inboxContextFactory, kv, kvPrefixes, queue, actorDispatcher, inboxListeners, inboxErrorHandler, unverifiedActivityHandler, onNotFound, signatureTimeWindow, skipSignatureVerification, inboxChallengePolicy, tracerProvider } = parameters;
2031
+ const { recipient, context: ctx, inboxContextFactory, kv, kvPrefixes, queue, actorDispatcher, inboxListeners, inboxErrorHandler, unverifiedActivityHandler, onNotFound, signatureTimeWindow, skipSignatureVerification, inboxChallengePolicy, meterProvider, tracerProvider } = parameters;
1521
2032
  const logger = getLogger([
1522
2033
  "fedify",
1523
2034
  "federation",
@@ -1589,28 +2100,105 @@ async function handleInboxInternal(request, parameters, span) {
1589
2100
  });
1590
2101
  }
1591
2102
  const keyCache = new KvKeyCache(kv, kvPrefixes.publicKey, ctx);
1592
- let ldSigVerified;
1593
- try {
1594
- ldSigVerified = await verifyJsonLd(json, {
1595
- contextLoader: ctx.contextLoader,
1596
- documentLoader: ctx.documentLoader,
1597
- keyCache,
1598
- tracerProvider
1599
- });
1600
- } catch (error) {
1601
- if (error instanceof Error && error.name === "jsonld.SyntaxError") {
1602
- logger.error("Failed to parse JSON-LD:\n{error}", {
2103
+ const jsonWithoutSig = detachSignature(json);
2104
+ const hasLdSignature = hasSignature(json);
2105
+ const canAttemptAlternateAuthAfterLdSignatureFailure = skipSignatureVerification || hasHttpSignatureHeaders(request) || hasObjectIntegrityProof(jsonWithoutSig);
2106
+ let deferredLdSignatureError = void 0;
2107
+ const respondInvalidActivity = async (error) => {
2108
+ logger.error("Failed to parse activity:\n{error}", {
2109
+ recipient,
2110
+ activity: json,
2111
+ error
2112
+ });
2113
+ try {
2114
+ await inboxErrorHandler?.(ctx, error);
2115
+ } catch (error) {
2116
+ logger.error("An unexpected error occurred in inbox error handler:\n{error}", {
2117
+ error,
2118
+ activity: json,
2119
+ recipient
2120
+ });
2121
+ }
2122
+ span.setStatus({
2123
+ code: SpanStatusCode.ERROR,
2124
+ message: `Failed to parse activity:\n${error}`
2125
+ });
2126
+ return new Response("Invalid activity.", {
2127
+ status: 400,
2128
+ headers: { "Content-Type": "text/plain; charset=utf-8" }
2129
+ });
2130
+ };
2131
+ let compactedJson = json;
2132
+ let compactedJsonWithoutSig = jsonWithoutSig;
2133
+ let ldSigVerified = false;
2134
+ if (hasLdSignature) {
2135
+ try {
2136
+ compactedJson = await compactJsonLd(json, ctx.contextLoader);
2137
+ } catch (error) {
2138
+ if (isInvalidJsonLdError(error)) {
2139
+ logger.error("Failed to parse JSON-LD:\n{error}", {
2140
+ recipient,
2141
+ error
2142
+ });
2143
+ return new Response("Invalid JSON-LD.", {
2144
+ status: 400,
2145
+ headers: { "Content-Type": "text/plain; charset=utf-8" }
2146
+ });
2147
+ }
2148
+ if (!canAttemptAlternateAuthAfterLdSignatureFailure) throw error;
2149
+ if (!skipSignatureVerification) deferredLdSignatureError = error;
2150
+ logger.debug("Failed to normalize JSON-LD for Linked Data Signatures; deferring to another authentication path only if it verifies:\n{error}", {
1603
2151
  recipient,
1604
2152
  error
1605
2153
  });
1606
- return new Response("Invalid JSON-LD.", {
1607
- status: 400,
1608
- headers: { "Content-Type": "text/plain; charset=utf-8" }
1609
- });
1610
2154
  }
1611
- ldSigVerified = false;
2155
+ if (compactedJson !== json) {
2156
+ compactedJsonWithoutSig = detachSignature(compactedJson);
2157
+ try {
2158
+ ldSigVerified = await verifyCompactJsonLd(compactedJson, {
2159
+ contextLoader: ctx.contextLoader,
2160
+ documentLoader: ctx.documentLoader,
2161
+ keyCache,
2162
+ meterProvider,
2163
+ tracerProvider
2164
+ });
2165
+ } catch (error) {
2166
+ if (error instanceof RangeError && await hasMalformedKnownTemporalLiteral(compactedJsonWithoutSig, ctx.contextLoader)) return await respondInvalidActivity(error);
2167
+ if (isInvalidJsonLdError(error)) {
2168
+ logger.error("Failed to parse JSON-LD:\n{error}", {
2169
+ recipient,
2170
+ error
2171
+ });
2172
+ return new Response("Invalid JSON-LD.", {
2173
+ status: 400,
2174
+ headers: { "Content-Type": "text/plain; charset=utf-8" }
2175
+ });
2176
+ }
2177
+ if (!canAttemptAlternateAuthAfterLdSignatureFailure) throw error;
2178
+ if (!skipSignatureVerification) try {
2179
+ await Object$1.fromJsonLd(compactedJson, {
2180
+ contextLoader: getNormalizationContextLoader(ctx.contextLoader),
2181
+ documentLoader: ctx.documentLoader,
2182
+ tracerProvider
2183
+ });
2184
+ } catch (parseError) {
2185
+ if (parseError instanceof RangeError && await hasMalformedKnownTemporalLiteral(compactedJsonWithoutSig, ctx.contextLoader)) return await respondInvalidActivity(parseError);
2186
+ if (isInvalidJsonLdError(parseError)) {
2187
+ logger.error("Failed to parse JSON-LD:\n{error}", {
2188
+ recipient,
2189
+ error: parseError
2190
+ });
2191
+ return new Response("Invalid JSON-LD.", {
2192
+ status: 400,
2193
+ headers: { "Content-Type": "text/plain; charset=utf-8" }
2194
+ });
2195
+ }
2196
+ deferredLdSignatureError = parseError;
2197
+ }
2198
+ ldSigVerified = false;
2199
+ }
2200
+ }
1612
2201
  }
1613
- const jsonWithoutSig = detachSignature(json);
1614
2202
  let activity = null;
1615
2203
  let activityVerified = false;
1616
2204
  if (ldSigVerified) {
@@ -1618,7 +2206,16 @@ async function handleInboxInternal(request, parameters, span) {
1618
2206
  recipient,
1619
2207
  json
1620
2208
  });
1621
- activity = await Activity.fromJsonLd(jsonWithoutSig, ctx);
2209
+ try {
2210
+ activity = await Activity.fromJsonLd(compactedJsonWithoutSig, {
2211
+ ...ctx,
2212
+ contextLoader: getNormalizationContextLoader(ctx.contextLoader)
2213
+ });
2214
+ } catch (error) {
2215
+ if (error instanceof RangeError && await hasMalformedKnownTemporalLiteral(compactedJsonWithoutSig, ctx.contextLoader)) return await respondInvalidActivity(error);
2216
+ if (!isPermanentActivityParseError(error)) throw error;
2217
+ return await respondInvalidActivity(error);
2218
+ }
1622
2219
  activityVerified = true;
1623
2220
  } else {
1624
2221
  logger.debug("Linked Data Signatures are not verified.", {
@@ -1627,12 +2224,22 @@ async function handleInboxInternal(request, parameters, span) {
1627
2224
  });
1628
2225
  try {
1629
2226
  activity = await verifyObject(Activity, jsonWithoutSig, {
1630
- contextLoader: ctx.contextLoader,
2227
+ contextLoader: wrapContextLoaderForJsonLd(ctx.contextLoader),
1631
2228
  documentLoader: ctx.documentLoader,
1632
2229
  keyCache,
2230
+ meterProvider,
1633
2231
  tracerProvider
1634
2232
  });
1635
2233
  } catch (error) {
2234
+ if (error instanceof RangeError && await hasMalformedKnownTemporalLiteral(jsonWithoutSig, ctx.contextLoader)) return await respondInvalidActivity(error);
2235
+ if (deferredLdSignatureError != null) {
2236
+ logger.debug("Object Integrity Proof fallback did not supersede a deferred Linked Data Signature failure:\n{error}", {
2237
+ recipient,
2238
+ error
2239
+ });
2240
+ activity = null;
2241
+ }
2242
+ if (!isPermanentActivityParseError(error)) throw error;
1636
2243
  logger.error("Failed to parse activity:\n{error}", {
1637
2244
  recipient,
1638
2245
  activity: json,
@@ -1677,10 +2284,14 @@ async function handleInboxInternal(request, parameters, span) {
1677
2284
  documentLoader: ctx.documentLoader,
1678
2285
  timeWindow: signatureTimeWindow,
1679
2286
  keyCache,
2287
+ meterProvider,
1680
2288
  tracerProvider
1681
2289
  });
1682
2290
  if (verification.verified === false) {
2291
+ if (deferredLdSignatureError != null) throw deferredLdSignatureError;
1683
2292
  const reason = verification.reason;
2293
+ const remoteHost = "keyId" in reason && reason.keyId != null ? getRemoteHost(reason.keyId) : void 0;
2294
+ getFederationMetrics(parameters.meterProvider).recordSignatureVerificationFailure(reason.type, remoteHost);
1684
2295
  logger.error("Failed to verify the request's HTTP Signatures.", {
1685
2296
  recipient,
1686
2297
  reason: reason.type,
@@ -1755,7 +2366,15 @@ async function handleInboxInternal(request, parameters, span) {
1755
2366
  }
1756
2367
  httpSigKey = verification.key;
1757
2368
  }
1758
- activity = await Activity.fromJsonLd(jsonWithoutSig, ctx);
2369
+ try {
2370
+ activity = await Activity.fromJsonLd(jsonWithoutSig, {
2371
+ ...ctx,
2372
+ contextLoader: wrapContextLoaderForJsonLd(ctx.contextLoader)
2373
+ });
2374
+ } catch (error) {
2375
+ if (!isPermanentActivityParseError(error)) throw error;
2376
+ return await respondInvalidActivity(error);
2377
+ }
1759
2378
  }
1760
2379
  if (activity.id != null) span.setAttribute("activitypub.activity.id", activity.id.href);
1761
2380
  span.setAttribute("activitypub.activity.type", getTypeId(activity).href);
@@ -1767,6 +2386,8 @@ async function handleInboxInternal(request, parameters, span) {
1767
2386
  "http_signatures.key_id": httpSigKey?.id?.href ?? ""
1768
2387
  });
1769
2388
  if (httpSigKey != null && !await doesActorOwnKey(activity, httpSigKey, ctx)) {
2389
+ if (deferredLdSignatureError != null) throw deferredLdSignatureError;
2390
+ getFederationMetrics(parameters.meterProvider).recordSignatureVerificationFailure("actorKeyMismatch", httpSigKey.id == null ? void 0 : getRemoteHost(httpSigKey.id));
1770
2391
  logger.error("The signer ({keyId}) and the actor ({actorId}) do not match.", {
1771
2392
  activity: json,
1772
2393
  recipient,
@@ -1784,6 +2405,7 @@ async function handleInboxInternal(request, parameters, span) {
1784
2405
  }
1785
2406
  if (pendingNonceLabel != null) {
1786
2407
  if (!await verifySignatureNonce(request, kv, kvPrefixes.acceptSignatureNonce, pendingNonceLabel)) {
2408
+ getFederationMetrics(parameters.meterProvider).recordSignatureVerificationFailure("invalidNonce", httpSigKey?.id == null ? void 0 : getRemoteHost(httpSigKey.id));
1787
2409
  logger.error("Signature nonce verification failed (missing, expired, or replayed).", { recipient });
1788
2410
  return await getFailedSignatureResponse(inboxChallengePolicy, kv, kvPrefixes);
1789
2411
  }
@@ -1791,15 +2413,20 @@ async function handleInboxInternal(request, parameters, span) {
1791
2413
  const routeResult = await routeActivity({
1792
2414
  context: ctx,
1793
2415
  json,
2416
+ originalJson: json,
2417
+ normalizedActivity: hasLdSignature && compactedJson !== json ? compactedJson : void 0,
2418
+ ldSignatureVerified: hasLdSignature ? ldSigVerified : void 0,
1794
2419
  activity,
1795
2420
  recipient,
1796
2421
  inboxListeners,
1797
2422
  inboxContextFactory,
2423
+ listenerInboxContextFactory: ldSigVerified ? inboxContextFactory[rawInboxContextFactorySymbol] : void 0,
1798
2424
  inboxErrorHandler,
1799
2425
  kv,
1800
2426
  kvPrefixes,
1801
2427
  queue,
1802
2428
  span,
2429
+ meterProvider: parameters.meterProvider,
1803
2430
  tracerProvider,
1804
2431
  idempotencyStrategy: parameters.idempotencyStrategy
1805
2432
  });
@@ -1840,11 +2467,31 @@ async function handleInboxInternal(request, parameters, span) {
1840
2467
  * @since 1.8.0
1841
2468
  */
1842
2469
  const handleCustomCollection = exceptWrapper(_handleCustomCollection);
1843
- async function _handleCustomCollection(request, { name, values, context, tracerProvider, collectionCallbacks: callbacks, filterPredicate }) {
2470
+ const pendingCollectionMetricRecorders = /* @__PURE__ */ new WeakMap();
2471
+ function deferPendingCollectionMetrics(error, recorder) {
2472
+ if (error == null || typeof error !== "object" && typeof error !== "function") return false;
2473
+ pendingCollectionMetricRecorders.set(error, recorder);
2474
+ return true;
2475
+ }
2476
+ function recordDeferredPendingCollectionMetrics(error, result, response) {
2477
+ if (error == null || typeof error !== "object" && typeof error !== "function") return;
2478
+ const recorder = pendingCollectionMetricRecorders.get(error);
2479
+ pendingCollectionMetricRecorders.delete(error);
2480
+ recorder?.(result, response);
2481
+ }
2482
+ async function _handleCustomCollection(request, { name, values, context, tracerProvider, meterProvider, collectionCallbacks: callbacks, filterPredicate }) {
1844
2483
  verifyDefined(callbacks);
1845
2484
  await authIfNeeded(context, values, callbacks);
1846
2485
  const cursor = new URL(request.url).searchParams.get("cursor");
1847
- return await new CustomCollectionHandler(name, values, context, callbacks, tracerProvider, Collection, CollectionPage, filterPredicate).fetchCollection(cursor).toJsonLd().then(respondAsActivity);
2486
+ const handler = new CustomCollectionHandler(name, values, context, callbacks, tracerProvider, meterProvider, Collection, CollectionPage, filterPredicate).fetchCollection(cursor);
2487
+ try {
2488
+ const response = await handler.toJsonLd().then(respondAsActivity);
2489
+ handler.recordPendingCollectionMetrics("served", response);
2490
+ return response;
2491
+ } catch (e) {
2492
+ if (!deferPendingCollectionMetrics(e, (result, response) => handler.recordPendingCollectionMetrics(result, response))) handler.recordPendingCollectionMetrics("error");
2493
+ throw e;
2494
+ }
1848
2495
  }
1849
2496
  /**
1850
2497
  * Handles an ordered collection request.
@@ -1858,11 +2505,19 @@ async function _handleCustomCollection(request, { name, values, context, tracerP
1858
2505
  * @since 1.8.0
1859
2506
  */
1860
2507
  const handleOrderedCollection = exceptWrapper(_handleOrderedCollection);
1861
- async function _handleOrderedCollection(request, { name, values, context, tracerProvider, collectionCallbacks: callbacks, filterPredicate }) {
2508
+ async function _handleOrderedCollection(request, { name, values, context, tracerProvider, meterProvider, collectionCallbacks: callbacks, filterPredicate }) {
1862
2509
  verifyDefined(callbacks);
1863
2510
  await authIfNeeded(context, values, callbacks);
1864
2511
  const cursor = new URL(request.url).searchParams.get("cursor");
1865
- return await new CustomCollectionHandler(name, values, context, callbacks, tracerProvider, OrderedCollection, OrderedCollectionPage, filterPredicate).fetchCollection(cursor).toJsonLd().then(respondAsActivity);
2512
+ const handler = new CustomCollectionHandler(name, values, context, callbacks, tracerProvider, meterProvider, OrderedCollection, OrderedCollectionPage, filterPredicate).fetchCollection(cursor);
2513
+ try {
2514
+ const response = await handler.toJsonLd().then(respondAsActivity);
2515
+ handler.recordPendingCollectionMetrics("served", response);
2516
+ return response;
2517
+ } catch (e) {
2518
+ if (!deferPendingCollectionMetrics(e, (result, response) => handler.recordPendingCollectionMetrics(result, response))) handler.recordPendingCollectionMetrics("error");
2519
+ throw e;
2520
+ }
1866
2521
  }
1867
2522
  /**
1868
2523
  * Handling custom collections with support for pagination and filtering.
@@ -1877,6 +2532,15 @@ async function _handleOrderedCollection(request, { name, values, context, tracer
1877
2532
  * @since 1.8.0
1878
2533
  */
1879
2534
  var CustomCollectionHandler = class {
2535
+ name;
2536
+ values;
2537
+ context;
2538
+ callbacks;
2539
+ tracerProvider;
2540
+ meterProvider;
2541
+ Collection;
2542
+ CollectionPage;
2543
+ filterPredicate;
1880
2544
  /**
1881
2545
  * The tracer for telemetry.
1882
2546
  * @type {Tracer}
@@ -1901,6 +2565,7 @@ var CustomCollectionHandler = class {
1901
2565
  */
1902
2566
  #dispatcher;
1903
2567
  #collection = null;
2568
+ #pendingCollectionMetrics = [];
1904
2569
  /**
1905
2570
  * Creates a new CustomCollection instance.
1906
2571
  * @param name The name of the collection.
@@ -1912,12 +2577,13 @@ var CustomCollectionHandler = class {
1912
2577
  * @param CollectionPage The CollectionPage constructor.
1913
2578
  * @param filterPredicate Optional filter predicate for items.
1914
2579
  */
1915
- constructor(name$2, values, context, callbacks, tracerProvider = trace.getTracerProvider(), Collection, CollectionPage, filterPredicate) {
2580
+ constructor(name$2, values, context, callbacks, tracerProvider = trace.getTracerProvider(), meterProvider, Collection, CollectionPage, filterPredicate) {
1916
2581
  this.name = name$2;
1917
2582
  this.values = values;
1918
2583
  this.context = context;
1919
2584
  this.callbacks = callbacks;
1920
2585
  this.tracerProvider = tracerProvider;
2586
+ this.meterProvider = meterProvider;
1921
2587
  this.Collection = Collection;
1922
2588
  this.CollectionPage = CollectionPage;
1923
2589
  this.filterPredicate = filterPredicate;
@@ -1970,10 +2636,12 @@ var CustomCollectionHandler = class {
1970
2636
  const { prevCursor, nextCursor } = pages;
1971
2637
  const partOf = new URL(id);
1972
2638
  partOf.searchParams.delete("cursor");
2639
+ const items = this.filterItems(pages.items);
2640
+ this.recordPendingCollectionItemCount(true, items.length);
1973
2641
  return {
1974
2642
  id,
1975
2643
  partOf,
1976
- items: this.filterItems(pages.items),
2644
+ items,
1977
2645
  prev: this.appendToUrl(prevCursor),
1978
2646
  next: this.appendToUrl(nextCursor)
1979
2647
  };
@@ -1986,11 +2654,16 @@ var CustomCollectionHandler = class {
1986
2654
  */
1987
2655
  async getProps(firstCursor) {
1988
2656
  const lastCursor = await this.callbacks.lastCursor?.(this.context, this.values);
2657
+ const totalItems = await this.totalItems;
2658
+ if (totalItems != null) this.#pendingCollectionMetrics.push({
2659
+ page: false,
2660
+ totalItems: Number(totalItems)
2661
+ });
1989
2662
  return {
1990
2663
  id: this.#id,
1991
2664
  first: this.appendToUrl(firstCursor),
1992
2665
  last: this.appendToUrl(lastCursor),
1993
- totalItems: await this.totalItems
2666
+ totalItems
1994
2667
  };
1995
2668
  }
1996
2669
  /**
@@ -2000,10 +2673,12 @@ var CustomCollectionHandler = class {
2000
2673
  async getPropsWithoutCursor() {
2001
2674
  const totalItems = await this.totalItems;
2002
2675
  const pages = await this.getPages({ totalItems });
2676
+ const items = this.filterItems(pages.items);
2677
+ this.recordPendingCollectionItemCount(false, items.length);
2003
2678
  return {
2004
2679
  id: this.#id,
2005
2680
  totalItems,
2006
- items: this.filterItems(pages.items)
2681
+ items
2007
2682
  };
2008
2683
  }
2009
2684
  /**
@@ -2038,12 +2713,24 @@ var CustomCollectionHandler = class {
2038
2713
  * @returns A function that handles the span operation.
2039
2714
  */
2040
2715
  spanPages = ({ totalItems = null, cursor = null }) => async (span) => {
2716
+ const pageMetricBase = this.metricBase(cursor !== null);
2717
+ const started = performance.now();
2041
2718
  try {
2042
2719
  if (totalItems !== null) span.setAttribute(this.ATTRS.TOTAL_ITEMS, totalItems);
2043
2720
  const page = await this.dispatch(cursor);
2721
+ const durationMs = getDurationMs(started);
2044
2722
  span.setAttribute(this.ATTRS.ITEMS, page.items.length);
2723
+ this.#pendingCollectionMetrics.push({
2724
+ page: pageMetricBase.page,
2725
+ dispatchDurationMs: durationMs,
2726
+ totalItems: totalItems == null ? void 0 : Number(totalItems)
2727
+ });
2045
2728
  return page;
2046
2729
  } catch (e) {
2730
+ this.#pendingCollectionMetrics.push({
2731
+ page: cursor !== null,
2732
+ dispatchDurationMs: getDurationMs(started)
2733
+ });
2047
2734
  const message = e instanceof Error ? e.message : String(e);
2048
2735
  span.setStatus({
2049
2736
  code: SpanStatusCode.ERROR,
@@ -2070,6 +2757,37 @@ var CustomCollectionHandler = class {
2070
2757
  filterItems(items) {
2071
2758
  return filterCollectionItems(items, this.name, this.filterPredicate);
2072
2759
  }
2760
+ metricBase(page) {
2761
+ return {
2762
+ kind: "custom",
2763
+ page,
2764
+ dispatcher: "custom"
2765
+ };
2766
+ }
2767
+ metricAttributes(page, result, response) {
2768
+ return collectionAttributes(this.metricBase(page), result, response);
2769
+ }
2770
+ recordPendingCollectionItemCount(page, itemCount) {
2771
+ for (let i = this.#pendingCollectionMetrics.length - 1; i >= 0; i--) {
2772
+ const measurement = this.#pendingCollectionMetrics[i];
2773
+ if (measurement.page === page && measurement.dispatchDurationMs != null && measurement.itemCount == null) {
2774
+ measurement.itemCount = itemCount;
2775
+ return;
2776
+ }
2777
+ }
2778
+ this.#pendingCollectionMetrics.push({
2779
+ page,
2780
+ itemCount
2781
+ });
2782
+ }
2783
+ recordPendingCollectionMetrics(result, response) {
2784
+ for (const measurement of this.#pendingCollectionMetrics.splice(0)) {
2785
+ const attrs = this.metricAttributes(measurement.page, result, response);
2786
+ if (measurement.dispatchDurationMs != null) recordCollectionDispatchDuration(this.meterProvider, measurement.dispatchDurationMs, attrs);
2787
+ if (measurement.itemCount != null) recordCollectionPageItems(this.meterProvider, measurement.itemCount, attrs);
2788
+ if (measurement.totalItems != null) recordCollectionTotalItems(this.meterProvider, measurement.totalItems, attrs);
2789
+ }
2790
+ }
2073
2791
  /**
2074
2792
  * Appends a cursor to the URL if it exists.
2075
2793
  * @param cursor The cursor to append, or null/undefined.
@@ -2133,14 +2851,36 @@ var CustomCollectionHandler = class {
2133
2851
  */
2134
2852
  function exceptWrapper(handler) {
2135
2853
  return async (request, handlerParams) => {
2854
+ const page = new URL(request.url).searchParams.get("cursor") != null;
2855
+ const { meterProvider } = handlerParams;
2856
+ const metricBase = {
2857
+ kind: "custom",
2858
+ page,
2859
+ dispatcher: "custom"
2860
+ };
2136
2861
  try {
2137
- return await handler(request, handlerParams);
2862
+ const response = await handler(request, handlerParams);
2863
+ recordCollectionRequest(meterProvider, collectionAttributes(metricBase, "served", response));
2864
+ return response;
2138
2865
  } catch (error) {
2139
2866
  const { onNotFound, onUnauthorized } = handlerParams;
2140
2867
  switch (error?.constructor) {
2141
- case ItemsNotFoundError: return await onNotFound(request);
2142
- case UnauthorizedError: return await onUnauthorized(request);
2143
- default: throw error;
2868
+ case ItemsNotFoundError: {
2869
+ const response = await onNotFound(request);
2870
+ recordDeferredPendingCollectionMetrics(error, "not_found", response);
2871
+ recordCollectionRequest(meterProvider, collectionAttributes(metricBase, "not_found", response));
2872
+ return response;
2873
+ }
2874
+ case UnauthorizedError: {
2875
+ const response = await onUnauthorized(request);
2876
+ recordDeferredPendingCollectionMetrics(error, "unauthorized", response);
2877
+ recordCollectionRequest(meterProvider, collectionAttributes(metricBase, "unauthorized", response));
2878
+ return response;
2879
+ }
2880
+ default:
2881
+ recordDeferredPendingCollectionMetrics(error, "error");
2882
+ recordCollectionRequest(meterProvider, collectionAttributes(metricBase, "error"));
2883
+ throw error;
2144
2884
  }
2145
2885
  }
2146
2886
  };
@@ -2358,36 +3098,6 @@ function handleNodeInfoJrd(_request, context) {
2358
3098
  return Promise.resolve(response);
2359
3099
  }
2360
3100
  //#endregion
2361
- //#region src/federation/retry.ts
2362
- /**
2363
- * Creates an exponential backoff retry policy. The delay between retries
2364
- * starts at the `initialDelay` and is multiplied by the `factor` for each
2365
- * subsequent retry, up to the `maxDelay`. The policy will give up after
2366
- * `maxAttempts` attempts. The actual delay is randomized to avoid
2367
- * synchronization (jitter).
2368
- * @param options The options for the policy.
2369
- * @returns The retry policy.
2370
- * @since 0.12.0
2371
- */
2372
- function createExponentialBackoffPolicy(options = {}) {
2373
- const initialDelay = Temporal.Duration.from(options.initialDelay ?? { seconds: 1 });
2374
- const maxDelay = Temporal.Duration.from(options.maxDelay ?? { hours: 12 });
2375
- const maxAttempts = options.maxAttempts ?? 10;
2376
- const factor = options.factor ?? 2;
2377
- const jitter = options.jitter ?? true;
2378
- return ({ attempts }) => {
2379
- if (attempts >= maxAttempts) return null;
2380
- let milliseconds = initialDelay.total("millisecond");
2381
- milliseconds *= factor ** attempts;
2382
- if (jitter) {
2383
- milliseconds *= 1 + Math.random();
2384
- milliseconds = Math.round(milliseconds);
2385
- }
2386
- const delay = Temporal.Duration.from({ milliseconds });
2387
- return Temporal.Duration.compare(delay, maxDelay) > 0 ? maxDelay : delay;
2388
- };
2389
- }
2390
- //#endregion
2391
3101
  //#region src/federation/send.ts
2392
3102
  /**
2393
3103
  * Extracts the inbox URLs from recipients.
@@ -2447,6 +3157,25 @@ function sendActivity(options) {
2447
3157
  });
2448
3158
  }
2449
3159
  const MAX_ERROR_RESPONSE_BODY_BYTES = 1024;
3160
+ function getActivityActorId(activity) {
3161
+ if (!isRecord(activity)) return void 0;
3162
+ return getIdValue(activity.actor);
3163
+ }
3164
+ function getIdValue(value) {
3165
+ if (typeof value === "string" && value !== "") return value;
3166
+ if (value instanceof URL) return value.href;
3167
+ if (Array.isArray(value)) {
3168
+ for (const item of value) {
3169
+ const id = getIdValue(item);
3170
+ if (id != null) return id;
3171
+ }
3172
+ return;
3173
+ }
3174
+ if (isRecord(value)) return getIdValue(value.id);
3175
+ }
3176
+ function isRecord(value) {
3177
+ return typeof value === "object" && value != null;
3178
+ }
2450
3179
  async function readLimitedResponseBody(response, maxBytes) {
2451
3180
  if (response.body == null) return "";
2452
3181
  const reader = response.body.getReader();
@@ -2474,12 +3203,15 @@ async function readLimitedResponseBody(response, maxBytes) {
2474
3203
  if (truncated) result += "… (truncated)";
2475
3204
  return result;
2476
3205
  }
2477
- async function sendActivityInternal({ activity, activityId, keys, inbox, headers, specDeterminer, tracerProvider }, span) {
3206
+ async function sendActivityInternal({ activity, activityId, activityType, keys, inbox, headers, specDeterminer, meterProvider, tracerProvider }, span) {
2478
3207
  const logger = getLogger([
2479
3208
  "fedify",
2480
3209
  "federation",
2481
3210
  "outbox"
2482
3211
  ]);
3212
+ const federationMetrics = getFederationMetrics(meterProvider);
3213
+ const started = performance.now();
3214
+ let deliverySuccess = false;
2483
3215
  headers = new Headers(headers);
2484
3216
  headers.set("Content-Type", "application/activity+json");
2485
3217
  const request = new Request(inbox, {
@@ -2506,34 +3238,49 @@ async function sendActivityInternal({ activity, activityId, keys, inbox, headers
2506
3238
  specDeterminer
2507
3239
  });
2508
3240
  } catch (error) {
3241
+ const transportError = error instanceof FetchError ? error : createFetchError(inbox.href, error);
2509
3242
  logger.error("Failed to send activity {activityId} to {inbox}:\n{error}", {
2510
3243
  activityId,
2511
3244
  inbox: inbox.href,
2512
- error
3245
+ error: transportError
2513
3246
  });
2514
- throw error;
3247
+ federationMetrics.recordDelivery(inbox, getDurationMs(started), false, activityType);
3248
+ throw transportError;
2515
3249
  }
2516
- if (!response.ok) {
2517
- let error;
2518
- try {
2519
- error = await readLimitedResponseBody(response, MAX_ERROR_RESPONSE_BODY_BYTES);
2520
- } catch (_) {
2521
- error = "";
3250
+ try {
3251
+ if (!response.ok) {
3252
+ let error;
3253
+ try {
3254
+ error = await readLimitedResponseBody(response, MAX_ERROR_RESPONSE_BODY_BYTES);
3255
+ } catch (_) {
3256
+ error = "";
3257
+ }
3258
+ logger.error("Failed to send activity {activityId} to {inbox} ({status} {statusText}):\n{error}", {
3259
+ activityId,
3260
+ inbox: inbox.href,
3261
+ status: response.status,
3262
+ statusText: response.statusText,
3263
+ error
3264
+ });
3265
+ throw new SendActivityError(inbox, response.status, `Failed to send activity ${activityId} to ${inbox.href} (${response.status} ${response.statusText}):\n${error}`, error, response.headers);
2522
3266
  }
2523
- logger.error("Failed to send activity {activityId} to {inbox} ({status} {statusText}):\n{error}", {
2524
- activityId,
2525
- inbox: inbox.href,
2526
- status: response.status,
2527
- statusText: response.statusText,
2528
- error
2529
- });
2530
- throw new SendActivityError(inbox, response.status, `Failed to send activity ${activityId} to ${inbox.href} (${response.status} ${response.statusText}):\n${error}`, error);
3267
+ deliverySuccess = true;
3268
+ const eventAttributes = {
3269
+ "activitypub.inbox.url": inbox.href,
3270
+ "activitypub.activity.id": activityId ?? ""
3271
+ };
3272
+ if (activityType != null) eventAttributes["activitypub.activity.type"] = activityType;
3273
+ const actorId = getActivityActorId(activity);
3274
+ if (actorId != null) eventAttributes["activitypub.actor.id"] = actorId;
3275
+ span.addEvent("activitypub.activity.sent", eventAttributes);
3276
+ } finally {
3277
+ federationMetrics.recordDelivery(inbox, getDurationMs(started), deliverySuccess, activityType);
2531
3278
  }
2532
- span.addEvent("activitypub.activity.sent", {
2533
- "activitypub.activity.json": JSON.stringify(activity),
2534
- "activitypub.inbox.url": inbox.href,
2535
- "activitypub.activity.id": activityId ?? ""
2536
- });
3279
+ }
3280
+ function createFetchError(url, cause) {
3281
+ const error = new FetchError(url, cause instanceof Error ? cause.message : String(cause));
3282
+ error.cause = cause;
3283
+ return error;
2537
3284
  }
2538
3285
  /**
2539
3286
  * An error that is thrown when an activity fails to send to a remote inbox.
@@ -2558,20 +3305,252 @@ var SendActivityError = class extends Error {
2558
3305
  */
2559
3306
  responseBody;
2560
3307
  /**
3308
+ * The response headers from the inbox.
3309
+ * @since 2.3.0
3310
+ */
3311
+ responseHeaders;
3312
+ /**
2561
3313
  * Creates a new {@link SendActivityError}.
2562
3314
  * @param inbox The inbox URL.
2563
3315
  * @param statusCode The HTTP status code.
2564
3316
  * @param message The error message.
2565
3317
  * @param responseBody The response body.
3318
+ * @param responseHeaders The response headers.
2566
3319
  */
2567
- constructor(inbox, statusCode, message, responseBody) {
3320
+ constructor(inbox, statusCode, message, responseBody, responseHeaders) {
2568
3321
  super(message);
2569
3322
  this.name = "SendActivityError";
2570
3323
  this.inbox = inbox;
2571
3324
  this.statusCode = statusCode;
2572
3325
  this.responseBody = responseBody;
3326
+ this.responseHeaders = new Headers(responseHeaders);
3327
+ }
3328
+ };
3329
+ //#endregion
3330
+ //#region src/federation/bench.ts
3331
+ /**
3332
+ * Metric reader owned by `benchmarkMode`.
3333
+ * @since 2.3.0
3334
+ */
3335
+ var BenchmarkMetricReader = class extends MetricReader {
3336
+ onShutdown() {
3337
+ return Promise.resolve();
3338
+ }
3339
+ onForceFlush() {
3340
+ return Promise.resolve();
3341
+ }
3342
+ };
3343
+ /**
3344
+ * Creates the in-process OpenTelemetry meter provider used by benchmark mode.
3345
+ * @returns The meter provider and the metric reader attached to it.
3346
+ * @since 2.3.0
3347
+ */
3348
+ function createBenchmarkMeterProvider() {
3349
+ const reader = new BenchmarkMetricReader();
3350
+ return {
3351
+ meterProvider: new MeterProvider$1({ readers: [reader] }),
3352
+ reader
3353
+ };
3354
+ }
3355
+ /**
3356
+ * Collects and serializes benchmark-mode metrics from a benchmark reader.
3357
+ * @param reader The benchmark metric reader to collect from.
3358
+ * @returns A server metric snapshot with any collection errors stringified.
3359
+ * @since 2.3.0
3360
+ */
3361
+ async function collectBenchmarkMetrics(reader) {
3362
+ const result = await reader.collect();
3363
+ return {
3364
+ version: 1,
3365
+ source: "server",
3366
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
3367
+ scopeMetrics: serializeScopeMetrics(result.resourceMetrics),
3368
+ errors: result.errors.map((error) => String(error))
3369
+ };
3370
+ }
3371
+ /**
3372
+ * Handles `GET /.well-known/fedify/bench/stats`.
3373
+ * @param request The HTTP request to handle.
3374
+ * @param reader The benchmark metric reader to collect from.
3375
+ * @returns A JSON metric snapshot response, or `405 Method Not Allowed`.
3376
+ * @since 2.3.0
3377
+ */
3378
+ async function handleBenchmarkStats(request, reader) {
3379
+ if (request.method !== "GET") return new Response("Method not allowed", {
3380
+ status: 405,
3381
+ headers: { "Allow": "GET" }
3382
+ });
3383
+ return jsonResponse(await collectBenchmarkMetrics(reader));
3384
+ }
3385
+ /**
3386
+ * Handles `POST /.well-known/fedify/bench/trigger`.
3387
+ *
3388
+ * The handler validates a benchmark trigger request, checks recipients against
3389
+ * server-controlled trigger options, and calls `Context.sendActivity()` to use
3390
+ * the target's normal outbox path.
3391
+ * @param request The HTTP request to handle.
3392
+ * @param context The Fedify context used to resolve actors and send activity.
3393
+ * @param options Server-controlled benchmark trigger delivery options.
3394
+ * @returns A JSON response describing the sent activity, or a validation error.
3395
+ * @since 2.3.0
3396
+ */
3397
+ async function handleBenchmarkTrigger(request, context, options = {}) {
3398
+ if (request.method !== "POST") return new Response("Method not allowed", {
3399
+ status: 405,
3400
+ headers: { "Allow": "POST" }
3401
+ });
3402
+ let json;
3403
+ try {
3404
+ json = await request.json();
3405
+ } catch {
3406
+ return jsonResponse({ error: "Invalid JSON request body." }, 400);
3407
+ }
3408
+ try {
3409
+ const body = asRecord(json, "request body");
3410
+ const sender = parseSender(body.sender);
3411
+ const recipients = await parseRecipients(body.recipients, context);
3412
+ const activity = await parseActivity(body.activity, context);
3413
+ if (activity.id == null) throw new BenchmarkTriggerError("activity must have an id.");
3414
+ const activityId = activity.id.href;
3415
+ const inboxes = extractInboxes({ recipients });
3416
+ const inboxUrls = Object.keys(inboxes);
3417
+ if (inboxUrls.length < 1) throw new BenchmarkTriggerError("No valid recipient inboxes found. The recipients list must not be empty.");
3418
+ const unsafeInboxes = options.allowUnsafeRecipients ? [] : inboxUrls.filter((inbox) => !options.sinks?.has(inbox));
3419
+ if (unsafeInboxes.length > 0) return jsonResponse({
3420
+ error: "unsafe_recipient",
3421
+ unsafeInboxes
3422
+ }, 403);
3423
+ await context.sendActivity(sender, recipients, activity);
3424
+ return jsonResponse({
3425
+ version: 1,
3426
+ activityId,
3427
+ queueCorrelationId: activityId,
3428
+ recipientCount: recipients.length,
3429
+ inboxCount: inboxUrls.length
3430
+ }, 202);
3431
+ } catch (error) {
3432
+ if (error instanceof BenchmarkTriggerError) return jsonResponse({ error: error.message }, error.status);
3433
+ throw error;
3434
+ }
3435
+ }
3436
+ var BenchmarkTriggerError = class extends Error {
3437
+ status;
3438
+ constructor(message, status = 400) {
3439
+ super(message);
3440
+ this.status = status;
2573
3441
  }
2574
3442
  };
3443
+ function parseSender(value) {
3444
+ const sender = asRecord(value, "sender");
3445
+ if (typeof sender.identifier === "string") return { identifier: sender.identifier };
3446
+ if (typeof sender.username === "string") return { username: sender.username };
3447
+ throw new BenchmarkTriggerError("sender must be { identifier } or { username }.");
3448
+ }
3449
+ async function parseRecipients(value, context) {
3450
+ if (!Array.isArray(value)) throw new BenchmarkTriggerError("recipients must be an array.");
3451
+ return await Promise.all(value.map(async (item) => {
3452
+ let object;
3453
+ try {
3454
+ object = await Object$1.fromJsonLd(item, {
3455
+ documentLoader: context.documentLoader,
3456
+ contextLoader: context.contextLoader
3457
+ });
3458
+ } catch (error) {
3459
+ throw new BenchmarkTriggerError(`Invalid ActivityPub recipient: ${error}`);
3460
+ }
3461
+ if (!isRecipient(object)) throw new BenchmarkTriggerError("each recipient must be an ActivityPub actor.");
3462
+ const recipient = object;
3463
+ if (recipient.id == null || recipient.inboxId == null) throw new BenchmarkTriggerError("each recipient must have id and inbox properties.");
3464
+ return recipient;
3465
+ }));
3466
+ }
3467
+ function isRecipient(value) {
3468
+ return value != null && typeof value === "object" && "id" in value && "inboxId" in value;
3469
+ }
3470
+ async function parseActivity(value, context) {
3471
+ try {
3472
+ return await Activity.fromJsonLd(value, {
3473
+ documentLoader: context.documentLoader,
3474
+ contextLoader: context.contextLoader
3475
+ });
3476
+ } catch (error) {
3477
+ throw new BenchmarkTriggerError(`Invalid ActivityPub activity: ${error}`);
3478
+ }
3479
+ }
3480
+ function asRecord(value, name) {
3481
+ if (value == null || typeof value !== "object" || Array.isArray(value)) throw new BenchmarkTriggerError(`${name} must be an object.`);
3482
+ return value;
3483
+ }
3484
+ function jsonResponse(body, status = 200) {
3485
+ return new Response(JSON.stringify(body), {
3486
+ status,
3487
+ headers: { "Content-Type": "application/json" }
3488
+ });
3489
+ }
3490
+ function serializeScopeMetrics(resourceMetrics) {
3491
+ return resourceMetrics.scopeMetrics.map(serializeScope);
3492
+ }
3493
+ function serializeScope(scopeMetrics) {
3494
+ return {
3495
+ scope: {
3496
+ name: scopeMetrics.scope.name,
3497
+ version: scopeMetrics.scope.version
3498
+ },
3499
+ metrics: scopeMetrics.metrics.map(serializeMetric)
3500
+ };
3501
+ }
3502
+ function serializeMetric(metric) {
3503
+ return {
3504
+ name: metric.descriptor.name,
3505
+ description: metric.descriptor.description,
3506
+ unit: metric.descriptor.unit,
3507
+ dataPointType: serializeDataPointType(metric.dataPointType),
3508
+ dataPoints: metric.dataPoints.map((point) => ({
3509
+ attributes: { ...point.attributes },
3510
+ startTime: point.startTime,
3511
+ endTime: point.endTime,
3512
+ value: point.value
3513
+ }))
3514
+ };
3515
+ }
3516
+ function serializeDataPointType(dataPointType) {
3517
+ switch (dataPointType) {
3518
+ case DataPointType.HISTOGRAM: return "histogram";
3519
+ case DataPointType.EXPONENTIAL_HISTOGRAM: return "exponential_histogram";
3520
+ case DataPointType.GAUGE: return "gauge";
3521
+ case DataPointType.SUM: return "sum";
3522
+ }
3523
+ }
3524
+ //#endregion
3525
+ //#region src/federation/retry.ts
3526
+ /**
3527
+ * Creates an exponential backoff retry policy. The delay between retries
3528
+ * starts at the `initialDelay` and is multiplied by the `factor` for each
3529
+ * subsequent retry, up to the `maxDelay`. The policy will give up after
3530
+ * `maxAttempts` attempts. The actual delay is randomized to avoid
3531
+ * synchronization (jitter).
3532
+ * @param options The options for the policy.
3533
+ * @returns The retry policy.
3534
+ * @since 0.12.0
3535
+ */
3536
+ function createExponentialBackoffPolicy(options = {}) {
3537
+ const initialDelay = Temporal.Duration.from(options.initialDelay ?? { seconds: 1 });
3538
+ const maxDelay = Temporal.Duration.from(options.maxDelay ?? { hours: 12 });
3539
+ const maxAttempts = options.maxAttempts ?? 10;
3540
+ const factor = options.factor ?? 2;
3541
+ const jitter = options.jitter ?? true;
3542
+ return ({ attempts }) => {
3543
+ if (attempts >= maxAttempts) return null;
3544
+ let milliseconds = initialDelay.total("millisecond");
3545
+ milliseconds *= factor ** attempts;
3546
+ if (jitter) {
3547
+ milliseconds *= 1 + Math.random();
3548
+ milliseconds = Math.round(milliseconds);
3549
+ }
3550
+ const delay = Temporal.Duration.from({ milliseconds });
3551
+ return Temporal.Duration.compare(delay, maxDelay) > 0 ? maxDelay : delay;
3552
+ };
3553
+ }
2575
3554
  //#endregion
2576
3555
  //#region src/federation/webfinger.ts
2577
3556
  const logger = getLogger([
@@ -2587,22 +3566,72 @@ const logger = getLogger([
2587
3566
  * @returns The response to the request.
2588
3567
  */
2589
3568
  async function handleWebFinger(request, options) {
2590
- if (options.tracer == null) return await handleWebFingerInternal(request, options);
2591
- return await options.tracer.startActiveSpan("webfinger.handle", { kind: SpanKind.SERVER }, async (span) => {
2592
- try {
2593
- const response = await handleWebFingerInternal(request, options);
2594
- span.setStatus({ code: response.ok ? SpanStatusCode.UNSET : SpanStatusCode.ERROR });
2595
- return response;
2596
- } catch (error) {
2597
- span.setStatus({
2598
- code: SpanStatusCode.ERROR,
2599
- message: String(error)
2600
- });
2601
- throw error;
2602
- } finally {
2603
- span.end();
3569
+ const meterProvider = options.meterProvider;
3570
+ const start = meterProvider == null ? 0 : performance.now();
3571
+ const scheme = computeResourceScheme(options.context.url.searchParams.get("resource"));
3572
+ let notFoundResponse;
3573
+ const wrappedOptions = {
3574
+ ...options,
3575
+ async onNotFound(req) {
3576
+ const r = await options.onNotFound(req);
3577
+ notFoundResponse = r;
3578
+ return r;
2604
3579
  }
2605
- });
3580
+ };
3581
+ let response;
3582
+ try {
3583
+ if (options.tracer == null) response = await handleWebFingerInternal(request, wrappedOptions);
3584
+ else response = await options.tracer.startActiveSpan("webfinger.handle", { kind: SpanKind.SERVER }, async (span) => {
3585
+ try {
3586
+ const inner = await handleWebFingerInternal(request, wrappedOptions);
3587
+ span.setStatus({ code: inner.ok ? SpanStatusCode.UNSET : SpanStatusCode.ERROR });
3588
+ return inner;
3589
+ } catch (error) {
3590
+ span.setStatus({
3591
+ code: SpanStatusCode.ERROR,
3592
+ message: String(error)
3593
+ });
3594
+ throw error;
3595
+ } finally {
3596
+ span.end();
3597
+ }
3598
+ });
3599
+ return response;
3600
+ } finally {
3601
+ if (meterProvider != null) recordWebFingerHandle(meterProvider, {
3602
+ durationMs: Math.max(0, performance.now() - start),
3603
+ result: classifyWebFingerHandleResult(response, notFoundResponse),
3604
+ scheme,
3605
+ statusCode: response?.status
3606
+ });
3607
+ }
3608
+ }
3609
+ const WEBFINGER_HANDLE_SCHEME_WHITELIST = new Set([
3610
+ "acct",
3611
+ "http",
3612
+ "https",
3613
+ "mailto"
3614
+ ]);
3615
+ function isAllowedResourceScheme(scheme) {
3616
+ return WEBFINGER_HANDLE_SCHEME_WHITELIST.has(scheme);
3617
+ }
3618
+ function computeResourceScheme(resource) {
3619
+ if (resource == null) return void 0;
3620
+ const colon = resource.indexOf(":");
3621
+ if (colon <= 0) return void 0;
3622
+ const candidate = resource.substring(0, colon).toLowerCase();
3623
+ return isAllowedResourceScheme(candidate) ? candidate : "other";
3624
+ }
3625
+ function classifyWebFingerHandleResult(response, notFoundResponse) {
3626
+ if (response == null) return "error";
3627
+ if (notFoundResponse != null && response === notFoundResponse) return "not_found";
3628
+ switch (response.status) {
3629
+ case 200: return "resolved";
3630
+ case 400: return "invalid";
3631
+ case 404: return "not_found";
3632
+ case 410: return "tombstoned";
3633
+ default: return "error";
3634
+ }
2606
3635
  }
2607
3636
  async function handleWebFingerInternal(request, { context, host, actorDispatcher, actorHandleMapper, actorAliasMapper, onNotFound, span, webFingerLinksDispatcher }) {
2608
3637
  if (actorDispatcher == null) {
@@ -2718,10 +3747,124 @@ var middleware_exports = /* @__PURE__ */ __exportAll({
2718
3747
  OutboxContextImpl: () => OutboxContextImpl,
2719
3748
  createFederation: () => createFederation
2720
3749
  });
3750
+ const circuitBreakerCasWarningKvStores = /* @__PURE__ */ new WeakSet();
3751
+ let nextQueueDepthGaugeSourceId = 0;
3752
+ const retryAfterHttpDate = /* @__PURE__ */ new RegExp("^(?:(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \\d{2} (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \\d{4} \\d{2}:\\d{2}:\\d{2} GMT|(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), \\d{2}-(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-\\d{2} \\d{2}:\\d{2}:\\d{2} GMT|(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (?: \\d|\\d{2}) \\d{2}:\\d{2}:\\d{2} \\d{4})$");
3753
+ function parseRetryAfter(headers, now = Temporal.Now.instant()) {
3754
+ const value = headers.get("Retry-After");
3755
+ if (value == null) return void 0;
3756
+ const trimmed = value.trim();
3757
+ if (/^\d+$/.test(trimmed)) {
3758
+ const seconds = Number(trimmed);
3759
+ if (!Number.isFinite(seconds)) return void 0;
3760
+ return parseRetryAfterDuration({ seconds });
3761
+ }
3762
+ if (!retryAfterHttpDate.test(trimmed)) return void 0;
3763
+ const httpDate = trimmed.endsWith("GMT") ? trimmed : `${trimmed} GMT`;
3764
+ const retryAtMs = Date.parse(httpDate);
3765
+ if (Number.isNaN(retryAtMs)) return void 0;
3766
+ const nowMs = Number(now.epochMilliseconds);
3767
+ return parseRetryAfterDuration({ milliseconds: Math.max(0, retryAtMs - nowMs) });
3768
+ }
3769
+ function parseRetryAfterDuration(durationLike) {
3770
+ try {
3771
+ return Temporal.Duration.from(durationLike);
3772
+ } catch (error) {
3773
+ if (error instanceof RangeError) return void 0;
3774
+ throw error;
3775
+ }
3776
+ }
3777
+ function clampNegativeDelay(delay) {
3778
+ return delay.sign < 0 ? Temporal.Duration.from({ seconds: 0 }) : delay;
3779
+ }
3780
+ function getBenchmarkRelaxations(allowPrivateAddress, signatureTimeWindow) {
3781
+ const relaxations = [];
3782
+ if (allowPrivateAddress) relaxations.push({
3783
+ protection: "private_address_checks",
3784
+ effect: "disabled",
3785
+ effectiveValue: true
3786
+ });
3787
+ if (signatureTimeWindow === false) relaxations.push({
3788
+ protection: "http_signature_time_window",
3789
+ effect: "disabled",
3790
+ effectiveValue: false,
3791
+ secureDefaultSeconds: 3600
3792
+ });
3793
+ else try {
3794
+ const seconds = Temporal.Duration.from(signatureTimeWindow).total({ unit: "seconds" });
3795
+ if (seconds !== 3600) relaxations.push({
3796
+ protection: "http_signature_time_window",
3797
+ effect: "changed",
3798
+ effectiveSeconds: seconds,
3799
+ secureDefaultSeconds: 3600
3800
+ });
3801
+ } catch {}
3802
+ return relaxations;
3803
+ }
3804
+ function formatBenchmarkRelaxations(relaxations) {
3805
+ if (relaxations.length < 1) return "no benchmark-only protections relaxed";
3806
+ return relaxations.map((relaxation) => {
3807
+ switch (relaxation.protection) {
3808
+ case "private_address_checks": return "private address checks disabled (allowPrivateAddress=true)";
3809
+ case "http_signature_time_window":
3810
+ if (relaxation.effect === "disabled") return `HTTP Signature time window disabled (signatureTimeWindow=false)`;
3811
+ return `HTTP Signature time window set to ${relaxation.effectiveSeconds}s (secure default: ${relaxation.secureDefaultSeconds}s)`;
3812
+ }
3813
+ }).join("; ");
3814
+ }
3815
+ function getBenchmarkTriggerOptions(benchmarkOptions) {
3816
+ const sinks = benchmarkOptions.triggerSinks?.map((sink) => {
3817
+ try {
3818
+ return new URL(sink).href;
3819
+ } catch {
3820
+ throw new TypeError("benchmarkMode.triggerSinks must contain only URLs.");
3821
+ }
3822
+ });
3823
+ return {
3824
+ sinks: sinks == null ? void 0 : new Set(sinks),
3825
+ allowUnsafeRecipients: benchmarkOptions.allowUnsafeTriggerRecipients === true
3826
+ };
3827
+ }
3828
+ function maxDelay(first, second) {
3829
+ return Temporal.Duration.compare(first, second) >= 0 ? first : second;
3830
+ }
3831
+ function isTransportDeliveryError(error) {
3832
+ return error instanceof FetchError || isAbortError(error);
3833
+ }
3834
+ function toCircuitBreakerMetricState(state) {
3835
+ return state === "half-open" ? "half_open" : state;
3836
+ }
3837
+ function recordCircuitBreakerSpanEvent(span, remoteHost, change) {
3838
+ span.addEvent("activitypub.circuit_breaker.state_change", {
3839
+ "activitypub.remote.host": remoteHost,
3840
+ "activitypub.circuit_breaker.previous_state": toCircuitBreakerMetricState(change.previousState),
3841
+ "activitypub.circuit_breaker.state": toCircuitBreakerMetricState(change.newState)
3842
+ });
3843
+ }
3844
+ function recordCircuitBreakerHeldSpanEvent(span, remoteHost, state) {
3845
+ span.addEvent("activitypub.circuit_breaker.held", {
3846
+ "activitypub.remote.host": remoteHost,
3847
+ "activitypub.circuit_breaker.state": toCircuitBreakerMetricState(state)
3848
+ });
3849
+ }
3850
+ function isRemoteContextLoadingFailure(error) {
3851
+ return error instanceof Error && typeof error.details === "object" && error.details != null && error.details.code === "loading remote context failed";
3852
+ }
3853
+ function isPermanentRemoteContextError(error) {
3854
+ if (!(error instanceof Error) || error.name !== "jsonld.InvalidUrl") return false;
3855
+ const details = error.details;
3856
+ if (details?.code === "invalid remote context") return true;
3857
+ return isRemoteContextLoadingFailure(error) && typeof details?.url === "string" && !URL.canParse(details.url) && isClearlyMalformedContextReference(details.url);
3858
+ }
3859
+ function isPermanentInboxParseError(error) {
3860
+ return error instanceof Error && (error.name === "UnsafeJsonLdError" || error instanceof InvalidContextReferenceError || isPermanentRemoteContextError(error) || error.name === "jsonld.SyntaxError" && !isRemoteContextLoadingFailure(error)) || error instanceof TypeError && (/^(Invalid JSON-LD:|Invalid type:|Unexpected type:|Invalid @id:)/.test(error.message) || isInvalidUrlTypeError(error));
3861
+ }
2721
3862
  /**
2722
3863
  * Create a new {@link Federation} instance.
2723
- * @param parameters Parameters for initializing the instance.
3864
+ * @param options Parameters for initializing the instance.
2724
3865
  * @returns A new {@link Federation} instance.
3866
+ * @throws {TypeError} If benchmark mode and `meterProvider` are both
3867
+ * specified.
2725
3868
  * @since 0.10.0
2726
3869
  */
2727
3870
  function createFederation(options) {
@@ -2749,12 +3892,37 @@ var FederationImpl = class extends FederationBuilderImpl {
2749
3892
  skipSignatureVerification;
2750
3893
  outboxRetryPolicy;
2751
3894
  inboxRetryPolicy;
3895
+ circuitBreaker;
2752
3896
  activityTransformers;
2753
3897
  _tracerProvider;
3898
+ _meterProvider;
2754
3899
  firstKnock;
2755
3900
  inboxChallengePolicy;
3901
+ benchmarkMode;
3902
+ benchmarkMetricReader;
3903
+ benchmarkTriggerOptions;
3904
+ #queueDepthGaugeSourceId = `fedify-${(++nextQueueDepthGaugeSourceId).toString(36)}`;
3905
+ #queueDepthGaugeEntries = [];
3906
+ #queueDepthGaugeMeterProvider;
2756
3907
  constructor(options) {
2757
3908
  super();
3909
+ const benchmarkMode = options.benchmarkMode != null && options.benchmarkMode !== false;
3910
+ const benchmarkOptions = typeof options.benchmarkMode === "object" ? options.benchmarkMode : {};
3911
+ const hasCustomLoaderFactory = options.documentLoaderFactory != null || options.contextLoaderFactory != null;
3912
+ const allowPrivateAddress = options.allowPrivateAddress ?? (benchmarkMode && !hasCustomLoaderFactory ? true : false);
3913
+ const signatureTimeWindow = options.signatureTimeWindow ?? (benchmarkMode ? false : { hours: 1 });
3914
+ if (benchmarkMode && options.meterProvider != null) throw new TypeError("benchmarkMode requires Fedify to own the meterProvider; OpenTelemetry metric readers cannot be added after a MeterProvider is constructed.");
3915
+ if (benchmarkMode) {
3916
+ const relaxations = getBenchmarkRelaxations(allowPrivateAddress, signatureTimeWindow);
3917
+ const relaxationSummary = formatBenchmarkRelaxations(relaxations);
3918
+ getLogger([
3919
+ "fedify",
3920
+ "federation",
3921
+ "benchmark"
3922
+ ]).warn(`Fedify benchmarkMode is enabled; ${relaxationSummary}. Benchmark endpoints are active and must not be used in production.`, { relaxations });
3923
+ }
3924
+ this.benchmarkMode = benchmarkMode;
3925
+ this.benchmarkTriggerOptions = benchmarkMode ? getBenchmarkTriggerOptions(benchmarkOptions) : {};
2758
3926
  this.kv = options.kv;
2759
3927
  this.kvPrefixes = {
2760
3928
  activityIdempotence: ["_fedify", "activityIdempotence"],
@@ -2762,6 +3930,7 @@ var FederationImpl = class extends FederationBuilderImpl {
2762
3930
  publicKey: ["_fedify", "publicKey"],
2763
3931
  httpMessageSignaturesSpec: ["_fedify", "httpMessageSignaturesSpec"],
2764
3932
  acceptSignatureNonce: ["_fedify", "acceptSignatureNonce"],
3933
+ circuitBreaker: ["_fedify", "circuit"],
2765
3934
  ...options.kvPrefixes ?? {}
2766
3935
  };
2767
3936
  if (options.queue == null) {
@@ -2777,6 +3946,25 @@ var FederationImpl = class extends FederationBuilderImpl {
2777
3946
  this.outboxQueue = options.queue.outbox;
2778
3947
  this.fanoutQueue = options.queue.fanout;
2779
3948
  }
3949
+ if (options.circuitBreaker !== false && this.outboxQueue != null) {
3950
+ this.circuitBreaker = new CircuitBreaker({
3951
+ kv: options.kv,
3952
+ prefix: this.kvPrefixes.circuitBreaker,
3953
+ options: options.circuitBreaker,
3954
+ stateChangeObserver: (remoteHost, _previousState, newState) => {
3955
+ const metricState = toCircuitBreakerMetricState(newState);
3956
+ recordCircuitBreakerStateChange(this.meterProvider, remoteHost, metricState);
3957
+ }
3958
+ });
3959
+ if (options.kv.cas == null && !circuitBreakerCasWarningKvStores.has(options.kv)) {
3960
+ circuitBreakerCasWarningKvStores.add(options.kv);
3961
+ getLogger([
3962
+ "fedify",
3963
+ "federation",
3964
+ "circuit"
3965
+ ]).warn("The configured key-value store does not support CAS; outbound delivery circuit breaker updates may race under concurrent workers.");
3966
+ }
3967
+ }
2780
3968
  this.inboxQueueStarted = false;
2781
3969
  this.outboxQueueStarted = false;
2782
3970
  this.fanoutQueueStarted = false;
@@ -2802,48 +3990,109 @@ var FederationImpl = class extends FederationBuilderImpl {
2802
3990
  }
2803
3991
  this.router.trailingSlashInsensitive = options.trailingSlashInsensitive ?? false;
2804
3992
  this._initializeRouter();
2805
- if (options.allowPrivateAddress || options.userAgent != null) {
3993
+ if (options.allowPrivateAddress === true || options.userAgent != null) {
2806
3994
  if (options.documentLoaderFactory != null) throw new TypeError("Cannot set documentLoaderFactory with allowPrivateAddress or userAgent options.");
2807
3995
  if (options.contextLoaderFactory != null) throw new TypeError("Cannot set contextLoaderFactory with allowPrivateAddress or userAgent options.");
2808
3996
  if (options.authenticatedDocumentLoaderFactory != null) throw new TypeError("Cannot set authenticatedDocumentLoaderFactory with allowPrivateAddress or userAgent options.");
2809
3997
  }
2810
- const { allowPrivateAddress, userAgent } = options;
2811
- this.allowPrivateAddress = allowPrivateAddress ?? false;
2812
- this.documentLoaderFactory = options.documentLoaderFactory ?? ((opts) => {
2813
- return kvCache({
2814
- loader: getDocumentLoader({
2815
- allowPrivateAddress: opts?.allowPrivateAddress ?? allowPrivateAddress,
2816
- userAgent: opts?.userAgent ?? userAgent
2817
- }),
2818
- kv: options.kv,
2819
- prefix: this.kvPrefixes.remoteDocument
2820
- });
3998
+ const { userAgent } = options;
3999
+ this.allowPrivateAddress = allowPrivateAddress;
4000
+ const userDocumentLoaderFactory = options.documentLoaderFactory;
4001
+ const userContextLoaderFactory = options.contextLoaderFactory;
4002
+ const userAuthFactory = options.authenticatedDocumentLoaderFactory;
4003
+ const builtinDocumentLoaderFactory = (opts) => kvCache({
4004
+ loader: getDocumentLoader({
4005
+ allowPrivateAddress: opts?.allowPrivateAddress ?? allowPrivateAddress,
4006
+ userAgent: opts?.userAgent ?? userAgent
4007
+ }),
4008
+ kv: options.kv,
4009
+ prefix: this.kvPrefixes.remoteDocument,
4010
+ meterProvider: this._meterProvider,
4011
+ kind: "object"
4012
+ });
4013
+ const builtinContextLoaderFactory = (opts) => kvCache({
4014
+ loader: getDocumentLoader({
4015
+ allowPrivateAddress: opts?.allowPrivateAddress ?? allowPrivateAddress,
4016
+ userAgent: opts?.userAgent ?? userAgent
4017
+ }),
4018
+ kv: options.kv,
4019
+ prefix: this.kvPrefixes.remoteDocument,
4020
+ meterProvider: this._meterProvider,
4021
+ kind: "context"
4022
+ });
4023
+ this.documentLoaderFactory = (opts) => instrumentDocumentLoader((userDocumentLoaderFactory ?? builtinDocumentLoaderFactory)(opts), {
4024
+ meterProvider: this._meterProvider,
4025
+ kind: "object",
4026
+ cacheEnabled: userDocumentLoaderFactory == null ? true : void 0
2821
4027
  });
2822
- this.contextLoaderFactory = options.contextLoaderFactory ?? this.documentLoaderFactory;
2823
- this.authenticatedDocumentLoaderFactory = options.authenticatedDocumentLoaderFactory ?? ((identity) => getAuthenticatedDocumentLoader(identity, {
2824
- allowPrivateAddress,
2825
- userAgent,
4028
+ const resolvedContextLoaderFactory = userContextLoaderFactory ?? userDocumentLoaderFactory ?? builtinContextLoaderFactory;
4029
+ this.contextLoaderFactory = (opts) => instrumentDocumentLoader(resolvedContextLoaderFactory(opts), {
4030
+ meterProvider: this._meterProvider,
4031
+ kind: "context",
4032
+ cacheEnabled: userContextLoaderFactory == null && userDocumentLoaderFactory == null ? true : void 0
4033
+ });
4034
+ this.authenticatedDocumentLoaderFactory = (identity, factoryOpts) => instrumentDocumentLoader(userAuthFactory != null ? userAuthFactory(identity, factoryOpts) : getAuthenticatedDocumentLoader(identity, {
4035
+ allowPrivateAddress: factoryOpts?.allowPrivateAddress ?? allowPrivateAddress,
4036
+ userAgent: factoryOpts?.userAgent ?? userAgent,
2826
4037
  specDeterminer: new KvSpecDeterminer(this.kv, this.kvPrefixes.httpMessageSignaturesSpec, options.firstKnock),
2827
4038
  tracerProvider: this.tracerProvider
2828
- }));
4039
+ }), {
4040
+ meterProvider: this._meterProvider,
4041
+ kind: "object",
4042
+ cacheEnabled: userAuthFactory == null ? false : void 0
4043
+ });
2829
4044
  this.userAgent = userAgent;
2830
4045
  this.onOutboxError = options.onOutboxError;
2831
4046
  this.permanentFailureStatusCodes = options.permanentFailureStatusCodes ?? [404, 410];
2832
- this.signatureTimeWindow = options.signatureTimeWindow ?? { hours: 1 };
4047
+ this.signatureTimeWindow = signatureTimeWindow;
2833
4048
  this.skipSignatureVerification = options.skipSignatureVerification ?? false;
2834
4049
  this.inboxChallengePolicy = options.inboxChallengePolicy;
2835
4050
  this.outboxRetryPolicy = options.outboxRetryPolicy ?? createExponentialBackoffPolicy();
2836
4051
  this.inboxRetryPolicy = options.inboxRetryPolicy ?? createExponentialBackoffPolicy();
2837
4052
  this.activityTransformers = options.activityTransformers ?? getDefaultActivityTransformers();
2838
4053
  this._tracerProvider = options.tracerProvider;
4054
+ if (benchmarkMode) {
4055
+ const benchmarkMetrics = createBenchmarkMeterProvider();
4056
+ this._meterProvider = benchmarkMetrics.meterProvider;
4057
+ this.benchmarkMetricReader = benchmarkMetrics.reader;
4058
+ } else this._meterProvider = options.meterProvider;
4059
+ this.#queueDepthGaugeEntries = [
4060
+ {
4061
+ role: "inbox",
4062
+ queue: this.inboxQueue
4063
+ },
4064
+ {
4065
+ role: "outbox",
4066
+ queue: this.outboxQueue
4067
+ },
4068
+ {
4069
+ role: "fanout",
4070
+ queue: this.fanoutQueue
4071
+ }
4072
+ ];
4073
+ this.#registerQueueDepthGauge(this._meterProvider ?? metrics.getMeterProvider());
2839
4074
  this.firstKnock = options.firstKnock;
2840
4075
  }
2841
4076
  get tracerProvider() {
2842
4077
  return this._tracerProvider ?? trace.getTracerProvider();
2843
4078
  }
4079
+ get meterProvider() {
4080
+ const meterProvider = this._meterProvider ?? metrics.getMeterProvider();
4081
+ this.#registerQueueDepthGauge(meterProvider);
4082
+ return meterProvider;
4083
+ }
4084
+ #registerQueueDepthGauge(meterProvider) {
4085
+ if (meterProvider === this.#queueDepthGaugeMeterProvider) return;
4086
+ registerQueueDepthGauge(meterProvider, this.#queueDepthGaugeEntries, { sourceId: this.#queueDepthGaugeSourceId });
4087
+ this.#queueDepthGaugeMeterProvider = meterProvider;
4088
+ }
2844
4089
  _initializeRouter() {
2845
4090
  this.router.add("/.well-known/webfinger", "webfinger");
2846
4091
  this.router.add("/.well-known/nodeinfo", "nodeInfoJrd");
4092
+ if (this.benchmarkMode) {
4093
+ this.router.add("/.well-known/fedify/bench/stats", "benchmarkStats");
4094
+ this.router.add("/.well-known/fedify/bench/trigger", "benchmarkTrigger");
4095
+ }
2847
4096
  }
2848
4097
  _getTracer() {
2849
4098
  return this.tracerProvider.getTracer(name, version);
@@ -2875,79 +4124,124 @@ var FederationImpl = class extends FederationBuilderImpl {
2875
4124
  }
2876
4125
  processQueuedTask(contextData, message) {
2877
4126
  const tracer = this._getTracer();
2878
- const extractedContext = propagation.extract(context.active(), message.traceContext);
2879
- return withContext({ messageId: message.id }, async () => {
2880
- if (message.type === "fanout") await tracer.startActiveSpan("activitypub.fanout", {
2881
- kind: SpanKind.CONSUMER,
2882
- attributes: { "activitypub.activity.type": message.activityType }
2883
- }, extractedContext, async (span) => {
2884
- const spanCtx = span.spanContext();
2885
- return await withContext({
2886
- traceId: spanCtx.traceId,
2887
- spanId: spanCtx.spanId
2888
- }, async () => {
2889
- if (message.activityId != null) span.setAttribute("activitypub.activity.id", message.activityId);
2890
- try {
2891
- await this.#listenFanoutMessage(contextData, message);
2892
- } catch (e) {
2893
- span.setStatus({
2894
- code: SpanStatusCode.ERROR,
2895
- message: String(e)
2896
- });
2897
- throw e;
2898
- } finally {
2899
- span.end();
2900
- }
4127
+ const extractedContext = propagation.extract(context.active(), message.traceContext);
4128
+ const meter = getFederationMetrics(this.meterProvider);
4129
+ return withContext({ messageId: message.id }, async () => {
4130
+ if (message.type === "fanout") {
4131
+ const common = {
4132
+ role: "fanout",
4133
+ queue: this.fanoutQueue,
4134
+ activityType: message.activityType
4135
+ };
4136
+ await tracer.startActiveSpan("activitypub.fanout", {
4137
+ kind: SpanKind.CONSUMER,
4138
+ attributes: { "activitypub.activity.type": message.activityType }
4139
+ }, extractedContext, async (span) => {
4140
+ const spanCtx = span.spanContext();
4141
+ return await withContext({
4142
+ traceId: spanCtx.traceId,
4143
+ spanId: spanCtx.spanId
4144
+ }, async () => {
4145
+ if (message.activityId != null) span.setAttribute("activitypub.activity.id", message.activityId);
4146
+ meter.recordQueueTaskStarted(common);
4147
+ meter.incrementQueueTaskInFlight(common);
4148
+ const startedAt = performance.now();
4149
+ let outcome = "completed";
4150
+ try {
4151
+ await this.#listenFanoutMessage(contextData, message);
4152
+ } catch (e) {
4153
+ const aborted = isAbortError(e);
4154
+ outcome = aborted ? "aborted" : "failed";
4155
+ if (!aborted) span.setStatus({
4156
+ code: SpanStatusCode.ERROR,
4157
+ message: String(e)
4158
+ });
4159
+ throw e;
4160
+ } finally {
4161
+ meter.recordQueueTaskOutcome(common, outcome, getDurationMs(startedAt));
4162
+ meter.decrementQueueTaskInFlight(common);
4163
+ span.end();
4164
+ }
4165
+ });
2901
4166
  });
2902
- });
2903
- else if (message.type === "outbox") await tracer.startActiveSpan("activitypub.outbox", {
2904
- kind: SpanKind.CONSUMER,
2905
- attributes: {
2906
- "activitypub.activity.type": message.activityType,
2907
- "activitypub.activity.retries": message.attempt
2908
- }
2909
- }, extractedContext, async (span) => {
2910
- const spanCtx = span.spanContext();
2911
- return await withContext({
2912
- traceId: spanCtx.traceId,
2913
- spanId: spanCtx.spanId
2914
- }, async () => {
2915
- if (message.activityId != null) span.setAttribute("activitypub.activity.id", message.activityId);
2916
- try {
2917
- await this.#listenOutboxMessage(contextData, message, span);
2918
- } catch (e) {
2919
- span.setStatus({
2920
- code: SpanStatusCode.ERROR,
2921
- message: String(e)
2922
- });
2923
- throw e;
2924
- } finally {
2925
- span.end();
4167
+ } else if (message.type === "outbox") {
4168
+ const common = {
4169
+ role: "outbox",
4170
+ queue: this.outboxQueue,
4171
+ activityType: message.activityType
4172
+ };
4173
+ await tracer.startActiveSpan("activitypub.outbox", {
4174
+ kind: SpanKind.CONSUMER,
4175
+ attributes: {
4176
+ "activitypub.activity.type": message.activityType,
4177
+ "activitypub.activity.retries": message.attempt
2926
4178
  }
4179
+ }, extractedContext, async (span) => {
4180
+ const spanCtx = span.spanContext();
4181
+ return await withContext({
4182
+ traceId: spanCtx.traceId,
4183
+ spanId: spanCtx.spanId
4184
+ }, async () => {
4185
+ if (message.activityId != null) span.setAttribute("activitypub.activity.id", message.activityId);
4186
+ meter.recordQueueTaskStarted(common);
4187
+ meter.incrementQueueTaskInFlight(common);
4188
+ const startedAt = performance.now();
4189
+ let outcome = "completed";
4190
+ try {
4191
+ await this.#listenOutboxMessage(contextData, message, span);
4192
+ } catch (e) {
4193
+ const aborted = isAbortError(e);
4194
+ outcome = aborted ? "aborted" : "failed";
4195
+ if (!aborted) span.setStatus({
4196
+ code: SpanStatusCode.ERROR,
4197
+ message: String(e)
4198
+ });
4199
+ throw e;
4200
+ } finally {
4201
+ meter.recordQueueTaskOutcome(common, outcome, getDurationMs(startedAt));
4202
+ meter.decrementQueueTaskInFlight(common);
4203
+ span.end();
4204
+ }
4205
+ });
2927
4206
  });
2928
- });
2929
- else if (message.type === "inbox") await tracer.startActiveSpan("activitypub.inbox", {
2930
- kind: SpanKind.CONSUMER,
2931
- attributes: { "activitypub.shared_inbox": message.identifier == null }
2932
- }, extractedContext, async (span) => {
2933
- const spanCtx = span.spanContext();
2934
- return await withContext({
2935
- traceId: spanCtx.traceId,
2936
- spanId: spanCtx.spanId
2937
- }, async () => {
2938
- try {
2939
- await this.#listenInboxMessage(contextData, message, span);
2940
- } catch (e) {
2941
- span.setStatus({
2942
- code: SpanStatusCode.ERROR,
2943
- message: String(e)
2944
- });
2945
- throw e;
2946
- } finally {
2947
- span.end();
2948
- }
4207
+ } else if (message.type === "inbox") {
4208
+ const common = {
4209
+ role: "inbox",
4210
+ queue: this.inboxQueue
4211
+ };
4212
+ await tracer.startActiveSpan("activitypub.inbox", {
4213
+ kind: SpanKind.CONSUMER,
4214
+ attributes: { "activitypub.shared_inbox": message.identifier == null }
4215
+ }, extractedContext, async (span) => {
4216
+ const spanCtx = span.spanContext();
4217
+ return await withContext({
4218
+ traceId: spanCtx.traceId,
4219
+ spanId: spanCtx.spanId
4220
+ }, async () => {
4221
+ meter.recordQueueTaskStarted(common);
4222
+ meter.incrementQueueTaskInFlight(common);
4223
+ const startedAt = performance.now();
4224
+ let outcome = "completed";
4225
+ try {
4226
+ await this.#listenInboxMessage(contextData, message, span, (activityType) => {
4227
+ common.activityType = activityType;
4228
+ });
4229
+ } catch (e) {
4230
+ const aborted = isAbortError(e);
4231
+ outcome = aborted ? "aborted" : "failed";
4232
+ if (!aborted) span.setStatus({
4233
+ code: SpanStatusCode.ERROR,
4234
+ message: String(e)
4235
+ });
4236
+ throw e;
4237
+ } finally {
4238
+ meter.recordQueueTaskOutcome(common, outcome, getDurationMs(startedAt));
4239
+ meter.decrementQueueTaskInFlight(common);
4240
+ span.end();
4241
+ }
4242
+ });
2949
4243
  });
2950
- });
4244
+ }
2951
4245
  });
2952
4246
  }
2953
4247
  async #listenFanoutMessage(data, message) {
@@ -3009,29 +4303,202 @@ var FederationImpl = class extends FederationBuilderImpl {
3009
4303
  if (rsaKeyPair == null && pair.privateKey.algorithm.name === "RSASSA-PKCS1-v1_5") rsaKeyPair = pair;
3010
4304
  keys.push(pair);
3011
4305
  }
4306
+ const loaderOptions = this.#getLoaderOptions(message.baseUrl);
4307
+ let parsedActorIds;
4308
+ const getActorIds = () => {
4309
+ parsedActorIds ??= (message.actorIds ?? []).flatMap((id) => {
4310
+ try {
4311
+ return [new URL(id)];
4312
+ } catch {
4313
+ logger.warn("Invalid actorId URL in OutboxMessage: {id}", { id });
4314
+ return [];
4315
+ }
4316
+ });
4317
+ return parsedActorIds;
4318
+ };
4319
+ const parseActivity = () => Activity.fromJsonLd(message.activity, {
4320
+ contextLoader: this.contextLoaderFactory(loaderOptions),
4321
+ documentLoader: rsaKeyPair == null ? this.documentLoaderFactory(loaderOptions) : this.authenticatedDocumentLoaderFactory(rsaKeyPair, loaderOptions),
4322
+ tracerProvider: this.tracerProvider
4323
+ });
4324
+ const enqueueHeldOutboxMessage = async (delay, heldSince) => {
4325
+ const { outboxQueue } = this;
4326
+ if (outboxQueue == null) return;
4327
+ const heldMessage = {
4328
+ ...message,
4329
+ circuitHeld: true,
4330
+ circuitHeldSince: heldSince.toString()
4331
+ };
4332
+ await outboxQueue.enqueue(heldMessage, {
4333
+ delay: clampNegativeDelay(delay),
4334
+ orderingKey: message.orderingKey
4335
+ });
4336
+ getFederationMetrics(this.meterProvider).recordQueueTaskEnqueued({
4337
+ role: "outbox",
4338
+ queue: outboxQueue,
4339
+ activityType: heldMessage.activityType
4340
+ }, heldMessage.attempt);
4341
+ };
4342
+ const dropHeldOutboxMessage = async (circuit, remoteHost, inbox, heldSince, activity) => {
4343
+ await circuit.dropActivity(remoteHost, {
4344
+ inbox,
4345
+ activity,
4346
+ activityId: message.activityId,
4347
+ activityType: message.activityType,
4348
+ actorIds: getActorIds(),
4349
+ heldSince
4350
+ });
4351
+ if (this.outboxPermanentFailureHandler != null) {
4352
+ const ctx = this.#createContext(new URL(message.baseUrl), _, { documentLoader: this.documentLoaderFactory(loaderOptions) });
4353
+ try {
4354
+ await this.outboxPermanentFailureHandler(ctx, {
4355
+ reason: "circuit-breaker-ttl",
4356
+ inbox,
4357
+ activity,
4358
+ error: new SendActivityError(inbox, 0, "Circuit breaker held activity expired.", ""),
4359
+ statusCode: 0,
4360
+ circuitHeldSince: heldSince,
4361
+ actorIds: getActorIds()
4362
+ });
4363
+ } catch (handlerError) {
4364
+ logger.error("An unexpected error occurred in outboxPermanentFailureHandler:\n{error}", {
4365
+ ...logData,
4366
+ error: handlerError
4367
+ });
4368
+ }
4369
+ }
4370
+ recordOutboxActivity(this.meterProvider, "abandoned", message.activityType);
4371
+ };
3012
4372
  try {
4373
+ const inbox = new URL(message.inbox);
4374
+ const circuit = this.outboxQueue == null ? void 0 : this.circuitBreaker;
4375
+ const remoteHost = getRemoteHost(inbox);
4376
+ let decision;
4377
+ if (circuit != null) try {
4378
+ decision = await circuit.beforeSend(remoteHost, message);
4379
+ } catch (circuitError) {
4380
+ getLogger([
4381
+ "fedify",
4382
+ "federation",
4383
+ "circuit"
4384
+ ]).error("Failed to check circuit breaker state before sending; proceeding with delivery:\n{error}", {
4385
+ ...logData,
4386
+ remoteHost,
4387
+ error: circuitError
4388
+ });
4389
+ }
4390
+ if (decision != null && circuit != null) {
4391
+ if (decision.type === "hold") {
4392
+ recordCircuitBreakerHeldSpanEvent(span, remoteHost, decision.state);
4393
+ await enqueueHeldOutboxMessage(decision.delay, decision.heldSince);
4394
+ return;
4395
+ }
4396
+ if (decision.type === "drop") {
4397
+ const activity = await parseActivity();
4398
+ await dropHeldOutboxMessage(circuit, remoteHost, inbox, decision.heldSince, activity);
4399
+ return;
4400
+ }
4401
+ if (decision.stateChange != null) recordCircuitBreakerSpanEvent(span, remoteHost, decision.stateChange);
4402
+ }
3013
4403
  await sendActivity({
3014
4404
  keys,
3015
4405
  activity: message.activity,
3016
4406
  activityId: message.activityId,
3017
4407
  activityType: message.activityType,
3018
- inbox: new URL(message.inbox),
4408
+ inbox,
3019
4409
  sharedInbox: message.sharedInbox,
3020
4410
  headers: new Headers(message.headers),
3021
4411
  specDeterminer: new KvSpecDeterminer(this.kv, this.kvPrefixes.httpMessageSignaturesSpec, this.firstKnock),
4412
+ meterProvider: this.meterProvider,
3022
4413
  tracerProvider: this.tracerProvider
3023
4414
  });
4415
+ if (circuit != null) try {
4416
+ const stateChange = await circuit.recordSuccess(remoteHost);
4417
+ if (stateChange != null) recordCircuitBreakerSpanEvent(span, remoteHost, stateChange);
4418
+ } catch (error) {
4419
+ getLogger([
4420
+ "fedify",
4421
+ "federation",
4422
+ "circuit"
4423
+ ]).error("Failed to record successful delivery in circuit breaker state; the activity was already delivered:\n{error}", {
4424
+ ...logData,
4425
+ remoteHost,
4426
+ error
4427
+ });
4428
+ }
3024
4429
  } catch (error) {
3025
4430
  span.setStatus({
3026
4431
  code: SpanStatusCode.ERROR,
3027
4432
  message: String(error)
3028
4433
  });
3029
- const loaderOptions = this.#getLoaderOptions(message.baseUrl);
3030
- const activity = await Activity.fromJsonLd(message.activity, {
3031
- contextLoader: this.contextLoaderFactory(loaderOptions),
3032
- documentLoader: rsaKeyPair == null ? this.documentLoaderFactory(loaderOptions) : this.authenticatedDocumentLoaderFactory(rsaKeyPair, loaderOptions),
3033
- tracerProvider: this.tracerProvider
4434
+ const remoteHost = (() => {
4435
+ if (error instanceof SendActivityError) return getRemoteHost(error.inbox);
4436
+ try {
4437
+ return getRemoteHost(new URL(message.inbox));
4438
+ } catch (_) {
4439
+ logger.warn("Invalid inbox URL in queued outbox message: {inbox}", logData);
4440
+ return;
4441
+ }
4442
+ })();
4443
+ let retryAfterDelay;
4444
+ let circuitHold;
4445
+ let circuitDrop;
4446
+ let retryPolicyDelay;
4447
+ let policyDelayCalculated = false;
4448
+ const getPolicyDelay = () => {
4449
+ if (!policyDelayCalculated) {
4450
+ retryPolicyDelay = this.outboxRetryPolicy({
4451
+ elapsedTime: Temporal.Instant.from(message.started).until(Temporal.Now.instant()),
4452
+ attempts: message.attempt
4453
+ });
4454
+ policyDelayCalculated = true;
4455
+ }
4456
+ return retryPolicyDelay;
4457
+ };
4458
+ const isPermanentFailure = error instanceof SendActivityError && this.permanentFailureStatusCodes.includes(error.statusCode);
4459
+ if (!isPermanentFailure && error instanceof SendActivityError && (error.statusCode === 429 || error.statusCode === 503)) retryAfterDelay = parseRetryAfter(error.responseHeaders);
4460
+ if (remoteHost != null && this.outboxQueue != null && this.circuitBreaker != null) try {
4461
+ if (error instanceof SendActivityError) {
4462
+ const { statusCode } = error;
4463
+ const stateChange = isPermanentFailure || statusCode === 429 || statusCode >= 400 && statusCode < 500 ? await this.circuitBreaker.recordReachableFailure(remoteHost) : statusCode >= 500 ? await this.circuitBreaker.recordFailure(remoteHost) : void 0;
4464
+ if (stateChange != null) recordCircuitBreakerSpanEvent(span, remoteHost, stateChange);
4465
+ } else if (isTransportDeliveryError(error)) {
4466
+ const stateChange = await this.circuitBreaker.recordFailure(remoteHost);
4467
+ if (stateChange != null) recordCircuitBreakerSpanEvent(span, remoteHost, stateChange);
4468
+ }
4469
+ if (!isPermanentFailure) {
4470
+ const circuitDecision = await this.circuitBreaker.beforeSend(remoteHost, message);
4471
+ if (circuitDecision.type === "hold") circuitHold = {
4472
+ delay: circuitDecision.delay,
4473
+ heldSince: circuitDecision.heldSince,
4474
+ remoteHost,
4475
+ state: circuitDecision.state
4476
+ };
4477
+ else if (circuitDecision.type === "drop") circuitDrop = {
4478
+ circuit: this.circuitBreaker,
4479
+ remoteHost,
4480
+ inbox: new URL(message.inbox),
4481
+ heldSince: circuitDecision.heldSince
4482
+ };
4483
+ }
4484
+ } catch (circuitError) {
4485
+ getLogger([
4486
+ "fedify",
4487
+ "federation",
4488
+ "circuit"
4489
+ ]).error("Failed to update circuit breaker state after delivery failure; falling back to normal failure handling:\n{error}", {
4490
+ ...logData,
4491
+ remoteHost,
4492
+ error: circuitError
4493
+ });
4494
+ }
4495
+ span.addEvent("activitypub.delivery.failed", {
4496
+ ...remoteHost == null ? {} : { "activitypub.remote.host": remoteHost },
4497
+ "activitypub.delivery.attempt": message.attempt,
4498
+ "activitypub.delivery.permanent_failure": isPermanentFailure,
4499
+ ...error instanceof SendActivityError ? { "http.response.status_code": error.statusCode } : {}
3034
4500
  });
4501
+ const activity = await parseActivity();
3035
4502
  try {
3036
4503
  await this.onOutboxError?.(error, activity);
3037
4504
  } catch (error) {
@@ -3040,7 +4507,12 @@ var FederationImpl = class extends FederationBuilderImpl {
3040
4507
  error
3041
4508
  });
3042
4509
  }
3043
- if (error instanceof SendActivityError && this.permanentFailureStatusCodes.includes(error.statusCode)) {
4510
+ if (circuitDrop != null) {
4511
+ await dropHeldOutboxMessage(circuitDrop.circuit, circuitDrop.remoteHost, circuitDrop.inbox, circuitDrop.heldSince, activity);
4512
+ return;
4513
+ }
4514
+ if (isPermanentFailure) {
4515
+ getFederationMetrics(this.meterProvider).recordPermanentFailure(error.inbox, error.statusCode);
3044
4516
  logger.warn("Permanent delivery failure for activity {activityId} to {inbox} ({status}); not retrying.", {
3045
4517
  ...logData,
3046
4518
  status: error.statusCode
@@ -3049,18 +4521,12 @@ var FederationImpl = class extends FederationBuilderImpl {
3049
4521
  const ctx = this.#createContext(new URL(message.baseUrl), _, { documentLoader: this.documentLoaderFactory(loaderOptions) });
3050
4522
  try {
3051
4523
  await this.outboxPermanentFailureHandler(ctx, {
4524
+ reason: "http",
3052
4525
  inbox: new URL(message.inbox),
3053
4526
  activity,
3054
4527
  error,
3055
4528
  statusCode: error.statusCode,
3056
- actorIds: (message.actorIds ?? []).flatMap((id) => {
3057
- try {
3058
- return [new URL(id)];
3059
- } catch {
3060
- logger.warn("Invalid actorId URL in OutboxMessage: {id}", { id });
3061
- return [];
3062
- }
3063
- })
4529
+ actorIds: getActorIds()
3064
4530
  });
3065
4531
  } catch (handlerError) {
3066
4532
  logger.error("An unexpected error occurred in outboxPermanentFailureHandler:\n{error}", {
@@ -3069,37 +4535,62 @@ var FederationImpl = class extends FederationBuilderImpl {
3069
4535
  });
3070
4536
  }
3071
4537
  }
4538
+ recordOutboxActivity(this.meterProvider, "abandoned", message.activityType);
3072
4539
  return;
3073
4540
  }
3074
- if (this.outboxQueue?.nativeRetrial) {
4541
+ if (circuitHold != null && getPolicyDelay() != null) {
4542
+ logger.error("Failed to send activity {activityId} to {inbox}; holding because the remote host circuit is open:\n{error}", {
4543
+ ...logData,
4544
+ error
4545
+ });
4546
+ recordCircuitBreakerHeldSpanEvent(span, circuitHold.remoteHost, circuitHold.state);
4547
+ const circuit = this.circuitBreaker;
4548
+ await enqueueHeldOutboxMessage(retryAfterDelay == null || circuit == null ? circuitHold.delay : circuit.capHeldDelay(circuitHold.heldSince, maxDelay(circuitHold.delay, retryAfterDelay)), circuitHold.heldSince);
4549
+ return;
4550
+ }
4551
+ if (this.outboxQueue?.nativeRetrial && retryAfterDelay == null) {
3075
4552
  logger.error("Failed to send activity {activityId} to {inbox}; backend will handle retry:\n{error}", {
3076
4553
  ...logData,
3077
4554
  error
3078
4555
  });
3079
4556
  throw error;
3080
4557
  }
3081
- const delay = this.outboxRetryPolicy({
3082
- elapsedTime: Temporal.Instant.from(message.started).until(Temporal.Now.instant()),
3083
- attempts: message.attempt
3084
- });
4558
+ const policyDelay = getPolicyDelay();
4559
+ const delay = policyDelay == null ? null : retryAfterDelay ?? policyDelay;
3085
4560
  if (delay != null) {
3086
4561
  logger.error("Failed to send activity {activityId} to {inbox} (attempt #{attempt}); retry...:\n{error}", {
3087
4562
  ...logData,
3088
4563
  error
3089
4564
  });
3090
- await this.outboxQueue?.enqueue({
4565
+ const retryMessage = {
3091
4566
  ...message,
3092
4567
  attempt: message.attempt + 1
3093
- }, { delay: Temporal.Duration.compare(delay, { seconds: 0 }) < 0 ? Temporal.Duration.from({ seconds: 0 }) : delay });
3094
- } else logger.error("Failed to send activity {activityId} to {inbox} after {attempt} attempts; giving up:\n{error}", {
3095
- ...logData,
3096
- error
3097
- });
4568
+ };
4569
+ const { outboxQueue } = this;
4570
+ if (outboxQueue != null) {
4571
+ await outboxQueue.enqueue(retryMessage, {
4572
+ delay: clampNegativeDelay(delay),
4573
+ orderingKey: message.orderingKey
4574
+ });
4575
+ getFederationMetrics(this.meterProvider).recordQueueTaskEnqueued({
4576
+ role: "outbox",
4577
+ queue: outboxQueue,
4578
+ activityType: retryMessage.activityType
4579
+ }, retryMessage.attempt);
4580
+ recordOutboxActivity(this.meterProvider, "retried", retryMessage.activityType);
4581
+ }
4582
+ } else {
4583
+ logger.error("Failed to send activity {activityId} to {inbox} after {attempt} attempts; giving up:\n{error}", {
4584
+ ...logData,
4585
+ error
4586
+ });
4587
+ recordOutboxActivity(this.meterProvider, "abandoned", message.activityType);
4588
+ }
3098
4589
  return;
3099
4590
  }
3100
4591
  logger.info("Successfully sent activity {activityId} to {inbox}.", { ...logData });
3101
4592
  }
3102
- async #listenInboxMessage(ctxData, message, span) {
4593
+ async #listenInboxMessage(ctxData, message, span, onActivityType) {
3103
4594
  const logger = getLogger([
3104
4595
  "fedify",
3105
4596
  "federation",
@@ -3112,68 +4603,37 @@ var FederationImpl = class extends FederationBuilderImpl {
3112
4603
  const identity = await this.sharedInboxKeyDispatcher(context);
3113
4604
  if (identity != null) context = this.#createContext(baseUrl, ctxData, { documentLoader: "identifier" in identity || "username" in identity ? await context.getDocumentLoader(identity) : context.getDocumentLoader(identity) });
3114
4605
  }
3115
- const activity = await Activity.fromJsonLd(message.activity, context);
3116
- span.setAttribute("activitypub.activity.type", getTypeId(activity).href);
3117
- if (activity.id != null) span.setAttribute("activitypub.activity.id", activity.id.href);
3118
- const cacheKey = activity.id == null ? null : [
3119
- ...this.kvPrefixes.activityIdempotence,
3120
- context.origin,
3121
- activity.id.href
3122
- ];
3123
- if (cacheKey != null) {
3124
- if (await this.kv.get(cacheKey) === true) {
3125
- logger.debug("Activity {activityId} has already been processed.", {
3126
- activityId: activity.id?.href,
3127
- activity: message.activity,
3128
- recipient: message.identifier
3129
- });
3130
- return;
3131
- }
3132
- }
3133
- await this._getTracer().startActiveSpan("activitypub.dispatch_inbox_listener", { kind: SpanKind.INTERNAL }, async (span) => {
3134
- const dispatched = this.inboxListeners?.dispatchWithClass(activity);
3135
- if (dispatched == null) {
3136
- logger.error("Unsupported activity type:\n{activity}", {
3137
- activityId: activity.id?.href,
3138
- activity: message.activity,
3139
- recipient: message.identifier,
3140
- trial: message.attempt
3141
- });
3142
- span.setStatus({
3143
- code: SpanStatusCode.ERROR,
3144
- message: `Unsupported activity type: ${getTypeId(activity).href}`
3145
- });
3146
- span.end();
3147
- return;
3148
- }
3149
- const { class: cls, listener } = dispatched;
3150
- span.updateName(`activitypub.dispatch_inbox_listener ${cls.name}`);
3151
- try {
3152
- await listener(context.toInboxContext(message.identifier, message.activity, activity.id?.href, getTypeId(activity).href), activity);
3153
- } catch (error) {
4606
+ await this._getTracer().startActiveSpan("activitypub.dispatch_inbox_listener", { kind: SpanKind.INTERNAL }, async (listenerSpan) => {
4607
+ let activity = null;
4608
+ let cacheKey = null;
4609
+ let activityType;
4610
+ const reportInboxError = async (error) => {
3154
4611
  try {
3155
4612
  await this.inboxErrorHandler?.(context, error);
3156
4613
  } catch (error) {
3157
4614
  logger.error("An unexpected error occurred in inbox error handler:\n{error}", {
3158
4615
  error,
3159
4616
  trial: message.attempt,
3160
- activityId: activity.id?.href,
4617
+ activityId: activity?.id?.href,
3161
4618
  activity: message.activity,
3162
4619
  recipient: message.identifier
3163
4620
  });
3164
4621
  }
4622
+ };
4623
+ const handleRetriableFailure = async (error) => {
4624
+ await reportInboxError(error);
3165
4625
  if (this.inboxQueue?.nativeRetrial) {
3166
4626
  logger.error("Failed to process the incoming activity {activityId}; backend will handle retry:\n{error}", {
3167
4627
  error,
3168
- activityId: activity.id?.href,
4628
+ activityId: activity?.id?.href,
3169
4629
  activity: message.activity,
3170
4630
  recipient: message.identifier
3171
4631
  });
3172
- span.setStatus({
4632
+ listenerSpan.setStatus({
3173
4633
  code: SpanStatusCode.ERROR,
3174
4634
  message: String(error)
3175
4635
  });
3176
- span.end();
4636
+ listenerSpan.end();
3177
4637
  throw error;
3178
4638
  }
3179
4639
  const delay = this.inboxRetryPolicy({
@@ -3184,34 +4644,165 @@ var FederationImpl = class extends FederationBuilderImpl {
3184
4644
  logger.error("Failed to process the incoming activity {activityId} (attempt #{attempt}); retry...:\n{error}", {
3185
4645
  error,
3186
4646
  attempt: message.attempt,
3187
- activityId: activity.id?.href,
4647
+ activityId: activity?.id?.href,
3188
4648
  activity: message.activity,
3189
4649
  recipient: message.identifier
3190
4650
  });
3191
- await this.inboxQueue?.enqueue({
4651
+ if (this.inboxQueue == null) {
4652
+ listenerSpan.setStatus({
4653
+ code: SpanStatusCode.ERROR,
4654
+ message: String(error)
4655
+ });
4656
+ listenerSpan.end();
4657
+ throw error;
4658
+ }
4659
+ const retryMessage = {
3192
4660
  ...message,
3193
4661
  attempt: message.attempt + 1
3194
- }, { delay: Temporal.Duration.compare(delay, { seconds: 0 }) < 0 ? Temporal.Duration.from({ seconds: 0 }) : delay });
3195
- } else logger.error("Failed to process the incoming activity {activityId} after {trial} attempts; giving up:\n{error}", {
3196
- error,
4662
+ };
4663
+ await this.inboxQueue.enqueue(retryMessage, { delay: clampNegativeDelay(delay) });
4664
+ if (activityType != null) {
4665
+ getFederationMetrics(this.meterProvider).recordQueueTaskEnqueued({
4666
+ role: "inbox",
4667
+ queue: this.inboxQueue,
4668
+ activityType
4669
+ }, retryMessage.attempt);
4670
+ recordInboxActivity(this.meterProvider, "retried", activityType);
4671
+ }
4672
+ } else {
4673
+ logger.error("Failed to process the incoming activity {activityId} after {trial} attempts; giving up:\n{error}", {
4674
+ error,
4675
+ activityId: activity?.id?.href,
4676
+ activity: message.activity,
4677
+ recipient: message.identifier
4678
+ });
4679
+ if (activityType != null) recordInboxActivity(this.meterProvider, "abandoned", activityType);
4680
+ }
4681
+ listenerSpan.setStatus({
4682
+ code: SpanStatusCode.ERROR,
4683
+ message: String(error)
4684
+ });
4685
+ listenerSpan.end();
4686
+ };
4687
+ let dispatched;
4688
+ let parseInput = void 0;
4689
+ let parseContextLoader = context.contextLoader;
4690
+ try {
4691
+ const hasSignatureField = hasSignature(message.activity);
4692
+ const shouldParseFromNormalizedSignedPayload = message.ldSignatureVerified === true || message.normalizedActivity != null || message.ldSignatureVerified == null && hasSignatureField;
4693
+ const parseContext = hasSignatureField ? {
4694
+ ...context,
4695
+ contextLoader: getNormalizationContextLoader(context.contextLoader)
4696
+ } : {
4697
+ ...context,
4698
+ contextLoader: wrapContextLoaderForJsonLd(context.contextLoader)
4699
+ };
4700
+ parseContextLoader = parseContext.contextLoader;
4701
+ let normalizedActivity;
4702
+ if (shouldParseFromNormalizedSignedPayload) {
4703
+ normalizedActivity = message.normalizedActivity ?? await compactJsonLd(message.activity, context.contextLoader);
4704
+ assertSafeJsonLd(normalizedActivity);
4705
+ }
4706
+ parseInput = shouldParseFromNormalizedSignedPayload ? detachSignature(normalizedActivity) : hasSignatureField ? detachSignature(message.activity) : message.activity;
4707
+ activity = await Activity.fromJsonLd(parseInput, parseContext);
4708
+ activityType = getTypeId(activity).href;
4709
+ span.setAttribute("activitypub.activity.type", activityType);
4710
+ listenerSpan.setAttribute("activitypub.activity.type", activityType);
4711
+ onActivityType?.(activityType);
4712
+ if (activity.id != null) {
4713
+ span.setAttribute("activitypub.activity.id", activity.id.href);
4714
+ listenerSpan.setAttribute("activitypub.activity.id", activity.id.href);
4715
+ }
4716
+ cacheKey = activity.id == null ? null : [
4717
+ ...this.kvPrefixes.activityIdempotence,
4718
+ context.origin,
4719
+ activity.id.href
4720
+ ];
4721
+ if (cacheKey != null) {
4722
+ if (await this.kv.get(cacheKey) === true) {
4723
+ logger.debug("Activity {activityId} has already been processed.", {
4724
+ activityId: activity.id?.href,
4725
+ activity: message.activity,
4726
+ recipient: message.identifier
4727
+ });
4728
+ recordInboxActivity(this.meterProvider, "rejected", activityType);
4729
+ listenerSpan.end();
4730
+ return;
4731
+ }
4732
+ }
4733
+ dispatched = this.inboxListeners?.dispatchWithClass(activity);
4734
+ } catch (error) {
4735
+ if (activity == null && error instanceof RangeError && await hasMalformedKnownTemporalLiteral(parseInput, parseContextLoader)) {
4736
+ await reportInboxError(error);
4737
+ logger.error("Failed to parse the queued incoming activity {activityId}:\n{error}", {
4738
+ error,
4739
+ trial: message.attempt,
4740
+ activityId: null,
4741
+ activity: message.activity,
4742
+ recipient: message.identifier
4743
+ });
4744
+ listenerSpan.setStatus({
4745
+ code: SpanStatusCode.ERROR,
4746
+ message: String(error)
4747
+ });
4748
+ listenerSpan.end();
4749
+ return;
4750
+ }
4751
+ if (isPermanentInboxParseError(error)) {
4752
+ await reportInboxError(error);
4753
+ logger.error("Failed to parse the queued incoming activity {activityId}:\n{error}", {
4754
+ error,
4755
+ trial: message.attempt,
4756
+ activityId: activity?.id?.href,
4757
+ activity: message.activity,
4758
+ recipient: message.identifier
4759
+ });
4760
+ listenerSpan.setStatus({
4761
+ code: SpanStatusCode.ERROR,
4762
+ message: String(error)
4763
+ });
4764
+ listenerSpan.end();
4765
+ return;
4766
+ }
4767
+ await handleRetriableFailure(error);
4768
+ return;
4769
+ }
4770
+ if (dispatched == null) {
4771
+ logger.error("Unsupported activity type:\n{activity}", {
3197
4772
  activityId: activity.id?.href,
3198
4773
  activity: message.activity,
3199
- recipient: message.identifier
4774
+ recipient: message.identifier,
4775
+ trial: message.attempt
3200
4776
  });
3201
- span.setStatus({
4777
+ listenerSpan.setStatus({
3202
4778
  code: SpanStatusCode.ERROR,
3203
- message: String(error)
4779
+ message: `Unsupported activity type: ${activityType}`
3204
4780
  });
3205
- span.end();
4781
+ recordInboxActivity(this.meterProvider, "rejected", activityType);
4782
+ listenerSpan.end();
4783
+ return;
4784
+ }
4785
+ const { class: cls, listener } = dispatched;
4786
+ listenerSpan.updateName(`activitypub.dispatch_inbox_listener ${cls.name}`);
4787
+ try {
4788
+ const started = performance.now();
4789
+ try {
4790
+ await listener(context.toInboxContext(message.identifier, message.activity, activity.id?.href, activityType), activity);
4791
+ } finally {
4792
+ getFederationMetrics(this.meterProvider).recordInboxProcessingDuration(activityType, getDurationMs(started));
4793
+ }
4794
+ recordInboxActivity(this.meterProvider, "processed", activityType);
4795
+ } catch (error) {
4796
+ await handleRetriableFailure(error);
3206
4797
  return;
3207
4798
  }
3208
4799
  if (cacheKey != null) await this.kv.set(cacheKey, true, { ttl: Temporal.Duration.from({ days: 1 }) });
3209
4800
  logger.info("Activity {activityId} has been processed.", {
3210
- activityId: activity.id?.href,
4801
+ activityId: activity?.id?.href,
3211
4802
  activity: message.activity,
3212
4803
  recipient: message.identifier
3213
4804
  });
3214
- span.end();
4805
+ listenerSpan.end();
3215
4806
  });
3216
4807
  }
3217
4808
  startQueue(contextData, options = {}) {
@@ -3332,6 +4923,7 @@ var FederationImpl = class extends FederationBuilderImpl {
3332
4923
  sharedInbox: inboxes[inbox].sharedInbox,
3333
4924
  headers: collectionSync == null ? void 0 : new Headers({ "Collection-Synchronization": await buildCollectionSynchronizationHeader(collectionSync, inboxes[inbox].actorIds) }),
3334
4925
  specDeterminer: new KvSpecDeterminer(this.kv, this.kvPrefixes.httpMessageSignaturesSpec, this.firstKnock),
4926
+ meterProvider: this.meterProvider,
3335
4927
  tracerProvider: this.tracerProvider
3336
4928
  }));
3337
4929
  await Promise.all(promises);
@@ -3379,9 +4971,11 @@ var FederationImpl = class extends FederationBuilderImpl {
3379
4971
  });
3380
4972
  }
3381
4973
  const { outboxQueue } = this;
3382
- if (outboxQueue.enqueueMany == null) {
3383
- const promises = messages.map((m) => outboxQueue.enqueue(m.message, { orderingKey: m.orderingKey }));
3384
- const errors = (await Promise.allSettled(promises)).filter((r) => r.status === "rejected").map((r) => r.reason);
4974
+ if (outboxQueue.enqueueMany == null || orderingKey != null) {
4975
+ const errors = (await Promise.allSettled(messages.map(async (m) => {
4976
+ await outboxQueue.enqueue(m.message, { orderingKey: m.orderingKey });
4977
+ recordOutboxEnqueue(this.meterProvider, outboxQueue, m.message);
4978
+ }))).filter((r) => r.status === "rejected").map((r) => r.reason);
3385
4979
  if (errors.length > 0) {
3386
4980
  logger.error("Failed to enqueue activity {activityId} to send later: {errors}", {
3387
4981
  activityId: activity.id.href,
@@ -3390,30 +4984,24 @@ var FederationImpl = class extends FederationBuilderImpl {
3390
4984
  if (errors.length > 1) throw new AggregateError(errors, `Failed to enqueue activity ${activityId} to send later.`);
3391
4985
  throw errors[0];
3392
4986
  }
3393
- } else if (orderingKey != null) {
3394
- const promises = messages.map((m) => outboxQueue.enqueue(m.message, { orderingKey: m.orderingKey }));
3395
- const errors = (await Promise.allSettled(promises)).filter((r) => r.status === "rejected").map((r) => r.reason);
3396
- if (errors.length > 0) {
3397
- logger.error("Failed to enqueue activity {activityId} to send later: {errors}", {
4987
+ } else {
4988
+ try {
4989
+ await outboxQueue.enqueueMany(messages.map((m) => m.message));
4990
+ } catch (error) {
4991
+ logger.error("Failed to enqueue activity {activityId} to send later: {error}", {
3398
4992
  activityId: activity.id.href,
3399
- errors
4993
+ error
3400
4994
  });
3401
- if (errors.length > 1) throw new AggregateError(errors, `Failed to enqueue activity ${activityId} to send later.`);
3402
- throw errors[0];
4995
+ throw error;
3403
4996
  }
3404
- } else try {
3405
- await outboxQueue.enqueueMany(messages.map((m) => m.message));
3406
- } catch (error) {
3407
- logger.error("Failed to enqueue activity {activityId} to send later: {error}", {
3408
- activityId: activity.id.href,
3409
- error
3410
- });
3411
- throw error;
4997
+ for (const m of messages) recordOutboxEnqueue(this.meterProvider, outboxQueue, m.message);
3412
4998
  }
3413
4999
  }
3414
5000
  fetch(request, options) {
3415
5001
  return withContext({ requestId: getRequestId(request) }, async () => {
3416
5002
  const tracer = this._getTracer();
5003
+ const metricState = {};
5004
+ const metricStart = performance.now();
3417
5005
  return await tracer.startActiveSpan(request.method, {
3418
5006
  kind: SpanKind.SERVER,
3419
5007
  attributes: {
@@ -3437,10 +5025,12 @@ var FederationImpl = class extends FederationBuilderImpl {
3437
5025
  response = await this.#fetch(request, {
3438
5026
  ...options,
3439
5027
  span,
3440
- tracer
5028
+ tracer,
5029
+ metricState
3441
5030
  });
3442
5031
  if (acceptsJsonLd(request)) response.headers.set("Vary", "Accept");
3443
5032
  } catch (error) {
5033
+ getFederationMetrics(this.meterProvider).recordHttpServerRequest(request.method, metricState.endpoint ?? "error", getDurationMs(metricStart), { routeTemplate: metricState.routeTemplate });
3444
5034
  span.setStatus({
3445
5035
  code: SpanStatusCode.ERROR,
3446
5036
  message: `${error}`
@@ -3453,6 +5043,10 @@ var FederationImpl = class extends FederationBuilderImpl {
3453
5043
  });
3454
5044
  throw error;
3455
5045
  }
5046
+ getFederationMetrics(this.meterProvider).recordHttpServerRequest(request.method, metricState.endpoint ?? "error", getDurationMs(metricStart), {
5047
+ statusCode: response.status,
5048
+ routeTemplate: metricState.routeTemplate
5049
+ });
3456
5050
  if (span.isRecording()) {
3457
5051
  span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, response.status);
3458
5052
  for (const [k, v] of response.headers) span.setAttribute(ATTR_HTTP_RESPONSE_HEADER(k), [v]);
@@ -3478,13 +5072,18 @@ var FederationImpl = class extends FederationBuilderImpl {
3478
5072
  });
3479
5073
  });
3480
5074
  }
3481
- async #fetch(request, { onNotFound, onNotAcceptable, onUnauthorized, contextData, span, tracer }) {
5075
+ async #fetch(request, { onNotFound, onNotAcceptable, onUnauthorized, contextData, span, tracer, metricState }) {
3482
5076
  onNotFound ??= notFound;
3483
5077
  onNotAcceptable ??= notAcceptable;
3484
5078
  onUnauthorized ??= unauthorized;
3485
5079
  const url = new URL(request.url);
3486
5080
  const route = this.router.route(url.pathname);
3487
- if (route == null) return await onNotFound(request);
5081
+ if (route == null) {
5082
+ metricState.endpoint = "not_found";
5083
+ return await onNotFound(request);
5084
+ }
5085
+ metricState.routeTemplate = route.template;
5086
+ metricState.endpoint = getEndpointCategory(route.name);
3488
5087
  span.updateName(`${request.method} ${route.template}`);
3489
5088
  let context = this.#createContext(request, contextData);
3490
5089
  const routeName = route.name.replace(/:.*$/, "");
@@ -3497,26 +5096,43 @@ var FederationImpl = class extends FederationBuilderImpl {
3497
5096
  actorAliasMapper: this.actorCallbacks?.aliasMapper,
3498
5097
  webFingerLinksDispatcher: this.webFingerLinksDispatcher,
3499
5098
  onNotFound,
3500
- tracer
5099
+ tracer,
5100
+ meterProvider: this._meterProvider
3501
5101
  });
3502
5102
  case "nodeInfoJrd": return await handleNodeInfoJrd(request, context);
3503
5103
  case "nodeInfo": return await handleNodeInfo(request, {
3504
5104
  context,
3505
5105
  nodeInfoDispatcher: this.nodeInfoDispatcher
3506
5106
  });
5107
+ case "benchmarkStats": return await handleBenchmarkStats(request, this.benchmarkMetricReader);
5108
+ case "benchmarkTrigger": return await handleBenchmarkTrigger(request, context, this.benchmarkTriggerOptions);
5109
+ }
5110
+ if (request.method !== "POST" && !acceptsJsonLd(request)) {
5111
+ metricState.endpoint = "not_acceptable";
5112
+ const response = await onNotAcceptable(request);
5113
+ const collectionRoute = getCollectionMetricRoute(routeName);
5114
+ if (collectionRoute != null) recordCollectionRequest(this._meterProvider, {
5115
+ ...collectionRoute,
5116
+ page: url.searchParams.get("cursor") != null,
5117
+ result: "not_acceptable",
5118
+ statusCode: response.status
5119
+ });
5120
+ return response;
3507
5121
  }
3508
- if (request.method !== "POST" && !acceptsJsonLd(request)) return await onNotAcceptable(request);
3509
5122
  switch (routeName) {
3510
5123
  case "actor":
3511
- context = this.#createContext(request, contextData, { invokedFromActorDispatcher: { identifier: route.values.identifier } });
5124
+ case "actorAlias": {
5125
+ const identifier = route.name.startsWith("actorAlias:") ? route.name.substring(11) : route.values.identifier;
5126
+ context = this.#createContext(request, contextData, { invokedFromActorDispatcher: { identifier } });
3512
5127
  return await handleActor(request, {
3513
- identifier: route.values.identifier,
5128
+ identifier,
3514
5129
  context,
3515
5130
  actorDispatcher: this.actorCallbacks?.dispatcher,
3516
5131
  authorizePredicate: this.actorCallbacks?.authorizePredicate,
3517
5132
  onUnauthorized,
3518
5133
  onNotFound
3519
5134
  });
5135
+ }
3520
5136
  case "object": {
3521
5137
  const typeId = route.name.replace(/^object:/, "");
3522
5138
  const callbacks = this.objectCallbacks[typeId];
@@ -3562,6 +5178,7 @@ var FederationImpl = class extends FederationBuilderImpl {
3562
5178
  context,
3563
5179
  collectionCallbacks: this.outboxCallbacks,
3564
5180
  tracerProvider: this.tracerProvider,
5181
+ meterProvider: this._meterProvider,
3565
5182
  onUnauthorized,
3566
5183
  onNotFound
3567
5184
  });
@@ -3573,20 +5190,23 @@ var FederationImpl = class extends FederationBuilderImpl {
3573
5190
  context,
3574
5191
  collectionCallbacks: this.inboxCallbacks,
3575
5192
  tracerProvider: this.tracerProvider,
5193
+ meterProvider: this._meterProvider,
3576
5194
  onUnauthorized,
3577
5195
  onNotFound
3578
5196
  });
3579
5197
  context = this.#createContext(request, contextData, { documentLoader: await context.getDocumentLoader({ identifier: route.values.identifier }) });
3580
- case "sharedInbox":
5198
+ case "sharedInbox": {
3581
5199
  if (routeName !== "inbox" && this.sharedInboxKeyDispatcher != null) {
3582
5200
  const identity = await this.sharedInboxKeyDispatcher(context);
3583
5201
  if (identity != null) context = this.#createContext(request, contextData, { documentLoader: "identifier" in identity || "username" in identity ? await context.getDocumentLoader(identity) : context.getDocumentLoader(identity) });
3584
5202
  }
3585
5203
  if (!this.manuallyStartQueue) this._startQueueInternal(contextData);
5204
+ const inboxContextFactory = context.toInboxContext.bind(context);
5205
+ inboxContextFactory[rawInboxContextFactorySymbol] = context.toInboxContext.bind(context);
3586
5206
  return await handleInbox(request, {
3587
5207
  recipient: route.values.identifier ?? null,
3588
5208
  context,
3589
- inboxContextFactory: context.toInboxContext.bind(context),
5209
+ inboxContextFactory,
3590
5210
  kv: this.kv,
3591
5211
  kvPrefixes: this.kvPrefixes,
3592
5212
  queue: this.inboxQueue,
@@ -3598,9 +5218,11 @@ var FederationImpl = class extends FederationBuilderImpl {
3598
5218
  signatureTimeWindow: this.signatureTimeWindow,
3599
5219
  skipSignatureVerification: this.skipSignatureVerification,
3600
5220
  inboxChallengePolicy: this.inboxChallengePolicy,
5221
+ meterProvider: this.meterProvider,
3601
5222
  tracerProvider: this.tracerProvider,
3602
5223
  idempotencyStrategy: this.idempotencyStrategy
3603
5224
  });
5225
+ }
3604
5226
  case "following": return await handleCollection(request, {
3605
5227
  name: "following",
3606
5228
  identifier: route.values.identifier,
@@ -3608,6 +5230,7 @@ var FederationImpl = class extends FederationBuilderImpl {
3608
5230
  context,
3609
5231
  collectionCallbacks: this.followingCallbacks,
3610
5232
  tracerProvider: this.tracerProvider,
5233
+ meterProvider: this._meterProvider,
3611
5234
  onUnauthorized,
3612
5235
  onNotFound
3613
5236
  });
@@ -3631,6 +5254,7 @@ var FederationImpl = class extends FederationBuilderImpl {
3631
5254
  filterPredicate: baseUrl != null ? ((i) => (i instanceof URL ? i.href : i.id?.href ?? "").startsWith(baseUrl)) : void 0,
3632
5255
  collectionCallbacks: this.followersCallbacks,
3633
5256
  tracerProvider: this.tracerProvider,
5257
+ meterProvider: this._meterProvider,
3634
5258
  onUnauthorized,
3635
5259
  onNotFound
3636
5260
  });
@@ -3642,6 +5266,7 @@ var FederationImpl = class extends FederationBuilderImpl {
3642
5266
  context,
3643
5267
  collectionCallbacks: this.likedCallbacks,
3644
5268
  tracerProvider: this.tracerProvider,
5269
+ meterProvider: this._meterProvider,
3645
5270
  onUnauthorized,
3646
5271
  onNotFound
3647
5272
  });
@@ -3652,6 +5277,7 @@ var FederationImpl = class extends FederationBuilderImpl {
3652
5277
  context,
3653
5278
  collectionCallbacks: this.featuredCallbacks,
3654
5279
  tracerProvider: this.tracerProvider,
5280
+ meterProvider: this._meterProvider,
3655
5281
  onUnauthorized,
3656
5282
  onNotFound
3657
5283
  });
@@ -3662,6 +5288,7 @@ var FederationImpl = class extends FederationBuilderImpl {
3662
5288
  context,
3663
5289
  collectionCallbacks: this.featuredTagsCallbacks,
3664
5290
  tracerProvider: this.tracerProvider,
5291
+ meterProvider: this._meterProvider,
3665
5292
  onUnauthorized,
3666
5293
  onNotFound
3667
5294
  });
@@ -3674,6 +5301,7 @@ var FederationImpl = class extends FederationBuilderImpl {
3674
5301
  values: route.values,
3675
5302
  collectionCallbacks: callbacks,
3676
5303
  tracerProvider: this.tracerProvider,
5304
+ meterProvider: this._meterProvider,
3677
5305
  onUnauthorized,
3678
5306
  onNotFound
3679
5307
  });
@@ -3687,17 +5315,64 @@ var FederationImpl = class extends FederationBuilderImpl {
3687
5315
  values: route.values,
3688
5316
  collectionCallbacks: callbacks,
3689
5317
  tracerProvider: this.tracerProvider,
5318
+ meterProvider: this._meterProvider,
3690
5319
  onUnauthorized,
3691
5320
  onNotFound
3692
5321
  });
3693
5322
  }
3694
5323
  default: {
5324
+ metricState.endpoint = "not_found";
3695
5325
  const response = onNotFound(request);
3696
5326
  return response instanceof Promise ? await response : response;
3697
5327
  }
3698
5328
  }
3699
5329
  }
3700
5330
  };
5331
+ function getEndpointCategory(routeName) {
5332
+ if (routeName.startsWith("object:")) return "object";
5333
+ if (routeName.startsWith("collection:") || routeName.startsWith("orderedCollection:")) return "collection";
5334
+ if (routeName.startsWith("actorAlias:")) return "actor";
5335
+ switch (routeName) {
5336
+ case "webfinger": return "webfinger";
5337
+ case "nodeInfoJrd":
5338
+ case "nodeInfo": return "nodeinfo";
5339
+ case "actor": return "actor";
5340
+ case "inbox": return "inbox";
5341
+ case "sharedInbox": return "shared_inbox";
5342
+ case "outbox": return "outbox";
5343
+ case "following": return "following";
5344
+ case "followers": return "followers";
5345
+ case "liked": return "liked";
5346
+ case "featured": return "featured";
5347
+ case "featuredTags": return "featured_tags";
5348
+ case "benchmarkStats":
5349
+ case "benchmarkTrigger": return "benchmark";
5350
+ default: return "not_found";
5351
+ }
5352
+ }
5353
+ function getCollectionMetricRoute(routeName) {
5354
+ switch (routeName) {
5355
+ case "inbox":
5356
+ case "outbox":
5357
+ case "following":
5358
+ case "followers":
5359
+ case "liked":
5360
+ case "featured": return {
5361
+ kind: routeName,
5362
+ dispatcher: "built_in"
5363
+ };
5364
+ case "featuredTags": return {
5365
+ kind: "featured_tags",
5366
+ dispatcher: "built_in"
5367
+ };
5368
+ case "collection":
5369
+ case "orderedCollection": return {
5370
+ kind: "custom",
5371
+ dispatcher: "custom"
5372
+ };
5373
+ default: return;
5374
+ }
5375
+ }
3701
5376
  const FANOUT_THRESHOLD = 5;
3702
5377
  var ContextImpl = class ContextImpl {
3703
5378
  url;
@@ -3759,13 +5434,16 @@ var ContextImpl = class ContextImpl {
3759
5434
  get tracerProvider() {
3760
5435
  return this.federation.tracerProvider;
3761
5436
  }
5437
+ get meterProvider() {
5438
+ return this.federation.meterProvider;
5439
+ }
3762
5440
  getNodeInfoUri() {
3763
5441
  const path = this.federation.router.build("nodeInfo", {});
3764
5442
  if (path == null) throw new RouterError("No NodeInfo dispatcher registered.");
3765
5443
  return new URL(path, this.canonicalOrigin);
3766
5444
  }
3767
5445
  getActorUri(identifier) {
3768
- const path = this.federation.router.build("actor", { identifier });
5446
+ const path = this.federation.router.build(`actorAlias:${identifier}`, {}) ?? this.federation.router.build("actor", { identifier });
3769
5447
  if (path == null) throw new RouterError("No actor dispatcher registered.");
3770
5448
  return new URL(path, this.canonicalOrigin);
3771
5449
  }
@@ -3831,8 +5509,8 @@ var ContextImpl = class ContextImpl {
3831
5509
  type: "inbox",
3832
5510
  identifier: void 0
3833
5511
  };
3834
- const identifier = route.values.identifier;
3835
- if (route.name === "actor") return {
5512
+ const identifier = route.name.startsWith("actorAlias:") ? route.name.substring(11) : route.values.identifier;
5513
+ if (route.name === "actor" || route.name.startsWith("actorAlias:")) return {
3836
5514
  type: "actor",
3837
5515
  identifier
3838
5516
  };
@@ -3998,6 +5676,7 @@ var ContextImpl = class ContextImpl {
3998
5676
  contextLoader: options.contextLoader ?? this.contextLoader,
3999
5677
  userAgent: options.userAgent ?? this.federation.userAgent,
4000
5678
  tracerProvider: options.tracerProvider ?? this.tracerProvider,
5679
+ meterProvider: options.meterProvider ?? this.meterProvider,
4001
5680
  allowPrivateAddress: this.federation.allowPrivateAddress
4002
5681
  });
4003
5682
  }
@@ -4024,6 +5703,7 @@ var ContextImpl = class ContextImpl {
4024
5703
  ...options,
4025
5704
  userAgent: options.userAgent ?? this.federation.userAgent,
4026
5705
  tracerProvider: options.tracerProvider ?? this.tracerProvider,
5706
+ meterProvider: options.meterProvider ?? this.federation._meterProvider,
4027
5707
  allowPrivateAddress: this.federation.allowPrivateAddress
4028
5708
  });
4029
5709
  }
@@ -4180,6 +5860,12 @@ var ContextImpl = class ContextImpl {
4180
5860
  };
4181
5861
  if (!this.federation.manuallyStartQueue) this.federation._startQueueInternal(this.data);
4182
5862
  await this.federation.fanoutQueue.enqueue(message, { orderingKey: options.orderingKey });
5863
+ getFederationMetrics(this.federation.meterProvider).recordQueueTaskEnqueued({
5864
+ role: "fanout",
5865
+ queue: this.federation.fanoutQueue,
5866
+ activityType: message.activityType
5867
+ }, 0);
5868
+ recordFanoutRecipients(this.federation.meterProvider, globalThis.Object.keys(message.inboxes).length, message.activityType);
4183
5869
  return true;
4184
5870
  }
4185
5871
  async *getFollowers(identifier) {
@@ -4243,6 +5929,7 @@ var ContextImpl = class ContextImpl {
4243
5929
  if (await verifyObject(Activity, json, {
4244
5930
  contextLoader,
4245
5931
  documentLoader: options.documentLoader ?? this.documentLoader,
5932
+ meterProvider: this.meterProvider,
4246
5933
  tracerProvider: options.tracerProvider ?? this.tracerProvider,
4247
5934
  keyCache
4248
5935
  }) == null) {
@@ -4301,6 +5988,7 @@ var ContextImpl = class ContextImpl {
4301
5988
  const routeResult = await routeActivity({
4302
5989
  context: this,
4303
5990
  json,
5991
+ ldSignatureVerified: false,
4304
5992
  activity,
4305
5993
  recipient,
4306
5994
  inboxListeners: this.federation.inboxListeners,
@@ -4310,6 +5998,7 @@ var ContextImpl = class ContextImpl {
4310
5998
  kvPrefixes: this.federation.kvPrefixes,
4311
5999
  queue: this.federation.inboxQueue,
4312
6000
  span,
6001
+ meterProvider: this.federation.meterProvider,
4313
6002
  tracerProvider: options.tracerProvider ?? this.tracerProvider,
4314
6003
  idempotencyStrategy: this.federation.idempotencyStrategy
4315
6004
  });
@@ -4384,6 +6073,7 @@ var RequestContextImpl = class RequestContextImpl extends ContextImpl {
4384
6073
  contextLoader: options.contextLoader ?? this.contextLoader,
4385
6074
  documentLoader: options.documentLoader ?? this.documentLoader,
4386
6075
  timeWindow: this.federation.signatureTimeWindow,
6076
+ meterProvider: this.meterProvider,
4387
6077
  tracerProvider: options.tracerProvider ?? this.tracerProvider
4388
6078
  });
4389
6079
  }
@@ -4508,6 +6198,7 @@ async function forwardActivityInternal(ctx, loggerCategory, forwarder, recipient
4508
6198
  activityType: ctx.activityType,
4509
6199
  inbox: new URL(inbox),
4510
6200
  sharedInbox: inboxes[inbox].sharedInbox,
6201
+ meterProvider: ctx.meterProvider,
4511
6202
  tracerProvider: ctx.tracerProvider,
4512
6203
  specDeterminer: new KvSpecDeterminer(ctx.federation.kv, ctx.federation.kvPrefixes.httpMessageSignaturesSpec, ctx.federation.firstKnock)
4513
6204
  }));
@@ -4558,8 +6249,10 @@ async function forwardActivityInternal(ctx, loggerCategory, forwarder, recipient
4558
6249
  }
4559
6250
  const { outboxQueue } = ctx.federation;
4560
6251
  if (outboxQueue.enqueueMany == null || orderingKey != null) {
4561
- const promises = messages.map((m) => outboxQueue.enqueue(m.message, { orderingKey: m.orderingKey }));
4562
- const errors = (await Promise.allSettled(promises)).filter((r) => r.status === "rejected").map((r) => r.reason);
6252
+ const errors = (await Promise.allSettled(messages.map(async (m) => {
6253
+ await outboxQueue.enqueue(m.message, { orderingKey: m.orderingKey });
6254
+ recordOutboxEnqueue(ctx.federation.meterProvider, outboxQueue, m.message);
6255
+ }))).filter((r) => r.status === "rejected").map((r) => r.reason);
4563
6256
  if (errors.length > 0) {
4564
6257
  logger.error("Failed to enqueue activity {activityId} to forward later:\n{errors}", {
4565
6258
  activityId: ctx.activityId,
@@ -4568,19 +6261,30 @@ async function forwardActivityInternal(ctx, loggerCategory, forwarder, recipient
4568
6261
  if (errors.length > 1) throw new AggregateError(errors, `Failed to enqueue activity ${ctx.activityId} to forward later.`);
4569
6262
  throw errors[0];
4570
6263
  }
4571
- } else try {
4572
- await outboxQueue.enqueueMany(messages.map((m) => m.message));
4573
- } catch (error) {
4574
- logger.error("Failed to enqueue activity {activityId} to forward later:\n{error}", {
4575
- activityId: ctx.activityId,
4576
- error
4577
- });
4578
- throw error;
6264
+ } else {
6265
+ try {
6266
+ await outboxQueue.enqueueMany(messages.map((m) => m.message));
6267
+ } catch (error) {
6268
+ logger.error("Failed to enqueue activity {activityId} to forward later:\n{error}", {
6269
+ activityId: ctx.activityId,
6270
+ error
6271
+ });
6272
+ throw error;
6273
+ }
6274
+ for (const m of messages) recordOutboxEnqueue(ctx.federation.meterProvider, outboxQueue, m.message);
4579
6275
  }
4580
6276
  return true;
4581
6277
  }
4582
6278
  var InboxContextImpl = class InboxContextImpl extends ContextImpl {
4583
6279
  recipient;
6280
+ /**
6281
+ * The original received activity payload.
6282
+ *
6283
+ * Fedify may normalize a Linked Data Signature payload internally for safe
6284
+ * parsing, but forwarding must keep the sender's payload unchanged so
6285
+ * third-party signatures/proofs remain intact.
6286
+ * @internal
6287
+ */
4584
6288
  activity;
4585
6289
  activityId;
4586
6290
  activityType;
@@ -4713,4 +6417,4 @@ function getRequestId(request) {
4713
6417
  return `req_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
4714
6418
  }
4715
6419
  //#endregion
4716
- export { createExponentialBackoffPolicy as a, buildCollectionSynchronizationHeader as c, Router$1 as d, RouterError as f, SendActivityError as i, digest as l, middleware_exports as n, respondWithObject as o, handleWebFinger as r, respondWithObjectIfAcceptable as s, createFederation as t, createFederationBuilder as u };
6420
+ export { SendActivityError as a, buildCollectionSynchronizationHeader as c, normalizeCircuitBreakerOptions as d, parseCircuitBreakerKvState as f, createExponentialBackoffPolicy as i, digest as l, middleware_exports as n, respondWithObject as o, createFederationBuilder as p, handleWebFinger as r, respondWithObjectIfAcceptable as s, createFederation as t, CircuitBreaker as u };