Seven focused chapters — one per day, ninety minutes each — taking you from DHT theory to hand-written binary protocols, NAT traversal, Noise handshakes, and the profiler flame graphs that prove your optimizations actually worked. Every chapter ends in a runnable lab.
The XOR metric that quietly organizes every modern swarm.
Almost everything else in this manual sits on top of one idea: a distributed hash table.
A DHT lets thousands of machines, none of which trust or even know each other, collectively behave like
one giant key–value store — with no server, no central index, and no single point of failure. When you
ask the network "who has the data for key K?", the lookup converges on the answer in roughly
log₂(N) hops even across millions of nodes. Kademlia is the design that made this practical,
and it is the ancestor of the BitTorrent DHT, IPFS, Ethereum's discovery layer, and Holepunch's
hyperdht.
Imagine you want to store a billion key–value pairs across a fluctuating set of peers. A naive approach puts everything on one coordinator — but that coordinator is a bottleneck and a kill switch. The DHT insight is to give every node and every key an ID drawn from the same address space (say, 256-bit numbers), then define a rule: a key lives on the node(s) whose ID is "closest" to the key's ID. Now any node can independently figure out where a key should be, without asking a server.
Two questions fall out of that: (1) what does "closest" mean, and (2) how does a node that knows only a handful of peers route a query to the right corner of the network? Kademlia answers both elegantly.
Kademlia measures distance between two IDs as their bitwise XOR, interpreted as an integer:
distance(a,b) = a XOR b. This looks strange the first time you see it, but it has exactly the
properties a metric needs:
d(a,a) = 0 — a node is distance zero from itself.d(a,b) = d(b,a) — if I am close to you, you are equally close to me.
(This is the property the older Chord ring lacked, and why Kademlia routing tables self-reinforce.)d(a,c) ≤ d(a,b) ⊕ d(b,c) holds.a and distance Δ, there is exactly
one point b with d(a,b)=Δ. This means all lookups for the same key converge
along the same path, which is what makes caching and routing-table convergence work.The magic is that XOR distance is prefix-sensitive: two IDs that share a long leading bit-prefix are numerically close. So "getting closer to the target" is the same as "matching more of its high bits," and that turns routing into a binary-search-like descent through the ID space.
N nodes is why lookups take O(log N) hops.
A node can't remember every peer, so Kademlia keeps a structured, partial view. For each bit-distance band
i (peers whose ID differs from mine starting at bit i), the node keeps a
k-bucket: a list of up to k contacts (commonly k = 20). The result is a
routing table that is dense for nearby IDs and sparse for far ones — you know your immediate neighborhood
in fine detail and the rest of the universe only coarsely. That asymmetry is exactly what you need: to route, you
only ever need a peer that is closer than you to the target, and the table guarantees you have one.
k-buckets also encode a liveness policy. When a bucket is full and a new node appears, Kademlia pings the least-recently-seen contact; if it still answers, the newcomer is discarded. This deliberately favors long-lived nodes, because empirically a node that has been up for an hour is likely to stay up another hour. It also makes the table naturally resistant to flooding attacks.
| RPC | Purpose |
|---|---|
PING | Liveness check; the heartbeat behind bucket maintenance. |
STORE | Tell a node to hold a (key, value) pair. |
FIND_NODE | "Give me the k closest contacts you know to this ID." The workhorse of routing. |
FIND_VALUE | Like FIND_NODE, but if the node holds the value it returns it directly. |
To find a target, a node starts with the closest contacts it knows and sends them FIND_NODE in
parallel. Each response returns contacts that are (hopefully) closer; the node keeps the best results it has seen,
picks the closest un-queried ones, and repeats. Lookups are iterative — the searcher drives every
step and stays in control — rather than recursive, which improves resilience to misbehaving nodes.
The concurrency factor α (typically 3) controls how many queries are in flight at once. It
trades latency against redundant traffic: higher α tolerates slow or dead peers without stalling
the descent, at the cost of extra packets.
hyperdht is Kademlia-derived but tuned for a different job: instead of storing arbitrary blobs, its
primary role is peer discovery and connection bootstrapping. It stores small mutable/immutable records
(signed announcements of "I am reachable at this address") and integrates UDP hole-punching directly into the
lookup. Same routing skeleton, different payload. Keep this contrast ready — it is a likely interview question.
Implement XOR distance, a k-bucket routing table, and a simulated iterative lookup over an in-memory network
of nodes. You will see the log N convergence with your own eyes by printing each hop.
# no dependencies — pure Node
mkdir kad-lab && cd kad-lab
node --version # v18+ recommended
We use 16-bit IDs (a Buffer of 2 bytes) so the numbers stay small and printable. Real systems use
160 or 256 bits, but the math is identical.
// kad.js
const ID_BITS = 16;
const ID_BYTES = ID_BITS / 8;
function randomId() {
const b = Buffer.allocUnsafe(ID_BYTES);
for (let i = 0; i < ID_BYTES; i++) b[i] = (Math.random() * 256) | 0;
return b;
}
// XOR distance as a BigInt so it is directly comparable
function distance(a, b) {
let d = 0n;
for (let i = 0; i < ID_BYTES; i++) d = (d << 8n) | BigInt(a[i] ^ b[i]);
return d;
}
// index of the most-significant differing bit -> which bucket a peer belongs in
function bucketIndex(self, other) {
for (let i = 0; i < ID_BYTES; i++) {
const x = self[i] ^ other[i];
if (x !== 0) return i * 8 + Math.clz32(x) - 24; // clz32 on a byte
}
return ID_BITS; // identical
}
const K = 4; // small k so buckets fill up visibly
class RoutingTable {
constructor(selfId) {
this.self = selfId;
this.buckets = Array.from({ length: ID_BITS + 1 }, () => []);
}
add(node) {
if (node.id.equals(this.self)) return;
const b = bucketIndex(this.self, node.id);
const bucket = this.buckets[b];
if (bucket.find(n => n.id.equals(node.id))) return;
if (bucket.length < K) bucket.push(node);
// (real impl: ping least-recently-seen before evicting)
}
// return the n contacts closest to a target id
closest(target, n = K) {
return this.buckets.flat()
.sort((x, y) => (distance(x.id, target) < distance(y.id, target) ? -1 : 1))
.slice(0, n);
}
}
const ALPHA = 3;
class Node {
constructor(id) { this.id = id; this.table = new RoutingTable(id); }
// the FIND_NODE RPC: just answer with my closest known contacts
findNode(target) { return this.table.closest(target); }
}
function buildNetwork(count) {
const nodes = Array.from({ length: count }, () => new Node(randomId()));
// seed each node's table with random acquaintances (bootstrap gossip)
for (const n of nodes)
for (let i = 0; i < 20; i++)
n.table.add(nodes[(Math.random() * count) | 0]);
return nodes;
}
// iterative lookup driven by one starting node
function lookup(start, target) {
let shortlist = start.table.closest(target, ALPHA);
const queried = new Set();
let hops = 0, best = distance(start.id, target);
while (true) {
const batch = shortlist.filter(n => !queried.has(n.id.toString('hex'))).slice(0, ALPHA);
if (batch.length === 0) break;
hops++;
let found = [];
for (const peer of batch) {
queried.add(peer.id.toString('hex'));
found = found.concat(peer.findNode(target));
}
// merge, dedupe, keep K closest overall
const seen = new Map();
for (const n of shortlist.concat(found)) seen.set(n.id.toString('hex'), n);
shortlist = [...seen.values()]
.sort((x, y) => (distance(x.id, target) < distance(y.id, target) ? -1 : 1))
.slice(0, K);
const closest = distance(shortlist[0].id, target);
if (closest >= best) break; // no improvement -> converged
best = closest;
}
return { hops, winner: shortlist[0], best };
}
// --- run it ---
const N = 5000;
const net = buildNetwork(N);
const target = randomId();
const start = net[0];
const r = lookup(start, target);
console.log(`network size : ${N}`);
console.log(`target : ${target.toString('hex')}`);
console.log(`hops to find : ${r.hops}`);
console.log(`log2(N) : ${Math.log2(N).toFixed(1)}`);
console.log(`closest found : ${r.winner.id.toString('hex')}`);
node kad.js
// network size : 5000
// target : 9c41
// hops to find : 4 <- compare to log2(5000) ≈ 12.3
// closest found : 9c2f <- shares the 9c.. prefix
N from 100 to 100000 and plot hops vs N. The curve is logarithmic — this is the headline property of every DHT.ALPHA = 1 and re-run. Lookups still work but take more rounds and are fragile if a queried node is "dead." Then try ALPHA = 8 and watch redundant queries climb.d(a,b)=d(b,a)) and unidirectional (one unique node at each distance), so all
lookups for a key converge along the same path and routing tables mutually reinforce. Subtraction is not symmetric
and breaks both properties.k in k-bucket buy you?k contacts per band means losing one peer doesn't blind
you to that region of the ID space, and the "ping LRU before evicting" rule biases toward stable, long-lived nodes.hyperdht differ from textbook Kademlia?α.Two ways to move data with no server: a static swarm and a signed, append-only log.
Yesterday's DHT tells peers how to find each other. Today is about what they do once connected: move data efficiently and verifiably. BitTorrent solved this for static files in 2001 with a beautifully pragmatic incentive design. Hypercore — the core of the Holepunch stack — solved a harder version: a single-writer, append-only log that any peer can replicate and cryptographically verify block-by-block. Understanding the contrast between them is the fastest way to sound fluent about P2P data transfer.
A torrent splits a file into fixed-size pieces (typically 256 KB–1 MB), and the
.torrent metadata (or magnet link) carries a SHA-1/SHA-256 hash of every piece. That hash list is the
trust anchor: a peer can verify any piece it receives against the expected hash and reject corrupt or malicious data
without trusting the sender. Peers swap pieces until everyone has the whole file.
If every peer downloaded pieces in order, the first pieces would be everywhere and the last pieces would be scarce — and if the only seed left before sharing a rare piece, the file would be unrecoverable. BitTorrent instead prefers the rarest piece first: each peer tracks how many of its neighbors have each piece and fetches the least-replicated ones. This spreads scarcity risk and keeps the swarm healthy as seeds come and go. A small exception ("random first piece") bootstraps a brand-new peer quickly so it has something to trade.
BitTorrent's real innovation is social, not technical. Each peer chokes (refuses to upload to) most of its neighbors and unchokes only a few — preferentially those who upload to it the fastest. This tit-for-tat reciprocation rewards contributors and starves freeloaders. Periodically a peer does an optimistic unchoke of a random neighbor, to discover better partners and to give newcomers (who have nothing to offer yet) a foot in the door.
| Mechanism | What it prevents |
|---|---|
| Per-piece hashes | Corrupt / malicious data acceptance |
| Rarest-first | Piece scarcity & unrecoverable swarms |
| Tit-for-tat choking | Freeloading (download without upload) |
| Optimistic unchoke | Newcomer starvation & local optima |
| BitTorrent DHT (BEP 5) + PEX | Dependence on a central tracker |
The bitfield message — sent on connection — is how a peer advertises which pieces it already has,
as a compact bit array. have messages then incrementally announce new pieces. The BitTorrent
DHT (a Kademlia network keyed by infohash) and PEX (peer exchange) let swarms operate with
no tracker at all — this is exactly the DHT material from Day 1 applied in production.
BitTorrent distributes a fixed, known-in-advance file. But many applications need a growing data feed — a chat history, a database changelog, a file that gets revised. Hypercore is the answer: a secure, append-only log owned by a single keypair. Only the holder of the secret key can append; anyone with the public key (which is the feed's address) can read and verify.
Each appended block is hashed (BLAKE2b), and those hashes form a Merkle tree. The signed root
commits to the entire log. The payoff: a peer can request block i alone and receive it plus a small set
of sibling hashes (a Merkle proof) that verify it against the signed root — without downloading the rest of the
log. This is what makes random access and partial replication trustworthy. It is the same idea as BitTorrent's
piece hashes, generalized into a tree so proofs stay O(log n) and the structure can grow.
The keypair is the heart of the model. The public key is the discovery key / address; the secret
key authorizes appends. Replication is a duplex stream: two peers exchange what blocks they have and want, request
ranges, and verify every block against the Merkle root as it arrives. Higher layers in the Holepunch stack
(hyperbee for key–value/B-tree, hyperdrive for filesystems, autobase for
multi-writer) are all built on this one primitive.
| BitTorrent | Hypercore | |
|---|---|---|
| Data shape | Static file, fixed pieces | Append-only growing log |
| Trust anchor | Flat list of piece hashes | Signed Merkle root + keypair |
| Writers | Whoever seeds (content-addressed) | Exactly one (key-addressed) |
| Mutability | Immutable | Append (history preserved) |
| Verification | Per-piece hash check | Per-block Merkle proof |
Build a tiny append-only log that produces a Merkle root and lets a "reader" verify a single block with a proof —
the conceptual core of Hypercore — using only Node's built-in crypto. Then run real Hypercore replication
between two instances.
// merkle-log.js
const crypto = require('crypto');
const H = (buf) => crypto.createHash('sha256').update(buf).digest();
class MerkleLog {
constructor() { this.blocks = []; this.leaves = []; }
append(data) {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
this.blocks.push(buf);
this.leaves.push(H(buf));
return this.blocks.length - 1; // index
}
// build the tree bottom-up; return the layered hash arrays
tree() {
let layer = this.leaves.slice();
const layers = [layer];
while (layer.length > 1) {
const next = [];
for (let i = 0; i < layer.length; i += 2) {
const left = layer[i];
const right = layer[i + 1] || left; // duplicate last if odd
next.push(H(Buffer.concat([left, right])));
}
layers.push(next);
layer = next;
}
return layers;
}
root() { const l = this.tree(); return l[l.length - 1][0]; }
// proof = sibling hash at each level needed to recompute the root
proof(index) {
const layers = this.tree();
const path = [];
let idx = index;
for (let lvl = 0; lvl < layers.length - 1; lvl++) {
const isRight = idx % 2 === 1;
const sibling = isRight ? idx - 1 : idx + 1;
const layer = layers[lvl];
path.push({ hash: (layer[sibling] || layer[idx]), isRight });
idx = idx >> 1;
}
return path;
}
}
// a reader who has ONLY the trusted root verifies one block
function verify(block, index, proof, trustedRoot) {
let h = H(block);
for (const step of proof) {
h = step.isRight
? H(Buffer.concat([step.hash, h])) // our node was on the right
: H(Buffer.concat([h, step.hash]));
}
return h.equals(trustedRoot);
}
// --- demo ---
const log = new MerkleLog();
['genesis', 'block-1', 'block-2', 'block-3', 'block-4'].forEach(d => log.append(d));
const root = log.root();
const i = 3;
const ok = verify(log.blocks[i], i, log.proof(i), root);
console.log('root :', root.toString('hex').slice(0, 16), '…');
console.log('verify #3 :', ok);
// tamper test: flip the block, proof must fail
const bad = Buffer.from('block-3-EVIL');
console.log('tampered :', verify(bad, i, log.proof(i), root));
node merkle-log.js
// root : 2f6a... …
// verify #3 : true
// tampered : false <- the Merkle proof rejects altered data
Now see it in production form. Modern Hypercore (v11+) is managed through a Corestore backed by a
storage path; we replicate a log from a writer store to a key-only reader store over piped streams (the same API
works over a real socket).
npm init -y
npm install hypercore corestore
// hc.js
const Corestore = require('corestore');
const os = require('os'), path = require('path'), fs = require('fs');
async function main() {
// two independent stores on disk (Hypercore 11+ requires a storage dir)
const dirA = fs.mkdtempSync(path.join(os.tmpdir(), 'hc-a-'));
const dirB = fs.mkdtempSync(path.join(os.tmpdir(), 'hc-b-'));
// writer core
const store = new Corestore(dirA);
const writer = store.get({ name: 'log' });
await writer.ready();
await writer.append([Buffer.from('hello'), Buffer.from('p2p'), Buffer.from('world')]);
console.log('writer length:', writer.length,
'key:', writer.key.toString('hex').slice(0, 12));
// reader store: opens the core by PUBLIC KEY only — it has no data yet
const store2 = new Corestore(dirB);
const reader = store2.get({ key: writer.key });
await reader.ready();
// pipe the two replication streams together (stand-in for a socket)
const s1 = store.replicate(true);
const s2 = store2.replicate(false);
s1.pipe(s2).pipe(s1);
// reader fetches block 2 — verified against the signed root automatically
const block = await reader.get(2);
console.log('reader got block 2:', block.toString()); // "world"
process.exit(0);
}
main();
log₂(n), not n. That logarithmic proof size is the whole point.reader.get(0) before the writer appends (start the reader first), then append — observe that get resolves once the block arrives. Hypercore get means "wait until available."reader.length vs reader.contiguousLength to see the difference between "known to exist" and "actually downloaded."O(log n) verification proofs and a single signed
root committing to all history — exactly what an append-only log needs.How two machines behind home routers manage to talk directly — the hardest, most-tested topic on the list.
This is the chapter most likely to expose gaps in an interview, because it's where networking theory meets ugly
real-world reality. The internet was designed for every machine to have a public address; instead, billions of
devices hide behind NAT (Network Address Translation). P2P only works if peers can punch through
that. By the end of today you will have two UDP "peers" establishing a direct connection through a simulated
rendezvous server — the exact dance hyperdht performs.
| TCP | UDP | |
|---|---|---|
| Connection | Handshake, stateful | Connectionless, fire-and-forget |
| Delivery | Reliable, ordered, retransmitted | Best-effort; may drop/reorder/dup |
| Flow/congestion control | Built in | You build it (or don't) |
| Head-of-line blocking | Yes — one lost segment stalls the stream | No — packets are independent |
| Overhead | 20-byte header, ACK traffic | 8-byte header |
| NAT traversal | Hard (stateful, sequence numbers) | Easier (stateless datagrams) |
P2P systems lean heavily on UDP for two reasons. First, NAT hole-punching is far more reliable with stateless datagrams than with TCP's handshake-and-sequence machinery. Second, head-of-line blocking: TCP guarantees order, so a single lost packet stalls everything behind it — fatal for real-time or multiplexed streams. This is exactly why QUIC (and HTTP/3) was built on UDP. The tradeoff is that you must implement whatever reliability you actually need yourself, on top of UDP.
Your router owns one public IP. Every device behind it has a private IP (192.168.x.x). When an inside
device sends a packet out, the router rewrites the source (private IP : port) to
(public IP : some external port) and records the mapping in a translation table. Replies to that
external port get rewritten back and forwarded inside. The problem for P2P: an unsolicited inbound packet
from a stranger has no table entry, so the router drops it. Two peers behind NATs each expect the other to "call
first" — a deadlock.
| Type | Mapping rule | Punchable? |
|---|---|---|
| Full-cone | One external port per internal socket; anyone can use it | Easy |
| Restricted-cone | Same port, but only hosts you've sent to may reply | Yes |
| Port-restricted | Only the exact host:port you contacted may reply | Yes (most common case) |
| Symmetric | A new external port per destination — mapping is unpredictable | Hard / often needs relay |
The killer is symmetric NAT: because it allocates a different external port for every destination, the port a peer learns via the rendezvous server is not the port that will be used to reach the other peer. Hole-punching relies on predicting the external mapping, and symmetric NAT breaks that prediction. This is the single most important NAT fact to be able to state crisply.
Both peers can make outbound connections; neither can receive unsolicited inbound. The trick is to use a mutually reachable rendezvous (signaling) server to learn each other's public mappings, then both fire packets at each other simultaneously. Each outbound packet opens a hole in its own NAT; when the other peer's packet arrives, a matching table entry already exists, so it's allowed through.
(IP:port) mappings (this is what STUN does).hyperdht rolls its own version of this. The DHT itself is the rendezvous layer: peers announce
on a topic, the DHT coordinates the simultaneous-open, and a technique sometimes called "UDX" provides a
reliable-stream abstraction over UDP afterward. When you connect via hyperswarm, this whole sequence
happens under the hood. Being able to say "the DHT replaces the STUN/signaling server" is a strong interview answer.
Build a rendezvous server and two peers using Node's dgram (UDP) module. The server introduces the
peers by their observed addresses; the peers then exchange packets directly. Running on localhost there's
no real NAT, but the protocol logic — observe address, exchange, simultaneous send — is identical to the
real thing.
// rendezvous.js — learns each peer's public address and introduces them
const dgram = require('dgram');
const server = dgram.createSocket('udp4');
const peers = {};
server.on('message', (msg, rinfo) => {
const { name } = JSON.parse(msg);
// rinfo is the address the SERVER sees — i.e. the public mapping (STUN)
peers[name] = { address: rinfo.address, port: rinfo.port };
console.log(`registered ${name} @ ${rinfo.address}:${rinfo.port}`);
// once both peers are known, introduce them to each other
const names = Object.keys(peers);
if (names.length === 2) {
const [a, b] = names;
introduce(a, b); introduce(b, a);
}
});
function introduce(to, about) {
const payload = Buffer.from(JSON.stringify({ peer: about, addr: peers[about] }));
server.send(payload, peers[to].port, peers[to].address);
}
server.bind(9000, () => console.log('rendezvous on :9000'));
// peer.js — usage: node peer.js <myName>
const dgram = require('dgram');
const myName = process.argv[2] || 'peerA';
const sock = dgram.createSocket('udp4');
const RDV = { port: 9000, host: '127.0.0.1' };
let partner = null;
let connected = false;
sock.on('message', (msg, rinfo) => {
let data; try { data = JSON.parse(msg); } catch { data = { raw: msg.toString() }; }
// (a) introduction from the rendezvous server
if (data.addr) {
partner = data.addr;
console.log(`got partner ${data.peer} @ ${partner.address}:${partner.port}`);
startPunching();
return;
}
// (b) a direct packet from the partner — the hole is open!
if (data.hello) {
if (!connected) {
connected = true;
console.log(`✅ DIRECT connection established with ${data.hello}`);
}
return;
}
});
// punch: fire packets straight at the partner's public mapping repeatedly
function startPunching() {
const timer = setInterval(() => {
if (connected) return clearInterval(timer);
const pkt = Buffer.from(JSON.stringify({ hello: myName }));
sock.send(pkt, partner.port, partner.address); // opens our hole + may pass theirs
}, 200);
}
// register with the rendezvous server on startup
sock.send(Buffer.from(JSON.stringify({ name: myName })), RDV.port, RDV.host);
console.log(`${myName} registering…`);
# terminal 1
node rendezvous.js
# terminal 2
node peer.js peerA
# terminal 3
node peer.js peerB
# peerA output:
# peerA registering…
# got partner peerB @ 127.0.0.1:53412
# ✅ DIRECT connection established with peerB
IP:port mapping. Both peers then send UDP packets directly to each other's mapping at the same time;
each outbound packet opens a hole in its own NAT, so the incoming packet finds a matching table entry and passes.
After that they talk directly. If both are behind symmetric NAT, punching fails and traffic falls back to a TURN relay.hyperdht replaces the signaling server.Why every serious P2P system hand-rolls its bytes — framing, varints, and zero-copy formats.
JSON is wonderful for humans and terrible for hot networking paths. It's bulky, slow to parse, ambiguous about number types, and forces you to allocate strings for everything. P2P protocols move millions of small messages, so they encode data as tight binary. Today you'll learn how messages are framed on a stream, how integers are packed with varints, and how the major serialization formats trade off — then hand-write an encoder/decoder, which is exactly the kind of thing you may be asked to live-code.
| Concern | JSON | Binary |
|---|---|---|
| Size | {"id":1000000} = 15 bytes | varint = 3 bytes |
| Parse cost | Tokenize text, allocate strings | Read fixed offsets / shift bits |
| Number fidelity | All numbers are float64; no int64 | Exact integer widths |
| Schema | Self-describing (keys repeated every message) | Schema known by both sides; no key overhead |
The self-describing nature of JSON is its biggest cost at scale: every message re-sends the field names. Binary protocols agree on a schema once, then send only values in a known order.
TCP and UDX give you a stream of bytes, not messages. If you write two messages, the reader may receive them glued together, or split across two reads. You must impose framing. Two common approaches:
'data' event can contain part of a message, multiple
messages, or a message plus part of the next. A correct length-prefix reader buffers incoming bytes and
emits messages only when a full frame has arrived. Get this wrong and your protocol works in tests and corrupts
under load.
Most integers in real protocols are small (lengths, counts, indices). Spending 4 or 8 fixed bytes on a value of
5 is wasteful. A varint (LEB128, as used by Protobuf, WebAssembly, and Holepunch's
compact-encoding) encodes an integer in a variable number of bytes: 7 bits of value per byte, with the
high bit as a "more bytes follow" continuation flag.
So small numbers cost one byte, and you only pay for big numbers when you actually use them. This is the single most common trick in compact wire formats.
| Format | Shape | Notes |
|---|---|---|
| Protocol Buffers | Schema (.proto), varint-based | Compact, fast, ubiquitous; needs codegen. Tag-length-value fields. |
| MessagePack | Schemaless, JSON-shaped | "Binary JSON" — drop-in for JSON, self-describing, no schema needed. |
| Cap'n Proto | Schema, zero-copy | Memory layout is the wire format; "infinitely faster" — no parse step. |
| FlatBuffers | Schema, zero-copy | Access fields without unpacking the whole buffer; great for large messages read partially. |
| compact-encoding | Hand-composed codecs | Holepunch's lib: tiny, explicit, varint-first. You compose encoders by hand. |
The key conceptual split: parse-based formats (Protobuf, MessagePack) decode bytes into new
in-memory objects, while zero-copy formats (Cap'n Proto, FlatBuffers) let you read fields directly
out of the received buffer with no allocation or decode pass. Zero-copy wins when messages are large or you only
read a few fields; parse-based is simpler and fine for small messages. compact-encoding sits in the
parse-based camp but is deliberately minimal and gives you total control — which is why it suits a P2P stack where
every byte and allocation counts.
Implement varint encode/decode, then a length-prefixed framer that correctly reassembles messages from an
arbitrarily-chunked byte stream. Finish by encoding a structured message by hand. Then compare against real
compact-encoding.
// varint.js
function encodeVarint(value) {
const bytes = [];
while (value > 0x7f) { // while more than 7 bits remain
bytes.push((value & 0x7f) | 0x80); // low 7 bits + continuation flag
value = Math.floor(value / 128); // >>> 7 but safe past 32 bits
}
bytes.push(value & 0x7f); // final byte, no continuation
return Buffer.from(bytes);
}
function decodeVarint(buf, offset = 0) {
let result = 0, shift = 0, pos = offset;
while (true) {
const byte = buf[pos++];
result += (byte & 0x7f) * Math.pow(2, shift);
if ((byte & 0x80) === 0) break; // continuation bit clear -> done
shift += 7;
}
return { value: result, bytesRead: pos - offset };
}
// sanity
for (const v of [0, 1, 127, 128, 300, 1000000]) {
const enc = encodeVarint(v);
const dec = decodeVarint(enc).value;
console.log(v, '->', [...enc], `(${enc.length} bytes) ->`, dec);
}
module.exports = { encodeVarint, decodeVarint };
node varint.js
// 0 -> [0] (1 bytes) -> 0
// 127 -> [127] (1 bytes) -> 127
// 128 -> [128,1] (2 bytes) -> 128
// 300 -> [172,2] (2 bytes) -> 300
// 1000000 -> [192,132,61] (3 bytes) -> 1000000 <- vs 15 bytes as JSON
This is the part interviewers love, because the naive version is subtly broken. The framer buffers raw bytes and only emits a message when a complete frame is present — handling split and coalesced reads.
// framer.js
const { encodeVarint, decodeVarint } = require('./varint');
const EventEmitter = require('events');
function frame(payload) {
const len = encodeVarint(payload.length);
return Buffer.concat([len, payload]); // [varint length][payload bytes]
}
class Decoder extends EventEmitter {
constructor() { super(); this.buf = Buffer.alloc(0); }
push(chunk) {
this.buf = Buffer.concat([this.buf, chunk]); // accumulate
while (this.buf.length > 0) {
let header;
try { header = decodeVarint(this.buf, 0); }
catch { return; } // length not fully arrived
const total = header.bytesRead + header.value;
if (this.buf.length < total) return; // full frame not here yet — wait
const payload = this.buf.subarray(header.bytesRead, total);
this.emit('message', Buffer.from(payload));
this.buf = this.buf.subarray(total); // keep the remainder
}
}
}
module.exports = { frame, Decoder };
// test-framer.js
const { frame, Decoder } = require('./framer');
const messages = ['hello', 'a', 'a much longer message here', 'last']
.map(s => Buffer.from(s));
const stream = Buffer.concat(messages.map(frame)); // all frames glued together
const dec = new Decoder();
const out = [];
dec.on('message', m => out.push(m.toString()));
// feed the stream in deliberately awful 3-byte chunks
for (let i = 0; i < stream.length; i += 3)
dec.push(stream.subarray(i, i + 3));
console.log(out);
// [ 'hello', 'a', 'a much longer message here', 'last' ] ✅ reassembled perfectly
A "peer announce" message: a 1-byte type tag, a varint topic length + topic bytes, and a fixed 6-byte address (4-byte IPv4 + 2-byte port).
// announce.js
const { encodeVarint, decodeVarint } = require('./varint');
const TYPE_ANNOUNCE = 0x01;
function encodeAnnounce({ topic, ip, port }) {
const topicBuf = Buffer.from(topic);
const addr = Buffer.alloc(6);
ip.split('.').forEach((o, i) => addr[i] = Number(o));
addr.writeUInt16BE(port, 4); // big-endian = network byte order
return Buffer.concat([
Buffer.from([TYPE_ANNOUNCE]),
encodeVarint(topicBuf.length), topicBuf,
addr
]);
}
function decodeAnnounce(buf) {
let off = 0;
const type = buf[off++];
const { value: tlen, bytesRead } = decodeVarint(buf, off); off += bytesRead;
const topic = buf.subarray(off, off + tlen).toString(); off += tlen;
const ip = [...buf.subarray(off, off + 4)].join('.'); off += 4;
const port = buf.readUInt16BE(off);
return { type, topic, ip, port };
}
const wire = encodeAnnounce({ topic: 'chat-room-42', ip: '192.168.1.50', port: 9000 });
console.log('bytes :', wire.length, [...wire]);
console.log('decoded:', decodeAnnounce(wire));
compact-encodingnpm install compact-encoding
// ce.js
const c = require('compact-encoding');
// compose a struct codec from primitives
const announce = {
preencode(state, m) { // pass 1: measure
c.string.preencode(state, m.topic);
c.uint16.preencode(state, m.port);
},
encode(state, m) { // pass 2: write
c.string.encode(state, m.topic);
c.uint16.encode(state, m.port);
},
decode(state) {
return { topic: c.string.decode(state), port: c.uint16.decode(state) };
}
};
const buf = c.encode(announce, { topic: 'chat-room-42', port: 9000 });
console.log('compact bytes:', buf.length, [...buf]);
console.log('decoded :', c.decode(announce, buf));
Notice the two-pass design: preencode computes the exact size so encode
can write into a single pre-allocated buffer with no resizing — an allocation optimization you'll revisit on Day 6.
compact-encoding's two-pass preencode/encode design.Signatures, key exchange, AEAD, and the Noise handshake that secures every Holepunch connection.
In a P2P network there's no certificate authority, no trusted server, no login. Identity, integrity, and
confidentiality all come from cryptography applied directly between peers. You will not implement crypto
primitives yourself (rule one of applied crypto: don't roll your own), but you must understand what each primitive
does, why it's there, and how they compose into the Noise Protocol handshake that hyperdht
uses to secure connections. Today is concept-heavy with a lab using sodium-native, the same library the
Holepunch stack relies on.
| Job | Primitive | Question it answers |
|---|---|---|
| Identity / authenticity | Ed25519 signatures | "Did the holder of this key really produce this?" |
| Key agreement | X25519 (Curve25519 ECDH) | "How do two strangers derive a shared secret over a public channel?" |
| Confidentiality + integrity | ChaCha20-Poly1305 / AES-GCM (AEAD) | "How do we encrypt so tampering is detectable?" |
| Fingerprinting / commitment | BLAKE2b hashing, Merkle trees | "How do we name and verify data compactly?" |
In Hypercore the public key is the address of a feed, and the secret key authorizes appends. Ed25519 is the signature scheme behind this: fast, deterministic (no risky randomness at signing time, the flaw that has broken ECDSA implementations), small keys and signatures (32 and 64 bytes). A signature proves a message was produced by the holder of the secret key and hasn't been altered. This is the entire basis of "single writer" — only one party can sign valid appends, and every reader can verify them with just the public key.
Two peers who have never met need a shared symmetric key to encrypt their session. Diffie–Hellman over Curve25519 (X25519) lets each side combine its own secret with the other's public value to compute the same shared secret, while an eavesdropper who sees only the public values cannot. Using fresh ephemeral keypairs per session gives forward secrecy: even if a long-term key leaks later, past sessions stay private because their ephemeral secrets are already gone.
Plain encryption hides content but doesn't stop an attacker flipping bits. AEAD (Authenticated Encryption with Associated Data) — ChaCha20-Poly1305 or AES-GCM — encrypts and produces an authentication tag, so any tampering is detected on decryption. "Associated data" lets you bind unencrypted context (like a header) to the ciphertext so it can't be swapped. Every byte of an established Noise session rides inside AEAD frames.
You met Merkle trees on Day 2. The hash underneath them in Hypercore is BLAKE2b — faster than SHA-256, with strong security margins. Hashing gives you fixed-size, collision-resistant fingerprints used for content addressing, the discovery key derived from a feed's public key, and the block/tree hashes that make partial replication verifiable.
Noise is a framework for building secure handshakes by composing the primitives above into a fixed sequence of DH operations. It's what WireGuard, WhatsApp, and Holepunch's transport use. A Noise handshake is named by a pattern of message tokens:
Each handshake message mixes new DH outputs into a running shared state; by the end both sides have derived matching symmetric keys for an AEAD-encrypted session with forward secrecy. The elegance is that the pattern declares the security properties, and the framework guarantees them.
hyperdht/hyperswarm, the peers run a Noise handshake (an
IK-style pattern fits the "I looked up your key in the DHT, now I'm dialing you" model) to
authenticate and establish an encrypted, forward-secret channel — before any application data flows. The
primitives are Ed25519/X25519/BLAKE2b/ChaCha20-Poly1305 via sodium-native. Being able to say "Noise IK,
because the dialer already has the responder's static key from the DHT lookup" is a top-tier interview answer.
Use real, production primitives to (1) sign and verify like a Hypercore writer, (2) perform an X25519 key exchange and confirm both sides derive the same secret, (3) AEAD-encrypt with tamper detection, and (4) sketch the shape of a Noise-style handshake.
npm install sodium-native
sodium-native is libsodium bindings — the exact crypto layer under Hypercore. (If it fails to build
in your environment, Node's built-in crypto also supports Ed25519/X25519; the concepts are identical.)
// sign.js
const sodium = require('sodium-native');
// generate a signing keypair
const pk = Buffer.alloc(sodium.crypto_sign_PUBLICKEYBYTES); // 32
const sk = Buffer.alloc(sodium.crypto_sign_SECRETKEYBYTES); // 64
sodium.crypto_sign_keypair(pk, sk);
const message = Buffer.from('append: block #42');
const sig = Buffer.alloc(sodium.crypto_sign_BYTES); // 64
sodium.crypto_sign_detached(sig, message, sk);
const ok = sodium.crypto_sign_verify_detached(sig, message, pk);
console.log('signature valid:', ok);
// tamper -> verification fails (integrity + authenticity)
const forged = Buffer.from('append: block #43');
console.log('forged valid :', sodium.crypto_sign_verify_detached(sig, forged, pk));
// dh.js
const sodium = require('sodium-native');
function keypair() {
const pk = Buffer.alloc(sodium.crypto_kx_PUBLICKEYBYTES);
const sk = Buffer.alloc(sodium.crypto_kx_SECRETKEYBYTES);
sodium.crypto_kx_keypair(pk, sk);
return { pk, sk };
}
const alice = keypair();
const bob = keypair();
// each derives session keys from own secret + peer's public key
const aRx = Buffer.alloc(sodium.crypto_kx_SESSIONKEYBYTES);
const aTx = Buffer.alloc(sodium.crypto_kx_SESSIONKEYBYTES);
const bRx = Buffer.alloc(sodium.crypto_kx_SESSIONKEYBYTES);
const bTx = Buffer.alloc(sodium.crypto_kx_SESSIONKEYBYTES);
sodium.crypto_kx_client_session_keys(aRx, aTx, alice.pk, alice.sk, bob.pk);
sodium.crypto_kx_server_session_keys(bRx, bTx, bob.pk, bob.sk, alice.pk);
// alice's TX key == bob's RX key -> shared secret agreed over a public channel
console.log('keys match:', aTx.equals(bRx) && bTx.equals(aRx));
// aead.js
const sodium = require('sodium-native');
const key = Buffer.alloc(sodium.crypto_aead_chacha20poly1305_ietf_KEYBYTES);
sodium.randombytes_buf(key);
const nonce = Buffer.alloc(sodium.crypto_aead_chacha20poly1305_ietf_NPUBBYTES);
sodium.randombytes_buf(nonce); // MUST be unique per message under a key
const plaintext = Buffer.from('secret peer payload');
const ad = Buffer.from('msg-type:3'); // associated data: authenticated, not encrypted
const ct = Buffer.alloc(plaintext.length + sodium.crypto_aead_chacha20poly1305_ietf_ABYTES);
sodium.crypto_aead_chacha20poly1305_ietf_encrypt(ct, plaintext, ad, null, nonce, key);
const out = Buffer.alloc(ct.length - sodium.crypto_aead_chacha20poly1305_ietf_ABYTES);
sodium.crypto_aead_chacha20poly1305_ietf_decrypt(out, null, ct, ad, nonce, key);
console.log('decrypted:', out.toString());
// flip one ciphertext byte -> decrypt throws (the Poly1305 tag fails)
ct[0] ^= 0xff;
try {
sodium.crypto_aead_chacha20poly1305_ietf_decrypt(out, null, ct, ad, nonce, key);
console.log('tamper undetected (BAD)');
} catch {
console.log('tamper detected: decryption rejected ✅');
}
This is a conceptual 2-message exchange (not a spec-compliant Noise impl) to show how DH + hashing compose into a session. Each side mixes ephemeral DH output into a running hash to derive a shared key.
// mini-noise.js — illustrative only; use the 'noise-protocol' / hyperswarm libs for real
const sodium = require('sodium-native');
const hash = (b) => { const o = Buffer.alloc(32); sodium.crypto_generichash(o, b); return o; };
function eph() {
const pk = Buffer.alloc(32), sk = Buffer.alloc(32);
sodium.crypto_box_keypair(pk, sk); return { pk, sk };
}
function dh(sk, pk) {
const out = Buffer.alloc(32);
sodium.crypto_scalarmult(out, sk, pk); return out;
}
const i = eph(), r = eph(); // initiator + responder ephemerals
// -> e : initiator sends its ephemeral public key
// <- e : responder sends its ephemeral public key
// both compute the same DH(e_i, e_r) and mix it into a session key
const kI = hash(dh(i.sk, r.pk));
const kR = hash(dh(r.sk, i.pk));
console.log('handshake keys match:', kI.equals(kR));
console.log('(real Noise also mixes static keys + transcript hash for auth)');
noise-protocol npm package and identify which functions correspond to the XX vs IK patterns.hyperdht.The event loop, buffers, backpressure, and the allocation discipline that separates a toy from a relay.
You can know every protocol and still write a P2P node that melts under load. Performance in Node networking is mostly about three things: respecting the event loop, managing buffers without drowning the garbage collector, and honoring backpressure so a fast sender can't overwhelm a slow consumer. This chapter is where the relay-and-throughput intuition that interviewers probe actually lives.
Node runs your JavaScript on a single thread. I/O (sockets, files) is handed off to libuv and
completes asynchronously, but every callback, every data handler, every bit of parsing runs on that one
thread. The rule that follows is absolute: never block the loop. A synchronous JSON.parse of a huge
payload, a tight CPU loop, a synchronous crypto call on every packet — each one stalls all connections, not
just one. Throughput collapses not because the network is slow but because the loop is busy.
worker_thread or a native addon that releases the loop.O(n²): repeated Buffer.concat on a growing buffer is a classic one (you'll fix it in the lab).Every Buffer you allocate in a hot path is future work for the garbage collector. Under high packet
rates, allocation churn causes GC pauses that show up as latency spikes and jitter. The disciplines that matter:
Buffer.allocUnsafe(n) skips zero-filling — faster when you're about to overwrite
every byte anyway (e.g. you're about to copy a frame in). Use Buffer.alloc only when you need the
zeroes. Never expose an allocUnsafe buffer's uninitialized tail to the wire.preencode/encode you saw in compact-encoding exists precisely so the library can
write into one correctly-sized buffer instead of growing and reallocating.buf.subarray(a, b) returns a view sharing the same memory — no
allocation. Buffer.from(buf) or .slice()-then-copy duplicates. Use views when the
lifetime is safe; copy only when you must retain data past the read buffer's reuse.concat, GC pressure caps
your throughput long before bandwidth does. Pre-sized read buffers, subarray views for forwarding, and
bounded per-event work are what let a single Node process push serious packet rates.
socket.write(chunk) returns a boolean. When it returns false, the kernel/stream buffer is
full and you must stop writing until the 'drain' event fires. Ignore this and Node
happily queues your data in memory — unbounded — until the process balloons and dies. A fast producer feeding a slow
socket is the most common way a P2P node runs out of memory.
The clean solutions: use stream.pipe() (which handles backpressure for you), or pipeline(),
or honor the write/'drain' contract manually. Async iterators (for await...of
over a readable) also propagate backpressure naturally.
Two different numbers people constantly conflate:
You can have great throughput and terrible tail latency (big batches, long GC pauses) or low latency and poor
throughput (tiny unbatched writes with per-message overhead). Always measure percentiles (p50, p99),
never just the mean — the mean hides exactly the stalls that matter. perf_hooks.monitorEventLoopDelay()
quantifies how much the loop itself is the bottleneck.
Measure three real performance effects: (1) the Buffer.concat O(n²) trap vs a
pre-sized buffer, (2) allocUnsafe vs alloc in a hot loop, and (3) backpressure on a TCP
socket. You'll produce numbers, not just theory.
// concat-trap.js — why naive reassembly is O(n^2)
const { performance } = require('perf_hooks');
const CHUNKS = 20000;
const chunk = Buffer.allocUnsafe(64);
// BAD: concat the whole growing buffer on every chunk
let t = performance.now();
let acc = Buffer.alloc(0);
for (let i = 0; i < CHUNKS; i++) acc = Buffer.concat([acc, chunk]);
console.log('concat-each-time :', (performance.now() - t).toFixed(1), 'ms');
// GOOD: collect references, concat ONCE at the end
t = performance.now();
const parts = [];
for (let i = 0; i < CHUNKS; i++) parts.push(chunk);
const joined = Buffer.concat(parts);
console.log('collect-then-join:', (performance.now() - t).toFixed(1), 'ms');
// BEST when total size known: one allocation, copy into it
t = performance.now();
const dst = Buffer.allocUnsafe(CHUNKS * 64);
for (let i = 0; i < CHUNKS; i++) chunk.copy(dst, i * 64);
console.log('presized-copy :', (performance.now() - t).toFixed(1), 'ms');
node concat-trap.js
// concat-each-time : 3380.0 ms <- quadratic; explodes with size
// collect-then-join: 2.3 ms <- linear
// presized-copy : 6.9 ms <- linear, one allocation
The exact numbers vary by machine, but the shape is the lesson: the naive version is hundreds to thousands of times slower and gets worse as the buffer grows. This is the framing decoder from Day 4 done wrong.
// alloc-bench.js
const { performance } = require('perf_hooks');
const N = 2_000_000, SIZE = 256;
let t = performance.now();
for (let i = 0; i < N; i++) { const b = Buffer.alloc(SIZE); b[0] = 1; }
console.log('alloc :', (performance.now() - t).toFixed(0), 'ms');
t = performance.now();
for (let i = 0; i < N; i++) { const b = Buffer.allocUnsafe(SIZE); b[0] = 1; }
console.log('allocUnsafe:', (performance.now() - t).toFixed(0), 'ms');
// BEST: one reusable buffer, no per-iteration allocation at all
t = performance.now();
const reusable = Buffer.allocUnsafe(SIZE);
for (let i = 0; i < N; i++) { reusable[0] = 1; }
console.log('reused :', (performance.now() - t).toFixed(0), 'ms');
Zero-filling (alloc) costs measurable time at scale, and not allocating at all wins biggest.
In a real reader you keep one read buffer and parse out of it.
// backpressure.js — a server that floods, a client that reads slowly
const net = require('net');
const server = net.createServer((socket) => {
const payload = Buffer.allocUnsafe(64 * 1024); // 64 KB
let sent = 0, blocked = 0;
function pump() {
while (sent < 2000) {
const ok = socket.write(payload); // returns false when buffer is full
sent++;
if (!ok) { // BACKPRESSURE: stop and wait for drain
blocked++;
socket.once('drain', pump); // resume only when flushed
return;
}
}
console.log(`done: sent ${sent} chunks, paused ${blocked} times for drain`);
socket.end();
}
pump();
});
server.listen(8124, () => {
const client = net.connect(8124);
let received = 0;
client.on('data', (d) => {
received += d.length;
client.pause(); // simulate a slow consumer
setTimeout(() => client.resume(), 1);
});
client.on('end', () => {
console.log(`client received ${(received / 1024 / 1024).toFixed(1)} MB`);
server.close();
});
});
node backpressure.js
// done: sent 2000 chunks, paused 80+ times for drain
// client received 125.0 MB
The "paused for drain" count is the system protecting itself. Delete the if (!ok) branch and keep
writing regardless: memory usage climbs without bound because Node queues everything you hand it. That is the
bug behind most "my P2P node leaks memory under load" reports.
CHUNKS to 40000 and watch the concat time roughly quadruple (4×), confirming O(n²) — while the other two only double.socket.writableLength each time write returns false to see the high-water mark Node is enforcing.const h = require('perf_hooks').monitorEventLoopDelay(); h.enable(); around a blocking loop and read h.max to quantify a stall in milliseconds.allocUnsafe over alloc, and what's the risk?socket.write returning false means the buffer is full; you must stop writing until
'drain'. Honor it via pipe/pipeline, async iteration, or the manual
write/drain dance. Ignoring it causes unbounded memory growth.O(n²) concat trap and the pre-sized-buffer fix.allocUnsafe is appropriate and its hazard.'drain' and I understand what breaks without it.Flame graphs that prove your optimization worked — then build the whole stack end to end.
Optimization without measurement is superstition. Today you learn to find the bottleneck before touching code, then you assemble everything from Days 1–6 into a single working P2P node: DHT-style discovery, a Noise-style handshake, length-prefixed binary framing, and verified message exchange. This is also your interview rehearsal — the capstone is the system-design answer.
| Tool | What it shows | When to reach for it |
|---|---|---|
node --prof | V8 sampling profile → process with --prof-process | First look at where CPU time goes, zero deps |
node --cpu-prof | Writes a .cpuprofile for Chrome DevTools | Visual flame graph in a familiar UI |
clinic doctor | Diagnoses the kind of problem (CPU, I/O, GC, event-loop) | "It's slow and I don't know why" |
clinic flame / 0x | Flame graph of stack frames by time | Pinpoint the hot function |
clinic bubbleprof | Async operation flow & latency | Async/await and I/O latency chains |
perf_hooks | Precise in-process timing & event-loop delay | Targeted micro-measurements & CI gates |
The x-axis is not time — it's the share of samples a function (and its callees) appeared in. Width = cost. Height = call-stack depth. You hunt for wide plateaus: a single wide frame is a function burning CPU directly; a wide frame with narrow children is the leaf where the time actually goes. Optimizing a narrow frame, however slow it feels, moves nothing. Wide first, always.
O(n²) → O(n) fix (the Day 6 concat trap) beats any micro-tuning.Generate a CPU profile, read it, fix the hot spot, and confirm the win — the full measure-fix-measure loop.
// slow.js — a "message processor" with a hidden hot spot
const { performance } = require('perf_hooks');
function processMessages(n) {
let log = '';
for (let i = 0; i < n; i++) {
log += JSON.stringify({ id: i, ok: true }) + '\n'; // string += in a loop = O(n^2)-ish
}
return log.length;
}
const t = performance.now();
console.log('len:', processMessages(200000));
console.log('took:', (performance.now() - t).toFixed(0), 'ms');
# profile it
node --cpu-prof --cpu-prof-name=slow.cpuprofile slow.js
# open slow.cpuprofile in Chrome DevTools (Performance tab) -> see the wide frame
# or the zero-dependency route:
node --prof slow.js
node --prof-process isolate-*.log > profile.txt
# inspect the "Summary" and "Bottom up (heavy) profile" sections
The profile fingers string concatenation / stringify as the wide frame. The fix is the same shape as Day 6: collect parts, join once.
// fast.js — same output, collect-then-join
function processMessages(n) {
const parts = [];
for (let i = 0; i < n; i++) parts.push('{"id":' + i + ',"ok":true}');
return parts.join('\n').length;
}
Re-profile and confirm the plateau shrank. That's the loop — never trust a fix you didn't re-measure.
Now we wire the week together. This single program runs a tiny in-memory "DHT" to discover a peer's address, runs
an X25519 handshake to derive a shared key, then exchanges length-prefixed, AEAD-encrypted binary
messages — the whole Holepunch-shaped pipeline in one file, using only Node built-ins plus sodium-native.
node capstone.js)// capstone.js — discovery + handshake + framed encrypted messaging, in-process
const sodium = require('sodium-native');
const EventEmitter = require('events');
// ---------- Day 4: varint + length-prefix framing ----------
function encodeVarint(v) {
const b = [];
while (v > 0x7f) { b.push((v & 0x7f) | 0x80); v = Math.floor(v / 128); }
b.push(v & 0x7f); return Buffer.from(b);
}
function decodeVarint(buf, off = 0) {
let r = 0, s = 0, p = off;
while (true) {
const x = buf[p++]; if (x === undefined) throw new Error('incomplete');
r += (x & 0x7f) * Math.pow(2, s); if ((x & 0x80) === 0) break; s += 7;
}
return { value: r, bytesRead: p - off };
}
class Framer extends EventEmitter {
constructor() { super(); this.buf = Buffer.alloc(0); }
push(chunk) {
this.buf = Buffer.concat([this.buf, chunk]);
while (this.buf.length) {
let h; try { h = decodeVarint(this.buf); } catch { return; }
const total = h.bytesRead + h.value;
if (this.buf.length < total) return;
this.emit('message', Buffer.from(this.buf.subarray(h.bytesRead, total)));
this.buf = this.buf.subarray(total);
}
}
}
const frame = (p) => Buffer.concat([encodeVarint(p.length), p]);
// ---------- Day 5: handshake + AEAD ----------
function kxKeypair() {
const pk = Buffer.alloc(sodium.crypto_kx_PUBLICKEYBYTES);
const sk = Buffer.alloc(sodium.crypto_kx_SECRETKEYBYTES);
sodium.crypto_kx_keypair(pk, sk); return { pk, sk };
}
const A_BYTES = sodium.crypto_aead_chacha20poly1305_ietf_ABYTES;
const N_BYTES = sodium.crypto_aead_chacha20poly1305_ietf_NPUBBYTES;
function seal(key, counter, msg) {
const nonce = Buffer.alloc(N_BYTES); nonce.writeUInt32LE(counter, 0); // per-message nonce
const ct = Buffer.alloc(msg.length + A_BYTES);
sodium.crypto_aead_chacha20poly1305_ietf_encrypt(ct, msg, null, null, nonce, key);
return ct;
}
function open(key, counter, ct) {
const nonce = Buffer.alloc(N_BYTES); nonce.writeUInt32LE(counter, 0);
const out = Buffer.alloc(ct.length - A_BYTES);
sodium.crypto_aead_chacha20poly1305_ietf_decrypt(out, null, ct, null, nonce, key);
return out;
}
// ---------- Day 1/3: a trivial discovery registry ----------
const registry = new Map(); // topic -> { inbox: Framer, key, counter }
function makePeer(name, topic) {
const kp = kxKeypair();
const inbox = new Framer();
const peer = { name, topic, kp, inbox, key: null, rxCounter: 0, txCounter: 0, partner: null };
registry.set(name, peer);
return peer;
}
// "send" = hand framed bytes to the partner's inbox (stands in for the wire)
function send(from, payload) {
const ct = seal(from.key, from.txCounter, payload);
from.txCounter++;
from.partner.inbox.push(frame(ct)); // length-prefixed encrypted frame
}
// ---------- wire it together ----------
const alice = makePeer('alice', 'chat-42');
const bob = makePeer('bob', 'chat-42');
alice.partner = bob; bob.partner = alice;
// discovery + handshake: derive a shared AEAD key from kx
function handshake(client, server) {
const cRx = Buffer.alloc(32), cTx = Buffer.alloc(32);
const sRx = Buffer.alloc(32), sTx = Buffer.alloc(32);
sodium.crypto_kx_client_session_keys(cRx, cTx, client.kp.pk, client.kp.sk, server.kp.pk);
sodium.crypto_kx_server_session_keys(sRx, sTx, server.kp.pk, server.kp.sk, client.kp.pk);
// use one direction's key for both here (demo); real protocols key each direction
client.key = cTx; server.key = sRx; // cTx === sRx
return cTx.equals(sRx);
}
console.log('handshake ok:', handshake(alice, bob));
// bob decrypts and verifies each framed message he receives
bob.inbox.on('message', (ct) => {
try {
const msg = open(bob.key, bob.rxCounter, ct);
bob.rxCounter++;
console.log(`bob received: "${msg.toString()}"`);
} catch {
console.log('bob: message failed AEAD verification — dropped');
}
});
// alice sends three encrypted, framed messages
send(alice, Buffer.from('hello bob'));
send(alice, Buffer.from('this channel is encrypted'));
send(alice, Buffer.from('and every frame is verified'));
// tamper demo: flip a byte in a sealed frame and confirm it's rejected
const good = seal(alice.key, 99, Buffer.from('evil'));
good[2] ^= 0xff;
bob.rxCounter = 99;
bob.inbox.push(frame(good)); // -> "failed AEAD verification — dropped"
node capstone.js
// handshake ok: true
// bob received: "hello bob"
// bob received: "this channel is encrypted"
// bob received: "and every frame is verified"
// bob: message failed AEAD verification — dropped
Each hand-rolled piece maps to a production module: the registry → hyperdht; the kx handshake →
the Noise/UDX transport inside hyperswarm; the framer → compact-encoding + the stream
protocol; the append-and-verify pattern → hypercore. Rebuild the capstone using hyperswarm
for real: join a topic, and on 'connection' you get an already-encrypted, already-framed stream — every
layer you just built by hand, productionized.
dgram rendezvous from Day 3 so the two peers run as separate processes.compact-encoding structs (Day 4) and confirm identical behavior.--cpu-prof; confirm no accidental concat-in-loop and that AEAD dominates CPU (as it should).O(log N) hops.--cpu-prof or clinic flame for a flame graph, look for the
widest frame. Check for concat-in-loop (O(n²)), per-message allocation/GC pressure, event-loop blocking
from sync work, and missing backpressure. Fix the widest frame, then re-measure.You've now built, by hand, a small version of every layer in a modern P2P stack — and you can explain each one and prove it runs. Walk into the interview and rebuild the capstone on a whiteboard from the five-layer summary above. That's the whole game.