@relay-federation/bridge 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli.js CHANGED
@@ -293,7 +293,7 @@ async function cmdStart () {
293
293
  }
294
294
 
295
295
  // ── 2. Core components ────────────────────────────────────
296
- const peerManager = new PeerManager({ maxPeers: config.maxPeers })
296
+ const peerManager = new PeerManager()
297
297
  const headerRelay = new HeaderRelay(peerManager)
298
298
  const txRelay = new TxRelay(peerManager)
299
299
 
@@ -4,6 +4,8 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Federation Mesh Explorer</title>
7
+ <script src="https://cdn.jsdelivr.net/npm/three@0.137.0/build/three.min.js"></script>
8
+ <script src="https://cdn.jsdelivr.net/npm/three@0.137.0/examples/js/controls/OrbitControls.js"></script>
7
9
  <style>
8
10
  :root {
9
11
  --bg-base: #121518;
@@ -185,7 +187,7 @@
185
187
  .tab-content {
186
188
  flex: 1;
187
189
  overflow-y: auto;
188
- padding: 20px 24px;
190
+ padding: 14px 20px;
189
191
  }
190
192
  .tab-content::-webkit-scrollbar { width: 6px; }
191
193
  .tab-content::-webkit-scrollbar-track { background: transparent; }
@@ -196,14 +198,14 @@
196
198
  display: grid;
197
199
  grid-template-columns: repeat(5, 1fr);
198
200
  gap: 12px;
199
- margin-bottom: 20px;
201
+ margin-bottom: 12px;
200
202
  }
201
203
  .stat-card {
202
- padding: 18px 20px;
204
+ padding: 12px 16px;
203
205
  text-align: center;
204
206
  }
