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.
$ composer require nette/mercure
JWT options to set. Secret, publish & subscribe can be configured at jwt.io
# Configure one default Mercure hub (default hub 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)
# following options depends on request parameters "hub" or "hubName" if several hubs are defined in configuration, and "topics" (and "additionnalClaims", specificaly for cookie)
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
# ...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,
);
// ...
}
}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>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;
}
};
);
});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();
};
}- Based on symfony/mercure / Documentation
Mercure
FrankenPHP Real-time and Hot-Reload
Nette,
Latte,
Tester,
Tracy
-
"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.