Construire Un Réseau P2P Avec Deno 🌐

Construire Un Réseau P2P Avec Deno 🌐

4 novembre 2022 | 14 mins de lecture

Publié sur dev.to et medium.com

Développer un système pair-à-pair en JavaScript, c'est possible avec Deno !

Récemment, j'ai découvert le framework SolidJS, et j'ai très vite souhaité mettre en pratique ce que j'avais appris en développant une application robuste. Pour cela, j'ai décidé de coder une interface Web permettant de contrôler les actions d'un noeud dans un réseau de pair-à-pair. Okay, mais c'est quoi un réseau de pair-à-pair ?

Un système P2P (ou réseau de pair-à-pair) est une architecture technique d'échange de données dans laquelle chaque "utilisateur" est à la fois client, mais aussi serveur. Dans ce genre de structure, on nomme l'utilisateur "noeud" ou encore "pair". Il existe 2 types de réseaux P2P :

  • Les systèmes centralisés, disposant d'un serveur principal, source de vérité des données de l'application
  • Les systèmes décentralisés, ou chaque noeud est garant de l'intégrité et de l'authenticité de la donnée

NB : C'est en m'intéressant aux concepts de "blockchain" et d'applications décentralisées (DApps) que j'ai décidé de monter ce projet, et de créer un réseau de pair-à-pair, de A à Z.

Naturellement, j'ai aussitôt cherché à développer ce genre de système en... JavaScript ! C'est effectivement possible, grâce au protocole WebSocket. Dans cet article, je vous propose de construire, étape par étape, un réseau P2P (décentralisé) en s'appuyant, non pas sur ExpressJS x SocketIO, ni NestJS (pour accélérer le projet), mais directement depuis Deno 🦕

Part I - Initialisation du Serveur

NB : J'ai déjà évoqué ce qu'est Deno dans un précédent article, je vous invite à y jeter un coup d'oeil si vous souhaitez en savoir plus, et quelles différences existent-ils avec son homologue Node.

Dans un système P2P, un noeud est à la fois client et serveur. Donc, première étape : "mettre en place le serveur" ; c'est parti !

import { serve } from 'https://deno.land/std@0.159.0/http/server.ts';

async function handler(req: Request): Promise<Response> {
  return new Response(
    JSON.stringify({
      message: 'Hello World'
    })
  );
}

serve(handler, { port: 3030 });

server.ts

À l'image d'ExpressJS, ces quelques lignes suffisent à initialiser un serveur HTTP, afin d'obtenir le message "Hello World" (au format JSON) à partir de n'importe quelle route. Testez-le en faisant un appel à http://localhost:3030 directement depuis votre navigateur, après avoir démarrer votre serveur : deno run --allow-net server.ts

NB : À noter qu'ici j'utilise la version 1.26.0 de Deno, ainsi que la version 0.159.0 de ses modules. Deno étant sécurisé par défaut, le paramètre --allow-net est requis pour autoriser l'accès réseau.

1ère API

Il est temps d'ajouter un peu de logique à ce simple serveur. On va donc créer une première variable permettant de stocker les clients WebSocket / les pairs spécifiques au noeud courant. Pour rappel, dans un modèle décentralisé, chaque pair est garant de l'intégrité et l'authenticité de ses données.

import { serve } from 'https://deno.land/std@0.159.0/http/server.ts';

let allPeers = [];

async function handler(req: Request): Promise<Response> {
  const url = new URL(req.url);

  if (url.pathname === '/peers' && req.method === 'GET') {
    return new Response(JSON.stringify(allPeers));
  }

  return new Response(
    JSON.stringify({
      message: 'Hello World'
    })
  );
}

serve(handler, { port: 3030 });

server.ts

Voici comment exposer simplement une API avec Deno, sans utiliser une librairie supplémentaire (Alosaur, Servest, etc...). Ici, on rend disponible la route /peers pour récupérer la liste des pairs associés au serveur. Cette fois-ci, démarrez votre serveur et accédez à l'URL http://localhost:3030/peers pour constater le résultat (à savoir, un tableau vide pour le moment...).

Part II - Support WebSocket

Entrons dans le vif du sujet, en ajoutant le support de la norme WebSocket. Le serveur sera ainsi capable de réagir en temps réel à la réception de données provenant d'un autre serveur sur un même réseau.

import { serve } from 'https://deno.land/std@0.159.0/http/server.ts';

