Skip to main content

Implementation of an instant messaging system with Mercure

Table of Contents
  1. Introduction to Mercure
  2. System Architecture
  3. Mercure Installation and Configuration
  4. Apache and SSL Configuration
  5. JWT Keys Generation
  6. Backend Implementation Example
  7. Frontend Implementation Example

1. Introduction to Mercure

Mercure is a real-time communication protocol based on Server-Sent Events (SSE). Unlike WebSockets, Mercure uses the standard HTTP protocol, making it simpler to implement and compatible with HTTP caching.

Advantages of Mercure

  • Simple: Uses HTTP/1.1 and HTTP/2
  • Efficient: Fewer resources than WebSockets
  • Compatible: Works behind proxies
  • Automatic reconnection: Handles disconnections
  • Native support in most browsers

2. System Architecture

Instant messaging architecture via Mercure

  1. The client (browser, mobile app, etc.) opens an HTTP/HTTPS connection to the Mercure hub (via the domain managed by the Apache vhost, e.g., messages.habeuk.com).
  2. The Apache vhost acts solely as a proxy: it redirects the request to the Mercure container (often on 127.0.0.1:3000).
  3. The Symfony application publishes a message to Mercure (via the HubInterface and an Update).
  4. The Mercure hub then sends this message in real-time to all subscribed clients on the relevant topic (via the SSE connection opened initially).

3. Mercure Installation and Configuration

3.1. Docker Compose Configuration

Create a folder where you want to manage the Mercure server (e.g., 'mercure_habeuk'), navigate to this folder and create the Mercure configuration file (docker-compose.yml).

# compose.yaml
services:
  mercure:
    image: dunglas/mercure
    restart: unless-stopped
    container_name: mercure_habeuk
    environment:
      # Au niveau de mercure on ecoute sur le port 80.( mercure est dans un docker ).
      SERVER_NAME: ':80'
      MERCURE_PUBLISHER_JWT_KEY: "${MERCURE_PUBLISHER_JWT_KEY}"
      MERCURE_SUBSCRIBER_JWT_KEY: "${MERCURE_SUBSCRIBER_JWT_KEY}" 
      MERCURE_EXTRA_DIRECTIVES: |
        cors_origins https://www.habeuk.com https://habeuk.com     
        anonymous    
    ports:
      - "127.0.0.1:3000:80"   
    # configuration avec volumes nommés gerer par docker ( /var/lib/docker/volumes/... )
    volumes:
      - mercure_data:/data
      - mercure_config:/config
volumes:
  mercure_data:
  mercure_config:

Explanations:

SERVER_NAME: ':80' → the number 80 indicates the port on which the Mercure server will listen inside the Docker container. Since Mercure runs in an isolated container, this port does not conflict with the host machine's port. We could have chosen another internal port if necessary.

MERCURE_PUBLISHER_JWT_KEY: "${MERCURE_PUBLISHER_JWT_KEY}" and MERCURE_SUBSCRIBER_JWT_KEY: "${MERCURE_SUBSCRIBER_JWT_KEY}" → these are the JWT keys used respectively to publish and subscribe to Mercure events. They are defined in the environment file mercure_habeuk/.env

MERCURE_EXTRA_DIRECTIVES → allows adding additional instructions to the Mercure hub configuration (it's like a small internal configuration block).

cors_origins https://www.habeuk.com https://habeuk.com → defines the allowed origins (CORS) that can connect to the Mercure hub. This means that only web pages served from https://www.habeuk.com and https://habeuk.com will have permission to: 
- subscribe to events (via EventSource);
- and send publication requests (if JWT permissions allow).

anonymous → allows connections without JWT tokens for subscription. In other words, clients can subscribe to certain topics without needing to authenticate, which is useful for public feeds (such as messages visible to everyone or global notifications).

ports:
127.0.0.1:3000:80 → instructs Docker to redirect port 80 of the Mercure container to port 3000 on the host machine, but only accessible from 127.0.0.1 (localhost).

In other words: - The Mercure service listens on port 80 inside the container. - This port is exposed locally on port 3000 of the host machine. - The address 127.0.0.1 limits access to the local machine only (no one can reach port 3000 from outside).

3.2. Starting the Service

Install and start Mercure

docker compose up -d

Stop and restart the service

docker compose down && docker compose up -d

3.3. Testing the Mercure Service

First, we check if the Mercure server is running on the defined IP address and port. (directly on the VPS)

curl http://127.0.0.1:3000/.well-known/mercure?topic=test

If everything is correct, the command should open an SSE connection.

We want the Mercure server to be accessible via the subdomain messages.habeuk.com.

Modify the /etc/hosts file by adding the following:

127.0.0.1	messages.habeuk.com

Then perform another test:

curl http://messages.habeuk.com:3000/.well-known/mercure?topic=test

If everything is correct, the command should open an SSE connection.

4. Apache and SSL Configuration

We want a clean address, meaning messages.habeuk.com without the port number. Apache will intervene to transfer requests received on messages.habeuk.com to the address 127.0.0.1:3000 transparently for the user.

We need to add a virtual host in /etc/apache2/sites-available/messages.habeuk.com.conf:

<VirtualHost *:80>
    ServerName messages.habeuk.com
    # Proxy all HTTP requests to Mercure (port 3000)
    ProxyPreserveHost On
    ProxyRequests Off
    ProxyPass / http://127.0.0.1:3000/
    ProxyPassReverse / http://127.0.0.1:3000/
    #
    ErrorLog /var/www/habeuk/logs/error-mercure.log
    CustomLog /var/www/habeuk/logs/access-mercure.log combined
</VirtualHost>

Activation of Apache 2 modules:

sudo a2enmod proxy
sudo a2enmod proxy_http
sudo a2enmod headers
sudo a2enmod rewrite

Activation of the virtual host:

sudo a2ensite messages.habeuk.com.conf

Restart Apache 2:

sudo systemctl restart apache2

Then, perform a test:

curl http://messages.habeuk.com/.well-known/mercure?topic=test

If everything is correct, the command should open an SSE connection.

You can also check directly in your browser: http://messages.habeuk.com should display the Mercure homepage.

Enable SSL on your subdomain messages.habeuk.com by following this documentation.

Update the virtual host file /etc/apache2/sites-available/messages.habeuk.com.conf:

<VirtualHost *:80>
    ServerName messages.habeuk.com
    Redirect permanent / https://messages.habeuk.com/
</VirtualHost>

<VirtualHost *:443>
    ServerName messages.habeuk.com
    # Proxy toutes les requêtes HTTPS vers Mercure (port 3000)
    ProxyPreserveHost On
    ProxyRequests Off
    ProxyPass / http://127.0.0.1:3000/
    ProxyPassReverse / http://127.0.0.1:3000/
    #
    ErrorLog /var/www/habeuk/logs/error-mercure.log
    CustomLog /var/www/habeuk/logs/access-mercure.log combined
    # Activation SSL
    SSLEngine On
    SSLCertificateFile /etc/letsencrypt/live/habeuk.com/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/habeuk.com/privkey.pem
    Include /etc/letsencrypt/options-ssl-apache.conf
</VirtualHost>

Finally, repeat the previous test, this time with HTTPS:
https://messages.habeuk.com

5. JWT Keys Generation

Mercure's security relies on JWT (JSON Web Tokens) signed with cryptographic keys. Generate sufficiently long random secrets using the following command:

openssl rand -base64 32

Then create the mercure_habeuk/.env file and add the following content:

MERCURE_PUBLISHER_JWT_KEY=your-secure-publisher-key-from-openssl 
MERCURE_SUBSCRIBER_JWT_KEY=your-secure-subscriber-key-from-openssl 

6. Backend Implementation Example (via Symfony)

<?php

// src/Service/ChatService.php
namespace App\Service;

use App\Entity\ {
  Notification,
  NotificationReceipt
};
use App\Enum\NotificationType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Psr\Log\LoggerInterface;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;

class SendNotificationByMercureService {

  public function __construct(private EntityManagerInterface $em, private HubInterface $hub, private SerializerInterface $serializer, private LoggerInterface $logger) {
  }

  public function publishViaMercure(Notification $notification): void {
    $receipts = $notification->getReceipts();
    $topics = [];
    if ($receipts->count() > 0) {
      foreach ($receipts->getValues() as $receipt) {
        /**
         *
         * @var NotificationReceipt $receipt
         */
        $topics[] = 'notification/' . $receipt->getRecipient()->getId();
      }
      try {
        $result = $this->serializer->serialize($notification, 'json',
          [
            AbstractNormalizer::GROUPS => [
              'notification:read'
            ],
            AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER => function ($object) {
              return $object->getId();
            }
          ]);
        $type = $notification->getType() == NotificationType::NEW_MESSAGE ? "new_message" : "notification";
        $update = new Update($topics, $result, true, null, $type);
        $this->hub->publish($update);
      }
      catch (\Exception $e) {
        $this->logger->error($e->getMessage());
      }
    }
  }
}

7. Frontend Implementation Example

// mercureClient.js
class MercureClient {
    constructor(url) {
        this.eventSource = new EventSource(url, { withCredentials: true });
        this.eventSource.onerror = (err) => {
            console.error("Mercure connection error:", err);
        };
    }

    build() {
        const events = ["chat", "read_message", "notification", "new_message"];
        events.forEach((event_name) => {
            this.Listerner(event_name);
        });
        this.eventSource.onmessage = (event) => {
            console.log("MercureClient event onmessage : ", event);
            alert("ezsd");
        };
    }

    Listerner(event_name) {
        this.eventSource.addEventListener(event_name, (event) => {
            const data = JSON.parse(event.data);
            this.dispatchCustomEvent(event_name, data);
        });
    }

    dispatchCustomEvent(event_name, data) {
        console.log("dispatchCustomEvent event_name : ", event_name, data);
        document.dispatchEvent(
            new CustomEvent(`mercure-${event_name}`, {
                detail: data,
            })
        );
    }
}
export default MercureClient;
Profile picture for user admin Stephane K

Écrit le

Il y'a 1 day
Modifié
Il y'a 1 day
Loading ...
WhatsApp
Habeuk Support: +49 152 108 01753
Send