diff --git a/scripts/patch-ap-mastodon-accounts.mjs b/scripts/patch-ap-mastodon-accounts.mjs new file mode 100644 index 00000000..a4598aa8 --- /dev/null +++ b/scripts/patch-ap-mastodon-accounts.mjs @@ -0,0 +1,127 @@ +/** + * Consolidated patch: Mastodon account resolution fixes. + * + * Absorbs: + * - patch-ap-actor-cache-await + * Await the ap_actor_cache MongoDB write so the entry exists before the + * search response is sent, making follow reliable after server restarts. + * + * - patch-ap-resolve-account-timeout-safe + * Fix unhandled rejection crash when withTimeout() timer fires before the + * original promise settles, and parallelise collection count fetches with + * Promise.allSettled (max 5 s instead of worst-case 15 s). + * + * Both patches target: + * lib/mastodon/helpers/resolve-account.js + */ + +import { access, readFile, writeFile } from "node:fs/promises"; + +const AP_BASE = "@rmdes/indiekit-endpoint-activitypub"; +const AP_ROOTS = [ + `node_modules/${AP_BASE}`, + `node_modules/@indiekit/indiekit/node_modules/${AP_BASE}`, +]; + +function apPath(rel) { + return AP_ROOTS.map(r => `${r}/${rel}`); +} + +async function fileExists(p) { + try { await access(p); return true; } catch { return false; } +} + +async function applyPatch(filePath, marker, oldSnippet, newSnippet) { + if (!(await fileExists(filePath))) return "file_not_found"; + const src = await readFile(filePath, "utf8"); + if (src.includes(marker)) return "already_applied"; + if (!src.includes(oldSnippet)) return "snippet_not_found"; + await writeFile(filePath, src.replace(oldSnippet, newSnippet), "utf8"); + return "applied"; +} + +const SCRIPT = "patch-ap-mastodon-accounts"; + +const PATCHES = [ + { + name: "ap-actor-cache-await", + files: apPath("lib/mastodon/helpers/resolve-account.js"), + marker: "// [patch] ap-actor-cache-await", + oldSnippet: ` // Persist actor URL mapping to MongoDB so follow/unfollow survives server restarts + if (collections?.ap_actor_cache && actorUrl) { + const hashId = remoteActorId(actorUrl); + collections.ap_actor_cache.updateOne( + { _id: hashId }, + { $set: { actorUrl, updatedAt: new Date() } }, + { upsert: true }, + ).catch(() => {}); // fire-and-forget, non-fatal + }`, + newSnippet: ` // Persist actor URL mapping to MongoDB so follow/unfollow survives server restarts + // [patch] ap-actor-cache-await + if (collections?.ap_actor_cache && actorUrl) { + const hashId = remoteActorId(actorUrl); + await collections.ap_actor_cache.updateOne( + { _id: hashId }, + { $set: { actorUrl, updatedAt: new Date() } }, + { upsert: true }, + ).catch(() => {}); // non-fatal, but now awaited so entry exists before response + }`, + }, + { + name: "ap-resolve-account-timeout-safe", + files: apPath("lib/mastodon/helpers/resolve-account.js"), + marker: "// [patch] ap-resolve-account-timeout-safe", + oldSnippet: ` const withTimeout = (promise, ms = 5000) => + Promise.race([promise, new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms))]); + + let followersCount = 0; + let followingCount = 0; + let statusesCount = 0; + try { + const followers = await withTimeout(actor.getFollowers()); + if (followers?.totalItems != null) followersCount = followers.totalItems; + } catch { /* ignore */ } + try { + const following = await withTimeout(actor.getFollowing()); + if (following?.totalItems != null) followingCount = following.totalItems; + } catch { /* ignore */ } + try { + const outbox = await withTimeout(actor.getOutbox()); + if (outbox?.totalItems != null) statusesCount = outbox.totalItems; + } catch { /* ignore */ }`, + newSnippet: ` const withTimeout = (promise, ms = 5000) => { // [patch] ap-resolve-account-timeout-safe + const abort = new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms)); + promise.catch(() => {}); // suppress unhandled rejection if timeout settles first + return Promise.race([promise, abort]); + }; + + // Fetch collection counts in parallel (max 5 s each) [patch] ap-resolve-account-timeout-safe + const [followersResult, followingResult, outboxResult] = await Promise.allSettled([ + withTimeout(actor.getFollowers()), + withTimeout(actor.getFollowing()), + withTimeout(actor.getOutbox()), + ]); + const followersCount = followersResult.status === "fulfilled" && followersResult.value?.totalItems != null ? followersResult.value.totalItems : 0; + const followingCount = followingResult.status === "fulfilled" && followingResult.value?.totalItems != null ? followingResult.value.totalItems : 0; + const statusesCount = outboxResult.status === "fulfilled" && outboxResult.value?.totalItems != null ? outboxResult.value.totalItems : 0;`, + }, +]; + +let total = 0; +for (const p of PATCHES) { + let done = false; + for (const f of p.files) { + const r = await applyPatch(f, p.marker, p.oldSnippet, p.newSnippet); + if (r === "applied") { + console.log(`[postinstall] ${SCRIPT}: applied ${p.name} to ${f}`); + total++; done = true; break; + } else if (r === "already_applied") { + console.log(`[postinstall] ${SCRIPT}: ${p.name} already applied in ${f}`); + done = true; break; + } else if (r === "snippet_not_found") { + console.warn(`[postinstall] ${SCRIPT}: ${p.name} — snippet not found in ${f}, skipping`); + } + } + if (!done) console.log(`[postinstall] ${SCRIPT}: ${p.name} — no target file found`); +} +console.log(`[postinstall] ${SCRIPT}: done (${total} patch(es) applied)`);