From 6f3cb4a3526864a99f9a3c2c23388f938504cdd3 Mon Sep 17 00:00:00 2001 From: Maxime Date: Thu, 3 Sep 2026 15:28:46 +0200 Subject: [PATCH 1/2] feat: migrate to the Mercure 1.0 protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test server now runs mercure/caddy v1.0.0-alpha.3. The 2.8 builder ships Go 1.23.4 and cannot build it (requires go >= 1.26), so the builder moves to 2.11.4 — the Caddy version mercure/caddy pins — and both plugins are pinned explicitly: unpinned, xcaddy resolved mercure/caddy to the latest stable tag and silently gave 0.24.2. Caddyfile ported to modern mode, directive for directive: flag-form anonymous, `debugger` for the renamed `ui`, and an issuer block replacing the flat publisher_jwt/subscriber_jwt, which now work only under protocol_version_compatibility. resource_identifier is set because an identifier ending in /.well-known/mercure also becomes the base URL relative topics resolve against, and the topics here are rel="self" link values. Client subscribes with `match` instead of the removed `topic` parameter. The tracked topics are those same relative link values, so the exact matcher is the right semantics; match_urlpattern is not exposed yet. Publisher fixtures reminted as RFC 9068 access tokens: typ at+jwt, iss, aud, exp and authorization_details. The legacy `mercure` claim and typ JWT are both rejected in modern mode. --- Caddyfile | 26 ++++++++++++++++++++++---- Dockerfile | 8 +++++--- packages/mercure/mercure.ts | 2 +- tests-server/github.html | 2 +- tests-server/mercure.html | 2 +- tests/mercure.spec.ts | 6 +++--- 6 files changed, 33 insertions(+), 13 deletions(-) diff --git a/Caddyfile b/Caddyfile index aeb7a79..7d16573 100644 --- a/Caddyfile +++ b/Caddyfile @@ -43,8 +43,26 @@ handle / { } mercure { - publisher_jwt key - subscriber_jwt key - anonymous true - ui true + anonymous + + # Mercure 1.0 modern mode: access tokens are RFC 9068 JWTs bound to an issuer + # and to this hub's resource identifier. An identifier ending in + # /.well-known/mercure doubles as the base URL relative topics resolve + # against, which is what our rel="self" link values are. + resource_identifier https://localhost/.well-known/mercure + issuer https://localhost { + publisher { + jwt key HS256 + } + subscriber { + jwt key HS256 + } + } + + # INSECURE: dev only. Serves the hub's debugger UI at + # /.well-known/mercure/debug/. This is the 1.0 name of the former "ui" + # directive. Not "playground": that one also forces cors_origins and + # publish_origins to "*" and drops the cookie name prefix, none of which this + # same-origin test server needs. + debugger } diff --git a/Dockerfile b/Dockerfile index 93a57db..e1f2fa7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,9 @@ -FROM caddy:2.8-builder AS builder +FROM caddy:2.11.4-builder AS builder -RUN xcaddy build --with github.com/dunglas/mercure/caddy --with github.com/dunglas/vulcain/caddy +RUN xcaddy build \ + --with github.com/dunglas/mercure/caddy@v1.0.0-alpha.3 \ + --with github.com/dunglas/vulcain/caddy@v1.4.3 -FROM caddy:2.8 AS app_server +FROM caddy:2.11.4 AS app_server COPY --from=builder /usr/bin/caddy /usr/bin/caddy diff --git a/packages/mercure/mercure.ts b/packages/mercure/mercure.ts index 1a5e941..5a2ddfc 100644 --- a/packages/mercure/mercure.ts +++ b/packages/mercure/mercure.ts @@ -26,7 +26,7 @@ function listen(mercureUrl: string, options: Options = {}) { const url = new URL(mercureUrl) topics.forEach((_, topic) => { - url.searchParams.append('topic', topic) + url.searchParams.append('match', topic) }) const headers: {[key: string]: string} = options.headers || {} diff --git a/tests-server/github.html b/tests-server/github.html index fdad921..637b48d 100644 --- a/tests-server/github.html +++ b/tests-server/github.html @@ -74,7 +74,7 @@ // You can pass formData as a fetch body directly: fetch(form.action, { method: form.method, body: body.toString(), headers: { 'Content-Type': 'application/x-www-form-urlencoded', - 'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXJjdXJlIjp7InN1YnNjcmliZSI6WyIqIl0sInB1Ymxpc2giOlsiKiJdfX0.NXhzhXJ8VTxiRRW3pAB4EgP7s_guZeibwzAGw3wZ_KY' + 'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6ImF0K2p3dCJ9.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdCIsImF1ZCI6Imh0dHBzOi8vbG9jYWxob3N0Ly53ZWxsLWtub3duL21lcmN1cmUiLCJzdWIiOiJlc2EtdGVzdC1zZXJ2ZXIiLCJjbGllbnRfaWQiOiJlc2EtdGVzdC1zZXJ2ZXIiLCJpYXQiOjE3ODg0MjU1MDUsImV4cCI6NDEwMjQ0NDgwMCwiYXV0aG9yaXphdGlvbl9kZXRhaWxzIjpbeyJ0eXBlIjoiaHR0cHM6Ly9tZXJjdXJlLnJvY2tzL2F1dGhvcml6YXRpb24tZGV0YWlsIiwiYWN0aW9ucyI6WyJwdWJsaXNoIl0sInRvcGljcyI6W3sibWF0Y2giOiIqIn1dfV19.825udJ6bE3p1HpZM_1QZ83DVpOTtuvGE_cQvJby7d0k' } }); } diff --git a/tests-server/mercure.html b/tests-server/mercure.html index d162ad8..5325d7e 100644 --- a/tests-server/mercure.html +++ b/tests-server/mercure.html @@ -53,7 +53,7 @@ // You can pass formData as a fetch body directly: fetch(form.action, { method: form.method, body: body.toString(), headers: { 'Content-Type': 'application/x-www-form-urlencoded', - 'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXJjdXJlIjp7InN1YnNjcmliZSI6WyIqIl0sInB1Ymxpc2giOlsiKiJdfX0.NXhzhXJ8VTxiRRW3pAB4EgP7s_guZeibwzAGw3wZ_KY' + 'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6ImF0K2p3dCJ9.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdCIsImF1ZCI6Imh0dHBzOi8vbG9jYWxob3N0Ly53ZWxsLWtub3duL21lcmN1cmUiLCJzdWIiOiJlc2EtdGVzdC1zZXJ2ZXIiLCJjbGllbnRfaWQiOiJlc2EtdGVzdC1zZXJ2ZXIiLCJpYXQiOjE3ODg0MjU1MDUsImV4cCI6NDEwMjQ0NDgwMCwiYXV0aG9yaXphdGlvbl9kZXRhaWxzIjpbeyJ0eXBlIjoiaHR0cHM6Ly9tZXJjdXJlLnJvY2tzL2F1dGhvcml6YXRpb24tZGV0YWlsIiwiYWN0aW9ucyI6WyJwdWJsaXNoIl0sInRvcGljcyI6W3sibWF0Y2giOiIqIn1dfV19.825udJ6bE3p1HpZM_1QZ83DVpOTtuvGE_cQvJby7d0k' } }); } diff --git a/tests/mercure.spec.ts b/tests/mercure.spec.ts index 84c6829..c4e8d90 100644 --- a/tests/mercure.spec.ts +++ b/tests/mercure.spec.ts @@ -10,15 +10,15 @@ test('mercure', async ({ page }) => { num++ } - if (request.url().startsWith('https://localhost/.well-known/mercure?topic=%2Fauthors%2F1')) { + if (request.url().startsWith('https://localhost/.well-known/mercure?match=%2Fauthors%2F1')) { requestedMercure = true } - if (request.url().startsWith('https://localhost/.well-known/mercure?topic=%2Fauthors%2F1&topic=%2Fauthors%2F2')) { + if (request.url().startsWith('https://localhost/.well-known/mercure?match=%2Fauthors%2F1&match=%2Fauthors%2F2')) { subscribedToBoth = true } - if (request.url().startsWith('https://localhost/.well-known/mercure?topic=%2Fauthors%2F2')) { + if (request.url().startsWith('https://localhost/.well-known/mercure?match=%2Fauthors%2F2')) { unsubscribedAuthor1 = true } }) From aa5e68f676fc32ee9e9de09edddb276c4ddf2d7b Mon Sep 17 00:00:00 2001 From: Maxime Date: Tue, 8 Sep 2026 16:44:57 +0200 Subject: [PATCH 2/2] fix(mercure): send the resume cursor as a query parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every listen() opens a fresh connection, so the id of the last update has to travel with the request. The client only set the Last-Event-Id header, which a native EventSource cannot do — resuming worked solely because of the npm eventsource polyfill, even though the README invites callers to pass their own EventSource. The spec is explicit that the hub "MUST then take the union" of the query and body components and that this "applies to the topic matcher parameters and to last_event_id ... alike", so the cursor now goes in the query too. The header is still sent, for implementations that support it. --- packages/mercure/README.md | 4 ++++ packages/mercure/mercure.ts | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/packages/mercure/README.md b/packages/mercure/README.md index a1e67e7..99b3c31 100644 --- a/packages/mercure/README.md +++ b/packages/mercure/README.md @@ -54,6 +54,10 @@ Available options: This can be used in conjunction with [@api-platform/ld](/linked-data) as the `fetchFn`. +### Resuming after a disconnection + +The id of the last update received is kept and sent back when a subscription is rebuilt, as both the `last_event_id` query parameter and the `Last-Event-Id` request header. The query parameter is what makes this work with a native `EventSource`, which cannot set headers. + ### Examples See [our Tanstack query example](https://github.com/api-platform/esa/blob/main/tests-server/mercure.html) or the source code of our [home page](https://github.com/api-platform/esa/blob/main/api/public/index.js). diff --git a/packages/mercure/mercure.ts b/packages/mercure/mercure.ts index 5a2ddfc..8d3428a 100644 --- a/packages/mercure/mercure.ts +++ b/packages/mercure/mercure.ts @@ -31,6 +31,15 @@ function listen(mercureUrl: string, options: Options = {}) { const headers: {[key: string]: string} = options.headers || {} if (lastEventId) { + // Every call here opens a fresh connection, so the cursor has to travel + // with the request. A native EventSource cannot set headers, hence the + // query parameter: the hub takes the union of the query and body + // components, and last_event_id is single-valued. The header is sent too, + // for EventSource implementations that support it and for the automatic + // reconnections they perform on their own. + url.searchParams.append('last_event_id', lastEventId) + // The request header keeps its name in 1.0; only the hub's response header + // was renamed to Mercure-Last-Event-ID. headers['Last-Event-Id'] = lastEventId }