Avec Mercure, le temps réel n’attend pas

Avec Mercure, le temps réel n’attend pas

Je m'abonne
Temps de lecture: 35 mins

Bienvenue dans un monde qui se veut toujours plus réel, mais qui reste pourtant freiné par les limites des échanges HTTP traditionnels. Un monde où l’on aimerait que tout soit instantané… alors que la réalité affiche encore une petite latence.

Bienvenue dans un univers où les interfaces semblent vivantes, où les notifications surgissent au moment même où l’action se produit, où l’information circule sans attendre : le monde du temps réel.

Maverick

Et pour nous y accompagner Symfony s’allie avec un compagnon taillé pour cette mission : Mercure.

Dans cet article on va explorer ensemble comment mettre en place ce fameux temps réel, comment il fonctionne, et surtout comment l’utiliser simplement pour créer une expérience plus fluide, plus moderne… plus vivante.

Le temps réel, ça peut impressionner au premier abord etpersonnellement, ça me faisait un peu peur au début. L'idée de voir des données se mettre à jour instantanément, sans recharger la page semblait complexe. Mais une fois qu'on met les mains dedans, tout devient beaucoup plus intuitif et surtout… très gratifiant !

Dans ce tutoriel, on va construire une petite application pour comprendre concrètement comment Mercure fonctionne, comment le configurer avec Symfony, et surtout comment envoyer et recevoir des notifications en temps réel de manière sécurisée et efficace. Pas de théorie abstraite ici : on va passer à la pratique dès le départ, avec des exemples concrets et faciles à suivre.

Prêt à plonger dans le temps réel ? Allons-y !

1. Mercure comment ça fonctionne ?

Mercure se base sur le SSE : Server-Sent Events.

Ce sont des événements envoyés par le serveur vers le client via une connexion persistante.

L'exemple du MDN sur la base des SSE est très parlant : Les SSE par le MDN.

Pour schématiser, je vais reprendre celui de mercure.rocks qui fonctionne très bien.

Hub Mercure

On a notre application avec des clients (PC, téléphones, tablettes, etc.) et au milieu de tout ça, notre Hub Mercure.

Notre application (le backend) peut publier des mises à jour, et les clients peuvent s'abonner pour être notifiés en temps réel.

Mercure ouvre une liaison permanente entre chaque client et le hub grâce aux EventSource (SSE), un mécanisme natif des navigateurs.

Quand un client effectue une action dans l'application (par exemple l’envoi d’un formulaire), il appelle simplement le backend via HTTP (GET, POST…) et ensuite l'application informe le Hub Mercure :

“Hey ! Quelque chose a changé, préviens les clients concernés !”

Le Hub Mercure se charge alors d'envoyer la notification uniquement aux clients autorisés à écouter les topics concernés.

Pour organiser tout ce petit monde, Mercure classe les utilisateurs en 2 catégories :

  • Les subscribers, qui peuvent écouter des topics
  • Les publishers, qui peuvent publier sur des topics

En règle générale, un publisher, c'est l’application : on évite de donner l'accès à la publication directement aux utilisateurs, pour des raisons de sécurité.

Un subscriber est donc un utilisateur qui possède une liaison ouverte avec Mercure sur un ou plusieurs topics.

Pour sécuriser les permissions de publication ou d'abonnement, Mercure utilise des tokens JWT.

Ils garantissent qu’un client ou une application :

  • a le droit de s’abonner (subscriber) au topic demandé ;
  • ou (pour le backend uniquement) de publier (publisher) sur un topic.

Le Hub Mercure vérifie ce token (signé par une clé secrète) au moment où le client ouvre la connexion SSE ou au moment où le backend publie.

Pour le moment, c'est un peu abstrait, mais on va détailler tout ça dans le tuto !

Pour simplifier parce que ce n’est pas forcément évident au début on peut voir Mercure comme un système de radio privée :

  • L'application est l’animateur de la radio (il publie)
  • Le client écoute la radio (il est abonné)
  • Le topic est la fréquence FM
  • Mercure est simplement le diffuseur, l'antenne

Le backend génère et envoie au client les droits nécessaires (via un JWT).

Ensuite, le client peut dire au Hub Mercure, via l'EventSource :

Le hub vérifie puis autorise (ou non) l'abonnement.

On va commencer par créer le projet sans Mercure pour l'instant, histoire d’avoir une base. Ensuite, on reprendra tout ça pas à pas  sinon ça ferait trop d'informations d’un coup !

2. Mais pourquoi utiliser Mercure ?

On vient de le voir : Mercure repose essentiellement sur les Server-Sent Events (SSE). Ce n'est donc plus toi qui envoies une requête vers le serveur, mais le serveur qui vient à toi ! Et ça c'est beau !!!

Mais alors, pourquoi faire ça si un simple fetch fonctionne très bien.

Oui, un fetch fonctionne, mais il est gourmand en ressources : à chaque appel, on interroge le serveur pour récupérer des informations… qui dans bien des cas, n'ont pas changé depuis la dernière requête. Résultat : perte de temps et d'énergie.

C'est ce qu'on appelle le polling, et on en distingue deux types :

  • Le regular polling
  • Le long polling

Le regular polling, c'est le fait d’appeler le serveur à intervalle régulier. En JavaScript, on utilisera par exemple un setInterval.

