raneomik / nette-mercure
🚀 Nette Mercure Extension: nette wrapper around https://github.com/symfony/mercure
Installs: 5
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/raneomik/nette-mercure
Requires
- php: >=8.3
- ext-tokenizer: *
- lcobucci/jwt: ^5.6.0
- nette/http: ^2.0|^3.3
- symfony/mercure: ^0.7.2
Requires (Dev)
- latte/latte: ^3.1@dev
- nette/application: ^4.0@dev
- nette/bootstrap: ^3.3@dev
- nette/di: ^3.0
- nette/security: ^3.0
- nette/tester: ^2.5@dev
- phpro/grumphp: v2.x-dev
- phpstan/extension-installer: 1.4.x-dev
- phpstan/phpstan-nette: ^2.0@stable
- rector/rector: ^2.3.4
- symplify/easy-coding-standard: ^13.0
- tomasvotruba/type-coverage: dev-main
- tracy/tracy: ^2.8
README
Work In Progress
🚀 Nette Mercure Extension: wrapper for symfony/mercure to use Mercure in Nette framework
Mercure is a protocol allowing to push data updates to web browsers and other HTTP clients in a convenient, fast, reliable and battery-efficient way. It is especially useful to publish real-time updates of resources served through web APIs, to reactive web and mobile apps.
Getting Started
$ composer require raneomik/nette-mercure
Configuration
JWT options to set. Secret, publish & subscribe can be configured at jwt.io
# Configure one default Mercure hub (e.g.: hub is confgigured to be on same host in frankenphp environment) mercure: url: '%baseUrl%/.well-known/mercure' jwt: secret: n3tt3-m3rcµr3-fr4nk3nphP-jwT-s3cr3t-k3y # Must be at least 32 characters long publish: ['test-topic'] # Optional, default is ['*']. Topics to narrow in JWT validation. subscribe: ['test-topic'] # Optional, default is ['*']. Topics to narrow in JWT validation. algorithm: HS256 # Optional, default is HS256. @see Symfony\Component\Mercure\Jwt\LcobucciFactory::SIGN_ALGORITHMS # You can implement your own Symfony\Component\Mercure\Jwt\TokenFactoryInterface factory: # Optional, default is Symfony\Component\Mercure\Jwt\LcobucciFactory useQueryParam: # false by default, to use JWT token in "authorization" query parameter when using {mercure()} function (https://mercure.rocks/spec#uri-query-parameter) lifetime: # Optional, default is 3600 (1 hour). # following options depends on request parameters # - "hub" or "hubName" if several hubs are defined in configuration, # - "topics" # [- "claims" to pass addtional data to cookie, e.g.: "claims=[exp=1800]" to specificaly set cookie lifetime to 30 minutes] useCookie: # true by default, to set JWT token in cookie (https://mercure.rocks/spec#cookie). SSL/Https required client-side autoDiscovery: # true by default, to add Link header for Mercure hub discovery (https://mercure.rocks/spec#discovery) # several Mercure hubs mercure: one url: 'https://hub1.mercure.dev/.well-known/mercure' jwt: secret: n3tt3-m3rcµr3-fr4nk3nphP-jwT-s3cr3t-k3y two url: 'https://hub2.mercure.dev/.well-known/mercure' jwt: secret: n3tt3-m3rcµr3-fr4nk3nphP-jwT-s3cr3t-k3y # ...
Publish messages
use Raneomik\NetteMercure\BroadcasterInterface; use Raneomik\NetteMercure\Core\Publish\Latte\TurboStream\Action; final class SomeService { public function __construct( private BroadcasterInterface $broadcaster, ) { } public function someAction(): void { // ... // minimalist broadcast to default hub $this->broadcaster->broadcast( data: 'Hello Nette from Mercure!', // ['message' => 'message'] / new Class('message') topics: 'test-topic' // ['test-topic']), ); // broadcast to specific hub $this->broadcaster->broadcast( data: 'Hello Nette from Mercure!', topics: ['test-topic'], template: 'test.latte', // existing template options: [ 'hub' => 'two' // hub name defined in configuration and where to publish, default is first found hub ], ); // broadcast to all hubs $this->broadcaster->broadcast( data: 'Hello Nette from Mercure!', topics: ['test-topic'], template: 'test.stream.latte', options: [ 'action' => Action::Update // for turbo streams or block organisation in same template. Template must have Action blocks ], toAll: true, ); // ... } }
Subscribe to updates
Generate mercure url in Latte templates, setup your JavaScript client to listen to Mercure updates and render them in selected containers :
When working with JWT token in Authorisation Header, you may need a polyfill.
<div class="mercure-container"> Waiting for updates... </div> <script type="module"> /** * use mercure(array|string|null $topics, ?string $hub = null) function to render mercure URL. * - "hub" param defines the hub to subscribe to if multiple hubs are defined in configuration, default is first found hub * - "addJwt" option adds jwt token in query url and overrides "useQueryParam: false" (default) configuration option */ const eventSource = new EventSource({mercure('test-topic', hub: 'hubName', [addJwt => true])}); const containers = document.querySelectorAll('.mercure-container'); eventSource.onmessage = event => { for (const container of containers) { container.textContent = event.data; } } // or using polyfill with jwt token as Auth Bearer import { EventSourcePolyfill } from 'event-source-polyfill'; const es = new EventSourcePolyfill({mercure('test-topic')}, headers: { // use mercureJWTToken(array|string|null $subscribe = ['*'], array|string|null $publish = ['*'], ?string $hub = null) function to render mercure JWT token 'Authorization': 'Bearer: ' + {mercureJWTToken('test-topic')} } ); eventSource.onmessage = event => { for (const container of containers) { container.textContent = event.data; } } </script>
Subscribe using discovery
You can subscribe to specific topic(s) and hub using Discovery mechanism.
- Setup a
/subscribeendpoint :
use Nette; use Nette\Application\Attributes\Parameter; use Raneomik\NetteMercure\SubscriberInterface; final class SubscribePresenter extends Nette\Application\UI\Presenter { #[Parameter] public ?string $hub = null; #[Parameter] public string|array $topics = ['*']; public function __construct( private readonly SubscriberInterface $subscriber, ) { } public function renderDefault(): void { if (!$this->isAjax()) { return; } $this->sendJson( $this->subscriber->subscribe($this->hub, $this->topics), ); } }
- Setup the listening client :
import { EventSourcePolyfill } from 'event-source-polyfill'; fetch('/subscribe?topics=/* topic(s) to define. "['*']" by default */&hub=/* hubname if multiple hubs configured, first by default */') // Has header Link: </* your defined hub url */>; rel="mercure" .then(response => { // Extract the hub URL from the Link header const hubUrl = response.headers.get('Link').match(/<([^>]+)>;\s+rel=(?:mercure|"[^"]*mercure[^"]*")/)[1]; // Append the topic(s) to subscribe as query parameter const hub = new URL(hubUrl, window.origin); hub.searchParams.append('topic', /*topic(s)*/); const jwtToken = response.json().jwtToken; const es = new EventSourcePolyfill(hub, headers: { 'Authorization': `Bearer: ${jwtToken}` }; es.onmessage = event => { for (const container of containers) { container.textContent = event.data; } }; ); });
Broadcast & Subscribe to turbo-streams
You can also subscribe to turbo-streams :
- Server side :
//... $this->broadcaster->broadcast( data: 'Hello Nette from Mercure!', topics: ['test-topic'], //to activate "text/vnd.turbo-stream.html" content type and "turbo-stream" mercure event type to listen to, template name must end with ".stream.latte" / "Stream.latte" and have matching "action" blocks template: 'test.stream.latte', options: [ /** @see Raneomik\NetteMercure\Core\Publish\Latte\TurboStream\Action for available action blocks */ 'action' => Action::Update 'target' => 'stream-container' // target container id to update in client side. Default is "stream-container" ], ); //...
<!-- test.stream.latte template, near to the broadcaster call --> {contentType $contentType ?? 'text/html'} {block update} <turbo-stream action="update" target="{$target ?? 'stream-container'}"> <template> {$data} </template> </turbo-stream> {/block}
- Client side :
import * as Turbo from '@hotwired/turbo'; //npm install @hotwired/turbo const eventSource = new EventSource($mercureUrl); const containers = document.querySelectorAll('.mercure-container'); eventSource.addEventListener('turbo-stream', event => { Turbo.renderStreamMessage(event.data); }); eventSource.onerror = event => { console.error("Mercure connection error: ", event); Turbo.disconnectStreamSource(eventSource); } document.onclose = () => { console.info("Bye !"); Turbo.disconnectStreamSource(eventSource); eventSource.close(); }; }
Resources
- Based on symfony/mercure / Documentation
Mercure
FrankenPHP Real-time and Hot-Reload
Nette,
Latte,
Tester,
Tracy
Known issues
-
"anonymous" option for mercure in Caddy configuration seems to work only with Symfony\Mercure\FrankenPhpHub and the FrankenPHP built-in
mercure_publishfunction. HttpClient shows errors such as "405 Method Not Allowed" in this case.