Skip to content

feat: upgrade to Mercure 1.0 - #2611

Open
dunglas wants to merge 10 commits into
mainfrom
mercure-1.0-alpha3
Open

dunglas wants to merge 10 commits into
mainfrom
mercure-1.0-alpha3

Conversation

@dunglas

@dunglas dunglas commented Aug 17, 2026

Copy link
Copy Markdown
Member

Upgrades github.com/dunglas/mercure and github.com/dunglas/mercure/caddy from v0.24.2 to v1.0.0, and adapts the integration to the protocol changes of draft-dunglas-mercure-08.

mercure_publish()

The hub validates updates before dispatching them, so the function now distinguishes the caller's mistakes from a failed publication:

  • a ValueError carrying the reason for a topic addressing the reserved /.well-known/mercure namespace or equal to *, an $id starting with # or equal to earliest, a $type equal to mercure, control characters, invalid UTF-8 $data, or no topic at all;
  • a RuntimeException with the message of the hub when the dispatch fails.

The negative values of $retry are also rejected, as the protocol only allows digits in that field.

An enum in frankenphp.h replaces the status codes go_mercure_publish() returned as bare integers. On the way, an array of topics that cannot be converted no longer reports No Mercure hub configured.

Hot reloading

The subscribe query parameter of the protocol is match, so $_SERVER['FRANKENPHP_HOT_RELOAD'] advertises /.well-known/mercure?match=<topic>. The topic parameter it replaces is only honored by a hub built with the deprecated_topic tag, which FrankenPHP doesn't set.

Hub configuration

Setting publisher_jwt or subscriber_jwt without protocol_version_compatibility is a configuration error now, because that mode also drops the required exp, the audience check, the at+jwt check and the issuer check. The keys are bound to a trusted issuer instead:

mercure {
	issuer {$MERCURE_TRUSTED_ISSUERS:https://localhost} {
		publisher {
			jwt {env.MERCURE_PUBLISHER_JWT_KEY} {env.MERCURE_PUBLISHER_JWT_ALG}
		}
		subscriber {
			jwt {env.MERCURE_SUBSCRIBER_JWT_KEY} {env.MERCURE_SUBSCRIBER_JWT_ALG}
		}
	}
}

php-server --mercure and the sample Caddyfile follow. MERCURE_TRUSTED_ISSUERS accepts several identifiers separated by commas or whitespace, all bound to the same keys.

FrankenPHP is built with the deprecated_topic and deprecated_claim tags of the hub, like the official Mercure binaries, so protocol_version_compatibility 8 accepts the 0.x tokens and topic= selectors during a migration. Static builds get the tags through static-php-cli: crazywhalecc/static-php-cli#1240.

Docs

docs/mercure.md and the Laravel Octane section of docs/laravel.md describe the 1.0 configuration. Octane writes each entry of its mercure array as a Caddyfile line, so the issuer block is passed as a multi-line value (verified with caddy adapt and a live hub). The Symfony example targets symfony/mercure 0.8, which ships ProtocolVersion::V1.

The translations of docs/mercure.md and docs/laravel.md still describe the 0.x configuration.

The other Go dependencies are updated on the way.

@alexandre-daubois alexandre-daubois left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know it's a draft so feel free close anything that was already planned to be fixed 馃檪

Comment thread docs/mercure.md Outdated
Comment thread mercure.go Outdated
Comment thread caddy/mercure.go Outdated
Comment thread mercure.go Outdated
Comment thread frankenphp.c Outdated
Comment thread mercure_test.go Outdated
Comment thread caddy/hotreload.go Outdated
Comment thread docs/mercure.md Outdated
Comment thread frankenphp.c Outdated
Publish-side validation is part of the protocol now: report what
Update.Validate() rejects (reserved topics and event types, forbidden
event IDs, invalid UTF-8 data) as a ValueError carrying the reason, and
the dispatch failures as a RuntimeException.

The subscribe query parameter becomes "match", so hot reloading advertises
its topic with it. The publisher_jwt and subscriber_jwt directives now
require the compatibility mode, which drops the audience, expiration and
issuer checks: bind the keys to a trusted issuer instead.
The publisher_jwt and subscriber_jwt directives only work in the compatibility
mode of the hub, which FrankenPHP is built without. Octane writes every entry
of the mercure array as a Caddyfile line, so the issuer block is passed as a
multi-line value.
Like the official Mercure binaries and images, ship the deprecated_topic and
deprecated_claim build tags so protocol_version_compatibility 8 can accept 0.x
tokens and topic selectors during a migration. The hub stays in modern mode
unless the directive is set.

