Skip to content
bpolaszekPublic

About

A Mercure Hub, written in PHP (8.3 + ReactPHP)

Topics

Resources

Stars

103 stars

Watchers

6 watching

Forks

Latest commit

Β 

History

37 Commits

Folders and files

Repository files navigation

Application Coverage

Freddie

Freddie is a PHP implementation of the Mercure Hub Specification (protocol 1.0). Mercure 0.x clients are still supported through a compatibility mode.

Upgrading from a version implementing Mercure 0.x? Read the upgrade guide.

It is blazing fast, built on the shoulders of giants:

See what features are covered and what aren't (yet) here.

Installation

PHP 8.1+ is required to run the hub.

As a standalone Mercure hub

composer create-project freddie/mercure-x freddie && cd freddie
bin/freddie

This will start a Freddie instance on 127.0.0.1:8080, with anonymous subscriptions enabled.

You can publish updates to the hub by generating a valid access token signed with the !ChangeThisMercureHubJWTSecretKey! key with HMAC SHA256 algorithm.

To change these values, see Security.

As a bundle of your existing Symfony application

composer req freddie/mercure-x

You can then start the hub by doing:

bin/console freddie:serve

You can override relevant env vars in your .env.local and services in your config/services.yaml as usual.

Then, you can inject Freddie\Hub\HubInterface in your services so that you can call $hub->publish($update), or listening to dispatched updates in a CLI context πŸ‘

Keep in mind this only works when using the Redis transport.

⚠️ Freddie uses its own routing/authentication system (because of async / event loop).

The controllers it exposes cannot be imported in your routes.yaml, and get out of your security.yaml scope.

Usage

./bin/freddie

It will start a new Mercure hub on 127.0.0.1:8080. To change this address, use the X_LISTEN environment variable:

X_LISTEN="0.0.0.0:8000" ./bin/freddie

Security

The default JWT key is !ChangeThisMercureHubJWTSecretKey! with a HS256 signature.

You can set different values by changing the environment variables (in .env.local or at the OS level): X_LISTEN, JWT_SECRET_KEY, JWT_ALGORITHM, JWT_PUBLIC_KEY and JWT_PASSPHRASE (when using RS512 or ECDSA). HMAC secrets must be at least 256 bits long, and PEM keys cannot be used with HMAC algorithms.

Publishers and subscribers authenticate with an RFC 9068 access token, through the Authorization: Bearer <token> header or the __Secure-mercure_access_token cookie (the header takes precedence). The token must:

  • have a typ header set to at+jwt;
  • carry iss, aud and exp claims;
  • grant actions through an RFC 9396 authorization_details claim:
{
  "typ": "at+jwt",
  "alg": "HS256"
}
{
  "iss": "https://example.com",
  "aud": "https://example.com/.well-known/mercure",
  "exp": 4102444800,
  "authorization_details": [
    {
      "type": "https://mercure.rocks/authorization-detail",
      "actions": ["publish", "subscribe"],
      "topics": [
        {"match": "https://example.com/books/:id", "match_type": "urlpattern"},
        {"match": "https://example.com/authors/1"}
      ],
      "payload": {"user": "https://example.com/users/1"}
    }
  ]
}

Publishers need the publish action on every topic of an update (private or not). Subscribers need the subscribe action on at least one topic of a private update to receive it. {"match": "*"} grants every topic.

Variable Description Default
JWT_ISSUER The expected iss claim (any issuer is accepted when empty)
RESOURCE_IDENTIFIER The expected aud claim, e.g. https://example.com/.well-known/mercure. Also the base URL relative URL patterns and topics are resolved against aud: derived from the request; base URL: http://mercure.invalid/.well-known/mercure (relative patterns then only match relative topics)
COOKIE_NAME The name of the cookie holding the access token __Secure-mercure_access_token
ALLOW_ANONYMOUS Whether subscribers may connect without a token true
SUBSCRIPTIONS Enables the subscription API and subscription events false
PROTOCOL_COMPATIBILITY 8 to support Mercure 0.x clients, see below
HEARTBEAT_INTERVAL Seconds between two SSE comments keeping connections alive (0 disables them) 40
CORS_ORIGINS The Access-Control-Allow-Origin value The request's Origin

⚠️ When RESOURCE_IDENTIFIER is not set, the audience is derived from the Host / X-Forwarded-* request headers, which clients control: set it in production.

⚠️ Subscription events are stored in the history like any update: with SUBSCRIPTIONS enabled, many (dis)connections can evict actual updates from a small history. The subscription API only lists the subscribers connected to the process serving the request, and does not serve the URLs of Mercure 0.x subscriptions (/subscriptions/{topic}/...).

Subscribing

Subscribers select topics with matchers: match (or match_exact) for exact matching, match_urlpattern for URL patterns:

GET /.well-known/mercure?match=https://example.com/authors/1&match_urlpattern=https://example.com/books/:id

Matchers can also be sent in an application/x-www-form-urlencoded body with the QUERY method, to work around URL length limits. Missed updates are replayed with the Last-Event-ID header or the last_event_id query parameter; the reconciliation cursor is returned in the Mercure-Last-Event-ID response header.

Mercure 0.x compatibility

Set PROTOCOL_COMPATIBILITY=8 to keep Mercure 0.x (protocol version 8) clients working while you migrate them:

  • the topic query parameter (exact match or URI Template),
  • the mercure.publish / mercure.subscribe / mercure.payload claims, with any signed JWT (typ, iss, aud and exp are not required),
  • the mercureAuthorization cookie and the authorization query parameter,
  • the lastEventID query parameter, the Last-Event-ID response header,
  • the event field and truthy values of the private field when publishing,
  • public updates published with any token having a mercure.publish claim (only private ones are checked).