205
207
  .stat-card .stat-value {
206
- font-size: 26px;
208
+ font-size: 22px;
207
209
  font-weight: 700;
208
210
  color: var(--text-primary);
209
211
  font-variant-numeric: tabular-nums;
@@ -221,7 +223,7 @@
221
223
  /* ── Overview cards grid ─────────────────────────── */
222
224
  .cards-grid {
223
225
  display: grid;
224
- grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
226
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
225
227
  gap: 12px;
226
228
  margin-bottom: 20px;
227
229
  }
@@ -583,13 +585,15 @@
583
585
 
584
586
  /* ── Peers ───────────────────────────────────────── */
585
587
  .peer-entry {
586
- padding: 10px 0;
588
+ padding: 6px 0;
587
589
  border-bottom: 1px solid var(--border);
588
590
  font-size: 12px;
589
591
  }
590
592
  .peer-entry:last-child { border-bottom: none; }
591
593
  .peer-entry .peer-name { color: var(--accent-blue); font-family: var(--mono); }
592
- .peer-entry .peer-meta { color: var(--text-dim); margin-top: 3px; font-size: 11px; }
594
+ .peer-entry .peer-meta { color: var(--text-dim); margin-top: 2px; font-size: 11px; }
595
+ .peer-entry .score-bars { display: none; }
596
+ .peer-entry.expanded .score-bars { display: block; }
593
597
  .peer-dot {
594
598
  display: inline-block;
595
599
  width: 6px;
@@ -647,6 +651,88 @@
647
651
  .mini-map .mm-label { fill: var(--text-muted); font-size: 9px; font-family: inherit; text-anchor: middle; }
648
652
  .mini-map .mm-label.name { fill: var(--accent-blue); font-size: 10px; font-weight: 600; }
649
653
 
654
+ /* ── 3D Mesh Map ──────────────────────────────────── */
655
+ .mesh3d-container {
656
+ position: relative;
657
+ width: 100%;
658
+ height: 180px;
659
+ border-radius: var(--radius-sm);
660
+ overflow: hidden;
661
+ background: radial-gradient(ellipse at center, rgba(20,30,50,0.9) 0%, rgba(8,10,14,0.95) 100%);
662
+ cursor: pointer;
663
+ }
664
+ .mesh3d-overlay {
665
+ position: fixed;
666
+ top: 0; left: 0; right: 0; bottom: 0;
667
+ z-index: 9999;
668
+ background: rgba(0,0,0,0.85);
669
+ display: flex;
670
+ align-items: center;
671
+ justify-content: center;
672
+ }
673
+ .mesh3d-overlay .mesh3d-expanded {
674
+ position: relative;
675
+ width: 90vw;
676
+ height: 85vh;
677
+ border-radius: var(--radius);
678
+ overflow: hidden;
679
+ background: radial-gradient(ellipse at center, rgba(20,30,50,0.95) 0%, rgba(8,10,14,0.98) 100%);
680
+ border: 1px solid var(--border-light);
681
+ }
682
+ .mesh3d-overlay .mesh3d-expanded canvas {
683
+ display: block;
684
+ width: 100% !important;
685
+ height: 100% !important;
686
+ }
687
+ .mesh3d-overlay .mesh3d-close {
688
+ position: absolute;
689
+ top: 12px; right: 16px;
690
+ z-index: 10;
691
+ background: rgba(0,0,0,0.5);
692
+ border: 1px solid var(--border);
693
+ color: var(--text-muted);
694
+ font-size: 18px;
695
+ width: 32px; height: 32px;
696
+ border-radius: 50%;
697
+ cursor: pointer;
698
+ display: flex;
699
+ align-items: center;
700
+ justify-content: center;
701
+ }
702
+ .mesh3d-overlay .mesh3d-close:hover { color: var(--text-primary); background: rgba(255,255,255,0.1); }
703
+ .mesh3d-container canvas {
704
+ display: block;
705
+ width: 100% !important;
706
+ height: 100% !important;
707
+ border-radius: var(--radius-sm);
708
+ }
709
+ .mesh3d-fallback {
710
+ display: flex;
711
+ flex-direction: column;
712
+ align-items: center;
713
+ justify-content: center;
714
+ height: 100%;
715
+ color: var(--text-dim);
716
+ font-size: 12px;
717
+ gap: 8px;
718
+ }
719
+ .mesh3d-label {
720
+ position: absolute;
721
+ font-size: 10px;
722
+ font-family: var(--mono);
723
+ pointer-events: none;
724
+ white-space: nowrap;
725
+ transform: translate(-50%, -50%);
726
+ background: rgba(0,0,0,0.7);
727
+ padding: 2px 7px;
728
+ border-radius: 4px;
729
+ border: 1px solid rgba(255,255,255,0.1);
730
+ letter-spacing: 0.5px;
731
+ }
732
+ .mesh3d-label.center { color: var(--accent-blue); font-weight: 700; font-size: 12px; border-color: rgba(88,166,255,0.3); background: rgba(88,166,255,0.12); text-shadow: 0 0 8px rgba(88,166,255,0.5); }
733
+ .mesh3d-label.online { color: #7ee8a0; border-color: rgba(63,185,80,0.25); text-shadow: 0 0 6px rgba(63,185,80,0.4); }
734
+ .mesh3d-label.offline { color: var(--accent-red); opacity: 0.7; border-color: rgba(248,81,73,0.2); }
735
+
650
736
  /* ── Modal ───────────────────────────────────────── */
651
737
  .modal-overlay {
652
738
  display: none;
@@ -917,11 +1003,9 @@ function generateQR(text) {
917
1003
  }
918
1004
 
919
1005
  // ── Configuration ──────────────────────────────────
920
- const SEED_BRIDGES = [
921
- { name: 'bridge-alpha', url: 'http://144.202.48.217:9333' },
922
- { name: 'bridge-beta', url: 'http://45.63.77.31:9333' }
923
- ];
924
- let BRIDGES = [...SEED_BRIDGES];
1006
+ // Bootstrap from self — discover the rest of the mesh via /discover
1007
+ const SEED_URLS = [window.location.origin];
1008
+ let BRIDGES = SEED_URLS.map(url => ({ name: null, url }));
925
1009
  const POLL_INTERVAL = 5000;
926
1010
  let discoveryDone = false;
927
1011
 
@@ -978,10 +1062,17 @@ async function fetchBridge(bridge) {
978
1062
  const r = await fetch(bridge.url + '/status' + getAuthParam(bridge.url), { signal: AbortSignal.timeout(5000) });
979
1063
  if (!r.ok) throw new Error('HTTP ' + r.status);
980
1064
  const data = await r.json();
981
- data._name = (data.bridge && data.bridge.name) || bridge.name; data._url = bridge.url; data._error = null; data._lastSeen = Date.now();
1065
+ // Bridge self-reports its name use that as canonical, fall back to URL-derived name
1066
+ const selfName = (data.bridge && data.bridge.name) ? data.bridge.name : null;
1067
+ const fallbackName = bridge.name || ('bridge-' + new URL(bridge.url).hostname.split('.').pop());
1068
+ data._name = selfName || fallbackName;
1069
+ // Update BRIDGES entry so the name sticks for future polls
1070
+ if (selfName && !bridge.name) bridge.name = selfName;
1071
+ data._url = bridge.url; data._error = null; data._lastSeen = Date.now();
982
1072
  return data;
983
1073
  } catch (e) {
984
- return { _name: bridge.name, _url: bridge.url, _error: e.message,
1074
+ const fallbackName = bridge.name || ('bridge-' + new URL(bridge.url).hostname.split('.').pop());
1075
+ return { _name: fallbackName, _url: bridge.url, _error: e.message,
985
1076
  bridge: { pubkeyHex: null, endpoint: null, meshId: null, uptimeSeconds: 0 },
986
1077
  peers: { connected: 0, max: 0, list: [] },
987
1078
  headers: { bestHeight: -1, bestHash: null, count: 0 },
@@ -998,10 +1089,11 @@ async function fetchMempool(bridge) {
998
1089
  }
999
1090
  async function discoverBridges() {
1000
1091
  const known = new Map();
1001
- for (const b of SEED_BRIDGES) known.set(b.url, b.name);
1002
- for (const seed of SEED_BRIDGES) {
1092
+ // Seed URLs start with no name — /status will fill them in
1093
+ for (const url of SEED_URLS) known.set(url, null);
1094
+ for (const url of SEED_URLS) {
1003
1095
  try {
1004
- const r = await fetch(seed.url + '/discover', { signal: AbortSignal.timeout(5000) });
1096
+ const r = await fetch(url + '/discover', { signal: AbortSignal.timeout(5000) });
1005
1097
  if (!r.ok) continue;
1006
1098
  const data = await r.json();
1007
1099
  if (!data.bridges) continue;
@@ -1009,8 +1101,8 @@ async function discoverBridges() {
1009
1101
  if (!b.statusUrl) continue;
1010
1102
  const base = b.statusUrl.replace(/\/status$/, '');
1011
1103
  if (!known.has(base)) {
1012
- const name = b.name || 'bridge-' + (b.pubkeyHex ? b.pubkeyHex.slice(0, 8) : known.size);
1013
- known.set(base, name);
1104
+ // Use name from /discover if available, otherwise placeholder until /status fills it in
1105
+ known.set(base, b.name || null);
1014
1106
  }
1015
1107
  }
1016
1108
  } catch {}
@@ -1031,12 +1123,9 @@ async function pollAll() {
1031
1123
  if (!selectedBridge && bridgeData.size > 0) {
1032
1124
  selectedBridge = bridgeData.keys().next().value;
1033
1125
  }
1034
- // Fetch price from first online bridge
1035
- const firstOnline = results.find(b => !b._error);
1036
- if (firstOnline) {
1037
- fetch(firstOnline._url + '/price', { signal: AbortSignal.timeout(5000) })
1038
- .then(r => r.ok ? r.json() : null).then(d => { if (d) latestPrice = d; }).catch(() => {});
1039
- }
1126
+ // Fetch price from self (same-origin, always works)
1127
+ fetch('/price', { signal: AbortSignal.timeout(5000) })
1128
+ .then(r => r.ok ? r.json() : null).then(d => { if (d) latestPrice = d; }).catch(() => {});
1040
1129
  renderHeader();
1041
1130
  renderBridgeRail();
1042
1131
  renderActiveTab(true);
@@ -1088,6 +1177,8 @@ function setTab(tab) {
1088
1177
  if (inp) savedExplorerInput = inp.value;
1089
1178
  if (res) savedExplorerResult = res.innerHTML;
1090
1179
  }
1180
+ // Destroy 3D scene when leaving overview
1181
+ if (activeTab === 'overview' && tab !== 'overview') destroyMeshMap3D();
1091
1182
  activeTab = tab;
1092
1183
  const buttons = document.querySelectorAll('#headerTabs button');
1093
1184
  const tabs = ['overview', 'mempool', 'explorer', 'inscriptions', 'tokens', 'apps'];
@@ -1101,6 +1192,13 @@ function renderActiveTab(fromPoll) {
1101
1192
  const el = document.getElementById('tabContent');
1102
1193
  // Skip apps/explorer re-render during poll (preserves state)
1103
1194
  if (fromPoll && (activeTab === 'apps' || activeTab === 'explorer' || activeTab === 'inscriptions' || activeTab === 'tokens')) return;
1195
+ // On poll, if overview tab has active 3D scene, just update data — don't rebuild DOM
1196
+ if (fromPoll && activeTab === 'overview' && mesh3d) {
1197
+ updateMeshMap3D();
1198
+ return;
1199
+ }
1200
+ // Destroy 3D before rebuilding overview DOM
1201
+ if (activeTab === 'overview') destroyMeshMap3D();
1104
1202
  let html;
1105
1203
  switch (activeTab) {
1106
1204
  case 'overview': html = renderOverviewTab(); break;
@@ -1115,6 +1213,20 @@ function renderActiveTab(fromPoll) {
1115
1213
  if (el.innerHTML !== html) {
1116
1214
  el.innerHTML = html;
1117
1215
  if (activeTab === 'explorer') restoreExplorer();
1216
+ // Init 3D mesh after overview DOM is in place
1217
+ if (activeTab === 'overview') {
1218
+ const onlineBridges = [...bridgeData.values()].filter(b => !b._error);
1219
+ if (onlineBridges.length > 0) {
1220
+ setTimeout(() => {
1221
+ if (!initMeshMap3D('mesh3dCanvas')) {
1222
+ // Fallback to 2D SVG if WebGL unavailable
1223
+ const b = bridgeData.get(selectedBridge);
1224
+ const container = document.getElementById('mesh3dCanvas');
1225
+ if (container && b) container.innerHTML = '<div class="mesh3d-fallback">' + renderMiniMap(b) + '<span>WebGL not available</span></div>';
1226
+ }
1227
+ }, 50);
1228
+ }
1229
+ }
1118
1230
  }
1119
1231
  }
1120
1232
 
@@ -1159,6 +1271,14 @@ function renderOverviewTab() {
1159
1271
 
1160
1272
  if (b._error) html += '<div class="error-banner">' + b._error + '</div>';
1161
1273
 
1274
+ // 3D Mesh Map — compact inline, click to expand
1275
+ const onlineBridgeCount = [...bridgeData.values()].filter(bb => !bb._error).length;
1276
+ if (onlineBridgeCount > 0) {
1277
+ html += '<div class="detail-card glass" style="margin-bottom:12px;padding:10px 14px"><h3 style="margin-bottom:6px">Mesh Topology (' + onlineBridgeCount + ' nodes, ' + bridgeData.size + ' known) <span style="font-size:9px;color:var(--text-dim);font-weight:400;text-transform:none;letter-spacing:0">— click to expand</span></h3>';
1278
+ html += '<div class="mesh3d-container" id="mesh3dCanvas" onclick="expandMeshMap()"></div>';
1279
+ html += '</div>';
1280
+ }
1281
+
1162
1282
  html += '<div class="cards-grid">';
1163
1283
 
1164
1284
  // Bridge info card
@@ -1206,17 +1326,22 @@ function renderOverviewTab() {
1206
1326
  }
1207
1327
 
1208
1328
  // Peers card
1209
- html += '<div class="detail-card glass"><h3>Peers (' + b.peers.connected + '/' + b.peers.max + ')</h3>';
1329
+ html += '<div class="detail-card glass"><h3>Peers (' + b.peers.connected + ')</h3>';
1210
1330
  if (b.peers.list.length === 0) {
1211
1331
  html += '<div style="color:var(--text-dim);font-size:12px">No peers connected</div>';
1212
1332
  } else {
1213
- for (const p of b.peers.list) {
1333
+ const showAll = window._showAllPeers || false;
1334
+ const visiblePeers = showAll ? b.peers.list : b.peers.list.slice(0, 3);
1335
+ for (const p of visiblePeers) {
1214
1336
  const dotC = p.connected ? 'green' : 'red';
1215
- html += '<div class="peer-entry"><div><span class="peer-dot ' + dotC + '"></span><span class="peer-name">' + truncPubkey(p.pubkeyHex) + '</span> <span style="color:var(--text-dim)">score ' + (p.score !== undefined ? p.score.toFixed(2) : '?') + '</span></div>';
1337
+ html += '<div class="peer-entry" style="cursor:pointer" onclick="this.classList.toggle(\'expanded\')"><div><span class="peer-dot ' + dotC + '"></span><span class="peer-name">' + truncPubkey(p.pubkeyHex) + '</span> <span style="color:var(--text-dim)">score ' + (p.score !== undefined ? p.score.toFixed(2) : '?') + '</span></div>';
1216
1338
  html += '<div class="peer-meta">' + (p.endpoint || '') + (p.health ? ' &middot; ' + p.health : '') + '</div>';
1217
- if (p.scoreBreakdown) html += renderScoreBars(p.scoreBreakdown);
1339
+ if (p.scoreBreakdown) html += '<div class="score-bars">' + renderScoreBars(p.scoreBreakdown) + '</div>';
1218
1340
  html += '</div>';
1219
1341
  }
1342
+ if (b.peers.list.length > 3) {
1343
+ html += '<div style="text-align:center;padding:4px 0"><button class="btn ghost" style="font-size:11px;padding:2px 10px" onclick="window._showAllPeers=!window._showAllPeers;renderActiveTab()">' + (showAll ? 'Show less' : 'Show all ' + b.peers.list.length + ' peers') + '</button></div>';
1344
+ }
1220
1345
  }
1221
1346
  html += '</div>';
1222
1347
 
@@ -1235,11 +1360,6 @@ function renderOverviewTab() {
1235
1360
  html += '</div>';
1236
1361
  }
1237
1362
 
1238
- // Mini map card
1239
- if (b.peers && b.peers.list && b.peers.list.length > 0) {
1240
- html += '<div class="detail-card glass"><h3>Mesh Map</h3>' + renderMiniMap(b) + '</div>';
1241
- }
1242
-
1243
1363
  html += '</div>'; // cards-grid
1244
1364
 
1245
1365
  html += '<div style="color:var(--text-dim);font-size:10px;text-align:center;padding:12px 0">Updated ' + new Date().toLocaleTimeString() + '</div>';
@@ -1686,6 +1806,385 @@ function renderParsedData(type, protocol, parsed, txid, vout) {
1686
1806
  }
1687
1807
 
1688
1808
 
1809
+ // ── 3D Mesh Map ───────────────────────────────────
1810
+ let mesh3d = null; // { renderer, scene, camera, controls, animId, nodes, lines, particles, labels, container, lastInteraction }
1811
+
1812
+ function hasWebGL() {
1813
+ try { const c = document.createElement('canvas'); return !!(window.WebGLRenderingContext && (c.getContext('webgl') || c.getContext('experimental-webgl'))); } catch { return false; }
1814
+ }
1815
+
1816
+ function initMeshMap3D(containerId) {
1817
+ if (mesh3d) destroyMeshMap3D();
1818
+ const container = document.getElementById(containerId);
1819
+ if (!container || !hasWebGL() || typeof THREE === 'undefined') return false;
1820
+
1821
+ const w = container.clientWidth, h = container.clientHeight;
1822
+
1823
+ // ── Build topology from ALL bridges ──────────────
1824
+ const allBridges = [...bridgeData.values()].filter(b => !b._error);
1825
+ const n = allBridges.length;
1826
+ if (n === 0) return false;
1827
+
1828
+ // Map pubkey → bridge name for cross-referencing
1829
+ const pubkeyToName = new Map();
1830
+ for (const b of allBridges) {
1831
+ if (b.bridge && b.bridge.pubkeyHex) pubkeyToName.set(b.bridge.pubkeyHex, b._name);
1832
+ }
1833
+
1834
+ // Build edge list: pairs of bridge indices that are connected
1835
+ const edges = [];
1836
+ const edgeSet = new Set();
1837
+ for (let i = 0; i < n; i++) {
1838
+ const b = allBridges[i];
1839
+ if (!b.peers || !b.peers.list) continue;
1840
+ for (const p of b.peers.list) {
1841
+ if (!p.connected) continue;
1842
+ const peerName = pubkeyToName.get(p.pubkeyHex);
1843
+ if (!peerName) continue;
1844
+ const j = allBridges.findIndex(bb => bb._name === peerName);
1845
+ if (j < 0 || j === i) continue;
1846
+ const key = Math.min(i, j) + ':' + Math.max(i, j);
1847
+ if (edgeSet.has(key)) continue;
1848
+ edgeSet.add(key);
1849
+ // Get score data from the peer entry
1850
+ const score = p.score !== undefined ? p.score : 0.5;
1851
+ const breakdown = p.scoreBreakdown || null;
1852
+ edges.push({ a: i, b: j, score, breakdown });
1853
+ }
1854
+ }
1855
+
1856
+ // ── Scene setup ──────────────────────────────────
1857
+ const scene = new THREE.Scene();
1858
+ const camera = new THREE.PerspectiveCamera(50, w / h, 0.1, 200);
1859
+ // Scale camera distance based on node count
1860
+ const camDist = Math.max(14, n * 2.5);
1861
+ camera.position.set(0, camDist * 0.4, camDist);
1862
+
1863
+ const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
1864
+ renderer.setSize(w, h);
1865
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
1866
+ renderer.setClearColor(0x000000, 0);
1867
+ container.appendChild(renderer.domElement);
1868
+
1869
+ const controls = new THREE.OrbitControls(camera, renderer.domElement);
1870
+ controls.enableDamping = true;
1871
+ controls.dampingFactor = 0.08;
1872
+ controls.enablePan = false;
1873
+ controls.minDistance = 5;
1874
+ controls.maxDistance = camDist * 2;
1875
+ controls.autoRotate = true;
1876
+ controls.autoRotateSpeed = 0.5;
1877
+
1878
+ // ── Lights ───────────────────────────────────────
1879
+ scene.add(new THREE.AmbientLight(0x334466, 0.8));
1880
+ const dirLight = new THREE.DirectionalLight(0xffffff, 0.9);
1881
+ dirLight.position.set(5, 10, 7);
1882
+ scene.add(dirLight);
1883
+ const rimLight = new THREE.DirectionalLight(0x58a6ff, 0.3);
1884
+ rimLight.position.set(-3, -5, -3);
1885
+ scene.add(rimLight);
1886
+
1887
+ // ── Starfield ────────────────────────────────────
1888
+ const starGeo = new THREE.BufferGeometry();
1889
+ const starPositions = new Float32Array(400 * 3);
1890
+ for (let i = 0; i < 400; i++) {
1891
+ starPositions[i * 3] = (Math.random() - 0.5) * 120;
1892
+ starPositions[i * 3 + 1] = (Math.random() - 0.5) * 120;
1893
+ starPositions[i * 3 + 2] = (Math.random() - 0.5) * 120;
1894
+ }
1895
+ starGeo.setAttribute('position', new THREE.BufferAttribute(starPositions, 3));
1896
+ scene.add(new THREE.Points(starGeo, new THREE.PointsMaterial({ color: 0xffffff, size: 0.15, transparent: true, opacity: 0.35 })));
1897
+
1898
+ // ── Node positions — even distribution in 3D ─────
1899
+ const orbitRadius = Math.max(5, n * 1.2);
1900
+ const nodePositions = [];
1901
+ for (let i = 0; i < n; i++) {
1902
+ if (n === 1) {
1903
+ nodePositions.push(new THREE.Vector3(0, 0, 0));
1904
+ } else if (n === 2) {
1905
+ nodePositions.push(new THREE.Vector3(i === 0 ? -3 : 3, 0, 0));
1906
+ } else {
1907
+ // Distribute on a circle (flatten Y) for small counts, Fibonacci sphere for large
1908
+ const phi = Math.acos(1 - 2 * (i + 0.5) / n);
1909
+ const theta = Math.PI * (1 + Math.sqrt(5)) * i;
1910
+ const x = orbitRadius * Math.sin(phi) * Math.cos(theta);
1911
+ const y = orbitRadius * Math.cos(phi) * 0.35;
1912
+ const z = orbitRadius * Math.sin(phi) * Math.sin(theta);
1913
+ nodePositions.push(new THREE.Vector3(x, y, z));
1914
+ }
1915
+ }
1916
+
1917
+ // ── Create bridge nodes ──────────────────────────
1918
+ const nodes = [];
1919
+ const labelEls = [];
1920
+ const glowRings = [];
1921
+
1922
+ for (let i = 0; i < n; i++) {
1923
+ const b = allBridges[i];
1924
+ const isSelected = b._name === selectedBridge;
1925
+ const isOnline = !b._error;
1926
+ const peerCount = b.peers ? b.peers.connected : 0;
1927
+
1928
+ // All bridges same size — color based on health only
1929
+ const radius = 0.65;
1930
+ const nodeGeo = new THREE.SphereGeometry(radius, 32, 32);
1931
+
1932
+ let color, emissive, emissiveIntensity;
1933
+ if (isOnline && peerCount > 0) {
1934
+ color = 0x3fb950; emissive = 0x2a5a2a; emissiveIntensity = 0.7;
1935
+ } else if (isOnline) {
1936
+ color = 0xd29922; emissive = 0x4a3a10; emissiveIntensity = 0.5;
1937
+ } else {
1938
+ color = 0xf85149; emissive = 0x5a2020; emissiveIntensity = 0.5;
1939
+ }
1940
+
1941
+ const nodeMat = new THREE.MeshStandardMaterial({ color, emissive, emissiveIntensity, roughness: 0.2, metalness: 0.6 });
1942
+ const nodeMesh = new THREE.Mesh(nodeGeo, nodeMat);
1943
+ nodeMesh.position.copy(nodePositions[i]);
1944
+ scene.add(nodeMesh);
1945
+
1946
+ // Point light per node — same for all
1947
+ const lightColor = isOnline ? 0x3fb950 : 0xf85149;
1948
+ const pointLight = new THREE.PointLight(lightColor, 0.8, 6);
1949
+ nodeMesh.add(pointLight);
1950
+
1951
+ // Subtle selection ring — thin white ring if this is the sidebar-selected bridge
1952
+ if (isSelected) {
1953
+ const selRing = new THREE.Mesh(
1954
+ new THREE.RingGeometry(0.85, 0.95, 48),
1955
+ new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.35, side: THREE.DoubleSide })
1956
+ );
1957
+ selRing.rotation.x = -Math.PI / 2;
1958
+ selRing.position.copy(nodePositions[i]);
1959
+ scene.add(selRing);
1960
+ glowRings.push(selRing);
1961
+ }
1962
+
1963
+ nodes.push({ mesh: nodeMesh, bridge: b, isSelected });
1964
+
1965
+ // HTML label — bridge name, all same style (online/offline only)
1966
+ const label = document.createElement('div');
1967
+ label.className = 'mesh3d-label ' + (isOnline ? 'online' : 'offline');
1968
+ label.textContent = b._name;
1969
+ container.appendChild(label);
1970
+ labelEls.push({ el: label, nodeMesh });
1971
+ }
1972
+
1973
+ // ── Create edges between connected bridges ───────
1974
+ const edgeObjs = [];
1975
+ const particles = [];
1976
+
1977
+ for (const edge of edges) {
1978
+ const posA = nodePositions[edge.a];
1979
+ const posB = nodePositions[edge.b];
1980
+
1981
+ // Bezier midpoint — arc upward for visual clarity
1982
+ const mid = new THREE.Vector3(
1983
+ (posA.x + posB.x) * 0.5,
1984
+ (posA.y + posB.y) * 0.5 + orbitRadius * 0.2,
1985
+ (posA.z + posB.z) * 0.5
1986
+ );
1987
+ const curve = new THREE.QuadraticBezierCurve3(posA.clone(), mid, posB.clone());
1988
+ const tubeGeo = new THREE.TubeGeometry(curve, 48, 0.03, 8, false);
1989
+ const tubeMat = new THREE.MeshBasicMaterial({ color: 0x58a6ff, transparent: true, opacity: 0.45 });
1990
+ const tubeMesh = new THREE.Mesh(tubeGeo, tubeMat);
1991
+ scene.add(tubeMesh);
1992
+ edgeObjs.push({ mesh: tubeMesh, curve, edge });
1993
+
1994
+ // Traffic particles — count driven by mempool activity, speed by latency
1995
+ // Get mempool sizes for the two bridges on this edge
1996
+ const bridgeA = allBridges[edge.a];
1997
+ const bridgeB = allBridges[edge.b];
1998
+ const mempoolA = bridgeA.txs ? bridgeA.txs.mempool : 0;
1999
+ const mempoolB = bridgeB.txs ? bridgeB.txs.mempool : 0;
2000
+ const traffic = Math.max(mempoolA, mempoolB);
2001
+ // 1 idle particle (keepalive pulse) + 1 per 5 mempool txs, max 8
2002
+ const pCount = Math.min(8, 1 + Math.floor(traffic / 5));
2003
+ // Speed: slow idle crawl when quiet, faster with more RTT score (inverted — high score = low latency = faster)
2004
+ const rtt = (edge.breakdown && edge.breakdown.responseTime) ? edge.breakdown.responseTime : 0.5;
2005
+ const baseSpeed = traffic > 0 ? 0.0015 : 0.0006; // idle crawl vs active
2006
+ const speed = baseSpeed + rtt * 0.002;
2007
+ for (let j = 0; j < pCount; j++) {
2008
+ const pGeo = new THREE.SphereGeometry(0.07, 8, 8);
2009
+ // Idle particles are dimmer, active ones brighter
2010
+ const brightness = traffic > 0 ? 0x7ec8ff : 0x4a7a9a;
2011
+ const pMat = new THREE.MeshBasicMaterial({ color: brightness, transparent: true, opacity: traffic > 0 ? 0.9 : 0.4 });
2012
+ const pMesh = new THREE.Mesh(pGeo, pMat);
2013
+ scene.add(pMesh);
2014
+ particles.push({ mesh: pMesh, curve, t: j / pCount, speed, direction: j % 2 === 0 ? 1 : -1, isIdle: traffic === 0 });
2015
+ }
2016
+ }
2017
+
2018
+ // ── Auto-rotate pause ────────────────────────────
2019
+ let lastInteraction = 0;
2020
+ const onInteract = () => { lastInteraction = Date.now(); controls.autoRotate = false; };
2021
+ renderer.domElement.addEventListener('pointerdown', onInteract);
2022
+ renderer.domElement.addEventListener('wheel', onInteract);
2023
+
2024
+ // ── Animation loop ───────────────────────────────
2025
+ const clock = new THREE.Clock();
2026
+ let animId;
2027
+
2028
+ function animate() {
2029
+ animId = requestAnimationFrame(animate);
2030
+ clock.getDelta();
2031
+ const t = clock.getElapsedTime();
2032
+
2033
+ if (!controls.autoRotate && Date.now() - lastInteraction > 10000) controls.autoRotate = true;
2034
+ controls.update();
2035
+
2036
+ // Edge pulsing
2037
+ for (let i = 0; i < edgeObjs.length; i++) {
2038
+ edgeObjs[i].mesh.material.opacity = 0.3 + 0.2 * Math.sin(t * 2 + i * 1.3);
2039
+ }
2040
+
2041
+ // Glow ring animation on selected bridge
2042
+ for (let i = 0; i < glowRings.length; i++) {
2043
+ const gr = glowRings[i];
2044
+ gr.material.opacity = (i % 2 === 0 ? 0.15 : 0.05) + (i % 2 === 0 ? 0.12 : 0.05) * Math.sin(t * 1.5 + i);
2045
+ gr.rotation.z = t * (i % 2 === 0 ? 0.1 : -0.08);
2046
+ }
2047
+
2048
+ // Particles along edges — idle ones crawl & fade, active ones zip
2049
+ for (const p of particles) {
2050
+ p.t += p.speed * p.direction;
2051
+ if (p.t > 1) p.t -= 1;
2052
+ if (p.t < 0) p.t += 1;
2053
+ const pos = p.curve.getPointAt(Math.max(0, Math.min(1, p.t)));
2054
+ p.mesh.position.copy(pos);
2055
+ if (p.isIdle) {
2056
+ // Slow breathing pulse — barely visible keepalive
2057
+ p.mesh.material.opacity = 0.15 + 0.2 * Math.sin(p.t * Math.PI);
2058
+ } else {
2059
+ p.mesh.material.opacity = 0.4 + 0.5 * Math.sin(p.t * Math.PI);
2060
+ }
2061
+ }
2062
+
2063
+ // Project labels 3D → 2D
2064
+ const rect = renderer.domElement.getBoundingClientRect();
2065
+ for (const lb of labelEls) {
2066
+ const pos = lb.nodeMesh.position.clone();
2067
+ pos.y += (lb.el.classList.contains('center') ? 1.6 : 1.2);
2068
+ pos.project(camera);
2069
+ const sx = (pos.x * 0.5 + 0.5) * rect.width;
2070
+ const sy = (-pos.y * 0.5 + 0.5) * rect.height;
2071
+ lb.el.style.left = sx + 'px';
2072
+ lb.el.style.top = sy + 'px';
2073
+ lb.el.style.display = pos.z > 1 ? 'none' : '';
2074
+ }
2075
+
2076
+ renderer.render(scene, camera);
2077
+ }
2078
+ animate();
2079
+
2080
+ // Resize — reads from mesh3d.container so it works after expand/collapse swap
2081
+ const onResize = () => {
2082
+ const c = mesh3d ? mesh3d.container : container;
2083
+ const nw = c.clientWidth, nh = c.clientHeight;
2084
+ if (nw === 0 || nh === 0) return;
2085
+ camera.aspect = nw / nh;
2086
+ camera.updateProjectionMatrix();
2087
+ renderer.setSize(nw, nh);
2088
+ };
2089
+ window.addEventListener('resize', onResize);
2090
+
2091
+ mesh3d = { renderer, scene, camera, controls, animId, nodes, edges: edgeObjs, particles, labels: labelEls, container, onResize, glowRings };
2092
+ return true;
2093
+ }
2094
+
2095
+ function updateMeshMap3D() {
2096
+ if (!mesh3d) return;
2097
+ const allBridges = [...bridgeData.values()].filter(b => !b._error);
2098
+ // If bridge count changed, full rebuild needed
2099
+ if (allBridges.length !== mesh3d.nodes.length) return;
2100
+ // Update node visuals
2101
+ for (let i = 0; i < mesh3d.nodes.length && i < allBridges.length; i++) {
2102
+ const node = mesh3d.nodes[i];
2103
+ const b = allBridges[i];
2104
+ const isOnline = !b._error;
2105
+ const peerCount = b.peers ? b.peers.connected : 0;
2106
+ if (isOnline && peerCount > 0) {
2107
+ node.mesh.material.color.setHex(0x3fb950);
2108
+ node.mesh.material.emissive.setHex(0x2a5a2a);
2109
+ } else if (isOnline) {
2110
+ node.mesh.material.color.setHex(0xd29922);
2111
+ node.mesh.material.emissive.setHex(0x4a3a10);
2112
+ } else {
2113
+ node.mesh.material.color.setHex(0xf85149);
2114
+ node.mesh.material.emissive.setHex(0x5a2020);
2115
+ }
2116
+ node.bridge = b;
2117
+ // Update label
2118
+ if (mesh3d.labels[i]) {
2119
+ mesh3d.labels[i].el.className = 'mesh3d-label ' + (isOnline ? 'online' : 'offline');
2120
+ }
2121
+ }
2122
+ }
2123
+
2124
+ function destroyMeshMap3D() {
2125
+ if (!mesh3d) return;
2126
+ // Remove overlay if open
2127
+ const overlay = document.getElementById('mesh3dOverlay');
2128
+ if (overlay) overlay.remove();
2129
+ cancelAnimationFrame(mesh3d.animId);
2130
+ window.removeEventListener('resize', mesh3d.onResize);
2131
+ // Dispose geometries and materials
2132
+ mesh3d.scene.traverse(obj => {
2133
+ if (obj.geometry) obj.geometry.dispose();
2134
+ if (obj.material) {
2135
+ if (Array.isArray(obj.material)) obj.material.forEach(m => m.dispose());
2136
+ else obj.material.dispose();
2137
+ }
2138
+ });
2139
+ mesh3d.renderer.dispose();
2140
+ // Remove canvas
2141
+ if (mesh3d.renderer.domElement && mesh3d.renderer.domElement.parentNode) {
2142
+ mesh3d.renderer.domElement.parentNode.removeChild(mesh3d.renderer.domElement);
2143
+ }
2144
+ // Remove labels
2145
+ for (const lb of mesh3d.labels) {
2146
+ if (lb.el && lb.el.parentNode) lb.el.parentNode.removeChild(lb.el);
2147
+ }
2148
+ mesh3d = null;
2149
+ }
2150
+
2151
+ function expandMeshMap() {
2152
+ if (!mesh3d) return;
2153
+ // Create overlay
2154
+ const overlay = document.createElement('div');
2155
+ overlay.className = 'mesh3d-overlay';
2156
+ overlay.id = 'mesh3dOverlay';
2157
+ overlay.innerHTML = '<div class="mesh3d-expanded"><button class="mesh3d-close" onclick="collapseMeshMap()">&times;</button></div>';
2158
+ document.body.appendChild(overlay);
2159
+ // Click backdrop to close
2160
+ overlay.addEventListener('click', function(e) { if (e.target === overlay) collapseMeshMap(); });
2161
+ // Move canvas + labels into expanded container
2162
+ const expanded = overlay.querySelector('.mesh3d-expanded');
2163
+ expanded.appendChild(mesh3d.renderer.domElement);
2164
+ for (const lb of mesh3d.labels) {
2165
+ if (lb.el) expanded.appendChild(lb.el);
2166
+ }
2167
+ mesh3d.container = expanded;
2168
+ // Resize renderer to new container
2169
+ setTimeout(() => { mesh3d.onResize(); }, 50);
2170
+ }
2171
+
2172
+ function collapseMeshMap() {
2173
+ const overlay = document.getElementById('mesh3dOverlay');
2174
+ if (!overlay) return;
2175
+ // Move canvas + labels back to inline container
2176
+ const inline = document.getElementById('mesh3dCanvas');
2177
+ if (mesh3d && inline) {
2178
+ inline.appendChild(mesh3d.renderer.domElement);
2179
+ for (const lb of mesh3d.labels) {
2180
+ if (lb.el) inline.appendChild(lb.el);
2181
+ }
2182
+ mesh3d.container = inline;
2183
+ setTimeout(() => { mesh3d.onResize(); }, 50);
2184
+ }
2185
+ overlay.remove();
2186
+ }
2187
+
1689
2188
 
1690
2189
  // ── Mini peer map ──────────────────────────────────
1691
2190
  function renderMiniMap(b) {
package/lib/actions.js CHANGED
@@ -24,7 +24,7 @@ export async function runRegister ({ config, store, log }) {
24
24
  log('step', `Mesh: ${config.meshId}`)
25
25
  log('step', `Capabilities: ${config.capabilities.join(', ')}`)
26
26
 
27
- const { buildRegistrationTx } = await import('../../registry/lib/registration.js')
27
+ const { buildRegistrationTx } = await import('@relay-federation/registry/lib/registration.js')
28
28
  const { BSVNodeClient } = await import('./bsv-node-client.js')
29
29
 
30
30
  // Get UTXOs from local store
@@ -65,7 +65,7 @@ export async function runRegister ({ config, store, log }) {
65
65
 
66
66
  try {
67
67
  // Step 1: Build and broadcast stake bond tx
68
- const { buildStakeBondTx } = await import('../../registry/lib/stake-bond.js')
68
+ const { buildStakeBondTx } = await import('@relay-federation/registry/lib/stake-bond.js')
69
69
  const { MIN_STAKE_SATS } = await import('@relay-federation/common/protocol')
70
70
 
71
71
  log('step', `Building stake bond (${MIN_STAKE_SATS} sats)...`)
@@ -136,7 +136,7 @@ export async function runDeregister ({ config, store, reason = 'shutdown', log }
136
136
  log('step', `Pubkey: ${config.pubkeyHex}`)
137
137
  log('step', `Reason: ${reason}`)
138
138
 
139
- const { buildDeregistrationTx } = await import('../../registry/lib/registration.js')
139
+ const { buildDeregistrationTx } = await import('@relay-federation/registry/lib/registration.js')
140
140
  const { BSVNodeClient } = await import('./bsv-node-client.js')
141
141
 
142
142
  // Get UTXOs from local store
@@ -83,7 +83,6 @@ export class AnchorManager extends EventEmitter {
83
83
 
84
84
  /**
85
85
  * Connect to all configured anchors that we're not already connected to.
86
- * Respects maxPeers — if at capacity, still tries anchors (they're priority).
87
86
  *
88
87
  * @returns {number} Number of new connections initiated
89
88
  */
package/lib/config.js CHANGED
@@ -51,7 +51,6 @@ export async function initConfig (dir = DEFAULT_DIR, opts = {}) {
51
51
  port: 8333,
52
52
  statusPort: 9333,
53
53
  statusSecret: randomBytes(32).toString('hex'),
54
- maxPeers: 20,
55
54
  dataDir: join(dir, 'data'),
56
55
  seedPeers: [],
57
56
  // apps: [
@@ -20,11 +20,9 @@ import { PeerConnection } from './peer-connection.js'
20
20
  export class PeerManager extends EventEmitter {
21
21
  /**
22
22
  * @param {object} [opts]
23
- * @param {number} [opts.maxPeers=20] - Maximum number of peer connections
24
23
  */
25
- constructor (opts = {}) {
24
+ constructor () {
26
25
  super()
27
- this.maxPeers = opts.maxPeers || 20
28
26
  /** @type {Map<string, PeerConnection>} pubkeyHex → PeerConnection */
29
27
  this.peers = new Map()
30
28
  this._server = null
@@ -36,17 +34,13 @@ export class PeerManager extends EventEmitter {
36
34
  * @param {object} peer - Peer from buildPeerList()
37
35
  * @param {string} peer.pubkeyHex
38
36
  * @param {string} peer.endpoint
39
- * @returns {PeerConnection|null} The connection, or null if at capacity
37
+ * @returns {PeerConnection|null} The connection, or null if already connected
40
38
  */
41
39
  connectToPeer (peer) {
42
40
  if (this.peers.has(peer.pubkeyHex)) {
43
41
  return this.peers.get(peer.pubkeyHex)
44
42
  }
45
43
 
46
- if (this.peers.size >= this.maxPeers) {
47
- return null
48
- }
49
-
50
44
  const conn = new PeerConnection({
51
45
  endpoint: peer.endpoint,
52
46
  pubkeyHex: peer.pubkeyHex
@@ -74,11 +68,6 @@ export class PeerManager extends EventEmitter {
74
68
  return null
75
69
  }
76
70
 
77
- if (this.peers.size >= this.maxPeers) {
78
- socket.close()
79
- return null
80
- }
81
-
82
71
  const conn = new PeerConnection({
83
72
  endpoint,
84
73
  pubkeyHex,
@@ -119,7 +119,6 @@ export class StatusServer {
119
119
  },
120
120
  peers: {
121
121
  connected: this._peerManager ? this._peerManager.connectedCount() : 0,
122
- max: this._peerManager ? this._peerManager.maxPeers : 0,
123
122
  list: peers
124
123
  },
125
124
  headers: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relay-federation/bridge",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Bridge server — WebSocket peering, header sync, tx relay, CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,7 +11,8 @@
11
11
  },
12
12
  "dependencies": {
13
13
  "@bsv/sdk": "^1.10.1",
14
- "@relay-federation/common": "^0.1.0",
14
+ "@relay-federation/common": "^0.2.0",
15
+ "@relay-federation/registry": "^0.2.0",
15
16
  "level": "^10.0.0",
16
17
  "ws": "^8.19.0"
17
18
  },