Skip to content

Commit 8f05953

Browse files
Melvin Carvalhoclaude
andcommitted
mesh.html: ?gossip=1 — make multi-hop gossip visible
peer-mesh.js: sendToOne() — send to one neighbor. mesh.html: with ?gossip=1 the seed sends each block to ONE neighbor instead of broadcasting; the gossip layer (b) carries it to every peer — so a tab the source never sent to still receives every block. Default (no param) stays full broadcast. Verified node-side (test-mesh-gossip.mjs): full 3-mesh, source sends to one peer, B and C both receive (C via gossip). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7e14235 commit 8f05953

3 files changed

Lines changed: 62 additions & 2 deletions

File tree

‎mesh.html‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ <h1>bitcoin-kernel / browser-node — mesh <span class="dim">(block propagation
4949
structure + proof-of-work (no UTXO set, so not full-consensus). Every tab connects to every other (full mesh), and a received
5050
block is <b>gossiped onward</b> to other peers (deduped by a seen-set), so it propagates across the mesh, multi-hop. Next: stream the full chain.
5151
Default room <code>c0ffeebabe123456</code>; override with
52-
<code>?room=&lt;hex&gt;</code>, the signaling server with <code>?signal=wss://your.pod/.webrtc</code>, and the block count with <code>?blocks=N</code> (default 5000).</p>
52+
<code>?room=&lt;hex&gt;</code>, the signaling server with <code>?signal=wss://your.pod/.webrtc</code>, and the block count with <code>?blocks=N</code> (default 5000).
53+
See gossip in action with <code>?gossip=1</code>: the source sends each block to <b>one</b> neighbor and the mesh carries it to the rest (so a tab the source never sent to still gets every block).</p>
5354

5455
<script type="module">
5556
import { Codec } from './engine/codec/codec.js';
@@ -69,6 +70,7 @@ <h1>bitcoin-kernel / browser-node — mesh <span class="dim">(block propagation
6970
const signalUrl = q.get('signal') || 'wss://melvincarvalho.com/.webrtc';
7071
const room = q.get('room') || 'c0ffeebabe123456'; // mesh's own room (distinct from the bridge's room)
7172
const seedCount = Math.max(1, parseInt(q.get('blocks'), 10) || 5000); // ?blocks=N (default 5000)
73+
const gossipMode = q.get('gossip') === '1'; // ?gossip=1 → seed sends each block to ONE neighbor; the mesh gossips it onward
7274

7375
let codec, p2p, be, peer = null, genesisHash = null;
7476
let relayed = 0;
@@ -90,6 +92,7 @@ <h1>bitcoin-kernel / browser-node — mesh <span class="dim">(block propagation
9092
// relay them to the mesh peer — block propagation at volume, browser→browser.
9193
async function seedBlocks(n = 5000) {
9294
$('seed').disabled = true;
95+
if (gossipMode) log('gossip mode: each block goes to ONE neighbor — the mesh carries it to the rest', 'ok');
9396
const bridgeSignal = q.get('signal') || 'wss://melvincarvalho.com/.webrtc';
9497
const bridgeRoom = q.get('bridgeRoom') || 'b17c0192abad1deacafe'; // the bridge's room (its network source)
9598
const net = new RtcPeer(p2p, codec);
@@ -123,7 +126,7 @@ <h1>bitcoin-kernel / browser-node — mesh <span class="dim">(block propagation
123126
for (const bytes of out) {
124127
if (bytes.length > MAX_MSG) { skipped++; continue; } // too big for one DC message (chunking is a TODO)
125128
await drain(peer);
126-
peer.send('block', toHex(bytes)); // broadcast to every connected peer
129+
if (gossipMode) peer.sendToOne('block', toHex(bytes)); else peer.send('block', toHex(bytes)); // gossip: one neighbor; the mesh carries it onward
127130
got++; $('seeded').textContent = fmt(got);
128131
}
129132
setStatus(`seed: relaying ${fmt(got)}/${fmt(hashes.length)}${skipped ? ` (${skipped} too large, skipped)` : ''}…`, 'warn');

‎peer-mesh.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ export class MeshPeer {
8383
send(command, payload) { const bytes = this.engine.encodeMessage(command, payload); for (const dc of this.channels) if (dc.readyState === 'open') dc.send(bytes); }
8484
// Gossip: re-broadcast to every peer except the one it came from.
8585
forward(command, payload, exceptDc) { const bytes = this.engine.encodeMessage(command, payload); for (const dc of this.channels) if (dc !== exceptDc && dc.readyState === 'open') dc.send(bytes); }
86+
// Send to ONE neighbor (for gossip-mode demos: the mesh carries it onward).
87+
sendToOne(command, payload) { const bytes = this.engine.encodeMessage(command, payload); for (const dc of this.channels) if (dc.readyState === 'open') { dc.send(bytes); return; } }
8688
bufferedHigh(max) { for (const dc of this.channels) if (dc.bufferedAmount > max) return true; return false; }
8789
close() { for (const pc of this.pcs) { try { pc.close(); } catch {} } try { this.ws?.close(); } catch {} }
8890

‎test-mesh-gossip.mjs‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// De-risk gossip mode on a FULL mesh: 3 peers all connected; the source sends a
2+
// block to ONE neighbor only; gossip forwarding carries it to the third peer
3+
// (the one the source never sent to). Proves the ?gossip=1 browser demo.
4+
import { readFile } from 'node:fs/promises';
5+
import { WebSocket } from 'ws';
6+
import nodeDataChannel from 'node-datachannel';
7+
import { Codec } from './engine/codec/codec.js';
8+
import { P2pEngine } from './engine/codec/p2p.js';
9+
import { startSignaling } from './signaling-stub.mjs';
10+
11+
const D = new URL('./', import.meta.url);
12+
const jl = async (n) => JSON.parse(await readFile(new URL(`engine/schema/${n}.jsonld`, D), 'utf8'));
13+
const PORT = 9091, ROOM = 'b17c0192abad1deacafe', SIGNAL = `ws://localhost:${PORT}/.webrtc`, ICE = ['stun:stun.l.google.com:19302'];
14+
15+
const codec = new Codec(await jl('core'), await jl('proof'), await jl('p2p'));
16+
const p2p = P2pEngine.fromSchemas(codec, await jl('p2p'), await jl('chain'), 'btc:testnet4');
17+
const blockHex = (await readFile(new URL('data/block-26000.hex', D), 'utf8')).trim();
18+
const expectHash = codec.blockHash(codec.decode('Block', blockHex).header);
19+
const log = (m) => console.log(m);
20+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
21+
const gather = (pc) => new Promise((res) => { if (pc.gatheringState() === 'complete') return res(pc.localDescription()); pc.onGatheringStateChange((s) => { if (s === 'complete') res(pc.localDescription()); }); });
22+
23+
// Full-mesh node peer WITH gossip forwarding (mirror of peer-mesh.js + onMessage).
24+
function makeMeshNode(name, { onBlock } = {}) {
25+
const ws = new WebSocket(SIGNAL);
26+
const offerPCs = new Map(), openDCs = [], seen = new Set();
27+
let joined = false;
28+
const bufs = new WeakMap();
29+
const rx = (dc) => (m) => { let b = bufs.get(dc) || Buffer.alloc(0); b = Buffer.concat([b, Buffer.isBuffer(m) ? m : Buffer.from(m)]); const r = p2p.decodeStream(new Uint8Array(b)); bufs.set(dc, b.subarray(r.consumed)); for (const msg of r.messages) { if (msg.command !== 'block') continue; const h = codec.blockHash(msg.payload.header); if (seen.has(h)) continue; seen.add(h); onBlock?.(name, h); for (const o of openDCs) if (o !== dc && o.isOpen()) { try { o.sendMessageBinary(Buffer.from(p2p.encodeMessage('block', msg.payload))); } catch {} } } };
30+
const setupDC = (dc) => { bufs.set(dc, Buffer.alloc(0)); dc.onOpen(() => openDCs.push(dc)); dc.onMessage(rx(dc)); };
31+
ws.on('open', () => ws.send(JSON.stringify({ type: 'announce', resource: ROOM, offers: [] })));
32+
ws.on('message', async (data) => { let m; try { m = JSON.parse(data.toString()); } catch { return; }
33+
if (m.type === 'resource-peers' && !joined) { joined = true; const offers = []; for (let i = 0; i < m.count; i++) { const pc = new nodeDataChannel.PeerConnection(`${name}o${i}`, { iceServers: ICE }); setupDC(pc.createDataChannel('mesh')); const d = await gather(pc); const id = `${name}-${i}`; offerPCs.set(id, pc); offers.push({ sdp: d.sdp, offer_id: id }); } if (offers.length) ws.send(JSON.stringify({ type: 'announce', resource: ROOM, offers })); }
34+
else if (m.type === 'offer' && m.resource === ROOM) { const pc = new nodeDataChannel.PeerConnection(`${name}a`, { iceServers: ICE }); pc.onDataChannel(setupDC); pc.setRemoteDescription(m.sdp, 'offer'); const d = await gather(pc); ws.send(JSON.stringify({ type: 'answer', resource: ROOM, to: m.from, offer_id: m.offer_id, sdp: d.sdp })); }
35+
else if (m.type === 'answer' && m.resource === ROOM) offerPCs.get(m.offer_id)?.setRemoteDescription(m.sdp, 'answer'); });
36+
return { name, openDCs, sendToOne: (cmd, payload) => openDCs[0]?.sendMessageBinary(Buffer.from(p2p.encodeMessage(cmd, payload))) };
37+
}
38+
39+
const sig = startSignaling(PORT, { log });
40+
let done = false; const end = (c) => { if (done) return; done = true; try { sig.close(); } catch {} try { nodeDataChannel.cleanup(); } catch {} setImmediate(() => process.exit(c)); };
41+
const to = setTimeout(() => { console.log('\n❌ timeout'); end(1); }, 40000);
42+
43+
const got = new Set();
44+
const onBlock = (who, h) => { if (h !== expectHash) return; got.add(who); log(` ${who}: received + validated the block`); if (got.has('B') && got.has('C')) { clearTimeout(to); log('\n✅ gossip mode: source A sent to ONE neighbor; B and C both got it (one via gossip) on a full mesh'); end(0); } };
45+
46+
log(`\nfull 3-mesh; A will send to ONE neighbor only…`);
47+
const A = makeMeshNode('A');
48+
await sleep(1200);
49+
const B = makeMeshNode('B', { onBlock });
50+
await sleep(1200);
51+
const C = makeMeshNode('C', { onBlock });
52+
for (let i = 0; i < 40 && !(A.openDCs.length >= 2 && B.openDCs.length >= 2 && C.openDCs.length >= 2); i++) await sleep(400);
53+
log(` mesh up — A:${A.openDCs.length} B:${B.openDCs.length} C:${C.openDCs.length}; A sending block to ONE neighbor`);
54+
if (A.openDCs.length < 2) { console.log('\n❌ mesh did not fully form'); end(1); }
55+
else A.sendToOne('block', blockHex);

0 commit comments

Comments
 (0)