Server-Sent Events with Mercure in Laravel/Lumen

Syed Sirajul Islam Anik
9 min readMar 8, 2021
Image from: https://github.com/dunglas/mercure

Server-Sent Events (SSE) are unidirectional event stream where the client subscribes to events from the stream server and the server only sends the updates/streams whenever available through that established connection. Unlike WebSocket, the client cannot send any data through the connection. In SSE, the client starts the SSE connection by specifying the Accept header with text/event-stream. And in SSE’s it’s only allowed to push only text-based updates.

Mercure

Mercure is a hub where the backend services push the event updates with payload using the HTTP POST request and the connected frontend clients get those updates delivered. Whenever the backend application intends to inform the user about an event, it sends the update payload to the Mercure hub, and the Mercure hub informs the client.

In this approach, there may require to authorize the client or the publisher, and these authorizations are done by the JWT. Whenever a client wants to listen for an update and asks the hub to let it connect, it may require JWT to validate if the client is allowed to listen for the private events, for public events it may not be required based on your hub’s settings.

The same goes for the publishers as well. When a publisher wants to push an update through the hub, the publisher also requires to submit JWT to check the authorization to publish that event.

Up & Running the Mercure Hub

If you’re familiar with the docker, then up & running the Mercure hub is not that complex. All you need to do is to choose the Mercure hub image. Tell the docker-compose file which port to use and a few configurations to set. The following snippet of docker-compose configuration allows using Mercure hub.

version: '3'services:
mercure:
image: dunglas/mercure
environment:
DEBUG: "debug"
SERVER_NAME: ':80'
MERCURE_PUBLISHER_JWT_KEY: '~my-jwt-key-here~'
MERCURE_SUBSCRIBER_JWT_KEY: '~my-jwt-key-here~'
MERCURE_EXTRA_DIRECTIVES: |-
cors_origins "http://127.0.0.1"
anonymous
ports:
- "9000:80"

In the above service configuration, I used the dunglas/mercure image. Let 9000 to be occupied and proxy to Mercure’s port. And the environments are for the Mercure’s configuration itself. The MERCURE_PUBLISHER_JWT_KEY is the key that’ll be used to validate the Publisher’s authorization to publish a message. Whereas the MERCURE_SUBSCRIBER_JWT_KEY will be used to validate the client’s authorization token. Mercure uses the caddy web server and that’s why configuring it needs to pass a few extra information. If you’re using a browser as a client, it’s mandatory to enable CORS, and if you want anonymous users to connect to your hub to listen to public updates, you need to enable it through the anonymous option. So, MERCURE_EXTRA_DIRECTIVES entry does it. For the above configuration, it is letting 127.0.0.1 to connect to the Mercure hub allowing CORS, and anonymous also allows listening for public updates without any token required (not JWT is required when connecting).

The full docker-compose.yml can be found here.

Terminology

  • Topic — Topic(s) are the units to which the clients subscribe for the upcoming events. Mercure hub spec recommends using HTTP/HTTPS URI as a topic name.
  • Topic Selector — Topic selector is an expression for matching one or more topics.
  • Subscriber — Subscriber(s) are the clients who connect to the hub using the topic/topic selector and listens for real-time updates.
  • Publisher — Publisher(s) are mainly the backend services, which know when an event occurs and inform the hub to convey an update due to the event.
  • Update — Updates are the payloads/messages containing updates of an event. The updates can be public or private. If an update is private, it’s only forwarded to the allowed subscribers.
  • Hub — The hub is the server that is responsible for accepting requests from the publishers and distributing those messages to the subscribers.

Connecting to Mercure hub from the frontend

Before we publish any message to the Mercure hub, let’s figure out how to connect to the Mercure hub from the frontend. To connect from the frontend, we need to use the browser’s EventSource interface. That’s what we need almost.

const url = new URL('http://127.0.0.1:9000/.well-known/mercure');
url.searchParams.append('topic', 'public-topic-1');
url.searchParams.append('topic', 'public-topic-2');
// You can add multiple topics appending to search param
const es = new EventSource(url);
es.onmessage = (msg) => {
console.log(msg);
}

If you run the above snippet in your browser’s JS console, then going to the Network tab, you can find that it successfully connected to the Mercure hub. (Status code: 200 OK)

Successfully connected to the Mercure Hub

Now, we have applied a onmessage listener which will log the upcoming message to the console. If we go back to the console tab and run the following snippet from our terminal, coming back to the JS console, we’ll see that some messages are getting logged in the console.

curl -H "Content-type: application/x-www-form-urlencoded" \
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdfX0.OwYVEF9qsVOpHeCx-iBV5jMVl0BVGivm0v8fsJTW5rw' \
-d "topic=public-topic-1&data=my+message+is+getting+published" \
-XPOST http://127.0.0.1:9000/.well-known/mercure
Logged message sent from the terminal

In the above curl command, we used -d to pass the message and the topic name. The topic name should match the topic name we used in the JS code.

The payload we wanted to pass to the frontend/client is under the data form value.

Finally, -H ‘Authorization: Bearer _TOKEN_’ is the JWT. How did we generate the JWT? BTW, this token is for the publisher. Subscriber tokens will be discussed later.

You can go to the JWT.io. In the payload section, paste the following JSON.

{
"mercure": {
"publish": [
"*"
]
}
}

In the verify signature section’s text field, paste the key you used in the docker-compose.yml file’s MERCURE_PUBLISHER_JWT_KEY value. On the left side, you’ll find your JWT, include that JWT in your curl command.

