Construire Un RĂ©seau P2P Avec Deno đ
4 novembre 2022 | 14 mins de lecturePublié 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 :
- Dans un terminal, démarrez un serveur (sur le port par défaut
3030) - Changer le port (
4040), puis démarrez un nouveau serveur dans un 2Úme terminal - Enfin, depuis un client REST, interrogez l'API
/peeren 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
3030qui est seulement serveur - Un "vrai" noeud démarré sur le port
4040qui 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) :
- Démarrez un 1er serveur (sur le port par défaut
3030) - Démarrez un 2Úme serveur en changeant le port (
4040) - Démarrez un 3Úme serveur en changeant le port (
5050) - Ajoutez les noeuds
4040et5050à la liste des pairs du serveur3030 - Enregistrez une ou plusieurs données depuis l'API
/recorddu premier serveur - Vérifier le contenu des enregistrements à partir du noeud
4040ou5050
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 !
