Passer au contenu principal

WebSocket

Messages instantanés entre le serveur et le client.

Introduction​

Avec WebSocket, il est possible d'établir une connexion permanente entre le serveur et le navigateur du client.

Cela signifie qu'à tout moment le navigateur pourra utiliser la connexion établie via WebSocket pour envoyer des données au serveur, sans qu'il soit obligatoire d'obtenir une réponse. Il en va de même pour le serveur qui pourra contacter le navigateur du client en envoyant des données de manière immédiate.

La communication est réalisée instantanément et à tout moment il est possible de faire transiter des données d'un côté à l'autre de manière indépendante et immédiate.

C'est très utile pour réaliser des opérations en temps réel avec des communications immédiates, c'est comme s'il s'agissait d'un chat, d'une discussion entre le navigateur et le serveur, à tout moment l'un ou l'autre peut envoyer un message et l'autre le reçoit.

Par exemple, il est très utilisé depuis des solutions de chats en général jusqu'aux jeux, nous l'utilisons aussi pour présenter des modifications de données en direct, c'est-à-dire lorsqu'il existe un besoin de réaliser une communication le plus immédiatement possible.

De manière simplifiée, la différence entre la communication HTTP classique et WebSocket :

http-classique-vs-websocket

Du côté du serveur, un endpoint est créé, une adresse qui permet d'établir des connexions avec WebSocket.

Le serveur a la capacité d'envoyer des données uniquement vers une connexion spécifique, c'est-à-dire qu'il est possible d'envoyer de nouveaux messages à un participant d'un chat en particulier.

De même, le serveur peut faire du broadcast, c'est-à-dire l'envoi de données à toutes les connexions actives sur une adresse déterminée, autrement dit lorsqu'un message est envoyé à tous les participants du chat qui sont dans une salle ou un groupe.

Activation et Configuration​

Pour activer et configurer WebSocket dans votre application Netuno, il est nécessaire de modifier le fichier de configuration de l'application correspondant à l'environnement utilisé, comme :

  • 📂 config/_development.json
  • 📂 config/_production.json

Insérez et ajustez les paramètres suivants :

...
"ws": {
"hosts": [...], "<-- OPTIONNEL": "plus d'informations dans la documentation ci-dessous.",
"endpoints": [
{
"name": "pool",
"enabled": true,
"sendTimeout": 10000,
"idleTimeout": 0,
"maxText": 15000,
"public": "/ws/pool",
"path": "/",
"service": "/services/ws/pool"
},
{
"name": "salle",
"enabled": true,
"sendTimeout": 10000,
"idleTimeout": 0,
"maxText": 15000,
"public": "/ws/salle",
"path": "/{uid}",
"service": "/services/ws/salle"
}
]
},
...

endpoints​

C'est un tableau qui permet de définir que le serveur prendra en charge WebSocket à travers les multiples adresses publiques spécifiées (endpoints), donc chaque endpoint est une adresse publique qui prend en charge la réception de connexions via WebSockets depuis les navigateurs.

name​

Nom d'identification de l'endpoint.

enabled​

Permet d'activer et de désactiver l'endpoint, la valeur par défaut est true (actif).

sendTimeout​

Limite de temps maximale pour envoyer un message aux clients, la valeur par défaut est 60000 millisecondes, soit l'équivalent de 1 minute.

idleTimeout​

Limite de temps maximale pour l'inactivité de la connexion, la valeur par défaut est 300000 millisecondes, soit l'équivalent de 5 minutes.

maxText​

Limite en octets pour la taille maximale de la longueur du message, la valeur par défaut est 1048576, équivalent à 1 mégaoctet.

public​

Définit la base de l'adresse publique qui permettra de recevoir les connexions via WebSocket provenant des navigateurs.

L'adresse finale est constituée de la valeur de configuration public suivie de ce qui est défini dans path.

path​

Définit la partie finale du chemin de l'adresse publique, peut être dynamique, ce qui permet de créer par exemple de multiples canaux séparés, par exemple c'est utile pour créer plusieurs salles.

Attention : le path doit toujours commencer par /, et dans la configuration de connexion côté front-end, il est très important de contenir la barre exactement comme défini dans path.

