Skip to content

feat: upgrade to Mercure 1.0 alpha 3 - #2611

Draft
dunglas wants to merge 2 commits into
mainfrom
mercure-1.0-alpha3
Draft

feat: upgrade to Mercure 1.0 alpha 3#2611
dunglas wants to merge 2 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-alpha.3, 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, and the access tokens using the 0.x mercure claim are rejected: FrankenPHP is built without the compatibility tags of the hub.

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.

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.

@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
$provider = new \Symfony\Component\Mercure\Jwt\FactoryTokenProvider(
$jwFactory,
[new \Symfony\Component\Mercure\Jwt\Grant([\Symfony\Component\Mercure\Jwt\Grant::ACTION_PUBLISH], ['*'])],
['iss' => 'https://localhost', 'aud' => 'https://localhost/.well-known/mercure'],

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 passes only iss and aud as additional claims, and LcobucciFactory in ProtocolVersion::V1 mode throws InvalidArgumentException from JwtClaims::buildAuthorizationDetails() when sub or client_id is missing, so $hub->publish() throws before sending anything. You should add sub and client_id to the claims array of the example.

Comment thread mercure.go
}

return nil, 1
return nil, C.CString(err.Error()), C.FRANKENPHP_MERCURE_INVALID_UPDATE

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 surfaces the raw GoPackedArray error, so mercure_publish(['foo', 1]) throws ValueError: cannot cast value of type int64 to type string, a Go type name with no Argument #1 ($topics) context. You could check the element types in C with zend_argument_type_error(1, ...) before crossing into Go, as the array|string check does.

Comment thread caddy/mercure.go

// The protocol requires access tokens to name their issuer, so the keys are
// bound to one trusted issuer instead of being set globally.
issuer := os.Getenv("MERCURE_TRUSTED_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.

No file under docs/ mentions this new variable, its https://localhost default or the new hard requirement on MERCURE_SUBSCRIBER_JWT_KEY, and the switch to RFC 9068 tokens means every publisher token that worked with php-server --mercure before the upgrade now fails on an untrusted issuer, a missing exp or a missing at+jwt type. It may be worth a paragraph in docs/mercure.md listing the variables --mercure reads and the claims a token needs.

Comment thread mercure.go

// The protocol constrains topics, id, type and data: report the violations
// as argument errors instead of as a failed publication.
if err := u.Validate(); err != nil {

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 runs Validate() a second time on every publish, because Hub.Publish() calls it first thing before dispatching, and one pass costs about 6 碌s and 185 allocations for two topics since addressesReservedNamespace() parses each topic as a WHATWG URL. You can drop this call and classify the Publish() error with errors.Is against the exported sentinels (ErrMissingTopic, ErrReservedTopic, ErrInvalidData, ...), which keeps the split in step with any rule the hub adds in a later alpha.

Comment thread frankenphp.c
case 2:
zend_throw_exception(spl_ce_RuntimeException, "Publish failed", 0);
case FRANKENPHP_MERCURE_INVALID_UPDATE:
zend_value_error("%s", result.r1);

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 raises the hub message bare, "/.well-known/mercure/subscriptions": topic value resolves into the reserved namespace, while the $retry check yields the standard mercure_publish(): Argument #6 ($retry) must be ... shape, so the two ValueErrors of one function read differently. The hub exports one sentinel per rule, so you could map them to an argument number on the Go side (topics 1, data 2, id 4, type 5) and raise through zend_argument_value_error().

Comment thread mercure_test.go
assert.Contains(t, body, "update 1: ")
assert.Contains(t, body, "update 2: ")
// Updates rejected by the protocol are reported as argument errors.
assert.Contains(t, body, `error 1: "/.well-known/mercure/subscriptions"`)

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.

These assertions cover the two ValueError paths only, and nothing in the suite hits FRANKENPHP_MERCURE_NO_HUB, FRANKENPHP_MERCURE_PUBLISH_FAILED, the nomercure build or the non-string array case at mercure.go:51, all of which this PR rewrote. A mercure_publish('foo') in a request without WithMercureHub and a mercure_publish([1]) in testdata/mercure-publish.php would cover two of them.

Comment thread caddy/hotreload.go

// add the hot reload to the env variables
// "match" is the exact topic matcher of the Mercure protocol, the "topic"
// parameter it replaces is only honored in compatibility mode.

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 overstates what compatibility mode does: without the deprecated_topic build tag, which FrankenPHP never sets, appendDeprecatedTopicMatchers() returns errTopicParamCompatNotSupported and the hub answers 400 to ?topic= in every mode. You could say the parameter needs a hub built with the deprecated_topic tag.

Comment thread docs/mercure.md
['iss' => 'https://localhost', 'aud' => 'https://localhost/.well-known/mercure'],
);

$hub = new \Symfony\Component\Mercure\Hub('https://localhost/.well-known/mercure', $provider);

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 constructs the Hub without protocolVersion: ProtocolVersion::V1, so it defaults to ProtocolVersion::Legacy and Authorization::setCookie() writes the 0.x mercureAuthorization cookie, which the 1.0 hub without deprecated_claim never reads. Publishing works either way, and the cookie-based subscriber authorization this section advertises is what breaks, so passing the version to the constructor keeps the example coherent.

Comment thread frankenphp.c
case 0:
switch (result.r2) {
case FRANKENPHP_MERCURE_OK:
if (result.r0 == NULL) {

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.

Isn't it dead code with the bundled transports?

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.

2 participants