async regularPolling() {
    setInterval(async () => {
        const resp = await fetch("/regular-polling");
        const data = await resp.json();
    }, 1000)
}

Séquence de polling

On voit bien ici tous les appels AJAX effectués chaque seconde. Si un utilisateur reste connecté toute la journée, ça en fait un paquet !

Le long polling, c'est comme le regular polling, mais en continu, sans le moindre répit : dès qu’une réponse arrive, on relance immédiatement une nouvelle requête.

async longPolling() {
    const resp = await fetch("/long-polling");
    const data = await resp.json();
    await this.longPolling()
}

On l'utilise en fonction récursive pour qu'il ne s’arrête jamais. C’est évidemment une mauvaise pratique ! Sauf pour ton Cloud Provider, qui te remerciera chaleureusement avec une facture salée.

Ces deux pratiques ont toujours une certaine utilité et on les retrouve encore un peu partout (un peu comme jQuery 😅) mais c'est aussi pour remplacer ce genre de mécanismes que l’équipe gravitant autour de Symfony a inventé le protocole Mercure (merci Kévin Dunglas !!!).

On pourrait aussi s'orienter vers les WebSockets, un protocole de communication différent (ws://) permettant au serveur et au client de communiquer dans les deux sens. Mais c'est plus fastidieux à mettre en place et c’est un peu de l'artillerie lourde pour un simple système de notification.

Par contre, j'en ferai sûrement un article parce que ce protocole est hyper intéressant pour du chat en temps réel !

Ici, je n'entrerai pas dans les détails pour éviter d'alourdir, mais si tu es curieux, voici un aperçu de son fonctionnement : Ratchet.

3. Let’s rock !

On va créer un projet Symfony très classique et utiliser Symfony Server pour notre tutoriel.

Comme pour l'article sur Stimulus, je vais créer un repository pour te faire gagner du temps, dans lequel tu trouveras 2 branches :

  • master ⇒ le projet finalisé (fin de l'article)
  • initialisation ⇒ le projet sans Mercure

De cette façon, tu peux suivre l’ensemble du tutoriel… ou te concentrer uniquement sur la partie Mercure.

symfony new mercure --version="7.4.x" --webapp
cd mercure
composer require symfony/stimulus-bundle
composer require symfonycasts/tailwind-bundle
symfony bin/console tailwind:init
docker compose up -d --build
symfony server:start --no-tls -d
symfony console tailwind:build --watch

Avec ça, on a notre début de projet. On le fera évoluer au fil de l'article mais c’est notre base.

Pour résumer, on a donc :

  • Symfony 7.4 + AssetMapper
  • Stimulus + Turbo
  • Tailwind

Remplace ton .env par celui-ci :

APP_ENV=dev
APP_SECRET=
APP_SHARE_DIR=var/share

DEFAULT_URI=http://127.0.0.1

DATABASE_URL="postgresql://app:!ChangeMe!@symfony-database-1
:5432/app?serverVersion=16&charset=utf8"

MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0

MAILER_DSN=null://null

 Petit point RolePlay

Le but de ce tutoriel est évidemment de comprendre Mercure et de voir comment le mettre en place en développement.

Pour y arriver, on va imaginer que l'on développe une application commerciale avec un formulaire de contact. De l'autre côté de l’app se trouvent des commerciaux prêts à bondir sur chaque nouvelle demande.

Pour cela, ils ont besoin de recevoir des notifications en temps réel afin d’être au maximum de leur efficacité !

Création de l’entité contact avec symfony console make:entity:

#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;

#[ORM\Column(length: 255)]
private ?string $firstname = null;

#[ORM\Column(length: 255)]
private ?string $email = null;

#[ORM\Column(type: Types::TEXT)]
private ?string $message = null;

#[ORM\Column(length: 255)]
private ?string $phoneNumber = null;

#[ORM\Column]
private bool $isProcessed = false;

#[ORM\Column]
private ?\DateTimeImmutable $createdAt = null;

public function __construct()
{
	$this->createdAt = new \DatetimeImmutable();
}

public function displayArray(): array
{
	return [
	  'id' => $this->getId(),
	  'firstname' => $this->getFirstname(),
	  'email' => $this->getEmail(),
	  'message' => $this->getMessage(),
	  'phoneNumber' => $this->getPhoneNumber(),
	];
}

On passe au formulaire : symfony console make:form qu'on appellera ContactType (quelle imagination !)

Puis notre Controller : symfony console make:controller qu'on appellera ContactController (quelle im… OK j’arrête !)

Pour le formulaire on ajoute ça :

public function buildForm(FormBuilderInterface $builder, array $options): void
{
    $builder
        ->add('firstname', TextType::class, [
            'label' => 'Prénom',
            'required' => true,
        ])
        ->add('email', EmailType::class, [
            'label' => 'Email',
            'required' => true,
        ])
        ->add('message', TextareaType::class, [
            'label' => 'Message',
            'required' => true,
        ])
        ->add('phoneNumber', TextType::class, [
            'label' => 'Numéro de téléphone',
            'required' => true,
        ])
    ;
}

Et pour le controller : 

#[Route('/contact', name: 'app_contact')]
public function index(Request $request, EntityManagerInterface $entityManager): Response
{
    $newContact = new Contact();

    $form = $this->createForm(ContactType::class, $newContact);

    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        $entityManager->persist($newContact);
        $entityManager->flush();

        $this->addFlash('success', 'Votre message a été envoyé avec succès !');

        return $this->redirectToRoute('app_contact');
    }

    return $this->render('contact/index.html.twig', [
        'contactForm' => $form->createView(),
    ]);
}

