github dunglas/mercure v1.0.0

7 hours ago

Community

Mercure 1.0 is final. When the protocol was drafted in 2018, the web standards covering its needs did not exist, so Mercure grew its own machinery. They exist now, so 1.0 drops the bespoke parts and the hacks, and reuses them. That is the through-line of the whole release. Topic matching moves to URL Patterns — itself a new web standard, and the syntax browsers already ship — in place of URI Templates, and a subscription now states which kind of match it wants: match= for an exact topic, match_urlpattern= for a pattern. No more guessing whether {id} was a template or a literal. Authorization is plain OAuth 2.0, and the hub is a generic resource server an off-the-shelf library can enforce.

Defaults are stricter throughout. The built-in UI is production-safe, and everything insecure about it moved behind a separate opt-in. Access tokens are never accepted in a URL. A mistyped directive stops the hub instead of silently disabling whatever it was meant to configure. A valid token without a grant gets a 403 rather than a silent drop. Compatibility with 0.x weakens token validation, so the hub never turns it on by itself.

They are breaking changes for subscribers, publishers, and token issuers. If you only run the hub, the upgrade is a config change. Otherwise, plan a synchronized cutover — or turn on compatibility mode and keep serving 0.x clients while you migrate. Everything below is in the upgrade guide.

✨ New Features

  • URL Pattern matchers. Templated topics use the pattern syntax browsers ship natively (:id, wildcards, groups) instead of URI Templates, so the same pattern object can be reused client-side. A subscription picks its matcher explicitly — match= for exact, match_urlpattern= for a pattern — and match_type does the same inside a token, defaulting to exact.
  • QUERY subscriptions (RFC 10008). Matcher parameters travel in an application/x-www-form-urlencoded request body instead of the query string, so a subscriber watching hundreds of topics no longer hits the 4–8 KB URL limits browsers, proxies, and CDNs enforce. Safe and idempotent like GET. EventSource can't issue it, so use a fetch-based client. The hub advertises support with Accept-Query on every subscription answer, including refusals.
  • A production-safe debugger. The old ui becomes debugger, which ships safe defaults and can be left on in production. The insecure parts moved to playground, which mints an all-access token and turns on anonymous, subscriptions, wildcard origins and a prefix-less cookie name on its own. Never enable playground in production.
  • mercure-token command, minting self-issued access tokens so a hub is usable before you stand up an authorization server.
  • RFC 9728 discovery. The hub advertises protected resource metadata, and authorization_server inside an issuer block advertises where to get a token.
  • Request-derived identity. The hub computes its public URL and OAuth 2.0 resource identifier from each request, so one hub reachable through several domains needs no configuration. resource_identifier pins a single canonical audience instead; on a catch-all site block, public_urls rejects an unlisted origin with 421 Misdirected Request.
  • max_request_body_size caps publish and QUERY subscribe bodies (default 1MiB, 413 above it); 0 delegates to a reverse proxy.
  • Incremental: ?1 response header (RFC 10036), the standardized counterpart of the X-Accel-Buffering: no the hub already sent.

⚠️ Breaking Changes

Matchers. topic= becomes match= (exact) or match_urlpattern= (WHATWG URL Pattern, :id rather than {id}). URI Templates are gone. Parameter names are case-sensitive and any other name under the match prefix is rejected with 400, so typos fail loudly instead of silently widening a subscription.

Tokens. The bespoke mercure JWT claim is replaced by an OAuth 2.0 access token: typ: at+jwt, an iss matching a trusted issuer, an aud holding the hub's resource identifier, a required exp, and an authorization_details array of { "type": "https://mercure.rocks/authorization-detail", "actions": [...], "topics": [...] } entries. topics holds { "match", "match_type"? } objects — bare strings are rejected, match_type is case-sensitive and defaults to exact, and { "match": "*" } still matches everything. A subscribe entry carries its own payload; the top-level mercure.payload is gone. The claim must be unambiguous JSON: a duplicate member or invalid UTF-8 rejects the token rather than letting the hub pick a reading the authorization server may not have validated. One invalid detail rejects the whole token.

Token presentation. The cookie is __Secure-mercure_access_token (was mercureAuthorization); override with cookie_name. The authorization query parameter is gone with no replacement — RFC 9700 forbids access tokens in URLs. Authorization: Bearer is unchanged and still wins over the cookie.

Authorization errors follow RFC 6750: no token where one is required gets 401 with a WWW-Authenticate: Bearer challenge carrying resource_metadata, an invalid token 401 invalid_token, a valid token without a grant 403 insufficient_scope (previously 401 or a silent drop), and a malformed request 400 invalid_request.

Subscription API and events.

  • The path gains a match-type segment: /.well-known/mercure/subscriptions/<match_type>/<match>/<subscriber>.
  • "topic" in the document becomes "match" plus "match_type".
  • The mercure.subscriber claim is gone; the hub assigns the subscriber identifier.
  • Documents are plain JSON served as application/json, not JSON-LD — no @context. type values are lowercased: Subscriptionsubscription, Subscriptionssubscriptions.
  • Documents serialize compactly, and <, >, & are no longer escaped as <, >, &. Both forms are the same JSON, so only a client string-matching raw bytes needs updating. A subscription event is now a single data: line.
  • Subscription events carry the SSE event: mercure field — route them with addEventListener("mercure", ...). Publishing an update whose type is mercure is rejected with 400.

