Create a P2P network with Node from scratch.
Tags: p2p, javascript, node, programming
Published: 2022-03-30 20:45:59

The best way to learn stuff in development is go and try to create your own whatever it is. In this article I'll walk you through creating a minimal example of _unstructured_ _untrusted_ peer to peer network using Node JS. I hope it will make you understand them better.

---

Basically P2P network is an abstraction based on a subset of nodes of underlaying network (TCP/IP for example), where all of the nodes are (in)directly connected to each other and equipotent (meaning they have the same role on application level, in oppose to server/client model).

In order to implement that, I be using `net` node module to establish connection and exchange information between nodes. In the end we'll make the simplest p2p chat application.

First of all, I want to define an interface of the library. As a consumer, I would like to get a function, that startups a node and returns a function, that stops it. Somewhere in the options, passed to that function I would like to handle the messages and do other stuff.

```js
const net = require('net');

module.exports = (options) => {
  // start the node
  return () => {
     // stop the node
  };
};

```

Now, to begin, I need to setup a server to accept connections from peers.

```js
const handleNewSocket = (socket) => {
  // peer connected

  socket.on('close', () => {
    // peer disconnected
  });

  socket.on('data', (data) => {
    // message from peer
  };
};

const server = net.createServer((socket) => {
  handleNewSocket(socket);
});
```

In order to understand, who is connected where and be able to send messages to those peers, I would like to keep them somewhere. To do this, I assign every connected socket with an ID and store them in a Map. The implementation of randomuuid is up to you.

Plus I know, that later, when I'll be implementing more logic I may need to catch the moment new connection is established, as well as the moment data arrives, but I don't know what I should put in there, so I leave a "socket" by emitting events for those cases. I will agree with myself, that I will only send JSON data to peers, so I also insert parsing code in there.

```js
const EventEmitter = require('events');

const emitter = new EventEmitter();

const handleNewSocket = (socket) => {
  const connectionId = randomuuid();

  connections.set(connectionId, socket);
  emitter.emit('connect', connectionId);

  socket.on('close', () => {
    connections.delete(connectionId);
    emitter.emit('disconnect', connectionId);
  });

  socket.on('data', (data) => {
    try {
      emitter.emit('message', { connectionId, message: JSON.parse(data.toString()) });
    } catch (e) {
      // console.error(`Cannot parse message from peer`, data.toString())
    }
  });
};
```

After I have my server setup done, I can create a method that actually connects to other peers by making new sockets. It's a bit unusual to think "as server" and "as client" at the same time within one application, but P2P application actually are TCP servers and clients at the same time.

I will use `handleNewSocket` to treat outgoing connections as there is no difference to me whether the connection is outgoing or ingoing. And I will create a `send` method that will directly send a message to a specific connection.

```js
const send = (connectionId, message) => {
  const socket = connections.get(connectionId);

  if (!socket) {
    throw new Error(`Attempt to send data to connection that does not exist ${connectionId}`);
  }

  socket.write(JSON.stringify(message));
};

const connect = (ip, port, cb) => {
  const socket = new net.Socket();

  socket.connect(port, ip, () => {
    handleNewSocket(socket);
    cb();
  });
};
```

After I have my `connect` method implemented, I only need the server to start listening to connections and I should be good to go. If you want, you can add a listener to an event when server is ready, I just didn't need that one.

```js
server.listen(options.port, '0.0.0.0' /* add options.onReady */);

return (cb) => {
  server.close(cb);
};
```

Okay, I can connect to peers, but what if I want to send data over them? To someone I am not connected to? Well, first of all, I need to identify them. To do so, every node has to have its own uniq name. I cannot use connection ids as they may be different for the same node, plus they can be closed and open simultaneously due to rebalancing, for example. 

To proceed, I need to create a second `layer`, that introduces the `Nodes` concept. Every Node will have its own uniq ID, that is defined by the Node on the startup. Once connection is established, the Node will send a specific message to its peers introducing itself. Peer Nodes will have to put their neighbor in a collection and introduce themselves too.

I'll listen to `connect` event to figure out when new connection is established and make the node introduce itself. Since this event is emitted for both outgoing and ingoing connections, I don't need to reply to that, both nodes on both sides of connection will receive that event.

```js
const NODE_ID = randomuuid();
const neighbors = new Map();

emitter.on('connect', (connectionId) => {
  send(connectionId, { type: 'handshake', data: { nodeId: NODE_ID } });
});
```

After that, just later below, I'll listen to messages coming from the connection and, if that message is a type of `handshake`, I'll store the node and it's connection in `neighbors` map. And, as in the code above, I will notify upcoming layers, that the new node has been connected.

```js
emitter.on('message', ({ connectionId, message }) => {
  const { type, data } = message;

  if (type === 'handshake') {
    const { nodeId } = data;

    neighbors.set(nodeId, connectionId);
    emitter.emit('node-connect', { nodeId });
  }
});
```

And, in the very end, I'll listen to `disconnect` event to see when the connection to a peer is lost and remove the corresponding node from the list of neighbors. To do so, I will need to find a nodeId (key) by connectionId(value) in my map, so I'll make a helper for that.

```js
const findNodeId = (connectionId) => {
  for (let [nodeId, $connectionId] of neighbors) {
    if (connectionId === $connectionId) {
      return nodeId;
    }
  }
};

emitter.on('disconnect', (connectionId) => {
  const nodeId = findNodeId(connectionId);

  if (!nodeId) {
    // Let you handle the errors
  } 

  neighbors.delete(nodeId);
  emitter.emit('node-disconnect', { nodeId });
});
```

And, lastly, I will treat messages from the connections, where I know the corresponding nodeId, a bit differently. I will agree with myself, that everything I send to Node is not only JSON, but a `{ type, data }` object, where `type` is either `handshake` or `message`. I'm already handling `handshake` properly, so I only need to add `message` handling. The resulting code should look like this:

```js
emitter.on('message', ({ connectionId, message }) => {
  const { type, data } = message;

  if (type === 'handshake') {
    const { nodeId } = data;

    neighbors.set(nodeId, connectionId);
    emitter.emit('node-connect', { nodeId });
  }

  if (type === 'message') {
    const nodeId = findNodeId(connectionId);

    // if (!nodeId) {
    //   oops
    // }

    emitter.emit('node-message', { nodeId, data });
  }
});
```

See that now I emit `node-*` events for the following layers to use instead of `connect`, `disconnect` or `message` as they have a different format and a bit different meaning. A `node-message` will have a specific ID that will stay the same even if reconnect happens. 

I can now receive data from Node, but I can't send a data there, so let's make a method for it. Remember I agreed upon `{ type, data }` format.

```js
const nodesend = (nodeId, data) => {
  const connectionId = neighbors.get(nodeId);

  if (!connectionId) {
    // error treatment for you
  }

  send(connectionId, { type: 'message', data });
};
```

Great! I have connections to our neighbors established, I can send and receive data, I know who sent this data and the ID is persistent, let's actually implement a method to send data **over** them. 

The first way to send the data over neighbors is to _recursively_ broadcast. That means I will send a message to my neighbors, they will send this message to their neighbors (including me) and so on. 

To eventually stop this process I should keep the track of all the messages I have broadcasted in a collection, so I will assign an ID to each message I send and put it to the Set. But what if the traffic is so big, so this Set is getting really big really fast?

To partially avoid that I can clean the Set once in a while, which may lead some messages I have already broadcasted to appear again. To protect from those scenarios I will track the **time to live** or TTL for each message, meaning it cannot be broadcasted more that N amount of times. This problem is really tricky, so I probably will make another article on that.

```js
const alreadySeenMessages = new Set();

const p2psend = (data) => {
  if (data.ttl < 1) {
    return;
  }

  for (const $nodeId of neighbors.keys()) {
    nodesend($nodeId, data);
    alreadySeenMessages.add(data.id);
  }
};

const broadcast = (message, id = randomuuid(), origin = NODE_ID, ttl = 1000) => {
  p2psend({ id, ttl, message, origin });
};
```

Correspondingly, I have to listen `node-message` and, once a message arrives, broadcast it to the next nodes.

```js
emitter.on('node-message', ({ nodeId, data }) => {
  if (!alreadySeenMessages.has(data.id)) {
    broadcast(data.message, data.id, data.origin, data.ttl - 1);
  }
});
```

Basically this is all about broadcasting, the very bare p2p network is already done, but I may also need to not only broadcast the data to everyone, but also to send a data to a specific node (direct message).

As you may suggest, since I may not have a direct connection to destination, the direct message will actually be a broadcast as well. It will only be the application layer that decides that this broadcast message should be ignored. To differentiate those 2, I will add `type` along `id` and `ttl`, which will be `broadcast` or `dm` correspondingly. (And yes, the full message body will be `{ type: '...', data: { type: '..', data: '...' }}`, but we don't care about it since it's on underlaying abstraction levels).

```js
const alreadySeenMessages = new Set();

const p2psend = (data) => {
  if (data.ttl < 1) {
    return;
  }

  for (const $nodeId of neighbors.keys()) {
    nodesend($nodeId, data);
    alreadySeenMessages.add(data.id);
  }
};

const broadcast = (message, id = randomuuid(), origin = NODE_ID, ttl = 1000) => {
  p2psend({ id, ttl, type: 'broadcast', message, origin });
};

const dm = (destination, message, origin = NODE_ID, ttl = 10, id = randomuuid()) => {
  p2psend({ id, ttl, type: 'dm', message, destination, origin });
};

emitter.on('node-message', ({ nodeId, data }) => {
  if (!alreadySeenMessages.has(data.id)) {
    if (data.type === 'broadcast') {
      emitter.emit('broadcast', { message: data.message, origin: data.origin });
      broadcast(data.message, data.id, data.origin, data.ttl - 1);
    }

    if (data.type === 'dm') {
      if (data.destination === NODE_ID) {
        emitter.emit('dm', { origin: data.origin, message: data.message });
      } else {
        dm(data.destination, data.message, data.origin, data.ttl - 1, data.id);
      }
    }
  }
});
```

And we're done! Let's adjust the function from the very beginning to provide the library consumer with some kind of interface and make a simple chat application with it. I decided to change the shape of returning object and simply include there everything I may need outside:

```js
return {
  broadcast, dm, on: emitter.on, connect,
  close: (cb) => {
    server.close(cb);
  },
};
```

And now, making a p2p chat application should be as simple as the following:

```js
const createp2pnode = require('./p2p');

const node = createp2pnode({ port: 8000 });

// connect to your peers here using node.connect(IP, PORT);

node.on('broadcast', ({ origin, message }) => {
  console.log(`${origin}: ${message}`);
});

process.stdin.on('data', (data) => {
  node.broadcast(data.toString());
});
```

That's it! A lot of topics remain uncovered, like structuring and balancing the network, protecting data that moves over the network, implementing DHTs, which I may tell you about in the future articles.

The code [in this repo](https://github.com/swensson/swenssonp2p).