Skip to content

Commit d66a0a7

Browse files
committed
feature #36692 [HttpClient] add EventSourceHttpClient to consume Server-Sent Events (soyuka)
This PR was squashed before being merged into the 5.2-dev branch. Discussion ---------- [HttpClient] add EventSourceHttpClient to consume Server-Sent Events | Q | A | ------------- | --- | Branch? | master | Bug fix? | no | New feature? | yes | Deprecations? | no | Tickets | na | License | MIT | Doc PR | symfony/symfony-docs#... <details> <summary>First implementation</summary> 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](https://github.com/clue/reactphp-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.). </details> This is an implementation of the [Server-Sent Events specification](https://html.spec.whatwg.org/multipage/server-sent-events.html) 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: ```php $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: - [x] validate implementation (~~don't use EventDispatcher ?~~, need to be implemented as `stream` instead of `message`) - [x] default timeout value - [x] implement retry/reconnection - [x] tests (do test with super long chunk, retry, bad http content-type response) - [ ] update changelog - [ ] document Commits ------- 12ccca3 [HttpClient] add EventSourceHttpClient to consume Server-Sent Events
2 parents 5e2abc6 + 12ccca3 commit d66a0a7

File tree

6 files changed

+502
-0
lines changed

6 files changed

+502
-0
lines changed

