[13.x] Add a Mercure broadcast driver - #61474
Conversation
| */ | ||
| protected function frankenPhpMercure(array $config) | ||
| { | ||
| if (! function_exists('mercure_publish')) { |
There was a problem hiding this comment.
This holds on every FrankenPHP build: the nomercure build defines mercure_publish() as a stub that fails with error 3, and a build without the mercure directive defines it as well, so a missing url resolves without error and the first broadcast throws a bare RuntimeException('No Mercure hub configured').
I don't know if you want to handle this case or if we consider FrankenPHP without mercure an exotic config?
There was a problem hiding this comment.
This is intended!
This tag is an exotic setup requiring a custom compilation of FrankenPHP.
There was a problem hiding this comment.
Alright and I guess there's no easy way to not declare at all the function when nomercure is used? Just out of curiosity, I think the current solution can be good enough
| } | ||
| } catch (JsonException $e) { | ||
| throw new BroadcastException(sprintf('Mercure error: %s.', $e->getMessage()), 0, $e); | ||
| } catch (MercureExceptionInterface $e) { |
There was a problem hiding this comment.
This wraps MercureExceptionInterface alone, but FrankenPhpHub::publish() calls mercure_publish() with no try/catch and that function throws a plain RuntimeException (No Mercure hub configured, Publish failed), which escapes broadcast() as something other than a BroadcastException. You could catch Throwable around the publish loop, or wrap the built-in hub's failures the way Hub::publish() wraps HTTP client errors.
There was a problem hiding this comment.
Not sure if it worth it (see previous comment).
| $this->mercurePublishClaims($config), | ||
| ); | ||
|
|
||
| return new Hub( |
There was a problem hiding this comment.
The cookie name, Secure flag and domain come from public_url (falling back to url) inside Authorization::createCookie() alone, so a plain http:// hub URL or an internal hostname such as https://mercure/.well-known/mercure resolves without error and then turns every broadcasting/auth call into a 500 (__Secure- cookie name over HTTP, or different second-level domain). You could validate those two conditions in createMercureDriver() so the failure points at the configuration.
|
|
||
| $user ??= $channelUser; | ||
|
|
||
| $result = $this->verifyUserCanAccessChannel($request, $normalizedChannelName); |
There was a problem hiding this comment.
A single denied channel rejects the whole batch, and the Echo connector treats a denial with no newcomers as a failure of the entire set and loops in reconnecting with backoff, so a user whose access to one channel is revoked mid-session loses every other channel until they leave the revoked one. You may want to return a per-channel result (cookie minted for the granted subset, 403 when nothing is granted) so the connector can evict the one denied channel.
|
|
||
| // 0 (the default) delegates the lifetime to the hub's token factory: | ||
| // "session.cookie_lifetime", or an hour when that setting is 0. | ||
| $publishExpiration = (int) (($config['publish_expiration'] ?? 0) * 60); |
There was a problem hiding this comment.
A publish_expiration under one minute truncates to 0 here and the < 0 guard passes, so WebTokenFactory reads it as the default lifetime (session.cookie_lifetime or one hour) while the subscribe side rejects the same truncation in createMercureDriver(). You could apply the same positive-seconds check when the value is set, and reserve 0 for the explicit default.
| } elseif (str_starts_with($channelName, 'presence-')) { | ||
| // A payload is scoped to its own authorization_details | ||
| // entry, so each presence channel gets its own grant. | ||
| $presenceGrants[] = new Grant([Grant::ACTION_SUBSCRIBE], [ |
There was a problem hiding this comment.
This ships the raw callback result as the presence payload, without the user_id that PusherBroadcaster and AblyBroadcaster add in validAuthenticationResponse(), and the Echo connector dedupes here() by JSON.stringify(payload), so two members whose callbacks return the same info collapse into one and a callback returning true yields a true member. It may be worth wrapping the result with the broadcasting identifier the way the other drivers do.
| return null; | ||
| } | ||
|
|
||
| $key = base64_decode($config['encryption_key'], true); |
There was a problem hiding this comment.
This decodes the raw value, so a key in the framework's base64: convention (what key:generate --show and APP_KEY use) fails the strict decode with the must be a base64-encoded 32-byte key message. You could accept the base64: prefix the way EncryptionServiceProvider::parseKey() does.
| * from colliding with other publishers sharing the hub, and lets two | ||
| * applications sharing one hub (and one JWT secret) stay apart. The | ||
| * default is a "laravel.alt" URL: ".alt" is reserved outside the DNS | ||
| * (RFC 9476), so the IRI is guaranteed non-resolvable and unsquattable. |
There was a problem hiding this comment.
You might want to add a link here, so IDEs can render and people can easily look it up:
| * (RFC 9476), so the IRI is guaranteed non-resolvable and unsquattable. | |
| * ([RFC 9476](https://www.rfc-editor.org/rfc/rfc9476.html)), so the | |
| * IRI is guaranteed non-resolvable and unsquattable. |
| * the hub inside a JWE; the routing envelope stays plaintext so a | ||
| * multiplexing subscriber can select the decryption key. | ||
| * | ||
| * @param array $channels |
There was a problem hiding this comment.
You can narrow this down if you want:
| * @param array $channels | |
| * @param string[] $channels |
| /** | ||
| * Build the hub topic of the given channel. | ||
| * | ||
| * Channel names are RFC 3986 encoded into a single path segment, so a |
There was a problem hiding this comment.
Same here:
| * Channel names are RFC 3986 encoded into a single path segment, so a | |
| * Channel names are [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986.html) | |
| * encoded into a single path segment, so a |
| /** | ||
| * Create an instance of the driver. | ||
| * | ||
| * @param array $config |
There was a problem hiding this comment.
You might type the config array:
| * @param array $config | |
| * @param array{topic_prefix?: string, client_events?: bool, public_url?: string, url?: string, publish_expiration?: non-negative-int, publish_algorithm?: 'HS256'|'HS384'|'HS512'|'ES256'|'ES384'|'ES512'|'RS256'|'RS384'|'RS512'|'PS256'|'PS384'|'PS512'|'EdDSA', algorithm?: 'HS256'|'HS384'|'HS512'|'ES256'|'ES384'|'ES512'|'RS256'|'RS384'|'RS512'|'PS256'|'PS384'|'PS512'|'EdDSA', publish_passphrase?: string, passphrase?: string, client_options?: array{auth_basic?: string|array{0: string, 1?: string}, auth_bearer?: string, query?: string[], headers?: iterable|string[]|string[][], body?: array|string|resource|\Traversable|\Closure, json?: mixed, user_data?: mixed, max_redirects?: int, http_version?: string, base_uri?: string, buffer?: bool|resource|\Closure(array<string, list<string>> $headers): (bool|resource), on_progress: callable(int, int, array): mixed, resolve?: string[], proxy?: string, timeout?: float, max_duration?: float, max_connect_duration?: float, bindto?: string, verify_peer?: bool, verify_host?: bool, cafile?: string, capath?: string, local_cert?: string, local_pk?: string, passphrase?: string, ciphers?: string, peer_fingerprint?: string|array<string, string>, capture_peer_cert_chain?: bool, crypto_method?: 9|17|33|65, extra?: array{use_persistent_connections: bool}}, cookie_name: string, subscribe_expiration?: int, subscribe_secret?: string, secret?: string, subscribe_algorithm?: string, subscribe_passphrase?: string, encryption_key?: string, claims?: array{aud?: string, iss?: string, client_id?: string}} $config |
or
| * @param array $config | |
| * @param array{ | |
| * topic_prefix?: string, | |
| * client_events?: bool, | |
| * public_url?: string, | |
| * url?: string, | |
| * publish_expiration?: non-negative-int, | |
| * publish_algorithm?: 'HS256'|'HS384'|'HS512'|'ES256'|'ES384'|'ES512'|'RS256'|'RS384'|'RS512'|'PS256'|'PS384'|'PS512'|'EdDSA', | |
| * algorithm?: 'HS256'|'HS384'|'HS512'|'ES256'|'ES384'|'ES512'|'RS256'|'RS384'|'RS512'|'PS256'|'PS384'|'PS512'|'EdDSA', | |
| * publish_passphrase?: string, | |
| * passphrase?: string, | |
| * client_options?: array{ | |
| * auth_basic?: string|array{0: string, 1?: string}, | |
| * auth_bearer?: string, | |
| * query?: string[], | |
| * headers?: iterable|string[]|string[][], | |
| * body?: array|string|resource|\Traversable|\Closure, | |
| * json?: mixed, | |
| * user_data?: mixed, | |
| * max_redirects?: int, | |
| * http_version?: string, | |
| * base_uri?: string, | |
| * buffer?: bool|resource|\Closure(array<string, list<string>> $headers): (bool|resource), | |
| * on_progress: callable(int, int, array): mixed, | |
| * resolve?: string[], | |
| * proxy?: string, | |
| * timeout?: float, | |
| * max_duration?: float, | |
| * max_connect_duration?: float, | |
| * bindto?: string, | |
| * verify_peer?: bool, | |
| * verify_host?: bool, | |
| * cafile?: string, | |
| * capath?: string, | |
| * local_cert?: string, | |
| * local_pk?: string, | |
| * passphrase?: string, | |
| * ciphers?: string, | |
| * peer_fingerprint?: string|array<string, string>, | |
| * capture_peer_cert_chain?: bool, | |
| * crypto_method?: 9|17|33|65, | |
| * extra?: array{use_persistent_connections?: bool} | |
| * }, | |
| * cookie_name: string, | |
| * subscribe_expiration?: int, | |
| * subscribe_secret?: string, | |
| * secret?: string, | |
| * subscribe_algorithm?: string, | |
| * subscribe_passphrase?: string, | |
| * encryption_key?: string, | |
| * claims?: array{aud?: string, iss?: string, client_id?: string} | |
| * } $config |
This would have to go to the other methods as well
| * API's importKey('jwk', ...). | ||
| * | ||
| * @param string $channel | ||
| * @return array |
There was a problem hiding this comment.
Here, you could type as well:
| * @return array | |
| * @return array{kty: string, k: string, alg: string, use: string} |
or
| * @return array | |
| * @return array{ | |
| * kty: 'EC'|'RSA'|'oct' | |
| * k: string | |
| * alg: 'A128CBC-HS256'|'A192CBC-HS384'|'A256CBC-HS512'|'A128GCM'|'A192GCM'|'A256GCM' | |
| * use: 'sig'|'enc' | |
| * } |
| * As recommended by the Mercure specification, updates are encrypted as JSON | ||
| * Web Encryption (RFC 7516) compact tokens and the keys are shared with | ||
| * subscribers out of band, as JSON Web Keys (RFC 7517) returned by the | ||
| * broadcasting auth endpoint; the hub is not involved in this exchange. |
There was a problem hiding this comment.
More links:
| * As recommended by the Mercure specification, updates are encrypted as JSON | |
| * Web Encryption (RFC 7516) compact tokens and the keys are shared with | |
| * subscribers out of band, as JSON Web Keys (RFC 7517) returned by the | |
| * broadcasting auth endpoint; the hub is not involved in this exchange. | |
| * Web Encryption ([RFC 7516](https://www.rfc-editor.org/rfc/rfc7516.html)) | |
| * compact tokens and the keys are shared with subscribers out of band, as | |
| * JSON Web Keys ([RFC 7517](https://www.rfc-editor.org/rfc/rfc7517.html)) | |
| * returned by thebroadcasting auth endpoint; the hub is not involved in | |
| * this exchange. |
| * RFC 9068 access tokens require "iss", "aud", and "client_id", so each | ||
| * defaults to a sensible identifier when not set explicitly (an empty | ||
| * string, e.g. from an unset .env value, counts as not set). |
There was a problem hiding this comment.
link again:
| * RFC 9068 access tokens require "iss", "aud", and "client_id", so each | |
| * defaults to a sensible identifier when not set explicitly (an empty | |
| * string, e.g. from an unset .env value, counts as not set). | |
| * [RFC 9068](https://www.rfc-editor.org/rfc/rfc9068.html) access tokens | |
| * require "iss", "aud", and "client_id", so each defaults to a sensible | |
| * identifier when not set explicitly (an empty string, e.g. from an | |
| * unset .env value, counts as not set). |
| * Built eagerly so a missing "web-token/jwt-library" package or a | ||
| * malformed key surfaces at driver resolution rather than mid-broadcast. |
There was a problem hiding this comment.
Maybe, here, too:
| * Built eagerly so a missing "web-token/jwt-library" package or a | |
| * malformed key surfaces at driver resolution rather than mid-broadcast. | |
| * Built eagerly so a missing "[web-token/jwt-library](https://packagist.org/packages/web-token/jwt-library)" | |
| * package or a malformed key surfaces at driver resolution rather | |
| * than mid-broadcast. |
As discussed with @taylorotwell and the team, here the PR introducing support for the upcoming 1.0 version of the Mercure real-time protocol for Broadcast. Companion PR for Echo: laravel/echo#549
Mercure runs over SSE, so there is no WebSocket server to operate. With FrankenPHP the built-in hub works with zero infrastructure: no
url, no publish secret, updates published in-process throughmercure_publish(). Any external hub works too.Features:
expires_inso the client can refresh it proactively.private-encrypted-*): per-channel AES-256-GCM keys derived with HKDF, delivered to authorized clients as JWKs through the auth response. The hub never sees plaintext or keys.topic_prefix(defaulthttps://laravel.alt/echo/, a non-resolvable RFC 9476.altURL), so several apps can share one hub and Laravel topics never collide with other publishers.Here is a demo app using this patch as well as the Echo patch: https://github.com/dunglas/laravel-mercure