symfony / ux

Symfony UX initiative: a JavaScript ecosystem for Symfony
https://ux.symfony.com/
MIT License
853 stars 315 forks source link

[Turbo][Mercure] Allow turbo_stream_listen() to subscribe multiple topics in single connection #213

Open JanMikes opened 2 years ago

JanMikes commented 2 years ago

Hi! First thanks for turbo and mercure integration, i am playing with it for last several days and it is awesome!

I am struggling with finding a way, how to subscribe to multiple topics using turbo_stream_listen() in twig and after some digging around, it seems that it is not currently supported.

I can do it this way, but i would love to avoid writing javascript in twig (as documented in https://symfony.com/doc/current/mercure.html#subscribing):

<script>
const eventSource = new EventSource("{{ mercure([
    'a',
    'b'
])|escape('js') }}");
</script>

But by doing this i am loosing a lot of comfort.

What i tried:

<div {{ turbo_stream_listen(['a', 'b']) }}></div>  {# Ends with error #}
<div {{ turbo_stream_listen('a,b']) }}></div>  {# Subscribes to single "a,b" topic #}
<div {{ turbo_stream_listen('a&topic=b') }}></div>  {# Subscribes to single "a&topic=b" topic #}

That a&topic=b variant was my favourite that it would work, but unfortunately it escapes the & so final URL is like this, which is why it is treated as single topic:

URL: http://localhost:8080/.well-known/mercure?topic=a%26topic%3Db

What i am actually trying to achieve is this hub url with single turbo_stream_listem():

http://localhost:8080/.well-known/mercure?topic=a&topic=b

My current solution is to:

<div {{ turbo_stream_listen('a') }}></div>
<div {{ turbo_stream_listen('b') }}></div>

Which results into two connections to hub and i believe it would be more efficient to have one connection with two subscriptions.

I could probably solve this by writing my own stimulus controller (inspired by turbo_stream_controller.js).

My idea is to update Symfony\UX\Turbo\Bridge\Mercure\TurboStreamListenRenderer

public function renderTurboStreamListen(Environment $env, $topic): string
    {
+        if (\is_array($topic)) {
+            // .. some logic here :-)
+        }

         if (\is_object($topic)) {
carsonbot commented 6 months ago

Thank you for this issue. There has not been a lot of activity here for a while. Has this been resolved?

JanMikes commented 6 months ago

Hi, afaik this issue (feature request) is still valid.

seb-jean commented 6 months ago

This is normal because the stimulus controller does not allow you to add several topics: https://github.com/symfony/ux/blob/416753fcdcd0c9b4a88b613239eeefe32583f8d9/src/Turbo/assets/src/turbo_stream_controller.ts#L36

It would then be necessary to modify the stimulus controller to be able to loop over several topics.

And

https://github.com/symfony/ux/blob/416753fcdcd0c9b4a88b613239eeefe32583f8d9/src/Turbo/assets/src/turbo_stream_controller.ts#L18

must become

topics: Array,

smnandre commented 6 months ago

But could you add several controllers ? (and that would be safer i guess)

seb-jean commented 6 months ago

@smnandre How so ? I don't understand.

JanMikes commented 6 months ago

@seb-jean i guess the idea is that twig function renderTurboStreamListen(Environment $env, $topic) would accept string|array and based on that would put into template different stimulus controller -> one with support for single topic, the other for multiple.

seb-jean commented 6 months ago

Why not!

seb-jean commented 6 months ago

@JanMikes do you think you can create a PR?

seb-jean commented 6 months ago

I looked on the Mercure site and you have to do this to add multiple topics at once:

// The subscriber subscribes to updates
// for the https://example.com/foo topic, the bar topic,
// and to any topic matching https://example.com/books/{name}
const url = new URL('https://example.com/.well-known/mercure');
url.searchParams.append('topic', 'https://example.com/foo');
url.searchParams.append('topic', 'bar');
url.searchParams.append('topic', 'https://example.com/bar/{id}');

const eventSource = new EventSource(url);

// The callback will be called every time an update is published
eventSource.onmessage = function ({data}) {
    console.log(data);
};

Source: https://mercure.rocks/spec#subscription

Which means that we could then loop on the following line to add several topics: https://github.com/symfony/ux/blob/2.x/src/Turbo/assets/src/turbo_stream_controller.ts#L36C9-L36C57

When do you think ? 😃

seb-jean commented 1 month ago

For renderTurboStreamListen function, https://github.com/symfony/ux/blob/3551bc2f664f89f8af8bcd0b5dee31916f2e6b9c/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php#L45-L67

I thought about this:

public function renderTurboStreamListen(Environment $env, $topics): string
{
    $topicsValue = [];

    foreach ($topics as $topic) {
        if (\is_object($topic)) {
            $class = $topic::class;

            if (!$id = $this->idAccessor->getEntityId($topic)) {
                throw new \LogicException(\sprintf('Cannot listen to entity of class "%s" as the PropertyAccess component is not installed. Try running "composer require symfony/property-access".', $class));
            }

            $topicsValue[] = \sprintf(Broadcaster::TOPIC_PATTERN, rawurlencode($class), rawurlencode(implode('-', $id)));
        } elseif (!preg_match('/[^a-zA-Z0-9_\x7f-\xff\\\\]/', $topic) && class_exists($topic)) {
            // Generate a URI template to subscribe to updates for all objects of this class
            $topicsValue[] = \sprintf(Broadcaster::TOPIC_PATTERN, rawurlencode($topic), '{id}');
        }
    }

    $stimulusAttributes = $this->stimulusHelper->createStimulusAttributes();
    $stimulusAttributes->addController(
        'symfony/ux-turbo/mercure-turbo-stream',
        ['topic' => $topicsValue, 'hub' => $this->hub->getPublicUrl()]
    );

    return (string) $stimulusAttributes;
}

For twig, it could be this:

<div {{ turbo_stream_listen(['topic-1', 'App\\Entity\\Book', 'book']) }}></div>

And then you just have to modify the turbo-stream data controller to loop over the topics.

What do you think?