src/Symfony/Component/HttpClient/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ CHANGELOG
88
* added support for pausing responses with a new `pause_handler` callable exposed as an info item
99
* added `StreamableInterface` to ease turning responses into PHP streams
1010
* added `MockResponse::getRequestMethod()` and `getRequestUrl()` to allow inspecting which request has been sent
11+
* added `EventSourceHttpClient` a Server-Sent events stream implementing the [EventSource specification](https://www.w3.org/TR/eventsource/#eventsource)
1112

1213
5.1.0
1314
-----
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\HttpClient\Chunk;
13+
14+
use Symfony\Contracts\HttpClient\ChunkInterface;
15+
16+
/**
17+
* @author Antoine Bluchet <soyuka@gmail.com>
18+
* @author Nicolas Grekas <p@tchwork.com>
19+
*/
20+
final class ServerSentEvent extends DataChunk implements ChunkInterface
21+
{
22+
private $data = '';
23+
private $id = '';
24+
private $type = 'message';
25+
private $retry = 0;
26+
27+
public function __construct(string $content)
28+
{
29+
parent::__construct(-1, $content);
30+
31+
// remove BOM
32+
if (0 === strpos($content, "\xEF\xBB\xBF")) {
33+
$content = substr($content, 3);
34+
}
35+
36+
foreach (preg_split("/(?:\r\n|[\r\n])/", $content) as $line) {
37+
if (0 === $i = strpos($line, ':')) {
38+
continue;
39+
}
40+
41+
$i = false === $i ? \strlen($line) : $i;
42+
$field = substr($line, 0, $i);
43+
$i += 1 + (' ' === ($line[1 + $i] ?? ''));
44+
45+
switch ($field) {
46+
case 'id': $this->id = substr($line, $i); break;
47+
case 'event': $this->type = substr($line, $i); break;
48+
case 'data': $this->data .= ('' === $this->data ? '' : "\n").substr($line, $i); break;
49+
case 'retry':
50+
$retry = substr($line, $i);
51+
52+
if ('' !== $retry && \strlen($retry) === strspn($retry, '0123456789')) {
53+
$this->retry = $retry / 1000.0;
54+
}
55+
break;
56+
}
57+
}
58+
}
59+
60+
public function getId(): string
61+
{
62+
return $this->id;
63+
}
64+
65+
public function getType(): string
66+
{
67+
return $this->type;
68+
}
69+
70+
public function getData(): string
71+
{
72+
return $this->data;
73+
}
74+
75+
public function getRetry(): float
76+
{
77+
return $this->retry;
78+
}
79+
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\HttpClient;
13+
14+
use Symfony\Component\HttpClient\Chunk\ServerSentEvent;
15+
use Symfony\Component\HttpClient\Exception\EventSourceException;
16+
use Symfony\Component\HttpClient\Response\AsyncContext;
17+
use Symfony\Component\HttpClient\Response\AsyncResponse;
18+
use Symfony\Contracts\HttpClient\ChunkInterface;
19+
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
20+
use Symfony\Contracts\HttpClient\HttpClientInterface;
21+
use Symfony\Contracts\HttpClient\ResponseInterface;
22+
23+
/**
24+
* @author Antoine Bluchet <soyuka@gmail.com>
25+
* @author Nicolas Grekas <p@tchwork.com>
26+
*/
27+
final class EventSourceHttpClient implements HttpClientInterface
28+
{
29+
use AsyncDecoratorTrait;
30+
use HttpClientTrait;
31+
32+
private $reconnectionTime;
33+
34+
public function __construct(HttpClientInterface $client = null, float $reconnectionTime = 10.0)
35+
{
36+
$this->client = $client ?? HttpClient::create();
37+
$this->reconnectionTime = $reconnectionTime;
38+
}
39+
40+
public function connect(string $url, array $options = []): ResponseInterface
41+
{
42+
return $this->request('GET', $url, self::mergeDefaultOptions($options, [
43+
'buffer' => false,
44+
'headers' => [
45+
'Accept' => 'text/event-stream',
46+
'Cache-Control' => 'no-cache',
47+
],
48+
], true));
49+
}
50+
51+
public function request(string $method, string $url, array $options = []): ResponseInterface
52+
{
53+
$state = new class() {
54+
public $buffer = null;
55+
public $lastEventId = null;
56+
public $reconnectionTime;
57+
public $lastError = null;
58+
};
59+
$state->reconnectionTime = $this->reconnectionTime;
60+
61+
if ($accept = self::normalizeHeaders($options['headers'] ?? [])['accept'] ?? []) {
62+
$state->buffer = \in_array($accept, [['Accept: text/event-stream'], ['accept: text/event-stream']], true) ? '' : null;
63+
}
64+
65+
return new AsyncResponse($this->client, $method, $url, $options, static function (ChunkInterface $chunk, AsyncContext $context) use ($state, $method, $url, $options) {
66+
if (null !== $state->buffer) {
67+
$context->setInfo('reconnection_time', $state->reconnectionTime);
68+
$isTimeout = false;
69+
}
70+
$lastError = $state->lastError;
71+
$state->lastError = null;
72+
73+
try {
74+
$isTimeout = $chunk->isTimeout();
75+
76+
if (null !== $chunk->getInformationalStatus()) {
77+
yield $chunk;
78+
79+
return;
80+
}
81+
} catch (TransportExceptionInterface $e) {
82+
$state->lastError = $lastError ?? microtime(true);
83+
84+
if (null === $state->buffer || ($isTimeout && microtime(true) - $state->lastError < $state->reconnectionTime)) {
85+
yield $chunk;
86+
} else {
87+
$options['headers']['Last-Event-ID'] = $state->lastEventId;
88+
$state->buffer = '';
89+
$state->lastError = microtime(true);
90+
$context->getResponse()->cancel();
91+
$context->replaceRequest($method, $url, $options);
92+
if ($isTimeout) {
93+
yield $chunk;
94+
} else {
95+
$context->pause($state->reconnectionTime);
96+
}
97+
}
98+
99+
return;
100+
}
101+
102+
if ($chunk->isFirst()) {
103+
if (preg_match('/^text\/event-stream(;|$)/i', $context->getHeaders()['content-type'][0] ?? '')) {
104+
$state->buffer = '';
105+
} elseif (null !== $lastError || (null !== $state->buffer && 200 === $context->getStatusCode())) {
106+
throw new EventSourceException(sprintf('Response content-type is "%s" while "text/event-stream" was expected for "%s".', $context->getHeaders()['content-type'][0] ?? '', $context->getInfo('url')));
107+
} else {
108+
$context->passthru();
109+
}
110+
111+
if (null === $lastError) {
112+
yield $chunk;
113+
}
114+
115+
return;
116+
}
117+
118+
$rx = '/((?:\r\n|[\r\n]){2,})/';
119+
$content = $state->buffer.$chunk->getContent();
120+
121+
if ($chunk->isLast()) {
122+
$rx = substr_replace($rx, '|$', -2, 0);
123+
}
124+
$events = preg_split($rx, $content, -1, PREG_SPLIT_DELIM_CAPTURE);
125+
$state->buffer = array_pop($events);
126+
127+
for ($i = 0; isset($events[$i]); $i += 2) {
128+
$event = new ServerSentEvent($events[$i].$events[1 + $i]);
129+
130+
if ('' !== $event->getId()) {
131+
$context->setInfo('last_event_id', $state->lastEventId = $event->getId());
132+
}
133+
134+
if ($event->getRetry()) {
135+
$context->setInfo('reconnection_time', $state->reconnectionTime = $event->getRetry());
136+
}
137+
138+
yield $event;
139+
}
140+
141+
if (preg_match('/^(?::[^\r\n]*+(?:\r\n|[\r\n]))+$/m', $state->buffer)) {
142+
$content = $state->buffer;
143+
$state->buffer = '';
144+
145+
yield $context->createChunk($content);
146+
}
147+
148+
if ($chunk->isLast()) {
149+
yield $chunk;
150+
}
151+
});
152+
}
153+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\HttpClient\Exception;
13+
14+
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
15+
16+
/**
17+
* @author Nicolas Grekas <p@tchwork.com>
18+
*/
19+
final class EventSourceException extends \RuntimeException implements DecodingExceptionInterface
20+
{
21+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\HttpClient\Tests\Chunk;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\HttpClient\Chunk\ServerSentEvent;
16+
17+
/**
18+
* @author Antoine Bluchet <soyuka@gmail.com>
19+
*/
20+
class ServerSentEventTest extends TestCase
21+
{
22+
public function testParse()
23+
{
24+
$rawData = <<<STR
25+
data: test
26+
data:test
27+
id: 12
28+
event: testEvent
29+
30+
STR;
31+
32+
$sse = new ServerSentEvent($rawData);
33+
$this->assertSame("test\ntest", $sse->getData());
34+
$this->assertSame('12', $sse->getId());
35+
$this->assertSame('testEvent', $sse->getType());
36+
}
37+
38+
public function testParseValid()
39+
{
40+
$rawData = <<<STR
41+
event: testEvent
42+
data
43+
44+
STR;
45+
46+
$sse = new ServerSentEvent($rawData);
47+
$this->assertSame('', $sse->getData());
48+
$this->assertSame('', $sse->getId());
49+
$this->assertSame('testEvent', $sse->getType());
50+
}
51+
52+
public function testParseRetry()
53+
{
54+
$rawData = <<<STR
55+
retry: 12
56+
STR;
57+
$sse = new ServerSentEvent($rawData);
58+
$this->assertSame('', $sse->getData());
59+
$this->assertSame('', $sse->getId());
60+
$this->assertSame('message', $sse->getType());
61+
$this->assertSame(0.012, $sse->getRetry());
62+
}
63+
64+
public function testParseNewLine()
65+
{
66+
$rawData = <<<STR
67+
68+
69+
data: <tag>
70+
data
71+
data: <foo />
72+
data:
73+
data:
74+
data: </tag>
75+
STR;
76+
$sse = new ServerSentEvent($rawData);
77+
$this->assertSame("<tag>\n\n <foo />\n\n\n</tag>", $sse->getData());
78+
}
79+
}

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy