@fedify/fedify 2.4.0-dev.1567 → 2.4.0-dev.1570

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 (113) hide show
  1. package/README.md +4 -0
  2. package/dist/{assert-OguE97r2.mjs → assert-DvVHoIZ0.mjs} +1 -1
  3. package/dist/{assert_instance_of-DBC5X09g.mjs → assert_instance_of-CxoC8bVq.mjs} +1 -1
  4. package/dist/{assert_not_equals-DkVK8oqV.mjs → assert_not_equals-C8vcTTA3.mjs} +1 -1
  5. package/dist/{assert_rejects-DN60FHPX.mjs → assert_rejects-DAdaaIWM.mjs} +2 -2
  6. package/dist/{assert_strict_equals-XEgZAlrj.mjs → assert_strict_equals-BzqyKuYL.mjs} +1 -1
  7. package/dist/{assert_throws-BOkhLGYc.mjs → assert_throws-zN79lgIC.mjs} +1 -1
  8. package/dist/{builder-DGWCUaNQ.mjs → builder-CJnWbgbj.mjs} +23 -3
  9. package/dist/codec-nfBU4Up4.mjs +124 -0
  10. package/dist/compat/mod.d.cts +1 -1
  11. package/dist/compat/mod.d.ts +1 -1
  12. package/dist/compat/outgoing-jsonld.test.mjs +3 -3
  13. package/dist/compat/public-audience.test.mjs +3 -3
  14. package/dist/compat/transformers.test.mjs +5 -5
  15. package/dist/{context-DVoTs_wM.mjs → context-BBRTgkrs.mjs} +8 -2
  16. package/dist/{context-BBVLF7lx.d.cts → context-BLe0NPtZ.d.cts} +530 -259
  17. package/dist/{context-BU6jSQdo.d.ts → context-GfQA-CqB.d.ts} +529 -259
  18. package/dist/{deno-Dho0TW0X.mjs → deno-klNxGW7W.mjs} +1 -1
  19. package/dist/{docloader-DCQSgDOg.mjs → docloader-CuFH35j1.mjs} +3 -3
  20. package/dist/federation/builder.test.mjs +5 -5
  21. package/dist/federation/circuit-breaker.test.mjs +5 -5
  22. package/dist/federation/collection.test.mjs +3 -3
  23. package/dist/federation/handler.test.mjs +13 -23
  24. package/dist/federation/idempotency.test.mjs +6 -6
  25. package/dist/federation/inbox.test.mjs +3 -3
  26. package/dist/federation/keycache.test.mjs +5 -5
  27. package/dist/federation/kv.test.mjs +3 -3
  28. package/dist/federation/metrics.test.mjs +4 -4
  29. package/dist/federation/middleware.test.mjs +204 -19
  30. package/dist/federation/mod.cjs +3 -259
  31. package/dist/federation/mod.d.cts +4 -4
  32. package/dist/federation/mod.d.ts +4 -4
  33. package/dist/federation/mod.js +1 -257
  34. package/dist/federation/mq.test.mjs +103 -261
  35. package/dist/federation/negotiation.test.mjs +4 -4
  36. package/dist/federation/retry.test.mjs +3 -3
  37. package/dist/federation/router.test.mjs +5 -5
  38. package/dist/federation/send.test.mjs +10 -10
  39. package/dist/federation/tasks/codec.test.d.mts +2 -0
  40. package/dist/federation/tasks/codec.test.mjs +302 -0
  41. package/dist/federation/tasks/enqueue.test.d.mts +2 -0
  42. package/dist/federation/tasks/enqueue.test.mjs +1057 -0
  43. package/dist/federation/tasks/tasks.test.d.mts +2 -0
  44. package/dist/federation/tasks/tasks.test.mjs +1228 -0
  45. package/dist/federation/temporal.test.mjs +4 -4
  46. package/dist/federation/webfinger.test.mjs +6 -6
  47. package/dist/{http-sngIMb9S.js → http-D0jH0kco.js} +16 -80
  48. package/dist/{http-C7Lot0it.cjs → http-DeuPW1Oh.cjs} +14 -78
  49. package/dist/{http-Bf8YhWOi.mjs → http-JdRPUYku.mjs} +4 -4
  50. package/dist/{key-3rYgSl9f.mjs → key-o0_ipvUR.mjs} +10 -76
  51. package/dist/{kv-cache-CO2IaDJu.cjs → kv-cache-C3rqzQvu.cjs} +1 -1
  52. package/dist/{kv-cache-P3scUO6t.mjs → kv-cache-CM7w20U5.mjs} +1 -1
  53. package/dist/{kv-cache-B0siRaoZ.js → kv-cache-DSWq2JHU.js} +1 -1
  54. package/dist/{ld-CCOPy-Co.mjs → ld-C__CEgQH.mjs} +3 -3
  55. package/dist/{metrics-CR4UD_gF.mjs → metrics-CLSPm4dZ.mjs} +8 -6
  56. package/dist/{mq-D-nlpY04.d.ts → metrics-CoUJLQhZ.d.cts} +69 -1
  57. package/dist/{mq-D8uSFzxe.d.cts → metrics-Dc4jR-IC.d.ts} +69 -1
  58. package/dist/{middleware-DUelXs9h.cjs → middleware-B502nwxx.cjs} +874 -134
  59. package/dist/{middleware-CIteB8IS.mjs → middleware-CdMiavoh.mjs} +1 -1
  60. package/dist/{middleware-DfBPuuvp.mjs → middleware-CjHqy-1i.mjs} +469 -150
  61. package/dist/{middleware-BoNiH9CQ.js → middleware-zuLSkPIk.js} +863 -135
  62. package/dist/{mod-C0F6kvgS.d.cts → mod-B8Efb9qp.d.cts} +1 -1
  63. package/dist/{mod-yvIXFAEi.d.cts → mod-BtEaIEcD.d.cts} +1 -31
  64. package/dist/{mod-vPYVoa5n.d.ts → mod-Cmr39iWq.d.ts} +1 -1
  65. package/dist/{mod-DFvNJcNb.d.ts → mod-UMmveiO0.d.ts} +1 -31
  66. package/dist/mod.cjs +6 -6
  67. package/dist/mod.d.cts +6 -6
  68. package/dist/mod.d.ts +6 -6
  69. package/dist/mod.js +5 -5
  70. package/dist/mq-03vS-C9P.mjs +276 -0
  71. package/dist/nodeinfo/client.test.mjs +3 -3
  72. package/dist/nodeinfo/handler.test.mjs +5 -5
  73. package/dist/nodeinfo/types.test.mjs +4 -4
  74. package/dist/otel/exporter.test.mjs +3 -3
  75. package/dist/{outgoing-jsonld-L_DbOaFe.mjs → outgoing-jsonld-BNL8AC14.mjs} +1 -1
  76. package/dist/{owner-TUz_H-M9.mjs → owner-gz4TUfKG.mjs} +2 -2
  77. package/dist/{proof-DzKUmE7R.cjs → proof-CiDzRtr0.cjs} +1 -1
  78. package/dist/{proof-CrI3UpuA.mjs → proof-CuQpZ_AT.mjs} +5 -5
  79. package/dist/{proof-C5i7MhbD.js → proof-DQWTf6oB.js} +1 -1
  80. package/dist/{send-BBs_erdP.mjs → send-B60teucv.mjs} +3 -3
  81. package/dist/sig/accept.test.mjs +1 -1
  82. package/dist/sig/http.test.mjs +8 -8
  83. package/dist/sig/key.test.mjs +7 -191
  84. package/dist/sig/ld.test.mjs +7 -7
  85. package/dist/sig/mod.cjs +2 -2
  86. package/dist/sig/mod.js +2 -2
  87. package/dist/sig/owner.test.mjs +6 -6
  88. package/dist/sig/proof.test.mjs +9 -69
  89. package/dist/{std__assert-BBjXFNOb.mjs → std__assert-Dh6uLf_q.mjs} +4 -4
  90. package/dist/{temporal-bBIy2G-F.mjs → temporal-CvVMrvJJ.mjs} +1 -1
  91. package/dist/testing/mod.d.mts +1037 -8
  92. package/dist/testing/mod.mjs +3 -2
  93. package/dist/testing-Dy-WhqpN.mjs +77 -0
  94. package/dist/utils/docloader.test.mjs +6 -6
  95. package/dist/utils/kv-cache.test.mjs +1 -1
  96. package/dist/utils/mod.cjs +1 -1
  97. package/dist/utils/mod.d.cts +1 -1
  98. package/dist/utils/mod.d.ts +1 -1
  99. package/dist/utils/mod.js +1 -1
  100. package/package.json +8 -6
  101. /package/dist/{accept-CPkZzmGN.mjs → accept-CgDcxvjV.mjs} +0 -0
  102. /package/dist/{activity-listener-tztVvlNb.mjs → activity-listener-BeTGV3wc.mjs} +0 -0
  103. /package/dist/{assert_equals-C-ZRDbaf.mjs → assert_equals-PBVKNMJd.mjs} +0 -0
  104. /package/dist/{circuit-breaker-CSWsyoef.mjs → circuit-breaker-hJBB6jwA.mjs} +0 -0
  105. /package/dist/{client-ByXmQhYD.mjs → client-B_A6mfn3.mjs} +0 -0
  106. /package/dist/{collection-Cc3DVAhE.mjs → collection-Dp5ky45w.mjs} +0 -0
  107. /package/dist/{keycache-BeU0LCII.mjs → keycache-CaOR6NYg.mjs} +0 -0
  108. /package/dist/{keys-DGu1NFwu.mjs → keys-CSYsOMFG.mjs} +0 -0
  109. /package/dist/{kv-rV3vodCc.mjs → kv-CIvR3Non.mjs} +0 -0
  110. /package/dist/{negotiation-DDstyBvc.mjs → negotiation-m_L2nyS3.mjs} +0 -0
  111. /package/dist/{public-audience-Cvbr2Gzt.mjs → public-audience-c9zmYKgA.mjs} +0 -0
  112. /package/dist/{retry-CXg_MBI-.mjs → retry-DI4O-zYl.mjs} +0 -0
  113. /package/dist/{types-J53Kw7so.mjs → types-BFowWFTT.mjs} +0 -0
