Changeset dc41bef in Klonkt


Ignore:
Timestamp:
07/01/2026 06:23:10 PM (2 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
43273c9
Parents:
7b07035
Message:

feat(fediverse): fetch remote reply threads (cached, stale-while-revalidate)

A local post's "From the fediverse" section only had replies that were delivered
to us; replies-to-replies living on other servers were missed. A background crawl
now pulls the AS2 replies collections of the replies we have and caches any new
ones in ap_interactions. It never runs in a page request — the view renders from
cache and a stale post triggers a background refresh for the next view. Bounded
(depth 3 / 30 fetches), polite (serial), PULL only, respects blocks + SSRF guard.

  • src/services/ActivityPubService.js — crawlThread() (bounded BFS over reply collections), collectReplyItems() (paged AS2 collection reader), and the maybeCrawlThread() stale-while-revalidate entry (per-post TTL + in-flight lock, timestamp in app_settings).
  • src/routes/posts.js — the post view fires maybeCrawlThread(post.id) after rendering interactions (non-blocking, gated on apEnabled).
  • test/thread-crawl.test.js — entry exists, no-seed no-op, TTL gate.

Co-Authored-By: Claude <noreply@…>

Files:
1 added
2 edited

Legend:

Unmodified
Added
Removed
  • src/routes/posts.js

    r7b07035 rdc41bef  
    11221122    const _apBase = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
    11231123    fediverse = ActivityPubService.getInteractions(post.id, _apBase, site);
     1124    // Stale-while-revalidate: render from cache now; refresh the remote thread in the
     1125    // background (TTL-gated, non-blocking) so undelivered replies-to-replies fill in next view.
     1126    if (res.locals.apEnabled !== false) ActivityPubService.maybeCrawlThread(post.id);
    11241127  } catch { /* non-fatal */ }
    11251128  // Owner/admin of this site may reply back to a fediverse interaction.
  • src/services/ActivityPubService.js

    r7b07035 rdc41bef  
    18361836  } catch { return 0; }
    18371837}
     1838
     1839// ── Remote thread crawl (fill the gaps in a local post's conversation) ────────────
     1840// Most replies reach us by delivery, but replies-to-replies that live on other servers and
     1841// aren't addressed to us are missed. This pulls the AS2 `replies` collections of the replies
     1842// we DO have, caching any newly-found ones in ap_interactions. Bounded (depth/fetch caps),
     1843// polite (serial), PULL only, and stale-while-revalidate: it never runs in a page request —
     1844// the view renders from cache; a stale post kicks off a background refresh for the NEXT view.
     1845const THREAD_TTL_MS = 15 * 60 * 1000;   // don't re-crawl a post more than ~4×/hour
     1846const THREAD_MAX_DEPTH = 3;             // replies-to-replies-to-replies
     1847const THREAD_MAX_FETCHES = 30;          // hard cap on remote GETs per crawl (be a good peer)
     1848const _crawlingThreads = new Set();     // per-post in-flight lock (no stampede across views)
     1849
     1850function threadCrawlTs(postId) {
     1851  try { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('thread_crawl:' + postId); return r ? (Number(r.value) || 0) : 0; }
     1852  catch { return 0; }
     1853}
     1854function setThreadCrawlTs(postId, ts) {
     1855  try { db.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run('thread_crawl:' + postId, String(ts)); }
     1856  catch { /* ignore */ }
     1857}
     1858
     1859// Read a note's `replies` (string ref / Collection with `first` / paged CollectionPages) →
     1860// child note URIs. Every remote GET goes through `budget` so the whole crawl stays capped.
     1861async function collectReplyItems(repliesRef, maxPages, budget) {
     1862  const uris = [];
     1863  let node = typeof repliesRef === 'string' ? await budget.get(repliesRef) : repliesRef;
     1864  if (node && node.first) node = typeof node.first === 'string' ? await budget.get(node.first) : node.first;
     1865  let pages = 0;
     1866  while (node && pages++ < maxPages) {
     1867    for (const it of (node.items || node.orderedItems || [])) {
     1868      const u = typeof it === 'string' ? it : (it && it.id);
     1869      if (u && /^https?:\/\//i.test(u)) uris.push(u);
     1870    }
     1871    if (!node.next) break;
     1872    node = typeof node.next === 'string' ? await budget.get(node.next) : node.next;
     1873  }
     1874  return uris;
     1875}
     1876
     1877async function crawlThread(postId) {
     1878  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     1879  if (!base) return;
     1880  // Seed frontier = the remote reply note URIs we already have; also the dedup set.
     1881  let known;
     1882  try { known = new Set(db.prepare("SELECT object_uri FROM ap_interactions WHERE post_id = ? AND kind = 'reply' AND object_uri != ''").all(postId).map((r) => r.object_uri)); }
     1883  catch { return; }
     1884  const seeds = [...known].filter((u) => /^https?:\/\//i.test(u));
     1885  if (!seeds.length) return; // nothing remote to expand
     1886
     1887  let fetches = 0;
     1888  const budget = { get: async (u) => { if (fetches >= THREAD_MAX_FETCHES) return null; fetches++; return apGetJson(u); } };
     1889  const visited = new Set(); // notes whose replies collection we've already expanded
     1890  let frontier = seeds.slice();
     1891  let added = 0;
     1892
     1893  for (let depth = 0; depth < THREAD_MAX_DEPTH && frontier.length && fetches < THREAD_MAX_FETCHES; depth++) {
     1894    const nextFrontier = [];
     1895    for (const noteUri of frontier) {
     1896      if (visited.has(noteUri) || fetches >= THREAD_MAX_FETCHES) continue;
     1897      visited.add(noteUri);
     1898      const note = await budget.get(noteUri);
     1899      if (!note || !note.replies) continue;
     1900      const childUris = await collectReplyItems(note.replies, 2, budget);
     1901      for (const cu of childUris) {
     1902        if (known.has(cu) || fetches >= THREAD_MAX_FETCHES) continue;
     1903        known.add(cu);
     1904        const child = await budget.get(cu);
     1905        if (!child || !child.id || (child.type !== 'Note' && child.type !== 'Article')) continue;
     1906        const actorUri = actorUriOf(child.attributedTo);
     1907        if (!actorUri || isBlockedAny(actorUri)) continue; // skip blocked authors
     1908        const actor = await budget.get(actorUri); // may be null if budget spent → fallback handle
     1909        const ai = actorInfo(actor, actorUri);
     1910        const html = HtmlSanitizerService.sanitize(child.content || '');
     1911        // The child replies to `note` by construction (it's in note's replies collection).
     1912        try { iStmts().ins.run('reply', postId, child.id, actorUri, ai.name, ai.handle, ai.url, ai.icon, html, child.published || null, note.id || noteUri); added++; } catch { /* ignore */ }
     1913        nextFrontier.push(child.id); // expand this reply's own replies next depth
     1914      }
     1915    }
     1916    frontier = nextFrontier;
     1917  }
     1918  if (added) console.log('[AP] thread crawl', postId, '+' + added, 'remote replies (' + fetches + ' fetches)');
     1919}
     1920
     1921// Stale-while-revalidate entry point: call from the post view. Renders nothing, blocks nothing —
     1922// fires a background crawl only if this post hasn't been crawled within the TTL.
     1923export function maybeCrawlThread(postId) {
     1924  if (!postId || _crawlingThreads.has(postId)) return;
     1925  if (Date.now() - threadCrawlTs(postId) < THREAD_TTL_MS) return;
     1926  _crawlingThreads.add(postId);
     1927  setThreadCrawlTs(postId, Date.now()); // optimistic mark so concurrent/next views don't re-fire
     1928  crawlThread(postId).catch((e) => console.warn('[AP] thread crawl failed:', e && e.message)).finally(() => _crawlingThreads.delete(postId));
     1929}
     1930
    18381931let _selfHealing = false;
    18391932export async function selfHealTimeline() {
     
    21402233  listOutbox, deliverOutboxDelete, deliverOutboxUpdate,
    21412234  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, sendInteraction, voteOnPoll, voteOnRemotePoll,
    2142   parseOwnPoll, pollTally, ownPollView, deliverPollUpdate,
     2235  parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread,
    21432236  autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
    21442237  getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
Note: See TracChangeset for help on using the changeset viewer.