Static builds go through static-php-cli, which sets the tags itself and needs
a matching change.
MERCURE_TRUSTED_ISSUERS is a list separated by commas or whitespace, each
identifier bound to the same publisher and subscriber keys.
@dunglas dunglas changed the title feat: upgrade to Mercure 1.0 alpha 3 feat: upgrade to Mercure 1.0 Sep 17, 2026
The caddy module only takes what Mercure 1.0.0 requires: a broader bump
outruns the versions Caddy 2.11.4 compiles against (automemlimit, cel-go).
The hub validates the update in Publish(), so the extra Validate() call is
dropped and its error is classified with errors.Is against the exported
sentinels, then raised through zend_argument_value_error() with the
position of the argument at fault. Non-string topics are rejected in C
with the standard TypeError wording instead of a Go type name.

Tests cover the missing hub, a failing transport, the invalid id and the
non-string topic cases.
symfony/mercure 0.8 requires the sub and client_id claims of RFC 9068 and
the Hub must be told the protocol version to write the 1.0 cookie.
@dunglas
dunglas marked this pull request as ready for review September 17, 2026 06:44
The protocol requires the hub to assign an ID to every update, so an empty
one is a transport bug rather than a case to handle.

@alexandre-daubois alexandre-daubois left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Congrats on the 1.0 release! 馃帀

Comment thread frankenphp.c

if (Z_TYPE_P(topics) == IS_ARRAY) {
zval *topic;
ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(topics), topic) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This tests Z_TYPE_P(topic) without a deref, so an array holding a reference such as ['a', &$b] is rejected, and because zend_zval_type_name() does deref the message reads must only contain strings, string given. You can ZVAL_DEREF(topic) before the check and handle IS_REFERENCE in goValue(), which GoPackedArray() needs to read the element.

Comment thread caddy/mercure.go
Key: mercureSubscriberJwtKey,
},
},
mercureCaddy.Mercure{Issuers: issuers},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neither PublicURLs nor ResourceIdentifier is set, so the aud the hub now requires comes from r.Host, and php-server builds a catch-all site whenever --domain is empty. It may be worth deriving public_urls from --domain, or pinning resource_identifier, so a client cannot pick the audience its own token is checked against.

Comment thread frankenphp.stub.php

/**
* @param string|string[] $topics
* @param string|string[] $topics The first topic is the canonical one, the others are alternate topics

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The stub hash recorded in frankenphp_arginfo.h is still 60f0d27c04f94d7b24c052e91ef294595a2bc421, while this file now hashes to 85fabd314ab885307e0032b9f7635fa13cc0c4b5, so the generated header is stale. Running gen_stub.php again would resync it before the next contributor regenerates and gets an unrelated diff.

# # MERCURE_TRUSTED_ISSUERS to the stable identifier of your token issuer
# # (your app's URL for self-issued tokens). Add more issuer blocks through
# # MERCURE_EXTRA_DIRECTIVES.
# issuer {$MERCURE_TRUSTED_ISSUERS:https://localhost} {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This takes a single identifier, so MERCURE_TRUSTED_ISSUERS="https://a https://b" expands to a stray token ahead of the block, while docs/mercure.md:59 documents the same variable as a comma or space separated list. Scoping that sentence to php-server, the one path that splits the value, would keep the two contracts apart.

Comment thread docs/mercure.md
Subscribers matching the canonical topic or any of the alternate topics receive the update.
The returned string is the ID of the update, to be used as `Last-Event-ID` to resume a stream.

`mercure_publish()` throws a `ValueError` when the update violates the protocol, notably when:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The list leaves out the two checks this change adds in C, a ValueError on a negative $retry and a TypeError on a $topics array holding a non-string, both pinned in mercure_test.go. Adding them would cover every rejection the function can raise.

golang.org/x/net v0.59.0 deprecates http2.Transport in favor of
http.Transport with Protocols, which staticcheck now flags.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants