Search by

raneomik / nette-mercure

raneomik

🚀 Nette Mercure Extension: nette wrapper around https://github.com/symfony/mercure

Package info

github.com/raneomik/nette-mercure

pkg:composer/raneomik/nette-mercure

Statistics

Installs: 17

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.5.0 2026-10-31 00:48 UTC

This package is auto-updated.

Last update: 2026-10-31 00:51:22 UTC


README

.github/workflows/ci.yml .github/workflows/coverage.yml Mutation Testing codecov Mutation testing badge

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

Register the extension in your config file:

extensions:
    mercure: Raneomik\NetteMercure\Bridge\DI\MercureExtension(%debugMode%)

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' # URL used by the server to publish updates
	publicUrl: 'https://example.com/.well-known/mercure' # Optional, defaults to "url". URL used by browsers (discovery, subscription, cookie)
	protocolVersion: '1.0' # Optional, '1.0' (default) or '0.x' (legacy). @see Symfony\Component\Mercure\ProtocolVersion
	jwt:
		# Signing key, never part of the token. "key: <secret>" is a shorthand for "key: {secret: <secret>}"
		key:
			secret: n3tt3-m3rcµr3-fr4nk3nphP-jwT-s3cr3t-k3y # HMAC secret (at least 32 characters long) or asymmetric private key
			passphrase: # Optional, for an encrypted asymmetric private key
			algorithm: HS256 # Optional, defaults to HS256 with WebTokenFactory, hmac.sha256 with LcobucciFactory
		publish: ['test-topic'] # Optional, default is ['*']. Topics to narrow in JWT validation.
		subscribe: ['test-topic'] # Optional, default is ['*']. Topics to narrow in JWT validation (1.0 also accepts matchers, e.g. {urlpattern: ['/books/:id']}).
		# Optional, only for custom Symfony\Component\Mercure\Jwt\TokenFactoryInterface implementations.
		# Use protocolVersion to switch between built-in factories: '1.0' → WebTokenFactory, '0.x' → LcobucciFactory.
		factory:
		# Optional, claims added to every token. With protocol 1.0, the RFC 9068 "iss", "aud", "sub" and "client_id" claims are required
		# and defaults on publicUrl, fallbacked on url, and derived values: set them for production.
		claims:
			iss: https://example.com #
			aud: https://example.com/.well-known/mercure
			sub: https://example.com
			clientId: nette-app # "client_id" claim
			additional: # Optional, any other claim
				tenant: acme
		lifetime: # Optional, 0 by default (session.cookie_lifetime, or 3600 seconds), null for non expiring tokens. Same value for the publisher token, the subscriber tokens and the cookie (session cookie with null).
    # following option depends on request parameter "hub" (or "hubName") if several hubs are defined in configuration
    autoDiscovery: # false 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:
			key: n3tt3-m3rcµr3-fr4nk3nphP-jwT-s3cr3t-k3y

	two
		url: 'https://hub2.mercure.dev/.well-known/mercure'
		jwt:
			key: n3tt3-m3rcµr3-fr4nk3nphP-jwT-s3cr3t-k3y

	# ...

Hub implementation is picked at runtime: when the FrankenPHP built-in Mercure hub is available (mercure_publish() function), updates are published through it (Symfony\Component\Mercure\FrankenPhpHub), otherwise through HTTP to url (Symfony\Component\Mercure\Hub). The same compiled container thus works in FrankenPHP workers and in CLI commands, as long as url is reachable from the CLI.

Publish messages

use Raneomik\NetteMercure\BroadcasterInterface;
use Raneomik\NetteMercure\Core\Publish\Latte\TurboStreamAction;

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' => TurboStreamAction::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, array $options = []) 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
     * - "lastEventId" option asks the hub for the events missed since this one
     */
    const eventSource = new EventSource({mercure('test-topic', hub: 'hubName')});

    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 $subscribe = ['*'], array $additionalClaims = [], ?string $hub = null) function to render mercure JWT token
            'Authorization': 'Bearer ' + {mercureJWTToken('test-topic')}
        }
    });

    es.onmessage = event => {
        for (const container of containers) {
            container.textContent = event.data;
        }
    }