let allPeers = [];

async function handler(req: Request): Promise<Response> {
  const url = new URL(req.url);

  if (url.pathname === '/peers' && req.method === 'GET') {
    return new Response(JSON.stringify(allPeers));
  }

  const upgrade = req.headers.get('upgrade') || '';
  let response, socket: WebSocket;

  try {
    ({ response, socket } = Deno.upgradeWebSocket(req));
  } catch {
    // Request isn't trying to upgrade to WebSocket
    return new Response(
      JSON.stringify({
        message: 'Hello World'
      })
    );
  }

  socket.onopen = () => console.log('on:open');
  socket.onmessage = () => console.log('on:message');
  socket.onclose = () => console.log('on:close');
  socket.onerror = () => console.log('on:error');

  return response;
}

serve(handler, { port: 3030 });

server.ts

Cette "mise à jour" permet au serveur original de supporter le protocole WebSocket. Pour information, cette spécification Web permet une communication bidirectionnelle entre un client et un serveur, en une seule requête TCP.

Client WebSocket

Jusqu'à présent, pas de réel changement. Si on démarre notre serveur (deno run --allow-net server.ts) on n'aperçoit pas de modification apparente. Pourtant, le serveur est dorénavant capable de réagir aux transactions WebSocket (pour peu qu'il soit sollicité par un client compatible). Implémentons une nouvelle API qui donnera également plus de sens à l'application.

import { serve } from 'https://deno.land/std@0.159.0/http/server.ts';
import { urlWrapper } from './utils.ts';

let allPeers = [];

async function handler(req: Request): Promise<Response> {
  const url = new URL(req.url);

  // ...

  if (url.pathname === '/peer' && req.method === 'POST') {
    const body = await req.json();

    if (!body.targetUrl) {
      return new Response(JSON.stringify({ message: 'URL Value Fail' }), {
        status: 400
      });
    }

    const [_, wsUrl] = urlWrapper(body.targetUrl);
    const wsClient = new WebSocket(wsUrl);

    wsClient.onopen = () => {
      allPeers = [...allPeers, wsClient];
    };

    wsClient.onclose = () => {
      allPeers = allPeers.filter(client => {
        const reUrl = new RegExp(wsUrl);
        return !reUrl.test(client.url);
      });
    };

    return new Response(JSON.stringify({ message: `New Peer: '${wsUrl}'` }), {
      status: 201
    });
  }

  // ...

  return response;
}

serve(handler, { port: 3030 });

server.ts (extrait de code)

Voici la seconde API permettant de créer un nouveau client WebSocket. Explicitement, l'utilisateur devra fournir l'URL cible du noeud client via un appel POST du protocole REST. À l'ouverture du client (onopen), on ajoute celui-ci à la liste des pairs du serveur courant ; à l'inverse, à la fermeture (onclose), on supprime ce même client de la liste. Enfin, l'API retourne un simple message avec un status 201 (CREATED), ou bien un code HTTP 400 (BAD REQUEST) si la valeur de l'URL n'est pas présente dans le corps de l'appel. Étant donnée qu'il s'agit d'une requête POST, il est nécessaire d'utiliser un client REST : cURL, Postman, etc... ; pour interroger l'API.

/**
 * @param {string} url
 * @returns {string[]} HTTP URL + WS URL
 */
export const urlWrapper = (url: string) => {
  // That's an HTTP URL
  if (/^(http:\/\/)/.test(url)) {
    const wsUrl = url.replace('http://', 'ws://');
    return [url, wsUrl];
  }

  // That's an WS URL
  if (/^(ws:\/\/)/.test(url)) {
    const httpUrl = url.replace('ws://', 'http://');
    return [httpUrl, url];
  }

  // There's no protocol...
  return [`http://${url}`, `ws://${url}`];
};

utils.ts

NB : J'utilise une fonction intermédiaire pour formatter l'URL. En effet, la création d'un nouveau client WebSocket nécessite la présence de son protocole (ws://) plutôt que la chaine de caractère spécifique à une URL HTTP (http://). Ma méthode (bien que perfectible) permet de récupérer l'une ou l'autre, en fonction de la valeur saisie par l'utilisateur lors de l'interrogation de l'API.

Pour tester cette seconde API, je vous invite à suivre le processus suivant :

  1. Dans un terminal, démarrez un serveur (sur le port par défaut 3030)
  2. Changer le port (4040), puis démarrez un nouveau serveur dans un 2ème terminal
  3. Enfin, depuis un client REST, interrogez l'API /peer en POST, depuis le 1er serveur (http://localhost:3030/peer), avec le corps { "targetUrl": "127.0.0.1:4040" }
curl -H "Content-Type: application/json" -X POST -d '{"url": "127.0.0.1:4040"}' http://localhost:3030/peer

Requête POST via cURL

Si tout se passe correctement, vous devriez voir le 2ème terminal (celui dont le serveur est démarré sur le port 4040) afficher un message "on:open". Cette information s'expliquer du fait que le 1er serveur a ouvert un client vers le second. De même si vous interrogez le 1er serveur pour connaitre la liste de ses noeuds, celui-ci devrait avoir complété la liste avec la pair suivante : ws://127.0.0.1:4040

curl -H "Content-Type: application/json" -X GET http://localhost:3030/peers

Requête GET via cURL

Okay, mais... On en est où avec tout ça !? À la fin de ce chapitre, vous devriez avoir deux "noeuds" de votre réseau P2P (si vous avez suivi scrupuleusement le tutoriel, et que vous n'avez pas démarré de 3ème instance) :

  • Un "faux" noeud démarré sur le port 3030 qui est seulement serveur
  • Un "vrai" noeud démarré sur le port 4040 qui est à la fois client (depuis le premier noeud) et serveur

En résumé, le 1er serveur est capable d'envoyer des données à 4040 et d'en recevoir. En revanche, le 2ème serveur reçoit bien les données mais ne peut pas en envoyer à son tour à 3030. Conclusion, nous ne sommes pas encore dans un système de pair-à-pair...

Part III - La "Poignée de Main" 🤝

Maintenant, ce qu'il reste à faire (par rapport à la partie précédente), c'est une méthode de "Poignée de Main" 🤝 / ou appairage. Ce procédé permet à un noeud d'ouvrir un client vers un serveur, afin que ce dernier s'y connecte à son tour. Mettons cela en place !

Pong !

A.K.A "Le Récepteur"

import { serve } from 'https://deno.land/std@0.159.0/http/server.ts';
import { urlWrapper } from './utils.ts';

let allPeers = [];

async function handler(req: Request): Promise<Response> {
  const url = new URL(req.url);

  // ...

  socket.onopen = () => console.log('on:open');

  socket.onmessage = ({ data }) => {
    const parsedData = JSON.parse(data);

    switch (parsedData.type) {
      case 'HANDSHAKE':
        const [_, wsUrl] = urlWrapper(parsedData.payload);
        const wsClient = new WebSocket(wsUrl);

        wsClient.onopen = () => {
          allPeers = [...allPeers, wsClient];
        };

        wsClient.onclose = () => {
          allPeers = allPeers.filter(client => {
            const reUrl = new RegExp(wsUrl);
            return !reUrl.test(client.url);
          });
        };
        break;

      default:
        throw new Error();
    }
  };

  socket.onclose = () => console.log('on:close');
  socket.onerror = () => console.log('on:error');

  return response;
}

serve(handler, { port: 3030 });

server.ts (extrait de code)

Dans cette 1ère partie du code, le serveur va réagir à la réception d'un message ayant pour type "HANDSHAKE" 🤝, puis celui-ci va ouvrir un client vers l'URL WebSocket de la cible, préalablement passée en tant que valeur (payload) de ce même message.

NB : Avec TypeScript, je vous conseille d'utiliser des enums plutôt que des constantes pour vos types, c'est plus malin 😉

Ping !

import { serve } from 'https://deno.land/std@0.159.0/http/server.ts';
import { urlWrapper } from './utils.ts';

let allPeers = [];

async function handler(req: Request): Promise<Response> {
  const url = new URL(req.url);

  // ...

  if (url.pathname === '/peer' && req.method === 'POST') {
    const body = await req.json();

    if (!body.targetUrl) {
      return new Response(JSON.stringify({ message: 'Target URL Value Fail' }), {
        status: 400
      });
    }

    const [_, wsUrl] = urlWrapper(body.targetUrl);
    const wsClient = new WebSocket(wsUrl);

    wsClient.onopen = () => {
      allPeers = [...allPeers, wsClient];

      wsClient.send(JSON.stringify({ type: 'HANDSHAKE', payload: '127.0.0.1:3030' }));
    };

    wsClient.onclose = () => {
      allPeers = allPeers.filter(client => {
        const reUrl = new RegExp(wsUrl);
        return !reUrl.test(client.url);
      });
    };

    return new Response(JSON.stringify({ message: `New Peer: '${wsUrl}'` }), {
      status: 201
    });
  }

  // ...

  return response;
}

serve(handler, { port: 3030 });

server.ts (extrait de code)

Ensuite, on va enrichir le service responsable de la création d'un pair / d'un noeud, afin que celui-ci envoie un message vers le serveur cible, à l'ouverture d'un nouveau client WebSocket. Ainsi, chacun des deux serveurs (d'après ce tutoriel) sera en capacité de communiquer l'un avec l'autre. Testez-le d'après le processus de la partie précédente. À nouveau, si tout se déroule bien, chaque terminal devrait afficher le message "on:open" qui signifie que chaque serveur à ouvert un client vers l'autre. Et voilà ! Votre système de pair-à-pair est fonctionnel 👍

Part IV - Diffusion de Données

Puisque nous avons un réseau de pair-à-pair opérationnel, il est temps de passer à l'envoi de données entre noeuds, et plus particulièrement en communiquant de manière globale. Pour ce faire, il va falloir mettre en oeuvre un nouveau type de données, enrichir cette nouvelle structure de données et y accéder via des APIs, et bien sûr, les diffuser à notre réseau.

import { serve } from 'https://deno.land/std@0.159.0/http/server.ts';
import { urlWrapper } from './utils.ts';

let allPeers = [];
let allRecords = [];

async function handler(req: Request): Promise<Response> {
  const url = new URL(req.url);

  // ...

  if (url.pathname === '/records' && req.method === 'GET') {
    return new Response(JSON.stringify(allRecords.map(record => record.value)));
  }

  if (url.pathname === '/record' && req.method === 'POST') {
    const body = await req.json();

    if (!body.record) {
      return new Response(JSON.stringify({ message: 'Record Value Fail' }), {
        status: 400
      });
    }

    allRecords = [
      ...allRecords,
      {
        key: Math.random().toString(36).slice(2),
        value: body.record
      }
    ];

    allPeers.forEach(client => {
      client.send(
        JSON.stringify({
          type: 'NEW_RECORD',
          payload: allRecords[allRecords.length - 1]
        })
      );
    });

    return new Response(JSON.stringify({ message: `New Record: '${body.record}'` }), { status: 201 });
  }

  // ...

  return response;
}

serve(handler, { port: 3030 });

server.ts (extrait de code)

Premièrement, on crée deux nouvelles APIs (respectivement en GET et POST) pour la récupération d'une liste d'enregistrements (allRecords), ainsi que l'ajout d'un nouvel élément (record). De plus, lors de l'insertion de cet enregistrement, on émet un message WebSocket à tous les pairs du serveur courant. L'information est donc diffusée de manière globale.

import { serve } from 'https://deno.land/std@0.159.0/http/server.ts';
import { urlWrapper } from './utils.ts';

let allPeers = [];
let allRecords = [];

async function handler(req: Request): Promise<Response> {
  const url = new URL(req.url);

  // ...

  socket.onopen = () => console.log('on:open');

  socket.onmessage = ({ data }) => {
    const parsedData = JSON.parse(data);

    switch (parsedData.type) {
      case 'HANDSHAKE':
        const [_, wsUrl] = urlWrapper(parsedData.payload);
        const wsClient = new WebSocket(wsUrl);

        wsClient.onopen = () => {
          allPeers = [...allPeers, wsClient];
        };

        wsClient.onclose = () => {
          allPeers = allPeers.filter(client => {
            const reUrl = new RegExp(wsUrl);
            return !reUrl.test(client.url);
          });
        };
        break;

      case 'NEW_RECORD':
        const foundedRecord = allRecords.find(record => record.key === parsedData.payload.key);

        if (!foundedRecord) {
          allRecords = [...allRecords, parsedData.payload];
        }
        break;

      default:
        throw new Error();
    }
  };

  socket.onclose = () => console.log('on:close');
  socket.onerror = () => console.log('on:error');

  return response;
}

serve(handler, { port: 3030 });

server.ts (extrait de code)

D'un autre côté, le serveur réceptionne le message de type "NEW_RECORD", puis on insère la valeur du message (payload) en tant que nouvel élément de la liste des enregistrements, s'il n'y figure pas déjà. À noter, qu'on identifie l'enregistrement par une clé unique et aléatoire (Merci à l'objet Math 😉)

Je vous invite donc à tester ce fonctionnement, en démarrant un serveur supplémentaire (dans un nouveau terminal) :

  1. Démarrez un 1er serveur (sur le port par défaut 3030)
  2. Démarrez un 2ème serveur en changeant le port (4040)
  3. Démarrez un 3ème serveur en changeant le port (5050)
  4. Ajoutez les noeuds 4040 et 5050 à la liste des pairs du serveur 3030
  5. Enregistrez une ou plusieurs données depuis l'API /record du premier serveur
  6. Vérifier le contenu des enregistrements à partir du noeud 4040 ou 5050
curl -H "Content-Type: application/json" -X POST -d '{"targetUrl": "127.0.0.1:4040"}' http://localhost:3030/peer
curl -H "Content-Type: application/json" -X POST -d '{"targetUrl": "127.0.0.1:5050"}' http://localhost:3030/peer
curl -H "Content-Type: application/json" -X POST -d '{"record": "hello"}' http://localhost:3030/record
curl -H "Content-Type: application/json" -X POST -d '{"record": "world"}' http://localhost:3030/record
curl -H "Content-Type: application/json" -X GET http://localhost:4040/records

Liste des requêtes cURL

Part V - Interface en Ligne de Commande

Vous l'aurez remarqué à plusieurs reprises, le processus de test n'est pas vraiment optimal... Changer le port à la main est relativement lourd. Pas de panique, cette dernière partie (bonus), va vous permettre d'être plus flexible dans la création de noeuds pour votre système de pair-à-pair.

import { parse } from 'https://deno.land/std@0.159.0/flags/mod.ts';
import { serve } from 'https://deno.land/std@0.159.0/http/server.ts';
import { urlWrapper } from './utils.ts';

const parsedArgs = parse(Deno.args);

const HOST = parsedArgs.host || '127.0.0.1';
const PORT = +parsedArgs.port || 3030;

let allPeers = [];
let allRecords = [];

async function handler(req: Request): Promise<Response> {
  // ...

  wsClient.send(JSON.stringify({ type: 'HANDSHAKE', payload: `${HOST}:${PORT}` }));

  // ...

  return response;
}

serve(handler, { hostname: HOST, port: PORT });

server.ts (extrait de code)

Ces quelques lignes de code supplémentaires devraient vous permettre de manipuler le host et le port plus facilement : deno run --allow-net server.ts --port 5050. Allons plus loin dans cette même logique en permettant l'ajout de pairs (déjà démarrés) au lancement du serveur.

import { parse } from 'https://deno.land/std@0.159.0/flags/mod.ts';
import { serve } from 'https://deno.land/std@0.159.0/http/server.ts';
import { urlWrapper } from './utils.ts';

const parsedArgs = parse(Deno.args);

const HOST = parsedArgs.host || '127.0.0.1';
const PORT = +parsedArgs.port || 3030;

let allPeers = [];
let allRecords = [];

async function handler(req: Request): Promise<Response> {}

serve(handler, {
  hostname: HOST,
  port: PORT,
  onListen: ({ hostname, port }) => {
    console.log(`Server started on http://${hostname}:${port}`);
  }
});

if (parsedArgs.peers) {
  const parsedPeers: string[] = parsedArgs.peers.split(',');

  parsedPeers.forEach(peer => {
    const [_, wsUrl] = urlWrapper(peer);
    const wsClient = new WebSocket(wsUrl);

    wsClient.onopen = () => {
      allPeers = [...allPeers, wsClient];

      wsClient.send(JSON.stringify({ type: 'HANDSHAKE', payload: `${HOST}:${PORT}` }));
    };

    wsClient.onclose = () => {
      allPeers = allPeers.filter(client => {
        const reUrl = new RegExp(wsUrl);
        return !reUrl.test(client.url);
      });
    };
  });
}

server.ts (extrait de code)

Et voilà ! Grâce à ce dernier paramètre vous êtes prêt à lancer votre réseau P2P 👍 Il suffit maintenant de le faire croitre, petit à petit, en connectant les Deno 🦕 les uns aux autres. Libre à vous d'y ajouter de la complexité, de la sécurité et pourquoi pas des concepts de "blockchain". Enjoy !