Part 2: Setting Up the Decentralized Chat Application

In the first part of this series, we looked at decentralization and why it’s becoming popular. We also talked about peer-to-peer (P2P) systems like Hyperswarm RPC and Hyperbee. Now, let’s start with the technical setup and learn how to use these technologies to create a decentralized chat app.

This post will explain how to set up the server for our chat app. It will cover the reasons for each design choice. We will discuss other options and show its relevance to real-world situations.

Core Technologies in Use

1. Hyperswarm RPC

  • Purpose: Facilitates communication between peers using a Distributed Hash Table (DHT).
  • Why choose it:
    • Direct peer-to-peer message exchange.
    • Lightweight and easy to implement.
    • Eliminates the need for centralized servers.
  • Alternatives:
    • WebSockets (great for centralized setups but unsuitable for decentralization).
    • Libp2p (similar functionality but has a steeper learning curve).

2. Hyperbee

  • Purpose: A distributed key-value store for persisting chat messages.
  • Why choose it:
    • Decentralized and immutable storage.
    • Simple query capabilities, making it ideal for storing chat history.
  • Alternatives:
    • A traditional database like MongoDB (requires a central server).
    • Flat files or in-memory storage (less scalable and harder to query).

Setting Up the Chat Server

The server serves two main functions:

  1. Receiving and storing messages: Saves incoming messages to the Hyperbee database.
  2. Retrieving chat history: Allows clients to query messages from the database.

Step 1: Project Setup

Before diving into the code, let’s ensure you have the necessary dependencies installed:

npm install hyperdht @hyperswarm/rpc hypercore hyperbee

We’ve organized the project into modules for better readability and maintainability:

/src
  /config
    dhtConfig.js        # Configures the DHT for peer-to-peer communication
    rpcConfig.js        # Configures the RPC server
    databaseConfig.js   # Sets up the Hyperbee database
  /services
    messageService.js   # Handles message storage and retrieval
  /handlers
    rpcHandlers.js      # Handles RPC methods
server.js               # Entry point to initialize and run the server

Step 2: Configuring Core Components

DHT Configuration

The Distributed Hash Table (DHT) is the backbone of our peer-to-peer communication. We need to:

  1. Generate a unique identity for the server.
  2. Announce the server’s public key on the DHT network so clients can discover it.
// src/config/dhtConfig.js
const DHT = require('hyperdht');
const crypto = require('crypto');

class DHTConfig {
    constructor() {
        this.dht = new DHT();
        this.keyPair = DHT.keyPair(crypto.randomBytes(32));
    }

    async announce(port) {
        await this.dht.announce(this.keyPair.publicKey, { port });
        console.log('Server announced with public key:', this.keyPair.publicKey.toString('hex'));
    }

    getDHT() {
        return this.dht;
    }

    getKeyPair() {
        return this.keyPair;
    }
}

module.exports = DHTConfig;

What Happens Here?

Other peers can use this public key to connect to the server.

DHT Initialization (new DHT()):

A Distributed Hash Table (DHT) is the backbone of our decentralized chat. It allows peers to discover each other without a central server.

Think of this as setting up a phone book for the network. Peers register themselves so others can find them.

Key Pair Generation:

DHT.keyPair(crypto.randomBytes(32)) generates a unique cryptographic key pair for the server.

This key acts as the server’s unique identity on the network.

Announcing the Server:

this.dht.announce() makes the server discoverable by announcing its public key to the DHT network.

Keeping DHT setup in a separate module makes the code reusable for other peer-to-peer applications. Announcing the public key allows clients to discover the server on the DHT network.

RPC Server Configuration

The RPC server manages client requests for sending and retrieving messages. It listens for specific methods like sendMessage and getMessages.

// src/config/rpcConfig.js
const RPC = require('@hyperswarm/rpc');

class RPCConfig {
    constructor(dht) {
        this.rpc = new RPC({ dht });
        this.server = this.rpc.createServer();
    }

    async listen() {
        await this.server.listen();
        console.log('RPC server is running...');
    }

    addHandler(method, handler) {
        this.server.respond(method, handler);
    }
    
    getServer() {
        return this.server;
    }
}

module.exports = RPCConfig;

What Happens Here?

  1. RPC Initialization:
    • new RPC({ dht }) creates an RPC instance tied to the DHT network.
    • RPC (Remote Procedure Call) allows us to define methods that can be called remotely by clients, such as sendMessage or getMessages.
  2. Server Creation:
    • this.rpc.createServer() initializes an RPC server to handle incoming requests.
  3. Listening for Requests:
    • this.server.listen() starts the RPC server, allowing clients to call methods like sendMessage and getMessages.
  4. Adding Handlers:
    • this.server.respond() binds a specific method (e.g., sendMessage) to a handler function that defines its behavior.

Separating RPC configuration allows us to easily modify or extend communication logic. If you want to add a new method, such as deleteMessage, register it using addHandler. This can be done without touching other parts of the code. Adding handlers dynamically (addHandler) makes the server adaptable.

Database Configuration

Messages are stored in a decentralized database using Hyperbee, a distributed key-value store.

// src/config/databaseConfig.js
const Hypercore = require('hypercore');
const Hyperbee = require('hyperbee');

class DatabaseConfig {
    constructor() {
        this.hypercore = new Hypercore('./db/chat-history');
        this.hyperbee = new Hyperbee(this.hypercore, {
            keyEncoding: 'utf-8',
            valueEncoding: 'json',
        });
    }

