@fedify/fedify 1.5.0-dev.718 → 1.5.0-dev.730

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGES.md CHANGED
@@ -8,6 +8,18 @@ Version 1.5.0
8
8
 
9
9
  To be released.
10
10
 
11
+ - Improved activity delivery performance with large audiences through
12
+ a two-stage queuing system. Sending activities to many recipients
13
+ (e.g., accounts with many followers) is now significantly faster and uses
14
+ less memory. [[#220]]
15
+
16
+ - Added `FederationQueueOptions.fanout` option.
17
+ - Changed the type of `FederationStartQueueOptions.queue` option to
18
+ `"inbox" | "outbox" | "fanout" | undefined` (was `"inbox" | "outbox" |
19
+ undefined`).
20
+ - Added `SendActivityOptions.fanout` option.
21
+ - Added OpenTelemetry instrumented span `activitypub.fanout`.
22
+
11
23
  - A `Federation` object now can have a canonical origin for web URLs and
12
24
  a canonical host for fediverse handles. This affects the URLs constructed
13
25
  by `Context` objects, and the WebFinger responses.
@@ -65,15 +77,30 @@ To be released.
65
77
  - Added more log messages using the [LogTape] library. Currently the below
66
78
  logger categories are used:
67
79
 
80
+ - `["fedify", "federation", "fanout"]`
68
81
  - `["fedify", "federation", "object"]`
69
82
 
70
83
  [#127]: https://github.com/fedify-dev/fedify/issues/127
71
84
  [#209]: https://github.com/fedify-dev/fedify/issues/209
72
85
  [#211]: https://github.com/fedify-dev/fedify/issues/211
73
86
  [#215]: https://github.com/fedify-dev/fedify/pull/215
87
+ [#220]: https://github.com/fedify-dev/fedify/issues/220
74
88
  [multibase]: https://github.com/multiformats/js-multibase
75
89
 
76
90
 
91
+ Version 1.4.7
92
+ -------------
93
+
94
+ Released on March 20, 2025.
95
+
96
+ - Fixed a bug of WebFinger handler where it had failed to match
97
+ `acct:` URIs with a host having a port number.
98
+ [[#218], [#219] by Revath S Kumar]
99
+
100
+ - Fixed a server error thrown when an invalid URL was passed to the `base-url`
101
+ parameter of the followers collection. [[#217]]
102
+
103
+
77
104
  Version 1.4.6
78
105
  -------------
79
106
 
@@ -237,6 +264,19 @@ Released on February 5, 2025.
237
264
  [#195]: https://github.com/fedify-dev/fedify/issues/195
238
265
 
239
266
 
267
+ Version 1.3.14
268
+ --------------
269
+
270
+ Released on March 20, 2025.
271
+
272
+ - Fixed a bug of WebFinger handler where it had failed to match
273
+ `acct:` URIs with a host having a port number.
274
+ [[#218], [#219] by Revath S Kumar]
275
+
276
+ - Fixed a server error thrown when an invalid URL was passed to the `base-url`
277
+ parameter of the followers collection. [[#217]]
278
+
279
+
240
280
  Version 1.3.13
241
281
  --------------
242
282
 
@@ -516,6 +556,19 @@ Released on November 30, 2024.
516
556
  [#193]: https://github.com/fedify-dev/fedify/issues/193
517
557
 
518
558
 
559
+ Version 1.2.18
560
+ --------------
561
+
562
+ Released on March 20, 2025.
563
+
564
+ - Fixed a bug of WebFinger handler where it had failed to match
565
+ `acct:` URIs with a host having a port number.
566
+ [[#218], [#219] by Revath S Kumar]
567
+
568
+ - Fixed a server error thrown when an invalid URL was passed to the `base-url`
569
+ parameter of the followers collection. [[#217]]
570
+
571
+
519
572
  Version 1.2.17
520
573
  --------------
521
574
 
@@ -844,6 +897,19 @@ Released on October 31, 2024.
844
897
  [#118]: https://github.com/fedify-dev/fedify/issues/118
845
898
 
846
899
 
900
+ Version 1.1.18
901
+ --------------
902
+
903
+ Released on March 20, 2025.
904
+
905
+ - Fixed a bug of WebFinger handler where it had failed to match
906
+ `acct:` URIs with a host having a port number.
907
+ [[#218], [#219] by Revath S Kumar]
908
+
909
+ - Fixed a server error thrown when an invalid URL was passed to the `base-url`
910
+ parameter of the followers collection. [[#217]]
911
+
912
+
847
913
  Version 1.1.17
848
914
  --------------
849
915
 
@@ -1213,6 +1279,23 @@ Released on October 20, 2024.
1213
1279
  [#150]: https://github.com/fedify-dev/fedify/issues/150
1214
1280
 
1215
1281
 
1282
+ Version 1.0.21
1283
+ --------------
1284
+
1285
+ Released on March 20, 2025.
1286
+
1287
+ - Fixed a bug of WebFinger handler where it had failed to match
1288
+ `acct:` URIs with a host having a port number.
1289
+ [[#218], [#219] by Revath S Kumar]
1290
+
1291
+ - Fixed a server error thrown when an invalid URL was passed to the `base-url`
1292
+ parameter of the followers collection. [[#217]]
1293
+
1294
+ [#217]: https://github.com/fedify-dev/fedify/issues/217
1295
+ [#218]: https://github.com/fedify-dev/fedify/issues/218
1296
+ [#219]: https://github.com/fedify-dev/fedify/pull/219
1297
+
1298
+
1216
1299
  Version 1.0.20
1217
1300
  --------------
1218
1301
 
@@ -3378,4 +3461,4 @@ Version 0.1.0
3378
3461
  Initial release. Released on March 8, 2024.
3379
3462
 
3380
3463
  <!-- cSpell: ignore Dogeon Fabien Wressell Emelia Fróði Karlsson -->
3381
- <!-- cSpell: ignore Hana Heesun Kyunghee Jiyu -->
3464
+ <!-- cSpell: ignore Hana Heesun Kyunghee Jiyu Revath Kumar -->
package/esm/deno.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export default {
2
2
  "name": "@fedify/fedify",
3
- "version": "1.5.0-dev.718+37c51836",
3
+ "version": "1.5.0-dev.730+390610bf",
4
4
  "license": "MIT",
5
5
  "exports": {
6
6
  ".": "./mod.ts",
@@ -37,8 +37,10 @@ export class FederationImpl {
37
37
  kvPrefixes;
38
38
  inboxQueue;
39
39
  outboxQueue;
40
+ fanoutQueue;
40
41
  inboxQueueStarted;
41
42
  outboxQueueStarted;
43
+ fanoutQueueStarted;
42
44
  manuallyStartQueue;
43
45
  origin;
44
46
  router;
@@ -83,17 +85,21 @@ export class FederationImpl {
83
85
  if (options.queue == null) {
84
86
  this.inboxQueue = undefined;
85
87
  this.outboxQueue = undefined;
88
+ this.fanoutQueue = undefined;
86
89
  }
87
90
  else if ("enqueue" in options.queue && "listen" in options.queue) {
88
91
  this.inboxQueue = options.queue;
89
92
  this.outboxQueue = options.queue;
93
+ this.fanoutQueue = options.queue;
90
94
  }
91
95
  else {
92
96
  this.inboxQueue = options.queue.inbox;
93
97
  this.outboxQueue = options.queue.outbox;
98
+ this.fanoutQueue = options.queue.fanout;
94
99
  }
95
100
  this.inboxQueueStarted = false;
96
101
  this.outboxQueueStarted = false;
102
+ this.fanoutQueueStarted = false;
97
103
  this.manuallyStartQueue = options.manuallyStartQueue ?? false;
98
104
  if (options.origin != null) {
99
105
  if (typeof options.origin === "string") {
@@ -206,7 +212,7 @@ export class FederationImpl {
206
212
  #getTracer() {
207
213
  return this.tracerProvider.getTracer(metadata.name, metadata.version);
208
214
  }
209
- async #startQueue(ctxData, signal, queue) {
215
+ async _startQueueInternal(ctxData, signal, queue) {
210
216
  if (this.inboxQueue == null && this.outboxQueue == null)
211
217
  return;
212
218
  const logger = getLogger(["fedify", "federation", "queue"]);
@@ -225,13 +231,47 @@ export class FederationImpl {
225
231
  this.outboxQueueStarted = true;
226
232
  promises.push(this.outboxQueue.listen((msg) => this.#listenQueue(ctxData, msg), { signal }));
227
233
  }
234
+ if (this.fanoutQueue != null &&
235
+ this.fanoutQueue !== this.inboxQueue &&
236
+ this.fanoutQueue !== this.outboxQueue &&
237
+ (queue == null || queue === "fanout") &&
238
+ !this.fanoutQueueStarted) {
239
+ logger.debug("Starting a fanout task worker.");
240
+ this.fanoutQueueStarted = true;
241
+ promises.push(this.fanoutQueue.listen((msg) => this.#listenQueue(ctxData, msg), { signal }));
242
+ }
228
243
  await Promise.all(promises);
229
244
  }
230
245
  #listenQueue(ctxData, message) {
231
246
  const tracer = this.#getTracer();
232
247
  const extractedContext = propagation.extract(context.active(), message.traceContext);
233
248
  return withContext({ messageId: message.id }, async () => {
234
- if (message.type === "outbox") {
249
+ if (message.type === "fanout") {
250
+ await tracer.startActiveSpan("activitypub.fanout", {
251
+ kind: SpanKind.CONSUMER,
252
+ attributes: {
253
+ "activitypub.activity.type": message.activityType,
254
+ },
255
+ }, extractedContext, async (span) => {
256
+ if (message.activityId != null) {
257
+ span.setAttribute("activitypub.activity.id", message.activityId);
258
+ }
259
+ try {
260
+ await this.#listenFanoutMessage(ctxData, message);
261
+ }
262
+ catch (e) {
263
+ span.setStatus({
264
+ code: SpanStatusCode.ERROR,
265
+ message: String(e),
266
+ });
267
+ throw e;
268
+ }
269
+ finally {
270
+ span.end();
271
+ }
272
+ });
273
+ }
274
+ else if (message.type === "outbox") {
235
275
  await tracer.startActiveSpan("activitypub.outbox", {
236
276
  kind: SpanKind.CONSUMER,
237
277
  attributes: {
@@ -281,6 +321,33 @@ export class FederationImpl {
281
321
  }
282
322
  });
283
323
  }
324
+ async #listenFanoutMessage(data, message) {
325
+ const keys = await Promise.all(message.keys.map(async ({ keyId, privateKey }) => ({
326
+ keyId: new URL(keyId),
327
+ privateKey: await importJwk(privateKey, "private"),
328
+ })));
329
+ const activity = await Activity.fromJsonLd(message.activity, {
330
+ contextLoader: this.contextLoaderFactory({
331
+ allowPrivateAddress: this.allowPrivateAddress,
332
+ userAgent: this.userAgent,
333
+ }),
334
+ documentLoader: this.documentLoaderFactory({
335
+ allowPrivateAddress: this.allowPrivateAddress,
336
+ userAgent: this.userAgent,
337
+ }),
338
+ tracerProvider: this.tracerProvider,
339
+ });
340
+ const context = this.#createContext(new URL(message.baseUrl), data, {
341
+ documentLoader: this.documentLoaderFactory({
342
+ allowPrivateAddress: this.allowPrivateAddress,
343
+ userAgent: this.userAgent,
344
+ }),
345
+ });
346
+ await this.sendActivity(keys, message.inboxes, activity, {
347
+ collectionSync: message.collectionSync,
348
+ context,
349
+ });
350
+ }
284
351
  async #listenOutboxMessage(_, message, span) {
285
352
  const logger = getLogger(["fedify", "federation", "outbox"]);
286
353
  const logData = {
@@ -485,7 +552,7 @@ export class FederationImpl {
485
552
  });
486
553
  }
487
554
  startQueue(contextData, options = {}) {
488
- return this.#startQueue(contextData, options.signal, options.queue);
555
+ return this._startQueueInternal(contextData, options.signal, options.queue);
489
556
  }
490
557
  createContext(urlOrRequest, contextData) {
491
558
  return urlOrRequest instanceof Request
@@ -1118,33 +1185,9 @@ export class FederationImpl {
1118
1185
  };
1119
1186
  return setters;
1120
1187
  }
1121
- async sendActivity(keys, recipients, activity, options, span) {
1188
+ async sendActivity(keys, inboxes, activity, options) {
1122
1189
  const logger = getLogger(["fedify", "federation", "outbox"]);
1123
- const { preferSharedInbox, immediate, excludeBaseUris, collectionSync, context: ctx, } = options;
1124
- if (keys.length < 1) {
1125
- throw new TypeError("The sender's keys must not be empty.");
1126
- }
1127
- for (const { privateKey } of keys) {
1128
- validateCryptoKey(privateKey, "private");
1129
- }
1130
- for (const activityTransformer of this.activityTransformers) {
1131
- activity = activityTransformer(activity, ctx);
1132
- }
1133
- span?.setAttribute("activitypub.activity.id", activity?.id?.href ?? "");
1134
- if (activity.actorId == null) {
1135
- logger.error("Activity {activityId} to send does not have an actor.", { activity, activityId: activity?.id?.href });
1136
- throw new TypeError("The activity to send must have at least one actor property.");
1137
- }
1138
- const inboxes = extractInboxes({
1139
- recipients: Array.isArray(recipients) ? recipients : [recipients],
1140
- preferSharedInbox,
1141
- excludeBaseUris,
1142
- });
1143
- logger.debug("Sending activity {activityId} to inboxes:\n{inboxes}", {
1144
- inboxes: globalThis.Object.keys(inboxes),
1145
- activityId: activity.id?.href,
1146
- activity,
1147
- });
1190
+ const { immediate, collectionSync, context: ctx } = options;
1148
1191
  if (activity.id == null) {
1149
1192
  throw new TypeError("The activity to send must have an id.");
1150
1193
  }
@@ -1238,7 +1281,7 @@ export class FederationImpl {
1238
1281
  keyJwkPairs.push({ keyId: keyId.href, privateKey: privateKeyJwk });
1239
1282
  }
1240
1283
  if (!this.manuallyStartQueue)
1241
- this.#startQueue(ctx.data);
1284
+ this._startQueueInternal(ctx.data);
1242
1285
  const carrier = {};
1243
1286
  propagation.inject(context.active(), carrier);
1244
1287
  const promises = [];
@@ -1441,7 +1484,7 @@ export class FederationImpl {
1441
1484
  }
1442
1485
  }
1443
1486
  if (!this.manuallyStartQueue)
1444
- this.#startQueue(contextData);
1487
+ this._startQueueInternal(contextData);
1445
1488
  return await handleInbox(request, {
1446
1489
  recipient: route.values.identifier ?? route.values.handle ?? null,
1447
1490
  context,
@@ -1472,8 +1515,13 @@ export class FederationImpl {
1472
1515
  case "followers": {
1473
1516
  let baseUrl = url.searchParams.get("base-url");
1474
1517
  if (baseUrl != null) {
1475
- const u = new URL(baseUrl);
1476
- baseUrl = `${u.origin}/`;
1518
+ try {
1519
+ baseUrl = `${new URL(baseUrl).origin}/`;
1520
+ }
1521
+ catch {
1522
+ // If base-url is invalid, set to null to behave as if it wasn't provided
1523
+ baseUrl = null;
1524
+ }
1477
1525
  }
1478
1526
  return await handleCollection(request, {
1479
1527
  name: "followers",
@@ -1534,6 +1582,7 @@ export class FederationImpl {
1534
1582
  }
1535
1583
  }
1536
1584
  }
1585
+ const FANOUT_THRESHOLD = 5;
1537
1586
  export class ContextImpl {
1538
1587
  url;
1539
1588
  federation;
@@ -1941,7 +1990,9 @@ export class ContextImpl {
1941
1990
  }
1942
1991
  sendActivity(sender, recipients, activity, options = {}) {
1943
1992
  const tracer = this.tracerProvider.getTracer(metadata.name, metadata.version);
1944
- return tracer.startActiveSpan("activitypub.outbox", {
1993
+ return tracer.startActiveSpan(this.federation.outboxQueue == null || options.immediate
1994
+ ? "activitypub.outbox"
1995
+ : "activitypub.fanout", {
1945
1996
  kind: this.federation.outboxQueue == null || options.immediate
1946
1997
  ? SpanKind.CLIENT
1947
1998
  : SpanKind.PRODUCER,
@@ -1969,6 +2020,7 @@ export class ContextImpl {
1969
2020
  });
1970
2021
  }
1971
2022
  async sendActivityInternal(sender, recipients, activity, options = {}, span) {
2023
+ const logger = getLogger(["fedify", "federation", "outbox"]);
1972
2024
  let keys;
1973
2025
  let identifier = null;
1974
2026
  if ("identifier" in sender || "username" in sender || "handle" in sender) {
@@ -1982,7 +2034,7 @@ export class ContextImpl {
1982
2034
  }
1983
2035
  else {
1984
2036
  username = sender.handle;
1985
- getLogger(["fedify", "federation", "outbox"]).warn('The "handle" property for the sender parameter is deprecated; ' +
2037
+ logger.warn('The "handle" property for the sender parameter is deprecated; ' +
1986
2038
  'use "identifier" or "username" instead.', { sender });
1987
2039
  }
1988
2040
  if (this.federation.actorCallbacks?.handleMapper == null) {
@@ -2011,10 +2063,13 @@ export class ContextImpl {
2011
2063
  else {
2012
2064
  keys = [sender];
2013
2065
  }
2014
- const opts = {
2015
- context: this,
2016
- ...options,
2017
- };
2066
+ if (keys.length < 1) {
2067
+ throw new TypeError("The sender's keys must not be empty.");
2068
+ }
2069
+ for (const { privateKey } of keys) {
2070
+ validateCryptoKey(privateKey, "private");
2071
+ }
2072
+ const opts = { context: this };
2018
2073
  let expandedRecipients;
2019
2074
  if (Array.isArray(recipients)) {
2020
2075
  expandedRecipients = recipients;
@@ -2037,7 +2092,55 @@ export class ContextImpl {
2037
2092
  expandedRecipients = [recipients];
2038
2093
  }
2039
2094
  span.setAttribute("activitypub.inboxes", expandedRecipients.length);
2040
- return await this.federation.sendActivity(keys, expandedRecipients, activity, opts, span);
2095
+ for (const activityTransformer of this.federation.activityTransformers) {
2096
+ activity = activityTransformer(activity, this);
2097
+ }
2098
+ span?.setAttribute("activitypub.activity.id", activity?.id?.href ?? "");
2099
+ if (activity.actorId == null) {
2100
+ logger.error("Activity {activityId} to send does not have an actor.", { activity, activityId: activity?.id?.href });
2101
+ throw new TypeError("The activity to send must have at least one actor property.");
2102
+ }
2103
+ const inboxes = extractInboxes({
2104
+ recipients: expandedRecipients,
2105
+ preferSharedInbox: options.preferSharedInbox,
2106
+ excludeBaseUris: options.excludeBaseUris,
2107
+ });
2108
+ logger.debug("Sending activity {activityId} to inboxes:\n{inboxes}", {
2109
+ inboxes: globalThis.Object.keys(inboxes),
2110
+ activityId: activity.id?.href,
2111
+ activity,
2112
+ });
2113
+ if (this.federation.fanoutQueue == null || options.immediate ||
2114
+ options.fanout === "skip" || (options.fanout ?? "auto") === "auto" &&
2115
+ globalThis.Object.keys(inboxes).length < FANOUT_THRESHOLD) {
2116
+ await this.federation.sendActivity(keys, inboxes, activity, opts);
2117
+ return;
2118
+ }
2119
+ const keyJwkPairs = await Promise.all(keys.map(async ({ keyId, privateKey }) => ({
2120
+ keyId: keyId.href,
2121
+ privateKey: await exportJwk(privateKey),
2122
+ })));
2123
+ const carrier = {};
2124
+ propagation.inject(context.active(), carrier);
2125
+ const message = {
2126
+ type: "fanout",
2127
+ id: dntShim.crypto.randomUUID(),
2128
+ baseUrl: this.origin,
2129
+ keys: keyJwkPairs,
2130
+ inboxes: globalThis.Object.fromEntries(globalThis.Object.entries(inboxes).map(([k, { actorIds, sharedInbox }]) => [k, { actorIds: [...actorIds], sharedInbox }])),
2131
+ activity: await activity.toJsonLd({
2132
+ format: "compact",
2133
+ contextLoader: this.contextLoader,
2134
+ }),
2135
+ activityId: activity.id?.href,
2136
+ activityType: getTypeId(activity).href,
2137
+ collectionSync: opts.collectionSync,
2138
+ traceContext: carrier,
2139
+ };
2140
+ if (!this.federation.manuallyStartQueue) {
2141
+ this.federation._startQueueInternal(this.data);
2142
+ }
2143
+ this.federation.fanoutQueue.enqueue(message);
2041
2144
  }
2042
2145
  async *getFollowers(identifier) {
2043
2146
  if (this.federation.followersCallbacks == null) {