DEV Community

Aleksandr Churbakov
Aleksandr Churbakov

Posted on • Updated on

Create a P2P network with Node from scratch.

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.

const net = require('net');

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

Enter fullscreen mode Exit fullscreen mode

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

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

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

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

const server = net.createServer((socket) => {
  handleNewSocket(socket);
});
Enter fullscreen mode Exit fullscreen mode

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.

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())
    }
  });
};
Enter fullscreen mode Exit fullscreen mode

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.

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();
  });
};
Enter fullscreen mode Exit fullscreen mode

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.

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

return (cb) => {
  server.close(cb);
};
Enter fullscreen mode Exit fullscreen mode

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.

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

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

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.

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

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

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

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.

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 });
});
Enter fullscreen mode Exit fullscreen mode

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:

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 });
  }
});
Enter fullscreen mode Exit fullscreen mode

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.

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

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

  send(connectionId, { type: 'message', data });
};
Enter fullscreen mode Exit fullscreen mode

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.

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 });
};
Enter fullscreen mode Exit fullscreen mode

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

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

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).

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);
      }
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

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:

return {
  broadcast, dm, on: emitter.on, connect,
  close: (cb) => {
    server.close(cb);
  },
};
Enter fullscreen mode Exit fullscreen mode

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

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());
});
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (7)

Collapse
 
kosm profile image
Kos-M

Hi Alexander , great work , thanks for this post/project.
I think DHT in combination with TURN/STUN servers is must.
For more robust uses.For example if peers dont known main servers ip/port to be self discoverable , even behind NAT without the need of port forwarding.
I was looking for a lightweight p2p solution to sharing files for a project im working theese days , if i can help you on this somehow, let me know..

Collapse
 
brother_ape profile image
business-guy

Hey Kos-M!
I stumbled across this reply with a few questions about p2p network in Node. Any chance I can take your offer up instead?

Collapse
 
kosm profile image
Kos-M

Sure , if i can help . Dm me in my Twitter : twitter.com/kos__m

Collapse
 
lxchurbakov profile image
Aleksandr Churbakov

Hey guys! I've updated the repo, added a working chat example. Feel free to post any suggestions and comments!

Collapse
 
sofia_churbakova_53e47179 profile image
Sofia Churbakova

Thank you! Good job!!

Collapse
 
stefcud profile image
Stefano Cudini

what tool do you use to do local tests?
it is difficult to test p2p applications because you have to have many different ip addresses

Collapse
 
calteen profile image
calteen

nice