The Last-Event-ID response header is now Mercure-Last-Event-ID. The request header keeps its name.

Hub configuration.

  • Declare your token issuer with an issuer <id> { ... } block binding the iss your tokens carry to its publisher/subscriber verifier (jwt or jwks_uri). Required when JWT auth is on in modern mode; repeat it to trust several issuers with distinct keys.
  • publisher_jwt, subscriber_jwt, publisher_jwks_url and subscriber_jwks_url still parse but map to one implicit issuer usable only in compatibility mode. Setting one without protocol_version_compatibility is now a configuration error.
  • An unrecognized directive inside the mercure block is a configuration error instead of being ignored — a typo used to silently disable whatever it configured. Check your MERCURE_EXTRA_DIRECTIVES if the hub refuses to start.
  • uidebugger, and its UI moved from /.well-known/mercure/ui/ to /.well-known/mercure/debug/. demoplayground, whose echo endpoints moved out of the reserved hub namespace to /playground/ so they are valid subscribable topics.
  • use_forwarded_headers is removed — the hub derives its public URL from the request.
  • topic_selector_cachetopic_matcher_cache.
  • transport_url (deprecated since 0.17) is removed; use transport <name> { ... }.
  • The bundled dev.Caddyfile is removed: it was the default config plus playground. Run MERCURE_EXTRA_DIRECTIVES=playground instead — the Docker image and the chart's dev: true do it for you.
  • The official Caddyfile no longer redacts query parameters from logs or serves /healthz; both only mattered for 0.x clients. The guide shows how to restore them under compatibility mode.

The legacy non-Caddy server is removed. Deprecated since 0.11, when the Caddy module became the primary hub. Its configuration goes with it; there is no flag-for-flag migration because it is a different binary. Compatibility mode restores 0.x protocol behaviors on the Caddy hub only.

Go API. WithUIWithDebugger, WithDemoWithPlayground (with a new WithPlaygroundTokenFunc, INSECURE and EXPERIMENTAL). canReceive / canDispatch are replaced by the internal authorization-detail grant logic. NewHub no longer requires a resource identifier — it derives identity per request, resolving the origin from NewRequestOriginContext when an embedding server sets one. WithResourceIdentifier still pins a static value; WithPublicURLs restricts the hub to an allowlist.

🔁 Compatibility mode

protocol_version_compatibility 8 keeps 0.x clients working during the migration, gated behind two build tags: deprecated_topic (URI Template selectors, bare-string matcher claims, the /subscriptions/{topic} routes) and deprecated_claim (the legacy mercure claim, mercure.payload, the authorization query parameter, the mercureAuthorization cookie, and tokens without typ: at+jwt, aud, exp or a matching iss).

It weakens access-token validation, which is why the hub never turns it on by itself. Official binaries and Docker images ship both tags; custom builds must pass them to go build.

🐛 Bug Fixes

  • Pin the JWT algorithm allowlist in compatibility mode, closing an algorithm-confusion path on legacy tokens. by @dunglas in #1298
  • Expose Mercure-Last-Event-Id to cross-origin subscribers. Without it a fetch-based cross-origin subscriber cannot read the resumption cursor, so it cannot detect data loss when reconnecting. by @dunglas in #1385
  • Announce a subscription only once it exists, closing a window where the subscription API advertised a subscriber that could not yet receive updates. by @dunglas
  • Don't close a connection whose write deadline is near, which dropped long-lived subscribers early. by @dunglas
  • Don't sort the update's own topic slice while dispatching — a data race on a caller-owned slice. by @dunglas
  • Bolt: report earliest when the requested Last-Event-ID is missing, instead of silently starting from the newest update. by @dunglas in #1321
  • Bolt: cleanup_frequency ran the cleanup with the inverse probability, so a low value cleaned aggressively and a high one barely at all. by @dunglas
  • Chart: guard the httpRoute templates against null or absent values. by @dunglas in #1268

⚡ Performance and Internal Improvements

  • 19.5% smaller subscription SSE frames. Subscription documents serialize with encoding/json/v2 without indentation: every JSON newline used to cost a further data: prefix on the wire. by @dunglas in #1378
  • Go 1.27, with the standard library uuid package replacing github.com/gofrs/uuid. by @dunglas in #1378

📖 Documentation

🎤 Keynote tomorrow at API Platform Con

The opening keynote at API Platform Con is all about Mercure 1.0. Join us in Lille, France, or online on September 17 and 18, 2026. Grab your ticket!

Enterprise

Mercure Cloud and Mercure Enterprise 1.0 are generally available. The on-prem hub ships as image v1.0.0.

💖 New Contributors

Full Changelog: v0.24.2...v1.0.0

Don't miss a new mercure release

NewReleases is sending notifications on new releases.