Côté front-end, l'adresse finale sera public + path, voir l'exemple :

...
"public": "/ws/pool",
"path": "/",
...

Donc l'adresse finale côté front-end sera /ws/pool/, avec la barre à la fin.

service​

Adresse de la base de service back-end de l'application qui traite les connexions et la communication via WebSocket.

hosts​

Optionnellement, nous pouvons définir les hôtes sur lesquels nos endpoints sont disponibles, c'est-à-dire que dans la connexion arrive l'adresse de l'hôte dans l'en-tête HTTP, exactement cet hôte en considérant uniquement l'information du domaine ou de l'IP et en écartant l'indication du port, c'est ce qui est utilisé pour associer et trouver l'endpoint correspondant.

Par conséquent, l'endpoint pour fonctionner correctement doit combiner host et public + path, de la manière suivante :

ws://[host]:9000[public][path]

En production avec NGINX, proxy inverse et SSL, cela doit ressembler à ceci :

wss://[host][public][path]

Netuno définit certains domaines locaux avec le préfixe du nom de l'app, qui s'associe automatiquement comme valeur par défaut pour les hôtes des endpoints, en plus pour l'app en développement il ajoute aussi localhost et 127.0.0.1 aux hôtes par défaut, c'est pourquoi la définition des hôtes est optionnelle, car il y a cette configuration automatique.

Les hôtes suivants sont définis automatiquement pour les endpoints, si le nom de l'app est monapp :

  • Celui-ci pointe directement vers l'IP locale 127.0.0.1 :
    • monapp.local.netu.no
  • Ceux-ci, pour fonctionner, il est nécessaire de les enregistrer dans le fichier hosts du système d'exploitation :
    • monapp.localhost
    • monapp.local

Si le nom de l'app est mon_app, alors les hôtes automatiques deviennent :

  • Celui-ci pointe directement vers l'IP locale 127.0.0.1 :
    • mon-app.local.netu.no
  • Ceux-ci, pour fonctionner, il est nécessaire de les enregistrer dans le fichier hosts du système d'exploitation :
    • mon-app.localhost
    • mon-app.local

Dans le domaine, le _ (tiret bas) n'est pas autorisé et dans le nom des apps le - (tiret) n'est pas autorisé, c'est pourquoi en externe nous utilisons le - (tiret) dans l'adresse, mais Netuno transforme automatiquement le - (tiret) en _ (tiret bas) pour trouver la bonne application.

L'application par défaut ou l'application définie à l'initialisation du serveur Netuno possède en plus les hôtes suivants :

  • localhost
  • 127.0.0.1

Pour définir des hôtes personnalisés, nous utilisons les configurations suivantes :

...
"ws": {
"endpoints": [
{
"hosts": ["mon-domaine1.com", "www.mon-domaine1.com"],
...
},
{
"hosts": ["mon-domaine2.com", "www.mon-domaine2.com"],
...
}
]
},
...

Si tous les endpoints fonctionnent pour les mêmes hôtes, alors nous pouvons définir globalement de cette façon :

...
"ws": {
"hosts": ["mon-domaine.com", "www.mon-domaine1.com"],
"endpoints": [
...
]
},
...

Assurez-vous que dans l'adresse finale du WebSocket vous utilisez toujours un domaine ou une IP qui soit un hôte valide, sinon cela ne fonctionnera pas car l'endpoint ne pourra pas être trouvé en interne.

Service pour les Endpoints​

POST = nouvelle connexion​

Le service configuré avec la méthode POST est exécuté lorsqu'une nouvelle connexion est établie, par exemple :

server/services/ws/salle/post.js
const uidSalle = _ws.path().getString("uid")
const dbSalle = _db.get("salle", uidSalle)

if (dbSalle == null) {
_log.warn(`Salle invalide ${uidSalle}.`)
_ws.close()
} else {
_log.info(`Nouvelle session WebSocket : ${_ws.sessionId()}`)
}

Normalement, le _ws.sessionId() est enregistré dans la base de données pour être utilisé dans de futures communications.

PUT = nouveau message​

Si le message reçu n'est pas au format JSON, ou s'il s'agit d'un JSON qui n'est pas au format pris en charge pour l'exécution d'un service spécifique (service), alors ce service générique sera exécuté :