Dans le block body du contact/index.html.twig on y ajoute ceci : 

<div class="w-full flex flex-col justify-center items-center py-12">
    <div class="w-full max-w-2xl bg-white p-8 rounded-lg shadow">
        <h1 class="text-3xl font-bold mb-4">Contactez-nous</h1>
        {% for message in app.flashes('success') %}
            <div class="bg-indigo-300 font-bold w-full text-center py-3 my-3">
                {{ message }}
            </div>
        {% endfor %}
        {{ form_start(contactForm, {'attr': {'class': 'space-y-6'}}) }}

        <div>
            {{ form_label(contactForm.firstname, null, {'label_attr': {'class': 'form-label'}}) }}
            {{ form_widget(contactForm.firstname, {'attr': {'class': 'form-input'}}) }}
            {% if form_errors(contactForm.firstname) %}
                <div class="form-error">{{ form_errors(contactForm.firstname)|raw }}</div>
            {% endif %}
        </div>

        <div>
            {{ form_label(contactForm.email, null, {'label_attr': {'class': 'form-label'}}) }}
            {{ form_widget(contactForm.email, {'attr': {'class': 'form-input'}}) }}
            {% if form_errors(contactForm.email) %}
                <div class="form-error">{{ form_errors(contactForm.email)|raw }}</div>
            {% endif %}
        </div>

        <div>
            {{ form_label(contactForm.message, null, {'label_attr': {'class': 'form-label'}}) }}
            {{ form_widget(contactForm.message, {'attr': {'class': 'form-input', 'rows': 6}}) }}
            {% if form_errors(contactForm.message) %}
                <div class="form-error">{{ form_errors(contactForm.message)|raw }}</div>
            {% endif %}
        </div>

        <div>
            {{ form_label(contactForm.phoneNumber, null, {'label_attr': {'class': 'form-label'}}) }}
            {{ form_widget(contactForm.phoneNumber, {'attr': {'class': 'form-input'}}) }}
            {% if form_errors(contactForm.phoneNumber) %}
                <div class="form-error">{{ form_errors(contactForm.phoneNumber)|raw }}</div>
            {% endif %}
        </div>

        <div class="flex items-center justify-between">
            <button type="submit" class="form-button">Envoyer</button>
            <a href="/" class="text-sm text-gray-600 hover:text-gray-900">Retour</a>
        </div>

        {{ form_rest(contactForm) }}
        {{ form_end(contactForm) }}
    </div>
</div>

Dans assets/styles/app.css tu ajoutes ça : 

@import "tailwindcss";

html {
    width: 100vw;
    height: 100vh;
}

body {
    @apply bg-grey-200;
}

@layer components {
    .form-input {
        @apply appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm;
    }

    .form-label {
        @apply block text-sm font-medium text-gray-700;
    }

    .form-error {
        @apply mt-1 text-sm text-red-600;
    }

    .form-button {
        @apply inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500;
    }

    .row {
        @apply px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider;
    }
}

et normalement … tu obtiens ceci sur /contact

Formulaire de contact

Je suis pas expert front okay ! 

On fait la migration :

symfony console make:migration
symfony console d:d:m

Puis on peut à présent remplir notre formulaire pour hydrater un peu la DB :

Formulaire de contact rempli

On sauvegarde, et on peut créer notre rendu côté commerciaux.

symfony console make:controller qu’on appellera CommercialController (Quelle … Ok ok !)

Puis mets y ceci :

#[Route('/commercial', name: 'app_commercial')]
public function index(ContactRepository $contactRepository): Response
{
    $contactToBeProcessed = $contactRepository->findBy(['isProcessed' => false]);

    return $this->render('commercial/index.html.twig', [
        'contacts' => $contactToBeProcessed,
    ]);
}

Dans ce controller on utilise un rendu twig que l'on va modifier commercial/index.html.twig :

<div class="min-h-screen bg-gray-50 py-10">
  <div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
    <div class="flex items-center justify-between mb-6">
      <p class="text-sm text-gray-600">Liste des personnes à relancer par l'équipe commerciale</p>
    </div>

    <div class="shadow-sm overflow-hidden border border-gray-200 rounded-lg bg-white">
      <div class="overflow-x-auto">
        <table class="min-w-full divide-y divide-gray-200">
          <thead class="bg-gray-50">
            <tr>
              <th scope="col" class="row">Prénom</th>
              <th scope="col" class="row">N° de téléphone</th>
              <th scope="col" class="row">Email</th>
              <th scope="col" class="row">A été rappelé ?</th>
            </tr>
          </thead>

          {% include "commercial/_table_contact.html.twig" %}
        </table>
      </div>
    </div>
  </div>
</div>

Et on crée notre partial commercial/_table_contact.html.twig :

<tbody class="text-center" id="contacts">
{% for contact in contacts %}
    <tr>
        <td class="row">{{ contact.firstname }}</td>
        <td class="row">{{ contact.phoneNumber }}</td>
        <td class="row">{{ contact.email }}</td>
        <td class="row">{{ contact.createdAt|date("d-m-Y (H:m:i)") }}</td>
        <td class="row"><a href="{{ path('app_commercial_mark_as_processed', {id: contact.id}) }}" data-turbo-prefetch="false">Oui ?</a></td>
    </tr>
{% endfor %}
</tbody>

