Skip to content

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.2.0

Generating the JWTs used to authorize with a hub, such as via AMC::TokenFactory::JWT, also requires the jwt shard:

dependencies:
  jwt:
    github: crystal-community/jwt
    version: ~> 1.7
require "jwt"

The jwt shard links OpenSSL and GMP, so applications that only use static tokens, such as ones built with -Dwithout_openssl, can leave it out.

With Dependency Injection#

Within an Athena Framework application, or any other application using the dependency injection and HTTP kernel components, require athena-mercure/bundle and configure at least one hub:

require "athena"
require "athena-mercure/bundle"

ADI.configure({
  mercure: {
    hubs: {
      default: {
        url: ENV["MERCURE_URL"],
        jwt: {
          secret:    ENV["MERCURE_JWT_SECRET"],
          publish:   ["*"],
          subscribe: ["*"],
          claims:    {
            "iss"       => "https://example.com",
            "sub"       => "urn:example:app",
            "client_id" => "https://example.com",
          },
        },
      },
    },
  },
})

Then inject an AMC::Hub::Interface, AMC::Bundle::Authorization, or AMC::Bundle::Discovery where it's needed. See AMC::Bundle for more information.

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:

Protocol version#

The component speaks the Mercure protocol 1.0 by default, as implemented by Mercure hubs 1.0 and newer. Its JWTs are RFC 9068 access tokens, which require the iss, aud, sub, and client_id claims. Subscribers select topics via the match query parameter, or match_urlpattern for URL patterns, instead of the 0.x topic query parameter. To use a hub older than 1.0, configure both the AMC::Hub and the AMC::TokenFactory::JWT for the Mercure protocol 0.x via protocol_version: :legacy, or the hub's protocol_version: "0.x" in the bundle.

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:

# Adds the claims required by the Mercure protocol 1.0 to every JWT the factory creates.
token_factory = AMC::TokenFactory::DefaultClaims.new(
  AMC::TokenFactory::JWT.new(ENV["MERCURE_JWT_SECRET"]),
  {
    "iss"       => "https://example.com",
    "aud"       => ENV["MERCURE_URL"],
    "sub"       => "urn:example:app",
    "client_id" => "https://example.com",
  } of String => String | Array(String)
)

# Use `*` to give the created JWT access to publish to all topics.
token_provider = AMC::TokenProvider::Factory.new token_factory, [AMC::Grant.new(: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("match", "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>
This code would log each received update to the console. Be sure to call 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. The JWT of the cookie is allowed the provided AMC::Grants, or one of their shorthands to subscribe to some topics:

authorization = AMC::Authorization.new hub_registry

# Subscribe to the updates of a topic.
authorization.set_cookie request, response, "https://example.com/books/1"

# Or of the topics matching a URL pattern.
authorization.set_cookie request, response, {"urlpattern" => ["https://example.com/books/:id"]}

# Or a list of grants, such as to also expose a payload to the subscribers of the topics.
authorization.set_cookie request, response, [
  AMC::Grant.new(:subscribe, ["https://example.com/books/1"], JSON.parse(%({"user": "george"}))),
  AMC::Grant.new(:publish, ["https://example.com/books/1/comments"]),
]

The cookie is named after the hub's cookie_name, which defaults to __Secure-mercure_access_token under the Mercure protocol 1.0, and mercureAuthorization under the 0.x protocol. Browsers only accept __Secure- cookies over HTTPS, so use a hub with a cookie_name without the prefix, such as mercure_access_token, when developing over plain HTTP. The bundle does so by default when the framework.debug parameter is true.

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('match_urlpattern', '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" }

# ...