How to Build a Decentralized Peer-to-peer Network in JavaScript

How to Build a Decentralized Peer-to-peer Network in JavaScript

·

10 min read

Featured on Hashnode

In this tutorial you will learn how to build a real-time & offline-first P2P chat application using Gun.js & React.

The source code for this app is located here. Watch a video walkthrough of how to build this app here

GUN.js is a small data sync and storage system that runs everywhere JavaScript does.

The aim of GUN is to let you focus on the data that needs to be stored, loaded, and shared in your app without worrying about servers, network calls, databases, or tracking offline changes or concurrency conflicts. This lets you build things like chat apps, games, or any type of app that requires real-time updates.

GUN apps are fully decentralized, meaning that changes aren't controlled by any central server. Servers are usually just another peer in the network, one that may have more reliable resources than a browser. You save data on one machine, and it will sync it to other peers without needing a complex consensus protocol.

How does it work?

Gun stores your data across all connected peers in the network. Each peer may own the complete graph, or just a subset of the complete graph, or it may possess data that does not yet exist on any other node. The whole database is considered to be the union of all peers' graphs.

From the docs:

There is no theoretical limit for the total size of a gun graph. The amount of data that a peer has locally available is limited by the memory constraints of the host environment, like operating system, browser, etc. The amount of data that can be persisted beyond the running process depends on the storage engine. In a web browser it will usually be 5MB of localStorage. A relay peer with Radix file storage can persist much bigger amounts of data.

Relay peers are dedicated gun peers running on NodeJS. They will receive all data that is broadcasted to the network and be able to serve it on request. Normal peers running in a web browser will not start downloading the whole database but only the parts they request. A well designed gun application will only download relevant data, instead of draining the users bandwidth and RAM. This and the general unreliability of browser peers make it essential to have one or more relay peers running 24/7 for the operation of most real world use cases.

Now that we've gone through an overview of Gun.js, let's start building.

Getting started

To get started, create a new empty folder and initialize a new package.json file:

mkdir gun-app
cd gun-app
npm init --y

Next, install Gun and express using either npm or yarn:

yarn add gun express

Next, create a file to hold the server code:

touch index.js

Add the following code to create the Gun server:

// index.js
const express = require('express')
const Gun = require('gun');
const app = express()
const port = 3030
app.use(Gun.serve);

const server = app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`)
})

Gun({ web: server });

Next, create a new React app in the root folder:

npx create-react-app react-gun

Change into the new directory and install Gun using either npm or yarn:

cd react-gun
yarn add gun

The Gun API

Before we start writing any client-side code, let's first take a look at the Gun API.

To start using Gun, import it and initialize it:

import Gun from 'gun'

const gun = Gun({
  peers: [
    'http://localhost:3030/gun'
  ]
})

Saving data

Gun has a few core methods for saving and fetching data using keys and values. If a key does not yet exist, Gun will create it for you dynamically.

// saving an item
gun.get('tutorial').put({
  title: "Intro to Gun"
})

You can also work with objects:

// saving an object
gun.get('nader').put({
  address: {
    city: "Jackson",
    state: "Mississippi"
  }
})

Reading data

To fetch an item once, you can use the once method:

gun.get('nader').get('address').once(data => {
  console.log('data changed: ', data)
})

To listen to updates in real-time you can use the on method:

gun.get('nader').get('address').on(data => {
  console.log('data changed: ', data)
})

You can nest them as deeply as you'd like:

// put
gun.get('nader').get('favorites').get('languages').get('javascript').get('frameworks').put({
  name: 'React'
})

// get
gun.get('nader').get('favorites').get('languages').get('javascript').get('frameworks').once(data => {
  console.log({ data })
})

You can also work with array-like data structures using set:

const messages = gun.get('messages')
messages.set({ message: "Hello World", createdAt: Date.now() })

Creating the chat app

Now that we know how the API works, let's create a simple chat app.

First open src/App.js and remove all of the code. Next, add the following code at the top of the file:

import { useEffect, useState, useReducer } from 'react'
import Gun from 'gun'

// initialize gun locally
// sync with as many peers as you would like by passing in an array of network uris
const gun = Gun({
  peers: [
    'http://localhost:3030/gun'
  ]
})

// create the initial state to hold the messages
const initialState = {
  messages: []
}

// Create a reducer that will update the messages array
function reducer(state, message) {
  return {
    messages: [message, ...state.messages]
  }
}

Next, create the main App component:

export default function App() {
  // the form state manages the form input for creating a new message
  const [formState, setForm] = useState({
    name: '', message: ''
  })

  // initialize the reducer & state for holding the messages array
  const [state, dispatch] = useReducer(reducer, initialState)

  // when the app loads, fetch the current messages and load them into the state
  // this also subscribes to new data as it changes and updates the local state
  useEffect(() => {
    const messages = gun.get('messages')
    messages.map().on(m => {
      dispatch({
        name: m.name,
        message: m.message,
        createdAt: m.createdAt
      })
    })
  }, [])

  // set a new message in gun, update the local state to reset the form field
  function saveMessage() {
    const messages = gun.get('messages')
    messages.set({
      name: formState.name,
      message: formState.message,
      createdAt: Date.now()
    })
    setForm({
      name: '', message: ''
    })
  }

  // update the form state as the user types
  function onChange(e) {
    setForm({ ...formState, [e.target.name]: e.target.value  })
  }

  return (
    <div style={{ padding: 30 }}>
      <input
        onChange={onChange}
        placeholder="Name"
        name="name"
        value={formState.name}
      />
      <input
        onChange={onChange}
        placeholder="Message"
        name="message"
        value={formState.message}
      />
      <button onClick={saveMessage}>Send Message</button>
      {
        state.messages.map(message => (
          <div key={message.createdAt}>
            <h2>{message.message}</h2>
            <h3>From: {message.name}</h3>
            <p>Date: {message.createdAt}</p>
          </div>
        ))
      }
    </div>
  );
}

Running the app

To run the app, first start the server by running this command in the root directory:

node index.js

Next, start the react app:

cd react-gun
npm start

The chat app should now be running. You should be able to open multiple browser windows and be able to see new messages coming through in real time.

Next steps

There is also a set of APIs for working with Security, Authorization, and Encryption. To learn how to work with the user API, check out the documentation here.

To learn how to enable alternative storage adapters, like Amazon S3, check out the docs here.