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

show some latencies in a histogram

+56 -37
package-lock.json
··· 8 "name": "firehose-diff", 9 "version": "0.0.0", 10 "dependencies": { 11 "@emotion/react": "^11.14.0", 12 "@emotion/styled": "^11.14.0", 13 "@mui/material": "^7.1.0", 14 - "@mui/x-charts": "^8.3.1", 15 "@skyware/firehose": "^0.5.1", 16 "react": "^19.1.0", 17 "react-dom": "^19.1.0" ··· 84 "dependencies": { 85 "@atcute/uint8array": "^1.0.1" 86 } 87 }, 88 "node_modules/@atcute/uint8array": { 89 "version": "1.0.1", ··· 316 } 317 }, 318 "node_modules/@babel/runtime": { 319 - "version": "7.27.1", 320 - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz", 321 - "integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==", 322 "license": "MIT", 323 "engines": { 324 "node": ">=6.9.0" ··· 1403 } 1404 }, 1405 "node_modules/@mui/types": { 1406 - "version": "7.4.2", 1407 - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.2.tgz", 1408 - "integrity": "sha512-edRc5JcLPsrlNFYyTPxds+d5oUovuUxnnDtpJUbP6WMeV4+6eaX/mqai1ZIWT62lCOe0nlrON0s9HDiv5en5bA==", 1409 "license": "MIT", 1410 "dependencies": { 1411 - "@babel/runtime": "^7.27.1" 1412 }, 1413 "peerDependencies": { 1414 "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" ··· 1420 } 1421 }, 1422 "node_modules/@mui/utils": { 1423 - "version": "7.1.0", 1424 - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.1.0.tgz", 1425 - "integrity": "sha512-/OM3S8kSHHmWNOP+NH9xEtpYSG10upXeQ0wLZnfDgmgadTAk5F4MQfFLyZ5FCRJENB3eRzltMmaNl6UtDnPovw==", 1426 "license": "MIT", 1427 "dependencies": { 1428 - "@babel/runtime": "^7.27.1", 1429 - "@mui/types": "^7.4.2", 1430 - "@types/prop-types": "^15.7.14", 1431 "clsx": "^2.1.1", 1432 "prop-types": "^15.8.1", 1433 - "react-is": "^19.1.0" 1434 }, 1435 "engines": { 1436 "node": ">=14.0.0" ··· 1450 } 1451 }, 1452 "node_modules/@mui/x-charts": { 1453 - "version": "8.3.1", 1454 - "resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-8.3.1.tgz", 1455 - "integrity": "sha512-jZClK40++ftcMwCeHKudGKmazd0MsgnrIP6RhYi2lH1kg0jK2upueokyxVIIxqquwWsQYE3WsflJBP61DvYXOQ==", 1456 "license": "MIT", 1457 "dependencies": { 1458 - "@babel/runtime": "^7.27.1", 1459 - "@mui/utils": "^7.0.2", 1460 - "@mui/x-charts-vendor": "8.3.1", 1461 - "@mui/x-internals": "8.3.1", 1462 "bezier-easing": "^2.1.0", 1463 "clsx": "^2.1.1", 1464 "prop-types": "^15.8.1", ··· 1486 } 1487 }, 1488 "node_modules/@mui/x-charts-vendor": { 1489 - "version": "8.3.1", 1490 - "resolved": "https://registry.npmjs.org/@mui/x-charts-vendor/-/x-charts-vendor-8.3.1.tgz", 1491 - "integrity": "sha512-UcUa7HDIpSfeVBYgeHewWoVALcB4Gg9we53l78j2cyadYBZOWdtLj8fezo9zAhxfZ5s9T+1yIyuD+CCnYJnUpQ==", 1492 "license": "MIT AND ISC", 1493 "dependencies": { 1494 - "@babel/runtime": "^7.27.1", 1495 "@types/d3-color": "^3.1.3", 1496 "@types/d3-delaunay": "^6.0.4", 1497 "@types/d3-interpolate": "^3.0.4", ··· 1510 "robust-predicates": "^3.0.2" 1511 } 1512 }, 1513 "node_modules/@mui/x-internals": { 1514 - "version": "8.3.1", 1515 - "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.3.1.tgz", 1516 - "integrity": "sha512-8kIxT66cea63iEseEIHSWzKju2Wzl7MsWFoAUQEyRvYqOFa2j9Un2Vn/EH2vy9nm/MtMAYpwOE/nt68/KTIA2w==", 1517 "license": "MIT", 1518 "dependencies": { 1519 - "@babel/runtime": "^7.27.1", 1520 - "@mui/utils": "^7.0.2" 1521 }, 1522 "engines": { 1523 "node": ">=14.0.0" ··· 1992 "license": "MIT" 1993 }, 1994 "node_modules/@types/prop-types": { 1995 - "version": "15.7.14", 1996 - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", 1997 - "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", 1998 "license": "MIT" 1999 }, 2000 "node_modules/@types/react": { ··· 4460 } 4461 }, 4462 "node_modules/react-is": { 4463 - "version": "19.1.0", 4464 - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.0.tgz", 4465 - "integrity": "sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg==", 4466 "license": "MIT" 4467 }, 4468 "node_modules/react-refresh": {
··· 8 "name": "firehose-diff", 9 "version": "0.0.0", 10 "dependencies": { 11 + "@atcute/tid": "^1.0.2", 12 "@emotion/react": "^11.14.0", 13 "@emotion/styled": "^11.14.0", 14 "@mui/material": "^7.1.0", 15 + "@mui/x-charts": "^8.10.2", 16 "@skyware/firehose": "^0.5.1", 17 "react": "^19.1.0", 18 "react-dom": "^19.1.0" ··· 85 "dependencies": { 86 "@atcute/uint8array": "^1.0.1" 87 } 88 + }, 89 + "node_modules/@atcute/tid": { 90 + "version": "1.0.2", 91 + "resolved": "https://registry.npmjs.org/@atcute/tid/-/tid-1.0.2.tgz", 92 + "integrity": "sha512-ahmjroNyeDPJhtuf3+HTJropaH04HmJ8fhntDu73Gpz/RkAF7+nkz6kcP2QTgfvMCgMPAJUdskAAP82GPDTY9w==", 93 + "license": "MIT" 94 }, 95 "node_modules/@atcute/uint8array": { 96 "version": "1.0.1", ··· 323 } 324 }, 325 "node_modules/@babel/runtime": { 326 + "version": "7.28.3", 327 + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz", 328 + "integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==", 329 "license": "MIT", 330 "engines": { 331 "node": ">=6.9.0" ··· 1410 } 1411 }, 1412 "node_modules/@mui/types": { 1413 + "version": "7.4.5", 1414 + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.5.tgz", 1415 + "integrity": "sha512-ZPwlAOE3e8C0piCKbaabwrqZbW4QvWz0uapVPWya7fYj6PeDkl5sSJmomT7wjOcZGPB48G/a6Ubidqreptxz4g==", 1416 "license": "MIT", 1417 "dependencies": { 1418 + "@babel/runtime": "^7.28.2" 1419 }, 1420 "peerDependencies": { 1421 "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" ··· 1427 } 1428 }, 1429 "node_modules/@mui/utils": { 1430 + "version": "7.3.1", 1431 + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.1.tgz", 1432 + "integrity": "sha512-/31y4wZqVWa0jzMnzo6JPjxwP6xXy4P3+iLbosFg/mJQowL1KIou0LC+lquWW60FKVbKz5ZUWBg2H3jausa0pw==", 1433 "license": "MIT", 1434 "dependencies": { 1435 + "@babel/runtime": "^7.28.2", 1436 + "@mui/types": "^7.4.5", 1437 + "@types/prop-types": "^15.7.15", 1438 "clsx": "^2.1.1", 1439 "prop-types": "^15.8.1", 1440 + "react-is": "^19.1.1" 1441 }, 1442 "engines": { 1443 "node": ">=14.0.0" ··· 1457 } 1458 }, 1459 "node_modules/@mui/x-charts": { 1460 + "version": "8.10.2", 1461 + "resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-8.10.2.tgz", 1462 + "integrity": "sha512-F4SdpixbaAeNaZHylYLpaj/WD1jKn6gMD1Twbd+Y8FzZneE2mrtXwaQc6W2Hbri/VdJl2OV55nL5pFMpDSlwAA==", 1463 "license": "MIT", 1464 "dependencies": { 1465 + "@babel/runtime": "^7.28.2", 1466 + "@mui/utils": "^7.3.1", 1467 + "@mui/x-charts-vendor": "8.6.0", 1468 + "@mui/x-internal-gestures": "0.2.4", 1469 + "@mui/x-internals": "8.10.2", 1470 "bezier-easing": "^2.1.0", 1471 "clsx": "^2.1.1", 1472 "prop-types": "^15.8.1", ··· 1494 } 1495 }, 1496 "node_modules/@mui/x-charts-vendor": { 1497 + "version": "8.6.0", 1498 + "resolved": "https://registry.npmjs.org/@mui/x-charts-vendor/-/x-charts-vendor-8.6.0.tgz", 1499 + "integrity": "sha512-TTtfhxXuwtoZfyno7+4y3ZhZeFqavFJecWbteLEby0lFqALWB9GGJpkc1TIHWr3GkWE5UHEbdADZ0pfrPenezA==", 1500 "license": "MIT AND ISC", 1501 "dependencies": { 1502 + "@babel/runtime": "^7.27.6", 1503 "@types/d3-color": "^3.1.3", 1504 "@types/d3-delaunay": "^6.0.4", 1505 "@types/d3-interpolate": "^3.0.4", ··· 1518 "robust-predicates": "^3.0.2" 1519 } 1520 }, 1521 + "node_modules/@mui/x-internal-gestures": { 1522 + "version": "0.2.4", 1523 + "resolved": "https://registry.npmjs.org/@mui/x-internal-gestures/-/x-internal-gestures-0.2.4.tgz", 1524 + "integrity": "sha512-Hpc5+LQfT0TrI7O0ngBlZ1LD6Gp1h5DUNs4ABbrotY/2OsOfJOzltV/QtBjINTmiBzDPnrE8l8nEiG97qOWX3Q==", 1525 + "license": "MIT", 1526 + "dependencies": { 1527 + "@babel/runtime": "^7.28.2" 1528 + } 1529 + }, 1530 "node_modules/@mui/x-internals": { 1531 + "version": "8.10.2", 1532 + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.10.2.tgz", 1533 + "integrity": "sha512-dlC0BQRRBdiWtqn1yDppaHYRUjU3OuPWTxy0UtqxDaJjJf4pfR8ALr243nbxgJAFqvQyWPWyO4A6p9x9eJMJEQ==", 1534 "license": "MIT", 1535 "dependencies": { 1536 + "@babel/runtime": "^7.28.2", 1537 + "@mui/utils": "^7.3.1", 1538 + "reselect": "^5.1.1", 1539 + "use-sync-external-store": "^1.5.0" 1540 }, 1541 "engines": { 1542 "node": ">=14.0.0" ··· 2011 "license": "MIT" 2012 }, 2013 "node_modules/@types/prop-types": { 2014 + "version": "15.7.15", 2015 + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", 2016 + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", 2017 "license": "MIT" 2018 }, 2019 "node_modules/@types/react": { ··· 4479 } 4480 }, 4481 "node_modules/react-is": { 4482 + "version": "19.1.1", 4483 + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz", 4484 + "integrity": "sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==", 4485 "license": "MIT" 4486 }, 4487 "node_modules/react-refresh": {
+2 -1
package.json
··· 10 "preview": "vite preview" 11 }, 12 "dependencies": { 13 "@emotion/react": "^11.14.0", 14 "@emotion/styled": "^11.14.0", 15 "@mui/material": "^7.1.0", 16 - "@mui/x-charts": "^8.3.1", 17 "@skyware/firehose": "^0.5.1", 18 "react": "^19.1.0", 19 "react-dom": "^19.1.0"
··· 10 "preview": "vite preview" 11 }, 12 "dependencies": { 13 + "@atcute/tid": "^1.0.2", 14 "@emotion/react": "^11.14.0", 15 "@emotion/styled": "^11.14.0", 16 "@mui/material": "^7.1.0", 17 + "@mui/x-charts": "^8.10.2", 18 "@skyware/firehose": "^0.5.1", 19 "react": "^19.1.0", 20 "react-dom": "^19.1.0"
+26 -1
src/App.tsx
··· 28 29 function App() { 30 const [relays, setRelays] = useState([] as string[]); 31 const [receiver, setReceiver] = useState(() => noopReceiver); 32 const [keepalive, setKeepalive] = useState(() => () => {}); 33 const [rateBars, setRateBars] = useState({ series: [] } as any); ··· 143 return ( 144 <> 145 <h1>compare hoses</h1> 146 - <p><em>warning: enabling many relay connections requires a lot of bandwidth</em></p> 147 148 <form style={{ display: 'block', textAlign: 'left' }}> 149 {knownRelays.map(({ url, desc }: Relay) => ( ··· 193 </p> 194 </form> 195 196 <div style={{ display: 'flex', flexWrap: 'wrap', gap: '2em', textAlign: 'left' }}> 197 {relays.map(url => { 198 const { desc } = knownRelays.find((r: Relay) => r.url === url) ?? { desc: "custom relay" }; ··· 201 <Relay 202 url={url} 203 desc={desc} 204 onRecieveEvent={(type: string, event: any) => receiver(url, type, event)} 205 /> 206 </div>
··· 28 29 function App() { 30 const [relays, setRelays] = useState([] as string[]); 31 + const [events, setEvents] = useState(['commit', 'sync', 'account', 'identity', 'unknown']); 32 + const [eventsSelected, setEventsSelected] = useState(new Set(events)); 33 const [receiver, setReceiver] = useState(() => noopReceiver); 34 const [keepalive, setKeepalive] = useState(() => () => {}); 35 const [rateBars, setRateBars] = useState({ series: [] } as any); ··· 145 return ( 146 <> 147 <h1>compare hoses</h1> 148 + <p><em>warning: enabling many relay connections requires a <strong>lot</strong> of bandwidth</em></p> 149 150 <form style={{ display: 'block', textAlign: 'left' }}> 151 {knownRelays.map(({ url, desc }: Relay) => ( ··· 195 </p> 196 </form> 197 198 + <form style={{ display: 'block', margin: '1rem 0' }}> 199 + <span>events: </span> 200 + {events.map(eventName => ( 201 + <label key={eventName}> 202 + <input 203 + type='checkbox' 204 + checked={eventsSelected.has(eventName)} 205 + onChange={e => { 206 + setEventsSelected(evs => { 207 + const s = new Set(evs); 208 + if (e.target.checked) s.add(eventName); 209 + else s.delete(eventName); 210 + return s; 211 + }) 212 + }} 213 + /> 214 + {' '} 215 + {eventName} 216 + </label> 217 + ))} 218 + </form> 219 + 220 <div style={{ display: 'flex', flexWrap: 'wrap', gap: '2em', textAlign: 'left' }}> 221 {relays.map(url => { 222 const { desc } = knownRelays.find((r: Relay) => r.url === url) ?? { desc: "custom relay" }; ··· 225 <Relay 226 url={url} 227 desc={desc} 228 + includeEvents={eventsSelected} 229 onRecieveEvent={(type: string, event: any) => receiver(url, type, event)} 230 /> 231 </div>
+89 -3
src/Relay.tsx
··· 1 - import { useEffect, useState } from 'react'; 2 import { Firehose } from '@skyware/firehose'; 3 import './Relay.css'; 4 5 type HoseState = 'connecting' | 'connected' | 'errored' | 'closed'; 6 7 - function Relay({ url, desc, onRecieveEvent }: { 8 url: string, 9 desc: string, 10 onRecieveEvent: (type: string, event: any) => void, 11 }) { 12 const [state, setState] = useState('connecting' as HoseState); 13 const [commits, setCommits] = useState(0); 14 const [reconnects, setReconnects] = useState(0); 15 16 useEffect(() => { 17 const sendIt = (type: string, event: any) => { 18 onRecieveEvent(type, event); 19 setCommits(n => n + 1); 20 }; 21 const firehose = new Firehose({ relay: url }); 22 firehose.on('open', () => setState('connected')); ··· 38 return () => { 39 firehose.close(); 40 }; 41 - }, [url]); 42 43 return ( 44 <div className="relay"> ··· 48 {(reconnects > 0) && ( 49 <p>reconnects: <code>{reconnects}</code></p> 50 )} 51 </div> 52 ); 53 }
··· 1 + import { useCallback, useEffect, useState } from 'react'; 2 import { Firehose } from '@skyware/firehose'; 3 + import * as TID from '@atcute/tid'; 4 + import { BarChart } from '@mui/x-charts/BarChart'; 5 import './Relay.css'; 6 7 type HoseState = 'connecting' | 'connected' | 'errored' | 'closed'; 8 9 + const TIME_SIGNAL_NSID = 'app.bsky.feed.like'; 10 + const BUCKET_WIDTH = 32; // ms 11 + const BUCKETS = 32; 12 + const MAX_BUCKET = BUCKET_WIDTH * BUCKETS; 13 + 14 + function Relay({ url, desc, includeEvents, onRecieveEvent }: { 15 url: string, 16 desc: string, 17 + includeEvents: Set, 18 onRecieveEvent: (type: string, event: any) => void, 19 }) { 20 const [state, setState] = useState('connecting' as HoseState); 21 const [commits, setCommits] = useState(0); 22 const [reconnects, setReconnects] = useState(0); 23 + const [buckets, setBuckets] = useState({ 24 + idx: Array.from({ length: BUCKETS + 2 }).map(() => 0), 25 + recv: Array.from({ length: BUCKETS + 2 }).map(() => 0), 26 + }); 27 28 useEffect(() => { 29 const sendIt = (type: string, event: any) => { 30 + if (!includeEvents.has(type)) return; 31 onRecieveEvent(type, event); 32 setCommits(n => n + 1); 33 + if (type === 'commit' && event.ops.length === 1) { 34 + const op = event.ops[0]; 35 + try { 36 + const [nsid, rkey] = op.path.split('/'); 37 + if (nsid === TIME_SIGNAL_NSID) { 38 + const posted = TID.parse(rkey).timestamp / 1000; 39 + const indexed = Date.parse(event.time) 40 + const indexed_dt = indexed - posted; 41 + const received_dt = +new Date() - indexed; 42 + 43 + let idx_bucket, recv_bucket; 44 + 45 + if (indexed_dt < 0) { 46 + idx_bucket = -1; 47 + } else if (indexed_dt >= MAX_BUCKET) { 48 + idx_bucket = BUCKETS; 49 + } else { 50 + idx_bucket = Math.min(Math.floor(indexed_dt / BUCKET_WIDTH), MAX_BUCKET) 51 + } 52 + if (received_dt < 0) { 53 + recv_bucket = -1; 54 + } else if (received_dt >= MAX_BUCKET) { 55 + recv_bucket = BUCKETS 56 + } else { 57 + recv_bucket = Math.min(Math.floor(received_dt / BUCKET_WIDTH), MAX_BUCKET) 58 + } 59 + 60 + setBuckets(({ idx, recv }) => { 61 + idx = idx.slice(); 62 + recv = recv.slice(); 63 + idx[idx_bucket + 1] += 1; 64 + recv[recv_bucket + 1] += 1; 65 + return { idx, recv }; 66 + }); 67 + } 68 + } catch (e) {} 69 + } 70 }; 71 const firehose = new Firehose({ relay: url }); 72 firehose.on('open', () => setState('connected')); ··· 88 return () => { 89 firehose.close(); 90 }; 91 + }, [url, includeEvents]); 92 93 return ( 94 <div className="relay"> ··· 98 {(reconnects > 0) && ( 99 <p>reconnects: <code>{reconnects}</code></p> 100 )} 101 + <BarChart 102 + height={180} 103 + width={420} 104 + yAxis={[{ 105 + label: 'events', 106 + scaleType: 'symlog', 107 + }]} 108 + skipAnimation={true} 109 + xAxis={[{ 110 + data: [-1] 111 + .concat(Array.from({ length: BUCKETS }).map((_, i) => i * BUCKET_WIDTH)) 112 + .concat(['+']), 113 + label: 'index latency (ms)', 114 + }]} 115 + series={[{ 116 + data: buckets.idx, 117 + }]} 118 + /> 119 + <BarChart 120 + height={180} 121 + width={420} 122 + yAxis={[{ 123 + label: 'events', 124 + scaleType: 'symlog', 125 + }]} 126 + skipAnimation={true} 127 + xAxis={[{ 128 + data: [-1] 129 + .concat(Array.from({ length: BUCKETS }).map((_, i) => i * BUCKET_WIDTH)) 130 + .concat(['+']), 131 + label: 'receive latency (ms)', 132 + }]} 133 + series={[{ 134 + data: buckets.recv, 135 + }]} 136 + /> 137 </div> 138 ); 139 }