@@ -1,39 +1,41 @@
1
1
  import { Temporal } from "@js-temporal/polyfill";
2
2
  import "urlpattern-polyfill";
3
3
  globalThis.addEventListener = () => {};
4
- import { n as version, t as name } from "./deno-Dho0TW0X.mjs";
5
- import { a as instrumentDocumentLoader, b as recordWebFingerHandle, c as recordCircuitBreakerStateChange, d as recordCollectionRequest, f as recordCollectionTotalItems, g as recordInboxActivity, h as recordFanoutRecipients, i as getRemoteHost, l as recordCollectionDispatchDuration, n as getDurationMs, o as isAbortError, r as getFederationMetrics, u as recordCollectionPageItems, v as recordOutboxActivity, x as registerQueueDepthGauge, y as recordOutboxEnqueue } from "./metrics-CR4UD_gF.mjs";
6
- import { t as formatAcceptSignature } from "./accept-CPkZzmGN.mjs";
7
- import { a as importJwk, o as validateCryptoKey, t as exportJwk } from "./key-3rYgSl9f.mjs";
8
- import { l as verifyRequest, o as parseRfc9421SignatureInput, u as verifyRequestDetailed } from "./http-Bf8YhWOi.mjs";
9
- import { t as getAuthenticatedDocumentLoader } from "./docloader-DCQSgDOg.mjs";
10
- import { n as kvCache } from "./kv-cache-P3scUO6t.mjs";
11
- import { _ as wrapContextLoaderForJsonLd, a as compactJsonLd, c as getNormalizationContextLoader, d as isClearlyMalformedContextReference, f as isInvalidUrlTypeError, l as hasSignature, m as verifyCompactJsonLd, p as signJsonLd, r as assertSafeJsonLd, s as detachSignature, t as InvalidContextReferenceError, u as hasSignatureLike } from "./ld-CCOPy-Co.mjs";
12
- import { n as getKeyOwner, t as doesActorOwnKey } from "./owner-TUz_H-M9.mjs";
13
- import { r as normalizeOutgoingActivityJsonLd } from "./outgoing-jsonld-L_DbOaFe.mjs";
14
- import { i as verifyObject, n as hasProofLike, r as signObject } from "./proof-CrI3UpuA.mjs";
15
- import { t as getNodeInfo } from "./client-ByXmQhYD.mjs";
16
- import { t as nodeInfoToJson } from "./types-J53Kw7so.mjs";
17
- import { n as extractInboxes, r as sendActivity, t as SendActivityError } from "./send-BBs_erdP.mjs";
18
- import { n as FederationBuilderImpl, t as ACTOR_ALIAS_PREFIX } from "./builder-DGWCUaNQ.mjs";
19
- import { t as CircuitBreaker } from "./circuit-breaker-CSWsyoef.mjs";
20
- import { t as buildCollectionSynchronizationHeader } from "./collection-Cc3DVAhE.mjs";
21
- import { t as KvKeyCache } from "./keycache-BeU0LCII.mjs";
22
- import { t as acceptsJsonLd } from "./negotiation-DDstyBvc.mjs";
23
- import { t as hasMalformedKnownTemporalLiteral } from "./temporal-bBIy2G-F.mjs";
24
- import { t as createExponentialBackoffPolicy } from "./retry-CXg_MBI-.mjs";
4
+ import { n as version, t as name } from "./deno-klNxGW7W.mjs";
5
+ import { a as instrumentDocumentLoader, b as recordWebFingerHandle, c as recordCircuitBreakerStateChange, d as recordCollectionRequest, f as recordCollectionTotalItems, g as recordInboxActivity, h as recordFanoutRecipients, i as getRemoteHost, l as recordCollectionDispatchDuration, n as getDurationMs, o as isAbortError, r as getFederationMetrics, u as recordCollectionPageItems, v as recordOutboxActivity, x as registerQueueDepthGauge, y as recordOutboxEnqueue } from "./metrics-CLSPm4dZ.mjs";
6
+ import { t as formatAcceptSignature } from "./accept-CgDcxvjV.mjs";
7
+ import { a as importJwk, o as validateCryptoKey, t as exportJwk } from "./key-o0_ipvUR.mjs";
8
+ import { l as verifyRequest, o as parseRfc9421SignatureInput, u as verifyRequestDetailed } from "./http-JdRPUYku.mjs";
9
+ import { t as getAuthenticatedDocumentLoader } from "./docloader-CuFH35j1.mjs";
10
+ import { n as kvCache } from "./kv-cache-CM7w20U5.mjs";
11
+ import { _ as wrapContextLoaderForJsonLd, a as compactJsonLd, c as getNormalizationContextLoader, d as isClearlyMalformedContextReference, f as isInvalidUrlTypeError, l as hasSignature, m as verifyCompactJsonLd, p as signJsonLd, r as assertSafeJsonLd, s as detachSignature, t as InvalidContextReferenceError, u as hasSignatureLike } from "./ld-C__CEgQH.mjs";
12
+ import { n as getKeyOwner, t as doesActorOwnKey } from "./owner-gz4TUfKG.mjs";
13
+ import { r as normalizeOutgoingActivityJsonLd } from "./outgoing-jsonld-BNL8AC14.mjs";
14
+ import { i as verifyObject, n as hasProofLike, r as signObject } from "./proof-CuQpZ_AT.mjs";
15
+ import { t as getNodeInfo } from "./client-B_A6mfn3.mjs";
16
+ import { t as nodeInfoToJson } from "./types-BFowWFTT.mjs";
17
+ import { n as extractInboxes, r as sendActivity, t as SendActivityError } from "./send-B60teucv.mjs";
18
+ import { n as FederationBuilderImpl, t as ACTOR_ALIAS_PREFIX } from "./builder-CJnWbgbj.mjs";
19
+ import { t as CircuitBreaker } from "./circuit-breaker-hJBB6jwA.mjs";
20
+ import { t as buildCollectionSynchronizationHeader } from "./collection-Dp5ky45w.mjs";
21
+ import { t as KvKeyCache } from "./keycache-CaOR6NYg.mjs";
22
+ import { t as acceptsJsonLd } from "./negotiation-m_L2nyS3.mjs";
23
+ import { t as hasMalformedKnownTemporalLiteral } from "./temporal-CvVMrvJJ.mjs";
24
+ import { t as createExponentialBackoffPolicy } from "./retry-DI4O-zYl.mjs";
25
+ import { t as TaskCodec } from "./codec-nfBU4Up4.mjs";
26
+ import { n as ParallelMessageQueue } from "./mq-03vS-C9P.mjs";
25
27
  import { getLogger, withContext } from "@logtape/logtape";