</script>

Subscribe using the authorization cookie

As with symfony/mercure, the app sets the cookie for the topics the current user may read (https://mercure.rocks/spec#cookie). Nette streams the template while rendering it: set the cookie in the presenter, before rendering.

use Raneomik\NetteMercure\Core\Subscribe\AuthorizationInterface;

final class ChatPresenter extends Nette\Application\UI\Presenter
{
    public function __construct(
        private readonly AuthorizationInterface $authorization,
    ) {
    }

    public function actionDefault(int $id): void
    {
        // check that the user may read this chat first: the cookie grants what you pass here
        $this->authorization->setCookie(["/chats/{$id}"]); // hub: 'hubName' if several hubs
    }

    public function actionLogout(): void
    {
        $this->authorization->clearCookie();
        // ...
    }
}
<script>
    const eventSource = new EventSource({mercure('/chats/' . $id)}, { withCredentials: true });
</script>

The hub public URL must be served over HTTPS with protocol 1.0 ("__Secure-mercure_access_token" cookie, "mercureAuthorization" with legacy), and on the same site as the app. The cookie is "SameSite=Strict" by default.

Subscribe using discovery

You can subscribe to specific topic(s) and hub using Discovery mechanism.

  • Setup a /subscribe endpoint :

$topics comes from the visitor: the token only grants "subscribe", but to any requested topic. Check that the current user may read them before calling subscribe().

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';

// hub: hub name if several hubs are configured, first one by default
const response = await fetch('/subscribe?topics[]=/chats/1&topics[]=/chats/2', {
    headers: {'X-Requested-With': 'XMLHttpRequest'},
});
// {jwtToken, hubUrl, subscribeUrl, expiresAt, jwtExtraClaims}
// subscribeUrl: hubUrl with the "match" (protocol 1.0) or "topic" (legacy) query, as built by {mercure()}
const {jwtToken, subscribeUrl} = await response.json();

const es = new EventSourcePolyfill(subscribeUrl, {
    headers: {'Authorization': `Bearer ${jwtToken}`},
});

es.onmessage = event => {
    console.log(event.data);
};

Broadcast & Subscribe to turbo-streams

You can also subscribe to turbo-streams :

NB: turbo needs mercure's <Link> headers - enable it using autoDiscovery: yes hub config

  • 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\TurboStreamAction for available action blocks */
				'action' => TurboStreamAction::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
    Turbo.session.drive = false; // disable turbo-drive to avoid conflicts with naja forms

    const eventSource = new EventSource($mercureUrl);

    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();
    };

Development mode

Frankenphp HotReload feature requires "anonymous" mercure configuration. Tracy bar Live mercure event listening follows this requirement.

Debug bus

An SSE event only carries its type, id and data: topics, visibility, hub name or Turbo Stream action never reach the subscribers. On every hub with debugger: true, each publication is therefore also announced, with its metadata, on the public topic _debug/mercure. The Tracy panel listens to that single topic and shows, for any publisher (any tab, any request): hub, topics, private, SSE type, Turbo action and target, template, size in bytes and a preview of the data.

If you restrict the publish grants of a hub, allow the _debug/mercure topic too, otherwise the announcements are silently dropped (they never break a real publication).

Observing the page itself

The Tracy bar installs a small hook, before the deferred / module scripts of your page run. It observes every connection opened by the page (connecting / open / reconnecting / closed, with the events received, their size and the latency since publication) and what Turbo does with each stream (including "target not found"). Nothing to add to your layout.

Two limits:

  • a classic script opening an EventSource while the page is still being parsed runs before the hook and is not seen;
  • a global patch cannot see an EventSource polyfill imported as a module: register it right after its creation.
window.__mercureDebug?.track(eventSource, 'turbo');

Naja events (success, error) are reported too when the instance is exposed (window.naja = naja).

Resources

Known issues

  • "anonymous" option for mercure in Caddy configuration seems to work only with Symfony\Mercure\FrankenPhpHub and the FrankenPHP built-in mercure_publish function. HttpClient shows errors such as "405 Method Not Allowed" in this case.

  • TODO