Skip to content

[HttpClient] add EventSourceHttpClient to consume Server-Sent Events #36692

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

Merged
merged 1 commit into from
Aug 6, 2020

Conversation

soyuka
Copy link
Contributor

@soyuka soyuka commented May 4, 2020

Q A
Branch? master
Bug fix? no
New feature? yes
Deprecations? no
Tickets na
License MIT
Doc PR symfony/symfony-docs#...
First implementation This patch implements the [w3c Server-Sent Events specification](https://www.w3.org/TR/eventsource/#eventsource) on top of symfony's http client. It provides an `EventSource` class that allows you to interact of server sent events.

Comparing to the Javascript implementation, we won't be able to use the same API. Indeed, in php listeners need to be setup before we connect to the HTTP stream.
I'm not fond of adding a dependency to EventDispatcher from HTTP Client, therefore I'm all ears if you have better solutions.
About event parsing, I wanted to avoid using regular expression and it uses smart data split. Note that I had to concatenate an internal buffer and only handle the data when a newline is found to cover long chunks. This is an alternative to this react php eventsource. Note that this implementation is closer to the specification in some cases that are still to be covered by tests (retry, data:value without space after colon is valid etc.).

This is an implementation of the Server-Sent Events specification based on symfony's HTTP Client. After a few suggestions on the first implementation (see details above), I've implemented a chunk generator with this kind of API:

$client = new EventSourceHttpClient($client, 10);
$source = $client->connect('GET', "http://localhost:8080/events");
while($source) {
    foreach ($client->stream($source, 2) as $r => $chunk) {
        if ($chunk->isTimeout()) {
            dump([
                'timeout' => [
                    'retry' => 1 + count($r->getInfo('previous_info') ?? [])
                ],
            ]);
            continue;
        }
        if ($chunk->isLast()) {
            dump([
                'eof' => [
                    'retries' => count($r->getInfo('previous_info') ?? [])
                ],
            ]);
            $source = null;
            return;
        }

        dump($chunk);
    }
}

TODO:

  • validate implementation (don't use EventDispatcher ?, need to be implemented as stream instead of message)
  • default timeout value
  • implement retry/reconnection
  • tests (do test with super long chunk, retry, bad http content-type response)
  • update changelog
  • document

@stof
Copy link
Member

stof commented May 4, 2020

Is ->connect() blocking until the event source is over ? Or how does this work to keep running while checking for data ?

Both JS and ReactPHP have an event loop running, and the checks in the EventSource are happening in this event loop. But without an event loop, I'm not sure this will be as easy to use.

@soyuka
Copy link
Contributor Author

soyuka commented May 4, 2020

Is ->connect() blocking until the event source is over ?

For now it is yes. It just opens an http connection open and uses a stream to get the data. On timeout, it reconnects to that http connection.
An event loop would definitely be appropriate. Even without I see use cases though.

@nicolas-grekas
Copy link
Member

nicolas-grekas commented May 4, 2020

I would suggest a radically different approach, based on the same design as HttpClient: a chunk generator.

@nicolas-grekas nicolas-grekas added this to the next milestone May 4, 2020
@dunglas
Copy link
Member

dunglas commented May 4, 2020

You should implement exactly the algorithm described in the WHATWG spec (9.2.4 and 9.2.5): https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream

@dunglas dunglas closed this May 4, 2020
@dunglas dunglas reopened this May 4, 2020
@dunglas
Copy link
Member

dunglas commented May 4, 2020

I would suggest a radically different approach, based on the same design as HttpClient: a chunk generator.

I agree. It could be a class decorating HttpClient and will an API like this:

$response = $client->request('GET', 'https://demo.mercure.rocks/.well-known/mercure?topic=https://example.com');

if (200 !== $response->getStatusCode()) {
    throw new \Exception('...');
}

// messages implement Symfony\Contracts\HttpClient\EventInterface
$fileHandler = fopen('/ubuntu.iso', 'w');
foreach ($client->messages($response) as $message) {
    // https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Event_stream_format
    // https://github.com/symfony/mercure/blob/master/src/Update.php#L26
    $message->getData(); // string
    $message->getType(); // ?string
    $message->getId(); // ?string
    $message->getRetry(); // ?string

    // When you're done, close the connection with the server
    $response->cancel();
}

@soyuka soyuka force-pushed the feat/eventsource branch 2 times, most recently from de989de to b88c4ca Compare May 5, 2020 08:08

$es = new EventSourceHttpClient(HttpClient::create());

$response = $es->request('GET', 'http://localhost:8080/events');
Copy link
Member

Choose a reason for hiding this comment

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

We already have Vulcain in the CI to test HttpClient, you could add Mercure, it uses all the spec capabilities.

@soyuka soyuka force-pushed the feat/eventsource branch 3 times, most recently from addda43 to 9bb244e Compare May 5, 2020 11:19
Copy link
Member

@nicolas-grekas nicolas-grekas left a comment

Choose a reason for hiding this comment

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

Don't we want to be able to read a stream in a non-blocking way?
HttpClientInterface::stream() allows doing so by passing $timeout = 0.

@soyuka soyuka force-pushed the feat/eventsource branch from 746abe2 to c539207 Compare May 7, 2020 07:31
Copy link
Member

@nicolas-grekas nicolas-grekas left a comment

Choose a reason for hiding this comment

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

Really nice :)
Just minor comments on my side.

$this->reconnectionTime = $reconnectionTime;
}

