Real-time Communications via Server-Sent Events: Mercure, Apache, PHP, and Symfony in Production

5/15/2022

When building web applications, I more commonly require updates from the server than bidirectional communication with it, which means that Server-Sent Events (SSEs) are a great implementation choice over WebSockets. Running as a layer on top of SSEs, Mercure is an open protocol for real-time communications between client and server, and even better, both Symfony and API Platform come with first-class support baked in.

I recently added Mercure to my existing Apache, PHP, and Symfony production stack and have documented the process here, including solutions to some of the challenges I faced.

To start using Mercure, you just need to install a Mercure Hub, which is any software that implements the Mercure protocol. The project provides a FOSS reference implementation, which is what I opted to use. Installing Mercure Hub turned out to be trickier than expected because it's actually a module packaged with a custom build of Caddy, which means you're installing another webserver. This adds a few challenges if you're already running Apache or nginx in your stack, but thankfully, Caddy is well-documented and I quickly managed to modify my Caddyfile (which is kind of like an Apache vhost) to run alongside Apache:

{
    https_port 8443
    auto_https off
    admin off
}

{$SERVER_NAME::8443}

# tls internal
tls /etc/ssl/dev.crt /etc/ssl/dev.pem

route {
    encode zstd gzip
    mercure {
        # Transport to use (default to Bolt)
        transport_url bolt:///var/run/mercure.db?size=100&cleanup_frequency=0.4
        # Publisher JWT key
        publisher_jwt "{$MERCURE_PUBLISHER_JWT_KEY}" {$MERCURE_PUBLISHER_JWT_ALG}
        # Subscriber JWT key
        subscriber_jwt "{$MERCURE_SUBSCRIBER_JWT_KEY}" {$MERCURE_SUBSCRIBER_JWT_ALG}
        cors_origins *
        publish_origins *
        subscriptions
        # Extra directives
        {$MERCURE_EXTRA_DIRECTIVES}
    }

    respond /healthz 200

    respond "Not Found" 404
}

log {
    output file /var/log/mercure/mercure.log {
        roll_local_time
        roll_keep 7
    }
}

The values inside { CURLY_BRACKETS } are variables, or placeholders in Caddy parlance (prepending a Caddy placeholder name with $ means you're referencing an environmental variable instead). At the very top is a global configuration block I've used to set Caddy to run on port 8443 (I already have Apache running on 443), I've disabled the automatic certificate generation (I'll be reusing my existing SSL certificate), and I've disabled the Caddy admin API. Next, I've defined a vhost for localhost:8443 and I've setup SSL to use my existing certificate. Finally, in the Mercure settings block, I'm using environmental variables for the value of my JWT signing keys. By default, Mercure uses a symmetrical JWT algorithm but since I'm already using the LexikJWTAuthenticationBundle in my project, I want to use the public key from that bundle as the signing key on my hub. That way, I can reuse the LexikJWTAuthenticationBundle JWT services when interacting with the Mercure Hub. The main gotcha I encountered is that since {$MERCURE_PUBLISHER_JWT_KEY} will resolve to a multi-line public key, so it has to be surrounded in quotes or Mercure Hub will fail to start.

To automatically set the Mercure Hub environmental variables at runtime, consider using a simple startup script:

#!/bin/bash
SYMFONY_ROOT="/var/www/html/apps"
MERCURE_PATH="$SYMFONY_ROOT/bin/vendor/mercure"

MERCURE_PUBLISHER_JWT_KEY="$(cat $SYMFONY_ROOT/config/jwt/public.pem)" \
MERCURE_PUBLISHER_JWT_ALG="RS256" \
MERCURE_SUBSCRIBER_JWT_KEY="$(cat $SYMFONY_ROOT/config/jwt/public.pem)" \
MERCURE_SUBSCRIBER_JWT_ALG="RS256" \
$MERCURE_PATH run -config $SYMFONY_ROOT/Caddyfile

The next step is to configure Apache as a reverse proxy, which can be done by adding the following block to your existing vhost:

    # proxy settings for mercure
    SSLProxyCACertificateFile /etc/ssl/dev.crt
    SSLProxyCheckPeerCN off
    SSLProxyCheckPeerName off
    SSLProxyEngine on
    <LocationMatch "/hub/">
        ProxyPass h2://localhost:8443/
        ProxyPassReverse https://localhost:8443/
    </LocationMatch>

Note that I'm disabling the SSL proxy hostname check because I'll be accessing the Mercure Hub via localhost, and that I've ensured the proxy maintains the http2 connection. The /hub/ LocationMatch (the path name is entirely arbitrary) means that requests coming in to example.com/hub/ will be proxied over to Mercure Hub. The only other thing to note here is that you'll need all the related Apache mods enabled (proxy, proxy_http2, proxy_balancer, etc).

As far as server configuration is concerned, the only step remaining is to ensure the Mercure Hub stays running. You can do this in many ways but since I already use supervisord for my Symfony messenger workers, it made sense just to add a configuration for Mercure Hub to /etc/supervisor.d:

[program:mercure]
autostart=true
command=/var/www/html/apps/bin/mercure.sh
numprocs=1
process_name=%(program_name)s_%(process_num)s
redirect_stderr=false
startsecs=5
stdout_capture_maxbytes=1MB
stderr_capture_maxbytes=1MB
stdout_logfile=/var/log/mercure/supervisor_out.log
stderr_logfile=/var/log/mercure/supervisor_err.log
user=root

Once you add the configuration file, you can run supervisorctl reread && supervisorctl update to start the Mercure Hub as a supervisord service.

After the Apache reverse proxy and the Mercure Hub are in place, all that's left is configuring Symfony. First, follow the installation steps for the Symfony Mercure bundle. Once done, update your .env file paths:

# The URL of the Mercure hub, used by the app to publish updates (can be a local URL)
MERCURE_URL=https://example.com/hub/.well-known/mercure
# The public URL of the Mercure hub, used by the browser to connect
MERCURE_PUBLIC_URL=https://example.com/hub/.well-known/mercure

Then, update your mercure.yaml and set it to use a token factory:

mercure:
    hubs:
        default:
            jwt:
                factory: App\Service\TokenFactory

The TokenFactory class is really simple since we're just wrapping the JWTAuthenticationBundle's $encoder:

namespace App\Service;

use Lexik\Bundle\JWTAuthenticationBundle\Encoder\JWTEncoderInterface;
use Symfony\Component\Mercure\Jwt\TokenFactoryInterface;

class TokenFactory implements TokenFactoryInterface
{
    public function __construct(
        private JWTEncoderInterface $encoder
    ) {
    }

    public function create(array $subscribe = [], array $publish = [], array $additionalClaims = []): string
    {
        return $this->encoder->encode(array_merge($additionalClaims, [
            'mercure' => [
                'subscribe' => $subscribe,
                'publish' => $publish,
            ],
        ]));
    }
}

On your development environment, if you're using a self-signed certificate you'll also need to configure the Symfony http_client to allow the connection to the Mercure Hub. You can do that by adding a config/packages/dev/http_client.yaml:

framework:
    http_client:
        default_options:
            verify_peer: false
            verify_host: false

With all that done, you're all setup for real-time updates with Mercure Hub in your existing Symfony application.