Keep the token, we’ll use that when publishing from the PHP code.

Alternatively, if we have checked the EventStream sub-tab next to headers in the network tab, we could have seen those upcoming messages

Upcoming message in the network tab

NOTE

If you’ve noticed or not, both the subscriber and publisher are requesting the {{hub_host:port}}/.well-known/mercure endpoint. Because Mercure has exposed this endpoint to communicate to. It will be the only endpoint in both publishing and subscription cases.

Publishing message from the backend

To publish a message from the backend, we’ll have to use our own implementation of curl. Rather we can just use the symfony/mercure package with the Laravel/Lumen.

Let’s install the package with composer require symfony/mercure

Next, in our app/Providers/AppServiceProvider's boot method, add the following binding.

<?php
// app/Providers/AppServiceProvider.php
namespace App\Providers;use Illuminate\Support\ServiceProvider;
use Symfony\Component\Mercure\Jwt\StaticJwtProvider;
use Symfony\Component\Mercure\Publisher;
class AppServiceProvider extends ServiceProvider
{
public function boot()
{
$this->app->bind(Publisher::class, function () {
$url = 'http://mercure/.well-known/mercure';
$token = env('MERCURE_PUBLISHER_JWT_KEY');
$jwtProvider = new StaticJwtProvider($token);
return new Publisher($url, $jwtProvider);
});
}
}

In case you’re using Lumen, you’ll need to uncomment the AppServiceProvider in your bootstrap/app.php as it’s commented by default.

Next, in the .env file, we will have to put the previously generated JWT with the key MERCURE_PUBLISHER_JWT_KEY .

// .env
MERCURE_PUBLISHER_JWT_KEY="PREVOIUSLY_GENERATED_TOKEN from JWT.io"

Now, from your controller, you can publish messages to the Mercure Hub.

<?php

namespace App\Http\Controllers;

use Symfony\Component\Mercure\Publisher;
use Symfony\Component\Mercure\Update;

class ExampleController extends Controller
{
public function pushEvent(Publisher $publisher)
{
$msg = 'my message from controller';
$topics = [
'public-topic-1',
'public-topic-2',
];
$publisher(new Update($topics, $msg));

return response()->json([
'error' => false,
'message' => 'Message got published',
]);
}
}

If you bind an URL to this controller in your route file, then running that endpoint will produce the messages like below. (Make sure your frontend client is already connected to the hub. If not, run the previous JS snippet to connect to the hub)

Received message from the hub

That’s all to publish messages to the hub from the backend code.

What about the authorization I talked about?

To publish a message to the hub, you need to have a JWT. And the content of the JWT must have {"mercure": { "publish": [] }} as the minimum claim payload. By creating a JWT with the above payload, you can publish only public updates to any topic.

But to publish private updates to any topic, you need to have that topic listed in the mercure.publish array. When we generated the JWT for the publisher, we used the following JSON.

{
"mercure": {
"publish": [
"*"
]
}
}

Now, as payload contains * (asterisk) in the topics array, it means that you can publish public and private updates to any topic. But if we change the topic to something like below.

Generating JWT for a specific topic
Trying to publish to private message

To publish private message used true in the third parameter.

It then returned the 401 (Unauthorized) status code.

Check the topic name it’s allowed to publish in the JWT and trying to publish from the controller.

Authorization for the frontend?

Like the publisher, to listen to private messages, you’ll need to provide JWT when connecting to the Mercure Hub. As in our docker-compose.yml file, we used anonymous, that’s why it allowed the JS code to connect to the hub. Removing that line will raise a 401 response.

With the EventSource interface, we cannot pass any authorization header. That’s why we’ll need to pass the Cookie to the hub’s server.

First of all, we need to set a cookie named mercureAuthorization that will be passed to the hub. And the value for the cookie will be the JWT containing the topics it can subscribe to.

Claims of the subscriber

Here, the key used in the verify signature’s marked box is the key we used in the docker-compose.yml's MERCURE_SUBSCRIBER_JWT_KEY. And the mercure.subscriber claim contains the topics the client can subscribe to.

Next, when connecting to the server, our new snippet to connect to the hub will be like.

const url = new URL('http://127.0.0.1:9000/.well-known/mercure');
url.searchParams.append('topic', 'private-topic-1');
url.searchParams.append('topic', 'private-topic-2');
const es = new EventSource(url, {withCredentials: true});
es.onmessage = (msg) => {
console.log(msg);
}

When connecting to the server, we’ll see that the cookie is being passed to the hub.

The cookies are being passed to the Mercure hub

When we again execute the endpoint after making changes to publish private messages, we get to see those messages coming.

Receiving private messages
Changes in controller

However, if you connect to topics without having the topic specified in the JWT, you’re only allowed to listen to public updates.

Sidenotes

  • If you don’t want to share the cookie, you can use any polyfill. Those allow you to connect with the Authorization header.
  • By default, we all listened to the onmessage which only listens to the message type. You can bind es.addEventListener(type, closure). Know more about the SSE and Event Source interface
  • You can do more with Symfony Mercure’s Publisher class. Go through their documentation.
  • In your JWTs, you can set all the basic claims like exp iat nbf.
  • I have created a repository where you can mostly play with all the scenarios I tried to cover. Go through the project readme.md to know how to install & configure.
  • Last but not least, go through the Mercure spec to know the topic thoroughly.

In this article, I tried to explain almost everything I could explore. That’s all for the article.

Happy coding. ❤

--

--

Syed Sirajul Islam Anik

procrastinator | programmer | !polyglot | What else 🙄 — Open to Remote