Skip to content

[Mercure] Add the component #28877

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 13 commits into from
Closed

[Mercure] Add the component #28877

wants to merge 13 commits into from

Conversation

dunglas
Copy link
Member

@dunglas dunglas commented Oct 15, 2018

Q A
Branch? master
Bug fix? no
New feature? yes
BC breaks? no
Deprecations? no
Tests pass? yes
Fixed tickets n/a
License MIT
Doc PR todo

Last week, I introduced the Mercure protocol as well as a reference implementation of a mercure hub.

Mercure allows to push updates from servers to clients, through a hub. It uses server-sent events, so it passes through all firewalls, works even with very old browsers, is super efficient when using HTTP/2 and is natively supported in all major browsers (no SDK required). Try the demo.

For instance, Mercure allows to automatically and instantly update all currently connected clients (web apps, mobile apps...) every time a resource is modified, created or deleted (e.g. when a POST request occurs).

This PR introduce a new component, allowing to use Mercure in Symfony projects in a very convenient way.

Minimal example

<?php

// dipatch an update from Symfony (for instance in a POST controller)
namespace App\Controller;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Mercure\Update;

class Publish
{
    public function __invoke(MessageBusInterface $bus)
    {
        // use doctrine to save an event
        $bus->dispatch(new Update(
            'http://example.com/foo/bar',
            'New content of the resource'
        ));

        return new Response('OK');
    }
}
// subscribe to updates client side, can be in a Twig template, in a Progressive Web App...
const eventSource = new EventSource('https://demo.mercure.rocks/subscribe?topic=http://example.com/foo/bar');

// The callback will be called every time an update is published
eventSource.onmessage = e => console.log(e); // do something with the payload, for instance update the view

Here we use the Messenger component to dispatch the update. It means that the request to the Mercure hub can be sent synchronously (by default), or asynchronously in a worker if you configure a transport such as RabbitMQ or Reddis.

The corresponding config:

# config
framework:
    mercure:
        hubs:
            default:
                url: 'https://demo.mercure.rocks/hub'
                jwt: '%env(MERCURE_JWT)%' # The publisher JWT, provided by the hub

Subscribing to several topics

Mercure allows to subscribe to several topics, and to topics matching a given pattern:

// client side
const hub = new URL('https://demo.mercure.rocks/hub');
hub.searchParams.append('topic', 'https://example.com/foo/{id}'); // Templated IRI, any topic matching it will be received
hub.searchParams.append('topic', 'https://example.com/another/resource');

const eventSource = new EventSource(hub);
eventSource.onmessage = e => console.log(e); // do something with the payload

Making the Hub auto-discoverable

Mercure can leverage the web linking RFC to advertise available hubs to the clients:

<?php

namespace App\Controller;

use Fig\Link\Link;
use Lcobucci\JWT\Builder;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;

class Discover extends AbstractController
{
    public function __invoke(Request $request)
    {
        $this->addLink($request, new Link('mercure', 'https://demo.mercure.rocks'));

        return $this->json(['@id' => 'http://example.com/foo/bar', 'availability' => 'https://schema.org/InStock']);
    }
}

In the previous example, the Symfony WebLink Component is used to generate the appropriate Link header. The hub can then be discovered using some lines of JS.

Requires #28875.

Authorization

Mercure also allows to securely dispatch updates only to users having a specific username, role or group... you name it. To do so, you just have to set a JSON Web Token containing the list of targets in a claim named mercureTargets. This token must be stored in a cookie named mercureAuthorization (the protocol also allows to transmit the JWT using OAuth, OpenID Connect, and any other transport).

<?php

namespace App\Controller;

use Fig\Link\Link;
use Lcobucci\JWT\Builder;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;

class Discover extends AbstractController
{
    public function __invoke(Request $request)
    {
        // ...
        $username = $this->getUser()->getUsername();
        $token = (new Builder())
            // set other JWT appropriate JWT claims, such as an expiration date
            ->set('mercure', ['subscribe' => [$username, 'another-target']]) // could also include the security roles
            ->sign(new Sha256(), '!UnsecureChangeMe!') // store the key in a parameter instead
            ->getToken();

        $response = new JsonResponse(['@id' => '/demo/books/1.jsonld','availability' => 'https://schema.org/InStock']);
        $response->headers->set('set-cookie', sprintf('mercureAuthorization=%s; path=/subscribe; secure; httponly; SameSite=strict', $token));

        return $response;
    }
}
<?php

namespace App\Controller;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Mercure\Update;

class Publish
{
    public function __invoke(MessageBusInterface $bus)
    {
        // use doctrine to save an event
        $id = $bus->dispatch(new Update(
            'http://example.com/foo/bar',
            'New content of the resource',
            ['a-username', 'a-group', 'anything-else'] // the targets authorized to receive this update
        ));

        return new Response($id, 200, ['Content-Type' => 'text/plain']);
    }
}

That's all! Only users having one of the specified targets will receive the update. In this example, lcobucci/jwt is used, but feel free to use any other library to generate the token.

To use the authorization feature, the hub and the Symfony app must be served from the same domain name (can be different subdomains).

@javiereguiluz
Copy link
Member

If this component is accepted, I wonder if we should rename it to a more generic name describing its purpose instead of using the name of the provider used. Laravel for example calls this "Broadcasting" and Rails calls it "ActionCable". Thanks!

@dunglas
Copy link
Member Author

dunglas commented Oct 15, 2018

@javiereguiluz actually, this is not an abstraction layer but an implementation of the protocol (like HttpFoundation implements the HTTP protocol or WebLink implements Web Linking).
It should indeed be possible to create publishers for other transports, but in this case it would be nice to identify which ones, to be sure we can implement them.

</xsd:complexType>

<xsd:complexType name="mercure_hub">
<xsd:attribute name="name" type="xsd:string" />
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that one should be marked as required, as it is the key in the map


<xsd:complexType name="mercure_hub">
<xsd:attribute name="name" type="xsd:string" />
<xsd:attribute name="url" type="xsd:string" use="required" />
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that one should not be required, as it is not required in all XML files being merged together (it is required in the merged config, not in each source config)

private $jwtProvider;
private $httpClient;

public function __construct(string $publishEndpoint, callable $jwtProvider, callable $httpClient = null)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest adding some phpdoc describing the expected signature of these callables.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possibly in the format used by Psalm and PHPStan IIRC which have the most chance becoming the standard.

@s7anley
Copy link

s7anley commented Oct 23, 2018

I'm just curious why Mercure should be part of Framework bundle? Why not integrate it by own bundle?

@dunglas
Copy link
Member Author

dunglas commented Oct 23, 2018

@s7anley I've no strong opinion about that. I added it to FrameworkBundle because it's what we do most of the time, but not all times (SecurityBundle, WebServerBundle...). Extracting it in a custom bundle will also allow to extract the library out of symfony/symfony the time Mercure becomes mature, so I'm 👍

@dunglas
Copy link
Member Author

dunglas commented Oct 28, 2018

The component, and the related bundle, are now available as standalone packages:

@dunglas dunglas closed this Oct 28, 2018
@nicolas-grekas nicolas-grekas modified the milestones: next, 4.2 Nov 1, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

9 participants