@fedify/fedify 1.5.0-dev.729 → 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 +14 -0
- package/esm/deno.js +1 -1
- package/esm/federation/middleware.js +136 -38
- package/esm/vocab/vocab.js +176 -176
- package/package.json +1 -1
- package/types/federation/context.d.ts +17 -0
- 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,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,12 +77,14 @@ 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
|
|
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,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
|
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,
|
1188
|
+
async sendActivity(keys, inboxes, activity, options) {
|
1122
1189
|
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
|
-
});
|
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
|
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
|
1487
|
+
this._startQueueInternal(contextData);
|
1445
1488
|
return await handleInbox(request, {
|
1446
1489
|
recipient: route.values.identifier ?? route.values.handle ?? null,
|
1447
1490
|
context,
|
@@ -1539,6 +1582,7 @@ export class FederationImpl {
|
|
1539
1582
|
}
|
1540
1583
|
}
|
1541
1584
|
}
|
1585
|
+
const FANOUT_THRESHOLD = 5;
|
1542
1586
|
export class ContextImpl {
|
1543
1587
|
url;
|
1544
1588
|
federation;
|
@@ -1946,7 +1990,9 @@ export class ContextImpl {
|
|
1946
1990
|
}
|
1947
1991
|
sendActivity(sender, recipients, activity, options = {}) {
|
1948
1992
|
const tracer = this.tracerProvider.getTracer(metadata.name, metadata.version);
|
1949
|
-
return tracer.startActiveSpan(
|
1993
|
+
return tracer.startActiveSpan(this.federation.outboxQueue == null || options.immediate
|
1994
|
+
? "activitypub.outbox"
|
1995
|
+
: "activitypub.fanout", {
|
1950
1996
|
kind: this.federation.outboxQueue == null || options.immediate
|
1951
1997
|
? SpanKind.CLIENT
|
1952
1998
|
: SpanKind.PRODUCER,
|
@@ -1974,6 +2020,7 @@ export class ContextImpl {
|
|
1974
2020
|
});
|
1975
2021
|
}
|
1976
2022
|
async sendActivityInternal(sender, recipients, activity, options = {}, span) {
|
2023
|
+
const logger = getLogger(["fedify", "federation", "outbox"]);
|
1977
2024
|
let keys;
|
1978
2025
|
let identifier = null;
|
1979
2026
|
if ("identifier" in sender || "username" in sender || "handle" in sender) {
|
@@ -1987,7 +2034,7 @@ export class ContextImpl {
|
|
1987
2034
|
}
|
1988
2035
|
else {
|
1989
2036
|
username = sender.handle;
|
1990
|
-
|
2037
|
+
logger.warn('The "handle" property for the sender parameter is deprecated; ' +
|
1991
2038
|
'use "identifier" or "username" instead.', { sender });
|
1992
2039
|
}
|
1993
2040
|
if (this.federation.actorCallbacks?.handleMapper == null) {
|
@@ -2016,10 +2063,13 @@ export class ContextImpl {
|
|
2016
2063
|
else {
|
2017
2064
|
keys = [sender];
|
2018
2065
|
}
|
2019
|
-
|
2020
|
-
|
2021
|
-
|
2022
|
-
}
|
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 };
|
2023
2073
|
let expandedRecipients;
|
2024
2074
|
if (Array.isArray(recipients)) {
|
2025
2075
|
expandedRecipients = recipients;
|
@@ -2042,7 +2092,55 @@ export class ContextImpl {
|
|
2042
2092
|
expandedRecipients = [recipients];
|
2043
2093
|
}
|
2044
2094
|
span.setAttribute("activitypub.inboxes", expandedRecipients.length);
|
2045
|
-
|
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);
|
2046
2144
|
}
|
2047
2145
|
async *getFollowers(identifier) {
|
2048
2146
|
if (this.federation.followersCallbacks == null) {
|