Le but de cette vue est de faire le bilan des prospects (demande de contacts) qui n'ont pas encore été traités grâce à "isProcessed". 

Quand un prospect est traité, on le marque à true pour l'enlever, via Turbo ! (Petite référence à mon article Stimulus/Turbo).

On va s’occuper du chemin Turbo dans notre CommercialController:

#[Route('/commercial/mark-as-processed/{id}', name: 'app_commercial_mark_as_processed')]
public function contactIsProcessed(Contact $contact, EntityManagerInterface $entityManager, ContactRepository $contactRepository): TurboStreamResponse
{
    $contact->setIsProcessed(true);

    $entityManager->persist($contact);
    $entityManager->flush();

    $contacts = $contactRepository->findBy(['isProcessed' => false]);

    return new TurboStreamResponse($this->renderView("commercial/_table.stream.twig", [
        'contacts' => $contacts,
    ]), 200, ['vnd.turbo-stream.html']);
}

Puis de notre render TurboStream :

<turbo-stream target="contacts" action="replace">
    <template>
        {% include "commercial/_table_contact.html.twig" %}
    </template>
</turbo-stream>

Je ne détaille pas comment fonctionne le stream, un des articles précédents y est complétement dédié.

Si tu te rends sur /commercial tu obtiens ton premier enregistrement :

TurboStream

On va à présent authentifier les commerciaux avec un firewall.

La procédure :

symfony console make:user
symfony console make:migration
symfony console d:m:m
// On crée dans la foulée l'authenticator : 
symfony console make:security:form-login

Pour l’authentification on le fait de manière classique avec un email + password tout bête.

On passe dans le config/packages/security.yaml pour ajouter la route /commercial et la future route /me :

access_control:
     - { path: ^/commercial, roles: ROLE_USER }
     - { path: ^/me, roles: ROLE_USER }

Et on est bon pour la partie Auth. On y reviendra un peu après lors qu'on aura avancé sur Mercure.

freeze!

Je te laisse créer un user en base de données directement pour aller plus vite !

On arrive à une petite application où l'on récupère, dans un tableau, les demandes de contact des prospects.

Cependant on doit rafraîchir la page pour afficher les nouvelles demandes : ce qui n'est pas très réactif.

