OverviewFilesCommitsIssuesDocsReleasesPeers

favela-gateway / lib / probes.js

136 lines · javascript ref:
// bsky probes — "are we resolving to bsky?", asked of bsky itself (hyper-social #80).
//
// Nothing local can see these: on 2026-10-13 bsky's relay had us "offline" while every local surface
// was healthy, and in 2026-07 a parked relay cursor hid a 20-hour federation outage. So the gateway asks:
//
//   relay      getHostStatus(host)                 status + seq, against our emitted seq
//   per DID    relay getLatestCommit(did)          does the relay hold the head we serve?
//              plc.directory/<did>                 does #atproto_pds point at us? (and the handle)
//              AppView getProfile / getAuthorFeed  does bsky resolve the account, and its newest post
//
// ⛔ CACHED, AND ONLY ON DEMAND. Probes run when metrics are asked for and the cache is older than the
// TTL — never on a timer and never once per page view. favela-web's 20 GB leak was repeated outbound
// HTTPS under Bare (bare-tls never settled `_open` on destroy); a probe loop would re-create it. The
// gateway's RSS is on the same panel, so a leak would be visible.
//
// ⛔ NEVER THROWS. A probe that fails is reported as unreachable with its reason; metrics always answer.

const RELAY = 'https://bsky.network'
const PLC = 'https://plc.directory'
const APPVIEW = 'https://public.api.bsky.app'
const TIMEOUT_MS = 8000

// A post as the page shows it: its uri, a short text and when it was written.
function postView (p) {
  const rec = p.record || {}
  return { uri: p.uri, text: typeof rec.text === 'string' ? rec.text.slice(0, 300) : '', createdAt: rec.createdAt || p.indexedAt || null }
}

export function createProbes ({ hostname, fetch: fetchImpl = null, ttlMs = 5 * 60 * 1000, now = () => Date.now(), timeoutMs = TIMEOUT_MS } = {}) {
  if (!hostname) throw new Error('createProbes: hostname required')
  const doFetch = fetchImpl || ((...a) => globalThis.fetch(...a))
  let cache = null
  let inFlight = null

  async function getJson (url) {
    let timer = null
    try {
      const res = await Promise.race([
        doFetch(url),
        new Promise((resolve, reject) => { timer = setTimeout(() => reject(new Error('timeout after ' + timeoutMs + ' ms')), timeoutMs) })
      ])
      if (!res || !res.ok) return { error: 'HTTP ' + (res && res.status) }
      return { body: await res.json() }
    } catch (err) {
      return { error: (err && err.message) || String(err) }
    } finally {
      clearTimeout(timer)
    }
  }

  async function probeDid ({ did, rev }, problems) {
    const out = { did, ourRev: rev || null, relayRev: null, inSync: null, plcPds: null, handle: null, appview: { resolves: null, postsCount: null, newestPost: null, newestIndexedAt: null, recent: [] } }

    const lc = await getJson(`${RELAY}/xrpc/com.atproto.sync.getLatestCommit?did=${encodeURIComponent(did)}`)
    if (lc.error) problems.push(`${did}: relay getLatestCommit unreachable (${lc.error})`)
    else {
      out.relayRev = lc.body.rev || null
      if (out.relayRev && out.ourRev) {
        out.inSync = out.relayRev === out.ourRev
        // Revs are TIDs, so string order is time order.
        if (out.relayRev < out.ourRev) problems.push(`${did}: the relay is behind — it holds ${out.relayRev}, we serve ${out.ourRev}`)
        else if (out.relayRev > out.ourRev) problems.push(`${did}: the relay holds a NEWER rev (${out.relayRev}) than we serve (${out.ourRev})`)
      }
    }

    // The documented form is the bare DID in the path; DIDs are URL-safe.
    const plc = await getJson(`${PLC}/${did}`)
    if (plc.error) problems.push(`${did}: PLC unreachable (${plc.error})`)
    else {
      const svc = (plc.body.service || []).find((s) => s && (s.id === '#atproto_pds' || s.id === did + '#atproto_pds'))
      out.plcPds = svc ? svc.serviceEndpoint : null
      const aka = (plc.body.alsoKnownAs || []).find((a) => typeof a === 'string' && a.startsWith('at://'))
      out.handle = aka ? aka.slice('at://'.length) : null
      if (out.plcPds !== 'https://' + hostname) problems.push(`${did}: PLC #atproto_pds points at ${out.plcPds || 'nothing'}, not https://${hostname}`)
    }

    const prof = await getJson(`${APPVIEW}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`)
    if (prof.error) {
      out.appview.resolves = false
      problems.push(`${did}: the bsky AppView does not resolve the profile (${prof.error})`)
    } else {
      out.appview.resolves = true
      out.appview.postsCount = prof.body.postsCount == null ? null : prof.body.postsCount
      // The last 3 posts AS THE APPVIEW SHOWS THEM — the public page's "what really shows up".
      const feed = await getJson(`${APPVIEW}/xrpc/app.bsky.feed.getAuthorFeed?actor=${encodeURIComponent(did)}&limit=3`)
      const items = (!feed.error && feed.body.feed) || []
      out.appview.recent = items.map((f) => f.post).filter(Boolean).map(postView)
      const top = items[0] && items[0].post
      if (top) { out.appview.newestPost = top.uri; out.appview.newestIndexedAt = top.indexedAt || null }
    }
    return out
  }

  // A lens feed as bsky's getFeed returns it: the end-to-end check that the feed actually works on bsky.
  async function probeFeed (uri, problems) {
    const r = await getJson(`${APPVIEW}/xrpc/app.bsky.feed.getFeed?feed=${encodeURIComponent(uri)}&limit=10`)
    if (r.error) {
      problems.push(`feed ${uri.split('/').pop()}: bsky getFeed fails (${r.error})`)
      return { uri, error: r.error, posts: [] }
    }
    return { uri, posts: (r.body.feed || []).map((f) => f.post).filter(Boolean).map((p) => ({ ...postView(p), author: (p.author && p.author.handle) || null })) }
  }

  async function probe ({ dids = [], emitted = null, feeds = [] } = {}) {
    const problems = []
    const relay = { status: null, seq: null, error: null }
    const hs = await getJson(`${RELAY}/xrpc/com.atproto.sync.getHostStatus?hostname=${encodeURIComponent(hostname)}`)
    if (hs.error) {
      relay.error = hs.error
      problems.push(`relay getHostStatus unreachable (${hs.error})`)
    } else {
      relay.status = hs.body.status || null
      relay.seq = hs.body.seq == null ? null : hs.body.seq
      if (relay.status && relay.status !== 'active') problems.push(`the bsky relay has ${hostname} ${relay.status} (seq ${relay.seq})`)
      if (emitted != null && relay.seq != null && emitted < relay.seq) problems.push(`our emitted seq ${emitted} is behind the relay's cursor ${relay.seq} — it can never advance`)
    }
    const rows = []
    for (const d of dids) rows.push(await probeDid(d, problems))
    const feedRows = []
    for (const uri of feeds) feedRows.push(await probeFeed(uri, problems))
    return { at: now(), hostname, relay, dids: rows, feeds: feedRows, problems }
  }

  // The cached read every caller uses. Concurrent callers share the probe in flight.
  async function get (args, { force = false } = {}) {
    if (!force && cache && now() - cache.at < ttlMs) return cache
    if (inFlight) return inFlight
    inFlight = probe(args).then((r) => { cache = r; return r }).finally(() => { inFlight = null })
    return inFlight
  }

  return { probe, get, cached: () => cache }
}

export default createProbes