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:
- PHP 8.1
- Framework X and ReactPHP
- Symfony 6
- Redis (optionally).
See what features are covered and what aren't (yet) here.
PHP 8.1+ is required to run the hub.
composer create-project freddie/mercure-x freddie && cd freddie
bin/freddieThis 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.
composer req freddie/mercure-xYou can then start the hub by doing:
bin/console freddie:serveYou 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.
The controllers it exposes cannot be imported in your routes.yaml, and get out of your security.yaml scope.
./bin/freddieIt 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/freddieThe 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
typheader set toat+jwt; - carry
iss,audandexpclaims; - grant actions through an RFC 9396
authorization_detailsclaim:
{
"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 |
RESOURCE_IDENTIFIER is not set, the audience is derived from the Host / X-Forwarded-* request headers,
which clients control: set it in production.
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}/...).
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.
Set PROTOCOL_COMPATIBILITY=8 to keep Mercure 0.x (protocol version 8) clients working while you migrate them:
- the
topicquery parameter (exact match or URI Template), - the
mercure.publish/mercure.subscribe/mercure.payloadclaims, with any signed JWT (typ,iss,audandexpare not required), - the
mercureAuthorizationcookie and theauthorizationquery parameter, - the
lastEventIDquery parameter, theLast-Event-IDresponse header, - the
eventfield and truthy values of theprivatefield when publishing, - public updates published with any token having a
mercure.publishclaim (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).
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 (default0: no history, so missed updates are not replayed)
TRANSPORT_DSN="php://default?size=1000" ./bin/freddieOn 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/freddieOptional 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 (default0: no history, so missed updates are not replayed)trimInterval- how often, in seconds, the history is trimmed down tosize(default0.0: never, so it keeps growing)pingInterval- regularly ping Redis connection, which will help detect outages (default2.0)readTimeout- max duration in seconds of a ping or publish request (default0.0: considered disabled)
Alternatively, you can set this variable into .env.local.
This implementation does not provide SSL nor HTTP2 termination, so you'd better put a reverse proxy in front of it.
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.com
reverse_proxy 127.0.0.1:8080example.com
reverse_proxy 10.1.2.3:8080 10.1.2.3:8081 10.1.2.4:8080 10.1.2.4:8081EventSource 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
}β 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 | 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) |
This project is 100% covered with Pest tests.
composer tests:runIf 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:checkGNU General Public License v3.0.