// 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