├── .github └── FUNDING.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json └── src ├── EventSource.php └── MessageEvent.php /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: clue 2 | custom: https://clue.engineering/support 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.3.0 (2025-04-11) 4 | 5 | Today’s release is extra special because it’s also my birthday! 🎉 6 | A huge thank you to all sponsors for your continued support, it means so much. 7 | If you’re thinking about sponsoring, now’s a great time to [join the fun](https://github.com/sponsors/clue)! ❤️ 8 | 9 | * Feature: Improve PHP 8.4+ support by avoiding implicitly nullable types. 10 | (#46 and #47 by @clue) 11 | 12 | * Improve test suite, update to reactphp/http `v1.10.0` and avoid using internal classes. 13 | (#45 by @SimonFrings and #48 by @clue) 14 | 15 | ## 1.2.0 (2024-01-25) 16 | 17 | * Feature / Fix: Forward compatibility with Promise v3. 18 | (#42 by @clue) 19 | 20 | * Feature: Full PHP 8.3 compatibility. 21 | (#44 by @yadaiio) 22 | 23 | * Minor documentation improvements. 24 | (#43 by @yadaiio) 25 | 26 | ## 1.1.0 (2023-04-11) 27 | 28 | * Feature: Public `MessageEvent` constructor and refactor property assignments. 29 | (#36 and #41 by @clue) 30 | 31 | This is mostly used internally to represent each incoming message event 32 | (see also `message` event). Likewise, you can also use this class in test 33 | cases to test how your application reacts to incoming messages. 34 | 35 | ```php 36 | $message = new Clue\React\EventSource\MessageEvent('hello', '42', 'message'); 37 | 38 | assert($message->data === 'hello'); 39 | assert($message->lastEventId === '42'); 40 | assert($message->type === 'message'); 41 | ``` 42 | 43 | * Feature / Fix: Use replacement character for invalid UTF-8, handle null bytes and ignore empty `event` type as per EventSource specs. 44 | (#33 and #40 by @clue) 45 | 46 | * Feature: Full support for PHP 8.2 and update test environment. 47 | (#38 by @clue) 48 | 49 | * Improve test suite, ensure 100% code coverage and report failed assertions. 50 | (#35 by @clue and #39 by @clue) 51 | 52 | ## 1.0.0 (2022-04-11) 53 | 54 | * First stable release, now following SemVer! 🎉 55 | Thanks to all the supporters and contributors who helped shape the project! 56 | 57 | ## 0.0.0 (2019-04-11) 58 | 59 | * Initial import 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # clue/reactphp-eventsource 2 | 3 | [![CI status](https://github.com/clue/reactphp-eventsource/actions/workflows/ci.yml/badge.svg)](https://github.com/clue/reactphp-eventsource/actions) 4 | [![code coverage](https://img.shields.io/badge/code%20coverage-100%25-success)](#tests) 5 | [![installs on Packagist](https://img.shields.io/packagist/dt/clue/reactphp-eventsource?color=blue&label=installs%20on%20Packagist)](https://packagist.org/packages/clue/reactphp-eventsource) 6 | 7 | Instant real-time updates. Lightweight EventSource client receiving live 8 | messages via HTML5 Server-Sent Events (SSE). Fast stream processing built on top 9 | of [ReactPHP](https://reactphp.org/)'s event-driven architecture. 10 | 11 | **Table of contents** 12 | 13 | * [Support us](#support-us) 14 | * [Quickstart example](#quickstart-example) 15 | * [Usage](#usage) 16 | * [EventSource](#eventsource) 17 | * [message event](#message-event) 18 | * [open event](#open-event) 19 | * [error event](#error-event) 20 | * [EventSource::$readyState](#eventsourcereadystate) 21 | * [EventSource::$url](#eventsourceurl) 22 | * [close()](#close) 23 | * [MessageEvent](#messageevent) 24 | * [MessageEvent::__construct()](#messageevent__construct) 25 | * [MessageEvent::$data](#messageeventdata) 26 | * [MessageEvent::$lastEventId](#messageeventlasteventid) 27 | * [MessageEvent::$type](#messageeventtype) 28 | * [Install](#install) 29 | * [Tests](#tests) 30 | * [License](#license) 31 | * [More](#more) 32 | 33 | ## Support us 34 | 35 | We invest a lot of time developing, maintaining and updating our awesome 36 | open-source projects. You can help us sustain this high-quality of our work by 37 | [becoming a sponsor on GitHub](https://github.com/sponsors/clue). Sponsors get 38 | numerous benefits in return, see our [sponsoring page](https://github.com/sponsors/clue) 39 | for details. 40 | 41 | Let's take these projects to the next level together! 🚀 42 | 43 | ## Quickstart example 44 | 45 | Once [installed](#install), you can use the following code to stream messages 46 | from any Server-Sent Events (SSE) server endpoint: 47 | 48 | ``` 49 | data: {"name":"Alice","message":"Hello everybody!"} 50 | 51 | data: {"name":"Bob","message":"Hey Alice!"} 52 | 53 | data: {"name":"Carol","message":"Nice to see you Alice!"} 54 | 55 | data: {"name":"Alice","message":"What a lovely chat!"} 56 | 57 | data: {"name":"Bob","message":"All powered by ReactPHP, such an awesome piece of technology :)"} 58 | ``` 59 | 60 | ```php 61 | $es = new Clue\React\EventSource\EventSource('https://example.com/stream.php'); 62 | 63 | $es->on('message', function (Clue\React\EventSource\MessageEvent $message) { 64 | $json = json_decode($message->data); 65 | echo $json->name . ': ' . $json->message . PHP_EOL; 66 | }); 67 | ``` 68 | 69 | See the [examples](examples/). 70 | 71 | ## Usage 72 | 73 | ### EventSource 74 | 75 | The `EventSource` class is responsible for communication with the remote Server-Sent Events (SSE) endpoint. 76 | 77 | The `EventSource` object works very similar to the one found in common 78 | web browsers. Unless otherwise noted, it follows the same semantics as defined 79 | under https://html.spec.whatwg.org/multipage/server-sent-events.html 80 | 81 | Its constructor simply requires the URL to the remote Server-Sent Events (SSE) endpoint: 82 | 83 | ```php 84 | $es = new Clue\React\EventSource\EventSource('https://example.com/stream.php'); 85 | ``` 86 | 87 | If you need custom connector settings (DNS resolution, TLS parameters, timeouts, 88 | proxy servers etc.), you can explicitly pass a custom instance of the 89 | [`ConnectorInterface`](https://github.com/reactphp/socket#connectorinterface) 90 | to the [`Browser`](https://github.com/reactphp/http#browser) instance 91 | and pass it as an additional argument to the `EventSource` like this: 92 | 93 | ```php 94 | $connector = new React\Socket\Connector([ 95 | 'dns' => '127.0.0.1', 96 | 'tcp' => [ 97 | 'bindto' => '192.168.10.1:0' 98 | ], 99 | 'tls' => [ 100 | 'verify_peer' => false, 101 | 'verify_peer_name' => false 102 | ] 103 | ]); 104 | $browser = new React\Http\Browser($connector); 105 | 106 | $es = new Clue\React\EventSource\EventSource('https://example.com/stream.php', $browser); 107 | ``` 108 | 109 | This class takes an optional `LoopInterface|null $loop` parameter that can be used to 110 | pass the event loop instance to use for this object. You can use a `null` value 111 | here in order to use the [default loop](https://github.com/reactphp/event-loop#loop). 112 | This value SHOULD NOT be given unless you're sure you want to explicitly use a 113 | given event loop instance. 114 | 115 | #### message event 116 | 117 | The `message` event will be emitted whenever an EventSource message is received. 118 | 119 | ```php 120 | $es->on('message', function (Clue\React\EventSource\MessageEvent $message) { 121 | // $json = json_decode($message->data); 122 | var_dump($message); 123 | }); 124 | ``` 125 | 126 | The EventSource stream may emit any number of messages over its lifetime. Each 127 | `message` event will receive a [`MessageEvent` object](#messageevent). 128 | 129 | The [`MessageEvent::$data` property](#messageeventdata) can be used to access 130 | the message payload data. It is commonly used for transporting structured data 131 | such as JSON: 132 | 133 | ``` 134 | data: {"name":"Alice","age":30} 135 | 136 | data: {"name":"Bob","age":50} 137 | ``` 138 | ```php 139 | $es->on('message', function (Clue\React\EventSource\MessageEvent $message) { 140 | $json = json_decode($message->data); 141 | echo "{$json->name} is {$json->age} years old" . PHP_EOL; 142 | }); 143 | ``` 144 | 145 | The EventSource stream may specify an event type for each incoming message. This 146 | `event` field can be used to emit appropriate event types like this: 147 | 148 | ``` 149 | data: Alice 150 | event: join 151 | 152 | data: Hello! 153 | event: chat 154 | 155 | data: Bob 156 | event: leave 157 | ``` 158 | ```php 159 | $es->on('join', function (Clue\React\EventSource\MessageEvent $message) { 160 | echo $message->data . ' joined' . PHP_EOL; 161 | }); 162 | 163 | $es->on('chat', function (Clue\React\EventSource\MessageEvent $message) { 164 | echo 'Message: ' . $message->data . PHP_EOL; 165 | }); 166 | 167 | $es->on('leave', function (Clue\React\EventSource\MessageEvent $message) { 168 | echo $message->data . ' left' . PHP_EOL; 169 | }); 170 | ``` 171 | 172 | See also [`MessageEvent::$type` property](#messageeventtype) for more details. 173 | 174 | #### open event 175 | 176 | The `open` event will be emitted when the EventSource connection is successfully established. 177 | 178 | ```php 179 | $es->on('open', function () { 180 | echo 'Connection opened' . PHP_EOL; 181 | }); 182 | ``` 183 | 184 | Once the EventSource connection is open, it may emit any number of 185 | [`message` events](#message-event). 186 | 187 | If the connection can not be opened successfully, it will emit an 188 | [`error` event](#error-event) instead. 189 | 190 | #### error event 191 | 192 | The `error` event will be emitted when the EventSource connection fails. 193 | The event receives a single `Exception` argument for the error instance. 194 | 195 | ```php 196 | $redis->on('error', function (Exception $e) { 197 | echo 'Error: ' . $e->getMessage() . PHP_EOL; 198 | }); 199 | ``` 200 | 201 | The EventSource connection will be retried automatically when it is temporarily 202 | disconnected. If the server sends a non-successful HTTP status code or an 203 | invalid `Content-Type` response header, the connection will fail permanently. 204 | 205 | ```php 206 | $es->on('error', function (Exception $e) use ($es) { 207 | if ($es->readyState === Clue\React\EventSource\EventSource::CLOSED) { 208 | echo 'Permanent error: ' . $e->getMessage() . PHP_EOL; 209 | } else { 210 | echo 'Temporary error: ' . $e->getMessage() . PHP_EOL; 211 | } 212 | }); 213 | ``` 214 | 215 | See also the [`EventSource::$readyState` property](#eventsourcereadystate). 216 | 217 | #### EventSource::$readyState 218 | 219 | The `int $readyState` property can be used to 220 | check the current EventSource connection state. 221 | 222 | The state is read-only and can be in one of three states over its lifetime: 223 | 224 | * `EventSource::CONNECTING` 225 | * `EventSource::OPEN` 226 | * `EventSource::CLOSED` 227 | 228 | #### EventSource::$url 229 | 230 | The `readonly string $url` property can be used to 231 | get the EventSource URL as given to the constructor. 232 | 233 | #### close() 234 | 235 | The `close(): void` method can be used to 236 | forcefully close the EventSource connection. 237 | 238 | This will close any active connections or connection attempts and go into the 239 | `EventSource::CLOSED` state. 240 | 241 | ### MessageEvent 242 | 243 | The `MessageEvent` class represents an incoming EventSource message. 244 | 245 | #### MessageEvent::__construct() 246 | 247 | The `new MessageEvent(string $data, string $lastEventId = '', string $type = 'message')` constructor can be used to 248 | create a new `MessageEvent` instance. 249 | 250 | This is mostly used internally to represent each incoming message event 251 | (see also [`message` event](#message-event)). Likewise, you can also use 252 | this class in test cases to test how your application reacts to incoming 253 | messages. 254 | 255 | The constructor validates and initializes all properties of this class. 256 | It throws an `InvalidArgumentException` if any parameters are invalid. 257 | 258 | #### MessageEvent::$data 259 | 260 | The `readonly string $data` property can be used to 261 | access the message payload data. 262 | 263 | ``` 264 | data: hello 265 | ``` 266 | ```php 267 | assert($message->data === 'hello'); 268 | ``` 269 | 270 | The `data` field may also span multiple lines. This is commonly used for 271 | transporting structured data such as JSON: 272 | 273 | ``` 274 | data: { 275 | data: "message": "hello" 276 | data: } 277 | ``` 278 | ```php 279 | $json = json_decode($message->data); 280 | assert($json->message === 'hello'); 281 | ``` 282 | 283 | If the message does not contain a `data` field or the `data` field is empty, the 284 | message will be discarded without emitting an event. 285 | 286 | #### MessageEvent::$lastEventId 287 | 288 | The `readonly string $lastEventId` property can be used to 289 | access the last event ID. 290 | 291 | ``` 292 | data: hello 293 | id: 1 294 | ``` 295 | ```php 296 | assert($message->data === 'hello'); 297 | assert($message->lastEventId === '1'); 298 | ``` 299 | 300 | Internally, the `id` field will automatically be used as the `Last-Event-ID` HTTP 301 | request header in case the connection is interrupted. 302 | 303 | If the message does not contain an `id` field, the `$lastEventId` property will 304 | be the value of the last ID received. If no previous message contained an ID, it 305 | will default to an empty string. 306 | 307 | #### MessageEvent::$type 308 | 309 | The `readonly string $type` property can be used to 310 | access the message event type. 311 | 312 | ``` 313 | data: Alice 314 | event: join 315 | ``` 316 | ```php 317 | assert($message->data === 'Alice'); 318 | assert($message->type === 'join'); 319 | ``` 320 | 321 | Internally, the `event` field will be used to emit the appropriate event type. 322 | See also [`message` event](#message-event). 323 | 324 | If the message does not contain a `event` field or the `event` field is empty, 325 | the `$type` property will default to `message`. 326 | 327 | ## Install 328 | 329 | The recommended way to install this library is [through Composer](https://getcomposer.org/). 330 | [New to Composer?](https://getcomposer.org/doc/00-intro.md) 331 | 332 | This project follows [SemVer](https://semver.org/). 333 | This will install the latest supported version: 334 | 335 | ```bash 336 | composer require clue/reactphp-eventsource:^1.3 337 | ``` 338 | 339 | See also the [CHANGELOG](CHANGELOG.md) for details about version upgrades. 340 | 341 | This project aims to run on any platform and thus does not require any PHP 342 | extensions and supports running on legacy PHP 5.4 through current PHP 8+. 343 | It's *highly recommended to use the latest supported PHP version* for this project. 344 | 345 | ## Tests 346 | 347 | To run the test suite, you first need to clone this repo and then install all 348 | dependencies [through Composer](https://getcomposer.org/): 349 | 350 | ```bash 351 | composer install 352 | ``` 353 | 354 | To run the test suite, go to the project root and run: 355 | 356 | ```bash 357 | vendor/bin/phpunit 358 | ``` 359 | 360 | The test suite is set up to always ensure 100% code coverage across all 361 | supported environments. If you have the Xdebug extension installed, you can also 362 | generate a code coverage report locally like this: 363 | 364 | ```bash 365 | XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text 366 | ``` 367 | 368 | ## License 369 | 370 | This project is released under the permissive [MIT license](LICENSE). 371 | 372 | > Did you know that I offer custom development services and issuing invoices for 373 | sponsorships of releases and for contributions? Contact me (@clue) for details. 374 | 375 | ## More 376 | 377 | * If you want to learn more about processing streams of data, refer to the documentation of 378 | the underlying [react/stream](https://github.com/reactphp/stream) component. 379 | 380 | * If you're looking to run the server side of your Server-Sent Events (SSE) 381 | application, you may want to use the powerful server implementation provided 382 | by [Framework X](https://framework-x.org/). 383 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "clue/reactphp-eventsource", 3 | "description": "Instant real-time updates. Lightweight EventSource client receiving live messages via HTML5 Server-Sent Events (SSE). Fast stream processing built on top of ReactPHP's event-driven architecture.", 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": "christian@clue.engineering" 11 | } 12 | ], 13 | "require": { 14 | "php": ">=5.4", 15 | "evenement/evenement": "^3.0 || ^2.0", 16 | "react/event-loop": "^1.2", 17 | "react/http": "^1.11", 18 | "react/promise": "^3.2 || ^2.10 || ^1.2.1" 19 | }, 20 | "require-dev": { 21 | "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" 22 | }, 23 | "autoload": { 24 | "psr-4": { 25 | "Clue\\React\\EventSource\\": "src/" 26 | } 27 | }, 28 | "autoload-dev": { 29 | "psr-4": { 30 | "Clue\\Tests\\React\\EventSource\\": "tests/" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/EventSource.php: -------------------------------------------------------------------------------- 1 | '127.0.0.1', 40 | * 'tcp' => [ 41 | * 'bindto' => '192.168.10.1:0' 42 | * ], 43 | * 'tls' => [ 44 | * 'verify_peer' => false, 45 | * 'verify_peer_name' => false 46 | * ] 47 | * ]); 48 | * $browser = new React\Http\Browser(null, $connector); 49 | * 50 | * $es = new Clue\React\EventSource\EventSource('https://example.com/stream.php', null, $browser); 51 | * ``` 52 | */ 53 | class EventSource extends EventEmitter 54 | { 55 | // ready state 56 | const CONNECTING = 0; 57 | const OPEN = 1; 58 | const CLOSED = 2; 59 | 60 | /** 61 | * @var int (read-only) 62 | * @see self::CONNECTING 63 | * @see self::OPEN 64 | * @see self::CLOSED 65 | * @psalm-readonly-allow-private-mutation 66 | */ 67 | public $readyState = self::CLOSED; 68 | 69 | /** 70 | * @var string (read-only) URL 71 | * @readonly 72 | */ 73 | public $url; 74 | 75 | /** 76 | * @var string last event ID received 77 | */ 78 | private $lastEventId = ''; 79 | 80 | /** 81 | * @var LoopInterface 82 | * @readonly 83 | */ 84 | private $loop; 85 | 86 | /** 87 | * @var Browser 88 | * @readonly 89 | */ 90 | private $browser; 91 | 92 | /** 93 | * @var ?\React\Promise\PromiseInterface 94 | */ 95 | private $request; 96 | 97 | /** 98 | * @var ?\React\EventLoop\TimerInterface 99 | */ 100 | private $timer; 101 | 102 | /** 103 | * @var float 104 | */ 105 | private $reconnectTime = 3.0; 106 | 107 | /** 108 | * The `EventSource` class is responsible for communication with the remote Server-Sent Events (SSE) endpoint. 109 | * 110 | * The `EventSource` object works very similar to the one found in common 111 | * web browsers. Unless otherwise noted, it follows the same semantics as defined 112 | * under https://html.spec.whatwg.org/multipage/server-sent-events.html 113 | * 114 | * Its constructor simply requires the URL to the remote Server-Sent Events (SSE) endpoint: 115 | * 116 | * ```php 117 | * $es = new Clue\React\EventSource\EventSource('https://example.com/stream.php'); 118 | * ``` 119 | * 120 | * If you need custom connector settings (DNS resolution, TLS parameters, timeouts, 121 | * proxy servers etc.), you can explicitly pass a custom instance of the 122 | * [`ConnectorInterface`](https://github.com/reactphp/socket#connectorinterface) 123 | * to the [`Browser`](https://github.com/reactphp/http#browser) instance 124 | * and pass it as an additional argument to the `EventSource` like this: 125 | * 126 | * ```php 127 | * $connector = new React\Socket\Connector([ 128 | * 'dns' => '127.0.0.1', 129 | * 'tcp' => [ 130 | * 'bindto' => '192.168.10.1:0' 131 | * ], 132 | * 'tls' => [ 133 | * 'verify_peer' => false, 134 | * 'verify_peer_name' => false 135 | * ] 136 | * ]); 137 | * $browser = new React\Http\Browser($connector); 138 | * 139 | * $es = new Clue\React\EventSource\EventSource('https://example.com/stream.php', $browser); 140 | * ``` 141 | * 142 | * This class takes an optional `LoopInterface|null $loop` parameter that can be used to 143 | * pass the event loop instance to use for this object. You can use a `null` value 144 | * here in order to use the [default loop](https://github.com/reactphp/event-loop#loop). 145 | * This value SHOULD NOT be given unless you're sure you want to explicitly use a 146 | * given event loop instance. 147 | * 148 | * @param string $url 149 | * @param ?Browser $browser 150 | * @param ?LoopInterface $loop 151 | * @throws \InvalidArgumentException for invalid URL 152 | */ 153 | public function __construct($url, $browser = null, $loop = null) 154 | { 155 | $parts = parse_url($url); 156 | if (!isset($parts['scheme'], $parts['host']) || !in_array($parts['scheme'], array('http', 'https'))) { 157 | throw new \InvalidArgumentException(); 158 | } 159 | 160 | if ($browser !== null && !$browser instanceof Browser) { // manual type check to support legacy PHP < 7.1 161 | throw new \InvalidArgumentException('Argument #2 ($browser) expected null|React\Http\Browser'); 162 | } 163 | if ($loop !== null && !$loop instanceof LoopInterface) { // manual type check to support legacy PHP < 7.1 164 | throw new \InvalidArgumentException('Argument #3 ($loop) expected null|React\EventLoop\LoopInterface'); 165 | } 166 | 167 | $this->loop = $loop ?: Loop::get(); 168 | if ($browser === null) { 169 | $browser = new Browser(null, $this->loop); 170 | } 171 | $this->browser = $browser->withRejectErrorResponse(false); 172 | $this->url = $url; 173 | 174 | $this->readyState = self::CONNECTING; 175 | $this->request(); 176 | } 177 | 178 | private function request() 179 | { 180 | $headers = array( 181 | 'Accept' => 'text/event-stream', 182 | 'Cache-Control' => 'no-cache' 183 | ); 184 | if ($this->lastEventId !== '') { 185 | $headers['Last-Event-ID'] = $this->lastEventId; 186 | } 187 | 188 | $this->request = $this->browser->requestStreaming( 189 | 'GET', 190 | $this->url, 191 | $headers 192 | ); 193 | $this->request->then(function (ResponseInterface $response) { 194 | if ($response->getStatusCode() !== 200) { 195 | $this->readyState = self::CLOSED; 196 | $this->emit('error', array(new \UnexpectedValueException('Unexpected status code'))); 197 | $this->close(); 198 | return; 199 | } 200 | 201 | // match `Content-Type: text/event-stream` (case insensitive and ignore additional parameters) 202 | if (!preg_match('/^text\/event-stream(?:$|;)/i', $response->getHeaderLine('Content-Type'))) { 203 | $this->readyState = self::CLOSED; 204 | $this->emit('error', array(new \UnexpectedValueException('Unexpected Content-Type'))); 205 | $this->close(); 206 | return; 207 | } 208 | 209 | $stream = $response->getBody(); 210 | assert($stream instanceof ReadableStreamInterface); 211 | 212 | $buffer = ''; 213 | $stream->on('data', function ($chunk) use (&$buffer, $stream) { 214 | $messageEvents = preg_split( 215 | '/(?:\r\n|\r(?!\n)|\n){2}/S', 216 | $buffer . $chunk 217 | ); 218 | $buffer = array_pop($messageEvents); 219 | 220 | foreach ($messageEvents as $data) { 221 | $message = MessageEvent::parse($data, $this->lastEventId, $this->reconnectTime); 222 | $this->lastEventId = $message->lastEventId; 223 | 224 | if ($message->data !== '') { 225 | $this->emit($message->type, array($message)); 226 | if ($this->readyState === self::CLOSED) { 227 | break; 228 | } 229 | } 230 | } 231 | }); 232 | 233 | $stream->on('close', function () use (&$buffer) { 234 | $buffer = ''; 235 | $this->request = null; 236 | if ($this->readyState === self::OPEN) { 237 | $this->readyState = self::CONNECTING; 238 | 239 | $this->emit('error', [new \RuntimeException('Stream closed, reconnecting in ' . $this->reconnectTime . ' seconds')]); 240 | if ($this->readyState === self::CLOSED) { 241 | return; 242 | } 243 | 244 | $this->timer = $this->loop->addTimer($this->reconnectTime, function () { 245 | $this->timer = null; 246 | $this->request(); 247 | }); 248 | } 249 | }); 250 | 251 | $this->readyState = self::OPEN; 252 | $this->emit('open'); 253 | })->then(null, function ($e) { 254 | $this->request = null; 255 | if ($this->readyState === self::CLOSED) { 256 | return; 257 | } 258 | 259 | $this->emit('error', [$e]); 260 | if ($this->readyState === self::CLOSED) { 261 | return; 262 | } 263 | 264 | $this->timer = $this->loop->addTimer($this->reconnectTime, function () { 265 | $this->timer = null; 266 | $this->request(); 267 | }); 268 | }); 269 | } 270 | 271 | public function close() 272 | { 273 | $this->readyState = self::CLOSED; 274 | if ($this->request !== null) { 275 | $request = $this->request; 276 | $this->request = null; 277 | 278 | $request->then(function (ResponseInterface $response) { 279 | $response->getBody()->close(); 280 | }, function () { 281 | // ignore to avoid reporting unhandled rejection 282 | }); 283 | $request->cancel(); 284 | } 285 | 286 | if ($this->timer !== null) { 287 | $this->loop->cancelTimer($this->timer); 288 | $this->timer = null; 289 | } 290 | 291 | $this->removeAllListeners(); 292 | } 293 | } 294 | -------------------------------------------------------------------------------- /src/MessageEvent.php: -------------------------------------------------------------------------------- 1 | = 0) { 38 | $retryTime = $value * 0.001; 39 | } 40 | } 41 | 42 | if (substr($data, -1) === "\n") { 43 | $data = substr($data, 0, -1); 44 | } 45 | 46 | /** @throws void because parameter values are validated above already */ 47 | return new self($data, $id, $type); 48 | } 49 | 50 | /** @return string */ 51 | private static function utf8($string) 52 | { 53 | return \htmlspecialchars_decode(\htmlspecialchars($string, \ENT_NOQUOTES | \ENT_SUBSTITUTE, 'utf-8')); 54 | } 55 | 56 | /** @return bool */ 57 | private static function isUtf8($string) 58 | { 59 | return $string === self::utf8($string); 60 | } 61 | 62 | /** 63 | * Create a new `MessageEvent` instance. 64 | * 65 | * This is mostly used internally to represent each incoming message event 66 | * (see also [`message` event](#message-event)). Likewise, you can also use 67 | * this class in test cases to test how your application reacts to incoming 68 | * messages. 69 | * 70 | * The constructor validates and initializes all properties of this class. 71 | * It throws an `InvalidArgumentException` if any parameters are invalid. 72 | * 73 | * @param string $data message data (requires valid UTF-8 data, possibly multi-line) 74 | * @param string $lastEventId optional last event ID (defaults to empty string, requires valid UTF-8, no null bytes, single line) 75 | * @param string $type optional event type (defaults to "message", requires valid UTF-8, single line) 76 | * @throws \InvalidArgumentException if any parameters are invalid 77 | */ 78 | final public function __construct($data, $lastEventId = '', $type = 'message') 79 | { 80 | if (!self::isUtf8($data)) { 81 | throw new \InvalidArgumentException('Invalid $data given, must be valid UTF-8 string'); 82 | } 83 | if (!self::isUtf8($lastEventId) || \strpos($lastEventId, "\0") !== false || \strpos($lastEventId, "\r") !== false || \strpos($lastEventId, "\n") !== false) { 84 | throw new \InvalidArgumentException('Invalid $lastEventId given, must be valid UTF-8 string with no null bytes or newline characters'); 85 | } 86 | if (!self::isUtf8($type) || $type === '' || \strpos($type, "\r") !== false || \strpos($type, "\n")) { 87 | throw new \InvalidArgumentException('Invalid $type given, must be valid UTF-8 string with no newline characters'); 88 | } 89 | 90 | $this->data = \preg_replace("/\r\n?/", "\n", $data); 91 | $this->lastEventId = $lastEventId; 92 | $this->type = $type; 93 | } 94 | 95 | /** 96 | * @var string 97 | * @readonly 98 | */ 99 | public $data = ''; 100 | 101 | /** 102 | * @var string defaults to empty string 103 | * @readonly 104 | */ 105 | public $lastEventId = ''; 106 | 107 | /** 108 | * @var string defaults to "message" 109 | * @readonly 110 | */ 111 | public $type = 'message'; 112 | } 113 | --------------------------------------------------------------------------------