Avant Mercure, on aurait pu utiliser du regular/long polling pour vérifier en base de données si une nouvelle demande était apparue. C'est encore une pratique courante, mais très gourmande en appels.
Pour éviter ça, on va plutôt utiliser Mercure (et aussi parce que… c'est le sujet de l’article !).

 

Mercure on the Rocks !

Notre première étape est d'installer notre Hub Mercure dans notre compose.yml grâce à composer : composer require symfony/mercure-bundle .

composer require

(Bien penser à faire "yes", sinon le compose.yml ne se mettra à jour pour Mercure).

Dans ce compose.yml dans "ETRA_MERCURE_DIRECTIVES", ajoute ceci pour éviter les connexions anonymes au Hub pour sécuriser :

MERCURE_EXTRA_DIRECTIVES: |
  cors_origins http://127.0.0.1:8000
  anonymous off

Pour décomposer un peu ce docker compose (désolé ... je contrôle pas mes jeux de mots ! j'ai honte !) :

docker compose

  • SERVER_NAME c'est le nom du serveur Mercure (ici mercure:80) sur lequel on postera les infos du Hub.
  • MERCURE_PUBLISHER_JWT_KEY et MERCURE_SUBSCRIBER_JWT_KEY sont les clés de chiffrement pour la publication et l'abonnements des users aux topics.
  • MERCURE_EXTRA_DIRECTIVES ce sont des configurations supplémentaires, notamment le CORS ou les connexions anonymes.

Puis on installe le bundle Mercure pour Symfony qui permettra de paramétrer toute la configuration de l’application pour Mercure comme le chiffrements des tokens, l'abonnements des users aux différentes topics …

Pour ça : composer require symfony/mercure

Une fois installé, on a maintenant un fichier de config dans config/packages/mercure.yaml et trois nouvelles variables d'environnement :

  • MERCURE_URL : c'est l’adresse physique du serveur, il permettra à l'application d’envoyer les notifications au hub (En dev c'est http://mercure/.well-known/mercure)
  • MERCURE_PUBLIC_URL : c’est l'adresse publique pour les utilisateurs. Quand un user s'abonne, il contacte cette URL ci (En dev c'est http://127.0.0.1/.well-known/mercure)
  • MERCURE_JWT_SECRET : c'est le même secret que dans le compose.yaml.

Pour éviter des problèmes de CORS, il est important d'avoir pour notre application le même nom d'hôte que Mercure, soit 127.0.0.1 et pas localhost.

Passons maintenant au config/packages/mercure.yaml

mercure:
    hubs:
        default:
            url: '%env(MERCURE_URL)%'
            public_url: '%env(MERCURE_PUBLIC_URL)%'
            jwt:
                secret: '%env(MERCURE_JWT_SECRET)%'
                publish: '*'

Si on a plusieurs hubs, on pourra définir des règles très précises pour chacun. Sinon, on utilise celui par défaut : le hub default.

Ici, on va rentrer dans quelques explications sur le token JWT, car Mercure l'utilise abondamment pour vérifier l'autorisation des subscribers et des publishers.

Ce token permet d'inclure les droits (comme les topics autorisés en lecture ou en écriture) et de les transmettre de manière sécurisée grâce à une signature.

Mercure vérifie cette signature à chaque requête de publication ou d'abonnement, et n'autorise l'action que si le token est valide et correspond aux permissions nécessaires.

À ce moment-là, on prend une petite pause pour expliquer ce qu’est un token JWT. J'en ai parlé au début, mais ici on va détailler, au cas où tu n'en aurais jamais croisé au détour d'une API ou d’une application nécessitant ce type de connexion.

 Le Json Web Token (aka JWT)

Un token de type JWT c'est tout simplement un Jeton (Token) au format Json utilisé pour le Web. (JsonWebToken). 

Ce jeton c'est un jeton en base64 pour la facilité de transmission qui représente à l'intérieur un composite de Json + signature

En exemple on va prendre celui-ci :

Un JWT

A première c’est juste indigeste, c'est tout à fait normal, c'est encodé en base64. Mais si on regarde de plus près …

On y voit 2 points:

Les séparations du JWT

En fait cette chaîne est séparée en 3 zones distinctes :

  • Le Header
  • Le Payload (La charge utile)
  • La Signature

Les trois parties du JWT

Le Header contient le type d'algorithme de signature (HS256, HS512, etc.) et le type de token (ici JWT). Il précise avec quel algorithme la signature doit être vérifiée.

Le Payload contient les informations nécessaires à l’application pour effectuer les actions suivantes (authentification, droits d’accès, etc.). Le Payload n'est pas chiffré et reste accessible via un simple base64decode().

La Signature permet d'authentifier la validité du token dans son intégralité.

C'est grâce à cette signature que l'on peut envoyer un token à un utilisateur et le récupérer en étant certain qu'il n'a pas été altéré.

Pourquoi ? Parce que l'ensemble Header + Payload génère une signature unique grâce à la clé de signature de l'application (MERCURE_JWT_SECRET).

Si l'on modifie ne serait-ce qu'un seul caractère du Header ou du Payload, la signature calculée ne correspondra plus à celle du token, et la vérification échouera.

Seule la clé secrète originale permet de produire la bonne signature.

Il est donc impossible de falsifier un JWT, ce qui garantit son intégrité dans les échanges.

Voici un exemple du type de composition que l’on peut trouver dans un JWT.

// Header
{
  "alg": "HS256",
  "typ": "JWT"
}

// Payload
{
  "sub": "1234567890",
  "name": "Exploris",
  "admin": true,
  "iat": 1764963249
}

En fournissant une clé de signature secrète, l'application est capable de calculer la troisième partie de la Signature et ainsi vérifier si le token a été altéré.

Sur jwt.io, on peut s'amuser avec des JWT : en fournissant un token et la clé, on peut vérifier la signature et savoir si elle est valide. C’est un excellent moyen de comprendre comment fonctionne un JWT.

Bref, revenons maintenant à notre fichier config/packages/mercure.yaml.

On a donc : publish: "*" pour indiquer que l'application Symfony peut publier sur n'importe quel topic du hub Mercure. C’est finalement le rôle du backend : envoyer des notifications.

On pourrait indiquer subscribe: "*" mais cela n’aurait pas vraiment de sens, puisque l’application (backend) ne s'abonne pas aux topics.

Pour cela, ce sont les utilisateurs qui recevront l'autorisation de s'abonner aux topics via le JWT et l’EventSource.

Pour rappel :

  • L'application peut publier partout (publish: "*" dans son mercure.yaml)
  • Un utilisateur peut s'abonner uniquement aux topics spécifiquement indiqués dans le JWT qui lui est destiné
{
  "mercure": {
    "subscribe": ["http://commercial/alert"]
  }
}

Mettre "publish" dans un token pour un utilisateur peut présenter des failles de sécurité, car cela lui permettrait de publier sur un topic et donc d'injecter ce qu’il souhaite à d’autres clients !

Dans l'exemple, j'ai mis : "<http://commercial/alert>". Qu'est-ce que c'est ? C'est un IRI (Internationalized Resource Identifier). Ça ressemble à une URL, mais ce n'est pas une URL existante : c'est juste un identifiant unique simple, qui a du sens. On pourrait mettre autre chose, comme un simple nom ("alert"), mais la convention préfère utiliser des IRI.

Concrètement, notre application va publier sur le channel http://commercial/alert, et notre client va écouter toutes les informations qui y transitent.

Dans un premier temps, pour publier, il faut que l'application dispose d’un contrôleur de publication !

<?php

namespace App\Controller;

use App\Entity\Contact;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;

class PublishController extends AbstractController
{
    public function __construct(private readonly HubInterface $hub)
    {

    }
    
    public function publish(Contact $contact): void
    {
        $update = new Update(
            'http://commercial/alerts',
            json_encode($contact->displayArray())
        );

        $this->hub->publish($update);
    }
}

Dans la fonction __construct(), on injecte le HubInterface fournit par le bundle, qui nous permettra d’envoyer une notification à Mercure via la méthode publish().

J’y ajoute également l’entité Contact, que l'on utilisera pour notre notification.

Chaque notification se génère grâce à un Update, qui contient :

  • Le channel
  • Les informations

Ensuite, grâce à la méthode publish(), on déclenche la notification vers Mercure.

Voilà, c'est la base : notre application peut à présent publier.

Mais à quel moment ? Eh bien, dès qu'un prospect nous contacte ! On va donc effectuer une petite modification dans notre ContactController.

On commence par ajouter notre PublishController dans le __construct() :

public function __construct(private readonly PublishController $publishController) {}

Puis dans notre fonction index() , juste après le flush pour enregistrer le formulaire :

if ($form->isSubmitted() && $form->isValid()) {
    $entityManager->persist($newContact);
    $entityManager->flush();

    $this->addFlash('success', 'Votre message a été envoyé avec succès !');

		// On publie la notification
    $this->publishController->publish($newContact);

    return $this->redirectToRoute('app_contact');
}

De cette façon, à chaque formulaire enregistré, l'application générera une notification pour nos commerciaux, qui seront ainsi informés en temps réel d’une nouvelle demande de contact.

Il ne nous reste plus qu'à présent à les informer via l'EventSource, la base du protocole.

On se rend donc dans notre template/base.html.twig pour ajouter, dans le body, le contrôleur Stimulus :

{% if app.user %}
    <div data-controller="mercure"
         data-mercure-hub-value="{{ mercure("http://commercial/alerts") }}"
    ></div>
{% endif %}

Puis on se crée notre controller Stimulus assets/controllers/mercure_controller.js :

import {Controller} from '@hotwired/stimulus';

export default class extends Controller {

    static values = {
        hub: String,
    }

    async connect() {

    }
}

Voila, c'est la base de notre controller Stimulus. La méthode connect s'exécute toute seule (un peu comme contruct()). A l'intérieur on va créer notre EventSource :

const es = new EventSource(this.hubValue, {withCredentials: false});

es.onopen = () => console.log("Connecté à Mercure ✅");

Rafraîchis une page et regarde en console :

console log de la connexion
On est connecté !

Ok donc ça marche comment ce bouzin ?

Dans notre controller, on injecte en twig data-mercure-hub-value="{{ mercure("<http://commercial/alerts>") }}

Cela nous permet de générer l'URL du serveur Mercure pour l'écoute des channels, avec en paramètre query notre topic : http://commercial/alerts.

Si on log this.hubValue on obtiens ceci :

console log de mercure

127.0.0.1:32768 c'est l’adresse du Hub Mercure, avec comme topic, notre channel demandé.

Dans le DevTool, rends toi maintenant dans l'onglet Network puis sélectionne fetch/xhr pour réduire la liste.

Tu devrais avoir ça :

Les Events Source

On a bien Mercure et en paramètre notre channel, c'est un appel de type EventSource.

En cliquant dessus tu accès même au stream actuel, c'est à dire la connexion en cours.

Les Events Source

Pour l'instant elle est vide, mais on va s'occuper de ça !

Pour voir un peu toutes les fonctionnalités de l'EventSource, on peut console.log notre variable.

J'aime bien console log ce genre de chose car ça permet de voir un peu ce qu’un objet JS à dans le ventre sans plonger dans la doc et se perdre :

L'objet EventSource
L'objet Event Source

On voit qu'on a pas mal de méthode, dont une qu'on utilise déjà : onopen().

Pour la suite on va donc se servir de onerror() et onmessage().

es.onmessage = async e => {
		const data = JSON.parse(e.data);
		console.log("Message reçu :", e.data);
	}
es.onerror = e => console.error("Erreur EventSource :", e);

C’est une fonction asynchrone puisqu'on va parser le JSON qu'on attend de recevoir.

On refresh la page /contact et /commercial côte à côte puis on remplit le formulaire de contact (en navigation privée) tandis que sur /commercial on est connecté :

On remplit le formulaire

On rentre les infos à gauche, et grâce à Mercure, on les reçoit en temps réel sur l'écran de droite.

On reçoit bien le message en JSON :

{"[id":16,"firstname":"Harry","email":"harry@zona.com](mailto:id%22:16,%22firstname%22:%22Harry%22,%22email%22:%22harry@zona.com)","message":"Coucou Mercure !","phoneNumber":"06"}

De là, on peut en faire ce que l'on veut, d’ailleurs on va pimper notre tableau pour remplir la liste au fur et à mesure que l'on reçoit les notifications.

Dans notre balise qui gère notre contrôleur Stimulus on rajoute ceci :

 data-mercure-hub-updated-value="{{ path('app_commercial_updated') }}" data-mercure-page-value="{{ app.request.get('_route') }}"

Puis dans notre JS : 

static values = {
    hub: String,
    hubUpdated: String,
    page: String
}

async connect() {
    const es = new EventSource(this.hubValue, {withCredentials: false});

    es.onopen = () => console.log("Connecté à Mercure ✅");
    es.onmessage = async e => {
        const data = JSON.parse(e.data);
        console.log("Message reçu :", data);
        await this.updateTable();
    }
    es.onerror = e => console.error("Erreur EventSource :", e);
}

async updateTable() {
    if (this.pageValue !== "app_commercial")
        return;

    const resp = await fetch(this.hubUpdatedValue)
    const text = await resp.text();
    return new Turbo.renderStreamMessage(text);
};

On passe ensuite dans notre ContactController.php pour rajouter la route du hubUpdatedValue :

  #[Route('/commercial/updated-contacts', name: 'app_commercial_updated')]
  public function updated(ContactRepository $contactRepository, HubInterface $hub): Response
  {
      $contactToBeProcessed = $contactRepository->findBy(['isProcessed' => false]);

      return new TurboStreamResponse($this->renderView("commercial/_table.stream.twig", [
          'contacts' => $contactToBeProcessed,
      ]), 200, ['vnd.turbo-stream.html']);
  }

A présent, le tableau s'update automatiquement à chaque notification.

Ça c'est déjà sympa, mais un truc sympa serait d'ajouter une modal pour informer le user d'un nouveau contact en pleine page histoire d'attirer l’attention du commercial.

On se crée alors notre _modal.html.twig :

<div data-mercure-target="modal" class="fixed hidden z-50 inset-0 bg-gray-900 bg-opacity-60 overflow-y-auto h-full w-full px-4 modal">
    <div class="relative top-40 mx-auto shadow-xl rounded-md bg-white max-w-md">
        <div class="flex justify-between items-center bg-gray-500 text-white text-xl rounded-t-md px-4 py-2">
            <h3 class="text-red-400 font-bold">Demande de contact !</h3>
            <button onclick="closeModal('modal')">x</button>
        </div>
        <div class="h-fit p-4">
            <p>Prénom: <span data-mercure-target="firstname" class="font-bold"></span></p>
            <p>Email: <span data-mercure-target="email" class="font-bold"></span></p>
            <p>Numéro: <span data-mercure-target="phone" class="font-bold"></span></p>
            <p>Message: <br/><span data-mercure-target="message" class="italic"></span></p>
        </div>
        <div class="px-4 py-2 border-t border-t-gray-500 flex justify-end items-center space-x-4">
            <button class="text-sm text-gray-600 hover:text-gray-900" data-action="click->mercure#closeModal">Close</button>
        </div>
    </div>
</div>

Tu as remarqué que l'on a intégré du stimulus, on va upgrade notre controller associé : 

static targets = ["modal", "firstname", "email", "phone", "message"]
    
createModal(contact) {
    if (!contact) return;

    this.firstnameTarget.textContent = contact.firstname;
    this.emailTarget.textContent = contact.email;
    this.phoneTarget.textContent = contact.phoneNumber;
    this.messageTarget.textContent = contact.message;

    this.modalTarget.style.display = "block";
}

closeModal() {
    this.modalTarget.style.display = "none";
}

Et dans notre connect() on ajoute createModal : 

async connect() {
    console.log(this.modalTarget);
    const es = new EventSource(this.hubValue, {withCredentials: false});

    es.onopen = () => console.log("Connecté à Mercure ✅");
    es.onmessage = async e => {
        const data = JSON.parse(e.data);
        console.log("Message reçu :", data);
        await this.updateTable();

        try {
            this.createModal(data);
        } catch (err) {
            console.error("Erreur createModal :", err);
        }
    };
    es.onerror = e => console.error("Erreur EventSource :", e);
}

Dans notre balise body on a donc ceci : 

{% if app.user %}
    <div data-controller="mercure"
         data-mercure-hub-value="{{ mercure("http://commercial/alerts") }}"
    >
    {% include "_modal.html.twig" %}
    </div>
{% endif %}
    {% block body %}{% endblock %}

On test : 

Remplissage du formulaire

Franchement, on n'est pas mal !

Pour terminer, on va juste renforcer un peu la sécurité.

Ici, on a limité Stimulus avec un {% if app.user %}, mais rien n'empêche un visiteur malveillant d’envoyer un EventSource lui-même vers notre Hub Mercure.

On a bien sûr le CORS pour nous protéger un peu, mais Mercure vérifie les JWT, alors autant en profiter !

Par défaut, avec Mercure, le bundle Lcobucci s’installe, mais si ce n'est pas le cas, il suffit de lancer :

composer require lcobucci/jwt .

On se crée TokenProvider notre App\Service\TokenProvider :

<?php

namespace App\Service;

use Lcobucci\JWT\Encoding\ChainedFormatter;
use Lcobucci\JWT\Encoding\JoseEncoder;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key;
use Lcobucci\JWT\Token\Builder;

class JWTProvider
{
    private string $key;

    public function __construct(string $key)
    {
        $this->key = $key;
    }

    public function getToken(string $topic): string
    {
        $now = new \DateTimeImmutable();
        
        $signer = new Sha256();
        return (new Builder(new JoseEncoder(), ChainedFormatter::default()))
            ->withClaim('mercure', ['subscribe' => [$topic]])
            ->expiresAt($now->modify('+3 hours'))
            ->getToken($signer, Key\InMemory::plainText($this->key))
            ->toString();
    }
}

getToken() permet de générer le token JWT correspondant à la clé secrète.

  • Builder permet de créer le JSON en base64
  • Claim c'est le contenu du JSON
  • getToken génère la signature qui permet de valider le token.

Dans le construct, on injecte via le config/services.yaml la clé secrète :

App\Service\JWTProvider:
    arguments:
        $key: '%env(MERCURE_JWT_SECRET)%'

Si le JWT expire, on peut le regénérer grâce à une route /me dans le SecurityController :

public function __construct(private readonly JWTProvider $provider)
{
}
  
#[Route(path: '/me', name: 'app_me')]
public function me(): Response
{
    $cookie = new Cookie("mercureAuthorization", $this->provider->getToken("http://commercial/alerts"), 0, '/', null, getenv("APP_ENV") == "PROD", true, false, 'lax');

    return new Response(null, 200, ['Set-Cookie' => $cookie->__toString()] );
}

On peut faire un dd($cookie) pour comprendre un peu ce cookie :

Le Cookie du JWT
Le Cookie du JWT

Si je copie colle le token chez jwt.io et que j'entre ma clé secrète pour vérifier la validité, on voit bien que c'est un token parfaitement valide :

Token valide

A présent, on va appeler la route /me pour récupérer ce token en cas de besoin et dire au PublishController de rendre la notification privée (ce qui exigera le token pour pouvoir s'abonner à l’IRI).

Pour ça, très simple, on retourne dans PublishController et on ajoute la mention "private" avec true :

public function publish(Contact $contact): void
{
    $update = new Update(
        'http://commercial/alerts',
        json_encode($contact->displayArray()),
        true // private
    );

    $this->hub->publish($update);
}

On y est presque, à présent on va indiquer à Mercure dans le compose.yaml que l'on autorise pas les connexions anonymes avec cette directive que l'on rajoute après le cors :

MERCURE_EXTRA_DIRECTIVES: |
  cors_origins http://127.0.0.1:8000
  anonymous off

Et dans notre Stimulus Controller, on va indiquer que l'eventSource nécessite des credentials : 

const es = new EventSource(this.hubValue, {withCredentials: true});

De cette manière, le cookie secure va être envoyé automatiquement dans l'EventSource pour dire à Mercure que l'on peut s'abonner.

Le cookie envoyé
Le cookie est bien envoyé

La route du /me c'est une bonne pratique, mais quelque chose d'encore plus pratique c'est d’intégrer le JWT à la connexion du user avec un CustomAuthenticator.

Dans le firewall du security.yaml on va ajouter un custom authenticator pour envoyer le JWT au moment de la connexion via un cookie de session :

    firewalls:
        dev:
            pattern: ^/(_profiler|_wdt|assets|build)/
            security: false
        main:
            lazy: true
            provider: app_user_provider
            custom_authenticators:
                - App\Security\CustomAuthenticator
            logout:
                path: app_logout

Puis on se crée notre authenticator custom dans App\Security\CustomAuthenticator :

<?php

namespace App\Security;

use App\Service\JWTProvider;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
use Symfony\Component\Security\Http\Util\TargetPathTrait;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;

class CustomAuthenticator extends AbstractLoginFormAuthenticator
{
    use TargetPathTrait;

    private UrlGeneratorInterface $urlGenerator;
    private JWTProvider $jwtProvider;
    private string $env;

    public const LOGIN_ROUTE = 'app_login';

    public function __construct(UrlGeneratorInterface $urlGenerator, JWTProvider $jwtProvider, private readonly AuthenticationUtils $authenticationUtils)
    {
        $this->urlGenerator = $urlGenerator;
        $this->jwtProvider = $jwtProvider;
        $this->env = getenv('APP_ENV');
    }

    public function authenticate(Request $request): Passport
    {
        $username = $request->request->get('_username', '');
        $password = $request->request->get('_password', '');
        $csrfToken = $request->request->get('_csrf_token');

        $request->getSession()->set($this->authenticationUtils->getLastUsername(), $username);

        return new Passport(
            new UserBadge($username),
            new PasswordCredentials($password),
            [
                new CsrfTokenBadge('authenticate', $csrfToken)
            ]
        );
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        $topic = 'http://commercial/alerts';
        // LE JWTProvider sera créé juste après
        $jwt = $this->jwtProvider->getToken($topic);

        $secure = ($this->env === 'prod' || $this->env === 'PROD');

        $cookie = new Cookie(
            'mercureAuthorization',
            $jwt,
            0,
            '/',
            null,
            $secure,
            true,
            false,
            Cookie::SAMESITE_LAX
        );

        $targetPath = $this->getTargetPath($request->getSession(), $firewallName);
        $redirectUrl = $targetPath ?? $this->urlGenerator->generate('app_commercial');

        $response = new RedirectResponse($redirectUrl);
        $response->headers->setCookie($cookie);

        return $response;
    }

    protected function getLoginUrl(Request $request): string
    {
        return $this->urlGenerator->generate(self::LOGIN_ROUTE);
    }
}

C’est ainsi que s'achève ce tutoriel sur Mercure et les ServerSource Event. Je n'ai pas évoqué chaque fonctionnalité de Mercure, notamment la notion de Discovery qui permet d'envoyer dans les headers l'ensemble des topics accessibles par le User pour qu'il puisse s'abonner à l’ensemble des topics en un seul EventSource. Ici le but était de faire un tour d'horizon des possibilités de Mercure.

Si en plus tu utilises API Plateform alors Mercure devient encore plus puissant car il prend en compte les changements dans les entités pour publier des notifications.

Lors d'un déploiement en prod, il faudra gérer évidement le SSL. Si tu utilises un Caddy ou Nginx, c'est la même chose qu'un site web d'un point de vue certificat.

J'espère que ce tutoriel t'évitera à présent de faire du polling à outrance !