Introduction
The Athena::Mercure component allows easily pushing updates to web browsers and other HTTP clients using the Mercure protocol.
Because it is built on top of Server-Sent Events (SSE), Mercure is supported out of the box in modern browsers.
Mercure comes with an authorization mechanism, automatic reconnection in case of network issues with retrieving of lost updates, a presence API, "connection-less" push for smartphones and auto-discoverability (a supported client can automatically discover and subscribe to updates of a given resource thanks to a specific HTTP header).
Unlike the Crystal stdlib's HTP::WebSocketHandler, Mercure relies on a centralized hub to manage the persistent SSE connections with the client(s) as opposed to connecting directly to the Crystal HTTP server.
flowchart LR
%% Publishers
subgraph Publishers
P1["Athena app"]
P2["Other HTTP service"]
end
%% Mercure Hub
H["Mercure Hub"]
%% Subscribers
subgraph Subscribers
S1["Browser client JavaScript"]
S2["Mobile app React Native"]
S3["Other HTTP client"]
end
%% Flows from publishers to hub
P1 -->|HTTP POST| H
P2 -->|HTTP POST| H
%% Flows from hub to subscribers
H -->|SSE| S1
H -->|SSE| S2
H -->|SSE| S3
Ultimately this makes the interactions/usage of it simpler since the majority of the complex parts are abstracted away.
Installation#
First, install the component by adding the following to your shard.yml, then running shards install:
dependencies:
athena-mercure:
github: athena-framework/mercure
version: ~> 0.1.0
Setup#
Because the Mercure Hub is a separate process from the Athena HTTP server, it does mean you have to install a Mercure hub by yourself. For production usages, an official and open source (AGPL) hub based on the Caddy web server can be downloaded as a static binary from Mercure.rocks. A Docker image, a Helm chart for Kubernetes and a managed, High Availability Hub are also provided.
Locally, it's easiest to run the Hub via docker compose. A minimal development compose file would look like:
services:
mercure:
image: dunglas/mercure
restart: unless-stopped
environment:
SERVER_NAME: ':80' # Disable HTTPS for local dev
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_EXTRA_DIRECTIVES: |
cors_origins http://localhost:3000 # Allow Athena Server
command: /usr/bin/caddy run --config /etc/caddy/dev.Caddyfile # Enable dev mode
ports:
- '80:80'
volumes:
- mercure_data:/data
- mercure_config:/config
volumes:
mercure_data:
mercure_config:
Usage#
Now that the Mercure hub is running, we can use it to publish updates, and subscribe to receive those updates on the client side.
Publishing#
In order to publish an update, a AMC::Hub instance is required. This type expects to be provided a URL to the Mercure Hub that updates should be sent to, and an AMC::TokenProvider::Interface instance. The token provider is responsible for returning a JWT token used to authenticate the request sent to the Mercure Hub. Most commonly this'll be generated using a static secret key via the Crystal JWT shard.
An AMC::Update instance should then be instantiated that represents the update to publish, and provided to the #publish method of the hub instance.
A complete example of this flow is as follows:
token_factory = AMC::TokenFactory::JWT.new ENV["MERCURE_JWT_SECRET"]
# Use `*` to give the created JWT access to all topics.
token_provider = AMC::TokenProvider::Factory.new token_factory, publish: ["*"]
hub = AMC::Hub.new ENV["MERCURE_URL"], token_provider, token_factory
update = AMC::Update.new(
"https://example.com/my-topic",
{message: "Hello world @ #{Time.local}!"}.to_json
)
hub.publish update # => urn:uuid:e1ee88e2-532a-4d6f-ba70-f0f8bd584022
Multiple hubs can be used and accessed by name via a AMC::Hub::Registry.
Subscribing#
Updates can be subscribed to on any platform that supports Server-Sent Events. For example via JS:
<!doctype html>
<html>
<body>
<script type="application/javascript">
const url = new URL("http://localhost/.well-known/mercure");
url.searchParams.append("topic", "https://example.com/my-topic");
const eventSource = new EventSource(url);
console.log("listening...");
eventSource.onmessage = (e) => console.log(e);
</script>
<h2>Mercure Testing</h2>
</body>
</html>
eventSource.close() when no longer needed to avoid a resource leak.
Authorization#
Mercure allows dispatching updates to only authorized clients.
To do so, mark an AMC::Update as private via the third constructor argument, the private named argument:
AMC::Update.new(
"https://example.com/books/1",
{status: "OutOfStock"}.to_json,
private: true
)
To subscribe to private updates, subscribers must provide to the Hub a JWT containing a topic selector matching by the topic of the update. The preferred way of providing the JWT in a browser context is via a cookie.
Warning
To use the cookies, the Athena app and the Mercure Hub must be served from the same domain (can be different sub-domains).
The Mercure component provides AMC::Authorization that can handle generating/setting the cookie given a request/response.
Cookies set by this helper class are automatically passed by the browser to the Mercure hub if the withCredentials attribute of EventSource is set to true:
const eventSource = new EventSource(url, { withCredentials: true });
Discovery#
Mercure comes with the ability to automatically discover the hub via a Link header.
sequenceDiagram
participant C as Client
participant A as Athena API
participant H as Mercure Hub
C->>A: GET resource
A-->>C: 200 OK resource
A-->>C: Link header rel mercure
Note over C: Discover hub URL
Note over C: Add topic parameter
C->>H: Open SSE connection
H-->>C: Updates for topic
The header may be added using the AMC::Discovery type.
The client would then be able to extract the hub URL from the Link header to be able to subscribe to updates related to that resource:
// Fetch the original resource served by the Athena web API
fetch('/books/1') // Has Link: <https://hub.example.com/.well-known/mercure>; rel="mercure"
.then(response => {
// Extract the hub URL from the Link header
const hubUrl = response.headers.get('Link').match(/<([^>]+)>;\s+rel=(?:mercure|"[^"]*mercure[^"]*")/)[1];
// Append the topic(s) to subscribe as query parameter
const hub = new URL(hubUrl, window.origin);
hub.searchParams.append('topic', 'https://example.com/books/{id}');
// Subscribe to updates
const eventSource = new EventSource(hub);
eventSource.onmessage = event => console.log(event.data);
});
Testing#
The Mercure component comes with some helper types for testing code that publishes updates, without actually sending the update. See AMC::Spec for more information.
require "athena-mercure/spec"
hub = AMC::Spec::MockHub.new("https://foo.com", AMC::TokenProvider::Static.new("JWT")) { "id" }
# ...