server/services/ws/salle/put.js
_log.info(`La session ${_ws.sessionId()} a envoyé ce message : ${_ws.message()}`)

DELETE = fermeture de la connexion​

Est exécuté lorsqu'une déconnexion du navigateur du client se produit.

server/services/ws/salle/delete.js
_log.info(`La session ${_ws.sessionId()} s'est déconnectée.`)

GET = flux continu de messages​

Démarre l'exécution lorsqu'une nouvelle connexion du navigateur du client se produit, et l'exécution est maintenue jusqu'à la fin de la connexion.

Autrement dit, le service pour la méthode HTTP GET reçoit continuellement les messages provenant du client en stream, c'est-à-dire qu'il s'agit d'un flux ouvert et continu de messages qui peut à la fois recevoir et envoyer.

server/services/ws/salle/get.js
if (_ws.isStream()) {
while (_ws.awaitStream()) {
if (_ws.isBinaryStreamed()) {
_log.info("Nouveau message binaire reçu : "+ _convert.textFromBytes(_ws.binaryStreamed()))
} else if (_ws.isTextStreamed()) {
_log.info("Nouveau message texte reçu : "+ _ws.textStreamed())
}
_ws.sendText("Ok ! Le message a été reçu avec succès.")
}
}

Le if avec _ws.isStream() fait en sorte de vérifier s'il s'agit bien d'une requête interne gérée par WebSocket pour réaliser le stream.

Dans le while avec _ws.awaitStream(), il attend qu'un nouveau flux de message arrive, c'est-à-dire qu'il attend l'arrivée d'un nouveau message. Dès qu'il y a un nouveau message, le code interne du while est exécuté.

Le _ws.awaitStream() fonctionne comme une boucle infinie en attente d'un nouveau message.

Ensuite, dans les ifs internes du while, le type de message est vérifié, si le message est binaire ou texte, dans les deux cas le contenu de ces messages est présenté dans les logs. Remarquez que n'importe quel format de message peut être utilisé, pouvant être traité de n'importe quelle manière qui soit convenable.

Enfin, dans _ws.sendText, un message de réponse est envoyé au client, confirmant la réception.

Chaque exécution du service GET sera une connexion active.

Lorsque la connexion avec l'endpoint WebSocket est terminée, alors l'exécution de ce service est terminée en interne.

Communication​

sendService​

Exécute le service et envoie la sortie générée à un client spécifique connecté via WebSocket, utilise l'ID de connexion du client (sessionId).

Exemple d'appel de service​

Le code ci-dessous peut être utilisé dans le service de méthode PUT de l'endpoint (service), remarquez que _ws.sessionId() est utilisé pour obtenir l'ID de la session de connexion du client ; si l'identifiant de session est enregistré dans la base de données, sendService pourrait être exécuté dans n'importe quel autre service à n'importe quel moment.

const uid = _ws.path().getString("uid")
_ws.sendService(
_ws.sessionId(), // Identification de la connexion WebSocket actuelle.
_val.map() // Paramétrage du service qui sera exécuté.
.set("method", "POST")
.set("service", "/services/salle/participant/nouveau")
.set(
"data", // Paramètres qui seront transmis au service et reçus via _req.
_val.map()
.set("uid", uid)
)
)

Autrement dit, dans la configuration, le path configuré dans l'endpoint a la valeur /{uid}, ce qui permet au client de se connecter via l'adresse publique :

  • /ws/salle/d641f095-30eb-4025-a39c-b2e3e497eab7

Donc la valeur de uid sera d641f095-30eb-4025-a39c-b2e3e497eab7.

Ensuite, le service situé à l'URL /services/salle/participant/nouveau sera exécuté et la valeur de uid est transmise.

Le uid peut être obtenu dans le code du service exécuté, comme un paramètre de la requête HTTP, voir un exemple :

/server/services/salle/participant/nouveau.js
// L'UID envoyé est obtenu directement dans la requête.
const uid = _req.getString('uid')

broadcastService​

Pour réaliser le broadcast, il faut le nom de l'endpoint WebSocket, définir le chemin (path) si applicable, et le service qui sera exécuté.

