Relay firehose browser tools: https://compare.hose.cam

relay checker for your followers

+1 -1
src/App.css
··· 1 1 #root { 2 2 margin: 0 auto; 3 - padding: 2rem; 3 + padding: 1rem; 4 4 text-align: center; 5 5 } 6 6
+74
src/deactivated/AccountInput.tsx
··· 1 + import { useState, useEffect, useCallback } from 'react'; 2 + import { MiniDoc } from './types'; 3 + import { asyncThrottle } from './throttle'; 4 + import { resolveMiniDoc } from './microcosm'; 5 + import LilUser from './LilUser'; 6 + 7 + function AccountInput({ onSet, children }: { 8 + onSet: (MiniDoc) => void, 9 + children: any, 10 + }) { 11 + const [identifier, setIdentifier] = useState(""); 12 + const [foundDoc, setFoundDoc] = useState(null); 13 + const [triedSubmitAt, setTriedSubmitAt] = useState(null); 14 + 15 + const lookup = useCallback(asyncThrottle(300, resolveMiniDoc, setFoundDoc), []); 16 + 17 + const handleIdChange = useCallback(e => { 18 + setIdentifier(e.target.value); 19 + lookup(e.target.value); 20 + }); 21 + 22 + const handleSubmit = useCallback(() => { 23 + if (foundDoc) { 24 + onSet(foundDoc); 25 + } else { 26 + setTriedSubmitAt(+new Date()); 27 + } 28 + }); 29 + 30 + useEffect(() => { 31 + if (!triedSubmitAt) return; 32 + if (!foundDoc) return; 33 + if (new Date() - triedSubmitAt > 500) return; 34 + onSet(foundDoc); 35 + }, [foundDoc, triedSubmitAt]) 36 + 37 + return ( 38 + <form 39 + style={{ 40 + maxWidth: "420px", 41 + margin: "0 auto", 42 + display: "block", 43 + }} 44 + onSubmit={handleSubmit} 45 + > 46 + <label style={{ 47 + display: "block", 48 + }}> 49 + Your handle or DID:{' '} 50 + <input 51 + style={{ 52 + margin: "0.5em 0", 53 + padding: "0.3em 0.5em", 54 + font: "inherit", 55 + borderRadius: "0.3em", 56 + border: "1px solid #444" 57 + }} 58 + placeholder="bad-example.com" 59 + value={identifier} 60 + onChange={handleIdChange} 61 + /> 62 + </label> 63 + {foundDoc 64 + ? <LilUser doc={foundDoc}> 65 + <button onClick={() => onSet(foundDoc)}>check</button> 66 + </LilUser> 67 + : <br/> 68 + } 69 + </form> 70 + ) 71 + } 72 + 73 + 74 + export default AccountInput;
+194
src/deactivated/CheckFollowers.tsx
··· 1 + import { useState, useCallback, useEffect } from 'react'; 2 + import { MiniDoc, Collection, Did } from './types'; 3 + import { listRecords, resolveMiniDoc, getRepoStatus } from './microcosm'; 4 + import LilUser from './LilUser'; 5 + import knownRelays from '../knownRelays.json'; 6 + 7 + const INCLUDED_RELAYS = [ 8 + // most relays don't send cors headers rn 9 + 'wss://relay.xero.systems', 10 + 'wss://relay1.us-east.bsky.network', 11 + 'wss://relay1.us-west.bsky.network', 12 + 13 + // these relays are ineligible for other reasons: 14 + // 'wss://atproto.africa', // rsky-relay does not implement getRepoStatus (and doesn't have this bug) 15 + // 'wss://bsky.network', // old bgs codes does not have getRepoStatus 16 + ]; 17 + 18 + 19 + async function checkRelayStatuses(repo: Did) { 20 + const deactivateds = []; 21 + const missings = []; 22 + const fails = []; 23 + for (const url of INCLUDED_RELAYS) { 24 + const u = new URL(url); 25 + u.protocol = u.protocol.replace('ws', 'http'); 26 + let repoStatus; 27 + try { 28 + repoStatus = await getRepoStatus(u, repo); 29 + } catch (e) {} 30 + if (repoStatus === 'notfound') { 31 + missings.push(u.hostname); 32 + continue; 33 + } 34 + if (!repoStatus) { 35 + fails.push(u.hostname); 36 + continue; 37 + } 38 + if (!repoStatus.active) { 39 + console.log('rs', repoStatus); 40 + deactivateds.push(u.hostname); 41 + } 42 + } 43 + return { deactivateds, missings, fails }; 44 + } 45 + 46 + function FailSummary({ oof, children }) { 47 + const badRelays = {}; 48 + const { deactivateds, missings, fails } = oof; 49 + 50 + deactivateds.forEach(u => badRelays[u] = 'deactivated'); 51 + missings.forEach(u => badRelays[u] = 'not crawling'); 52 + fails.forEach(u => badRelays[u] = 'check failed'); 53 + 54 + return ( 55 + <p style={{ fontSize: '0.8em', textAlign: 'right', margin: '0' }}> 56 + {Object.keys(badRelays).map(k => (<> 57 + <code>{k}</code>: <span style={{ color: "#f64" }}>{badRelays[k]}</span><br /> 58 + </>))} 59 + <strong>pds:</strong> <code>{oof.doc.pds.hostname}</code> (<span style={{ color: "#7f6"}}>active</span>)<br/> 60 + </p> 61 + ) 62 + } 63 + 64 + function Results({ actives }) { 65 + const hasFails = []; 66 + let oks = 0; 67 + actives.forEach(a => { 68 + if (a.deactivateds.length > 0 || a.missings.length > 0 || a.fails.length > 0) { 69 + hasFails.push(a); 70 + } else { 71 + oks += 1; 72 + } 73 + }) 74 + return ( 75 + <> 76 + <p>{oks} account{oks !== 1 && 's'} on alternative PDSs checked out ok.</p> 77 + {hasFails.length > 0 && 78 + <> 79 + <h3>{hasFails.length} account{hasFails.length !== 1 && 's'} found with relay problems</h3> 80 + {hasFails.map(f => ( 81 + <div key={f.doc.did.val} style={{ margin: "0.5rem 0" }}> 82 + <LilUser doc={f.doc}> 83 + <FailSummary oof={f} /> 84 + </LilUser> 85 + </div> 86 + ))} 87 + </> 88 + } 89 + </> 90 + ); 91 + } 92 + 93 + function CheckFollowers({ doc }: { 94 + doc: MiniDoc, 95 + }) { 96 + const [seenDids, setSeenDids] = useState({}); 97 + const [actives, setActives] = useState([]); 98 + const [actuallyDeactivated, setActuallyDeactivated] = useState([]); 99 + const [mushrooms, setMushrooms] = useState([]); 100 + const [failures, setFailures] = useState([]); 101 + 102 + const checkFollowing = useCallback(async subject => { 103 + if (seenDids[subject]) return; 104 + else setSeenDids(s => ({ ...s, [subject]: true })); 105 + 106 + let doc; 107 + try { 108 + doc = await resolveMiniDoc(subject); 109 + } catch {} 110 + if (!doc) { 111 + setFailures(fs => [...fs, { subject, reason: 'resolution' }]); 112 + return; 113 + } 114 + if (doc.pds.hostname.endsWith(".host.bsky.network")) { 115 + setMushrooms(ms => [...ms, doc]); 116 + return; 117 + } 118 + let repoStatus; 119 + try { 120 + repoStatus = await getRepoStatus(doc.pds, doc.did); 121 + } catch (e) {} 122 + if (repoStatus === 'notfound') { 123 + setFailures(fs => [...fs, { subject, reason: 'notfound' } ]); 124 + return; 125 + } 126 + if (!repoStatus) { 127 + setFailures(fs => [...fs, { subject, reason: 'pds getRepoStatus' } ]); 128 + return; 129 + } 130 + if (!repoStatus.active) { 131 + setActuallyDeactivated(ads => [...ads, doc]); 132 + return; 133 + } 134 + const { deactivateds, missings, fails } = await checkRelayStatuses(doc.did); 135 + setActives(as => [...as, { doc, deactivateds, missings, fails }]); 136 + }, []); 137 + 138 + useEffect(() => { 139 + let cancel = false; 140 + (async() => { 141 + // check ourselves first 142 + checkFollowing(doc.did.val); 143 + 144 + const gen = listRecords(doc.pds, doc.did, new Collection('app.bsky.graph.follow')); 145 + 146 + for await (const record of gen) { 147 + if (cancel) break; 148 + checkFollowing(record.subject); 149 + } 150 + })(); 151 + return () => cancel = true; 152 + }, [doc.did.val, doc.pds]); 153 + 154 + return ( 155 + <div style={{ marginBottom: "4em" }}> 156 + <h2>Checking following ({Object.keys(seenDids).length})&hellip;</h2> 157 + <p>Of your follows, {failures.length} failed resolution, {mushrooms.length} are on bsky mushroom PDSs, and {actuallyDeactivated.length} are actually deactivated.</p> 158 + <Results actives={actives} /> 159 + 160 + <div style={{ textAlign: "left" }}> 161 + <h3 style={{ margin: "3em 0 0" }}>What these results mean</h3> 162 + 163 + <h4 style={{ marginBottom: "0", color: "#f64" }}>Deactivated</h4> 164 + <p>The relay has become desynchronized with this account, incorrectly marking it as not <code>active</code>. All commits from this account will be blocked by the relay; none will be broadcast to relay consumers.</p> 165 + 166 + <h4 style={{ marginBottom: "0", color: "#f64" }}>Not crawling</h4> 167 + <p>The relay doesn't know about this account—perhaps it as never crawled its PDS. No content from this account will be discovered by the relay, so relay consumers won't see it.</p> 168 + 169 + <h4 style={{ marginBottom: "0", color: "#f64" }}>Check failed</h4> 170 + <p>This account seems active, but something went wrong when checking its status with the relay. It might be fine!</p> 171 + 172 + <h3 style={{ margin: "3em 0 0" }}>Which relays are checked?</h3> 173 + 174 + <ul> 175 + {INCLUDED_RELAYS.map(u => ( 176 + <li key={u}><code>{new URL(u).hostname}</code></li> 177 + ))} 178 + </ul> 179 + 180 + <h4 style={{ marginBottom: "0" }}>Excluded relays</h4> 181 + 182 + <ul> 183 + <li><code>atproto.africa</code> does not store repo status, so it can't get desynchronized, and won't drop commits.</li> 184 + <li><code>bsky.network</code>, running the old BGS code, does not implement <code>com.atproto.sync.getRepoStatus</code>.</li> 185 + <li>All other known relays do not allow CORS XRPC requests, so we can't check from your browser.</li> 186 + </ul> 187 + 188 + <p>Accounts on Bluesky's mushroom PDSs are not checked because accounts seem to mainly desynchronize when migrating PDSs. Since accounts can now be migrated into the mushrooms, perhaps they should be checked too?</p> 189 + </div> 190 + </div> 191 + ); 192 + } 193 + 194 + export default CheckFollowers;
+36
src/deactivated/Deactivated.tsx
··· 1 + import { useState } from 'react'; 2 + import { MiniDoc } from './types'; 3 + import { resolveMiniDoc } from './microcosm'; 4 + import LilUser from './LilUser'; 5 + import AccountInput from './AccountInput'; 6 + import CheckFollowers from './CheckFollowers'; 7 + 8 + function Deactivated() { 9 + const [doc, setDoc] = useState(null); 10 + 11 + return ( 12 + <div style={{ 13 + maxWidth: "800px", 14 + }}> 15 + <h1>Oops deactivated checker</h1> 16 + <p>This is a relay debugging tool to check if relays are blocking accounts you follow due to desynchronized <code>active</code> state. This can happen when accounts migrate to an alternative PDS host.</p> 17 + 18 + {doc 19 + ? <LilUser doc={doc}> 20 + <button 21 + style={{color: "#f90"}} 22 + title="clear" 23 + onClick={() => setDoc(null)} 24 + >&times;</button> 25 + </LilUser> 26 + : <AccountInput onSet={setDoc} /> 27 + } 28 + 29 + {doc && <CheckFollowers doc={doc} />} 30 + 31 + <p><small>False positive note: it's possible for a relay to set an account as <code>deactivated</code> on purpose, but this moderation action is extremely rare.</small></p> 32 + </div> 33 + ); 34 + } 35 + 36 + export default Deactivated;
+101
src/deactivated/LilUser.tsx
··· 1 + import { useState, useEffect } from 'react'; 2 + import { MiniDoc, Collection, Rkey } from './types'; 3 + import { getRecord } from './microcosm'; 4 + 5 + function Pfp({ did, link }: { 6 + did: Did, 7 + link: String, 8 + }) { 9 + const CDN = 'https://cdn.bsky.app/img/avatar_thumbnail/plain'; // freeloading 10 + const url = `${CDN}/${did.val}/${link}@jpeg` 11 + return <img 12 + alt="avatar" 13 + src={url} 14 + style={{ 15 + display: "block", 16 + width: "100%", 17 + height: "100%", 18 + }} 19 + />; 20 + } 21 + 22 + function LilUser({ doc, children }: { 23 + doc: MiniDoc, 24 + children: any, 25 + }) { 26 + const [pfpLink, setPfpLink] = useState(null); 27 + const [displayName, setDisplayName] = useState(null); 28 + 29 + useEffect(() => { 30 + let cancel = false; 31 + (async () => { 32 + const uri = `at://${doc.did.val}/app.bsky.actor.profile/self`; 33 + const profile = await getRecord( 34 + doc.did, 35 + new Collection('app.bsky.actor.profile'), 36 + new Rkey('self'), 37 + ); 38 + const link = profile?.avatar?.ref?.$link; 39 + if (link && !cancel) setPfpLink(link); 40 + const name = profile?.displayName; 41 + if (name && !cancel) setDisplayName(name); 42 + })(); 43 + return () => cancel = true; 44 + }, [doc.did.val]); 45 + 46 + return ( 47 + <div style={{ 48 + display: "flex", 49 + textAlign: "left", 50 + alignItems: "center", 51 + background: "#333", 52 + padding: "0.5em 0.6em", 53 + gap: "0.6em", 54 + borderRadius: "0.3em", 55 + }}> 56 + <div style={{ 57 + background: "#000", 58 + height: "42px", 59 + width: "42px", 60 + display: "flex", 61 + justifyContent: "center", 62 + alignItems: "center", 63 + color: "#858", 64 + fontSize: "0.8em", 65 + borderRadius: "100%", 66 + flexShrink: "0", 67 + overflow: "hidden", 68 + }}> 69 + {pfpLink 70 + ? <Pfp did={doc.did} link={pfpLink} /> 71 + : <>&hellip;</>} 72 + </div> 73 + <div style={{ 74 + flexGrow: "1", 75 + }}> 76 + <h3 style={{ 77 + margin: 0, 78 + fontSize: "1em", 79 + }}> 80 + {displayName || doc.handle.val} 81 + </h3> 82 + <p style={{ 83 + fontSize: "1em", 84 + margin: 0, 85 + lineHeight: "1", 86 + opacity: "0.8", 87 + }}> 88 + {displayName && <><code>{doc.handle.val}</code><br/></>} 89 + <code>{doc.did.val}</code> 90 + </p> 91 + </div> 92 + {children && ( 93 + <div> 94 + {children} 95 + </div> 96 + )} 97 + </div> 98 + ) 99 + } 100 + 101 + export default LilUser;
+77
src/deactivated/microcosm.ts
··· 1 + import { MiniDoc, Handle, Did, Collection, Rkey } from './types'; 2 + 3 + const SLINGSHOT = 'https://slingshot.microcosm.blue'; 4 + 5 + export async function resolveMiniDoc(identifier: String): MiniDoc | null { 6 + const search = new URLSearchParams(); 7 + search.set('identifier', identifier); 8 + const res = await fetch(`${SLINGSHOT}/xrpc/com.bad-example.identity.resolveMiniDoc?${search}`); 9 + if (!res.ok) { 10 + res.text().then(t => console.warn(`slingshot failed to resolve ${identifier} (${res.status})`, t)); 11 + return null; 12 + } 13 + const data = await res.json(); 14 + const did = new Did(data.did); 15 + const handle = new Handle(data.handle); 16 + const pds = new URL(data.pds); 17 + let doc = new MiniDoc(did, handle, pds); 18 + return doc; 19 + } 20 + 21 + export async function getRepoStatus(host: URL, repo: Did) { 22 + const search = new URLSearchParams(); 23 + search.set('did', repo.val); 24 + const res = await fetch(`${host}xrpc/com.atproto.sync.getRepoStatus?${search}`); 25 + if (res.status === 404) { 26 + try { 27 + const err = await res.json(); 28 + if (err.error === 'RepoNotFound') { 29 + return 'notfound'; // hacklaskjdflaksjdflkajsf 30 + } 31 + } catch (_) {} 32 + } 33 + if (!res.ok) { 34 + res.text().then(t => console.warn(`slingshot failed to getRepoStatus ${host} / ${repo} (${res.status})`, t)); 35 + return null; 36 + } 37 + return await res.json(); 38 + } 39 + 40 + export async function getRecord(repo: Did, collection: Collection, rkey: Rkey) { 41 + const search = new URLSearchParams(); 42 + search.set('repo', repo.val); 43 + search.set('collection', collection.val); 44 + search.set('rkey', rkey.val); 45 + const res = await fetch(`${SLINGSHOT}/xrpc/com.atproto.repo.getRecord?${search}`); 46 + if (!res.ok) { 47 + res.text().then(t => console.warn(`slingshot failed to getRecord ${repo}/${collection}/${rkey} (${res.status})`, t)); 48 + return null; 49 + } 50 + const data = await res.json(); 51 + return data.value; 52 + } 53 + 54 + export async function* listRecords( 55 + pds: URL, 56 + repo: Did, 57 + collection: Collection, 58 + ) { 59 + let cursor = null; 60 + do { 61 + const search = new URLSearchParams(); 62 + search.set('repo', repo.val); 63 + search.set('collection', collection.val); 64 + search.set('limit', '100'); 65 + if (cursor) search.set('cursor', cursor); 66 + const res = await fetch(`${pds}xrpc/com.atproto.repo.listRecords?${search}`); 67 + if (!res.ok) { 68 + res.text().then(t => console.warn(`slingshot failed to listRecords ${repo} / ${collection} (${res.status})`, t)); 69 + return null; 70 + } 71 + const data = await res.json(); 72 + for (const { value } of data.records) { 73 + yield value; 74 + } 75 + cursor = data.cursor; 76 + } while (cursor); 77 + }
+17
src/deactivated/throttle.ts
··· 1 + import { useCallback } from 'react'; 2 + 3 + export function asyncThrottle(minT, callback, followUp) { 4 + let timer = null; 5 + let lastArgs = null; 6 + 7 + function throttled(...args) { 8 + lastArgs = args; 9 + if (timer === null) { 10 + timer = setTimeout(async () => { 11 + followUp(await callback(...lastArgs)); 12 + timer = null; 13 + }, minT); 14 + } 15 + } 16 + return throttled; 17 + }
+23
src/deactivated/types.ts
··· 1 + class Stringy { 2 + val: String; 3 + constructor(val: String) { 4 + this.val = val; 5 + } 6 + } 7 + 8 + export class Did extends Stringy {} 9 + export class Handle extends Stringy {} 10 + export class Collection extends Stringy {} 11 + export class Rkey extends Stringy {} 12 + 13 + export class MiniDoc { 14 + did: Did; 15 + handle: Handle; 16 + pds: URL; 17 + 18 + constructor(did: Did, handle: Handle, pds: URL) { 19 + this.did = did; 20 + this.handle = handle; 21 + this.pds = pds; 22 + } 23 + };
+2 -3
src/index.css
··· 36 36 } 37 37 38 38 button { 39 - border-radius: 8px; 39 + border-radius: 0.3em; 40 40 border: 1px solid transparent; 41 - padding: 0.6em 1.2em; 41 + padding: 0.5em; 42 42 font-size: 1em; 43 43 font-weight: 500; 44 44 font-family: inherit; 45 45 background-color: #1a1a1a; 46 46 cursor: pointer; 47 - transition: border-color 0.25s; 48 47 } 49 48 button:hover { 50 49 border-color: #646cff;
+10 -1
src/main.tsx
··· 3 3 import { ThemeProvider, createTheme } from '@mui/material/styles'; 4 4 import './index.css' 5 5 import App from './App.tsx' 6 + import Deactivated from './deactivated/Deactivated.tsx' 6 7 7 8 const theme = createTheme({ 8 9 colorSchemes: { ··· 10 11 }, 11 12 }); 12 13 14 + const deactivated = location.search.includes("deactivated"); 15 + 16 + if (deactivated) { 17 + document.title = "Oops deactivated checker"; 18 + } 19 + 13 20 createRoot(document.getElementById('root')!).render( 14 21 <StyledEngineProvider injectFirst> 15 22 <ThemeProvider theme={theme}> 16 - <App /> 23 + {deactivated 24 + ? <Deactivated /> 25 + : <App />} 17 26 </ThemeProvider> 18 27 </StyledEngineProvider> 19 28 )