├── .gitignore ├── test ├── ab │ ├── docker_bootstrap.sh │ ├── fuzzingserver.json │ ├── fuzzingclient.json │ ├── testServer.php │ ├── README.md │ ├── run_ab_tests.sh │ └── clientRunner.php ├── bootstrap.php ├── TestCase.php ├── ABResultsTest.php ├── ServerTest.php ├── ClientTest.php └── MessageSubjectTest.php ├── phpunit.xml ├── src ├── WebsocketErrorException.php ├── Server.php ├── Client.php └── MessageSubject.php ├── .github └── workflows │ └── ci.yml ├── composer.json ├── README.md └── CHANGELOG.md /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | /composer.lock 3 | /test/ab/reports 4 | -------------------------------------------------------------------------------- /test/ab/docker_bootstrap.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -x 3 | 4 | echo "Running $0" 5 | 6 | echo Adding "$1 host.dock.internal" to /etc/hosts file 7 | 8 | echo $1 host.dock.internal >> /etc/hosts 9 | 10 | echo /etc/hosts contains: 11 | cat /etc/hosts 12 | echo 13 | -------------------------------------------------------------------------------- /test/ab/fuzzingserver.json: -------------------------------------------------------------------------------- 1 | { 2 | "url": "ws://127.0.0.1:9001" 3 | , "options": { 4 | "failByDrop": false 5 | } 6 | , "outdir": "./reports/clients" 7 | , "cases": ["*"] 8 | , "exclude-cases": ["10.*", "12.*", "13.*"] 9 | , "exclude-agent-cases": {} 10 | } 11 | -------------------------------------------------------------------------------- /test/ab/fuzzingclient.json: -------------------------------------------------------------------------------- 1 | { 2 | "options": {"failByDrop": false}, 3 | "outdir": "./reports/servers", 4 | 5 | "servers": [ 6 | {"agent": "RxWebsocketServer/0.0.0", 7 | "url": "ws://host.dock.internal:9002", 8 | "options": {"version": 18}} 9 | ], 10 | "cases": ["*"], 11 | "exclude-cases": ["10.*", "12.*", "13.*"], 12 | "exclude-agent-cases": {} 13 | } 14 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | test/ 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /test/bootstrap.php: -------------------------------------------------------------------------------- 1 | 1 && is_numeric($argv[1])) { 10 | echo "Setting test server to stop in " . $argv[1] . " seconds.\n"; 11 | $timerObservable = Observable::timer(1000 * $argv[1]); 12 | } 13 | 14 | $server = new \Rx\Websocket\Server("tcp://0.0.0.0:9002", true); 15 | 16 | $server 17 | ->takeUntil($timerObservable) 18 | ->subscribe(function (\Rx\Websocket\MessageSubject $ms) { 19 | $ms->subscribe($ms); 20 | }); 21 | -------------------------------------------------------------------------------- /src/WebsocketErrorException.php: -------------------------------------------------------------------------------- 1 | closeCode = $closeCode; 19 | } 20 | 21 | /** 22 | * @return int 23 | */ 24 | public function getCloseCode() 25 | { 26 | return $this->closeCode; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /test/ab/README.md: -------------------------------------------------------------------------------- 1 | This directory is used to run the [Autobahn Testsuite](http://autobahn.ws/testsuite/) against the Rx\Websocket library. 2 | 3 | ### Client tests (Rx\Websocket is the client being tested) 4 | 5 | Start the fuzzing server: 6 | ``` 7 | wstest -m fuzzingserver -s fuzzingserver.json 8 | ``` 9 | 10 | In another shell, run the test client: 11 | ``` 12 | php clientRunner.php 13 | ``` 14 | 15 | ### Server tests (Rx\Websocket is the server being tested) 16 | 17 | Start the server: 18 | ``` 19 | php testServer.php 20 | ``` 21 | 22 | In another shell, run the fuzzing client: 23 | ``` 24 | wstest -m fuzzingclient -s fuzzingclient.json 25 | ``` -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | PHPUnit: 9 | name: PHPUnit (PHP ${{ matrix.php }} on ${{ matrix.os }}) 10 | runs-on: ${{ matrix.os }} 11 | strategy: 12 | matrix: 13 | os: 14 | - ubuntu-22.04 15 | php: 16 | - 8.3 17 | - 8.2 18 | - 8.1 19 | - 8.0 20 | - 7.4 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: shivammathur/setup-php@v2 24 | with: 25 | php-version: ${{ matrix.php }} 26 | - run: composer install 27 | - run: sh test/ab/run_ab_tests.sh 28 | - run: vendor/bin/phpunit 29 | -------------------------------------------------------------------------------- /test/TestCase.php: -------------------------------------------------------------------------------- 1 | scheduler; 18 | }); 19 | } 20 | 21 | public static function resetScheduler() 22 | { 23 | $ref = new \ReflectionClass(Scheduler::class); 24 | $props = $ref->getProperties(); 25 | 26 | foreach ($props as $prop) { 27 | $prop->setAccessible(true); 28 | $prop->setValue($prop, null); 29 | $prop->setAccessible(false); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /test/ab/run_ab_tests.sh: -------------------------------------------------------------------------------- 1 | cd test/ab 2 | 3 | docker run --rm \ 4 | -d \ 5 | -v ${PWD}:/config \ 6 | -v ${PWD}/reports:/reports \ 7 | -p 9001:9001 \ 8 | --name rxwsfuzzingserver \ 9 | crossbario/autobahn-testsuite wstest -m fuzzingserver -s /config/fuzzingserver.json 10 | sleep 10 11 | php -d memory_limit=256M clientRunner.php 12 | 13 | 14 | 15 | sleep 10 16 | 17 | 18 | php -d memory_limit=256M testServer.php & 19 | SERVER_PID=$! 20 | sleep 3 21 | 22 | if [ "$RUNNER_OS" = "Linux" ]; then 23 | IPADDR=`hostname -I | cut -f 1 -d ' '` 24 | else 25 | IPADDR=`ifconfig | grep "inet " | grep -Fv 127.0.0.1 | awk '{print $2}' | head -1 | tr -d 'adr:'` 26 | fi 27 | 28 | docker run --rm \ 29 | \ 30 | -v ${PWD}:/config \ 31 | -v ${PWD}/reports:/reports \ 32 | --name rxwsfuzzingclient \ 33 | crossbario/autobahn-testsuite /bin/sh -c "sh /config/docker_bootstrap.sh $IPADDR; wstest -m fuzzingclient -s /config/fuzzingclient.json" 34 | 35 | sleep 12 36 | 37 | kill $SERVER_PID 38 | -------------------------------------------------------------------------------- /test/ABResultsTest.php: -------------------------------------------------------------------------------- 1 | assertFileExists($fileName); 10 | 11 | $resultsJson = file_get_contents($fileName); 12 | $results = json_decode($resultsJson); 13 | 14 | $agentName = array_keys(get_object_vars($results))[0]; 15 | 16 | foreach ($results->$agentName as $name => $result) { 17 | if ($result->behavior === "INFORMATIONAL") { 18 | continue; 19 | } 20 | $this->assertContains($result->behavior, ['OK', 'NON-STRICT'], "Autobahn test case " . $name . " in " . $fileName); 21 | } 22 | } 23 | 24 | public function testAutobahnClientResults() 25 | { 26 | $this->verifyAutobahnResults(__DIR__ . '/ab/reports/clients/index.json'); 27 | } 28 | 29 | public function testAutobahnServerResults() 30 | { 31 | $this->verifyAutobahnResults(__DIR__ . '/ab/reports/servers/index.json'); 32 | } 33 | } -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rx/websocket", 3 | "type": "library", 4 | "description": "Websockets for PHP using Rx", 5 | "keywords": [ 6 | "websocket", 7 | "websockets", 8 | "rfc6455", 9 | "rx", 10 | "rx.php", 11 | "rxphp", 12 | "react", 13 | "reactive" 14 | ], 15 | "license": "MIT", 16 | "authors": [ 17 | { 18 | "name": "Matt Bonneau", "email": "matt@bonneau.net", "role": "Developer" 19 | }, 20 | 21 | { 22 | "name": "David Dan", "email": "davidwdan@gmail.com", "role": "Developer" 23 | } 24 | ], 25 | "autoload": { 26 | "psr-4": { 27 | "Rx\\Websocket\\": "src/" 28 | } 29 | }, 30 | "autoload-dev": { 31 | "psr-4": { 32 | "Rx\\Websocket\\Test\\": "test/", 33 | "Rx\\": "vendor/reactivex/rxphp/test/Rx" 34 | }, 35 | "files": [ 36 | "vendor/reactivex/rxphp/test/helper-functions.php" 37 | ] 38 | }, 39 | "require": { 40 | "react/http": "1.5.* | 1.6.* | 1.7.* | 1.8.*", 41 | "ratchet/rfc6455": "^0.3", 42 | "reactivex/rxphp": "^2.0.1", 43 | "react/event-loop": "^1.2" 44 | }, 45 | "require-dev": { 46 | "phpunit/phpunit": "^9 | ^10" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CI status](https://github.com/RxPHP/RxWebsocket/workflows/CI/badge.svg)](https://github.com/RxPHP/RxWebsocket/actions) 2 | 3 | Rx\Websocket is a PHP Websocket library. 4 | 5 | ## Usage 6 | 7 | #### Client 8 | ```php 9 | $client = new \Rx\Websocket\Client('ws://127.0.0.1:9191/'); 10 | 11 | $client->subscribe( 12 | function (\Rx\Websocket\MessageSubject $ms) { 13 | $ms->subscribe( 14 | function ($message) { 15 | echo $message . "\n"; 16 | } 17 | ); 18 | 19 | $sayHello = function () use ($ms) { 20 | $ms->onNext('Hello'); 21 | }; 22 | 23 | $sayHello(); 24 | \EventLoop\addPeriodicTimer(5, $sayHello); 25 | }, 26 | function ($error) { 27 | // connection errors here 28 | }, 29 | function () { 30 | // stopped trying to connect here 31 | } 32 | ); 33 | ``` 34 | 35 | #### An Echo Server 36 | ```php 37 | $server = new \Rx\Websocket\Server('127.0.0.1:9191'); 38 | 39 | $server->subscribe(function (\Rx\Websocket\MessageSubject $cs) { 40 | $cs->subscribe($cs); 41 | }); 42 | ``` 43 | 44 | #### Server that dumps everything to the console 45 | ```php 46 | $server = new \Rx\Websocket\Server('127.0.0.1:9191'); 47 | 48 | $server->subscribe(function (\Rx\Websocket\MessageSubject $cs) { 49 | $cs->subscribe(function ($message) { 50 | echo $message; 51 | }); 52 | }); 53 | ``` 54 | 55 | ## Installation 56 | 57 | Using [composer](https://getcomposer.org/): 58 | 59 | ```composer require rx/websocket``` 60 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 2.2.0 2 | 3 | - Changes for PHP 8.3 4 | - Test newer versions of PHP 5 | 6 | # 2.1.8 7 | 8 | - Limit react/http dependencies 9 | 10 | # 2.1.7 11 | 12 | - Allow http headers to be set on client 13 | 14 | # 2.1.6 15 | 16 | - Remove optional before required parameters to MessageSubject 17 | 18 | # 2.1.5 19 | 20 | - Bump react/http to version ^1 21 | 22 | # 2.1.4 23 | 24 | - Allow Ratchet/RFC6455 0.3 25 | 26 | # 2.1.3 27 | 28 | - Forward compatibility with voryx/event-loop 3.0 while supporting 2.0 29 | 30 | # 2.1.2 31 | 32 | - Update deps 33 | 34 | # 2.1.1 35 | 36 | - Emit socket errors instead of throwing 37 | 38 | # 2.1.0 39 | 40 | - Added websocket ping keepalive 41 | 42 | # 2.0.0 43 | 44 | - Updated react libraries (http/http-client) 45 | - Changed API to allow passing of `Connector` 46 | 47 | before 48 | ```PHP 49 | $server = new \Rx\Websocket\Server('127.0.0.1', 9191); 50 | ``` 51 | after 52 | ```PHP 53 | $server = new \Rx\Websocket\Server('127.0.0.1:9191'); 54 | ``` 55 | 56 | # 1.0.2 57 | 58 | - End the request not the response when dispose is called ([b77c5118](https://github.com/RxPHP/RxWebsocket/commit/b77c5118c14d34e034b19383974337aec05d787a)) 59 | 60 | # 1.0.1 61 | 62 | - Connection errors are now sent to `onError` #6 ([a880353](https://github.com/RxPHP/RxWebsocket/commit/a88035322fea54638d67d67985e8f938200155cd)) 63 | 64 | # 1.0.0 65 | 66 | - Upgrade to RxPHP v2 67 | 68 | # 0.10.0 69 | 70 | ## Changes/Additions 71 | 72 | - Project now uses [RFC6455](https://github.com/ratchetphp/RFC6455) library for underlying protocol support 73 | - Message subject now emits `Ratchet\RFC6455\Messaging\Message` instead of `Rx\Websocket\Message` 74 | - `Client` is no longer a `Subject` 75 | -------------------------------------------------------------------------------- /test/ServerTest.php: -------------------------------------------------------------------------------- 1 | subscribe(); 19 | 20 | Loop::addTimer(0.1, function () use ($serverDisp) { 21 | $serverDisp->dispose(); 22 | }); 23 | 24 | Loop::get()->run(); 25 | 26 | // we are making sure it is not hanging - if it gets here it worked 27 | $this->assertTrue(true); 28 | } 29 | 30 | public function testServerShutsDownAfterOneConnection() 31 | { 32 | $server = new Server('127.0.0.1:1236'); 33 | 34 | $serverDisp = $server->take(1)->subscribe( 35 | function (MessageSubject $ms) { 36 | $ms->map('strrev')->subscribe($ms); 37 | } 38 | ); 39 | 40 | $value = null; 41 | 42 | Loop::addTimer(0.1, function () use (&$value) { 43 | $client = new Client('ws://127.0.0.1:1236'); 44 | $client 45 | ->flatMap(function (MessageSubject $ms) { 46 | $ms->send('Hello'); 47 | return $ms; 48 | }) 49 | ->take(1) 50 | ->subscribe(function ($x) use (&$value) { 51 | $this->assertNull($value); 52 | $value = $x; 53 | }); 54 | }); 55 | 56 | Loop::get()->run(); 57 | 58 | $this->assertEquals('olleH', $value); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /test/ClientTest.php: -------------------------------------------------------------------------------- 1 | subscribe( 23 | null, 24 | function ($err) use (&$errored) { 25 | $errored = true; 26 | } 27 | ); 28 | 29 | $loop->run(); 30 | 31 | $this->assertTrue($errored); 32 | } 33 | 34 | public function testPassthroughOfHeaders() { 35 | $writtenData = ''; 36 | 37 | $connection = $this->getMockBuilder(ConnectionInterface::class) 38 | ->getMock(); 39 | $connection 40 | ->method('write') 41 | ->with($this->callback(function($data) use (&$writtenData) { $writtenData .= $data; return true;})) 42 | ->willReturn(true); 43 | 44 | 45 | $connector = $this->getMockBuilder(ConnectorInterface::class) 46 | ->getMock(); 47 | 48 | $connector 49 | ->expects($this->once()) 50 | ->method('connect') 51 | ->willReturn(resolve($connection)); 52 | 53 | $loop = Factory::create(); 54 | 55 | // expecting connection error 56 | $client = new \Rx\Websocket\Client( 57 | 'ws://127.0.0.1:12340/', 58 | false, 59 | [], 60 | $loop, 61 | $connector, 62 | 60000, 63 | [ 64 | 'X-Test-Header' => 'test header value' 65 | ] 66 | ); 67 | 68 | $client->subscribe( 69 | null, 70 | function ($err) use (&$errored) { 71 | $errored = true; 72 | } 73 | ); 74 | 75 | // This should be the Request 76 | $requestRaw = $writtenData; 77 | $request = parse_request($requestRaw); 78 | $this->assertEquals(['test header value'], $request->getHeader('X-Test-Header')); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /test/ab/clientRunner.php: -------------------------------------------------------------------------------- 1 | subscribe( 16 | function (\Rx\Websocket\MessageSubject $messages) { 17 | echo "Report runner connected.\n"; 18 | $messages->subscribe(new \Rx\Observer\CallbackObserver( 19 | function ($x) use ($messages) { 20 | echo "Message received by report runner connection: " . $x . "\n";; 21 | }, 22 | [$messages, "onError"], 23 | [$messages, "onCompleted"] 24 | )); 25 | }, 26 | function (Throwable $error) { 27 | echo "Error on report runner connection:" . $error->getMessage() . "\n"; 28 | echo "Seeing an error here might be normal. Network trace shows that AB fuzzingserver\n"; 29 | echo "disconnects without sending an HTTP response.\n"; 30 | }, 31 | function () { 32 | echo "Report runner connection completed.\n"; 33 | } 34 | ); 35 | }; 36 | 37 | $runIndividualTest = function ($case, $timeout = 60000) { 38 | echo "Running " . $case . "\n"; 39 | 40 | $casePath = "/runCase?case={$case}&agent=" . AGENT . "-" . $timeout; 41 | 42 | $client = new \Rx\Websocket\Client("ws://127.0.0.1:9001" . $casePath, true, [], null, null, $timeout); 43 | 44 | $deferred = new \React\Promise\Deferred(); 45 | 46 | $client->subscribe( 47 | function (\Rx\Websocket\MessageSubject $messages) { 48 | $messages->subscribe(new \Rx\Observer\CallbackObserver( 49 | function ($x) use ($messages) { 50 | //echo $x . "\n"; 51 | $messages->onNext($x); 52 | }, 53 | [$messages, "onError"], 54 | [$messages, "onCompleted"] 55 | )); 56 | }, 57 | function ($error) use ($case, $deferred) { 58 | echo "Error on " . $case . "\n"; 59 | $deferred->reject($error); 60 | }, 61 | function () use ($case, $deferred) { 62 | echo "Finished " . $case . "\n"; 63 | $deferred->resolve(null); 64 | } 65 | ); 66 | 67 | return $deferred->promise(); 68 | }; 69 | 70 | $runTests = function ($testCount) use ($runIndividualTest, $runReports) { 71 | echo "Server would like us to run " . $testCount . " tests.\n"; 72 | 73 | $i = 0; 74 | 75 | $deferred = new \React\Promise\Deferred(); 76 | 77 | $runNextCase = function () use (&$i, &$runNextCase, $testCount, $deferred, $runIndividualTest) { 78 | $i++; 79 | if ($i > $testCount) { 80 | $deferred->resolve(null); 81 | return; 82 | } 83 | $runIndividualTest($i, 60000)->then(function ($result) use ($runIndividualTest, &$i) { 84 | // Use this if you want to run with no keepalive 85 | //return $runIndividualTest($i, 0); 86 | return $result; 87 | })->then($runNextCase); 88 | }; 89 | 90 | $runNextCase(); 91 | 92 | $deferred->promise()->then($runReports); 93 | }; 94 | 95 | // get the tests that need to run 96 | $client = new \Rx\Websocket\Client("ws://127.0.0.1:9001/getCaseCount"); 97 | 98 | $client 99 | ->flatMap(function ($x) { 100 | return $x; 101 | }) 102 | ->subscribe( 103 | $runTests, 104 | function ($error) { 105 | echo $error . "\n"; 106 | } 107 | ); 108 | -------------------------------------------------------------------------------- /src/Server.php: -------------------------------------------------------------------------------- 1 | bindAddress = $bindAddressOrPort; 36 | $this->useMessageObject = $useMessageObject; 37 | $this->subProtocols = $subProtocols; 38 | $this->loop = $loop ?: \React\EventLoop\Loop::get(); 39 | $this->keepAlive = $keepAlive; 40 | } 41 | 42 | public function _subscribe(ObserverInterface $observer): DisposableInterface 43 | { 44 | $socket = new SocketServer($this->bindAddress, [], $this->loop); 45 | 46 | $negotiator = new ServerNegotiator(new RequestVerifier()); 47 | if (!empty($this->subProtocols)) { 48 | $negotiator->setSupportedSubProtocols($this->subProtocols); 49 | } 50 | 51 | $http = new HttpServer( 52 | $this->loop, 53 | new StreamingRequestMiddlewareAlias(), 54 | function (ServerRequestInterface $request) use ($negotiator, $observer) { 55 | // cram the remote address into the header in our own X- header so 56 | // the user will have access to it 57 | $request = $request->withAddedHeader('X-RxWebsocket-Remote-Address', $request->getServerParams()['REMOTE_ADDR'] ?? ''); 58 | 59 | $negotiatorResponse = $negotiator->handshake($request); 60 | 61 | $requestStream = new ThroughStream(); 62 | $responseStream = new ThroughStream(); 63 | 64 | $response = new Response( 65 | $negotiatorResponse->getStatusCode(), 66 | array_merge( 67 | $negotiatorResponse->getHeaders() 68 | ), 69 | new CompositeStream( 70 | $responseStream, 71 | $requestStream 72 | ) 73 | ); 74 | 75 | if ($negotiatorResponse->getStatusCode() !== 101) { 76 | $responseStream->end(str($negotiatorResponse)); 77 | return new EmptyDisposable(); 78 | } 79 | 80 | $subProtocol = ""; 81 | if (count($negotiatorResponse->getHeader('Sec-WebSocket-Protocol')) > 0) { 82 | $subProtocol = $negotiatorResponse->getHeader('Sec-WebSocket-Protocol')[0]; 83 | } 84 | 85 | $messageSubject = new MessageSubject( 86 | new AnonymousObservable( 87 | function (ObserverInterface $observer) use ($requestStream) { 88 | $requestStream->on('data', function ($data) use ($observer) { 89 | $observer->onNext($data); 90 | }); 91 | $requestStream->on('error', function ($error) use ($observer) { 92 | $observer->onError($error); 93 | }); 94 | $requestStream->on('close', function () use ($observer) { 95 | $observer->onCompleted(); 96 | }); 97 | $requestStream->on('end', function () use ($observer) { 98 | $observer->onCompleted(); 99 | }); 100 | 101 | return new CallbackDisposable( 102 | function () use ($requestStream) { 103 | $requestStream->close(); 104 | } 105 | ); 106 | } 107 | ), 108 | new CallbackObserver( 109 | function ($x) use ($responseStream) { 110 | $responseStream->write($x); 111 | }, 112 | function ($error) use ($responseStream) { 113 | $responseStream->close(); 114 | }, 115 | function () use ($responseStream) { 116 | $responseStream->end(); 117 | } 118 | ), 119 | false, 120 | $this->useMessageObject, 121 | $subProtocol, 122 | $request, 123 | $negotiatorResponse, 124 | $this->keepAlive 125 | ); 126 | 127 | $observer->onNext($messageSubject); 128 | 129 | return $response; 130 | } 131 | ); 132 | 133 | $http->listen($socket); 134 | 135 | return new CallbackDisposable(function () use ($socket) { 136 | $socket->close(); 137 | }); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/Client.php: -------------------------------------------------------------------------------- 1 | url = $url; 47 | $this->useMessageObject = $useMessageObject; 48 | $this->subProtocols = $subProtocols; 49 | $this->loop = $loop ?: \React\EventLoop\Loop::get(); 50 | $this->connector = $connector; 51 | $this->keepAlive = $keepAlive; 52 | $this->headers = $headers; 53 | } 54 | 55 | public function _subscribe(ObserverInterface $clientObserver): DisposableInterface 56 | { 57 | $client = new HttpClient($this->loop, $this->connector); 58 | 59 | $cNegotiator = new ClientNegotiator(); 60 | 61 | /** @var Psr7Request $nRequest */ 62 | $nRequest = $cNegotiator->generateRequest(new Uri($this->url)); 63 | 64 | if (!empty($this->subProtocols)) { 65 | $nRequest = $nRequest 66 | ->withoutHeader('Sec-WebSocket-Protocol') 67 | ->withHeader('Sec-WebSocket-Protocol', $this->subProtocols); 68 | } 69 | 70 | $headers = $nRequest->getHeaders(); 71 | 72 | $flatHeaders = []; 73 | foreach ($headers as $k => $v) { 74 | $flatHeaders[$k] = $v[0]; 75 | } 76 | 77 | foreach ($this->headers as $k => $v) { 78 | $flatHeaders[$k] = $v; 79 | } 80 | 81 | $request = $client->request('GET', $this->url, $flatHeaders, '1.1'); 82 | 83 | $request->on('error', function ($error) use ($clientObserver) { 84 | $clientObserver->onError($error); 85 | }); 86 | 87 | $request->on('response', function (ResponseInterface $response, ConnectionInterface $request) use ($flatHeaders, $cNegotiator, $nRequest, $clientObserver) { 88 | if ($response->getStatusCode() !== 101) { 89 | $clientObserver->onError(new \Exception('Unexpected response code ' . $response->getStatusCode())); 90 | return; 91 | } 92 | 93 | $psr7Response = new Psr7Response( 94 | $response->getStatusCode(), 95 | $response->getHeaders(), 96 | null, 97 | $response->getProtocolVersion() 98 | ); 99 | 100 | $psr7Request = new Psr7Request('GET', $this->url, $flatHeaders); 101 | 102 | if (!$cNegotiator->validateResponse($psr7Request, $psr7Response)) { 103 | $clientObserver->onError(new \Exception('Invalid response')); 104 | return; 105 | } 106 | 107 | $subprotoHeader = $psr7Response->getHeader('Sec-WebSocket-Protocol'); 108 | 109 | $clientObserver->onNext(new MessageSubject( 110 | new AnonymousObservable(function (ObserverInterface $observer) use ($response, $request, $clientObserver) { 111 | 112 | $request->on('data', function ($data) use ($observer) { 113 | $observer->onNext($data); 114 | }); 115 | 116 | $request->on('error', function ($e) use ($observer) { 117 | $observer->onError($e); 118 | }); 119 | 120 | $request->on('close', function () use ($observer, $clientObserver) { 121 | $observer->onCompleted(); 122 | 123 | // complete the parent observer - we only do 1 connection 124 | $clientObserver->onCompleted(); 125 | }); 126 | 127 | $request->on('end', function () use ($observer, $clientObserver) { 128 | $observer->onCompleted(); 129 | 130 | // complete the parent observer - we only do 1 connection 131 | $clientObserver->onCompleted(); 132 | }); 133 | 134 | return new CallbackDisposable(function () use ($request) { 135 | $request->end(); 136 | }); 137 | }), 138 | new CallbackObserver( 139 | function ($x) use ($request) { 140 | $request->write($x); 141 | }, 142 | function ($e) use ($request) { 143 | $request->close(); 144 | }, 145 | function () use ($request) { 146 | $request->end(); 147 | } 148 | ), 149 | true, 150 | $this->useMessageObject, 151 | $subprotoHeader, 152 | $nRequest, 153 | $psr7Response, 154 | $this->keepAlive 155 | )); 156 | }); 157 | 158 | // empty write to force connection and header send 159 | $request->write(''); 160 | 161 | return new CallbackDisposable(function () use ($request) { 162 | $request->close(); 163 | }); 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /test/MessageSubjectTest.php: -------------------------------------------------------------------------------- 1 | subscribe(new CallbackObserver( 35 | function ($x) { 36 | $this->fail('Was not expecting a message.'); 37 | }, 38 | function (\Exception $exception) use (&$closeCode) { 39 | $this->assertInstanceOf(WebsocketErrorException::class, $exception); 40 | 41 | /** @var WebsocketErrorException $exception */ 42 | $closeCode = $exception->getCloseCode(); 43 | }, 44 | function () use (&$closeCode) { 45 | $this->fail('Was not expecting observable to complete'); 46 | } 47 | )); 48 | 49 | $closeFrame = new Frame(pack('n', 4000), true, Frame::OP_CLOSE); 50 | $closeFrame->maskPayload(); 51 | $rawDataIn->onNext($closeFrame->getContents()); 52 | 53 | $this->assertEquals(4000, $closeCode); 54 | } 55 | 56 | public function testPingPongTimeout() 57 | { 58 | $dataIn = $this->createHotObservable([ 59 | onNext(200, (new Frame('', true, Frame::OP_TEXT))->getContents()), 60 | onNext(205, (new Frame('', true, Frame::OP_TEXT))->getContents()), 61 | ]); 62 | 63 | $dataOut = new Subject(); 64 | 65 | $ms = new MessageSubject( 66 | $dataIn, 67 | $dataOut, 68 | true, 69 | false, 70 | '', 71 | new Request('GET', '/ws'), 72 | new Response(), 73 | 300 74 | ); 75 | 76 | $result = $this->scheduler->startWithCreate(function () use ($dataOut) { 77 | return $dataOut; 78 | }); 79 | 80 | $this->assertMessages([ 81 | onNext(650, (new Frame('', true, Frame::OP_PING))->getContents()), 82 | onError(950, new TimeoutException()) 83 | ], $result->getMessages()); 84 | } 85 | 86 | public function testPingPong() 87 | { 88 | $dataIn = $this->createHotObservable([ 89 | onNext(200, (new Frame('', true, Frame::OP_TEXT))->getContents()), 90 | onNext(205, (new Frame('', true, Frame::OP_TEXT))->getContents()), 91 | onNext(651, (new Frame('', true, Frame::OP_PONG))->getContents()) 92 | ]); 93 | 94 | $dataOut = new Subject(); 95 | 96 | $ms = new MessageSubject( 97 | $dataIn, 98 | $dataOut, 99 | true, 100 | false, 101 | '', 102 | new Request('GET', '/ws'), 103 | new Response(), 104 | 300 105 | ); 106 | 107 | $result = $this->scheduler->startWithDispose(function () use ($dataOut) { 108 | return $dataOut; 109 | }, 2000); 110 | 111 | $this->assertMessages([ 112 | onNext(650, (new Frame('', true, Frame::OP_PING))->getContents()), 113 | onNext(951, (new Frame('', true, Frame::OP_PING))->getContents()), 114 | onError(1251, new TimeoutException()) 115 | ], $result->getMessages()); 116 | } 117 | 118 | public function testPingPongDataSuppressesPing() 119 | { 120 | $dataIn = $this->createHotObservable([ 121 | onNext(201, (new Frame('', true, Frame::OP_TEXT))->getContents()), 122 | onNext(205, (new Frame('', true, Frame::OP_TEXT))->getContents()), 123 | onNext(649, (new Frame('', true, Frame::OP_TEXT))->getContents()) 124 | ]); 125 | 126 | $dataOut = new Subject(); 127 | 128 | $ms = new MessageSubject( 129 | $dataIn, 130 | $dataOut, 131 | true, 132 | false, 133 | '', 134 | new Request('GET', '/ws'), 135 | new Response(), 136 | 300 137 | ); 138 | 139 | $result = $this->scheduler->startWithDispose(function () use ($dataOut) { 140 | return $dataOut; 141 | }, 2000); 142 | 143 | $this->assertMessages([ 144 | onNext(949, (new Frame('', true, Frame::OP_PING))->getContents()), 145 | onError(1249, new TimeoutException()) 146 | ], $result->getMessages()); 147 | } 148 | 149 | public function testDisposeOnMessageSubjectClosesConnection() 150 | { 151 | $dataIn = $this->createHotObservable([ 152 | onNext(201, (new Frame('', true, Frame::OP_TEXT))->getContents()), 153 | onNext(205, (new Frame('', true, Frame::OP_TEXT))->getContents()), 154 | ]); 155 | 156 | $dataOut = new MockObserver($this->scheduler); 157 | 158 | $ms = new MessageSubject( 159 | $dataIn, 160 | $dataOut, 161 | true, 162 | false, 163 | '', 164 | new Request('GET', '/ws'), 165 | new Response(), 166 | 300 167 | ); 168 | 169 | $result = $this->scheduler->startWithDispose(function () use ($ms) { 170 | return $ms; 171 | }, 300); 172 | 173 | $this->assertMessages([ 174 | onNext(201, ''), 175 | onNext(205, ''), 176 | ], $result->getMessages()); 177 | 178 | $this->assertSubscriptions([ 179 | subscribe(0,300) 180 | ], $dataIn->getSubscriptions()); 181 | 182 | $this->assertMessages([ 183 | onCompleted(300) 184 | ], $dataOut->getMessages()); 185 | } 186 | 187 | public function testMessageSubjectErrorsIfDataInStreamEndsClosesOrErrors() { 188 | $dataIn = $this->createHotObservable([ 189 | onNext(201, (new Frame('', true, Frame::OP_TEXT))->getContents()), 190 | onCompleted(205) 191 | ]); 192 | 193 | $dataOut = new MockObserver($this->scheduler); 194 | 195 | $ms = new MessageSubject( 196 | $dataIn, 197 | $dataOut, 198 | true, 199 | false, 200 | '', 201 | new Request('GET', '/ws'), 202 | new Response(), 203 | 300 204 | ); 205 | 206 | $result = $this->scheduler->startWithDispose(function () use ($ms) { 207 | return $ms; 208 | }, 500); 209 | 210 | $this->assertMessages([ 211 | onNext(201, ''), 212 | onError(205, new WebsocketErrorException(Frame::CLOSE_ABNORMAL)) 213 | ], $result->getMessages()); 214 | 215 | $this->assertSubscriptions([ 216 | subscribe(0,205) 217 | ], $dataIn->getSubscriptions()); 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/MessageSubject.php: -------------------------------------------------------------------------------- 1 | request = $request; 44 | $this->response = $response; 45 | $this->rawDataIn = $rawDataIn->share(); 46 | $this->rawDataOut = $rawDataOut; 47 | $this->mask = $mask; 48 | $this->subProtocol = $subProtocol; 49 | 50 | $messageBuffer = new MessageBuffer( 51 | new CloseFrameChecker(), 52 | function (MessageInterface $msg) use ($useMessageObject) { 53 | parent::onNext($useMessageObject ? $msg : $msg->getPayload()); 54 | }, 55 | function (FrameInterface $frame) { 56 | switch ($frame->getOpcode()) { 57 | case Frame::OP_PING: 58 | $this->sendFrame(new Frame($frame->getPayload(), true, Frame::OP_PONG)); 59 | return; 60 | case Frame::OP_CLOSE: 61 | // send close frame to remote 62 | $this->sendFrame($frame); 63 | 64 | // get close code 65 | list($closeCode) = array_merge(unpack('n*', substr($frame->getPayload(), 0, 2))); 66 | if ($closeCode !== 1000) { 67 | // emit close code as error 68 | $exception = new WebsocketErrorException($closeCode); 69 | parent::onError($exception); 70 | } 71 | 72 | $this->rawDataOut->onCompleted(); 73 | 74 | parent::onCompleted(); 75 | 76 | $this->rawDataDisp->dispose(); 77 | return; 78 | } 79 | }, 80 | !$this->mask 81 | ); 82 | 83 | // keepAlive 84 | $keepAliveObs = Observable::empty(); 85 | if ($keepAlive > 0) { 86 | $keepAliveObs = $this->rawDataIn 87 | ->startWith(0) 88 | ->throttle($keepAlive / 2) 89 | ->map(function () use ($keepAlive, $rawDataOut) { 90 | return Observable::timer($keepAlive) 91 | ->do(function () use ($rawDataOut) { 92 | $frame = new Frame('', true, Frame::OP_PING); 93 | if ($this->mask) { 94 | $frame->maskPayload(); 95 | } 96 | $rawDataOut->onNext($frame->getContents()); 97 | }) 98 | ->delay($keepAlive) 99 | ->do(function () use ($rawDataOut) { 100 | $rawDataOut->onError(new TimeoutException()); 101 | }); 102 | }) 103 | ->switch() 104 | ->flatMapTo(Observable::never()) 105 | // This detects close or error notifications from the raw data input and stops the keepalive 106 | ->takeUntil($this->rawDataIn 107 | ->materialize() 108 | ->filter(function($notification) { return ! $notification instanceof OnNextNotification; }) 109 | ->take(1)); 110 | } 111 | 112 | $this->rawDataDisp = $this->rawDataIn 113 | ->merge($keepAliveObs) 114 | ->subscribe( 115 | [$messageBuffer, 'onData'], 116 | function (\Throwable $e) { parent::onError($e); }, 117 | // onCompleted needs to send an error. If a close frame comes in, this should be disposed already 118 | function () { parent::onError(new WebsocketErrorException(Frame::CLOSE_ABNORMAL)); } 119 | ); 120 | 121 | $this->subProtocol = $subProtocol; 122 | } 123 | 124 | protected function _subscribe(ObserverInterface $observer): DisposableInterface 125 | { 126 | $disposable = new CompositeDisposable([ 127 | parent::_subscribe($observer), 128 | $this->rawDataDisp, 129 | new CallbackDisposable([$this->rawDataOut, 'onCompleted']) 130 | ]); 131 | 132 | return $disposable; 133 | } 134 | 135 | private function createCloseFrame(int $closeCode = Frame::CLOSE_NORMAL): Frame 136 | { 137 | $frame = new Frame(pack('n', $closeCode), true, Frame::OP_CLOSE); 138 | if ($this->mask) { 139 | $frame->maskPayload(); 140 | } 141 | return $frame; 142 | } 143 | 144 | public function send($value) 145 | { 146 | $this->onNext($value); 147 | } 148 | 149 | public function sendFrame(Frame $frame) 150 | { 151 | if ($this->mask) { 152 | $this->rawDataOut->onNext($frame->maskPayload()->getContents()); 153 | return; 154 | } 155 | 156 | $this->rawDataOut->onNext($frame->getContents()); 157 | } 158 | 159 | // The ObserverInterface is commandeered by this class. We will use the parent:: stuff ourselves for notifying 160 | // subscribers 161 | public function onNext($value) 162 | { 163 | if ($value instanceof Message) { 164 | $this->sendFrame(new Frame($value, true, $value->isBinary() ? Frame::OP_BINARY : Frame::OP_TEXT)); 165 | return; 166 | } 167 | $this->sendFrame(new Frame($value)); 168 | } 169 | 170 | public function onError(\Throwable $exception) 171 | { 172 | $this->rawDataDisp->dispose(); 173 | 174 | parent::onError($exception); 175 | } 176 | 177 | public function onCompleted() 178 | { 179 | $this->sendFrame($this->createCloseFrame()); 180 | 181 | parent::onCompleted(); 182 | } 183 | 184 | public function getSubProtocol(): string 185 | { 186 | return $this->subProtocol; 187 | } 188 | 189 | public function getRequest(): RequestInterface 190 | { 191 | return $this->request; 192 | } 193 | 194 | public function getResponse(): ResponseInterface 195 | { 196 | return $this->response; 197 | } 198 | } 199 | --------------------------------------------------------------------------------