Skip to content

Commit 957836e

Browse files
committed
Initial commit
0 parents  commit 957836e

11 files changed

+1115
-0
lines changed

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/vendor/
2+
/composer.lock

.travis.yml

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
language: php
2+
3+
php:
4+
- 5.4
5+
- 5.5
6+
- 5.6
7+
- 7.0
8+
- 7.1
9+
- 7.2
10+
- 7.3
11+
12+
# lock distro so new future defaults will not break the build
13+
dist: trusty
14+
15+
sudo: false
16+
17+
install:
18+
- composer install --no-interaction
19+
20+
script:
21+
- vendor/bin/phpunit --coverage-text

LICENSE

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
The MIT License (MIT)
2+
3+
Copyright (c) 2019 Christian Lück
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is furnished
10+
to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21+
THE SOFTWARE.

README.md

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# clue/reactphp-eventsource [![Build Status](https://travis-ci.org/clue/reactphp-eventsource.svg?branch=master)](https://travis-ci.org/clue/reactphp-eventsource)
2+
3+
Event-driven EventSource client, receiving streaming messages from any HTML5 Server-Sent Events (SSE) server,
4+
built on top of [ReactPHP](https://reactphp.org/).
5+
6+
> Note: This project is in early alpha stage! Feel free to report any issues you encounter.
7+
8+
**Table of contents**
9+
10+
* [Quickstart example](#quickstart-example)
11+
* [Install](#install)
12+
* [Tests](#tests)
13+
* [License](#license)
14+
15+
## Quickstart example
16+
17+
See the [examples](examples).
18+
19+
## Install
20+
21+
The recommended way to install this library is [through Composer](https://getcomposer.org).
22+
[New to Composer?](https://getcomposer.org/doc/00-intro.md)
23+
24+
This will install the latest supported version:
25+
26+
```bash
27+
$ composer require clue/reactphp-eventsource:dev-master
28+
```
29+
30+
This project aims to run on any platform and thus does not require any PHP
31+
extensions and supports running on legacy PHP 5.4 through current PHP 7+.
32+
It's *highly recommended to use PHP 7+* for this project.
33+
34+
## Tests
35+
36+
To run the test suite, you first need to clone this repo and then install all
37+
dependencies [through Composer](https://getcomposer.org):
38+
39+
```bash
40+
$ composer install
41+
```
42+
43+
To run the test suite, go to the project root and run:
44+
45+
```bash
46+
$ php vendor/bin/phpunit
47+
```
48+
49+
## License
50+
51+
This project is released under the permissive [MIT license](LICENSE).
52+
53+
> Did you know that I offer custom development services and issuing invoices for
54+
sponsorships of releases and for contributions? Contact me (@clue) for details.

composer.json

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "clue/reactphp-eventsource",
3+
"description": "Event-driven EventSource client, receiving streaming messages from any HTML5 Server-Sent Events (SSE) server, built on top of ReactPHP",
4+
"keywords": ["EventSource", "Server-Side Events", "SSE", "event-driven", "ReactPHP", "async"],
5+
"homepage": "https://github.com/clue/reactphp-eventsource",
6+
"license": "MIT",
7+
"authors": [
8+
{
9+
"name": "Christian Lück",
10+
"email": "[email protected]"
11+
}
12+
],
13+
"autoload": {
14+
"psr-4": { "Clue\\React\\EventSource\\": "src/" }
15+
},
16+
"require": {
17+
"php": ">=5.4",
18+
"clue/buzz-react": "^2.5",
19+
"evenement/evenement": "^3.0 || ^2.0"
20+
},
21+
"require-dev": {
22+
"phpunit/phpunit": "^7.0 || ^6.4 || ^5.7 || ^4.8.35"
23+
}
24+
}

examples/stream.php

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
use React\EventLoop\Factory;
4+
use Clue\React\EventSource\EventSource;
5+
6+
require __DIR__ . '/../vendor/autoload.php';
7+
8+
if (!isset($argv[1]) || isset($argv[2])) {
9+
exit('Usage error: stream.php <uri>' . PHP_EOL);
10+
}
11+
12+
$loop = Factory::create();
13+
$es = new EventSource($argv[1], $loop);
14+
15+
$es->on('message', function ($message) {
16+
//$data = json_decode($message->data);
17+
var_dump($message);
18+
});
19+
20+
$es->on('error', function (Exception $e) use ($es) {
21+
if ($es->readyState === EventSource::CLOSED) {
22+
echo 'Permanent error: ' . $e->getMessage() . PHP_EOL;
23+
} else {
24+
echo 'Temporary error: ' . $e->getMessage() . PHP_EOL;
25+
}
26+
});
27+
28+
$loop->run();

phpunit.xml.dist

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
3+
<phpunit bootstrap="vendor/autoload.php"
4+
colors="true"
5+
convertErrorsToExceptions="true"
6+
convertNoticesToExceptions="true"
7+
convertWarningsToExceptions="true"
8+
>
9+
<testsuites>
10+
<testsuite name="EventSource Test Suite">
11+
<directory>./tests/</directory>
12+
</testsuite>
13+
</testsuites>
14+
<filter>
15+
<whitelist>
16+
<directory>./src/</directory>
17+
</whitelist>
18+
</filter>
19+
</phpunit>

src/EventSource.php

+167
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
<?php
2+
3+
namespace Clue\React\EventSource;
4+
5+
use React\EventLoop\LoopInterface;
6+
use Psr\Http\Message\ResponseInterface;
7+
use React\Stream\ReadableStreamInterface;
8+
use Clue\React\Buzz\Browser;
9+
use Evenement\EventEmitter;
10+
use React\Socket\ConnectorInterface;
11+
12+
class EventSource extends EventEmitter
13+
{
14+
/**
15+
* @var string (read-only) last event ID received
16+
*/
17+
public $lastEventId = '';
18+
19+
// ready state
20+
const CONNECTING = 0;
21+
const OPEN = 1;
22+
const CLOSED = 2;
23+
24+
/**
25+
* @var int (read-only)
26+
* @see self::CONNECTING
27+
* @see self::OPEN
28+
* @see self::CLOSED
29+
*/
30+
public $readyState = self::CLOSED;
31+
32+
/**
33+
* @var string (read-only) URL
34+
*/
35+
public $url;
36+
37+
private $loop;
38+
private $browser;
39+
private $request;
40+
private $timer;
41+
private $reconnectTime = 3.0;
42+
43+
public function __construct($url, LoopInterface $loop, ConnectorInterface $connector = null)
44+
{
45+
$parts = parse_url($url);
46+
if (!isset($parts['scheme'], $parts['host']) || !in_array($parts['scheme'], array('http', 'https'))) {
47+
throw new \InvalidArgumentException();
48+
}
49+
50+
$browser = new Browser($loop, $connector);
51+
$this->browser = $browser->withOptions(array('streaming' => true, 'obeySuccessCode' => false));
52+
$this->loop = $loop;
53+
$this->url = $url;
54+
55+
$this->readyState = self::CONNECTING;
56+
57+
$this->timer = $loop->addTimer(0, function () {
58+
$this->timer = null;
59+
$this->send();
60+
});
61+
}
62+
63+
private function send()
64+
{
65+
$headers = array(
66+
'Accept' => 'text/event-stream',
67+
'Cache-Control' => 'no-cache'
68+
);
69+
if ($this->lastEventId !== '') {
70+
$headers['Last-Event-ID'] = $this->lastEventId;
71+
}
72+
73+
$this->request = $this->browser->get(
74+
$this->url,
75+
$headers
76+
);
77+
$this->request->then(function (ResponseInterface $response) {
78+
if ($response->getStatusCode() !== 200) {
79+
$this->readyState = self::CLOSED;
80+
$this->emit('error', array(new \UnexpectedValueException('Unexpected status code')));
81+
$this->close();
82+
return;
83+
}
84+
85+
if ($response->getHeaderLine('Content-Type') !== 'text/event-stream') {
86+
$this->readyState = self::CLOSED;
87+
$this->emit('error', array(new \UnexpectedValueException('Unexpected Content-Type')));
88+
$this->close();
89+
return;
90+
}
91+
92+
$stream = $response->getBody();
93+
assert($stream instanceof ReadableStreamInterface);
94+
95+
$buffer = '';
96+
$stream->on('data', function ($chunk) use (&$buffer, $stream) {
97+
$buffer .= $chunk;
98+
99+
while (($pos = strpos($buffer, "\n\n")) !== false) {
100+
$data = substr($buffer, 0, $pos);
101+
$buffer = substr($buffer, $pos + 2);
102+
103+
$message = MessageEvent::parse($data);
104+
if ($message->lastEventId === null) {
105+
$message->lastEventId = $this->lastEventId;
106+
} else {
107+
$this->lastEventId = $message->lastEventId;
108+
}
109+
110+
if ($message->data !== '') {
111+
$this->emit($message->type, array($message));
112+
}
113+
}
114+
});
115+
116+
$stream->on('close', function () {
117+
$this->request = null;
118+
if ($this->readyState === self::OPEN) {
119+
$this->readyState = self::CONNECTING;
120+
$this->timer = $this->loop->addTimer($this->reconnectTime, function () {
121+
$this->timer = null;
122+
$this->send();
123+
});
124+
}
125+
});
126+
127+
$this->readyState = self::OPEN;
128+
$this->emit('open');
129+
})->then(null, function ($e) {
130+
$this->request = null;
131+
if ($this->readyState === self::CLOSED) {
132+
return;
133+
}
134+
135+
$this->emit('error', array($e));
136+
if ($this->readyState === self::CLOSED) {
137+
return;
138+
}
139+
140+
$this->timer = $this->loop->addTimer($this->reconnectTime, function () {
141+
$this->timer = null;
142+
$this->send();
143+
});
144+
});
145+
}
146+
147+
public function close()
148+
{
149+
$this->readyState = self::CLOSED;
150+
if ($this->request !== null) {
151+
$request = $this->request;
152+
$this->request = null;
153+
154+
$request->then(function (ResponseInterface $response) {
155+
$response->getBody()->close();
156+
});
157+
$request->cancel();
158+
}
159+
160+
if ($this->timer !== null) {
161+
$this->loop->cancelTimer($this->timer);
162+
$this->timer = null;
163+
}
164+
165+
$this->removeAllListeners();
166+
}
167+
}

src/MessageEvent.php

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
namespace Clue\React\EventSource;
4+
5+
class MessageEvent
6+
{
7+
/**
8+
* @param string $data
9+
* @return self
10+
* @internal
11+
*/
12+
public static function parse($data)
13+
{
14+
$message = new MessageEvent();
15+
16+
preg_match_all('/^([a-z]*)\: ?(.*)/m', $data, $matches, PREG_SET_ORDER);
17+
foreach ($matches as $match) {
18+
if ($match[1] === 'data') {
19+
$message->data .= $match[2] . "\n";
20+
} elseif ($match[1] === 'id') {
21+
$message->lastEventId .= $match[2];
22+
} elseif ($match[1] === 'event') {
23+
$message->type = $match[2];
24+
}
25+
}
26+
27+
if (substr($message->data, -1) === "\n") {
28+
$message->data = substr($message->data, 0, -1);
29+
}
30+
//$message->data = rtrim($message->data, "\r\n");
31+
32+
return $message;
33+
}
34+
35+
/**
36+
* @var string
37+
*/
38+
public $data = '';
39+
40+
/**
41+
* @var ?string
42+
*/
43+
public $lastEventId = null;
44+
45+
/**
46+
* @var string
47+
*/
48+
public $type = 'message';
49+
}

0 commit comments

Comments
 (0)