| … | … | @@ -12,10 +12,12 @@ Install Pear once: |
| 12 | 12 | |
| 13 | 13 | ``` |
| 14 | 14 | npm i -g pear |
| 15 | pear # bootstraps the runtime | |
| 16 | export PATH="$HOME/Library/Application Support/pear/bin:$PATH" | |
| 15 | pear # bootstraps the runtime + adds pear to your PATH | |
| 17 | 16 | ``` |
| 18 | 17 | |
| 18 | Then open a new terminal so the PATH change takes effect (`which pear` should | |
| 19 | print `~/.local/bin/pear` on macOS/Linux). | |
| 20 | ||
| 19 | 21 | Run p2pbuilders: |
| 20 | 22 | |
| 21 | 23 | ``` |
| … | … | @@ -106,7 +108,7 @@ scripts/ |
| 106 | 108 | publish.js publish a directory to hiverelay |
| 107 | 109 | seed-pear.js ask hiverelay to seed a pear:// key |
| 108 | 110 | probe-relays.mjs list relays currently pinning the live key |
| 109 | test/ 41 tests covering every layer | |
| 111 | test/ 42 tests covering every layer | |
| 110 | 112 | ``` |
| 111 | 113 | |
| 112 | 114 | ## How it works |
| … | … | @@ -138,7 +140,7 @@ npm install |
| 138 | 140 | # run the optional Node dev server (browser at http://localhost:8787) |
| 139 | 141 | npm run dev |
| 140 | 142 | |
| 141 | # run the full test suite (41 tests) | |
| 143 | # run the full test suite (42 backend tests + 24 web engine checks) | |
| 142 | 144 | npm test |
| 143 | 145 | ``` |
| 144 | 146 | |
| … | … | @@ -180,7 +182,7 @@ node scripts/publish.js # publish ./public as a hyperdrive |
| 180 | 182 | |
| 181 | 183 | **Shipping now:** |
| 182 | 184 | - Terminal app (TUI) via `pear run pear://…` |
| 183 | - Full backend verified with 41 tests | |
| 185 | - Full backend verified with 42 tests (plus 24 web engine checks) | |
| 184 | 186 | - Auto-seeding to the public hiverelay network |
| 185 | 187 | |
| 186 | 188 | **Parked:** |
| … | … | @@ -291,15 +291,16 @@ |
| 291 | 291 | <p><strong>install pear</strong> — the p2p runtime holepunch ships.</p> |
| 292 | 292 | <pre><code>npm install -g pear |
| 293 | 293 | pear</code></pre> |
| 294 | <p>the second command bootstraps the real runtime. takes 30s on first run.</p> | |
| 294 | <p> | |
| 295 | the second command bootstraps the real runtime and adds it to your | |
| 296 | <code>PATH</code>. takes 30s on first run. | |
| 297 | </p> | |
| 295 | 298 | </li> |
| 296 | 299 | |
| 297 | 300 | <li> |
| 298 | <p><strong>add pear to your path.</strong> one-time setup.</p> | |
| 299 | <pre><code>echo 'export PATH="$HOME/Library/Application Support/pear/bin:$PATH"' >> ~/.zshrc | |
| 300 | source ~/.zshrc | |
| 301 | which pear | |
| 302 | # should print: /Users/you/Library/Application Support/pear/bin/pear</code></pre> | |
| 301 | <p><strong>open a new terminal</strong> so the <code>PATH</code> change takes effect.</p> | |
| 302 | <pre><code>which pear | |
| 303 | # should print: ~/.local/bin/pear</code></pre> | |
| 303 | 304 | </li> |
| 304 | 305 | |
| 305 | 306 | <li> |
| … | … | @@ -37,7 +37,8 @@ |
| 37 | 37 | "pear:stage": "pear stage dev .", |
| 38 | 38 | "relay": "node src/relay/server.js", |
| 39 | 39 | "publish": "node scripts/publish.js", |
| 40 | "test": "node test/m1.js && node test/m2.js && node test/m3.js && node test/m4.js && node test/m5.js && node test/m6.js && node test/m7.js && node test/m8.js && node test/m9.js && node test/m10-bare.js && node test/m11.js" | |
| 40 | "test": "node test/m1.js && node test/m2.js && node test/m3.js && node test/m4.js && node test/m5.js && node test/m6.js && node test/m7.js && node test/m8.js && node test/m9.js && node test/m10-bare.js && node test/m11.js && npm run test:web", | |
| 41 | "test:web": "node web/test/engine.mjs" | |
| 41 | 42 | }, |
| 42 | 43 | "pear": { |
| 43 | 44 | "name": "p2pbuilders", |
| … | … | @@ -291,15 +291,16 @@ |
| 291 | 291 | <p><strong>install pear</strong> — the p2p runtime holepunch ships.</p> |
| 292 | 292 | <pre><code>npm install -g pear |
| 293 | 293 | pear</code></pre> |
| 294 | <p>the second command bootstraps the real runtime. takes 30s on first run.</p> | |
| 294 | <p> | |
| 295 | the second command bootstraps the real runtime and adds it to your | |
| 296 | <code>PATH</code>. takes 30s on first run. | |
| 297 | </p> | |
| 295 | 298 | </li> |
| 296 | 299 | |
| 297 | 300 | <li> |
| 298 | <p><strong>add pear to your path.</strong> one-time setup.</p> | |
| 299 | <pre><code>echo 'export PATH="$HOME/Library/Application Support/pear/bin:$PATH"' >> ~/.zshrc | |
| 300 | source ~/.zshrc | |
| 301 | which pear | |
| 302 | # should print: /Users/you/Library/Application Support/pear/bin/pear</code></pre> | |
| 301 | <p><strong>open a new terminal</strong> so the <code>PATH</code> change takes effect.</p> | |
| 302 | <pre><code>which pear | |
| 303 | # should print: ~/.local/bin/pear</code></pre> | |
| 303 | 304 | </li> |
| 304 | 305 | |
| 305 | 306 | <li> |
| … | … | @@ -14,13 +14,22 @@ const pubkeyArray = cenc.array(cenc.fixed(PUBKEY_BYTES)) |
| 14 | 14 | // selfPubkey — our own user pubkey (so we don't echo it back) |
| 15 | 15 | // getKnownKeys() — returns Buffer[] of pubkeys we'd like to gossip |
| 16 | 16 | // onKey(pubkey) — callback for each new pubkey the peer announces |
| 17 | function setupAnnounce (muxer, { selfPubkey, getKnownKeys, onKey }) { | |
| 17 | // onOpen(handle) — channel is live; handle.sendKeys() can push later updates | |
| 18 | // onClose(handle) — channel closed; drop the handle | |
| 19 | // Returns a handle with sendKeys(keys) for announcing keys learned after the | |
| 20 | // initial exchange (transitive gossip), or null if the peer lacks the protocol. | |
| 21 | function setupAnnounce (muxer, { selfPubkey, getKnownKeys, onKey, onOpen, onClose }) { | |
| 18 | 22 | let keysMsg |
| 23 | let handle | |
| 19 | 24 | const channel = muxer.createChannel({ |
| 20 | 25 | protocol: PROTOCOL, |
| 21 | 26 | onopen () { |
| 22 | 27 | const keys = getKnownKeys().slice(0, MAX_KEYS_PER_ANNOUNCE) |
| 23 | 28 | keysMsg.send(keys) |
| 29 | if (onOpen) onOpen(handle) | |
| 30 | }, | |
| 31 | onclose () { | |
| 32 | if (onClose) onClose(handle) | |
| 24 | 33 | } |
| 25 | 34 | }) |
| 26 | 35 | if (!channel) return null // peer doesn't support the protocol |
| … | … | @@ -34,8 +43,15 @@ function setupAnnounce (muxer, { selfPubkey, getKnownKeys, onKey }) { |
| 34 | 43 | } |
| 35 | 44 | } |
| 36 | 45 | }) |
| 46 | handle = { | |
| 47 | channel, | |
| 48 | sendKeys (keys) { | |
| 49 | if (!keys.length) return | |
| 50 | keysMsg.send(keys.slice(0, MAX_KEYS_PER_ANNOUNCE)) | |
| 51 | } | |
| 52 | } | |
| 37 | 53 | channel.open() |
| 38 | return channel | |
| 54 | return handle | |
| 39 | 55 | } |
| 40 | 56 | |
| 41 | 57 | module.exports = { setupAnnounce, PROTOCOL, MAX_KEYS_PER_ANNOUNCE } |
| … | … | @@ -26,6 +26,7 @@ class Node extends EventEmitter { |
| 26 | 26 | this._cleanup = cleanup |
| 27 | 27 | this.swarm = null |
| 28 | 28 | this._tracked = new Map() // hex(pubkey) -> remote Hypercore |
| 29 | this._announces = new Set() // live announce-channel handles | |
| 29 | 30 | } |
| 30 | 31 | |
| 31 | 32 | static async openDisk (dir, opts) { |
| … | … | @@ -43,7 +44,7 @@ class Node extends EventEmitter { |
| 43 | 44 | get pubkey () { return this.core.key } |
| 44 | 45 | get length () { return this.core.length } |
| 45 | 46 | |
| 46 | async attachSwarm ({ bootstrap, keyPair, autoTrack = true, relays = [] } = {}) { | |
| 47 | async attachSwarm ({ bootstrap, keyPair, autoTrack = true, relays = [], firewalled, host, port } = {}) { | |
| 47 | 48 | if (this.swarm) throw new Error('swarm already attached') |
| 48 | 49 | const onConnection = ({ muxer }) => { |
| 49 | 50 | setupAnnounce(muxer, { |
| … | … | @@ -52,10 +53,12 @@ class Node extends EventEmitter { |
| 52 | 53 | onKey: (pubkey) => { |
| 53 | 54 | if (autoTrack) this.trackUser(pubkey).catch(err => this.emit('error', err)) |
| 54 | 55 | this.emit('announce', pubkey) |
| 55 | } | |
| 56 | }, | |
| 57 | onOpen: (handle) => this._announces.add(handle), | |
| 58 | onClose: (handle) => this._announces.delete(handle) | |
| 56 | 59 | }) |
| 57 | 60 | } |
| 58 | this.swarm = new SwarmHub(this.store, { bootstrap, keyPair, onConnection }) | |
| 61 | this.swarm = new SwarmHub(this.store, { bootstrap, keyPair, onConnection, firewalled, host, port }) | |
| 59 | 62 | for (const pk of relays) this.swarm.joinPeer(pk) |
| 60 | 63 | return this.swarm |
| 61 | 64 | } |
| … | … | @@ -94,6 +97,13 @@ class Node extends EventEmitter { |
| 94 | 97 | // it costs a small counter. close() takes care of teardown. |
| 95 | 98 | const done = remote.findingPeers() |
| 96 | 99 | remote.on('close', done) |
| 100 | // Gossip the newly learned key to peers we're already connected to — the | |
| 101 | // channel-open exchange only covers keys known at connect time, so without | |
| 102 | // this, transitive discovery depends on connection ordering. Each node only | |
| 103 | // re-announces a key the first time it tracks it, so the flood terminates. | |
| 104 | for (const handle of this._announces) { | |
| 105 | try { handle.sendKeys([pubkey]) } catch { /* channel tearing down */ } | |
| 106 | } | |
| 97 | 107 | this.emit('track', pubkey) |
| 98 | 108 | return remote |
| 99 | 109 | } |
| … | … | @@ -1,14 +1,22 @@ |
| 1 | 1 | 'use strict' |
| 2 | 2 | |
| 3 | 3 | const Hyperswarm = require('hyperswarm') |
| 4 | const DHT = require('hyperdht') | |
| 4 | 5 | |
| 5 | 6 | // Wraps Hyperswarm, pipes every connection into the corestore replicator. |
| 6 | 7 | // If onConnection is provided, it's invoked per connection with { conn, stream, muxer } |
| 7 | 8 | // so callers can attach additional protomux channels (e.g. announce). |
| 9 | // `firewalled`/`host`/`port` are forwarded to a dedicated HyperDHT instance — | |
| 10 | // tests and LAN deployments pass { firewalled: false, host: '127.0.0.1' } so | |
| 11 | // server connections don't depend on holepunching. Hyperswarm destroys the DHT | |
| 12 | // it's handed, so no extra teardown is needed here. | |
| 8 | 13 | class SwarmHub { |
| 9 | constructor (store, { bootstrap, keyPair, onConnection } = {}) { | |
| 14 | constructor (store, { bootstrap, keyPair, onConnection, firewalled, host, port } = {}) { | |
| 10 | 15 | this.store = store |
| 11 | this.swarm = new Hyperswarm({ bootstrap, keyPair }) | |
| 16 | const needsOwnDht = firewalled !== undefined || host !== undefined || port !== undefined | |
| 17 | this.swarm = needsOwnDht | |
| 18 | ? new Hyperswarm({ keyPair, dht: new DHT({ bootstrap, firewalled, host, port }) }) | |
| 19 | : new Hyperswarm({ bootstrap, keyPair }) | |
| 12 | 20 | this.swarm.on('connection', (conn) => { |
| 13 | 21 | const stream = this.store.replicate(conn) |
| 14 | 22 | const muxer = stream.noiseStream.userData |
| … | … | @@ -33,11 +33,12 @@ async function startRelay ({ |
| 33 | 33 | boards = [], |
| 34 | 34 | bootstrap, |
| 35 | 35 | dhtRelayPort, |
| 36 | announcePubkey = true | |
| 36 | announcePubkey = true, | |
| 37 | swarmOpts = {} // extra SwarmHub/DHT options, e.g. { firewalled: false, host: '127.0.0.1' } for local testnets | |
| 37 | 38 | } = {}) { |
| 38 | 39 | if (!dir) throw new Error('dir required') |
| 39 | 40 | const node = await Node.openDisk(dir, { |
| 40 | swarm: bootstrap ? { bootstrap } : {} | |
| 41 | swarm: { ...(bootstrap ? { bootstrap } : {}), ...swarmOpts } | |
| 41 | 42 | }) |
| 42 | 43 | for (const name of boards) await node.joinBoard(name) |
| 43 | 44 |
| … | … | @@ -0,0 +1,11 @@ |
| 1 | 'use strict' | |
| 2 | ||
| 3 | // Swarm options for tests running against a local hyperdht testnet. | |
| 4 | // firewalled:false + loopback binding skip firewall detection and holepunching, | |
| 5 | // which don't work in sandboxed/containerized CI environments — server-side | |
| 6 | // connections would otherwise never complete and the tests flake. | |
| 7 | function localSwarm (bootstrap) { | |
| 8 | return { bootstrap, firewalled: false, host: '127.0.0.1' } | |
| 9 | } | |
| 10 | ||
| 11 | module.exports = { localSwarm } |
| … | … | @@ -12,6 +12,7 @@ const { Node } = require('../src/backend/node') |
| 12 | 12 | const { connectDirect } = require('../src/backend/swarm') |
| 13 | 13 | const { boardTopic } = require('../src/backend/board') |
| 14 | 14 | const createTestnet = require('hyperdht/testnet') |
| 15 | const { localSwarm } = require('./_helpers') | |
| 15 | 16 | |
| 16 | 17 | const tests = [] |
| 17 | 18 | function test (name, fn) { tests.push({ name, fn }) } |
| … | … | @@ -95,8 +96,8 @@ test('swarm: two nodes joining same board connect + replicate', async () => { |
| 95 | 96 | const testnet = await createTestnet(3, { teardown: () => {} }) |
| 96 | 97 | const bootstrap = testnet.bootstrap |
| 97 | 98 | |
| 98 | const a = await Node.openTemp({ swarm: { bootstrap } }) | |
| 99 | const b = await Node.openTemp({ swarm: { bootstrap } }) | |
| 99 | const a = await Node.openTemp({ swarm: localSwarm(bootstrap) }) | |
| 100 | const b = await Node.openTemp({ swarm: localSwarm(bootstrap) }) | |
| 100 | 101 | |
| 101 | 102 | await a.post('general', 'swarm-test', 'over the wire') |
| 102 | 103 |
| … | … | @@ -9,6 +9,7 @@ const b4a = require('b4a') |
| 9 | 9 | const { Node } = require('../src/backend/node') |
| 10 | 10 | const createTestnet = require('hyperdht/testnet') |
| 11 | 11 | const { decodeOp } = require('../src/backend/ops') |
| 12 | const { localSwarm } = require('./_helpers') | |
| 12 | 13 | |
| 13 | 14 | const tests = [] |
| 14 | 15 | function test (name, fn) { tests.push({ name, fn }) } |
| … | … | @@ -17,8 +18,8 @@ test('swarm+announce: B auto-tracks A after joining same board', async () => { |
| 17 | 18 | const testnet = await createTestnet(3, { teardown: () => {} }) |
| 18 | 19 | const bootstrap = testnet.bootstrap |
| 19 | 20 | |
| 20 | const a = await Node.openTemp({ swarm: { bootstrap } }) | |
| 21 | const b = await Node.openTemp({ swarm: { bootstrap } }) | |
| 21 | const a = await Node.openTemp({ swarm: localSwarm(bootstrap) }) | |
| 22 | const b = await Node.openTemp({ swarm: localSwarm(bootstrap) }) | |
| 22 | 23 | |
| 23 | 24 | await a.post('general', 'auto-discovery test', 'body') |
| 24 | 25 | |
| … | … | @@ -44,9 +45,9 @@ test('swarm+announce: transitive discovery (C learns A via B)', async () => { |
| 44 | 45 | const testnet = await createTestnet(3, { teardown: () => {} }) |
| 45 | 46 | const bootstrap = testnet.bootstrap |
| 46 | 47 | |
| 47 | const a = await Node.openTemp({ swarm: { bootstrap } }) | |
| 48 | const b = await Node.openTemp({ swarm: { bootstrap } }) | |
| 49 | const c = await Node.openTemp({ swarm: { bootstrap } }) | |
| 48 | const a = await Node.openTemp({ swarm: localSwarm(bootstrap) }) | |
| 49 | const b = await Node.openTemp({ swarm: localSwarm(bootstrap) }) | |
| 50 | const c = await Node.openTemp({ swarm: localSwarm(bootstrap) }) | |
| 50 | 51 | |
| 51 | 52 | await a.post('general', 'hello', 'from A') |
| 52 | 53 | |
| … | … | @@ -71,6 +72,37 @@ test('swarm+announce: transitive discovery (C learns A via B)', async () => { |
| 71 | 72 | await testnet.destroy() |
| 72 | 73 | }) |
| 73 | 74 | |
| 75 | test('swarm+announce: late announce — C learns a key B tracked after C connected', async () => { | |
| 76 | const testnet = await createTestnet(3, { teardown: () => {} }) | |
| 77 | const bootstrap = testnet.bootstrap | |
| 78 | ||
| 79 | const a = await Node.openTemp({ swarm: localSwarm(bootstrap) }) | |
| 80 | const b = await Node.openTemp({ swarm: localSwarm(bootstrap) }) | |
| 81 | const c = await Node.openTemp({ swarm: localSwarm(bootstrap) }) | |
| 82 | ||
| 83 | // B and C connect first, before B knows anything about A. The channel-open | |
| 84 | // key exchange between B and C therefore cannot carry A's key. | |
| 85 | await b.joinBoard('meta') | |
| 86 | await c.joinBoard('meta') | |
| 87 | await waitForEvent(c, 'track', (pk) => b4a.equals(pk, b.pubkey), 15000) | |
| 88 | ||
| 89 | // Now A shows up on a board shared with B only. | |
| 90 | await a.joinBoard('general') | |
| 91 | await b.joinBoard('general') | |
| 92 | await waitForEvent(b, 'track', (pk) => b4a.equals(pk, a.pubkey), 15000) | |
| 93 | ||
| 94 | // C must learn A via B's re-broadcast on the already-open channel. | |
| 95 | if (!c.userCore(a.pubkey)) { | |
| 96 | await waitForEvent(c, 'track', (pk) => b4a.equals(pk, a.pubkey), 15000) | |
| 97 | } | |
| 98 | assert.ok(c.userCore(a.pubkey), 'C discovered A via late announce') | |
| 99 | ||
| 100 | await a.close() | |
| 101 | await b.close() | |
| 102 | await c.close() | |
| 103 | await testnet.destroy() | |
| 104 | }) | |
| 105 | ||
| 74 | 106 | // --------------------------------------------------------------------------- |
| 75 | 107 | |
| 76 | 108 | function waitForEvent (emitter, event, predicate, timeoutMs) { |
| … | … | @@ -16,6 +16,7 @@ const createTestnet = require('hyperdht/testnet') |
| 16 | 16 | const { Node } = require('../src/backend/node') |
| 17 | 17 | const { startRelay } = require('../src/relay/server') |
| 18 | 18 | const { decodeOp } = require('../src/backend/ops') |
| 19 | const { localSwarm } = require('./_helpers') | |
| 19 | 20 | const fs = require('fs'), os = require('os'), path = require('path') |
| 20 | 21 | |
| 21 | 22 | const tests = [] |
| … | … | @@ -37,9 +38,15 @@ test('relay seeds offline author to late-arriving peer', async () => { |
| 37 | 38 | const bootstrap = testnet.bootstrap |
| 38 | 39 | |
| 39 | 40 | const relayDir = mkdir() |
| 40 | const relay = await startRelay({ dir: relayDir, boards: ['general'], bootstrap, announcePubkey: false }) | |
| 41 | ||
| 42 | const a = await Node.openTemp({ swarm: { bootstrap } }) | |
| 41 | const relay = await startRelay({ | |
| 42 | dir: relayDir, | |
| 43 | boards: ['general'], | |
| 44 | bootstrap, | |
| 45 | announcePubkey: false, | |
| 46 | swarmOpts: { firewalled: false, host: '127.0.0.1' } | |
| 47 | }) | |
| 48 | ||
| 49 | const a = await Node.openTemp({ swarm: localSwarm(bootstrap) }) | |
| 43 | 50 | await a.joinBoard('general') |
| 44 | 51 | |
| 45 | 52 | // wait for the relay to see A |
| … | … | @@ -57,7 +64,7 @@ test('relay seeds offline author to late-arriving peer', async () => { |
| 57 | 64 | await a.close() |
| 58 | 65 | |
| 59 | 66 | // B comes online, joins same board |
| 60 | const b = await Node.openTemp({ swarm: { bootstrap } }) | |
| 67 | const b = await Node.openTemp({ swarm: localSwarm(bootstrap) }) | |
| 61 | 68 | await b.joinBoard('general') |
| 62 | 69 | |
| 63 | 70 | // B learns about A's pubkey via the relay's announce |
| … | … | @@ -0,0 +1,3 @@ |
| 1 | { | |
| 2 | "type": "module" | |
| 3 | } |