Access tokens with authorization_details and match* parameters are accepted in this mode too, so clients can be migrated one at a time. Error status codes follow protocol 1.0 in both modes (401 with a WWW-Authenticate: Bearer challenge for missing or invalid tokens, 403 with error="insufficient_scope" for insufficient grants).

PHP Transport (default)

By default, the hub will run as a simple event-dispatcher, in a single PHP process.

It can fit common needs for a basic usage, but using this transport prevents scalability, as opening another process won't share the same event emitter.

It's still prefectly usable as soon as :

  • You don't expect more than a few hundreds updates per second
  • Your application is served from a single server.

Optional parameter you can pass in the DSN's query string:

  • size - number of updates kept in memory to replay them to subscribers reconnecting with a last event ID (default 0: no history, so missed updates are not replayed)
TRANSPORT_DSN="php://default?size=1000" ./bin/freddie

Redis transport

On the other hand, you can launch the hub on multiple ports and/or multiple servers with a Redis transport (as soon as they share the same Redis instance), and optionally use a load-balancer to distribute the traffic.

The official open-source version of the hub doesn't allow scaling because of concurrency restrictions on the bolt transport.

To launch the hub with the Redis transport, change the TRANSPORT_DSN environment variable:

TRANSPORT_DSN="redis://127.0.0.1:6379" ./bin/freddie

Optional parameters you can pass in the DSN's query string:

  • size - number of updates kept in Redis to replay them to subscribers reconnecting with a last event ID (default 0: no history, so missed updates are not replayed)
  • trimInterval - how often, in seconds, the history is trimmed down to size (default 0.0: never, so it keeps growing)
  • pingInterval - regularly ping Redis connection, which will help detect outages (default 2.0)
  • readTimeout - max duration in seconds of a ping or publish request (default 0.0: considered disabled)

Alternatively, you can set this variable into .env.local.

Advantages and limitations

This implementation does not provide SSL nor HTTP2 termination, so you'd better put a reverse proxy in front of it.

Example Nginx configuration

upstream freddie {
    # Example with a single node
    server 127.0.0.1:8080;

    # Example with several nodes (they must share the same Redis instance)
    # 2 instances on 10.1.2.3
    server 10.1.2.3:8080;
    server 10.1.2.3:8081;

    # 2 instances on 10.1.2.4
    server 10.1.2.4:8080;
    server 10.1.2.4:8081;
}

server {
    
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name example.com;

    ssl_certificate /etc/ssl/certs/example.com/example.com.cert;
    ssl_certificate_key /etc/ssl/certs/example.com/example.com.key;
    ssl_ciphers EECDH+CHACHA20:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;

    location /.well-known/mercure {
        proxy_pass http://freddie;
        proxy_read_timeout 24h;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Example Caddy configuration

Single node

example.com

reverse_proxy 127.0.0.1:8080

With multiple nodes

example.com

reverse_proxy 10.1.2.3:8080 10.1.2.3:8081 10.1.2.4:8080 10.1.2.4:8081

Surviving hub restarts

EventSource clients reconnect after a connection loss, but give up for good on a 502. To make them wait for the hub while it restarts, let the proxy retry instead of failing right away:

example.com

reverse_proxy 127.0.0.1:8080 {
    lb_try_duration 30s
}

Payload limitations

⚠ There's a known limit in Framework-X which prevents request bodies to weigh more than 64 KB. At the time of writing, this limit cannot be raised due to Framework-X encapsulating HTTP Server instantiation.

Publishing bigger updates to Freddie (through HTTP, at least) could result in 400 errors.

Feature coverage

Feature Covered
Access tokens through Authorization header βœ…
Access tokens through cookie βœ…
authorization_details (RFC 9396) grants βœ…
Allow anonymous subscribers βœ…
Alternate topics βœ…οΈ
Private updates βœ…
Exact and URL Pattern matchers βœ…
QUERY subscriptions βœ…
Subscription API and events βœ… (subscribers of the current process only)
Mercure 0.x compatibility mode βœ…
HMAC SHA256 JWT signatures βœ…
RS512 JWT signatures βœ…
Environment variables configuration βœ…
Custom message IDs βœ…
Last event ID (including earliest) βœ…οΈ
Customizable event type βœ…οΈ
Customizable retry directive βœ…οΈ
CORS βœ… (CORS_ORIGINS)
Health check endpoint ❌ (PR welcome)
Logging ❌ (PR welcome))️
Metrics ❌ (PR welcome)️
Different JWTs for subscribers / publishers ❌ (PR welcome)
Protected resource metadata (RFC 9728) ❌ (PR welcome)

Tests

This project is 100% covered with Pest tests.

composer tests:run

Contribute

If you want to improve this project, feel free to submit PRs:

  • CI will yell if you don't follow PSR-12 coding standards
  • In the case of a new feature, it must come along with tests
  • PHPStan analysis must pass at level 8

You can run the following command before committing to ensure all CI requirements are successfully met:

composer ci:check

License

GNU General Public License v3.0.

About

A Mercure Hub, written in PHP (8.3 + ReactPHP)

Topics

Resources

Stars

103 stars

Watchers

6 watching

Forks

Releases

Packages

Used by

Contributors

Languages