26
28
  import { RouterError } from "@fedify/uri-template";
27
29
  import { Activity, Collection, CollectionPage, CryptographicKey, Link, Multikey, Object as Object$1, OrderedCollection, OrderedCollectionPage, Tombstone, getTypeId, lookupObject, traverseCollection } from "@fedify/vocab";
28
30
  import { lookupWebFinger } from "@fedify/webfinger";
29
31
  import { SpanKind, SpanStatusCode, context, metrics, propagation, trace } from "@opentelemetry/api";
32
+ import { uniq } from "es-toolkit";
30
33
  import { FetchError, getDocumentLoader } from "@fedify/vocab-runtime";
31
34
  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";
32
- import { uniq } from "es-toolkit";
33
35
  import { DataPointType, MeterProvider as MeterProvider$1, MetricReader } from "@opentelemetry/sdk-metrics";
34
36
  import { domainToASCII } from "node:url";
35
37
  //#region src/compat/transformers.ts
36
- const logger$1 = getLogger([
38
+ const logger$2 = getLogger([
37
39
  "fedify",
38
40
  "compat",
39
41
  "transformers"
@@ -59,7 +61,7 @@ const logger$1 = getLogger([
59
61
  function autoIdAssigner(activity, context) {
60
62
  if (activity.id != null) return activity;
61
63
  const id = new URL(`/#${activity.constructor.name}/${crypto.randomUUID()}`, context.origin);
62
- logger$1.warn("As the activity to send does not have an id, a new id {id} has been generated for it. However, it is recommended to explicitly set the id for the activity.", { id: id.href });
64
+ logger$2.warn("As the activity to send does not have an id, a new id {id} has been generated for it. However, it is recommended to explicitly set the id for the activity.", { id: id.href });
63
65
  return activity.clone({ id });
64
66
  }
65
67
  /**
@@ -2093,6 +2095,199 @@ const MIN_COMPONENTS = [
2093
2095
  "@authority"
2094
2096
  ];
2095
2097
  //#endregion
2098
+ //#region src/federation/tasks/enqueue.ts
2099
+ /**
2100
+ * The enqueue pipeline for custom background tasks. `ContextImpl.enqueueTask`
2101
+ * and `ContextImpl.enqueueTaskMany` delegate to {@link enqueueTasks} so the
2102
+ * handle validation, deduplication planning, payload encoding, and queue
2103
+ * dispatch live in one cohesive place instead of one oversized method.
2104
+ *
2105
+ * @module
2106
+ */
2107
+ /**
2108
+ * Validates the task handle, plans deduplication, encodes every payload, then
2109
+ * dispatches the resulting messages to the queue. A single item flows through
2110
+ * the same pipeline as a batch, so {@link Context.enqueueTask} and
2111
+ * {@link Context.enqueueTaskMany} share one implementation.
2112
+ * @template TContextData The context data to pass to the {@link Context}.
2113
+ * @template TData The type of the task payload, inferred from the task's schema.
2114
+ * @param ctx The enqueueing dependencies assembled by `ContextImpl`.
2115
+ * @param task The handle returned by `defineTask()`.
2116
+ * @param items The payloads to enqueue, in order.
2117
+ * @param options The enqueue options governing delay, ordering, and dedup.
2118
+ * @internal
2119
+ */
2120
+ const enqueueTasks = (ctx) => async function(task, items, options) {
2121
+ const def = ctx.federation.taskDefinitions.get(task.name);
2122
+ if (def == null || def.handle !== task) throw new TypeError(`Task ${JSON.stringify(task.name)} is not defined on this federation; pass a handle returned by its defineTask().`);
2123
+ const queue = ctx.federation.resolveTaskQueue(task.name);
2124
+ if (queue == null) throw new TypeError("No message queue is configured for tasks; pass `queue` to createFederation() or to defineTask().");
2125
+ if (items.length < 1) return;
2126
+ const plan = planDeduplication(ctx, queue, task.name, options, items.length);
2127
+ const messages = await Promise.all(items.map(encodeTaskMessage(ctx.codec, ctx.origin, task, options)));
2128
+ const claim = await claimDeduplication(ctx, plan, task.name);
2129
+ if (!claim.proceed) return;
2130
+ if (!ctx.federation.manuallyStartQueue) ctx.federation._startQueueInternal(ctx.data);
2131
+ try {
2132
+ await dispatch(queue, messages, {
2133
+ delay: getDurationIfDefined(options.delay),
2134
+ orderingKey: options.orderingKey,
2135
+ deduplicationKey: claim.forwardedDeduplicationKey
2136
+ }, {
2137
+ meterProvider: ctx.federation.meterProvider,
2138
+ taskName: task.name
2139
+ });
2140
+ } catch (error) {
2141
+ if (claim.rollback != null) try {
2142
+ await claim.rollback();
2143
+ } catch (rollbackError) {
2144
+ logger$1.warn("Failed to roll back the deduplication marker for task {taskName} after a failed enqueue; it will expire by TTL. {rollbackError}", {
2145
+ taskName: task.name,
2146
+ rollbackError
2147
+ });
2148
+ }
2149
+ throw error;
2150
+ }
2151
+ };
2152
+ const getDurationIfDefined = (item) => item == null ? void 0 : Temporal.Duration.from(item);
2153
+ /**
2154
+ * Decides how a `deduplicationKey` (if any) is honored: forwarded to a native
2155
+ * queue, claimed via `cas`, or—when neither is available—dropped or rejected
2156
+ * per the federation's `taskDeduplicationFallback`. Throws the fail-fast
2157
+ * `TypeError`s so they precede the encode.
2158
+ */
2159
+ function planDeduplication(ctx, queue, taskName, options, itemCount) {
2160
+ if (options.deduplicationKey == null) return { kind: "none" };
2161
+ const key = options.deduplicationKey;
2162
+ const native = queue.nativeDeduplication === true;
2163
+ const canCas = ctx.federation.kv.cas != null;
2164
+ const canBatchAtomically = queue.enqueueMany != null && !(queue instanceof ParallelMessageQueue && queue.queue.enqueueMany == null);
2165
+ if (itemCount > 1 && !canBatchAtomically && (native || canCas)) throw new TypeError(`Task ${JSON.stringify(taskName)} was enqueued as a batch with a deduplicationKey, but its message queue does not implement enqueueMany; a multi-item batch cannot be deduplicated atomically without it. Implement enqueueMany on the queue, or enqueue the tasks individually with enqueueTask().`);
2166
+ if (native) return {
2167
+ kind: "native",
2168
+ key
2169
+ };
2170
+ if (canCas) return {
2171
+ kind: "cas",
2172
+ key
2173
+ };
2174
+ if (ctx.federation.taskDeduplicationFallback === "closed") throw new TypeError("deduplicationKey was set but the message queue does not declare nativeDeduplication and the key-value store exposes no conditional write (cas); set taskDeduplicationFallback to \"open\" to proceed without deduplication, or use a backend that supports it.");
2175
+ return {
2176
+ kind: "open",
2177
+ key
2178
+ };
2179
+ }
2180
+ /**
2181
+ * Executes the planned deduplication once the payloads are encoded. A native
2182
+ * plan forwards its key to the queue; a `cas` plan claims the marker and stops
2183
+ * the enqueue when it loses the race; an `open` plan logs and proceeds.
2184
+ * @returns Whether to proceed, and the key (if any) to forward to the queue.
2185
+ */
2186
+ async function claimDeduplication(ctx, plan, taskName) {
2187
+ switch (plan.kind) {
2188
+ case "native": return {
2189
+ proceed: true,
2190
+ forwardedDeduplicationKey: plan.key
2191
+ };
2192
+ case "cas": {
2193
+ const cacheKey = [...ctx.federation.kvPrefixes.taskDeduplication, plan.key];
2194
+ const token = crypto.randomUUID();
2195
+ if (!await ctx.federation.kv.cas(cacheKey, void 0, token, { ttl: ctx.federation.taskDeduplicationTtl })) return { proceed: false };
2196
+ return {
2197
+ proceed: true,
2198
+ rollback: async () => {
2199
+ await ctx.federation.kv.cas(cacheKey, token, void 0);
2200
+ }
2201
+ };
2202
+ }
2203
+ case "open": logger$1.debug("deduplicationKey {deduplicationKey} for task {taskName} ignored: the message queue declares no nativeDeduplication and the key-value store has no cas; proceeding (taskDeduplicationFallback is \"open\").", {
2204
+ deduplicationKey: plan.key,
2205
+ taskName
2206
+ });
2207
+ case "none": return { proceed: true };
2208
+ default: throw new TypeError(`Unknown deduplication plan: ${JSON.stringify(plan)}`);
2209
+ }
2210
+ }
2211
+ /**
2212
+ * Sends the encoded messages to the queue, picking the bulk path when the
2213
+ * queue implements `enqueueMany` and otherwise fanning out parallel single
2214
+ * enqueues. The fan-out drops `deduplicationKey`, which is only ever set for a
2215
+ * native plan that the bulk paths already cover.
2216
+ */
2217
+ async function dispatch(queue, messages, options, { meterProvider, taskName }) {
2218
+ const metrics = getFederationMetrics(meterProvider);
2219
+ if (messages.length === 1) await queue.enqueue(messages[0], options);
2220
+ else if (queue.enqueueMany != null) await queue.enqueueMany(messages, options);
2221
+ else {
2222
+ const fanoutOptions = {
2223
+ delay: options.delay,
2224
+ orderingKey: options.orderingKey
2225
+ };
2226
+ const rejected = (await Promise.allSettled(messages.map(async (message) => {
2227
+ await queue.enqueue(message, fanoutOptions);
2228
+ metrics.recordQueueTaskEnqueued({
2229
+ role: "task",
2230
+ queue,
2231
+ taskName
2232
+ }, message.attempt);
2233
+ }))).find((result) => result.status === "rejected");
2234
+ if (rejected != null) throw rejected.reason;
2235
+ return;
2236
+ }
2237
+ metrics.recordQueueTaskEnqueued({
2238
+ role: "task",
2239
+ queue,
2240
+ taskName
2241
+ }, messages[0].attempt, messages.length);
2242
+ }
2243
+ /**
2244
+ * Builds the per-payload encoder: validates and serializes the payload, then
2245
+ * stamps the message envelope with a fresh id, the context's origin, and the
2246
+ * active trace context. Curried so the batch encode reuses one bound encoder.
2247
+ */
2248
+ const encodeTaskMessage = (codec, origin, task, options) => async (data) => {
2249
+ const encoded = await codec.encode(task.schema, data);
2250
+ const carrier = {};
2251
+ propagation.inject(context.active(), carrier);
2252
+ return {
2253
+ type: "task",
2254
+ id: crypto.randomUUID(),
2255
+ baseUrl: origin,
2256
+ taskName: task.name,
2257
+ data: encoded,
2258
+ started: Temporal.Now.instant().toString(),
2259
+ attempt: 0,
2260
+ orderingKey: options.orderingKey,
2261
+ traceContext: carrier
2262
+ };
2263
+ };
2264
+ const logger$1 = getLogger([
2265
+ "fedify",
2266
+ "federation",
2267
+ "task"
2268
+ ]);
2269
+ //#endregion
2270
+ //#region src/federation/tasks/error.ts
2271
+ var TaskRetryEnqueueError = class extends Error {
2272
+ constructor(cause) {
2273
+ super("Failed to re-enqueue a custom task for a retry.", { cause });
2274
+ this.name = "TaskRetryEnqueueError";
2275
+ }
2276
+ };
2277
+ const classifyAbortableError = (error) => isAbortError(error) ? { outcome: "aborted" } : {
2278
+ outcome: "failed",
2279
+ error
2280
+ };
2281
+ const classifyTaskError = (error) => isAbortError(error) ? { outcome: "aborted" } : error instanceof TaskRetryEnqueueError ? {
2282
+ outcome: "failed",
2283
+ failureReason: "retry_enqueue",
2284
+ error: error.cause ?? error
2285
+ } : {
2286
+ outcome: "failed",
2287
+ failureReason: "handler",
2288
+ error
2289
+ };
2290
+ //#endregion
2096
2291
  //#region src/federation/webfinger.ts
2097
2292
  const logger = getLogger([
2098
2293
  "fedify",
@@ -2418,9 +2613,8 @@ var FederationImpl = class extends FederationBuilderImpl {
2418
2613
  inboxQueue;
2419
2614
  outboxQueue;
2420
2615
  fanoutQueue;
2421
- inboxQueueStarted;
2422
- outboxQueueStarted;
2423
- fanoutQueueStarted;
2616
+ taskQueue;
2617
+ startedQueues;
2424
2618
  manuallyStartQueue;
2425
2619
  origin;
2426
2620
  documentLoaderFactory;
@@ -2434,6 +2628,10 @@ var FederationImpl = class extends FederationBuilderImpl {
2434
2628
  skipSignatureVerification;
2435
2629
  outboxRetryPolicy;
2436
2630
  inboxRetryPolicy;
2631
+ taskRetryPolicy;
2632
+ taskQueueResolution;
2633
+ taskDeduplicationTtl;
2634
+ taskDeduplicationFallback;
2437
2635
  circuitBreaker;
2438
2636
  activityTransformers;
2439
2637
  _tracerProvider;
@@ -2473,20 +2671,24 @@ var FederationImpl = class extends FederationBuilderImpl {
2473
2671
  httpMessageSignaturesSpec: ["_fedify", "httpMessageSignaturesSpec"],
2474
2672
  acceptSignatureNonce: ["_fedify", "acceptSignatureNonce"],
2475
2673
  circuitBreaker: ["_fedify", "circuit"],
2674
+ taskDeduplication: ["_fedify", "taskDeduplication"],
2476
2675
  ...options.kvPrefixes ?? {}
2477
2676
  };
2478
2677
  if (options.queue == null) {
2479
2678
  this.inboxQueue = void 0;
2480
2679
  this.outboxQueue = void 0;
2481
2680
  this.fanoutQueue = void 0;
2681
+ this.taskQueue = void 0;
2482
2682
  } else if ("enqueue" in options.queue && "listen" in options.queue) {
2483
2683
  this.inboxQueue = options.queue;
2484
2684
  this.outboxQueue = options.queue;
2485
2685
  this.fanoutQueue = options.queue;
2686
+ this.taskQueue = void 0;
2486
2687
  } else {
2487
2688
  this.inboxQueue = options.queue.inbox;
2488
2689
  this.outboxQueue = options.queue.outbox;
2489
2690
  this.fanoutQueue = options.queue.fanout;
2691
+ this.taskQueue = options.queue.task;
2490
2692
  }
2491
2693
  if (options.circuitBreaker !== false && this.outboxQueue != null) {
2492
2694
  this.circuitBreaker = new CircuitBreaker({
@@ -2507,9 +2709,7 @@ var FederationImpl = class extends FederationBuilderImpl {
2507
2709
  ]).warn("The configured key-value store does not support CAS; outbound delivery circuit breaker updates may race under concurrent workers.");
2508
2710
  }
2509
2711
  }
2510
- this.inboxQueueStarted = false;
2511
- this.outboxQueueStarted = false;
2512
- this.fanoutQueueStarted = false;
2712
+ this.startedQueues = /* @__PURE__ */ new Set();
2513
2713
  this.manuallyStartQueue = options.manuallyStartQueue ?? false;
2514
2714
  if (options.origin != null) if (typeof options.origin === "string") {
2515
2715
  if (!URL.canParse(options.origin) || !options.origin.match(/^https?:\/\//)) throw new TypeError(`Invalid origin: ${JSON.stringify(options.origin)}`);
@@ -2591,6 +2791,10 @@ var FederationImpl = class extends FederationBuilderImpl {
2591
2791
  this.inboxChallengePolicy = options.inboxChallengePolicy;
2592
2792
  this.outboxRetryPolicy = options.outboxRetryPolicy ?? createExponentialBackoffPolicy();
2593
2793
  this.inboxRetryPolicy = options.inboxRetryPolicy ?? createExponentialBackoffPolicy();
2794
+ this.taskRetryPolicy = options.taskRetryPolicy ?? createExponentialBackoffPolicy();
2795
+ this.taskQueueResolution = options.taskQueueResolution ?? "fallback";
2796
+ this.taskDeduplicationTtl = Temporal.Duration.from(options.taskDeduplicationTtl ?? { hours: 1 });
2797
+ this.taskDeduplicationFallback = options.taskDeduplicationFallback ?? "open";
2594
2798
  this.activityTransformers = options.activityTransformers ?? getDefaultActivityTransformers();
2595
2799
  this._tracerProvider = options.tracerProvider;
2596
2800
  if (benchmarkMode) {
@@ -2623,6 +2827,9 @@ var FederationImpl = class extends FederationBuilderImpl {
2623
2827
  this.#registerQueueDepthGauge(meterProvider);
2624
2828
  return meterProvider;
2625
2829
  }
2830
+ get metrics() {
2831
+ return getFederationMetrics(this.meterProvider);
2832
+ }
2626
2833
  #registerQueueDepthGauge(meterProvider) {
2627
2834
  if (meterProvider === this.#queueDepthGaugeMeterProvider) return;
2628
2835
  registerQueueDepthGauge(meterProvider, this.#queueDepthGaugeEntries, { sourceId: this.#queueDepthGaugeSourceId });
@@ -2639,35 +2846,38 @@ var FederationImpl = class extends FederationBuilderImpl {
2639
2846
  _getTracer() {
2640
2847
  return this.tracerProvider.getTracer(name, version);
2641
2848
  }
2849
+ resolveTaskQueue(taskName) {
2850
+ const resolved = this.taskDefinitions.get(taskName)?.queue ?? this.taskQueue;
2851
+ if (resolved != null) return resolved;
2852
+ return this.taskQueueResolution === "strict" ? void 0 : this.outboxQueue;
2853
+ }
2642
2854
  async _startQueueInternal(ctxData, signal, queue) {
2643
- if (this.inboxQueue == null && this.outboxQueue == null) return;
2855
+ const taskQueue = this.taskQueue ?? (this.taskQueueResolution === "fallback" ? this.outboxQueue : void 0);
2856
+ const customQueues = this.taskDefinitions.values().map((def) => ["task", def.queue]);
2857
+ const targets = [
2858
+ ["inbox", this.inboxQueue],
2859
+ ["outbox", this.outboxQueue],
2860
+ ["fanout", this.fanoutQueue],
2861
+ ["task", taskQueue],
2862
+ ...customQueues
2863
+ ];
2644
2864
  const logger = getLogger([
2645
2865
  "fedify",
2646
2866
  "federation",
2647
2867
  "queue"
2648
2868
  ]);
2649
2869
  const promises = [];
2650
- if (this.inboxQueue != null && (queue == null || queue === "inbox") && !this.inboxQueueStarted) {
2651
- logger.debug("Starting an inbox task worker.");
2652
- this.inboxQueueStarted = true;
2653
- promises.push(this.inboxQueue.listen((msg) => this.processQueuedTask(ctxData, msg), { signal }));
2654
- }
2655
- if (this.outboxQueue != null && this.outboxQueue !== this.inboxQueue && (queue == null || queue === "outbox") && !this.outboxQueueStarted) {
2656
- logger.debug("Starting an outbox task worker.");
2657
- this.outboxQueueStarted = true;
2658
- promises.push(this.outboxQueue.listen((msg) => this.processQueuedTask(ctxData, msg), { signal }));
2659
- }
2660
- if (this.fanoutQueue != null && this.fanoutQueue !== this.inboxQueue && this.fanoutQueue !== this.outboxQueue && (queue == null || queue === "fanout") && !this.fanoutQueueStarted) {
2661
- logger.debug("Starting a fanout task worker.");
2662
- this.fanoutQueueStarted = true;
2663
- promises.push(this.fanoutQueue.listen((msg) => this.processQueuedTask(ctxData, msg), { signal }));
2870
+ for (const [role, target] of targets) {
2871
+ if (target == null || !(queue == null || queue === role)) continue;
2872
+ if (this.startedQueues.has(target)) continue;
2873
+ this.startedQueues.add(target);
2874
+ logger.debug("Starting a {role} queue worker.", { role });
2875
+ promises.push(target.listen((msg) => this.processQueuedTask(ctxData, msg), { signal }));
2664
2876
  }
2665
2877
  await Promise.all(promises);
2666
2878
  }
2667
2879
  processQueuedTask(contextData, message) {
2668
- const tracer = this._getTracer();
2669
2880
  const extractedContext = propagation.extract(context.active(), message.traceContext);
2670
- const meter = getFederationMetrics(this.meterProvider);
2671
2881
  return withContext({ messageId: message.id }, async () => {
2672
2882
  if (message.type === "fanout") {
2673
2883
  const common = {
@@ -2675,117 +2885,108 @@ var FederationImpl = class extends FederationBuilderImpl {
2675
2885
  queue: this.fanoutQueue,
2676
2886
  activityType: message.activityType
2677
2887
  };
2678
- await tracer.startActiveSpan("activitypub.fanout", {
2679
- kind: SpanKind.CONSUMER,
2680
- attributes: { "activitypub.activity.type": message.activityType }
2681
- }, extractedContext, async (span) => {
2682
- const spanCtx = span.spanContext();
2683
- return await withContext({
2684
- traceId: spanCtx.traceId,
2685
- spanId: spanCtx.spanId
2686
- }, async () => {
2687
- if (message.activityId != null) span.setAttribute("activitypub.activity.id", message.activityId);
2688
- meter.recordQueueTaskStarted(common);
2689
- meter.incrementQueueTaskInFlight(common);
2690
- const startedAt = performance.now();
2691
- let outcome = "completed";
2692
- try {
2693
- await this.#listenFanoutMessage(contextData, message);
2694
- } catch (e) {
2695
- const aborted = isAbortError(e);
2696
- outcome = aborted ? "aborted" : "failed";
2697
- if (!aborted) span.setStatus({
2698
- code: SpanStatusCode.ERROR,
2699
- message: String(e)
2700
- });
2701
- throw e;
2702
- } finally {
2703
- meter.recordQueueTaskOutcome(common, outcome, getDurationMs(startedAt));
2704
- meter.decrementQueueTaskInFlight(common);
2705
- span.end();
2706
- }
2707
- });
2708
- });
2888
+ await this.#runWorkerSpan("activitypub.fanout", { "activitypub.activity.type": message.activityType }, extractedContext, this.#instrumentWorkerBody(common, async (span) => {
2889
+ if (message.activityId != null) span.setAttribute("activitypub.activity.id", message.activityId);
2890
+ await this.#listenFanoutMessage(contextData, message);
2891
+ return { outcome: "completed" };
2892
+ }, classifyAbortableError));
2709
2893
  } else if (message.type === "outbox") {
2710
2894
  const common = {
2711
2895
  role: "outbox",
2712
2896
  queue: this.outboxQueue,
2713
2897
  activityType: message.activityType
2714
2898
  };
2715
- await tracer.startActiveSpan("activitypub.outbox", {
2716
- kind: SpanKind.CONSUMER,
2717
- attributes: {
2718
- "activitypub.activity.type": message.activityType,
2719
- "activitypub.activity.retries": message.attempt
2720
- }
2721
- }, extractedContext, async (span) => {
2722
- const spanCtx = span.spanContext();
2723
- return await withContext({
2724
- traceId: spanCtx.traceId,
2725
- spanId: spanCtx.spanId
2726
- }, async () => {
2727
- if (message.activityId != null) span.setAttribute("activitypub.activity.id", message.activityId);
2728
- meter.recordQueueTaskStarted(common);
2729
- meter.incrementQueueTaskInFlight(common);
2730
- const startedAt = performance.now();
2731
- let outcome = "completed";
2732
- try {
2733
- await this.#listenOutboxMessage(contextData, message, span);
2734
- } catch (e) {
2735
- const aborted = isAbortError(e);
2736
- outcome = aborted ? "aborted" : "failed";
2737
- if (!aborted) span.setStatus({
2738
- code: SpanStatusCode.ERROR,
2739
- message: String(e)
2740
- });
2741
- throw e;
2742
- } finally {
2743
- meter.recordQueueTaskOutcome(common, outcome, getDurationMs(startedAt));
2744
- meter.decrementQueueTaskInFlight(common);
2745
- span.end();
2746
- }
2747
- });
2748
- });
2899
+ await this.#runWorkerSpan("activitypub.outbox", {
2900
+ "activitypub.activity.type": message.activityType,
2901
+ "activitypub.activity.retries": message.attempt
2902
+ }, extractedContext, this.#instrumentWorkerBody(common, async (span) => {
2903
+ if (message.activityId != null) span.setAttribute("activitypub.activity.id", message.activityId);
2904
+ await this.#listenOutboxMessage(contextData, message, span);
2905
+ return { outcome: "completed" };
2906
+ }, classifyAbortableError));
2749
2907
  } else if (message.type === "inbox") {
2750
2908
  const common = {
2751
2909
  role: "inbox",
2752
2910
  queue: this.inboxQueue
2753
2911
  };
2754
- await tracer.startActiveSpan("activitypub.inbox", {
2755
- kind: SpanKind.CONSUMER,
2756
- attributes: { "activitypub.shared_inbox": message.identifier == null }
2757
- }, extractedContext, async (span) => {
2758
- const spanCtx = span.spanContext();
2759
- return await withContext({
2760
- traceId: spanCtx.traceId,
2761
- spanId: spanCtx.spanId
2762
- }, async () => {
2763
- meter.recordQueueTaskStarted(common);
2764
- meter.incrementQueueTaskInFlight(common);
2765
- const startedAt = performance.now();
2766
- let outcome = "completed";
2767
- try {
2768
- await this.#listenInboxMessage(contextData, message, span, (activityType) => {
2769
- common.activityType = activityType;
2770
- });
2771
- } catch (e) {
2772
- const aborted = isAbortError(e);
2773
- outcome = aborted ? "aborted" : "failed";
2774
- if (!aborted) span.setStatus({
2775
- code: SpanStatusCode.ERROR,
2776
- message: String(e)
2777
- });
2778
- throw e;
2779
- } finally {
2780
- meter.recordQueueTaskOutcome(common, outcome, getDurationMs(startedAt));
2781
- meter.decrementQueueTaskInFlight(common);
2782
- span.end();
2783
- }
2912
+ await this.#runWorkerSpan("activitypub.inbox", { "activitypub.shared_inbox": message.identifier == null }, extractedContext, this.#instrumentWorkerBody(common, async (span) => {
2913
+ await this.#listenInboxMessage(contextData, message, span, (activityType) => {
2914
+ common.activityType = activityType;
2784
2915
  });
2785
- });
2916
+ return { outcome: "completed" };
2917
+ }, classifyAbortableError));
2918
+ } else if (message.type === "task") {
2919
+ const registered = this.taskDefinitions.get(message.taskName) != null;
2920
+ const common = {
2921
+ role: "task",
2922
+ queue: this.resolveTaskQueue(message.taskName),
2923
+ taskName: registered ? message.taskName : void 0
2924
+ };
2925
+ await this.#runWorkerSpan("fedify.task", {
2926
+ "fedify.task.name": message.taskName,
2927
+ "fedify.task.attempt": message.attempt
2928
+ }, extractedContext, this.#instrumentWorkerBody(common, () => this.#listenTaskMessage(contextData, message), classifyTaskError));
2786
2929
  }
2787
2930
  });
2788
2931
  }
2932
+ /**
2933
+ * Opens a CONSUMER span for a queue worker and scopes the extracted trace
2934
+ * context around its body. The body—typically built by
2935
+ * {@link FederationImpl.instrumentWorkerBody}—receives the span and returns
2936
+ * the context-scoped worker callback.
2937
+ * @param spanName The consumer span name (for example, `"fedify.task"`).
2938
+ * @param spanAttributes The initial span attributes.
2939
+ * @param extractedContext The trace context extracted from the message.
2940
+ * @param instrumentedBody Builds the context-scoped worker callback for the
2941
+ * opened span.
2942
+ */
2943
+ async #runWorkerSpan(spanName, spanAttributes, extractedContext, instrumentedBody) {
2944
+ await this._getTracer().startActiveSpan(spanName, {
2945
+ kind: SpanKind.CONSUMER,
2946
+ attributes: spanAttributes
2947
+ }, extractedContext, async (span) => {
2948
+ const spanCtx = span.spanContext();
2949
+ return await withContext({
2950
+ traceId: spanCtx.traceId,
2951
+ spanId: spanCtx.spanId
2952
+ }, instrumentedBody(span));
2953
+ });
2954
+ }
2955
+ /**
2956
+ * Wraps a worker body with the shared `fedify.queue.task.*` boundary
2957
+ * telemetry, returning the instrumented body keyed by its span. The
2958
+ * instrumentation records the started, outcome, and duration metrics, pairs
2959
+ * the in-flight increment with its decrement, sets the span's error status
2960
+ * from the terminal outcome, and ends the span. The fanout, outbox, inbox,
2961
+ * and task workers differ only in `common`, the body, and how a thrown error
2962
+ * maps to an outcome.
2963
+ * @param common The shared queue-task metric attributes for this worker.
2964
+ * @param run Runs the worker body and resolves to its terminal outcome.
2965
+ * @param classifyError Maps a thrown error to a terminal outcome.
2966
+ */
2967
+ #instrumentWorkerBody = (common, run, classifyError) => (span) => async () => {
2968
+ this.metrics.recordQueueTaskStarted(common);
2969
+ this.metrics.incrementQueueTaskInFlight(common);
2970
+ const startedAt = performance.now();
2971
+ let result = { outcome: "completed" };
2972
+ try {
2973
+ result = await run(span);
2974
+ } catch (e) {
2975
+ result = classifyError(e);
2976
+ throw e;
2977
+ } finally {
2978
+ if (result.outcome === "failed") {
2979
+ if (result.failureReason != null) span.setAttribute("fedify.task.failure_reason", result.failureReason);
2980
+ span.setStatus({
2981
+ code: SpanStatusCode.ERROR,
2982
+ ...result.error == null ? {} : { message: String(result.error) }
2983
+ });
2984
+ }
2985
+ this.metrics.recordQueueTaskOutcome(common, result.outcome, getDurationMs(startedAt), result.outcome === "failed" ? result.failureReason : void 0);
2986
+ this.metrics.decrementQueueTaskInFlight(common);
2987
+ span.end();
2988
+ }
2989
+ };
2789
2990
  async #listenFanoutMessage(data, message) {
2790
2991
  getLogger([
2791
2992
  "fedify",
@@ -2875,7 +3076,7 @@ var FederationImpl = class extends FederationBuilderImpl {
2875
3076
  delay: clampNegativeDelay(delay),
2876
3077
  orderingKey: message.orderingKey
2877
3078
  });
2878
- getFederationMetrics(this.meterProvider).recordQueueTaskEnqueued({
3079
+ this.metrics.recordQueueTaskEnqueued({
2879
3080
  role: "outbox",
2880
3081
  queue: outboxQueue,
2881
3082
  activityType: heldMessage.activityType
@@ -3054,7 +3255,7 @@ var FederationImpl = class extends FederationBuilderImpl {
3054
3255
  return;
3055
3256
  }
3056
3257
  if (isPermanentFailure) {
3057
- getFederationMetrics(this.meterProvider).recordPermanentFailure(error.inbox, error.statusCode);
3258
+ this.metrics.recordPermanentFailure(error.inbox, error.statusCode);
3058
3259
  logger.warn("Permanent delivery failure for activity {activityId} to {inbox} ({status}); not retrying.", {
3059
3260
  ...logData,
3060
3261
  status: error.statusCode
@@ -3114,7 +3315,7 @@ var FederationImpl = class extends FederationBuilderImpl {
3114
3315
  delay: clampNegativeDelay(delay),
3115
3316
  orderingKey: message.orderingKey
3116
3317
  });
3117
- getFederationMetrics(this.meterProvider).recordQueueTaskEnqueued({
3318
+ this.metrics.recordQueueTaskEnqueued({
3118
3319
  role: "outbox",
3119
3320
  queue: outboxQueue,
3120
3321
  activityType: retryMessage.activityType
@@ -3204,7 +3405,7 @@ var FederationImpl = class extends FederationBuilderImpl {
3204
3405
  };
3205
3406
  await this.inboxQueue.enqueue(retryMessage, { delay: clampNegativeDelay(delay) });
3206
3407
  if (activityType != null) {
3207
- getFederationMetrics(this.meterProvider).recordQueueTaskEnqueued({
3408
+ this.metrics.recordQueueTaskEnqueued({
3208
3409
  role: "inbox",
3209
3410
  queue: this.inboxQueue,
3210
3411
  activityType
@@ -3331,7 +3532,7 @@ var FederationImpl = class extends FederationBuilderImpl {
3331
3532
  try {
3332
3533
  await listener(context.toInboxContext(message.identifier, message.activity, activity.id?.href, activityType), activity);
3333
3534
  } finally {
3334
- getFederationMetrics(this.meterProvider).recordInboxProcessingDuration(activityType, getDurationMs(started));
3535
+ this.metrics.recordInboxProcessingDuration(activityType, getDurationMs(started));
3335
3536
  }
3336
3537
  recordInboxActivity(this.meterProvider, "processed", activityType);
3337
3538
  } catch (error) {
@@ -3347,6 +3548,105 @@ var FederationImpl = class extends FederationBuilderImpl {
3347
3548
  listenerSpan.end();
3348
3549
  });
3349
3550
  }
3551
+ async #listenTaskMessage(contextData, message) {
3552
+ const logger = getLogger([
3553
+ "fedify",
3554
+ "federation",
3555
+ "task"
3556
+ ]);
3557
+ const def = this.taskDefinitions.get(message.taskName);
3558
+ if (def == null) {
3559
+ logger.warn("Received a custom task {taskName} with no registered handler; dropping.", { taskName: message.taskName });
3560
+ return {
3561
+ outcome: "failed",
3562
+ failureReason: "unknown_task"
3563
+ };
3564
+ }
3565
+ const context = this.#createContext(new URL(message.baseUrl), contextData);
3566
+ const data = await context.codec.decode(def.schema, message.data);
3567
+ if (!data.ok) {
3568
+ if (data.phase === "deserialization") logger.error("Custom task {taskName} payload could not be deserialized; dropping:\n{error}", {
3569
+ taskName: message.taskName,
3570
+ error: data.error
3571
+ });
3572
+ else logger.error("Custom task {taskName} payload failed schema validation; dropping:\n{error}", {
3573
+ taskName: message.taskName,
3574
+ error: data.error
3575
+ });
3576
+ return {
3577
+ outcome: "failed",
3578
+ failureReason: data.phase
3579
+ };
3580
+ }
3581
+ try {
3582
+ await def.handler(context, data.value);
3583
+ return { outcome: "completed" };
3584
+ } catch (error) {
3585
+ if (def.onError != null) try {
3586
+ await def.onError(context, error, data.value);
3587
+ } catch (onErrorError) {
3588
+ logger.error("onError for custom task {taskName} threw:\n{error}", {
3589
+ taskName: message.taskName,
3590
+ error: onErrorError
3591
+ });
3592
+ }
3593
+ const queue = this.resolveTaskQueue(def.name);
3594
+ if (queue?.nativeRetrial) throw error;
3595
+ const retryPolicy = def.retryPolicy ?? this.taskRetryPolicy;
3596
+ let elapsedTime = Temporal.Duration.from({ seconds: 0 });
3597
+ try {
3598
+ elapsedTime = Temporal.Instant.from(message.started).until(Temporal.Now.instant());
3599
+ } catch (parseError) {
3600
+ logger.error("Custom task {taskName} has an unparsable started time {started}; treating elapsedTime as zero:\n{error}", {
3601
+ taskName: message.taskName,
3602
+ started: message.started,
3603
+ error: parseError
3604
+ });
3605
+ }
3606
+ const delay = retryPolicy({
3607
+ elapsedTime,
3608
+ attempts: message.attempt
3609
+ });
3610
+ if (delay != null && queue != null) {
3611
+ logger.error("Custom task {taskName} failed (attempt #{attempt}); retry...:\n{error}", {
3612
+ taskName: message.taskName,
3613
+ attempt: message.attempt,
3614
+ error
3615
+ });
3616
+ const retryMessage = {
3617
+ ...message,
3618
+ attempt: message.attempt + 1
3619
+ };
3620
+ try {
3621
+ await queue.enqueue(retryMessage, {
3622
+ delay: clampNegativeDelay(delay),
3623
+ orderingKey: message.orderingKey
3624
+ });
3625
+ } catch (enqueueError) {
3626
+ logger.error("Custom task {taskName} could not be re-enqueued for a retry (attempt #{attempt}):\n{error}", {
3627
+ taskName: message.taskName,
3628
+ attempt: retryMessage.attempt,
3629
+ error: enqueueError
3630
+ });
3631
+ throw new TaskRetryEnqueueError(error);
3632
+ }
3633
+ this.metrics.recordQueueTaskEnqueued({
3634
+ role: "task",
3635
+ queue,
3636
+ taskName: message.taskName
3637
+ }, retryMessage.attempt);
3638
+ return { outcome: "completed" };
3639
+ } else logger.error("Custom task {taskName} failed after {attempt} attempts; giving up:\n{error}", {
3640
+ taskName: message.taskName,
3641
+ attempt: message.attempt,
3642
+ error
3643
+ });
3644
+ return isAbortError(error) ? { outcome: "aborted" } : {
3645
+ outcome: "failed",
3646
+ failureReason: "handler"
3647
+ };
3648
+ }
3649
+ }
3350
3650
  startQueue(contextData, options = {}) {
3351
3651
  return this._startQueueInternal(contextData, options.signal, options.queue);
3352
3652
  }
@@ -3580,7 +3880,7 @@ var FederationImpl = class extends FederationBuilderImpl {
3580
3880
  });
3581
3881
  if (acceptsJsonLd(request)) response.headers.set("Vary", "Accept");
3582
3882
  } catch (error) {
3583
- getFederationMetrics(this.meterProvider).recordHttpServerRequest(request.method, metricState.endpoint ?? "error", getDurationMs(metricStart), { routeTemplate: metricState.routeTemplate });
3883
+ this.metrics.recordHttpServerRequest(request.method, metricState.endpoint ?? "error", getDurationMs(metricStart), { routeTemplate: metricState.routeTemplate });
3584
3884
  span.setStatus({
3585
3885
  code: SpanStatusCode.ERROR,
3586
3886
  message: `${error}`
@@ -3593,7 +3893,7 @@ var FederationImpl = class extends FederationBuilderImpl {
3593
3893
  });
3594
3894
  throw error;
3595
3895
  }
3596
- getFederationMetrics(this.meterProvider).recordHttpServerRequest(request.method, metricState.endpoint ?? "error", getDurationMs(metricStart), {
3896
+ this.metrics.recordHttpServerRequest(request.method, metricState.endpoint ?? "error", getDurationMs(metricStart), {
3597
3897
  statusCode: response.status,
3598
3898
  routeTemplate: metricState.routeTemplate
3599
3899
  });
@@ -3931,6 +4231,7 @@ var ContextImpl = class ContextImpl {
3931
4231
  documentLoader;
3932
4232
  contextLoader;
3933
4233
  invokedFromActorKeyPairsDispatcher;
4234
+ #codec;
3934
4235
  constructor({ url, federation, data, documentLoader, contextLoader, invokedFromActorKeyPairsDispatcher }) {
3935
4236
  this.url = url;
3936
4237
  this.federation = federation;
@@ -3939,6 +4240,18 @@ var ContextImpl = class ContextImpl {
3939
4240
  this.contextLoader = contextLoader;
3940
4241
  this.invokedFromActorKeyPairsDispatcher = invokedFromActorKeyPairsDispatcher;
3941
4242
  }
4243
+ /**
4244
+ * A {@link TaskCodec} bound to this context's loaders, used to encode
4245
+ * and decode custom task payloads. Lazily created and cached so a context
4246
+ * that never enqueues or dispatches a task pays nothing.
4247
+ * @internal
4248
+ */
4249
+ get codec() {
4250
+ return this.#codec ??= new TaskCodec(this);
4251
+ }
4252
+ get #enqueueTasks() {
4253
+ return enqueueTasks(this);
4254
+ }
3942
4255
  clone(data) {
3943
4256
  return new ContextImpl({
3944
4257
  url: this.url,
@@ -4257,6 +4570,12 @@ var ContextImpl = class ContextImpl {
4257
4570
  allowPrivateAddress: this.federation.allowPrivateAddress
4258
4571
  });
4259
4572
  }
4573
+ async enqueueTask(task, data, options = {}) {
4574
+ await this.#enqueueTasks(task, [data], options);
4575
+ }
4576
+ async enqueueTaskMany(task, payloads, options = {}) {
4577
+ await this.#enqueueTasks(task, payloads, options);
4578
+ }
4260
4579
  sendActivity(sender, recipients, activity, options = {}) {
4261
4580
  return this.tracerProvider.getTracer(name, version).startActiveSpan(this.federation.outboxQueue == null || options.immediate ? "activitypub.outbox" : "activitypub.fanout", {
4262
4581
  kind: this.federation.outboxQueue == null || options.immediate ? SpanKind.CLIENT : SpanKind.PRODUCER,