public function connect($method, string $url, array $options = []): ResponseInterface
Copy link
Member

Choose a reason for hiding this comment

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

Would it make sense to have an interface for this one?
I'm not sure, just wondering.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't think so but let's gather opinions.

Copy link
Member

Choose a reason for hiding this comment

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

I'm also in favor of having a dedicated interface. The user may want to provide its own EventSource implementation (for instance, to support a server not respecting strictly the standard or something like that).

Copy link
Member

@nicolas-grekas nicolas-grekas Jul 15, 2020

Choose a reason for hiding this comment

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

I think I'd prefer to have the new interface in the component, not in the contracts.

@nicolas-grekas
Copy link
Member

What about adding an autowiring alias btw?

@soyuka
Copy link
Contributor Author

soyuka commented Jun 12, 2020

What about adding an autowiring alias btw?

Now I think having an interface would make sense :p. If we do so should we also add a configuration for the reconnectionTime? What about doing so in another PR?

@nicolas-grekas
Copy link
Member

Another PR works. We need to gain some experience to know what's the best way to consume this class.

}

if ($chunk->isLast()) {
yield $chunk;
Copy link
Member

Choose a reason for hiding this comment

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

is it possible for the last chunk to have a non-empty content ? If yes, shouldn't we recreate a new last chunk instead, excluding the content we processed for new chunks ?

Copy link
Member

Choose a reason for hiding this comment

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

also, is it possible to reach the last chunk with some remaining buffered content in the state ? and what happens in that case ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'd say that it's not really a problem and if the last chunk is partial content it won't be transformed as a ServerSentEvent chunk (which parses only full chunks). The full data will be retrieved later by using a Last-Event-Id and there's no need to process it here.

Copy link
Member

Choose a reason for hiding this comment

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

The last chunk is always empty so that's OK. About the remaining data in the buffer, L122 above ensures it's always emptied when the last chunk arrives.

], true));
}

public function request(string $method, string $url, array $options = []): ResponseInterface
Copy link
Member

Choose a reason for hiding this comment

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

I'm wondering whether it make sense for an EventSource implementation to still be a full HttpClient as well. Shouldn't it use a client inside it (a special one) instead ?

Also, will code using ->connect() receive other chunks than event ones ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Also, will code using ->connect() receive other chunks than event ones ?

Yes it does, full power to the end-user. As the API exposed here is rather low-level, having a full HttpClient makes things easier. Also, wrapping a special client inside a new class would add a rather useless class to this component in my opinion.

Copy link
Member

@nicolas-grekas nicolas-grekas Jun 12, 2020

Choose a reason for hiding this comment

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

Also, having this implement HttpClientInterface allows streaming both regular requests and event-streams at the same time.

@soyuka soyuka force-pushed the feat/eventsource branch from 522a4b7 to e923244 Compare June 13, 2020 16:08
@soyuka soyuka force-pushed the feat/eventsource branch 3 times, most recently from cb05755 to 25387a5 Compare July 16, 2020 08:21
@fabpot
Copy link
Member

fabpot commented Aug 6, 2020

@soyuka Can you work on a PR for the docs?

@fabpot fabpot force-pushed the feat/eventsource branch from 25387a5 to 12ccca3 Compare August 6, 2020 06:59
@fabpot
Copy link
Member

fabpot commented Aug 6, 2020

Thank you @soyuka.

@fabpot fabpot merged commit d66a0a7 into symfony:master Aug 6, 2020
javiereguiluz added a commit to symfony/symfony-docs that referenced this pull request Sep 23, 2020
This PR was merged into the master branch.

Discussion
----------

[HttpClient] Document EventSourceHttpClient

Documenting symfony/symfony#36692

Commits
-------

1bfa68f Document EventSourceHttpClient
@nicolas-grekas nicolas-grekas modified the milestones: next, 5.2 Oct 5, 2020
@fabpot fabpot mentioned this pull request Oct 5, 2020
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.