@fedify/fedify 1.5.0-dev.729 → 1.5.0-dev.731
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 +17 -0
- package/esm/deno.js +1 -1
- package/esm/federation/middleware.js +141 -38
- package/esm/vocab/vocab.js +176 -176
- package/package.json +1 -1
- package/types/federation/context.d.ts +19 -2
- package/types/federation/context.d.ts.map +1 -1
- package/types/federation/federation.d.ts +3 -2
- package/types/federation/federation.d.ts.map +1 -1
- package/types/federation/middleware.d.ts +16 -2
- package/types/federation/middleware.d.ts.map +1 -1
- package/types/federation/queue.d.ts +16 -1
- package/types/federation/queue.d.ts.map +1 -1
package/CHANGES.md
CHANGED
@@ -8,6 +8,21 @@ 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
|
+
- The `ForwardActivityOptions` interface became a type alias of
|
23
|
+
`Omit<SendActivityOptions, "fanout"> & { skipIfUnsigned: boolean }`,
|
24
|
+
which is still compatible with the previous version.
|
25
|
+
|
11
26
|
- A `Federation` object now can have a canonical origin for web URLs and
|
12
27
|
a canonical host for fediverse handles. This affects the URLs constructed
|
13
28
|
by `Context` objects, and the WebFinger responses.
|
@@ -65,12 +80,14 @@ To be released.
|
|
65
80
|
- Added more log messages using the [LogTape] library. Currently the below
|
66
81
|
logger categories are used:
|
67
82
|
|
83
|
+
- `["fedify", "federation", "fanout"]`
|
68
84
|
- `["fedify", "federation", "object"]`
|
69
85
|
|
70
86
|
[#127]: https://github.com/fedify-dev/fedify/issues/127
|
71
87
|
[#209]: https://github.com/fedify-dev/fedify/issues/209
|
72
88
|
[#211]: https://github.com/fedify-dev/fedify/issues/211
|
73
89
|
[#215]: https://github.com/fedify-dev/fedify/pull/215
|
90
|
+
[#220]: https://github.com/fedify-dev/fedify/issues/220
|
74
91
|
[multibase]: https://github.com/multiformats/js-multibase
|
75
92
|
|
76
93
|
|
package/esm/deno.js
CHANGED
@@ -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
|
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 === "
|
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,38 @@ export class FederationImpl {
|
|
281
321
|
}
|
282
322
|
});
|
283
323
|
}
|
324
|
+
async #listenFanoutMessage(data, message) {
|
325
|
+
const logger = getLogger(["fedify", "federation", "fanout"]);
|
326
|
+
logger.debug("Fanning out activity {activityId} to {inboxes} inbox(es)...", {
|
327
|
+
activityId: message.activityId,
|
328
|
+
inboxes: globalThis.Object.keys(message.inboxes).length,
|
329
|
+
});
|
330
|
+
const keys = await Promise.all(message.keys.map(async ({ keyId, privateKey }) => ({
|
331
|
+
keyId: new URL(keyId),
|
332
|
+
privateKey: await importJwk(privateKey, "private"),
|
333
|
+
})));
|
334
|
+
const activity = await Activity.fromJsonLd(message.activity, {
|
335
|
+
contextLoader: this.contextLoaderFactory({
|
336
|
+
allowPrivateAddress: this.allowPrivateAddress,
|
337
|
+
userAgent: this.userAgent,
|
338
|
+
}),
|
339
|
+
documentLoader: this.documentLoaderFactory({
|
340
|
+
allowPrivateAddress: this.allowPrivateAddress,
|
341
|
+
userAgent: this.userAgent,
|
342
|
+
}),
|
343
|
+
tracerProvider: this.tracerProvider,
|
344
|
+
});
|
345
|
+
const context = this.#createContext(new URL(message.baseUrl), data, {
|
346
|
+
documentLoader: this.documentLoaderFactory({
|
347
|
+
allowPrivateAddress: this.allowPrivateAddress,
|
348
|
+
userAgent: this.userAgent,
|
349
|
+
}),
|
350
|
+
});
|
351
|
+
await this.sendActivity(keys, message.inboxes, activity, {
|
352
|
+
collectionSync: message.collectionSync,
|
353
|
+
context,
|
354
|
+
});
|
355
|
+
}
|
284
356
|
async #listenOutboxMessage(_, message, span) {
|
285
357
|
const logger = getLogger(["fedify", "federation", "outbox"]);
|
286
358
|
const logData = {
|
@@ -485,7 +557,7 @@ export class FederationImpl {
|
|
485
557
|
});
|
486
558
|
}
|
487
559
|
startQueue(contextData, options = {}) {
|
488
|
-
return this
|
560
|
+
return this._startQueueInternal(contextData, options.signal, options.queue);
|
489
561
|
}
|
490
562
|
createContext(urlOrRequest, contextData) {
|
491
563
|
return urlOrRequest instanceof Request
|
@@ -1118,33 +1190,9 @@ export class FederationImpl {
|
|
1118
1190
|
};
|
1119
1191
|
return setters;
|
1120
1192
|
}
|
1121
|
-
async sendActivity(keys,
|
1193
|
+
async sendActivity(keys, inboxes, activity, options) {
|
1122
1194
|
const logger = getLogger(["fedify", "federation", "outbox"]);
|
1123
|
-
const {
|
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
|
-
});
|
1195
|
+
const { immediate, collectionSync, context: ctx } = options;
|
1148
1196
|
if (activity.id == null) {
|
1149
1197
|
throw new TypeError("The activity to send must have an id.");
|
1150
1198
|
}
|
@@ -1238,7 +1286,7 @@ export class FederationImpl {
|
|
1238
1286
|
keyJwkPairs.push({ keyId: keyId.href, privateKey: privateKeyJwk });
|
1239
1287
|
}
|
1240
1288
|
if (!this.manuallyStartQueue)
|
1241
|
-
this
|
1289
|
+
this._startQueueInternal(ctx.data);
|
1242
1290
|
const carrier = {};
|
1243
1291
|
propagation.inject(context.active(), carrier);
|
1244
1292
|
const promises = [];
|
@@ -1441,7 +1489,7 @@ export class FederationImpl {
|
|
1441
1489
|
}
|
1442
1490
|
}
|
1443
1491
|
if (!this.manuallyStartQueue)
|
1444
|
-
this
|
1492
|
+
this._startQueueInternal(contextData);
|
1445
1493
|
return await handleInbox(request, {
|
1446
1494
|
recipient: route.values.identifier ?? route.values.handle ?? null,
|
1447
1495
|
context,
|
@@ -1539,6 +1587,7 @@ export class FederationImpl {
|
|
1539
1587
|
}
|
1540
1588
|
}
|
1541
1589
|
}
|
1590
|
+
const FANOUT_THRESHOLD = 5;
|
1542
1591
|
export class ContextImpl {
|
1543
1592
|
url;
|
1544
1593
|
federation;
|
@@ -1946,7 +1995,9 @@ export class ContextImpl {
|
|
1946
1995
|
}
|
1947
1996
|
sendActivity(sender, recipients, activity, options = {}) {
|
1948
1997
|
const tracer = this.tracerProvider.getTracer(metadata.name, metadata.version);
|
1949
|
-
return tracer.startActiveSpan(
|
1998
|
+
return tracer.startActiveSpan(this.federation.outboxQueue == null || options.immediate
|
1999
|
+
? "activitypub.outbox"
|
2000
|
+
: "activitypub.fanout", {
|
1950
2001
|
kind: this.federation.outboxQueue == null || options.immediate
|
1951
2002
|
? SpanKind.CLIENT
|
1952
2003
|
: SpanKind.PRODUCER,
|
@@ -1974,6 +2025,7 @@ export class ContextImpl {
|
|
1974
2025
|
});
|
1975
2026
|
}
|
1976
2027
|
async sendActivityInternal(sender, recipients, activity, options = {}, span) {
|
2028
|
+
const logger = getLogger(["fedify", "federation", "outbox"]);
|
1977
2029
|
let keys;
|
1978
2030
|
let identifier = null;
|
1979
2031
|
if ("identifier" in sender || "username" in sender || "handle" in sender) {
|
@@ -1987,7 +2039,7 @@ export class ContextImpl {
|
|
1987
2039
|
}
|
1988
2040
|
else {
|
1989
2041
|
username = sender.handle;
|
1990
|
-
|
2042
|
+
logger.warn('The "handle" property for the sender parameter is deprecated; ' +
|
1991
2043
|
'use "identifier" or "username" instead.', { sender });
|
1992
2044
|
}
|
1993
2045
|
if (this.federation.actorCallbacks?.handleMapper == null) {
|
@@ -2016,10 +2068,13 @@ export class ContextImpl {
|
|
2016
2068
|
else {
|
2017
2069
|
keys = [sender];
|
2018
2070
|
}
|
2019
|
-
|
2020
|
-
|
2021
|
-
|
2022
|
-
}
|
2071
|
+
if (keys.length < 1) {
|
2072
|
+
throw new TypeError("The sender's keys must not be empty.");
|
2073
|
+
}
|
2074
|
+
for (const { privateKey } of keys) {
|
2075
|
+
validateCryptoKey(privateKey, "private");
|
2076
|
+
}
|
2077
|
+
const opts = { context: this };
|
2023
2078
|
let expandedRecipients;
|
2024
2079
|
if (Array.isArray(recipients)) {
|
2025
2080
|
expandedRecipients = recipients;
|
@@ -2042,7 +2097,55 @@ export class ContextImpl {
|
|
2042
2097
|
expandedRecipients = [recipients];
|
2043
2098
|
}
|
2044
2099
|
span.setAttribute("activitypub.inboxes", expandedRecipients.length);
|
2045
|
-
|
2100
|
+
for (const activityTransformer of this.federation.activityTransformers) {
|
2101
|
+
activity = activityTransformer(activity, this);
|
2102
|
+
}
|
2103
|
+
span?.setAttribute("activitypub.activity.id", activity?.id?.href ?? "");
|
2104
|
+
if (activity.actorId == null) {
|
2105
|
+
logger.error("Activity {activityId} to send does not have an actor.", { activity, activityId: activity?.id?.href });
|
2106
|
+
throw new TypeError("The activity to send must have at least one actor property.");
|
2107
|
+
}
|
2108
|
+
const inboxes = extractInboxes({
|
2109
|
+
recipients: expandedRecipients,
|
2110
|
+
preferSharedInbox: options.preferSharedInbox,
|
2111
|
+
excludeBaseUris: options.excludeBaseUris,
|
2112
|
+
});
|
2113
|
+
logger.debug("Sending activity {activityId} to inboxes:\n{inboxes}", {
|
2114
|
+
inboxes: globalThis.Object.keys(inboxes),
|
2115
|
+
activityId: activity.id?.href,
|
2116
|
+
activity,
|
2117
|
+
});
|
2118
|
+
if (this.federation.fanoutQueue == null || options.immediate ||
|
2119
|
+
options.fanout === "skip" || (options.fanout ?? "auto") === "auto" &&
|
2120
|
+
globalThis.Object.keys(inboxes).length < FANOUT_THRESHOLD) {
|
2121
|
+
await this.federation.sendActivity(keys, inboxes, activity, opts);
|
2122
|
+
return;
|
2123
|
+
}
|
2124
|
+
const keyJwkPairs = await Promise.all(keys.map(async ({ keyId, privateKey }) => ({
|
2125
|
+
keyId: keyId.href,
|
2126
|
+
privateKey: await exportJwk(privateKey),
|
2127
|
+
})));
|
2128
|
+
const carrier = {};
|
2129
|
+
propagation.inject(context.active(), carrier);
|
2130
|
+
const message = {
|
2131
|
+
type: "fanout",
|
2132
|
+
id: dntShim.crypto.randomUUID(),
|
2133
|
+
baseUrl: this.origin,
|
2134
|
+
keys: keyJwkPairs,
|
2135
|
+
inboxes: globalThis.Object.fromEntries(globalThis.Object.entries(inboxes).map(([k, { actorIds, sharedInbox }]) => [k, { actorIds: [...actorIds], sharedInbox }])),
|
2136
|
+
activity: await activity.toJsonLd({
|
2137
|
+
format: "compact",
|
2138
|
+
contextLoader: this.contextLoader,
|
2139
|
+
}),
|
2140
|
+
activityId: activity.id?.href,
|
2141
|
+
activityType: getTypeId(activity).href,
|
2142
|
+
collectionSync: opts.collectionSync,
|
2143
|
+
traceContext: carrier,
|
2144
|
+
};
|
2145
|
+
if (!this.federation.manuallyStartQueue) {
|
2146
|
+
this.federation._startQueueInternal(this.data);
|
2147
|
+
}
|
2148
|
+
this.federation.fanoutQueue.enqueue(message);
|
2046
2149
|
}
|
2047
2150
|
async *getFollowers(identifier) {
|
2048
2151
|
if (this.federation.followersCallbacks == null) {
|