Une application de chat MQTT est l'une des meilleures façons d'apprendre concrètement le publish/subscribe : les salons correspondent aux topics, la présence aux retained messages, et la détection des plantages est offerte par le Last Will. Dans ce tutoriel, vous allez construire un chat complet dans le navigateur, dans un seul fichier HTML, avec MQTT.js en WebSocket sécurisé, connecté à broker.connectmq.com, le serveur MQTT public gratuit réservé aux tests. Au passage, vous utiliserez l'option No Local de MQTT 5 pour que vos propres messages ne vous soient pas renvoyés.
Pourquoi MQTT convient bien au chat
Le chat ressemble à un problème de messagerie, et MQTT est un protocole de messagerie : l'adéquation est naturelle. Chaque concept d'une application de chat a un équivalent MQTT direct :
| Fonctionnalité de chat | Mécanisme MQTT |
|---|---|
| Salon de discussion | Un topic, par exemple .../room/general/messages |
| Rejoindre un salon | S'abonner à ce topic |
| Envoyer un message | Publier sur ce topic |
| Liste « qui est en ligne » | Un retained message par utilisateur sur un topic de présence |
| Détecter un onglet fermé ou une perte de réseau | Last Will and Testament, publié par le broker |
| Ne pas voir deux fois son propre message | Option d'abonnement No Local de MQTT 5 |
Ce que MQTT ne vous donne pas, c'est l'historique des messages. Le broker livre les messages à ceux qui sont abonnés à l'instant présent ; ce n'est pas une base de données. C'est un compromis délibéré, et nous verrons à la fin comment ajouter un historique.
Si certains de ces termes sont nouveaux pour vous, notre présentation de ce que fait un broker MQTT et le guide des fonctionnalités de MQTT 5 sont de bonnes introductions.
Conception des topics de l'application de chat MQTT
Une bonne conception des topics garde le code simple. Chaque topic commence par un préfixe aléatoire pour que votre test n'entre pas en collision avec d'autres personnes sur le broker public, suivi du nom du salon :
connectmq/demo/<prefix>/room/<room>/messages messages du chat (non retained)
connectmq/demo/<prefix>/room/<room>/presence/<clientId> présence (JSON retained)
Un client s'abonne à .../messages et à .../presence/+. Le wildcard à un niveau + correspond au topic de présence de chaque utilisateur, et comme les messages de présence sont retained, un nouvel arrivant reçoit immédiatement le statut actuel de tous les membres du salon. Cela respecte les limites du broker public, à savoir 8 niveaux de topic et 10 abonnements par client.
La page de chat complète
Enregistrez ceci sous chat.html. Ouvrez-le dans deux fenêtres de navigateur (ou envoyez le fichier à un ami), saisissez des noms différents et le même salon, et commencez à discuter.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MQTT Chat</title>
<script src="https://unpkg.com/mqtt@5.10.1/dist/mqtt.min.js"></script>
<style>
body { font-family: system-ui, sans-serif; max-width: 860px; margin: 1.5rem auto; padding: 0 1rem; }
.layout { display: grid; grid-template-columns: 1fr 200px; gap: 1rem; }
#messages { border: 1px solid #ccc; height: 360px; overflow-y: auto; padding: .5rem; }
#users { border: 1px solid #ccc; padding: .5rem; list-style: none; margin: 0; }
.me { color: #0a58ca; }
.meta { color: #777; font-size: .8rem; }
form { display: flex; gap: .5rem; margin-top: .5rem; }
form input[type=text] { flex: 1; }
input, button { font: inherit; padding: .4rem .6rem; }
</style>
</head>
<body>
<h1>MQTT Chat</h1>
<form id="joinForm">
<input type="text" id="name" placeholder="Your name" required maxlength="24">
<input type="text" id="room" value="general" required maxlength="24">
<button>Join</button>
</form>
<div class="layout">
<div>
<div id="messages"></div>
<form id="sendForm">
<input type="text" id="text" placeholder="Type a message" maxlength="500" autocomplete="off">
<button>Send</button>
</form>
</div>
<ul id="users"></ul>
</div>
<script>
// Remplacez ceci par votre propre chaîne aléatoire, partagée par tous ceux qui doivent voir le chat.
const PREFIX = "connectmq/demo/chat-k7f3q2";
const URL = "wss://broker.connectmq.com:8084/mqtt";
const clientId = "chat-" + Math.random().toString(16).slice(2, 10);
const messagesEl = document.getElementById("messages");
const usersEl = document.getElementById("users");
const users = new Map();
let client = null, me = null, topics = null;
function addMessage(from, text, ts, mine) {
const div = document.createElement("div");
const who = document.createElement("strong");
who.textContent = from + ": ";
if (mine) who.className = "me";
const body = document.createElement("span");
body.textContent = text; // jamais innerHTML : les payloads ne sont pas fiables
const meta = document.createElement("span");
meta.className = "meta";
meta.textContent = " " + new Date(ts).toLocaleTimeString();
div.append(who, body, meta);
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function renderUsers() {
usersEl.replaceChildren();
for (const [id, u] of users) {
if (u.status !== "online") continue;
const li = document.createElement("li");
li.textContent = u.name + (id === clientId ? " (you)" : "");
usersEl.appendChild(li);
}
}
function presence(status) {
return JSON.stringify({ name: me, status, ts: Date.now() });
}
document.getElementById("joinForm").onsubmit = (e) => {
e.preventDefault();
if (client) return;
me = document.getElementById("name").value.trim();
const room = document.getElementById("room").value.trim().replace(/[#+\/]/g, "");
const base = `${PREFIX}/room/${room}`;
topics = {
messages: `${base}/messages`,
presenceAll: `${base}/presence/+`,
presenceMe: `${base}/presence/${clientId}`
};
client = mqtt.connect(URL, {
clientId,
protocolVersion: 5,
clean: true,
keepalive: 20,
reconnectPeriod: 3000,
will: {
topic: topics.presenceMe,
payload: presence("offline"),
qos: 1,
retain: true
}
// Broker privé : username: "YOUR_USERNAME", password: "YOUR_PASSWORD"
});
client.on("connect", () => {
client.subscribe({
[topics.messages]: { qos: 1, nl: true }, // No Local : ignorer mes propres messages
[topics.presenceAll]: { qos: 1 }
});
client.publish(topics.presenceMe, presence("online"), { qos: 1, retain: true });
addMessage("system", `Joined room "${room}" as ${me}`, Date.now(), false);
});
client.on("message", (topic, payload) => {
const presencePrefix = topics.presenceAll.slice(0, -1);
if (topic.startsWith(presencePrefix) && payload.length === 0) {
users.delete(topic.split("/").pop()); // la présence retained a été effacée
return renderUsers();
}
let data;
try { data = JSON.parse(payload.toString()); } catch { return; }
if (topic === topics.messages) {
addMessage(String(data.from), String(data.text), data.ts, false);
} else if (topic.startsWith(presencePrefix)) {
const id = topic.split("/").pop();
users.set(id, { name: String(data.name), status: data.status });
renderUsers();
}
});
client.on("error", (err) => addMessage("system", "Error: " + err.message, Date.now(), false));
};
document.getElementById("sendForm").onsubmit = (e) => {
e.preventDefault();
const input = document.getElementById("text");
const text = input.value.trim();
if (!client || !client.connected || !text) return;
const msg = { from: me, text, ts: Date.now() };
client.publish(topics.messages, JSON.stringify(msg), { qos: 1 });
addMessage(me, text, msg.ts, true); // afficher localement tout de suite
input.value = "";
};
window.addEventListener("beforeunload", () => {
if (client && client.connected) {
// Sortie propre : effacer ma présence retained au lieu de laisser un "offline" derrière moi.
client.publish(topics.presenceMe, "", { qos: 0, retain: true });
client.end(true);
}
});
</script>
</body>
</html>
Comment ça fonctionne
La présence avec les retained messages
Quand un utilisateur rejoint le salon, la page publie {"status":"online"} sur son propre topic de présence avec le flag retain. Le broker conserve le dernier retained message de chaque topic et le transmet à chaque nouvel abonné. Ainsi, quand un deuxième utilisateur arrive et s'abonne à presence/+, le broker lui envoie immédiatement un retained message par utilisateur déjà en ligne, et la liste se remplit sans que personne n'ait à s'annoncer de nouveau.
La détection des plantages avec le Last Will
Fermer l'onglet normalement exécute le handler beforeunload, qui publie un payload retained vide. Un retained message vide indique au broker de supprimer le message conservé pour ce topic : l'utilisateur disparaît donc proprement.
Mais les onglets plantent, les ordinateurs portables se mettent en veille et le Wi-Fi décroche. C'est là qu'intervient le Last Will. Pendant le CONNECT, le client enregistre un message de testament : une présence « offline », retained. Si le broker n'a plus de nouvelles du client pendant environ une fois et demie l'intervalle de keepalive (ici, une trentaine de secondes), il publie ce testament au nom du client. Tous les autres utilisateurs le reçoivent et la liste se met à jour. Un keepalive court rend la présence plus précise, au prix de quelques paquets de ping supplémentaires.
No Local : pas d'écho de vos propres messages
Sans option particulière, un client qui publie sur un topic auquel il est abonné reçoit son propre message en retour. Dans un chat, cela signifie que chaque ligne que vous tapez apparaît deux fois, ou que vous devez dédupliquer par expéditeur. MQTT 5 ajoute l'option d'abonnement No Local, exposée dans MQTT.js sous la forme nl: true. Le broker s'abstient alors de livrer un message à la connexion même qui l'a publié.
La page affiche votre message localement dès que vous appuyez sur Send : l'interface paraît donc instantanée, même sur un réseau lent. No Local exige protocolVersion: 5 ; en MQTT 3.1.1, l'option n'existe pas et vous devriez filtrer par client ID dans le handler de messages.
Un affichage sûr
Chaque payload est inséré dans le DOM avec textContent. Sur un broker public, quiconque connaît votre préfixe peut publier dans votre salon : traitez donc chaque message comme une entrée hostile. Utiliser innerHTML ici transformerait votre chat en terrain de jeu pour le cross-site scripting.
Liste de vérification des tests
- Ouvrez
chat.htmldans deux fenêtres, rejoignez le même salon avec des noms différents et vérifiez que les deux noms apparaissent dans chaque liste d'utilisateurs. - Envoyez un message depuis chaque fenêtre et vérifiez qu'il apparaît exactement une fois des deux côtés.
- Fermez normalement une fenêtre et vérifiez que le nom disparaît immédiatement.
- Coupez le réseau sur une machine et vérifiez que son utilisateur apparaît hors ligne au bout d'une trentaine de secondes.
- Observez le trafic brut depuis un terminal :
mosquitto_sub -h broker.connectmq.com -p 1883 -i chat-watch-9c1e -t 'connectmq/demo/chat-k7f3q2/#' -v
Vous verrez d'abord les messages de présence retained, puis les messages du chat au fur et à mesure de leur arrivée.
Limites et comment étendre le chat
Historique. Les retardataires ne voient pas les messages précédents. Pour ajouter un historique, faites tourner un petit subscriber qui stocke les messages dans une base de données, par exemple avec l'approche du tutoriel MQTT vers SQLite, et exposez un endpoint de lecture que la page appelle en rejoignant le salon.
Limites de débit. Le serveur MQTT public gratuit autorise 10 messages par seconde et par client et 64 Ko par message. Ajoutez une simple limitation d'envoi dans la page si vous prévoyez des rafales, comme un texte collé découpé en de nombreuses lignes.
Confidentialité. L'espace public de broker.connectmq.com est lisible par tout le monde. Pour tout ce qui dépasse la démo, créez un broker ConnectMQ privé, utilisez ses identifiants générés et réfléchissez à l'endroit où ces identifiants sont stockés, puisque le code du navigateur est visible par ses utilisateurs. L'article sur les bonnes pratiques de sécurité MQTT traite de la stratégie de gestion des identifiants.
Frameworks. La même logique se transpose directement dans des composants. Le tutoriel React MQTT et le guide des patterns MQTT avec Next.js montrent comment gérer le cycle de vie du client dans un framework, et le client MQTT minimal pour navigateur explique plus en détail la connexion WebSocket. Pour les autres langages, consultez le guide des bibliothèques clientes MQTT.
Hébergez votre chat sur un broker ConnectMQ privé
Quand votre chat dépasse le stade de la démo, un broker MQTT managé vous évite d'exploiter et de patcher votre propre serveur. ConnectMQ prend en charge MQTT 5 avec les retained messages, le Last Will et le WebSocket sécurisé sur le port 8084 dès le départ, et l'explorateur de topics en direct de son portail montre les topics de présence et de chat à mesure qu'ils évoluent. Commencez sur le serveur MQTT public gratuit, puis créez gratuitement votre propre broker avec l'offre Serverless, sans carte bancaire.
Questions fréquentes
MQTT est-il un bon choix pour une application de chat ?
Oui pour la livraison en temps réel, la présence et les clients légers, surtout quand le chat cohabite avec des données IoT. MQTT ne stocke pas d'historique : ajoutez un subscriber qui écrit dans une base de données si les utilisateurs doivent pouvoir remonter dans la conversation.
À quoi sert l'option No Local ?
No Local est une option d'abonnement de MQTT 5 qui empêche le broker de renvoyer un message à la connexion qui l'a publié. Dans MQTT.js, vous l'activez avec nl: true dans les options d'abonnement.
Comment le chat sait-il qu'un utilisateur s'est déconnecté de façon inattendue ?
Le client enregistre un message Last Will à la connexion. Si le broker ne reçoit plus les pings de keepalive, il publie ce testament, une présence « offline » retained, à tous les abonnés.
Peut-on utiliser cette application de chat MQTT avec MQTT 3.1.1 ?
En grande partie, oui. La présence retained et le Last Will fonctionnent de la même façon, mais No Local n'existe qu'en MQTT 5 : vous devez donc filtrer vos propres messages par identifiant d'expéditeur dans le handler de messages.