La sortie du service sera envoyée à toutes les connexions actives sur l'endpoint spécifié.

Exemple de broadcast​

/server/services/salle/participant/nouveau.js
_ws.broadcastService(
"admin", // Nom de l'endpoint WebSocket.
"/", // Chemin configuré dans l'endpoint (path).
_val.map() // Paramétrage du service qui sera exécuté.
.set("service", "/services/salle/participant/liste")
)

Dans cet exemple, le contenu de la sortie générée par le service salle/participant/liste sera envoyé à tous ceux qui sont connectés à l'endpoint admin.

WS Client - NPM​

Pour réaliser facilement l'intégration avec le frontend, le module NPM est mis à disposition :

Commande d'installation :

  • bun install @netuno/ws-client
import _ws from '@netuno/ws-client';

_ws.config({
url: 'ws://localhost:9000/ws/exemple/',
servicesPrefix: '/services',
autoReconnect: true,
connect: (event) => {
console.info('WebSocket connecté', event);
},
close: (event) => {
console.warn('WebSocket fermé', event);
},
error: (error) => {
console.error('Erreur WebSocket', error);
},
message: (data, event) => {
console.debug('Message WebSocket', data);
}
});

Assurez-vous que l'URL combine correctement les valeurs configurées dans l'endpoint pour host et public + path, de la manière suivante :

ws://[host]:9000[public][path]

Quant à l'URL de production avec l'intégration NGINX, proxy inverse et SSL, elle sera probablement ainsi :

wss://[host][public][path]

Ensuite, vous pouvez initier la connexion :

_ws.connect();

Exécuter un service via WebSocket​

Nous pouvons exécuter des services de l'API REST via la connexion WebSocket, cela signifie qu'aucune requête HTTP ne sera réalisée, donc les données de la requête sont envoyées comme message, et la réponse du service est obtenue comme un message reçu en retour.

Avant de commencer, nous devons créer un observateur de la réponse du service, par exemple :

const refEcouteur = _ws.addListener({
method: 'GET', // Optionnel ; omettez pour écouter toutes les méthodes.
service: 'chemin/de/mon/service/ici',
success: (data) => {
console.log('Réponse du service exécuté via WebSocket :', data);
},
fail: (error)=> {
console.log('Le service exécuté via WebSocket a échoué :', error);
}
});

Maintenant, nous pouvons envoyer une demande d'exécution du service, sachant que la réponse du service sera obtenue dans l'observateur créé ci-dessus, nous exécutons le service ainsi :

_ws.sendService({
method: 'GET', // Optionnel ; les requêtes utilisent GET lorsqu'il est omis.
service: 'chemin/de/mon/service/ici',
data: {
message: 'Bonjour...'
}
});

Pour supprimer l'observateur ajouté, utilisez :

_ws.removeListener(refEcouteur);

Et pour fermer la connexion WebSocket :

_ws.close();

NGINX Proxy inverse​

Dans NGINX, le proxy inverse peut être configuré de cette façon :

location /ws {
proxy_pass http://localhost:9000;
proxy_set_header Host mon-app.local.netu.no;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_http_version 1.1;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
add_header X-Frame-Options "";
}

Sur la ligne 3 où est défini le Host, il faut ajuster la partie mon-app pour le nom correspondant de l'application Netuno. Si le nom de l'application contient _, alors dans l'adresse du Host il sera remplacé par -, par exemple, l'application mon_app aura la valeur du Host ainsi : mon-app.local.netu.no.

Redémarrez NGINX pour que la configuration prenne effet.

Les domaines configurés dans NGINX, c'est-à-dire le paramétrage du server_name, n'ont pas besoin d'être dans les hôtes de l'endpoint dans l'application Netuno, car dans la configuration de proxy ci-dessus, le Host dans l'en-tête HTTP est modifié pour une adresse standard qui est déjà configurée automatiquement par Netuno toujours, tant que le préfixe avec le nom de l'application est correct.

Si le serveur NGINX utilise le port 80, alors l'URL finale sera :

  • ws://[host][public][path]

Et avec SSL/HTTPS, si le serveur NGINX utilise le port 443, alors l'URL finale sera :

  • wss://[host][public][path]