feat: batch broadcast delivery and redirect browsers on actor URL

broadcastActorUpdate() now fetches followers from MongoDB, deduplicates
by shared inbox, and delivers in batches of 25 with 5s delays to prevent
thundering herd (hundreds of 499s from simultaneous re-fetches).

Browser GET on /users/:handle now redirects to homepage instead of 404.
This commit is contained in:
Ricardo
2026-02-22 12:13:58 +01:00
parent c648606525
commit 7587d99013
2 changed files with 77 additions and 8 deletions

View File

@@ -158,6 +158,12 @@ export default class ActivityPubEndpoint {
return self._fedifyMiddleware(req, res, next);
});
// HTML fallback for actor URL — redirect browsers to the site homepage.
// Fedify only serves JSON-LD; browsers get 406 and fall through here.
router.get("/users/:identifier", (req, res) => {
res.redirect(self._publicationUrl || "/");
});
// Catch-all for federation paths that Fedify didn't handle (e.g. GET
// on inbox). Without this, they fall through to Indiekit's auth
// middleware and redirect to the login page.
@@ -678,6 +684,10 @@ export default class ActivityPubEndpoint {
* Send an Update(Person) activity to all followers so remote servers
* re-fetch the actor object (picking up profile changes, new featured
* collections, attachments, etc.).
*
* Delivery is batched to avoid a thundering herd: hundreds of remote
* servers simultaneously re-fetching the actor, featured posts, and
* featured tags after receiving the Update all at once.
*/
async broadcastActorUpdate() {
if (!this._federation) return;
@@ -709,21 +719,80 @@ export default class ActivityPubEndpoint {
object: actor,
});
await ctx.sendActivity(
{ identifier: handle },
"followers",
update,
{ preferSharedInbox: true },
// Fetch followers and deduplicate by shared inbox so each remote
// server only gets one delivery (same as preferSharedInbox but
// gives us control over batching).
const followers = await this._collections.ap_followers
.find({})
.project({ actorUrl: 1, inbox: 1, sharedInbox: 1 })
.toArray();
// Group by shared inbox (or direct inbox if none)
const inboxMap = new Map();
for (const f of followers) {
const key = f.sharedInbox || f.inbox;
if (key && !inboxMap.has(key)) {
inboxMap.set(key, f);
}
}
const uniqueRecipients = [...inboxMap.values()];
const BATCH_SIZE = 25;
const BATCH_DELAY_MS = 5000;
let delivered = 0;
let failed = 0;
console.info(
`[ActivityPub] Broadcasting Update(Person) to ${uniqueRecipients.length} ` +
`unique inboxes (${followers.length} followers) in batches of ${BATCH_SIZE}`,
);
console.info("[ActivityPub] Sent Update(Person) to followers");
for (let i = 0; i < uniqueRecipients.length; i += BATCH_SIZE) {
const batch = uniqueRecipients.slice(i, i + BATCH_SIZE);
// Build Fedify-compatible Recipient objects:
// extractInboxes() reads: recipient.id, recipient.inboxId,
// recipient.endpoints?.sharedInbox
const recipients = batch.map((f) => ({
id: new URL(f.actorUrl),
inboxId: new URL(f.inbox || f.sharedInbox),
endpoints: f.sharedInbox
? { sharedInbox: new URL(f.sharedInbox) }
: undefined,
}));
try {
await ctx.sendActivity(
{ identifier: handle },
recipients,
update,
{ preferSharedInbox: true },
);
delivered += batch.length;
} catch (error) {
failed += batch.length;
console.warn(
`[ActivityPub] Batch ${Math.floor(i / BATCH_SIZE) + 1} failed: ${error.message}`,
);
}
// Stagger batches so remote servers don't all re-fetch at once
if (i + BATCH_SIZE < uniqueRecipients.length) {
await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY_MS));
}
}
console.info(
`[ActivityPub] Update(Person) broadcast complete: ` +
`${delivered} delivered, ${failed} failed`,
);
await logActivity(this._collections.ap_activities, {
direction: "outbound",
type: "Update",
actorUrl: this._publicationUrl,
objectUrl: this._getActorUrl(),
summary: "Sent Update(Person) to followers",
summary: `Sent Update(Person) to ${delivered}/${uniqueRecipients.length} inboxes`,
}).catch(() => {});
} catch (error) {
console.error(

View File

@@ -1,6 +1,6 @@
{
"name": "@rmdes/indiekit-endpoint-activitypub",
"version": "1.1.18",
"version": "1.1.19",
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
"keywords": [
"indiekit",