├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── other-issue.md └── workflows │ └── acceptance.yml ├── .gitignore ├── COPYING.md ├── Makefile ├── README.md ├── composer.json ├── docs ├── Changelog.md ├── Client.md ├── Contributing.md ├── Examples.md ├── Message.md └── Server.md ├── examples ├── echoserver.php ├── random_client.php ├── random_server.php └── send.php ├── lib ├── BadOpcodeException.php ├── BadUriException.php ├── Client.php ├── Connection.php ├── ConnectionException.php ├── Exception.php ├── Message │ ├── Binary.php │ ├── Close.php │ ├── Factory.php │ ├── Message.php │ ├── Ping.php │ ├── Pong.php │ └── Text.php ├── OpcodeTrait.php ├── Server.php └── TimeoutException.php ├── phpunit.xml.dist └── tests ├── ClientTest.php ├── ExceptionTest.php ├── MessageTest.php ├── README.md ├── ServerTest.php ├── bootstrap.php ├── mock ├── EchoLog.php ├── MockSocket.php ├── mock-socket.php ├── payload.128.txt └── payload.65536.txt └── scripts ├── client.close.json ├── client.connect-authed.json ├── client.connect-bad-context.json ├── client.connect-bad-stream.json ├── client.connect-context.json ├── client.connect-default-port-ws.json ├── client.connect-default-port-wss.json ├── client.connect-error.json ├── client.connect-extended.json ├── client.connect-failed.json ├── client.connect-handshake-error.json ├── client.connect-handshake-failure.json ├── client.connect-headers.json ├── client.connect-invalid-key.json ├── client.connect-invalid-upgrade.json ├── client.connect-persistent-failure.json ├── client.connect-persistent.json ├── client.connect-root.json ├── client.connect-timeout.json ├── client.connect.json ├── client.destruct.json ├── client.reconnect.json ├── close-remote.json ├── config-timeout.json ├── ping-pong.json ├── receive-bad-opcode.json ├── receive-broken-read.json ├── receive-client-timeout.json ├── receive-empty-read.json ├── receive-fragmentation.json ├── send-bad-opcode.json ├── send-broken-write.json ├── send-convenicance.json ├── send-failed-write.json ├── send-receive-128.json ├── send-receive-65536.json ├── send-receive-multi-fragment.json ├── send-receive.json ├── server.accept-destruct.json ├── server.accept-error-connect.json ├── server.accept-failed-connect.json ├── server.accept-failed-handshake.json ├── server.accept-failed-http.json ├── server.accept-failed-ws-key.json ├── server.accept-timeout.json ├── server.accept.json ├── server.close.json ├── server.construct-error-socket-server.json ├── server.construct-failed-socket-server.json ├── server.construct.json └── server.disconnect.json /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Use this if you believe there is a bug in this repo 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | Please provide a clear and concise description of the suspected issue. 12 | 13 | **How to reproduce** 14 | If possible, provide information - possibly including code snippets - on how to reproduce the issue. 15 | 16 | **Logs** 17 | If possible, provide logs that indicate the issue. See https://github.com/Textalk/websocket-php/blob/master/docs/Examples.md#echo-logger on how to use the EchoLog. 18 | 19 | **Versions** 20 | * Version of this library 21 | * PHP version 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this library 4 | title: '' 5 | labels: feature request 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is it within the scope of this library?** 11 | Consider and describe why the feature would be beneficial in this library, and not implemented as a separate project using this as a dependency. 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/other-issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Other issue 3 | about: Use this for other issues 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe your issue** 11 | -------------------------------------------------------------------------------- /.github/workflows/acceptance.yml: -------------------------------------------------------------------------------- 1 | name: Acceptance 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test-7-4: 7 | runs-on: ubuntu-latest 8 | name: Test PHP 7.4 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v3 12 | - name: Set up PHP 7.4 13 | uses: shivammathur/setup-php@v2 14 | with: 15 | php-version: '7.4' 16 | - name: Composer 17 | run: make install 18 | - name: Test 19 | run: make test 20 | 21 | test-8-0: 22 | runs-on: ubuntu-latest 23 | name: Test PHP 8.0 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v3 27 | - name: Set up PHP 8.0 28 | uses: shivammathur/setup-php@v2 29 | with: 30 | php-version: '8.0' 31 | - name: Composer 32 | run: make install 33 | - name: Test 34 | run: make test 35 | 36 | test-8-1: 37 | runs-on: ubuntu-latest 38 | name: Test PHP 8.1 39 | steps: 40 | - name: Checkout 41 | uses: actions/checkout@v3 42 | - name: Set up PHP 8.1 43 | uses: shivammathur/setup-php@v2 44 | with: 45 | php-version: '8.1' 46 | - name: Composer 47 | run: make install 48 | - name: Test 49 | run: make test 50 | 51 | test-8-2: 52 | runs-on: ubuntu-latest 53 | name: Test PHP 8.2 54 | steps: 55 | - name: Checkout 56 | uses: actions/checkout@v3 57 | - name: Set up PHP 8.2 58 | uses: shivammathur/setup-php@v2 59 | with: 60 | php-version: '8.2' 61 | - name: Composer 62 | run: make install 63 | - name: Test 64 | run: make test 65 | 66 | cs-check: 67 | runs-on: ubuntu-latest 68 | name: Code standard 69 | steps: 70 | - name: Checkout 71 | uses: actions/checkout@v3 72 | - name: Set up PHP 8.0 73 | uses: shivammathur/setup-php@v2 74 | with: 75 | php-version: '8.0' 76 | - name: Composer 77 | run: make install 78 | - name: Code standard 79 | run: make cs-check 80 | 81 | coverage: 82 | runs-on: ubuntu-latest 83 | name: Code coverage 84 | steps: 85 | - name: Checkout 86 | uses: actions/checkout@v3 87 | - name: Set up PHP 8.0 88 | uses: shivammathur/setup-php@v2 89 | with: 90 | php-version: '8.0' 91 | extensions: xdebug 92 | - name: Composer 93 | run: make install 94 | - name: Code coverage 95 | env: 96 | COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} 97 | run: make coverage 98 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .phpunit.result.cache 3 | build/ 4 | composer.lock 5 | composer.phar 6 | vendor/ -------------------------------------------------------------------------------- /COPYING.md: -------------------------------------------------------------------------------- 1 | # Websocket: License 2 | 3 | Websocket PHP is free software released under the following license: 4 | 5 | [ISC License](http://en.wikipedia.org/wiki/ISC_license) 6 | 7 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without 8 | fee is hereby granted, provided that the above copyright notice and this permission notice appear 9 | in all copies. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS 12 | SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE 13 | AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 14 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, 15 | NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF 16 | THIS SOFTWARE. 17 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | install: composer.phar 2 | ./composer.phar install 3 | 4 | update: composer.phar 5 | ./composer.phar self-update 6 | ./composer.phar update 7 | 8 | test: composer.lock 9 | ./vendor/bin/phpunit 10 | 11 | cs-check: composer.lock 12 | ./vendor/bin/phpcs --standard=PSR1,PSR12 --encoding=UTF-8 --report=full --colors lib tests examples 13 | 14 | coverage: composer.lock build 15 | XDEBUG_MODE=coverage ./vendor/bin/phpunit --coverage-clover build/logs/clover.xml 16 | ./vendor/bin/php-coveralls -v 17 | 18 | composer.phar: 19 | curl -s http://getcomposer.org/installer | php 20 | 21 | composer.lock: composer.phar 22 | ./composer.phar --no-interaction install 23 | 24 | vendor/bin/phpunit: install 25 | 26 | build: 27 | mkdir build 28 | 29 | clean: 30 | rm composer.phar 31 | rm -r vendor 32 | rm -r build 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Websocket Client and Server for PHP 2 | 3 | [![Build Status](https://github.com/Textalk/websocket-php/actions/workflows/acceptance.yml/badge.svg)](https://github.com/Textalk/websocket-php/actions) 4 | [![Coverage Status](https://coveralls.io/repos/github/Textalk/websocket-php/badge.svg?branch=master)](https://coveralls.io/github/Textalk/websocket-php) 5 | 6 | ## Archived project 7 | 8 | This project has been archived and is no longer maintained. No bug fix and no additional features will be added.
9 | You won't be able to submit new issues or pull requests, and no additional features will be added 10 | 11 | This library has been replaced by [sirn-se/websocket-php](https://github.com/sirn-se/websocket-php) 12 | 13 | ## Websocket Client and Server for PHP 14 | 15 | This library contains WebSocket client and server for PHP. 16 | 17 | The client and server provides methods for reading and writing to WebSocket streams. 18 | It does not include convenience operations such as listeners and implicit error handling. 19 | 20 | ## Documentation 21 | 22 | - [Client](docs/Client.md) 23 | - [Server](docs/Server.md) 24 | - [Examples](docs/Examples.md) 25 | - [Changelog](docs/Changelog.md) 26 | - [Contributing](docs/Contributing.md) 27 | 28 | ## Installing 29 | 30 | Preferred way to install is with [Composer](https://getcomposer.org/). 31 | ``` 32 | composer require textalk/websocket 33 | ``` 34 | 35 | * Current version support PHP versions `^7.4|^8.0`. 36 | * For PHP `7.2` and `7.3` support use version [`1.5`](https://github.com/Textalk/websocket-php/tree/1.5.0). 37 | * For PHP `7.1` support use version [`1.4`](https://github.com/Textalk/websocket-php/tree/1.4.0). 38 | * For PHP `^5.4` and `7.0` support use version [`1.3`](https://github.com/Textalk/websocket-php/tree/1.3.0). 39 | 40 | ## Client 41 | 42 | The [client](docs/Client.md) can read and write on a WebSocket stream. 43 | It internally supports Upgrade handshake and implicit close and ping/pong operations. 44 | 45 | ```php 46 | $client = new WebSocket\Client("ws://echo.websocket.org/"); 47 | $client->text("Hello WebSocket.org!"); 48 | echo $client->receive(); 49 | $client->close(); 50 | ``` 51 | 52 | ## Server 53 | 54 | The library contains a rudimentary single stream/single thread [server](docs/Server.md). 55 | It internally supports Upgrade handshake and implicit close and ping/pong operations. 56 | 57 | Note that it does **not** support threading or automatic association ot continuous client requests. 58 | If you require this kind of server behavior, you need to build it on top of provided server implementation. 59 | 60 | ```php 61 | $server = new WebSocket\Server(); 62 | $server->accept(); 63 | $message = $server->receive(); 64 | $server->text($message); 65 | $server->close(); 66 | ``` 67 | 68 | ### License and Contributors 69 | 70 | [ISC License](COPYING.md) 71 | 72 | Fredrik Liljegren, Armen Baghumian Sankbarani, Ruslan Bekenev, 73 | Joshua Thijssen, Simon Lipp, Quentin Bellus, Patrick McCarren, swmcdonnell, 74 | Ignas Bernotas, Mark Herhold, Andreas Palm, Sören Jensen, pmaasz, Alexey Stavrov, 75 | Michael Slezak, Pierre Seznec, rmeisler, Nickolay V. Shmyrev, Christoph Kempen, 76 | Marc Roberts, Antonio Mora, Simon Podlipsky, etrinh. 77 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "textalk/websocket", 3 | "description": "WebSocket client and server", 4 | "license": "ISC", 5 | "type": "library", 6 | "authors": [ 7 | { 8 | "name": "Fredrik Liljegren" 9 | }, 10 | { 11 | "name": "Sören Jensen" 12 | } 13 | ], 14 | "autoload": { 15 | "psr-4": { 16 | "WebSocket\\": "lib" 17 | } 18 | }, 19 | "autoload-dev": { 20 | "psr-4": { 21 | "WebSocket\\": "tests/mock" 22 | } 23 | }, 24 | "require": { 25 | "php": "^7.4 | ^8.0", 26 | "phrity/net-uri": "^1.0", 27 | "phrity/util-errorhandler": "^1.0", 28 | "psr/log": "^1.0 | ^2.0 | ^3.0", 29 | "psr/http-message": "^1.0" 30 | }, 31 | "require-dev": { 32 | "phpunit/phpunit": "^9.0", 33 | "php-coveralls/php-coveralls": "^2.0", 34 | "squizlabs/php_codesniffer": "^3.5" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /docs/Changelog.md: -------------------------------------------------------------------------------- 1 | [Client](Client.md) • [Server](Server.md) • [Message](Message.md) • [Examples](Examples.md) • Changelog • [Contributing](Contributing.md) 2 | 3 | # Websocket: Changelog 4 | 5 | ## `v1.6` 6 | 7 | > PHP version `^7.4|^8.0` 8 | 9 | ### `1.6.3` 10 | 11 | * Fix issue with implicit default ports (@etrinh, @sirn-se) 12 | 13 | ### `1.6.2` 14 | 15 | * Fix issue where port was missing in socket uri (@sirn-se) 16 | 17 | ### `1.6.1` 18 | 19 | * Fix client path for http request (@simPod, @sirn-se) 20 | 21 | ### `1.6.0` 22 | * Connection separate from Client and Server (@sirn-se) 23 | * getPier() deprecated, replaced by getRemoteName() (@sirn-se) 24 | * Client accepts `Psr\Http\Message\UriInterface` as input for URI:s (@sirn-se) 25 | * Bad URI throws exception when Client is instanciated, previously when used (@sirn-se) 26 | * Preparations for multiple conection and listeners (@sirn-se) 27 | * Major internal refactoring (@sirn-se) 28 | 29 | ## `v1.5` 30 | 31 | > PHP version `^7.2|^8.0` 32 | 33 | ### `1.5.8` 34 | 35 | * Handle read error during handshake (@sirn-se) 36 | 37 | ### `1.5.7` 38 | 39 | * Large header block fix (@sirn-se) 40 | 41 | ### `1.5.6` 42 | 43 | * Add test for PHP 8.1 (@sirn-se) 44 | * Code standard (@sirn-se) 45 | 46 | ### `1.5.5` 47 | 48 | * Support for psr/log v2 and v3 (@simPod) 49 | * GitHub Actions replaces Travis (@sirn-se) 50 | 51 | ### `1.5.4` 52 | 53 | * Keep open connection on read timeout (@marcroberts) 54 | 55 | ### `1.5.3` 56 | 57 | * Fix for persistent connection (@sirn-se) 58 | 59 | ### `1.5.2` 60 | 61 | * Fix for getName() method (@sirn-se) 62 | 63 | ### `1.5.1` 64 | 65 | * Fix for persistent connections (@rmeisler) 66 | 67 | ### `1.5.0` 68 | 69 | * Convenience send methods; text(), binary(), ping(), pong() (@sirn-se) 70 | * Optional Message instance as receive() method return (@sirn-se) 71 | * Opcode filter for receive() method (@sirn-se) 72 | * Added PHP `8.0` support (@webpatser) 73 | * Dropped PHP `7.1` support (@sirn-se) 74 | * Fix for unordered fragmented messages (@sirn-se) 75 | * Improved error handling on stream calls (@sirn-se) 76 | * Various code re-write (@sirn-se) 77 | 78 | ## `v1.4` 79 | 80 | > PHP version `^7.1` 81 | 82 | #### `1.4.3` 83 | 84 | * Solve stream closure/get meta conflict (@sirn-se) 85 | * Examples and documentation overhaul (@sirn-se) 86 | 87 | #### `1.4.2` 88 | 89 | * Force stream close on read error (@sirn-se) 90 | * Authorization headers line feed (@sirn-se) 91 | * Documentation (@matias-pool, @sirn-se) 92 | 93 | #### `1.4.1` 94 | 95 | * Ping/Pong, handled internally to avoid breaking fragmented messages (@nshmyrev, @sirn-se) 96 | * Fix for persistent connections (@rmeisler) 97 | * Fix opcode bitmask (@peterjah) 98 | 99 | #### `1.4.0` 100 | 101 | * Dropped support of old PHP versions (@sirn-se) 102 | * Added PSR-3 Logging support (@sirn-se) 103 | * Persistent connection option (@slezakattack) 104 | * TimeoutException on connection time out (@slezakattack) 105 | 106 | ## `v1.3` 107 | 108 | > PHP version `^5.4` and `^7.0` 109 | 110 | #### `1.3.1` 111 | 112 | * Allow control messages without payload (@Logioniz) 113 | * Error code in ConnectionException (@sirn-se) 114 | 115 | #### `1.3.0` 116 | 117 | * Implements ping/pong frames (@pmccarren @Logioniz) 118 | * Close behaviour (@sirn-se) 119 | * Various fixes concerning connection handling (@sirn-se) 120 | * Overhaul of Composer, Travis and Coveralls setup, PSR code standard and unit tests (@sirn-se) 121 | 122 | ## `v1.2` 123 | 124 | > PHP version `^5.4` and `^7.0` 125 | 126 | #### `1.2.0` 127 | 128 | * Adding stream context options (to set e.g. SSL `allow_self_signed`). 129 | 130 | ## `v1.1` 131 | 132 | > PHP version `^5.4` and `^7.0` 133 | 134 | #### `1.1.2` 135 | 136 | * Fixed error message on broken frame. 137 | 138 | #### `1.1.1` 139 | 140 | * Adding license information. 141 | 142 | #### `1.1.0` 143 | 144 | * Supporting huge payloads. 145 | 146 | ## `v1.0` 147 | 148 | > PHP version `^5.4` and `^7.0` 149 | 150 | #### `1.0.3` 151 | 152 | * Bugfix: Correcting address in error-message 153 | 154 | #### `1.0.2` 155 | 156 | * Bugfix: Add port in request-header. 157 | 158 | #### `1.0.1` 159 | 160 | * Fixing a bug from empty payloads. 161 | 162 | #### `1.0.0` 163 | 164 | * Release as production ready. 165 | * Adding option to set/override headers. 166 | * Supporting basic authentication from user:pass in URL. 167 | 168 | -------------------------------------------------------------------------------- /docs/Client.md: -------------------------------------------------------------------------------- 1 | Client • [Server](Server.md) • [Message](Message.md) • [Examples](Examples.md) • [Changelog](Changelog.md) • [Contributing](Contributing.md) 2 | 3 | # Websocket: Client 4 | 5 | The client can read and write on a WebSocket stream. 6 | It internally supports Upgrade handshake and implicit close and ping/pong operations. 7 | 8 | ## Class synopsis 9 | 10 | ```php 11 | WebSocket\Client { 12 | 13 | public __construct(UriInterface|string $uri, array $options = []); 14 | public __destruct(); 15 | public __toString() : string; 16 | 17 | public text(string $payload) : void; 18 | public binary(string $payload) : void; 19 | public ping(string $payload = '') : void; 20 | public pong(string $payload = '') : void; 21 | public send(Message|string $payload, string $opcode = 'text', bool $masked = true) : void; 22 | public close(int $status = 1000, mixed $message = 'ttfn') : void; 23 | public receive() : Message|string|null; 24 | 25 | public getName() : string|null; 26 | public getRemoteName() : string|null; 27 | public getLastOpcode() : string; 28 | public getCloseStatus() : int; 29 | public isConnected() : bool; 30 | public setTimeout(int $seconds) : void; 31 | public setFragmentSize(int $fragment_size) : self; 32 | public getFragmentSize() : int; 33 | public setLogger(Psr\Log\LoggerInterface $logger = null) : void; 34 | } 35 | ``` 36 | 37 | ## Examples 38 | 39 | ### Simple send-receive operation 40 | 41 | This example send a single message to a server, and output the response. 42 | 43 | ```php 44 | $client = new WebSocket\Client("ws://echo.websocket.org/"); 45 | $client->text("Hello WebSocket.org!"); 46 | echo $client->receive(); 47 | $client->close(); 48 | ``` 49 | 50 | ### Listening to a server 51 | 52 | To continuously listen to incoming messages, you need to put the receive operation within a loop. 53 | Note that these functions **always** throw exception on any failure, including recoverable failures such as connection time out. 54 | By consuming exceptions, the code will re-connect the socket in next loop iteration. 55 | 56 | ```php 57 | $client = new WebSocket\Client("ws://echo.websocket.org/"); 58 | while (true) { 59 | try { 60 | $message = $client->receive(); 61 | // Act on received message 62 | // Break while loop to stop listening 63 | } catch (\WebSocket\ConnectionException $e) { 64 | // Possibly log errors 65 | } 66 | } 67 | $client->close(); 68 | ``` 69 | 70 | ### Filtering received messages 71 | 72 | By default the `receive()` method return messages of 'text' and 'binary' opcode. 73 | The filter option allows you to specify which message types to return. 74 | 75 | ```php 76 | $client = new WebSocket\Client("ws://echo.websocket.org/", ['filter' => ['text']]); 77 | $client->receive(); // Only return 'text' messages 78 | 79 | $client = new WebSocket\Client("ws://echo.websocket.org/", ['filter' => ['text', 'binary', 'ping', 'pong', 'close']]); 80 | $client->receive(); // Return all messages 81 | ``` 82 | 83 | ### Sending messages 84 | 85 | There are convenience methods to send messages with different opcodes. 86 | ```php 87 | $client = new WebSocket\Client("ws://echo.websocket.org/"); 88 | 89 | // Convenience methods 90 | $client->text('A plain text message'); // Send an opcode=text message 91 | $client->binary($binary_string); // Send an opcode=binary message 92 | $client->ping(); // Send an opcode=ping frame 93 | $client->pong(); // Send an unsolicited opcode=pong frame 94 | 95 | // Generic send method 96 | $client->send($payload); // Sent as masked opcode=text 97 | $client->send($payload, 'binary'); // Sent as masked opcode=binary 98 | $client->send($payload, 'binary', false); // Sent as unmasked opcode=binary 99 | ``` 100 | 101 | ## Constructor options 102 | 103 | The `$options` parameter in constructor accepts an associative array of options. 104 | 105 | * `context` - A stream context created using [stream_context_create](https://www.php.net/manual/en/function.stream-context-create). 106 | * `filter` - Array of opcodes to return on receive, default `['text', 'binary']` 107 | * `fragment_size` - Maximum payload size. Default 4096 chars. 108 | * `headers` - Additional headers as associative array name => content. 109 | * `logger` - A [PSR-3](https://www.php-fig.org/psr/psr-3/) compatible logger. 110 | * `persistent` - Connection is re-used between requests until time out is reached. Default false. 111 | * `return_obj` - Return a [Message](Message.md) instance on receive, default false 112 | * `timeout` - Time out in seconds. Default 5 seconds. 113 | 114 | ```php 115 | $context = stream_context_create(); 116 | stream_context_set_option($context, 'ssl', 'verify_peer', false); 117 | stream_context_set_option($context, 'ssl', 'verify_peer_name', false); 118 | 119 | $client = new WebSocket\Client("ws://echo.websocket.org/", [ 120 | 'context' => $context, // Attach stream context created above 121 | 'filter' => ['text', 'binary', 'ping'], // Specify message types for receive() to return 122 | 'headers' => [ // Additional headers, used to specify subprotocol 123 | 'Sec-WebSocket-Protocol' => 'soap', 124 | 'origin' => 'localhost', 125 | ], 126 | 'logger' => $my_psr3_logger, // Attach a PSR3 compatible logger 127 | 'return_obj' => true, // Return Message instance rather than just text 128 | 'timeout' => 60, // 1 minute time out 129 | ]); 130 | ``` 131 | 132 | ## Exceptions 133 | 134 | * `WebSocket\BadOpcodeException` - Thrown if provided opcode is invalid. 135 | * `WebSocket\BadUriException` - Thrown if provided URI is invalid. 136 | * `WebSocket\ConnectionException` - Thrown on any socket I/O failure. 137 | * `WebSocket\TimeoutException` - Thrown when the socket experiences a time out. 138 | -------------------------------------------------------------------------------- /docs/Contributing.md: -------------------------------------------------------------------------------- 1 | [Client](Client.md) • [Server](Server.md) • [Message](Message.md) • [Examples](Examples.md) • [Changelog](Changelog.md) • Contributing 2 | 3 | # Websocket: Contributing 4 | 5 | Everyone is welcome to help out! 6 | But to keep this project sustainable, please ensure your contribution respects the requirements below. 7 | 8 | ## PR Requirements 9 | 10 | Requirements on pull requests; 11 | * All tests **MUST** pass. 12 | * Code coverage **MUST** remain at 100%. 13 | * Code **MUST** adhere to PSR-1 and PSR-12 code standards. 14 | 15 | Base your patch on corresponding version branch, and target that version branch in your pull request. 16 | 17 | * `v1.6-master` current version 18 | * `v1.5-master` previous version, bug fixes only 19 | * Older versions should not be target of pull requests 20 | 21 | 22 | ## Dependency management 23 | 24 | Install or update dependencies using [Composer](https://getcomposer.org/). 25 | 26 | ``` 27 | # Install dependencies 28 | make install 29 | 30 | # Update dependencies 31 | make update 32 | ``` 33 | 34 | ## Code standard 35 | 36 | This project uses [PSR-1](https://www.php-fig.org/psr/psr-1/) and [PSR-12](https://www.php-fig.org/psr/psr-12/) code standards. 37 | ``` 38 | # Check code standard adherence 39 | make cs-check 40 | ``` 41 | 42 | ## Unit testing 43 | 44 | Unit tests with [PHPUnit](https://phpunit.readthedocs.io/), coverage with [Coveralls](https://github.com/php-coveralls/php-coveralls) 45 | ``` 46 | # Run unit tests 47 | make test 48 | 49 | # Create coverage 50 | make coverage 51 | ``` 52 | -------------------------------------------------------------------------------- /docs/Examples.md: -------------------------------------------------------------------------------- 1 | [Client](Client.md) • [Server](Server.md) • [Message](Message.md) • Examples • [Changelog](Changelog.md) • [Contributing](Contributing.md) 2 | 3 | # Websocket: Examples 4 | 5 | Here are some examples on how to use the WebSocket library. 6 | 7 | ## Echo logger 8 | 9 | In dev environment (as in having run composer to include dev dependencies) you have 10 | access to a simple echo logger that print out information synchronously. 11 | 12 | This is usable for debugging. For production, use a proper logger. 13 | 14 | ```php 15 | namespace WebSocket; 16 | 17 | $logger = new EchoLogger(); 18 | 19 | $client = new Client('ws://echo.websocket.org/'); 20 | $client->setLogger($logger); 21 | 22 | $server = new Server(); 23 | $server->setLogger($logger); 24 | ``` 25 | 26 | An example of server output; 27 | ``` 28 | info | Server listening to port 8000 [] 29 | debug | Wrote 129 of 129 bytes. [] 30 | info | Server connected to port 8000 [] 31 | info | Received 'text' message [] 32 | debug | Wrote 9 of 9 bytes. [] 33 | info | Sent 'text' message [] 34 | debug | Received 'close', status: 1000. [] 35 | debug | Wrote 32 of 32 bytes. [] 36 | info | Sent 'close' message [] 37 | info | Received 'close' message [] 38 | ``` 39 | 40 | ## The `send` client 41 | 42 | Source: [examples/send.php](../examples/send.php) 43 | 44 | A simple, single send/receive client. 45 | 46 | Example use: 47 | ``` 48 | php examples/send.php --opcode text "A text message" // Send a text message to localhost 49 | php examples/send.php --opcode ping "ping it" // Send a ping message to localhost 50 | php examples/send.php --uri ws://echo.websocket.org "A text message" // Send a text message to echo.websocket.org 51 | php examples/send.php --opcode text --debug "A text message" // Use runtime debugging 52 | ``` 53 | 54 | ## The `echoserver` server 55 | 56 | Source: [examples/echoserver.php](../examples/echoserver.php) 57 | 58 | A simple server that responds to recevied commands. 59 | 60 | Example use: 61 | ``` 62 | php examples/echoserver.php // Run with default settings 63 | php examples/echoserver.php --port 8080 // Listen on port 8080 64 | php examples/echoserver.php --debug // Use runtime debugging 65 | ``` 66 | 67 | These strings can be sent as message to trigger server to perform actions; 68 | * `auth` - Server will respond with auth header if provided by client 69 | * `close` - Server will close current connection 70 | * `exit` - Server will close all active connections 71 | * `headers` - Server will respond with all headers provided by client 72 | * `ping` - Server will send a ping message 73 | * `pong` - Server will send a pong message 74 | * `stop` - Server will stop listening 75 | * For other sent strings, server will respond with the same strings 76 | 77 | ## The `random` client 78 | 79 | Source: [examples/random_client.php](../examples/random_client.php) 80 | 81 | The random client will use random options and continuously send/receive random messages. 82 | 83 | Example use: 84 | ``` 85 | php examples/random_client.php --uri ws://echo.websocket.org // Connect to echo.websocket.org 86 | php examples/random_client.php --timeout 5 --fragment_size 16 // Specify settings 87 | php examples/random_client.php --debug // Use runtime debugging 88 | ``` 89 | 90 | ## The `random` server 91 | 92 | Source: [examples/random_server.php](../examples/random_server.php) 93 | 94 | The random server will use random options and continuously send/receive random messages. 95 | 96 | Example use: 97 | ``` 98 | php examples/random_server.php --port 8080 // // Listen on port 8080 99 | php examples/random_server.php --timeout 5 --fragment_size 16 // Specify settings 100 | php examples/random_server.php --debug // Use runtime debugging 101 | ``` 102 | -------------------------------------------------------------------------------- /docs/Message.md: -------------------------------------------------------------------------------- 1 | [Client](Client.md) • [Server](Server.md) • Message • [Examples](Examples.md) • [Changelog](Changelog.md) • [Contributing](Contributing.md) 2 | 3 | # Websocket: Messages 4 | 5 | If option `return_obj` is set to `true` on [client](Client.md) or [server](Server.md), 6 | the `receive()` method will return a Message instance instead of a string. 7 | 8 | Available classes correspond to opcode; 9 | * WebSocket\Message\Text 10 | * WebSocket\Message\Binary 11 | * WebSocket\Message\Ping 12 | * WebSocket\Message\Pong 13 | * WebSocket\Message\Close 14 | 15 | Additionally; 16 | * WebSocket\Message\Message - abstract base class for all messages above 17 | * WebSocket\Message\Factory - Factory class to create Message instances 18 | 19 | ## Message abstract class synopsis 20 | 21 | ```php 22 | WebSocket\Message\Message { 23 | 24 | public __construct(string $payload = ''); 25 | public __toString() : string; 26 | 27 | public getOpcode() : string; 28 | public getLength() : int; 29 | public getTimestamp() : DateTime; 30 | public getContent() : string; 31 | public setContent(string $payload = '') : void; 32 | public hasContent() : bool; 33 | } 34 | ``` 35 | 36 | ## Factory class synopsis 37 | 38 | ```php 39 | WebSocket\Message\Factory { 40 | 41 | public create(string $opcode, string $payload = '') : Message; 42 | } 43 | ``` 44 | 45 | ## Example 46 | 47 | Receving a Message and echo some methods. 48 | 49 | ```php 50 | $client = new WebSocket\Client('ws://echo.websocket.org/', ['return_obj' => true]); 51 | $client->text('Hello WebSocket.org!'); 52 | // Echo return same message as sent 53 | $message = $client->receive(); 54 | echo $message->getOpcode(); // -> "text" 55 | echo $message->getLength(); // -> 20 56 | echo $message->getContent(); // -> "Hello WebSocket.org!" 57 | echo $message->hasContent(); // -> true 58 | echo $message->getTimestamp()->format('H:i:s'); // -> 19:37:18 59 | $client->close(); 60 | ``` 61 | -------------------------------------------------------------------------------- /docs/Server.md: -------------------------------------------------------------------------------- 1 | [Client](Client.md) • Server • [Message](Message.md) • [Examples](Examples.md) • [Changelog](Changelog.md) • [Contributing](Contributing.md) 2 | 3 | # Websocket: Server 4 | 5 | The library contains a rudimentary single stream/single thread server. 6 | It internally supports Upgrade handshake and implicit close and ping/pong operations. 7 | 8 | Note that it does **not** support threading or automatic association ot continuous client requests. 9 | If you require this kind of server behavior, you need to build it on top of provided server implementation. 10 | 11 | ## Class synopsis 12 | 13 | ```php 14 | WebSocket\Server { 15 | 16 | public __construct(array $options = []); 17 | public __destruct(); 18 | public __toString() : string; 19 | 20 | public accept() : bool; 21 | public text(string $payload) : void; 22 | public binary(string $payload) : void; 23 | public ping(string $payload = '') : void; 24 | public pong(string $payload = '') : void; 25 | public send(Message|string $payload, string $opcode = 'text', bool $masked = true) : void; 26 | public close(int $status = 1000, mixed $message = 'ttfn') : void; 27 | public receive() : Message|string|null; 28 | 29 | public getPort() : int; 30 | public getPath() : string; 31 | public getRequest() : array; 32 | public getHeader(string $header_name) : string|null; 33 | 34 | public getName() : string|null; 35 | public getRemoteName() : string|null; 36 | public getLastOpcode() : string; 37 | public getCloseStatus() : int; 38 | public isConnected() : bool; 39 | public setTimeout(int $seconds) : void; 40 | public setFragmentSize(int $fragment_size) : self; 41 | public getFragmentSize() : int; 42 | public setLogger(Psr\Log\LoggerInterface $logger = null) : void; 43 | } 44 | ``` 45 | 46 | ## Examples 47 | 48 | ### Simple receive-send operation 49 | 50 | This example reads a single message from a client, and respond with the same message. 51 | 52 | ```php 53 | $server = new WebSocket\Server(); 54 | $server->accept(); 55 | $message = $server->receive(); 56 | $server->text($message); 57 | $server->close(); 58 | ``` 59 | 60 | ### Listening to clients 61 | 62 | To continuously listen to incoming messages, you need to put the receive operation within a loop. 63 | Note that these functions **always** throw exception on any failure, including recoverable failures such as connection time out. 64 | By consuming exceptions, the code will re-connect the socket in next loop iteration. 65 | 66 | ```php 67 | $server = new WebSocket\Server(); 68 | while ($server->accept()) { 69 | try { 70 | $message = $server->receive(); 71 | // Act on received message 72 | // Break while loop to stop listening 73 | } catch (\WebSocket\ConnectionException $e) { 74 | // Possibly log errors 75 | } 76 | } 77 | $server->close(); 78 | ``` 79 | 80 | ### Filtering received messages 81 | 82 | By default the `receive()` method return messages of 'text' and 'binary' opcode. 83 | The filter option allows you to specify which message types to return. 84 | 85 | ```php 86 | $server = new WebSocket\Server(['filter' => ['text']]); 87 | $server->receive(); // only return 'text' messages 88 | 89 | $server = new WebSocket\Server(['filter' => ['text', 'binary', 'ping', 'pong', 'close']]); 90 | $server->receive(); // return all messages 91 | ``` 92 | 93 | ### Sending messages 94 | 95 | There are convenience methods to send messages with different opcodes. 96 | ```php 97 | $server = new WebSocket\Server(); 98 | 99 | // Convenience methods 100 | $server->text('A plain text message'); // Send an opcode=text message 101 | $server->binary($binary_string); // Send an opcode=binary message 102 | $server->ping(); // Send an opcode=ping frame 103 | $server->pong(); // Send an unsolicited opcode=pong frame 104 | 105 | // Generic send method 106 | $server->send($payload); // Sent as masked opcode=text 107 | $server->send($payload, 'binary'); // Sent as masked opcode=binary 108 | $server->send($payload, 'binary', false); // Sent as unmasked opcode=binary 109 | ``` 110 | 111 | ## Constructor options 112 | 113 | The `$options` parameter in constructor accepts an associative array of options. 114 | 115 | * `filter` - Array of opcodes to return on receive, default `['text', 'binary']` 116 | * `fragment_size` - Maximum payload size. Default 4096 chars. 117 | * `logger` - A [PSR-3](https://www.php-fig.org/psr/psr-3/) compatible logger. 118 | * `port` - The server port to listen to. Default 8000. 119 | * `return_obj` - Return a [Message](Message.md) instance on receive, default false 120 | * `timeout` - Time out in seconds. Default 5 seconds. 121 | 122 | ```php 123 | $server = new WebSocket\Server([ 124 | 'filter' => ['text', 'binary', 'ping'], // Specify message types for receive() to return 125 | 'logger' => $my_psr3_logger, // Attach a PSR3 compatible logger 126 | 'port' => 9000, // Listening port 127 | 'return_obj' => true, // Return Message instance rather than just text 128 | 'timeout' => 60, // 1 minute time out 129 | ]); 130 | ``` 131 | 132 | ## Exceptions 133 | 134 | * `WebSocket\BadOpcodeException` - Thrown if provided opcode is invalid. 135 | * `WebSocket\ConnectionException` - Thrown on any socket I/O failure. 136 | * `WebSocket\TimeoutException` - Thrown when the socket experiences a time out. 137 | -------------------------------------------------------------------------------- /examples/echoserver.php: -------------------------------------------------------------------------------- 1 | : The port to listen to, default 8000 9 | * --timeout : Timeout in seconds, default 200 seconds 10 | * --debug : Output log data (if logger is available) 11 | */ 12 | 13 | namespace WebSocket; 14 | 15 | require __DIR__ . '/../vendor/autoload.php'; 16 | 17 | error_reporting(-1); 18 | 19 | echo "> Echo server\n"; 20 | 21 | // Server options specified or random 22 | $options = array_merge([ 23 | 'port' => 8000, 24 | 'timeout' => 200, 25 | 'filter' => ['text', 'binary', 'ping', 'pong', 'close'], 26 | ], getopt('', ['port:', 'timeout:', 'debug'])); 27 | 28 | // If debug mode and logger is available 29 | if (isset($options['debug']) && class_exists('WebSocket\EchoLog')) { 30 | $logger = new EchoLog(); 31 | $options['logger'] = $logger; 32 | echo "> Using logger\n"; 33 | } 34 | 35 | // Initiate server. 36 | try { 37 | $server = new Server($options); 38 | } catch (ConnectionException $e) { 39 | echo "> ERROR: {$e->getMessage()}\n"; 40 | die(); 41 | } 42 | 43 | echo "> Listening to port {$server->getPort()}\n"; 44 | 45 | // Force quit to close server 46 | while (true) { 47 | try { 48 | while ($server->accept()) { 49 | echo "> Accepted on port {$server->getPort()}\n"; 50 | while (true) { 51 | $message = $server->receive(); 52 | $opcode = $server->getLastOpcode(); 53 | if (is_null($message)) { 54 | echo "> Closing connection\n"; 55 | continue 2; 56 | } 57 | echo "> Got '{$message}' [opcode: {$opcode}]\n"; 58 | if (in_array($opcode, ['ping', 'pong'])) { 59 | $server->send($message); 60 | continue; 61 | } 62 | // Allow certain string to trigger server action 63 | switch ($message) { 64 | case 'exit': 65 | echo "> Client told me to quit. Bye bye.\n"; 66 | $server->close(); 67 | echo "> Close status: {$server->getCloseStatus()}\n"; 68 | exit; 69 | case 'headers': 70 | $server->text(implode("\r\n", $server->getRequest())); 71 | break; 72 | case 'ping': 73 | $server->ping($message); 74 | break; 75 | case 'auth': 76 | $auth = $server->getHeader('Authorization'); 77 | $server->text("{$auth} - {$message}"); 78 | break; 79 | default: 80 | $server->text($message); 81 | } 82 | } 83 | } 84 | } catch (ConnectionException $e) { 85 | echo "> ERROR: {$e->getMessage()}\n"; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /examples/random_client.php: -------------------------------------------------------------------------------- 1 | : The URI to connect to, default ws://localhost:8000 9 | * --timeout : Timeout in seconds, random default 10 | * --fragment_size : Fragment size as bytes, random default 11 | * --debug : Output log data (if logger is available) 12 | */ 13 | 14 | namespace WebSocket; 15 | 16 | require __DIR__ . '/../vendor/autoload.php'; 17 | 18 | error_reporting(-1); 19 | 20 | $randStr = function (int $maxlength = 4096) { 21 | $string = ''; 22 | $length = rand(1, $maxlength); 23 | for ($i = 0; $i < $length; $i++) { 24 | $string .= chr(rand(33, 126)); 25 | } 26 | return $string; 27 | }; 28 | 29 | echo "> Random client\n"; 30 | 31 | // Server options specified or random 32 | $options = array_merge([ 33 | 'uri' => 'ws://localhost:8000', 34 | 'timeout' => rand(1, 60), 35 | 'fragment_size' => rand(1, 4096) * 8, 36 | ], getopt('', ['uri:', 'timeout:', 'fragment_size:', 'debug'])); 37 | 38 | // If debug mode and logger is available 39 | if (isset($options['debug']) && class_exists('WebSocket\EchoLog')) { 40 | $logger = new EchoLog(); 41 | $options['logger'] = $logger; 42 | echo "> Using logger\n"; 43 | } 44 | 45 | // Main loop 46 | while (true) { 47 | try { 48 | $client = new Client($options['uri'], $options); 49 | $info = json_encode([ 50 | 'uri' => $options['uri'], 51 | 'timeout' => $options['timeout'], 52 | 'framgemt_size' => $client->getFragmentSize(), 53 | ]); 54 | echo "> Creating client {$info}\n"; 55 | 56 | try { 57 | while (true) { 58 | // Random actions 59 | switch (rand(1, 10)) { 60 | case 1: 61 | echo "> Sending text\n"; 62 | $client->text("Text message {$randStr()}"); 63 | break; 64 | case 2: 65 | echo "> Sending binary\n"; 66 | $client->binary("Binary message {$randStr()}"); 67 | break; 68 | case 3: 69 | echo "> Sending close\n"; 70 | $client->close(rand(1000, 2000), "Close message {$randStr(8)}"); 71 | break; 72 | case 4: 73 | echo "> Sending ping\n"; 74 | $client->ping("Ping message {$randStr(8)}"); 75 | break; 76 | case 5: 77 | echo "> Sending pong\n"; 78 | $client->pong("Pong message {$randStr(8)}"); 79 | break; 80 | default: 81 | echo "> Receiving\n"; 82 | $received = $client->receive(); 83 | echo "> Received {$client->getLastOpcode()}: {$received}\n"; 84 | } 85 | sleep(rand(1, 5)); 86 | } 87 | } catch (\Throwable $e) { 88 | echo "ERROR I/O: {$e->getMessage()} [{$e->getCode()}]\n"; 89 | } 90 | } catch (\Throwable $e) { 91 | echo "ERROR: {$e->getMessage()} [{$e->getCode()}]\n"; 92 | } 93 | sleep(rand(1, 5)); 94 | } 95 | -------------------------------------------------------------------------------- /examples/random_server.php: -------------------------------------------------------------------------------- 1 | : The port to listen to, default 8000 9 | * --timeout : Timeout in seconds, random default 10 | * --fragment_size : Fragment size as bytes, random default 11 | * --debug : Output log data (if logger is available) 12 | */ 13 | 14 | namespace WebSocket; 15 | 16 | require __DIR__ . '/../vendor/autoload.php'; 17 | 18 | error_reporting(-1); 19 | 20 | $randStr = function (int $maxlength = 4096) { 21 | $string = ''; 22 | $length = rand(1, $maxlength); 23 | for ($i = 0; $i < $length; $i++) { 24 | $string .= chr(rand(33, 126)); 25 | } 26 | return $string; 27 | }; 28 | 29 | echo "> Random server\n"; 30 | 31 | // Server options specified or random 32 | $options = array_merge([ 33 | 'port' => 8000, 34 | 'timeout' => rand(1, 60), 35 | 'fragment_size' => rand(1, 4096) * 8, 36 | ], getopt('', ['port:', 'timeout:', 'fragment_size:', 'debug'])); 37 | 38 | // If debug mode and logger is available 39 | if (isset($options['debug']) && class_exists('WebSocket\EchoLog')) { 40 | $logger = new EchoLog(); 41 | $options['logger'] = $logger; 42 | echo "> Using logger\n"; 43 | } 44 | 45 | // Force quit to close server 46 | while (true) { 47 | try { 48 | // Setup server 49 | $server = new Server($options); 50 | $info = json_encode([ 51 | 'port' => $server->getPort(), 52 | 'timeout' => $options['timeout'], 53 | 'framgemt_size' => $server->getFragmentSize(), 54 | ]); 55 | echo "> Creating server {$info}\n"; 56 | 57 | while ($server->accept()) { 58 | while (true) { 59 | // Random actions 60 | switch (rand(1, 10)) { 61 | case 1: 62 | echo "> Sending text\n"; 63 | $server->text("Text message {$randStr()}"); 64 | break; 65 | case 2: 66 | echo "> Sending binary\n"; 67 | $server->binary("Binary message {$randStr()}"); 68 | break; 69 | case 3: 70 | echo "> Sending close\n"; 71 | $server->close(rand(1000, 2000), "Close message {$randStr(8)}"); 72 | break; 73 | case 4: 74 | echo "> Sending ping\n"; 75 | $server->ping("Ping message {$randStr(8)}"); 76 | break; 77 | case 5: 78 | echo "> Sending pong\n"; 79 | $server->pong("Pong message {$randStr(8)}"); 80 | break; 81 | default: 82 | echo "> Receiving\n"; 83 | $received = $server->receive(); 84 | echo "> Received {$server->getLastOpcode()}: {$received}\n"; 85 | } 86 | sleep(rand(1, 5)); 87 | } 88 | } 89 | } catch (\Throwable $e) { 90 | echo "ERROR: {$e->getMessage()} [{$e->getCode()}]\n"; 91 | } 92 | sleep(rand(1, 5)); 93 | } 94 | -------------------------------------------------------------------------------- /examples/send.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * Console options: 8 | * --uri : The URI to connect to, default ws://localhost:8000 9 | * --opcode : Opcode to send, default 'text' 10 | * --debug : Output log data (if logger is available) 11 | */ 12 | 13 | namespace WebSocket; 14 | 15 | require __DIR__ . '/../vendor/autoload.php'; 16 | 17 | error_reporting(-1); 18 | 19 | echo "> Send client\n"; 20 | 21 | // Server options specified or random 22 | $options = array_merge([ 23 | 'uri' => 'ws://localhost:8000', 24 | 'opcode' => 'text', 25 | ], getopt('', ['uri:', 'opcode:', 'debug'])); 26 | $message = array_pop($argv); 27 | 28 | // If debug mode and logger is available 29 | if (isset($options['debug']) && class_exists('WebSocket\EchoLog')) { 30 | $logger = new EchoLog(); 31 | $options['logger'] = $logger; 32 | echo "> Using logger\n"; 33 | } 34 | 35 | try { 36 | // Create client, send and recevie 37 | $client = new Client($options['uri'], $options); 38 | $client->send($message, $options['opcode']); 39 | echo "> Sent '{$message}' [opcode: {$options['opcode']}]\n"; 40 | if (in_array($options['opcode'], ['text', 'binary'])) { 41 | $message = $client->receive(); 42 | $opcode = $client->getLastOpcode(); 43 | if (!is_null($message)) { 44 | echo "> Got '{$message}' [opcode: {$opcode}]\n"; 45 | } 46 | } 47 | $client->close(); 48 | echo "> Closing client\n"; 49 | } catch (\Throwable $e) { 50 | echo "ERROR: {$e->getMessage()} [{$e->getCode()}]\n"; 51 | } 52 | -------------------------------------------------------------------------------- /lib/BadOpcodeException.php: -------------------------------------------------------------------------------- 1 | null, 33 | 'filter' => ['text', 'binary'], 34 | 'fragment_size' => 4096, 35 | 'headers' => null, 36 | 'logger' => null, 37 | 'origin' => null, // @deprecated 38 | 'persistent' => false, 39 | 'return_obj' => false, 40 | 'timeout' => 5, 41 | ]; 42 | 43 | private $socket_uri; 44 | private $connection; 45 | private $options = []; 46 | private $listen = false; 47 | private $last_opcode = null; 48 | 49 | 50 | /* ---------- Magic methods ------------------------------------------------------ */ 51 | 52 | /** 53 | * @param UriInterface|string $uri A ws/wss-URI 54 | * @param array $options 55 | * Associative array containing: 56 | * - context: Set the stream context. Default: empty context 57 | * - timeout: Set the socket timeout in seconds. Default: 5 58 | * - fragment_size: Set framgemnt size. Default: 4096 59 | * - headers: Associative array of headers to set/override. 60 | */ 61 | public function __construct($uri, array $options = []) 62 | { 63 | $this->socket_uri = $this->parseUri($uri); 64 | $this->options = array_merge(self::$default_options, [ 65 | 'logger' => new NullLogger(), 66 | ], $options); 67 | $this->setLogger($this->options['logger']); 68 | } 69 | 70 | /** 71 | * Get string representation of instance. 72 | * @return string String representation. 73 | */ 74 | public function __toString(): string 75 | { 76 | return sprintf( 77 | "%s(%s)", 78 | get_class($this), 79 | $this->getName() ?: 'closed' 80 | ); 81 | } 82 | 83 | 84 | /* ---------- Client option functions -------------------------------------------- */ 85 | 86 | /** 87 | * Set timeout. 88 | * @param int $timeout Timeout in seconds. 89 | */ 90 | public function setTimeout(int $timeout): void 91 | { 92 | $this->options['timeout'] = $timeout; 93 | if (!$this->isConnected()) { 94 | return; 95 | } 96 | $this->connection->setTimeout($timeout); 97 | $this->connection->setOptions($this->options); 98 | } 99 | 100 | /** 101 | * Set fragmentation size. 102 | * @param int $fragment_size Fragment size in bytes. 103 | * @return self. 104 | */ 105 | public function setFragmentSize(int $fragment_size): self 106 | { 107 | $this->options['fragment_size'] = $fragment_size; 108 | $this->connection->setOptions($this->options); 109 | return $this; 110 | } 111 | 112 | /** 113 | * Get fragmentation size. 114 | * @return int $fragment_size Fragment size in bytes. 115 | */ 116 | public function getFragmentSize(): int 117 | { 118 | return $this->options['fragment_size']; 119 | } 120 | 121 | 122 | /* ---------- Connection operations ---------------------------------------------- */ 123 | 124 | /** 125 | * Send text message. 126 | * @param string $payload Content as string. 127 | */ 128 | public function text(string $payload): void 129 | { 130 | $this->send($payload); 131 | } 132 | 133 | /** 134 | * Send binary message. 135 | * @param string $payload Content as binary string. 136 | */ 137 | public function binary(string $payload): void 138 | { 139 | $this->send($payload, 'binary'); 140 | } 141 | 142 | /** 143 | * Send ping. 144 | * @param string $payload Optional text as string. 145 | */ 146 | public function ping(string $payload = ''): void 147 | { 148 | $this->send($payload, 'ping'); 149 | } 150 | 151 | /** 152 | * Send unsolicited pong. 153 | * @param string $payload Optional text as string. 154 | */ 155 | public function pong(string $payload = ''): void 156 | { 157 | $this->send($payload, 'pong'); 158 | } 159 | 160 | /** 161 | * Send message. 162 | * @param string $payload Message to send. 163 | * @param string $opcode Opcode to use, default: 'text'. 164 | * @param bool $masked If message should be masked default: true. 165 | */ 166 | public function send(string $payload, string $opcode = 'text', bool $masked = true): void 167 | { 168 | if (!$this->isConnected()) { 169 | $this->connect(); 170 | } 171 | 172 | if (!in_array($opcode, array_keys(self::$opcodes))) { 173 | $warning = "Bad opcode '{$opcode}'. Try 'text' or 'binary'."; 174 | $this->logger->warning($warning); 175 | throw new BadOpcodeException($warning); 176 | } 177 | 178 | $factory = new Factory(); 179 | $message = $factory->create($opcode, $payload); 180 | $this->connection->pushMessage($message, $masked); 181 | } 182 | 183 | /** 184 | * Tell the socket to close. 185 | * @param integer $status http://tools.ietf.org/html/rfc6455#section-7.4 186 | * @param string $message A closing message, max 125 bytes. 187 | */ 188 | public function close(int $status = 1000, string $message = 'ttfn'): void 189 | { 190 | if (!$this->isConnected()) { 191 | return; 192 | } 193 | $this->connection->close($status, $message); 194 | } 195 | 196 | /** 197 | * Disconnect from server. 198 | */ 199 | public function disconnect(): void 200 | { 201 | if ($this->isConnected()) { 202 | $this->connection->disconnect(); 203 | } 204 | } 205 | 206 | /** 207 | * Receive message. 208 | * Note that this operation will block reading. 209 | * @return mixed Message, text or null depending on settings. 210 | */ 211 | public function receive() 212 | { 213 | $filter = $this->options['filter']; 214 | $return_obj = $this->options['return_obj']; 215 | 216 | if (!$this->isConnected()) { 217 | $this->connect(); 218 | } 219 | 220 | while (true) { 221 | $message = $this->connection->pullMessage(); 222 | $opcode = $message->getOpcode(); 223 | if (in_array($opcode, $filter)) { 224 | $this->last_opcode = $opcode; 225 | $return = $return_obj ? $message : $message->getContent(); 226 | break; 227 | } elseif ($opcode == 'close') { 228 | $this->last_opcode = null; 229 | $return = $return_obj ? $message : null; 230 | break; 231 | } 232 | } 233 | return $return; 234 | } 235 | 236 | 237 | /* ---------- Connection functions ----------------------------------------------- */ 238 | 239 | /** 240 | * Get last received opcode. 241 | * @return string|null Opcode. 242 | */ 243 | public function getLastOpcode(): ?string 244 | { 245 | return $this->last_opcode; 246 | } 247 | 248 | /** 249 | * Get close status on connection. 250 | * @return int|null Close status. 251 | */ 252 | public function getCloseStatus(): ?int 253 | { 254 | return $this->connection ? $this->connection->getCloseStatus() : null; 255 | } 256 | 257 | /** 258 | * If Client has active connection. 259 | * @return bool True if active connection. 260 | */ 261 | public function isConnected(): bool 262 | { 263 | return $this->connection && $this->connection->isConnected(); 264 | } 265 | 266 | /** 267 | * Get name of local socket, or null if not connected. 268 | * @return string|null 269 | */ 270 | public function getName(): ?string 271 | { 272 | return $this->isConnected() ? $this->connection->getName() : null; 273 | } 274 | 275 | /** 276 | * Get name of remote socket, or null if not connected. 277 | * @return string|null 278 | */ 279 | public function getRemoteName(): ?string 280 | { 281 | return $this->isConnected() ? $this->connection->getRemoteName() : null; 282 | } 283 | 284 | /** 285 | * Get name of remote socket, or null if not connected. 286 | * @return string|null 287 | * @deprecated Will be removed in future version, use getPeer() instead. 288 | */ 289 | public function getPier(): ?string 290 | { 291 | trigger_error( 292 | 'getPier() is deprecated and will be removed in future version. Use getRemoteName() instead.', 293 | E_USER_DEPRECATED 294 | ); 295 | return $this->getRemoteName(); 296 | } 297 | 298 | 299 | /* ---------- Helper functions --------------------------------------------------- */ 300 | 301 | /** 302 | * Perform WebSocket handshake 303 | */ 304 | protected function connect(): void 305 | { 306 | $this->connection = null; 307 | 308 | $host_uri = $this->socket_uri 309 | ->withScheme($this->socket_uri->getScheme() == 'wss' ? 'ssl' : 'tcp') 310 | ->withPort($this->socket_uri->getPort() ?? ($this->socket_uri->getScheme() == 'wss' ? 443 : 80)) 311 | ->withPath('') 312 | ->withQuery('') 313 | ->withFragment('') 314 | ->withUserInfo(''); 315 | 316 | // Path must be absolute 317 | $http_path = $this->socket_uri->getPath(); 318 | if ($http_path === '' || $http_path[0] !== '/') { 319 | $http_path = "/{$http_path}"; 320 | } 321 | 322 | $http_uri = (new Uri()) 323 | ->withPath($http_path) 324 | ->withQuery($this->socket_uri->getQuery()); 325 | 326 | // Set the stream context options if they're already set in the config 327 | if (isset($this->options['context'])) { 328 | // Suppress the error since we'll catch it below 329 | if (@get_resource_type($this->options['context']) === 'stream-context') { 330 | $context = $this->options['context']; 331 | } else { 332 | $error = "Stream context in \$options['context'] isn't a valid context."; 333 | $this->logger->error($error); 334 | throw new \InvalidArgumentException($error); 335 | } 336 | } else { 337 | $context = stream_context_create(); 338 | } 339 | 340 | $persistent = $this->options['persistent'] === true; 341 | $flags = STREAM_CLIENT_CONNECT; 342 | $flags = $persistent ? $flags | STREAM_CLIENT_PERSISTENT : $flags; 343 | $socket = null; 344 | 345 | try { 346 | $handler = new ErrorHandler(); 347 | $socket = $handler->with(function () use ($host_uri, $flags, $context) { 348 | $error = $errno = $errstr = null; 349 | // Open the socket. 350 | return stream_socket_client( 351 | $host_uri, 352 | $errno, 353 | $errstr, 354 | $this->options['timeout'], 355 | $flags, 356 | $context 357 | ); 358 | }); 359 | if (!$socket) { 360 | throw new ErrorException('No socket'); 361 | } 362 | } catch (ErrorException $e) { 363 | $error = "Could not open socket to \"{$host_uri->getAuthority()}\": {$e->getMessage()} ({$e->getCode()})."; 364 | $this->logger->error($error, ['severity' => $e->getSeverity()]); 365 | throw new ConnectionException($error, 0, [], $e); 366 | } 367 | 368 | $this->connection = new Connection($socket, $this->options); 369 | $this->connection->setLogger($this->logger); 370 | if (!$this->isConnected()) { 371 | $error = "Invalid stream on \"{$host_uri->getAuthority()}\"."; 372 | $this->logger->error($error); 373 | throw new ConnectionException($error); 374 | } 375 | 376 | if (!$persistent || $this->connection->tell() == 0) { 377 | // Set timeout on the stream as well. 378 | $this->connection->setTimeout($this->options['timeout']); 379 | 380 | // Generate the WebSocket key. 381 | $key = self::generateKey(); 382 | 383 | // Default headers 384 | $headers = [ 385 | 'Host' => $host_uri->getAuthority(), 386 | 'User-Agent' => 'websocket-client-php', 387 | 'Connection' => 'Upgrade', 388 | 'Upgrade' => 'websocket', 389 | 'Sec-WebSocket-Key' => $key, 390 | 'Sec-WebSocket-Version' => '13', 391 | ]; 392 | 393 | // Handle basic authentication. 394 | if ($userinfo = $this->socket_uri->getUserInfo()) { 395 | $headers['authorization'] = 'Basic ' . base64_encode($userinfo); 396 | } 397 | 398 | // Deprecated way of adding origin (use headers instead). 399 | if (isset($this->options['origin'])) { 400 | $headers['origin'] = $this->options['origin']; 401 | } 402 | 403 | // Add and override with headers from options. 404 | if (isset($this->options['headers'])) { 405 | $headers = array_merge($headers, $this->options['headers']); 406 | } 407 | 408 | $header = "GET {$http_uri} HTTP/1.1\r\n" . implode( 409 | "\r\n", 410 | array_map( 411 | function ($key, $value) { 412 | return "$key: $value"; 413 | }, 414 | array_keys($headers), 415 | $headers 416 | ) 417 | ) . "\r\n\r\n"; 418 | 419 | // Send headers. 420 | $this->connection->write($header); 421 | 422 | // Get server response header (terminated with double CR+LF). 423 | $response = ''; 424 | try { 425 | do { 426 | $buffer = $this->connection->gets(1024); 427 | $response .= $buffer; 428 | } while (substr_count($response, "\r\n\r\n") == 0); 429 | } catch (Exception $e) { 430 | throw new ConnectionException('Client handshake error', $e->getCode(), $e->getData(), $e); 431 | } 432 | 433 | // Validate response. 434 | if (!preg_match('#Sec-WebSocket-Accept:\s(.*)$#mUi', $response, $matches)) { 435 | $error = sprintf( 436 | "Connection to '%s' failed: Server sent invalid upgrade response: %s", 437 | (string)$this->socket_uri, 438 | (string)$response 439 | ); 440 | $this->logger->error($error); 441 | throw new ConnectionException($error); 442 | } 443 | 444 | $keyAccept = trim($matches[1]); 445 | $expectedResonse = base64_encode( 446 | pack('H*', sha1($key . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')) 447 | ); 448 | 449 | if ($keyAccept !== $expectedResonse) { 450 | $error = 'Server sent bad upgrade response.'; 451 | $this->logger->error($error); 452 | throw new ConnectionException($error); 453 | } 454 | } 455 | 456 | $this->logger->info("Client connected to {$this->socket_uri}"); 457 | } 458 | 459 | /** 460 | * Generate a random string for WebSocket key. 461 | * @return string Random string 462 | */ 463 | protected static function generateKey(): string 464 | { 465 | $key = ''; 466 | for ($i = 0; $i < 16; $i++) { 467 | $key .= chr(rand(33, 126)); 468 | } 469 | return base64_encode($key); 470 | } 471 | 472 | protected function parseUri($uri): UriInterface 473 | { 474 | if ($uri instanceof UriInterface) { 475 | $uri = $uri; 476 | } elseif (is_string($uri)) { 477 | try { 478 | $uri = new Uri($uri); 479 | } catch (InvalidArgumentException $e) { 480 | throw new BadUriException("Invalid URI '{$uri}' provided.", 0, $e); 481 | } 482 | } else { 483 | throw new BadUriException("Provided URI must be a UriInterface or string."); 484 | } 485 | if (!in_array($uri->getScheme(), ['ws', 'wss'])) { 486 | throw new BadUriException("Invalid URI scheme, must be 'ws' or 'wss'."); 487 | } 488 | return $uri; 489 | } 490 | } 491 | -------------------------------------------------------------------------------- /lib/Connection.php: -------------------------------------------------------------------------------- 1 | stream = $stream; 42 | $this->setOptions($options); 43 | $this->setLogger(new NullLogger()); 44 | $this->msg_factory = new Factory(); 45 | } 46 | 47 | public function __destruct() 48 | { 49 | if ($this->getType() === 'stream') { 50 | fclose($this->stream); 51 | } 52 | } 53 | 54 | public function setOptions(array $options = []): void 55 | { 56 | $this->options = array_merge($this->options, $options); 57 | } 58 | 59 | public function getCloseStatus(): ?int 60 | { 61 | return $this->close_status; 62 | } 63 | 64 | /** 65 | * Tell the socket to close. 66 | * 67 | * @param integer $status http://tools.ietf.org/html/rfc6455#section-7.4 68 | * @param string $message A closing message, max 125 bytes. 69 | */ 70 | public function close(int $status = 1000, string $message = 'ttfn'): void 71 | { 72 | if (!$this->isConnected()) { 73 | return; 74 | } 75 | $status_binstr = sprintf('%016b', $status); 76 | $status_str = ''; 77 | foreach (str_split($status_binstr, 8) as $binstr) { 78 | $status_str .= chr(bindec($binstr)); 79 | } 80 | $message = $this->msg_factory->create('close', $status_str . $message); 81 | $this->pushMessage($message, true); 82 | 83 | $this->logger->debug("Closing with status: {$status}."); 84 | 85 | $this->is_closing = true; 86 | while (true) { 87 | $message = $this->pullMessage(); 88 | if ($message->getOpcode() == 'close') { 89 | break; 90 | } 91 | } 92 | } 93 | 94 | 95 | /* ---------- Message methods ---------------------------------------------------- */ 96 | 97 | // Push a message to stream 98 | public function pushMessage(Message $message, bool $masked = true): void 99 | { 100 | $frames = $message->getFrames($masked, $this->options['fragment_size']); 101 | foreach ($frames as $frame) { 102 | $this->pushFrame($frame); 103 | } 104 | $this->logger->info("[connection] Pushed {$message}", [ 105 | 'opcode' => $message->getOpcode(), 106 | 'content-length' => $message->getLength(), 107 | 'frames' => count($frames), 108 | ]); 109 | } 110 | 111 | // Pull a message from stream 112 | public function pullMessage(): Message 113 | { 114 | do { 115 | $frame = $this->pullFrame(); 116 | $frame = $this->autoRespond($frame); 117 | list ($final, $payload, $opcode, $masked) = $frame; 118 | 119 | if ($opcode == 'close') { 120 | $this->close(); 121 | } 122 | 123 | // Continuation and factual opcode 124 | $continuation = $opcode == 'continuation'; 125 | $payload_opcode = $continuation ? $this->read_buffer['opcode'] : $opcode; 126 | 127 | // First continuation frame, create buffer 128 | if (!$final && !$continuation) { 129 | $this->read_buffer = ['opcode' => $opcode, 'payload' => $payload, 'frames' => 1]; 130 | continue; // Continue reading 131 | } 132 | 133 | // Subsequent continuation frames, add to buffer 134 | if ($continuation) { 135 | $this->read_buffer['payload'] .= $payload; 136 | $this->read_buffer['frames']++; 137 | } 138 | } while (!$final); 139 | 140 | // Final, return payload 141 | $frames = 1; 142 | if ($continuation) { 143 | $payload = $this->read_buffer['payload']; 144 | $frames = $this->read_buffer['frames']; 145 | $this->read_buffer = null; 146 | } 147 | 148 | $message = $this->msg_factory->create($payload_opcode, $payload); 149 | 150 | $this->logger->info("[connection] Pulled {$message}", [ 151 | 'opcode' => $payload_opcode, 152 | 'content-length' => strlen($payload), 153 | 'frames' => $frames, 154 | ]); 155 | 156 | return $message; 157 | } 158 | 159 | 160 | /* ---------- Frame I/O methods -------------------------------------------------- */ 161 | 162 | // Pull frame from stream 163 | private function pullFrame(): array 164 | { 165 | // Read the fragment "header" first, two bytes. 166 | $data = $this->read(2); 167 | list ($byte_1, $byte_2) = array_values(unpack('C*', $data)); 168 | $final = (bool)($byte_1 & 0b10000000); // Final fragment marker. 169 | $rsv = $byte_1 & 0b01110000; // Unused bits, ignore 170 | 171 | // Parse opcode 172 | $opcode_int = $byte_1 & 0b00001111; 173 | $opcode_ints = array_flip(self::$opcodes); 174 | if (!array_key_exists($opcode_int, $opcode_ints)) { 175 | $warning = "Bad opcode in websocket frame: {$opcode_int}"; 176 | $this->logger->warning($warning); 177 | throw new ConnectionException($warning, ConnectionException::BAD_OPCODE); 178 | } 179 | $opcode = $opcode_ints[$opcode_int]; 180 | 181 | // Masking bit 182 | $masked = (bool)($byte_2 & 0b10000000); 183 | 184 | $payload = ''; 185 | 186 | // Payload length 187 | $payload_length = $byte_2 & 0b01111111; 188 | 189 | if ($payload_length > 125) { 190 | if ($payload_length === 126) { 191 | $data = $this->read(2); // 126: Payload is a 16-bit unsigned int 192 | $payload_length = current(unpack('n', $data)); 193 | } else { 194 | $data = $this->read(8); // 127: Payload is a 64-bit unsigned int 195 | $payload_length = current(unpack('J', $data)); 196 | } 197 | } 198 | 199 | // Get masking key. 200 | if ($masked) { 201 | $masking_key = $this->read(4); 202 | } 203 | 204 | // Get the actual payload, if any (might not be for e.g. close frames. 205 | if ($payload_length > 0) { 206 | $data = $this->read($payload_length); 207 | 208 | if ($masked) { 209 | // Unmask payload. 210 | for ($i = 0; $i < $payload_length; $i++) { 211 | $payload .= ($data[$i] ^ $masking_key[$i % 4]); 212 | } 213 | } else { 214 | $payload = $data; 215 | } 216 | } 217 | 218 | $this->logger->debug("[connection] Pulled '{opcode}' frame", [ 219 | 'opcode' => $opcode, 220 | 'final' => $final, 221 | 'content-length' => strlen($payload), 222 | ]); 223 | return [$final, $payload, $opcode, $masked]; 224 | } 225 | 226 | // Push frame to stream 227 | private function pushFrame(array $frame): void 228 | { 229 | list ($final, $payload, $opcode, $masked) = $frame; 230 | $data = ''; 231 | $byte_1 = $final ? 0b10000000 : 0b00000000; // Final fragment marker. 232 | $byte_1 |= self::$opcodes[$opcode]; // Set opcode. 233 | $data .= pack('C', $byte_1); 234 | 235 | $byte_2 = $masked ? 0b10000000 : 0b00000000; // Masking bit marker. 236 | 237 | // 7 bits of payload length... 238 | $payload_length = strlen($payload); 239 | if ($payload_length > 65535) { 240 | $data .= pack('C', $byte_2 | 0b01111111); 241 | $data .= pack('J', $payload_length); 242 | } elseif ($payload_length > 125) { 243 | $data .= pack('C', $byte_2 | 0b01111110); 244 | $data .= pack('n', $payload_length); 245 | } else { 246 | $data .= pack('C', $byte_2 | $payload_length); 247 | } 248 | 249 | // Handle masking 250 | if ($masked) { 251 | // generate a random mask: 252 | $mask = ''; 253 | for ($i = 0; $i < 4; $i++) { 254 | $mask .= chr(rand(0, 255)); 255 | } 256 | $data .= $mask; 257 | 258 | // Append payload to frame: 259 | for ($i = 0; $i < $payload_length; $i++) { 260 | $data .= $payload[$i] ^ $mask[$i % 4]; 261 | } 262 | } else { 263 | $data .= $payload; 264 | } 265 | 266 | $this->write($data); 267 | 268 | $this->logger->debug("[connection] Pushed '{$opcode}' frame", [ 269 | 'opcode' => $opcode, 270 | 'final' => $final, 271 | 'content-length' => strlen($payload), 272 | ]); 273 | } 274 | 275 | // Trigger auto response for frame 276 | private function autoRespond(array $frame) 277 | { 278 | list ($final, $payload, $opcode, $masked) = $frame; 279 | $payload_length = strlen($payload); 280 | 281 | switch ($opcode) { 282 | case 'ping': 283 | // If we received a ping, respond with a pong 284 | $this->logger->debug("[connection] Received 'ping', sending 'pong'."); 285 | $message = $this->msg_factory->create('pong', $payload); 286 | $this->pushMessage($message, $masked); 287 | return [$final, $payload, $opcode, $masked]; 288 | case 'close': 289 | // If we received close, possibly acknowledge and close connection 290 | $status_bin = ''; 291 | $status = ''; 292 | if ($payload_length > 0) { 293 | $status_bin = $payload[0] . $payload[1]; 294 | $status = current(unpack('n', $payload)); 295 | $this->close_status = $status; 296 | } 297 | // Get additional close message 298 | if ($payload_length >= 2) { 299 | $payload = substr($payload, 2); 300 | } 301 | 302 | $this->logger->debug("[connection] Received 'close', status: {$status}."); 303 | if (!$this->is_closing) { 304 | $ack = "{$status_bin}Close acknowledged: {$status}"; 305 | $message = $this->msg_factory->create('close', $ack); 306 | $this->pushMessage($message, $masked); 307 | } else { 308 | $this->is_closing = false; // A close response, all done. 309 | } 310 | $this->disconnect(); 311 | return [$final, $payload, $opcode, $masked]; 312 | default: 313 | return [$final, $payload, $opcode, $masked]; 314 | } 315 | } 316 | 317 | 318 | /* ---------- Stream I/O methods ------------------------------------------------- */ 319 | 320 | /** 321 | * Close connection stream. 322 | * @return bool 323 | */ 324 | public function disconnect(): bool 325 | { 326 | $this->logger->debug('Closing connection'); 327 | return fclose($this->stream); 328 | } 329 | 330 | /** 331 | * If connected to stream. 332 | * @return bool 333 | */ 334 | public function isConnected(): bool 335 | { 336 | return in_array($this->getType(), ['stream', 'persistent stream']); 337 | } 338 | 339 | /** 340 | * Return type of connection. 341 | * @return string|null Type of connection or null if invalid type. 342 | */ 343 | public function getType(): ?string 344 | { 345 | return get_resource_type($this->stream); 346 | } 347 | 348 | /** 349 | * Get name of local socket, or null if not connected. 350 | * @return string|null 351 | */ 352 | public function getName(): ?string 353 | { 354 | return stream_socket_get_name($this->stream, false); 355 | } 356 | 357 | /** 358 | * Get name of remote socket, or null if not connected. 359 | * @return string|null 360 | */ 361 | public function getRemoteName(): ?string 362 | { 363 | return stream_socket_get_name($this->stream, true); 364 | } 365 | 366 | /** 367 | * Get meta data for connection. 368 | * @return array 369 | */ 370 | public function getMeta(): array 371 | { 372 | return stream_get_meta_data($this->stream); 373 | } 374 | 375 | /** 376 | * Returns current position of stream pointer. 377 | * @return int 378 | * @throws ConnectionException 379 | */ 380 | public function tell(): int 381 | { 382 | $tell = ftell($this->stream); 383 | if ($tell === false) { 384 | $this->throwException('Could not resolve stream pointer position'); 385 | } 386 | return $tell; 387 | } 388 | 389 | /** 390 | * If stream pointer is at end of file. 391 | * @return bool 392 | */ 393 | public function eof(): int 394 | { 395 | return feof($this->stream); 396 | } 397 | 398 | 399 | /* ---------- Stream option methods ---------------------------------------------- */ 400 | 401 | /** 402 | * Set time out on connection. 403 | * @param int $seconds Timeout part in seconds 404 | * @param int $microseconds Timeout part in microseconds 405 | * @return bool 406 | */ 407 | public function setTimeout(int $seconds, int $microseconds = 0): bool 408 | { 409 | $this->logger->debug("Setting timeout {$seconds}:{$microseconds} seconds"); 410 | return stream_set_timeout($this->stream, $seconds, $microseconds); 411 | } 412 | 413 | 414 | /* ---------- Stream read/write methods ------------------------------------------ */ 415 | 416 | /** 417 | * Read line from stream. 418 | * @param int $length Maximum number of bytes to read 419 | * @param string $ending Line delimiter 420 | * @return string Read data 421 | */ 422 | public function getLine(int $length, string $ending): string 423 | { 424 | $line = stream_get_line($this->stream, $length, $ending); 425 | if ($line === false) { 426 | $this->throwException('Could not read from stream'); 427 | } 428 | $read = strlen($line); 429 | $this->logger->debug("Read {$read} bytes of line."); 430 | return $line; 431 | } 432 | 433 | /** 434 | * Read line from stream. 435 | * @param int $length Maximum number of bytes to read 436 | * @return string Read data 437 | */ 438 | public function gets(int $length): string 439 | { 440 | $line = fgets($this->stream, $length); 441 | if ($line === false) { 442 | $this->throwException('Could not read from stream'); 443 | } 444 | $read = strlen($line); 445 | $this->logger->debug("Read {$read} bytes of line."); 446 | return $line; 447 | } 448 | 449 | /** 450 | * Read characters from stream. 451 | * @param int $length Maximum number of bytes to read 452 | * @return string Read data 453 | */ 454 | public function read(string $length): string 455 | { 456 | $data = ''; 457 | while (strlen($data) < $length) { 458 | $buffer = fread($this->stream, $length - strlen($data)); 459 | if (!$buffer) { 460 | $meta = stream_get_meta_data($this->stream); 461 | if (!empty($meta['timed_out'])) { 462 | $message = 'Client read timeout'; 463 | $this->logger->error($message, $meta); 464 | throw new TimeoutException($message, ConnectionException::TIMED_OUT, $meta); 465 | } 466 | } 467 | if ($buffer === false) { 468 | $read = strlen($data); 469 | $this->throwException("Broken frame, read {$read} of stated {$length} bytes."); 470 | } 471 | if ($buffer === '') { 472 | $this->throwException("Empty read; connection dead?"); 473 | } 474 | $data .= $buffer; 475 | $read = strlen($data); 476 | $this->logger->debug("Read {$read} of {$length} bytes."); 477 | } 478 | return $data; 479 | } 480 | 481 | /** 482 | * Write characters to stream. 483 | * @param string $data Data to read 484 | */ 485 | public function write(string $data): void 486 | { 487 | $length = strlen($data); 488 | $written = fwrite($this->stream, $data); 489 | if ($written === false) { 490 | $this->throwException("Failed to write {$length} bytes."); 491 | } 492 | if ($written < strlen($data)) { 493 | $this->throwException("Could only write {$written} out of {$length} bytes."); 494 | } 495 | $this->logger->debug("Wrote {$written} of {$length} bytes."); 496 | } 497 | 498 | 499 | /* ---------- Internal helper methods -------------------------------------------- */ 500 | 501 | private function throwException(string $message, int $code = 0): void 502 | { 503 | $meta = ['closed' => true]; 504 | if ($this->isConnected()) { 505 | $meta = $this->getMeta(); 506 | $this->disconnect(); 507 | if (!empty($meta['timed_out'])) { 508 | $this->logger->error($message, $meta); 509 | throw new TimeoutException($message, ConnectionException::TIMED_OUT, $meta); 510 | } 511 | if (!empty($meta['eof'])) { 512 | $code = ConnectionException::EOF; 513 | } 514 | } 515 | $this->logger->error($message, $meta); 516 | throw new ConnectionException($message, $code, $meta); 517 | } 518 | } 519 | -------------------------------------------------------------------------------- /lib/ConnectionException.php: -------------------------------------------------------------------------------- 1 | data = $data; 27 | } 28 | 29 | public function getData(): array 30 | { 31 | return $this->data; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /lib/Exception.php: -------------------------------------------------------------------------------- 1 | payload = $payload; 23 | $this->timestamp = new DateTime(); 24 | } 25 | 26 | public function getOpcode(): string 27 | { 28 | return $this->opcode; 29 | } 30 | 31 | public function getLength(): int 32 | { 33 | return strlen($this->payload); 34 | } 35 | 36 | public function getTimestamp(): DateTime 37 | { 38 | return $this->timestamp; 39 | } 40 | 41 | public function getContent(): string 42 | { 43 | return $this->payload; 44 | } 45 | 46 | public function setContent(string $payload = ''): void 47 | { 48 | $this->payload = $payload; 49 | } 50 | 51 | public function hasContent(): bool 52 | { 53 | return $this->payload != ''; 54 | } 55 | 56 | public function __toString(): string 57 | { 58 | return get_class($this); 59 | } 60 | 61 | // Split messages into frames 62 | public function getFrames(bool $masked = true, int $framesize = 4096): array 63 | { 64 | 65 | $frames = []; 66 | $split = str_split($this->getContent(), $framesize) ?: ['']; 67 | foreach ($split as $payload) { 68 | $frames[] = [false, $payload, 'continuation', $masked]; 69 | } 70 | $frames[0][2] = $this->opcode; 71 | $frames[array_key_last($frames)][0] = true; 72 | return $frames; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /lib/Message/Ping.php: -------------------------------------------------------------------------------- 1 | 0, 16 | 'text' => 1, 17 | 'binary' => 2, 18 | 'close' => 8, 19 | 'ping' => 9, 20 | 'pong' => 10, 21 | ]; 22 | } 23 | -------------------------------------------------------------------------------- /lib/Server.php: -------------------------------------------------------------------------------- 1 | ['text', 'binary'], 32 | 'fragment_size' => 4096, 33 | 'logger' => null, 34 | 'port' => 8000, 35 | 'return_obj' => false, 36 | 'timeout' => null, 37 | ]; 38 | 39 | protected $port; 40 | protected $listening; 41 | protected $request; 42 | protected $request_path; 43 | private $connections = []; 44 | private $options = []; 45 | private $listen = false; 46 | private $last_opcode; 47 | 48 | 49 | /* ---------- Magic methods ------------------------------------------------------ */ 50 | 51 | /** 52 | * @param array $options 53 | * Associative array containing: 54 | * - filter: Array of opcodes to handle. Default: ['text', 'binary']. 55 | * - fragment_size: Set framgemnt size. Default: 4096 56 | * - logger: PSR-3 compatible logger. Default NullLogger. 57 | * - port: Chose port for listening. Default 8000. 58 | * - return_obj: If receive() function return Message instance. Default false. 59 | * - timeout: Set the socket timeout in seconds. 60 | */ 61 | public function __construct(array $options = []) 62 | { 63 | $this->options = array_merge(self::$default_options, [ 64 | 'logger' => new NullLogger(), 65 | ], $options); 66 | $this->port = $this->options['port']; 67 | $this->setLogger($this->options['logger']); 68 | 69 | $error = $errno = $errstr = null; 70 | set_error_handler(function (int $severity, string $message, string $file, int $line) use (&$error) { 71 | $this->logger->warning($message, ['severity' => $severity]); 72 | $error = $message; 73 | }, E_ALL); 74 | 75 | do { 76 | $this->listening = stream_socket_server("tcp://0.0.0.0:$this->port", $errno, $errstr); 77 | } while ($this->listening === false && $this->port++ < 10000); 78 | 79 | restore_error_handler(); 80 | 81 | if (!$this->listening) { 82 | $error = "Could not open listening socket: {$errstr} ({$errno}) {$error}"; 83 | $this->logger->error($error); 84 | throw new ConnectionException($error, (int)$errno); 85 | } 86 | 87 | $this->logger->info("Server listening to port {$this->port}"); 88 | } 89 | 90 | /** 91 | * Get string representation of instance. 92 | * @return string String representation. 93 | */ 94 | public function __toString(): string 95 | { 96 | return sprintf( 97 | "%s(%s)", 98 | get_class($this), 99 | $this->getName() ?: 'closed' 100 | ); 101 | } 102 | 103 | 104 | /* ---------- Server operations -------------------------------------------------- */ 105 | 106 | /** 107 | * Accept a single incoming request. 108 | * Note that this operation will block accepting additional requests. 109 | * @return bool True if listening. 110 | */ 111 | public function accept(): bool 112 | { 113 | $this->disconnect(); 114 | return (bool)$this->listening; 115 | } 116 | 117 | 118 | /* ---------- Server option functions -------------------------------------------- */ 119 | 120 | /** 121 | * Get current port. 122 | * @return int port. 123 | */ 124 | public function getPort(): int 125 | { 126 | return $this->port; 127 | } 128 | 129 | /** 130 | * Set timeout. 131 | * @param int $timeout Timeout in seconds. 132 | */ 133 | public function setTimeout(int $timeout): void 134 | { 135 | $this->options['timeout'] = $timeout; 136 | if (!$this->isConnected()) { 137 | return; 138 | } 139 | foreach ($this->connections as $connection) { 140 | $connection->setTimeout($timeout); 141 | $connection->setOptions($this->options); 142 | } 143 | } 144 | 145 | /** 146 | * Set fragmentation size. 147 | * @param int $fragment_size Fragment size in bytes. 148 | * @return self. 149 | */ 150 | public function setFragmentSize(int $fragment_size): self 151 | { 152 | $this->options['fragment_size'] = $fragment_size; 153 | foreach ($this->connections as $connection) { 154 | $connection->setOptions($this->options); 155 | } 156 | return $this; 157 | } 158 | 159 | /** 160 | * Get fragmentation size. 161 | * @return int $fragment_size Fragment size in bytes. 162 | */ 163 | public function getFragmentSize(): int 164 | { 165 | return $this->options['fragment_size']; 166 | } 167 | 168 | 169 | /* ---------- Connection broadcast operations ------------------------------------ */ 170 | 171 | /** 172 | * Broadcast text message to all conenctions. 173 | * @param string $payload Content as string. 174 | */ 175 | public function text(string $payload): void 176 | { 177 | $this->send($payload); 178 | } 179 | 180 | /** 181 | * Broadcast binary message to all conenctions. 182 | * @param string $payload Content as binary string. 183 | */ 184 | public function binary(string $payload): void 185 | { 186 | $this->send($payload, 'binary'); 187 | } 188 | 189 | /** 190 | * Broadcast ping message to all conenctions. 191 | * @param string $payload Optional text as string. 192 | */ 193 | public function ping(string $payload = ''): void 194 | { 195 | $this->send($payload, 'ping'); 196 | } 197 | 198 | /** 199 | * Broadcast pong message to all conenctions. 200 | * @param string $payload Optional text as string. 201 | */ 202 | public function pong(string $payload = ''): void 203 | { 204 | $this->send($payload, 'pong'); 205 | } 206 | 207 | /** 208 | * Send message on all connections. 209 | * @param string $payload Message to send. 210 | * @param string $opcode Opcode to use, default: 'text'. 211 | * @param bool $masked If message should be masked default: true. 212 | */ 213 | public function send(string $payload, string $opcode = 'text', bool $masked = true): void 214 | { 215 | if (!$this->isConnected()) { 216 | $this->connect(); 217 | } 218 | if (!in_array($opcode, array_keys(self::$opcodes))) { 219 | $warning = "Bad opcode '{$opcode}'. Try 'text' or 'binary'."; 220 | $this->logger->warning($warning); 221 | throw new BadOpcodeException($warning); 222 | } 223 | 224 | $factory = new Factory(); 225 | $message = $factory->create($opcode, $payload); 226 | 227 | foreach ($this->connections as $connection) { 228 | $connection->pushMessage($message, $masked); 229 | } 230 | } 231 | 232 | /** 233 | * Close all connections. 234 | * @param int $status Close status, default: 1000. 235 | * @param string $message Close message, default: 'ttfn'. 236 | */ 237 | public function close(int $status = 1000, string $message = 'ttfn'): void 238 | { 239 | foreach ($this->connections as $connection) { 240 | if ($connection->isConnected()) { 241 | $connection->close($status, $message); 242 | } 243 | } 244 | } 245 | 246 | /** 247 | * Disconnect all connections. 248 | */ 249 | public function disconnect(): void 250 | { 251 | foreach ($this->connections as $connection) { 252 | if ($connection->isConnected()) { 253 | $connection->disconnect(); 254 | } 255 | } 256 | $this->connections = []; 257 | } 258 | 259 | /** 260 | * Receive message from single connection. 261 | * Note that this operation will block reading and only read from first available connection. 262 | * @return mixed Message, text or null depending on settings. 263 | */ 264 | public function receive() 265 | { 266 | $filter = $this->options['filter']; 267 | $return_obj = $this->options['return_obj']; 268 | 269 | if (!$this->isConnected()) { 270 | $this->connect(); 271 | } 272 | $connection = current($this->connections); 273 | 274 | while (true) { 275 | $message = $connection->pullMessage(); 276 | $opcode = $message->getOpcode(); 277 | if (in_array($opcode, $filter)) { 278 | $this->last_opcode = $opcode; 279 | $return = $return_obj ? $message : $message->getContent(); 280 | break; 281 | } elseif ($opcode == 'close') { 282 | $this->last_opcode = null; 283 | $return = $return_obj ? $message : null; 284 | break; 285 | } 286 | } 287 | return $return; 288 | } 289 | 290 | 291 | /* ---------- Connection functions ----------------------------------------------- */ 292 | 293 | /** 294 | * Get requested path from last connection. 295 | * @return string Path. 296 | */ 297 | public function getPath(): string 298 | { 299 | return $this->request_path; 300 | } 301 | 302 | /** 303 | * Get request from last connection. 304 | * @return array Request. 305 | */ 306 | public function getRequest(): array 307 | { 308 | return $this->request; 309 | } 310 | 311 | /** 312 | * Get headers from last connection. 313 | * @return string|null Headers. 314 | */ 315 | public function getHeader($header): ?string 316 | { 317 | foreach ($this->request as $row) { 318 | if (stripos($row, $header) !== false) { 319 | list($headername, $headervalue) = explode(":", $row); 320 | return trim($headervalue); 321 | } 322 | } 323 | return null; 324 | } 325 | 326 | /** 327 | * Get last received opcode. 328 | * @return string|null Opcode. 329 | */ 330 | public function getLastOpcode(): ?string 331 | { 332 | return $this->last_opcode; 333 | } 334 | 335 | /** 336 | * Get close status from single connection. 337 | * @return int|null Close status. 338 | */ 339 | public function getCloseStatus(): ?int 340 | { 341 | return $this->connections ? current($this->connections)->getCloseStatus() : null; 342 | } 343 | 344 | /** 345 | * If Server has active connections. 346 | * @return bool True if active connection. 347 | */ 348 | public function isConnected(): bool 349 | { 350 | foreach ($this->connections as $connection) { 351 | if ($connection->isConnected()) { 352 | return true; 353 | } 354 | } 355 | return false; 356 | } 357 | 358 | /** 359 | * Get name of local socket from single connection. 360 | * @return string|null Name of local socket. 361 | */ 362 | public function getName(): ?string 363 | { 364 | return $this->isConnected() ? current($this->connections)->getName() : null; 365 | } 366 | 367 | /** 368 | * Get name of remote socket from single connection. 369 | * @return string|null Name of remote socket. 370 | */ 371 | public function getRemoteName(): ?string 372 | { 373 | return $this->isConnected() ? current($this->connections)->getRemoteName() : null; 374 | } 375 | 376 | /** 377 | * @deprecated Will be removed in future version. 378 | */ 379 | public function getPier(): ?string 380 | { 381 | trigger_error( 382 | 'getPier() is deprecated and will be removed in future version. Use getRemoteName() instead.', 383 | E_USER_DEPRECATED 384 | ); 385 | return $this->getRemoteName(); 386 | } 387 | 388 | 389 | /* ---------- Helper functions --------------------------------------------------- */ 390 | 391 | // Connect when read/write operation is performed. 392 | private function connect(): void 393 | { 394 | try { 395 | $handler = new ErrorHandler(); 396 | $socket = $handler->with(function () { 397 | if (isset($this->options['timeout'])) { 398 | $socket = stream_socket_accept($this->listening, $this->options['timeout']); 399 | } else { 400 | $socket = stream_socket_accept($this->listening); 401 | } 402 | if (!$socket) { 403 | throw new ErrorException('No socket'); 404 | } 405 | return $socket; 406 | }); 407 | } catch (ErrorException $e) { 408 | $error = "Server failed to connect. {$e->getMessage()}"; 409 | $this->logger->error($error, ['severity' => $e->getSeverity()]); 410 | throw new ConnectionException($error, 0, [], $e); 411 | } 412 | 413 | $connection = new Connection($socket, $this->options); 414 | $connection->setLogger($this->logger); 415 | 416 | if (isset($this->options['timeout'])) { 417 | $connection->setTimeout($this->options['timeout']); 418 | } 419 | 420 | $this->logger->info("Client has connected to port {port}", [ 421 | 'port' => $this->port, 422 | 'peer' => $connection->getRemoteName(), 423 | ]); 424 | $this->performHandshake($connection); 425 | $this->connections = ['*' => $connection]; 426 | } 427 | 428 | // Perform upgrade handshake on new connections. 429 | private function performHandshake(Connection $connection): void 430 | { 431 | $request = ''; 432 | do { 433 | $buffer = $connection->getLine(1024, "\r\n"); 434 | $request .= $buffer . "\n"; 435 | $metadata = $connection->getMeta(); 436 | } while (!$connection->eof() && $metadata['unread_bytes'] > 0); 437 | 438 | if (!preg_match('/GET (.*) HTTP\//mUi', $request, $matches)) { 439 | $error = "No GET in request: {$request}"; 440 | $this->logger->error($error); 441 | throw new ConnectionException($error); 442 | } 443 | $get_uri = trim($matches[1]); 444 | $uri_parts = parse_url($get_uri); 445 | 446 | $this->request = explode("\n", $request); 447 | $this->request_path = $uri_parts['path']; 448 | /// @todo Get query and fragment as well. 449 | 450 | if (!preg_match('#Sec-WebSocket-Key:\s(.*)$#mUi', $request, $matches)) { 451 | $error = "Client had no Key in upgrade request: {$request}"; 452 | $this->logger->error($error); 453 | throw new ConnectionException($error); 454 | } 455 | 456 | $key = trim($matches[1]); 457 | 458 | /// @todo Validate key length and base 64... 459 | $response_key = base64_encode(pack('H*', sha1($key . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'))); 460 | 461 | $header = "HTTP/1.1 101 Switching Protocols\r\n" 462 | . "Upgrade: websocket\r\n" 463 | . "Connection: Upgrade\r\n" 464 | . "Sec-WebSocket-Accept: $response_key\r\n" 465 | . "\r\n"; 466 | 467 | $connection->write($header); 468 | $this->logger->debug("Handshake on {$get_uri}"); 469 | } 470 | } 471 | -------------------------------------------------------------------------------- /lib/TimeoutException.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | lib/ 6 | 7 | 8 | 9 | 10 | tests 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /tests/ExceptionTest.php: -------------------------------------------------------------------------------- 1 | 'with data'], 28 | new TimeoutException( 29 | 'Nested exception', 30 | ConnectionException::TIMED_OUT 31 | ) 32 | ); 33 | } catch (Throwable $e) { 34 | } 35 | 36 | $this->assertInstanceOf('WebSocket\ConnectionException', $e); 37 | $this->assertInstanceOf('WebSocket\Exception', $e); 38 | $this->assertInstanceOf('Exception', $e); 39 | $this->assertInstanceOf('Throwable', $e); 40 | $this->assertEquals('An error message', $e->getMessage()); 41 | $this->assertEquals(1025, $e->getCode()); 42 | $this->assertEquals(['test' => 'with data'], $e->getData()); 43 | 44 | $p = $e->getPrevious(); 45 | $this->assertInstanceOf('WebSocket\TimeoutException', $p); 46 | $this->assertInstanceOf('WebSocket\ConnectionException', $p); 47 | $this->assertEquals('Nested exception', $p->getMessage()); 48 | $this->assertEquals(1024, $p->getCode()); 49 | $this->assertEquals([], $p->getData()); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tests/MessageTest.php: -------------------------------------------------------------------------------- 1 | create('text', 'Some content'); 26 | $this->assertInstanceOf('WebSocket\Message\Text', $message); 27 | $message = $factory->create('binary', 'Some content'); 28 | $this->assertInstanceOf('WebSocket\Message\Binary', $message); 29 | $message = $factory->create('ping', 'Some content'); 30 | $this->assertInstanceOf('WebSocket\Message\Ping', $message); 31 | $message = $factory->create('pong', 'Some content'); 32 | $this->assertInstanceOf('WebSocket\Message\Pong', $message); 33 | $message = $factory->create('close', 'Some content'); 34 | $this->assertInstanceOf('WebSocket\Message\Close', $message); 35 | } 36 | 37 | public function testMessage() 38 | { 39 | $message = new Text('Some content'); 40 | $this->assertInstanceOf('WebSocket\Message\Message', $message); 41 | $this->assertInstanceOf('WebSocket\Message\Text', $message); 42 | $this->assertEquals('Some content', $message->getContent()); 43 | $this->assertEquals('text', $message->getOpcode()); 44 | $this->assertEquals(12, $message->getLength()); 45 | $this->assertTrue($message->hasContent()); 46 | $this->assertInstanceOf('DateTime', $message->getTimestamp()); 47 | $message->setContent(''); 48 | $this->assertEquals(0, $message->getLength()); 49 | $this->assertFalse($message->hasContent()); 50 | $this->assertEquals('WebSocket\Message\Text', "{$message}"); 51 | } 52 | 53 | public function testBadOpcode() 54 | { 55 | $factory = new Factory(); 56 | $this->expectException('WebSocket\BadOpcodeException'); 57 | $this->expectExceptionMessage("Invalid opcode 'invalid' provided"); 58 | $message = $factory->create('invalid', 'Some content'); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | Unit tests with [PHPUnit](https://phpunit.readthedocs.io/). 4 | 5 | 6 | ## How to run 7 | 8 | To run all test, run in console. 9 | 10 | ``` 11 | make test 12 | ``` 13 | 14 | 15 | ## Continuous integration 16 | 17 | GitHub Actions are run on PHP versions `7.4`, `8.0`, `8.1` and `8.2`. 18 | 19 | Code coverage by [Coveralls](https://coveralls.io/github/Textalk/websocket-php). 20 | 21 | 22 | ## Test strategy 23 | 24 | Test set up overloads various stream and socket functions, 25 | and use "scripts" to define and mock input/output of these functions. 26 | 27 | This set up negates the dependency on running servers, 28 | and allow testing various errors that might occur. 29 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | interpolate($message, $context); 16 | $context_string = empty($context) ? '' : json_encode($context); 17 | echo str_pad($level, 8) . " | {$message} {$context_string}\n"; 18 | } 19 | 20 | public function interpolate($message, array $context = []) 21 | { 22 | // Build a replacement array with braces around the context keys 23 | $replace = []; 24 | foreach ($context as $key => $val) { 25 | // Check that the value can be cast to string 26 | if (!is_array($val) && (!is_object($val) || method_exists($val, '__toString'))) { 27 | $replace['{' . $key . '}'] = $val; 28 | } 29 | } 30 | 31 | // Interpolate replacement values into the message and return 32 | return strtr($message, $replace); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/mock/MockSocket.php: -------------------------------------------------------------------------------- 1 | assertEquals($current['function'], $function); 23 | foreach ($current['params'] as $index => $param) { 24 | if (isset($current['input-op'])) { 25 | $param = self::op($current['input-op'], $params, $param); 26 | } 27 | self::$asserter->assertEquals($param, $params[$index], json_encode([$current, $params])); 28 | } 29 | if (isset($current['error'])) { 30 | $map = array_merge(['msg' => 'Error', 'type' => E_USER_NOTICE], (array)$current['error']); 31 | trigger_error($map['msg'], $map['type']); 32 | } 33 | if (isset($current['return-op'])) { 34 | return self::op($current['return-op'], $params, $current['return']); 35 | } 36 | if (isset($current['return'])) { 37 | return $current['return']; 38 | } 39 | return call_user_func_array($function, $params); 40 | } 41 | 42 | // Check if all expected calls are performed 43 | public static function isEmpty(): bool 44 | { 45 | return empty(self::$queue); 46 | } 47 | 48 | // Initialize call queue 49 | public static function initialize($op_file, $asserter): void 50 | { 51 | $file = dirname(__DIR__) . "/scripts/{$op_file}.json"; 52 | self::$queue = json_decode(file_get_contents($file), true); 53 | self::$asserter = $asserter; 54 | } 55 | 56 | // Special output handling 57 | private static function op($op, $params, $data) 58 | { 59 | switch ($op) { 60 | case 'chr-array': 61 | // Convert int array to string 62 | $out = ''; 63 | foreach ($data as $val) { 64 | $out .= chr($val); 65 | } 66 | return $out; 67 | case 'file': 68 | $content = file_get_contents(__DIR__ . "/{$data[0]}"); 69 | return substr($content, $data[1], $data[2]); 70 | case 'key-save': 71 | preg_match('#Sec-WebSocket-Key:\s(.*)$#mUi', $params[1], $matches); 72 | self::$stored['sec-websocket-key'] = trim($matches[1]); 73 | return str_replace('{key}', self::$stored['sec-websocket-key'], $data); 74 | case 'key-respond': 75 | $key = self::$stored['sec-websocket-key'] . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; 76 | $encoded = base64_encode(pack('H*', sha1($key))); 77 | return str_replace('{key}', $encoded, $data); 78 | } 79 | return $data; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /tests/mock/mock-socket.php: -------------------------------------------------------------------------------- 1 |