    async init() {
        await this.hyperbee.ready();
        console.log('Database initialized.');
    }

    getDatabase() {
        return this.hyperbee;
    }
}

module.exports = DatabaseConfig;

What Happens Here?

  1. Hypercore Initialization:
    • new Hypercore('./db/chat-history') creates a file-based storage backend for our data.
    • This acts as the foundation for the Hyperbee key-value store.
  2. Hyperbee Setup:
    • new Hyperbee(this.hypercore, { keyEncoding: 'utf-8', valueEncoding: 'json' }) initializes a key-value database.
    • It uses string keys (utf-8) and stores values as JSON objects.
  3. Database Initialization:
    • this.hyperbee.ready() ensures the database is ready before storing or retrieving data.
  4. Accessing the Database:
    • The getDatabase() method provides access to the Hyperbee instance, making it easy to use across the app.

Centralized database configuration ensures a clean and consistent way to access storage across the application. Hyperbee’s key-value structure is ideal for storing timestamped chat messages. This modular setup makes it easy to replace Hyperbee with another database if needed.

Step 3: Implementing Message Logic

The message service is responsible for:

  1. Storing messages in the database.
  2. Retrieving chat history.
// src/services/messageService.js
class MessageService {
    constructor(database) {
        this.database = database;
    }

    async storeMessage(from, to, message) {
        const timestamp = Date.now();
        await this.database.put(`message:${timestamp}`, { from, to, message });
        console.log(`Message stored from ${from}:`, message);
    }

    async getMessages() {
        const messages = [];
        for await (const { key, value } of this.database.createReadStream()) {
            messages.push({ key, value });
        }
        return messages;
    }
}

module.exports = MessageService;

What Happens Here?

  1. Storing Messages:
    • this.database.put() saves a message using a timestamp as the key (e.g., message:1668881234567).
    • This structure ensures messages are stored in chronological order, making retrieval easy.
  2. Retrieving Messages:
    • this.database.createReadStream() reads all messages from the database in the order they were stored.
    • Each message is pushed to an array and returned to the caller.

Encapsulating storage logic in a service makes it easier to add features (e.g., filtering messages) without modifying other parts of the application. We can add features here, such as filtering messages by sender. This can be done without affecting other parts of the app.

Step 4: Setting Up RPC Handlers

The RPC handlers bridge the client’s requests with the message service.

// src/handlers/rpcHandlers.js
const MessageService = require('../services/messageService');

class RPCHandlers {
    constructor(database) {
        this.messageService = new MessageService(database);
    }

    sendMessage = async (req) => {
        const { from, to, message } = JSON.parse(req.toString());
        await this.messageService.storeMessage(from, to, message);
        return Buffer.from(JSON.stringify({ status: 'success' }));
    };

    getMessages = async () => {
        const messages = await this.messageService.getMessages();
        return Buffer.from(JSON.stringify(messages));
    };
}

module.exports = RPCHandlers;

What Happens Here?

  1. sendMessage Handler:
    • Parses the incoming request to extract from, to, and message.
    • Stores the message using the MessageService.
  2. getMessages Handler:
    • Fetches all messages from the MessageService and returns them as a JSON response.

By separating handlers from the RPC configuration, we make it easier to add, modify, or remove methods without affecting the server setup. Handlers are focused solely on business logic, keeping them simple and easy to test.

Step 5: Bringing It All Together

The server.js file initializes the server and binds all components.

// server.js
const DHTConfig = require('./config/dhtConfig');
const RPCConfig = require('./config/rpcConfig');
const DatabaseConfig = require('./config/databaseConfig');
const RPCHandlers = require('./handlers/rpcHandlers');

const main = async () => {
    // Initialize configurations
    const dhtConfig = new DHTConfig();
    const databaseConfig = new DatabaseConfig();
    const rpcConfig = new RPCConfig(dhtConfig.getDHT());

    // Initialize services
    await dhtConfig.announce(40001);
    await databaseConfig.init();

    const rpcHandlers = new RPCHandlers(databaseConfig.getDatabase());

    // Register RPC Handlers
    rpcConfig.addHandler('sendMessage', rpcHandlers.sendMessage);
    rpcConfig.addHandler('getMessages', rpcHandlers.getMessages);

    // Start RPC Server
    await rpcConfig.listen();

    console.log('Chat server is ready!');
};

main().catch(console.error);

How This Structure Helps

  1. Modularity:
    • Each component (DHT, RPC, database, message logic) is independent, making the code easier to maintain and test.
  2. Extensibility:
    • Adding new features, such as message encryption or group chats, only requires changes to specific modules.
  3. Real-World Alignment:
    • A modular approach mirrors how scalable systems are designed, with clear boundaries and responsibilities.

What’s Next?

In Part 3, we’ll:

  • Build the client-side application to send and retrieve messages.
  • Discuss how to test and debug decentralized systems.
  • Explore adding enhancements like encryption and group chats.

Let me know your thoughts or questions in the comments! 😊

If you want to revisit Part 1 – Click Here


Discover more from Amal Gamage

Subscribe to get the latest posts sent to your email.

Previous Article

Building a Decentralized Chat Application using Hyperswarm RPC and Hyperbee

Next Article

Part 3: Building the Client for a Decentralized Chat Application

Write a Comment

Leave a Reply